exwiw 1.1.6 → 1.1.8

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,6 +6,20 @@ 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)
@@ -97,7 +111,9 @@ module Exwiw
97
111
  tables.reject(&:ignore).each do |table|
98
112
  next unless table.respond_to?(:batch_scope) && table.batch_scope
99
113
 
100
- 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
101
117
  end
102
118
  end
103
119
 
@@ -216,14 +232,21 @@ module Exwiw
216
232
 
217
233
  def run
218
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
219
238
 
220
- return build_scoped(table) if scope_mode?
221
-
239
+ private def build_single_target(table)
222
240
  arm_queries = target_polymorphic_arm_queries(table)
241
+ tree_route_clauses = arm_queries ? nil : dump_target_tree_route_clauses(table)
223
242
  if arm_queries
224
243
  where_clauses = [pk_union_clause(table, arm_queries)]
225
244
  where_clauses.push(table.filter) if table.filter
226
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 = []
227
250
  else
228
251
  where_clauses = build_where_clauses(table, dump_target)
229
252
  join_clauses = build_join_clauses(table, table_by_name, dump_target)
@@ -283,6 +306,13 @@ module Exwiw
283
306
  )
284
307
  end
285
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)
286
316
  QueryAst::Select.new.tap do |ast|
287
317
  ast.from(table.name)
288
318
  if table.rails_managed?
@@ -290,8 +320,28 @@ module Exwiw
290
320
  else
291
321
  ast.select(table.columns)
292
322
  end
293
- join_clauses.each { |join_clause| ast.join(join_clause) }
294
- 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))
295
345
  end
296
346
  end
297
347
 
@@ -552,22 +602,52 @@ module Exwiw
552
602
  return nil
553
603
  end
554
604
 
555
- relation, parent, parent_query = candidates.first
556
-
557
- # Project the parent's extraction query down to just its primary key — the
558
- # column this table's foreign key points at.
559
- pk_column = TableColumn.from_symbol_keys(name: parent.primary_key)
560
- projected = QueryAst::Select.new
561
- projected.from(parent_query.from_table_name)
562
- projected.select([pk_column])
563
- parent_query.join_clauses.each { |j| projected.join(j) }
564
- 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
565
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)
566
612
  QueryAst::WhereClause.new(
567
613
  column_name: relation.foreign_key,
568
614
  operator: :in_subquery,
569
- 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
570
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?
571
651
  end
572
652
 
573
653
  private def polymorphic_parent_groups(table)
@@ -695,6 +775,28 @@ module Exwiw
695
775
  )
696
776
  end
697
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
+
698
800
  # Single-target counterpart of #scoped_arms.
699
801
  private def target_polymorphic_arm_queries(table)
700
802
  return nil if table.name == dump_target.table_name
@@ -721,18 +823,25 @@ module Exwiw
721
823
  end
722
824
 
723
825
  private def target_arm_query(table, relation)
724
- 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
725
838
  return arm_pk_query(table).tap do |query|
726
839
  query.where dump_target_fk_clause(relation.foreign_key)
727
840
  query.where polymorphic_type_clause(relation)
728
841
  end
729
842
  end
730
843
 
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)
844
+ if joinable
736
845
  return arm_pk_query(table).tap do |query|
737
846
  query.join(build_join_clause(table, target, relation))
738
847
  build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
@@ -824,13 +933,7 @@ module Exwiw
824
933
  end
825
934
 
826
935
  private def build_scoped(table)
827
- ast = QueryAst::Select.new
828
- ast.from(table.name)
829
- if table.rails_managed?
830
- ast.select_all!
831
- else
832
- ast.select(table.columns)
833
- end
936
+ ast = new_extraction_query(table)
834
937
 
835
938
  # Reference/master (or rails-managed) table: export every row.
836
939
  return ast if scope_exempt?(table)
@@ -852,6 +955,10 @@ module Exwiw
852
955
  build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |join_clause| ast.join(join_clause) }
853
956
  ast.where(table.filter) if table.filter
854
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
855
962
  elsif arms.size >= 1
856
963
  # Several polymorphic arms: INNER JOINing one of them would drop the rows
857
964
  # of every other type, so constrain this table to the UNION of the ids the
@@ -1048,6 +1155,30 @@ module Exwiw
1048
1155
  terminus
1049
1156
  end
