exwiw 0.9.5 → 0.9.6

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: 26a737cacfa93caa21e4198a6b82df13daa24708940ef3792a1df8a948c22a39
4
- data.tar.gz: 8a8ab4cd417c3555d364fad969c2c6e930303b04c1795f7dda85d8b5f57eae24
3
+ metadata.gz: 4a9938f5cd311ecefb5568ed27f35ed8594a9ba15e0041e241c423b25af12cba
4
+ data.tar.gz: f838bd892aecb7a717eac84f1bf52045cec0b6c1881c3a26c29bd4983c845326
5
5
  SHA512:
6
- metadata.gz: 980386da8daf57851f67f6f67deee5d0c107407d3458946985528624224a4a4462bf551008e57f2c4e9055a82e53146f253ec256cfca1bfccf3695aba2571fdb
7
- data.tar.gz: f9e0abde493ddaf4bf714eab396e1d8e9a7ce0b828d87cff3e22b1b5b1bb70584148a940a664cfa09f6a0a1f4a798c56b375479c94c176cf391b88a0a7b5b73e
6
+ metadata.gz: 17568187de281651d94abf50190bd0926a3a85eaaf468f9dcdc43b1254583c8cc416f5282e55de16f8af7a44dc1635f95d10f6a06b33de668ab98ace8e7eda79
7
+ data.tar.gz: 8530a5f2eaf0c15dda1c604e6bc65e923b8f91322990352e3de6ccb3c8dc832f2184e76496941cc4d64c49089b1639bc7b3743f20e79c3ad6083acff1b9c6f26
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.6] - 2026-07-08
6
+
7
+ ### Added
8
+
9
+ - **Ruby after-insert hooks can seed named MongoDB collections with `insert_jsonl(collection, template)`.** The single-argument `insert_sql(template)` / `insert_jsonl(template)` form writes all hook output to one collection-less `insert-{N+1}-after_insert.{ext}` file. That is fine for SQL — the statements name their table in-band — but MongoDB import derives the target collection from the filename (`insert-NNN-<collection>.jsonl` → `mongoimport --collection <collection>`), so a hook had no way to seed documents into specific collections. The new two-argument form (**mongodb-only**; a SQL adapter raises `ArgumentError`) appends the ERB-rendered extended-JSON lines to the named collection's own buffer; after the hook finishes each targeted collection is written to its own `insert-NNN-<collection>.jsonl`, numbered sequentially after the dump's own files (and after the collection-less `after_insert` file when both forms are used), so the existing filename-based import convention applies to hook output unchanged. The single-argument form is untouched.
10
+
11
+ ### Fixed
12
+
13
+ - **The MySQL adapter now escapes newline and control characters (`\n`, `\r`, `\0`, `\Z`) in string values, matching mysqldump.** They were previously written raw, so a value containing a newline was emitted with a literal line break inside its `VALUES` tuple. That is valid SQL on its own, but it breaks any consumer that splits a dump into statements on a semicolon-newline boundary when a string value contains `;` immediately followed by a newline — the statement is cut mid-value, leaving an unterminated string literal. Escaping keeps every tuple on a single line. Single-quote escaping (doubling) is unchanged, and the SQLite/PostgreSQL adapters are unaffected. MySQL insert output changes only for string values that contain these characters.
14
+
5
15
  ## [0.9.5] - 2026-07-07
6
16
 
7
17
  ### Fixed
data/README.md CHANGED
@@ -488,10 +488,11 @@ By default, exwiw generates `delete-*.sql` files alongside the `insert-*.sql` fi
488
488
 
489
489
  `--after-insert-hook=PATH` runs a post-processing hook **after** all per-table insert/delete files have been written. The hook can be either a Ruby file (`.rb`) or any executable script (e.g. `.sh`).
490
490
 
491
- **Ruby hook (`.rb`)**: provides a tiny DSL with two builtins:
491
+ **Ruby hook (`.rb`)**: provides a tiny DSL with these builtins:
492
492
 
493
493
  - `cli_options` — Hash of all parsed CLI options (e.g. `cli_options.fetch(:ids)` returns the `--ids` array).
494
494
  - `insert_sql(template)` — appends an ERB-rendered string to a buffer. After the hook finishes, the buffer is concatenated and written to `insert-{N+1}-after_insert.{ext}` where `{N+1}` is one past the last per-table insert file. For the MongoDB adapter the equivalent alias `insert_jsonl(template)` is available; output goes to `insert-{N+1}-after_insert.jsonl`. Multiple `insert_sql` calls in a single hook are joined with `"\n"` into the same file. If no `insert_sql` call is made, no file is created.
