packages/ztd-query-core/src/Shadow/Mutation/RowConstraints.php
1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Shadow\Mutation;
6
7use ZtdQuery\Exception\DuplicateKeyException;
8use ZtdQuery\Exception\NotNullViolationException;
9use ZtdQuery\Schema\TableDefinition;
10
11/**
12 * Refuses a row the table would not have accepted.
13 *
14 * Nothing is inserted, so nothing checks these but this. A statement whose
15 * rows the database would have rejected has to be rejected here too, or the
16 * shadow ends up describing a state the database could never have been in.
17 *
18 * A key with a null in it is left alone: SQL says such a key collides with
19 * nothing, not even with another row carrying the same nulls.
20 *
21 * @phpstan-import-type Row from TableDefinition
22 * @phpstan-import-type RowValue from TableDefinition
23 */
24final class RowConstraints
25{
26 /**
27 * @param TableDefinition|null $definition What the table declares, or null when nothing has described it
28 * @param string $tableName Table being written to, for the refusal
29 * @param string $sql Statement being simulated, for the refusal
30 */
31 public function __construct(
32 private readonly ?TableDefinition $definition,
33 private readonly string $tableName,
34 private readonly string $sql,
35 ) {
36 }
37
38 /**
39 * Refuses a row that leaves null in a column the table will not take one in.
40 *
41 * A column the row does not carry at all is not a null: the database would
42 * put its default there.
43 *
44 * @param Row $row Row that would be written
45 *
46 * @throws NotNullViolationException When a column that cannot be null is written as null
47 */
48 public function assertNoNullWhereNoneIsAllowed(array $row): void
49 {
50 if ($this->definition === null) {
51 return;
52 }
53
54 foreach ($this->definition->notNullColumns as $columnName) {
55 if (array_key_exists($columnName, $row) && $row[$columnName] === null) {
56 throw new NotNullViolationException($this->sql, $this->tableName, $columnName);
57 }
58 }
59 }
60
61 /**
62 * Refuses a row that would collide with one already there on a unique key.
63 *
64 * A row being changed does not collide with itself, so the row already
65 * there that is this row is left out. Which columns say so is passed in,
66 * because an insert has no such row and an update does.
67 *
68 * @param Row $row Row that would be written
69 * @param array<int, Row> $existingRows Rows already there
70 * @param list<string> $identityColumns Columns that say a row already there is this row
71 *
72 * @throws DuplicateKeyException When the row collides on one of the table's unique keys
73 */
74 public function assertNoDuplicateUniqueKey(array $row, array $existingRows, array $identityColumns = []): void
75 {
76 if ($this->definition === null) {
77 return;
78 }
79
80 foreach ($this->definition->uniqueConstraints as $keyName => $columns) {
81 if ($this->carriesNullIn($row, $columns)) {
82 continue;
83 }
84
85 foreach ($existingRows as $existing) {
86 if ($identityColumns !== [] && $this->agreeOn($row, $existing, $identityColumns)) {
87 continue;
88 }
89 if ($this->agreeOn($row, $existing, $columns)) {
90 throw new DuplicateKeyException(
91 $this->sql,
92 $this->tableName,
93 $keyName,
94 $this->keyValues($row, $columns),
95 );
96 }
97 }
98 }
99 }
100
101 /**
102 * Reports whether a row leaves any of the named columns unwritten or null.
103 *
104 * @param Row $row Row to read
105 * @param list<string> $columns Columns of one key
106 *
107 * @return bool True when the key cannot collide with anything
108 */
109 public function carriesNullIn(array $row, array $columns): bool
110 {
111 foreach ($columns as $column) {
112 if (!array_key_exists($column, $row) || $row[$column] === null) {
113 return true;
114 }
115 }
116
117 return false;
118 }
119
120 /**
121 * Reports whether two rows carry the same values in the named columns.
122 *
123 * A row that does not carry one of them agrees with nothing on it.
124 *
125 * @param Row $row Row that would be written
126 * @param Row $existing Row already there
127 * @param list<string> $columns Columns of one key
128 *
129 * @return bool True when both carry every column identically
130 */
131 public function agreeOn(array $row, array $existing, array $columns): bool
132 {
133 foreach ($columns as $column) {
134 if (!isset($existing[$column]) || ($row[$column] ?? null) !== $existing[$column]) {
135 return false;
136 }
137 }
138
139 return true;
140 }
141
142 /**
143 * Reads the values a row carries on one key, under the column names.
144 *
145 * @param Row $row Row to read
146 * @param list<string> $columns Columns of one key
147 *
148 * @return Row Column => the value it carries, or null where it carries none
149 */
150 public function keyValues(array $row, array $columns): array
151 {
152 $values = [];
153 foreach ($columns as $column) {
154 $values[$column] = $row[$column] ?? null;
155 }
156
157 return $values;
158 }
159}
160