fix(datasource-active-record): mark non-polymorphic has_one :through identity joins read-only - #381
Open
matthv wants to merge 1 commit into
Open
Conversation
…identity joins read-only A non-polymorphic has_one :through (e.g. Supplier -> Account -> AccountHistory) falls into a OneToOneSchema branch that hardcodes origin_key: association.klass. primary_key, origin_key_target: @model.primary_key -- an identity join, since OneToOneSchema has no through_collection to route through the real FK. This isn't just misleading display data: update_related.rb's PUT /relationships route and store.rb's create-with-relationships path both write through relation.origin_key, which for this shape is the FOREIGN COLLECTION'S OWN PRIMARY KEY. Trying to (re)associate the relation via the standard admin UI would attempt to overwrite (or null out) that primary key. Read/filter/sort was already unaffected and stays that way: Utils::Query resolves has_one :through joins via direct ActiveRecord reflection, not via origin_key/origin_key_target, confirmed by the existing join_to_one_optimization suite exercising this exact shape successfully. Dropping the field outright was rejected -- it would break that already-working, already-tested functionality and trip FieldValidator's "Relation not found" on any caller referencing it. Fix, across the three touched packages: - RelationSchema (toolkit) gains is_read_only: (default false, attr_reader -- nothing needs to flip it after construction). Every relation type inherits it uniformly, so no consumer has to duck-type-probe for its presence. - collection.rb sets is_read_only: true on the identity-join branch and logs a diagnostic (warn_readonly_identity_join, no removal promised -- the relation stays published on purpose). A composite primary key on either endpoint can't even be a single origin_key/origin_key_target column, so that shape is skipped entirely instead (unrepresentable_one_to_one? / warn_unrepresentable_one_to_one). - Utils::Collection.assert_writable_relation! (toolkit) is the actual enforcement: both update_related.rb (PUT) and store.rb (POST-with- relationships) call it before writing through a to-one relation's origin_key, raising ForestException when is_read_only. The published isReadOnly flag (generator_field.rb) only hides the UI control -- a direct API call bypasses it entirely without this server-side check. 197/197 -> 203/203 (active_record), 481/481 (toolkit), 1175/1175 (agent). Rubocop clean on all three. Fixes #379 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2 new issues
|
| is_read_only: true | ||
| ) | ||
| ) | ||
| warn_readonly_identity_join(association, through_reflection) |
|
|
||
| def initialize(origin_key:, origin_key_target:, foreign_collection:) | ||
| super(foreign_collection, 'OneToOne') | ||
| def initialize(origin_key:, origin_key_target:, foreign_collection:, is_read_only: false) |
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (6) 🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
christophebrun-forest
left a comment
Member
There was a problem hiding this comment.
2 blockers reported by Claude and an error message improvement.
christophebrun-forest
left a comment
Member
There was a problem hiding this comment.
need fixes , comments inline
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.

