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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f565edc88b4eba108ef4d9accf7bbaa7d48f0e1700007cc9d280b0033e357b6
4
- data.tar.gz: 822e5cd0b9d33c81d0b396cc641fa960cf80bffbebd6bd078322460a140b37cf
3
+ metadata.gz: 61f6374ae972477087c63744e9a3702acc795872f7d08599cef69bc7898563db
4
+ data.tar.gz: 5a140799d94c5804fec5988c0643acbcc79c33ed442e7be36fe2b65bcce14a3b
5
5
  SHA512:
6
- metadata.gz: 20da3c9ee09865762f40a04b6c4bf6cf24582545ba7c51945a69aed69798906a19a214c9bd2ad588e0b6740f29a3ebfa08a122845f9a28fe64b654ba723088a0
7
- data.tar.gz: db91db3a7e1774d386f45d3582301098a23df86f48fe790fae3631895b87fe686dcce3d048a17827f69ce530f948ca423bb9028f8a4b2495d167e820edd6f3fb
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 unless field.replace_with
736
+ next if field.replace_with.nil?
737
737
 
738
- acc << [field.name, compile_template(field.replace_with)]
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, segments|
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(segments, doc)
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
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
490
- if part.start_with?('{')
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
- idempotent = stdout
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
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
514
- if part.start_with?('{')
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
- parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
333
- if part.start_with?('{')
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 if 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
- if field.replace_with
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."
@@ -5,7 +5,7 @@ module Exwiw
5
5
  include Serdes
6
6
 
7
7
  attribute :name, String
8
- attribute :replace_with, optional(String), skip_serializing_if_nil: true
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))
@@ -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
- def initialize(models:, output_dir:)
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: aggregate_belongs_tos(model_group),
246
- columns: representative.column_names.map { |name| { name: name } },
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
@@ -5,7 +5,7 @@ module Exwiw
5
5
  include Serdes
6
6
 
7
7
  attribute :name, String
8
- attribute :replace_with, optional(String), skip_serializing_if_nil: true
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))
@@ -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"].compact
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.19"
4
+ VERSION = "0.9.21"
5
5
  end
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.19
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