packages/ztd-query-core/tests/Unit/Shadow/ForeignKeyCascadeTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Shadow;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use ZtdQuery\Exception\ForeignKeyViolationException;
11use ZtdQuery\Schema\Key\ForeignKeyDefinition;
12use ZtdQuery\Schema\Key\ReferentialAction;
13use ZtdQuery\Schema\TableDefinitionRegistry;
14use ZtdQuery\Shadow\CascadedChildren;
15use ZtdQuery\Shadow\FollowedConstraint;
16use ZtdQuery\Shadow\ForeignKeyCascade;
17use ZtdQuery\Shadow\ForeignKeyEnds;
18use ZtdQuery\Shadow\ParentKeyLookup;
19use ZtdQuery\Shadow\Row\RowChange;
20use ZtdQuery\Shadow\Row\RowMatch;
21use ZtdQuery\Shadow\Row\TableTransition;
22use ZtdQuery\Shadow\ShadowStore;
23
24#[CoversClass(ForeignKeyCascade::class)]
25#[UsesClass(CascadedChildren::class)]
26#[UsesClass(FollowedConstraint::class)]
27#[UsesClass(ForeignKeyEnds::class)]
28#[UsesClass(ParentKeyLookup::class)]
29#[UsesClass(RowMatch::class)]
30#[UsesClass(RowChange::class)]
31#[UsesClass(TableTransition::class)]
32#[UsesClass(ShadowStore::class)]
33#[UsesClass(ForeignKeyDefinition::class)]
34#[UsesClass(TableDefinitionRegistry::class)]
35#[UsesClass(ForeignKeyViolationException::class)]
36#[UsesClass(\ZtdQuery\Schema\RowSet::class)]
37final class ForeignKeyCascadeTest extends TestCase
38{
39    public function testOfDeletesTheChildrenOfADeletedParentWhereTheActionSaysToCascade(): void
40    {
41        $store = new ShadowStore();
42        $store->set('parents', []);
43        $store->set('children', [['id' => 10, 'parent_id' => 1]]);
44        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
45        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id'], ReferentialAction::Cascade);
46
47        $child = $cascade->of(
48            $store,
49            'children',
50            'fk',
51            $foreignKey,
52            new TableTransition('parents', [['id' => 1]], []),
53            'DELETE',
54        );
55
56        self::assertNotNull($child);
57        self::assertSame([['id' => 10, 'parent_id' => 1]], $child->deleted);
58        self::assertSame([], $store->get('children'));
59    }
60
61    public function testOfCarriesTheNewKeyToTheChildrenOfAnUpdatedParent(): void
62    {
63        $store = new ShadowStore();
64        $store->set('parents', [['id' => 2]]);
65        $store->set('children', [['id' => 10, 'parent_id' => 1]]);
66        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
67        $foreignKey = new ForeignKeyDefinition(
68            ['parent_id'],
69            'parents',
70            ['id'],
71            onUpdate: ReferentialAction::Cascade,
72        );
73
74        $child = $cascade->of(
75            $store,
76            'children',
77            'fk',
78            $foreignKey,
79            new TableTransition('parents', [], [new RowChange(['id' => 1], ['id' => 2])]),
80            'UPDATE',
81        );
82
83        self::assertNotNull($child);
84        self::assertSame([['id' => 10, 'parent_id' => 2]], $store->get('children'));
85    }
86
87    public function testOfLeavesTheChildrenAloneWhileAnotherParentRowHoldsTheKeyUp(): void
88    {
89        $store = new ShadowStore();
90        $store->set('parents', [['id' => 1]]);
91        $store->set('children', [['id' => 10, 'parent_id' => 1]]);
92        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
93        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id'], ReferentialAction::Cascade);
94
95        $child = $cascade->of(
96            $store,
97            'children',
98            'fk',
99            $foreignKey,
100            new TableTransition('parents', [['id' => 1]], []),
101            'DELETE',
102        );
103
104        self::assertNull($child);
105        self::assertSame([['id' => 10, 'parent_id' => 1]], $store->get('children'));
106    }
107
108    public function testOfFollowsNothingThroughAKeyWhoseEndsDisagree(): void
109    {
110        $store = new ShadowStore();
111        $store->set('children', [['id' => 10, 'parent_id' => 1]]);
112        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
113        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['a', 'b'], ReferentialAction::Cascade);
114
115        $child = $cascade->of(
116            $store,
117            'children',
118            'fk',
119            $foreignKey,
120            new TableTransition('parents', [['id' => 1]], []),
121            'DELETE',
122        );
123
124        self::assertNull($child);
125    }
126
127    public function testApplyActionSetsTheKeyToNullWhereTheActionSaysTo(): void
128    {
129        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
130        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id']);
131
132        $row = $cascade->applyAction(
133            ['id' => 10, 'parent_id' => 1],
134            [],
135            ReferentialAction::SetNull,
136            new FollowedConstraint('children', 'fk', $foreignKey, 'DELETE'),
137        );
138
139        self::assertSame(['id' => 10, 'parent_id' => null], $row);
140    }
141
142    public function testApplyActionCarriesTheNewValueWhereTheActionSaysToCascade(): void
143    {
144        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
145        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id']);
146
147        $row = $cascade->applyAction(
148            ['id' => 10, 'parent_id' => 1],
149            [2],
150            ReferentialAction::Cascade,
151            new FollowedConstraint('children', 'fk', $foreignKey, 'UPDATE'),
152        );
153
154        self::assertSame(['id' => 10, 'parent_id' => 2], $row);
155    }
156
157    public function testApplyActionRefusesTheStatementWhereTheActionForbidsIt(): void
158    {
159        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
160        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id']);
161
162        $this->expectException(ForeignKeyViolationException::class);
163
164        $cascade->applyAction(
165            ['id' => 10, 'parent_id' => 1],
166            [],
167            ReferentialAction::Restrict,
168            new FollowedConstraint('children', 'fk', $foreignKey, 'DELETE'),
169        );
170    }
171
172    public function testCarryUpdateWritesTheNewKeyIntoEveryChildThatWasHoldingTheOldOne(): void
173    {
174        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
175        $foreignKey = new ForeignKeyDefinition(
176            ['parent_id'],
177            'parents',
178            ['id'],
179            onUpdate: ReferentialAction::Cascade,
180        );
181        $constraint = new FollowedConstraint('children', 'fk', $foreignKey, 'UPDATE');
182        $store = new ShadowStore();
183        $store->set('parents', [['id' => 2]]);
184        $children = new CascadedChildren([['id' => 10, 'parent_id' => 1], ['id' => 11, 'parent_id' => 9]]);
185
186        $cascade->carryUpdate($children, $store, $constraint, ['id'], new RowChange(['id' => 1], ['id' => 2]));
187
188        self::assertSame(
189            [['id' => 10, 'parent_id' => 2], ['id' => 11, 'parent_id' => 9]],
190            $children->rows(),
191        );
192    }
193
194    public function testCarryUpdateReachesNothingWhereTheParentTableStillHoldsTheOldKey(): void
195    {
196        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
197        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id']);
198        $constraint = new FollowedConstraint('children', 'fk', $foreignKey, 'UPDATE');
199        $store = new ShadowStore();
200        $store->set('parents', [['id' => 1], ['id' => 2]]);
201        $children = new CascadedChildren([['id' => 10, 'parent_id' => 1]]);
202
203        $cascade->carryUpdate($children, $store, $constraint, ['id'], new RowChange(['id' => 1], ['id' => 2]));
204
205        self::assertTrue($children->areUnchanged());
206    }
207
208    public function testCarryDeleteDropsEveryChildThatWasHoldingTheKeyWhereTheActionCascades(): void
209    {
210        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
211        $foreignKey = new ForeignKeyDefinition(
212            ['parent_id'],
213            'parents',
214            ['id'],
215            onDelete: ReferentialAction::Cascade,
216        );
217        $constraint = new FollowedConstraint('children', 'fk', $foreignKey, 'DELETE');
218        $store = new ShadowStore();
219        $store->set('parents', []);
220        $children = new CascadedChildren([['id' => 10, 'parent_id' => 1], ['id' => 11, 'parent_id' => 9]]);
221
222        $cascade->carryDelete($children, $store, $constraint, ['id'], ['id' => 1]);
223
224        self::assertSame(
225            [[['id' => 11, 'parent_id' => 9]], [['id' => 10, 'parent_id' => 1]]],
226            [$children->rows(), $children->deleted()],
227        );
228    }
229
230    public function testCarryDeleteSetsTheKeyToNullWhereTheActionSaysToRatherThanDroppingTheRow(): void
231    {
232        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
233        $foreignKey = new ForeignKeyDefinition(
234            ['parent_id'],
235            'parents',
236            ['id'],
237            onDelete: ReferentialAction::SetNull,
238        );
239        $constraint = new FollowedConstraint('children', 'fk', $foreignKey, 'DELETE');
240        $store = new ShadowStore();
241        $store->set('parents', []);
242        $children = new CascadedChildren([['id' => 10, 'parent_id' => 1]]);
243
244        $cascade->carryDelete($children, $store, $constraint, ['id'], ['id' => 1]);
245
246        self::assertSame([['id' => 10, 'parent_id' => null]], $children->rows());
247    }
248
249    public function testCarryDeleteReachesNothingWhereTheParentTableStillHoldsTheKey(): void
250    {
251        $cascade = new ForeignKeyCascade(new ForeignKeyEnds(new TableDefinitionRegistry()));
252        $foreignKey = new ForeignKeyDefinition(['parent_id'], 'parents', ['id']);
253        $constraint = new FollowedConstraint('children', 'fk', $foreignKey, 'DELETE');
254        $store = new ShadowStore();
255        $store->set('parents', [['id' => 1]]);
256        $children = new CascadedChildren([['id' => 10, 'parent_id' => 1]]);
257
258        $cascade->carryDelete($children, $store, $constraint, ['id'], ['id' => 1]);
259
260        self::assertTrue($children->areUnchanged());
261    }
262}
263