Skip to content

Fix:one-way relationship twoWayKey handling in createRelationship#842

Open
HarshMN2345 wants to merge 3 commits intomainfrom
fix-one-way-relationship-twowaykey
Open

Fix:one-way relationship twoWayKey handling in createRelationship#842
HarshMN2345 wants to merge 3 commits intomainfrom
fix-one-way-relationship-twowaykey

Conversation

@HarshMN2345
Copy link
Member

@HarshMN2345 HarshMN2345 commented Mar 19, 2026

Summary by CodeRabbit

  • Bug Fixes
    • Improved relationship validation logic to only enforce duplicate checks when creating two-way relationships, enabling users to create multiple one-way relationships with different keys on the same collection.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The duplicate-relationship check in createRelationship() is now conditional on the $twoWay parameter, allowing one-way relationships with matching attributes to coexist while maintaining the check for two-way relationships. The test is updated to verify both relationships can be created independently.

Changes

Cohort / File(s) Summary
Implementation
src/Database/Database.php
Modified createRelationship() to gate the duplicate-relationship exception on $twoWay being true, preventing the exception from blocking creation of one-way relationships with identical attributes.
Tests
tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
Updated testIdenticalTwoWayKeyRelationship() to verify that multiple relationships with different twoWayKey values can coexist; removed exception assertion and added validation for both children and childrenById relationship metadata.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 Two paths can now dance without fear,
One-way and two-way, both sincere!
The gate swings open, duplication freed,
When $twoWay whispers what we need.
Relationships bloom in harmony,
A rabbit's hop toward unity! 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing one-way relationship twoWayKey handling in the createRelationship method, which matches the core logic modification in Database.php.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-one-way-relationship-twowaykey

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 19, 2026

Greptile Summary

This PR fixes a regression in createRelationship where the reverse-key duplicate check fired for all relationship types — including one-way — which prevented creating multiple one-way relationships from the same collection to the same related collection. The fix is a one-line addition of $twoWay && to the guard condition.

Key concerns found:

  • Metadata corruption risk (P1): The $twoWayRelationship document is unconditionally appended to the related collection's metadata at line 3718 regardless of the $twoWay flag. With the new fix allowing two one-way relationships that share the same default twoWayKey (i.e., both defaulting to the collection ID), both calls write a document with the same $id into the related collection's attributes array, producing duplicate metadata entries that persist via updateDocument. This could cause undefined behaviour when querying or introspecting the related collection.

  • Test asserts against a phantom conflict (P1): The DuplicateException in the new test is triggered by a one-way relationship's stored twoWayKey — not by an actual reverse attribute in the related collection. The error message 'Related attribute already exists' is misleading in this scenario. A two-way vs. two-way collision (where the reverse attribute genuinely exists) is never tested, leaving a gap in regression coverage.

  • Removed document/query assertions (P2): The test deletes the only block that verified document creation and querying with multiple one-way relationships, reducing end-to-end confidence in the fix.

Confidence Score: 2/5

  • Not safe to merge — the fix may introduce related-collection metadata corruption when multiple one-way relationships share the same default twoWayKey.
  • The core one-line fix ($twoWay &&) is conceptually correct, but it does not account for the unconditional $relatedCollection->setAttribute append at line 3718 that writes the reverse-key document into the related collection's metadata for every relationship creation, one-way or not. Two one-way relationships sharing the same twoWayKey would silently produce duplicate attribute entries in the related collection. Combined with the weakened test coverage (phantom conflict assertion, removed document-operation assertions, missing two-way vs. two-way test), the change carries meaningful risk of data-integrity issues and undetected future regressions.
  • src/Database/Database.php — specifically lines 3591-3598 (loop guard) and 3717-3718 (unconditional metadata append to related collection).

Important Files Changed

Filename Overview
src/Database/Database.php Adds $twoWay && guard to the reverse-key duplicate check in createRelationship, allowing multiple one-way relationships to coexist; however, the unconditional append of $twoWayRelationship to the related collection's metadata (line 3718) means two one-way relationships sharing the same default twoWayKey will produce duplicate attribute entries in that collection's stored metadata.
tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php Test reworked to assert that multiple one-way relationships can be created and that a two-way relationship with a conflicting twoWayKey still throws; however, the DuplicateException is asserted against a one-way relationship's metadata (not a genuine reverse-attribute conflict), the canonical two-way vs. two-way collision case is not covered, and document-creation/query assertions were removed, reducing overall integration coverage.

Comments Outside Diff (1)

  1. src/Database/Database.php, line 3591-3598 (link)

    P1 Duplicate twoWayKey metadata written to related collection

    When two one-way relationships are created with the same twoWayKey pointing to the same related collection (e.g., child1 and children both defaulting to twoWayKey = 'parent'), the block at lines 3717–3718 unconditionally appends $twoWayRelationship (with '$id' => ID::custom($twoWayKey)) to $relatedCollection's attributes on every call, regardless of $twoWay:

    $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND);
    $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND);

    Because this is not guarded by $twoWay, the second one-way relationship call appends another document with the same $id (e.g., parent) to the child collection's attributes array before the metadata is persisted via updateDocument. This produces duplicate attribute entries in the related collection's stored metadata.

    The test at line 1058–1072 only validates parent's attributes, not child's, so this corruption goes undetected.

    The loop guard (line 3591) should either:

    • Also block one-way relationships from sharing the same twoWayKey with an existing attribute (one-way or two-way) in the same relatedCollection, or
    • The relatedCollection->setAttribute(...) call at line 3718 should be guarded by $twoWay to avoid writing phantom metadata into the related collection entirely.
    // Option: only append twoWayRelationship metadata when twoWay is true
    if ($twoWay) {
        $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND);
    }

    Note: if the append is removed for one-way relationships, the twoWayKey loop check for $twoWay === true would need a separate sweep of $relatedCollection's existing attributes to ensure $twoWayKey is not already taken.

Reviews (3): Last reviewed commit: "add test" | Re-trigger Greptile

if ($relatedCollection->isEmpty()) {
throw new NotFoundException('Related collection not found');
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 strtolower(null) deprecation in PHP 8.4

Since $twoWayKey ??= $collection->getId() was moved to after the loop, $twoWayKey can now be null at line 3577 when a caller passes twoWay: true but omits twoWayKey. Calling \strtolower(null) triggers a deprecation warning in PHP 8.1+ and the project requires PHP >= 8.4. This also silently breaks the duplicate twoWayKey guard for two-way relationships when the default key is used: strtolower(null) === "" will never match any stored twoWayKey string, so the loop won't detect a conflict, and two two-way relationships pointing at the same collection with the same default key could be created without throwing DuplicateException.

Adding a $twoWayKey !== null guard preserves the intended duplicate check and avoids the deprecation notice:

Suggested change
if (
$twoWay
&& $twoWayKey !== null
&& $attribute->getAttribute('type') === self::VAR_RELATIONSHIP
&& isset($attribute->getAttribute('options')['twoWayKey'])
&& \strtolower($attribute->getAttribute('options')['twoWayKey']) === \strtolower($twoWayKey)
&& $attribute->getAttribute('options')['relatedCollection'] === $relatedCollection->getId()
) {

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php (1)

1027-1042: ⚠️ Potential issue | 🟡 Minor

The new default makes the later duplicate-update check inconsistent.

With children now defaulting to parent, the later updateRelationship(... newTwoWayKey: 'parent') call is a no-op, not a conflicting change. This either makes the test fail once idempotent updates are allowed, or it locks in the wrong behavior. Please change that later assertion to use a genuinely conflicting key, or assert that reapplying the same twoWayKey succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php` around lines 1027 -
1042, The test becomes inconsistent because the collection 'children' now
defaults its twoWayKey to 'parent', making the later call updateRelationship(...
newTwoWayKey: 'parent') a no-op; update the test in OneToOneTests.php to either
(a) change the later updateRelationship call to use a genuinely conflicting key
(e.g. 'otherParent') and assert the conflict/changed value, or (b) if the intent
is to allow idempotent updates, change the assertion to verify that reapplying
newTwoWayKey: 'parent' succeeds (no-op) — locate the createRelationship call and
the later updateRelationship(...) invocation and adjust the assertion for the
'children' attribute accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Database.php`:
- Around line 3588-3592: The condition uses strtolower($twoWayKey) while
$twoWayKey can be null, causing deprecation/TypeError; ensure $twoWayKey is
initialized to an empty string (or safe default) before the loop that checks
$attribute and $attribute->getAttribute('options')['twoWayKey'] so strtolower is
never passed null. Update the code that sets/accepts $twoWayKey (the variable
used in the two-way duplicate scan condition involving $twoWay,
self::VAR_RELATIONSHIP, $attribute->getAttribute('options')['twoWayKey'], and
$relatedCollection->getId()) to default $twoWayKey to '' when null or missing so
the existing comparison logic remains unchanged and avoids strtolower(null).

In `@tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php`:
- Around line 53-64: The test currently asserts absence of the old backreference
key 'reviews' on the movie payload but the related-side metadata was renamed to
'review', so update the hidden-backreference assertion to check the new key
name: replace the assertArrayNotHasKey('reviews', $movie) check with
assertArrayNotHasKey('review', $movie) (look for usages of assertArrayNotHasKey
and the $movie variable to locate and update the assertion).

---

Outside diff comments:
In `@tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php`:
- Around line 1027-1042: The test becomes inconsistent because the collection
'children' now defaults its twoWayKey to 'parent', making the later call
updateRelationship(... newTwoWayKey: 'parent') a no-op; update the test in
OneToOneTests.php to either (a) change the later updateRelationship call to use
a genuinely conflicting key (e.g. 'otherParent') and assert the conflict/changed
value, or (b) if the intent is to allow idempotent updates, change the assertion
to verify that reapplying newTwoWayKey: 'parent' succeeds (no-op) — locate the
createRelationship call and the later updateRelationship(...) invocation and
adjust the assertion for the 'children' attribute accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3a0b6d17-3486-481d-bec7-1c647e6fcff1

📥 Commits

Reviewing files that changed from the base of the PR and between f121418 and 504c45e.

📒 Files selected for processing (3)
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php

Comment on lines +53 to +64
$this->assertEquals('review', $attribute['options']['twoWayKey']);
}
}

// Check metadata for related collection
$collection = $database->getCollection('movie');
$attributes = $collection->getAttribute('attributes', []);
foreach ($attributes as $attribute) {
if ($attribute['key'] === 'reviews') {
if ($attribute['key'] === 'review') {
$this->assertEquals('relationship', $attribute['type']);
$this->assertEquals('reviews', $attribute['$id']);
$this->assertEquals('reviews', $attribute['key']);
$this->assertEquals('review', $attribute['$id']);
$this->assertEquals('review', $attribute['key']);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the hidden-backreference assertions to the renamed key.

The related-side metadata now uses review, but the later assertArrayNotHasKey('reviews', $movie) checks still verify the old key. That means this test no longer catches regressions that accidentally expose the one-way backreference under its new name.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php` around lines 53 -
64, The test currently asserts absence of the old backreference key 'reviews' on
the movie payload but the related-side metadata was renamed to 'review', so
update the hidden-backreference assertion to check the new key name: replace the
assertArrayNotHasKey('reviews', $movie) check with
assertArrayNotHasKey('review', $movie) (look for usages of assertArrayNotHasKey
and the $movie variable to locate and update the assertion).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

3991-4014: ⚠️ Potential issue | 🔴 Critical

Keep one-to-one reverse indexes in sync when twoWay flips.

These metadata updates can rename the reverse attribute between the generated internal key and the public key, but the one-to-one index path later only handles rename-in-place. createRelationship() only creates the reverse unique index when twoWay=true, so false -> true needs a createIndex() path, and true -> false needs a deleteIndex() path. Otherwise the toggle either fails on a missing reverse index or leaves stale reverse-index metadata behind.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Database.php` around lines 3991 - 4014, The metadata update
handles renames but doesn't create or remove the one-to-one reverse index when
twoWay flips; update the logic around updateAttributeMeta (the blocks that
modify the attribute and $twoWay reverse attribute) to detect a change from
false->true and call the code path that creates the reverse unique index
(reuse/createIndex behavior from createRelationship) and detect true->false to
remove the reverse unique index (call deleteIndex path), ensuring you reference
the same attribute IDs/keys ($actualNewKey, $actualNewTwoWayKey, $oldTwoWayKey)
when creating or deleting the index so the physical index state stays in sync
with the updated options.
🧹 Nitpick comments (2)
tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php (1)

416-418: Harden relationship lookup to avoid undefined-offset failures.

Line 416 directly indexes [0] after filtering. If setup regresses, this can fail before a useful assertion. Add an explicit count/assert first for clearer diagnostics.

Proposed test-hardening diff
-        $relationship = \array_values(\array_filter($attributes, fn (Document $attribute) => $attribute->getId() === 'books'))[0];
-
-        $this->assertEquals(null, $relationship['options']['twoWayKey']);
+        $matches = \array_values(\array_filter($attributes, fn (Document $attribute) => $attribute->getId() === 'books'));
+        $this->assertCount(1, $matches, 'Expected exactly one relationship attribute with id "books"');
+        $relationship = $matches[0];
+        $this->assertEquals(null, $relationship['options']['twoWayKey']);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php` around lines 416 -
418, The test currently assumes the filtered array contains an element by doing
array_values(...)[0], which can produce an undefined-offset error; modify the
lookup in the test around $relationship and $attributes (the filtering closure
using Document::getId and the resulting $relationship variable) to first capture
the filtered/array_values result into a variable, assert that it is not empty
(e.g., assertNotEmpty or assertCount > 0) and only then access the [0] element,
then proceed to assertEquals(null, $relationship['options']['twoWayKey']); this
ensures a clear diagnostic if the relationship is missing.
tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php (1)

353-356: Avoid unguarded [0] access after filtering.

Line [355] can fail with a less-informative undefined offset if no tags attribute is found. Add an explicit assertion before dereferencing.

Suggested test-hardening diff
-        $relationship = \array_values(\array_filter($attributes, fn (Document $attribute) => $attribute->getId() === 'tags'))[0];
+        $matches = \array_values(\array_filter(
+            $attributes,
+            fn (Document $attribute) => $attribute->getId() === 'tags'
+        ));
+        $this->assertNotEmpty($matches, 'Relationship attribute "tags" was not created on course.');
+        $relationship = $matches[0];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php` around lines 353
- 356, The code uses array_values(array_filter(...))[0] to find the 'tags'
attribute which will throw an undefined offset if not found; update the test
around $collection, $attributes, and the $relationship extraction to assert that
the filtered result is not empty before dereferencing (e.g., check
count(array_filter(...)) > 0 or assertNotEmpty on the filtered array / use
current() safely) so the test fails with a clear assertion message instead of an
undefined offset error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Database.php`:
- Around line 3939-3942: The duplicate-check currently treats the existing
reverse attribute as a duplicate because actualPublicNewTwoWayKey can equal the
current related attribute key; update the condition so you first exclude the
attribute whose 'key' equals the existing counterpart from
relatedCollection->getAttribute('attributes', []) (e.g. filter out
attribute['key'] === $actualPublicNewTwoWayKey or the existing counterpart key
variable) and then check if actualPublicNewTwoWayKey exists among the remaining
attributes; this prevents metadata-only updates (onDelete, etc.) from raising a
DuplicateException while keeping the duplicate detection for other attributes.
- Around line 3550-3556: The method getRelationshipTwoWayKey currently accepts
array|Document but only Document is used; change its signature to accept
Document $relationship (remove the array union) and simplify the body to
retrieve options via $relationship->getAttribute('options', []) and return
$options['internalTwoWayKey'] ?? $options['twoWayKey'] ?? ''; update any
docblock/type hints referencing array|Document to Document to satisfy PHPStan
and avoid the dead-code is_array branch.

---

Outside diff comments:
In `@src/Database/Database.php`:
- Around line 3991-4014: The metadata update handles renames but doesn't create
or remove the one-to-one reverse index when twoWay flips; update the logic
around updateAttributeMeta (the blocks that modify the attribute and $twoWay
reverse attribute) to detect a change from false->true and call the code path
that creates the reverse unique index (reuse/createIndex behavior from
createRelationship) and detect true->false to remove the reverse unique index
(call deleteIndex path), ensuring you reference the same attribute IDs/keys
($actualNewKey, $actualNewTwoWayKey, $oldTwoWayKey) when creating or deleting
the index so the physical index state stays in sync with the updated options.

---

Nitpick comments:
In `@tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php`:
- Around line 353-356: The code uses array_values(array_filter(...))[0] to find
the 'tags' attribute which will throw an undefined offset if not found; update
the test around $collection, $attributes, and the $relationship extraction to
assert that the filtered result is not empty before dereferencing (e.g., check
count(array_filter(...)) > 0 or assertNotEmpty on the filtered array / use
current() safely) so the test fails with a clear assertion message instead of an
undefined offset error.

In `@tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php`:
- Around line 416-418: The test currently assumes the filtered array contains an
element by doing array_values(...)[0], which can produce an undefined-offset
error; modify the lookup in the test around $relationship and $attributes (the
filtering closure using Document::getId and the resulting $relationship
variable) to first capture the filtered/array_values result into a variable,
assert that it is not empty (e.g., assertNotEmpty or assertCount > 0) and only
then access the [0] element, then proceed to assertEquals(null,
$relationship['options']['twoWayKey']); this ensures a clear diagnostic if the
relationship is missing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a33b325d-1393-4bfd-bc1d-43fe4593bce0

📥 Commits

Reviewing files that changed from the base of the PR and between 504c45e and af610c0.

📒 Files selected for processing (5)
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php

Comment on lines +3550 to +3556
private function getRelationshipTwoWayKey(array|Document $relationship): string
{
$options = \is_array($relationship)
? ($relationship['options'] ?? [])
: $relationship->getAttribute('options', []);

return $options['internalTwoWayKey'] ?? $options['twoWayKey'] ?? '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the method definition
fd 'Database.php' | head -20

Repository: utopia-php/database

Length of output: 88


🏁 Script executed:

# Search for the getRelationshipTwoWayKey method definition
rg 'getRelationshipTwoWayKey' -A 8 -B 2

Repository: utopia-php/database

Length of output: 11891


🏁 Script executed:

# Check all call sites of getRelationshipTwoWayKey
rg 'getRelationshipTwoWayKey\(' -B 2 -A 2

Repository: utopia-php/database

Length of output: 5764


🏁 Script executed:

# Find the updateRelationship method to see what type $attributes has
rg 'public function updateRelationship' -A 30 | head -50

Repository: utopia-php/database

Length of output: 2859


🏁 Script executed:

# Look for where $oldAttribute is being used and its type context
rg '\$oldAttribute = \$attributes' -B 10 -A 5

Repository: utopia-php/database

Length of output: 1262


🏁 Script executed:

# Check the type hint for $attributes parameter in updateRelationship
rg 'updateRelationship\(' -B 3 -A 15 | grep -A 15 'function updateRelationship'

Repository: utopia-php/database

Length of output: 7528


🏁 Script executed:

# Check what Collection::getAttribute returns for 'attributes'
rg 'class Collection' -A 30 | head -60

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look at a few more call sites to understand what types are actually passed
rg 'getRelationshipTwoWayKey\(' -B 5 -A 2 | grep -A 2 -B 5 'processedRelationship\|relationship\[' | head -40

Repository: utopia-php/database

Length of output: 2626


🏁 Script executed:

# Let me check what the actual attributes look like in the collection
# Search for where attributes are set on Collection
rg "setAttribute.*'attributes'" -B 2 -A 2 | head -40

Repository: utopia-php/database

Length of output: 2079


🏁 Script executed:

# Look at the specific call site in updateRelationship more carefully
rg 'updateRelationship\(' -A 50 | head -80

Repository: utopia-php/database

Length of output: 5621


🏁 Script executed:

# Check if there's a phpstan config and any reported errors
fd -e 'phpstan.neon' -o -e 'phpstan.neon.dist'

Repository: utopia-php/database

Length of output: 179


🏁 Script executed:

# Look for Document class definition to understand its structure
rg 'class Document' -A 10 | head -30

Repository: utopia-php/database

Length of output: 1771


🏁 Script executed:

# Get the exact context around the updateRelationship call to getRelationshipTwoWayKey
sed -n '3900,3970p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 3126


🏁 Script executed:

# Check if getAttribute returns Document objects or arrays for attributes
rg 'getAttribute.*attributes' -A 1 -B 1 | grep -A 3 -B 3 'getAttribute.*attributes.*\[\]' | head -30

Repository: utopia-php/database

Length of output: 2432


🏁 Script executed:

# Look for the Document class and its ArrayAccess implementation
rg 'class Document' -A 50 | grep -A 50 'class Document'

Repository: utopia-php/database

Length of output: 19878


🏁 Script executed:

# Search for ArrayAccess or magic methods in Document
rg 'ArrayAccess|__get|__set|offsetGet|offsetSet' src/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look at the actual line where getAttribute is called and what it returns
rg 'getAttribute\(.attributes' -B 2 -A 5 | head -80

Repository: utopia-php/database

Length of output: 5424


🏁 Script executed:

# Check if attributes are stored as Document objects or plain arrays
rg 'setAttribute.*attributes.*Document\|append' -B 2 -A 2 | head -50

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for where attributes are set and what type they are
rg 'setAttribute.*attributes.*APPEND' -B 3 -A 3

Repository: utopia-php/database

Length of output: 2346


🏁 Script executed:

# Check how attributes are constructed and what type is stored
rg 'attributes\[\].*=' -A 2 -B 2 | head -40

Repository: utopia-php/database

Length of output: 2265


🏁 Script executed:

# Look at one complete flow - examine lines around the updateRelationship call to understand types better
sed -n '3895,3945p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 2341


🏁 Script executed:

# Check what type getAttribute returns for 'attributes'
rg 'function getAttribute' src/Database/Document.php -A 10

Repository: utopia-php/database

Length of output: 601


🏁 Script executed:

# Verify whether Document objects are what's actually stored in the attributes array
rg 'relationships as \$relationship' -B 5 -A 5

Repository: utopia-php/database

Length of output: 5322


🏁 Script executed:

# Final check: confirm the attributes array actually contains Document objects
rg "setAttribute\('attributes'" -A 1 -B 1 | grep -i document | head -20

Repository: utopia-php/database

Length of output: 1250


Tighten getRelationshipTwoWayKey() parameter type for PHPStan compliance.

This method is private and all call sites pass Document objects exclusively. The array|Document union type is dead code that triggers PHPStan's iterable-value-type error and can be safely removed.

Suggested fix
-    private function getRelationshipTwoWayKey(array|Document $relationship): string
+    private function getRelationshipTwoWayKey(Document $relationship): string
     {
-        $options = \is_array($relationship)
-            ? ($relationship['options'] ?? [])
-            : $relationship->getAttribute('options', []);
+        $options = $relationship->getAttribute('options', []);
 
         return $options['internalTwoWayKey'] ?? $options['twoWayKey'] ?? '';
     }
🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 3550-3550: PHPStan (level 7) error: Method Utopia\Database\Database::getRelationshipTwoWayKey() has parameter $relationship with no value type specified in iterable type array.


[error] 3550-3550: PHPStan (level 7) error: Method Utopia\Database\Database::getRelationshipTwoWayKey() has parameter $relationship with no value type specified in iterable type array|Utopia\Database\Document.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Database.php` around lines 3550 - 3556, The method
getRelationshipTwoWayKey currently accepts array|Document but only Document is
used; change its signature to accept Document $relationship (remove the array
union) and simplify the body to retrieve options via
$relationship->getAttribute('options', []) and return
$options['internalTwoWayKey'] ?? $options['twoWayKey'] ?? ''; update any
docblock/type hints referencing array|Document to Document to satisfy PHPStan
and avoid the dead-code is_array branch.

Comment on lines +3939 to +3942
if (
!\is_null($newTwoWayKey)
&& \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', [])))
$actualTwoWay
&& !\is_null($actualPublicNewTwoWayKey)
&& \in_array($actualPublicNewTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', [])))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Exclude the existing counterpart from this duplicate check.

When the reverse key is unchanged, actualPublicNewTwoWayKey resolves to the current related attribute, so this in_array() turns metadata-only updates like onDelete changes into a false DuplicateException.

🩹 Suggested fix
-        if (
-            $actualTwoWay
-            && !\is_null($actualPublicNewTwoWayKey)
-            && \in_array($actualPublicNewTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', [])))
-        ) {
-            throw new DuplicateException('Related attribute already exists');
-        }
+        if ($actualTwoWay && !\is_null($actualPublicNewTwoWayKey)) {
+            foreach ($relatedCollection->getAttribute('attributes', []) as $relatedAttribute) {
+                if (
+                    $relatedAttribute->getAttribute('key') === $actualPublicNewTwoWayKey
+                    && $relatedAttribute->getId() !== $oldTwoWayKey
+                ) {
+                    throw new DuplicateException('Related attribute already exists');
+                }
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Database.php` around lines 3939 - 3942, The duplicate-check
currently treats the existing reverse attribute as a duplicate because
actualPublicNewTwoWayKey can equal the current related attribute key; update the
condition so you first exclude the attribute whose 'key' equals the existing
counterpart from relatedCollection->getAttribute('attributes', []) (e.g. filter
out attribute['key'] === $actualPublicNewTwoWayKey or the existing counterpart
key variable) and then check if actualPublicNewTwoWayKey exists among the
remaining attributes; this prevents metadata-only updates (onDelete, etc.) from
raising a DuplicateException while keeping the duplicate detection for other
attributes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/e2e/Adapter/Scopes/RelationshipTests.php (1)

673-687: Widened exception assertion is appropriate but consider a more descriptive assertion.

The change correctly handles that one-way relationships (with twoWay: false) no longer create a twoWayKey attribute on the related collection. Setting a value for this non-existent attribute can be caught at different validation layers, resulting in either exception type.

For improved test clarity and failure messages, consider using assertThat with a custom constraint or extracting a helper:

💡 Optional: Improve assertion readability
 } catch (Exception $e) {
-    $this->assertTrue($e instanceof RelationshipException || $e instanceof StructureException);
+    $this->assertTrue(
+        $e instanceof RelationshipException || $e instanceof StructureException,
+        'Expected RelationshipException or StructureException, got ' . get_class($e) . ': ' . $e->getMessage()
+    );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/RelationshipTests.php` around lines 673 - 687,
Replace the terse assertTrue checks that an exception is either
RelationshipException or StructureException with a more descriptive assertion
message or helper to improve failure output: update the two occurrences where
you currently call $this->assertTrue($e instanceof RelationshipException || $e
instanceof StructureException) (inside the try/catch blocks around
createDocument calls) to either call a helper like
$this->assertRelationshipOrStructureException($e) or use $this->assertTrue(...,
"Expected RelationshipException or StructureException, got ".get_class($e)) so
failures show the actual exception class; add the helper method
assertRelationshipOrStructureException(Exception $e) that asserts the instance
types and includes the descriptive message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/e2e/Adapter/Scopes/RelationshipTests.php`:
- Around line 673-687: Replace the terse assertTrue checks that an exception is
either RelationshipException or StructureException with a more descriptive
assertion message or helper to improve failure output: update the two
occurrences where you currently call $this->assertTrue($e instanceof
RelationshipException || $e instanceof StructureException) (inside the try/catch
blocks around createDocument calls) to either call a helper like
$this->assertRelationshipOrStructureException($e) or use $this->assertTrue(...,
"Expected RelationshipException or StructureException, got ".get_class($e)) so
failures show the actual exception class; add the helper method
assertRelationshipOrStructureException(Exception $e) that asserts the instance
types and includes the descriptive message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 609174b3-b857-4f49-88ad-b6a3f192d8ab

📥 Commits

Reviewing files that changed from the base of the PR and between 3844d74 and 7799a19.

📒 Files selected for processing (2)
  • tests/e2e/Adapter/Scopes/RelationshipTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php

@HarshMN2345 HarshMN2345 force-pushed the fix-one-way-relationship-twowaykey branch from 7799a19 to cff2b6e Compare March 24, 2026 07:18
@HarshMN2345 HarshMN2345 reopened this Mar 24, 2026
@HarshMN2345 HarshMN2345 force-pushed the fix-one-way-relationship-twowaykey branch from 630eb82 to 84ed291 Compare March 24, 2026 08:18
@HarshMN2345
Copy link
Member Author

@greptile

Comment on lines 1044 to 1056
try {
$database->createRelationship(
collection: 'parent',
relatedCollection: 'child',
type: Database::RELATION_ONE_TO_MANY,
id: 'children',
twoWay: true,
id: 'twoWayChildren',
twoWayKey: 'parent_id'
);
$this->fail('Failed to throw Exception');
} catch (Exception $e) {
$this->assertEquals('Related attribute already exists', $e->getMessage());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Exception asserted against a one-way relationship's metadata

The DuplicateException is thrown here because childrenById (a one-way relationship) happens to carry the same twoWayKey value in its stored options. Since one-way relationships do not create a real reverse attribute in the related collection, the error message 'Related attribute already exists' is misleading — the attribute does not actually exist at the schema level.

More importantly, this test does not exercise the core guard: a genuine two-way vs two-way reverse-key collision, where the reverse attribute was actually created in the related collection. A stronger coverage would first create a twoWay: true relationship, then attempt to create a second twoWay: true relationship pointing at the same relatedCollection with an identical reverse key, and assert DuplicateException is thrown. Without that scenario, a future change could accidentally weaken the guard for real two-way key conflicts and the test suite would not catch the regression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant