activerecord-cipherstash-pg-adapter 0.8.1 → 0.8.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/activerecord-cipherstash-pg-adapter.gemspec +1 -1
  4. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/column.rb +70 -0
  5. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/database_statements.rb +199 -0
  6. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/explain_pretty_printer.rb +44 -0
  7. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/array.rb +91 -0
  8. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bit.rb +53 -0
  9. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bit_varying.rb +15 -0
  10. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/bytea.rb +17 -0
  11. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/cidr.rb +48 -0
  12. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/date.rb +31 -0
  13. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/date_time.rb +36 -0
  14. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/decimal.rb +15 -0
  15. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/enum.rb +20 -0
  16. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/hstore.rb +109 -0
  17. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/inet.rb +15 -0
  18. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/interval.rb +49 -0
  19. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/jsonb.rb +15 -0
  20. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/legacy_point.rb +44 -0
  21. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/macaddr.rb +25 -0
  22. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/money.rb +41 -0
  23. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/oid.rb +15 -0
  24. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/point.rb +64 -0
  25. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/range.rb +124 -0
  26. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/specialized_string.rb +18 -0
  27. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/timestamp.rb +15 -0
  28. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/timestamp_with_time_zone.rb +30 -0
  29. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/type_map_initializer.rb +125 -0
  30. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/uuid.rb +35 -0
  31. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/vector.rb +28 -0
  32. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid/xml.rb +30 -0
  33. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/oid.rb +38 -0
  34. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/quoting.rb +237 -0
  35. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/referential_integrity.rb +71 -0
  36. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_creation.rb +170 -0
  37. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_definitions.rb +372 -0
  38. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_dumper.rb +116 -0
  39. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/schema_statements.rb +1110 -0
  40. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/type_metadata.rb +44 -0
  41. data/lib/active_record/connection_adapters/7.1/cipherstash_pg/utils.rb +79 -0
  42. data/lib/active_record/connection_adapters/7.1/postgres_cipherstash_adapter.rb +1266 -0
  43. data/lib/active_record/connection_adapters/postgres_cipherstash_adapter.rb +5 -1
  44. data/lib/version.rb +1 -1
  45. metadata +44 -5
