exwiw 0.9.21 → 0.9.22

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: 61f6374ae972477087c63744e9a3702acc795872f7d08599cef69bc7898563db
4
- data.tar.gz: 5a140799d94c5804fec5988c0643acbcc79c33ed442e7be36fe2b65bcce14a3b
3
+ metadata.gz: 3abd99db26266c75e79f46cd0fc14a2a0344308031422385ca22dd2a77ab0130
4
+ data.tar.gz: 9c78446793e1c91947bcf7aa6b933d623c87d004d84fbcb3df8b86886e136482
5
5
  SHA512:
6
- metadata.gz: 78154be467c8920c5011f527957635145089c07bba5268ce4ebe7ac3272622243acb36b05bd1b538740f76a9167dafe48498a022b458bdd11610544d2c10d446
7
- data.tar.gz: 7aea16379a332986f78b18f07a1c6c258745c407a804510de1451c17324c1533f7f17fda6ed725bc4e982e5b5824395fd96f01ffa30fc46c90b0a2e10d25c033
6
+ metadata.gz: 0406c29183e0a896ae9de40e24622239caa4cf06b898784d1aa24633d479b0e4ee5717f8d755e5c323af1dd290e18e92f201f4d2ac6f192feb71e9c1a947eee2
7
+ data.tar.gz: 63e754207a3c08d97d9b9ceed684b5e112c770c02e9638dd80e9ba958fd1d9ecc96784e6d0431cb27c818e254177d62b506ea587fc1ba55d7068125c889a7fd2
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.22] - 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - **The safe-mode / check workflow now covers Mongoid applications: `schema:generate_mongoid` flags new fields, and `rake exwiw:schema:check_mongoid` / `exwiw:schema:tidy_mongoid` complete the loop.** `generate_mongoid` runs in safe mode by default (opt out with `EXWIW_NEW_COLUMNS=plain`, same as `generate`): every field the config does not have yet is emitted flagged `needs_mask_decision: true`, with a default `replace_with` where the field's Mongoid type allows one — the same per-type defaults the ActiveRecord generator emits, with `masked-{_id}` for text (the MongoDB adapter renders the placeholder per document). Structural fields are flagged but never masked: `_id`, the STI discriminator (`Model.discriminator_key`), and every `belongs_to` foreign key declared by the collection's models — including the ones the generator deliberately does not emit as `belongs_tos` (polymorphic relations, and referenced relations on embedded documents), whose foreign keys and polymorphic type fields are still live cross-collection references. Uniqueness comes from the model's `index_specifications` (any field of a unique index only gets a mask that varies per document); an index never declared on the model is invisible here, since class-level introspection is what keeps the whole workflow connection-free. `tidy_mongoid` deletes the config file of a collection no model stores into any more — the removal `generate_mongoid` can never do — using exactly the collection grouping `generate!` writes files from, so the two cannot disagree. A config declaring `embedded_in` is never deleted, whatever its name: when two embedded classes share a collection name only one of them can be generated, so the other exists only as a hand-written file named for where it lives — a name that matches no model by construction, and whose deletion would silently export those subdocuments unmasked. A file that does not parse is left in place with a warning for the same reason: its name cannot be trusted to say what it describes. `check_mongoid` mirrors `check` (same JSON report, `EXWIW_SCHEMA_CHECK_OUTPUT`, non-zero exit on drift or an unresolved flag), regenerating into a throwaway copy via `SchemaCheck.from_mongoid_application`; `SchemaCheck` itself now takes an injectable `regenerator:`, which is what makes a non-ActiveRecord schema source pluggable.
10
+
11
+ - **`exwiw schema generate|check|tidy --from-db` regenerates and checks a schema config straight from a live database connection, so an application exwiw cannot load — any language, no Rails — gets the same safe-mode / check workflow.** The introspectors (MySQL and PostgreSQL) read tables, single-column primary keys, ordinal-ordered columns with types mapped to the same symbols `DefaultMask` masks by, unique indexes, and single-column foreign-key constraints; views and composite foreign keys are out (the latter skipped with a warning), a composite primary key is emitted as the same `ignore: true` signpost the ActiveRecord generator writes, and a table with **no** primary key — which ActiveRecord can never report but a database is free to have — is emitted `ignore: true` with a comment naming the fix. Safe mode matches `schema:generate` (`EXWIW_NEW_COLUMNS=plain` opts out) and `schema check` matches `rake exwiw:schema:check` (same JSON report, `EXWIW_SCHEMA_CHECK_OUTPUT`, exit 1 on drift or an unresolved `needs_mask_decision` — and exit 2 when the check could not run at all, so an unreachable database never reads as "config out of date"). One deliberate departure from the model-driven generator: **introspection only ever adds a `belongs_to`, never rewrites the list.** A foreign-key constraint is strictly weaker evidence than an application model — plenty of schemas express a relation only in application code, and hand-written belongs_tos are load-bearing extraction paths — so the existing config's relations are kept verbatim (their foreign-key columns are protected from default masks too) and constraint-discovered edges are appended; removal is `schema tidy`'s explicit job, driven by the target table actually being gone, with `ignore: true` tombstones kept. Connection settings come from the same flags/env the exporter uses; `DATABASE_PASSWORD` may be empty here (CI databases commonly run with trust auth), while `export`'s requirement is unchanged. A `primary_key` the user writes by hand on one of the `ignore: true` signpost tables survives regeneration and promotes the table to an ordinary config: the signposts exist to be acted on, and the database still reports no usable key, so the config is the only record of that decision.
12
+
13
+ ### Fixed
14
+
15
+ - **Regenerating a Mongoid config no longer re-masks a field somebody deliberately unmasked.** `MongodbCollectionConfig#merge` kept the generated side's `replace_with` when the on-disk field had none — harmless while the generator emitted no masks, but wrong under safe mode, which proposes default masks: a field whose flag was resolved as "export raw" would silently get its mask back on the next regeneration. For a field the config already has, every decision-owned attribute (`replace_with`, `replace_with_fake_data`, `comment`, `ignore`, `needs_mask_decision`) now comes from the on-disk side even when unset; only structural facts (`name`, `mongoid_field_name`) track the model.
16
+
17
+ - `schema:generate` no longer drops a hand-set `bulk_insert_chunk_size` when regenerating an existing config. The key is user-owned — the generator never emits it — but the merge took it from the freshly generated side (always empty) instead of carrying the existing value over like `filter`, `scope_column` and the other user-owned keys, so every regeneration silently deleted it.
18
+
5
19
  ## [0.9.21] - 2026-08-06
