Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,12 @@ class Database
*/
protected array $relationshipWriteStack = [];

/**
* Reverse links to parents that are still being created.
* @var array<array{collection: string, id: string, tenant: int|string|null, key: string, relatedCollection: string, relatedId: string}>
*/
protected array $relationshipWriteLinks = [];

/**
* @var array<Document>
*/
Expand Down Expand Up @@ -6159,20 +6165,24 @@ private function relateDocuments(
string $twoWayKey,
string $side,
): string {
$writeLink = false;
switch ($relationType) {
case Database::RELATION_ONE_TO_ONE:
if ($twoWay) {
$relation->setAttribute($twoWayKey, $document->getId());
$writeLink = true;
}
break;
case Database::RELATION_ONE_TO_MANY:
if ($side === Database::RELATION_SIDE_PARENT) {
$relation->setAttribute($twoWayKey, $document->getId());
$writeLink = true;
}
break;
case Database::RELATION_MANY_TO_ONE:
if ($side === Database::RELATION_SIDE_CHILD) {
$relation->setAttribute($twoWayKey, $document->getId());
$writeLink = true;
}
break;
}
Expand All @@ -6193,7 +6203,24 @@ private function relateDocuments(
$related->setAttribute($attribute, $value);
}

$related = $this->updateDocument($relatedCollection->getId(), $related->getId(), $related);
if ($writeLink) {
$this->relationshipWriteLinks[] = [
'collection' => $relatedCollection->getId(),
'id' => $related->getId(),
'tenant' => $related->getTenant(),
'key' => $twoWayKey,
'relatedCollection' => $collection->getId(),
'relatedId' => $document->getId(),
];
}

try {
$related = $this->updateDocument($relatedCollection->getId(), $related->getId(), $related);
} finally {
if ($writeLink) {
\array_pop($this->relationshipWriteLinks);
}
}
}

