exwiw 1.1.4 → 1.1.6

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.
@@ -26,6 +26,25 @@ module Exwiw
26
26
  end
27
27
  end
28
28
 
29
+ # The ID spaces whose values `query` filters by, anywhere in it.
30
+ def self.id_spaces(query)
31
+ where_clauses = query.where_clauses +
32
+ query.join_clauses.flat_map { |j| j.where_clauses + j.base_where_clauses }
33
+
34
+ where_clauses.flat_map do |where_clause|
35
+ next [] unless where_clause.is_a?(WhereClause)
36
+
37
+ own = where_clause.is_a?(ScopeWhereClause) ? [where_clause.id_space] : []
38
+ nested =
39
+ case where_clause.value
40
+ when SelectSubquery then id_spaces(where_clause.value.query)
41
+ when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
42
+ else []
43
+ end
44
+ own + nested
45
+ end.uniq
46
+ end
47
+
29
48
  class JoinClause
30
49
  # `where_clauses` is compiled against this join's join_table_name (the
31
50
  # joined-to table). `base_where_clauses`, on the other hand, is compiled
@@ -70,6 +89,12 @@ module Exwiw
70
89
  end
71
90
  end
72
91
 
92
+ # A scope-column filter. It compiles like any WhereClause; `id_space` records
93
+ # which ID space its values come from, so a query mixing spaces can be found.
94
+ ScopeWhereClause = Class.new(WhereClause) do
95
+ attr_accessor :id_space
96
+ end
97
+
73
98
  # Resolves a set of values on `where_column` to the rows' `select_column`
74
99
  # via a nested SELECT. Used as the `value` of a WhereClause whose operator
75
100
  # is `:in_subquery`, so a non primary-key `ids_field` can filter related
@@ -12,25 +12,57 @@ module Exwiw
12
12
  new(table_name, table_by_name, dump_target, logger).scope_category
13
13
  end
14
14
 
15
- # Scope-column mode is active when EITHER the named `--target-table` declares a
16
- # per-table `scope_column` (the preferred trigger: the target is then scoped
17
- # like any other table — its `--ids` are scope-column values, not primary
18
- # keys), OR the deprecated `--scope-column` flag is set (a global column with no
19
- # target). In both cases every table is filtered by a shared column instead of
20
- # being anchored on one named target's primary key.
15
+ # Scope-column mode is active when the named `--target-table` declares a
16
+ # per-table `scope_column` (the target is then scoped like any other table,
17
+ # and its `--ids` are scope-column values, not primary keys); when no target is
18
+ # named but `--ids` are given and some table declares a `scope_column`; or when
19
+ # the deprecated `--scope-column` flag is set (a global column with no target).
20
+ # In each case every table is filtered by a shared column instead of being
21
+ # anchored on one named target's primary key.
21
22
  def self.scope_mode?(table_by_name, dump_target)
22
23
  return true unless dump_target.scope_column.nil?
23
- return false if dump_target.table_name.nil?
24
+ if dump_target.table_name.nil?
25
+ return ids_given?(dump_target) && scope_column_declared?(table_by_name)
26
+ end
24
27
 
25
28
  target = table_by_name[dump_target.table_name]
26
29
  !!(target && target.respond_to?(:scope_column) && target.scope_column)
27
30
  end
28
31
 
32
+ def self.ids_given?(dump_target)
33
+ dump_target.ids.each_value.any?(&:any?)
34
+ end
35
+
36
+ # An ignore:true table is not extracted, so its declaration cannot anchor the
37
+ # scope.
38
+ def self.scope_column_declared?(table_by_name)
39
+ table_by_name.each_value.any? do |table|
40
+ !table.ignore && table.respond_to?(:scope_column) && !table.scope_column.nil?
41
+ end
42
+ end
43
+
44
+ # `--ids` without `--target-table` (and without the deprecated flag) selects
45
+ # scope-column mode, which needs a declared `scope_column` to start from.
46
+ def self.validate_ids_anchor!(table_by_name, dump_target)
47
+ return unless dump_target.table_name.nil? && dump_target.scope_column.nil?
48
+ return unless ids_given?(dump_target)
49
+ return if scope_column_declared?(table_by_name)
50
+
51
+ raise ArgumentError,
52
+ "--ids was given without --target-table, but no table in the schema declares " \
53
+ "`scope_column` (tables marked `ignore: true` are not counted). Either name the table " \
54
+ "the ids belong to with --target-table (single-target mode), or declare " \
55
+ "`scope_column: <column>` on the tables to filter by it (scope-column mode)."
56
+ end
57
+
29
58
  # Strict pre-flight: abort if any extractable table cannot be scoped (scope
