exwiw 0.9.19 → 0.9.21
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 +24 -0
- data/README.md +128 -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 +83 -8
- data/lib/exwiw/adapter/sqlite_adapter.rb +4 -7
- data/lib/exwiw/adapter.rb +27 -0
- data/lib/exwiw/ddl_postprocessor.rb +44 -0
- data/lib/exwiw/default_mask.rb +104 -0
- data/lib/exwiw/mask_value.rb +30 -0
- data/lib/exwiw/mongodb_collection_config.rb +5 -2
- data/lib/exwiw/mongodb_field.rb +5 -1
- data/lib/exwiw/query_ast.rb +1 -1
- data/lib/exwiw/schema_check.rb +125 -0
- data/lib/exwiw/schema_generator.rb +64 -5
- data/lib/exwiw/table_column.rb +7 -1
- data/lib/exwiw/table_config.rb +1 -1
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +3 -0
- data/lib/tasks/exwiw.rake +24 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61f6374ae972477087c63744e9a3702acc795872f7d08599cef69bc7898563db
|
|
4
|
+
data.tar.gz: 5a140799d94c5804fec5988c0643acbcc79c33ed442e7be36fe2b65bcce14a3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78154be467c8920c5011f527957635145089c07bba5268ce4ebe7ac3272622243acb36b05bd1b538740f76a9167dafe48498a022b458bdd11610544d2c10d446
|
|
7
|
+
data.tar.gz: 7aea16379a332986f78b18f07a1c6c258745c407a804510de1451c17324c1533f7f17fda6ed725bc4e982e5b5824395fd96f01ffa30fc46c90b0a2e10d25c033
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.21] - 2026-08-06
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **`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.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
- **`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.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
## [0.9.20] - 2026-08-05
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **PostgreSQL: the extensions a managed platform installs to run the source instance itself (Cloud SQL / AlloyDB) are now explicitly out of target and left out of `insert-000-schema.sql`.** A full-database `pg_dump` of a Cloud SQL instance emits `CREATE SCHEMA google_vacuum_mgmt` plus the `CREATE EXTENSION` / `COMMENT ON EXTENSION` for it; an AlloyDB one emits `google_columnar_engine` and `google_db_advisor`. exwiw already made these survivable — earlier releases wrapped both statements in `DO` blocks that warn-and-skip when the restore target cannot provide the extension — but they still occupied the dump, left an empty vendor schema behind on every restore, and logged a `WARNING` each time. They serve the *source* instance's operation (vacuum tuning, the in-memory columnar cache, index advice), carry no application data, are named by nothing in the application's own schema, and ship only with the managed platform, so no restore target outside it can ever create them. Both halves are now dropped: their schemas via `pg_dump --exclude-schema` (which also covers any object the platform adds inside one later), and the extension statements — not schema-qualified, so no `pg_dump` filter reaches them — via a new `DdlPostprocessor.strip_extensions` pass, with whatever was excluded named in the run's log rather than dropped silently. The set is an explicit list of names (`PostgresqlAdapter::PLATFORM_MANAGED_EXTENSIONS`), deliberately not a `google_*` prefix match: the prefixes are not reserved, so a prefix rule would also drop a schema an application legitimately owns — `google_calendar` for a Google Calendar integration — together with its tables. Adding a name costs a release, which is the right trade. Every other extension is still emitted wrapped in the warn-and-skip `DO` block, including two kinds that are also managed-platform-only: a third-party extension pulled in as a dependency of an excluded one (`google_db_advisor` requires `hypopg`, installable on any plain PostgreSQL), and an application-facing platform extension (`google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl`), which application SQL and dumped DDL can name (a ScaNN index is `USING scann`) — removing its `CREATE` would strand whatever refers to it. Data extraction never touched any of these objects — it is driven by the schema config, which has no entry for a table without an application model — so no `insert-*` / `delete-*` file changes.
|
|
28
|
+
|
|
5
29
|
## [0.9.19] - 2026-08-04
|
|
6
30
|
|
|
7
31
|
### Added
|
data/README.md
CHANGED
|
@@ -108,6 +108,13 @@ so you should import the dump in order.
|
|
|
108
108
|
|
|
109
109
|
`insert-000-schema.sql` is generated by shelling out to the database client tools (`mysqldump` for `mysql`, `pg_dump` for `postgresql`, and the sqlite3 driver for `sqlite`), so the corresponding client must be available on PATH when running exwiw. For `mysql`, set `EXWIW_MYSQLDUMP` to point at a specific `mysqldump` binary when the one on PATH is incompatible with the server (e.g. a MySQL 9.x `mysqldump` cannot load `mysql_native_password` against a server still using that auth plugin — `EXWIW_MYSQLDUMP=/path/to/mysql@8.0/bin/mysqldump`). The output is post-processed to make it idempotent: `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS` (where the engine supports it), and PostgreSQL's `ALTER TABLE ... ADD CONSTRAINT` statements are wrapped in `DO $$ ... EXCEPTION WHEN duplicate_object`. For `mysql`, the source server's `DEFINER=user@host` stamp on views and triggers is stripped too, so restoring into a managed MySQL instance (which usually can't grant the privilege to recreate someone else's `DEFINER`) does not fail.
|
|
110
110
|
|
|
111
|
+
For `postgresql`, the extensions a managed platform installs to run the source instance itself are treated as out of target and left out of the dump entirely — currently `google_vacuum_mgmt` (Cloud SQL / AlloyDB adaptive autovacuum), `google_columnar_engine` and `google_db_advisor` (AlloyDB). They serve the source instance's operation (vacuum tuning, the in-memory columnar cache, index advice), hold no application data, are referenced by nothing in the application's own schema, and ship only with the managed platform, so a restore target outside it can never create them. Their schemas are dropped via `pg_dump --exclude-schema` and their `CREATE EXTENSION` / `COMMENT ON EXTENSION` statements — which are not schema-qualified, so no `pg_dump` filter reaches them — are removed from the output; whatever was excluded is named in the run's log.
|
|
112
|
+
|
|
113
|
+
The list is exact names, not a `google_*` prefix match: those prefixes are not reserved, so a prefix rule would also drop a schema an application legitimately owns (`google_calendar` for a Google Calendar integration) together with its tables. Every other extension is kept and wrapped in the usual warn-and-skip `DO` block, including two kinds that are also managed-platform-only:
|
|
114
|
+
|
|
115
|
+
- a third-party extension pulled in as a dependency of an excluded one (`google_db_advisor` requires `hypopg`), since that one *is* installable on a plain PostgreSQL, and
|
|
116
|
+
- an application-facing platform extension (`google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl`), which the application's own SQL and DDL can name (a ScaNN index is `USING scann`) — removing its `CREATE` would strand whatever refers to it, so it warns and skips instead.
|
|
117
|
+
|
|
111
118
|
you need to delete the records before importing the dump,
|
|
112
119
|
`delete-{idx}-{table_name}.sql` will help you to do that.
|
|
113
120
|
This sql will delete "all" related records to the extract targets.
|
|
@@ -344,6 +351,52 @@ EXWIW_SCHEMA_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
|
|
|
344
351
|
|
|
345
352
|
As with the CLI, a relative `schema_dir` in the config file is resolved relative to the config file's own directory.
|
|
346
353
|
|
|
354
|
+
#### Safe mode (masking new columns by default)
|
|
355
|
+
|
|
356
|
+
A migration that adds a column would otherwise leave `schema:generate` emitting it unmasked, so
|
|
357
|
+
it starts being exported the moment the config is regenerated — before anyone has judged whether
|
|
358
|
+
it holds personal data. So `schema:generate` runs in **safe mode by default**: every column the
|
|
359
|
+
config does not have yet is emitted **masked** and flagged
|
|
360
|
+
[`needs_mask_decision: true`](#needs_mask_decision).
|
|
361
|
+
|
|
362
|
+
Columns already in the config keep whatever they say — the merge that preserves `replace_with` /
|
|
363
|
+
`comment` / `ignore` preserves a resolved decision too — so in practice this marks exactly the
|
|
364
|
+
columns a migration just added.
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
bundle exec rake exwiw:schema:generate # safe mode
|
|
368
|
+
EXWIW_NEW_COLUMNS=plain bundle exec rake exwiw:schema:generate # opt out
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Opting out is for the **first-time bootstrap** of a config, where every column of every table is
|
|
372
|
+
new and safe mode would flag the whole thing at once. Use it nowhere else: a column committed
|
|
373
|
+
under `plain` carries no flag, so nothing afterwards can tell it apart from one whose masking was
|
|
374
|
+
decided.
|
|
375
|
+
|
|
376
|
+
A column that has a **default of its own** is masked with that default: it is a value the column
|
|
377
|
+
provably holds, and it is what the application treats as neutral, so masking a `default: true`
|
|
378
|
+
flag does not quietly turn the feature off for every row in the dump. A default the database
|
|
379
|
+
computes (`now()`) is not a constant and does not count, and neither does a JSON object — `{...}`
|
|
380
|
+
in a mask is a column placeholder, so those fall back to `{}`. Otherwise the mask depends on the column
|
|
381
|
+
type: `masked-{primary key}` for text (with `@example.com` appended when the column name mentions
|
|
382
|
+
mail, so it stays a valid address), `0` for numbers, `false` for booleans, a fixed date/timestamp,
|
|
383
|
+
and `{}` for JSON. Text always takes the template rather than its default, since the mask has to
|
|
384
|
+
vary per row. Three kinds of
|
|
385
|
+
column are flagged but deliberately **not** masked:
|
|
386
|
+
|
|
387
|
+
- **The primary key, and the foreign keys/types the `belongs_tos` join on.** Masking them
|
|
388
|
+
would break the joins and leave the dump referencing rows that were never exported.
|
|
389
|
+
- **Types no constant safely fits** — `uuid`, `binary`, enums, array columns (which report their
|
|
390
|
+
member type, so a scalar default would not fit), and text columns too short to hold the masked
|
|
391
|
+
value. An invalid default would fail the restore the dump feeds, which is worse than exporting
|
|
392
|
+
the column while the flag keeps the change from being merged.
|
|
393
|
+
- **Columns covered by a unique index**, unless the mask varies per row (the text masks do, via
|
|
394
|
+
the primary key). A constant would collapse every row onto one value and break the restore with
|
|
395
|
+
a duplicate key.
|
|
396
|
+
|
|
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.
|
|
399
|
+
|
|
347
400
|
#### Tidying stale config (`schema:tidy`)
|
|
348
401
|
|
|
349
402
|
`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:
|
|
@@ -361,6 +414,43 @@ Because it reads the database directly, a table that still exists in the databas
|
|
|
361
414
|
|
|
362
415
|
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.
|
|
363
416
|
|
|
417
|
+
#### Checking the config against the schema
|
|
418
|
+
|
|
419
|
+
`schema:check` reports how the committed config differs from what the application would
|
|
420
|
+
generate now — without writing anything, so it can run on a working tree it must not modify:
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
bundle exec rake exwiw:schema:check
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
It regenerates into a throwaway copy of the config directory (safe mode + `tidy`) and prints
|
|
427
|
+
the comparison as JSON, then exits non-zero when anything needs attention:
|
|
428
|
+
|
|
429
|
+
```json
|
|
430
|
+
{
|
|
431
|
+
"added_tables": [],
|
|
432
|
+
"added_columns": ["users.contact_email"],
|
|
433
|
+
"removed_tables": [],
|
|
434
|
+
"removed_columns": [],
|
|
435
|
+
"changed_tables": ["users"],
|
|
436
|
+
"needs_mask_decision": ["orders.memo"]
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`added_*` / `removed_*` / `changed_tables` mean the config no longer matches the schema — run
|
|
441
|
+
`schema:generate` and `schema:tidy` to reconcile it. `needs_mask_decision` lists the columns
|
|
442
|
+
whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). The exit code
|
|
443
|
+
makes it usable as a CI check that keeps a schema change from being merged until both are
|
|
444
|
+
resolved; the JSON is stable and sorted, so it can be posted as-is. In a multi-database app each
|
|
445
|
+
entry is prefixed with its database (`primary/users.email`), so the same table name in two
|
|
446
|
+
databases stays distinct.
|
|
447
|
+
|
|
448
|
+
Set `EXWIW_SCHEMA_CHECK_OUTPUT=<path>` to have the same JSON written to a file, which spares a
|
|
449
|
+
caller from assuming stdout carries nothing else (application boot is free to print).
|
|
450
|
+
|
|
451
|
+
Like safe mode, this is ActiveRecord-only — it regenerates through `SchemaGenerator`, so a
|
|
452
|
+
Mongoid config directory is not supported yet.
|
|
453
|
+
|
|
364
454
|
#### Multiple databases
|
|
365
455
|
|
|
366
456
|
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`, ...):
|
|
@@ -528,6 +618,27 @@ Individual `columns` (SQL) / `fields` (MongoDB) and `belongs_tos` entries accept
|
|
|
528
618
|
|
|
529
619
|
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.
|
|
530
620
|
|
|
621
|
+
### `needs_mask_decision`
|
|
622
|
+
|
|
623
|
+
A column/field may also carry `needs_mask_decision: true`, marking a column whose masking
|
|
624
|
+
nobody has decided on yet:
|
|
625
|
+
|
|
626
|
+
```json
|
|
627
|
+
{ "name": "contact_email", "replace_with": "masked-{id}@example.com", "needs_mask_decision": true }
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
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
|
|
632
|
+
[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
|
|
635
|
+
resolved. Resolving it means removing the key — after keeping the mask (ideally recording why
|
|
636
|
+
in `comment`), replacing it with a real masking rule, dropping `replace_with` to export the
|
|
637
|
+
raw value, or setting `ignore: true`.
|
|
638
|
+
|
|
639
|
+
Like `comment` / `ignore`, the on-disk state wins over regeneration: once removed,
|
|
640
|
+
`schema:generate` does not bring it back.
|
|
641
|
+
|
|
531
642
|
### Polymorphic `belongs_to`
|
|
532
643
|
|
|
533
644
|
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:
|
|
@@ -819,6 +930,23 @@ absent field) is left as-is instead of being replaced by the masked literal, so
|
|
|
819
930
|
string is a real value and is still masked. Because of this you do not need to hand-write a
|
|
820
931
|
`raw_sql` `CASE WHEN ... IS NOT NULL ...` to keep NULLs.
|
|
821
932
|
|
|
933
|
+
A **non-String** value (number or boolean) is used verbatim instead of being rendered as a
|
|
934
|
+
template, so a column that is not text keeps its type:
|
|
935
|
+
|
|
936
|
+
```jsonc
|
|
937
|
+
{ "name": "score", "replace_with": 0 } // integer column -> SELECT emits the literal 0
|
|
938
|
+
{ "name": "active", "replace_with": false } // boolean column
|
|
939
|
+
{ "name": "email", "replace_with": "masked-{id}@example.com" } // template, as above
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
The SQL adapters emit it as a typed literal (not concatenated into text) and the MongoDB
|
|
943
|
+
adapter assigns it as-is, so the field keeps its BSON type. NULL preservation applies to both
|
|
944
|
+
forms.
|
|
945
|
+
|
|
946
|
+
In the String form, a `{...}` placeholder must name a column: an empty brace pair (`{}`) names
|
|
947
|
+
nothing, so it is emitted literally — which is what makes `"replace_with": "{}"` a usable
|
|
948
|
+
empty-JSON mask, on every adapter.
|
|
949
|
+
|
|
822
950
|
#### `raw_sql`
|
|
823
951
|
|
|
824
952
|
It will used instead of the original value.
|
|
@@ -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
|
|
@@ -98,6 +98,41 @@ module Exwiw
|
|
|
98
98
|
connection.exec("EXPLAIN #{sql}").values.map(&:first).join("\n")
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# Extensions a managed PostgreSQL platform installs to run the *source*
|
|
102
|
+
# instance itself, which exwiw treats as out of target and leaves out of the
|
|
103
|
+
# dump entirely. Each one here satisfies both conditions:
|
|
104
|
+
#
|
|
105
|
+
# 1. it ships only with the managed platform, so no restore target outside
|
|
106
|
+
# that platform can create it, and
|
|
107
|
+
# 2. nothing in the application's own schema or queries references it — it
|
|
108
|
+
# is operational machinery (autovacuum tuning, the in-memory columnar
|
|
109
|
+
# cache, index advice), not a feature the app builds on.
|
|
110
|
+
#
|
|
111
|
+
# Condition 2 is what keeps this list short. AlloyDB's application-facing
|
|
112
|
+
# extensions — `google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl` —
|
|
113
|
+
# meet condition 1 but are called from application SQL and can be named by
|
|
114
|
+
# dumped DDL (a ScaNN index is `USING scann`), so dropping their CREATE would
|
|
115
|
+
# silently strand whatever refers to them. They stay in the dump, wrapped in
|
|
116
|
+
# the usual warn-and-skip DO block, like `pglogical` and like a third-party
|
|
117
|
+
# extension pulled in as a dependency of one listed here (`google_db_advisor`
|
|
118
|
+
# requires `hypopg`, which any plain PostgreSQL can install).
|
|
119
|
+
#
|
|
120
|
+
# Deliberately an explicit list of names, not a `google_`/`alloydb_` prefix
|
|
121
|
+
# match (which is how the old `pg_extension` query filtered, before switching
|
|
122
|
+
# to a full-database pg_dump dropped that query and the filter with it): the
|
|
123
|
+
# prefixes are not reserved, so a schema an application legitimately owns —
|
|
124
|
+
# `google_calendar` for a Google Calendar integration — would be matched and
|
|
125
|
+
# dropped together with its tables. The vendor gains extensions faster than
|
|
126
|
+
# this list does; that costs a release, which is the right trade against
|
|
127
|
+
# deleting an application's own objects. Its `rds_%` / `aiven_%` arms are not
|
|
128
|
+
# carried over for the same reason: naming their members takes a dump to
|
|
129
|
+
# confirm, and adding one here is a one-line change.
|
|
130
|
+
PLATFORM_MANAGED_EXTENSIONS = %w[
|
|
131
|
+
google_columnar_engine
|
|
132
|
+
google_db_advisor
|
|
133
|
+
google_vacuum_mgmt
|
|
134
|
+
].freeze
|
|
135
|
+
|
|
101
136
|
def dump_schema(ordered_tables, output_path)
|
|
102
137
|
require 'open3'
|
|
103
138
|
|
|
@@ -117,10 +152,20 @@ module Exwiw
|
|
|
117
152
|
'--schema-only',
|
|
118
153
|
'--no-owner',
|
|
119
154
|
'--no-acl',
|
|
155
|
+
# An extension that needs a schema of its own puts it under its own name
|
|
156
|
+
# (Cloud SQL's google_vacuum_mgmt does; the AlloyDB two install into
|
|
157
|
+
# public), so excluding the same names covers the schema half — including
|
|
158
|
+
# any object the platform adds inside one later. The patterns are exact
|
|
159
|
+
# names: pg_dump reads them psql \d-style, where `_` is literal and only
|
|
160
|
+
# `*` globs. A pattern matching nothing is not an error (that needs
|
|
161
|
+
# --strict-names), so these are harmless against a self-hosted server.
|
|
162
|
+
*PLATFORM_MANAGED_EXTENSIONS.map { |name| "--exclude-schema=#{name}" },
|
|
120
163
|
@connection_config.database_name,
|
|
121
164
|
]
|
|
122
165
|
env = { 'PGPASSWORD' => @connection_config.password.to_s }
|
|
123
166
|
|
|
167
|
+
log_excluded_platform_managed_schemas
|
|
168
|
+
|
|
124
169
|
@logger.debug(" Running pg_dump for the whole database (#{@connection_config.database_name})...")
|
|
125
170
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
126
171
|
unless status.success?
|
|
@@ -141,7 +186,9 @@ module Exwiw
|
|
|
141
186
|
# EXTENSION that pg_dump emits alongside is likewise wrapped to swallow
|
|
142
187
|
# undefined_object, so a skipped extension's trailing comment does not
|
|
143
188
|
# abort the restore either.
|
|
144
|
-
|
|
189
|
+
# Platform-managed extensions are stripped first: the wrapping passes below
|
|
190
|
+
# rewrite the bare CREATE/COMMENT statements this removes.
|
|
191
|
+
idempotent = strip_platform_managed_extensions(stdout)
|
|
145
192
|
idempotent = DdlPostprocessor.wrap_create_type_enum_in_do_block(idempotent)
|
|
146
193
|
idempotent = DdlPostprocessor.wrap_create_extension_in_do_block(idempotent)
|
|
147
194
|
idempotent = DdlPostprocessor.wrap_comment_on_extension_in_do_block(idempotent)
|
|
@@ -159,6 +206,37 @@ module Exwiw
|
|
|
159
206
|
@logger.info(" Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
|
|
160
207
|
end
|
|
161
208
|
|
|
209
|
+
# Drop the platform-managed extensions from a raw pg_dump, naming the ones
|
|
210
|
+
# actually removed so the run records what it left out rather than silently
|
|
211
|
+
# dropping it. See PLATFORM_MANAGED_EXTENSIONS.
|
|
212
|
+
private def strip_platform_managed_extensions(sql)
|
|
213
|
+
removed = DdlPostprocessor.extension_names(sql) & PLATFORM_MANAGED_EXTENSIONS
|
|
214
|
+
unless removed.empty?
|
|
215
|
+
@logger.info(" Excluded platform-managed extension(s) from the schema dump: #{removed.join(', ')}.")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
DdlPostprocessor.strip_extensions(sql, PLATFORM_MANAGED_EXTENSIONS)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Name the --exclude-schema patterns the source instance actually has, so the
|
|
222
|
+
# run records the schema half of the exclusion the way
|
|
223
|
+
# #strip_platform_managed_extensions records the extension half. Only exact
|
|
224
|
+
# names are excluded, but nothing stops an application from owning one of
|
|
225
|
+
# them, and excluding a schema takes every object inside it along — that must
|
|
226
|
+
# not happen without the log saying so. See PLATFORM_MANAGED_EXTENSIONS.
|
|
227
|
+
private def log_excluded_platform_managed_schemas
|
|
228
|
+
# The names are bare identifiers from the constant, so they need no quoting
|
|
229
|
+
# inside the array literal ANY() takes.
|
|
230
|
+
result = connection.exec_params(
|
|
231
|
+
'SELECT nspname FROM pg_namespace WHERE nspname = ANY($1) ORDER BY nspname',
|
|
232
|
+
["{#{PLATFORM_MANAGED_EXTENSIONS.join(',')}}"],
|
|
233
|
+
)
|
|
234
|
+
excluded = result.values.map(&:first)
|
|
235
|
+
return if excluded.empty?
|
|
236
|
+
|
|
237
|
+
@logger.info(" Excluded platform-managed schema(s) from the schema dump: #{excluded.join(', ')}.")
|
|
238
|
+
end
|
|
239
|
+
|
|
162
240
|
# The INSERT header for this adapter. PostgreSQL uses bare identifiers,
|
|
163
241
|
# quoted only when required (reserved word / unsafe characters).
|
|
164
242
|
# #to_bulk_insert / #write_inserts (SqlBulkInsert) append the value tuples
|
|
@@ -510,15 +588,12 @@ module Exwiw
|
|
|
510
588
|
when Exwiw::QueryAst::ColumnValue::RawSql
|
|
511
589
|
column.value
|
|
512
590
|
when Exwiw::QueryAst::ColumnValue::ReplaceWith
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
name = part[1..-2]
|
|
516
|
-
qualified_name(ast.from_table_name, name)
|
|
517
|
-
else
|
|
518
|
-
"'#{part}'"
|
|
519
|
-
end
|
|
591
|
+
if Exwiw::MaskValue.scalar?(column.value)
|
|
592
|
+
return null_preserving(ast, column, scalar_literal(column.value))
|
|
520
593
|
end
|
|
521
594
|
|
|
595
|
+
parts = mask_template_parts(ast, column)
|
|
596
|
+
|
|
522
597
|
replaced = parts.join(", ")
|
|
523
598
|
null_preserving(ast, column, "CONCAT(#{replaced})")
|
|
524
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).
|
|
@@ -170,6 +170,50 @@ module Exwiw
|
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# Every extension name a dump installs, in the order the CREATE EXTENSION
|
|
174
|
+
# statements appear. Used to report which of them #strip_extensions is about
|
|
175
|
+
# to drop; run it on the raw dump, before any wrapping pass.
|
|
176
|
+
def extension_names(sql)
|
|
177
|
+
sql.scan(CREATE_EXTENSION_RE).map { |(name)| name.delete('"') }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Drop every trace of the extensions named in `names` — the ones a managed
|
|
181
|
+
# PostgreSQL platform installs to run the source instance itself (see
|
|
182
|
+
# PostgresqlAdapter::PLATFORM_MANAGED_EXTENSIONS). Removed rather than merely
|
|
183
|
+
# wrapped like any other extension, because they cannot be created anywhere
|
|
184
|
+
# outside that platform, so keeping them only leaves a restore-time WARNING
|
|
185
|
+
# and objects the target will never have.
|
|
186
|
+
#
|
|
187
|
+
# Three things are emitted per extension and all three must go, or a leftover
|
|
188
|
+
# references a name that is no longer installed: pg_dump's `-- Name: <ext>;
|
|
189
|
+
# Type: EXTENSION` header block, the `CREATE EXTENSION`, and the
|
|
190
|
+
# `COMMENT ON EXTENSION` (whose own header reads `-- Name: EXTENSION <ext>;
|
|
191
|
+
# Type: COMMENT`). Run this on the raw dump, before the wrapping passes: they
|
|
192
|
+
# rewrite the bare statements this matches into DO blocks.
|
|
193
|
+
def strip_extensions(sql, names)
|
|
194
|
+
return sql if names.empty?
|
|
195
|
+
|
|
196
|
+
# Whole names only: each match is anchored by the whitespace/quote before it
|
|
197
|
+
# and a \b after, so `google_vacuum_mgmt` never matches an extension merely
|
|
198
|
+
# containing it (`not_google_vacuum_mgmt`, `google_vacuum_mgmt_v2`).
|
|
199
|
+
names_re = Regexp.union(names.map { |n| Regexp.escape(n) })
|
|
200
|
+
name = /(?:"#{names_re}"|#{names_re}\b)/
|
|
201
|
+
|
|
202
|
+
# Each removal takes the blank lines that trailed the object too, so the
|
|
203
|
+
# surrounding statements keep pg_dump's spacing instead of gaining a gap.
|
|
204
|
+
trailing_blank_lines = /(?:[ \t]*\r?\n(?:[ \t]*\r?\n)*)?/
|
|
205
|
+
|
|
206
|
+
sql = sql.gsub(
|
|
207
|
+
/^--\r?\n-- Name: (?:EXTENSION\s+)?#{name};[ \t]*Type:[ \t]*(?:EXTENSION|COMMENT);[^\n]*\n--(?:\r?\n)+/i,
|
|
208
|
+
"",
|
|
209
|
+
)
|
|
210
|
+
sql = sql.gsub(/^[ \t]*CREATE\s+EXTENSION\b(?:\s+IF\s+NOT\s+EXISTS)?\s+#{name}[^;]*;#{trailing_blank_lines}/i, "")
|
|
211
|
+
sql.gsub(
|
|
212
|
+
/^[ \t]*COMMENT\s+ON\s+EXTENSION\s+#{name}\s+IS\s+(?:'(?:[^']|'')*'|NULL)\s*;#{trailing_blank_lines}/i,
|
|
213
|
+
"",
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
173
217
|
# Generate idempotent CREATE TYPE ... AS ENUM statements.
|
|
174
218
|
# +enum_types+ is an Array of Hashes with keys :schema, :name, :labels.
|
|
175
219
|
def create_type_enum_statements(enum_types)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Exwiw
|
|
6
|
+
# The `replace_with` value safe mode attaches to a newly discovered column.
|
|
7
|
+
#
|
|
8
|
+
# Only types a constant can safely stand in for are masked: a default the
|
|
9
|
+
# column cannot hold would fail the restore the dump feeds, which is worse
|
|
10
|
+
# than exporting the column while its `needs_mask_decision` flag keeps the
|
|
11
|
+
# change from being merged.
|
|
12
|
+
module DefaultMask
|
|
13
|
+
FIXED_DATE = "2000-01-01"
|
|
14
|
+
FIXED_TIME = "2000-01-01 00:00:00"
|
|
15
|
+
EMPTY_JSON = "{}"
|
|
16
|
+
JSON_TYPES = %i[json jsonb].freeze
|
|
17
|
+
|
|
18
|
+
# Skip the mask when the column is too short to hold `masked-<primary key>`
|
|
19
|
+
# for a realistic key; the rendered length is only known per row at dump time.
|
|
20
|
+
MIN_TEXT_LIMIT = 20
|
|
21
|
+
MIN_EMAIL_LIMIT = 40
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The default mask for a column, or nil when no safe constant fits it.
|
|
26
|
+
# `primary_key` is what keeps a text mask unique per row, so without one text
|
|
27
|
+
# is left unmasked too. Under a unique index (`unique`) only a mask that
|
|
28
|
+
# varies per row is allowed, or every row would collide on restore.
|
|
29
|
+
def for(name:, type:, limit:, primary_key:, array: false, unique: false, column_default: nil)
|
|
30
|
+
return nil if array
|
|
31
|
+
|
|
32
|
+
mask =
|
|
33
|
+
case type
|
|
34
|
+
when :string, :text then text_mask(name, limit, primary_key)
|
|
35
|
+
else constant_mask(type, column_default)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return nil if mask.nil?
|
|
39
|
+
return nil if unique && !varies_per_row?(mask, primary_key)
|
|
40
|
+
|
|
41
|
+
mask
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The column's own default wins over the per-type constant: it is a value the
|
|
45
|
+
# column provably holds and the one the application treats as neutral, so
|
|
46
|
+
# masking a `default: true` flag does not turn the feature off for every row.
|
|
47
|
+
# A default the database computes (`now()`) arrives as nil and falls through.
|
|
48
|
+
def constant_mask(type, column_default)
|
|
49
|
+
from_default = default_value(type, column_default)
|
|
50
|
+
return from_default unless from_default.nil?
|
|
51
|
+
|
|
52
|
+
case type
|
|
53
|
+
when :integer, :decimal, :float then 0
|
|
54
|
+
when :boolean then false
|
|
55
|
+
when :date then FIXED_DATE
|
|
56
|
+
when :datetime, :timestamp, :time then FIXED_TIME
|
|
57
|
+
when :json, :jsonb then EMPTY_JSON
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The default as a mask value, or nil when it cannot be one. A JSON column's
|
|
62
|
+
# default is serialized as JSON whatever Ruby class it arrives as, so a string
|
|
63
|
+
# default keeps its quoting. Anything whose JSON contains an object is
|
|
64
|
+
# rejected rather than mis-parsed, so an array of objects falls back to the
|
|
65
|
+
# empty-JSON constant too.
|
|
66
|
+
def default_value(type, value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
mask = JSON_TYPES.include?(type) ? value.to_json : scalar_default(value)
|
|
70
|
+
return nil if mask.is_a?(String) && mask.match?(MaskValue::PLACEHOLDER)
|
|
71
|
+
|
|
72
|
+
mask
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scalar_default(value)
|
|
76
|
+
case value
|
|
77
|
+
when nil then nil
|
|
78
|
+
when true, false, Integer, Float, String then value
|
|
79
|
+
when Numeric then value.to_f
|
|
80
|
+
when Time, DateTime then value.strftime("%Y-%m-%d %H:%M:%S")
|
|
81
|
+
when Date then value.strftime("%Y-%m-%d")
|
|
82
|
+
when Hash, Array then value.to_json
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def text_mask(name, limit, primary_key)
|
|
87
|
+
return nil if primary_key.nil?
|
|
88
|
+
|
|
89
|
+
if name.to_s.include?("mail")
|
|
90
|
+
return nil if limit && limit < MIN_EMAIL_LIMIT
|
|
91
|
+
|
|
92
|
+
"masked-{#{primary_key}}@example.com"
|
|
93
|
+
else
|
|
94
|
+
return nil if limit && limit < MIN_TEXT_LIMIT
|
|
95
|
+
|
|
96
|
+
"masked-{#{primary_key}}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def varies_per_row?(mask, primary_key)
|
|
101
|
+
mask.is_a?(String) && mask.include?("{#{primary_key}}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exwiw
|
|
4
|
+
# Serdes type for a `replace_with` value: a template String with `{column}`
|
|
5
|
+
# placeholders, rendered as text, or a non-String JSON scalar used verbatim so
|
|
6
|
+
# a column that is not text keeps its type (in MongoDB, its BSON type).
|
|
7
|
+
class MaskValue < Serdes::TypeBase
|
|
8
|
+
PERMITTED = [String, Integer, Float].freeze
|
|
9
|
+
|
|
10
|
+
# What counts as a `{column}` placeholder: an empty brace pair names no
|
|
11
|
+
# column, which is what lets `{}` be an empty-JSON mask. One definition,
|
|
12
|
+
# shared by the adapters that parse a template and the generator that must
|
|
13
|
+
# avoid emitting one — keep it capture-free, since the splitter's `scan`
|
|
14
|
+
# reads whole matches.
|
|
15
|
+
PLACEHOLDER = /\{[^{}]+\}/
|
|
16
|
+
|
|
17
|
+
def permit?(value)
|
|
18
|
+
value == true || value == false || PERMITTED.any? { |type| value.is_a?(type) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Whether `value` is used verbatim rather than rendered as a template.
|
|
22
|
+
def self.scalar?(value)
|
|
23
|
+
!value.is_a?(String) && !value.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
"mask_value"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -139,10 +139,13 @@ module Exwiw
|
|
|
139
139
|
merged.fields = passed.fields.map do |pf|
|
|
140
140
|
receiver = receiver_field_by_name[pf.name]
|
|
141
141
|
if receiver
|
|
142
|
-
pf.replace_with = receiver.replace_with
|
|
142
|
+
pf.replace_with = receiver.replace_with unless receiver.replace_with.nil?
|
|
143
143
|
pf.replace_with_fake_data = receiver.replace_with_fake_data if receiver.replace_with_fake_data
|
|
144
144
|
pf.comment = receiver.comment if receiver.comment
|
|
145
145
|
pf.ignore = receiver.ignore unless receiver.ignore.nil?
|
|
146
|
+
# Receiver wins even when unset: clearing the flag is how a human
|
|
147
|
+
# records the decision, and regeneration must not bring it back.
|
|
148
|
+
pf.needs_mask_decision = receiver.needs_mask_decision
|
|
146
149
|
end
|
|
147
150
|
pf
|
|
148
151
|
end
|
|
@@ -162,7 +165,7 @@ module Exwiw
|
|
|
162
165
|
fake_data = field.replace_with_fake_data
|
|
163
166
|
next unless fake_data
|
|
164
167
|
|
|
165
|
-
|
|
168
|
+
unless field.replace_with.nil?
|
|
166
169
|
raise ArgumentError,
|
|
167
170
|
"MongodbCollectionConfig '#{name}' field '#{field.name}': replace_with and " \
|
|
168
171
|
"replace_with_fake_data cannot be combined; use only one."
|
data/lib/exwiw/mongodb_field.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Exwiw
|
|
|
5
5
|
include Serdes
|
|
6
6
|
|
|
7
7
|
attribute :name, String
|
|
8
|
-
attribute :replace_with,
|
|
8
|
+
attribute :replace_with, Serdes::OptionalType.new(MaskValue.new), skip_serializing_if_nil: true
|
|
9
9
|
# Ruby-process-side masking: replace the value with a deterministic fake
|
|
10
10
|
# value derived from a seed field (see FakeData / RowTransformer). Unlike the
|
|
11
11
|
# SQL adapters — where replace_with runs in the database and fake data needs
|
|
@@ -23,6 +23,10 @@ module Exwiw
|
|
|
23
23
|
# once the config is loaded (see MongodbCollectionConfig#reject_ignored_members!).
|
|
24
24
|
attribute :comment, optional(String), skip_serializing_if_nil: true
|
|
25
25
|
attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
26
|
+
# See TableColumn#needs_mask_decision.
|
|
27
|
+
attribute :needs_mask_decision,
|
|
28
|
+
Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)),
|
|
29
|
+
skip_serializing_if_nil: true
|
|
26
30
|
|
|
27
31
|
def self.from_symbol_keys(hash)
|
|
28
32
|
from(hash.transform_keys(&:to_s))
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -177,7 +177,7 @@ module Exwiw
|
|
|
177
177
|
columns.map do |c|
|
|
178
178
|
if c.raw_sql
|
|
179
179
|
QueryAst::ColumnValue::RawSql.new(name: c.name, value: c.raw_sql)
|
|
180
|
-
elsif c.replace_with
|
|
180
|
+
elsif !c.replace_with.nil?
|
|
181
181
|
QueryAst::ColumnValue::ReplaceWith.new(name: c.name, value: c.replace_with)
|
|
182
182
|
else
|
|
183
183
|
QueryAst::ColumnValue::Plain.new(name: c.name, value: c.name)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module Exwiw
|
|
9
|
+
# Reports how the committed schema config differs from what the application
|
|
10
|
+
# would generate now, plus the columns still waiting for a masking decision.
|
|
11
|
+
# Regenerating into a copy rather than over the config directory lets it run
|
|
12
|
+
# on a working tree it must not modify (CI). ActiveRecord only.
|
|
13
|
+
class SchemaCheck
|
|
14
|
+
CATEGORIES = %w[
|
|
15
|
+
added_tables added_columns removed_tables removed_columns changed_tables needs_mask_decision
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.from_rails_application(schema_dir:)
|
|
19
|
+
Rails.application.eager_load!
|
|
20
|
+
new(models: ActiveRecord::Base.descendants, schema_dir: schema_dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(models:, schema_dir:)
|
|
24
|
+
@models = models
|
|
25
|
+
@schema_dir = schema_dir
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The report as a plain Hash. Every list is sorted, so a given state always
|
|
29
|
+
# produces the same output (it ends up in a CI comment).
|
|
30
|
+
def run
|
|
31
|
+
committed = read_configs(@schema_dir)
|
|
32
|
+
regenerated = Dir.mktmpdir do |tmp_dir|
|
|
33
|
+
regenerate_into(tmp_dir)
|
|
34
|
+
read_configs(tmp_dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
report = diff(committed, regenerated)
|
|
38
|
+
report["needs_mask_decision"] = flagged_columns(committed)
|
|
39
|
+
report
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the report requires someone to act: the config is out of date, or
|
|
43
|
+
# a column's masking has not been decided yet.
|
|
44
|
+
def self.clean?(report)
|
|
45
|
+
CATEGORIES.all? { |category| report.fetch(category, []).empty? }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private def regenerate_into(tmp_dir)
|
|
49
|
+
FileUtils.cp_r(File.join(@schema_dir, "."), tmp_dir) if Dir.exist?(@schema_dir)
|
|
50
|
+
# Explicit: safe mode is not optional here, whatever the library default is.
|
|
51
|
+
SchemaGenerator.new(models: @models, output_dir: tmp_dir, safe_new_columns: true).generate!
|
|
52
|
+
SchemaGenerator.new(models: @models, output_dir: tmp_dir).tidy!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every config file under `dir`, keyed by its path relative to `dir` so the
|
|
56
|
+
# per-database subdirectories stay distinct. Hand-editing these files is the
|
|
57
|
+
# workflow this drives, so a syntax error has to name the file it is in.
|
|
58
|
+
private def read_configs(dir)
|
|
59
|
+
return {} unless Dir.exist?(dir)
|
|
60
|
+
|
|
61
|
+
Dir[File.join(dir, "**", "*.json")].each_with_object({}) do |path, acc|
|
|
62
|
+
key = Pathname.new(path).relative_path_from(Pathname.new(dir)).to_s
|
|
63
|
+
begin
|
|
64
|
+
acc[key] = JSON.parse(File.read(path))
|
|
65
|
+
rescue JSON::ParserError => e
|
|
66
|
+
raise JSON::ParserError, "invalid JSON in schema config '#{path}': #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private def diff(committed, regenerated)
|
|
72
|
+
report = CATEGORIES.to_h { |category| [category, []] }
|
|
73
|
+
|
|
74
|
+
(committed.keys | regenerated.keys).sort.each do |key|
|
|
75
|
+
before = committed[key]
|
|
76
|
+
after = regenerated[key]
|
|
77
|
+
|
|
78
|
+
if before.nil?
|
|
79
|
+
report["added_tables"] << table_label(key, after)
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
if after.nil?
|
|
83
|
+
report["removed_tables"] << table_label(key, before)
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
next if before == after
|
|
87
|
+
|
|
88
|
+
label = table_label(key, before)
|
|
89
|
+
report["changed_tables"] << label
|
|
90
|
+
added, removed = column_diff(before, after)
|
|
91
|
+
report["added_columns"] += added.sort.map { |column| "#{label}.#{column}" }
|
|
92
|
+
report["removed_columns"] += removed.sort.map { |column| "#{label}.#{column}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
report
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private def column_diff(before, after)
|
|
99
|
+
before_names = column_names(before)
|
|
100
|
+
after_names = column_names(after)
|
|
101
|
+
[after_names - before_names, before_names - after_names]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `fields` is the MongoDB config's spelling of `columns`.
|
|
105
|
+
private def column_names(config)
|
|
106
|
+
(config["columns"] || config["fields"] || []).map { |column| column["name"] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# How a table is named in the report. The database is part of the label, or
|
|
110
|
+
# the same table name in two of them collides (each has `schema_migrations`).
|
|
111
|
+
private def table_label(key, config)
|
|
112
|
+
name = config&.fetch("name", nil) || File.basename(key, ".json")
|
|
113
|
+
db = File.dirname(key)
|
|
114
|
+
db == "." ? name : "#{db}/#{name}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private def flagged_columns(committed)
|
|
118
|
+
committed.flat_map do |key, config|
|
|
119
|
+
(config["columns"] || config["fields"] || [])
|
|
120
|
+
.select { |column| column["needs_mask_decision"] }
|
|
121
|
+
.map { |column| "#{table_label(key, config)}.#{column['name']}" }
|
|
122
|
+
end.sort
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -43,14 +43,20 @@ module Exwiw
|
|
|
43
43
|
# belongs_to to it.
|
|
44
44
|
ACTIVE_STORAGE_VARIANT_RECORDS_TABLE = "active_storage_variant_records"
|
|
45
45
|
|
|
46
|
-
def self.from_rails_application(output_dir:)
|
|
46
|
+
def self.from_rails_application(output_dir:, safe_new_columns: true)
|
|
47
47
|
Rails.application.eager_load!
|
|
48
|
-
new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
|
|
48
|
+
new(models: ActiveRecord::Base.descendants, output_dir: output_dir, safe_new_columns: safe_new_columns)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# `safe_new_columns` (the default) emits every column masked — as far as its
|
|
52
|
+
# type allows, see DefaultMask — and flagged `needs_mask_decision: true`.
|
|
53
|
+
# #merge lets an existing entry win, so in practice only columns a migration
|
|
54
|
+
# has just added keep that treatment. Pass false to bootstrap a config, where
|
|
55
|
+
# every column is new and flagging all of them at once is noise.
|
|
56
|
+
def initialize(models:, output_dir:, safe_new_columns: true)
|
|
52
57
|
@models = models
|
|
53
58
|
@output_dir = output_dir
|
|
59
|
+
@safe_new_columns = safe_new_columns
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
def generate!
|
|
@@ -239,11 +245,12 @@ module Exwiw
|
|
|
239
245
|
columns: representative.column_names.map { |name| { name: name } },
|
|
240
246
|
)
|
|
241
247
|
else
|
|
248
|
+
belongs_tos = aggregate_belongs_tos(model_group)
|
|
242
249
|
TableConfig.from_symbol_keys(
|
|
243
250
|
name: table_name,
|
|
244
251
|
primary_key: primary_key,
|
|
245
|
-
belongs_tos:
|
|
246
|
-
columns: representative
|
|
252
|
+
belongs_tos: belongs_tos,
|
|
253
|
+
columns: build_columns(representative, primary_key, belongs_tos, conn),
|
|
247
254
|
)
|
|
248
255
|
end
|
|
249
256
|
end
|
|
@@ -251,6 +258,58 @@ module Exwiw
|
|
|
251
258
|
tables_from_models + build_rails_managed_tables(conn)
|
|
252
259
|
end
|
|
253
260
|
|
|
261
|
+
# The `columns` entries for a table: just the name, or — in safe mode — also
|
|
262
|
+
# a default mask and the `needs_mask_decision` flag. The primary key and the
|
|
263
|
+
# foreign keys/types the belongs_tos join on are flagged but never masked,
|
|
264
|
+
# since masking them would break the joins.
|
|
265
|
+
private def build_columns(representative, primary_key, belongs_tos, conn)
|
|
266
|
+
names = representative.column_names
|
|
267
|
+
return names.map { |name| { name: name } } unless @safe_new_columns
|
|
268
|
+
|
|
269
|
+
structural = belongs_tos.flat_map { |bt| [bt[:foreign_key], bt[:foreign_type]] }.compact.to_set
|
|
270
|
+
structural << primary_key if primary_key
|
|
271
|
+
columns_by_name = representative.columns.each_with_object({}) { |column, acc| acc[column.name] = column }
|
|
272
|
+
unique = unique_column_names(conn, representative.table_name)
|
|
273
|
+
defaults = representative.column_defaults
|
|
274
|
+
|
|
275
|
+
names.map do |name|
|
|
276
|
+
entry = { name: name, needs_mask_decision: true }
|
|
277
|
+
next entry if structural.include?(name)
|
|
278
|
+
|
|
279
|
+
column = columns_by_name[name]
|
|
280
|
+
mask = column && DefaultMask.for(
|
|
281
|
+
name: name,
|
|
282
|
+
type: column.type,
|
|
283
|
+
limit: column.limit,
|
|
284
|
+
primary_key: primary_key,
|
|
285
|
+
# `integer[]` reports `:integer`, so a scalar default would not fit.
|
|
286
|
+
array: column.respond_to?(:array?) && column.array?,
|
|
287
|
+
unique: unique.nil? || unique.include?(name),
|
|
288
|
+
column_default: defaults[name],
|
|
289
|
+
)
|
|
290
|
+
mask.nil? ? entry : entry.merge(replace_with: mask)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Columns covered by a unique index, or nil when they could not be read —
|
|
295
|
+
# callers treat that as "assume every column is unique", since masking a
|
|
296
|
+
# unique column with a constant breaks the restore the dump feeds.
|
|
297
|
+
#
|
|
298
|
+
# `Array()` because an expression index reports a String (`lower((email)::text)`)
|
|
299
|
+
# rather than a list; wrapping keeps it from being walked character by
|
|
300
|
+
# character, and it simply never matches a column name.
|
|
301
|
+
private def unique_column_names(conn, table_name)
|
|
302
|
+
conn.indexes(table_name).select(&:unique).flat_map { |index| Array(index.columns) }.to_set
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
# Once per run: a systematic failure would otherwise repeat per table.
|
|
305
|
+
unless @warned_missing_indexes
|
|
306
|
+
@warned_missing_indexes = true
|
|
307
|
+
warn "exwiw: could not read the indexes of '#{table_name}' (#{e.class}); " \
|
|
308
|
+
"treating every column as unique-indexed so no constant mask is emitted."
|
|
309
|
+
end
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
254
313
|
private def concrete_models
|
|
255
314
|
@models.reject(&:abstract_class?).select(&:table_exists?)
|
|
256
315
|
end
|
data/lib/exwiw/table_column.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Exwiw
|
|
|
5
5
|
include Serdes
|
|
6
6
|
|
|
7
7
|
attribute :name, String
|
|
8
|
-
attribute :replace_with,
|
|
8
|
+
attribute :replace_with, Serdes::OptionalType.new(MaskValue.new), skip_serializing_if_nil: true
|
|
9
9
|
attribute :raw_sql, optional(String), skip_serializing_if_nil: true
|
|
10
10
|
# Ruby-process-side masking modes, applied to the fetched rows by
|
|
11
11
|
# RowTransformer (SQL adapters only) — unlike replace_with/raw_sql, which
|
|
@@ -17,6 +17,12 @@ module Exwiw
|
|
|
17
17
|
# INSERT) once the config is loaded (see TableConfig#reject_ignored_members!).
|
|
18
18
|
attribute :comment, optional(String), skip_serializing_if_nil: true
|
|
19
19
|
attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
20
|
+
# Marks a column whose masking nobody has decided on yet. Extraction never
|
|
21
|
+
# reads it; `schema:generate` emits it and `schema:check` reports it, so CI
|
|
22
|
+
# can require the decision.
|
|
23
|
+
attribute :needs_mask_decision,
|
|
24
|
+
Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)),
|
|
25
|
+
skip_serializing_if_nil: true
|
|
20
26
|
|
|
21
27
|
def self.from_symbol_keys(hash)
|
|
22
28
|
from(hash.transform_keys(&:to_s))
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -259,7 +259,7 @@ module Exwiw
|
|
|
259
259
|
ruby_side = [column.map && "map", column.replace_with_fake_data && "replace_with_fake_data"].compact
|
|
260
260
|
return if ruby_side.empty?
|
|
261
261
|
|
|
262
|
-
sql_side = [column.raw_sql && "raw_sql", column.replace_with && "replace_with"].
|
|
262
|
+
sql_side = [column.raw_sql && "raw_sql", !column.replace_with.nil? && "replace_with"].select { |v| v }
|
|
263
263
|
if ruby_side.size > 1 || sql_side.any?
|
|
264
264
|
raise ArgumentError,
|
|
265
265
|
"Table '#{name}' column '#{column.name}': #{(ruby_side + sql_side).join('/')} cannot be combined; " \
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative "exwiw/config_file"
|
|
|
10
10
|
require_relative "exwiw/strict_keys"
|
|
11
11
|
require_relative "exwiw/belongs_to"
|
|
12
12
|
require_relative "exwiw/fake_data"
|
|
13
|
+
require_relative "exwiw/mask_value"
|
|
14
|
+
require_relative "exwiw/default_mask"
|
|
13
15
|
require_relative "exwiw/table_column"
|
|
14
16
|
require_relative "exwiw/reverse_scope"
|
|
15
17
|
require_relative "exwiw/batch_scope"
|
|
@@ -38,6 +40,7 @@ require_relative "exwiw/after_insert_hook"
|
|
|
38
40
|
require_relative "exwiw/runner"
|
|
39
41
|
require_relative "exwiw/explain_runner"
|
|
40
42
|
require_relative "exwiw/schema_generator"
|
|
43
|
+
require_relative "exwiw/schema_check"
|
|
41
44
|
require_relative "exwiw/mongoid_schema_generator"
|
|
42
45
|
|
|
43
46
|
begin
|
data/lib/tasks/exwiw.rake
CHANGED
|
@@ -13,12 +13,18 @@ namespace :exwiw do
|
|
|
13
13
|
ENV["EXWIW_SCHEMA_DIR_PATH"] || Exwiw::ConfigFile.schema_dir || "exwiw/schema"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Safe mode is on unless EXWIW_NEW_COLUMNS=plain, which a first-time
|
|
17
|
+
# bootstrap wants: there every column is new, so safe mode would flag the
|
|
18
|
+
# whole config at once. See SchemaGenerator#initialize.
|
|
19
|
+
safe_new_columns = lambda { ENV["EXWIW_NEW_COLUMNS"] != "plain" }
|
|
20
|
+
|
|
16
21
|
desc "Generate schema from application"
|
|
17
22
|
task generate: :environment do
|
|
18
23
|
require "exwiw"
|
|
19
24
|
|
|
20
25
|
groups = Exwiw::SchemaGenerator.from_rails_application(
|
|
21
26
|
output_dir: resolve_schema_dir.call,
|
|
27
|
+
safe_new_columns: safe_new_columns.call,
|
|
22
28
|
).generate!
|
|
23
29
|
|
|
24
30
|
# Surface cross-database belongs_tos the generator auto-ignored: these
|
|
@@ -38,6 +44,24 @@ namespace :exwiw do
|
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
|
|
47
|
+
desc "Report how the committed schema config differs from the application, without changing it"
|
|
48
|
+
task check: :environment do
|
|
49
|
+
require "exwiw"
|
|
50
|
+
|
|
51
|
+
report = Exwiw::SchemaCheck.from_rails_application(schema_dir: resolve_schema_dir.call).run
|
|
52
|
+
json = JSON.pretty_generate(report)
|
|
53
|
+
puts json
|
|
54
|
+
# A file too, so a caller need not assume stdout carries only the JSON.
|
|
55
|
+
File.write(ENV["EXWIW_SCHEMA_CHECK_OUTPUT"], json + "\n") if ENV["EXWIW_SCHEMA_CHECK_OUTPUT"]
|
|
56
|
+
|
|
57
|
+
unless Exwiw::SchemaCheck.clean?(report)
|
|
58
|
+
$stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
|
|
59
|
+
"run `rake exwiw:schema:generate exwiw:schema:tidy` " \
|
|
60
|
+
"and resolve every `needs_mask_decision` column."
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
41
65
|
desc "Remove tables/columns from the schema config that no longer exist in the application"
|
|
42
66
|
task tidy: :environment do
|
|
43
67
|
require "exwiw"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exwiw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -70,12 +70,14 @@ files:
|
|
|
70
70
|
- lib/exwiw/cli.rb
|
|
71
71
|
- lib/exwiw/config_file.rb
|
|
72
72
|
- lib/exwiw/ddl_postprocessor.rb
|
|
73
|
+
- lib/exwiw/default_mask.rb
|
|
73
74
|
- lib/exwiw/determine_table_processing_order.rb
|
|
74
75
|
- lib/exwiw/embedded_in.rb
|
|
75
76
|
- lib/exwiw/explain_runner.rb
|
|
76
77
|
- lib/exwiw/ext_json.rb
|
|
77
78
|
- lib/exwiw/fake_data.rb
|
|
78
79
|
- lib/exwiw/japanese_names.rb
|
|
80
|
+
- lib/exwiw/mask_value.rb
|
|
79
81
|
- lib/exwiw/mongo_query.rb
|
|
80
82
|
- lib/exwiw/mongodb_collection_config.rb
|
|
81
83
|
- lib/exwiw/mongodb_field.rb
|
|
@@ -88,6 +90,7 @@ files:
|
|
|
88
90
|
- lib/exwiw/reverse_scope.rb
|
|
89
91
|
- lib/exwiw/row_transformer.rb
|
|
90
92
|
- lib/exwiw/runner.rb
|
|
93
|
+
- lib/exwiw/schema_check.rb
|
|
91
94
|
- lib/exwiw/schema_generator.rb
|
|
92
95
|
- lib/exwiw/strict_keys.rb
|
|
93
96
|
- lib/exwiw/table_column.rb
|