Class: Exwiw::Adapter::PostgresqlAdapter

Inherits:
Base
  • Object
show all
Includes:
IdentifierQuoting::Postgresql, SqlBulkInsert
Defined in:
lib/exwiw/adapter/postgresql_adapter.rb

Defined Under Namespace

Classes: StreamingResult

Constant Summary collapse

PLATFORM_MANAGED_EXTENSIONS =

Extensions a managed PostgreSQL platform installs to run the source instance itself, which exwiw treats as out of target and leaves out of the dump entirely. Each one here satisfies both conditions:

1. it ships only with the managed platform, so no restore target outside
 that platform can create it, and
2. nothing in the application's own schema or queries references it — it
 is operational machinery (autovacuum tuning, the in-memory columnar
 cache, index advice), not a feature the app builds on.

Condition 2 is what keeps this list short. AlloyDB's application-facing extensions — google_ml_integration, alloydb_scann, alloydb_ai_nl — meet condition 1 but are called from application SQL and can be named by dumped DDL (a ScaNN index is USING scann), so dropping their CREATE would silently strand whatever refers to them. They stay in the dump, wrapped in the usual warn-and-skip DO block, like pglogical and like a third-party extension pulled in as a dependency of one listed here (google_db_advisor requires hypopg, which any plain PostgreSQL can install).

Deliberately an explicit list of names, not a google_/alloydb_ prefix match (which is how the old pg_extension query filtered, before switching to a full-database pg_dump dropped that query and the filter with it): the prefixes are not reserved, so a schema an application legitimately owns — google_calendar for a Google Calendar integration — would be matched and dropped together with its tables. The vendor gains extensions faster than this list does; that costs a release, which is the right trade against deleting an application's own objects. Its rds_% / aiven_% arms are not carried over for the same reason: naming their members takes a dump to confirm, and adding one here is a one-line change.

%w[
  google_columnar_engine
  google_db_advisor
  google_vacuum_mgmt
].freeze

Constants included from SqlBulkInsert

SqlBulkInsert::STREAM_FLUSH_ROWS

Constants included from IdentifierQuoting

IdentifierQuoting::BARE_IDENTIFIER_PATTERN, IdentifierQuoting::MYSQL_RESERVED_WORDS, IdentifierQuoting::POSTGRESQL_RESERVED_WORDS, IdentifierQuoting::SQLITE_RESERVED_WORDS

Instance Attribute Summary

Attributes inherited from Base

#connection_config

Instance Method Summary collapse

Methods included from SqlBulkInsert

#to_bulk_insert, #write_inserts

Methods included from IdentifierQuoting

#force_quote_identifier, #force_quote_table_name, #qualified_name, #quote_identifier, #quote_table_name

Methods inherited from Base

#commented_sql, #default_bulk_insert_chunk_size, #describe_query, #dumpable?, #explain_scope_with_placeholders!, #initialize, #output_extension, #pre_insert_sql, #query_comment_text, #schema_output_extension, #sql_query_comment, #supports_bulk_delete?, table_config_class, #validate_as_dump_target!, #write_inserts

Constructor Details

This class inherits a constructor from Exwiw::Adapter::Base

Instance Method Details

#build_query(table, dump_target, table_by_name) ⇒ Object



80
81
82
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 80

def build_query(table, dump_target, table_by_name)
  Exwiw::QueryAstBuilder.run(table.name, table_by_name, dump_target, @logger)
end

#compile_ast(query_ast, select_cast_to: nil) ⇒ Object

Raises:

  • (NotImplementedError)


372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 372

