Skip to content

Commit 7b9c704

Browse files
abnegateclaude
andcommitted
test(relationship): cover factories, apply, inverse and storage round trip
Replaces the magic-accessor model test and its counting double, which tested the removed Document-backed definition, with behavioural tests of the value object: every factory's arguments and defaults, sparse RelationshipUpdate semantics, inverse() key swapping, toDocument()/fromDocument()/fromArray() round trips, the 7.x stored shape with 'side' ignored, and rejection of malformed stored rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b05a3f6 commit 7b9c704

3 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Tests\Unit\Model;
4+
5+
use Closure;
6+
use PHPUnit\Framework\Attributes\DataProvider;
7+
use PHPUnit\Framework\TestCase;
8+
use Utopia\Database\Relationship;
9+
use Utopia\Database\RelationshipDeleteAction;
10+
use Utopia\Database\RelationshipType;
11+
12+
final class RelationshipFactoryTest extends TestCase
13+
{
14+
/**
15+
* @return array<string, array{Closure(string, ?string=, bool=, ?string=, RelationshipDeleteAction=): Relationship, RelationshipType}>
16+
*/
17+
public static function factories(): array
18+
{
19+
return [
20+
'oneToOne' => [Relationship::oneToOne(...), RelationshipType::OneToOne],
21+
'oneToMany' => [Relationship::oneToMany(...), RelationshipType::OneToMany],
22+
'manyToOne' => [Relationship::manyToOne(...), RelationshipType::ManyToOne],
23+
'manyToMany' => [Relationship::manyToMany(...), RelationshipType::ManyToMany],
24+
];
25+
}
26+
27+
/**
28+
* @param Closure(string, ?string=, bool=, ?string=, RelationshipDeleteAction=): Relationship $factory
29+
*/
30+
#[DataProvider('factories')]
31+
public function testFactoryCarriesEveryArgument(Closure $factory, RelationshipType $type): void
32+
{
33+
$relationship = $factory('comments', 'comments', true, 'post', RelationshipDeleteAction::Cascade);
34+
35+
$this->assertSame('comments', $relationship->relatedCollection);
36+
$this->assertSame($type, $relationship->type);
37+
$this->assertTrue($relationship->twoWay);
38+
$this->assertSame('comments', $relationship->key);
39+
$this->assertSame('post', $relationship->twoWayKey);
40+
$this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete);
41+
}
42+
43+
/**
44+
* @param Closure(string, ?string=, bool=, ?string=, RelationshipDeleteAction=): Relationship $factory
45+
*/
46+
#[DataProvider('factories')]
47+
public function testFactoryDefaultsToOneWayRestrictWithDerivedKeys(Closure $factory, RelationshipType $type): void
48+
{
49+
$relationship = $factory('users');
50+
51+
$this->assertSame('users', $relationship->relatedCollection);
52+
$this->assertSame($type, $relationship->type);
53+
$this->assertFalse($relationship->twoWay);
54+
$this->assertNull($relationship->key);
55+
$this->assertNull($relationship->twoWayKey);
56+
$this->assertSame(RelationshipDeleteAction::Restrict, $relationship->onDelete);
57+
}
58+
59+
public function testFactoriesAcceptNamedArguments(): void
60+
{
61+
$relationship = Relationship::manyToMany(
62+
relatedCollection: 'tags',
63+
key: 'tags',
64+
twoWay: true,
65+
twoWayKey: 'posts',
66+
onDelete: RelationshipDeleteAction::SetNull,
67+
);
68+
69+
$this->assertSame('tags', $relationship->relatedCollection);
70+
$this->assertSame(RelationshipType::ManyToMany, $relationship->type);
71+
$this->assertTrue($relationship->twoWay);
72+
$this->assertSame('tags', $relationship->key);
73+
$this->assertSame('posts', $relationship->twoWayKey);
74+
$this->assertSame(RelationshipDeleteAction::SetNull, $relationship->onDelete);
75+
}
76+
77+
public function testOneWayRelationshipMayNameItsTwoWayKey(): void
78+
{
79+
$relationship = Relationship::manyToOne('users', key: 'author', twoWayKey: 'posts');
80+
81+
$this->assertFalse($relationship->twoWay);
82+
$this->assertSame('posts', $relationship->twoWayKey);
83+
}
84+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
namespace Tests\Unit\Model;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use PHPUnit\Framework\TestCase;
7+
use Utopia\Database\Document;
8+
use Utopia\Database\Exception\Relationship as RelationshipException;
9+
use Utopia\Database\Relationship;
10+
use Utopia\Database\RelationshipDeleteAction;
11+
use Utopia\Database\RelationshipSide;
12+
use Utopia\Database\RelationshipType;
13+
use Utopia\Query\Schema\ForeignKeyAction;
14+
15+
final class RelationshipTest extends TestCase
16+
{
17+
public function testConstructorIsNotCallableFromOutside(): void
18+
{
19+
$this->expectException(\Error::class);
20+
21+
new Relationship('users', RelationshipType::OneToOne, false, null, null, RelationshipDeleteAction::Restrict); // @phpstan-ignore new.privateConstructor
22+
}
23+
24+
public function testInverseSwapsKeysAndPointsAtTheGivenCollection(): void
25+
{
26+
$relationship = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade);
27+
28+
$inverse = $relationship->inverse('posts');
29+
30+
$this->assertSame('posts', $inverse->relatedCollection);
31+
$this->assertSame('post', $inverse->key);
32+
$this->assertSame('comments', $inverse->twoWayKey);
33+
$this->assertSame(RelationshipType::OneToMany, $inverse->type);
34+
$this->assertTrue($inverse->twoWay);
35+
$this->assertSame(RelationshipDeleteAction::Cascade, $inverse->onDelete);
36+
}
37+
38+
public function testInverseLeavesTheOriginalUntouched(): void
39+
{
40+
$relationship = Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts');
41+
42+
$relationship->inverse('posts');
43+
44+
$this->assertSame('tags', $relationship->relatedCollection);
45+
$this->assertSame('tags', $relationship->key);
46+
$this->assertSame('posts', $relationship->twoWayKey);
47+
}
48+
49+
public function testInverseOfInverseRestoresTheKeys(): void
50+
{
51+
$relationship = Relationship::manyToOne('users', key: 'author', twoWay: true, twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull);
52+
53+
$this->assertSameRelationship($relationship, $relationship->inverse('posts')->inverse('users'));
54+
}
55+
56+
public function testInverseWithUnresolvedKeysKeepsThemUnresolved(): void
57+
{
58+
$inverse = Relationship::oneToOne('profiles', key: 'profile')->inverse('users');
59+
60+
$this->assertNull($inverse->key);
61+
$this->assertSame('profile', $inverse->twoWayKey);
62+
}
63+
64+
public function testToDocumentWritesTheStoredOptionsShape(): void
65+
{
66+
$stored = Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::SetNull)->toDocument();
67+
68+
$this->assertSame([
69+
'relatedCollection' => 'comments',
70+
'relationType' => 'oneToMany',
71+
'twoWay' => true,
72+
'key' => 'comments',
73+
'twoWayKey' => 'post',
74+
'onDelete' => 'setNull',
75+
], $stored->getArrayCopy());
76+
}
77+
78+
/**
79+
* @return array<string, array{Relationship}>
80+
*/
81+
public static function relationships(): array
82+
{
83+
return [
84+
'oneToOne defaults' => [Relationship::oneToOne('profiles')],
85+
'oneToMany two-way cascade' => [Relationship::oneToMany('comments', key: 'comments', twoWay: true, twoWayKey: 'post', onDelete: RelationshipDeleteAction::Cascade)],
86+
'manyToOne set null' => [Relationship::manyToOne('users', key: 'author', twoWayKey: 'posts', onDelete: RelationshipDeleteAction::SetNull)],
87+
'manyToMany two-way' => [Relationship::manyToMany('tags', key: 'tags', twoWay: true, twoWayKey: 'posts')],
88+
];
89+
}
90+
91+
#[DataProvider('relationships')]
92+
public function testDocumentRoundTrip(Relationship $relationship): void
93+
{
94+
$this->assertSameRelationship($relationship, Relationship::fromDocument($relationship->toDocument()));
95+
}
96+
97+
#[DataProvider('relationships')]
98+
public function testArrayRoundTrip(Relationship $relationship): void
99+
{
100+
$this->assertSameRelationship($relationship, Relationship::fromArray($relationship->toDocument()->getArrayCopy()));
101+
}
102+
103+
public function testSevenXStoredOptionsHydrate(): void
104+
{
105+
$relationship = Relationship::fromDocument(new Document([
106+
'relatedCollection' => 'comments',
107+
'relationType' => 'oneToMany',
108+
'twoWay' => true,
109+
'twoWayKey' => 'post',
110+
'onDelete' => 'cascade',
111+
'side' => 'parent',
112+
]));
113+
114+
$this->assertSame('comments', $relationship->relatedCollection);
115+
$this->assertSame(RelationshipType::OneToMany, $relationship->type);
116+
$this->assertTrue($relationship->twoWay);
117+
$this->assertNull($relationship->key);
118+
$this->assertSame('post', $relationship->twoWayKey);
119+
$this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete);
120+
}
121+
122+
public function testSideIsIgnored(): void
123+
{
124+
$data = ['relatedCollection' => 'users', 'relationType' => 'manyToOne', 'key' => 'author', 'twoWayKey' => 'posts'];
125+
126+
$this->assertSameRelationship(
127+
Relationship::fromArray($data),
128+
Relationship::fromArray($data + ['side' => RelationshipSide::Child->value]),
129+
);
130+
$this->assertArrayNotHasKey('side', Relationship::fromArray($data + ['side' => 'child'])->toDocument()->getArrayCopy());
131+
}
132+
133+
public function testMissingOptionalKeysFallBackToDefaults(): void
134+
{
135+
$relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne']);
136+
137+
$this->assertFalse($relationship->twoWay);
138+
$this->assertNull($relationship->key);
139+
$this->assertNull($relationship->twoWayKey);
140+
$this->assertSame(RelationshipDeleteAction::Restrict, $relationship->onDelete);
141+
}
142+
143+
public function testEmptyStoredKeysHydrateAsUnresolved(): void
144+
{
145+
$relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'key' => '', 'twoWayKey' => '']);
146+
147+
$this->assertNull($relationship->key);
148+
$this->assertNull($relationship->twoWayKey);
149+
}
150+
151+
public function testEnumValuesAreAcceptedInPlaceOfStrings(): void
152+
{
153+
$relationship = Relationship::fromArray([
154+
'relatedCollection' => 'users',
155+
'relationType' => RelationshipType::ManyToMany,
156+
'onDelete' => RelationshipDeleteAction::SetNull,
157+
]);
158+
159+
$this->assertSame(RelationshipType::ManyToMany, $relationship->type);
160+
$this->assertSame(RelationshipDeleteAction::SetNull, $relationship->onDelete);
161+
}
162+
163+
public function testSupportedForeignKeyActionIsAccepted(): void
164+
{
165+
$relationship = Relationship::fromArray(['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'onDelete' => ForeignKeyAction::Cascade]);
166+
167+
$this->assertSame(RelationshipDeleteAction::Cascade, $relationship->onDelete);
168+
}
169+
170+
/**
171+
* @return array<string, array{array<string, mixed>}>
172+
*/
173+
public static function invalidRows(): array
174+
{
175+
return [
176+
'missing related collection' => [['relationType' => 'oneToOne']],
177+
'empty related collection' => [['relatedCollection' => '', 'relationType' => 'oneToOne']],
178+
'non-string related collection' => [['relatedCollection' => 7, 'relationType' => 'oneToOne']],
179+
'missing type' => [['relatedCollection' => 'users']],
180+
'unknown type' => [['relatedCollection' => 'users', 'relationType' => 'oneToFew']],
181+
'non-string type' => [['relatedCollection' => 'users', 'relationType' => 1]],
182+
'non-string key' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'key' => 5]],
183+
'non-string two-way key' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'twoWayKey' => ['posts']]],
184+
'unknown delete action' => [['relatedCollection' => 'users', 'relationType' => 'oneToOne', 'onDelete' => 'explode']],
185+
];
186+
}
187+
188+
/**
189+
* @param array<string, mixed> $data
190+
*/
191+
#[DataProvider('invalidRows')]
192+
public function testInvalidArrayIsRejected(array $data): void
193+
{
194+
$this->expectException(RelationshipException::class);
195+
196+
Relationship::fromArray($data);
197+
}
198+
199+
/**
200+
* @param array<string, mixed> $data
201+
*/
202+
#[DataProvider('invalidRows')]
203+
public function testInvalidDocumentIsRejected(array $data): void
204+
{
205+
$this->expectException(RelationshipException::class);
206+
207+
Relationship::fromDocument(new Document($data));
208+
}
209+
210+
private function assertSameRelationship(Relationship $expected, Relationship $actual): void
211+
{
212+
$this->assertSame($expected->relatedCollection, $actual->relatedCollection);
213+
$this->assertSame($expected->type, $actual->type);
214+
$this->assertSame($expected->twoWay, $actual->twoWay);
215+
$this->assertSame($expected->key, $actual->key);
216+
$this->assertSame($expected->twoWayKey, $actual->twoWayKey);
217+
$this->assertSame($expected->onDelete, $actual->onDelete);
218+
}
219+
}

0 commit comments

Comments
 (0)