exwiw 1.1.5 → 1.1.7

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.
@@ -6,31 +6,77 @@ module Exwiw
6
6
  new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, allow_declared_reverse: allow_declared_reverse, forward_path: forward_path, reverse_path: reverse_path, deep_chain_warned: deep_chain_warned, batch_ids: batch_ids).run
7
7
  end
8
8
 
9
+ # The self-referencing foreign keys whose ancestors a narrowed `table` also
10
+ # keeps. A polymorphic one is not supported: following it would also need its
11
+ # type column checked at every step.
12
+ def self.self_ancestor_links(table)
13
+ return [] if table.primary_key.nil?
14
+
15
+ table.belongs_tos.filter_map do |relation|
16
+ next unless relation.table_name == table.name
17
+ next if relation.polymorphic?
18
+
19
+ QueryAst::AncestorLink.new(foreign_key: relation.foreign_key, references: relation.references)
20
+ end.uniq
21
+ end
22
+
9
23
  # Scope-column mode classification for a single table. One of
10
24
  # :exempt / :direct / :via_path / :referenced_by / :via_scoped_parent / :unscopable.
11
25
  def self.scope_category(table_name, table_by_name, dump_target, logger)
12
26
  new(table_name, table_by_name, dump_target, logger).scope_category
13
27
  end
14
28
 
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.
29
+ # Scope-column mode is active when the named `--target-table` declares a
30
+ # per-table `scope_column` (the target is then scoped like any other table,
31
+ # and its `--ids` are scope-column values, not primary keys); when no target is
32
+ # named but `--ids` are given and some table declares a `scope_column`; or when
33
+ # the deprecated `--scope-column` flag is set (a global column with no target).
34
+ # In each case every table is filtered by a shared column instead of being
35
+ # anchored on one named target's primary key.
21
36
  def self.scope_mode?(table_by_name, dump_target)
22
37
  return true unless dump_target.scope_column.nil?
23
- return false if dump_target.table_name.nil?
38
+ if dump_target.table_name.nil?
39
+ return ids_given?(dump_target) && scope_column_declared?(table_by_name)
40
+ end
24
41
 
25
42
  target = table_by_name[dump_target.table_name]
26
43
  !!(target && target.respond_to?(:scope_column) && target.scope_column)
27
44
  end
28
45
 
46
+ def self.ids_given?(dump_target)
47
+ dump_target.ids.each_value.any?(&:any?)
48
+ end
49
+
50
+ # An ignore:true table is not extracted, so its declaration cannot anchor the
51
+ # scope.
52
+ def self.scope_column_declared?(table_by_name)
53
+ table_by_name.each_value.any? do |table|
54
+ !table.ignore && table.respond_to?(:scope_column) && !table.scope_column.nil?
55
+ end
56
+ end
57
+
58
+ # `--ids` without `--target-table` (and without the deprecated flag) selects
59
+ # scope-column mode, which needs a declared `scope_column` to start from.
60
+ def self.validate_ids_anchor!(table_by_name, dump_target)
61
+ return unless dump_target.table_name.nil? && dump_target.scope_column.nil?
62
+ return unless ids_given?(dump_target)
63
+ return if scope_column_declared?(table_by_name)
64
+
65
+ raise ArgumentError,
66
+ "--ids was given without --target-table, but no table in the schema declares " \
67
+ "`scope_column` (tables marked `ignore: true` are not counted). Either name the table " \
68
+ "the ids belong to with --target-table (single-target mode), or declare " \
69
+ "`scope_column: <column>` on the tables to filter by it (scope-column mode)."
70
+ end
71
+
29
72
  # Strict pre-flight: abort if any extractable table cannot be scoped (scope
30
73
  # mode), or declares a `batch_scope` its scoping shape cannot be sliced by
31
74
  # (both modes) — before any output is written. `tables` is the set of
32
75
  # dumpable configs (ignore:true tables are skipped — they are not extracted).
33
76
  def self.validate_scope!(tables, table_by_name, dump_target, logger)
77
+ validate_ids_anchor!(table_by_name, dump_target)
78
+ validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
79
+
34
80
  # Unscopable is reported before a bad batch_scope shape — it is the more
35
81
  # fundamental problem.
36
82
  if scope_mode?(table_by_name, dump_target)
@@ -57,15 +103,91 @@ module Exwiw
57
103
  "add a belongs_to path to a table that carries the scope column, mark it " \
58
104
  "`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
