feat(datasource-active-record): expose has_one :through as a to-one relation#325
feat(datasource-active-record): expose has_one :through as a to-one relation#325bexchauveto wants to merge 1 commit into
Conversation
…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>
5 new issues
|
| origin_key: association.klass.primary_key, | ||
| origin_key_target: @model.primary_key | ||
| ) | ||
| ) |
| 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 | ||
| ) |
| @@ -305,6 +317,21 @@ def joinable_target(collection, relation_name, used_tables) | |||
| target | |||
|
|
||
| same_database?(reflection.active_record, through.klass) | ||
| rescue StandardError | ||
| false |
| ) | ||
| ) | ||
| ) | ||
| else |
There was a problem hiding this comment.
🟠 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
OneToOnejoins atjoinable_targetwithout also tracking the intermediatethroughtable breaks the duplicate-join safeguard. For a projection like['order:reference', 'account_history:id']onAccount, the newhas_one :throughbranch treatsorderas joinable butjoinable_tablesonly records the finalorderstable, even though ActiveRecord must also joinaccount_historiesfororder. The lateraccount_historyrelation is therefore considered joinable too, so the same table is joined twice and ActiveRecord aliases one occurrence.collect_joined_selectsandresolve_fieldstill refer to the plainaccount_historiestable 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.
| through_fk = through_foreign_key(collection, relation_name) | ||
| @select << "#{collection.model.table_name}.#{through_fk}" if through_fk |
There was a problem hiding this comment.
🟠 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`.
Expose
has_one :throughas a to-one (HasOne) relationProblem
The ActiveRecord datasource mapped a
has_one :throughassociation to aManyToManyschema, so Forest rendered it as a to-many list(
BelongsToMany). Ahas_one :throughis single-valued, so it should be ato-one relation — the way
forest_liana(v1) exposed it. As a result therelation could not be shown as a clickable record in a summary/detail view, and
it was resolved with extra per-hop preload queries.
Change
Schema — a
has_one :throughnow emitsOneToOneSchema→HasOne(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 :throughis left asManyToMany(unchanged).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 JOINinstead of two per-hop preload queries.A
has_onehop (which can multiply rows) stays on preload.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 :throughrelation is now a to-one (HasOne) instead of ato-many (
BelongsToMany). Agents that reference such a relation as acollection — in segments, smart views, or permissions — must be updated to
treat it as a to-one. Non-
throughrelations are unaffected.Tests
has_one :throughemitsOneToOneSchema(collection spec)has_one-hop through → preloadforest_admin_agent,forest_admin_datasource_customizer,forest_admin_datasource_toolkitsuites verified green (schema change has nodownstream 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 :throughas a to-one relation in the ActiveRecord datasourcehas_one :throughassociations are now exposed asOneToOneSchemainstead ofManyToManySchemaincollection.rb; polymorphic cases remainManyToMany.has_one :throughchains composed entirely ofbelongs_tohops are now collapsed into LEFT OUTER JOINs via a newbelongs_to_chain_through?guard inquery.rb; other to-one relations retain previous behavior.has_one :throughto-one relations so that preload fallback (e.g. when the relation is also used in a filter) resolves correctly without missing columns.has_one :throughassociation 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.