What
A non-polymorphic
has_one :through(e.g.Supplier -> Account -> AccountHistory) falls into aOneToOneSchemabranch inCollection#fetch_associationsthat hardcodes:-- an identity join, since
OneToOneSchemahas nothrough_collection(unlikeManyToManySchema) to route through the real FK.This isn't just misleading display data.
update_related.rb'sPUT /:collection/:id/relationships/:relation_nameroute andstore.rb's create-with-relationships path both write throughrelation.origin_key-- for this shape, that's the foreign collection's own primary key. Trying to (re)associate the relation via the standard admin UI (or a direct API call) would attempt to overwrite, or null out, that primary key.What's not affected
List/filter/sort was already fine and stays that way:
Utils::Queryresolveshas_one :throughjoins via direct ActiveRecord reflection (reflect_on_association), not viaorigin_key/origin_key_target-- confirmed by the existingjoin_to_one_optimization_spec.rbsuite, which already exercises this exact shape successfully (JOIN-folding across multiplehas_one/belongs_tohops, with correct data). Dropping the field from the schema was considered and rejected: it would break that already-working, already-tested query functionality, and any caller referencing the relation would tripFieldValidator's "Relation not found" error.Fix
Three packages:
forest_admin_datasource_toolkit:RelationSchemagainsis_read_only:(defaultfalse,attr_reader-- nothing needs to flip it after construction). Every relation type inherits it uniformly, so consumers don't need to duck-type-probe for its presence.forest_admin_datasource_active_record:collection.rbsetsis_read_only: trueon the identity-join branch and logs a diagnostic (warn_readonly_identity_join) -- no removal promised, the relation stays published on purpose (same reasoning as Spurious "Field '<col>' not found in schema of collection '<Model>'" WARN during schema generation for has_many :through walking a has_one with custom foreign_key #370/fix(datasource-active-record): skip unrepresentable has_many/has_one :through relations and deprecate silent identity joins (#370) #378's bucket-B design: don't narrow the schema for something that was silently working, even if wrong). A composite primary key on either endpoint can't even be a singleorigin_key/origin_key_targetcolumn, so that shape is skipped entirely instead of published read-only (unrepresentable_one_to_one?/warn_unrepresentable_one_to_one).forest_admin_agent:Utils::Collection.assert_writable_relation!(toolkit-side helper, shared by both routes) is the actual enforcement -- bothupdate_related.rb(PUT) andstore.rb(POST-with-relationships) call it before writing through a to-one relation'sorigin_key, raisingForestExceptionwhenis_read_only. The publishedisReadOnlyflag (generator_field.rb) only hides the UI control; a direct API call bypasses it entirely without this server-side check.Investigation notes (context for reviewers)
This went through a few rounds before landing here:
ManyToManySchema(which hasthrough_collection) for this shape too. Rejected: it changes the JSON:API relationship type (BelongsToManyvsHasOne) and the PUT route's contract, a much bigger, more visible change than the bug warrants.OneToOneSchema(preserves the read path, no API contract change), mark read-only, enforce server-side./pr-review-toolkit:review-prpass (5 agents: code-reviewer, pr-test-analyzer, comment-analyzer, silent-failure-hunter, type-design-analyzer) independently caught, across 3 separate agents, that the initial fix only guarded the PUT route --store.rb's create-with-relationships path had the identical unguarded write. Fixed and covered by a new regression test. The same pass recommended promotingis_read_onlyto the sharedRelationSchemabase (done) instead of duck-typing it per-consumer.How tested
forest_admin_datasource_toolkit: 481 examples, 0 failures (incl. newis_read_onlydefault/override tests onOneToOneSchema).forest_admin_datasource_active_record: 203 examples, 0 failures (incl. the rewrittenSupplier#account_historytest, a newAccount#ordersymmetry test, a regression test that a plain non-throughhas_onestays writable, and two composite-primary-key skip tests -- one per endpoint).forest_admin_agent: 1175 examples, 0 failures (incl. new tests on bothupdate_related.rbandstore.rbasserting a read-only relation is rejected withForestExceptionand never reaches the underlyingupdate/createcall, plus agenerator_field.rbtest isolating the relation-level flag from the column-level one).Fixes #379
🤖 Generated with Claude Code
Note
Mark non-polymorphic
has_one :throughidentity joins as read-onlyis_read_onlytoRelationSchemaandOneToOneSchemaand introducesCollection.assert_writable_relation!which raisesForestExceptionfor read-only relationshas_one :throughas a read-onlyOneToOneSchema; when either endpoint has a composite primary key the relation is skipped entirely with a warningassert_writable_relation!before updating or linking one-to-one relations, andGeneratorFieldmarks the field read-only when the relation is read-onlyhas_one :throughrelations will now receive aForestException; checkfetch_associationsin collection.rb andupdate_one_to_one/linked_one_to_one_relationin update_related.rb and store.rbMacroscope summarized 067b8d6.