if ($relationType === Database::RELATION_MANY_TO_MANY) {
Expand All @@ -6213,6 +6240,24 @@ private function relateDocuments(
return $related->getId();
}

private function isRelationshipWriteLink(Document $collection, Document $document, string $key, string $relatedCollection): bool
{
foreach ($this->relationshipWriteLinks as $link) {
if (
$link['collection'] === $collection->getId()
&& $link['id'] === $document->getId()
&& $link['tenant'] === $document->getTenant()
&& $link['key'] === $key
&& $link['relatedCollection'] === $relatedCollection
&& $link['relatedId'] === $document->getAttribute($key)
) {
return true;
}
}

return false;
}

/**
* @param Document $collection
* @param Document $relatedCollection
Expand Down Expand Up @@ -6375,7 +6420,10 @@ public function updateDocument(string $collection, string $id, Document $documen
// Compare if the document has any changes
foreach ($document as $key => $value) {
if (\array_key_exists($key, $relationships)) {
if (\count($this->relationshipWriteStack) >= Database::RELATION_MAX_DEPTH - 1) {
if (
\count($this->relationshipWriteStack) >= Database::RELATION_MAX_DEPTH - 1
&& !$this->isRelationshipWriteLink($collection, $document, $key, $relationships[$key]['options']['relatedCollection'])
) {
continue;
}

Expand Down Expand Up @@ -6864,6 +6912,12 @@ private function updateDocumentRelationships(Document $collection, Document $old
}
}

// The enclosing create owns this reverse link. Its parent is not
// persisted yet, but the child's other relationships still need processing.
if ($this->isRelationshipWriteLink($collection, $document, $key, $relatedCollection->getId())) {
continue;
}

if ($oldValue == $value) {
if (
($relationType === Database::RELATION_ONE_TO_ONE
Expand Down
226 changes: 226 additions & 0 deletions tests/e2e/Adapter/Scopes/RelationshipTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,232 @@ public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void



public function testCreateWithExistingNestedRelationship(): void
{
$database = $this->getDatabase();
if (!$database->getAdapter()->getSupportForRelationships()) {
$this->expectNotToPerformAssertions();
return;
}

foreach ([
[Database::RELATION_ONE_TO_ONE, true, false],
[Database::RELATION_ONE_TO_MANY, false, false],
[Database::RELATION_ONE_TO_MANY, true, false],
[Database::RELATION_MANY_TO_ONE, false, true],
[Database::RELATION_MANY_TO_ONE, true, true],
] as $index => [$type, $twoWay, $inverse]) {
$parents = 'nestedParents' . $index;
$children = 'nestedChildren' . $index;
$details = 'nestedDetails' . $index;
$permissions = [Permission::read(Role::any()), Permission::update(Role::any())];

$database->createCollection($parents, documentSecurity: true);
$database->createCollection($children, documentSecurity: true);
$database->createCollection($details, documentSecurity: true);
$database->createAttribute($children, 'name', Database::VAR_STRING, 50, true);
$database->createAttribute($details, 'note', Database::VAR_STRING, 50, true);
$database->createRelationship(
$inverse ? $children : $parents,
$inverse ? $parents : $children,
$type,
$twoWay,
$inverse ? 'parent' : 'child',
$inverse ? 'child' : 'parent'
);
$database->createRelationship($children, $details, Database::RELATION_ONE_TO_ONE, false, 'details', 'child');
$database->createDocument($children, new Document([
'$id' => 'existing',
'$permissions' => $permissions,
'name' => 'before',
]));

$child = new Document([
'$id' => 'existing',
'name' => 'after',
'details' => new Document([
'$id' => 'newDetail',
'$permissions' => $permissions,
'note' => 'nested update',
]),
]);
$multiple = $type !== Database::RELATION_ONE_TO_ONE;
$created = $database->createDocument($parents, new Document([
'$id' => 'newParent',
'$permissions' => $permissions,
'child' => $multiple ? [$child] : $child,
]));

if (!$inverse || $twoWay) {
$related = $created->getAttribute('child');
if ($multiple) {
$this->assertCount(1, $related, $type . ' twoWay=' . (int) $twoWay);
}
$this->assertSame('existing', ($multiple ? $related[0] : $related)->getId());
}

$parent = $database->getDocument($parents, 'newParent');
$this->assertFalse($parent->isEmpty());
if (!$inverse || $twoWay) {
$related = $parent->getAttribute('child');
if ($multiple) {
$this->assertCount(1, $related, $type . ' twoWay=' . (int) $twoWay);
}
$this->assertSame('existing', ($multiple ? $related[0] : $related)->getId());
}

$stored = $database->getDocument($children, 'existing');
$this->assertSame('after', $stored->getAttribute('name'));
$this->assertSame('newDetail', $stored->getAttribute('details')->getId());
$this->assertSame('nested update', $database->getDocument($details, 'newDetail')->getAttribute('note'));
if ($twoWay || $inverse) {
$this->assertSame('newParent', $stored->getAttribute('parent')->getId());
}

$database->deleteCollection($parents);
$database->deleteCollection($children);
$database->deleteCollection($details);
}
}

public function testCreateWithExistingNestedRelationshipFailure(): void
{
$database = $this->getDatabase();
if (!$database->getAdapter()->getSupportForRelationships()) {
$this->expectNotToPerformAssertions();
return;
}

foreach (['read', 'update', 'duplicate'] as $failure) {
$parents = 'nestedFailureParents' . $failure;
$children = 'nestedFailureChildren' . $failure;
$details = 'nestedFailureDetails' . $failure;
$permissions = [Permission::read(Role::any()), Permission::update(Role::any())];
$childPermissions = match ($failure) {
'read' => [Permission::update(Role::any())],
'update' => [Permission::read(Role::any())],
default => $permissions,
};
$database->createCollection($parents, documentSecurity: true);
$database->createCollection($children, documentSecurity: true);
$database->createCollection($details, documentSecurity: true);
$database->createAttribute($children, 'name', Database::VAR_STRING, 50, true);
$database->createAttribute($details, 'note', Database::VAR_STRING, 50, true);
$database->createRelationship($parents, $children, Database::RELATION_ONE_TO_ONE, true, 'child', 'parent');
$database->createRelationship($children, $details, Database::RELATION_ONE_TO_ONE, false, 'details', 'child');
$database->createDocument($children, new Document([
'$id' => 'existing',
'$permissions' => $childPermissions,
'name' => 'before',
]));
if ($failure === 'duplicate') {
$database->createDocument($parents, new Document([
'$id' => 'originalParent',
'$permissions' => $permissions,
'child' => 'existing',
]));
}

$parent = [
'$id' => 'rejectedParent',
'$permissions' => $permissions,
'child' => [
'$id' => 'existing',
'name' => 'after',
'details' => [
'$id' => 'rejectedDetail',
'$permissions' => $permissions,
'note' => 'must roll back',
],
],
];
try {
$database->createDocument($parents, new Document($parent));
$this->fail('Expected nested write to reject ' . $failure);
} catch (AuthorizationException|DuplicateException $e) {
if ($failure === 'update') {
$this->assertInstanceOf(AuthorizationException::class, $e);
} else {
$this->assertInstanceOf(DuplicateException::class, $e);
}
}

$database->getAuthorization()->skip(function () use ($database, $parents, $children, $details, $failure) {
$this->assertTrue($database->getDocument($parents, 'rejectedParent')->isEmpty());
$this->assertTrue($database->getDocument($details, 'rejectedDetail')->isEmpty());
$stored = $database->getDocument($children, 'existing');
$this->assertSame('before', $stored->getAttribute('name'));
if ($failure === 'duplicate') {
$this->assertSame('originalParent', $stored->getAttribute('parent')->getId());
}
});

if ($failure === 'update') {
$database->getAuthorization()->skip(fn () => $database->updateDocument($children, 'existing', new Document([
'$permissions' => $permissions,
])));

// A failed nested write must not leave its pending edge active.
$database->updateDocument($children, 'existing', new Document(['parent' => 'rejectedParent']));
$stored = $database->skipRelationships(fn () => $database->getDocument($children, 'existing'));
$this->assertNull($stored->getAttribute('parent'));

$database->createDocument($parents, new Document($parent));
$stored = $database->getDocument($children, 'existing');
$this->assertSame('rejectedParent', $stored->getAttribute('parent')->getId());
$this->assertSame('after', $stored->getAttribute('name'));
$this->assertSame('rejectedDetail', $stored->getAttribute('details')->getId());
}

$database->deleteCollection($parents);
$database->deleteCollection($children);
$database->deleteCollection($details);
}
}

public function testCreateWithExistingNestedRelationshipDepthPermission(): void
{
$database = $this->getDatabase();
if (!$database->getAdapter()->getSupportForRelationships()) {
$this->expectNotToPerformAssertions();
return;
}

$database->createCollection('nestedDepthRoots', documentSecurity: true);
$database->createCollection('nestedDepthParents', documentSecurity: true);
$database->createCollection('nestedDepthChildren', documentSecurity: true);
$database->createRelationship('nestedDepthRoots', 'nestedDepthParents', Database::RELATION_ONE_TO_MANY, true, 'parents', 'root');
$database->createRelationship('nestedDepthParents', 'nestedDepthChildren', Database::RELATION_ONE_TO_MANY, true, 'children', 'parent');
$database->createDocument('nestedDepthChildren', new Document([
'$id' => 'existing',
'$permissions' => [Permission::read(Role::any())],
]));
$permissions = [Permission::read(Role::any()), Permission::update(Role::any())];

try {
$database->createDocument('nestedDepthRoots', new Document([
'$id' => 'root',
'$permissions' => $permissions,
'parents' => [new Document([
'$id' => 'parent',
'$permissions' => $permissions,
'children' => [new Document(['$id' => 'existing'])],
])],
]));
$this->fail('Linking the existing child still requires update permission at the depth limit.');
} catch (AuthorizationException $e) {
$this->assertStringContainsString('update', $e->getMessage());
}

$this->assertTrue($database->getDocument('nestedDepthRoots', 'root')->isEmpty());
$this->assertTrue($database->getDocument('nestedDepthParents', 'parent')->isEmpty());
$this->assertNull($database->getDocument('nestedDepthChildren', 'existing')->getAttribute('parent'));

$database->deleteCollection('nestedDepthRoots');
$database->deleteCollection('nestedDepthParents');
$database->deleteCollection('nestedDepthChildren');
}

public function testUpdateAttributeRenameRelationshipTwoWay(): void
{
/** @var Database $database */
Expand Down
Loading