6
20
 
7
21
  ### Changed
data/README.md CHANGED
@@ -19,7 +19,7 @@ You want to export only the data you want to export.
19
19
 
20
20
  - Export the full list of INSERT sql for the specified conditions.
21
21
  - Provide serveral masking options for sensitive columns.
22
- - Provide config generator for ActiveRecord.
22
+ - Provide config generator for ActiveRecord, for Mongoid, and from a live database connection (any application, any language).
23
23
 
24
24
  ## Installation
25
25
 
@@ -56,10 +56,11 @@ driver implements that auth handshake itself and sidesteps the issue.
56
56
 
57
57
  ## Usage
58
58
 
59
- exwiw has two subcommands:
59
+ 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
64
 
64
65
  ### `exwiw export`
65
66
 
@@ -326,7 +327,7 @@ Notes:
326
327
  - **Database connection settings stay on the CLI/environment.** `host`, `port`, `user`, `database`, `uri`, and `password` are **rejected** in the config file (exwiw exits with an error). `adapter` is the one connection-related key that *is* allowed in the file.
327
328
  - **Relative paths in the config (`schema_dir`, `output_dir`, `after_insert_hook`) are resolved relative to the config file's own directory**, not the current working directory. So with the config at the project root, `schema_dir: exwiw/schema` reads naturally, and an absolute `--config=/path/to/exwiw.yml` works no matter where you run from. (CLI path flags remain relative to the current directory — each source resolves relative to where it is written.) Absolute paths are used as-is.
328
329
  - Unknown keys are rejected so a typo surfaces immediately.
329
- - Export-only keys (`output_dir`, `output_format`, `insert_only`, `after_insert_hook`) are ignored when running `explain`, so a single config file can be shared by both subcommands.
330
+ - Export-only keys (`output_dir`, `output_format`, `insert_only`, `after_insert_hook`) are ignored when running `explain` or `schema`, so a single config file can be shared by every subcommand.
330
331
  - `explain_verbosity` sets the mongodb `explain` verbosity (`queryPlanner` | `executionStats` | `allPlansExecution`, default `queryPlanner`); the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` env var overrides it. Ignored by the SQL adapters and by `export`. See [MongoDB support](docs/mongodb.md#exwiw-explain-verbosity).
331
332
  - `mongodb_query_timeout_ms` sets the global, server-enforced query timeout (mongodb only); the `--mongodb-query-timeout-ms` CLI flag overrides it. Ignored by the SQL adapters. See [MongoDB support](docs/mongodb.md).
332
333
 
@@ -351,6 +352,12 @@ EXWIW_SCHEMA_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
351
352
 
352
353
  As with the CLI, a relative `schema_dir` in the config file is resolved relative to the config file's own directory.
353
354
 
355
+ An application with more than one schema source — ActiveRecord models and Mongoid documents, say —
356
+ should give each source its own directory (`EXWIW_SCHEMA_DIR_PATH`), because every task judges the
357
+ whole directory against the one source it reads. Left sharing a directory, `tidy` / `check` see the
358
+ other source's configs as belonging to tables and collections that no longer exist, and report or
359
+ remove them.
360
+
354
361
  #### Safe mode (masking new columns by default)
355
362
 
356
363
  A migration that adds a column would otherwise leave `schema:generate` emitting it unmasked, so
@@ -394,8 +401,15 @@ column are flagged but deliberately **not** masked:
394
401
  the primary key). A constant would collapse every row onto one value and break the restore with
395
402
  a duplicate key.
396
403
 
397
- Safe mode is ActiveRecord-only for now: `schema:generate_mongoid` does not flag new fields yet,
398
- though the `needs_mask_decision` key itself is understood on a MongoDB field.
404
+ `schema:generate_mongoid` runs in safe mode too, on the same `EXWIW_NEW_COLUMNS=plain` opt-out. The
405
+ masks come from the Mongoid field type (`String`, `Integer`, `Float`, `BigDecimal`,
406
+ `Mongoid::Boolean`, `Date`, `Time` / `DateTime` / `ActiveSupport::TimeWithZone`); a field of any
407
+ other type — `Hash`, `Array`, a typeless field, a BSON type — is flagged but not masked, as is a
408
+ field covered by a unique index unless its mask varies per document. The structural fields are the
409
+ `_id` primary key, the STI discriminator (`_type`) and every `belongs_to` foreign key of the
410
+ collection: flagged, never masked. The foreign keys are read from the models, so the ones the
411
+ config itself drops (a polymorphic `belongs_to`, a `belongs_to` on an embedded document) are
412
+ covered too.
399
413
 
400
414
  #### Tidying stale config (`schema:tidy`)
401
415
 
@@ -448,8 +462,12 @@ databases stays distinct.
448
462
  Set `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` to have the same JSON written to a file, which spares a
449
463
  caller from assuming stdout carries nothing else (application boot is free to print).
450
464
 
451
- Like safe mode, this is ActiveRecord-only it regenerates through `SchemaGenerator`, so a
452
- Mongoid config directory is not supported yet.
465
+ A Mongoid config directory has its own task, `schema:check_mongoid`, with the same output,
466
+ the same `EXWIW_SCHEMA_CHECK_OUTPUT` file and the same exit code — it just regenerates through
467
+ `MongoidSchemaGenerator` (safe mode + `tidy_mongoid`) instead. Collections and fields are
468
+ reported under the same keys as tables and columns. An application that cannot be loaded to
469
+ generate from its models at all can run the same check against its database instead: see
470
+ [Non-Rails applications](#non-rails-applications-exwiw-schema---from-db).
453
471
 
454
472
  #### Multiple databases
455
473
 
@@ -479,10 +497,82 @@ For MongoDB applications backed by [Mongoid](https://www.mongodb.com/docs/mongoi
479
497
 
480
498
  ```bash
481
499
  bundle exec rake exwiw:schema:generate_mongoid
