exwiw 0.9.3 → 0.9.4
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 +8 -0
- data/lib/exwiw/adapter/mysql_adapter.rb +8 -8
- data/lib/exwiw/adapter/postgresql_adapter.rb +20 -95
- data/lib/exwiw/adapter/sqlite_adapter.rb +13 -11
- data/lib/exwiw/ddl_postprocessor.rb +42 -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: c472ab8d5098ed7cd796e8549329dadba6b4cce31b2d44b956f463699643a353
|
|
4
|
+
data.tar.gz: c5b53ba657142131d1dacdb44b3bca0117b55052384d9da2257108be6073b511
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79879008903ad2eff63b7c14851611fd4d6b8161726240faef77cf4533a1c172a9cbed140abe71dde1e1026de521d4d81b5d2eff074d977a8883476979f10ad0
|
|
7
|
+
data.tar.gz: bbb86bbd868783c891a1a45a337977160e569c920ded2e3a8782c098e702dabfe0f6bbbad599fd0db0b0949731a471193529c65f382de6d22c31524c776468ef
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.4] - 2026-07-07
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **The schema dump (`insert-000-schema.sql`) now emits DDL for every table in the source database, not just the config-scoped tables.** Previously the SQL adapters restricted the schema dump to the tables that had a config entry (`pg_dump --table <t>` / `mysqldump <db> <t...>` / a `sqlite_master` filter by `ordered_tables`), so any table without a JSON config was absent from the dump — and a restore target then failed with `relation does not exist` the moment anything referenced an unscoped table. All three SQL adapters (postgresql / mysql / sqlite) now dump the whole database's schema; a table absent from the config gets its `CREATE TABLE` (schema) but no `INSERT` (empty data), which is the intended result. Data extraction (the `INSERT` files, `validate_scope!`, the `DELETE` pass) is unchanged: `dump_schema(ordered_tables, …)` keeps its signature but uses `ordered_tables` only for logging, not to select which tables are emitted. This is a default, non-opt-in behavior change: consumer repos' baked schemas will now contain every table in the source DB.
|
|
10
|
+
- **PostgreSQL**: because a whole-database `pg_dump` emits `CREATE EXTENSION` and `CREATE TYPE … AS ENUM` itself (a `--table` dump omitted both, which is why exwiw used to prepend them by hand from `pg_extension` / `pg_enum`), the manual prepend and its two helper queries are removed. Their robustness is preserved by two new `DdlPostprocessor` passes applied in place: enum types are wrapped in a `DO $exwiw$ … EXCEPTION WHEN duplicate_object THEN NULL … $exwiw$` block (so a re-restore does not fail), and each `CREATE EXTENSION` is wrapped in a `DO $$ … EXCEPTION WHEN feature_not_supported OR invalid_schema_name THEN RAISE WARNING … $$` block (so a target that cannot provide the extension warns-and-skips rather than aborting; `insufficient_privilege` is still not caught). A managed-platform or `pglogical` extension the source happens to have is now handled by this graceful skip instead of being filtered out by name.
|
|
11
|
+
- `strip_triggers` is still applied unchanged. A whole-database dump now also contains the `CREATE FUNCTION` definitions that triggers reference, so exwiw could in principle keep triggers instead of stripping them — but this release does not change that behavior.
|
|
12
|
+
|
|
5
13
|
## [0.9.3] - 2026-07-02
|
|
6
14
|
|
|
7
15
|
### Fixed
|
|
@@ -80,11 +80,12 @@ module Exwiw
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def dump_schema(ordered_tables, output_path)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
# Full-database schema dump: emit DDL for every table in the database,
|
|
84
|
+
# not just the config-scoped tables. A table absent from the config gets
|
|
85
|
+
# its schema here but no data (empty INSERT), which is the intended
|
|
86
|
+
# result — the restore target then has every relation even when exwiw was
|
|
87
|
+
# configured to extract only a subset. `ordered_tables` no longer selects
|
|
88
|
+
# which tables are emitted; it is kept only for the log line.
|
|
88
89
|
|
|
89
90
|
# The mysqldump binary is invoked directly (not via the mysql2/trilogy
|
|
90
91
|
# driver), so point EXWIW_MYSQLDUMP at a specific binary when the one on
|
|
@@ -111,11 +112,10 @@ module Exwiw
|
|
|
111
112
|
*gtid_flags,
|
|
112
113
|
'--compact',
|
|
113
114
|
@connection_config.database_name,
|
|
114
|
-
*table_names,
|
|
115
115
|
]
|
|
116
116
|
env = { 'MYSQL_PWD' => @connection_config.password.to_s }
|
|
117
117
|
|
|
118
|
-
@logger.debug(" Running #{mysqldump_bin} for #{
|
|
118
|
+
@logger.debug(" Running #{mysqldump_bin} for the whole database (#{@connection_config.database_name})...")
|
|
119
119
|
stdout, stderr, status =
|
|
120
120
|
begin
|
|
121
121
|
Open3.capture3(env, *cmd)
|
|
@@ -140,7 +140,7 @@ module Exwiw
|
|
|
140
140
|
file.puts(idempotent)
|
|
141
141
|
file.puts("SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;")
|
|
142
142
|
end
|
|
143
|
-
@logger.info(" Wrote schema
|
|
143
|
+
@logger.info(" Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def pre_insert_sql(_table)
|
|
@@ -100,12 +100,14 @@ module Exwiw
|
|
|
100
100
|
def dump_schema(ordered_tables, output_path)
|
|
101
101
|
require 'open3'
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
# Full-database schema dump: emit DDL for every table in the database,
|
|
104
|
+
# not just the config-scoped tables. A table absent from the config
|
|
105
|
+
# gets its schema here but no data (empty INSERT), which is the intended
|
|
106
|
+
# result — the restore target then has every relation even when exwiw
|
|
107
|
+
# was configured to extract only a subset, so a later reference to an
|
|
108
|
+
# unscoped table does not fail with "relation does not exist".
|
|
109
|
+
# `ordered_tables` no longer selects which tables are emitted; it is kept
|
|
110
|
+
# only for the log line (how many tables are in scope for data).
|
|
109
111
|
cmd = [
|
|
110
112
|
'pg_dump',
|
|
111
113
|
"--host=#{@connection_config.host}",
|
|
@@ -114,12 +116,11 @@ module Exwiw
|
|
|
114
116
|
'--schema-only',
|
|
115
117
|
'--no-owner',
|
|
116
118
|
'--no-acl',
|
|
117
|
-
*table_names.flat_map { |t| ['--table', t] },
|
|
118
119
|
@connection_config.database_name,
|
|
119
120
|
]
|
|
120
121
|
env = { 'PGPASSWORD' => @connection_config.password.to_s }
|
|
121
122
|
|
|
122
|
-
@logger.debug(" Running pg_dump for #{
|
|
123
|
+
@logger.debug(" Running pg_dump for the whole database (#{@connection_config.database_name})...")
|
|
123
124
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
124
125
|
unless status.success?
|
|
125
126
|
if stderr.include?('command not found') || stderr.empty?
|
|
@@ -128,37 +129,17 @@ module Exwiw
|
|
|
128
129
|
raise "pg_dump failed (exit #{status.exitstatus}): #{stderr}"
|
|
129
130
|
end
|
|
130
131
|
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
extensions = query_extensions
|
|
140
|
-
unless extensions.empty?
|
|
141
|
-
ext_ddl = extensions.map do |extname, schema|
|
|
142
|
-
stmt = "CREATE EXTENSION IF NOT EXISTS #{connection.quote_ident(extname)}"
|
|
143
|
-
stmt += " SCHEMA #{connection.quote_ident(schema)}" unless schema == "public"
|
|
144
|
-
# Best-effort prepend: a restore target that genuinely cannot create the
|
|
145
|
-
# extension should not abort the whole restore. Two such cases are caught:
|
|
146
|
-
# feature_not_supported (0A000) -- the extension's binaries are unavailable
|
|
147
|
-
# invalid_schema_name (3F000) -- the extension's required schema is absent
|
|
148
|
-
# insufficient_privilege (42501) is deliberately NOT caught: a restore role
|
|
149
|
-
# lacking CREATE privilege is a misconfiguration to fix, not to skip silently.
|
|
150
|
-
# The skip is re-raised as a WARNING so it surfaces in the restore logs
|
|
151
|
-
# instead of vanishing.
|
|
152
|
-
warning = connection.escape_literal("exwiw: skipped CREATE EXTENSION #{extname} (SQLSTATE %): %")
|
|
153
|
-
"DO $$ BEGIN #{stmt}; " \
|
|
154
|
-
"EXCEPTION WHEN feature_not_supported OR invalid_schema_name THEN " \
|
|
155
|
-
"RAISE WARNING #{warning}, SQLSTATE, SQLERRM; END $$;"
|
|
156
|
-
end.join("\n") + "\n\n"
|
|
157
|
-
@logger.debug(" Found #{extensions.size} extension(s) to prepend.")
|
|
158
|
-
stdout = ext_ddl + stdout
|
|
159
|
-
end
|
|
160
|
-
|
|
132
|
+
# A full-database dump emits CREATE EXTENSION and CREATE TYPE ... AS ENUM
|
|
133
|
+
# itself (a `--table` dump omitted both, which is why they used to be
|
|
134
|
+
# prepended by hand), but pg_dump's bare forms are neither idempotent nor
|
|
135
|
+
# restore-tolerant. Wrap them in place to restore the previous
|
|
136
|
+
# robustness: enums swallow duplicate_object on re-restore; extensions
|
|
137
|
+
# warn-and-skip feature_not_supported / invalid_schema_name so a target
|
|
138
|
+
# that cannot provide the extension (e.g. pglogical's schema absent, or a
|
|
139
|
+
# managed-platform extension) does not abort the restore.
|
|
161
140
|
idempotent = stdout
|
|
141
|
+
idempotent = DdlPostprocessor.wrap_create_type_enum_in_do_block(idempotent)
|
|
142
|
+
idempotent = DdlPostprocessor.wrap_create_extension_in_do_block(idempotent)
|
|
162
143
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_schema(idempotent)
|
|
163
144
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_sequence(idempotent)
|
|
164
145
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(idempotent)
|
|
@@ -170,7 +151,7 @@ module Exwiw
|
|
|
170
151
|
file.puts("-- Auto-generated by exwiw via pg_dump. Idempotent DDL for postgresql.")
|
|
171
152
|
file.write(idempotent)
|
|
172
153
|
end
|
|
173
|
-
@logger.info(" Wrote schema
|
|
154
|
+
@logger.info(" Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
|
|
174
155
|
end
|
|
175
156
|
|
|
176
157
|
# The INSERT header for this adapter. PostgreSQL uses bare identifiers.
|
|
@@ -533,62 +514,6 @@ module Exwiw
|
|
|
533
514
|
end
|
|
534
515
|
end
|
|
535
516
|
|
|
536
|
-
private def query_extensions
|
|
537
|
-
# Skip plpgsql (always present) and managed-platform bookkeeping extensions
|
|
538
|
-
# (google_*/rds_*/aiven_*). pglogical is also skipped: it is a logical-
|
|
539
|
-
# replication mechanism of the source, not part of the data being copied,
|
|
540
|
-
# and its dedicated `pglogical` schema is typically absent on the restore
|
|
541
|
-
# target — so prepending CREATE EXTENSION for it only breaks the restore.
|
|
542
|
-
sql = <<~SQL
|
|
543
|
-
SELECT e.extname, n.nspname
|
|
544
|
-
FROM pg_extension e
|
|
545
|
-
JOIN pg_namespace n ON n.oid = e.extnamespace
|
|
546
|
-
WHERE e.extname != 'plpgsql'
|
|
547
|
-
AND e.extname != 'pglogical'
|
|
548
|
-
AND e.extname NOT LIKE 'google\\_%' ESCAPE '\\'
|
|
549
|
-
AND e.extname NOT LIKE 'rds\\_%' ESCAPE '\\'
|
|
550
|
-
AND e.extname NOT LIKE 'aiven\\_%' ESCAPE '\\'
|
|
551
|
-
ORDER BY e.extname
|
|
552
|
-
SQL
|
|
553
|
-
connection.exec(sql).map { |row| [row["extname"], row["nspname"]] }
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
private def query_enum_types(table_names)
|
|
557
|
-
return [] if table_names.empty?
|
|
558
|
-
|
|
559
|
-
placeholders = table_names.each_with_index.map { |_, i| "$#{i + 1}" }.join(', ')
|
|
560
|
-
sql = <<~SQL
|
|
561
|
-
SELECT
|
|
562
|
-
n.nspname AS type_schema,
|
|
563
|
-
t.typname AS type_name,
|
|
564
|
-
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS enum_labels
|
|
565
|
-
FROM pg_type t
|
|
566
|
-
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
567
|
-
JOIN pg_enum e ON e.enumtypid = t.oid
|
|
568
|
-
WHERE t.typtype = 'e'
|
|
569
|
-
AND t.oid IN (
|
|
570
|
-
SELECT a.atttypid
|
|
571
|
-
FROM pg_attribute a
|
|
572
|
-
JOIN pg_class c ON c.oid = a.attrelid
|
|
573
|
-
WHERE c.relname IN (#{placeholders})
|
|
574
|
-
AND a.attnum > 0
|
|
575
|
-
AND NOT a.attisdropped
|
|
576
|
-
)
|
|
577
|
-
GROUP BY n.nspname, t.typname
|
|
578
|
-
ORDER BY n.nspname, t.typname
|
|
579
|
-
SQL
|
|
580
|
-
|
|
581
|
-
result = connection.exec_params(sql, table_names)
|
|
582
|
-
decoder = PG::TextDecoder::Array.new
|
|
583
|
-
result.map do |row|
|
|
584
|
-
{
|
|
585
|
-
schema: row['type_schema'],
|
|
586
|
-
name: row['type_name'],
|
|
587
|
-
labels: decoder.decode(row['enum_labels']),
|
|
588
|
-
}
|
|
589
|
-
end
|
|
590
|
-
end
|
|
591
|
-
|
|
592
517
|
private def column_pg_type(table_name, column_name)
|
|
593
518
|
@column_type_cache ||= {}
|
|
594
519
|
cache_key = [table_name, column_name]
|
|
@@ -81,25 +81,27 @@ module Exwiw
|
|
|
81
81
|
|
|
82
82
|
def dump_schema(ordered_tables, output_path)
|
|
83
83
|
@logger.debug(" Reading schema from sqlite_master...")
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
# the
|
|
88
|
-
# ordered_tables
|
|
84
|
+
# Full-database schema dump: emit DDL for every table in the database,
|
|
85
|
+
# not just the config-scoped tables. A table absent from the config gets
|
|
86
|
+
# its schema here but no data (empty INSERT), which is the intended
|
|
87
|
+
# result — the restore target then has every relation even when exwiw was
|
|
88
|
+
# configured to extract only a subset. `ordered_tables` no longer selects
|
|
89
|
+
# which tables are emitted; it is kept only for the log line.
|
|
90
|
+
#
|
|
91
|
+
# `sqlite_master` row order preserves creation order, which is also the
|
|
92
|
+
# dependency order produced by ActiveRecord-style migrations (and lists a
|
|
93
|
+
# table before its owned indexes/triggers), so emitting rows in that
|
|
94
|
+
# order keeps each table's DDL ahead of its dependents.
|
|
89
95
|
all = connection.execute(<<~SQL)
|
|
90
96
|
SELECT type, name, tbl_name, sql FROM sqlite_master
|
|
91
97
|
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
|
92
98
|
SQL
|
|
93
99
|
|
|
94
|
-
tables_by_name = all.select { |type, _, _, _| type == 'table' }.to_h { |_, name, _, sql| [name, sql] }
|
|
95
100
|
indexes_by_owner = all.select { |type, _, _, _| type == 'index' }.group_by { |_, _, tbl, _| tbl }
|
|
96
101
|
triggers_by_owner = all.select { |type, _, _, _| type == 'trigger' }.group_by { |_, _, tbl, _| tbl }
|
|
97
102
|
|
|
98
103
|
statements = []
|
|
99
|
-
|
|
100
|
-
table_sql = tables_by_name[name]
|
|
101
|
-
next unless table_sql
|
|
102
|
-
|
|
104
|
+
all.select { |type, _, _, _| type == 'table' }.each do |_, name, _, table_sql|
|
|
103
105
|
statements << finalize_stmt(DdlPostprocessor.add_if_not_exists_to_create_table(table_sql.strip))
|
|
104
106
|
(indexes_by_owner[name] || []).each do |_, _, _, idx_sql|
|
|
105
107
|
statements << finalize_stmt(DdlPostprocessor.add_if_not_exists_to_create_index(idx_sql.strip))
|
|
@@ -113,7 +115,7 @@ module Exwiw
|
|
|
113
115
|
file.puts("-- Auto-generated by exwiw. Idempotent CREATE statements for SQLite.")
|
|
114
116
|
file.puts(statements.join("\n"))
|
|
115
117
|
end
|
|
116
|
-
@logger.info(" Wrote #{statements.size} schema statement(s) to #{output_path}.")
|
|
118
|
+
@logger.info(" Wrote #{statements.size} schema statement(s) to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
private def finalize_stmt(stmt)
|
|
@@ -64,6 +64,48 @@ module Exwiw
|
|
|
64
64
|
sql.gsub(/^[ \t]*CREATE\s+(?:OR\s+REPLACE\s+)?(?:CONSTRAINT\s+)?TRIGGER\b[^;]*;\r?\n?/i, "")
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# A bare `CREATE TYPE ... AS ENUM (...)` (as a full-database pg_dump emits,
|
|
68
|
+
# unlike a `--table` dump, which omits enum types) is not idempotent: a
|
|
69
|
+
# second restore raises `duplicate_object`. Wrap each in a DO block that
|
|
70
|
+
# swallows that error, matching the form of #create_type_enum_statements.
|
|
71
|
+
# Enum labels never contain a semicolon or an unescaped `)`, so the match
|
|
72
|
+
# ends at the first `);` after `AS ENUM (`.
|
|
73
|
+
CREATE_TYPE_ENUM_RE = /^[ \t]*CREATE\s+TYPE\b.+?\bAS\s+ENUM\s*\(.+?\)\s*;/mi.freeze
|
|
74
|
+
|
|
75
|
+
def wrap_create_type_enum_in_do_block(sql)
|
|
76
|
+
sql.gsub(CREATE_TYPE_ENUM_RE) do |stmt|
|
|
77
|
+
<<~SQL.chomp
|
|
78
|
+
DO $exwiw$ BEGIN
|
|
79
|
+
#{stmt.strip}
|
|
80
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
81
|
+
END $exwiw$;
|
|
82
|
+
SQL
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A bare `CREATE EXTENSION ...;` (as a full-database pg_dump emits, unlike a
|
|
87
|
+
# `--table` dump, which omits extensions) has no graceful skip: a restore
|
|
88
|
+
# target that cannot create the extension aborts the whole restore. Wrap
|
|
89
|
+
# each in a DO block that catches only the two "cannot provide it here"
|
|
90
|
+
# cases — feature_not_supported (0A000, binaries absent) and
|
|
91
|
+
# invalid_schema_name (3F000, required schema absent) — and re-raises them
|
|
92
|
+
# as a WARNING so the skip surfaces in the restore logs. insufficient_
|
|
93
|
+
# privilege (42501) is deliberately NOT caught: a restore role lacking
|
|
94
|
+
# CREATE privilege is a misconfiguration to fix, not to skip silently.
|
|
95
|
+
CREATE_EXTENSION_RE = /^[ \t]*CREATE\s+EXTENSION\b(?:\s+IF\s+NOT\s+EXISTS)?\s+(?<name>"[^"]+"|[^\s;]+)[^;]*;/i.freeze
|
|
96
|
+
|
|
97
|
+
def wrap_create_extension_in_do_block(sql)
|
|
98
|
+
sql.gsub(CREATE_EXTENSION_RE) do
|
|
99
|
+
stmt = Regexp.last_match(0).strip
|
|
100
|
+
extname = Regexp.last_match(:name).delete('"')
|
|
101
|
+
warning = "exwiw: skipped CREATE EXTENSION #{extname} (SQLSTATE %): %"
|
|
102
|
+
warning_literal = "'#{warning.gsub("'", "''")}'"
|
|
103
|
+
"DO $$ BEGIN #{stmt} " \
|
|
104
|
+
"EXCEPTION WHEN feature_not_supported OR invalid_schema_name THEN " \
|
|
105
|
+
"RAISE WARNING #{warning_literal}, SQLSTATE, SQLERRM; END $$;"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
67
109
|
# Generate idempotent CREATE TYPE ... AS ENUM statements.
|
|
68
110
|
# +enum_types+ is an Array of Hashes with keys :schema, :name, :labels.
|
|
69
111
|
def create_type_enum_statements(enum_types)
|
data/lib/exwiw/version.rb
CHANGED