@@ -0,0 +1,1110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CipherStashPG
6
+ module SchemaStatements
7
+ # Drops the database specified on the +name+ attribute
8
+ # and creates it again using the provided +options+.
9
+ def recreate_database(name, options = {}) # :nodoc:
10
+ drop_database(name)
11
+ create_database(name, options)
12
+ end
13
+
14
+ # Create a new PostgreSQL database. Options include <tt>:owner</tt>, <tt>:template</tt>,
15
+ # <tt>:encoding</tt> (defaults to utf8), <tt>:collation</tt>, <tt>:ctype</tt>,
16
+ # <tt>:tablespace</tt>, and <tt>:connection_limit</tt> (note that MySQL uses
17
+ # <tt>:charset</tt> while PostgreSQL uses <tt>:encoding</tt>).
18
+ #
19
+ # Example:
20
+ # create_database config[:database], config
21
+ # create_database 'foo_development', encoding: 'unicode'
22
+ def create_database(name, options = {})
23
+ options = { encoding: "utf8" }.merge!(options.symbolize_keys)
24
+
25
+ option_string = options.each_with_object(+"") do |(key, value), memo|
26
+ memo << case key
27
+ when :owner
28
+ " OWNER = \"#{value}\""
29
+ when :template
30
+ " TEMPLATE = \"#{value}\""
31
+ when :encoding
32
+ " ENCODING = '#{value}'"
33
+ when :collation
34
+ " LC_COLLATE = '#{value}'"
35
+ when :ctype
36
+ " LC_CTYPE = '#{value}'"
37
+ when :tablespace
38
+ " TABLESPACE = \"#{value}\""
39
+ when :connection_limit
40
+ " CONNECTION LIMIT = #{value}"
41
+ else
42
+ ""
43
+ end
44
+ end
45
+
46
+ execute "CREATE DATABASE #{quote_table_name(name)}#{option_string}"
47
+ end
48
+
49
+ # Drops a PostgreSQL database.
50
+ #
51
+ # Example:
52
+ # drop_database 'matt_development'
53
+ def drop_database(name) # :nodoc:
54
+ execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
55
+ end
56
+
57
+ def drop_table(table_name, **options) # :nodoc:
58
+ schema_cache.clear_data_source_cache!(table_name.to_s)
59
+ execute "DROP TABLE#{' IF EXISTS' if options[:if_exists]} #{quote_table_name(table_name)}#{' CASCADE' if options[:force] == :cascade}"
60
+ end
61
+
62
+ # Returns true if schema exists.
63
+ def schema_exists?(name)
64
+ query_value("SELECT COUNT(*) FROM pg_namespace WHERE nspname = #{quote(name)}", "SCHEMA").to_i > 0
65
+ end
66
+
67
+ # Verifies existence of an index with a given name.
68
+ def index_name_exists?(table_name, index_name)
69
+ table = quoted_scope(table_name)
70
+ index = quoted_scope(index_name)
71
+
72
+ query_value(<<~SQL, "SCHEMA").to_i > 0
73
+ SELECT COUNT(*)
74
+ FROM pg_class t
75
+ INNER JOIN pg_index d ON t.oid = d.indrelid
76
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
77
+ LEFT JOIN pg_namespace n ON n.oid = t.relnamespace
78
+ WHERE i.relkind IN ('i', 'I')
79
+ AND i.relname = #{index[:name]}
80
+ AND t.relname = #{table[:name]}
81
+ AND n.nspname = #{table[:schema]}
82
+ SQL
83
+ end
84
+
85
+ # Returns an array of indexes for the given table.
86
+ def indexes(table_name) # :nodoc:
87
+ scope = quoted_scope(table_name)
88
+
89
+ result = query(<<~SQL, "SCHEMA")
90
+ SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid,
91
+ pg_catalog.obj_description(i.oid, 'pg_class') AS comment, d.indisvalid
92
+ FROM pg_class t
93
+ INNER JOIN pg_index d ON t.oid = d.indrelid
94
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
95
+ LEFT JOIN pg_namespace n ON n.oid = t.relnamespace
96
+ WHERE i.relkind IN ('i', 'I')
97
+ AND d.indisprimary = 'f'
98
+ AND t.relname = #{scope[:name]}
99
+ AND n.nspname = #{scope[:schema]}
100
+ ORDER BY i.relname
101
+ SQL
102
+
103
+ result.map do |row|
104
+ index_name = row[0]
105
+ unique = row[1]
106
+ indkey = row[2].split(" ").map(&:to_i)
107
+ inddef = row[3]
108
+ oid = row[4]
109
+ comment = row[5]
110
+ valid = row[6]
111
+ using, expressions, include, nulls_not_distinct, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: INCLUDE \((.+?)\))?( NULLS NOT DISTINCT)?(?: WHERE (.+))?\z/m).flatten
112
+
113
+ orders = {}
114
+ opclasses = {}
115
+ include_columns = include ? include.split(",").map(&:strip) : []
116
+
117
+ if indkey.include?(0)
118
+ columns = expressions
119
+ else
120
+ columns = column_names_from_column_numbers(oid, indkey)
121
+
122
+ # prevent INCLUDE columns from being matched
123
+ columns.reject! { |c| include_columns.include?(c) }
124
+
125
+ # add info on sort order (only desc order is explicitly specified, asc is the default)
126
+ # and non-default opclasses
127
+ expressions.scan(/(?<column>\w+)"?\s?(?<opclass>\w+_ops(_\w+)?)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls|
128
+ opclasses[column] = opclass.to_sym if opclass
129
+ if nulls
130
+ orders[column] = [desc, nulls].compact.join(" ")
131
+ else
132
+ orders[column] = :desc if desc
133
+ end
134
+ end
135
+ end
136
+
137
+ IndexDefinition.new(
138
+ table_name,
139
+ index_name,
140
+ unique,
141
+ columns,
142
+ orders: orders,
143
+ opclasses: opclasses,
144
+ where: where,
145
+ using: using.to_sym,
146
+ include: include_columns.presence,
147
+ nulls_not_distinct: nulls_not_distinct.present?,
148
+ comment: comment.presence,
149
+ valid: valid
150
+ )
151
+ end
152
+ end
153
+
154
+ def table_options(table_name) # :nodoc:
155
+ if comment = table_comment(table_name)
156
+ { comment: comment }
157
+ end
158
+ end
159
+
160
+ # Returns a comment stored in database for given table
161
+ def table_comment(table_name) # :nodoc:
162
+ scope = quoted_scope(table_name, type: "BASE TABLE")
163
+ if scope[:name]
164
+ query_value(<<~SQL, "SCHEMA")
165
+ SELECT pg_catalog.obj_description(c.oid, 'pg_class')
166
+ FROM pg_catalog.pg_class c
167
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
168
+ WHERE c.relname = #{scope[:name]}
169
+ AND c.relkind IN (#{scope[:type]})
170
+ AND n.nspname = #{scope[:schema]}
171
+ SQL
172
+ end
173
+ end
174
+
175
+ # Returns the current database name.
176
+ def current_database
177
+ query_value("SELECT current_database()", "SCHEMA")
178
+ end
179
+
180
+ # Returns the current schema name.
181
+ def current_schema
182
+ query_value("SELECT current_schema", "SCHEMA")
183
+ end
184
+
185
+ # Returns the current database encoding format.
186
+ def encoding
187
+ query_value("SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()", "SCHEMA")
188
+ end
189
+
190
+ # Returns the current database collation.
191
+ def collation
192
+ query_value("SELECT datcollate FROM pg_database WHERE datname = current_database()", "SCHEMA")
193
+ end
194
+
195
+ # Returns the current database ctype.
196
+ def ctype
197
+ query_value("SELECT datctype FROM pg_database WHERE datname = current_database()", "SCHEMA")
198
+ end
199
+
200
+ # Returns an array of schema names.
201
+ def schema_names
202
+ query_values(<<~SQL, "SCHEMA")
203
+ SELECT nspname
204
+ FROM pg_namespace
205
+ WHERE nspname !~ '^pg_.*'
206
+ AND nspname NOT IN ('information_schema')
207
+ ORDER by nspname;
208
+ SQL
209
+ end
210
+
211
+ # Creates a schema for the given schema name.
212
+ def create_schema(schema_name)
213
+ execute "CREATE SCHEMA #{quote_schema_name(schema_name)}"
214
+ end
215
+
216
+ # Drops the schema for the given schema name.
217
+ def drop_schema(schema_name, **options)
218
+ execute "DROP SCHEMA#{' IF EXISTS' if options[:if_exists]} #{quote_schema_name(schema_name)} CASCADE"
219
+ end
220
+
221
+ # Sets the schema search path to a string of comma-separated schema names.
222
+ # Names beginning with $ have to be quoted (e.g. $user => '$user').
223
+ # See: https://www.postgresql.org/docs/current/static/ddl-schemas.html
224
+ #
225
+ # This should be not be called manually but set in database.yml.
226
+ def schema_search_path=(schema_csv)
227
+ if schema_csv
228
+ internal_execute("SET search_path TO #{schema_csv}")
229
+ @schema_search_path = schema_csv
230
+ end
231
+ end
232
+
233
+ # Returns the active schema search path.
234
+ def schema_search_path
235
+ @schema_search_path ||= query_value("SHOW search_path", "SCHEMA")
236
+ end
237
+
238
+ # Returns the current client message level.
239
+ def client_min_messages
240
+ query_value("SHOW client_min_messages", "SCHEMA")
241
+ end
242
+
243
+ # Set the client message level.
244
+ def client_min_messages=(level)
245
+ internal_execute("SET client_min_messages TO '#{level}'")
246
+ end
247
+
248
+ # Returns the sequence name for a table's primary key or some other specified key.
249
+ def default_sequence_name(table_name, pk = "id") # :nodoc:
250
+ result = serial_sequence(table_name, pk)
251
+ return nil unless result
252
+ Utils.extract_schema_qualified_name(result).to_s
253
+ rescue ActiveRecord::StatementInvalid
254
+ CipherStashPG::Name.new(nil, "#{table_name}_#{pk}_seq").to_s
255
+ end
256
+
257
+ def serial_sequence(table, column)
258
+ query_value("SELECT pg_get_serial_sequence(#{quote(table)}, #{quote(column)})", "SCHEMA")
259
+ end
260
+
261
+ # Sets the sequence of a table's primary key to the specified value.
262
+ def set_pk_sequence!(table, value) # :nodoc:
263
+ pk, sequence = pk_and_sequence_for(table)
264
+
265
+ if pk
266
+ if sequence
267
+ quoted_sequence = quote_table_name(sequence)
268
+
269
+ query_value("SELECT setval(#{quote(quoted_sequence)}, #{value})", "SCHEMA")
270
+ else
271
+ @logger.warn "#{table} has primary key #{pk} with no default sequence." if @logger
272
+ end
273
+ end
274
+ end
275
+
276
+ # Resets the sequence of a table's primary key to the maximum value.
277
+ def reset_pk_sequence!(table, pk = nil, sequence = nil) # :nodoc:
278
+ unless pk && sequence
279
+ default_pk, default_sequence = pk_and_sequence_for(table)
280
+
281
+ pk ||= default_pk
282
+ sequence ||= default_sequence
283
+ end
284
+
285
+ if @logger && pk && !sequence
286
+ @logger.warn "#{table} has primary key #{pk} with no default sequence."
287
+ end
288
+
289
+ if pk && sequence
290
+ quoted_sequence = quote_table_name(sequence)
291
+ max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA")
292
+ if max_pk.nil?
293
+ if database_version >= 10_00_00
294
+ minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA")
295
+ else
296
+ minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA")
297
+ end
298
+ end
299
+
300
+ query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk || minvalue}, #{max_pk ? true : false})", "SCHEMA")
301
+ end
302
+ end
303
+
304
+ # Returns a table's primary key and belonging sequence.
305
+ def pk_and_sequence_for(table) # :nodoc:
306
+ # First try looking for a sequence with a dependency on the
307
+ # given table's primary key.
308
+ result = query(<<~SQL, "SCHEMA")[0]
309
+ SELECT attr.attname, nsp.nspname, seq.relname
310
+ FROM pg_class seq,
311
+ pg_attribute attr,
312
+ pg_depend dep,
313
+ pg_constraint cons,
314
+ pg_namespace nsp
315
+ WHERE seq.oid = dep.objid
316
+ AND seq.relkind = 'S'
317
+ AND attr.attrelid = dep.refobjid
318
+ AND attr.attnum = dep.refobjsubid
319
+ AND attr.attrelid = cons.conrelid
320
+ AND attr.attnum = cons.conkey[1]
321
+ AND seq.relnamespace = nsp.oid
322
+ AND cons.contype = 'p'
323
+ AND dep.classid = 'pg_class'::regclass
324
+ AND dep.refobjid = #{quote(quote_table_name(table))}::regclass
325
+ SQL
326
+
327
+ if result.nil? || result.empty?
328
+ result = query(<<~SQL, "SCHEMA")[0]
329
+ SELECT attr.attname, nsp.nspname,
330
+ CASE
331
+ WHEN pg_get_expr(def.adbin, def.adrelid) !~* 'nextval' THEN NULL
332
+ WHEN split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2) ~ '.' THEN
333
+ substr(split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2),
334
+ strpos(split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2), '.')+1)
335
+ ELSE split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2)
336
+ END
337
+ FROM pg_class t
338
+ JOIN pg_attribute attr ON (t.oid = attrelid)
339
+ JOIN pg_attrdef def ON (adrelid = attrelid AND adnum = attnum)
340
+ JOIN pg_constraint cons ON (conrelid = adrelid AND adnum = conkey[1])
341
+ JOIN pg_namespace nsp ON (t.relnamespace = nsp.oid)
342
+ WHERE t.oid = #{quote(quote_table_name(table))}::regclass
343
+ AND cons.contype = 'p'
344
+ AND pg_get_expr(def.adbin, def.adrelid) ~* 'nextval|uuid_generate'
345
+ SQL
346
+ end
347
+
348
+ pk = result.shift
349
+ if result.last
350
+ [pk, CipherStashPG::Name.new(*result)]
351
+ else
352
+ [pk, nil]
353
+ end
354
+ rescue
355
+ nil
356
+ end
357
+
358
+ def primary_keys(table_name) # :nodoc:
359
+ query_values(<<~SQL, "SCHEMA")
360
+ SELECT a.attname
361
+ FROM (
362
+ SELECT indrelid, indkey, generate_subscripts(indkey, 1) idx
363
+ FROM pg_index
364
+ WHERE indrelid = #{quote(quote_table_name(table_name))}::regclass
365
+ AND indisprimary
366
+ ) i
367
+ JOIN pg_attribute a
368
+ ON a.attrelid = i.indrelid
369
+ AND a.attnum = i.indkey[i.idx]
370
+ ORDER BY i.idx
371
+ SQL
372
+ end
373
+
374
+ # Renames a table.
375
+ # Also renames a table's primary key sequence if the sequence name exists and
376
+ # matches the Active Record default.
377
+ #
378
+ # Example:
379
+ # rename_table('octopuses', 'octopi')
380
+ def rename_table(table_name, new_name, **options)
381
+ validate_table_length!(new_name) unless options[:_uses_legacy_table_name]
382
+ clear_cache!
383
+ schema_cache.clear_data_source_cache!(table_name.to_s)
384
+ schema_cache.clear_data_source_cache!(new_name.to_s)
385
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_table_name(new_name)}"
386
+ pk, seq = pk_and_sequence_for(new_name)
387
+ if pk
388
+ idx = "#{table_name}_pkey"
389
+ new_idx = "#{new_name}_pkey"
390
+ execute "ALTER INDEX #{quote_table_name(idx)} RENAME TO #{quote_table_name(new_idx)}"
391
+ if seq && seq.identifier == "#{table_name}_#{pk}_seq"
392
+ new_seq = "#{new_name}_#{pk}_seq"
393
+ execute "ALTER TABLE #{seq.quoted} RENAME TO #{quote_table_name(new_seq)}"
394
+ end
395
+ end
396
+ rename_table_indexes(table_name, new_name)
397
+ end
398
+
399
+ def add_column(table_name, column_name, type, **options) # :nodoc:
400
+ clear_cache!
401
+ super
402
+ change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment)
403
+ end
404
+
405
+ def change_column(table_name, column_name, type, **options) # :nodoc:
406
+ clear_cache!
407
+ sqls, procs = Array(change_column_for_alter(table_name, column_name, type, **options)).partition { |v| v.is_a?(String) }
408
+ execute "ALTER TABLE #{quote_table_name(table_name)} #{sqls.join(", ")}"
409
+ procs.each(&:call)
410
+ end
411
+
412
+ # Builds a ChangeColumnDefinition object.
413
+ #
414
+ # This definition object contains information about the column change that would occur
415
+ # if the same arguments were passed to #change_column. See #change_column for information about
416
+ # passing a +table_name+, +column_name+, +type+ and other options that can be passed.
417
+ def build_change_column_definition(table_name, column_name, type, **options) # :nodoc:
418
+ td = create_table_definition(table_name)
419
+ cd = td.new_column_definition(column_name, type, **options)
420
+ ChangeColumnDefinition.new(cd, column_name)
421
+ end
422
+
423
+ # Changes the default value of a table column.
424
+ def change_column_default(table_name, column_name, default_or_changes) # :nodoc:
425
+ execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_default_for_alter(table_name, column_name, default_or_changes)}"
426
+ end
427
+
428
+ def build_change_column_default_definition(table_name, column_name, default_or_changes) # :nodoc:
429
+ column = column_for(table_name, column_name)
430
+ return unless column
431
+
432
+ default = extract_new_default_value(default_or_changes)
433
+ ChangeColumnDefaultDefinition.new(column, default)
434
+ end
435
+
436
+ def change_column_null(table_name, column_name, null, default = nil) # :nodoc:
437
+ validate_change_column_null_argument!(null)
438
+
439
+ clear_cache!
440
+ unless null || default.nil?
441
+ column = column_for(table_name, column_name)
442
+ execute "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_expression(default, column)} WHERE #{quote_column_name(column_name)} IS NULL" if column
443
+ end
444
+ execute "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL"
445
+ end
446
+
447
+ # Adds comment for given table column or drops it if +comment+ is a +nil+
448
+ def change_column_comment(table_name, column_name, comment_or_changes) # :nodoc:
449
+ clear_cache!
450
+ comment = extract_new_comment_value(comment_or_changes)
451
+ execute "COMMENT ON COLUMN #{quote_table_name(table_name)}.#{quote_column_name(column_name)} IS #{quote(comment)}"
452
+ end
453
+
454
+ # Adds comment for given table or drops it if +comment+ is a +nil+
455
+ def change_table_comment(table_name, comment_or_changes) # :nodoc:
456
+ clear_cache!
457
+ comment = extract_new_comment_value(comment_or_changes)
458
+ execute "COMMENT ON TABLE #{quote_table_name(table_name)} IS #{quote(comment)}"
459
+ end
460
+
461
+ # Renames a column in a table.
462
+ def rename_column(table_name, column_name, new_column_name) # :nodoc:
463
+ clear_cache!
464
+ execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_sql(table_name, column_name, new_column_name)}")
465
+ rename_column_indexes(table_name, column_name, new_column_name)
466
+ end
467
+
468
+ def add_index(table_name, column_name, **options) # :nodoc:
469
+ create_index = build_create_index_definition(table_name, column_name, **options)
470
+ result = execute schema_creation.accept(create_index)
471
+
472
+ index = create_index.index
473
+ execute "COMMENT ON INDEX #{quote_column_name(index.name)} IS #{quote(index.comment)}" if index.comment
474
+ result
475
+ end
476
+
477
+ def build_create_index_definition(table_name, column_name, **options) # :nodoc:
478
+ index, algorithm, if_not_exists = add_index_options(table_name, column_name, **options)
479
+ CreateIndexDefinition.new(index, algorithm, if_not_exists)
480
+ end
481
+
482
+ def remove_index(table_name, column_name = nil, **options) # :nodoc:
483
+ table = Utils.extract_schema_qualified_name(table_name.to_s)
484
+
485
+ if options.key?(:name)
486
+ provided_index = Utils.extract_schema_qualified_name(options[:name].to_s)
487
+
488
+ options[:name] = provided_index.identifier
489
+ table = CipherStashPG::Name.new(provided_index.schema, table.identifier) unless table.schema.present?
490
+
491
+ if provided_index.schema.present? && table.schema != provided_index.schema
492
+ raise ArgumentError.new("Index schema '#{provided_index.schema}' does not match table schema '#{table.schema}'")
493
+ end
494
+ end
495
+
496
+ return if options[:if_exists] && !index_exists?(table_name, column_name, **options)
497
+
498
+ index_to_remove = CipherStashPG::Name.new(table.schema, index_name_for_remove(table.to_s, column_name, options))
499
+
500
+ execute "DROP INDEX #{index_algorithm(options[:algorithm])} #{quote_table_name(index_to_remove)}"
501
+ end
502
+
503
+ # Renames an index of a table. Raises error if length of new
504
+ # index name is greater than allowed limit.
505
+ def rename_index(table_name, old_name, new_name)
506
+ validate_index_length!(table_name, new_name)
507
+
508
+ schema, = extract_schema_qualified_name(table_name)
509
+ execute "ALTER INDEX #{quote_table_name(schema) + '.' if schema}#{quote_column_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
510
+ end
511
+
512
+ def index_name(table_name, options) # :nodoc:
513
+ _schema, table_name = extract_schema_qualified_name(table_name.to_s)
514
+ super
515
+ end
516
+
517
+ def add_foreign_key(from_table, to_table, **options)
518
+ if options[:deferrable] == true
519
+ ActiveRecord.deprecator.warn(<<~MSG)
520
+ `deferrable: true` is deprecated in favor of `deferrable: :immediate`, and will be removed in Rails 7.2.
521
+ MSG
522
+
523
+ options[:deferrable] = :immediate
524
+ end
525
+
526
+ assert_valid_deferrable(options[:deferrable])
527
+
528
+ super
529
+ end
530
+
531
+ def foreign_keys(table_name)
532
+ scope = quoted_scope(table_name)
533
+ fk_info = internal_exec_query(<<~SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
534
+ SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred, c.conkey, c.confkey, c.conrelid, c.confrelid
535
+ FROM pg_constraint c
536
+ JOIN pg_class t1 ON c.conrelid = t1.oid
537
+ JOIN pg_class t2 ON c.confrelid = t2.oid
538
+ JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid
539
+ JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid
540
+ JOIN pg_namespace t3 ON c.connamespace = t3.oid
541
+ WHERE c.contype = 'f'
542
+ AND t1.relname = #{scope[:name]}
543
+ AND t3.nspname = #{scope[:schema]}
544
+ ORDER BY c.conname
545
+ SQL
546
+
547
+ fk_info.map do |row|
548
+ to_table = Utils.unquote_identifier(row["to_table"])
549
+ conkey = row["conkey"].scan(/\d+/).map(&:to_i)
550
+ confkey = row["confkey"].scan(/\d+/).map(&:to_i)
551
+
552
+ if conkey.size > 1
553
+ column = column_names_from_column_numbers(row["conrelid"], conkey)
554
+ primary_key = column_names_from_column_numbers(row["confrelid"], confkey)
555
+ else
556
+ column = Utils.unquote_identifier(row["column"])
557
+ primary_key = row["primary_key"]
558
+ end
559
+
560
+ options = {
561
+ column: column,
562
+ name: row["name"],
563
+ primary_key: primary_key
564
+ }
565
+
566
+ options[:on_delete] = extract_foreign_key_action(row["on_delete"])
567
+ options[:on_update] = extract_foreign_key_action(row["on_update"])
568
+ options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"])
569
+
570
+ options[:validate] = row["valid"]
571
+
572
+ ForeignKeyDefinition.new(table_name, to_table, options)
573
+ end
574
+ end
575
+
576
+ def foreign_tables
577
+ query_values(data_source_sql(type: "FOREIGN TABLE"), "SCHEMA")
578
+ end
579
+
580
+ def foreign_table_exists?(table_name)
581
+ query_values(data_source_sql(table_name, type: "FOREIGN TABLE"), "SCHEMA").any? if table_name.present?
582
+ end
583
+
584
+ def check_constraints(table_name) # :nodoc:
585
+ scope = quoted_scope(table_name)
586
+
587
+ check_info = internal_exec_query(<<-SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
588
+ SELECT conname, pg_get_constraintdef(c.oid, true) AS constraintdef, c.convalidated AS valid
589
+ FROM pg_constraint c
590
+ JOIN pg_class t ON c.conrelid = t.oid
591
+ JOIN pg_namespace n ON n.oid = c.connamespace
592
+ WHERE c.contype = 'c'
593
+ AND t.relname = #{scope[:name]}
594
+ AND n.nspname = #{scope[:schema]}
595
+ SQL
596
+
597
+ check_info.map do |row|
598
+ options = {
599
+ name: row["conname"],
600
+ validate: row["valid"]
601
+ }
602
+ expression = row["constraintdef"][/CHECK \((.+)\)/m, 1]
603
+
604
+ CheckConstraintDefinition.new(table_name, expression, options)
605
+ end
606
+ end
607
+
608
+ # Returns an array of exclusion constraints for the given table.
609
+ # The exclusion constraints are represented as ExclusionConstraintDefinition objects.
610
+ def exclusion_constraints(table_name)
611
+ scope = quoted_scope(table_name)
612
+
613
+ exclusion_info = internal_exec_query(<<-SQL, "SCHEMA")
614
+ SELECT conname, pg_get_constraintdef(c.oid) AS constraintdef, c.condeferrable, c.condeferred
615
+ FROM pg_constraint c
616
+ JOIN pg_class t ON c.conrelid = t.oid
617
+ JOIN pg_namespace n ON n.oid = c.connamespace
618
+ WHERE c.contype = 'x'
619
+ AND t.relname = #{scope[:name]}
620
+ AND n.nspname = #{scope[:schema]}
621
+ SQL
622
+
623
+ exclusion_info.map do |row|
624
+ method_and_elements, predicate = row["constraintdef"].split(" WHERE ")
625
+ method_and_elements_parts = method_and_elements.match(/EXCLUDE(?: USING (?<using>\S+))? \((?<expression>.+)\)/)
626
+ predicate.remove!(/ DEFERRABLE(?: INITIALLY (?:IMMEDIATE|DEFERRED))?/) if predicate
627
+ predicate = predicate.from(2).to(-3) if predicate # strip 2 opening and closing parentheses
628
+
629
+ deferrable = extract_constraint_deferrable(row["condeferrable"], row["condeferred"])
630
+
631
+ options = {
632
+ name: row["conname"],
633
+ using: method_and_elements_parts["using"].to_sym,
634
+ where: predicate,
635
+ deferrable: deferrable
636
+ }
637
+
638
+ ExclusionConstraintDefinition.new(table_name, method_and_elements_parts["expression"], options)
639
+ end
640
+ end
641
+
642
+ # Returns an array of unique constraints for the given table.
643
+ # The unique constraints are represented as UniqueKeyDefinition objects.
644
+ def unique_keys(table_name)
645
+ scope = quoted_scope(table_name)
646
+
647
+ unique_info = internal_exec_query(<<~SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
648
+ SELECT c.conname, c.conindid, c.condeferrable, c.condeferred
649
+ FROM pg_constraint c
650
+ JOIN pg_class t ON c.conrelid = t.oid
651
+ JOIN pg_namespace n ON n.oid = c.connamespace
652
+ WHERE c.contype = 'u'
653
+ AND t.relname = #{scope[:name]}
654
+ AND n.nspname = #{scope[:schema]}
655
+ SQL
656
+
657
+ unique_info.map do |row|
658
+ deferrable = extract_constraint_deferrable(row["condeferrable"], row["condeferred"])
659
+
660
+ columns = query_values(<<~SQL, "SCHEMA")
661
+ SELECT a.attname
662
+ FROM pg_attribute a
663
+ WHERE a.attrelid = #{row['conindid']}
664
+ ORDER BY a.attnum
665
+ SQL
666
+
667
+ options = {
668
+ name: row["conname"],
669
+ deferrable: deferrable
670
+ }
671
+
672
+ UniqueKeyDefinition.new(table_name, columns, options)
673
+ end
674
+ end
675
+
676
+ # Adds a new exclusion constraint to the table. +expression+ is a String
677
+ # representation of a list of exclusion elements and operators.
678
+ #
679
+ # add_exclusion_constraint :products, "price WITH =, availability_range WITH &&", using: :gist, name: "price_check"
680
+ #
681
+ # generates:
682
+ #
683
+ # ALTER TABLE "products" ADD CONSTRAINT price_check EXCLUDE USING gist (price WITH =, availability_range WITH &&)
684
+ #
685
+ # The +options+ hash can include the following keys:
686
+ # [<tt>:name</tt>]
687
+ # The constraint name. Defaults to <tt>excl_rails_<identifier></tt>.
688
+ # [<tt>:deferrable</tt>]
689
+ # Specify whether or not the exclusion constraint should be deferrable. Valid values are +false+ or +:immediate+ or +:deferred+ to specify the default behavior. Defaults to +false+.
690
+ def add_exclusion_constraint(table_name, expression, **options)
691
+ options = exclusion_constraint_options(table_name, expression, options)
692
+ at = create_alter_table(table_name)
693
+ at.add_exclusion_constraint(expression, options)
694
+
695
+ execute schema_creation.accept(at)
696
+ end
697
+
698
+ def exclusion_constraint_options(table_name, expression, options) # :nodoc:
699
+ assert_valid_deferrable(options[:deferrable])
700
+
701
+ options = options.dup
702
+ options[:name] ||= exclusion_constraint_name(table_name, expression: expression, **options)
703
+ options
704
+ end
705
+
706
+ # Removes the given exclusion constraint from the table.
707
+ #
708
+ # remove_exclusion_constraint :products, name: "price_check"
709
+ #
710
+ # The +expression+ parameter will be ignored if present. It can be helpful
711
+ # to provide this in a migration's +change+ method so it can be reverted.
712
+ # In that case, +expression+ will be used by #add_exclusion_constraint.
713
+ def remove_exclusion_constraint(table_name, expression = nil, **options)
714
+ excl_name_to_delete = exclusion_constraint_for!(table_name, expression: expression, **options).name
715
+
716
+ at = create_alter_table(table_name)
717
+ at.drop_exclusion_constraint(excl_name_to_delete)
718
+
719
+ execute schema_creation.accept(at)
720
+ end
721
+
722
+ # Adds a new unique constraint to the table.
723
+ #
724
+ # add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_position"
725
+ #
726
+ # generates:
727
+ #
728
+ # ALTER TABLE "sections" ADD CONSTRAINT unique_position UNIQUE (position) DEFERRABLE INITIALLY DEFERRED
729
+ #
730
+ # If you want to change an existing unique index to deferrable, you can use :using_index to create deferrable unique constraints.
731
+ #
732
+ # add_unique_key :sections, deferrable: :deferred, name: "unique_position", using_index: "index_sections_on_position"
733
+ #
734
+ # The +options+ hash can include the following keys:
735
+ # [<tt>:name</tt>]
736
+ # The constraint name. Defaults to <tt>uniq_rails_<identifier></tt>.
737
+ # [<tt>:deferrable</tt>]
738
+ # Specify whether or not the unique constraint should be deferrable. Valid values are +false+ or +:immediate+ or +:deferred+ to specify the default behavior. Defaults to +false+.
739
+ # [<tt>:using_index</tt>]
740
+ # To specify an existing unique index name. Defaults to +nil+.
741
+ def add_unique_key(table_name, column_name = nil, **options)
742
+ options = unique_key_options(table_name, column_name, options)
743
+ at = create_alter_table(table_name)
744
+ at.add_unique_key(column_name, options)
745
+
746
+ execute schema_creation.accept(at)
747
+ end
748
+
749
+ def unique_key_options(table_name, column_name, options) # :nodoc:
750
+ assert_valid_deferrable(options[:deferrable])
751
+
752
+ if column_name && options[:using_index]
753
+ raise ArgumentError, "Cannot specify both column_name and :using_index options."
754
+ end
755
+
756
+ options = options.dup
757
+ options[:name] ||= unique_key_name(table_name, column: column_name, **options)
758
+ options
759
+ end
760
+
761
+ # Removes the given unique constraint from the table.
762
+ #
763
+ # remove_unique_key :sections, name: "unique_position"
764
+ #
765
+ # The +column_name+ parameter will be ignored if present. It can be helpful
766
+ # to provide this in a migration's +change+ method so it can be reverted.
767
+ # In that case, +column_name+ will be used by #add_unique_key.
768
+ def remove_unique_key(table_name, column_name = nil, **options)
769
+ unique_name_to_delete = unique_key_for!(table_name, column: column_name, **options).name
770
+
771
+ at = create_alter_table(table_name)
772
+ at.drop_unique_key(unique_name_to_delete)
773
+
774
+ execute schema_creation.accept(at)
775
+ end
776
+
777
+ # Maps logical Rails types to PostgreSQL-specific data types.
778
+ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, enum_type: nil, **) # :nodoc:
779
+ sql = \
780
+ case type.to_s
781
+ when "binary"
782
+ # PostgreSQL doesn't support limits on binary (bytea) columns.
783
+ # The hard limit is 1GB, because of a 32-bit size field, and TOAST.
784
+ case limit
785
+ when nil, 0..0x3fffffff; super(type)
786
+ else raise ArgumentError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte."
787
+ end
788
+ when "text"
789
+ # PostgreSQL doesn't support limits on text columns.
790
+ # The hard limit is 1GB, according to section 8.3 in the manual.
791
+ case limit
792
+ when nil, 0..0x3fffffff; super(type)
793
+ else raise ArgumentError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte."
794
+ end
795
+ when "integer"
796
+ case limit
797
+ when 1, 2; "smallint"
798
+ when nil, 3, 4; "integer"
799
+ when 5..8; "bigint"
800
+ else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
801
+ end
802
+ when "enum"
803
+ raise ArgumentError, "enum_type is required for enums" if enum_type.nil?
804
+
805
+ enum_type
806
+ else
807
+ super
808
+ end
809
+
810
+ sql = "#{sql}[]" if array && type != :primary_key
811
+ sql
812
+ end
813
+
814
+ # PostgreSQL requires the ORDER BY columns in the select list for distinct queries, and
815
+ # requires that the ORDER BY include the distinct column.
816
+ def columns_for_distinct(columns, orders) # :nodoc:
817
+ order_columns = orders.compact_blank.map { |s|
818
+ # Convert Arel node to string
819
+ s = visitor.compile(s) unless s.is_a?(String)
820
+ # Remove any ASC/DESC modifiers
821
+ s.gsub(/\s+(?:ASC|DESC)\b/i, "")
822
+ .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, "")
823
+ }.compact_blank.map.with_index { |column, i| "#{column} AS alias_#{i}" }
824
+
825
+ (order_columns << super).join(", ")
826
+ end
827
+
828
+ def update_table_definition(table_name, base) # :nodoc:
829
+ CipherStashPG::Table.new(table_name, base)
830
+ end
831
+
832
+ def create_schema_dumper(options) # :nodoc:
833
+ CipherStashPG::SchemaDumper.create(self, options)
834
+ end
835
+
836
+ # Validates the given constraint.
837
+ #
838
+ # Validates the constraint named +constraint_name+ on +accounts+.
839
+ #
840
+ # validate_constraint :accounts, :constraint_name
841
+ def validate_constraint(table_name, constraint_name)
842
+ at = create_alter_table table_name
843
+ at.validate_constraint constraint_name
844
+
845
+ execute schema_creation.accept(at)
846
+ end
847
+
848
+ # Validates the given foreign key.
849
+ #
850
+ # Validates the foreign key on +accounts.branch_id+.
851
+ #
852
+ # validate_foreign_key :accounts, :branches
853
+ #
854
+ # Validates the foreign key on +accounts.owner_id+.
855
+ #
856
+ # validate_foreign_key :accounts, column: :owner_id
857
+ #
858
+ # Validates the foreign key named +special_fk_name+ on the +accounts+ table.
859
+ #
860
+ # validate_foreign_key :accounts, name: :special_fk_name
861
+ #
862
+ # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key.
863
+ def validate_foreign_key(from_table, to_table = nil, **options)
864
+ fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name
865
+
866
+ validate_constraint from_table, fk_name_to_validate
867
+ end
868
+
869
+ # Validates the given check constraint.
870
+ #
871
+ # validate_check_constraint :products, name: "price_check"
872
+ #
873
+ # The +options+ hash accepts the same keys as add_check_constraint[rdoc-ref:ConnectionAdapters::SchemaStatements#add_check_constraint].
874
+ def validate_check_constraint(table_name, **options)
875
+ chk_name_to_validate = check_constraint_for!(table_name, **options).name
876
+
877
+ validate_constraint table_name, chk_name_to_validate
878
+ end
879
+
880
+ def foreign_key_column_for(table_name, column_name) # :nodoc:
881
+ _schema, table_name = extract_schema_qualified_name(table_name)
882
+ super
883
+ end
884
+
885
+ def add_index_options(table_name, column_name, **options) # :nodoc:
886
+ if (where = options[:where]) && table_exists?(table_name) && column_exists?(table_name, where)
887
+ options[:where] = quote_column_name(where)
888
+ end
889
+ super
890
+ end
891
+
892
+ def quoted_include_columns_for_index(column_names) # :nodoc:
893
+ return quote_column_name(column_names) if column_names.is_a?(Symbol)
894
+
895
+ quoted_columns = column_names.each_with_object({}) do |name, result|
896
+ result[name.to_sym] = quote_column_name(name).dup
897
+ end
898
+ add_options_for_index_columns(quoted_columns).values.join(", ")
899
+ end
900
+
901
+ def schema_creation # :nodoc:
902
+ CipherStashPG::SchemaCreation.new(self)
903
+ end
904
+
905
+ private
906
+ def create_table_definition(name, **options)
907
+ CipherStashPG::TableDefinition.new(self, name, **options)
908
+ end
909
+
910
+ def create_alter_table(name)
911
+ CipherStashPG::AlterTable.new create_table_definition(name)
912
+ end
913
+
914
+ def new_column_from_field(table_name, field, _definitions)
915
+ column_name, type, default, notnull, oid, fmod, collation, comment, attgenerated = field
916
+ type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i)
917
+ default_value = extract_value_from_default(default)
918
+
919
+ if attgenerated.present?
920
+ default_function = default
921
+ else
922
+ default_function = extract_default_function(default_value, default)
923
+ end
924
+
925
+ if match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/)
926
+ serial = sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name]
927
+ end
928
+
929
+ CipherStashPG::Column.new(
930
+ column_name,
931
+ default_value,
932
+ type_metadata,
933
+ !notnull,
934
+ default_function,
935
+ collation: collation,
936
+ comment: comment.presence,
937
+ serial: serial,
938
+ generated: attgenerated
939
+ )
940
+ end
941
+
942
+ def fetch_type_metadata(column_name, sql_type, oid, fmod)
943
+ cast_type = get_oid_type(oid, fmod, column_name, sql_type)
944
+ simple_type = SqlTypeMetadata.new(
945
+ sql_type: sql_type,
946
+ type: cast_type.type,
947
+ limit: cast_type.limit,
948
+ precision: cast_type.precision,
949
+ scale: cast_type.scale,
950
+ )
951
+ CipherStashPG::TypeMetadata.new(simple_type, oid: oid, fmod: fmod)
952
+ end
953
+
954
+ def sequence_name_from_parts(table_name, column_name, suffix)
955
+ over_length = [table_name, column_name, suffix].sum(&:length) + 2 - max_identifier_length
956
+
957
+ if over_length > 0
958
+ column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min
959
+ over_length -= column_name.length - column_name_length
960
+ column_name = column_name[0, column_name_length - [over_length, 0].min]
961
+ end
962
+
963
+ if over_length > 0
964
+ table_name = table_name[0, table_name.length - over_length]
965
+ end
966
+
967
+ "#{table_name}_#{column_name}_#{suffix}"
968
+ end
969
+
970
+ def extract_foreign_key_action(specifier)
971
+ case specifier
972
+ when "c"; :cascade
973
+ when "n"; :nullify
974
+ when "r"; :restrict
975
+ end
976
+ end
977
+
978
+ def assert_valid_deferrable(deferrable)
979
+ return if !deferrable || %i(immediate deferred).include?(deferrable)
980
+
981
+ raise ArgumentError, "deferrable must be `:immediate` or `:deferred`, got: `#{deferrable.inspect}`"
982
+ end
983
+
984
+ def extract_constraint_deferrable(deferrable, deferred)
985
+ deferrable && (deferred ? :deferred : :immediate)
986
+ end
987
+
988
+ def reference_name_for_table(table_name)
989
+ _schema, table_name = extract_schema_qualified_name(table_name.to_s)
990
+ table_name.singularize
991
+ end
992
+
993
+ def add_column_for_alter(table_name, column_name, type, **options)
994
+ return super unless options.key?(:comment)
995
+ [super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }]
996
+ end
997
+
998
+ def change_column_for_alter(table_name, column_name, type, **options)
999
+ change_col_def = build_change_column_definition(table_name, column_name, type, **options)
1000
+ sqls = [schema_creation.accept(change_col_def)]
1001
+ sqls << Proc.new { change_column_comment(table_name, column_name, options[:comment]) } if options.key?(:comment)
1002
+ sqls
1003
+ end
1004
+
1005
+ def change_column_null_for_alter(table_name, column_name, null, default = nil)
1006
+ if default.nil?
1007
+ "ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL"
1008
+ else
1009
+ Proc.new { change_column_null(table_name, column_name, null, default) }
1010
+ end
1011
+ end
1012
+
1013
+ def add_index_opclass(quoted_columns, **options)
1014
+ opclasses = options_for_index_columns(options[:opclass])
1015
+ quoted_columns.each do |name, column|
1016
+ column << " #{opclasses[name]}" if opclasses[name].present?
1017
+ end
1018
+ end
1019
+
1020
+ def add_options_for_index_columns(quoted_columns, **options)
1021
+ quoted_columns = add_index_opclass(quoted_columns, **options)
1022
+ super
1023
+ end
1024
+
1025
+ def exclusion_constraint_name(table_name, **options)
1026
+ options.fetch(:name) do
1027
+ expression = options.fetch(:expression)
1028
+ identifier = "#{table_name}_#{expression}_excl"
1029
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
1030
+
1031
+ "excl_rails_#{hashed_identifier}"
1032
+ end
1033
+ end
1034
+
1035
+ def exclusion_constraint_for(table_name, **options)
1036
+ excl_name = exclusion_constraint_name(table_name, **options)
1037
+ exclusion_constraints(table_name).detect { |excl| excl.name == excl_name }
1038
+ end
1039
+
1040
+ def exclusion_constraint_for!(table_name, expression: nil, **options)
1041
+ exclusion_constraint_for(table_name, expression: expression, **options) ||
1042
+ raise(ArgumentError, "Table '#{table_name}' has no exclusion constraint for #{expression || options}")
1043
+ end
1044
+
1045
+ def unique_key_name(table_name, **options)
1046
+ options.fetch(:name) do
1047
+ column_or_index = Array(options[:column] || options[:using_index]).map(&:to_s)
1048
+ identifier = "#{table_name}_#{column_or_index * '_and_'}_unique"
1049
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
1050
+
1051
+ "uniq_rails_#{hashed_identifier}"
1052
+ end
1053
+ end
1054
+
1055
+ def unique_key_for(table_name, **options)
1056
+ name = unique_key_name(table_name, **options) unless options.key?(:column)
1057
+ unique_keys(table_name).detect { |unique_key| unique_key.defined_for?(name: name, **options) }
1058
+ end
1059
+
1060
+ def unique_key_for!(table_name, column: nil, **options)
1061
+ unique_key_for(table_name, column: column, **options) ||
1062
+ raise(ArgumentError, "Table '#{table_name}' has no unique constraint for #{column || options}")
1063
+ end
1064
+
1065
+ def data_source_sql(name = nil, type: nil)
1066
+ scope = quoted_scope(name, type: type)
1067
+ scope[:type] ||= "'r','v','m','p','f'" # (r)elation/table, (v)iew, (m)aterialized view, (p)artitioned table, (f)oreign table
1068
+
1069
+ sql = +"SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace"
1070
+ sql << " WHERE n.nspname = #{scope[:schema]}"
1071
+ sql << " AND c.relname = #{scope[:name]}" if scope[:name]
1072
+ sql << " AND c.relkind IN (#{scope[:type]})"
1073
+ sql
1074
+ end
1075
+
1076
+ def quoted_scope(name = nil, type: nil)
1077
+ schema, name = extract_schema_qualified_name(name)
1078
+ type = \
1079
+ case type
1080
+ when "BASE TABLE"
1081
+ "'r','p'"
1082
+ when "VIEW"
1083
+ "'v','m'"
1084
+ when "FOREIGN TABLE"
1085
+ "'f'"
1086
+ end
1087
+ scope = {}
1088
+ scope[:schema] = schema ? quote(schema) : "ANY (current_schemas(false))"
1089
+ scope[:name] = quote(name) if name
1090
+ scope[:type] = type if type
1091
+ scope
1092
+ end
1093
+
1094
+ def extract_schema_qualified_name(string)
1095
+ name = Utils.extract_schema_qualified_name(string.to_s)
1096
+ [name.schema, name.identifier]
1097
+ end
1098
+
1099
+ def column_names_from_column_numbers(table_oid, column_numbers)
1100
+ Hash[query(<<~SQL, "SCHEMA")].values_at(*column_numbers).compact
1101
+ SELECT a.attnum, a.attname
1102
+ FROM pg_attribute a
1103
+ WHERE a.attrelid = #{table_oid}
1104
+ AND a.attnum IN (#{column_numbers.join(", ")})
1105
+ SQL
1106
+ end
1107
+ end
1108
+ end
1109
+ end
1110
+ end