exwiw 0.9.14 → 0.9.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e66762378ba8b4357a2d42fff5fbb1f0c12e96979d0e59f6fdcaae46bd7da4d
4
- data.tar.gz: 1c1cd268f516ab60b34915f77fc1ab2ed7e6cdfb8d4cc1e508abf026bb5519fa
3
+ metadata.gz: 471697b8003fdbb3178382139b96d1954e4610691e1086d1336169cc5f26e572
4
+ data.tar.gz: afd836794750c8ebd6b137f0067b386e8558a43b0c05f29df461e4e565257d11
5
5
  SHA512:
6
- metadata.gz: dd5dda5f6dcfa4fce06b3e9bd7ef5c8d9d45fb32a2606db0bc2dcf5aa047784036bec61d7a4efe49d92632fb4bdd9bb460445b72587503a05e1bd49f0fa1fd63
7
- data.tar.gz: 267f0de9ca7fcca1ee19e4005031c5740493ea9d8550e13af6bc199f90f45377d5f9a840f4379a28fa8fe73ec7e26514a0fa0e2523df4df43143663dbed2dbf0
6
+ metadata.gz: 9be9e481528119cacda6da2cd7be4379803c8be4fc03c49e9361d07f578f7e70ea6b5dab6c639c2acde2d63ec23933ba8e40e6ac60cddd2063db56ff7876aafe
7
+ data.tar.gz: 5e40c552665e7a4b24e4729c8b4e6abb5d0dcefd52b61baf42e747a93b46e6268c09db53f70b4f45b60d63e451bb0a4588a3968b1bef02181e2544c7d8c3c1b0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.15] - 2026-07-31
6
+
7
+ ### Fixed
8
+
9
+ - **Scope-column mode now extracts every polymorphic arm of a join table, not just one.** A polymorphic `belongs_to` is stored as one entry per concrete target and a row picks between them with its type column, but the scope walk (`find_path_to_scoped`) returned the single shortest path — so a table reached through a polymorphic hop was INNER JOINed to exactly one owner table and filtered by that one `type_value`, silently dropping every other type. `active_storage_attachments` is the case that surfaced it: in one app a config declaring 21 polymorphic `record_type` arms dumped rows for a single arm, and in another only 1 of 8 arms came out — the missing attachments were not reported anywhere, they were simply absent. When the walk now leaves through a polymorphic relation, every sibling arm of the same (`foreign_key`, `foreign_type`) group is resolved and the table is constrained to the `UNION` of the ids the arms keep (`pk IN (SELECT pk FROM t <arm 1 joins> UNION SELECT pk FROM t <arm 2 joins> …)`), which reuses the existing scope id-set machinery: all three SQL adapters emit it as a materialized `SELECT DISTINCT` derived-table JOIN, and mysql materializes it into a session `TEMPORARY TABLE` like any other scope id-set. An arm whose target has no scope of its own is **dropped, never widened** (an unscoped arm would union in every tenant's rows), as is an arm whose target is scoped *through this very table* (the `ActiveStorage::Blob` self-reference — adopting it would leave the referenced table short of rows the join table kept). An arm's target need not have a `belongs_to` path of its own: one scoped via referenced-by / `reverse_scope` / the parent cascade contributes an arm probing that query's ids, still pinned by the type column. `ignore: true` on an arm keeps working (it is dropped before any of this). **Output is unchanged for every table that is not a multi-arm polymorphic one** — a single arm, or a walk that leaves through a plain `belongs_to`, still compiles to the same single-JOIN SQL byte for byte. Single `--target-table` mode is unchanged (it still follows one path per table).
10
+ - **mysql: a `delete-*.sql` whose scope subquery reads the table being deleted from is now accepted by the server.** MySQL rejects `DELETE FROM t WHERE … IN (SELECT … FROM t …)` with "You can't specify target table 't' for update in FROM clause", which is exactly the shape the polymorphic arm UNION above produces (each arm projects the join table's own primary key). Such a subquery is now wrapped in a derived table (`IN (SELECT * FROM (…) AS exwiw_delete_src)`), which MySQL materializes before the DELETE runs. Only that self-referencing shape is wrapped; every other subquery (a scope id-set projected from another table, the `ids_field` probe) compiles byte-identically, and the postgresql/sqlite adapters are untouched.
11
+
5
12
  ## [0.9.14] - 2026-07-29
6
13
 
7
14
  ### Changed
data/README.md CHANGED
@@ -617,11 +617,42 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
617
617
 
618
618
  The same type filter is applied on the join path — and in the matching `delete-*.sql` bulk-delete subquery — when the polymorphic table is an intermediate hop rather than the directly-dumped table.
619
619
 
620
+ #### Every arm is extracted (scope-column mode)
621
+
622
+ A polymorphic `belongs_to` is several `belongs_to` entries — one per concrete target — that a row selects between via its type column. A single JOIN can only follow **one** of them, so a join table reached through such a hop would come out holding only the rows of that one `type_value`. In [scope-column mode](#scope-column-mode) exwiw therefore resolves **every** arm of the group and constrains the table to the union of the ids the arms keep:
623
+
624
+ ```sql
625
+ SELECT comments.* FROM comments
626
+ JOIN (
627
+ SELECT DISTINCT exwiw_scope_src_0.id AS exwiw_scope_id
628
+ FROM (
629
+ SELECT comments.id FROM comments
630
+ JOIN posts ON comments.commentable_id = posts.id AND comments.commentable_type = 'Post'
631
+ JOIN shops ON posts.shop_id = shops.id AND shops.tenant_id = 't1'
632
+ UNION
633
+ SELECT comments.id FROM comments
634
+ JOIN pages ON comments.commentable_id = pages.id AND comments.commentable_type = 'Page'
635
+ JOIN shops ON pages.shop_id = shops.id AND shops.tenant_id = 't1'
636
+ ) AS exwiw_scope_src_0
637
+ ) AS exwiw_scope_ids_0
638
+ ON comments.id = exwiw_scope_ids_0.exwiw_scope_id
639
+ ```
640
+
641
+ `UNION`, not `OR`, because each arm joins a *different* table: OR-ing them in one `WHERE` would need outer joins, whereas each arm is a self-contained query of exactly the shape a single-arm table already produces. It rides on the existing scope id-set machinery, so the id set is materialized once (see [Why a JOIN, not `IN (subquery)`](#why-a-join-not-in-subquery)) and, on mysql, into a session `TEMPORARY TABLE`.
642
+
643
+ Notes:
644
+
645
+ - **Only arms that reach the scope are included.** An arm whose target has no scope of its own is dropped, never widened — an unscoped arm would pull in every tenant's rows. An arm marked `"ignore": true` is dropped as usual, before any of this.
646
+ - An arm's target does not need a `belongs_to` path to a scoped table: if it is scoped by other means (referenced-by, [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope), or the parent cascade) the arm probes that query's ids instead, still pinned by the type column.
647
+ - An arm whose target is scoped **through this same table** (e.g. `active_storage_blobs`, narrowed by referenced-by from `active_storage_attachments`, appearing as an `ActiveStorage::Blob` arm of those same attachments) is dropped: adopting it would make the two tables scope each other and leave the referenced table short of rows the join table kept — a dangling foreign key on import.
648
+ - Nothing changes when there is a single arm, or when the walk leaves through a non-polymorphic `belongs_to`: the plain single-JOIN SQL is emitted, byte for byte as before.
649
+ - This applies to the scope-column mode walk. The single `--target-table` mode still follows one path per table.
650
+
620
651
  ### ActiveStorage (`has_one_attached` / `has_many_attached`)
621
652
 
622
653
  ActiveStorage is handled automatically — no ActiveStorage-specific configuration is required. The `has_one_attached` / `has_many_attached` macros don't add a column to the owning model; they generate ordinary associations that exwiw already understands:
623
654
 
624
- - **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted.
655
+ - **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted. In scope-column mode every owner type that reaches the scope is extracted (see [Every arm is extracted](#every-arm-is-extracted-scope-column-mode)); before that, only the single owner type the walk happened to settle on came out.
625
656
  - **`active_storage_blobs`** has no `belongs_to` of its own (attachments point *at* it), so it has no path to the dump target. exwiw narrows it via **reverse / "referenced_by" extraction**: a parent table referenced by exactly one constrained, non-polymorphic child is constrained to just the referenced ids instead of dumping every row. The id set is materialized once and joined back (see [Why a JOIN, not `IN (subquery)`](#why-a-join-not-in-subquery)):
626
657
 
627
658
  ```sql
@@ -182,7 +182,7 @@ module Exwiw
182
182
  where_clauses.
183
183
  select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
184
184
  map do |where|
185
- compile_where_condition(where, select_query_ast.from_table_name)
185
+ compile_delete_where_condition(where, select_query_ast.from_table_name)
186
186
  end
187
187
 
188
188
  if compiled_where_conditions.size > 0
@@ -376,6 +376,39 @@ module Exwiw
376
376
  name
377
377
  end
378
378
 
379
+ # A WHERE condition for the DELETE statement.
380
+ #
381
+ # MySQL refuses a subquery that reads the table being deleted from
382
+ # ("You can't specify target table 'x' for update in FROM clause"), and a
383
+ # polymorphic multi-arm scope produces exactly that: each arm selects the
384
+ # join table's own primary key, so the delete's `pk IN (…)` reads the
385
+ # delete target. Wrapping the subquery in a derived table lifts the
386
+ # restriction — MySQL materializes the derived table before the DELETE
387
+ # runs, so the rows deleted are the ones the SELECT matched.
388
+ #
389
+ # Only that self-referencing shape is wrapped; every other subquery
390
+ # (a scope id-set projected from *another* table, the ids_field probe)
391
+ # compiles exactly as before.
392
+ private def compile_delete_where_condition(where_clause, table_name)
393
+ if where_clause.operator == :in_subquery && delete_target_self_reference?(where_clause.value, table_name)
394
+ key = qualified_name(table_name, where_clause.column_name)
395
+ return "#{key} IN (SELECT * FROM (#{compile_subquery(where_clause.value)}) AS exwiw_delete_src)"
396
+ end
397
+
398
+ compile_where_condition(where_clause, table_name)
399
+ end
400
+
401
+ private def delete_target_self_reference?(subquery, table_name)
402
+ case subquery
403
+ when Exwiw::QueryAst::SelectSubquery
404
+ Exwiw::QueryAst.reads_table?(subquery.query, table_name)
405
+ when Exwiw::QueryAst::UnionSubquery
406
+ subquery.queries.any? { |query| Exwiw::QueryAst.reads_table?(query, table_name) }
407
+ else
408
+ false
409
+ end
410
+ end
411
+
379
412
  private def compile_where_condition(where_clause, table_name)
380
413
  # Use as it is if it's a raw query
381
414
  return where_clause if where_clause.is_a?(String)
@@ -2,6 +2,30 @@
2
2
 
3
3
  module Exwiw
4
4
  module QueryAst
5
+ # Whether `query` (a Select) reads `table_name` anywhere — as its FROM, as a
6
+ # join, or inside a nested scope subquery. Used to detect the two shapes a
7
+ # self-referencing scope subquery creates: a polymorphic arm whose target is
8
+ # scoped through the very table the arms belong to (QueryAstBuilder), and a
9
+ # DELETE whose subquery reads the table being deleted from, which MySQL
10
+ # rejects outright (MysqlAdapter).
11
+ def self.reads_table?(query, table_name)
12
+ return true if query.from_table_name == table_name
13
+ return true if query.join_clauses.any? { |j| j.join_table_name == table_name || j.base_table_name == table_name }
14
+
15
+ query.where_clauses.any? do |where_clause|
16
+ next false unless where_clause.is_a?(WhereClause)
17
+
18
+ case where_clause.value
19
+ when SelectSubquery
20
+ reads_table?(where_clause.value.query, table_name)
21
+ when UnionSubquery
22
+ where_clause.value.queries.any? { |q| reads_table?(q, table_name) }
23
+ else
24
+ false
25
+ end
26
+ end
27
+ end
28
+
5
29
  class JoinClause
6
30
  # `where_clauses` is compiled against this join's join_table_name (the
7
31
  # joined-to table). `base_where_clauses`, on the other hand, is compiled
@@ -505,7 +505,7 @@ module Exwiw
505
505
  table = table_by_name.fetch(table_name)
506
506
  return :exempt if scope_exempt?(table)
507
507
  return :direct if directly_scoped?(table)
508
- return :via_path if build_join_clauses_scoped(table).any?
508
+ return :via_path if scoped_arms(table).any?
509
509
  return :referenced_by if @allow_reverse && build_referenced_by_clause(table)
510
510
  return :via_scoped_parent if forward_scope_allowed?(table) && build_belongs_to_scoped_clause(table)
511
511
 
@@ -539,10 +539,20 @@ module Exwiw
539
539
  end
540
540
 
541
541
  # Reachable via belongs_to: join up to the scoped ancestor (the scope
542
- # filter is applied at the terminal join inside build_join_clauses_scoped).
543
- join_clauses = build_join_clauses_scoped(table)
544
- unless join_clauses.empty?
545
- join_clauses.each { |join_clause| ast.join(join_clause) }
542
+ # filter is applied at the terminal join inside build_scoped_join_clauses).
543
+ # A polymorphic hop resolves to one arm per concrete target type, so there
544
+ # may be several such routes; see #scoped_arms.
545
+ arms = scoped_arms(table)
546
+ if arms.size == 1 && arms.first.path
547
+ arm = arms.first
548
+ build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |join_clause| ast.join(join_clause) }
549
+ ast.where(table.filter) if table.filter
550
+ return ast
551
+ elsif arms.size >= 1
552
+ # Several polymorphic arms: INNER JOINing one of them would drop the rows
553
+ # of every other type, so constrain this table to the UNION of the ids the
554
+ # arms keep instead.
555
+ ast.where(polymorphic_arms_clause(table, arms))
546
556
  ast.where(table.filter) if table.filter
547
557
  return ast
548
558
  end
@@ -613,9 +623,28 @@ module Exwiw
613
623
  # target-mode walk, the returned path INCLUDES that ancestor: the scope column
614
624
  # lives on the ancestor itself (not on a foreign key of the child), so the
615
625
  # ancestor must be joined and then filtered.
616
- private def find_path_to_scoped(table)
626
+ #
627
+ # `first_relation` pins the first hop to one specific belongs_to instead of
628
+ # letting the BFS pick it. This is how a single polymorphic arm is resolved
629
+ # (see #scoped_arms): the walk is forced out through that arm's target and
630
+ # then continues normally. The origin is pre-marked visited so the walk can
631
+ # never come back through it — exactly what the unseeded form's first pop
632
+ # does — which also keeps the BFS terminating on a belongs_to cycle.
633
+ private def find_path_to_scoped(table, first_relation: nil)
617
634
  visited = {}
618
- queue = [[table.name, [table.name]]]
635
+ queue =
636
+ if first_relation
637
+ first_table = table_by_name[first_relation.table_name]
638
+ return [] if first_table.nil?
639
+
640
+ first_path = [table.name, first_relation.table_name]
641
+ return first_path if directly_scoped?(first_table)
642
+
643
+ visited[table.name] = true
644
+ [[first_relation.table_name, first_path]]
645
+ else
646
+ [[table.name, [table.name]]]
647
+ end
619
648
 
620
649
  until queue.empty?
621
650
  current_table_name, path = queue.shift
@@ -640,17 +669,223 @@ module Exwiw
640
669
  []
641
670
  end
642
671
 
643
- private def build_join_clauses_scoped(table)
644
- path_tables = find_path_to_scoped(table)
645
- @logger.debug(" Join path from #{table.name} to a scoped table: #{path_tables}")
672
+ # One resolved route from a table to the scope.
673
+ #
674
+ # `relation` is the belongs_to the route leaves through (nil when the plain
675
+ # BFS picked it). Exactly one of the two other members is set:
676
+ # - `path` — table names from this table up to a directly-scoped
677
+ # ancestor, compiled into a chain of JOINs.
678
+ # - `target_query` — the arm target's own (already scoped) extraction
679
+ # query, used when the target carries no scope column
680
+ # and no join path reaches one, but is scoped by other
681
+ # means (referenced_by / reverse_scope / the belongs_to
682
+ # cascade). The arm then probes that query's ids.
683
+ ScopedArm = Struct.new(:relation, :path, :target_query, keyword_init: true)
684
+
685
+ # Every route this table has to the scope.
686
+ #
687
+ # A non-polymorphic belongs_to addresses exactly one parent table, so the
688
+ # single shortest path `find_path_to_scoped` returns is the whole story and
689
+ # this returns one arm — the historical behavior, byte-for-byte.
690
+ #
691
+ # A *polymorphic* hop is different: one (foreign_key, foreign_type) pair
692
+ # addresses several parent tables — one per `type_value` — and each row
693
+ # belongs to whichever arm its type column names. Following a single path
694
+ # therefore extracts only the rows of that one arm and silently drops every
695
+ # other type. (`active_storage_attachments` is the canonical case: the BFS
696
+ # settles on one owner table and the query filters `record_type = '<that
697
+ # one>'`, so attachments of the other 20-odd owner types never make it into
698
+ # the dump.) So when the shortest path leaves through a polymorphic relation,
699
+ # resolve every sibling arm of the same (foreign_key, foreign_type) group.
700
+ #
701
+ # Note the entry condition: this only ever widens a table that *already*
702
+ # reaches the scope through a polymorphic join path. A table with no path at
703
+ # all still returns [] and keeps its existing treatment (referenced_by, the
704
+ # belongs_to cascade, or :unscopable).
705
+ #
706
+ # Arms that reach nothing are **dropped, never widened**: emitting an arm
707
+ # with no scope predicate would pull in every tenant's rows. So the union is
708
+ # only ever as broad as the scoped arms allow.
709
+ private def scoped_arms(table)
710
+ (@scoped_arms ||= {})[table.name] ||= begin
711
+ shortest = find_path_to_scoped(table)
712
+ @logger.debug(" Join path from #{table.name} to a scoped table: #{shortest}")
713
+
714
+ if shortest.size < 2
715
+ []
716
+ else
717
+ sibling_arms = polymorphic_sibling_arms(table, shortest[1])
718
+ if sibling_arms.nil?
719
+ [ScopedArm.new(relation: nil, path: shortest)]
720
+ else
721
+ arms = sibling_arms.filter_map { |relation| resolve_scoped_arm(table, relation) }
722
+ @logger.debug(
723
+ " #{table.name} reaches the scope through #{arms.size} of " \
724
+ "#{sibling_arms.size} polymorphic '#{sibling_arms.first.foreign_type}' arm(s)."
725
+ )
726
+ arms
727
+ end
728
+ end
729
+ end
730
+ end
731
+
732
+ # Resolve one polymorphic arm, or nil when its target cannot be scoped.
733
+ #
734
+ # The cheap case is a join path from this table up to a directly-scoped
735
+ # ancestor. When there is none, the target may still be scoped by one of the
736
+ # other mechanisms — most commonly a `reverse_scope`d owner table (one
737
+ # narrowed by the rows that reference it, rather than by a path of its own),
738
+ # whose children therefore have no path of their own. Rather than dropping
739
+ # such an arm, reuse the target's own extraction query as the id set; that
740
+ # query is built by the same recursion every other rescue uses, so it picks
741
+ # up referenced_by / reverse_scope / the multi-hop cascade for free.
742
+ private def resolve_scoped_arm(table, relation)
743
+ path = find_path_to_scoped(table, first_relation: relation)
744
+ return ScopedArm.new(relation: relation, path: path) if path.size >= 2
745
+
746
+ target = table_by_name[relation.table_name]
747
+ return nil if target.nil? || target.primary_key.nil?
748
+ # Descending into a table already being resolved would close a cycle.
749
+ return nil if target.name == table.name || @forward_path.include?(target.name)
750
+
751
+ target_query = self.class.run(
752
+ target.name, table_by_name, dump_target, @logger,
753
+ allow_reverse: true, forward_path: @forward_path + [table.name]
754
+ )
755
+ # An unconstrained target selects every id, i.e. does not scope the arm at
756
+ # all; dropping the arm is the safe outcome.
757
+ return nil unless target_query.where_clauses.any? || target_query.join_clauses.any?
758
+
759
+ # The target is scoped *through this very table* (the classic shape is
760
+ # active_storage_blobs, narrowed by referenced_by from
761
+ # active_storage_attachments, appearing as an `ActiveStorage::Blob` arm of
762
+ # those same attachments). Adopting the arm would make the two tables
763
+ # scope each other: this table's query would widen to rows whose ids the
764
+ # target's query — built without the new arm — never saw, so the target
765
+ # would no longer cover every row this table keeps (a dangling foreign key
766
+ # on import). Drop the arm and leave the pair consistent.
767
+ if QueryAst.reads_table?(target_query, table.name)
768
+ @logger.debug(
769
+ " Skipping #{table.name} polymorphic arm '#{relation.type_value}': " \
770
+ "#{target.name} is itself scoped through #{table.name}."
771
+ )
772
+ return nil
773
+ end
774
+
775
+ ScopedArm.new(relation: relation, target_query: target_query)
776
+ end
646
777
 
778
+ # The polymorphic belongs_to arms sharing the (foreign_key, foreign_type) of
779
+ # the relation this table uses to reach `first_hop_table_name`, or nil when
780
+ # the caller should stay on the single-path behavior: the hop is not
781
+ # polymorphic, or its group has only one arm, or this table has no usable
782
+ # primary key to union the arms on.
783
+ #
784
+ # The relation is looked up with `belongs_to(table_name)` — the same lookup
785
+ # #build_scoped_join_clause performs — so the decision is made about exactly
786
+ # the relation that would be compiled.
787
+ private def polymorphic_sibling_arms(table, first_hop_table_name)
788
+ return nil if table.primary_key.nil?
789
+
790
+ relation = table.belongs_to(first_hop_table_name)
791
+ return nil if relation.nil? || !relation.polymorphic?
792
+
793
+ arms = table.belongs_tos.select do |other|
794
+ other.polymorphic? &&
795
+ other.foreign_key == relation.foreign_key &&
796
+ other.foreign_type == relation.foreign_type
797
+ end
798
+ arms.size > 1 ? arms : nil
799
+ end
800
+
801
+ # Constrain `table` to the UNION of the ids each polymorphic arm's join path
802
+ # keeps:
803
+ #
804
+ # <table>.<pk> IN (
805
+ # SELECT <table>.<pk> FROM <table> <arm 1 joins + scope filter>
806
+ # UNION
807
+ # SELECT <table>.<pk> FROM <table> <arm 2 joins + scope filter>
808
+ # ...
809
+ # )
810
+ #
811
+ # UNION rather than OR because the arms join *different* tables: OR-ing them
812
+ # in one WHERE would need the joins to be outer joins, whereas each arm is a
813
+ # self-contained INNER-JOIN query of exactly the shape a single-arm table
814
+ # already produces. It also lands on the existing scope id-set machinery —
815
+ # the adapters lift a `pk IN (UnionSubquery)` clause into a materialized
816
+ # `SELECT DISTINCT` derived-table JOIN (and the mysql adapter into a session
817
+ # TEMPORARY TABLE), so the arms are evaluated once instead of per outer row,
818
+ # and the DISTINCT keeps the row set identical to the IN form.
819
+ #
820
+ # The projected primary key is forced to a plain column so any masking
821
+ # (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
822
+ # comparison — the same guard the reverse-scope projections use.
823
+ private def polymorphic_arms_clause(table, arms)
824
+ pk_column = TableColumn.from_symbol_keys(name: table.primary_key)
825
+
826
+ queries = arms.map do |arm|
827
+ query = QueryAst::Select.new
828
+ query.from(table.name)
829
+ query.select([pk_column])
830
+
831
+ if arm.path
832
+ build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
833
+ else
834
+ # The arm's target is scoped without a join path of its own, so probe
835
+ # its id set instead of joining up to a scoped ancestor. The type
836
+ # column still has to be constrained: the foreign key alone cannot tell
837
+ # this arm's rows from another arm's (record_id=1 may be any type).
838
+ target = table_by_name.fetch(arm.relation.table_name)
839
+ query.where QueryAst::WhereClause.new(
840
+ column_name: arm.relation.foreign_key,
841
+ operator: :in_subquery,
842
+ value: QueryAst::SelectSubquery.new(
843
+ query: project_query_to(arm.target_query, target.primary_key)
844
+ )
845
+ )
846
+ query.where QueryAst::WhereClause.new(
847
+ column_name: arm.relation.foreign_type,
848
+ operator: :eq,
849
+ value: [arm.relation.type_value]
850
+ )
851
+ end
852
+
853
+ query
854
+ end
855
+
856
+ QueryAst::WhereClause.new(
857
+ column_name: table.primary_key,
858
+ operator: :in_subquery,
859
+ value: QueryAst::UnionSubquery.new(queries: queries)
860
+ )
861
+ end
862
+
863
+ # Reduce an extraction query to a single-column id projection, keeping its
864
+ # joins and filters. The column is forced to a plain TableColumn so any
865
+ # masking (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
866
+ # comparison.
867
+ private def project_query_to(query, column_name)
868
+ projected = QueryAst::Select.new
869
+ projected.from(query.from_table_name)
870
+ projected.select([TableColumn.from_symbol_keys(name: column_name)])
871
+ query.join_clauses.each { |join_clause| projected.join(join_clause) }
872
+ query.where_clauses.each { |where_clause| projected.where(where_clause) }
873
+ projected
874
+ end
875
+
876
+ # Compile one route (a table-name path from this table up to a directly
877
+ # scoped ancestor) into JoinClauses. `first_relation` pins the first hop's
878
+ # belongs_to when the caller resolved it explicitly (a polymorphic arm);
879
+ # otherwise every hop is looked up by target table name, as before.
880
+ private def build_scoped_join_clauses(path_tables, first_relation: nil)
647
881
  return [] if path_tables.size < 2
648
882
 
649
- path_tables.each_cons(2).map do |from_table_name, to_table_name|
883
+ path_tables.each_cons(2).with_index.map do |(from_table_name, to_table_name), idx|
650
884
  from_table = table_by_name[from_table_name]
651
885
  to_table = table_by_name[to_table_name]
652
886
 
653
- join_clause = build_scoped_join_clause(from_table, to_table)
887
+ hop_relation = idx.zero? ? first_relation : nil
888
+ join_clause = build_scoped_join_clause(from_table, to_table, hop_relation)
654
889
 
655
890
  # Only the final hop's to_table is directly scoped (the BFS stops there),
656
891
  # so the scope filter rides on that join's where_clauses, compiled against
@@ -670,8 +905,15 @@ module Exwiw
670
905
  # One belongs_to hop as a JoinClause, with the polymorphic type condition
671
906
  # placed on the source table (base_where_clauses) when the hop is polymorphic
672
907
  # — mirroring the target-mode loop in build_join_clauses.
673
- private def build_scoped_join_clause(from_table, to_table)
674
- relation = from_table.belongs_to(to_table.name)
908
+ #
909
+ # `relation` may be supplied to pin the hop to one specific belongs_to. The
910
+ # name-based lookup cannot express "this arm": a table can declare several
911
+ # relations to the same target (e.g. active_storage_attachments has both the
912
+ # plain `blob_id` and the polymorphic `record_id`/`ActiveStorage::Blob` edge
913
+ # to active_storage_blobs) and #belongs_to returns whichever comes first, so
914
+ # a polymorphic arm resolved by #scoped_arms passes itself in.
915
+ private def build_scoped_join_clause(from_table, to_table, relation = nil)
916
+ relation ||= from_table.belongs_to(to_table.name)
675
917
 
676
918
  join_clause = QueryAst::JoinClause.new(
677
919
  base_table_name: from_table.name,
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.14"
4
+ VERSION = "0.9.15"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.14
4
+ version: 0.9.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia