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