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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +318 -1017
- data/docs/mongodb.md +4 -0
- data/docs/scope-id-set-join-notes.md +12 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +12 -0
- data/lib/exwiw/adapter/mysql_adapter.rb +37 -15
- data/lib/exwiw/adapter/mysql_client.rb +33 -2
- data/lib/exwiw/adapter/postgresql_adapter.rb +26 -16
- data/lib/exwiw/adapter/sqlite_adapter.rb +16 -13
- data/lib/exwiw/adapter.rb +30 -1
- data/lib/exwiw/cli.rb +33 -1
- data/lib/exwiw/query_ast.rb +34 -1
- data/lib/exwiw/query_ast_builder.rb +177 -44
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +3 -1
- metadata +2 -1
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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,
|
|
556
|
-
|
|
557
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
[
|
|
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
|
|
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
|
|
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
|
|
1332
|
-
#
|
|
1333
|
-
#
|
|
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,
|
|
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(
|
|
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
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
|
-
|
|
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.
|
|
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
|