59
105
  end
106
+
107
+ validate_id_space_values!(tables, table_by_name, dump_target, logger)
108
+ validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
60
109
  end
61
110
 
62
111
  tables.reject(&:ignore).each do |table|
63
112
  next unless table.respond_to?(:batch_scope) && table.batch_scope
64
113
 
65
- new(table.name, table_by_name, dump_target, logger).batch_scope_terminus!
114
+ builder = new(table.name, table_by_name, dump_target, logger)
115
+ builder.batch_scope_terminus!
116
+ builder.warn_batch_scope_ancestor_gaps
117
+ end
118
+ end
119
+
120
+ # Only scope-column mode on the SQL adapters can filter by a named ID space.
121
+ def self.validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
122
+ named = dump_target.named_id_spaces
123
+ return if named.empty?
124
+
125
+ given = named.map { |space| "'#{space}'" }.join(', ')
126
+ # The CLI rejects this up front; this covers callers that build a Runner directly.
127
+ if tables.any? { |table| table.is_a?(MongodbCollectionConfig) }
128
+ raise ArgumentError,
129
+ "--ids were given for the ID space(s) #{given}, but the mongodb adapter supports " \
130
+ "only the default ID space (--ids without a `<id_space>=` prefix)."
131
+ end
132
+
133
+ return if scope_mode?(table_by_name, dump_target)
134
+
135
+ raise ArgumentError,
136
+ "--ids were given for the ID space(s) #{given}, but --target-table " \
137
+ "'#{dump_target.table_name}' declares no scope_column, so the run is single-target mode, " \
138
+ "which uses only the default ID space. Name a table that declares a scope_column as " \
139
+ "--target-table, or drop the named --ids."
140
+ end
141
+
142
+ # Every ID space a scope_column belongs to must be given values,
143
+ # and every ID space given values must be used by some table.
144
+ def self.validate_id_space_values!(tables, table_by_name, dump_target, logger)
145
+ tables_by_space =
146
+ tables.reject(&:ignore).each_with_object({}) do |table, hash|
147
+ space = new(table.name, table_by_name, dump_target, logger).declared_id_space
148
+ (hash[space] ||= []) << table.name if space
149
+ end
150
+
151
+ missing = tables_by_space.keys.select { |space| dump_target.ids_for(space).empty? }.sort
152
+ if missing.any?
153
+ details = missing.map do |space|
154
+ flag = space == DEFAULT_ID_SPACE ? "--ids=<ids>" : "--ids=#{space}=<ids>"
155
+ "'#{space}' (#{tables_by_space[space].sort.join(', ')}; pass #{flag})"
156
+ end
157
+ raise ArgumentError,
158
+ "scope-column mode: no ids were given for the ID space(s) that these tables are " \
159
+ "filtered by: #{details.join('; ')}."
160
+ end
161
+
162
+ unused = dump_target.ids.keys.reject { |space| tables_by_space.key?(space) }.sort
163
+ if unused.any?
164
+ raise ArgumentError,
165
+ "scope-column mode: ids were given for the ID space(s) #{unused.map { |space| "'#{space}'" }.join(', ')}, " \
166
+ "but no table declares a scope_column in them (a table without `id_space` is in " \
167
+ "'#{DEFAULT_ID_SPACE}'). Check the ID space name, or declare `id_space` on the tables it scopes."
66
168
  end
67
169
  end
68
170
 
171
+ # A table is filtered by the values of one ID space only. Without this check
172
+ # the belongs_to walk would silently settle on the nearest scoped table and
173
+ # ignore a route into another space.
174
+ def self.validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
175
+ return if dump_target.ids.size < 2
176
+
177
+ mixed =
178
+ tables.reject(&:ignore).filter_map do |table|
179
+ spaces = new(table.name, table_by_name, dump_target, logger).reached_id_spaces
180
+ "#{table.name} (#{spaces.join(', ')})" if spaces.size > 1
181
+ end
182
+ return if mixed.empty?
183
+
184
+ raise ArgumentError,
185
+ "scope-column mode: #{mixed.size} table(s) reach more than one ID space: #{mixed.sort.join(', ')}. " \
186
+ "A table must be scoped by the values of a single ID space; set `ignore: true` on the " \
187
+ "belongs_to (or remove the reverse_scope arm) that leads into the other space, or declare " \
188
+ "`scope_column:` on the table to filter it directly."
189
+ end
190
+
69
191
  attr_reader :table_name, :table_by_name, :dump_target