500
+ bundle exec rake exwiw:schema:tidy_mongoid # delete the config of a collection no model stores into
501
+ bundle exec rake exwiw:schema:check_mongoid # report the difference without changing anything
482
502
  ```
483
503
 
484
504
  What it derives from each model (fields, `belongs_tos`, `embedded_in`, STI handling), how to annotate constructs exwiw cannot represent with `ignore` / `ignore_type`, and the `EXWIW_SKIP_UNSUPPORTED=1` bootstrap flag are all documented in [MongoDB support](docs/mongodb.md#generating-config-from-mongoid-models).
485
505
 
506
+ `tidy_mongoid` reconciles against the *models* rather than a live connection (MongoDB has no schema to read, and a collection exists only once something is written to it): a config file whose collection no model stores into any more is deleted, and nothing else is touched — fields already track the models through `generate_mongoid`. `check_mongoid` runs both into a throwaway copy, so it never writes to the working tree.
507
+
508
+ #### Non-Rails applications (`exwiw schema ... --from-db`)
509
+
510
+ The rake tasks above read the application's models, which requires loading the application — so
511
+ they are only available where that is possible. For an application written in any other language
512
+ (or a Ruby one exwiw cannot boot), the same three operations are available on the CLI, reading
513
+ the **database** instead of the models:
514
+
515
+ ```bash
516
+ # generate / refresh the config from the live schema
517
+ exwiw schema generate --from-db -a postgresql -h db.example.com -p 5432 -u app --database=app --schema-dir=exwiw/schema
518
+
519
+ # report how the committed config differs from the database (exits 1 when it needs work)
520
+ exwiw schema check --from-db -a postgresql -h db.example.com -p 5432 -u app --database=app --schema-dir=exwiw/schema
521
+
522
+ # remove tables/columns/relations the database no longer has
523
+ exwiw schema tidy --from-db -a postgresql -h db.example.com -p 5432 -u app --database=app --schema-dir=exwiw/schema
524
+ ```
525
+
526
+ - `--from-db` is **required**: the schema source is always stated explicitly rather than inferred.
527
+ - mysql and postgresql only. sqlite is not supported, and a MongoDB schema lives in the
528
+ application rather than the database (use `schema:generate_mongoid`).
529
+ - The usual connection flags and `DATABASE_PASSWORD` apply, and `adapter` / `schema_dir` may come
530
+ from [the config file](#config-file-exwiwyml) instead (`--schema-dir` wins). Unlike `export`,
531
+ an empty or absent `DATABASE_PASSWORD` is accepted, since these commands are commonly pointed
532
+ at a CI database that runs with trust authentication.
533
+ - `generate` creates the schema directory if it does not exist; `check` and `tidy` require it.
534
+ - Everything else behaves as the rake tasks do: [safe mode](#safe-mode-masking-new-columns-by-default)
535
+ is on unless `EXWIW_NEW_COLUMNS=plain`, `check` prints the same JSON report, honours
536
+ `EXWIW_SCHEMA_CHECK_OUTPUT`, and exits 1 when the config needs attention. A check that could
537
+ not *run* (an unreachable database, a malformed config) exits with a different status, so CI
538
+ can tell the two apart.
539
+ - One run covers one database — the connection addresses one — so the files are written flat into
540
+ the schema directory. There is no per-database subdirectory layout here; a second database is a
541
+ second run against a second connection.
542
+
543
+ The database is read through its catalog only: tables, columns and their types/defaults, primary
544
+ keys, unique indexes and foreign keys. Views are skipped, since they hold no rows of their own,
545
+ and a table with no primary key is emitted with `ignore: true` and a comment saying what to add
546
+ to export it (exwiw identifies and joins rows by primary key). Following that advice sticks: a
547
+ `primary_key` written by hand — on a table the database reports none for, or one column of a
548
+ composite key that identifies a row on its own — is kept by later runs, which then treat the table
549
+ as an ordinary one rather than re-imposing the signpost `type` / `comment`.
550
+
551
+ **`belongs_tos` are only ever added, never rewritten.** A foreign-key constraint is weaker
552
+ evidence than an application model: plenty of schemas express a relation only in application code,
553
+ and a `belongs_to` is the path extraction follows to reach a table, so silently dropping one
554
+ narrows the dump. Regeneration therefore keeps every relation the config already declares —
555
+ in its existing order, with its `comment` / `ignore` / `ignore_type` — and appends only the
556
+ foreign-key-backed relations that are not there yet. A hand-written relation's foreign-key column
557
+ is treated as structural too, so safe mode never masks it. Removing a relation is `tidy`'s job:
558
+ it drops a `belongs_to` whose target table no longer exists in the database (an `ignore: true`
559
+ entry is kept, since it records a decision), alongside the tables and columns that are gone.
560
+
561
+ So a relation the database does not know about is declared once, by hand, and survives from then
562
+ on:
563
+
564
+ ```json
565
+ {
566
+ "name": "orders",
567
+ "primary_key": "id",
568
+ "belongs_tos": [{
569
+ "table_name": "buyers",
570
+ "foreign_key": "buyer_id",
571
+ "comment": "enforced in the application; no foreign key in the database"
572
+ }]
573
+ }
574
+ ```
575
+
486
576
  ### Configuration
487
577
 
488
578
  This is an example of the one table schema:
@@ -628,10 +718,11 @@ nobody has decided on yet:
628
718
  ```