495
+ - `insert_jsonl(collection, template)` — **MongoDB adapter only**. SQL statements name their table in-band, but JSONL documents do not — the import convention derives the target collection from the filename — so the two-argument form writes the ERB-rendered extended-JSON lines to the named collection's own `insert-NNN-<collection>.jsonl` file, importable with the same `mongoimport --collection <collection>` convention as the per-collection dump files. Multiple calls targeting the same collection are appended (joined with `"\n"`) into that collection's file; distinct collections get one file each, numbered sequentially after the last per-collection dump file (the collection-less `after_insert` buffer, when also used, keeps `{N+1}` and the collection files follow it). Calling this form with a SQL adapter raises an error.
495
496
 
496
497
  Example `hooks/seed_default_users.rb`:
497
498
 
@@ -504,6 +505,17 @@ insert_sql <<~SQL
504
505
  SQL
505
506
  ```
506
507
 
508
+ MongoDB example seeding two collections (`insert-{N+1}-users.jsonl` and `insert-{N+2}-posts.jsonl`):
509
+
510
+ ```ruby
511
+ insert_jsonl 'users', <<~JSONL
512
+ <%- cli_options.fetch(:ids).each do |shop_id| -%>
513
+ {"shop_id":{"$oid":"<%= shop_id %>"},"email":"default@example.com"}
514
+ <%- end -%>
515
+ JSONL
516
+ insert_jsonl 'posts', '{"title":"welcome"}'
517
+ ```
518
+
507
519
  **Shell hook**: anything other than `.rb` is exec'd as a child process. It is a pure side-effect hook — exwiw does not capture its stdout. The hook receives these env vars and inherits `DATABASE_PASSWORD` from the parent:
508
520
 
509
521
  - `EXWIW_OUTPUT_DIR`, `EXWIW_SCHEMA_DIR`
@@ -808,6 +820,7 @@ The MongoDB adapter is experimental. To use it:
808
820
  ```bash
809
821
  mongoimport --db app_dev --collection users --file dump/insert-002-users.jsonl
810
822
  ```
823
+ - A Ruby [after-insert hook](#after-insert-hook) can seed extra documents into named collections with `insert_jsonl(collection, template)`; each targeted collection gets its own `insert-NNN-<collection>.jsonl` file numbered after the dump's own files, so the filename-based `mongoimport` convention above applies to hook output unchanged.
811
824
  - The leading `dump/insert-000-schema.js` contains `db.createCollection(...)` and `db.<col>.createIndex(...)` calls for every top-level collection (indexes are introspected from the source via `listIndexes`; the auto-created `_id_` index is skipped). Apply it with mongosh **before** running `mongoimport`:
812
825
  ```bash
813
826
  mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
@@ -340,12 +340,24 @@ module Exwiw
340
340
  "WHERE #{subquery.table_name}.#{subquery.where_column} IN (#{inner_values.join(', ')})"
341
341
  end
342
342
 
343
+ # Backslash and control-character escapes, matching mysqldump. Escaping
344
+ # newlines keeps every VALUES tuple on a single line, so a value that
345
+ # itself contains a newline cannot break consumers that split the dump
346
+ # into statements on a semicolon-newline boundary.
347
+ SPECIAL_CHARACTER_ESCAPES = {
348
+ "\\" => "\\\\",
349
+ "\n" => "\\n",
350
+ "\r" => "\\r",
351
+ "\u0000" => "\\0",
352
+ "\u001A" => "\\Z",
353
+ }.freeze
354
+
343
355
  private def escape_value(value)
344
356
  case value
345
357
  when nil
346
358
  "NULL"
347
359
  when String
348
- qv = value.gsub('\\') { '\\\\' }
360
+ qv = value.gsub(/[\\\r\n\u0000\u001A]/) { |char| SPECIAL_CHARACTER_ESCAPES.fetch(char) }
349
361
  qv = escape_single_quote(qv)
350
362
  "'#{qv}'"
351
363
  else
@@ -6,26 +6,51 @@ module Exwiw
6
6
  class AfterInsertHook
