exwiw 1.0.0 → 1.1.1
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 +22 -0
- data/README.md +23 -6
- data/lib/exwiw/adapter/mysql_adapter.rb +16 -2
- data/lib/exwiw/cli.rb +41 -3
- data/lib/exwiw/query_ast_builder.rb +129 -31
- data/lib/exwiw/schema_check.rb +41 -2
- data/lib/exwiw/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be2a5e77df07b1f463bb898d1ae06fd98bf1744d5e9cd82951bb37ba080ed379
|
|
4
|
+
data.tar.gz: dfb735937b5afb901a314fc72349e6093df0796b166e1d9b4a7212de0a2654e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9736dc30f83eb07744acc42413f2b2f697c19267bcbd40a355166e78e8fae7fdd42c66f057d5583297dc21f7c9ac6a2dd84814f1cfd819208dadad97e5b3afda
|
|
7
|
+
data.tar.gz: bf9797812840921845668d489015f05d017418e298075bde89fdc98c1d32f322520f8194a6b4f989a47fc65a5d0955ffd941fc51c20270745a391a18361e26b7
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.1] - 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`logger` is now a declared runtime dependency.** It stopped being a default gem in Ruby 4.0, so `exwiw` running under `bundle exec` on Ruby 4.0 failed at load with `cannot load such file -- logger` unless the application happened to depend on it itself (Rails applications do, through `activesupport`, which is why the gem's own test suite never noticed). Invoking the bare `exwiw` executable outside Bundler was unaffected, since RubyGems finds the bundled gem on its own.
|
|
10
|
+
|
|
11
|
+
## [1.1.0] - 2026-09-07
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`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.
|
|
16
|
+
|
|
17
|
+
- **`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.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
5
27
|
## [1.0.0] - 2026-09-01
|
|
6
28
|
|
|
7
29
|
### 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
|
|
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)).
|
|
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
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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).
|
|
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/schema_check.rb
CHANGED
|
@@ -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
|
-
|
|
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
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.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: logger
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
26
40
|
description: Export What I Want (Exwiw) is a Ruby gem that allows you to export records
|
|
27
41
|
from a database to a dump file.
|
|
28
42
|
email:
|