exwiw 1.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecbb0b978dfd64c1e48c396e600662a9f7d4bb9c2c8f0e0050440987b53c815d
4
- data.tar.gz: eff38c81ce04abb165f0edcad7316e1820d44abd54c6596ce450357c11a9c201
3
+ metadata.gz: e0b25a06efa96c6a7cfcc0e19187a735e7d83f317dc41f585af904e665a0ff46
4
+ data.tar.gz: b646c7949e2425e0155a0e70cd93eb4190506d97f9ecca790e77f7a9f9d469ed
5
5
  SHA512:
6
- metadata.gz: 2b119cb478ae9e6bb019bb2d0d94272ed84f7bb20ac3013236260b8c98433c9c97f37b3d4dafc132a70736d0d6eb4a20e4fc0e44e473dfc5d1a91f7dbda560a0
7
- data.tar.gz: 17268aa8ec3ad2959521cc4b16d453e86480935ae98ba43e4f38fb4545dada3af23980fc79b8f3751268fa79e078e87c7e7388e8bab7ce9fefbef9e6344c9453
6
+ metadata.gz: d6b42bb54f5bc27345d69c4d634f70f964be04b67845c8b64a791fa8659eb04cbdb92fe13ce86ebb8dec717ef8caa8b9cbfb33d5de5e55769010363f8f4c2bdf
7
+ data.tar.gz: 3f6f4bc0637983318900442495f2b5dd9a29d3d147064494d22216e6a3ee284b4ff5af5b882e51b5bcc1c4e3655c39664a7caf7ae010105818b46644e1a278c8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-09-07
6
+
7
+ ### Added
8
+
9
+ - **`exwiw schema check --from-db --fail-on=stale` keys the exit code to the drift that would break an export, so the check can run as a pre-extraction gate ([#223](https://github.com/heyinc/exwiw/pull/223)).** The report gains two categories, `stale_tables` and `stale_columns`: a non-ignored config still naming a table or column the schema no longer has — the extraction SELECT would name something that does not exist. Additions (a new table or column the config has not adopted yet) and unresolved `needs_mask_decision` flags stay in the report but no longer drive the exit code under `--fail-on=stale`, because for a gate that runs before every extraction they are someone's TODO rather than a reason to stop the run; `--fail-on=any` (the default) keeps today's behavior exactly, and the new report keys are additive, so existing consumers of the JSON are unaffected. Removals on an `ignore: true` table or of an `ignore: true` column are not stale — nothing selects them; a removed rails-managed table IS stale (it is dumped whole), though its columns can never be (the dump is `SELECT *`) — a case only the rake tasks' reports can produce today, since the `--from-db` generator never emits rails-managed configs. When staleness is what fails the check, the CLI names the stale tables/columns on stderr instead of the generic "resolve every `needs_mask_decision` column" advice.
10
+
11
+ - **`reverse_scope` declarations now chain: an arm whose referencer is itself scoped only by its own `reverse_scope` resolves instead of being dropped ([#224](https://github.com/heyinc/exwiw/pull/224)).** Previously the arm's referencer query was built with reverse extraction disabled entirely, so a referencer with no `belongs_to` path of its own came out unconstrained and the arm was skipped — and in single `--target-table` mode the table then fell back to a **full dump** (scope-column mode rejected it in pre-flight). Each skipped arm did warn, but nothing stated that consequence, which is what the new outcome warning below now does. Declared chains — `attachments <- documents <- join rows <- the target` — now nest: each arm resolves the referencer's own declared `reverse_scope`. The nesting applies **only inside reverse_scope arm builds**: the automatic single-referencer detection still builds its candidate children without it, so which tables that detection scopes is unchanged (a widened candidate set could flip its single-candidate requirement and cost a table the scope it had). A cycle of declarations is cut (the repeated table's arm comes out unconstrained and is dropped with the existing warning) rather than recursing forever, and a chain more than three declarations deep is flagged with a warning — every level re-embeds its referencers' subqueries, so generated SQL grows exponentially with depth. MongoDB's runtime reverse scoping is unchanged.
12
+
13
+ ### Changed
14
+
15
+ - **A table left unscoped because several constrained tables reference it is now flagged at `warn`, not `debug`.** In single `--target-table` mode that outcome is a full dump of the table, and the only signal used to be a debug line; the warning names the referencers and suggests declaring `reverse_scope` to union their ids. In scope-column mode the pre-flight abort message changed shape for the same situation: the affected table's entry now names the referencers with the same `reverse_scope` suggestion, per table, instead of a generic clause in the message tail.
16
+
17
+ ### Fixed
18
+
19
+ - **MySQL: sibling `UNION` arms sharing a nested scope id-set no longer fail id-set materialization.** Each arm of a union id-set (several foreign keys of one referencer, or polymorphic arms probing the same ancestor) could embed the same materialized id-set, and MySQL cannot reference one `TEMPORARY` table twice in a single statement (`ER_CANT_REOPEN_TABLE`); the failed `CREATE` then disabled materialization for the rest of the run. Nested scopes inside union arms now compile as inline derived tables, so the outer id-set materializes once and the temp table appears once per statement.
20
+
5
21
  ## [1.0.0] - 2026-09-01
6
22
 
7
23
  ### Removed
data/README.md CHANGED
@@ -60,7 +60,7 @@ exwiw has three subcommands:
60
60
 
61
61
  - `export` (default) — generate INSERT/COPY SQL files. If the subcommand is omitted, `export` is assumed.
62
62
  - `explain` — print each query `export` would run together with its `EXPLAIN` output. SQL adapters compile the SELECT without executing it; mongodb runs the server's explain (defaulting to the execution-free `queryPlanner`).
63
- - `schema generate|check|tidy --from-db` — maintain the schema config by reading a live database, for applications that cannot be loaded to generate it from their models. See [Non-Rails applications](#non-rails-applications-exwiw-schema---from-db).
63
+ - `schema generate|check|tidy --from-db` — maintain the schema config by reading a live database, for applications that cannot be loaded to generate it from their models. See [Non-Rails applications](#non-rails-applications-exwiw-schema----from-db).
64
64
 
65
65
  ### `exwiw export`
66
66
 
@@ -442,15 +442,22 @@ the comparison as JSON, then exits non-zero when anything needs attention:
442
442
  "added_tables": [],
443
443
  "added_columns": ["users.contact_email"],
444
444
  "removed_tables": [],
445
- "removed_columns": [],
446
- "changed_tables": ["users"],
447
- "needs_mask_decision": ["orders.memo"]
445
+ "removed_columns": ["orders.legacy_flag"],
446
+ "changed_tables": ["orders", "users"],
447
+ "needs_mask_decision": ["orders.memo"],
448
+ "stale_tables": [],
449
+ "stale_columns": ["orders.legacy_flag"]
448
450
  }
449
451
  ```
450
452
 
451
453
  `added_*` / `removed_*` / `changed_tables` mean the config no longer matches the schema — run
452
454
  `schema:generate` and `schema:tidy` to reconcile it. `needs_mask_decision` lists the columns
453
- whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). The exit code
455
+ whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). `stale_tables` /
456
+ `stale_columns` are the subset of the removals an extraction would actually trip over — a
457
+ non-ignored config still naming a table or column the schema no longer has, so the export's
458
+ SELECT would fail; removals of `ignore: true` entries (and of a rails-managed table's columns,
459
+ which are dumped as `SELECT *`) stay out of them. They drive the exit code only under
460
+ [`--fail-on=stale`](#non-rails-applications-exwiw-schema----from-db). The exit code
454
461
  makes it usable as a CI check that keeps a schema change from being merged until both are
455
462
  resolved; the JSON is stable and sorted, so it can be posted as-is. In a multi-database app each
456
463
  entry is prefixed with its database (`primary/users.email`), so the same table name in two
@@ -464,7 +471,7 @@ the same `EXWIW_SCHEMA_CHECK_OUTPUT` file and the same exit code — it just reg
464
471
  `MongoidSchemaGenerator` (safe mode + `tidy_mongoid`) instead. Collections and fields are
465
472
  reported under the same keys as tables and columns. An application that cannot be loaded to
466
473
  generate from its models at all can run the same check against its database instead: see
467
- [Non-Rails applications](#non-rails-applications-exwiw-schema---from-db).
474
+ [Non-Rails applications](#non-rails-applications-exwiw-schema----from-db).
468
475
 
469
476
  #### Multiple databases
470
477
 
@@ -533,6 +540,15 @@ exwiw schema tidy --from-db -a postgresql -h db.example.com -p 5432 -u app --dat
533
540
  `EXWIW_SCHEMA_CHECK_OUTPUT`, and exits 1 when the config needs attention. A check that could
534
541
  not *run* (an unreachable database, a malformed config) exits with a different status, so CI
535
542
  can tell the two apart.
543
+ - `check` also accepts `--fail-on=stale` for use as a pre-extraction gate: the exit code then
544
+ tracks only the report's `stale_tables` / `stale_columns` — a non-ignored config still naming
545
+ a table or column the schema no longer has, which is exactly the drift that would fail the
546
+ export's SELECT. Additions and unresolved `needs_mask_decision` flags stay visible in the
547
+ report but do not stop the run, so a schema migration that merely *adds* a column does not
548
+ block extraction. The default (`--fail-on=any`) is the CI behavior above, unchanged.
549
+ `--fail-on` is command-line only (not a config-file key — a gate flag belongs to the
550
+ invocation, not the committed config) and is meaningful only on `schema check`: the other
551
+ schema verbs reject it, and `export` ignores it like the other schema-only flags.
536
552
  - One run covers one database — the connection addresses one — so the files are written flat into
537
553
  the schema directory. There is no per-database subdirectory layout here; a second database is a
538
554
  second run against a second connection.
@@ -864,6 +880,7 @@ Notes:
864
880
 
865
881
  - **`column` is explicit**, so a *non-default* foreign key (e.g. `kantan_yoyaku_user_id`, or `organization_admins.id` which itself references `users.id`) is honored, and even a column with no declared `belongs_to` edge can be enumerated.
866
882
  - **Only scoped referencers belong in `via`.** Each arm's query must come out constrained; an unconstrained referencer (e.g. a `scope_exempt` table, or one with no path to a scope) would project *every* id and union the whole table back — so such an arm is **skipped with a warning** rather than silently widening the dump. An unknown table is likewise skipped with a warning. If no arm survives, the table stays unscopable and (in [scope-column mode](#scope-column-mode)) the run aborts via `validate_scope!`.
883
+ - **Declarations chain.** A referencer that is itself scoped only by its *own* `reverse_scope` counts as scoped: the arm nests that declaration's `UNION` inside its query, so a normalized side table referenced by a document-style hub that is in turn reverse-scoped through a join table resolves end to end (`attachments <- documents <- join rows <- the target`). A cycle of declarations is cut — the repeated table's arm comes out unconstrained and is dropped with the warning above. The chaining is deliberately limited to `reverse_scope` arms: the *automatic* single-referencer detection still treats a child scoped solely by its own declaration as unconstrained, so such a child never rescues (or, by widening the candidate set past one, costs) a parent that relies on the automatic detection — declare `reverse_scope` on the parent if you need that shape. Chains deeper than three declarations are flagged with a warning, since each level re-embeds its referencers' subqueries and the generated SQL grows exponentially with depth.
867
884
  - **NULLs are excluded** per arm (`IS NOT NULL`).
868
885
  - **Satellites need no config.** A table that `belongs_to` the reverse-scoped table (e.g. `end_users.id → users.id`, or `identities.user_id → users.id`) tightens to the kept ids automatically through the normal cascade — only the reverse-scoped table itself declares `reverse_scope`. The cascade is **multi-hop**, so a table several `belongs_to` hops below the reverse-scoped table (e.g. `end_user_profiles → end_users → users`) also tightens automatically, with no config of its own.
869
886
  - Works in both single-target and scope-column mode. In single-target mode there is no scope-column pre-flight (`validate_scope!`), so a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is dumped in full with a warning rather than aborting. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
@@ -269,6 +269,16 @@ module Exwiw
269
269
  @materialize_scopes = false
270
270
  end
271
271
 
272
+ # The inverse, for a region that may embed the same id-set more than once
273
+ # in one statement (MySQL cannot reopen a TEMPORARY table).
274
+ private def without_scope_materialization
275
+ saved = @materialize_scopes
276
+ @materialize_scopes = false
277
+ yield
278
+ ensure
279
+ @materialize_scopes = saved
280
+ end
281
+
272
282
  private def materialized_scope_table(subquery)
273
283
  return nil unless @materialize_scopes
274
284
  return nil if @scope_materialization_disabled
@@ -348,9 +358,13 @@ module Exwiw
348
358
  return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
349
359
 
350
360
  # A UnionSubquery wraps several such Selects; UNION their compiled forms
351
- # into a single id set.
361
+ # into a single id set. Sibling arms can carry the same nested scope
362
+ # id-set, and MySQL cannot reference one TEMPORARY table twice in a
363
+ # statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
352
364
  if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
353
- return subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
365
+ return without_scope_materialization do
366
+ subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
367
+ end
354
368
  end
355
369
 
356
370
  inner_values = subquery.where_values.map { |v| escape_value(v) }
data/lib/exwiw/cli.rb CHANGED
@@ -119,6 +119,10 @@ module Exwiw
119
119
  @parallel_workers = nil
120
120
  @mongodb_query_timeout_ms = nil
121
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
122
126
  # nil (not :info) so we can tell "user passed --log-level" from the default,
123
127
  # letting a config-file value fill in; the :info default is applied later.
124
128
  @log_level = nil
@@ -252,9 +256,33 @@ module Exwiw
252
256
 
253
257
  return if SchemaCheck.clean?(report)
254
258
 
255
- $stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
256
- "run `exwiw schema generate --from-db` (then `exwiw schema tidy --from-db`) " \
257
- "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
258
286
  exit SCHEMA_CHECK_DIRTY_EXIT
259
287
  end
260
288
 
@@ -639,6 +667,15 @@ module Exwiw
639
667
  exit 1
640
668
  end
641
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
+
642
679
  unless DbIntrospector::SUPPORTED_ADAPTERS.include?(@database_adapter)
643
680
  $stderr.puts "--from-db supports the #{DbIntrospector::SUPPORTED_ADAPTERS.join(' and ')} adapters only " \
644
681
  "(got '#{@database_adapter}'). sqlite and mongodb schemas are not read this way."
@@ -826,6 +863,7 @@ module Exwiw
826
863
  end
827
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 }
828
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 }
829
867
  opts.on("--log-level=LEVEL", "Log level (debug, info). default is info") { |v| @log_level = v.to_sym }
830
868
 
831
869
  opts.on("--help", "Print this help") do
@@ -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
@@ -21,6 +21,15 @@ module Exwiw
21
21
  added_tables added_columns removed_tables removed_columns changed_tables needs_mask_decision
22
22
  ].freeze
23
23
 
24
+ # The subset of the removals that would break an `export` run: a non-ignored
25
+ # config still referencing a table or column the schema no longer has, i.e.
26
+ # the extraction SELECT would name something that does not exist. Removals of
27
+ # `ignore: true` entries stay out (nothing selects them), as do additions.
28
+ # `--fail-on=stale` keys the exit code to these. Deliberate over-approximation:
29
+ # a hand-written config naming a database VIEW lands here too (tidy's stance),
30
+ # though a SELECT against it would succeed.
31
+ STALE_CATEGORIES = %w[stale_tables stale_columns].freeze
32
+
24
33
  def self.from_rails_application(schema_dir:)
25
34
  Rails.application.eager_load!
26
35
  new(models: ActiveRecord::Base.descendants, schema_dir: schema_dir)
@@ -81,6 +90,12 @@ module Exwiw
81
90
  CATEGORIES.all? { |category| report.fetch(category, []).empty? }
82
91
  end
83
92
 
93
+ # Whether the report contains drift that would break an extraction run
94
+ # (see STALE_CATEGORIES). Always a subset of what makes clean? false.
95
+ def self.stale?(report)
96
+ STALE_CATEGORIES.any? { |category| !report.fetch(category, []).empty? }
97
+ end
98
+
84
99
  private def regenerate_into(tmp_dir)
85
100
  FileUtils.cp_r(File.join(@schema_dir, "."), tmp_dir) if Dir.exist?(@schema_dir)
86
101
  @regenerator.call(tmp_dir)
@@ -111,7 +126,7 @@ module Exwiw
111
126
  end
112
127
 
113
128
  private def diff(committed, regenerated)
114
- report = CATEGORIES.to_h { |category| [category, []] }
129
+ report = (CATEGORIES + STALE_CATEGORIES).to_h { |category| [category, []] }
115
130
 
116
131
  (committed.keys | regenerated.keys).sort.each do |key|
117
132
  before = committed[key]
@@ -122,7 +137,11 @@ module Exwiw
122
137
  next
123
138
  end
124
139
  if after.nil?
125
- report["removed_tables"] << table_label(key, before)
140
+ label = table_label(key, before)
141
+ report["removed_tables"] << label
142
+ # A rails-managed table is dumped whole, so its disappearance breaks
143
+ # the export too; only `ignore: true` keeps a removed table out.
144
+ report["stale_tables"] << label unless before["ignore"]
126
145
  next
127
146
  end
128
147
  next if before == after
@@ -132,6 +151,10 @@ module Exwiw
132
151
  added, removed = column_diff(before, after)
133
152
  report["added_columns"] += added.sort.map { |column| "#{label}.#{column}" }
134
153
  report["removed_columns"] += removed.sort.map { |column| "#{label}.#{column}" }
154
+ if extracted?(before)
155
+ stale = removed & extracted_column_names(before)
156
+ report["stale_columns"] += stale.sort.map { |column| "#{label}.#{column}" }
157
+ end
135
158
  end
136
159
 
137
160
  report
@@ -148,6 +171,22 @@ module Exwiw
148
171
  (config["columns"] || config["fields"] || []).map { |column| column["name"] }
149
172
  end
150
173
 
174
+ # Whether the extraction SELECT names this table's columns: an `ignore: true`
175
+ # config only contributes DDL, and a rails-managed one is dumped as
176
+ # `SELECT *` — so a removed column cannot break either. (A removed
177
+ # rails-managed table still can; #diff handles that separately.)
178
+ private def extracted?(config)
179
+ !config["ignore"] && !TableConfig::RAILS_MANAGED_TYPES.include?(config["type"])
180
+ end
181
+
182
+ # The columns the extraction SELECT actually names: `ignore: true` columns
183
+ # are dropped when the config loads, so their staleness breaks nothing.
184
+ private def extracted_column_names(config)
185
+ (config["columns"] || config["fields"] || [])
186
+ .reject { |column| column["ignore"] }
187
+ .map { |column| column["name"] }
188
+ end
189
+
151
190
  # How a table is named in the report. The database is part of the label, or
152
191
  # the same table name in two of them collides (each has `schema_migrations`).
153
192
  private def table_label(key, config)
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia