packages/ztd-query-core/src/Shadow/ReferentialIntegrityEnforcer.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Shadow;
6
7use ZtdQuery\Exception\ForeignKeyViolationException;
8use ZtdQuery\Schema\TableDefinition;
9use ZtdQuery\Schema\TableDefinitionRegistry;
10use ZtdQuery\Shadow\Mutation\DataMutation;
11use ZtdQuery\Shadow\Mutation\ShadowMutation;
12
13/**
14 * Carries what a statement did outward through the constraints that declare it.
15 *
16 * A write to a parent table does not stop at that table: a key declared
17 * ON DELETE CASCADE takes the children with it, and those children may
18 * themselves be parents. What happened is therefore followed outward until
19 * nothing more follows, and only then is the result checked for the references
20 * it has left dangling.
21 *
22 * @phpstan-import-type Row from TableDefinition
23 */
24final class ReferentialIntegrityEnforcer
25{
26    /**
27     * @param TableDefinitionRegistry $registry Answers what a table declares
28     */
29    public function __construct(private readonly TableDefinitionRegistry $registry)
30    {
31    }
32
33    /**
34     * Applies every consequence of a statement, and refuses one it cannot.
35     *
36     * @param ShadowStore $before Shadow as it was
37     * @param ShadowStore $after Shadow as it became, written back in place
38     * @param ShadowMutation $mutation Statement that was simulated
39     * @param array<int, Row> $resultRows Rows the rewritten statement read back
40     * @param string $sql Statement being simulated, for the refusal
41     *
42     * @throws ForeignKeyViolationException When a constraint forbids the statement or is left broken
43     */
44    public function synchronize(
45        ShadowStore $before,
46        ShadowStore $after,
47        ShadowMutation $mutation,
48        array $resultRows,
49        string $sql,
50    ): void {
51        if (!$mutation instanceof DataMutation) {
52            return;
53        }
54
55        $ends = new ForeignKeyEnds($this->registry);
56        $cascade = new ForeignKeyCascade($ends);
57
58        $pending = (new TableTransitions($this->registry))->of($before, $after, $mutation, $resultRows);
59        while ($pending !== []) {
60            $parent = array_shift($pending);
61            foreach ($this->registry->getAll() as $childTable => $childDefinition) {
62                foreach ($childDefinition->foreignKeys as $constraintName => $foreignKey) {
63                    if (strcasecmp($foreignKey->referencedTable, $parent->table) !== 0) {
64                        continue;
65                    }
66                    $child = $cascade->of($after, $childTable, $constraintName, $foreignKey, $parent, $sql);
67                    if ($child !== null) {
68                        $pending[] = $child;
69                    }
70                }
71            }
72        }
73
74        (new ForeignKeyIntegrity($this->registry, $ends))->assertHolds($after, $sql);
75    }
76}
77