7
7
  def self.run(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
8
8
  ext = File.extname(path)
9
- idx_str = next_idx.to_s.rjust(3, '0')
10
- output_path = File.join(output_dir, "insert-#{idx_str}-after_insert.#{output_extension}")
11
9
 
12
10
  if ext == '.rb'
13
- run_ruby(path: path, cli_options: cli_options, output_path: output_path, logger: logger)
11
+ run_ruby(
12
+ path: path,
13
+ cli_options: cli_options,
14
+ output_dir: output_dir,
15
+ next_idx: next_idx,
16
+ output_extension: output_extension,
17
+ logger: logger,
18
+ )
14
19
  else
15
20
  run_shell(path: path, cli_options: cli_options, output_dir: output_dir, logger: logger)
16
21
  end
17
22
  end
18
23
 
19
- def self.run_ruby(path:, cli_options:, output_path:, logger:)
20
- ctx = Context.new(cli_options)
24
+ def self.run_ruby(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
25
+ ctx = Context.new(cli_options, output_extension: output_extension)
21
26
  ctx.instance_eval(File.read(path), path)
27
+
28
+ # One output file per buffer, numbered sequentially from next_idx: first
29
+ # the shared collection-less buffer (kept at next_idx so existing hooks
30
+ # produce the same filename as before), then one file per targeted
31
+ # collection in first-targeted order. Collection files reuse the
32
+ # insert-NNN-<collection>.{ext} naming of the per-table dump files, so
33
+ # filename-driven import tooling handles them unchanged.
34
+ outputs = []
22
35
  content = ctx.collected.join("\n")
23
- if content.empty?
36
+ outputs << ['after_insert', content] unless content.empty?
37
+ ctx.collected_by_collection.each do |collection, chunks|
38
+ body = chunks.join("\n")
39
+ next if body.empty?
40
+ outputs << [collection, body]
41
+ end
42
+
43
+ if outputs.empty?
24
44
  logger.info("After-insert hook produced no output; skipping file write.")
25
45
  return
26
46
  end
27
- File.write(output_path, content)
28
- logger.info("Wrote after-insert hook output to #{output_path}")
47
+
48
+ outputs.each_with_index do |(name, body), offset|
49
+ idx_str = (next_idx + offset).to_s.rjust(3, '0')
50
+ output_path = File.join(output_dir, "insert-#{idx_str}-#{name}.#{output_extension}")
51
+ File.write(output_path, body)
52
+ logger.info("Wrote after-insert hook output to #{output_path}")
53
+ end
29
54
  end
30
55
 
31
56
  def self.run_shell(path:, cli_options:, output_dir:, logger:)
@@ -48,17 +73,52 @@ module Exwiw
48
73
  end
49
74
 
50
75
  class Context
51
- attr_reader :cli_options, :collected
76
+ # Collection names become part of the output filename, so restrict them
77
+ # to plain path-safe names (no separators, no leading dot).
78
+ COLLECTION_NAME_PATTERN = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/
79
+
80
+ attr_reader :cli_options, :collected, :collected_by_collection
52
81
 
53
- def initialize(cli_options)
82
+ def initialize(cli_options, output_extension: nil)
54
83
  @cli_options = cli_options
84
+ @output_extension = output_extension
55
85
  @collected = []
86
+ @collected_by_collection = {}
56
87
  end
57
88
 
58
89
  def insert_sql(template)
59
- @collected << ERB.new(template, trim_mode: '-').result(binding)
90
+ @collected << render(template)
91
+ end
92
+
93
+ # With one argument, identical to insert_sql: the rendered output goes to
94
+ # the shared collection-less buffer (insert-NNN-after_insert.{ext}).
95
+ #
96
+ # With two arguments (MongoDB adapter only), the rendered output is
97
+ # appended to the named collection's own buffer and written to
98
+ # insert-NNN-<collection>.jsonl. SQL statements name their table in-band,
99
+ # but JSONL documents do not — the import convention derives the target
100
+ # collection from the filename — so this is the only way a hook can seed
101
+ # documents into a specific collection.
102
+ def insert_jsonl(collection_or_template, template = nil)
103
+ return insert_sql(collection_or_template) if template.nil?
104
+
105
+ unless @output_extension == 'jsonl'
106
+ raise ArgumentError,
107
+ "insert_jsonl(collection, template) is only supported for the MongoDB adapter; " \
108
+ "SQL statements already name their table, use insert_sql(template) instead."
109
+ end
110
+
111
+ collection = collection_or_template.to_s
112
+ unless collection.match?(COLLECTION_NAME_PATTERN)
113
+ raise ArgumentError, "invalid collection name for insert_jsonl: #{collection_or_template.inspect}"
114
+ end
115
+
116
+ (@collected_by_collection[collection] ||= []) << render(template)
117
+ end
118
+
119
+ private def render(template)
120
+ ERB.new(template, trim_mode: '-').result(binding)
60
121
  end
61
- alias_method :insert_jsonl, :insert_sql
62
122
  end
63
123
  end
64
124
  end
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.5"
4
+ VERSION = "0.9.6"
5
5
  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.5
4
+ version: 0.9.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia