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.
@@ -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 insert_only after_insert_hook parallel_workers].freeze
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
- $stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
254
- "run `exwiw schema generate --from-db` (then `exwiw schema tidy --from-db`) " \
255
- "and resolve every `needs_mask_decision` column."
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", "Do not generate DELETE SQL files (export subcommand only)") { @insert_only = true }
820
- opts.on("--after-insert-hook=PATH", "Path to a .rb or .sh post-processing hook executed after all insert/delete files are written (export subcommand only)") do |v|
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 --table includes triggers but not the referenced function
62
- # definitions, causing UndefinedFunction errors on the target DB.
63
- def strip_triggers(sql)
64
- sql.gsub(/^[ \t]*CREATE\s+(?:OR\s+REPLACE\s+)?(?:CONSTRAINT\s+)?TRIGGER\b[^;]*;\r?\n?/i, "")
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).select do |table|
39
- scope_category(table.name, table_by_name, dump_target, logger) == :unscopable
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: #{names}. " \
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
- def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil)
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. Disabled (@allow_reverse=false) while building
95
- # a child's subquery, so this never recurses.
96
- if @allow_reverse && table.name != dump_target.table_name &&
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
- if table.reverse_scope && table.reverse_scope.via.any?
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; adding this
229
- # table to forward_path stops the child from forward-scoping back through
230
- # it (which would loop) while still letting the child forward-scope
231
- # through other tables.
232
- child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false, forward_path: @forward_path + [table.name])
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 their subqueries OR'd together (not yet supported); falling
243
- # back to dump-all preserves today's behavior for those.
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
- @logger.debug(" #{table.name} has multiple referencing tables; skipping reverse extraction (dump all).")
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. allow_reverse is
291
- # disabled and this table is added to forward_path to bound recursion
292
- # exactly as the single-referencer path does (a referencer that could only
293
- # be scoped by recursing back into this table would loop); the referencer
294
- # may still forward-scope through other tables.
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
- parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
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
- "Table '#{table.name}' cannot be scoped in scope-column mode: it carries no scope " \
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 DELETE file and the error message
109
- # below describe that one.
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:")