exwiw 0.6.1 → 0.7.0

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: b66bdb8ccf3d2043c0a903c70071e0f070b41b9fd89dbc09e3c5385c9b4916c1
4
- data.tar.gz: cf1224f47635113127f2e1fcf38f161c539f7b68f0bb8c85ba0c428fa6a202d1
3
+ metadata.gz: 12765a8f130dec0055149beb7ba69b37c9b548758cfbd8dd807c1499a9fbf0b5
4
+ data.tar.gz: 9165cda22d6e4eb2ff97a6d386510a04f37d353e2b31ea4b6bada2fc1ad2adb7
5
5
  SHA512:
6
- metadata.gz: 41a4ef3c8a6da41ccbb92d7e42a519c5260a1bbcf53ce9cdcfe3a18dbca55d509c7ca1492e726bdb82bfc8c101a0a6bcee1dec1c051ceae44910009801d4b64d
7
- data.tar.gz: ce5cdc2e96fcacbfc2a7c3ca23c72a22a1c3e765da2e69ad21fd9ebd87bbfdc3619e54a0c6eb5ae94321e4dc1b8651ca73a965cf33509ecafba9349b8a894883
6
+ metadata.gz: eb9ad3e65e4b8756574b5c97fec28be38cc708604cbdaffcb772bd8958b183a067dffafa44078705ed00346be27a9aa81bd68a212b7bcecf635a84d6a7f86adc
7
+ data.tar.gz: fd47a8459abec5a56ab2c64c7cecc4ffa916608b2cf9278a68a2985defa18812b4f09b70a54b8d6ac498a305022644a0a0d923358a5a06143dedcb550aab7e84
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.0] - 2026-06-23
6
+
7
+ ### Changed
8
+
9
+ - **`replace_with` now preserves NULL.** A NULL source value stays NULL instead of being replaced by the masked literal (all adapters: MySQL, PostgreSQL, SQLite, MongoDB). Previously masking a nullable column clobbered NULL into a non-NULL value, losing the "not set" signal in the dump and making `.nil?`/`.present?`-dependent code behave differently than against production. The SQL adapters now wrap the masking expression in `CASE WHEN <column> IS NOT NULL THEN <masked> ELSE NULL END`, and the MongoDB adapter skips masking a field whose value is nil or absent. This is a behavior change for **nullable** masked columns only — `NOT NULL` columns are unaffected, and an empty string is a real value that is still masked (only true NULL/absent is preserved). It removes the need for hand-written `raw_sql` `CASE` workarounds to keep NULLs.
10
+
11
+ ## [0.6.2] - 2026-06-21
12
+
5
13
  ## [0.6.1] - 2026-06-20
6
14
 
7
15
  ## [0.6.0] - 2026-06-20
data/README.md CHANGED
@@ -253,12 +253,18 @@ The config generator is provided as a Rake task.
253
253
  bundle exec rake exwiw:schema:generate
254
254
  ```
255
255
 
256
- By default, the schema files will be saved in the `exwiw/schema` directory. You can specify a different output directory by setting the `EXWIW_SCHEMA_DIR_PATH` environment variable:
256
+ The output directory is resolved in this order:
257
+
258
+ 1. the `EXWIW_SCHEMA_DIR_PATH` environment variable, if set;
259
+ 2. otherwise `schema_dir` from the config file (`exwiw.yml` / `exwiw.yaml` in the current directory), so the generator and the `exwiw` CLI share one location without repeating the path;
260
+ 3. otherwise the `exwiw/schema` default.
257
261
 
258
262
  ```sh
259
263
  EXWIW_SCHEMA_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
260
264
  ```
261
265
 
266
+ As with the CLI, a relative `schema_dir` in the config file is resolved relative to the config file's own directory.
267
+
262
268
  #### Tidying stale config (`schema:tidy`)
263
269
 
264
270
  `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:
@@ -608,6 +614,12 @@ and you can use the column name with `{}` to replace the value with the column v
608
614
  For example, Let assume we have the record which id is 1,
