Skip to content

fix(datasource-active-record): mark non-polymorphic has_one :through identity joins read-only - #381

Open
matthv wants to merge 1 commit into
mainfrom
fix/379-non-polymorphic-has-one-through-identity-join
Open

fix(datasource-active-record): mark non-polymorphic has_one :through identity joins read-only#381
matthv wants to merge 1 commit into
mainfrom
fix/379-non-polymorphic-has-one-through-identity-join

Conversation

@matthv

@matthv matthv commented Aug 27, 2026

Copy link
Copy Markdown
Member

What

A non-polymorphic has_one :through (e.g. Supplier -> Account -> AccountHistory) falls into a OneToOneSchema branch in Collection#fetch_associations that hardcodes:

origin_key: association.klass.primary_key,
origin_key_target: @model.primary_key

-- an identity join, since OneToOneSchema has no through_collection (unlike ManyToManySchema) to route through the real FK.

This isn't just misleading display data. update_related.rb's PUT /:collection/:id/relationships/:relation_name route and store.rb's create-with-relationships path both write through relation.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::Query resolves has_one :through joins via direct ActiveRecord reflection (reflect_on_association), not via origin_key/origin_key_target -- confirmed by the existing join_to_one_optimization_spec.rb suite, which already exercises this exact shape successfully (JOIN-folding across multiple has_one/belongs_to hops, 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 trip FieldValidator's "Relation not found" error.

Fix

Three packages:

  • forest_admin_datasource_toolkit: RelationSchema gains is_read_only: (default false, 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.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 (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 single origin_key/origin_key_target column, 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 -- 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.

Investigation notes (context for reviewers)

This went through a few rounds before landing here:

  1. First instinct was to drop the field entirely (mirroring 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 A" for genuinely unrepresentable relations). Investigation showed this regresses real, tested query functionality (see "What's not affected" above) -- rejected.
  2. Second instinct was to reuse ManyToManySchema (which has through_collection) for this shape too. Rejected: it changes the JSON:API relationship type (BelongsToMany vs HasOne) and the PUT route's contract, a much bigger, more visible change than the bug warrants.
  3. Landed on: keep publishing as OneToOneSchema (preserves the read path, no API contract change), mark read-only, enforce server-side.
  4. A /pr-review-toolkit:review-pr pass (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 promoting is_read_only to the shared RelationSchema base (done) instead of duck-typing it per-consumer.

How tested

  • forest_admin_datasource_toolkit: 481 examples, 0 failures (incl. new is_read_only default/override tests on OneToOneSchema).
  • forest_admin_datasource_active_record: 203 examples, 0 failures (incl. the rewritten Supplier#account_history test, a new Account#order symmetry test, a regression test that a plain non-through has_one stays writable, and two composite-primary-key skip tests -- one per endpoint).
  • forest_admin_agent: 1175 examples, 0 failures (incl. new tests on both update_related.rb and store.rb asserting a read-only relation is rejected with ForestException and never reaches the underlying update/create call, plus a generator_field.rb test isolating the relation-level flag from the column-level one).
  • Every discrimination-critical test verified non-vacuous by hand-reverting the corresponding production code and confirming the right failure.
  • Rubocop clean on all three packages.

Fixes #379

🤖 Generated with Claude Code

Note

Mark non-polymorphic has_one :through identity joins as read-only

  • Adds is_read_only to RelationSchema and OneToOneSchema and introduces Collection.assert_writable_relation! which raises ForestException for read-only relations
  • ActiveRecord datasource now publishes non-polymorphic has_one :through as a read-only OneToOneSchema; when either endpoint has a composite primary key the relation is skipped entirely with a warning
  • Agent routes call assert_writable_relation! before updating or linking one-to-one relations, and GeneratorField marks the field read-only when the relation is read-only
  • Risk: existing consumers that write through non-polymorphic has_one :through relations will now receive a ForestException; check fetch_associations in collection.rb and update_one_to_one / linked_one_to_one_relation in update_related.rb and store.rb

Macroscope summarized 067b8d6.

…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>
@qltysh

qltysh Bot commented Aug 27, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Deeply nested control flow (level = 4) 1
qlty Structure Function with many parameters (count = 4): initialize 1

is_read_only: true
)
)
warn_readonly_identity_join(association, through_reflection)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deeply nested control flow (level = 4) [qlty:nested-control-flow]


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Function with many parameters (count = 4): initialize [qlty:function-parameters]

@qltysh

qltysh Bot commented Aug 27, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: B Coverage rating: A
...record/lib/forest_admin_datasource_active_record/collection.rb100.0%
Coverage rating: A Coverage rating: A
.../forest_admin_agent/routes/resources/related/update_related.rb100.0%
Coverage rating: A Coverage rating: A
.../lib/forest_admin_datasource_toolkit/schema/relation_schema.rb100.0%
Coverage rating: A Coverage rating: A
...t_admin_agent/lib/forest_admin_agent/routes/resources/store.rb100.0%
Coverage rating: A Coverage rating: A
...admin_datasource_toolkit/schema/relations/one_to_one_schema.rb100.0%
Coverage rating: A Coverage rating: A
...oolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb33.3%86-88
Total91.3%
🤖 Increase coverage with AI coding...
In the `fix/379-non-polymorphic-has-one-through-identity-join` branch, add test coverage for this new code:

- `packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb` -- Line 86-88

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@christophebrun-forest christophebrun-forest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

2 blockers reported by Claude and an error message improvement.

@christophebrun-forest christophebrun-forest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

need fixes , comments inline

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.

Non-polymorphic has_one :through always publishes a bogus identity join, regardless of source type

2 participants