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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +14 -1
- data/lib/exwiw/adapter/mysql_adapter.rb +13 -1
- data/lib/exwiw/after_insert_hook.rb +72 -12
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a9938f5cd311ecefb5568ed27f35ed8594a9ba15e0041e241c423b25af12cba
|
|
4
|
+
data.tar.gz: f838bd892aecb7a717eac84f1bf52045cec0b6c1881c3a26c29bd4983c845326
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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(
|
|
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:,
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 <<
|
|
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