Skip to content

fix(datasource-active-record): normalize demodulized polymorphic types & reuse shared joins#328

Merged
bexchauveto merged 5 commits into
mainfrom
local/polymorphic-name-fix
Jul 3, 2026
Merged

fix(datasource-active-record): normalize demodulized polymorphic types & reuse shared joins#328
bexchauveto merged 5 commits into
mainfrom
local/polymorphic-name-fix

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

Two related fixes in the ActiveRecord datasource:

  • Normalize demodulized polymorphic types on read — when a record's stored polymorphic *_type is demodulized (e.g. Account instead of Module::Account), resolve it through polymorphic_class_for so the serialized value matches the fully-qualified class name Forest expects.
  • Reuse a shared join instead of preloading a has_one :through — track joins by table signature (foreign key → target) rather than by table name only. Joins that share a table with a compatible signature are reused; only genuinely conflicting joins fall back to preload. Filter/sort joins are still never reused.

Test plan

  • join_to_one_optimization_spec.rb updated and passing

Note

Normalize demodulized polymorphic types and reuse shared JOINs in ActiveRecord datasource

  • Adds normalize_polymorphic_types to ActiveRecordSerializer to resolve demodulized polymorphic type values (e.g. from models with store_full_class_name = false) to fully qualified class names via polymorphic_class_for, for both direct and JOIN-hydrated relations.
  • Reworks JOIN planning in Query#split_relations to track joins by signature (source_table.foreign_key->target_table.primary_key) instead of table name, allowing identical JOINs to be reused rather than duplicated or fallen back to preload unnecessarily.
  • Introduces joinable_joins, conflicting?, join_signatures, and signature helpers to encapsulate the new conflict-detection logic; joinable_target no longer rejects joins based solely on table reuse.
  • Adds dummy models (Api::Note, Api::Topic) and migrations to support polymorphic type normalization tests.
  • Behavioral Change: serialized hashes for polymorphic belongs_to type columns now return the resolved class name rather than the raw DB value; JOIN reuse may change query structure for associations sharing an intermediate table.

Macroscope summarized c9caa98.

bexchauveto and others added 2 commits July 3, 2026 11:21
…s on read [local]

Local-only workaround so Forest can resolve the target collection when the app
stores a demodulized polymorphic *_type (e.g. "Income" for Api::Income). Not for
the has_one:through PR; the real fix lives in feat/support-polymorphic-qonto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding a has_one :through

Track joins by signature (their ON condition) rather than table name, so a has_one :through whose
intermediate table is also joined by a belongs_to of the same association reuses that single join
(ActiveRecord dedupes it) instead of falling back to per-hop preload queries. A belongs_to reaching
the same table via a different foreign key still bails to preload, since ActiveRecord would alias it
and collect_joined_selects cannot reference the alias.

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

qltysh Bot commented Jul 3, 2026

Copy link
Copy Markdown

4 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 6): normalize_polymorphic_types 2
qlty Structure Function with many parameters (count = 4): joinable_joins 1
qlty Structure Function with many returns (count = 6): joinable_target 1

rescue StandardError
next
end
hash

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 high complexity (count = 9): normalize_polymorphic_types [qlty:function-complexity]

# Set of tables the subtree adds via JOIN, or nil if any relation in it can't be safely joined.
def joinable_tables(collection, relation_name, sub_projection, used_tables)
target = joinable_target(collection, relation_name, used_tables)
def joinable_joins(collection, relation_name, sub_projection, used_joins)

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): joinable_joins [qlty:function-parameters]

joins = joins.merge(nested)
end
tables
joins

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 high complexity (count = 5): joinable_joins [qlty:function-complexity]

return if used_tables.include?(target.model.table_name) # a table joined twice would be aliased by AR
return if through_tables(collection, relation_name).intersect?(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 = 6): joinable_target [qlty:return-statements]

Two associations joining the same target table with the same FK name
from different parents produced identical signatures, so conflicting?
treated them as one join and allowed reuse. AR aliases the second, but
collect_joined_selects reads the unaliased table.column and got the
wrong join's data. Include the source table in the signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bexchauveto bexchauveto force-pushed the local/polymorphic-name-fix branch from b913911 to e100c13 Compare July 3, 2026 12:28
…relations too