def compile_ast(query_ast, select_cast_to: nil)
  raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)

  # Lift scope id-set clauses (reverse_scope UNION / forward cascade /
  # single referenced_by) out of `WHERE <col> IN (subquery)` and into a
  # JOIN against a materialized derived table. See #compile_scope_join.
  scope_clauses, plain_where_clauses = partition_scope_clauses(query_ast.where_clauses)

  sql = "SELECT "
  sql += if query_ast.select_all
           # A lifted scope JOIN brings a derived table into FROM, so a bare
           # `*` would also project its column. Qualify to this table's own.
           scope_clauses.any? ? "#{quote_table_name(query_ast.from_table_name)}.*" : "*"
         else
           cols = query_ast.columns.map { |col| compile_column_name(query_ast, col) }
           cols = cols.map { |c| "#{c}::#{select_cast_to}" } if select_cast_to
           cols.join(', ')
         end
  sql += " FROM #{quote_table_name(query_ast.from_table_name)}"

  query_ast.join_clauses.each do |join|
    fk_expr = qualified_name(join.base_table_name, join.foreign_key)
    pk_expr = qualified_name(join.join_table_name, join.primary_key)
    if types_need_cast?(
      column_pg_type(join.base_table_name, join.foreign_key),
      column_pg_type(join.join_table_name, join.primary_key)
    )
      fk_expr = "#{fk_expr}::text"
      pk_expr = "#{pk_expr}::text"
    end
    sql += " JOIN #{quote_table_name(join.join_table_name)} ON #{fk_expr} = #{pk_expr}"

    join.where_clauses.each do |where|
      compiled_where_condition = compile_where_condition(where, join.join_table_name)
      sql += " AND #{compiled_where_condition}"
    end

    # base_where_clauses is compiled against the joined-from table
    # (base_table_name), e.g. the type-column filter on a polymorphic
    # source table.
    join.base_where_clauses.each do |where|
      compiled_where_condition = compile_where_condition(where, join.base_table_name)
      sql += " AND #{compiled_where_condition}"
    end
  end

  scope_clauses.each_with_index do |where_clause, idx|
    sql += " #{compile_scope_join(query_ast.from_table_name, where_clause, idx)}"
  end

  if plain_where_clauses.any?
    sql += " WHERE "
    sql += plain_where_clauses.map { |where| compile_where_condition(where, query_ast.from_table_name) }.join(' AND ')
  end

  sql
end

#dump_schema(ordered_tables, output_path) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 136

def dump_schema(ordered_tables, output_path)
  require 'open3'

  # Full-database schema dump: emit DDL for every table in the database,
  # not just the config-scoped tables. A table absent from the config
  # gets its schema here but no data (empty INSERT), which is the intended
  # result — the restore target then has every relation even when exwiw
  # was configured to extract only a subset, so a later reference to an
  # unscoped table does not fail with "relation does not exist".
  # `ordered_tables` no longer selects which tables are emitted; it is kept
  # only for the log line (how many tables are in scope for data).
  cmd = [
    'pg_dump',
    "--host=#{@connection_config.host}",
    "--port=#{@connection_config.port}",
    "--username=#{@connection_config.user}",
    '--schema-only',
    '--no-owner',
    '--no-acl',
    # An extension that needs a schema of its own puts it under its own name
    # (Cloud SQL's google_vacuum_mgmt does; the AlloyDB two install into
    # public), so excluding the same names covers the schema half — including
    # any object the platform adds inside one later. The patterns are exact
    # names: pg_dump reads them psql \d-style, where `_` is literal and only
    # `*` globs. A pattern matching nothing is not an error (that needs
    # --strict-names), so these are harmless against a self-hosted server.
    *PLATFORM_MANAGED_EXTENSIONS.map { |name| "--exclude-schema=#{name}" },
    @connection_config.database_name,
  ]
  env = { 'PGPASSWORD' => @connection_config.password.to_s }

  log_excluded_platform_managed_schemas

  @logger.debug("  Running pg_dump for the whole database (#{@connection_config.database_name})...")
  stdout, stderr, status = Open3.capture3(env, *cmd)
  unless status.success?
    if stderr.include?('command not found') || stderr.empty?
      raise "Failed to run `pg_dump`. Ensure the postgresql client is installed and on PATH. stderr: #{stderr}"
    end
    raise "pg_dump failed (exit #{status.exitstatus}): #{stderr}"
  end

  # A full-database dump emits CREATE EXTENSION and CREATE TYPE ... AS ENUM
  # itself (a `--table` dump omitted both, which is why they used to be
  # prepended by hand), but pg_dump's bare forms are neither idempotent nor
  # restore-tolerant. Wrap them in place to restore the previous
  # robustness: enums swallow duplicate_object on re-restore; extensions
  # warn-and-skip feature_not_supported / invalid_schema_name so a target
  # that cannot provide the extension (e.g. pglogical's schema absent, or a
  # managed-platform extension) does not abort the restore. The COMMENT ON
  # EXTENSION that pg_dump emits alongside is likewise wrapped to swallow
  # undefined_object, so a skipped extension's trailing comment does not
  # abort the restore either.
  # Platform-managed extensions are stripped first: the wrapping passes below
  # rewrite the bare CREATE/COMMENT statements this removes.
  idempotent = strip_platform_managed_extensions(stdout)
  idempotent = DdlPostprocessor.wrap_create_type_enum_in_do_block(idempotent)
  idempotent = DdlPostprocessor.wrap_create_extension_in_do_block(idempotent)
  idempotent = DdlPostprocessor.wrap_comment_on_extension_in_do_block(idempotent)
  idempotent = DdlPostprocessor.add_if_not_exists_to_create_schema(idempotent)
  idempotent = DdlPostprocessor.add_if_not_exists_to_create_sequence(idempotent)
  idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(idempotent)
  idempotent = DdlPostprocessor.add_if_not_exists_to_create_index(idempotent)
  idempotent = DdlPostprocessor.wrap_add_constraint_in_do_block(idempotent)
  idempotent = DdlPostprocessor.strip_triggers(idempotent)

  File.open(output_path, 'w') do |file|
    file.puts("-- Auto-generated by exwiw via pg_dump. Idempotent DDL for postgresql.")
    file.write(idempotent)
  end
  @logger.info("  Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
end

#execute(query_ast) ⇒ Object



84
85
86
87
88
89
90
91
92
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 84

def execute(query_ast)
  data_sql = commented_sql(query_ast)
  # Count via the same query (wrapped as a subquery) so the Runner can
  # skip empty tables and log the row count without draining the stream.
  count_sql = "#{sql_query_comment(query_ast)} SELECT COUNT(*) FROM (#{compile_ast(query_ast)}) AS exwiw_count_src"

  @logger.debug("  Executing SQL (single-row stream): \n#{data_sql}")
  StreamingResult.new(connection: connection, data_sql: data_sql, count_sql: count_sql)
end

#explain(query_ast, verbosity: nil) ⇒ Object



94
95
96
97
98
99
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 94

def explain(query_ast, verbosity: nil)
  sql = commented_sql(query_ast)

  @logger.debug("  Executing EXPLAIN: \n#{sql}")
  connection.exec("EXPLAIN #{sql}").values.map(&:first).join("\n")
end

#post_insert_sql(table) ⇒ Object

Transcribe the FROM-side sequence cursor backing table.primary_key onto the import target. Without this, importing into a clean DB leaves the sequence at 1 while the inserted rows occupy higher IDs, so the next default-PK INSERT collides. We query FROM's last_value / is_called directly (matching what pg_dump emits) rather than using MAX(pk), so a subsetted dump still preserves the source's "next id". Returns nil for non-auto-increment PKs (pg_get_serial_sequence -> NULL).

Scope: ONLY the sequence attached to the primary key is synced. If a table has additional auto-increment columns (e.g. a non-PK SERIAL), those sequences are NOT transcribed and a subsequent default-value INSERT on them can collide. Rails-managed schemas don't hit this because only id is auto-increment, but bare PostgreSQL schemas may.



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 282

def post_insert_sql(table)
  pk = table.primary_key
  return nil if pk.nil? || pk.empty?

  # pg_get_serial_sequence parses its first argument with identifier
  # rules (unquoted parts are case-folded), so pass the same
  # conditionally quoted form the extraction queries use — a bare
  # 'Order' would fold to the nonexistent relation 'order' even though
  # the quoted extraction just succeeded. The second argument is taken
  # literally (no folding), so the raw column name is correct.
  seq_name = connection
    .exec_params("SELECT pg_get_serial_sequence($1, $2)", [quote_table_name(table.name), pk])
    .values.dig(0, 0)
  return nil if seq_name.nil?

  last_value, is_called = connection
    .exec("SELECT last_value, is_called FROM #{seq_name}")
    .values.first
  is_called_sql = (is_called == 't' || is_called == true) ? 'true' : 'false'

  "SELECT pg_catalog.setval('#{escape_single_quote(seq_name)}', #{last_value}, #{is_called_sql});"
end

#to_bulk_delete(select_query_ast, table) ⇒ Object

Raises:

  • (NotImplementedError)


305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 305

def to_bulk_delete(select_query_ast, table)
  raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)

  sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"

  if select_query_ast.join_clauses.empty?
    # Ignore filter option, because bulk delete is for cleaning before import,
    # so it should delete all records to avoid foreign key violation & data consistancy.
    compiled_where_conditions = select_query_ast.
      where_clauses.
      select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
      map do |where|
      compile_where_condition(where, select_query_ast.from_table_name)
    end

    if compiled_where_conditions.size > 0
      sql += "\nWHERE "
      sql += compiled_where_conditions.join(' AND ')
    end
    sql += ";"

    return sql
  end

  subquery_ast = Exwiw::QueryAst::Select.new
  first_join = select_query_ast.join_clauses.first.clone

  subquery_ast.from(first_join.join_table_name)
  primay_key_col = table.columns.find { |col| col.name == table.primary_key }
  subquery_ast.select([primay_key_col])
  select_query_ast.join_clauses[1..].each do |join|
    subquery_ast.join(join)
  end
  first_join.where_clauses.each do |where|
    # Ignore filter option, because bulk delete is for cleaning before import,
    # so it should delete all records to avoid foreign key violation & data consistancy.
    subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
  end

  foreign_key = first_join.foreign_key
  outer_table = select_query_ast.from_table_name
  inner_table = first_join.join_table_name
  inner_column = first_join.primary_key
  cast_to = types_need_cast?(
    column_pg_type(outer_table, foreign_key),
    column_pg_type(inner_table, inner_column)
  ) ? 'text' : nil
  subquery_sql = compile_ast(subquery_ast, select_cast_to: cast_to)
  outer_expr = qualified_name(outer_table, foreign_key)
  outer_expr = "#{outer_expr}::text" if cast_to
  sql += "\nWHERE #{outer_expr} IN (#{subquery_sql})"

  # first_join.base_where_clauses holds conditions on the outer
  # delete-target table (from_table_name), such as a polymorphic type
  # column. They are not part of the subquery, so add them to the outer
  # WHERE. This prevents deleting rows that belong to a different
  # polymorphic type.
  first_join.base_where_clauses.each do |where|
    next unless where.is_a?(Exwiw::QueryAst::WhereClause)

    sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
  end
  sql += ";"

  sql
end

#to_copy_from_stdin(results, table) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/exwiw/adapter/postgresql_adapter.rb', line 254

def to_copy_from_stdin(results, table)
  header = if table.rails_managed?
             "COPY #{quote_table_name(table.name)} FROM stdin;"
           else
             column_names = table.columns.map { |c| quote_identifier(c.name) }.join(', ')
             "COPY #{quote_table_name(table.name)} (#{column_names}) FROM stdin;"
           end
  lines = [header]
  results.each do |row|
    lines << row.map { |v| escape_copy_value(v) }.join("\t")
  end
  lines << '\\.'
  lines.join("\n")
end