629
719
 
630
720
  Extraction ignores the key entirely — what the column exports is whatever `replace_with` /
631
- `ignore` say. It exists so the decision can be tracked and required: `schema:generate`'s
721
+ `ignore` say. It exists so the decision can be tracked and required:
632
722
  [safe mode](#safe-mode-masking-new-columns-by-default) attaches it to every newly discovered
633
- column together with a default mask, and [`schema:check`](#checking-the-config-against-the-schema)
634
- reports the columns that still carry it, so CI can keep a pull request red until each one is
723
+ column/field together with a default mask, and
724
+ [`schema:check`](#checking-the-config-against-the-schema) `schema:check_mongoid` for a Mongoid
725
+ config — reports the ones that still carry it, so CI can keep a pull request red until each is
635
726
  resolved. Resolving it means removing the key — after keeping the mask (ideally recording why
636
727
  in `comment`), replacing it with a real masking rule, dropping `replace_with` to export the
637
728
  raw value, or setting `ignore: true`.
data/docs/mongodb.md CHANGED
@@ -149,6 +149,19 @@ Models in an inheritance hierarchy whose subclasses share the base's collection
149
149
 
150
150
  Regeneration preserves hand-edited `replace_with`, `filter`, `ignore`, `bulk_insert_chunk_size`, and `query_timeout_ms` values, like the ActiveRecord generator. Indexes are not written to the config — they are introspected from the live database at dump time (see [Output](#output)). Polymorphic `belongs_to` is not yet expanded by this task.
151
151
 
152
+ ### Safe mode, `tidy_mongoid` and `check_mongoid`
153
+
154
+ Like the ActiveRecord task, `generate_mongoid` runs in [safe mode](../README.md#safe-mode-masking-new-columns-by-default) by default (`EXWIW_NEW_COLUMNS=plain` opts out for a first bootstrap): a field the config does not have yet is emitted masked — as far as its Mongoid type allows — and flagged `needs_mask_decision: true`, so a field added to a model does not start being exported before somebody has judged whether it holds personal data. The `_id` primary key, the `_type` STI discriminator and every `belongs_to` foreign key are flagged but never masked (masking them would rewrite the very references the dump is assembled from), and neither is a field whose type no constant fits (`Hash`, `Array`, a typeless field, a BSON type) or one covered by a unique index unless its mask varies per document. A field's own `default:` is preferred over the per-type constant. A decision recorded on disk — a mask kept, replaced, dropped, or the flag removed — wins over every later regeneration.
155
+
156
+ ```bash
157
+ bundle exec rake exwiw:schema:tidy_mongoid # delete the config of a collection no model stores into
158
+ bundle exec rake exwiw:schema:check_mongoid # report the difference, writing nothing
159
+ ```
160
+
161
+ `tidy_mongoid` is the counterpart `generate_mongoid` cannot be: it deletes the config file of a collection no model stores into any more. It reconciles against the **models**, not a live connection — MongoDB has no schema to read, and a collection exists only once a document is written to it — using exactly the grouping `generate_mongoid` writes files from, embedded collections included. Fields are left alone, since `generate_mongoid` already tracks them through the model.
162
+
163
+ `check_mongoid` mirrors [`schema:check`](../README.md#checking-the-config-against-the-schema): it regenerates (safe mode + tidy) into a throwaway copy of the config directory, prints the same sorted JSON report — collections and fields under the same keys as tables and columns — honors `EXWIW_SCHEMA_CHECK_OUTPUT`, and exits non-zero while anything is out of date or undecided, so it can gate a pull request. It regenerates in strict mode: a newly introduced construct exwiw cannot represent fails the task rather than reporting clean.
164
+
152
165
  By default the task **aborts** when a model uses a construct exwiw cannot represent: a `belongs_to` whose target class can no longer be resolved (a stale relation left behind after its model was removed), or a polymorphic / self-referential-cyclic / ambiguous / unresolvable-parent `embedded_in` (see the cases above).
153
166
 
154
167
  ### Honoring an explicit `ignore` (the recommended way to keep these out)
data/lib/exwiw/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'logger'
4
5
  require 'optparse'
5
6
  require 'pathname'
@@ -11,7 +12,18 @@ require 'exwiw'
11
12
 
12
13
  module Exwiw
13
14
  class CLI
14
- KNOWN_SUBCOMMANDS = %w[export explain].freeze
15
+ KNOWN_SUBCOMMANDS = %w[export explain schema].freeze
16
+
17
+ # The verbs `exwiw schema` takes, mirroring the `exwiw:schema:*` rake tasks
18
+ # a Rails application would run instead.
19
+ SCHEMA_VERBS = %w[generate check tidy].freeze
20
+
21
+ # `schema check` exit codes. 1 means "the config needs work" — that is the
22
+ # signal CI acts on — so a failure to *perform* the check (an unreachable
23
+ # database, a malformed config) must not use it, or an infrastructure
24
+ # problem reads as a schema problem and gets "fixed" by regenerating.
25
+ SCHEMA_CHECK_DIRTY_EXIT = 1
26
+ SCHEMA_CHECK_ERROR_EXIT = 2
15
27
 
16
28
  # Config file loaded automatically when --config is omitted, if one exists in
17
29
  # the current directory. Kept at the project root (rather than under exwiw/)
@@ -52,7 +64,8 @@ module Exwiw
52
64
  REJECTED_CONNECTION_KEYS = %w[host port user database uri password].freeze
53
65
 
54
66
  # Keys that only make sense for `export`. They are skipped when merging config
55
- # for `explain` so a shared config file does not trip validate_explain_only!.
67
+ # for `explain` so a shared config file does not trip validate_explain_only!,
68
+ # and for `schema`, which performs no export at all.
56
69
  EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format insert_only after_insert_hook parallel_workers].freeze
57
70
 
58
71
  def self.start(argv)
@@ -69,6 +82,16 @@ module Exwiw
69
82
  "export"
70
83
  end
71
84
 
85
+ # `schema` is the one subcommand taking a verb of its own
86
+ # (`exwiw schema generate`), so the next positional argument belongs to
87
+ # it. An unknown or missing verb is not consumed here — validation
88
+ # reports it with the list of valid ones rather than OptionParser
89
+ # failing on a stray argument.
90
+ @schema_verb =
91
+ if @subcommand == "schema" && !@argv.empty? && !@argv.first.start_with?("-")
92
+ @argv.shift
93
+ end
94
+
72
95
  @help = @argv.empty?
73
96
 
74
97
  @database_host = nil
@@ -78,6 +101,7 @@ module Exwiw
78
101
  @connection_uri = nil
79
102
  @output_dir = nil
80
103
  @schema_dir = nil
104
+ @from_db = false
81
105
  @config_file_path = nil
82
106
  @database_adapter = nil
83
107
  @database_name = nil
@@ -151,9 +175,94 @@ module Exwiw
151
175
  io: $stdout,
152
176
  explain_verbosity: @explain_verbosity,
153
177
  ).run
178
+ when "schema"
179
+ run_schema(connection_config)
154
180
  end
155
181
  end
156
182
 
183
+ # `exwiw schema generate|check|tidy --from-db`: the same three operations
184
+ # the `exwiw:schema:*` rake tasks perform for a Rails application, driven
185
+ # from a database connection instead of from the application's models.
186
+ private def run_schema(connection_config)
187
+ introspector = DbIntrospector.build(connection_config)
188
+
189
+ case @schema_verb
190
+ when "generate" then run_schema_generate(introspector)
191
+ when "tidy" then run_schema_tidy(introspector)
192
+ when "check" then run_schema_check(introspector)
193
+ end
194
+ end
195
+
196
+ private def run_schema_generate(introspector)
197
+ DbSchemaGenerator.new(
198
+ introspector: introspector,
199
+ output_dir: @schema_dir,
200
+ safe_new_columns: schema_safe_new_columns?,
201
+ ).generate!
202
+
203
+ puts "exwiw: wrote the schema config for #{@database_name} to #{@schema_dir}."
204
+ end
205
+
206
+ private def run_schema_tidy(introspector)
207
+ result = DbSchemaGenerator.new(introspector: introspector, output_dir: @schema_dir).tidy!
208
+
209
+ if result.empty?
210
+ puts "exwiw: schema config is already tidy; nothing to remove."
211
+ return
212
+ end
213
+
214
+ result.removed_tables.each do |name|
215
+ puts "exwiw: removed config for table '#{name}' (no longer exists in the database)."
216
+ end
217
+ result.removed_columns.each do |table_name, columns|
218
+ puts "exwiw: removed column(s) #{columns.join(', ')} from '#{table_name}' (no longer in the table)."
219
+ end
220
+ result.removed_belongs_tos.each do |table_name, targets|
221
+ puts "exwiw: removed belongs_to(s) to #{targets.join(', ')} from '#{table_name}' " \
222
+ "(the target table no longer exists)."
223
+ end
224
+ end
225
+
226
+ private def run_schema_check(introspector)
227
+ # Regenerate exactly as `generate` + `tidy` would, into the copy
228
+ # SchemaCheck hands over, so the report is the diff against a config that
229
+ # has been through both steps rather than only the first.
230
+ regenerator = lambda do |tmp_dir|
231
+ # Explicit: safe mode is not optional here, whatever the library default is.
232
+ DbSchemaGenerator.new(introspector: introspector, output_dir: tmp_dir, safe_new_columns: true).generate!
233
+ DbSchemaGenerator.new(introspector: introspector, output_dir: tmp_dir).tidy!
234
+ end
235
+
236
+ report =
237
+ begin
238
+ SchemaCheck.new(schema_dir: @schema_dir, regenerator: regenerator).run
239
+ rescue StandardError => e
240
+ # See SCHEMA_CHECK_ERROR_EXIT: not being able to run the check is a
241
+ # different answer from "the config is out of date".
242
+ $stderr.puts "exwiw: could not run the schema check (#{e.class}: #{e.message})"
243
+ exit SCHEMA_CHECK_ERROR_EXIT
244
+ end
245
+
246
+ json = JSON.pretty_generate(report)
247
+ puts json
248
+ # A file too, so a caller need not assume stdout carries only the JSON.
249
+ File.write(ENV["EXWIW_SCHEMA_CHECK_OUTPUT"], json + "\n") if ENV["EXWIW_SCHEMA_CHECK_OUTPUT"]
250
+
251
+ return if SchemaCheck.clean?(report)
252
+
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."
256
+ exit SCHEMA_CHECK_DIRTY_EXIT
257
+ end
258
+
259
+ # Safe mode is on unless EXWIW_NEW_COLUMNS=plain, matching the rake task: a
260
+ # first-time bootstrap wants it off, since there every column is new and
261
+ # flagging the whole config at once is noise.
262
+ private def schema_safe_new_columns?
263
+ ENV["EXWIW_NEW_COLUMNS"] != "plain"
264
+ end
265
+
157
266
  private def validate_options!
158
267
  # Fill in any options not given on the CLI from the config file. Done first
159
268
  # so a config-provided `adapter` is in place before normalization below.
@@ -188,6 +297,16 @@ module Exwiw
188
297
  resolve_explain_verbosity!
189
298
  end
190
299
 
300
+ # `schema` shares only the connection options with the other subcommands:
301
+ # it neither extracts rows nor reads a dump target, so every check below
302
+ # is about an export it will not perform. Its own requirements — a schema
303
+ # source, an introspectable adapter, a schema dir the verb can use — are
304
+ # validated instead.
305
+ if @subcommand == "schema"
306
+ validate_schema_options!
307
+ return
308
+ end
309
+
191
310
  if @database_adapter != "sqlite"
192
311
  # When a connection URI is supplied (mongodb only), host/port/database
193
312
  # are read from the URI, so none of them are required on the CLI.
@@ -315,7 +434,9 @@ module Exwiw
315
434
 
316
435
  # For `explain`, drop export-only keys so a config shared with `export`
317
436
  # does not make validate_explain_only! reject the run.
318
- config = config.reject { |k, _| EXPORT_ONLY_CONFIG_KEYS.include?(k) } if @subcommand == "explain"
437
+ if @subcommand == "explain" || @subcommand == "schema"
438
+ config = config.reject { |k, _| EXPORT_ONLY_CONFIG_KEYS.include?(k) }
439
+ end
319
440
 
320
441
  @database_adapter ||= config["adapter"]
321
442
  @schema_dir ||= expand_dir(config["schema_dir"], base)
@@ -494,6 +615,66 @@ module Exwiw
494
615
  exit 1
495
616
  end
496
617
 
618
+ # Everything `exwiw schema <verb>` needs, and nothing the export path
619
+ # requires. Runs instead of the export/explain validations, not after them.
620
+ private def validate_schema_options!
621
+ unless SCHEMA_VERBS.include?(@schema_verb)
622
+ $stderr.puts "Usage: exwiw schema #{SCHEMA_VERBS.join('|')} --from-db [options] " \
623
+ "(got #{@schema_verb.inspect})"
624
+ exit 1
625
+ end
626
+
627
+ # The schema source is spelled out rather than assumed. Reading a live
628
+ # database is one way to describe an application's schema and not the
629
+ # only conceivable one, so the flag keeps the command unambiguous today
630
+ # and leaves the reader expecting alternatives tomorrow.
631
+ unless @from_db
632
+ $stderr.puts "`exwiw schema #{@schema_verb}` requires --from-db to say where the schema is read from " \
633
+ "(a live database connection). A Rails application can use the exwiw:schema:#{@schema_verb} " \
634
+ "rake task instead, which reads its models."
635
+ exit 1
636
+ end
637
+
638
+ unless DbIntrospector::SUPPORTED_ADAPTERS.include?(@database_adapter)
639
+ $stderr.puts "--from-db supports the #{DbIntrospector::SUPPORTED_ADAPTERS.join(' and ')} adapters only " \
640
+ "(got '#{@database_adapter}'). sqlite and mongodb schemas are not read this way."
641
+ exit 1
642
+ end
643
+
644
+ {
645
+ "Target database host" => @database_host,
646
+ "Target database port" => @database_port,
647
+ "Target database name" => @database_name,
648
+ "Database user" => @database_user,
649
+ }.each do |name, value|
650
+ if value.nil?
651
+ $stderr.puts "#{name} is required"
652
+ exit 1
653
+ end
654
+ end
655
+
656
+ # Deliberately no DATABASE_PASSWORD requirement, unlike export: schema
657
+ # generation is commonly run in CI against a throwaway database started
658
+ # with trust/empty authentication, and refusing an empty password would
659
+ # make the check unrunnable exactly where it is most wanted. The password
660
+ # is still used when set.
661
+
662
+ if @schema_dir.nil?
663
+ $stderr.puts "Schema dir is required (pass --schema-dir or set schema_dir in the config file)"
664
+ exit 1
665
+ end
666
+
667
+ # `generate` is also the bootstrap command, so it creates the directory;
668
+ # `check` and `tidy` only ever read an existing config, and a missing
669
+ # directory there means the wrong path far more often than an empty one.
670
+ if @schema_verb == "generate"
671
+ FileUtils.mkdir_p(@schema_dir)
672
+ elsif !Dir.exist?(@schema_dir)
673
+ $stderr.puts "Schema dir does not exist: #{@schema_dir}"
674
+ exit 1
675
+ end
676
+ end
677
+
497
678
  private def validate_explain_only!
498
679
  rejected = []
499
680
  rejected << "--output-dir" unless @output_dir.nil?
@@ -603,6 +784,11 @@ module Exwiw
603
784
  For mongodb, set verbosity via EXWIW_MONGODB_EXPLAIN_VERBOSITY
604
785
  or `explain_verbosity:` in config (queryPlanner (default,
605
786
  no query is executed) | executionStats | allPlansExecution).
787
+ schema Maintain the schema config (generate | check | tidy) by
788
+ reading a live database, for applications that cannot be
789
+ loaded to generate it from their models. Requires --from-db;
790
+ mysql and postgresql only. `check` prints a JSON report and
791
+ exits 1 when the config needs work.
606
792
  BANNER
607
793
  opts.version = Exwiw::VERSION
608
794
 
@@ -617,6 +803,7 @@ module Exwiw
617
803
  v = v.end_with?("/") ? v[0..-2] : v
618
804
  @schema_dir = File.expand_path(v)
619
805
  end
806
+ opts.on("--from-db", "Read the schema from the database the connection options point at (schema subcommand only; mysql/postgresql). Required by `exwiw schema`.") { @from_db = true }
620
807
  opts.on("-c", "--config=CONFIG_FILE_PATH", "Path to the exwiw config YAML. Defaults to ./#{DEFAULT_CONFIG_PATHS.first} (or .#{File.extname(DEFAULT_CONFIG_PATHS.last)}) when present. CLI options take precedence; paths inside the file are resolved relative to the file.") do |v|
621
808
  @config_file_path = File.expand_path(v)
622
809
  end