exwiw 0.9.20 → 0.9.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +215 -3
- data/docs/mongodb.md +13 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +7 -4
- data/lib/exwiw/adapter/mysql_adapter.rb +4 -7
- data/lib/exwiw/adapter/postgresql_adapter.rb +4 -7
- data/lib/exwiw/adapter/sqlite_adapter.rb +4 -7
- data/lib/exwiw/adapter.rb +27 -0
- data/lib/exwiw/cli.rb +190 -3
- data/lib/exwiw/db_introspector/mysql_introspector.rb +160 -0
- data/lib/exwiw/db_introspector/postgresql_introspector.rb +215 -0
- data/lib/exwiw/db_introspector.rb +166 -0
- data/lib/exwiw/db_schema_generator.rb +298 -0
- data/lib/exwiw/default_mask.rb +104 -0
- data/lib/exwiw/mask_value.rb +30 -0
- data/lib/exwiw/mongodb_collection_config.rb +22 -10
- data/lib/exwiw/mongodb_field.rb +5 -1
- data/lib/exwiw/mongoid_schema_generator.rb +368 -36
- data/lib/exwiw/query_ast.rb +1 -1
- data/lib/exwiw/schema_check.rb +167 -0
- data/lib/exwiw/schema_generator.rb +64 -5
- data/lib/exwiw/table_column.rb +7 -1
- data/lib/exwiw/table_config.rb +2 -2
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +7 -0
- data/lib/tasks/exwiw.rake +61 -0
- metadata +8 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Exwiw
|
|
6
|
+
# Reads a database's structure — tables, primary keys, columns, unique
|
|
7
|
+
# indexes, foreign keys — straight from its catalog, with no application code
|
|
8
|
+
# in the process.
|
|
9
|
+
#
|
|
10
|
+
# This is what lets a non-Ruby application keep an exwiw schema config: the
|
|
11
|
+
# ActiveRecord generator needs the app's models loaded in memory, which only
|
|
12
|
+
# its own runtime can do, while the database schema is a description every
|
|
13
|
+
# application shares regardless of the language it is written in. What is
|
|
14
|
+
# lost by reading the database instead of the models is relations that exist
|
|
15
|
+
# only in application code (no foreign-key constraint backs them) — which is
|
|
16
|
+
# why DbSchemaGenerator only ever *adds* belongs_tos and never rewrites the
|
|
17
|
+
# ones already in the config.
|
|
18
|
+
#
|
|
19
|
+
# Everything is scoped to the connection's current database/schema (MySQL:
|
|
20
|
+
# `DATABASE()`, PostgreSQL: `current_schema()`), i.e. exactly the objects the
|
|
21
|
+
# extraction itself would see through the same connection. There is no
|
|
22
|
+
# multi-database grouping equivalent to the ActiveRecord generator's: a
|
|
23
|
+
# connection points at one database, so one run generates one config
|
|
24
|
+
# directory, and a second database is a second run.
|
|
25
|
+
module DbIntrospector
|
|
26
|
+
# The adapters that can be introspected. sqlite is excluded because its
|
|
27
|
+
# catalog (PRAGMA-based) is a different shape entirely, and mongodb has no
|
|
28
|
+
# fixed schema to read — MongoidSchemaGenerator covers that case from the
|
|
29
|
+
# application side.
|
|
30
|
+
SUPPORTED_ADAPTERS = %w[mysql postgresql].freeze
|
|
31
|
+
|
|
32
|
+
# One column of a table, in the vocabulary DefaultMask.for speaks:
|
|
33
|
+
#
|
|
34
|
+
# - `type` is an ActiveRecord-ish symbol (:string, :integer, ...) or nil
|
|
35
|
+
# when the database type has no equivalent there. nil is deliberate and
|
|
36
|
+
# safe: DefaultMask emits no mask for a type it does not recognize, so an
|
|
37
|
+
# exotic column is exported unmasked-but-flagged rather than masked with a
|
|
38
|
+
# value it cannot hold.
|
|
39
|
+
# - `limit` is the character length for text-ish columns, else nil.
|
|
40
|
+
# - `array` marks a PostgreSQL ARRAY column, where no scalar mask fits.
|
|
41
|
+
# - `default` is the column's default as a Ruby scalar, or nil when it is
|
|
42
|
+
# absent or is an expression the database evaluates per row.
|
|
43
|
+
Column = Data.define(:name, :type, :limit, :array, :default)
|
|
44
|
+
|
|
45
|
+
# Build the introspector for a connection. Takes the same ConnectionConfig
|
|
46
|
+
# the adapters do, so the CLI can hand over exactly what it already parsed.
|
|
47
|
+
def self.build(connection_config)
|
|
48
|
+
case Adapter.normalize_name(connection_config.adapter)
|
|
49
|
+
when "mysql" then MysqlIntrospector.new(connection_config)
|
|
50
|
+
when "postgresql" then PostgresqlIntrospector.new(connection_config)
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"Schema generation from a database connection supports " \
|
|
54
|
+
"#{SUPPORTED_ADAPTERS.join(' / ')} only, got #{connection_config.adapter.inspect}."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Base
|
|
59
|
+
def initialize(connection_config)
|
|
60
|
+
@connection_config = connection_config
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sorted names of the tables in the current database/schema. Views are
|
|
64
|
+
# excluded: they hold no rows of their own, so exporting one would
|
|
65
|
+
# duplicate data already covered by its underlying tables (and the
|
|
66
|
+
# restore would fail against a target where the same view exists).
|
|
67
|
+
def table_names
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The table's primary key as a String, an Array for a composite key, or
|
|
72
|
+
# nil when the table has none. The three cases are what the generator
|
|
73
|
+
# branches on, so they are kept distinct rather than normalized.
|
|
74
|
+
def primary_key(_table_name)
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The table's columns as Column structs, in ordinal_position order — the
|
|
79
|
+
# order the config lists them in, which mirrors what a `SELECT *` returns.
|
|
80
|
+
def columns(_table_name)
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The names of columns covered by any unique index or constraint, or nil
|
|
85
|
+
# when the catalog could not be read. Callers treat nil as "assume every
|
|
86
|
+
# column is unique", since masking a unique column with a constant makes
|
|
87
|
+
# every row collide on restore. The primary key is included; it is
|
|
88
|
+
# unique, and the generator never masks it anyway.
|
|
89
|
+
def unique_column_names(_table_name)
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The table's single-column foreign keys as sorted
|
|
94
|
+
# `{ table_name:, foreign_key: }` hashes — the belongs_to shape. Composite
|
|
95
|
+
# foreign keys are skipped with a warning: exwiw joins on one column, so a
|
|
96
|
+
# multi-column edge cannot be expressed, and silently emitting one of its
|
|
97
|
+
# columns would produce a join that is quietly wrong.
|
|
98
|
+
def foreign_keys(_table_name)
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Turn the plain default literal the catalog reported into the Ruby
|
|
103
|
+
# scalar DefaultMask can use as a mask value.
|
|
104
|
+
#
|
|
105
|
+
# Deliberately narrow. A default only earns its place as a mask because it
|
|
106
|
+
# is a value the column provably holds and the application treats as
|
|
107
|
+
# neutral; a value we had to guess at loses both properties. So a type
|
|
108
|
+
# whose text form we cannot map back with certainty yields nil and the
|
|
109
|
+
# per-type constant is used instead. JSON is excluded for that reason:
|
|
110
|
+
# DefaultMask re-serializes a JSON default with #to_json, which would turn
|
|
111
|
+
# the catalog's already-serialized text into a doubly-encoded string.
|
|
112
|
+
private def coerce_default(type, literal)
|
|
113
|
+
return nil if literal.nil?
|
|
114
|
+
|
|
115
|
+
case type
|
|
116
|
+
when :integer then Integer(literal, exception: false)
|
|
117
|
+
when :decimal, :float then Float(literal, exception: false)
|
|
118
|
+
when :boolean then BOOLEAN_DEFAULTS[literal.downcase]
|
|
119
|
+
when :string, :text, :date, :datetime, :time then literal
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# How the two databases spell a boolean default in the catalog: MySQL
|
|
124
|
+
# stores a TINYINT(1) default as "1"/"0", PostgreSQL a boolean one as
|
|
125
|
+
# "true"/"false". Anything else is not a boolean literal and falls through
|
|
126
|
+
# to nil.
|
|
127
|
+
BOOLEAN_DEFAULTS = {
|
|
128
|
+
"1" => true, "true" => true,
|
|
129
|
+
"0" => false, "false" => false,
|
|
130
|
+
}.freeze
|
|
131
|
+
|
|
132
|
+
# Emit `message` to stderr the first time this introspector hits `key`.
|
|
133
|
+
# A catalog failure is systematic rather than per-table, so repeating it
|
|
134
|
+
# once per table would bury the rest of the run's output.
|
|
135
|
+
private def warn_once(key, message)
|
|
136
|
+
@warned ||= {}
|
|
137
|
+
return if @warned[key]
|
|
138
|
+
|
|
139
|
+
@warned[key] = true
|
|
140
|
+
warn(message)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Group the catalog's one-row-per-key-column foreign-key listing into
|
|
144
|
+
# `{ table_name:, foreign_key: }` entries, dropping composite constraints.
|
|
145
|
+
# Both introspectors read their catalog in that shape (ordered by
|
|
146
|
+
# constraint name, then key position), so the grouping and the warning
|
|
147
|
+
# live here rather than being written twice.
|
|
148
|
+
private def build_foreign_keys(table_name, rows)
|
|
149
|
+
rows.group_by { |constraint_name, _column_name, _referenced_table| constraint_name }
|
|
150
|
+
.filter_map do |constraint_name, group|
|
|
151
|
+
if group.size > 1
|
|
152
|
+
warn "exwiw: skipping composite foreign key '#{constraint_name}' on '#{table_name}' " \
|
|
153
|
+
"(#{group.map { |_c, column_name, _r| column_name }.join(', ')}); " \
|
|
154
|
+
"exwiw joins a belongs_to on a single column."
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
_constraint_name, column_name, referenced_table = group.first
|
|
159
|
+
{ table_name: referenced_table, foreign_key: column_name }
|
|
160
|
+
end
|
|
161
|
+
.uniq
|
|
162
|
+
.sort_by { |entry| [entry[:table_name], entry[:foreign_key]] }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Exwiw
|
|
8
|
+
# Generates (and tidies) a schema config from a live database connection
|
|
9
|
+
# instead of from an application's models — see DbIntrospector for why an
|
|
10
|
+
# application exwiw cannot load needs this.
|
|
11
|
+
#
|
|
12
|
+
# It mirrors SchemaGenerator table for table and column for column, with one
|
|
13
|
+
# deliberate difference in how belongs_tos are reconciled (see
|
|
14
|
+
# #merged_belongs_tos) and one structural simplification: the output directory
|
|
15
|
+
# is flat, because a connection addresses exactly one database. A second
|
|
16
|
+
# database is a second run against a second connection, which is also how the
|
|
17
|
+
# extraction itself treats it.
|
|
18
|
+
class DbSchemaGenerator
|
|
19
|
+
# SchemaGenerator::TidyResult plus the belongs_tos this generator can also
|
|
20
|
+
# remove. Subclassed rather than extended in place so the ActiveRecord
|
|
21
|
+
# generator's result — and the rake task reporting it — keeps exactly the
|
|
22
|
+
# contract it has today.
|
|
23
|
+
class TidyResult < SchemaGenerator::TidyResult
|
|
24
|
+
attr_reader :removed_belongs_tos
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
super
|
|
28
|
+
@removed_belongs_tos = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_removed_belongs_to(table_name, target_table_name)
|
|
32
|
+
(@removed_belongs_tos[table_name] ||= []) << target_table_name
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
super && @removed_belongs_tos.empty?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `safe_new_columns` matches SchemaGenerator's: on (the default) every
|
|
41
|
+
# column is emitted masked as far as its type allows and flagged
|
|
42
|
+
# `needs_mask_decision: true`, and #merge lets an already-decided entry win,
|
|
43
|
+
# so in practice only genuinely new columns keep that treatment.
|
|
44
|
+
def initialize(introspector:, output_dir:, safe_new_columns: true)
|
|
45
|
+
@introspector = introspector
|
|
46
|
+
@output_dir = output_dir
|
|
47
|
+
@safe_new_columns = safe_new_columns
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Write one config file per table in the database, merged with whatever is
|
|
51
|
+
# already on disk, and return the configs as written.
|
|
52
|
+
#
|
|
53
|
+
# Unlike SchemaGenerator, the configs are built *inside* the write path
|
|
54
|
+
# rather than by a separate builder: what the generated belongs_tos are —
|
|
55
|
+
# and therefore which columns count as structural and must not be masked —
|
|
56
|
+
# depends on the existing file (see #merged_belongs_tos), so the two cannot
|
|
57
|
+
# be separated without introspecting the disk twice.
|
|
58
|
+
def generate!
|
|
59
|
+
FileUtils.mkdir_p(@output_dir)
|
|
60
|
+
|
|
61
|
+
@introspector.table_names.map do |table_name|
|
|
62
|
+
path = File.join(@output_dir, "#{table_name}.json")
|
|
63
|
+
existing = read_config(path)
|
|
64
|
+
generated = build_table(table_name, existing)
|
|
65
|
+
|
|
66
|
+
config_to_write = existing ? existing.merge(generated) : generated
|
|
67
|
+
File.write(path, JSON.pretty_generate(config_to_write.to_hash) + "\n")
|
|
68
|
+
config_to_write
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reconcile the config files on disk against the database, removing only
|
|
73
|
+
# what no longer exists there:
|
|
74
|
+
#
|
|
75
|
+
# - a config file whose table is gone is deleted,
|
|
76
|
+
# - columns a surviving table no longer has are dropped, and
|
|
77
|
+
# - a belongs_to pointing at a table that is gone is dropped.
|
|
78
|
+
#
|
|
79
|
+
# The belongs_to case is this generator's own. Because #generate! only ever
|
|
80
|
+
# adds relations (never rewrites the list), a relation whose target table
|
|
81
|
+
# was dropped would otherwise survive every regeneration, and a belongs_to
|
|
82
|
+
# with no target table crashes dependency resolution at extraction time.
|
|
83
|
+
# An `ignore: true` entry is kept regardless: those are user tombstones
|
|
84
|
+
# recording a decision ("this relation is deliberately not extracted"), and
|
|
85
|
+
# deleting one would invite the next regeneration to add the edge back.
|
|
86
|
+
#
|
|
87
|
+
# Like SchemaGenerator#tidy!, nothing is added or regenerated here: every
|
|
88
|
+
# surviving entry keeps its hand-edited `comment` / `ignore` /
|
|
89
|
+
# `replace_with` untouched. Returns a TidyResult describing the removals.
|
|
90
|
+
def tidy!
|
|
91
|
+
result = TidyResult.new
|
|
92
|
+
return result unless Dir.exist?(@output_dir)
|
|
93
|
+
|
|
94
|
+
# Views are not generated (see DbIntrospector::Base#table_names), so a
|
|
95
|
+
# config naming one is stale by the same definition as a dropped table.
|
|
96
|
+
existing_tables = @introspector.table_names.to_set
|
|
97
|
+
|
|
98
|
+
Dir[File.join(@output_dir, "*.json")].sort.each do |path|
|
|
99
|
+
existing = TableConfig.from(JSON.parse(File.read(path)))
|
|
100
|
+
|
|
101
|
+
unless existing_tables.include?(existing.name)
|
|
102
|
+
File.delete(path)
|
|
103
|
+
result.add_removed_table(existing.name)
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
changed = false
|
|
108
|
+
changed |= remove_stale_columns(existing, result)
|
|
109
|
+
changed |= remove_dangling_belongs_tos(existing, existing_tables, result)
|
|
110
|
+
File.write(path, JSON.pretty_generate(existing.to_hash) + "\n") if changed
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private def read_config(path)
|
|
117
|
+
return nil unless File.exist?(path)
|
|
118
|
+
|
|
119
|
+
TableConfig.from(JSON.parse(File.read(path)))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The config for one table, in the same three shapes SchemaGenerator emits
|
|
123
|
+
# — with one more case it has to handle: a table with no primary key at
|
|
124
|
+
# all. ActiveRecord always reports one (a model without it cannot be
|
|
125
|
+
# queried), but a database is free to have none.
|
|
126
|
+
private def build_table(table_name, existing)
|
|
127
|
+
introspected_primary_key = @introspector.primary_key(table_name)
|
|
128
|
+
primary_key = declared_primary_key(introspected_primary_key, existing)
|
|
129
|
+
belongs_tos = merged_belongs_tos(table_name, existing)
|
|
130
|
+
column_names = @introspector.columns(table_name).map(&:name)
|
|
131
|
+
|
|
132
|
+
# A composite primary key is not supported yet. The config file is still
|
|
133
|
+
# generated — with `primary_key` omitted, `ignore: true` and a `type`
|
|
134
|
+
# marking it unsupported — so it can serve as a signpost for adding
|
|
135
|
+
# support later, and so a user can wire it up by hand meanwhile.
|
|
136
|
+
if primary_key.nil? && introspected_primary_key.is_a?(Array)
|
|
137
|
+
TableConfig.from_symbol_keys(
|
|
138
|
+
name: table_name,
|
|
139
|
+
type: TableConfig::UNSUPPORTED_COMPOSITE_PRIMARY_KEY,
|
|
140
|
+
ignore: true,
|
|
141
|
+
comment: "exwiw does not support composite primary keys " \
|
|
142
|
+
"(#{introspected_primary_key.join(', ')}); data extraction is skipped.",
|
|
143
|
+
belongs_tos: belongs_tos,
|
|
144
|
+
columns: column_names.map { |name| { name: name } },
|
|
145
|
+
)
|
|
146
|
+
elsif primary_key.nil?
|
|
147
|
+
# exwiw addresses rows by primary key — it is what an extraction query
|
|
148
|
+
# filters and joins on — so a table without one cannot be extracted as
|
|
149
|
+
# it stands. Emitted with `ignore: true` rather than skipped entirely so
|
|
150
|
+
# the table is visible in the config (and in schema:check) instead of
|
|
151
|
+
# silently missing, and so opting it in is an edit rather than a
|
|
152
|
+
# discovery.
|
|
153
|
+
TableConfig.from_symbol_keys(
|
|
154
|
+
name: table_name,
|
|
155
|
+
ignore: true,
|
|
156
|
+
comment: "This table has no primary key, which exwiw needs to identify and join rows; " \
|
|
157
|
+
"data extraction is skipped. To export it, set `primary_key` to a column that " \
|
|
158
|
+
"uniquely identifies a row and remove `ignore`.",
|
|
159
|
+
belongs_tos: belongs_tos,
|
|
160
|
+
columns: column_names.map { |name| { name: name } },
|
|
161
|
+
)
|
|
162
|
+
else
|
|
163
|
+
TableConfig.from_symbol_keys(
|
|
164
|
+
name: table_name,
|
|
165
|
+
primary_key: primary_key,
|
|
166
|
+
belongs_tos: belongs_tos,
|
|
167
|
+
columns: build_columns(table_name, primary_key, belongs_tos),
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The primary key to build a table's config from: the one the database
|
|
173
|
+
# reports, or — when it reports none exwiw can use — the one the config on
|
|
174
|
+
# disk declares.
|
|
175
|
+
#
|
|
176
|
+
# The fallback is what makes the two signpost shapes below actionable. Both
|
|
177
|
+
# tell the user to name a primary key by hand ("set `primary_key` to a
|
|
178
|
+
# column that uniquely identifies a row and remove `ignore`"), and a table
|
|
179
|
+
# can be perfectly extractable that way — a natural key with a unique index
|
|
180
|
+
# but no PK constraint, or one column of a composite key that is unique on
|
|
181
|
+
# its own. Without this, following those instructions would not survive the
|
|
182
|
+
# next run: `TableConfig#merge` takes `primary_key` from the generated side,
|
|
183
|
+
# which is still nil because the database is unchanged, so the hand-set key
|
|
184
|
+
# would be dropped and the table left with nothing to join or filter on —
|
|
185
|
+
# silently, since the regenerated config is also what `schema check`
|
|
186
|
+
# compares against.
|
|
187
|
+
#
|
|
188
|
+
# A declared key also selects the ordinary table shape, so the `type` /
|
|
189
|
+
# `comment` signposts are not re-imposed on a table the user has since wired
|
|
190
|
+
# up (`ignore` is receiver-owned in the merge and already stays removed).
|
|
191
|
+
private def declared_primary_key(introspected_primary_key, existing)
|
|
192
|
+
return introspected_primary_key if introspected_primary_key.is_a?(String)
|
|
193
|
+
|
|
194
|
+
declared = existing&.primary_key
|
|
195
|
+
declared.is_a?(String) ? declared : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The belongs_tos to generate for a table: everything the existing config
|
|
199
|
+
# already declares, verbatim and in its on-disk order, plus the
|
|
200
|
+
# foreign-key-derived relations it does not have yet, appended in sorted
|
|
201
|
+
# order.
|
|
202
|
+
#
|
|
203
|
+
# This union is the one place this generator deliberately departs from the
|
|
204
|
+
# ActiveRecord one, and it exists because a foreign-key constraint is
|
|
205
|
+
# strictly weaker evidence than an application model. Plenty of schemas
|
|
206
|
+
# express a relation only in application code — no constraint backs it —
|
|
207
|
+
# and the belongs_tos in an existing config are frequently hand-written for
|
|
208
|
+
# exactly that reason. They are load-bearing: a belongs_to is the path
|
|
209
|
+
# extraction follows to reach a table, so dropping one silently narrows the
|
|
210
|
+
# dump. TableConfig#merge rebuilds `belongs_tos` from the generated side,
|
|
211
|
+
# which is safe when that side saw the models and knows the full set, but
|
|
212
|
+
# would delete every unbacked relation here.
|
|
213
|
+
#
|
|
214
|
+
# So introspection only ever *adds* an edge. Removing one is tidy's job,
|
|
215
|
+
# where it is driven by the target table actually being gone rather than by
|
|
216
|
+
# the absence of a constraint (see #tidy!).
|
|
217
|
+
# Returns plain hashes rather than BelongsTo objects, because that is what
|
|
218
|
+
# TableConfig.from_symbol_keys consumes (it round-trips the whole table
|
|
219
|
+
# through JSON) — and because a hash of the existing entry carries its
|
|
220
|
+
# user-owned `comment` / `ignore` / `ignore_type` / `references` along
|
|
221
|
+
# without this method having to know they exist.
|
|
222
|
+
private def merged_belongs_tos(table_name, existing)
|
|
223
|
+
declared = (existing&.belongs_tos || []).map(&:to_hash)
|
|
224
|
+
# Identity here is the physical join — target table plus foreign-key
|
|
225
|
+
# column — rather than BelongsTo#identity, which also distinguishes the
|
|
226
|
+
# polymorphic type value. A hand-written polymorphic relation already
|
|
227
|
+
# covers its foreign-key column, so re-adding the bare constraint edge
|
|
228
|
+
# would emit a second belongs_to joining on the same column.
|
|
229
|
+
declared_keys = declared.map { |entry| [entry["table_name"], entry["foreign_key"]] }.to_set
|
|
230
|
+
|
|
231
|
+
discovered = @introspector.foreign_keys(table_name)
|
|
232
|
+
.reject { |entry| declared_keys.include?([entry[:table_name], entry[:foreign_key]]) }
|
|
233
|
+
.map { |entry| { "table_name" => entry[:table_name], "foreign_key" => entry[:foreign_key] } }
|
|
234
|
+
|
|
235
|
+
declared + discovered
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The `columns` entries for a table: just the name, or — in safe mode — also
|
|
239
|
+
# a default mask and the `needs_mask_decision` flag, exactly as
|
|
240
|
+
# SchemaGenerator#build_columns does it. The primary key and the foreign
|
|
241
|
+
# keys/types the belongs_tos join on are flagged but never masked, since
|
|
242
|
+
# masking them would break the joins.
|
|
243
|
+
#
|
|
244
|
+
# The structural set is computed from the *merged* belongs_tos, so a
|
|
245
|
+
# relation that exists only in the config — with no constraint behind it —
|
|
246
|
+
# protects its foreign-key column from a default mask just as a discovered
|
|
247
|
+
# one does. An `ignore: true` relation counts too: the column is still a
|
|
248
|
+
# foreign key, and the tombstone says nothing about masking it.
|
|
249
|
+
private def build_columns(table_name, primary_key, belongs_tos)
|
|
250
|
+
columns = @introspector.columns(table_name)
|
|
251
|
+
return columns.map { |column| { name: column.name } } unless @safe_new_columns
|
|
252
|
+
|
|
253
|
+
structural = belongs_tos.flat_map { |bt| [bt["foreign_key"], bt["foreign_type"]] }.compact.to_set
|
|
254
|
+
structural << primary_key
|
|
255
|
+
unique = @introspector.unique_column_names(table_name)
|
|
256
|
+
|
|
257
|
+
columns.map do |column|
|
|
258
|
+
entry = { name: column.name, needs_mask_decision: true }
|
|
259
|
+
next entry if structural.include?(column.name)
|
|
260
|
+
|
|
261
|
+
mask = DefaultMask.for(
|
|
262
|
+
name: column.name,
|
|
263
|
+
type: column.type,
|
|
264
|
+
limit: column.limit,
|
|
265
|
+
primary_key: primary_key,
|
|
266
|
+
array: column.array,
|
|
267
|
+
unique: unique.nil? || unique.include?(column.name),
|
|
268
|
+
column_default: column.default,
|
|
269
|
+
)
|
|
270
|
+
mask.nil? ? entry : entry.merge(replace_with: mask)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private def remove_stale_columns(existing, result)
|
|
275
|
+
valid_column_names = @introspector.columns(existing.name).map(&:name).to_set
|
|
276
|
+
stale = existing.columns.reject { |column| valid_column_names.include?(column.name) }
|
|
277
|
+
return false if stale.empty?
|
|
278
|
+
|
|
279
|
+
existing.columns = existing.columns.select { |column| valid_column_names.include?(column.name) }
|
|
280
|
+
stale.each { |column| result.add_removed_column(existing.name, column.name) }
|
|
281
|
+
true
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private def remove_dangling_belongs_tos(existing, existing_tables, result)
|
|
285
|
+
dangling = existing.belongs_tos.reject do |belongs_to|
|
|
286
|
+
# An ignored relation is a user tombstone and is kept whatever its
|
|
287
|
+
# target is; one with no target at all is already inert (it records a
|
|
288
|
+
# relation exwiw cannot resolve) and is left alone as well.
|
|
289
|
+
belongs_to.ignore || belongs_to.table_name.nil? || existing_tables.include?(belongs_to.table_name)
|
|
290
|
+
end
|
|
291
|
+
return false if dangling.empty?
|
|
292
|
+
|
|
293
|
+
existing.belongs_tos = existing.belongs_tos - dangling
|
|
294
|
+
dangling.each { |belongs_to| result.add_removed_belongs_to(existing.name, belongs_to.table_name) }
|
|
295
|
+
true
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -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
|
|
@@ -96,7 +96,8 @@ module Exwiw
|
|
|
96
96
|
# bulk_insert_chunk_size, query_timeout_ms, and each field's
|
|
97
97
|
# `replace_with` / `replace_with_fake_data` masking rule.
|
|
98
98
|
# - generated fields drive the field list (so added/removed fields track the
|
|
99
|
-
# model), but a
|
|
99
|
+
# model), but for a field the receiver already has, its masking decision
|
|
100
|
+
# wins outright — including the parts of it left unset.
|
|
100
101
|
def merge(passed)
|
|
101
102
|
return passed if passed.to_hash == to_hash
|
|
102
103
|
|
|
@@ -133,17 +134,28 @@ module Exwiw
|
|
|
133
134
|
end
|
|
134
135
|
|
|
135
136
|
# Take each field from the freshly generated config (so structural facts
|
|
136
|
-
# like `mongoid_field_name` track the model) but
|
|
137
|
-
#
|
|
137
|
+
# like `mongoid_field_name` track the model), but once the field already
|
|
138
|
+
# exists in the receiver, EVERY masking-decision attribute comes from the
|
|
139
|
+
# receiver — even when it is unset.
|
|
140
|
+
#
|
|
141
|
+
# "Even when unset" is the whole point: what these attributes record is a
|
|
142
|
+
# human's decision about the field, and an absent value is a decision too
|
|
143
|
+
# ("export this raw", "the flag is resolved"). Keeping the generated value
|
|
144
|
+
# where the receiver has none was equivalent to "receiver wins" only while
|
|
145
|
+
# the generator emitted no masks at all; under safe mode
|
|
146
|
+
# (MongoidSchemaGenerator's `safe_new_columns`) it would put a default mask
|
|
147
|
+
# back on a field somebody had deliberately unmasked, and silently mask it
|
|
148
|
+
# again on every regeneration.
|
|
138
149
|
receiver_field_by_name = fields.each_with_object({}) { |f, h| h[f.name] = f }
|
|
139
150
|
merged.fields = passed.fields.map do |pf|
|
|
140
151
|
receiver = receiver_field_by_name[pf.name]
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
next pf unless receiver
|
|
153
|
+
|
|
154
|
+
pf.replace_with = receiver.replace_with
|
|
155
|
+
pf.replace_with_fake_data = receiver.replace_with_fake_data
|
|
156
|
+
pf.comment = receiver.comment
|
|
157
|
+
pf.ignore = receiver.ignore
|
|
158
|
+
pf.needs_mask_decision = receiver.needs_mask_decision
|
|
147
159
|
pf
|
|
148
160
|
end
|
|
149
161
|
end
|
|
@@ -162,7 +174,7 @@ module Exwiw
|
|
|
162
174
|
fake_data = field.replace_with_fake_data
|
|
163
175
|
next unless fake_data
|
|
164
176
|
|
|
165
|
-
|
|
177
|
+
unless field.replace_with.nil?
|
|
166
178
|
raise ArgumentError,
|
|
167
179
|
"MongodbCollectionConfig '#{name}' field '#{field.name}': replace_with and " \
|
|
168
180
|
"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))
|