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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dd53c3798a3084444114cbf85e6a798d656d4a8e0a0db553185673ee80ad1ca
4
- data.tar.gz: 35f28a107fabb0de4efdd8a4d4701676fac5d39b6ffb0d5b0cd8dae578cd9048
3
+ metadata.gz: c472ab8d5098ed7cd796e8549329dadba6b4cce31b2d44b956f463699643a353
4
+ data.tar.gz: c5b53ba657142131d1dacdb44b3bca0117b55052384d9da2257108be6073b511
5
5
  SHA512:
6
- metadata.gz: bef6a4a3ba42324f6ad06de118e1f08473c85c76dae4eb3a1573476dd9bc97d00bd2a8bfac15f3c4bdc746825eeb9c64ee047d7920d8327b0b76608ef3cb433e
7
- data.tar.gz: 20ed7a044c94b0ad476a7ace6ebef261441a0e2c9e0afc0d913e4d25a540e3b8e7d3949d4b77dda0d2745bfaab986bed5ed59b1c41ecc526f4ccad48667e8369
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
- table_names = ordered_tables.map(&:name)
84
- if table_names.empty?
85
- File.write(output_path, "-- Auto-generated by exwiw. No tables in scope.\n")
86
- return
87
- end
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 #{table_names.size} table(s)...")
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 for #{table_names.size} table(s) to #{output_path}.")
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
- table_names = ordered_tables.map(&:name)
104
- if table_names.empty?
105
- File.write(output_path, "-- Auto-generated by exwiw. No tables in scope.\n")
106
- return
107
- end
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 #{table_names.size} table(s)...")
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
- # Enums are prepended first, then extensions so extensions end up at the top of the output.
132
- enum_types = query_enum_types(table_names)
133
- unless enum_types.empty?
134
- enum_ddl = DdlPostprocessor.create_type_enum_statements(enum_types)
135
- @logger.debug(" Found #{enum_types.size} enum type(s) to prepend.")
136
- stdout = enum_ddl + stdout
137
- end
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 for #{table_names.size} table(s) to #{output_path}.")
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
- target_names = ordered_tables.map(&:name)
85
- # `sqlite_master` row order preserves table creation order, which is also
86
- # the dependency order produced by ActiveRecord-style migrations. To respect
87
- # the caller-provided order, we partition tables / their owned indexes by
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
- target_names.each do |name|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.3"
4
+ VERSION = "0.9.4"
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.3
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia