Skip to content

fix: look up referencing documents in deleteSetNull instead of trusting the relationship value - #979

Merged
HarshMN2345 merged 2 commits into
mainfrom
fix/set-null-missing-relationship-value
Sep 21, 2026
Merged

HarshMN2345 merged 2 commits into
mainfrom
fix/set-null-missing-relationship-value

Conversation

@HarshMN2345

Copy link
Copy Markdown
Member

What

deleteSetNull() read the related documents off the document being deleted. That value is only present when the document was fetched with its relationships populated — and deleteDocuments() passes the caller's queries straight to find(), where a select query turns relationship population off. The document then reaches deleteSetNull() with the relationship attribute missing entirely:

  • one-to-many (parent side) and many-to-one (child side): foreach () argument must be of type array|object, null given → HTTP 500 general_unknown
  • one-to-one (two-way): took the empty($value) early return and silently left the other side pointing at a deleted row

This queries the referencing documents instead — the way deleteCascade() and deleteRestrict() already do — so the outcome no longer depends on how the document happened to be loaded.

The lookup runs with permissions skipped. A referencing document the caller cannot read still has to have its foreign key cleared, or deleting a row leaves dangling references whenever document security hides some of the referencing rows. That also fixes the pre-existing one-way many-to-one lookup, which queried but did so as the caller.

$value is no longer read on entry, so it is dropped from the private method's signature.

Why not just guard the empty value

Two things worth recording, because they rule out the smaller-looking fixes:

  • if (empty($value)) break; stops the crash but skips the null-out, turning a 500 into a silent dangling foreign key — the same failure mode as the one-to-one empty() return above.
  • Making the many-to-one lookup unconditional without skipping permissions is a regression. Today the populated value comes from a permission-skipped fetch, so every referencing row is cleared; running the lookup as the caller silently skips rows they cannot read. testManyToOneSetNullClearsUnreadableReferences covers that.

Note also that a plain single-row delete of a two-way many-to-one child with zero related rows already works on main: populateManyToOneRelationshipsBatch() sets the child-side attribute to [], never null. I instrumented both branches and ran all 118 relationship tests — $value is never non-array there. The null is only reachable through the bulk-delete-with-select path above, which is what the tests below exercise.

Test Plan

Four tests added, in the per-type traits:

Test On main
testOneToManySetNullAfterSelectDelete foreach () argument must be of type array|object, null given
testManyToOneSetNullAfterSelectDelete foreach () argument must be of type array|object, null given
testOneToOneSetNullAfterSelectDelete fails — foreign key left dangling
testManyToOneSetNullClearsUnreadableReferences passes — locks in the permission behaviour

All four pass here.

  • Full MariaDBTest: 689 tests, 11593 assertions. The only failure is testCacheFallback, which greps for a utopia-redis container; I had renamed containers to isolate my stack from another checkout, and it fails identically on unmodified main under the same setup.
  • Relationship suite green on MariaDB, Postgres and MongoDB (the one testDeleteCollectionDeletesRelationships error is a pre-existing --filter ordering artifact, identical on main).
  • phpstan --level 7 src clean. pint --test on Database.php fails identically on unmodified main, so I left the file's existing formatting alone rather than reformat ~2000 unrelated lines.

Related

…ctions (appwrite/appwrite#13766)

In deleteSetNull(), RELATION_MANY_TO_ONE previously wrapped the related documents lookup in `if (!$twoWay)`. When deleting a child document in a two-way Many-to-One relationship, `$this->find()` was skipped, leaving `$value` unpopulated (null) and causing a fatal `TypeError: foreach() argument must be of type array|object, null given`.

This fix aligns deleteSetNull() with deleteCascade() and deleteRestrict() by unconditionally querying the referencing parent documents, and adds defensive empty checks for RELATION_ONE_TO_MANY and RELATION_MANY_TO_ONE.
…ng the relationship value

deleteSetNull() read the related documents off the document being deleted.
That value is only there when the document was fetched with its relationships
populated. deleteDocuments() passes the caller's queries straight to find(),
and a select query turns relationship population off, so the document reaches
deleteSetNull() with the relationship attribute missing:

  - one-to-many (parent side) and many-to-one (child side) hit
    `foreach () argument must be of type array|object, null given`,
    surfacing as HTTP 500 general_unknown
  - one-to-one (two-way) took the `empty($value)` early return and silently
    left the other side pointing at a deleted row

Query the referencing documents instead, the way deleteCascade() and
deleteRestrict() already do, so the outcome no longer depends on how the
document was loaded.

The lookup runs with permissions skipped. A referencing document the caller
cannot read still has to have its foreign key cleared, otherwise deleting a
row leaves dangling references behind whenever document security hides some
of the referencing rows. This also fixes that case for the one-way
many-to-one lookup, which already queried but did so as the caller.

`$value` is no longer read on entry, so it is dropped from the signature.

Co-authored-by: PINYO PATTANAWASANPORN <pattanawasanporn@gmail.com>
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 54 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: utopia-php/database/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 9eebd057-d5f2-4b6b-bbd2-de845eddaec1

📥 Commits

Reviewing files that changed from the base of the PR and between e45195f and f148181.

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

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.

@greptile-apps

greptile-apps Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or repository-rule violations remain.

Summary

This PR makes SET_NULL relationship cleanup independent of whether the deleted document was loaded with populated relationships.

  • Adds a permission-skipped lookup for documents referencing the row being deleted.
  • Uses that lookup for one-to-many and many-to-one cleanup and an equivalent lookup for one-to-one cleanup.
  • Adds observable-behavior coverage for bulk deletion with projections and cleanup of unreadable referencing documents.

Reviews (1) · Last reviewed commit: "fix: look up referencing documents in de..."

@HarshMN2345
HarshMN2345 merged commit bc0c031 into main Sep 21, 2026
42 checks passed
@HarshMN2345
HarshMN2345 deleted the fix/set-null-missing-relationship-value branch September 21, 2026 09:13
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.

3 participants