70
192
 
71
193
  # The multiple constrained referencers (comma-joined) that made the
@@ -110,14 +232,21 @@ module Exwiw
110
232
 
111
233
  def run
112
234
  table = table_by_name.fetch(table_name)
235
+ ast = scope_mode? ? build_scoped(table) : build_single_target(table)
236
+ with_self_ancestors(table, ast)
237
+ end
113
238
 
114
- return build_scoped(table) if scope_mode?
115
-
239
+ private def build_single_target(table)
116
240
  arm_queries = target_polymorphic_arm_queries(table)
241
+ tree_route_clauses = arm_queries ? nil : dump_target_tree_route_clauses(table)
117
242
  if arm_queries
118
243
  where_clauses = [pk_union_clause(table, arm_queries)]
119
244
  where_clauses.push(table.filter) if table.filter
120
245
  join_clauses = []
246
+ elsif tree_route_clauses
247
+ where_clauses = tree_route_clauses
248
+ where_clauses.push(table.filter) if table.filter
249
+ join_clauses = []
121
250
  else
122
251
  where_clauses = build_where_clauses(table, dump_target)
123
252
  join_clauses = build_join_clauses(table, table_by_name, dump_target)
@@ -177,6 +306,13 @@ module Exwiw
177
306
  )
178
307
  end
179
308
 
309
+ new_extraction_query(table).tap do |ast|
310
+ join_clauses.each { |join_clause| ast.join(join_clause) }
311
+ where_clauses.each { |where_clause| ast.where(where_clause) }
312
+ end
313
+ end
314
+
315
+ private def new_extraction_query(table)
180
316
  QueryAst::Select.new.tap do |ast|
181
317
  ast.from(table.name)
182
318
  if table.rails_managed?
@@ -184,8 +320,28 @@ module Exwiw
184
320
  else
185
321
  ast.select(table.columns)
186
322
  end
187
- join_clauses.each { |join_clause| ast.join(join_clause) }
188
- where_clauses.each { |where_clause| ast.where(where_clause) }
323
+ end
324
+ end
325
+
326
+ # Keeps a narrowed tree table's parent ids from dangling. The ancestors are
327
+ # not held to the table's filter or scope, since valid foreign keys win. A
328
+ # batched table is left alone: every batch would repeat the ancestors.
329
+ private def with_self_ancestors(table, ast)
330
+ return ast unless @batch_ids.nil?
331
+ return ast if ast.where_clauses.empty? && ast.join_clauses.empty?
332
+
333
+ links = self.class.self_ancestor_links(table)
334
+ return ast if links.empty?
335
+
336
+ closure = QueryAst::RecursiveAncestorSubquery.new(
337
+ base: project_query_to(ast, [table.primary_key, *links.map(&:foreign_key)].uniq),
338
+ table_name: table.name,
339
+ primary_key: table.primary_key,
340
+ links: links
341
+ )
342
+
343
+ new_extraction_query(table).tap do |widened|
344
+ widened.where(QueryAst::WhereClause.new(column_name: table.primary_key, operator: :in_subquery, value: closure))
189
345
  end
190
346
  end
191
347
 
@@ -446,22 +602,52 @@ module Exwiw
446
602
  return nil
447
603
  end
448
604
 
449
- relation, parent, parent_query = candidates.first
450
-
451
- # Project the parent's extraction query down to just its primary key — the
452
- # column this table's foreign key points at.
453
- pk_column = TableColumn.from_symbol_keys(name: parent.primary_key)
454
- projected = QueryAst::Select.new
455
- projected.from(parent_query.from_table_name)
456
- projected.select([pk_column])
457
- parent_query.join_clauses.each { |j| projected.join(j) }
458
- parent_query.where_clauses.each { |w| projected.where(w) }
605
+ relation, _parent, parent_query = candidates.first
606
+ parent_ids_clause(relation, parent_query)
607
+ end
459
608
 
609
+ # `<fk> IN (<the parent's extraction query, projected to its primary key>)`.
610
+ private def parent_ids_clause(relation, parent_query)
611
+ parent = table_by_name.fetch(relation.table_name)
460
612
  QueryAst::WhereClause.new(
461
613
  column_name: relation.foreign_key,
462
614
  operator: :in_subquery,
463
- value: QueryAst::SelectSubquery.new(query: projected)
615
+ value: QueryAst::SelectSubquery.new(query: project_query_to(parent_query, parent.primary_key))
616
+ )
617
+ end
618
+
619
+ # A JOIN through a tree table applies that table's own conditions, which its
620
+ # ancestors are exempt from, so such a route narrows by the first hop's
621
+ # extraction query instead. A batched table keeps the JOIN its batches slice.
622
+ private def route_through_tree?(table, route_tables)
623
+ return false if batched?(table)
624
+
625
+ !tree_tables_in(route_tables).empty?
626
+ end
627
+
628
+ private def tree_tables_in(table_names)
629
+ table_names.select { |name| (hop = table_by_name[name]) && self.class.self_ancestor_links(hop).any? }
630
+ end
631
+
632
+ # nil keeps the JOIN: on a cycle, or when the parent is unconstrained or
633
+ # scoped through this very table.
634
+ private def route_parent_query(table, relation)
635
+ parent = table_by_name[relation.table_name]
636
+ return nil if parent.nil? || parent.primary_key.nil?
637
+ return nil if parent.name == table.name || @forward_path.include?(parent.name)
638
+
639
+ parent_query = self.class.run(
640
+ parent.name, table_by_name, dump_target, @logger,
641
+ forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
464
642
  )
643
+ return nil unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
644
+ return nil if QueryAst.reads_table?(parent_query, table.name)
645
+
646
+ parent_query
647
+ end
648
+
649
+ private def batched?(table)
650
+ table.respond_to?(:batch_scope) && !table.batch_scope.nil?
465
651
  end
466
652
 
467
653
  private def polymorphic_parent_groups(table)
@@ -530,7 +716,7 @@ module Exwiw
530
716
  clauses.push Exwiw::QueryAst::WhereClause.new(
531
717
  column_name: dump_target.ids_field || table.primary_key,
532
718
  operator: :eq,
533
- value: dump_target.ids
719
+ value: dump_target.default_ids
534
720
  )
535
721
 
536
722
  return clauses
@@ -572,7 +758,7 @@ module Exwiw
572
758
  return Exwiw::QueryAst::WhereClause.new(
573
759
  column_name: foreign_key,
574
760
  operator: :eq,
575
- value: dump_target.ids
761
+ value: dump_target.default_ids
576
762
  )
577
763
  end
578
764
 
@@ -584,11 +770,33 @@ module Exwiw
584
770
  table_name: target.name,
585
771
  select_column: target.primary_key,
586
772
  where_column: dump_target.ids_field,
587
- where_values: dump_target.ids
773
+ where_values: dump_target.default_ids
588
774
  )
589
775
  )
590
776
  end
591
777
 
778
+ # Single-target counterpart of #path_or_parent_query_arm; nil keeps the JOIN.
779
+ private def dump_target_tree_route_clauses(table)
780
+ return nil if table.name == dump_target.table_name
781
+
782
+ relation = table.belongs_to(dump_target.table_name)
783
+ route = relation ? [dump_target.table_name] : dump_target_route_after_first_hop(table)
784
+ return nil if route.empty? || !route_through_tree?(table, route)
785
+
786
+ relation ||= table.belongs_to(route.first)
787
+ parent_query = route_parent_query(table, relation)
788
+ return nil if parent_query.nil?
789
+
790
+ [parent_ids_clause(relation, parent_query)].tap do |clauses|
791
+ clauses.push(polymorphic_type_clause(relation)) if relation.polymorphic?
792
+ end
793
+ end
794
+
795
+ private def dump_target_route_after_first_hop(table)
796
+ path = find_path_to_dump_target(table, table_by_name, dump_target)
797
+ path.size < 2 ? [] : path.drop(1) + [dump_target.table_name]
798
+ end
799
+
592
800
  # Single-target counterpart of #scoped_arms.
593
801
  private def target_polymorphic_arm_queries(table)
594
802
  return nil if table.name == dump_target.table_name
@@ -615,18 +823,25 @@ module Exwiw
615
823
  end
616
824
 
617
825
  private def target_arm_query(table, relation)
618
- if relation.table_name == dump_target.table_name
826
+ target = table_by_name[relation.table_name]
827
+ return nil if target.nil?
828
+
829
+ on_target = relation.table_name == dump_target.table_name
830
+ path = on_target ? [] : find_path_to_dump_target(target, table_by_name, dump_target)
831
+ joinable = on_target || (path.any? && !path.include?(table.name))
832
+ route = on_target ? [target.name] : path + [dump_target.table_name]
833
+ if joinable && route_through_tree?(table, route) && (parent_query = route_parent_query(table, relation))
834
+ return target_ids_arm_query(table, relation, parent_query)
835
+ end
836
+
837
+ if on_target
619
838
  return arm_pk_query(table).tap do |query|