hash_joined_relation read projected *_type columns straight from the
aliased root row, so a JOINed polymorphic belongs_to returned the raw
stored value while the preloaded path returned the fully-qualified
class name. Normalize the joined hash against the relation's target
model (resolved by walking the reflection chain) so both paths agree.

Also wrap the join signature string to satisfy Layout/LineLength.

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

matthv commented Jul 3, 2026

Copy link
Copy Markdown
Member

Reviewed both fixes (join-signature reuse + polymorphic type normalization). The overall design is sound — source.fk → target captures the common belongs_to ON clause, the :root/:filter sentinels correctly keep self-joins and filter/sort joins out of reuse, and the conflict logic is nicely unit-tested. A few things to address before merge, one of them a confirmed correctness bug.

🔴 Blocking — join signature omits the target join key → wrong record served, silently

utils/query.rb (signature, ~L334) builds "<source_table>.<fk> -> <target_table>", but a belongs_to ON clause is <target>.<join_key> = <source>.<fk>, where <join_key> is the :primary_key option (defaults to the target PK). Two unscoped belongs_to on the same source, same FK, same target table but a different :primary_key produce identical signatures with different ON clauses. conflicting? then treats the second as reusable; AR aliases the second join (distinct reflection), but collect_joined_selects reads the target by its raw table name — so the second relation is served the first join's row. No error, no log.

Reproduced end-to-end on the dummy app (two belongs_to on account_history_id, one primary_key: 'id', one primary_key: 'order_id'): both signatures equal, AR aliases the 2nd join, and the serialized result returns the wrong history id (9 instead of 10).

Suggested fix — fold the target join key into the signature so a differing ON yields a differing signature (→ conflict → safe preload, matching the old behavior):

def signature(reflection)
  join_key = Array(reflection.respond_to?(:join_primary_key) ? reflection.join_primary_key : reflection.association_primary_key)
  "#{reflection.active_record.table_name}.#{Array(reflection.foreign_key).join(',')}" \
    "->#{reflection.klass.table_name}.#{join_key.join(',')}"
end

(The same-FK/same-target case without a primary_key: difference is benign — identical ON, AR collapses to one join. The signature is the only thing standing between "collapse" and "silent alias", so it must encode everything the ON depends on.)

🔴 Blocking — polymorphic normalization is untested and not reproducible with current fixtures

