exwiw 0.9.21 → 0.9.23

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: 9cbe5a94be08902db6990812f48eb07b3aa5cae7a507acb0f8c3cf1f90edc175
4
+ data.tar.gz: 19b92d8feee9e874135fc0c9f833d290aacdb1be35c6bcd3da72c7c212352327
5
5
  SHA512:
6
- metadata.gz: 78154be467c8920c5011f527957635145089c07bba5268ce4ebe7ac3272622243acb36b05bd1b538740f76a9167dafe48498a022b458bdd11610544d2c10d446
7
- data.tar.gz: 7aea16379a332986f78b18f07a1c6c258745c407a804510de1451c17324c1533f7f17fda6ed725bc4e982e5b5824395fd96f01ffa30fc46c90b0a2e10d25c033
6
+ metadata.gz: 75e631242a39ba72bd16ed7d28435f935c9d591d89888632f5fd835b2b727d6f497a976b91bbcc69bdbc8c85fbfb68d2265488d3b07b19baa37001a7d832d173
7
+ data.tar.gz: abcfacf75eff9c74324fae77915f10b86ab7258f5251bf5fa7adbe73a3af318fd06ffc4e791e7abc00439b1007901d43e80865ff0394ada7eb8deba040707d1d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.23] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - **A collection stored into by both a top-level and an embedded model is now generated as the top-level collection it is, instead of silently ceasing to be dumped.** `MongoidSchemaGenerator` treated a collection group as embedded as soon as *any* model in it was — and an embedded class's collection name derives from its class name alone, so it collides with any top-level model that stores into a collection of that name, with nothing declaring the two to be related and neither side wrong. The emitted config then carried an `embedded_in` and an empty `belongs_tos`, with three consequences: `MongodbAdapter#dumpable?` (`!embedded?`) silently never dumped the collection's root documents; the top-level models' `belongs_tos` — the extraction paths that reach everything scoped below them — disappeared from the config; and a hand-maintained top-level config could not survive regeneration either, because `MongodbCollectionConfig#merge` always takes the freshly generated `embedded_in`, so `schema:check_mongoid` reported that file as changed on every run with no way to resolve it. Such a collection is genuinely top-level — it has root documents that must be dumped — so it is now built from its **root** models alone (their fields, their `belongs_tos`, and safe mode's structural/unique sets), with no `embedded_in`, and the collision is named on stderr instead: the embedded documents of the same name are **not** covered by that config and are masked, as before, through a config written by hand with an `embedded_in` and a name of its own (generation and `tidy_mongoid` both leave `embedded_in` configs alone, so such a file is stable). The collision is deliberately *not* recorded as a generated `comment` on the config, since a generated comment wins the merge and would overwrite — on every run — the note a user wrote about this very situation. Only a *root* model counts as evidence of a root collection, which is the second shape this distinguishes: an **embedded family under a plain base class** (`Address` holding the shared fields and declaring no `embedded_in`, `BillingAddress < Address` declaring one and inheriting the collection name) is not mixed at all. `embedded?` answers "does this class declare an `embedded_in`", not "does this collection have root documents", and a shared base stores nothing at the root — its fields describe subdocuments. Such a family keeps generating exactly the embedded config it always did (the same field union, in the same order, with the embedding derived from a class that actually declares one), because flipping it to a top-level config would delete the config masking those subdocuments: the same worst-case failure in the opposite direction. A non-embedded model whose embedded descendants are in the group is therefore always read as their base class, conservatively so even if the application also stored root documents under it — that keeps the behavior (and the limitation) the generator always had rather than trading a silent masking regression for a silent dumping one. Collections stored into only by top-level models, or only by embedded ones, are unaffected, including the unrepresentable embedding shapes that abort or, under `EXWIW_SKIP_UNSUPPORTED=1`, emit an `ignore: true` config. Related: a model declared `store_in collection: nil` — how an application says a document class is never persisted and only wants Mongoid's casting — is now skipped entirely instead of contributing to a config for the nameless group every such class collapses into, which describes nothing and, as a top-level config, would instruct the dump to read a collection with no name. They are dropped where the generator selects the models it describes, so they leave `tidy_mongoid`'s live-collection set as well.
10
+
11
+ ## [0.9.22] - 2026-08-13
12
+
13
+ ### Added
14
+
15
+ - **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.
16
+
17
+ - **`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.
18
+
19
+ ### Fixed
20
+
21
+ - **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.
22
+
23
+ - `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.
24
+
5
25
  ## [0.9.21] - 2026-08-06
