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