exwiw 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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))
@@ -170,9 +170,9 @@ module Exwiw
170
170
  merged_table.type = passed_table.type
171
171
  merged_table.comment = comment
172
172
  merged_table.filter = filter
173
- merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
174
173
  merged_table.ignore = ignore
175
174
  # User-owned, never regenerated: carry over from the existing config.
175
+ merged_table.bulk_insert_chunk_size = bulk_insert_chunk_size
176
176
  merged_table.scope_exempt = scope_exempt
177
177
  merged_table.scope_column = scope_column
178
178
  merged_table.reverse_scope = reverse_scope
@@ -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.20"
4
+ VERSION = "0.9.22"
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,11 @@ 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/db_introspector"
44
+ require_relative "exwiw/db_introspector/mysql_introspector"
45
+ require_relative "exwiw/db_introspector/postgresql_introspector"
46
+ require_relative "exwiw/db_schema_generator"
47
+ require_relative "exwiw/schema_check"
41
48
  require_relative "exwiw/mongoid_schema_generator"
42
49
 
43
50
  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"
@@ -76,7 +100,44 @@ namespace :exwiw do
76
100
  Exwiw::MongoidSchemaGenerator.from_rails_application(
77
101
  output_dir: resolve_schema_dir.call,
78
102
  skip_unsupported: ENV["EXWIW_SKIP_UNSUPPORTED"] == "1",
103
+ # Same convention as `generate`: safe unless EXWIW_NEW_COLUMNS=plain.
104
+ safe_new_columns: safe_new_columns.call,
79
105
  ).generate!
80
106
  end
107
+
108
+ desc "Remove collections from the schema config that no longer exist in the Mongoid application"
109
+ task tidy_mongoid: :environment do
110
+ require "exwiw"
111
+
112
+ result = Exwiw::MongoidSchemaGenerator.from_rails_application(
113
+ output_dir: resolve_schema_dir.call,
114
+ ).tidy!
115
+
116
+ if result.empty?
117
+ puts "exwiw: schema config is already tidy; nothing to remove."
118
+ else
119
+ result.removed_tables.each do |name|
120
+ puts "exwiw: removed config for collection '#{name}' (no longer exists in the application)."
121
+ end
122
+ end
123
+ end
124
+
125
+ desc "Report how the committed schema config differs from the Mongoid application, without changing it"
126
+ task check_mongoid: :environment do
127
+ require "exwiw"
128
+
129
+ report = Exwiw::SchemaCheck.from_mongoid_application(schema_dir: resolve_schema_dir.call).run
130
+ json = JSON.pretty_generate(report)
131
+ puts json
132
+ # A file too, so a caller need not assume stdout carries only the JSON.
133
+ File.write(ENV["EXWIW_SCHEMA_CHECK_OUTPUT"], json + "\n") if ENV["EXWIW_SCHEMA_CHECK_OUTPUT"]
134
+
135
+ unless Exwiw::SchemaCheck.clean?(report)
136
+ $stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
137
+ "run `rake exwiw:schema:generate_mongoid exwiw:schema:tidy_mongoid` " \
138
+ "and resolve every `needs_mask_decision` field."
139
+ exit 1
140
+ end
141
+ end
81
142
  end
82
143
  end
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.20
4
+ version: 0.9.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -69,13 +69,19 @@ files:
69
69
  - lib/exwiw/belongs_to.rb
70
70
  - lib/exwiw/cli.rb
71
71
  - lib/exwiw/config_file.rb
72
+ - lib/exwiw/db_introspector.rb
73
+ - lib/exwiw/db_introspector/mysql_introspector.rb
74
+ - lib/exwiw/db_introspector/postgresql_introspector.rb
75
+ - lib/exwiw/db_schema_generator.rb
72
76
  - lib/exwiw/ddl_postprocessor.rb
77
+ - lib/exwiw/default_mask.rb
73
78
  - lib/exwiw/determine_table_processing_order.rb
74
79
  - lib/exwiw/embedded_in.rb
75
80
  - lib/exwiw/explain_runner.rb
76
81
  - lib/exwiw/ext_json.rb
77
82
  - lib/exwiw/fake_data.rb
78
83
  - lib/exwiw/japanese_names.rb
84
+ - lib/exwiw/mask_value.rb
79
85
  - lib/exwiw/mongo_query.rb
80
86
  - lib/exwiw/mongodb_collection_config.rb
81
87
  - lib/exwiw/mongodb_field.rb
@@ -88,6 +94,7 @@ files:
88
94
  - lib/exwiw/reverse_scope.rb
89
95
  - lib/exwiw/row_transformer.rb
90
96
  - lib/exwiw/runner.rb
97
+ - lib/exwiw/schema_check.rb
91
98
  - lib/exwiw/schema_generator.rb
92
99
  - lib/exwiw/strict_keys.rb
93
100
  - lib/exwiw/table_column.rb