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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +318 -965
- data/docs/mongodb.md +4 -0
- data/docs/scope-id-set-join-notes.md +12 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +14 -2
- data/lib/exwiw/adapter/mysql_adapter.rb +18 -15
- 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/after_insert_hook.rb +17 -7
- data/lib/exwiw/cli.rb +88 -17
- data/lib/exwiw/query_ast.rb +59 -1
- data/lib/exwiw/query_ast_builder.rb +348 -58
- data/lib/exwiw/runner.rb +1 -0
- data/lib/exwiw/table_config.rb +17 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +40 -1
- metadata +2 -1
|
@@ -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
|
|
16
|
-
# per-table `scope_column` (the
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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,
|
|
450
|
-
|
|
451
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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::
|
|
1038
|
+
Exwiw::QueryAst::ScopeWhereClause.new(
|
|
799
1039
|
column_name: resolved_scope_column(table),
|
|
800
1040
|
operator: :eq,
|
|
801
|
-
value: dump_target.
|
|
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
|
-
[
|
|
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
|
|
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
|
|
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
|
|
1175
|
-
#
|
|
1176
|
-
#
|
|
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,
|
|
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(
|
|
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
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -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