exwiw 0.9.20 → 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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +215 -3
- data/docs/mongodb.md +13 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +7 -4
- data/lib/exwiw/adapter/mysql_adapter.rb +4 -7
- data/lib/exwiw/adapter/postgresql_adapter.rb +4 -7
- data/lib/exwiw/adapter/sqlite_adapter.rb +4 -7
- data/lib/exwiw/adapter.rb +27 -0
- 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/default_mask.rb +104 -0
- data/lib/exwiw/mask_value.rb +30 -0
- data/lib/exwiw/mongodb_collection_config.rb +22 -10
- data/lib/exwiw/mongodb_field.rb +5 -1
- data/lib/exwiw/mongoid_schema_generator.rb +368 -36
- data/lib/exwiw/query_ast.rb +1 -1
- data/lib/exwiw/schema_check.rb +167 -0
- data/lib/exwiw/schema_generator.rb +64 -5
- data/lib/exwiw/table_column.rb +7 -1
- data/lib/exwiw/table_config.rb +2 -2
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +7 -0
- data/lib/tasks/exwiw.rake +61 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3abd99db26266c75e79f46cd0fc14a2a0344308031422385ca22dd2a77ab0130
|
|
4
|
+
data.tar.gz: 9c78446793e1c91947bcf7aa6b933d623c87d004d84fbcb3df8b86886e136482
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0406c29183e0a896ae9de40e24622239caa4cf06b898784d1aa24633d479b0e4ee5717f8d755e5c323af1dd290e18e92f201f4d2ac6f192feb71e9c1a947eee2
|
|
7
|
+
data.tar.gz: 63e754207a3c08d97d9b9ceed684b5e112c770c02e9638dd80e9ba958fd1d9ecc96784e6d0431cb27c818e254177d62b506ea587fc1ba55d7068125c889a7fd2
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
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
|
+
|
|
19
|
+
## [0.9.21] - 2026-08-06
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`schema:generate` now runs in safe mode by default, so a column a migration just added cannot start being exported before anyone has judged whether it holds personal data.** Every column the config does not have yet is emitted **masked** and flagged `needs_mask_decision: true`; existing entries keep their resolved state through the usual merge, so in practice this marks exactly the new columns. `EXWIW_NEW_COLUMNS=plain` opts out, which is what a first-time bootstrap wants — every column of every table is new there. `SchemaGenerator.new` / `.from_rails_application` default to safe too, so a caller driving the generator from its own task or script gets the same behavior. A column that has a default of its own is masked with that default — a value the column provably holds, and the one the application treats as neutral, so masking a `default: true` flag does not quietly turn the feature off for every row (a default the database computes, like `now()`, is not a constant and does not count, and neither does a JSON object — `{...}` in a mask is a column placeholder, so those fall back to `{}`). Otherwise the mask follows the column type: `masked-{primary key}` for text (with `@example.com` when the column name mentions mail), `0` for numbers, `false` for booleans, a fixed date/timestamp, `{}` for JSON; text always takes the template, since its mask has to vary per row. Three kinds of column are flagged but never masked: the primary key and the foreign keys/types the `belongs_tos` join on (masking them would break the joins and leave the dump referencing rows that were never exported); types no constant safely fits (`uuid`, `binary`, enums, array columns — which report their member type, so a scalar default would not fit — and text columns too short to hold the masked value), since a default the column cannot hold would fail the restore the dump feeds; and columns covered by a unique index unless the mask varies per row, since a constant would collapse every row onto one value and break the restore with a duplicate key. If the index list cannot be read at all, every column is treated as unique-indexed rather than risking the latter. ActiveRecord only for now — `schema:generate_mongoid` does not flag new fields yet.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`rake exwiw:schema:check` reports how the committed config differs from what the application would generate now, plus the columns still waiting for a masking decision.** Added/removed/changed tables and columns and the flagged columns come out as sorted JSON with a non-zero exit, each entry prefixed with its database in a multi-database app; `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` additionally writes the report to a file, so a caller need not assume stdout carries nothing else. It regenerates into a throwaway copy rather than over the config directory, so it runs on a working tree it must not modify — a CI check that keeps a schema change from being merged until the config is reconciled and every new column's masking is decided. ActiveRecord only, like safe mode.
|
|
28
|
+
|
|
29
|
+
- **A column/field can carry `needs_mask_decision: true`, marking a masking decision nobody has made yet.** Extraction ignores the key entirely — what the column exports is whatever `replace_with` / `ignore` say — so it is purely a decision-tracking bit that tooling can attach and report, letting a check refuse to merge a schema change while any column still carries one. The on-disk state wins over regeneration: removing the key is how the decision is recorded, so a regenerated config must not restore it.
|
|
30
|
+
|
|
31
|
+
- **`replace_with` accepts a non-String value, so a column that is not text can be masked without changing its type.** A String mask is rendered as a template and emitted as text, which is the wrong shape for an integer/boolean column (and would change a MongoDB field's BSON type). A JSON scalar — `"replace_with": 0`, `false`, `1.5` — is used verbatim instead: the SQL adapters emit it as a typed literal rather than concatenating it into text, and the MongoDB adapter assigns the value as-is. `{}` placeholders are only interpreted in the String form, and NULL preservation applies to both. Because `false` is a valid mask value and falsy in Ruby, the checks that decide whether a column is masked now test for nil, so a `replace_with: false` column is no longer emitted unmasked.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **A `{}` in a `replace_with` template is emitted literally instead of compiling to a reference to a column with no name.** The SQL adapters read `{...}` as a placeholder even when it was empty, so `"replace_with": "{}"` compiled to `CONCAT(t."")` (`t.``` on mysql, `(t."")` on sqlite) and the extraction failed on that table. The MongoDB adapter never had the bug — its placeholder pattern requires at least one character — so the same config meant two different things per adapter. A brace pair with nothing between it names no column and is now a literal everywhere, which is what makes `{}` usable as an empty-JSON mask (the default safe-mode mask for a `json`/`jsonb` column). Templates that name a real column are unaffected, byte for byte.
|
|
36
|
+
|
|
5
37
|
## [0.9.20] - 2026-08-05
|
|
6
38
|
|
|
7
39
|
### 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,65 @@ 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
|
+
|
|
361
|
+
#### Safe mode (masking new columns by default)
|
|
362
|
+
|
|
363
|
+
A migration that adds a column would otherwise leave `schema:generate` emitting it unmasked, so
|
|
364
|
+
it starts being exported the moment the config is regenerated — before anyone has judged whether
|
|
365
|
+
it holds personal data. So `schema:generate` runs in **safe mode by default**: every column the
|
|
366
|
+
config does not have yet is emitted **masked** and flagged
|
|
367
|
+
[`needs_mask_decision: true`](#needs_mask_decision).
|
|
368
|
+
|
|
369
|
+
Columns already in the config keep whatever they say — the merge that preserves `replace_with` /
|
|
370
|
+
`comment` / `ignore` preserves a resolved decision too — so in practice this marks exactly the
|
|
371
|
+
columns a migration just added.
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
bundle exec rake exwiw:schema:generate # safe mode
|
|
375
|
+
EXWIW_NEW_COLUMNS=plain bundle exec rake exwiw:schema:generate # opt out
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Opting out is for the **first-time bootstrap** of a config, where every column of every table is
|
|
379
|
+
new and safe mode would flag the whole thing at once. Use it nowhere else: a column committed
|
|
380
|
+
under `plain` carries no flag, so nothing afterwards can tell it apart from one whose masking was
|
|
381
|
+
decided.
|
|
382
|
+
|
|
383
|
+
A column that has a **default of its own** is masked with that default: it is a value the column
|
|
384
|
+
provably holds, and it is what the application treats as neutral, so masking a `default: true`
|
|
385
|
+
flag does not quietly turn the feature off for every row in the dump. A default the database
|
|
386
|
+
computes (`now()`) is not a constant and does not count, and neither does a JSON object — `{...}`
|
|
387
|
+
in a mask is a column placeholder, so those fall back to `{}`. Otherwise the mask depends on the column
|
|
388
|
+
type: `masked-{primary key}` for text (with `@example.com` appended when the column name mentions
|
|
389
|
+
mail, so it stays a valid address), `0` for numbers, `false` for booleans, a fixed date/timestamp,
|
|
390
|
+
and `{}` for JSON. Text always takes the template rather than its default, since the mask has to
|
|
391
|
+
vary per row. Three kinds of
|
|
392
|
+
column are flagged but deliberately **not** masked:
|
|
393
|
+
|
|
394
|
+
- **The primary key, and the foreign keys/types the `belongs_tos` join on.** Masking them
|
|
395
|
+
would break the joins and leave the dump referencing rows that were never exported.
|
|
396
|
+
- **Types no constant safely fits** — `uuid`, `binary`, enums, array columns (which report their
|
|
397
|
+
member type, so a scalar default would not fit), and text columns too short to hold the masked
|
|
398
|
+
value. An invalid default would fail the restore the dump feeds, which is worse than exporting
|
|
399
|
+
the column while the flag keeps the change from being merged.
|
|
400
|
+
- **Columns covered by a unique index**, unless the mask varies per row (the text masks do, via
|
|
401
|
+
the primary key). A constant would collapse every row onto one value and break the restore with
|
|
402
|
+
a duplicate key.
|
|
403
|
+
|
|
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.
|
|
413
|
+
|
|
354
414
|
#### Tidying stale config (`schema:tidy`)
|
|
355
415
|
|
|
356
416
|
`schema:generate` adds and updates config files for the tables it finds, but it never deletes the config file of a table that has been dropped from the application. To reconcile the existing config against the current schema, run:
|
|
@@ -368,6 +428,47 @@ Because it reads the database directly, a table that still exists in the databas
|
|
|
368
428
|
|
|
369
429
|
It respects `EXWIW_SCHEMA_DIR_PATH` and the per-database subdirectory layout in the same way as `schema:generate`. Unlike `generate`, `tidy` never adds or regenerates entries — every surviving table/column (including hand-edited `comment` / `ignore` / `replace_with`) is left untouched, so it is safe to run on a customized config. The task prints which tables and columns it removed (or that the config was already tidy). Stale `belongs_tos` are not pruned by `tidy`; rerun `schema:generate` to refresh those.
|
|
370
430
|
|
|
431
|
+
#### Checking the config against the schema
|
|
432
|
+
|
|
433
|
+
`schema:check` reports how the committed config differs from what the application would
|
|
434
|
+
generate now — without writing anything, so it can run on a working tree it must not modify:
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
bundle exec rake exwiw:schema:check
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
It regenerates into a throwaway copy of the config directory (safe mode + `tidy`) and prints
|
|
441
|
+
the comparison as JSON, then exits non-zero when anything needs attention:
|
|
442
|
+
|
|
443
|
+
```json
|
|
444
|
+
{
|
|
445
|
+
"added_tables": [],
|
|
446
|
+
"added_columns": ["users.contact_email"],
|
|
447
|
+
"removed_tables": [],
|
|
448
|
+
"removed_columns": [],
|
|
449
|
+
"changed_tables": ["users"],
|
|
450
|
+
"needs_mask_decision": ["orders.memo"]
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`added_*` / `removed_*` / `changed_tables` mean the config no longer matches the schema — run
|
|
455
|
+
`schema:generate` and `schema:tidy` to reconcile it. `needs_mask_decision` lists the columns
|
|
456
|
+
whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). The exit code
|
|
457
|
+
makes it usable as a CI check that keeps a schema change from being merged until both are
|
|
458
|
+
resolved; the JSON is stable and sorted, so it can be posted as-is. In a multi-database app each
|
|
459
|
+
entry is prefixed with its database (`primary/users.email`), so the same table name in two
|
|
460
|
+
databases stays distinct.
|
|
461
|
+
|
|
462
|
+
Set `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` to have the same JSON written to a file, which spares a
|
|
463
|
+
caller from assuming stdout carries nothing else (application boot is free to print).
|
|
464
|
+
|
|
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).
|
|
471
|
+
|
|
371
472
|
#### Multiple databases
|
|
372
473
|
|
|
373
474
|
If the application uses Rails' multiple-database support (`connects_to`), `schema:generate` buckets models by the database they connect to and writes each database's config files into its own subdirectory of the output directory, named after the database config name (`primary`, `analytics`, ...):
|
|
@@ -396,10 +497,82 @@ For MongoDB applications backed by [Mongoid](https://www.mongodb.com/docs/mongoi
|
|
|
396
497
|
|
|
397
498
|
```bash
|
|
398
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
|
|
399
502
|
```
|
|
400
503
|
|
|
401
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).
|
|
402
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
|
+
|
|
403
576
|
### Configuration
|
|
404
577
|
|
|
405
578
|
This is an example of the one table schema:
|
|
@@ -535,6 +708,28 @@ Individual `columns` (SQL) / `fields` (MongoDB) and `belongs_tos` entries accept
|
|
|
535
708
|
|
|
536
709
|
The ignored entries are removed only at runtime, right after the config is loaded from file; the JSON on disk keeps them. Both `comment` and `ignore` are **preserved across `exwiw:schema:generate` / `exwiw:mongoid:schema:generate` regenerations** (the hand-edited value wins over the auto-generated config), just like `replace_with`. This applies to the MongoDB `MongodbCollectionConfig` (`fields` / `belongs_tos`) as well.
|
|
537
710
|
|
|
711
|
+
### `needs_mask_decision`
|
|
712
|
+
|
|
713
|
+
A column/field may also carry `needs_mask_decision: true`, marking a column whose masking
|
|
714
|
+
nobody has decided on yet:
|
|
715
|
+
|
|
716
|
+
```json
|
|
717
|
+
{ "name": "contact_email", "replace_with": "masked-{id}@example.com", "needs_mask_decision": true }
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Extraction ignores the key entirely — what the column exports is whatever `replace_with` /
|
|
721
|
+
`ignore` say. It exists so the decision can be tracked and required:
|
|
722
|
+
[safe mode](#safe-mode-masking-new-columns-by-default) attaches it to every newly discovered
|
|
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
|
|
726
|
+
resolved. Resolving it means removing the key — after keeping the mask (ideally recording why
|
|
727
|
+
in `comment`), replacing it with a real masking rule, dropping `replace_with` to export the
|
|
728
|
+
raw value, or setting `ignore: true`.
|
|
729
|
+
|
|
730
|
+
Like `comment` / `ignore`, the on-disk state wins over regeneration: once removed,
|
|
731
|
+
`schema:generate` does not bring it back.
|
|
732
|
+
|
|
538
733
|
### Polymorphic `belongs_to`
|
|
539
734
|
|
|
540
735
|
A Rails polymorphic association (`belongs_to :reviewable, polymorphic: true`) does not point at a single table — the target row is selected at runtime by a type column. exwiw models this as **one `belongs_to` entry per concrete target table**, each carrying two extra fields:
|
|
@@ -826,6 +1021,23 @@ absent field) is left as-is instead of being replaced by the masked literal, so
|
|
|
826
1021
|
string is a real value and is still masked. Because of this you do not need to hand-write a
|
|
827
1022
|
`raw_sql` `CASE WHEN ... IS NOT NULL ...` to keep NULLs.
|
|
828
1023
|
|
|
1024
|
+
A **non-String** value (number or boolean) is used verbatim instead of being rendered as a
|
|
1025
|
+
template, so a column that is not text keeps its type:
|
|
1026
|
+
|
|
1027
|
+
```jsonc
|
|
1028
|
+
{ "name": "score", "replace_with": 0 } // integer column -> SELECT emits the literal 0
|
|
1029
|
+
{ "name": "active", "replace_with": false } // boolean column
|
|
1030
|
+
{ "name": "email", "replace_with": "masked-{id}@example.com" } // template, as above
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
The SQL adapters emit it as a typed literal (not concatenated into text) and the MongoDB
|
|
1034
|
+
adapter assigns it as-is, so the field keeps its BSON type. NULL preservation applies to both
|
|
1035
|
+
forms.
|
|
1036
|
+
|
|
1037
|
+
In the String form, a `{...}` placeholder must name a column: an empty brace pair (`{}`) names
|
|
1038
|
+
nothing, so it is emitted literally — which is what makes `"replace_with": "{}"` a usable
|
|
1039
|
+
empty-JSON mask, on every adapter.
|
|
1040
|
+
|
|
829
1041
|
#### `raw_sql`
|
|
830
1042
|
|
|
831
1043
|
It will used instead of the original value.
|
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)
|
|
@@ -733,9 +733,12 @@ module Exwiw
|
|
|
733
733
|
|
|
734
734
|
private def build_mask_plan(config)
|
|
735
735
|
masked_fields = config.fields.each_with_object([]) do |field, acc|
|
|
736
|
-
next
|
|
736
|
+
next if field.replace_with.nil?
|
|
737
737
|
|
|
738
|
-
|
|
738
|
+
# A non-String replace_with (see Exwiw::MaskValue) is stored verbatim
|
|
739
|
+
# so the field keeps its BSON type; only a template compiles to segments.
|
|
740
|
+
mask = Exwiw::MaskValue.scalar?(field.replace_with) ? field.replace_with : compile_template(field.replace_with)
|
|
741
|
+
acc << [field.name, mask]
|
|
739
742
|
end
|
|
740
743
|
faked_fields = build_faked_fields(config)
|
|
741
744
|
embedded = embedded_children_of(config).map do |child|
|
|
@@ -779,13 +782,13 @@ module Exwiw
|
|
|
779
782
|
# masked value — matching the SQL adapters, where replace_with runs in the
|
|
780
783
|
# database before the Ruby-side fake transform sees the row.
|
|
781
784
|
private def apply_mask_plan!(doc, plan)
|
|
782
|
-
plan.masked_fields.each do |name,
|
|
785
|
+
plan.masked_fields.each do |name, mask|
|
|
783
786
|
# Preserve a NULL / absent source value instead of clobbering it into a
|
|
784
787
|
# masked literal. `doc[name].nil?` is true for both an explicit nil and
|
|
785
788
|
# an absent key, so an absent key is left absent (not created).
|
|
786
789
|
next if doc[name].nil?
|
|
787
790
|
|
|
788
|
-
doc[name] = render_template(
|
|
791
|
+
doc[name] = mask.is_a?(Array) ? render_template(mask, doc) : mask
|
|
789
792
|
end
|
|
790
793
|
plan.faked_fields.each do |name, deriver, seed_field|
|
|
791
794
|
# NULL-preserving like replace_with (an absent key stays absent). The
|
|
@@ -486,15 +486,12 @@ module Exwiw
|
|
|
486
486
|
when Exwiw::QueryAst::ColumnValue::RawSql
|
|
487
487
|
column.value
|
|
488
488
|
when Exwiw::QueryAst::ColumnValue::ReplaceWith
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
name = part[1..-2]
|
|
492
|
-
qualified_name(ast.from_table_name, name)
|
|
493
|
-
else
|
|
494
|
-
"'#{part}'"
|
|
495
|
-
end
|
|
489
|
+
if Exwiw::MaskValue.scalar?(column.value)
|
|
490
|
+
return null_preserving(ast, column, scalar_literal(column.value))
|
|
496
491
|
end
|
|
497
492
|
|
|
493
|
+
parts = mask_template_parts(ast, column)
|
|
494
|
+
|
|
498
495
|
replaced = parts.join(", ")
|
|
499
496
|
null_preserving(ast, column, "CONCAT(#{replaced})")
|
|
500
497
|
else
|
|
@@ -588,15 +588,12 @@ module Exwiw
|
|
|
588
588
|
when Exwiw::QueryAst::ColumnValue::RawSql
|
|
589
589
|
column.value
|
|
590
590
|
when Exwiw::QueryAst::ColumnValue::ReplaceWith
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
name = part[1..-2]
|
|
594
|
-
qualified_name(ast.from_table_name, name)
|
|
595
|
-
else
|
|
596
|
-
"'#{part}'"
|
|
597
|
-
end
|
|
591
|
+
if Exwiw::MaskValue.scalar?(column.value)
|
|
592
|
+
return null_preserving(ast, column, scalar_literal(column.value))
|
|
598
593
|
end
|
|
599
594
|
|
|
595
|
+
parts = mask_template_parts(ast, column)
|
|
596
|
+
|
|
600
597
|
replaced = parts.join(", ")
|
|
601
598
|
null_preserving(ast, column, "CONCAT(#{replaced})")
|
|
602
599
|
else
|
|
@@ -329,15 +329,12 @@ module Exwiw
|
|
|
329
329
|
when Exwiw::QueryAst::ColumnValue::RawSql
|
|
330
330
|
column.value
|
|
331
331
|
when Exwiw::QueryAst::ColumnValue::ReplaceWith
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
name = part[1..-2]
|
|
335
|
-
qualified_name(ast.from_table_name, name)
|
|
336
|
-
else
|
|
337
|
-
"'#{part}'"
|
|
338
|
-
end
|
|
332
|
+
if Exwiw::MaskValue.scalar?(column.value)
|
|
333
|
+
return null_preserving(ast, column, scalar_literal(column.value))
|
|
339
334
|
end
|
|
340
335
|
|
|
336
|
+
parts = mask_template_parts(ast, column)
|
|
337
|
+
|
|
341
338
|
replaced = parts.join(" || ")
|
|
342
339
|
null_preserving(ast, column, "(#{replaced})")
|
|
343
340
|
else
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -263,6 +263,33 @@ module Exwiw
|
|
|
263
263
|
"CASE WHEN #{qualified_name(ast.from_table_name, column.name)} IS NOT NULL THEN #{masked_expr} ELSE NULL END"
|
|
264
264
|
end
|
|
265
265
|
|
|
266
|
+
# Split a `replace_with` template into the expressions it concatenates: a
|
|
267
|
+
# `{column}` placeholder becomes that column's qualified name, everything
|
|
268
|
+
# else a quoted literal. An empty brace pair names no column, so it stays
|
|
269
|
+
# literal — which is what makes `"replace_with": "{}"` an empty-JSON mask.
|
|
270
|
+
# A quote in a literal is doubled; a backslash is not, so a hand-written
|
|
271
|
+
# mask containing one means what MySQL makes of it.
|
|
272
|
+
private def mask_template_parts(ast, column)
|
|
273
|
+
column.value.scan(/#{Exwiw::MaskValue::PLACEHOLDER}|[^{}]+|[{}]/).map do |part|
|
|
274
|
+
if part.size > 1 && part.start_with?("{")
|
|
275
|
+
qualified_name(ast.from_table_name, part[1..-2])
|
|
276
|
+
else
|
|
277
|
+
"'#{part.gsub("'", "''")}'"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# A non-String `replace_with` (see Exwiw::MaskValue) is emitted as a typed
|
|
283
|
+
# literal rather than concatenated into text, so an integer column masked
|
|
284
|
+
# with `0` keeps its numeric type.
|
|
285
|
+
private def scalar_literal(value)
|
|
286
|
+
case value
|
|
287
|
+
when true then "TRUE"
|
|
288
|
+
when false then "FALSE"
|
|
289
|
+
else value.to_s
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
266
293
|
# Split an outer query's WHERE clauses into the scope id-set clauses to
|
|
267
294
|
# lift into a materialized derived-table JOIN (see each adapter's
|
|
268
295
|
# #compile_scope_join) and the remaining plain clauses (kept in WHERE).
|