exwiw 0.9.24 → 1.1.0
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 +32 -0
- data/README.md +34 -29
- data/docs/mongodb.md +1 -1
- data/lib/exwiw/adapter/identifier_quoting.rb +2 -2
- data/lib/exwiw/adapter/mongodb_adapter.rb +0 -8
- data/lib/exwiw/adapter/mysql_adapter.rb +17 -94
- data/lib/exwiw/adapter/postgresql_adapter.rb +48 -70
- data/lib/exwiw/adapter/sqlite_adapter.rb +0 -58
- data/lib/exwiw/adapter.rb +0 -11
- data/lib/exwiw/cli.rb +55 -13
- data/lib/exwiw/ddl_postprocessor.rb +44 -4
- data/lib/exwiw/query_ast_builder.rb +129 -31
- data/lib/exwiw/runner.rb +2 -19
- data/lib/exwiw/schema_check.rb +41 -2
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
|
@@ -137,64 +137,6 @@ module Exwiw
|
|
|
137
137
|
end
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
def to_bulk_delete(select_query_ast, table)
|
|
141
|
-
raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
142
|
-
|
|
143
|
-
sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
|
|
144
|
-
|
|
145
|
-
if select_query_ast.join_clauses.empty?
|
|
146
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
147
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
148
|
-
compiled_where_conditions = select_query_ast.
|
|
149
|
-
where_clauses.
|
|
150
|
-
select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
|
|
151
|
-
map do |where|
|
|
152
|
-
compile_where_condition(where, select_query_ast.from_table_name)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
if compiled_where_conditions.size > 0
|
|
156
|
-
sql += "\nWHERE "
|
|
157
|
-
sql += compiled_where_conditions.join(' AND ')
|
|
158
|
-
end
|
|
159
|
-
sql += ";"
|
|
160
|
-
|
|
161
|
-
return sql
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
subquery_ast = Exwiw::QueryAst::Select.new
|
|
165
|
-
first_join = select_query_ast.join_clauses.first.clone
|
|
166
|
-
|
|
167
|
-
subquery_ast.from(first_join.join_table_name)
|
|
168
|
-
primay_key_col = table.columns.find { |col| col.name == table.primary_key }
|
|
169
|
-
subquery_ast.select([primay_key_col])
|
|
170
|
-
select_query_ast.join_clauses[1..].each do |join|
|
|
171
|
-
subquery_ast.join(join)
|
|
172
|
-
end
|
|
173
|
-
first_join.where_clauses.each do |where|
|
|
174
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
175
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
176
|
-
subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
foreign_key = first_join.foreign_key
|
|
180
|
-
subquery_sql = compile_ast(subquery_ast)
|
|
181
|
-
sql += "\nWHERE #{qualified_name(select_query_ast.from_table_name, foreign_key)} IN (#{subquery_sql})"
|
|
182
|
-
|
|
183
|
-
# first_join.base_where_clauses holds conditions on the outer
|
|
184
|
-
# delete-target table (from_table_name), such as a polymorphic type
|
|
185
|
-
# column. They are not part of the subquery, so add them to the outer
|
|
186
|
-
# WHERE. This prevents deleting rows that belong to a different
|
|
187
|
-
# polymorphic type.
|
|
188
|
-
first_join.base_where_clauses.each do |where|
|
|
189
|
-
next unless where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
190
|
-
|
|
191
|
-
sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
|
|
192
|
-
end
|
|
193
|
-
sql += ";"
|
|
194
|
-
|
|
195
|
-
sql
|
|
196
|
-
end
|
|
197
|
-
|
|
198
140
|
# @param count_only [Boolean] emit `SELECT COUNT(*)` instead of the
|
|
199
141
|
# projected columns (used by StreamingResult#size). Safe because exwiw's
|
|
200
142
|
# extraction queries have no DISTINCT/GROUP BY/LIMIT, so the count does
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -82,11 +82,6 @@ module Exwiw
|
|
|
82
82
|
def dump_schema(ordered_tables, output_path)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# Whether this adapter emits delete-NNN-*.sql files.
|
|
86
|
-
def supports_bulk_delete?
|
|
87
|
-
true
|
|
88
|
-
end
|
|
89
|
-
|
|
90
85
|
# Whether the given config produces its own dump output and needs an
|
|
91
86
|
# independent processing pass. SQL adapters always do; non-SQL adapters
|
|
92
87
|
# may exclude e.g. embedded subdocument configs.
|
|
@@ -339,12 +334,6 @@ module Exwiw
|
|
|
339
334
|
raise NotImplementedError
|
|
340
335
|
end
|
|
341
336
|
|
|
342
|
-
# @params [Exwiw::QueryAst] select_query_ast
|
|
343
|
-
# @params [Exwiw::TableConfig] table
|
|
344
|
-
def to_bulk_delete(select_query_ast, table)
|
|
345
|
-
raise NotImplementedError
|
|
346
|
-
end
|
|
347
|
-
|
|
348
337
|
def self.build(connection_config, logger)
|
|
349
338
|
case normalize_name(connection_config.adapter)
|
|
350
339
|
when 'sqlite'
|
data/lib/exwiw/cli.rb
CHANGED
|
@@ -39,7 +39,6 @@ module Exwiw
|
|
|
39
39
|
schema_dir
|
|
40
40
|
output_dir
|
|
41
41
|
output_format
|
|
42
|
-
insert_only
|
|
43
42
|
after_insert_hook
|
|
44
43
|
log_level
|
|
45
44
|
target_table
|
|
@@ -58,6 +57,11 @@ module Exwiw
|
|
|
58
57
|
EXPLAIN_VERBOSITIES = %w[queryPlanner executionStats allPlansExecution].freeze
|
|
59
58
|
DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
|
|
60
59
|
|
|
60
|
+
# Keys that configured behavior that no longer exists (insert_only toggled
|
|
61
|
+
# the removed delete-*.sql generation). Accepted so a committed config file
|
|
62
|
+
# keeps working across the upgrade, warned about so it gets cleaned up.
|
|
63
|
+
OBSOLETE_CONFIG_KEYS = %w[insert_only].freeze
|
|
64
|
+
|
|
61
65
|
# Database connection settings are environment-specific (and sometimes
|
|
62
66
|
# secret-adjacent), so they must be passed via CLI/env, never the committed
|
|
63
67
|
# config file. `adapter` is the one connection-ish key allowed in config.
|
|
@@ -66,7 +70,7 @@ module Exwiw
|
|
|
66
70
|
# Keys that only make sense for `export`. They are skipped when merging config
|
|
67
71
|
# for `explain` so a shared config file does not trip validate_explain_only!,
|
|
68
72
|
# and for `schema`, which performs no export at all.
|
|
69
|
-
EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format
|
|
73
|
+
EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format after_insert_hook parallel_workers].freeze
|
|
70
74
|
|
|
71
75
|
def self.start(argv)
|
|
72
76
|
new(argv).run
|
|
@@ -111,11 +115,14 @@ module Exwiw
|
|
|
111
115
|
@ids_field = nil
|
|
112
116
|
@scope_column = nil
|
|
113
117
|
@output_format = nil
|
|
114
|
-
@insert_only = nil
|
|
115
118
|
@after_insert_hook_path = nil
|
|
116
119
|
@parallel_workers = nil
|
|
117
120
|
@mongodb_query_timeout_ms = nil
|
|
118
121
|
@explain_verbosity = nil
|
|
122
|
+
# `schema check` only: which part of the report drives the exit code.
|
|
123
|
+
# nil (not "any") so validation can tell "user passed --fail-on" apart
|
|
124
|
+
# from the default when rejecting it on the other verbs.
|
|
125
|
+
@fail_on = nil
|
|
119
126
|
# nil (not :info) so we can tell "user passed --log-level" from the default,
|
|
120
127
|
# letting a config-file value fill in; the :info default is applied later.
|
|
121
128
|
@log_level = nil
|
|
@@ -160,7 +167,6 @@ module Exwiw
|
|
|
160
167
|
schema_dir: @schema_dir,
|
|
161
168
|
dump_target: dump_target,
|
|
162
169
|
output_format: @output_format,
|
|
163
|
-
insert_only: @insert_only,
|
|
164
170
|
after_insert_hook_path: @after_insert_hook_path,
|
|
165
171
|
parallel_workers: @parallel_workers,
|
|
166
172
|
cli_options: build_cli_options_hash,
|
|
@@ -250,9 +256,33 @@ module Exwiw
|
|
|
250
256
|
|
|
251
257
|
return if SchemaCheck.clean?(report)
|
|
252
258
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
# --fail-on=stale: a pre-extraction gate stops only on the drift that
|
|
260
|
+
# would break the export itself; the report above still carries the rest.
|
|
261
|
+
if @fail_on == "stale" && !SchemaCheck.stale?(report)
|
|
262
|
+
$stderr.puts "exwiw: the schema config has drifted, but nothing the extraction reads is stale " \
|
|
263
|
+
"(--fail-on=stale); run `exwiw schema generate --from-db` at your leisure."
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if SchemaCheck.stale?(report)
|
|
268
|
+
# Name what blocks the export, capped — a migration can drop dozens of
|
|
269
|
+
# columns, and the full list is in the report above.
|
|
270
|
+
stale = SchemaCheck::STALE_CATEGORIES.flat_map { |category| report.fetch(category, []) }
|
|
271
|
+
listed = stale.first(10)
|
|
272
|
+
listed << "and #{stale.size - listed.size} more (see the report)" if stale.size > listed.size
|
|
273
|
+
$stderr.puts "exwiw: the config still references #{listed.join(', ')} — gone from the database, " \
|
|
274
|
+
"so an extraction SELECT would fail on them; " \
|
|
275
|
+
"run `exwiw schema generate --from-db` (then `exwiw schema tidy --from-db`) to drop them."
|
|
276
|
+
# The default (CI) mode is a one-shot report; the gate deliberately
|
|
277
|
+
# stays quiet about mask decisions.
|
|
278
|
+
if @fail_on != "stale" && report.fetch("needs_mask_decision", []).any?
|
|
279
|
+
$stderr.puts "exwiw: the report also lists `needs_mask_decision` columns; resolve those too."
|
|
280
|
+
end
|
|
281
|
+
else
|
|
282
|
+
$stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
|
|
283
|
+
"run `exwiw schema generate --from-db` (then `exwiw schema tidy --from-db`) " \
|
|
284
|
+
"and resolve every `needs_mask_decision` column."
|
|
285
|
+
end
|
|
256
286
|
exit SCHEMA_CHECK_DIRTY_EXIT
|
|
257
287
|
end
|
|
258
288
|
|
|
@@ -333,7 +363,6 @@ module Exwiw
|
|
|
333
363
|
if @subcommand == "export"
|
|
334
364
|
@output_dir ||= "dump"
|
|
335
365
|
@output_format ||= "insert"
|
|
336
|
-
@insert_only = @insert_only ? true : false
|
|
337
366
|
|
|
338
367
|
valid_output_formats = ["insert", "copy"]
|
|
339
368
|
unless valid_output_formats.include?(@output_format)
|
|
@@ -426,6 +455,10 @@ module Exwiw
|
|
|
426
455
|
$stderr.puts "'#{key}' is a database connection setting and must be passed via the CLI/environment, not the config file (#{path})"
|
|
427
456
|
exit 1
|
|
428
457
|
end
|
|
458
|
+
if OBSOLETE_CONFIG_KEYS.include?(key)
|
|
459
|
+
$stderr.puts "warning: config key '#{key}' in #{path} is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove it from the config file"
|
|
460
|
+
next
|
|
461
|
+
end
|
|
429
462
|
unless ALLOWED_CONFIG_KEYS.include?(key)
|
|
430
463
|
$stderr.puts "Unknown config key '#{key}' in #{path}. Allowed keys: #{ALLOWED_CONFIG_KEYS.join(', ')}"
|
|
431
464
|
exit 1
|
|
@@ -443,7 +476,6 @@ module Exwiw
|
|
|
443
476
|
@output_dir ||= expand_dir(config["output_dir"], base)
|
|
444
477
|
@after_insert_hook_path ||= (File.expand_path(config["after_insert_hook"], base) if config["after_insert_hook"])
|
|
445
478
|
@output_format ||= config["output_format"]
|
|
446
|
-
@insert_only = config["insert_only"] if @insert_only.nil? && config.key?("insert_only")
|
|
447
479
|
@log_level ||= config["log_level"]&.to_sym
|
|
448
480
|
@target_table_name ||= config["target_table"]
|
|
449
481
|
@target_collection_name ||= config["target_collection"]
|
|
@@ -635,6 +667,15 @@ module Exwiw
|
|
|
635
667
|
exit 1
|
|
636
668
|
end
|
|
637
669
|
|
|
670
|
+
if @fail_on && @schema_verb != "check"
|
|
671
|
+
$stderr.puts "--fail-on only applies to `exwiw schema check` (got `exwiw schema #{@schema_verb}`)."
|
|
672
|
+
exit 1
|
|
673
|
+
end
|
|
674
|
+
if @fail_on && !%w[any stale].include?(@fail_on)
|
|
675
|
+
$stderr.puts "--fail-on must be 'any' or 'stale' (got '#{@fail_on}')."
|
|
676
|
+
exit 1
|
|
677
|
+
end
|
|
678
|
+
|
|
638
679
|
unless DbIntrospector::SUPPORTED_ADAPTERS.include?(@database_adapter)
|
|
639
680
|
$stderr.puts "--from-db supports the #{DbIntrospector::SUPPORTED_ADAPTERS.join(' and ')} adapters only " \
|
|
640
681
|
"(got '#{@database_adapter}'). sqlite and mongodb schemas are not read this way."
|
|
@@ -679,7 +720,6 @@ module Exwiw
|
|
|
679
720
|
rejected = []
|
|
680
721
|
rejected << "--output-dir" unless @output_dir.nil?
|
|
681
722
|
rejected << "--output-format" unless @output_format.nil?
|
|
682
|
-
rejected << "--insert-only" unless @insert_only.nil?
|
|
683
723
|
rejected << "--after-insert-hook" unless @after_insert_hook_path.nil?
|
|
684
724
|
rejected << "--parallel-workers" unless @parallel_workers.nil?
|
|
685
725
|
|
|
@@ -744,7 +784,6 @@ module Exwiw
|
|
|
744
784
|
ids_field: @ids_field,
|
|
745
785
|
scope_column: @scope_column,
|
|
746
786
|
output_format: @output_format,
|
|
747
|
-
insert_only: @insert_only,
|
|
748
787
|
log_level: @log_level,
|
|
749
788
|
after_insert_hook: @after_insert_hook_path,
|
|
750
789
|
}.freeze
|
|
@@ -816,12 +855,15 @@ module Exwiw
|
|
|
816
855
|
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 }
|
|
817
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 and running with --target-table.") { |v| @scope_column = v }
|
|
818
857
|
opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
|
|
819
|
-
opts.on("--insert-only", "
|
|
820
|
-
|
|
858
|
+
opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
|
|
859
|
+
$stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"
|
|
860
|
+
end
|
|
861
|
+
opts.on("--after-insert-hook=PATH", "Path to a .rb or .sh post-processing hook executed after all insert files are written (export subcommand only)") do |v|
|
|
821
862
|
@after_insert_hook_path = File.expand_path(v)
|
|
822
863
|
end
|
|
823
864
|
opts.on("--parallel-workers=N", Integer, "Fork N workers for the MongoDB dump's parallel schedule (mongodb + export only; N>=2 enables it, default is serial). Output is byte-identical to serial; falls back to serial where fork is unavailable.") { |v| @parallel_workers = v }
|
|
824
865
|
opts.on("--mongodb-query-timeout-ms=N", Integer, "Global server-enforced timeout (ms) for every MongoDB query (mongodb only). Aborts an accidentally heavy/unscoped query past the deadline. Overridden per collection by `query_timeout_ms` in the schema config.") { |v| @mongodb_query_timeout_ms = v }
|
|
866
|
+
opts.on("--fail-on=CATEGORY", "schema check only: what makes the exit code non-zero. 'any' (default) fails on any drift or undecided masking; 'stale' fails only when the committed config references tables/columns the schema no longer has - the drift that would break an export. The full report is printed either way.") { |v| @fail_on = v }
|
|
825
867
|
opts.on("--log-level=LEVEL", "Log level (debug, info). default is info") { |v| @log_level = v.to_sym }
|
|
826
868
|
|
|
827
869
|
opts.on("--help", "Print this help") do
|
|
@@ -58,10 +58,50 @@ module Exwiw
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# pg_dump
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
# One trigger as pg_dump writes it: its header comment block ($1) followed by
|
|
62
|
+
# the `CREATE TRIGGER` statement ($2).
|
|
63
|
+
#
|
|
64
|
+
# --
|
|
65
|
+
# -- Name: users set_timestamp; Type: TRIGGER; Schema: public; Owner: -
|
|
66
|
+
# --
|
|
67
|
+
#
|
|
68
|
+
# CREATE TRIGGER set_timestamp BEFORE UPDATE ON public.users ...;
|
|
69
|
+
#
|
|
70
|
+
# The header is what keeps the pass off a `CREATE TRIGGER` line inside a
|
|
71
|
+
# dollar-quoted function body — a function that installs a trigger itself,
|
|
72
|
+
# whose meaning a rewrite would change. pg_dump never writes a header block
|
|
73
|
+
# inside a body, so requiring one limits the match to top-level statements.
|
|
74
|
+
# (The stripping pass this replaced matched the bare form and deleted such a
|
|
75
|
+
# line, leaving a function that compiled but installed nothing.)
|
|
76
|
+
#
|
|
77
|
+
# The statement ends at the first semicolon outside a single-quoted string, so
|
|
78
|
+
# a `;` in a WHEN clause or an argument (`EXECUTE FUNCTION f('a;b')`) does not
|
|
79
|
+
# truncate it. A trigger definition has no body, so no dollar quoting either.
|
|
80
|
+
CREATE_TRIGGER_RE = /
|
|
81
|
+
(^--\r?\n
|
|
82
|
+
--[ \t]+Name:[^\n]*;[ \t]*Type:[ \t]*TRIGGER;[^\n]*\r?\n
|
|
83
|
+
--\r?\n
|
|
84
|
+
(?:[ \t]*\r?\n)*)
|
|
85
|
+
([ \t]*CREATE\s+(?:OR\s+REPLACE\s+)?(?:CONSTRAINT\s+)?TRIGGER\b
|
|
86
|
+
(?:[^;']|'(?:[^']|'')*')*;)
|
|
87
|
+
/xi.freeze
|
|
88
|
+
|
|
89
|
+
# A bare `CREATE TRIGGER ...;` is not idempotent (a second restore raises
|
|
90
|
+
# `duplicate_object`, 42710), so each statement goes into a DO block that
|
|
91
|
+
# swallows that error, in the form #wrap_add_constraint_in_do_block uses; the
|
|
92
|
+
# header is left as written. `CREATE OR REPLACE TRIGGER` would be shorter but
|
|
93
|
+
# is PostgreSQL 14+ only and pg_dump never emits it.
|
|
94
|
+
def wrap_create_trigger_in_do_block(sql)
|
|
95
|
+
sql.gsub(CREATE_TRIGGER_RE) do
|
|
96
|
+
header = Regexp.last_match(1)
|
|
97
|
+
stmt = Regexp.last_match(2).strip
|
|
98
|
+
<<~SQL.chomp
|
|
99
|
+
#{header}DO $exwiw$ BEGIN
|
|
100
|
+
#{stmt}
|
|
101
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
102
|
+
END $exwiw$;
|
|
103
|
+
SQL
|
|
104
|
+
end
|
|
65
105
|
end
|
|
66
106
|
|
|
67
107
|
# A user@host pair as mysqldump writes it. Each side is independently a
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Exwiw
|
|
4
4
|
class QueryAstBuilder
|
|
5
|
-
def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil)
|
|
6
|
-
new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path, batch_ids: batch_ids).run
|
|
5
|
+
def self.run(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)
|
|
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
9
|
# Scope-column mode classification for a single table. One of
|
|
@@ -34,15 +34,25 @@ module Exwiw
|
|
|
34
34
|
# Unscopable is reported before a bad batch_scope shape — it is the more
|
|
35
35
|
# fundamental problem.
|
|
36
36
|
if scope_mode?(table_by_name, dump_target)
|
|
37
|
+
# Keep each table's builder: classification records ambiguous_referencers,
|
|
38
|
+
# and this pre-flight message is what an operator actually reads.
|
|
37
39
|
unscopable =
|
|
38
|
-
tables.reject(&:ignore).
|
|
39
|
-
|
|
40
|
+
tables.reject(&:ignore).filter_map do |table|
|
|
41
|
+
builder = new(table.name, table_by_name, dump_target, logger)
|
|
42
|
+
next unless builder.scope_category == :unscopable
|
|
43
|
+
|
|
44
|
+
if builder.ambiguous_referencers
|
|
45
|
+
"#{table.name} (referenced by multiple constrained tables: " \
|
|
46
|
+
"#{builder.ambiguous_referencers} — declaring `reverse_scope` on it " \
|
|
47
|
+
"to union their ids is likely the fix)"
|
|
48
|
+
else
|
|
49
|
+
table.name
|
|
50
|
+
end
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
if unscopable.any?
|
|
43
|
-
names = unscopable.map(&:name).sort.join(", ")
|
|
44
54
|
raise ArgumentError,
|
|
45
|
-
"scope-column mode: #{unscopable.size} table(s) cannot be scoped: #{
|
|
55
|
+
"scope-column mode: #{unscopable.size} table(s) cannot be scoped: #{unscopable.sort.join(', ')}. " \
|
|
46
56
|
"For each, declare `scope_column: <column>` on the table to filter it directly, " \
|
|
47
57
|
"add a belongs_to path to a table that carries the scope column, mark it " \
|
|
48
58
|
"`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
|
|
@@ -58,7 +68,11 @@ module Exwiw
|
|
|
58
68
|
|
|
59
69
|
attr_reader :table_name, :table_by_name, :dump_target
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
# The multiple constrained referencers (comma-joined) that made the
|
|
72
|
+
# automatic reverse detection step aside, when it did.
|
|
73
|
+
attr_reader :ambiguous_referencers
|
|
74
|
+
|
|
75
|
+
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)
|
|
62
76
|
@table_name = table_name
|
|
63
77
|
@table_by_name = table_by_name
|
|
64
78
|
@dump_target = dump_target
|
|
@@ -76,6 +90,19 @@ module Exwiw
|
|
|
76
90
|
# table already on the path is not re-resolved, falling through to
|
|
77
91
|
# :unscopable instead of looping forever.
|
|
78
92
|
@forward_path = forward_path
|
|
93
|
+
# @reverse_path is the reverse counterpart: the chain of tables whose
|
|
94
|
+
# reverse resolution is in progress up the stack. It bounds the declared
|
|
95
|
+
# reverse_scope recursion — a table already being reverse-resolved is not
|
|
96
|
+
# resolved again, so a cycle of declarations terminates.
|
|
97
|
+
@reverse_path = reverse_path
|
|
98
|
+
# Whether this build may honor an explicit `reverse_scope` even though
|
|
99
|
+
# @allow_reverse is off: true for a reverse_scope arm build, false for the
|
|
100
|
+
# auto-detection's child builds (widening those would change the detection's
|
|
101
|
+
# candidate count and could bail a scoped table into a full dump).
|
|
102
|
+
@allow_declared_reverse = allow_declared_reverse
|
|
103
|
+
# Deep-chain warnings already emitted, keyed by chain path and shared (the
|
|
104
|
+
# same Hash) across every recursive build under one top-level run.
|
|
105
|
+
@deep_chain_warned = deep_chain_warned || {}
|
|
79
106
|
end
|
|
80
107
|
|
|
81
108
|
def run
|
|
@@ -91,9 +118,12 @@ module Exwiw
|
|
|
91
118
|
# every row (see the "no relation -> dump all" case). If an extractable
|
|
92
119
|
# child table references it via a foreign key (e.g. active_storage_blobs is
|
|
93
120
|
# referenced by active_storage_attachments.blob_id), constrain it to just
|
|
94
|
-
# the referenced ids instead.
|
|
95
|
-
# a child's subquery, so
|
|
96
|
-
|
|
121
|
+
# the referenced ids instead. The automatic detection is disabled
|
|
122
|
+
# (@allow_reverse=false) while building a child's subquery, so it never
|
|
123
|
+
# recurses; an explicit `reverse_scope` still applies inside a reverse_scope
|
|
124
|
+
# arm build (@allow_declared_reverse), with @reverse_path cutting cycles.
|
|
125
|
+
if (@allow_reverse || (@allow_declared_reverse && explicit_reverse_scope?(table))) &&
|
|
126
|
+
table.name != dump_target.table_name &&
|
|
97
127
|
where_clauses.empty? && join_clauses.empty?
|
|
98
128
|
reverse_clause = build_referenced_by_clause(table)
|
|
99
129
|
where_clauses.push(reverse_clause) if reverse_clause
|
|
@@ -121,6 +151,19 @@ module Exwiw
|
|
|
121
151
|
end
|
|
122
152
|
end
|
|
123
153
|
|
|
154
|
+
# The reverse detection stepped aside on an ambiguity and nothing else
|
|
155
|
+
# scoped the table: at the top level of single-target mode that is a full
|
|
156
|
+
# dump, which must not happen at debug volume. Only the final outcome
|
|
157
|
+
# warns — a table the cascade rescued stays quiet.
|
|
158
|
+
if @ambiguous_referencers && where_clauses.empty? && join_clauses.empty? &&
|
|
159
|
+
@forward_path.empty? && @reverse_path.empty? && !scope_exempt?(table)
|
|
160
|
+
@logger.warn(
|
|
161
|
+
" #{table.name} is referenced by multiple constrained tables (#{@ambiguous_referencers}), " \
|
|
162
|
+
"which the automatic reverse extraction cannot pick between, and nothing else scopes it — " \
|
|
163
|
+
"it is dumped in full. Declare `reverse_scope` on it to union their ids."
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
124
167
|
QueryAst::Select.new.tap do |ast|
|
|
125
168
|
ast.from(table.name)
|
|
126
169
|
if table.rails_managed?
|
|
@@ -210,11 +253,20 @@ module Exwiw
|
|
|
210
253
|
# the schema author has enumerated the referencers explicitly, constrain
|
|
211
254
|
# the table to the UNION of those referencers' scoped queries instead of
|
|
212
255
|
# the single-referencer auto-detection below (which bails to a full dump
|
|
213
|
-
# once two or more tables reference the table).
|
|
214
|
-
|
|
256
|
+
# once two or more tables reference the table). Unlike the auto-detection,
|
|
257
|
+
# this branch also runs during a reverse_scope arm build
|
|
258
|
+
# (@allow_declared_reverse); @reverse_path bounds the recursion.
|
|
259
|
+
if explicit_reverse_scope?(table)
|
|
260
|
+
return nil unless @allow_reverse || @allow_declared_reverse
|
|
261
|
+
|
|
215
262
|
return build_reverse_scope_via_clause(table)
|
|
216
263
|
end
|
|
217
264
|
|
|
265
|
+
# The automatic single-referencer detection is top-level only: during a
|
|
266
|
+
# subquery build it could recurse through a chain of FK-less tables that
|
|
267
|
+
# reference each other.
|
|
268
|
+
return nil unless @allow_reverse
|
|
269
|
+
|
|
218
270
|
candidates = table_by_name.each_value.filter_map do |other|
|
|
219
271
|
next if other.name == table.name
|
|
220
272
|
|
|
@@ -225,11 +277,12 @@ module Exwiw
|
|
|
225
277
|
next if relation.nil? || relation.polymorphic?
|
|
226
278
|
|
|
227
279
|
# Build the child's own extraction query. allow_reverse:false stops a
|
|
228
|
-
# chain of FK-less tables from recursing back into each other;
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
|
|
280
|
+
# chain of FK-less tables from recursing back into each other; the grown
|
|
281
|
+
# forward_path/reverse_path stop the child from scoping back through this
|
|
282
|
+
# table. allow_declared_reverse is OFF: a child scoped only by its own
|
|
283
|
+
# reverse_scope must stay unconstrained here, or it would widen the
|
|
284
|
+
# candidate set and could bail this detection out of a scope it had.
|
|
285
|
+
child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false, allow_declared_reverse: false, forward_path: @forward_path + [table.name], reverse_path: @reverse_path + [table.name], deep_chain_warned: @deep_chain_warned)
|
|
233
286
|
|
|
234
287
|
# Only an *already constrained* child narrows anything; an unconstrained
|
|
235
288
|
# child would select every fk value (i.e. dump all) and not help.
|
|
@@ -239,11 +292,14 @@ module Exwiw
|
|
|
239
292
|
end
|
|
240
293
|
|
|
241
294
|
# Scope: only the unambiguous single-referencer case. Multiple referencers
|
|
242
|
-
# would need
|
|
243
|
-
#
|
|
295
|
+
# would need `reverse_scope` to union them; falling back preserves today's
|
|
296
|
+
# behavior for those.
|
|
244
297
|
if candidates.size != 1
|
|
245
298
|
if candidates.size > 1
|
|
246
|
-
|
|
299
|
+
# Whether the ambiguity deserves a warning depends on the outcome —
|
|
300
|
+
# the forward cascade may still scope the table — so `run` decides.
|
|
301
|
+
@ambiguous_referencers = candidates.map { |_, query| query.from_table_name }.sort.join(', ')
|
|
302
|
+
@logger.debug(" #{table.name} has multiple referencing tables; skipping reverse extraction.")
|
|
247
303
|
end
|
|
248
304
|
return nil
|
|
249
305
|
end
|
|
@@ -279,7 +335,33 @@ module Exwiw
|
|
|
279
335
|
# every row's id and union the whole table back, silently defeating the
|
|
280
336
|
# prune. Returns nil when no arm survives, leaving the caller to fall back to
|
|
281
337
|
# the dump-all behavior (which validate_scope! then rejects in scope mode).
|
|
338
|
+
#
|
|
339
|
+
# A referencer that is itself scoped only by its own `reverse_scope` still
|
|
340
|
+
# resolves, so declared chains nest, with @reverse_path cutting cycles.
|
|
282
341
|
private def build_reverse_scope_via_clause(table)
|
|
342
|
+
if @reverse_path.include?(table.name)
|
|
343
|
+
@logger.debug(
|
|
344
|
+
" #{table.name}.reverse_scope is already being resolved " \
|
|
345
|
+
"(#{(@reverse_path + [table.name]).join(' -> ')}); cutting the cycle."
|
|
346
|
+
)
|
|
347
|
+
return nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Each level of a declared chain re-embeds its referencers' subqueries, so
|
|
351
|
+
# the generated SQL grows exponentially with depth; flag chains deeper than
|
|
352
|
+
# the intended 2-3. Deduped: this build repeats once per arm per level.
|
|
353
|
+
if @reverse_path.size >= 3
|
|
354
|
+
path = (@reverse_path + [table.name]).join(' -> ')
|
|
355
|
+
unless @deep_chain_warned[path]
|
|
356
|
+
@deep_chain_warned[path] = true
|
|
357
|
+
@logger.warn(
|
|
358
|
+
" #{table.name}.reverse_scope is nested #{@reverse_path.size + 1} declarations deep " \
|
|
359
|
+
"(#{path}); the generated SQL grows exponentially with chain depth — consider scoping " \
|
|
360
|
+
"an intermediate table another way."
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
283
365
|
arms = table.reverse_scope.via.filter_map do |via|
|
|
284
366
|
referencer = table_by_name[via.table]
|
|
285
367
|
if referencer.nil?
|
|
@@ -287,12 +369,11 @@ module Exwiw
|
|
|
287
369
|
next
|
|
288
370
|
end
|
|
289
371
|
|
|
290
|
-
# Build the referencer's own scoped extraction query.
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
ref_query = self.class.run(referencer.name, table_by_name, dump_target, @logger, allow_reverse: false, forward_path: @forward_path + [table.name])
|
|
372
|
+
# Build the referencer's own scoped extraction query. The automatic
|
|
373
|
+
# reverse detection stays off; the referencer may forward-scope through
|
|
374
|
+
# other tables and (allow_declared_reverse) resolve its own explicit
|
|
375
|
+
# reverse_scope, with the grown paths keeping it from coming back here.
|
|
376
|
+
ref_query = self.class.run(referencer.name, table_by_name, dump_target, @logger, allow_reverse: false, allow_declared_reverse: true, forward_path: @forward_path + [table.name], reverse_path: @reverse_path + [table.name], deep_chain_warned: @deep_chain_warned)
|
|
296
377
|
|
|
297
378
|
unless ref_query.where_clauses.any? || ref_query.join_clauses.any?
|
|
298
379
|
@logger.warn(
|
|
@@ -392,8 +473,9 @@ module Exwiw
|
|
|
392
473
|
|
|
393
474
|
# allow_reverse and forward scoping stay enabled so the parent may itself
|
|
394
475
|
# be scoped via referenced_by or via *its* parent — this is what makes the
|
|
395
|
-
# cascade multi-hop
|
|
396
|
-
|
|
476
|
+
# cascade multi-hop; reverse_path rides along to keep an in-progress
|
|
477
|
+
# reverse resolution from being re-entered.
|
|
478
|
+
parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path, reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned)
|
|
397
479
|
|
|
398
480
|
# Only a constrained parent narrows anything; an unconstrained parent
|
|
399
481
|
# would select every pk (i.e. dump all) and not help.
|
|
@@ -570,7 +652,7 @@ module Exwiw
|
|
|
570
652
|
return ast
|
|
571
653
|
end
|
|
572
654
|
|
|
573
|
-
if @allow_reverse
|
|
655
|
+
if @allow_reverse || (@allow_declared_reverse && explicit_reverse_scope?(table))
|
|
574
656
|
# Referenced by an extractable (scoped) child: constrain via subquery.
|
|
575
657
|
reverse_clause = build_referenced_by_clause(table)
|
|
576
658
|
if reverse_clause
|
|
@@ -619,6 +701,11 @@ module Exwiw
|
|
|
619
701
|
table.scope_exempt || table.rails_managed?
|
|
620
702
|
end
|
|
621
703
|
|
|
704
|
+
# Whether the schema author declared how this table is reverse-scoped.
|
|
705
|
+
private def explicit_reverse_scope?(table)
|
|
706
|
+
!!(table.reverse_scope && table.reverse_scope.via.any?)
|
|
707
|
+
end
|
|
708
|
+
|
|
622
709
|
private def directly_scoped?(table)
|
|
623
710
|
column = resolved_scope_column(table)
|
|
624
711
|
table.columns.any? { |c| c.name == column }
|
|
@@ -855,7 +942,7 @@ module Exwiw
|
|
|
855
942
|
|
|
856
943
|
target_query = self.class.run(
|
|
857
944
|
target.name, table_by_name, dump_target, @logger,
|
|
858
|
-
allow_reverse: true, forward_path: @forward_path + [table.name]
|
|
945
|
+
allow_reverse: true, forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
|
|
859
946
|
)
|
|
860
947
|
# An unconstrained target selects every id, i.e. does not scope the arm at
|
|
861
948
|
# all; dropping the arm is the safe outcome.
|
|
@@ -1041,11 +1128,22 @@ module Exwiw
|
|
|
1041
1128
|
end
|
|
1042
1129
|
|
|
1043
1130
|
private def scope_unscopable_message(table)
|
|
1044
|
-
|
|
1131
|
+
message =
|
|
1132
|
+
"Table '#{table.name}' cannot be scoped in scope-column mode: it carries no scope " \
|
|
1045
1133
|
"column (no per-table `scope_column` is declared on it) and has no belongs_to path " \
|
|
1046
1134
|
"to a table that does. Declare `scope_column: <column>` on it, mark it " \
|
|
1047
1135
|
"`scope_exempt: true` to export it in full, set `ignore: true` to skip it, or add " \
|
|
1048
1136
|
"the missing belongs_to."
|
|
1137
|
+
|
|
1138
|
+
# None of the generic options above is the right fix for the referencer
|
|
1139
|
+
# ambiguity; give the same precise remedy the single-target warning gives.
|
|
1140
|
+
if @ambiguous_referencers
|
|
1141
|
+
message += " Note: it is referenced by multiple constrained tables " \
|
|
1142
|
+
"(#{@ambiguous_referencers}); declaring `reverse_scope` on it to union " \
|
|
1143
|
+
"their ids is likely the fix."
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
message
|
|
1049
1147
|
end
|
|
1050
1148
|
end
|
|
1051
1149
|
end
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -11,7 +11,6 @@ module Exwiw
|
|
|
11
11
|
dump_target:,
|
|
12
12
|
logger:,
|
|
13
13
|
output_format: 'insert',
|
|
14
|
-
insert_only: false,
|
|
15
14
|
after_insert_hook_path: nil,
|
|
16
15
|
parallel_workers: nil,
|
|
17
16
|
cli_options: {}
|
|
@@ -21,7 +20,6 @@ module Exwiw
|
|
|
21
20
|
@schema_dir = schema_dir
|
|
22
21
|
@dump_target = dump_target
|
|
23
22
|
@output_format = output_format
|
|
24
|
-
@insert_only = insert_only
|
|
25
23
|
@after_insert_hook_path = after_insert_hook_path
|
|
26
24
|
@parallel_workers = parallel_workers
|
|
27
25
|
@cli_options = cli_options
|
|
@@ -105,8 +103,8 @@ module Exwiw
|
|
|
105
103
|
|
|
106
104
|
# `batch_scope` splits the extraction into one query per slice of the
|
|
107
105
|
# scope's id set, streaming rows like any adapter result. `query_ast`
|
|
108
|
-
# stays the unbatched query — the
|
|
109
|
-
#
|
|
106
|
+
# stays the unbatched query — the error message below describes that
|
|
107
|
+
# one.
|
|
110
108
|
phase = "resolving the batch_scope id set"
|
|
111
109
|
batched = BatchedExtraction.build(
|
|
112
110
|
adapter: adapter,
|
|
@@ -184,21 +182,6 @@ module Exwiw
|
|
|
184
182
|
|
|
185
183
|
@logger.info(" Generated INSERT statement for #{record_num} records (#{statement_count} statement(s)).")
|
|
186
184
|
end
|
|
187
|
-
|
|
188
|
-
if adapter.supports_bulk_delete? && !@insert_only && !(table.respond_to?(:rails_managed?) && table.rails_managed?)
|
|
189
|
-
phase = "generating DELETE statement"
|
|
190
|
-
@logger.debug(" Generate DELETE statement...")
|
|
191
|
-
delete_sql = adapter.to_bulk_delete(query_ast, table)
|
|
192
|
-
if @logger.debug?
|
|
193
|
-
@logger.debug(" Generated DELETE statement:\n#{delete_sql}")
|
|
194
|
-
else
|
|
195
|
-
@logger.info(" Generated DELETE statement.")
|
|
196
|
-
end
|
|
197
|
-
delete_idx = (total_size - idx).to_s.rjust(3, '0')
|
|
198
|
-
File.open(File.join(@output_dir, "delete-#{delete_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
|
|
199
|
-
file.puts(delete_sql)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
185
|
rescue => e
|
|
203
186
|
@logger.error("Error while #{phase} for table '#{table_name}' (#{idx + 1}/#{total_size}): #{e.class}: #{e.message}")
|
|
204
187
|
@logger.error(" Extraction query that produced the data being processed:")
|