exwiw 1.1.6 → 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 +6 -0
- data/README.md +317 -1018
- 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 +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/query_ast.rb +34 -1
- data/lib/exwiw/query_ast_builder.rb +177 -44
- data/lib/exwiw/version.rb +1 -1
- metadata +2 -1
data/docs/mongodb.md
CHANGED
|
@@ -71,6 +71,10 @@ Where the SQL adapters emit a `UNION` subquery, MongoDB has no cross-collection
|
|
|
71
71
|
|
|
72
72
|
Masking (`replace_with`) and `fields` behavior on a reverse-scoped collection are unchanged. Like the SQL key, `reverse_scope` is user-owned: `exwiw:mongoid:schema:generate` never emits it and regeneration preserves a hand-added value.
|
|
73
73
|
|
|
74
|
+
## Self-referencing `belongs_to`
|
|
75
|
+
|
|
76
|
+
Unlike the SQL adapters, which keep the ancestors of a narrowed [tree table](../README.md#self-referencing-belongs_to-tree-tables), the MongoDB adapter extracts each collection with a single `find` and does not walk a self-reference. A narrowed collection with a `belongs_to` to itself logs a warning, since a kept document's parent may be missing from the dump.
|
|
77
|
+
|
|
74
78
|
## Embedded documents
|
|
75
79
|
|
|
76
80
|
MongoDB models often store one-to-many relationships as embedded subdocument arrays (e.g. `users` documents with a `posts: [...]` field). To mask fields inside embedded subdocuments, declare a separate config with `embedded_in`:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Why scope id sets are joined, not `IN (subquery)`
|
|
2
|
+
|
|
3
|
+
When a table is narrowed by a set of ids that another query produces (referenced-by, `reverse_scope`, the narrowed-parent cascade, polymorphic arms, tree ancestors), the SQL adapters emit a `JOIN` to a `SELECT DISTINCT` derived table instead of `<col> IN (<subquery>)`:
|
|
4
|
+
|
|
5
|
+
```sql
|
|
6
|
+
… JOIN (SELECT DISTINCT src.<id> AS exwiw_scope_id FROM (<id-set subquery>) AS src) AS ids
|
|
7
|
+
ON <table>.<col> = ids.exwiw_scope_id
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Both forms return the same rows; the `DISTINCT` keeps the join from multiplying them. The plans differ on a large table. MySQL cannot turn `IN (… UNION …)` into a materialized semi-join, so it rewrites it into a correlated `EXISTS` that is evaluated once per outer row: a full scan of the outer table, with the union re-run for every row. The `DISTINCT` derived table cannot be merged into the outer query, so it is materialized once and the outer table is probed by its key. On a large `users` table this is the difference between a full scan and an index lookup. Nested cascades are materialized once per level for the same reason.
|
|
11
|
+
|
|
12
|
+
PostgreSQL casts both sides to `text` when the key types differ (`uuid` against `varchar`).
|
|
@@ -171,6 +171,7 @@ module Exwiw
|
|
|
171
171
|
else
|
|
172
172
|
related_collection_filter(config, config_by_name, dump_target)
|
|
173
173
|
end
|
|
174
|
+
warn_missing_self_ancestors(config) unless filter.empty?
|
|
174
175
|
|
|
175
176
|
Exwiw::MongoQuery::Find.new(
|
|
176
177
|
collection: config.name,
|
|
@@ -181,6 +182,17 @@ module Exwiw
|
|
|
181
182
|
)
|
|
182
183
|
end
|
|
183
184
|
|
|
185
|
+
# The SQL adapters add the ancestors of a narrowed self-referencing table;
|
|
186
|
+
# this adapter extracts each collection with a single find, so it cannot.
|
|
187
|
+
private def warn_missing_self_ancestors(config)
|
|
188
|
+
return unless config.belongs_tos.any? { |relation| relation.table_name == config.name && !relation.polymorphic? }
|
|
189
|
+
|
|
190
|
+
@logger.warn(
|
|
191
|
+
" #{config.name} references itself, but the mongodb adapter does not add the ancestors " \
|
|
192
|
+
"of the documents it keeps; a kept document's parent may be missing."
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
184
196
|
def execute(query)
|
|
185
197
|
@logger.debug(" Executing Mongo find on '#{query.collection}': filter=#{query.filter.inspect} projection=#{query.projection.inspect}")
|
|
186
198
|
|
|
@@ -353,24 +353,27 @@ module Exwiw
|
|
|
353
353
|
end
|
|
354
354
|
|
|
355
355
|
private def compile_subquery(subquery)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
356
|
+
case subquery
|
|
357
|
+
in Exwiw::QueryAst::RecursiveAncestorSubquery
|
|
358
|
+
compile_ancestor_closure(subquery)
|
|
359
|
+
in Exwiw::QueryAst::SelectSubquery
|
|
360
|
+
# A SelectSubquery wraps a full Select (the referencing table's
|
|
361
|
+
# extraction query, projected to a foreign key); compile it as-is.
|
|
362
|
+
compile_ast(subquery.query)
|
|
363
|
+
in Exwiw::QueryAst::UnionSubquery
|
|
364
|
+
# A UnionSubquery wraps several such Selects; UNION their compiled forms
|
|
365
|
+
# into a single id set. Sibling arms can carry the same nested scope
|
|
366
|
+
# id-set, and MySQL cannot reference one TEMPORARY table twice in a
|
|
367
|
+
# statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
|
|
368
|
+
without_scope_materialization do
|
|
366
369
|
subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
|
|
367
370
|
end
|
|
371
|
+
in Exwiw::QueryAst::Subquery
|
|
372
|
+
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
373
|
+
"SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
|
|
374
|
+
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
375
|
+
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
368
376
|
end
|
|
369
|
-
|
|
370
|
-
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
371
|
-
"SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
|
|
372
|
-
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
373
|
-
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
374
377
|
end
|
|
375
378
|
|
|
376
379
|
# Backslash and control-character escapes, matching mysqldump. Escaping
|
|
@@ -461,25 +461,35 @@ module Exwiw
|
|
|
461
461
|
private def compile_subquery(subquery, outer_table: nil, outer_column: nil)
|
|
462
462
|
cast_to = subquery_cast_to(subquery, outer_table, outer_column)
|
|
463
463
|
|
|
464
|
-
|
|
465
|
-
|
|
464
|
+
case subquery
|
|
465
|
+
in Exwiw::QueryAst::RecursiveAncestorSubquery
|
|
466
|
+
# The outer key is compared with this table's own primary keys, so it needs no cast.
|
|
467
|
+
compile_ancestor_closure(subquery)
|
|
468
|
+
in Exwiw::QueryAst::SelectSubquery
|
|
469
|
+
compile_ast(subquery.query, select_cast_to: cast_to)
|
|
470
|
+
in Exwiw::QueryAst::UnionSubquery
|
|
471
|
+
# A UnionSubquery wraps several projected Selects; UNION their compiled
|
|
472
|
+
# forms. cast_to is the union-wide decision (see union_cast_to): when any
|
|
473
|
+
# arm's column type would clash with the outer column or another arm,
|
|
474
|
+
# every arm's projected column and the outer key are cast to text so the
|
|
475
|
+
# UNION and the enclosing IN comparison resolve to one type.
|
|
476
|
+
subquery.queries.map { |q| compile_ast(q, select_cast_to: cast_to) }.join(' UNION ')
|
|
477
|
+
in Exwiw::QueryAst::Subquery
|
|
478
|
+
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
479
|
+
select_expr = qualified_name(subquery.table_name, subquery.select_column)
|
|
480
|
+
select_expr = "#{select_expr}::#{cast_to}" if cast_to
|
|
481
|
+
"SELECT #{select_expr} " \
|
|
482
|
+
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
483
|
+
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
466
484
|
end
|
|
485
|
+
end
|
|
467
486
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
# UNION and the enclosing IN comparison resolve to one type.
|
|
473
|
-
if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
474
|
-
return subquery.queries.map { |q| compile_ast(q, select_cast_to: cast_to) }.join(' UNION ')
|
|
475
|
-
end
|
|
487
|
+
# A self-referencing foreign key may be stored as text while the key it
|
|
488
|
+
# points at is a uuid; reconcile them the same way a JOIN does.
|
|
489
|
+
private def ancestor_link_condition(table, referenced_column, foreign_key)
|
|
490
|
+
return super unless types_need_cast?(column_pg_type(table, referenced_column), column_pg_type(table, foreign_key))
|
|
476
491
|
|
|
477
|
-
|
|
478
|
-
select_expr = qualified_name(subquery.table_name, subquery.select_column)
|
|
479
|
-
select_expr = "#{select_expr}::#{cast_to}" if cast_to
|
|
480
|
-
"SELECT #{select_expr} " \
|
|
481
|
-
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
482
|
-
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
492
|
+
"#{qualified_name(table, referenced_column)}::text = #{ANCESTOR_SET_NAME}.#{quote_identifier(foreign_key)}::text"
|
|
483
493
|
end
|
|
484
494
|
|
|
485
495
|
private def subquery_select_target(subquery)
|
|
@@ -232,20 +232,23 @@ module Exwiw
|
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
private def compile_subquery(subquery)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
235
|
+
case subquery
|
|
236
|
+
in Exwiw::QueryAst::RecursiveAncestorSubquery
|
|
237
|
+
compile_ancestor_closure(subquery)
|
|
238
|
+
in Exwiw::QueryAst::SelectSubquery
|
|
239
|
+
# A SelectSubquery wraps a full Select (the referencing table's
|
|
240
|
+
# extraction query, projected to a foreign key); compile it as-is.
|
|
241
|
+
compile_ast(subquery.query)
|
|
242
|
+
in Exwiw::QueryAst::UnionSubquery
|
|
243
|
+
# A UnionSubquery wraps several such Selects; UNION their compiled forms
|
|
244
|
+
# into a single id set.
|
|
245
|
+
subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
|
|
246
|
+
in Exwiw::QueryAst::Subquery
|
|
247
|
+
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
248
|
+
"SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
|
|
249
|
+
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
250
|
+
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
243
251
|
end
|
|
244
|
-
|
|
245
|
-
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
246
|
-
"SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
|
|
247
|
-
"FROM #{quote_table_name(subquery.table_name)} " \
|
|
248
|
-
"WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
|
|
249
252
|
end
|
|
250
253
|
|
|
251
254
|
private def escape_value(value)
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -307,7 +307,8 @@ module Exwiw
|
|
|
307
307
|
where_clause.is_a?(Exwiw::QueryAst::WhereClause) &&
|
|
308
308
|
where_clause.operator == :in_subquery &&
|
|
309
309
|
(where_clause.value.is_a?(Exwiw::QueryAst::SelectSubquery) ||
|
|
310
|
-
where_clause.value.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
310
|
+
where_clause.value.is_a?(Exwiw::QueryAst::UnionSubquery) ||
|
|
311
|
+
where_clause.value.is_a?(Exwiw::QueryAst::RecursiveAncestorSubquery))
|
|
311
312
|
end
|
|
312
313
|
|
|
313
314
|
# The bare name of the single column a scope subquery projects, used to
|
|
@@ -319,8 +320,36 @@ module Exwiw
|
|
|
319
320
|
subquery.query.columns.first.name
|
|
320
321
|
when Exwiw::QueryAst::UnionSubquery
|
|
321
322
|
subquery.queries.first.columns.first.name
|
|
323
|
+
when Exwiw::QueryAst::RecursiveAncestorSubquery
|
|
324
|
+
subquery.primary_key
|
|
322
325
|
end
|
|
323
326
|
end
|
|
327
|
+
|
|
328
|
+
ANCESTOR_SET_NAME = "exwiw_ancestors"
|
|
329
|
+
|
|
330
|
+
# The WITH clause sits inside the subquery, so the id set stays valid wherever
|
|
331
|
+
# it is embedded: a derived-table JOIN, a MySQL temporary table, a UNION arm.
|
|
332
|
+
private def compile_ancestor_closure(subquery)
|
|
333
|
+
table = subquery.table_name
|
|
334
|
+
ancestor_columns = [subquery.primary_key, *subquery.links.map(&:foreign_key)].uniq
|
|
335
|
+
column_list = ancestor_columns.map { |c| quote_identifier(c) }.join(', ')
|
|
336
|
+
recursive_select = ancestor_columns.map { |c| qualified_name(table, c) }.join(', ')
|
|
337
|
+
link_conditions = subquery.links.map do |link|
|
|
338
|
+
ancestor_link_condition(table, link.references || subquery.primary_key, link.foreign_key)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
"WITH RECURSIVE #{ANCESTOR_SET_NAME} (#{column_list}) AS (" \
|
|
342
|
+
"#{compile_ast(subquery.base)} " \
|
|
343
|
+
"UNION " \
|
|
344
|
+
"SELECT #{recursive_select} FROM #{quote_table_name(table)} " \
|
|
345
|
+
"JOIN #{ANCESTOR_SET_NAME} ON #{link_conditions.join(' OR ')}" \
|
|
346
|
+
") SELECT #{ANCESTOR_SET_NAME}.#{quote_identifier(subquery.primary_key)} FROM #{ANCESTOR_SET_NAME}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Overridden where a dialect compares the two columns differently.
|
|
350
|
+
private def ancestor_link_condition(table, referenced_column, foreign_key)
|
|
351
|
+
"#{qualified_name(table, referenced_column)} = #{ANCESTOR_SET_NAME}.#{quote_identifier(foreign_key)}"
|
|
352
|
+
end
|
|
324
353
|
end
|
|
325
354
|
|
|
326
355
|
# @params [Exwiw::QueryAst] query_ast
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -20,6 +20,8 @@ module Exwiw
|
|
|
20
20
|
reads_table?(where_clause.value.query, table_name)
|
|
21
21
|
when UnionSubquery
|
|
22
22
|
where_clause.value.queries.any? { |q| reads_table?(q, table_name) }
|
|
23
|
+
when RecursiveAncestorSubquery
|
|
24
|
+
where_clause.value.table_name == table_name || reads_table?(where_clause.value.base, table_name)
|
|
23
25
|
else
|
|
24
26
|
false
|
|
25
27
|
end
|
|
@@ -39,6 +41,7 @@ module Exwiw
|
|
|
39
41
|
case where_clause.value
|
|
40
42
|
when SelectSubquery then id_spaces(where_clause.value.query)
|
|
41
43
|
when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
|
|
44
|
+
when RecursiveAncestorSubquery then id_spaces(where_clause.value.base)
|
|
42
45
|
else []
|
|
43
46
|
end
|
|
44
47
|
own + nested
|
|
@@ -84,7 +87,7 @@ module Exwiw
|
|
|
84
87
|
{
|
|
85
88
|
column_name: column_name,
|
|
86
89
|
operator: operator,
|
|
87
|
-
value:
|
|
90
|
+
value: SUBQUERY_VALUE_TYPES.any? { |type| value.is_a?(type) } ? value.to_h : value,
|
|
88
91
|
}
|
|
89
92
|
end
|
|
90
93
|
end
|
|
@@ -151,6 +154,36 @@ module Exwiw
|
|
|
151
154
|
end
|
|
152
155
|
end
|
|
153
156
|
|
|
157
|
+
# The primary keys of the rows `base` keeps plus every ancestor reachable
|
|
158
|
+
# through the table's own self-referencing foreign keys (`links`), so a tree
|
|
159
|
+
# table never keeps a row whose parent was left out:
|
|
160
|
+
#
|
|
161
|
+
# WITH RECURSIVE exwiw_ancestors (<pk>, <fk>...) AS (
|
|
162
|
+
# <base projected to pk and every link's fk>
|
|
163
|
+
# UNION
|
|
164
|
+
# SELECT <table>.<pk>, <table>.<fk>... FROM <table>
|
|
165
|
+
# JOIN exwiw_ancestors ON <table>.<ref> = exwiw_ancestors.<fk> OR ...
|
|
166
|
+
# ) SELECT <pk> FROM exwiw_ancestors
|
|
167
|
+
#
|
|
168
|
+
# UNION (not UNION ALL) stops the walk on cyclic data.
|
|
169
|
+
RecursiveAncestorSubquery = Struct.new(:base, :table_name, :primary_key, :links, keyword_init: true) do
|
|
170
|
+
def to_h
|
|
171
|
+
{
|
|
172
|
+
ancestors_of: base.to_h,
|
|
173
|
+
table_name: table_name,
|
|
174
|
+
primary_key: primary_key,
|
|
175
|
+
links: links.map(&:to_h),
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# One self-referencing foreign key of a RecursiveAncestorSubquery.
|
|
181
|
+
# `references` is the column the foreign key points at (the primary key
|
|
182
|
+
# unless the belongs_to says otherwise).
|
|
183
|
+
AncestorLink = Struct.new(:foreign_key, :references, keyword_init: true)
|
|
184
|
+
|
|
185
|
+
SUBQUERY_VALUE_TYPES = [Subquery, SelectSubquery, UnionSubquery, RecursiveAncestorSubquery].freeze
|
|
186
|
+
|
|
154
187
|
module ColumnValue
|
|
155
188
|
Base = Struct.new(:name, :value, keyword_init: true)
|
|
156
189
|
Plain = Class.new(Base)
|
|
@@ -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