Repository navigation
fix: many-to-many add() and remove() identify instances by primary key (#830) - #839
Merged
Merged
Conversation
…ary key (#830) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closed
6 tasks
0x054
commented
Oct 30, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Spec: PASS
- Minor · [none] · tests/test_m2m_target_pk.py:108-138 at 3376358 · Missing · the
remove()refusal cases run against an emptytag_posts, so_links(...) == 0would still pass ifremove()sent its DELETE before refusing · ticket#Test scenarios asks for "the same two refusals fromremove()" 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 writepost.tags.add()); accepted. - Checked:
src/ferro/relations/descriptors.pyis outside Files owned, declared in the PR body;_m2mhas one caller (git grep), and_clone'scopy.copycarries_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 theidrestriction.
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: nomatches 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
commented
Oct 30, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
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") == 1after each refusedadd()/remove() - No new breakage in the fix diff (test-only, 5 lines). The UUID model now declares
primary_key=Trueonce (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
commented
Oct 30, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
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, agetattr(..., "__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 withTypeError: 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>
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #830
What
add()andremove()on a many-to-many relation identify each related instance by the related model's primary key (__ferro_pk__) instead of a literalid, so targets keyed byslug,codeor 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 relationships2 passed; docs lint clean; ty gate clean).TDD: RED
pytest tests/test_m2m_target_pk.py --db-backends=sqlitefailed 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
Notes for the reviewer
Outside Files owned:
src/ferro/relations/descriptors.pypasses alabel("post.tags") intoQuery._m2mso the refusals can name the relation; the query did not know its owner._m2mgains an optionallabelargument (private method). Tests usebackend_matrixso Postgres runs.🤖 Generated with Claude Code