Skip to content

fix: many-to-many add() and remove() identify instances by primary key (#830) - #839

Merged
0x054 merged 4 commits into
mainfrom
sdd/830-m2m-add-target-pk
Oct 30, 2026
Merged

0x054 merged 4 commits into
mainfrom
sdd/830-m2m-add-target-pk

Conversation

@0x054

@0x054 0x054 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #830

What

add() and remove() on a many-to-many relation identify each related instance by the related model's primary key (__ferro_pk__) instead of a literal id, so targets keyed by slug, code or a UUID work. An unsaved instance (ValueError) and an instance of another model (TypeError) are refused before any statement.

Verify

Ran the ticket's Verify block on 0083a6d: passed (SQLite + Postgres 18: 43 passed; docs examples -k relationships 2 passed; docs lint clean; ty gate clean).
TDD: RED pytest tests/test_m2m_target_pk.py --db-backends=sqlite failed 6/7 before the fix (AttributeError / no refusal); GREEN same file passed 7/7 on both dialects.
Verify block corrected: no

Interfaces

Interfaces changed: none. The refusal texts use the lowercase owner model name (post.tags.add()), as the issue's examples show; the Interfaces line's {Owner} is read as that.

Laws

  • AGENTS.md#I-14: docstrings updated in the Google style, no internal references
  • AGENTS.md#I-7, I-8: n/a: no new field-declaring doc example; one sentence reworded
  • AGENTS.md#I-1, I-2, I-3, I-13: n/a: no DDL, hydration, FFI or wire-scalar change
  • AGENTS.md#I-10: CHANGELOG.md untouched

Notes for the reviewer

Outside Files owned: src/ferro/relations/descriptors.py passes a label ("post.tags") into Query._m2m so the refusals can name the relation; the query did not know its owner. _m2m gains an optional label argument (private method). Tests use backend_matrix so Postgres runs.

🤖 Generated with Claude Code

…ary key (#830)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Spec: PASS

  • Minor · [none] · tests/test_m2m_target_pk.py:108-138 at 3376358 · Missing · the remove() refusal cases run against an empty tag_posts, so _links(...) == 0 would still pass if remove() sent its DELETE before refusing · ticket#Test scenarios asks for "the same two refusals from remove()" with no row change; link one valid tag first and assert the count stays 1
  • Checked: both refusal texts match the Interfaces line byte for byte ({Owner} rendered as the lowercase model name, as the ticket's own examples write post.tags.add()); accepted.
  • Checked: src/ferro/relations/descriptors.py is outside Files owned, declared in the PR body; _m2m has one caller (git grep), and _clone's copy.copy carries _m2m_label; accepted.
  • Checked: clear(), all(), count(), exists() and the id-keyed path are unchanged in the diff; docstrings and the guide line no longer state the id restriction.
    Cannot verify: none

Quality: PASS

  • Checked: the new test file carries @pytest.mark.backend_matrix; 7 of its tests reported PASSED on postgres with -rA.
  • Checked: Laws lines (I-14 docstrings, I-10 CHANGELOG untouched, I-1/I-2/I-3/I-13 n/a) hold against the diff; Verify block corrected: no matches the run.

Verify: PR head 3376358 passed (ticket Verify block: 43 passed on sqlite,postgres; docs examples -k relationships 2 passed; docs lint clean)
Trial merge: not run (base unchanged)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re-review: 3376358..f02952a

  • Minor (remove() refusal tests ran against an empty join table): ADDRESSED · tests/test_m2m_target_pk.py:116,134 at f02952a link one saved tag first and assert _links("tag_posts") == 1 after each refused add() / remove()
  • No new breakage in the fix diff (test-only, 5 lines). The UUID model now declares primary_key=True once (tests/test_m2m_target_pk.py:89).

Spec: PASS

Cannot verify: none

Quality: PASS

Verify: PR head f02952a passed (ticket pytest line: 43 passed on sqlite,postgres; all 7 test_m2m_target_pk postgres cases PASSED with -rA)
Trial merge: not run (base unchanged)

#830)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re-review: f02952a..01a8d88

  • Prior findings: none open (the Minor from 3376358 was ADDRESSED at f02952a).
  • Fix diff: src/ferro/query/builder.py:1516-1522, a getattr(..., "__ferro_pk__", None) guard for ty, the same pattern as _assert_position_order_keys (builder.py:1136). The two refusal texts are unchanged.

Spec: PASS

Cannot verify: none

Quality: PASS

  • Minor · [AGENTS.md#I-6] · src/ferro/query/builder.py:1519-1521 at 01a8d88 · the new refusal Tag declares no primary-key column, so its instances cannot be linked. gives the cause but not the fix. The schema-time refusal for the same model already names it (Declare a primary_key=True field on Tag.), so end the message with that clause.
  • Checked (no test needed): the probe defines a many-to-many target with no primary key, then connect(auto_migrate=True) on SQLite. It is refused first with TypeError: tag_posts.tag_id references Tag, which declares no primary key. Declare a primary_key=True field on Tag.. The new branch is a type-narrowing guard that a connected relation does not reach, so no test is needed.

Verify: PR head 01a8d88 passed (ticket pytest line: 43 passed on sqlite,postgres; all 7 test_m2m_target_pk postgres cases PASSED with -rA)
Trial merge: not run (base unchanged)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@0x054
0x054 merged commit 2c58a4d into main Oct 30, 2026
11 checks passed
@0x054
0x054 deleted the sdd/830-m2m-add-target-pk branch October 30, 2026 21:01
@cursor cursor Bot mentioned this pull request Nov 2, 2026
8 tasks
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.

Many-to-many add() reads the target's .id, so it fails on a target keyed by another column

1 participant