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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +32 -1
- data/lib/exwiw/adapter/mysql_adapter.rb +34 -1
- data/lib/exwiw/query_ast.rb +24 -0
- data/lib/exwiw/query_ast_builder.rb +256 -14
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 471697b8003fdbb3178382139b96d1954e4610691e1086d1336169cc5f26e572
|
|
4
|
+
data.tar.gz: afd836794750c8ebd6b137f0067b386e8558a43b0c05f29df461e4e565257d11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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)
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -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
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
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