Skip to content

Add RefersToMorphed relation for cyclic morphed references#570

Merged
roxblnfk merged 3 commits into
2.xfrom
feature/refers-to-morphed
Jun 15, 2026
Merged

Add RefersToMorphed relation for cyclic morphed references#570
roxblnfk merged 3 commits into
2.xfrom
feature/refers-to-morphed

Conversation

@roxblnfk

Copy link
Copy Markdown
Member

🔍 What was changed

Added a new morphed relation type Relation::REFERS_TO_MORPHED together with the RefersToMorphed relation class.

RefersToMorphed is to BelongsToMorphed what RefersTo is to BelongsTo: it stores both the outer key and the target role (morph key) on the owner entity, but resolves the outer key in a deferred / "soft" way instead of as a hard "parent before child" dependency.

  • src/Relation.php — new REFERS_TO_MORPHED constant.
  • src/Relation/Morphed/RefersToMorphed.php — new relation extending RefersTo; adds morph-key handling (reads the target role from the morph column in initReference, keeps the morph key in sync with the related entity role, and clears it when the relation is set to null).
  • src/Config/RelationConfig.php — registers the type, reusing the existing BelongsToMorphedLoader (read semantics are identical).

Note

The #[RefersToMorphed] attribute lives in the separate cycle/annotated package and is intentionally not part of this PR.

🤔 Why?

BelongsToMorphed inherits the hard ordering dependency of BelongsTo, so persisting a closed cycle through a morphed relation in a single transaction deadlocks the pool:

Pool has gone into an infinite loop.

This affects both self-references (A > A) and two-entity cycles (A > B > A). For non-morphed relations this is solved by RefersTo, but until now there was no morphed counterpart. RefersToMorphed allows such cyclic / self-linked morphed references to be saved within one transaction (INSERT + deferred UPDATE), exactly like HasOne/RefersTo cycles.

📝 Checklist

  • How was this tested:
    • Tested manually
    • Unit tests added

Functional tests added for A > A and A > B > A (read, single-transaction create, detach to null) across all four drivers (SQLite, MySQL, Postgres, SQL Server). Additionally added coverage — previously missing — for the interaction of both morphed belongs-to / refers-to relations with Options::$ignoreUninitializedRelations (unset relation property is ignored when the option is true, and clears both the outer key and the morph key when false).

Full SQLite suite, unit tests, Psalm and PHP-CS-Fixer are green.

📃 Documentation

No documentation change shipped in this repo. A follow-up in cycle/annotated (the #[RefersToMorphed] attribute) and the docs site would complete the feature for end users.

BelongsToMorphed inherits the hard "parent before child" dependency of
BelongsTo and therefore deadlocks the pool when persisting a closed cycle
(A > A or A > B > A) in a single transaction. There was no morphed
counterpart of RefersTo to break such cycles.

Add Relation::REFERS_TO_MORPHED and the RefersToMorphed relation, which
mirrors BelongsToMorphed on top of RefersTo: it stores the outer key and the
target role (morph key) on the owner but resolves the outer key in a deferred,
"soft" way, allowing self-linked and cyclic morphed references to be persisted
within one transaction. The morph key is kept in sync with the related role and
cleared when the relation is set to null; the BelongsToMorphedLoader is reused.

Tests cover A > A and A > B > A (read, single-transaction create, detach) on all
four drivers, plus the previously uncovered interaction of both morphed
belongs-to/refers-to relations with Options::$ignoreUninitializedRelations.
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.74359% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.61%. Comparing base (8109639) to head (12afd12).

Files with missing lines Patch % Lines
src/Relation/Morphed/RefersToMorphed.php 88.57% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #570      +/-   ##
============================================
- Coverage     91.63%   91.61%   -0.02%     
- Complexity     2014     2027      +13     
============================================
  Files           131      132       +1     
  Lines          5235     5274      +39     
============================================
+ Hits           4797     4832      +35     
- Misses          438      442       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

roxblnfk and others added 2 commits June 15, 2026 19:25
Verify the promise reference resolves with the expected number of read queries:
a self-reference is taken from the heap (0 queries), a parent that is not yet
loaded costs exactly one query, and the back-reference across an A > B > A cycle
is served from the heap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover one-level eager loading via Select::load() and batch loading via
BulkLoader for the morphed refers-to relation (reusing BelongsToMorphedLoader).
Both resolve the parent up front, so accessing it afterwards issues no queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@roxblnfk roxblnfk merged commit f856301 into 2.x Jun 15, 2026
26 of 29 checks passed
@roxblnfk roxblnfk deleted the feature/refers-to-morphed branch June 15, 2026 16:27
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