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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +101 -10
- data/docs/mongodb.md +21 -1
- data/lib/exwiw/cli.rb +190 -3
- data/lib/exwiw/db_introspector/mysql_introspector.rb +160 -0
- data/lib/exwiw/db_introspector/postgresql_introspector.rb +215 -0
- data/lib/exwiw/db_introspector.rb +166 -0
- data/lib/exwiw/db_schema_generator.rb +298 -0
- data/lib/exwiw/mongodb_collection_config.rb +21 -12
- data/lib/exwiw/mongoid_schema_generator.rb +468 -69
- data/lib/exwiw/schema_check.rb +48 -6
- data/lib/exwiw/table_config.rb +1 -1
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +4 -0
- data/lib/tasks/exwiw.rake +37 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9cbe5a94be08902db6990812f48eb07b3aa5cae7a507acb0f8c3cf1f90edc175
|
|
4
|
+
data.tar.gz: 19b92d8feee9e874135fc0c9f833d290aacdb1be35c6bcd3da72c7c212352327
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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:
|
|
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
|
|
634
|
-
|
|
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
|
-
|
|
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
|