Skip to content

feat(datasource-active-record): expose has_one :through as a to-one relation#325

Open
bexchauveto wants to merge 1 commit into
mainfrom
feat/ar-datasource-has-one-through-to-one
Open

feat(datasource-active-record): expose has_one :through as a to-one relation#325
bexchauveto wants to merge 1 commit into
mainfrom
feat/ar-datasource-has-one-through-to-one

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Jul 2, 2026

Copy link
Copy Markdown
Member

Expose has_one :through as a to-one (HasOne) relation

Base branch: perf/ar-datasource-join-to-one-same-db — this builds on that
branch's JOIN-to-one infrastructure. Merge/rebase order: perf branch first.

Problem

The ActiveRecord datasource mapped a has_one :through association to a
ManyToMany schema, so Forest rendered it as a to-many list
(BelongsToMany). A has_one :through is single-valued, so it should be a
to-one relation — the way forest_liana (v1) exposed it. As a result the
relation could not be shown as a clickable record in a summary/detail view, and
it was resolved with extra per-hop preload queries.

Change

  1. Schema — a has_one :through now emits OneToOneSchemaHasOne
    (to-one). ActiveRecord resolves the join natively via the association name,
    so the read path was already correct; only the emitted schema shape changes.
    Polymorphic has_one :through is left as ManyToMany (unchanged).

  2. Performance — when every hop of the through chain is a belongs_to
    (no scope/default_scope, same database), the relation is folded into the
    existing to-one LEFT OUTER JOIN instead of two per-hop preload queries.
    A has_one hop (which can multiply rows) stays on preload.

  3. Correctness — when a through relation falls back to preload (e.g. it is
    also used by a filter), the intermediate foreign key is now selected so
    ActiveRecord can load it (previously raised MissingAttributeError).

Change

A has_one :through relation is now a to-one (HasOne) instead of a
to-many (BelongsToMany). Agents that reference such a relation as a
collection — in segments, smart views, or permissions — must be updated to
treat it as a to-one. Non-through relations are unaffected.

Tests

  • has_one :through emits OneToOneSchema (collection spec)
  • belongs_to-chain through → single JOIN; has_one-hop through → preload
  • filter through the relation returns the right records (and the negative case)
  • aggregate grouped by a through-relation field
  • Full package suite green; forest_admin_agent, forest_admin_datasource_customizer,
    forest_admin_datasource_toolkit suites verified green (schema change has no
    downstream fallout). Rubocop clean.

Out of scope

Polymorphic-relation support for renamed/demodulized types is handled separately
in feat/support-polymorphic-qonto; this PR intentionally contains none of it.

Note

Expose has_one :through as a to-one relation in the ActiveRecord datasource

  • Non-polymorphic has_one :through associations are now exposed as OneToOneSchema instead of ManyToManySchema in collection.rb; polymorphic cases remain ManyToMany.
  • has_one :through chains composed entirely of belongs_to hops are now collapsed into LEFT OUTER JOINs via a new belongs_to_chain_through? guard in query.rb; other to-one relations retain previous behavior.
  • The SELECT list now includes the intermediate foreign key for has_one :through to-one relations so that preload fallback (e.g. when the relation is also used in a filter) resolves correctly without missing columns.
  • Behavioral Change: any has_one :through association that was previously surfaced as a many-to-many relation will now appear as a one-to-one relation in the schema.
📊 Macroscope summarized ebc2dfe. 2 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…elation

Map a `has_one :through` association to a OneToOne (HasOne) schema instead of
ManyToMany, so it renders as a to-one relation (matching forest_liana v1)
rather than a to-many list. ActiveRecord resolves the join natively via the
association name, so read, filter and aggregate all work through the relation.

Resolve it efficiently too: when every hop of the through chain is a belongs_to
(no scope/default_scope, same database), fold it into the existing to-one LEFT
JOIN instead of issuing per-hop preload queries. When it does fall back to
preload (e.g. the same relation is also used by a filter), the intermediate
foreign key is now selected so ActiveRecord can still load it.

BREAKING CHANGE: a `has_one :through` relation is now exposed as a to-one
(HasOne) relation instead of a to-many (BelongsToMany). Agents that reference
such a relation as a collection (segments, smart views, permissions) must be
updated to treat it as a to-one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qltysh

qltysh Bot commented Jul 2, 2026

Copy link
Copy Markdown

5 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 7): joinable_target 2
qlty Structure Deeply nested control flow (level = 4) 1
qlty Duplication Found 15 lines of similar code in 2 locations (mass = 94) 1
qlty Structure Function with high complexity (count = 8): belongs_to_chain_through? 1

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

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]

origin_type_value: is_polymorphic ? @model.name : nil,
foreign_type_field: source_polymorphic ? association.source_reflection.foreign_type : nil,
foreign_type_value: source_polymorphic ? association.options[:source_type] : nil
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 15 lines of similar code in 2 locations (mass = 94) [qlty:similar-code]

@@ -305,6 +317,21 @@ def joinable_target(collection, relation_name, used_tables)
target

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 returns (count = 7): joinable_target [qlty:return-statements]


same_database?(reflection.active_record, through.klass)
rescue StandardError
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.

Found 2 issues:

1. Function with many returns (count = 5): belongs_to_chain_through? [qlty:return-statements]


2. Function with high complexity (count = 8): belongs_to_chain_through? [qlty:function-complexity]

)
)
)
else

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High forest_admin_datasource_active_record/collection.rb:119

When a non-polymorphic has_one :through association is processed, the OneToOneSchema is created with origin_key: association.klass.primary_key and origin_key_target: @model.primary_key, which describes a direct primary-key-to-primary-key link. Downstream write paths (update_related, associate_related, store) use those fields to update the child collection, so updating a has_one :through relation writes the target model's primary key (e.g. AccountHistory.id) instead of the intermediate join table's foreign key, corrupting identifiers or breaking the relation update. Consider using through_reflection.foreign_key / through_reflection.join_foreign_key (or the intermediate collection's keys) as the origin_key / origin_key_target, consistent with the ManyToManySchema branch above.

Also found in 1 other location(s)

packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb:308

Enabling OneToOne joins at joinable_target without also tracking the intermediate through table breaks the duplicate-join safeguard. For a projection like [&#39;order:reference&#39;, &#39;account_history:id&#39;] on Account, the new has_one :through branch treats order as joinable but joinable_tables only records the final orders table, even though ActiveRecord must also join account_histories for order. The later account_history relation is therefore considered joinable too, so the same table is joined twice and ActiveRecord aliases one occurrence. collect_joined_selects and resolve_field still refer to the plain account_histories table name, which can generate incorrect SQL/alias references or serialize data from the wrong join.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb around line 119:

When a non-polymorphic `has_one :through` association is processed, the `OneToOneSchema` is created with `origin_key: association.klass.primary_key` and `origin_key_target: @model.primary_key`, which describes a direct primary-key-to-primary-key link. Downstream write paths (`update_related`, `associate_related`, `store`) use those fields to update the child collection, so updating a `has_one :through` relation writes the target model's primary key (e.g. `AccountHistory.id`) instead of the intermediate join table's foreign key, corrupting identifiers or breaking the relation update. Consider using `through_reflection.foreign_key` / `through_reflection.join_foreign_key` (or the intermediate collection's keys) as the `origin_key` / `origin_key_target`, consistent with the `ManyToManySchema` branch above.

Also found in 1 other location(s):
- packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb:308 -- Enabling `OneToOne` joins at `joinable_target` without also tracking the intermediate `through` table breaks the duplicate-join safeguard. For a projection like `['order:reference', 'account_history:id']` on `Account`, the new `has_one :through` branch treats `order` as joinable but `joinable_tables` only records the final `orders` table, even though ActiveRecord must also join `account_histories` for `order`. The later `account_history` relation is therefore considered joinable too, so the same table is joined twice and ActiveRecord aliases one occurrence. `collect_joined_selects` and `resolve_field` still refer to the plain `account_histories` table name, which can generate incorrect SQL/alias references or serialize data from the wrong join.

Comment on lines +201 to +202
through_fk = through_foreign_key(collection, relation_name)
@select << "#{collection.model.table_name}.#{through_fk}" if through_fk

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High utils/query.rb:201

build_select calls through_foreign_key for every OneToOne relation and appends the result to @select prefixed with the root table name. For a has_one :through where the intermediate hop is itself a has_one (e.g. Supplier.has_one :account_history, through: :account), through_foreign_key returns the child table's foreign key (accounts.supplier_id), not a column on the root table. This emits SQL like SELECT suppliers.supplier_id, and the database raises an unknown-column error whenever such a relation is projected. The fix should only select the intermediate FK when it actually lives on the root table — i.e. when the through reflection is a belongs_to.

-              through_fk = through_foreign_key(collection, relation_name)
-              @select << "#{collection.model.table_name}.#{through_fk}" if through_fk
+              through_fk = root_through_foreign_key(collection, relation_name)
+              @select << "#{collection.model.table_name}.#{through_fk}" if through_fk
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb around lines 201-202:

`build_select` calls `through_foreign_key` for every `OneToOne` relation and appends the result to `@select` prefixed with the root table name. For a `has_one :through` where the intermediate hop is itself a `has_one` (e.g. `Supplier.has_one :account_history, through: :account`), `through_foreign_key` returns the child table's foreign key (`accounts.supplier_id`), not a column on the root table. This emits SQL like `SELECT suppliers.supplier_id`, and the database raises an unknown-column error whenever such a relation is projected. The fix should only select the intermediate FK when it actually lives on the root table — i.e. when the `through` reflection is a `belongs_to`.

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