1050
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
+
1051
1182
  # BFS over belongs_tos to the nearest *directly scoped* ancestor. Unlike the
1052
1183
  # target-mode walk, the returned path INCLUDES that ancestor: the scope column
1053
1184
  # lives on the ancestor itself (not on a foreign key of the child), so the
@@ -1145,7 +1276,7 @@ module Exwiw
1145
1276
  else
1146
1277
  sibling_arms = polymorphic_sibling_arms(table, shortest[1])
1147
1278
  if sibling_arms.nil?
1148
- [ScopedArm.new(relation: nil, path: shortest)]
1279
+ [path_or_parent_query_arm(table, table.belongs_to(shortest[1]), shortest, pinned: false)]
1149
1280
  else
1150
1281
  arms = sibling_arms.filter_map { |relation| resolve_scoped_arm(table, relation) }
1151
1282
  @logger.debug(
@@ -1170,12 +1301,21 @@ module Exwiw
1170
1301
  # up referenced_by / reverse_scope / the multi-hop cascade for free.
1171
1302
  private def resolve_scoped_arm(table, relation)
1172
1303
  path = find_path_to_scoped(table, first_relation: relation)
1173
- 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
1174
1305
 
1175
1306
  target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
1176
1307
  target_query && ScopedArm.new(relation: relation, target_query: target_query)
1177
1308
  end
1178
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
+
1179
1319
  private def scoped_target_arm_query(table, relation)
1180
1320
  # A parent kept only by the automatic referenced_by exists to keep a child's
1181
1321
  # foreign key valid. Single-target mode extracts only what sits under the
@@ -1307,15 +1447,8 @@ module Exwiw
1307
1447
  # to be constrained: the foreign key alone cannot tell this arm's rows from
1308
1448
  # another arm's (record_id=1 may be any type).
1309
1449
  private def target_ids_arm_query(table, relation, target_query)
1310
- target = table_by_name.fetch(relation.table_name)
1311
1450
  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
- )
1451
+ query.where parent_ids_clause(relation, target_query)
1319
1452
  query.where polymorphic_type_clause(relation)
1320
1453
  end
1321
1454
  end
@@ -1328,14 +1461,14 @@ module Exwiw
1328
1461
  )
1329
1462
  end
1330
1463
 
1331
- # Reduce an extraction query to a single-column id projection, keeping its
1332
- # joins and filters. The column is forced to a plain TableColumn so any
1333
- # 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
1334
1467
  # comparison.
1335
- private def project_query_to(query, column_name)
1468
+ private def project_query_to(query, column_names)
1336
1469
  projected = QueryAst::Select.new
1337
1470
  projected.from(query.from_table_name)
1338
- projected.select([TableColumn.from_symbol_keys(name: column_name)])
1471
+ projected.select(Array(column_names).map { |name| TableColumn.from_symbol_keys(name: name) })
1339
1472
  query.join_clauses.each { |join_clause| projected.join(join_clause) }
1340
1473
  query.where_clauses.each { |where_clause| projected.where(where_clause) }
1341
1474
  projected
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.6"
4
+ VERSION = "1.1.8"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -116,5 +116,7 @@ module Exwiw
116
116
  # source: the server aborts the operation past the deadline. nil leaves it
117
117
  # unset (no timeout). A per-collection `query_timeout_ms` overrides it.
118
118
  # mongodb adapter only.
119
- ConnectionConfig = Struct.new(:adapter, :host, :port, :user, :password, :database_name, :uri, :mongodb_query_timeout_ms, keyword_init: true)
119
+ #
120
+ # `ssl_mode` / `ssl_ca` apply to both the driver connection and mysqldump. mysql adapter only.
121
+ ConnectionConfig = Struct.new(:adapter, :host, :port, :user, :password, :database_name, :uri, :mongodb_query_timeout_ms, :ssl_mode, :ssl_ca, keyword_init: true)
120
122
  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.6
4
+ version: 1.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -65,6 +65,7 @@ files:
65
65
  - docs/plans/2026-06-19-mongodb-export-remove-parallelism-native-ext.md
66
66
  - docs/row-transform-masking-notes.md
67
67
  - docs/scope-column-redesign.md
68
+ - docs/scope-id-set-join-notes.md
68
69
  - docs/sql-dump-optimization-notes.md
69
70
  - exe/exwiw
70
71
  - ext/exwiw/ext_json/ext_json.c