exwiw 0.9.9 → 0.9.11

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: 8b734fe407ce3c430c3197b04db275df36640849f6795d7636614d8fab0c6574
4
- data.tar.gz: 4be4a94e18f929ef13c97072248e4c7b7e867ad80160841a81c0146a929b9e21
3
+ metadata.gz: 6f85d2a708bc4da44d7a32b0ef91dcf42967b65f5ddcfca516e0ce674de568b4
4
+ data.tar.gz: 0eb9ea0cd15abd41d64380170323070c0961a37a1bd796bfd9c1c63484c8b6d3
5
5
  SHA512:
6
- metadata.gz: cdb946b9f0b3ae2bfca4aa74aca93454cbc0ae6f332f0900216c3a35be9d8bdcef2b2082744bcc6262b6bc9cf72afae9a477c85f7e00d60da8d6eaa4ed55b825
7
- data.tar.gz: 12f63468a2d834e558b98e73937e739aa2ab4581cf83699e0a675e280cfd6e6f158d136156400e3ec7c7672beaa63189a6e90c727cc1c87281412e061c72b417
6
+ metadata.gz: 645793841437d64ad976c84e89dc7f0bde405dbc549a953b870085a6fac26886060535a69bc3f41f5480f4dc31d5c15ce97b598d0146bb96981004cdcee3679e
7
+ data.tar.gz: 15efb4638a90daf07b9cc83cf2f6613fd0ecee93daaff6fbad40608002d0bf26c4bf61a64a83389d7e672101a6d15c3b4250e34b7daa202d176c62d931b1a7a5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.11] - 2026-07-09
6
+
7
+ ### Added
8
+
9
+ - **Reserved-word and special-character identifiers are now quoted (SQL adapters).** Table and column names that are reserved words (`order`, `from`, `group`, …) or contain characters invalid as a bare identifier are conditionally quoted — backticks on mysql, double quotes on postgresql/sqlite — across every emission path: SELECT projection and masking expressions, FROM/JOIN, WHERE, subqueries, materialized scope JOINs, INSERT / COPY headers and DELETE. Previously only the mysql INSERT header was quoted ([#83](https://github.com/heyinc/exwiw/pull/83)), so a reserved table name broke the extraction SELECT everywhere, a reserved column broke postgresql/sqlite INSERTs, and sqlite rejected even table-qualified reserved columns. Ordinary names stay bare, so output for existing configs is byte-identical (mysql INSERT headers keep their always-backtick form from 0.4.5). Dotted table names are treated as schema qualification and quoted per part (`billing.order` → `` billing.`order` ``), preserving Rails multi-schema `table_name`s. The reserved-word lists cover MySQL 8.4 and 9.x (including 9's `LIBRARY`) plus MariaDB-specific words, PostgreSQL's fully reserved key words, and — for sqlite — exactly the keywords that fail to parse bare in exwiw's emission positions, so fallback-accepted names like `key` are not churned. The lists are enforced by specs that probe the live mysql/postgresql servers' own keyword catalogs and re-derive the sqlite set empirically.
10
+
11
+ ### Fixed
12
+
13
+ - **PostgreSQL: post-import sequence sync now works for reserved or mixed-case table names.** `post_insert_sql` passed the raw table name to `pg_get_serial_sequence`, which parses its first argument with identifier rules and case-folds it — so a table like `"Order"` (now extractable thanks to identifier quoting) failed with `relation "order" does not exist` right after a successful data pass. The conditionally quoted name is passed instead; ordinary lowercase names are unaffected.
14
+
15
+ ## [0.9.10] - 2026-07-09
16
+
17
+ ### Fixed
18
+
19
+ - **A whole-database schema restore no longer aborts when a preload-required extension (e.g. pglogical) is absent on the target.** `wrap_create_extension_in_do_block` already skipped a `CREATE EXTENSION` the target cannot provide for `feature_not_supported` (0A000) and `invalid_schema_name` (3F000), but an extension that must be loaded via `shared_preload_libraries` raises a bare `elog(ERROR, "<name> is not in shared_preload_libraries")`, which carries the default SQLSTATE `XX000` (`internal_error`) — not caught, so the whole `insert-000-schema.sql` restore aborted (observed restoring a pglogical-carrying production dump into a plain RDS mirage target). `internal_error` is now caught alongside the other two and re-raised as a WARNING, so the extension is skipped and the restore continues. `insufficient_privilege` (42501) is still deliberately NOT caught.
20
+
5
21
  ## [0.9.9] - 2026-07-09
6
22
 
7
23
  ### Changed
@@ -17,6 +33,7 @@
17
33
  ### Changed
18
34
 
19
35
  - **Unknown keys in schema config JSON are now rejected on load instead of being silently dropped.** Serdes deserialization is lenient, so a key that matched no declared attribute was discarded without a word — turning a typo (`reverse_scop`, `bulk_insert_chunk_sise`) or a key another adapter supports but this one does not (`raw_sql`/`map` on a MongoDB field; `reverse_scope` on a MongoDB collection before this release) into a silent no-op: the dump ran and the requested masking/scoping simply never happened. `TableConfig.from` / `MongodbCollectionConfig.from` now validate the raw hash against the declared attributes — including the nested `belongs_tos` / `columns` / `fields` / `reverse_scope` / `embedded_in` / `replace_with_fake_data` entries — and raise `Exwiw::UnknownConfigKeyError` (an `ArgumentError` subclass) naming the key(s), the table/collection, the nested position, and the allowed keys; `export`/`explain` prepend the offending file path. This is a deliberate hard error with no opt-out: every declared key still passes — including the documentation-only `comment` on table/collection configs and their `belongs_tos`/`columns`/`fields` entries, which remains the supported place for free-form notes — so a config that only uses supported keys is unaffected, while anything now rejected was already being ignored. If a config carries stray keys, remove them or fold them into `comment`.
36
+ >>>>>>> 71a0958eea6e031d2beeb7349bea35cb460414ca
20
37
 
21
38
  ## [0.9.7] - 2026-07-08
22
39
 
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Exwiw
6
+ module Adapter
7
+ # Conditional identifier quoting shared by the SQL adapters (mysql /
8
+ # postgresql / sqlite).
9
+ #
10
+ # Table and column names are emitted bare unless they NEED quoting — a
11
+ # reserved word for that database, or characters outside the safe
12
+ # bare-identifier set (e.g. a column named `from`, which is a syntax error
13
+ # unquoted in every dialect's INSERT column list and in sqlite even when
14
+ # table-qualified). Quoting conditionally rather than always (Rails-style)
15
+ # keeps the generated SQL byte-identical to previous exwiw releases for
16
+ # ordinary names, so existing dump snapshots and diffs stay stable.
17
+ # Known exception to that byte-identity promise: mysql-only exotic names —
18
+ # digit-leading (`2fa_codes`) — are valid bare in mysql but fall outside
19
+ # BARE_IDENTIFIER_PATTERN and are now backtick-quoted (still valid SQL,
20
+ # cosmetic difference only).
21
+ #
22
+ # Table names get dot-aware handling (#quote_table_name): a Rails
23
+ # multi-schema app sets `self.table_name = "billing.invoices"` and the
24
+ # schema generator copies it verbatim into the config, so the name must be
25
+ # emitted as two identifiers (`billing."order"`), never quoted whole
26
+ # (`"billing.invoices"` names a nonexistent relation). An identifier with
27
+ # a literal dot in it cannot be expressed — but its bare form was already
28
+ # misparsed as schema.table before quoting existed, so nothing regresses.
29
+ #
30
+ # Each adapter includes its dialect submodule (Mysql / Postgresql /
31
+ # Sqlite below), which bundles the quote character with the matching
32
+ # reserved-word set. `Base` includes the bare module so shared helpers
33
+ # (Base#null_preserving) can rely on #qualified_name existing; the hooks
34
+ # raise NotImplementedError until a dialect submodule provides them.
35
+ #
36
+ # The word sets may over-approximate what strictly needs quoting in a
37
+ # given position (e.g. mysql accepts a reserved word unquoted right after
38
+ # a `.`): quoting a word that did not need it is still valid SQL and — for
39
+ # the lowercase names these lists contain — refers to the same identifier,
40
+ # while missing one is a syntax error. Reservedness is checked
41
+ # case-insensitively but the identifier is quoted with its exact spelling
42
+ # preserved, matching the catalog-exact names exwiw configs carry.
43
+ #
44
+ # spec/adapter/identifier_quoting_spec.rb keeps the lists honest: it
45
+ # probes every sqlite keyword in every emission context and asserts the
46
+ # live mysql / postgresql servers' own reserved-word catalogs are covered.
47
+ module IdentifierQuoting
48
+ # A name every supported dialect accepts bare: leading letter or
49
+ # underscore, then letters, digits, `_` or `$` (`$` verified bare-valid
50
+ # on mysql, postgresql and sqlite — and postgresql resolves such names
51
+ # by case-folding, so quoting them would break configs that spell a
52
+ # folded name in mixed case).
53
+ BARE_IDENTIFIER_PATTERN = /\A[A-Za-z_][A-Za-z0-9_$]*\z/
54
+
55
+ # Quote a single identifier (column name, or one part of a table name)
56
+ # only when the bare form would not parse (reserved word or unsafe
57
+ # characters).
58
+ def quote_identifier(name)
59
+ name = name.to_s
60
+ if name.match?(BARE_IDENTIFIER_PATTERN) && !reserved_words.include?(name.downcase)
61
+ name
62
+ else
63
+ force_quote_identifier(name)
64
+ end
65
+ end
66
+
67
+ # Always-quoted form of a single identifier (embedded quote chars
68
+ # doubled). Used where the adapter's output format has historically
69
+ # always quoted (mysql INSERT headers) and must stay byte-identical.
70
+ def force_quote_identifier(name)
71
+ quote_char = identifier_quote_char
72
+ "#{quote_char}#{name.to_s.gsub(quote_char) { quote_char * 2 }}#{quote_char}"
73
+ end
74
+
75
+ # Table-name form: dot-aware. `billing.invoices` is a schema-qualified
76
+ # name (two identifiers), so each dot-separated part is quoted
77
+ # independently — `billing."order"`, not `"billing.order"`.
78
+ def quote_table_name(name)
79
+ name.to_s.split(".", -1).map { |part| quote_identifier(part) }.join(".")
80
+ end
81
+
82
+ # Always-quoted, dot-aware table name (mysql INSERT headers). Splitting
83
+ # changes PR #83's output only for dotted names, whose whole-name quoting
84
+ # (`` `billing.invoices` ``) named a nonexistent table anyway.
85
+ def force_quote_table_name(name)
86
+ name.to_s.split(".", -1).map { |part| force_quote_identifier(part) }.join(".")
87
+ end
88
+
89
+ # `<table>.<column>` with the table part dot-aware and every identifier
90
+ # quoted as needed.
91
+ def qualified_name(table_name, column_name)
92
+ "#{quote_table_name(table_name)}.#{quote_identifier(column_name)}"
93
+ end
94
+
95
+ private def identifier_quote_char
96
+ raise NotImplementedError, "#{self.class} must provide #identifier_quote_char (include an IdentifierQuoting dialect submodule)"
97
+ end
98
+
99
+ private def reserved_words
100
+ raise NotImplementedError, "#{self.class} must provide #reserved_words (include an IdentifierQuoting dialect submodule)"
101
+ end
102
+
103
+ # MySQL reserved words: the 8.4 manual's "(R)" entries
104
+ # (https://dev.mysql.com/doc/refman/8.4/en/keywords.html), plus the
105
+ # 9.x additions (verified against a live 9.7 server's
106
+ # INFORMATION_SCHEMA.KEYWORDS by the identifier_quoting spec), plus the
107
+ # MariaDB-specific reserved words (https://mariadb.com/kb/en/reserved-words/
108
+ # — exwiw supports MariaDB servers, see MysqlAdapter#mariadb_mysqldump?).
109
+ # Words reserved in only one engine are still safe to quote in the
110
+ # other: backticks are always valid, and names in either engine's list
111
+ # were a syntax error there before, so no previously working output
112
+ # changes on the engine that reserves them.
113
+ MYSQL_RESERVED_WORDS = Set.new(%w[
114
+ accessible add all alter analyze and as asc asensitive before between
115
+ bigint binary blob both by call cascade case change char character
116
+ check collate column condition constraint continue convert create
117
+ cross cube cume_dist current_date current_time current_timestamp
118
+ current_user cursor database databases day_hour day_microsecond
119
+ day_minute day_second dec decimal declare default delayed delete
120
+ dense_rank desc describe deterministic distinct distinctrow div double
121
+ drop dual each else elseif empty enclosed escaped except exists exit
122
+ explain false fetch first_value float float4 float8 for force foreign
123
+ from fulltext function generated get grant group grouping groups
124
+ having high_priority hour_microsecond hour_minute hour_second if
125
+ ignore in index infile inner inout insensitive insert int int1 int2
126
+ int3 int4 int8 integer intersect interval into io_after_gtids
127
+ io_before_gtids is iterate join json_table key keys kill lag
128
+ last_value lateral lead leading leave left library like limit linear
129
+ lines load localtime localtimestamp lock long longblob longtext loop
130
+ low_priority master_bind master_ssl_verify_server_cert match maxvalue
131
+ mediumblob mediumint mediumtext middleint minute_microsecond
132
+ minute_second mod modifies natural not no_write_to_binlog nth_value
133
+ ntile null numeric of on optimize optimizer_costs option optionally
134
+ or order out outer outfile over partition percent_rank precision
135
+ primary procedure purge range rank read reads read_write real
136
+ recursive references regexp release rename repeat replace require
137
+ resignal restrict return revoke right rlike row rows row_number
138
+ schema schemas second_microsecond select sensitive separator set show
139
+ signal smallint spatial specific sql sqlexception sqlstate sqlwarning
140
+ sql_big_result sql_calc_found_rows sql_small_result ssl starting
141
+ stored straight_join system table terminated then tinyblob tinyint
142
+ tinytext to trailing trigger true undo union unique unlock unsigned
143
+ update usage use using utc_date utc_time utc_timestamp values
144
+ varbinary varchar varcharacter varying virtual when where while
145
+ window with write xor year_month zerofill
146
+ conversion current_role delete_domain_id do_domain_ids general
147
+ ignore_domain_ids ignore_server_ids master_heartbeat_period offset
148
+ page_checksum parse_vcol_expr ref_system_id returning slow
149
+ st_collect stats_auto_recalc stats_persistent stats_sample_pages
150
+ to_date vector
151
+ ]).freeze
152
+
153
+ # PostgreSQL reserved key words (SQL Key Words appendix): the "reserved"
154
+ # entries plus the "reserved (can be function or type name)" entries —
155
+ # neither may appear as a bare table/column identifier.
156
+ POSTGRESQL_RESERVED_WORDS = Set.new(%w[
157
+ all analyse analyze and any array as asc asymmetric authorization
158
+ binary both case cast check collate collation column concurrently
159
+ constraint create cross current_catalog current_date current_role
160
+ current_schema current_time current_timestamp current_user default
161
+ deferrable desc distinct do else end except false fetch for foreign
162
+ freeze from full grant group having ilike in initially inner intersect
163
+ into is isnull join lateral leading left like limit localtime
164
+ localtimestamp natural not notnull null offset on only or order outer
165
+ overlaps placing primary references returning right select
166
+ session_user similar some symmetric system_user table tablesample
167
+ then to trailing true union unique user using variadic verbose when
168
+ where window with
169
+ ]).freeze
170
+
171
+ # The subset of SQLite keywords (https://sqlite.org/lang_keywords.html)
172
+ # that actually fail to parse as bare identifiers in the positions exwiw
173
+ # emits (qualified column, INSERT column list, FROM/DELETE/JOIN table
174
+ # name, CASE masking, derived-table scope JOIN). SQLite's parser accepts
175
+ # the other ~half of its keywords as identifiers via fallback (e.g.
176
+ # `key`, `temp`, `row`), and those are deliberately NOT quoted so output
177
+ # for such names stays byte-identical with previous releases — a name in
178
+ # this list produced a syntax error before, so quoting it cannot change
179
+ # any previously working output. Derived empirically; the probe is
180
+ # checked in as spec/adapter/identifier_quoting_spec.rb, which re-runs
181
+ # it against the bundled sqlite3 and asserts this exact set, so a new
182
+ # SQLite reserved word (as RETURNING was in 3.35) or a drifted list
183
+ # fails the suite instead of silently emitting broken SQL.
184
+ SQLITE_RESERVED_WORDS = Set.new(%w[
185
+ add all alter and as autoincrement between case cast check collate
186
+ commit constraint create current_date current_time current_timestamp
187
+ default deferrable delete distinct drop else escape except exists
188
+ foreign from group having in index insert intersect into is isnull
189
+ join limit not nothing notnull null on or order primary raise
190
+ references returning select set table then to transaction union
191
+ unique update using values when where
192
+ ]).freeze
193
+
194
+ # Dialect submodules: bundle the quote character with the matching
195
+ # reserved-word set so an adapter opts in with a single include.
196
+ module Mysql
197
+ include IdentifierQuoting
198
+
199
+ private def identifier_quote_char
200
+ '`'
201
+ end
202
+
203
+ private def reserved_words
204
+ MYSQL_RESERVED_WORDS
205
+ end
206
+ end
207
+
208
+ module Postgresql
209
+ include IdentifierQuoting
210
+
211
+ private def identifier_quote_char
212
+ '"'
213
+ end
214
+
215
+ private def reserved_words
216
+ POSTGRESQL_RESERVED_WORDS
217
+ end
218
+ end
219
+
220
+ module Sqlite
221
+ include IdentifierQuoting
222
+
223
+ private def identifier_quote_char
224
+ '"'
225
+ end
226
+
227
+ private def reserved_words
228
+ SQLITE_RESERVED_WORDS
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -5,6 +5,7 @@ require 'open3'
5
5
  module Exwiw
6
6
  module Adapter
7
7
  class MysqlAdapter < Base
8
+ include IdentifierQuoting::Mysql
8
9
  include SqlBulkInsert
9
10
 
10
11
  # A lazy, streaming stand-in for the materialized rows #execute used to
@@ -152,22 +153,23 @@ module Exwiw
152
153
  end
153
154
 
154
155
  # The INSERT header for this adapter. MySQL backtick-quotes the table and
155
- # column identifiers. #to_bulk_insert / #write_inserts (SqlBulkInsert)
156
- # append the value tuples and the trailing `;`.
156
+ # column identifiers (always, to stay byte-identical with historical
157
+ # output). #to_bulk_insert / #write_inserts (SqlBulkInsert) append the
158
+ # value tuples and the trailing `;`.
157
159
  private def insert_header(table)
158
- table_name = table.name
160
+ table_name = force_quote_table_name(table.name)
159
161
  if table.rails_managed?
160
- "INSERT INTO `#{table_name}` VALUES\n"
162
+ "INSERT INTO #{table_name} VALUES\n"
161
163
  else
162
- column_names = table.columns.map { |c| "`#{c.name}`" }.join(', ')
163
- "INSERT INTO `#{table_name}` (#{column_names}) VALUES\n"
164
+ column_names = table.columns.map { |c| force_quote_identifier(c.name) }.join(', ')
165
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n"
164
166
  end
165
167
  end
166
168
 
167
169
  def to_bulk_delete(select_query_ast, table)
168
170
  raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
169
171
 
170
- sql = "DELETE FROM #{select_query_ast.from_table_name}"
172
+ sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
171
173
 
172
174
  if select_query_ast.join_clauses.empty?
173
175
  # Ignore filter option, because bulk delete is for cleaning before import,
@@ -205,7 +207,7 @@ module Exwiw
205
207
 
206
208
  foreign_key = first_join.foreign_key
207
209
  subquery_sql = compile_ast(subquery_ast)
208
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
210
+ sql += "\nWHERE #{qualified_name(select_query_ast.from_table_name, foreign_key)} IN (#{subquery_sql})"
209
211
 
210
212
  # first_join.base_where_clauses holds conditions on the outer
211
213
  # delete-target table (from_table_name), such as a polymorphic type
@@ -240,14 +242,14 @@ module Exwiw
240
242
  elsif query_ast.select_all
241
243
  # A lifted scope JOIN brings a derived table into FROM, so a bare
242
244
  # `*` would also project its column. Qualify to this table's own.
243
- scope_clauses.any? ? "#{query_ast.from_table_name}.*" : "*"
245
+ scope_clauses.any? ? "#{quote_table_name(query_ast.from_table_name)}.*" : "*"
244
246
  else
245
247
  query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
246
248
  end
247
- sql += " FROM #{query_ast.from_table_name}"
249
+ sql += " FROM #{quote_table_name(query_ast.from_table_name)}"
248
250
 
249
251
  query_ast.join_clauses.each do |join|
250
- sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
252
+ sql += " JOIN #{quote_table_name(join.join_table_name)} ON #{qualified_name(join.base_table_name, join.foreign_key)} = #{qualified_name(join.join_table_name, join.primary_key)}"
251
253
 
252
254
  join.where_clauses.each do |where|
253
255
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
@@ -290,10 +292,10 @@ module Exwiw
290
292
  # to `<col> IN (subquery)`.
291
293
  private def compile_scope_join(from_table_name, where_clause, idx)
292
294
  subquery = where_clause.value
293
- projection = subquery_projection_name(subquery)
295
+ projection = quote_identifier(subquery_projection_name(subquery))
294
296
  src_alias = "exwiw_scope_src_#{idx}"
295
297
  ids_alias = "exwiw_scope_ids_#{idx}"
296
- outer_key = "#{from_table_name}.#{where_clause.column_name}"
298
+ outer_key = qualified_name(from_table_name, where_clause.column_name)
297
299
 
298
300
  "JOIN (SELECT DISTINCT #{src_alias}.#{projection} AS exwiw_scope_id " \
299
301
  "FROM (#{compile_subquery(subquery)}) AS #{src_alias}) AS #{ids_alias} " \
@@ -304,7 +306,7 @@ module Exwiw
304
306
  # Use as it is if it's a raw query
305
307
  return where_clause if where_clause.is_a?(String)
306
308
 
307
- key = "#{table_name}.#{where_clause.column_name}"
309
+ key = qualified_name(table_name, where_clause.column_name)
308
310
 
309
311
  if where_clause.operator == :eq
310
312
  values = where_clause.value.map { |v| escape_value(v) }
@@ -335,9 +337,9 @@ module Exwiw
335
337
  end
336
338
 
337
339
  inner_values = subquery.where_values.map { |v| escape_value(v) }
338
- "SELECT #{subquery.table_name}.#{subquery.select_column} " \
339
- "FROM #{subquery.table_name} " \
340
- "WHERE #{subquery.table_name}.#{subquery.where_column} IN (#{inner_values.join(', ')})"
340
+ "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
341
+ "FROM #{quote_table_name(subquery.table_name)} " \
342
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
341
343
  end
342
344
 
343
345
  # Backslash and control-character escapes, matching mysqldump. Escaping
@@ -372,14 +374,14 @@ module Exwiw
372
374
  private def compile_column_name(ast, column)
373
375
  case column
374
376
  when Exwiw::QueryAst::ColumnValue::Plain
375
- "#{ast.from_table_name}.#{column.name}"
377
+ qualified_name(ast.from_table_name, column.name)
376
378
  when Exwiw::QueryAst::ColumnValue::RawSql
377
379
  column.value
378
380
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
379
381
  parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
380
382
  if part.start_with?('{')
381
383
  name = part[1..-2]
382
- "#{ast.from_table_name}.#{name}"
384
+ qualified_name(ast.from_table_name, name)
383
385
  else
384
386
  "'#{part}'"
385
387
  end
@@ -3,6 +3,7 @@
3
3
  module Exwiw
4
4
  module Adapter
5
5
  class PostgresqlAdapter < Base
6
+ include IdentifierQuoting::Postgresql
6
7
  include SqlBulkInsert
7
8
 
8
9
  # A lazy, streaming stand-in for the materialized rows #execute used to
@@ -158,25 +159,26 @@ module Exwiw
158
159
  @logger.info(" Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
159
160
  end
160
161
 
161
- # The INSERT header for this adapter. PostgreSQL uses bare identifiers.
162
+ # The INSERT header for this adapter. PostgreSQL uses bare identifiers,
163
+ # quoted only when required (reserved word / unsafe characters).
162
164
  # #to_bulk_insert / #write_inserts (SqlBulkInsert) append the value tuples
163
165
  # and the trailing `;`.
164
166
  private def insert_header(table)
165
- table_name = table.name
167
+ table_name = quote_table_name(table.name)
166
168
  if table.rails_managed?
167
169
  "INSERT INTO #{table_name} VALUES\n"
168
170
  else
169
- column_names = table.columns.map(&:name).join(', ')
171
+ column_names = table.columns.map { |c| quote_identifier(c.name) }.join(', ')
170
172
  "INSERT INTO #{table_name} (#{column_names}) VALUES\n"
171
173
  end
172
174
  end
173
175
 
174
176
  def to_copy_from_stdin(results, table)
175
177
  header = if table.rails_managed?
176
- "COPY #{table.name} FROM stdin;"
178
+ "COPY #{quote_table_name(table.name)} FROM stdin;"
177
179
  else
178
- column_names = table.columns.map(&:name).join(', ')
179
- "COPY #{table.name} (#{column_names}) FROM stdin;"
180
+ column_names = table.columns.map { |c| quote_identifier(c.name) }.join(', ')
181
+ "COPY #{quote_table_name(table.name)} (#{column_names}) FROM stdin;"
180
182
  end
181
183
  lines = [header]
182
184
  results.each do |row|
@@ -203,8 +205,14 @@ module Exwiw
203
205
  pk = table.primary_key
204
206
  return nil if pk.nil? || pk.empty?
205
207
 
208
+ # pg_get_serial_sequence parses its first argument with identifier
209
+ # rules (unquoted parts are case-folded), so pass the same
210
+ # conditionally quoted form the extraction queries use — a bare
211
+ # 'Order' would fold to the nonexistent relation 'order' even though
212
+ # the quoted extraction just succeeded. The second argument is taken
213
+ # literally (no folding), so the raw column name is correct.
206
214
  seq_name = connection
207
- .exec_params("SELECT pg_get_serial_sequence($1, $2)", [table.name, pk])
215
+ .exec_params("SELECT pg_get_serial_sequence($1, $2)", [quote_table_name(table.name), pk])
208
216
  .values.dig(0, 0)
209
217
  return nil if seq_name.nil?
210
218
 
@@ -219,7 +227,7 @@ module Exwiw
219
227
  def to_bulk_delete(select_query_ast, table)
220
228
  raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
221
229
 
222
- sql = "DELETE FROM #{select_query_ast.from_table_name}"
230
+ sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
223
231
 
224
232
  if select_query_ast.join_clauses.empty?
225
233
  # Ignore filter option, because bulk delete is for cleaning before import,
@@ -264,7 +272,7 @@ module Exwiw
264
272
  column_pg_type(inner_table, inner_column)
265
273
  ) ? 'text' : nil
266
274
  subquery_sql = compile_ast(subquery_ast, select_cast_to: cast_to)
267
- outer_expr = "#{outer_table}.#{foreign_key}"
275
+ outer_expr = qualified_name(outer_table, foreign_key)
268
276
  outer_expr = "#{outer_expr}::text" if cast_to
269
277
  sql += "\nWHERE #{outer_expr} IN (#{subquery_sql})"
270
278
 
@@ -295,17 +303,17 @@ module Exwiw
295
303
  sql += if query_ast.select_all
296
304
  # A lifted scope JOIN brings a derived table into FROM, so a bare
297
305
  # `*` would also project its column. Qualify to this table's own.
298
- scope_clauses.any? ? "#{query_ast.from_table_name}.*" : "*"
306
+ scope_clauses.any? ? "#{quote_table_name(query_ast.from_table_name)}.*" : "*"
299
307
  else
300
308
  cols = query_ast.columns.map { |col| compile_column_name(query_ast, col) }
301
309
  cols = cols.map { |c| "#{c}::#{select_cast_to}" } if select_cast_to
302
310
  cols.join(', ')
303
311
  end
304
- sql += " FROM #{query_ast.from_table_name}"
312
+ sql += " FROM #{quote_table_name(query_ast.from_table_name)}"
305
313
 
306
314
  query_ast.join_clauses.each do |join|
307
- fk_expr = "#{join.base_table_name}.#{join.foreign_key}"
308
- pk_expr = "#{join.join_table_name}.#{join.primary_key}"
315
+ fk_expr = qualified_name(join.base_table_name, join.foreign_key)
316
+ pk_expr = qualified_name(join.join_table_name, join.primary_key)
309
317
  if types_need_cast?(
310
318
  column_pg_type(join.base_table_name, join.foreign_key),
311
319
  column_pg_type(join.join_table_name, join.primary_key)
@@ -313,7 +321,7 @@ module Exwiw
313
321
  fk_expr = "#{fk_expr}::text"
314
322
  pk_expr = "#{pk_expr}::text"
315
323
  end
316
- sql += " JOIN #{join.join_table_name} ON #{fk_expr} = #{pk_expr}"
324
+ sql += " JOIN #{quote_table_name(join.join_table_name)} ON #{fk_expr} = #{pk_expr}"
317
325
 
318
326
  join.where_clauses.each do |where|
319
327
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
@@ -354,13 +362,13 @@ module Exwiw
354
362
  # outer key is cast to match.
355
363
  private def compile_scope_join(from_table_name, where_clause, idx)
356
364
  subquery = where_clause.value
357
- projection = subquery_projection_name(subquery)
365
+ projection = quote_identifier(subquery_projection_name(subquery))
358
366
  src_alias = "exwiw_scope_src_#{idx}"
359
367
  ids_alias = "exwiw_scope_ids_#{idx}"
360
368
 
361
369
  inner_sql = compile_subquery(subquery, outer_table: from_table_name, outer_column: where_clause.column_name)
362
370
  cast_to = subquery_cast_to(subquery, from_table_name, where_clause.column_name)
363
- outer_key = "#{from_table_name}.#{where_clause.column_name}"
371
+ outer_key = qualified_name(from_table_name, where_clause.column_name)
364
372
  outer_key = "#{outer_key}::#{cast_to}" if cast_to
365
373
 
366
374
  "JOIN (SELECT DISTINCT #{src_alias}.#{projection} AS exwiw_scope_id " \
@@ -372,7 +380,7 @@ module Exwiw
372
380
  # Use as it is if it's a raw query
373
381
  return where_clause if where_clause.is_a?(String)
374
382
 
375
- key = "#{table_name}.#{where_clause.column_name}"
383
+ key = qualified_name(table_name, where_clause.column_name)
376
384
 
377
385
  if where_clause.operator == :eq
378
386
  values = where_clause.value.map { |v| escape_value(v) }
@@ -411,11 +419,11 @@ module Exwiw
411
419
  end
412
420
 
413
421
  inner_values = subquery.where_values.map { |v| escape_value(v) }
414
- select_expr = "#{subquery.table_name}.#{subquery.select_column}"
422
+ select_expr = qualified_name(subquery.table_name, subquery.select_column)
415
423
  select_expr = "#{select_expr}::#{cast_to}" if cast_to
416
424
  "SELECT #{select_expr} " \
417
- "FROM #{subquery.table_name} " \
418
- "WHERE #{subquery.table_name}.#{subquery.where_column} IN (#{inner_values.join(', ')})"
425
+ "FROM #{quote_table_name(subquery.table_name)} " \
426
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
419
427
  end
420
428
 
421
429
  private def subquery_select_target(subquery)
@@ -498,14 +506,14 @@ module Exwiw
498
506
  private def compile_column_name(ast, column)
499
507
  case column
500
508
  when Exwiw::QueryAst::ColumnValue::Plain
501
- "#{ast.from_table_name}.#{column.name}"
509
+ qualified_name(ast.from_table_name, column.name)
502
510
  when Exwiw::QueryAst::ColumnValue::RawSql
503
511
  column.value
504
512
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
505
513
  parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
506
514
  if part.start_with?('{')
507
515
  name = part[1..-2]
508
- "#{ast.from_table_name}.#{name}"
516
+ qualified_name(ast.from_table_name, name)
509
517
  else
510
518
  "'#{part}'"
511
519
  end
@@ -3,6 +3,7 @@
3
3
  module Exwiw
4
4
  module Adapter
5
5
  class SqliteAdapter < Base
6
+ include IdentifierQuoting::Sqlite
6
7
  include SqlBulkInsert
7
8
 
8
9
  # A lazy, streaming stand-in for the materialized rows #execute used to
@@ -122,15 +123,16 @@ module Exwiw
122
123
  stmt.end_with?(';') ? stmt : "#{stmt};"
123
124
  end
124
125
 
125
- # The INSERT header for this adapter. SQLite uses bare identifiers.
126
+ # The INSERT header for this adapter. SQLite uses bare identifiers,
127
+ # quoted only when required (reserved word / unsafe characters).
126
128
  # #to_bulk_insert / #write_inserts (SqlBulkInsert) append the value tuples
127
129
  # and the trailing `;`.
128
130
  private def insert_header(table)
129
- table_name = table.name
131
+ table_name = quote_table_name(table.name)
130
132
  if table.rails_managed?
131
133
  "INSERT INTO #{table_name} VALUES\n"
132
134
  else
133
- column_names = table.columns.map(&:name).join(', ')
135
+ column_names = table.columns.map { |c| quote_identifier(c.name) }.join(', ')
134
136
  "INSERT INTO #{table_name} (#{column_names}) VALUES\n"
135
137
  end
136
138
  end
@@ -138,7 +140,7 @@ module Exwiw
138
140
  def to_bulk_delete(select_query_ast, table)
139
141
  raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
140
142
 
141
- sql = "DELETE FROM #{select_query_ast.from_table_name}"
143
+ sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
142
144
 
143
145
  if select_query_ast.join_clauses.empty?
144
146
  # Ignore filter option, because bulk delete is for cleaning before import,
@@ -176,7 +178,7 @@ module Exwiw
176
178
 
177
179
  foreign_key = first_join.foreign_key
178
180
  subquery_sql = compile_ast(subquery_ast)
179
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
181
+ sql += "\nWHERE #{qualified_name(select_query_ast.from_table_name, foreign_key)} IN (#{subquery_sql})"
180
182
 
181
183
  # first_join.base_where_clauses holds conditions on the outer
182
184
  # delete-target table (from_table_name), such as a polymorphic type
@@ -211,14 +213,14 @@ module Exwiw
211
213
  elsif query_ast.select_all
212
214
  # A lifted scope JOIN brings a derived table into FROM, so a bare
213
215
  # `*` would also project its column. Qualify to this table's own.
214
- scope_clauses.any? ? "#{query_ast.from_table_name}.*" : "*"
216
+ scope_clauses.any? ? "#{quote_table_name(query_ast.from_table_name)}.*" : "*"
215
217
  else
216
218
  query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
217
219
  end
218
- sql += " FROM #{query_ast.from_table_name}"
220
+ sql += " FROM #{quote_table_name(query_ast.from_table_name)}"
219
221
 
220
222
  query_ast.join_clauses.each do |join|
221
- sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
223
+ sql += " JOIN #{quote_table_name(join.join_table_name)} ON #{qualified_name(join.base_table_name, join.foreign_key)} = #{qualified_name(join.join_table_name, join.primary_key)}"
222
224
 
223
225
  join.where_clauses.each do |where|
224
226
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
@@ -254,10 +256,10 @@ module Exwiw
254
256
  # `<col> IN (subquery)`.
255
257
  private def compile_scope_join(from_table_name, where_clause, idx)
256
258
  subquery = where_clause.value
257
- projection = subquery_projection_name(subquery)
259
+ projection = quote_identifier(subquery_projection_name(subquery))
258
260
  src_alias = "exwiw_scope_src_#{idx}"
259
261
  ids_alias = "exwiw_scope_ids_#{idx}"
260
- outer_key = "#{from_table_name}.#{where_clause.column_name}"
262
+ outer_key = qualified_name(from_table_name, where_clause.column_name)
261
263
 
262
264
  "JOIN (SELECT DISTINCT #{src_alias}.#{projection} AS exwiw_scope_id " \
263
265
  "FROM (#{compile_subquery(subquery)}) AS #{src_alias}) AS #{ids_alias} " \
@@ -268,7 +270,7 @@ module Exwiw
268
270
  # Use as it is if it's a raw query
269
271
  return where_clause if where_clause.is_a?(String)
270
272
 
271
- key = "#{table_name}.#{where_clause.column_name}"
273
+ key = qualified_name(table_name, where_clause.column_name)
272
274
 
273
275
  if where_clause.operator == :eq
274
276
  values = where_clause.value.map { |v| escape_value(v) }
@@ -299,9 +301,9 @@ module Exwiw
299
301
  end
300
302
 
301
303
  inner_values = subquery.where_values.map { |v| escape_value(v) }
302
- "SELECT #{subquery.table_name}.#{subquery.select_column} " \
303
- "FROM #{subquery.table_name} " \
304
- "WHERE #{subquery.table_name}.#{subquery.where_column} IN (#{inner_values.join(', ')})"
304
+ "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
305
+ "FROM #{quote_table_name(subquery.table_name)} " \
306
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
305
307
  end
306
308
 
307
309
  private def escape_value(value)
@@ -323,14 +325,14 @@ module Exwiw
323
325
  private def compile_column_name(ast, column)
324
326
  case column
325
327
  when Exwiw::QueryAst::ColumnValue::Plain
326
- "#{ast.from_table_name}.#{column.name}"
328
+ qualified_name(ast.from_table_name, column.name)
327
329
  when Exwiw::QueryAst::ColumnValue::RawSql
328
330
  column.value
329
331
  when Exwiw::QueryAst::ColumnValue::ReplaceWith
330
332
  parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
331
333
  if part.start_with?('{')
332
334
  name = part[1..-2]
333
- "#{ast.from_table_name}.#{name}"
335
+ qualified_name(ast.from_table_name, name)
334
336
  else
335
337
  "'#{part}'"
336
338
  end
data/lib/exwiw/adapter.rb CHANGED
@@ -33,6 +33,12 @@ module Exwiw
33
33
  end
34
34
 
35
35
  class Base
36
+ # Gives every adapter #qualified_name & co. so shared helpers below
37
+ # (#null_preserving) can use them; the quote-char/reserved-word hooks
38
+ # raise NotImplementedError until a SQL adapter includes its
39
+ # IdentifierQuoting dialect submodule (Mysql / Postgresql / Sqlite).
40
+ include IdentifierQuoting
41
+
36
42
  attr_reader :connection_config
37
43
 
38
44
  def initialize(connection_config, logger)
@@ -249,10 +255,11 @@ module Exwiw
249
255
  # column itself (`column.name`) — not any other column the template may
250
256
  # reference — so only true NULL is preserved; an empty string is a real
251
257
  # value and is still masked. The column reference uses the same
252
- # `<from_table>.<col>` form as the Plain branch. Shared by the SQL
258
+ # `<from_table>.<col>` form as the Plain branch (qualified_name comes
259
+ # from IdentifierQuoting, included in Base). Shared by the SQL
253
260
  # adapters' #compile_column_name ReplaceWith handling.
254
261
  private def null_preserving(ast, column, masked_expr)
255
- "CASE WHEN #{ast.from_table_name}.#{column.name} IS NOT NULL THEN #{masked_expr} ELSE NULL END"
262
+ "CASE WHEN #{qualified_name(ast.from_table_name, column.name)} IS NOT NULL THEN #{masked_expr} ELSE NULL END"
256
263
  end
257
264
 
258
265
  # Split an outer query's WHERE clauses into the scope id-set clauses to
@@ -86,12 +86,18 @@ module Exwiw
86
86
  # A bare `CREATE EXTENSION ...;` (as a full-database pg_dump emits, unlike a
87
87
  # `--table` dump, which omits extensions) has no graceful skip: a restore
88
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.
89
+ # each in a DO block that catches only the "cannot provide it here" cases and
90
+ # re-raises them as a WARNING so the skip surfaces in the restore logs:
91
+ # - feature_not_supported (0A000, binaries absent)
92
+ # - invalid_schema_name (3F000, required schema absent)
93
+ # - internal_error (XX000): an extension that must be preloaded via
94
+ # shared_preload_libraries raises a bare `elog(ERROR, "<name> is not in
95
+ # shared_preload_libraries")` (default SQLSTATE XX000) when it is not
96
+ # preloaded on the target — e.g. pglogical restored into a plain RDS. Like
97
+ # the other two, this is the target being unable to provide the extension,
98
+ # not a broken dump, so the restore should skip it rather than abort.
99
+ # insufficient_privilege (42501) is deliberately NOT caught: a restore role
100
+ # lacking CREATE privilege is a misconfiguration to fix, not to skip silently.
95
101
  CREATE_EXTENSION_RE = /^[ \t]*CREATE\s+EXTENSION\b(?:\s+IF\s+NOT\s+EXISTS)?\s+(?<name>"[^"]+"|[^\s;]+)[^;]*;/i.freeze
96
102
 
97
103
  def wrap_create_extension_in_do_block(sql)
@@ -101,7 +107,7 @@ module Exwiw
101
107
  warning = "exwiw: skipped CREATE EXTENSION #{extname} (SQLSTATE %): %"
102
108
  warning_literal = "'#{warning.gsub("'", "''")}'"
103
109
  "DO $$ BEGIN #{stmt} " \
104
- "EXCEPTION WHEN feature_not_supported OR invalid_schema_name THEN " \
110
+ "EXCEPTION WHEN feature_not_supported OR invalid_schema_name OR internal_error THEN " \
105
111
  "RAISE WARNING #{warning_literal}, SQLSTATE, SQLERRM; END $$;"
106
112
  end
107
113
  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.9"
4
+ VERSION = "0.9.11"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -17,6 +17,7 @@ require_relative "exwiw/embedded_in"
17
17
  require_relative "exwiw/mongodb_field"
18
18
  require_relative "exwiw/mongodb_collection_config"
19
19
  require_relative "exwiw/ddl_postprocessor"
20
+ require_relative "exwiw/adapter/identifier_quoting"
20
21
  require_relative "exwiw/adapter"
21
22
  require_relative "exwiw/adapter/sql_bulk_insert"
22
23
  require_relative "exwiw/adapter/sqlite_adapter"
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.9
4
+ version: 0.9.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -55,6 +55,7 @@ files:
55
55
  - ext/exwiw/ext_json/extconf.rb
56
56
  - lib/exwiw.rb
57
57
  - lib/exwiw/adapter.rb
58
+ - lib/exwiw/adapter/identifier_quoting.rb
58
59
  - lib/exwiw/adapter/mongodb_adapter.rb
59
60
  - lib/exwiw/adapter/mysql_adapter.rb
60
61
  - lib/exwiw/adapter/mysql_client.rb