exwiw 0.9.6 → 0.9.8

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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ # Raised when a schema config JSON carries a key that no declared attribute
5
+ # accepts. A subclass of ArgumentError so existing "invalid config" handling
6
+ # (and specs) that rescue ArgumentError keep working.
7
+ UnknownConfigKeyError = Class.new(ArgumentError)
8
+
9
+ # Strict key validation for the schema config JSON.
10
+ #
11
+ # Serdes deserialization is lenient: a key that matches no declared attribute
12
+ # is silently dropped. For hand-maintained schema configs that turns a typo
13
+ # (`reverse_scop`) — or a key another adapter supports but this one does not
14
+ # (`raw_sql` on a MongoDB field) — into a silent no-op: the config loads, the
15
+ # dump runs, and the requested masking/scoping simply never happens.
16
+ # {TableConfig.from} / {MongodbCollectionConfig.from} therefore validate the
17
+ # raw hash against the declared attributes before deserializing, recursing
18
+ # into nested config objects (belongs_tos, columns/fields, reverse_scope,
19
+ # embedded_in, replace_with_fake_data).
20
+ #
21
+ # Free-form annotations do not need an escape hatch: `comment` is a declared
22
+ # (documentation-only) attribute on the table/collection configs and their
23
+ # belongs_to/column/field entries, so it always passes.
24
+ module StrictKeys
25
+ module_function
26
+
27
+ # Validate `hash` (a raw config hash, string- or symbol-keyed) against the
28
+ # attributes declared on the Serdes class `klass`, recursing into nested
29
+ # Serdes-typed attributes. `owner` names the config for the error message
30
+ # (e.g. "table 'users'"); `path` locates a nested entry within it (e.g.
31
+ # "belongs_tos[0]"). No-op for non-Hash input — Serdes' own type errors
32
+ # cover malformed values.
33
+ def validate!(klass, hash, owner:, path: nil)
34
+ return unless hash.is_a?(Hash)
35
+
36
+ attributes = klass.__send__(:_serde_attrs)
37
+ rename_strategy = klass.__send__(:_serde_rename_strategy)
38
+ allowed = attributes.each_value.to_h { |attr| [attr.serialized_name(rename_strategy), attr] }
39
+
40
+ unknown = hash.keys.map(&:to_s) - allowed.keys
41
+ unless unknown.empty?
42
+ location = path ? " (in #{path})" : ""
43
+ raise UnknownConfigKeyError,
44
+ "Unknown key#{'s' if unknown.size > 1} #{unknown.map { |key| "'#{key}'" }.join(', ')} " \
45
+ "in #{owner}#{location}. Allowed keys: #{allowed.keys.sort.join(', ')}. " \
46
+ "Unknown keys are rejected because they would otherwise be silently ignored " \
47
+ "(hiding typos and keys this adapter does not support); remove or rename the key, " \
48
+ "or use `comment` for free-form notes."
49
+ end
50
+
51
+ allowed.each do |key, attr|
52
+ nested_klass = serdes_class(attr.attr_type)
53
+ next if nested_klass.nil?
54
+
55
+ value = hash.key?(key) ? hash[key] : hash[key.to_sym]
56
+ nested_path = path ? "#{path}.#{key}" : key
57
+ case value
58
+ when Hash
59
+ validate!(nested_klass, value, owner: owner, path: nested_path)
60
+ when Array
61
+ value.each_with_index do |element, index|
62
+ validate!(nested_klass, element, owner: owner, path: "#{nested_path}[#{index}]") if element.is_a?(Hash)
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ # The Serdes-including class a (possibly optional/array-wrapped) attribute
69
+ # type deserializes into, or nil for scalar types.
70
+ def serdes_class(type)
71
+ case type
72
+ when Serdes::OptionalType then serdes_class(type.base_type)
73
+ when Serdes::ArrayType then serdes_class(type.element_type)
74
+ when Serdes::ConcreteType then serdes_class(type.exact_type)
75
+ when Class then type if type.include?(Serdes)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -7,6 +7,11 @@ module Exwiw
7
7
  attribute :name, String
8
8
  attribute :replace_with, optional(String), skip_serializing_if_nil: true
9
9
  attribute :raw_sql, optional(String), skip_serializing_if_nil: true
10
+ # Ruby-process-side masking modes, applied to the fetched rows by
11
+ # RowTransformer (SQL adapters only) — unlike replace_with/raw_sql, which
12
+ # compile into the SELECT and run in the database.
13
+ attribute :map, optional(String), skip_serializing_if_nil: true
14
+ attribute :replace_with_fake_data, Serdes::OptionalType.new(FakeData), skip_serializing_if_nil: true
10
15
  # User-owned fields preserved across schema regeneration (see
11
16
  # TableConfig#merge). `ignore:true` drops the column from extraction (SELECT /
12
17
  # INSERT) once the config is loaded (see TableConfig#reject_ignored_members!).
@@ -48,6 +48,15 @@ module Exwiw
48
48
  attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
49
49
 
50
50
  def self.from(hash)
