exwiw 0.9.4 → 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 +16 -0
- data/README.md +14 -1
- data/lib/exwiw/adapter/mysql_adapter.rb +13 -1
- data/lib/exwiw/adapter/postgresql_adapter.rb +5 -1
- data/lib/exwiw/after_insert_hook.rb +72 -12
- data/lib/exwiw/ddl_postprocessor.rb +20 -0
- 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,22 @@
|
|
|
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
|
+
|
|
15
|
+
## [0.9.5] - 2026-07-07
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **PostgreSQL: a `COMMENT ON EXTENSION` for a skipped extension no longer aborts the restore.** pg_dump emits `COMMENT ON EXTENSION <name> IS '...'` right after each `CREATE EXTENSION`. When the `CREATE` was skipped by the graceful `DO … EXCEPTION WHEN feature_not_supported` block (a target that cannot provide the extension — e.g. AlloyDB's `google_vacuum_mgmt` restored into vanilla PostgreSQL), the bare `COMMENT` then failed with `undefined_object` (`extension "…" does not exist`) and aborted the whole restore. A new `DdlPostprocessor.wrap_comment_on_extension_in_do_block` pass wraps each `COMMENT ON EXTENSION` in a `DO $exwiw$ … EXCEPTION WHEN undefined_object THEN NULL … $exwiw$` block, so it applies when the extension exists and is a no-op when it was skipped. This makes any managed-platform extension (AlloyDB `google_*`, `alloydb_scann`, etc.) skip cleanly, not just the one reported.
|
|
20
|
+
|
|
5
21
|
## [0.9.4] - 2026-07-07
|
|
6
22
|
|
|
7
23
|
### Changed
|
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
|
|
@@ -136,10 +136,14 @@ module Exwiw
|
|
|
136
136
|
# robustness: enums swallow duplicate_object on re-restore; extensions
|
|
137
137
|
# warn-and-skip feature_not_supported / invalid_schema_name so a target
|
|
138
138
|
# that cannot provide the extension (e.g. pglogical's schema absent, or a
|
|
139
|
-
# managed-platform extension) does not abort the restore.
|
|
139
|
+
# managed-platform extension) does not abort the restore. The COMMENT ON
|
|
140
|
+
# EXTENSION that pg_dump emits alongside is likewise wrapped to swallow
|
|
141
|
+
# undefined_object, so a skipped extension's trailing comment does not
|
|
142
|
+
# abort the restore either.
|
|
140
143
|
idempotent = stdout
|
|
141
144
|
idempotent = DdlPostprocessor.wrap_create_type_enum_in_do_block(idempotent)
|
|
142
145
|
idempotent = DdlPostprocessor.wrap_create_extension_in_do_block(idempotent)
|
|
146
|
+
idempotent = DdlPostprocessor.wrap_comment_on_extension_in_do_block(idempotent)
|
|
143
147
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_schema(idempotent)
|
|
144
148
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_sequence(idempotent)
|
|
145
149
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(idempotent)
|
|
@@ -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
|
|
@@ -106,6 +106,26 @@ module Exwiw
|
|
|
106
106
|
end
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# pg_dump emits `COMMENT ON EXTENSION <name> IS '...';` right after the
|
|
110
|
+
# matching CREATE EXTENSION. When the CREATE was skipped (its DO block caught
|
|
111
|
+
# feature_not_supported because the target cannot provide the extension —
|
|
112
|
+
# e.g. AlloyDB's google_vacuum_mgmt restored into vanilla PostgreSQL), this
|
|
113
|
+
# bare COMMENT then aborts the whole restore with `undefined_object` (42704,
|
|
114
|
+
# "extension ... does not exist"). Wrap each COMMENT in a DO block that
|
|
115
|
+
# swallows undefined_object, so it applies when the extension exists and is a
|
|
116
|
+
# no-op when it was skipped. The IS clause is matched as a whole single-quoted
|
|
117
|
+
# string (doubled quotes escaped) or NULL, so an embedded `;` does not end the
|
|
118
|
+
# match early.
|
|
119
|
+
COMMENT_ON_EXTENSION_RE =
|
|
120
|
+
/^[ \t]*COMMENT\s+ON\s+EXTENSION\s+(?:"[^"]+"|[^\s]+)\s+IS\s+(?:'(?:[^']|'')*'|NULL)\s*;/i.freeze
|
|
121
|
+
|
|
122
|
+
def wrap_comment_on_extension_in_do_block(sql)
|
|
123
|
+
sql.gsub(COMMENT_ON_EXTENSION_RE) do
|
|
124
|
+
stmt = Regexp.last_match(0).strip
|
|
125
|
+
"DO $exwiw$ BEGIN #{stmt} EXCEPTION WHEN undefined_object THEN NULL; END $exwiw$;"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
109
129
|
# Generate idempotent CREATE TYPE ... AS ENUM statements.
|
|
110
130
|
# +enum_types+ is an Array of Hashes with keys :schema, :name, :labels.
|
|
111
131
|
def create_type_enum_statements(enum_types)
|
data/lib/exwiw/version.rb
CHANGED