exwiw 1.1.3 → 1.1.5

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: 0feed4cda7c72c24738b7ccc03fa5cadc5321b636e48a86e07d8a169c5b7d941
4
- data.tar.gz: 50241d3f38a64485adcfbc8740e5558e8049c136762068f56e9b505a93251d19
3
+ metadata.gz: 2df915162dc88ac29ccc262c436d5d7ab1797a7e026398ced3e66d1e5b73a7b7
4
+ data.tar.gz: 3144680abf12865a3db9d4173e4338101fa318da9f5e61e3367c5924fb24f9c6
5
5
  SHA512:
6
- metadata.gz: 1fad77e8f61f216afc2c9af89f54784e90fc50bfa691b29bd198727be29a6114d208d867e261c89e6b14d3ad27d385c6e981bd133df4803d2cab4c201eb9e090
7
- data.tar.gz: a874e74e0c5c6fe674511de041baf80b80c7dc6495b7c96a950d5200f393a9e6e6dfa004f0d341245fdf576e4f42c0425079225895e9143f8001612a6d403383
6
+ metadata.gz: 2726b1b47c6584d6a85ab6f1342fdfb8ac760d237c09854f81a34e36d403a3720efbe25ee47185d3f381b4384e6a3f75f47ff6002fcbaa09d4613f937b39cc1f
7
+ data.tar.gz: 75bb6c28ecd32adaa2aeb3488d319784811ad4ab08104560dea54982e94de51c7a7ff606b81815f6d73a41608f5f6dae49c57774a35ad837a8b6b5dab994c45d
data/AGENTS.md ADDED
@@ -0,0 +1,24 @@
1
+ # AGENTS.md
2
+
3
+ ## This repository is public
4
+
5
+ Everything here is world-readable: code, comments, fixtures, commit messages,
6
+ PR and issue text, review comments, and their edit history. Deleting or editing
7
+ text after the fact does not unpublish it.
8
+
9
+ Do not include anything that comes from a private codebase:
10
+
11
+ - table, column, model, or system names from a private application
12
+ - references to private repositories or their PR / issue numbers
13
+ - internal terminology, internal links (chat, wiki, tickets), or any customer or production data
14
+
15
+ Describe motivating cases with invented, generic names, and keep planning and
16
+ tracking in the private repository that needs the change. Re-read the title,
17
+ body, and commit messages against this list before committing or opening a PR.
18
+
19
+ ## Development
20
+
21
+ See [README.md](README.md) for usage and [CHANGELOG.md](CHANGELOG.md) for the
22
+ release history. `bundle exec rake` compiles the native extension and runs the
23
+ specs; most scripts under `e2e/` need the databases from `compose.yml`
24
+ (the sqlite ones run without it).
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.5] - 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - **Single `--target-table` mode extracts every polymorphic arm, not just one.** The multi-arm union was only built in scope-column mode. In single-target mode a table reached through a polymorphic `belongs_to` followed one arm: the arm pointing at the dump target if there was one, otherwise the arm on the BFS's shortest path. Every other type's rows were dropped without a warning. Dumping `shops`, a `reviews` table with `Shop` and `Product` arms came out with the shop's own reviews but none of the reviews of its products. The arms are now resolved as in scope-column mode (an arm pointing at the dump target compares its foreign key with `--ids`, the others join or probe their target's ids) and the table is constrained to their `UNION`. One single-target rule keeps existing dumps from widening: an arm target that is constrained only by the automatic referenced-by detection does not count, because such a parent is extracted just to keep a child's foreign key valid. Dumping `products` therefore still yields only `reviewable_type = 'Product'` reviews. A declared `reverse_scope` does count. Tables that reach the target through a single arm, or through a non-polymorphic `belongs_to`, emit the same SQL as before.
10
+ - **A table whose only route is one polymorphic arm to a table scoped by other means is no longer unscopable.** The parent cascade skipped polymorphic `belongs_to`s outright, so a table like `pictures` with a lone `imageable_type = 'Shop'` arm, pointing at a `shops` table narrowed by `reverse_scope`, was rejected in scope-column mode and dumped in full in single-target mode. The cascade now falls back to the polymorphic arms, pinned by the type column, when no plain `belongs_to` parent is scopable. A table that already had a scopable plain parent is scoped as before. A newly scoped table does, however, count as a constrained child in the automatic referenced-by detection of the tables it points at. A parent that had exactly one constrained child can now have two, so the detection steps aside and the parent falls back to the parent cascade, or is left unscopable when nothing else narrows it. Declare `reverse_scope` on the parent to keep the referencer it used before. When two independent polymorphic associations are both scopable, the table stays unscopable, and single-target mode now warns about the full dump as it does for two plain parents.
11
+
12
+ ## [1.1.4] - 2026-09-18
13
+
14
+ ### Fixed
15
+
16
+ - **Polymorphic arms are grouped by the type column, so a config that gives each arm its own foreign key no longer loses rows.** The multi-arm walk keyed the group on `(foreign_key, foreign_type)`, which fits what the generators emit for Rails (`<name>_id` + `<name>_type`) but not a hand-written config where one type column selects between several foreign key columns — `owner_type` choosing between `user_id` and `team_id`, say. Such arms were not recognised as siblings, so the table fell back to the single-path behavior: only the arm the BFS happened to settle on was extracted and every other arm's rows were dropped without a warning. Which arm survived depended on path length, so a table could even lose the arm it used to keep once a shorter one appeared. The group is now keyed on `foreign_type` alone and each arm joins on its own foreign key; two *independent* polymorphic associations have distinct type columns and stay in distinct groups, as before. Two consequences for such a table, both already true of the single-foreign-key case: its SQL becomes the `UNION` id-set form instead of a single JOIN, and `batch_scope` on it is now rejected in pre-flight (a batch of one terminus's ids does not constrain the other arms).
17
+
5
18
  ## [1.1.3] - 2026-09-08
6
19
 
7
20
  ### Fixed
data/README.md CHANGED
@@ -153,8 +153,8 @@ Only the dump target itself is filtered by `--ids` directly. Every *other* table
153
153
  How the paths behave and interact:
154
154
 
155
155
  1. **Direct filter.** In the default single-target mode the target is anchored on its primary key (or a custom field via the mongodb-only `--ids-field`). In [scope-column mode](#scope-column-mode) there is no single anchor: every table that declares a `scope_column` is filtered on that column directly.
156
- 2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column; in scope-column mode a polymorphic hop is resolved for **every** concrete arm and the arms are `UNION`ed (see [Every arm is extracted](#every-arm-is-extracted-scope-column-mode)).
157
- 3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside (path 6) unless you declare `reverse_scope`.
156
+ 2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column, and is resolved for **every** concrete arm with the arms `UNION`ed (see [Every arm is extracted](#every-arm-is-extracted)).
157
+ 3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside to path 5, then 6, unless you declare `reverse_scope`.
158
158
  4. **[`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope)** is the declared, multi-referencer form of path 3: the config enumerates which referencers' (already scoped) queries feed the id set. Unscoped arms are skipped with a warning rather than widening the dump.
159
159
  5. **Scoped-parent cascade** rescues satellites: a table whose only link is a `belongs_to` toward a hub that is itself scoped (e.g. via referenced-by or `reverse_scope`) is constrained to that parent's in-scope ids. The cascade recurses hop by hop (each level requires a single unambiguous scopable parent) and stops on `belongs_to` cycles.
160
160
  6. **Full dump** is the fallback for a genuinely unrelated table — intended for reference/master data. Single-target mode dumps it in full (with a warning when an ambiguous cascade was the reason); scope-column mode refuses to run instead, unless the table is explicitly marked [`scope_exempt: true`](#scope_exempt-intentional-full-dump) (Rails-managed tables are exempt automatically).
@@ -783,9 +783,9 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
783
783
 
784
784
  The same type filter is applied on the join path when the polymorphic table is an intermediate hop rather than the directly-dumped table.
785
785
 
786
- #### Every arm is extracted (scope-column mode)
786
+ #### Every arm is extracted
787
787
 
788
- 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:
788
+ 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`. exwiw therefore resolves **every** arm of the group and constrains the table to the union of the ids the arms keep. In [scope-column mode](#scope-column-mode):
789
789
 
790
790
  ```sql
791
791
  SELECT comments.* FROM comments
@@ -811,14 +811,35 @@ Notes:
811
811
  - **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.
812
812
  - 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.
813
813
  - 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.
814
- - 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.
815
- - This applies to the scope-column mode walk. The single `--target-table` mode still follows one path per table.
814
+ - **Arms are grouped by the type column (`foreign_type`), not by the foreign key.** Rails keeps every arm's id in one `<name>_id` column, which is what the generators emit, but a hand-written config may give each arm its own foreign key while one type column still selects between them — e.g. `owner_type` choosing between `user_id` and `team_id`. Those are arms of the same discriminator, so each is resolved and joined on its own key. Two *independent* polymorphic associations on one table have distinct type columns and therefore stay in distinct groups.
815
+ - **The route the walk picks decides whether the arms are unioned.** The route is the shortest one; in single-target mode a `belongs_to` pointing at the dump target itself is taken first. When the route leaves through a polymorphic arm, every arm of that type column is unioned. When it leaves through a non-polymorphic `belongs_to`, the table is joined along that route alone and the polymorphic arms are not unioned in, so a row reachable only through an arm is not extracted. A plain `belongs_to` is not preferred over a shorter polymorphic route. A single arm is emitted as a plain JOIN.
816
+ - A table with no route of its own falls back to the parent cascade, which does prefer plain parents. A scopable plain parent is used when there is exactly one. Only when there is none are the polymorphic `belongs_to`s consulted: the arms of one type column that point at tables scoped by other means (referenced-by, `reverse_scope`, or the cascade) are unioned, each pinned by the type column. Multiple independent polymorphic associations that are all scopable are as ambiguous as multiple plain parents, so the table is left unscopable (dumped in full with a warning in single-target mode). A table scoped this way also counts as a constrained child in the [referenced-by](#how-each-table-is-narrowed--the-six-scoping-paths) detection of the tables it points at, so a parent that had a single constrained referencer may now have two and be narrowed by the next path instead.
817
+
818
+ In single `--target-table` mode the same union is built. An arm that points at the dump target itself compares its foreign key with `--ids`, and the other arms join or probe their way to the target as above. One difference: an arm target that is constrained **only** by the automatic referenced-by detection does not count. Such a parent is extracted just to keep a child's foreign key valid; it does not own the rows that point at it. So dumping `products` still pulls only `reviewable_type = 'Product'` reviews, even though the product's shop is extracted too, while dumping `shops` pulls the shop's own reviews **and** the reviews of its products:
819
+
820
+ ```sql
821
+ SELECT reviews.* FROM reviews
822
+ JOIN (
823
+ SELECT DISTINCT exwiw_scope_src_0.id AS exwiw_scope_id
824
+ FROM (
825
+ SELECT reviews.id FROM reviews
826
+ JOIN products ON reviews.reviewable_id = products.id
827
+ AND products.shop_id = 1 AND reviews.reviewable_type = 'Product'
828
+ UNION
829
+ SELECT reviews.id FROM reviews
830
+ WHERE reviews.reviewable_id = 1 AND reviews.reviewable_type = 'Shop'
831
+ ) AS exwiw_scope_src_0
832
+ ) AS exwiw_scope_ids_0
833
+ ON reviews.id = exwiw_scope_ids_0.exwiw_scope_id
834
+ ```
835
+
836
+ A declared [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope) does count, since declaring it states that the referencers own the rows.
816
837
 
817
838
  ### ActiveStorage (`has_one_attached` / `has_many_attached`)
818
839
 
819
840
  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:
820
841
 
821
- - **`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.
842
+ - **`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. Every owner type that reaches the scope is extracted (see [Every arm is extracted](#every-arm-is-extracted)); before that, only the single owner type the walk happened to settle on came out.
822
843
  - **`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)):
823
844
 
824
845
  ```sql
@@ -72,6 +72,8 @@ module Exwiw
72
72
  # automatic reverse detection step aside, when it did.
73
73
  attr_reader :ambiguous_referencers
74
74
 
75
+ attr_reader :narrowed_by_automatic_reverse
76
+
75
77
  def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, allow_declared_reverse: true, forward_path: [], reverse_path: [], deep_chain_warned: nil, batch_ids: nil)
76
78
  @table_name = table_name
77
79
  @table_by_name = table_by_name
@@ -103,6 +105,7 @@ module Exwiw
103
105
  # Deep-chain warnings already emitted, keyed by chain path and shared (the
104
106
  # same Hash) across every recursive build under one top-level run.
105
107
  @deep_chain_warned = deep_chain_warned || {}
108
+ @narrowed_by_automatic_reverse = false
106
109
  end
107
110
 
108
111
  def run
@@ -110,8 +113,15 @@ module Exwiw
110
113
 
111
114
  return build_scoped(table) if scope_mode?
112
115
 
113
- where_clauses = build_where_clauses(table, dump_target)
114
- join_clauses = build_join_clauses(table, table_by_name, dump_target)
116
+ arm_queries = target_polymorphic_arm_queries(table)
117
+ if arm_queries
118
+ where_clauses = [pk_union_clause(table, arm_queries)]
119
+ where_clauses.push(table.filter) if table.filter
120
+ join_clauses = []
121
+ else
122
+ where_clauses = build_where_clauses(table, dump_target)
123
+ join_clauses = build_join_clauses(table, table_by_name, dump_target)
124
+ end
115
125
 
116
126
  # Reverse / "referenced_by" extraction. A table with no belongs_to path to
117
127
  # the dump target produces no where/join clauses and would otherwise dump
@@ -126,7 +136,10 @@ module Exwiw
126
136
  table.name != dump_target.table_name &&
127
137
  where_clauses.empty? && join_clauses.empty?
128
138
  reverse_clause = build_referenced_by_clause(table)
129
- where_clauses.push(reverse_clause) if reverse_clause
139
+ if reverse_clause
140
+ where_clauses.push(reverse_clause)
141
+ @narrowed_by_automatic_reverse = !explicit_reverse_scope?(table)
142
+ end
130
143
  end
131
144
 
132
145
  # Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
@@ -141,7 +154,7 @@ module Exwiw
141
154
  if parent_clause
142
155
  where_clauses.push(parent_clause)
143
156
  elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
144
- scopable_parent_candidates(table).size > 1
157
+ (scopable_parent_candidates(table).size > 1 || polymorphic_parent_groups(table).size > 1)
145
158
  @logger.warn(
146
159
  " #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
147
160
  "pick one unambiguously, so it is dumped in full. If this is intended, set " \
@@ -184,63 +197,60 @@ module Exwiw
184
197
  # the path is 1, it's impossible case
185
198
  return [] if path_tables.size < 2
186
199
 
187
- join_clauses = []
188
-
189
- path_tables.each_cons(2) do |from_table_name, to_table_name|
200
+ path_tables.each_cons(2).map do |from_table_name, to_table_name|
190
201
  from_table = table_by_name[from_table_name]
191
202
  to_table = table_by_name[to_table_name]
203
+ build_join_clause(from_table, to_table, from_table.belongs_to(to_table_name))
204
+ end
205
+ end
192
206
 
193
- relation = from_table.belongs_to(to_table_name)
207
+ private def build_join_clause(from_table, to_table, relation)
208
+ join_clause = QueryAst::JoinClause.new(
209
+ base_table_name: from_table.name,
210
+ foreign_key: relation.foreign_key,
211
+ join_table_name: to_table.name,
212
+ primary_key: to_table.primary_key,
213
+ where_clauses: [],
214
+ base_where_clauses: []
215
+ )
194
216
 
195
- join_clause = QueryAst::JoinClause.new(
196
- base_table_name: from_table.name,
197
- foreign_key: relation.foreign_key,
198
- join_table_name: to_table.name,
199
- primary_key: to_table.primary_key,
200
- where_clauses: [],
201
- base_where_clauses: []
217
+ # When this hop itself is a polymorphic belongs_to (e.g. comments
218
+ # polymorphically belongs_to posts as commentable), the type column
219
+ # (foreign_type) lives on the source table (from_table = base_table_name).
220
+ # The foreign key alone is not enough — a value like reviewable_id=1 can
221
+ # collide with rows of another model — so add the type condition to
222
+ # base_where_clauses to narrow down the source table.
223
+ if relation.polymorphic?
224
+ join_clause.base_where_clauses.push QueryAst::WhereClause.new(
225
+ column_name: relation.foreign_type,
226
+ operator: :eq,
227
+ value: [relation.type_value]
202
228
  )
203
-
204
- # When this hop itself is a polymorphic belongs_to (e.g. comments
205
- # polymorphically belongs_to posts as commentable), the type column
206
- # (foreign_type) lives on the source table (from_table = base_table_name).
207
- # The foreign key alone is not enough — a value like reviewable_id=1 can
208
- # collide with rows of another model — so add the type condition to
209
- # base_where_clauses to narrow down the source table.
210
- if relation.polymorphic?
211
- join_clause.base_where_clauses.push QueryAst::WhereClause.new(
212
- column_name: relation.foreign_type,
229
+ end
230
+ relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
231
+ if relation_to_dump_target
232
+ join_clause.where_clauses.push dump_target_fk_clause(relation_to_dump_target.foreign_key)
233
+
234
+ # When the intermediate table polymorphically belongs_to the dump
235
+ # target, also add the type column (foreign_type) to the join
236
+ # condition. The type column lives on to_table (= join_table_name), so
237
+ # it rides on the existing mechanism where a JoinClause's where_clauses
238
+ # are compiled against join_table_name.
239
+ if relation_to_dump_target.polymorphic?
240
+ join_clause.where_clauses.push QueryAst::WhereClause.new(
241
+ column_name: relation_to_dump_target.foreign_type,
213
242
  operator: :eq,
214
- value: [relation.type_value]
243
+ value: [relation_to_dump_target.type_value]
215
244
  )
216
245
  end
217
- relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
218
- if relation_to_dump_target
219
- join_clause.where_clauses.push dump_target_fk_clause(relation_to_dump_target.foreign_key)
220
-
221
- # When the intermediate table polymorphically belongs_to the dump
222
- # target, also add the type column (foreign_type) to the join
223
- # condition. The type column lives on to_table (= join_table_name), so
224
- # it rides on the existing mechanism where a JoinClause's where_clauses
225
- # are compiled against join_table_name.
226
- if relation_to_dump_target.polymorphic?
227
- join_clause.where_clauses.push QueryAst::WhereClause.new(
228
- column_name: relation_to_dump_target.foreign_type,
229
- operator: :eq,
230
- value: [relation_to_dump_target.type_value]
231
- )
232
- end
233
- end
234
-
235
- # Add filter from intermediate table to join clause
236
- if to_table.filter
237
- join_clause.where_clauses.push to_table.filter
238
- end
246
+ end
239
247
 
240
- join_clauses.push(join_clause)
248
+ # Add filter from intermediate table to join clause
249
+ if to_table.filter
250
+ join_clause.where_clauses.push to_table.filter
241
251
  end
242
252
 
243
- join_clauses
253
+ join_clause
244
254
  end
245
255
 
246
256
  # Builds a `pk IN (SELECT child.fk FROM <child extraction query>)` clause
@@ -419,6 +429,14 @@ module Exwiw
419
429
  private def build_belongs_to_scoped_clause(table)
420
430
  candidates = scopable_parent_candidates(table)
421
431
 
432
+ # Plain parents take precedence over polymorphic ones: polymorphic parents
433
+ # are consulted only when no plain parent is scopable. Multiple independent
434
+ # polymorphic associations are as ambiguous as multiple plain parents.
435
+ if candidates.empty?
436
+ groups = polymorphic_parent_groups(table)
437
+ return groups.size == 1 ? pk_union_clause(table, groups.first) : nil
438
+ end
439
+
422
440
  # Only the unambiguous single-parent case. Multiple scopable parents would
423
441
  # need their subqueries combined (not supported); fall back to unscopable.
424
442
  if candidates.size != 1
@@ -446,6 +464,21 @@ module Exwiw
446
464
  )
447
465
  end
448
466
 
467
+ private def polymorphic_parent_groups(table)
468
+ @polymorphic_parent_groups ||= {}
469
+ return @polymorphic_parent_groups[table.name] if @polymorphic_parent_groups.key?(table.name)
470
+
471
+ @polymorphic_parent_groups[table.name] =
472
+ if table.primary_key.nil?
473
+ []
474
+ else
475
+ table.belongs_tos.select(&:polymorphic?).group_by(&:foreign_type).filter_map do |_, relations|
476
+ arm_queries = relations.filter_map { |relation| scoped_target_arm_query(table, relation) }
477
+ arm_queries unless arm_queries.empty?
478
+ end
479
+ end
480
+ end
481
+
449
482
  # The scopable belongs_to parents of `table`: each non-polymorphic parent
450
483
  # whose own extraction query comes out constrained, paired with the relation
451
484
  # and that query. Shared by build_belongs_to_scoped_clause (which requires
@@ -556,6 +589,53 @@ module Exwiw
556
589
  )
557
590
  end
558
591
 
592
+ # Single-target counterpart of #scoped_arms.
593
+ private def target_polymorphic_arm_queries(table)
594
+ return nil if table.name == dump_target.table_name
595
+
596
+ first_hop =
597
+ if table.belongs_to(dump_target.table_name)
598
+ dump_target.table_name
599
+ else
600
+ find_path_to_dump_target(table, table_by_name, dump_target)[1]
601
+ end
602
+ return nil if first_hop.nil?
603
+
604
+ sibling_arms = polymorphic_sibling_arms(table, first_hop)
605
+ return nil if sibling_arms.nil?
606
+
607
+ arm_queries = sibling_arms.filter_map { |relation| target_arm_query(table, relation) }
608
+ @logger.debug(
609
+ " #{table.name} reaches the dump target through #{arm_queries.size} of " \
610
+ "#{sibling_arms.size} polymorphic '#{sibling_arms.first.foreign_type}' arm(s)."
611
+ )
612
+ # A lone arm is always the one the shortest path leaves through, which the
613
+ # single-path build already emits as a plain JOIN.
614
+ arm_queries.size >= 2 ? arm_queries : nil
615
+ end
616
+
617
+ private def target_arm_query(table, relation)
618
+ if relation.table_name == dump_target.table_name
619
+ return arm_pk_query(table).tap do |query|
620
+ query.where dump_target_fk_clause(relation.foreign_key)
621
+ query.where polymorphic_type_clause(relation)
622
+ end
623
+ end
624
+
625
+ target = table_by_name[relation.table_name]
626
+ return nil if target.nil?
627
+
628
+ path = find_path_to_dump_target(target, table_by_name, dump_target)
629
+ if path.any? && !path.include?(table.name)
630
+ return arm_pk_query(table).tap do |query|
631
+ query.join(build_join_clause(table, target, relation))
632
+ build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
633
+ end
634
+ end
635
+
636
+ scoped_target_arm_query(table, relation)
637
+ end
638
+
559
639
  private def find_path_to_dump_target(table, table_by_name, dump_target)
560
640
  return [] if table.name == dump_target.table_name
561
641
 
@@ -880,7 +960,7 @@ module Exwiw
880
960
  # single shortest path `find_path_to_scoped` returns is the whole story and
881
961
  # this returns one arm — the historical behavior, byte-for-byte.
882
962
  #
883
- # A *polymorphic* hop is different: one (foreign_key, foreign_type) pair
963
+ # A *polymorphic* hop is different: one type column (`foreign_type`)
884
964
  # addresses several parent tables — one per `type_value` — and each row
885
965
  # belongs to whichever arm its type column names. Following a single path
886
966
  # therefore extracts only the rows of that one arm and silently drops every
@@ -888,7 +968,7 @@ module Exwiw
888
968
  # settles on one owner table and the query filters `record_type = '<that
889
969
  # one>'`, so attachments of the other 20-odd owner types never make it into
890
970
  # the dump.) So when the shortest path leaves through a polymorphic relation,
891
- # resolve every sibling arm of the same (foreign_key, foreign_type) group.
971
+ # resolve every sibling arm sharing its type column.
892
972
  #
893
973
  # Note the entry condition: this only ever widens a table that *already*
894
974
  # reaches the scope through a polymorphic join path. A table with no path at
@@ -935,15 +1015,33 @@ module Exwiw
935
1015
  path = find_path_to_scoped(table, first_relation: relation)
936
1016
  return ScopedArm.new(relation: relation, path: path) if path.size >= 2
937
1017
 
1018
+ target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
1019
+ target_query && ScopedArm.new(relation: relation, target_query: target_query)
1020
+ end
1021
+
1022
+ private def scoped_target_arm_query(table, relation)
1023
+ # A parent kept only by the automatic referenced_by exists to keep a child's
1024
+ # foreign key valid. Single-target mode extracts only what sits under the
1025
+ # dump target, so such a parent must not pull in the rows that point at it.
1026
+ target_query = scoped_target_query(table, relation, allow_automatic_reverse: scope_mode?)
1027
+ target_query && target_ids_arm_query(table, relation, target_query)
1028
+ end
1029
+
1030
+ private def scoped_target_query(table, relation, allow_automatic_reverse:)
938
1031
  target = table_by_name[relation.table_name]
939
1032
  return nil if target.nil? || target.primary_key.nil?
940
1033
  # Descending into a table already being resolved would close a cycle.
941
1034
  return nil if target.name == table.name || @forward_path.include?(target.name)
942
1035
 
943
- target_query = self.class.run(
1036
+ # Built the same way as the target's own extraction, so the arm never keeps
1037
+ # a row pointing at a target row the dump leaves out.
1038
+ builder = self.class.new(
944
1039
  target.name, table_by_name, dump_target, @logger,
945
- allow_reverse: true, forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
1040
+ forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
946
1041
  )
1042
+ target_query = builder.run
1043
+ return nil if !allow_automatic_reverse && builder.narrowed_by_automatic_reverse
1044
+
947
1045
  # An unconstrained target selects every id, i.e. does not scope the arm at
948
1046
  # all; dropping the arm is the safe outcome.
949
1047
  return nil unless target_query.where_clauses.any? || target_query.join_clauses.any?
@@ -964,14 +1062,22 @@ module Exwiw
964
1062
  return nil
965
1063
  end
966
1064
 
967
- ScopedArm.new(relation: relation, target_query: target_query)
1065
+ target_query
968
1066
  end
969
1067
 
970
- # The polymorphic belongs_to arms sharing the (foreign_key, foreign_type) of
971
- # the relation this table uses to reach `first_hop_table_name`, or nil when
972
- # the caller should stay on the single-path behavior: the hop is not
973
- # polymorphic, or its group has only one arm, or this table has no usable
974
- # primary key to union the arms on.
1068
+ # The polymorphic belongs_to arms sharing the `foreign_type` of the relation
1069
+ # this table uses to reach `first_hop_table_name`, or nil when the caller
1070
+ # should stay on the single-path behavior: the hop is not polymorphic, or its
1071
+ # group has only one arm, or this table has no usable primary key to union
1072
+ # the arms on.
1073
+ #
1074
+ # The group is keyed on the type column alone. A Rails association stores
1075
+ # every arm's id in one column, but a hand-written config may give each arm
1076
+ # its own foreign key while one type column still selects between them; both
1077
+ # are arms of the same discriminator, and everything downstream reads
1078
+ # `foreign_key` off the arm's own relation. Two *independent* polymorphic
1079
+ # associations on one table have distinct type columns, so they stay in
1080
+ # distinct groups.
975
1081
  #
976
1082
  # The relation is looked up with `belongs_to(table_name)` — the same lookup
977
1083
  # #build_scoped_join_clause performs — so the decision is made about exactly
@@ -983,9 +1089,7 @@ module Exwiw
983
1089
  return nil if relation.nil? || !relation.polymorphic?
984
1090
 
985
1091
  arms = table.belongs_tos.select do |other|
986
- other.polymorphic? &&
987
- other.foreign_key == relation.foreign_key &&
988
- other.foreign_type == relation.foreign_type
1092
+ other.polymorphic? && other.foreign_type == relation.foreign_type
989
1093
  end
990
1094
  arms.size > 1 ? arms : nil
991
1095
  end
@@ -1013,42 +1117,57 @@ module Exwiw
1013
1117
  # (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
1014
1118
  # comparison — the same guard the reverse-scope projections use.
1015
1119
  private def polymorphic_arms_clause(table, arms)
1016
- pk_column = TableColumn.from_symbol_keys(name: table.primary_key)
1017
-
1018
1120
  queries = arms.map do |arm|
1019
- query = QueryAst::Select.new
1020
- query.from(table.name)
1021
- query.select([pk_column])
1022
-
1023
1121
  if arm.path
1024
- build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
1122
+ arm_pk_query(table).tap do |query|
1123
+ build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
1124
+ end
1025
1125
  else
1026
- # The arm's target is scoped without a join path of its own, so probe
1027
- # its id set instead of joining up to a scoped ancestor. The type
1028
- # column still has to be constrained: the foreign key alone cannot tell
1029
- # this arm's rows from another arm's (record_id=1 may be any type).
1030
- target = table_by_name.fetch(arm.relation.table_name)
1031
- query.where QueryAst::WhereClause.new(
1032
- column_name: arm.relation.foreign_key,
1033
- operator: :in_subquery,
1034
- value: QueryAst::SelectSubquery.new(
1035
- query: project_query_to(arm.target_query, target.primary_key)
1036
- )
1037
- )
1038
- query.where QueryAst::WhereClause.new(
1039
- column_name: arm.relation.foreign_type,
1040
- operator: :eq,
1041
- value: [arm.relation.type_value]
1042
- )
1126
+ target_ids_arm_query(table, arm.relation, arm.target_query)
1043
1127
  end
1044
-
1045
- query
1046
1128
  end
1047
1129
 
1130
+ pk_union_clause(table, queries)
1131
+ end
1132
+
1133
+ private def pk_union_clause(table, arm_queries)
1048
1134
  QueryAst::WhereClause.new(
1049
1135
  column_name: table.primary_key,
1050
1136
  operator: :in_subquery,
1051
- value: QueryAst::UnionSubquery.new(queries: queries)
1137
+ value: QueryAst::UnionSubquery.new(queries: arm_queries)
1138
+ )
1139
+ end
1140
+
1141
+ private def arm_pk_query(table)
1142
+ QueryAst::Select.new.tap do |query|
1143
+ query.from(table.name)
1144
+ query.select([TableColumn.from_symbol_keys(name: table.primary_key)])
1145
+ end
1146
+ end
1147
+
1148
+ # The arm's target is scoped without a join path of its own, so probe its id
1149
+ # set instead of joining up to a scoped ancestor. The type column still has
1150
+ # to be constrained: the foreign key alone cannot tell this arm's rows from
1151
+ # another arm's (record_id=1 may be any type).
1152
+ private def target_ids_arm_query(table, relation, target_query)
1153
+ target = table_by_name.fetch(relation.table_name)
1154
+ arm_pk_query(table).tap do |query|
1155
+ query.where QueryAst::WhereClause.new(
1156
+ column_name: relation.foreign_key,
1157
+ operator: :in_subquery,
1158
+ value: QueryAst::SelectSubquery.new(
1159
+ query: project_query_to(target_query, target.primary_key)
1160
+ )
1161
+ )
1162
+ query.where polymorphic_type_clause(relation)
1163
+ end
1164
+ end
1165
+
1166
+ private def polymorphic_type_clause(relation)
1167
+ QueryAst::WhereClause.new(
1168
+ column_name: relation.foreign_type,
1169
+ operator: :eq,
1170
+ value: [relation.type_value]
1052
1171
  )
1053
1172
  end
1054
1173
 
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "1.1.3"
4
+ VERSION = "1.1.5"
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: 1.1.3
4
+ version: 1.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -47,6 +47,7 @@ extensions:
47
47
  - ext/exwiw/ext_json/extconf.rb
48
48
  extra_rdoc_files: []
49
49
  files:
50
+ - AGENTS.md
50
51
  - CHANGELOG.md
51
52
  - LICENSE.txt
52
53
  - README.md