51
+ # Reject unknown keys before deserializing: Serdes silently drops them,
52
+ # which would turn a typo'd or unsupported key into a silent no-op (see
53
+ # Exwiw::StrictKeys). `comment` is a declared attribute here and on the
54
+ # nested belongs_to/column entries, so free-form notes stay accepted.
55
+ if hash.is_a?(Hash)
56
+ table_name = hash["name"] || hash[:name]
57
+ StrictKeys.validate!(self, hash, owner: "table '#{table_name}'")
58
+ end
59
+
51
60
  config = super
52
61
  config.send(:validate_after_load!)
53
62
  config
@@ -219,6 +228,42 @@ module Exwiw
219
228
  if primary_key.nil? && !ignore
220
229
  raise ArgumentError, "Table '#{name}' requires primary_key."
221
230
  end
231
+
232
+ columns.each { |column| validate_ruby_side_masking!(column) }
233
+ end
234
+ end
235
+
236
+ # The Ruby-side masking modes (`map` / `replace_with_fake_data`) are strictly
237
+ # exclusive — with each other and with the SQL-side modes. Only the new keys
238
+ # are restricted: the legacy raw_sql > replace_with precedence stays lenient.
239
+ # Deliberately static (no faker require, no proc eval) so schema
240
+ # regeneration never executes config Ruby; the seed column and the map proc
241
+ # are resolved at dump time by RowTransformer.build.
242
+ private def validate_ruby_side_masking!(column)
243
+ ruby_side = [column.map && "map", column.replace_with_fake_data && "replace_with_fake_data"].compact
244
+ return if ruby_side.empty?
245
+
246
+ sql_side = [column.raw_sql && "raw_sql", column.replace_with && "replace_with"].compact
247
+ if ruby_side.size > 1 || sql_side.any?
248
+ raise ArgumentError,
249
+ "Table '#{name}' column '#{column.name}': #{(ruby_side + sql_side).join('/')} cannot be combined; " \
250
+ "use at most one of map/replace_with_fake_data, without raw_sql/replace_with."
251
+ end
252
+
253
+ fake_data = column.replace_with_fake_data
254
+ return unless fake_data
255
+
256
+ unless RowTransformer::FAKE_TYPES.key?(fake_data.type)
257
+ raise ArgumentError,
258
+ "Table '#{name}' column '#{column.name}': unknown replace_with_fake_data type '#{fake_data.type}' " \
259
+ "(supported: #{RowTransformer::FAKE_TYPES.keys.join(', ')})."
260
+ end
261
+
262
+ seed_column = fake_data.seed.delete_prefix("#{name}.")
263
+ if seed_column.include?(".") || columns.none? { |c| c.name == seed_column }
264
+ raise ArgumentError,
265
+ "Table '#{name}' column '#{column.name}': replace_with_fake_data seed '#{fake_data.seed}' " \
266
+ "does not name a column of this table (use 'column' or '#{name}.column')."
222
267
  end
223
268
  end
224
269
 
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.6"
4
+ VERSION = "0.9.8"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -7,7 +7,9 @@ require "serdes"
7
7
 
8
8
  require_relative "exwiw/ext_json"
9
9
  require_relative "exwiw/config_file"
10
+ require_relative "exwiw/strict_keys"
10
11
  require_relative "exwiw/belongs_to"
12
+ require_relative "exwiw/fake_data"
11
13
  require_relative "exwiw/table_column"
12
14
  require_relative "exwiw/reverse_scope"
13
15
  require_relative "exwiw/table_config"
@@ -28,6 +30,7 @@ require_relative "exwiw/mongodb_parallel_dumper"
28
30
  require_relative "exwiw/mongo_query"
29
31
  require_relative "exwiw/query_ast"
30
32
  require_relative "exwiw/query_ast_builder"
33
+ require_relative "exwiw/row_transformer"
31
34
  require_relative "exwiw/after_insert_hook"
32
35
  require_relative "exwiw/runner"
33
36
  require_relative "exwiw/explain_runner"
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.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -47,6 +47,7 @@ files:
47
47
  - docs/plans/2026-05-29-rails-managed-tables.md
48
48
  - docs/plans/2026-05-31-ids-column-for-sql-adapters.md
49
49
  - docs/plans/2026-06-19-mongodb-export-remove-parallelism-native-ext.md
50
+ - docs/row-transform-masking-notes.md
50
51
  - docs/scope-column-redesign.md
51
52
  - docs/sql-dump-optimization-notes.md
52
53
  - exe/exwiw
@@ -69,6 +70,7 @@ files:
69
70
  - lib/exwiw/embedded_in.rb
70
71
  - lib/exwiw/explain_runner.rb
71
72
  - lib/exwiw/ext_json.rb
73
+ - lib/exwiw/fake_data.rb
72
74
  - lib/exwiw/mongo_query.rb
73
75
  - lib/exwiw/mongodb_collection_config.rb
74
76
  - lib/exwiw/mongodb_field.rb
@@ -79,8 +81,10 @@ files:
79
81
  - lib/exwiw/query_ast_builder.rb
80
82
  - lib/exwiw/railtie.rb
81
83
  - lib/exwiw/reverse_scope.rb
84
+ - lib/exwiw/row_transformer.rb
82
85
  - lib/exwiw/runner.rb
83
86
  - lib/exwiw/schema_generator.rb
87
+ - lib/exwiw/strict_keys.rb
84
88
  - lib/exwiw/table_column.rb
85
89
  - lib/exwiw/table_config.rb
86
90
  - lib/exwiw/version.rb