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