609
615
  then "user{id}@example.com" will be replaced with "user1@example.com".
610
616
 
617
+ `replace_with` **preserves NULL**: a source value that is `NULL` (or, for MongoDB, an
618
+ absent field) is left as-is instead of being replaced by the masked literal, so the
619
+ "not set" signal survives into the dump. Only true `NULL`/absent is preserved — an empty
620
+ string is a real value and is still masked. Because of this you do not need to hand-write a
621
+ `raw_sql` `CASE WHEN ... IS NOT NULL ...` to keep NULLs.
622
+
611
623
  #### `raw_sql`
612
624
 
613
625
  It will used instead of the original value.
@@ -514,6 +514,11 @@ module Exwiw
514
514
  # pair, with all per-config lookups hoisted into the plan.
515
515
  private def apply_mask_plan!(doc, plan)
516
516
  plan.masked_fields.each do |name, segments|
517
+ # Preserve a NULL / absent source value instead of clobbering it into a
518
+ # masked literal. `doc[name].nil?` is true for both an explicit nil and
519
+ # an absent key, so an absent key is left absent (not created).
520
+ next if doc[name].nil?
521
+
517
522
  doc[name] = render_template(segments, doc)
518
523
  end
519
524
  plan.embedded.each do |child|
@@ -330,7 +330,7 @@ module Exwiw
330
330
  end
331
331
 
332
332
  replaced = parts.join(", ")
333
- "CONCAT(#{replaced})"
333
+ null_preserving(ast, column, "CONCAT(#{replaced})")
334
334
  else
335
335
  raise "Unreachable case: #{column.inspect}"
336
336
  end
@@ -456,7 +456,7 @@ module Exwiw
456
456
  end
457
457
 
458
458
  replaced = parts.join(", ")
459
- "CONCAT(#{replaced})"
459
+ null_preserving(ast, column, "CONCAT(#{replaced})")
460
460
  else
461
461
  raise "Unreachable case: #{column.inspect}"
462
462
  end
@@ -298,7 +298,7 @@ module Exwiw
298
298
  end
299
299
 
300
300
  replaced = parts.join(" || ")
301
- "(#{replaced})"
301
+ null_preserving(ast, column, "(#{replaced})")
302
302
  else
303
303
  raise "Unreachable case: #{column.inspect}"
304
304
  end
data/lib/exwiw/adapter.rb CHANGED
@@ -202,6 +202,17 @@ module Exwiw
202
202
  rescue => e
203
203
  "<unavailable: #{e.class}: #{e.message}>"
204
204
  end
205
+
206
+ # Wrap a masking expression so a NULL source value stays NULL instead of
207
+ # being clobbered into a non-NULL literal. The guard checks the masked
208
+ # column itself (`column.name`) — not any other column the template may
209
+ # reference — so only true NULL is preserved; an empty string is a real
210
+ # value and is still masked. The column reference uses the same
211
+ # `<from_table>.<col>` form as the Plain branch. Shared by the SQL
212
+ # adapters' #compile_column_name ReplaceWith handling.
213
+ private def null_preserving(ast, column, masked_expr)
214
+ "CASE WHEN #{ast.from_table_name}.#{column.name} IS NOT NULL THEN #{masked_expr} ELSE NULL END"
215
+ end
205
216
  end
206
217
 
207
218
  # @params [Exwiw::QueryAst] query_ast
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Exwiw
6
+ # Minimal reader for the exwiw config YAML (exwiw.yml / exwiw.yaml).
7
+ #
8
+ # The CLI has its own, richer config handling (CLI#apply_config_file!); this
9
+ # module exists for contexts that have no CLI in scope — chiefly the
10
+ # `exwiw:schema:*` rake tasks, which otherwise only knew the
11
+ # EXWIW_SCHEMA_DIR_PATH env var and a hard-coded default. It deliberately
12
+ # reads only what those tasks need (schema_dir) and never aborts the process:
13
+ # an absent or unreadable config simply yields nil so the caller can fall back.
14
+ module ConfigFile
15
+ # Mirrors CLI::DEFAULT_CONFIG_PATHS; .yml wins when both are present.
16
+ DEFAULT_PATHS = %w[exwiw.yml exwiw.yaml].freeze
17
+
18
+ module_function
19
+
20
+ # The `schema_dir` from the config file, expanded to an absolute path
21
+ # relative to the config file's own directory (matching the CLI). Returns
22
+ # nil when no config file is found, it cannot be parsed, or it does not set
23
+ # `schema_dir`. Pass an explicit `path` to read a specific file; otherwise
24
+ # the default paths are looked up in the current directory.
25
+ def schema_dir(path = nil)
26
+ path ||= DEFAULT_PATHS.map { |p| File.expand_path(p) }.find { |p| File.file?(p) }
27
+ return nil if path.nil? || !File.file?(path)
28
+
29
+ config =
30
+ begin
31
+ YAML.safe_load(File.read(path))
32
+ rescue Psych::SyntaxError
33
+ nil
34
+ end
35
+ return nil unless config.is_a?(Hash)
36
+
37
+ value = config["schema_dir"]
38
+ return nil if value.nil?
39
+
40
+ # Strip a trailing slash and resolve relative to the config file's
41
+ # directory, exactly as CLI#expand_dir does.
42
+ value = value.end_with?("/") ? value[0..-2] : value
43
+ File.expand_path(value, File.dirname(File.expand_path(path)))
44
+ end
45
+ end
46
+ end
@@ -24,6 +24,7 @@ module Exwiw
24
24
  table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
25
25
 
26
26
  target = table_by_name[@dump_target.table_name]
27
+ validate_target_exists!(target)
27
28
  adapter.validate_as_dump_target!(target) if target
28
29
 
29
30
  dumpable_configs = configs.select { |c| adapter.dumpable?(c) }
@@ -62,6 +63,21 @@ module Exwiw
62
63
  end
63
64
  end
64
65
 
66
+ # Reject a `--target-table` (or `--target-collection`) absent from the loaded
67
+ # schema; mirrors Runner#validate_target_exists! so explain and export fail the
68
+ # same way on a typo. `target` is the looked-up config (nil when not found).
69
+ #
70
+ # TODO: same caveat as Runner#validate_target_exists! — this checks the schema
71
+ # (schema_dir JSON), not the live DB connection; verifying against the
72
+ # connection would need a table-exists capability on each adapter. revisit.
73
+ private def validate_target_exists!(target)
74
+ return if @dump_target.table_name.nil?
75
+ return unless target.nil?
76
+
77
+ raise ArgumentError,
78
+ "--target-table '#{@dump_target.table_name}' does not exist in the schema (#{@schema_dir})."
79
+ end
80
+
65
81
  private def validate_ignored(configs)
66
82
  ignored_names = configs.select { |c| c.ignore }.map(&:name).to_set
67
83
  return if ignored_names.empty?
data/lib/exwiw/runner.rb CHANGED
@@ -36,6 +36,7 @@ module Exwiw
36
36
  table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
37
37
 
38
38
  target = table_by_name[@dump_target.table_name]
39
+ validate_target_exists!(target)
39
40
  adapter.validate_as_dump_target!(target) if target
40
41
 
41
42
  dumpable_configs = configs.select { |c| adapter.dumpable?(c) }
@@ -211,6 +212,24 @@ module Exwiw
211
212
  ignored_names.each { |n| @logger.info("Table '#{n}' is marked ignore:true (schema will be included, data extraction skipped)") }
212
213
  end
213
214
 