620
839
  query.where dump_target_fk_clause(relation.foreign_key)
621
840
  query.where polymorphic_type_clause(relation)
622
841
  end
623
842
  end
624
843
 
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)
844
+ if joinable
630
845
  return arm_pk_query(table).tap do |query|
631
846
  query.join(build_join_clause(table, target, relation))
632
847
  build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
@@ -672,7 +887,9 @@ module Exwiw
672
887
  # ------------------------------------------------------------------
673
888
 
674
889
  private def scope_mode?
675
- self.class.scope_mode?(table_by_name, dump_target)
890
+ return @scope_mode if defined?(@scope_mode)
891
+
892
+ @scope_mode = self.class.scope_mode?(table_by_name, dump_target)
676
893
  end
677
894
 
678
895
  # Classifier used by validate_scope! and mirrored by build_scoped below.
@@ -687,6 +904,27 @@ module Exwiw
687
904
  :unscopable
688
905
  end
689
906
 
907
+ # The ID space this table's scope column belongs to, or nil when it has none.
908
+ # A scope_exempt table is dumped in full, but tables reaching it through
909
+ # belongs_to are still filtered by its scope column, so its ID space counts.
910
+ def declared_id_space
911
+ table = table_by_name.fetch(table_name)
912
+ return nil unless directly_scoped?(table)
913
+
914
+ id_space_of(table)
915
+ end
916
+
917
+ # The ID spaces a table that is not directly scoped can be scoped through:
918
+ # those of the scoped tables its belongs_to walk can stop at, and those its
919
+ # built query filters by (polymorphic arms, reverse_scope, referenced-by and
920
+ # the parent cascade).
921
+ def reached_id_spaces
922
+ table = table_by_name.fetch(table_name)
923
+ return [] if scope_exempt?(table) || directly_scoped?(table)
924
+
925
+ (belongs_to_reachable_id_spaces(table) | QueryAst.id_spaces(run)).sort
926
+ end
927
+
690
928
  # True when this table may still attempt the forward "scope via a scoped
691
929
  # belongs_to parent" rescue: it is not already on the forward-resolution
692
930
  # path, so descending into its parent cannot revisit it (a belongs_to cycle).
@@ -695,13 +933,7 @@ module Exwiw
695
933
  end
696
934
 
697
935
  private def build_scoped(table)
698
- ast = QueryAst::Select.new
699
- ast.from(table.name)
700
- if table.rails_managed?
701
- ast.select_all!
702
- else
703
- ast.select(table.columns)
704
- end
936
+ ast = new_extraction_query(table)
705
937
 
706
938
  # Reference/master (or rails-managed) table: export every row.
707
939
  return ast if scope_exempt?(table)
@@ -723,6 +955,10 @@ module Exwiw
723
955
  build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |join_clause| ast.join(join_clause) }
724
956
  ast.where(table.filter) if table.filter
725
957
  return ast
958
+ elsif arms.size == 1 && !arms.first.relation.polymorphic?
959
+ ast.where(parent_ids_clause(arms.first.relation, arms.first.target_query))
960
+ ast.where(table.filter) if table.filter
961
+ return ast
726
962
  elsif arms.size >= 1
727
963
  # Several polymorphic arms: INNER JOINing one of them would drop the rows
728
964
  # of every other type, so constrain this table to the UNION of the ids the
@@ -791,15 +1027,43 @@ module Exwiw
791
1027
  table.columns.any? { |c| c.name == column }
792
1028
  end
793
1029
 
1030
+ private def id_space_of(table)
1031
+ table.id_space || DEFAULT_ID_SPACE
1032
+ end
1033
+
794
1034
  private def scope_where_clause(table)
795
1035
  batch_clause = batch_ids_clause(table)
796
1036
  return batch_clause if batch_clause
797
1037
 
798
- Exwiw::QueryAst::WhereClause.new(
1038
+ Exwiw::QueryAst::ScopeWhereClause.new(
799
1039
  column_name: resolved_scope_column(table),
800
1040
  operator: :eq,
801
- value: dump_target.ids
802
- )
1041
+ value: dump_target.ids_for(id_space_of(table))
1042
+ ).tap { |clause| clause.id_space = id_space_of(table) }
1043
+ end
1044
+
1045
+ # The ID spaces of every directly scoped table the belongs_to walk of
1046
+ # #find_path_to_scoped could stop at, not only the nearest one.
1047
+ private def belongs_to_reachable_id_spaces(table)
1048
+ visited = Set[table.name]
1049
+ queue = [table]
1050
+ spaces = Set.new
1051
+
1052
+ until queue.empty?
1053
+ current = queue.shift
1054
+ current.belongs_tos.each do |relation|
1055
+ next_table = table_by_name[relation.table_name]
1056
+ next if next_table.nil? || !visited.add?(next_table.name)
1057
+
1058
+ if directly_scoped?(next_table)
1059
+ spaces << id_space_of(next_table)
1060
+ else
1061
+ queue.push(next_table)
1062
+ end
1063
+ end
1064
+ end
1065
+
1066
+ spaces
803
1067
  end
804
1068
 
805
1069
  # This batch's ids, in place of the batch table's scope filter. nil when the
@@ -891,6 +1155,30 @@ module Exwiw
891
1155
  terminus
892
1156
  end
893
1157
 
1158
+ # These gaps should reject the batch_scope like the shapes
1159
+ # #batch_scope_terminus! raises on, but only warn so that configs batching a
1160
+ # table that now keeps ancestors still run. Called once from the pre-flight,
1161
+ # after #batch_scope_terminus! accepts the shape; #batch_scope_terminus!
1162
+ # itself also runs mid-extraction.
1163
+ def warn_batch_scope_ancestor_gaps
1164
+ table = table_by_name.fetch(table_name)
1165
+
1166
+ if self.class.self_ancestor_links(table).any?
1167
+ @logger.warn(
1168
+ " #{table.name} references itself, but a batched table does not add the ancestors " \
1169
+ "of its rows (every batch would repeat them); a kept row's parent may be missing."
1170
+ )
1171
+ end
1172
+
1173
+ route = directly_scoped?(table) ? [] : scoped_arms(table).first.path.drop(1)
1174
+ tree_tables_in(route).each do |tree|
1175
+ @logger.warn(
1176
+ " #{table.name} is batched through #{tree}, which keeps the ancestors of its rows, but " \
1177
+ "the batches join #{tree} directly; #{table.name} rows of those ancestors are not extracted."
1178
+ )
1179
+ end
1180
+ end
1181
+
894
1182
  # BFS over belongs_tos to the nearest *directly scoped* ancestor. Unlike the
895
1183
  # target-mode walk, the returned path INCLUDES that ancestor: the scope column
896
1184
  # lives on the ancestor itself (not on a foreign key of the child), so the
@@ -988,7 +1276,7 @@ module Exwiw
988
1276
  else
989
1277
  sibling_arms = polymorphic_sibling_arms(table, shortest[1])
990
1278
  if sibling_arms.nil?
991
- [ScopedArm.new(relation: nil, path: shortest)]
1279
+ [path_or_parent_query_arm(table, table.belongs_to(shortest[1]), shortest, pinned: false)]
992
1280
  else
993
1281
  arms = sibling_arms.filter_map { |relation| resolve_scoped_arm(table, relation) }
994
1282
  @logger.debug(
@@ -1013,12 +1301,21 @@ module Exwiw
1013
1301
  # up referenced_by / reverse_scope / the multi-hop cascade for free.
1014
1302
  private def resolve_scoped_arm(table, relation)
1015
1303
  path = find_path_to_scoped(table, first_relation: relation)
1016
- return ScopedArm.new(relation: relation, path: path) if path.size >= 2
1304
+ return path_or_parent_query_arm(table, relation, path, pinned: true) if path.size >= 2
1017
1305
 
1018
1306
  target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
1019
1307
  target_query && ScopedArm.new(relation: relation, target_query: target_query)
1020
1308
  end
1021
1309
 
1310
+ # `pinned` keeps the relation on a JOIN arm, which only a polymorphic arm needs.
1311
+ private def path_or_parent_query_arm(table, relation, path, pinned:)
1312
+ if route_through_tree?(table, path.drop(1)) && (parent_query = route_parent_query(table, relation))
1313
+ return ScopedArm.new(relation: relation, target_query: parent_query)
1314
+ end
1315
+
1316
+ ScopedArm.new(relation: pinned ? relation : nil, path: path)
1317
+ end
1318
+
1022
1319
  private def scoped_target_arm_query(table, relation)
1023
1320
  # A parent kept only by the automatic referenced_by exists to keep a child's
1024
1321
  # foreign key valid. Single-target mode extracts only what sits under the
@@ -1150,15 +1447,8 @@ module Exwiw
1150
1447
  # to be constrained: the foreign key alone cannot tell this arm's rows from
1151
1448
  # another arm's (record_id=1 may be any type).
1152
1449
  private def target_ids_arm_query(table, relation, target_query)
1153
- target = table_by_name.fetch(relation.table_name)
1154
1450
  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
- )
1451
+ query.where parent_ids_clause(relation, target_query)
1162
1452
  query.where polymorphic_type_clause(relation)
1163
1453
  end
1164
1454
  end
@@ -1171,14 +1461,14 @@ module Exwiw
1171
1461
  )
1172
1462
  end
1173
1463
 
1174
- # Reduce an extraction query to a single-column id projection, keeping its
1175
- # joins and filters. The column is forced to a plain TableColumn so any
1176
- # masking (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
1464
+ # Reduce an extraction query to an id projection, keeping its joins and
1465
+ # filters. The columns are forced to plain TableColumns so any masking
1466
+ # (`replace_with` / `raw_sql`) configured on them cannot corrupt the id
1177
1467
  # comparison.
1178
- private def project_query_to(query, column_name)
1468
+ private def project_query_to(query, column_names)
1179
1469
  projected = QueryAst::Select.new
1180
1470
  projected.from(query.from_table_name)
1181
- projected.select([TableColumn.from_symbol_keys(name: column_name)])
1471
+ projected.select(Array(column_names).map { |name| TableColumn.from_symbol_keys(name: name) })
1182
1472
  query.join_clauses.each { |join_clause| projected.join(join_clause) }
1183
1473
  query.where_clauses.each { |where_clause| projected.where(where_clause) }
1184
1474
  projected
data/lib/exwiw/runner.rb CHANGED
@@ -203,6 +203,7 @@ module Exwiw
203
203
  AfterInsertHook.run(
204
204
  path: @after_insert_hook_path,
205
205
  cli_options: @cli_options,
206
+ dump_target: @dump_target,
206
207
  output_dir: @output_dir,
207
208
  next_idx: total_size + 1,
208
209
  output_extension: adapter.output_extension,
@@ -38,6 +38,9 @@ module Exwiw
38
38
  # different column name on this table).
39
39
  attribute :scope_exempt, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
40
40
  attribute :scope_column, optional(String), skip_serializing_if_nil: true
41
+ # The ID space (Exwiw::DumpTarget#ids) the `scope_column` values belong to.
42
+ # nil means `default`. User-configured, never generated.
43
+ attribute :id_space, optional(String), skip_serializing_if_nil: true
41
44
 
42
45
  # `reverse_scope` opts a table into multi-referencer reverse scoping (see
43
46
  # Exwiw::ReverseScope and QueryAstBuilder#build_referenced_by_clause): a
@@ -175,6 +178,7 @@ module Exwiw
175
178
  merged_table.bulk_insert_chunk_size = bulk_insert_chunk_size
176
179
  merged_table.scope_exempt = scope_exempt
177
180
  merged_table.scope_column = scope_column
181
+ merged_table.id_space = id_space
178
182
  merged_table.reverse_scope = reverse_scope
179
183
  merged_table.batch_scope = batch_scope
180
184
 
@@ -250,6 +254,19 @@ module Exwiw
250
254
  "Table '#{name}': reverse_scope.column '#{reverse_scope.column}' is not a declared column."
251
255
  end
252
256
 
257
+ if id_space
258
+ unless id_space.match?(ID_SPACE_NAME_PATTERN)
259
+ raise ArgumentError,
260
+ "Table '#{name}': id_space '#{id_space}' must start with a lowercase letter and " \
261
+ "contain only lowercase letters, digits and underscores."
262
+ end
263
+ if scope_column.nil?
264
+ raise ArgumentError,
265
+ "Table '#{name}': id_space names the ID space of the scope_column values, " \
266
+ "so it requires scope_column."
267
+ end
268
+ end
269
+
253
270
  columns.each { |column| validate_ruby_side_masking!(column) }
254
271
  end
255
272
  end
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.5"
4
+ VERSION = "1.1.7"
5
5
  end