30
59
  # mode), or declares a `batch_scope` its scoping shape cannot be sliced by
31
60
  # (both modes) — before any output is written. `tables` is the set of
32
61
  # dumpable configs (ignore:true tables are skipped — they are not extracted).
33
62
  def self.validate_scope!(tables, table_by_name, dump_target, logger)
63
+ validate_ids_anchor!(table_by_name, dump_target)
64
+ validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
65
+
34
66
  # Unscopable is reported before a bad batch_scope shape — it is the more
35
67
  # fundamental problem.
36
68
  if scope_mode?(table_by_name, dump_target)
@@ -57,6 +89,9 @@ module Exwiw
57
89
  "add a belongs_to path to a table that carries the scope column, mark it " \
58
90
  "`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
59
91
  end
92
+
93
+ validate_id_space_values!(tables, table_by_name, dump_target, logger)
94
+ validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
60
95
  end
61
96
 
62
97
  tables.reject(&:ignore).each do |table|
@@ -66,12 +101,85 @@ module Exwiw
66
101
  end
67
102
  end
68
103
 
104
+ # Only scope-column mode on the SQL adapters can filter by a named ID space.
105
+ def self.validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
106
+ named = dump_target.named_id_spaces
107
+ return if named.empty?
108
+
109
+ given = named.map { |space| "'#{space}'" }.join(', ')
110
+ # The CLI rejects this up front; this covers callers that build a Runner directly.
111
+ if tables.any? { |table| table.is_a?(MongodbCollectionConfig) }
112
+ raise ArgumentError,
113
+ "--ids were given for the ID space(s) #{given}, but the mongodb adapter supports " \
114
+ "only the default ID space (--ids without a `<id_space>=` prefix)."
115
+ end
116
+
117
+ return if scope_mode?(table_by_name, dump_target)
118
+
119
+ raise ArgumentError,
120
+ "--ids were given for the ID space(s) #{given}, but --target-table " \
121
+ "'#{dump_target.table_name}' declares no scope_column, so the run is single-target mode, " \
122
+ "which uses only the default ID space. Name a table that declares a scope_column as " \
123
+ "--target-table, or drop the named --ids."
124
+ end
125
+
126
+ # Every ID space a scope_column belongs to must be given values,
127
+ # and every ID space given values must be used by some table.
128
+ def self.validate_id_space_values!(tables, table_by_name, dump_target, logger)
129
+ tables_by_space =
130
+ tables.reject(&:ignore).each_with_object({}) do |table, hash|
131
+ space = new(table.name, table_by_name, dump_target, logger).declared_id_space
132
+ (hash[space] ||= []) << table.name if space
133
+ end
134
+
135
+ missing = tables_by_space.keys.select { |space| dump_target.ids_for(space).empty? }.sort
136
+ if missing.any?
137
+ details = missing.map do |space|
138
+ flag = space == DEFAULT_ID_SPACE ? "--ids=<ids>" : "--ids=#{space}=<ids>"
139
+ "'#{space}' (#{tables_by_space[space].sort.join(', ')}; pass #{flag})"
140
+ end
141
+ raise ArgumentError,
142
+ "scope-column mode: no ids were given for the ID space(s) that these tables are " \
143
+ "filtered by: #{details.join('; ')}."
144
+ end
145
+
146
+ unused = dump_target.ids.keys.reject { |space| tables_by_space.key?(space) }.sort
147
+ if unused.any?
148
+ raise ArgumentError,
149
+ "scope-column mode: ids were given for the ID space(s) #{unused.map { |space| "'#{space}'" }.join(', ')}, " \
150
+ "but no table declares a scope_column in them (a table without `id_space` is in " \
151
+ "'#{DEFAULT_ID_SPACE}'). Check the ID space name, or declare `id_space` on the tables it scopes."
152
+ end
153
+ end
154
+
155
+ # A table is filtered by the values of one ID space only. Without this check
156
+ # the belongs_to walk would silently settle on the nearest scoped table and
157
+ # ignore a route into another space.
158
+ def self.validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
159
+ return if dump_target.ids.size < 2
160
+
161
+ mixed =
162
+ tables.reject(&:ignore).filter_map do |table|
163
+ spaces = new(table.name, table_by_name, dump_target, logger).reached_id_spaces
164
+ "#{table.name} (#{spaces.join(', ')})" if spaces.size > 1
165
+ end
166
+ return if mixed.empty?
167
+
168
+ raise ArgumentError,
169
+ "scope-column mode: #{mixed.size} table(s) reach more than one ID space: #{mixed.sort.join(', ')}. " \
170
+ "A table must be scoped by the values of a single ID space; set `ignore: true` on the " \
171
+ "belongs_to (or remove the reverse_scope arm) that leads into the other space, or declare " \
172
+ "`scope_column:` on the table to filter it directly."
173
+ end
174
+
69
175
  attr_reader :table_name, :table_by_name, :dump_target
70
176
 
71
177
  # The multiple constrained referencers (comma-joined) that made the
72
178
  # automatic reverse detection step aside, when it did.
73
179
  attr_reader :ambiguous_referencers
74
180
 
181
+ attr_reader :narrowed_by_automatic_reverse
182
+
75
183
  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
184
  @table_name = table_name
77
185
  @table_by_name = table_by_name
@@ -103,6 +211,7 @@ module Exwiw
103
211
  # Deep-chain warnings already emitted, keyed by chain path and shared (the
104
212
  # same Hash) across every recursive build under one top-level run.
105
213
  @deep_chain_warned = deep_chain_warned || {}
214
+ @narrowed_by_automatic_reverse = false
106
215
  end
107
216
 
108
217
  def run
@@ -110,8 +219,15 @@ module Exwiw
110
219
 
111
220
  return build_scoped(table) if scope_mode?
112
221
 
113
- where_clauses = build_where_clauses(table, dump_target)
114
- join_clauses = build_join_clauses(table, table_by_name, dump_target)
222
+ arm_queries = target_polymorphic_arm_queries(table)
223
+ if arm_queries
224
+ where_clauses = [pk_union_clause(table, arm_queries)]
225
+ where_clauses.push(table.filter) if table.filter
226
+ join_clauses = []
227
+ else
228
+ where_clauses = build_where_clauses(table, dump_target)
229
+ join_clauses = build_join_clauses(table, table_by_name, dump_target)
230
+ end
115
231
 
116
232
  # Reverse / "referenced_by" extraction. A table with no belongs_to path to
117
233
  # the dump target produces no where/join clauses and would otherwise dump
@@ -126,7 +242,10 @@ module Exwiw
126
242
  table.name != dump_target.table_name &&
127
243
  where_clauses.empty? && join_clauses.empty?
128
244
  reverse_clause = build_referenced_by_clause(table)
129
- where_clauses.push(reverse_clause) if reverse_clause
245
+ if reverse_clause
246
+ where_clauses.push(reverse_clause)
247
+ @narrowed_by_automatic_reverse = !explicit_reverse_scope?(table)
248
+ end
130
249
  end
131
250
 
132
251
  # Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
@@ -141,7 +260,7 @@ module Exwiw
141
260
  if parent_clause
142
261
  where_clauses.push(parent_clause)
143
262
  elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
144
- scopable_parent_candidates(table).size > 1
263
+ (scopable_parent_candidates(table).size > 1 || polymorphic_parent_groups(table).size > 1)
145
264
  @logger.warn(
146
265
  " #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
147
266
  "pick one unambiguously, so it is dumped in full. If this is intended, set " \
@@ -184,63 +303,60 @@ module Exwiw
184
303
  # the path is 1, it's impossible case
185
304
  return [] if path_tables.size < 2
186
305
 
187
- join_clauses = []
188
-
189
- path_tables.each_cons(2) do |from_table_name, to_table_name|
306
+ path_tables.each_cons(2).map do |from_table_name, to_table_name|
190
307
  from_table = table_by_name[from_table_name]
191
308
  to_table = table_by_name[to_table_name]
309
+ build_join_clause(from_table, to_table, from_table.belongs_to(to_table_name))
310
+ end
311
+ end
192
312
 
193
- relation = from_table.belongs_to(to_table_name)
313
+ private def build_join_clause(from_table, to_table, relation)
314
+ join_clause = QueryAst::JoinClause.new(
315
+ base_table_name: from_table.name,
316
+ foreign_key: relation.foreign_key,
317
+ join_table_name: to_table.name,
318
+ primary_key: to_table.primary_key,
319
+ where_clauses: [],
320
+ base_where_clauses: []
321
+ )
194
322
 
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: []
323
+ # When this hop itself is a polymorphic belongs_to (e.g. comments
324
+ # polymorphically belongs_to posts as commentable), the type column
325
+ # (foreign_type) lives on the source table (from_table = base_table_name).
326
+ # The foreign key alone is not enough — a value like reviewable_id=1 can
327
+ # collide with rows of another model — so add the type condition to
328
+ # base_where_clauses to narrow down the source table.
329
+ if relation.polymorphic?
330
+ join_clause.base_where_clauses.push QueryAst::WhereClause.new(
331
+ column_name: relation.foreign_type,
332
+ operator: :eq,
333
+ value: [relation.type_value]
202
334
  )
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,
335
+ end
336
+ relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
337
+ if relation_to_dump_target
338
+ join_clause.where_clauses.push dump_target_fk_clause(relation_to_dump_target.foreign_key)
339
+
340
+ # When the intermediate table polymorphically belongs_to the dump
341
+ # target, also add the type column (foreign_type) to the join
342
+ # condition. The type column lives on to_table (= join_table_name), so
343
+ # it rides on the existing mechanism where a JoinClause's where_clauses
344
+ # are compiled against join_table_name.
345
+ if relation_to_dump_target.polymorphic?
346
+ join_clause.where_clauses.push QueryAst::WhereClause.new(
347
+ column_name: relation_to_dump_target.foreign_type,
213
348
  operator: :eq,
214
- value: [relation.type_value]
349
+ value: [relation_to_dump_target.type_value]
215
350
  )
216
351
  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
352
+ end
239
353
 
240
- join_clauses.push(join_clause)
354
+ # Add filter from intermediate table to join clause
355
+ if to_table.filter
356
+ join_clause.where_clauses.push to_table.filter
241
357
  end
242
358
 
243
- join_clauses
359
+ join_clause
244
360
  end
245
361
 
246
362
  # Builds a `pk IN (SELECT child.fk FROM <child extraction query>)` clause
@@ -419,6 +535,14 @@ module Exwiw
419
535
  private def build_belongs_to_scoped_clause(table)
420
536
  candidates = scopable_parent_candidates(table)
421
537
 
538
+ # Plain parents take precedence over polymorphic ones: polymorphic parents
539
+ # are consulted only when no plain parent is scopable. Multiple independent
540
+ # polymorphic associations are as ambiguous as multiple plain parents.
541
+ if candidates.empty?
542
+ groups = polymorphic_parent_groups(table)
543
+ return groups.size == 1 ? pk_union_clause(table, groups.first) : nil
544
+ end
545
+
422
546
  # Only the unambiguous single-parent case. Multiple scopable parents would
423
547
  # need their subqueries combined (not supported); fall back to unscopable.
424
548
  if candidates.size != 1
@@ -446,6 +570,21 @@ module Exwiw
446
570
  )
447
571
  end
448
572
 
573
+ private def polymorphic_parent_groups(table)
574
+ @polymorphic_parent_groups ||= {}
575
+ return @polymorphic_parent_groups[table.name] if @polymorphic_parent_groups.key?(table.name)
576
+
577
+ @polymorphic_parent_groups[table.name] =
578
+ if table.primary_key.nil?
579
+ []
580
+ else
581
+ table.belongs_tos.select(&:polymorphic?).group_by(&:foreign_type).filter_map do |_, relations|
582
+ arm_queries = relations.filter_map { |relation| scoped_target_arm_query(table, relation) }
583
+ arm_queries unless arm_queries.empty?
584
+ end
585
+ end
586
+ end
587
+
449
588
  # The scopable belongs_to parents of `table`: each non-polymorphic parent
450
589
  # whose own extraction query comes out constrained, paired with the relation
451
590
  # and that query. Shared by build_belongs_to_scoped_clause (which requires
@@ -497,7 +636,7 @@ module Exwiw
497
636
  clauses.push Exwiw::QueryAst::WhereClause.new(
498
637
  column_name: dump_target.ids_field || table.primary_key,
499
638
  operator: :eq,
500
- value: dump_target.ids
639
+ value: dump_target.default_ids
501
640
  )
502
641
 
503
642
  return clauses
@@ -539,7 +678,7 @@ module Exwiw
539
678
  return Exwiw::QueryAst::WhereClause.new(
540
679
  column_name: foreign_key,
541
680
  operator: :eq,
542
- value: dump_target.ids
681
+ value: dump_target.default_ids
543
682
  )
544
683
  end
545
684
 
@@ -551,11 +690,58 @@ module Exwiw
551
690
  table_name: target.name,
552
691
  select_column: target.primary_key,
553
692
  where_column: dump_target.ids_field,
554
- where_values: dump_target.ids
693
+ where_values: dump_target.default_ids
555
694
  )
556
695
  )
557
696
  end
558
697
 
698
+ # Single-target counterpart of #scoped_arms.
699
+ private def target_polymorphic_arm_queries(table)
700
+ return nil if table.name == dump_target.table_name
701
+
702
+ first_hop =
703
+ if table.belongs_to(dump_target.table_name)
704
+ dump_target.table_name
705
+ else
706
+ find_path_to_dump_target(table, table_by_name, dump_target)[1]
707
+ end
708
+ return nil if first_hop.nil?
709
+
710
+ sibling_arms = polymorphic_sibling_arms(table, first_hop)
711
+ return nil if sibling_arms.nil?
712
+
713
+ arm_queries = sibling_arms.filter_map { |relation| target_arm_query(table, relation) }
714
+ @logger.debug(
715
+ " #{table.name} reaches the dump target through #{arm_queries.size} of " \
716
+ "#{sibling_arms.size} polymorphic '#{sibling_arms.first.foreign_type}' arm(s)."
717
+ )
718
+ # A lone arm is always the one the shortest path leaves through, which the
719
+ # single-path build already emits as a plain JOIN.
720
+ arm_queries.size >= 2 ? arm_queries : nil
721
+ end
722
+
723
+ private def target_arm_query(table, relation)
724
+ if relation.table_name == dump_target.table_name
725
+ return arm_pk_query(table).tap do |query|
726
+ query.where dump_target_fk_clause(relation.foreign_key)
727
+ query.where polymorphic_type_clause(relation)
728
+ end
729
+ end
730
+
731
+ target = table_by_name[relation.table_name]
732
+ return nil if target.nil?
733
+
734
+ path = find_path_to_dump_target(target, table_by_name, dump_target)
735
+ if path.any? && !path.include?(table.name)
736
+ return arm_pk_query(table).tap do |query|
737
+ query.join(build_join_clause(table, target, relation))
738
+ build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
739
+ end
740
+ end
741
+
742
+ scoped_target_arm_query(table, relation)
743
+ end
744
+
559
745
  private def find_path_to_dump_target(table, table_by_name, dump_target)
560
746
  return [] if table.name == dump_target.table_name
561
747
 
@@ -592,7 +778,9 @@ module Exwiw
592
778
  # ------------------------------------------------------------------
593
779
 
594
780
  private def scope_mode?
595
- self.class.scope_mode?(table_by_name, dump_target)
781
+ return @scope_mode if defined?(@scope_mode)
782
+
783
+ @scope_mode = self.class.scope_mode?(table_by_name, dump_target)
596
784
  end
597
785
 
598
786
  # Classifier used by validate_scope! and mirrored by build_scoped below.
@@ -607,6 +795,27 @@ module Exwiw
607
795
  :unscopable
608
796
  end
609
797
 
798
+ # The ID space this table's scope column belongs to, or nil when it has none.
799
+ # A scope_exempt table is dumped in full, but tables reaching it through
800
+ # belongs_to are still filtered by its scope column, so its ID space counts.
801
+ def declared_id_space
802
+ table = table_by_name.fetch(table_name)
803
+ return nil unless directly_scoped?(table)
804
+
805
+ id_space_of(table)
806
+ end
807
+
808
+ # The ID spaces a table that is not directly scoped can be scoped through:
809
+ # those of the scoped tables its belongs_to walk can stop at, and those its
810
+ # built query filters by (polymorphic arms, reverse_scope, referenced-by and
811
+ # the parent cascade).
812
+ def reached_id_spaces
813
+ table = table_by_name.fetch(table_name)
814
+ return [] if scope_exempt?(table) || directly_scoped?(table)
815
+
816
+ (belongs_to_reachable_id_spaces(table) | QueryAst.id_spaces(run)).sort
817
+ end
818
+
610
819
  # True when this table may still attempt the forward "scope via a scoped
611
820
  # belongs_to parent" rescue: it is not already on the forward-resolution
612
821
  # path, so descending into its parent cannot revisit it (a belongs_to cycle).
@@ -711,15 +920,43 @@ module Exwiw
711
920
  table.columns.any? { |c| c.name == column }
712
921
  end
713
922
 
923
+ private def id_space_of(table)
924
+ table.id_space || DEFAULT_ID_SPACE
925
+ end
926
+
714
927
  private def scope_where_clause(table)
715
928
  batch_clause = batch_ids_clause(table)
716
929
  return batch_clause if batch_clause
717
930
 
718
- Exwiw::QueryAst::WhereClause.new(
931
+ Exwiw::QueryAst::ScopeWhereClause.new(
719
932
  column_name: resolved_scope_column(table),
720
933
  operator: :eq,
721
- value: dump_target.ids
722
- )
934
+ value: dump_target.ids_for(id_space_of(table))
935
+ ).tap { |clause| clause.id_space = id_space_of(table) }
936
+ end
937
+
938
+ # The ID spaces of every directly scoped table the belongs_to walk of
939
+ # #find_path_to_scoped could stop at, not only the nearest one.
940
+ private def belongs_to_reachable_id_spaces(table)
941
+ visited = Set[table.name]
942
+ queue = [table]
943
+ spaces = Set.new
944
+
945
+ until queue.empty?
946
+ current = queue.shift
947
+ current.belongs_tos.each do |relation|
948
+ next_table = table_by_name[relation.table_name]
949
+ next if next_table.nil? || !visited.add?(next_table.name)
950
+
951
+ if directly_scoped?(next_table)
952
+ spaces << id_space_of(next_table)
953
+ else
954
+ queue.push(next_table)
955
+ end
956
+ end
957
+ end
958
+
959
+ spaces
723
960
  end
724
961
 
725
962
  # This batch's ids, in place of the batch table's scope filter. nil when the
@@ -935,15 +1172,33 @@ module Exwiw
935
1172
  path = find_path_to_scoped(table, first_relation: relation)
936
1173
  return ScopedArm.new(relation: relation, path: path) if path.size >= 2
937
1174
 
1175
+ target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
1176
+ target_query && ScopedArm.new(relation: relation, target_query: target_query)
1177
+ end
1178
+
1179
+ private def scoped_target_arm_query(table, relation)
1180
+ # A parent kept only by the automatic referenced_by exists to keep a child's
1181
+ # foreign key valid. Single-target mode extracts only what sits under the
1182
+ # dump target, so such a parent must not pull in the rows that point at it.
1183
+ target_query = scoped_target_query(table, relation, allow_automatic_reverse: scope_mode?)
1184
+ target_query && target_ids_arm_query(table, relation, target_query)
1185
+ end
1186
+
1187
+ private def scoped_target_query(table, relation, allow_automatic_reverse:)
938
1188
  target = table_by_name[relation.table_name]
939
1189
  return nil if target.nil? || target.primary_key.nil?
940
1190
  # Descending into a table already being resolved would close a cycle.
941
1191
  return nil if target.name == table.name || @forward_path.include?(target.name)
942
1192
 
943
- target_query = self.class.run(
1193
+ # Built the same way as the target's own extraction, so the arm never keeps
1194
+ # a row pointing at a target row the dump leaves out.
1195
+ builder = self.class.new(
944
1196
  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
1197
+ forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
946
1198
  )
1199
+ target_query = builder.run
1200
+ return nil if !allow_automatic_reverse && builder.narrowed_by_automatic_reverse
1201
+
947
1202
  # An unconstrained target selects every id, i.e. does not scope the arm at
948
1203
  # all; dropping the arm is the safe outcome.
949
1204
  return nil unless target_query.where_clauses.any? || target_query.join_clauses.any?
@@ -964,7 +1219,7 @@ module Exwiw
964
1219
  return nil
965
1220
  end
966
1221
 
967
- ScopedArm.new(relation: relation, target_query: target_query)
1222
+ target_query
968
1223
  end
969
1224
 
970
1225
  # The polymorphic belongs_to arms sharing the `foreign_type` of the relation
@@ -1019,42 +1274,57 @@ module Exwiw
1019
1274
  # (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
1020
1275
  # comparison — the same guard the reverse-scope projections use.
1021
1276
  private def polymorphic_arms_clause(table, arms)
1022
- pk_column = TableColumn.from_symbol_keys(name: table.primary_key)
1023
-
1024
1277
  queries = arms.map do |arm|
1025
- query = QueryAst::Select.new
1026
- query.from(table.name)
1027
- query.select([pk_column])
1028
-
1029
1278
  if arm.path
1030
- build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
1279
+ arm_pk_query(table).tap do |query|
1280
+ build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
1281
+ end
1031
1282
  else
1032
- # The arm's target is scoped without a join path of its own, so probe
1033
- # its id set instead of joining up to a scoped ancestor. The type
1034
- # column still has to be constrained: the foreign key alone cannot tell
1035
- # this arm's rows from another arm's (record_id=1 may be any type).
1036
- target = table_by_name.fetch(arm.relation.table_name)
1037
- query.where QueryAst::WhereClause.new(
1038
- column_name: arm.relation.foreign_key,
1039
- operator: :in_subquery,
1040
- value: QueryAst::SelectSubquery.new(
1041
- query: project_query_to(arm.target_query, target.primary_key)
1042
- )
1043
- )
1044
- query.where QueryAst::WhereClause.new(
1045
- column_name: arm.relation.foreign_type,
1046
- operator: :eq,
1047
- value: [arm.relation.type_value]
1048
- )
1283
+ target_ids_arm_query(table, arm.relation, arm.target_query)
1049
1284
  end
1050
-
1051
- query
1052
1285
  end
1053
1286
 
1287
+ pk_union_clause(table, queries)
1288
+ end
1289
+
1290
+ private def pk_union_clause(table, arm_queries)
1054
1291
  QueryAst::WhereClause.new(
1055
1292
  column_name: table.primary_key,
1056
1293
  operator: :in_subquery,
1057
- value: QueryAst::UnionSubquery.new(queries: queries)
1294
+ value: QueryAst::UnionSubquery.new(queries: arm_queries)
1295
+ )
1296
+ end
1297
+
1298
+ private def arm_pk_query(table)
1299
+ QueryAst::Select.new.tap do |query|
1300
+ query.from(table.name)
1301
+ query.select([TableColumn.from_symbol_keys(name: table.primary_key)])
1302
+ end
1303
+ end
1304
+
1305
+ # The arm's target is scoped without a join path of its own, so probe its id
1306
+ # set instead of joining up to a scoped ancestor. The type column still has
1307
+ # to be constrained: the foreign key alone cannot tell this arm's rows from
1308
+ # another arm's (record_id=1 may be any type).
1309
+ private def target_ids_arm_query(table, relation, target_query)
1310
+ target = table_by_name.fetch(relation.table_name)
1311
+ arm_pk_query(table).tap do |query|
1312
+ query.where QueryAst::WhereClause.new(
1313
+ column_name: relation.foreign_key,
1314
+ operator: :in_subquery,
1315
+ value: QueryAst::SelectSubquery.new(
1316
+ query: project_query_to(target_query, target.primary_key)
1317
+ )
1318
+ )
1319
+ query.where polymorphic_type_clause(relation)
1320
+ end
1321
+ end
1322
+
1323
+ private def polymorphic_type_clause(relation)
1324
+ QueryAst::WhereClause.new(
1325
+ column_name: relation.foreign_type,
1326
+ operator: :eq,
1327
+ value: [relation.type_value]
1058
1328
  )
1059
1329
  end
1060
1330