6
26
 
7
27
  ### 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
@@ -138,7 +138,7 @@ For MongoDB applications backed by [Mongoid](https://www.mongodb.com/docs/mongoi
138
138
  bundle exec rake exwiw:schema:generate_mongoid
139
139
  ```
140
140
 
141
- It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the ActiveRecord generator because the two ORMs expose entirely different metadata. From each model it derives:
141
+ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the ActiveRecord generator because the two ORMs expose entirely different metadata. Every application document model is described, except one declared `store_in collection: nil` — the way an application says a document class is never persisted and only wants Mongoid's casting: it has no collection name to describe, so it is skipped (and does not count as a live collection when tidying). From each remaining model it derives:
142
142
 
143
143
  - the collection name and the `_id` primary key,
144
144
  - `fields` from the declared Mongoid fields (referenced `belongs_to` foreign keys such as `shop_id`, and the `created_at` / `updated_at` columns added by `Mongoid::Timestamps`, are ordinary fields — their BSON `ObjectId` / `Date` values serialize as MongoDB Extended JSON at dump time). For an aliased field (`field :ctry, as: :country`), the generator emits the **stored** document key (`ctry`), never the Ruby accessor (`country`), so masking and projection target the key that actually appears in the document, and additionally records the accessor as `mongoid_field_name` on that field so the short key stays understandable (association aliases such as `shop => shop_id` and the built-in `id => _id` are not field renames and are not annotated),
@@ -147,8 +147,28 @@ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the Activ
147
147
 
148
148
  Models in an inheritance hierarchy whose subclasses share the base's collection (Mongoid STI, distinguished by the auto-added `_type` discriminator) collapse into a single config: the generator discovers the subclasses via `descendants` (Mongoid registers only the base class in `Mongoid.models`) and unions every class's `fields` and `belongs_tos` into the collection config, so subclass-only fields and associations are not lost.
149
149
 
150
+ A collection name can also be shared by embedded and non-embedded models, in two shapes the generator tells apart. `embedded?` only answers "does this class declare an `embedded_in`", which is not the same question as "does this collection have root documents":
151
+
152
+ - **An embedded family under a plain base class** — `Address` holding the shared fields and declaring no `embedded_in`, with `BillingAddress < Address` declaring one and inheriting the collection name. Nothing is stored at the root under the base; it is part of the embedded family, and its fields describe subdocuments. The collection stays **embedded**, exactly as before: one `embedded_in` config unioning the base's fields with its subclasses', derived from a class that actually declares the embedding. (A non-embedded model whose embedded descendants are in the same group is always read as their base — conservatively so, even if the application also stored root documents under it, since the alternative would silently delete the config masking those subdocuments.)
153
+ - **A genuine collision** — an unrelated top-level model stores into a collection whose name an embedded class derives from its own class name. Such a collection is genuinely **top-level**: it has root documents that must be dumped, so its config is built from the root models only (their fields and `belongs_tos`) with no `embedded_in`. An embedded base in the same group does not contribute its fields. Representing the group as embedded would make `dumpable?` skip the collection and silently drop those documents from the export.
154
+
155
+ The collision is reported on stderr, because the embedded documents of the same name are **not** covered by that config: to mask them, add a config by hand with an `embedded_in` and a `name` / file name of your own choosing (generation and `tidy_mongoid` both leave `embedded_in` configs alone, so a hand-written one is stable). This is deliberately not recorded as a `comment` on the generated config — a generated comment takes precedence when merging, so it would overwrite a note you wrote about this very situation on every run.
156
+
150
157
  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
158
 
159
+ ### Safe mode, `tidy_mongoid` and `check_mongoid`
160
+
161
+ 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.
162
+
163
+ ```bash
164
+ bundle exec rake exwiw:schema:tidy_mongoid # delete the config of a collection no model stores into
165
+ bundle exec rake exwiw:schema:check_mongoid # report the difference, writing nothing
166
+ ```
167
+
168
+ `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.
169
+
170
+ `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.
171
+
152
172
  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
173
 
154
174
  ### 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