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
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`).
|
|
@@ -164,13 +164,14 @@ module Exwiw
|
|
|
164
164
|
# left as Strings rather than guessed at — the caller passes values
|
|
165
165
|
# matching the field's actual type.
|
|
166
166
|
if dump_target.ids_field
|
|
167
|
-
{ dump_target.ids_field => { "$in" => dump_target.
|
|
167
|
+
{ dump_target.ids_field => { "$in" => dump_target.default_ids } }
|
|
168
168
|
else
|
|
169
|
-
{ config.primary_key => { "$in" => coerce_ids(dump_target.
|
|
169
|
+
{ config.primary_key => { "$in" => coerce_ids(dump_target.default_ids) } }
|
|
170
170
|
end
|
|
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
|
|
@@ -4,25 +4,26 @@ require 'erb'
|
|
|
4
4
|
|
|
5
5
|
module Exwiw
|
|
6
6
|
class AfterInsertHook
|
|
7
|
-
def self.run(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
|
|
7
|
+
def self.run(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
|
|
8
8
|
ext = File.extname(path)
|
|
9
9
|
|
|
10
10
|
if ext == '.rb'
|
|
11
11
|
run_ruby(
|
|
12
12
|
path: path,
|
|
13
13
|
cli_options: cli_options,
|
|
14
|
+
dump_target: dump_target,
|
|
14
15
|
output_dir: output_dir,
|
|
15
16
|
next_idx: next_idx,
|
|
16
17
|
output_extension: output_extension,
|
|
17
18
|
logger: logger,
|
|
18
19
|
)
|
|
19
20
|
else
|
|
20
|
-
run_shell(path: path, cli_options: cli_options, output_dir: output_dir, logger: logger)
|
|
21
|
+
run_shell(path: path, cli_options: cli_options, dump_target: dump_target, output_dir: output_dir, logger: logger)
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
def self.run_ruby(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
|
|
25
|
-
ctx = Context.new(cli_options, output_extension: output_extension)
|
|
25
|
+
def self.run_ruby(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
|
|
26
|
+
ctx = Context.new(cli_options, dump_target: dump_target, output_extension: output_extension)
|
|
26
27
|
ctx.instance_eval(File.read(path), path)
|
|
27
28
|
|
|
28
29
|
# One output file per buffer, numbered sequentially from next_idx: first
|
|
@@ -53,7 +54,7 @@ module Exwiw
|
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
def self.run_shell(path:, cli_options:, output_dir:, logger:)
|
|
57
|
+
def self.run_shell(path:, cli_options:, dump_target:, output_dir:, logger:)
|
|
57
58
|
env = {
|
|
58
59
|
'EXWIW_OUTPUT_DIR' => output_dir,
|
|
59
60
|
'EXWIW_SCHEMA_DIR' => cli_options[:schema_dir].to_s,
|
|
@@ -64,9 +65,12 @@ module Exwiw
|
|
|
64
65
|
'EXWIW_DATABASE_NAME' => cli_options[:database_name].to_s,
|
|
65
66
|
'EXWIW_TARGET_TABLE' => cli_options[:target_table].to_s,
|
|
66
67
|
'EXWIW_SCOPE_COLUMN' => cli_options[:scope_column].to_s,
|
|
67
|
-
'EXWIW_IDS' =>
|
|
68
|
+
'EXWIW_IDS' => dump_target.default_ids.join(','),
|
|
68
69
|
'EXWIW_OUTPUT_FORMAT' => cli_options[:output_format].to_s,
|
|
69
70
|
}
|
|
71
|
+
dump_target.ids.each do |space, values|
|
|
72
|
+
env["EXWIW_IDS_#{space.upcase}"] = Array(values).join(',')
|
|
73
|
+
end
|
|
70
74
|
logger.info("Running after-insert shell hook: #{path}")
|
|
71
75
|
ok = system(env, path)
|
|
72
76
|
raise "after-insert shell hook failed: #{path}" unless ok
|
|
@@ -79,13 +83,19 @@ module Exwiw
|
|
|
79
83
|
|
|
80
84
|
attr_reader :cli_options, :collected, :collected_by_collection
|
|
81
85
|
|
|
82
|
-
def initialize(cli_options, output_extension: nil)
|
|
86
|
+
def initialize(cli_options, dump_target:, output_extension: nil)
|
|
83
87
|
@cli_options = cli_options
|
|
88
|
+
@dump_target = dump_target
|
|
84
89
|
@output_extension = output_extension
|
|
85
90
|
@collected = []
|
|
86
91
|
@collected_by_collection = {}
|
|
87
92
|
end
|
|
88
93
|
|
|
94
|
+
# The values the run was scoped by for the given ID space (`default` when omitted).
|
|
95
|
+
def ids_for(id_space = DEFAULT_ID_SPACE)
|
|
96
|
+
@dump_target.ids_for(id_space.to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
89
99
|
def insert_sql(template)
|
|
90
100
|
@collected << render(template)
|
|
91
101
|
end
|
data/lib/exwiw/cli.rb
CHANGED
|
@@ -111,7 +111,8 @@ module Exwiw
|
|
|
111
111
|
@database_name = nil
|
|
112
112
|
@target_table_name = nil
|
|
113
113
|
@target_collection_name = nil
|
|
114
|
-
@
|
|
114
|
+
@ids_by_id_space = {}
|
|
115
|
+
@ids_errors = []
|
|
115
116
|
@ids_field = nil
|
|
116
117
|
@scope_column = nil
|
|
117
118
|
@output_format = nil
|
|
@@ -151,7 +152,7 @@ module Exwiw
|
|
|
151
152
|
|
|
152
153
|
dump_target = DumpTarget.new(
|
|
153
154
|
table_name: @target_table_name,
|
|
154
|
-
ids: @
|
|
155
|
+
ids: @ids_by_id_space,
|
|
155
156
|
ids_field: @ids_field,
|
|
156
157
|
scope_column: @scope_column,
|
|
157
158
|
)
|
|
@@ -297,6 +298,9 @@ module Exwiw
|
|
|
297
298
|
# Fill in any options not given on the CLI from the config file. Done first
|
|
298
299
|
# so a config-provided `adapter` is in place before normalization below.
|
|
299
300
|
# CLI values always win (the merge only fills nil/empty ivars).
|
|
301
|
+
@ids_errors.each { |message| $stderr.puts message }
|
|
302
|
+
exit 1 if @ids_errors.any?
|
|
303
|
+
|
|
300
304
|
apply_config_file!
|
|
301
305
|
|
|
302
306
|
# Default log level once CLI and config have both had their say.
|
|
@@ -316,6 +320,7 @@ module Exwiw
|
|
|
316
320
|
end
|
|
317
321
|
|
|
318
322
|
resolve_target_collection_alias!
|
|
323
|
+
resolve_named_ids!
|
|
319
324
|
resolve_scope_column!
|
|
320
325
|
resolve_ids_field!
|
|
321
326
|
resolve_uri_option!
|
|
@@ -391,18 +396,22 @@ module Exwiw
|
|
|
391
396
|
exit 1
|
|
392
397
|
end
|
|
393
398
|
|
|
394
|
-
if @target_table_name &&
|
|
399
|
+
if @target_table_name && !ids_given?
|
|
395
400
|
$stderr.puts "--ids is required when --target-table is specified"
|
|
396
401
|
exit 1
|
|
397
402
|
end
|
|
398
403
|
|
|
399
|
-
if @scope_column &&
|
|
404
|
+
if @scope_column && !ids_given?
|
|
400
405
|
$stderr.puts "--ids is required when --scope-column is specified"
|
|
401
406
|
exit 1
|
|
402
407
|
end
|
|
403
408
|
|
|
404
|
-
|
|
405
|
-
|
|
409
|
+
# For the SQL adapters, --ids without a target is scope-column mode; whether
|
|
410
|
+
# the schema supports it is checked once it is loaded
|
|
411
|
+
# (QueryAstBuilder.validate_scope!). MongoDB has no scope-column mode.
|
|
412
|
+
if @database_adapter == "mongodb" && !@target_table_name && default_ids.any?
|
|
413
|
+
$stderr.puts "--target-collection (or --target-table) is required when --ids is specified " \
|
|
414
|
+
"with the mongodb adapter"
|
|
406
415
|
exit 1
|
|
407
416
|
end
|
|
408
417
|
|
|
@@ -479,11 +488,23 @@ module Exwiw
|
|
|
479
488
|
@log_level ||= config["log_level"]&.to_sym
|
|
480
489
|
@target_table_name ||= config["target_table"]
|
|
481
490
|
@target_collection_name ||= config["target_collection"]
|
|
482
|
-
|
|
491
|
+
# Like the other keys, `ids` is taken from the config only when the CLI
|
|
492
|
+
# passed no --ids at all; the two are never merged per ID space.
|
|
493
|
+
if !ids_given? && config.key?("ids")
|
|
483
494
|
raw = config["ids"]
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
495
|
+
if raw.is_a?(Hash)
|
|
496
|
+
raw.each do |space, values|
|
|
497
|
+
space = space.to_s
|
|
498
|
+
unless space.match?(ID_SPACE_NAME_PATTERN)
|
|
499
|
+
$stderr.puts "config 'ids' keys must be ID space names (a lowercase letter followed by " \
|
|
500
|
+
"lowercase letters, digits or underscores; got #{space.inspect}) in #{path}"
|
|
501
|
+
exit 1
|
|
502
|
+
end
|
|
503
|
+
@ids_by_id_space[space] = parse_config_ids(values)
|
|
504
|
+
end
|
|
505
|
+
else
|
|
506
|
+
@ids_by_id_space[DEFAULT_ID_SPACE] = parse_config_ids(raw)
|
|
507
|
+
end
|
|
487
508
|
end
|
|
488
509
|
@ids_field ||= config["ids_field"]
|
|
489
510
|
@scope_column ||= config["scope_column"]
|
|
@@ -492,6 +513,56 @@ module Exwiw
|
|
|
492
513
|
@explain_verbosity ||= config["explain_verbosity"]
|
|
493
514
|
end
|
|
494
515
|
|
|
516
|
+
# Accept either a YAML list or a "1,2" string; coerce to strings to match
|
|
517
|
+
# the CLI's `--ids=1,2` -> ["1", "2"] shape.
|
|
518
|
+
private def parse_config_ids(raw)
|
|
519
|
+
(raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# `--ids=<id_space>=1,2` gives the values of a named ID space; without the
|
|
523
|
+
# prefix the values are the default space's. The prefix is recognized only
|
|
524
|
+
# when it is shaped like an ID space name, so an id containing `=` is passed
|
|
525
|
+
# as `--ids=default=<ids>`. Each ID space may be given once: a second --ids
|
|
526
|
+
# for it would otherwise replace the first without notice.
|
|
527
|
+
private def add_ids_option(value)
|
|
528
|
+
prefix, rest = value.to_s.split("=", 2)
|
|
529
|
+
space, list =
|
|
530
|
+
if rest && prefix.match?(ID_SPACE_NAME_PATTERN)
|
|
531
|
+
[prefix, rest]
|
|
532
|
+
else
|
|
533
|
+
[DEFAULT_ID_SPACE, value.to_s]
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
if @ids_by_id_space.key?(space)
|
|
537
|
+
@ids_errors << "--ids was given more than once for the ID space '#{space}'; " \
|
|
538
|
+
"pass all of its ids in one comma-separated --ids."
|
|
539
|
+
end
|
|
540
|
+
@ids_by_id_space[space] = list.split(",")
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
private def ids_given?
|
|
544
|
+
@ids_by_id_space.each_value.any?(&:any?)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
private def default_ids
|
|
548
|
+
@ids_by_id_space.fetch(DEFAULT_ID_SPACE, [])
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Named ID spaces exist only for scope-column mode, which the mongodb
|
|
552
|
+
# adapter does not have. Whether a SQL run is in scope-column mode depends on
|
|
553
|
+
# the schema, so that is checked once it is loaded (QueryAstBuilder.validate_scope!).
|
|
554
|
+
private def resolve_named_ids!
|
|
555
|
+
named = @ids_by_id_space.keys - [DEFAULT_ID_SPACE]
|
|
556
|
+
return if named.empty?
|
|
557
|
+
|
|
558
|
+
if @database_adapter == "mongodb"
|
|
559
|
+
$stderr.puts "--ids for a named ID space (#{named.map { |space| "'#{space}'" }.join(', ')}) " \
|
|
560
|
+
"is not supported by the mongodb adapter, which uses only the default ID space " \
|
|
561
|
+
"(--ids without an `<id_space>=` prefix)."
|
|
562
|
+
exit 1
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
495
566
|
# Strip a trailing slash (like the CLI's dir options) and expand relative to
|
|
496
567
|
# `base` (the config file's directory). Returns nil for a nil value.
|
|
497
568
|
private def expand_dir(value, base)
|
|
@@ -542,8 +613,8 @@ module Exwiw
|
|
|
542
613
|
|
|
543
614
|
# `--scope-column` is **deprecated**: it selected scope-column mode with a
|
|
544
615
|
# single global column for every table. The preferred way is to declare a
|
|
545
|
-
# per-table `scope_column:` in the schema config
|
|
546
|
-
#
|
|
616
|
+
# per-table `scope_column:` in the schema config (`--target-table` is then
|
|
617
|
+
# optional). The flag still works as
|
|
547
618
|
# before — SQL-only and mutually exclusive with `--target-table` — but emits a
|
|
548
619
|
# deprecation warning. Runs after resolve_target_collection_alias! so
|
|
549
620
|
# --target-collection is already folded into @target_table_name.
|
|
@@ -567,7 +638,7 @@ module Exwiw
|
|
|
567
638
|
end
|
|
568
639
|
|
|
569
640
|
$stderr.puts "warning: --scope-column is deprecated; declare a per-table `scope_column:` " \
|
|
570
|
-
"in the schema config and
|
|
641
|
+
"in the schema config and drop the flag instead."
|
|
571
642
|
end
|
|
572
643
|
|
|
573
644
|
# `--uri` supplies a full connection string (e.g. `mongodb+srv://...`) and is
|
|
@@ -780,7 +851,7 @@ module Exwiw
|
|
|
780
851
|
database_adapter: @database_adapter,
|
|
781
852
|
database_name: @database_name,
|
|
782
853
|
target_table: @target_table_name,
|
|
783
|
-
ids:
|
|
854
|
+
ids: default_ids.dup.freeze,
|
|
784
855
|
ids_field: @ids_field,
|
|
785
856
|
scope_column: @scope_column,
|
|
786
857
|
output_format: @output_format,
|
|
@@ -849,11 +920,11 @@ module Exwiw
|
|
|
849
920
|
opts.on("-a", "--adapter=ADAPTER", "Database adapter: mysql, sqlite, postgresql, mongodb (aliases: mysql2, sqlite3)") { |v| @database_adapter = v }
|
|
850
921
|
opts.on("--uri=URI", "Full MongoDB connection URI (mongodb:// or mongodb+srv://). mongodb adapter only; takes precedence over --host/--port/--user. TLS, replicaSet, authSource and credentials are read from the URI.") { |v| @connection_uri = v }
|
|
851
922
|
opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
|
|
852
|
-
opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted, dump all tables.") { |v| @target_table_name = v }
|
|
923
|
+
opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted with --ids, run in scope-column mode (SQL adapters; the schema must declare `scope_column:`). If omitted without --ids, dump all tables.") { |v| @target_table_name = v }
|
|
853
924
|
opts.on("--target-collection=[COLLECTION]", "Alias of --target-table for the mongodb adapter.") { |v| @target_collection_name = v }
|
|
854
|
-
opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given.") { |v|
|
|
925
|
+
opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given. Prefix with `<id_space>=` (e.g. --ids=org=a,b) to give the values of a named ID space; may be repeated, once per ID space. An id containing `=` is passed as --ids=default=<ids>.") { |v| add_ids_option(v) }
|
|
855
926
|
opts.on("--ids-field=[FIELD]", "Field on the target collection that --ids is matched against. Defaults to the primary key. (mongodb adapter only)") { |v| @ids_field = v }
|
|
856
|
-
opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config
|
|
927
|
+
opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config.") { |v| @scope_column = v }
|
|
857
928
|
opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
|
|
858
929
|
opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
|
|
859
930
|
$stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -20,12 +20,34 @@ 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
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# The ID spaces whose values `query` filters by, anywhere in it.
|
|
32
|
+
def self.id_spaces(query)
|
|
33
|
+
where_clauses = query.where_clauses +
|
|
34
|
+
query.join_clauses.flat_map { |j| j.where_clauses + j.base_where_clauses }
|
|
35
|
+
|
|
36
|
+
where_clauses.flat_map do |where_clause|
|
|
37
|
+
next [] unless where_clause.is_a?(WhereClause)
|
|
38
|
+
|
|
39
|
+
own = where_clause.is_a?(ScopeWhereClause) ? [where_clause.id_space] : []
|
|
40
|
+
nested =
|
|
41
|
+
case where_clause.value
|
|
42
|
+
when SelectSubquery then id_spaces(where_clause.value.query)
|
|
43
|
+
when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
|
|
44
|
+
when RecursiveAncestorSubquery then id_spaces(where_clause.value.base)
|
|
45
|
+
else []
|
|
46
|
+
end
|
|
47
|
+
own + nested
|
|
48
|
+
end.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
29
51
|
class JoinClause
|
|
30
52
|
# `where_clauses` is compiled against this join's join_table_name (the
|
|
31
53
|
# joined-to table). `base_where_clauses`, on the other hand, is compiled
|
|
@@ -65,11 +87,17 @@ module Exwiw
|
|
|
65
87
|
{
|
|
66
88
|
column_name: column_name,
|
|
67
89
|
operator: operator,
|
|
68
|
-
value:
|
|
90
|
+
value: SUBQUERY_VALUE_TYPES.any? { |type| value.is_a?(type) } ? value.to_h : value,
|
|
69
91
|
}
|
|
70
92
|
end
|
|
71
93
|
end
|
|
72
94
|
|
|
95
|
+
# A scope-column filter. It compiles like any WhereClause; `id_space` records
|
|
96
|
+
# which ID space its values come from, so a query mixing spaces can be found.
|
|
97
|
+
ScopeWhereClause = Class.new(WhereClause) do
|
|
98
|
+
attr_accessor :id_space
|
|
99
|
+
end
|
|
100
|
+
|
|
73
101
|
# Resolves a set of values on `where_column` to the rows' `select_column`
|
|
74
102
|
# via a nested SELECT. Used as the `value` of a WhereClause whose operator
|
|
75
103
|
# is `:in_subquery`, so a non primary-key `ids_field` can filter related
|
|
@@ -126,6 +154,36 @@ module Exwiw
|
|
|
126
154
|
end
|
|
127
155
|
end
|
|
128
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
|
+
|
|
129
187
|
module ColumnValue
|
|
130
188
|
Base = Struct.new(:name, :value, keyword_init: true)
|
|
131
189
|
Plain = Class.new(Base)
|