215
+ # Reject a `--target-table` (or `--target-collection`) that does not match any
216
+ # table/collection in the loaded schema. Without this a typo'd target silently
217
+ # matched nothing and produced an empty dump with no indication of the mistake.
218
+ # `target` is the looked-up config (nil when not found); a nil dump target
219
+ # (dump-all / scope-column mode) is allowed through.
220
+ #
221
+ # TODO: this checks the loaded schema (schema_dir JSON), not the live DB
222
+ # connection — a table that exists in the database but has no schema config
223
+ # is still rejected here. We may instead want to verify existence against the
224
+ # connection (would need a table-exists capability on each adapter). revisit.
225
+ private def validate_target_exists!(target)
226
+ return if @dump_target.table_name.nil?
227
+ return unless target.nil?
228
+
229
+ raise ArgumentError,
230
+ "--target-table '#{@dump_target.table_name}' does not exist in the schema (#{@schema_dir})."
231
+ end
232
+
214
233
  private def validate_rails_managed_target!(configs)
215
234
  return if @dump_target.table_name.nil?
216
235
 
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.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -6,6 +6,7 @@ require "json"
6
6
  require "serdes"
7
7
 
8
8
  require_relative "exwiw/ext_json"
9
+ require_relative "exwiw/config_file"
9
10
  require_relative "exwiw/belongs_to"
10
11
  require_relative "exwiw/table_column"
11
12
  require_relative "exwiw/table_config"
data/lib/tasks/exwiw.rake CHANGED
@@ -2,12 +2,23 @@
2
2
 
3
3
  namespace :exwiw do
4
4
  namespace :schema do
5
+ # Output directory for the generated schema config. Precedence:
6
+ # 1. EXWIW_SCHEMA_DIR_PATH env var (explicit per-run override)
7
+ # 2. schema_dir in the exwiw config file (exwiw.yml/.yaml), so generating
8
+ # the schema and running the `exwiw` CLI agree on one location without
9
+ # repeating the path
10
+ # 3. the historical "exwiw/schema" default
11
+ # Resolved at task-run time (after `require "exwiw"` has loaded ConfigFile).
12
+ resolve_schema_dir = lambda do
13
+ ENV["EXWIW_SCHEMA_DIR_PATH"] || Exwiw::ConfigFile.schema_dir || "exwiw/schema"
14
+ end
15
+
5
16
  desc "Generate schema from application"
6
17
  task generate: :environment do
7
18
  require "exwiw"
8
19
 
9
20
  Exwiw::SchemaGenerator.from_rails_application(
10
- output_dir: ENV["EXWIW_SCHEMA_DIR_PATH"] || "exwiw/schema",
21
+ output_dir: resolve_schema_dir.call,
11
22
  ).generate!
12
23
  end
13
24
 
@@ -16,7 +27,7 @@ namespace :exwiw do
16
27
  require "exwiw"
17
28
 
18
29
  result = Exwiw::SchemaGenerator.from_rails_application(
19
- output_dir: ENV["EXWIW_SCHEMA_DIR_PATH"] || "exwiw/schema",
30
+ output_dir: resolve_schema_dir.call,
20
31
  ).tidy!
21
32
 
22
33
  if result.empty?
@@ -47,7 +58,7 @@ namespace :exwiw do
47
58
  require "exwiw"
48
59
 
49
60
  Exwiw::MongoidSchemaGenerator.from_rails_application(
50
- output_dir: ENV["EXWIW_SCHEMA_DIR_PATH"] || "exwiw/schema",
61
+ output_dir: resolve_schema_dir.call,
51
62
  skip_unsupported: ENV["EXWIW_SKIP_UNSUPPORTED"] == "1",
52
63
  ).generate!
53
64
  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.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -61,6 +61,7 @@ files:
61
61
  - lib/exwiw/after_insert_hook.rb
62
62
  - lib/exwiw/belongs_to.rb
63
63
  - lib/exwiw/cli.rb
64
+ - lib/exwiw/config_file.rb
64
65
  - lib/exwiw/ddl_postprocessor.rb
65
66
  - lib/exwiw/determine_table_processing_order.rb
66
67
  - lib/exwiw/embedded_in.rb