normalize_polymorphic_types (the whole point of fix #1) has no behavioral coverage — the new active_record_serializer_spec.rb only exercises the target_model helper, never asserts a normalized *_type value. More importantly, both polymorphic dummy models (Member, Address) resolve to top-level classes, so polymorphic_class_for(x).name is an identity transform — deleting normalize_polymorphic_types today keeps the suite green.

Please add a namespaced polymorphic fixture (e.g. Api::Foo stored as "Foo" with store_full_class_name = false) and assert the normalized value on both the preloaded (hash_object) and JOINed (hash_joined_relation) paths — the JOINed path is a separate commit and shares no code beyond this method.

🟠 Silent-failure direction — new rescues fail toward "join anyway / keep raw value" instead of the conservative path, and none log

  • active_record_serializer.rb (normalize_polymorphic_types, ~L87): rescue StandardError; next keeps the raw, un-normalized type — which is exactly the namespaced case this fix targets (a NameError from constantize gets swallowed → fix silently no-ops). Narrow the rescue to NameError and log (see the [ForestAdmin] Unable to process association … convention in datasource_active_record/…/collection.rb).
  • query.rb (join_signatures, ~L330): rescue => {} returns an empty hash, which makes the relation joinable while contributing nothing to conflict detection → can re-open the wrong-data path above. Prefer returning nil → fall back to preload.
  • active_record_serializer.rb (target_model, ~L93): rescue => nil silently skips normalization on the JOINed path.

🟠 Test — conflict/preload-fallback case only asserts SQL shape

join_to_one_optimization_spec.rb (~L185) checks includes_values/JOIN count but never runs collection.list, and the fixture Account has no secondary_history, so there's no scenario where the two same-table relations point at different rows. That's exactly the scenario a wrong merge would corrupt. Suggest: two distinct AccountHistory rows, then assert result.first['secondary_history']['id'] is the secondary row (and order:reference still correct).

🟡 Minor

  • query.rb:278 — comment still says "Set of tables"; it now returns a Hash{table => signature}. Also worth restoring the deleted rationale (why signature reuse is safe: same table via a different FK→target path is a distinct join AR would alias).
  • normalize_polymorphic_types calls reflect_on_all_associations(:belongs_to) per record even for models with no polymorphic belongs_to — worth memoizing per model_class given the perf focus.
  • Commit 14ef31b9 carries a trailing [local] marker in its message.

Happy to pair on the signature fix / the namespaced fixture if useful.

…re; test polymorphic normalization

Address review (matthv):
- signature: a belongs_to ON clause is target.join_key = source.fk; two
  associations with the same source/FK/target but a different :primary_key
  had identical signatures and were wrongly reused (AR aliases the 2nd join,
  collect_joined_selects reads the unaliased name -> wrong record served).
  Encode the target join key so a differing ON yields a differing signature
  (-> conflict -> safe preload).
- join_signatures now returns nil on error -> preload, instead of {} which
  left the relation joinable but invisible to conflict detection.
- normalize_polymorphic_types: narrow rescue to NameError + log, and memoize
  the polymorphic belongs_to lookup per model_class.
- target_model: narrow rescue to NameError.
- Add a namespaced polymorphic fixture (Api::Note/Api::Topic, stored
  demodulized) and assert normalization on both the preloaded and JOINed
  paths (previously untested / not reproducible with existing fixtures).
- Preload-fallback spec now runs list() and asserts the secondary relation
  is served its own row, not the collapsed through row.

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

Copy link
Copy Markdown
Member Author

Thanks for the thorough review @matthv — all addressed in c9caa98.

🔴 Signature omits the target join key — confirmed and fixed. signature now folds in the target join key (join_primary_key, falling back to association_primary_key), so source.fk → target.join_key. Same source/FK/target but a differing :primary_key now yields a differing signature → conflict → safe preload, exactly as before the optimization. Added a unit assertion on the new format.

🔴 Polymorphic normalization untested / not reproducible — added a namespaced fixture: Api::Note (store_full_class_name = false, polymorphic notable) + Api::Topic, with the *_type column stored demodulized as "Topic". active_record_serializer_spec now asserts the normalized "Api::Topic" on both paths — the preloaded hash_object and the JOINed hash_joined_relation (reached end-to-end via Account belongs_to :note). Confirmed both fail (read raw "Topic") if normalize_polymorphic_types is removed.

🟠 Silent-failure direction

  • normalize_polymorphic_types: rescue narrowed to NameError + logs via the in-package [ForestAdmin] Unable to … convention, keeping the raw value.
  • join_signatures: now returns nil (→ preload) instead of {}, so an unknown ON clause can no longer re-open the wrong-data path.
  • target_model: rescue narrowed to NameError.

🟠 Conflict/preload-fallback test only asserted SQL shape — it now creates a distinct AccountHistory, sets it as secondary_history, runs collection.list, and asserts secondary_history.id is the secondary row while order:reference stays correct — i.e. the exact wrong-merge scenario.

🟡 Minor — fixed the stale "Set of tables" comment (now Hash{table => signature}) and restored the reuse rationale; memoized the polymorphic-belongs_to lookup per model_class.

One I left as-is: the [local] marker in 14ef31b9's message — not worth rewriting pushed history over. Full suite green (179 examples, 0 failures), Rubocop clean.

@bexchauveto bexchauveto merged commit 503a25a into main Jul 3, 2026
48 checks passed
@bexchauveto bexchauveto deleted the local/polymorphic-name-fix branch July 3, 2026 13:55
forest-bot added a commit that referenced this pull request Jul 3, 2026
## [1.35.1](v1.35.0...v1.35.1) (2026-07-03)

### Bug Fixes

* **datasource-active-record:** normalize demodulized polymorphic types & reuse shared joins ([#328](#328)) ([503a25a](503a25a))
@forest-bot

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.35.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants