activerecord-materialize-adapter 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/lib/active_record/connection_adapters/materialize/column.rb +30 -0
  4. data/lib/active_record/connection_adapters/materialize/database_statements.rb +199 -0
  5. data/lib/active_record/connection_adapters/materialize/explain_pretty_printer.rb +44 -0
  6. data/lib/active_record/connection_adapters/materialize/oid/array.rb +91 -0
  7. data/lib/active_record/connection_adapters/materialize/oid/bit.rb +53 -0
  8. data/lib/active_record/connection_adapters/materialize/oid/bit_varying.rb +15 -0
  9. data/lib/active_record/connection_adapters/materialize/oid/bytea.rb +17 -0
  10. data/lib/active_record/connection_adapters/materialize/oid/cidr.rb +50 -0
  11. data/lib/active_record/connection_adapters/materialize/oid/date.rb +23 -0
  12. data/lib/active_record/connection_adapters/materialize/oid/date_time.rb +23 -0
  13. data/lib/active_record/connection_adapters/materialize/oid/decimal.rb +15 -0
  14. data/lib/active_record/connection_adapters/materialize/oid/enum.rb +20 -0
  15. data/lib/active_record/connection_adapters/materialize/oid/hstore.rb +70 -0
  16. data/lib/active_record/connection_adapters/materialize/oid/inet.rb +15 -0
  17. data/lib/active_record/connection_adapters/materialize/oid/jsonb.rb +15 -0
  18. data/lib/active_record/connection_adapters/materialize/oid/legacy_point.rb +44 -0
  19. data/lib/active_record/connection_adapters/materialize/oid/money.rb +41 -0
  20. data/lib/active_record/connection_adapters/materialize/oid/oid.rb +15 -0
  21. data/lib/active_record/connection_adapters/materialize/oid/point.rb +64 -0
  22. data/lib/active_record/connection_adapters/materialize/oid/range.rb +96 -0
  23. data/lib/active_record/connection_adapters/materialize/oid/specialized_string.rb +18 -0
  24. data/lib/active_record/connection_adapters/materialize/oid/type_map_initializer.rb +112 -0
  25. data/lib/active_record/connection_adapters/materialize/oid/uuid.rb +25 -0
  26. data/lib/active_record/connection_adapters/materialize/oid/vector.rb +28 -0
  27. data/lib/active_record/connection_adapters/materialize/oid/xml.rb +30 -0
  28. data/lib/active_record/connection_adapters/materialize/oid.rb +35 -0
  29. data/lib/active_record/connection_adapters/materialize/quoting.rb +205 -0
  30. data/lib/active_record/connection_adapters/materialize/referential_integrity.rb +43 -0
  31. data/lib/active_record/connection_adapters/materialize/schema_creation.rb +76 -0
  32. data/lib/active_record/connection_adapters/materialize/schema_definitions.rb +222 -0
  33. data/lib/active_record/connection_adapters/materialize/schema_dumper.rb +49 -0
  34. data/lib/active_record/connection_adapters/materialize/schema_statements.rb +742 -0
  35. data/lib/active_record/connection_adapters/materialize/type_metadata.rb +36 -0
  36. data/lib/active_record/connection_adapters/materialize/utils.rb +80 -0
  37. data/lib/active_record/connection_adapters/materialize/version.rb +9 -0
  38. data/lib/active_record/connection_adapters/materialize_adapter.rb +952 -0
  39. data/lib/active_record/tasks/materialize_database_tasks.rb +130 -0
  40. data/lib/activerecord-materialize-adapter.rb +3 -0
  41. data/lib/materialize/errors/database_error.rb +10 -0
  42. data/lib/materialize/errors/incomplete_input.rb +10 -0
  43. metadata +170 -0
@@ -0,0 +1,742 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Materialize
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 Materialize 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 Materialize 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 = {}.merge(options.symbolize_keys)
24
+ if_exists = "IF NOT EXISTS " unless [false, 'false', '0', 0].include? options[:if_exists]
25
+
26
+ execute "CREATE DATABASE #{if_exists}#{quote_table_name(name)}"
27
+ end
28
+
29
+ # Drops a Materialize database.
30
+ #
31
+ # Example:
32
+ # drop_database 'matt_development'
33
+ def drop_database(name) #:nodoc:
34
+ execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
35
+ end
36
+
37
+ def drop_table(table_name, options = {}) # :nodoc:
38
+ execute "DROP TABLE#{' IF EXISTS' if options[:if_exists]} #{quote_table_name(table_name)}#{' CASCADE' if options[:force] == :cascade}"
39
+ end
40
+
41
+ # Returns true if schema exists.
42
+ def schema_exists?(name)
43
+ query_value("SELECT COUNT(*) FROM pg_namespace WHERE nspname = #{quote(name)}", "SCHEMA").to_i > 0
44
+ end
45
+
46
+ # Verifies existence of an index with a given name.
47
+ def index_name_exists?(table_name, index_name)
48
+ table = quoted_scope(table_name)
49
+ index = quoted_scope(index_name)
50
+
51
+ query_value(<<~SQL, "SCHEMA").to_i > 0
52
+ SELECT COUNT(*)
53
+ FROM pg_class t
54
+ INNER JOIN pg_index d ON t.oid = d.indrelid
55
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
56
+ LEFT JOIN pg_namespace n ON n.oid = i.relnamespace
57
+ WHERE i.relkind IN ('i', 'I')
58
+ AND i.relname = #{index[:name]}
59
+ AND t.relname = #{table[:name]}
60
+ AND n.nspname = #{index[:schema]}
61
+ SQL
62
+ end
63
+
64
+ # Returns an array of indexes for the given table.
65
+ def indexes(table_name) # :nodoc:
66
+ scope = quoted_scope(table_name)
67
+
68
+ result = query(<<~SQL, "SCHEMA")
69
+ SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid,
70
+ pg_catalog.obj_description(i.oid, 'pg_class') AS comment
71
+ FROM pg_class t
72
+ INNER JOIN pg_index d ON t.oid = d.indrelid
73
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
74
+ LEFT JOIN pg_namespace n ON n.oid = i.relnamespace
75
+ WHERE i.relkind IN ('i', 'I')
76
+ AND d.indisprimary = 'f'
77
+ AND t.relname = #{scope[:name]}
78
+ AND n.nspname = #{scope[:schema]}
79
+ ORDER BY i.relname
80
+ SQL
81
+
82
+ result.map do |row|
83
+ index_name = row[0]
84
+ unique = row[1]
85
+ indkey = row[2].split(" ").map(&:to_i)
86
+ inddef = row[3]
87
+ oid = row[4]
88
+ comment = row[5]
89
+
90
+ using, expressions, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: WHERE (.+))?\z/m).flatten
91
+
92
+ orders = {}
93
+ opclasses = {}
94
+
95
+ if indkey.include?(0)
96
+ columns = expressions
97
+ else
98
+ columns = Hash[query(<<~SQL, "SCHEMA")].values_at(*indkey).compact
99
+ SELECT a.attnum, a.attname
100
+ FROM pg_attribute a
101
+ WHERE a.attrelid = #{oid}
102
+ AND a.attnum IN (#{indkey.join(",")})
103
+ SQL
104
+
105
+ # add info on sort order (only desc order is explicitly specified, asc is the default)
106
+ # and non-default opclasses
107
+ expressions.scan(/(?<column>\w+)"?\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls|
108
+ opclasses[column] = opclass.to_sym if opclass
109
+ if nulls
110
+ orders[column] = [desc, nulls].compact.join(" ")
111
+ else
112
+ orders[column] = :desc if desc
113
+ end
114
+ end
115
+ end
116
+
117
+ IndexDefinition.new(
118
+ table_name,
119
+ index_name,
120
+ unique,
121
+ columns,
122
+ orders: orders,
123
+ opclasses: opclasses,
124
+ where: where,
125
+ using: using.to_sym,
126
+ comment: comment.presence
127
+ )
128
+ end
129
+ end
130
+
131
+ def table_options(table_name) # :nodoc:
132
+ if comment = table_comment(table_name)
133
+ { comment: comment }
134
+ end
135
+ end
136
+
137
+ # Returns a comment stored in database for given table
138
+ def table_comment(table_name) # :nodoc:
139
+ scope = quoted_scope(table_name, type: "BASE TABLE")
140
+ if scope[:name]
141
+ query_value(<<~SQL, "SCHEMA")
142
+ SELECT pg_catalog.obj_description(c.oid, 'pg_class')
143
+ FROM pg_catalog.pg_class c
144
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
145
+ WHERE c.relname = #{scope[:name]}
146
+ AND c.relkind IN (#{scope[:type]})
147
+ AND n.nspname = #{scope[:schema]}
148
+ SQL
149
+ end
150
+ end
151
+
152
+ # Returns the current database name.
153
+ def current_database
154
+ query_value("SELECT current_database()", "SCHEMA")
155
+ end
156
+
157
+ # Returns the current schema name.
158
+ def current_schema
159
+ query_value("SELECT current_schema", "SCHEMA")
160
+ end
161
+
162
+ # Returns the current database encoding format.
163
+ def encoding
164
+ query_value("SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database()", "SCHEMA")
165
+ end
166
+
167
+ # Returns the current database collation.
168
+ def collation
169
+ query_value("SELECT datcollate FROM pg_database WHERE datname = current_database()", "SCHEMA")
170
+ end
171
+
172
+ # Returns the current database ctype.
173
+ def ctype
174
+ query_value("SELECT datctype FROM pg_database WHERE datname = current_database()", "SCHEMA")
175
+ end
176
+
177
+ # Returns an array of schema names.
178
+ def schema_names
179
+ query_values(<<~SQL, "SCHEMA")
180
+ SELECT nspname
181
+ FROM pg_namespace
182
+ WHERE nspname !~ '^pg_.*'
183
+ AND nspname NOT IN ('information_schema')
184
+ ORDER by nspname;
185
+ SQL
186
+ end
187
+
188
+ # Creates a schema for the given schema name.
189
+ def create_schema(schema_name)
190
+ execute "CREATE SCHEMA #{quote_schema_name(schema_name)}"
191
+ end
192
+
193
+ # Drops the schema for the given schema name.
194
+ def drop_schema(schema_name, options = {})
195
+ execute "DROP SCHEMA#{' IF EXISTS' if options[:if_exists]} #{quote_schema_name(schema_name)} CASCADE"
196
+ end
197
+
198
+ # Sets the schema search path to a string of comma-separated schema names.
199
+ # Names beginning with $ have to be quoted (e.g. $user => '$user').
200
+ # See: https://www.postgresql.org/docs/current/static/ddl-schemas.html
201
+ #
202
+ # This should be not be called manually but set in database.yml.
203
+ def schema_search_path=(schema_csv)
204
+ if schema_csv
205
+ execute("SET search_path TO #{schema_csv}", "SCHEMA")
206
+ @schema_search_path = schema_csv
207
+ end
208
+ end
209
+
210
+ # Returns the active schema search path.
211
+ def schema_search_path
212
+ @schema_search_path ||= query_value("SHOW search_path", "SCHEMA")
213
+ end
214
+
215
+ # Returns the current client message level.
216
+ def client_min_messages
217
+ query_value("SHOW client_min_messages", "SCHEMA")
218
+ end
219
+
220
+ # Set the client message level.
221
+ def client_min_messages=(level)
222
+ execute("SET client_min_messages TO '#{level}'", "SCHEMA")
223
+ end
224
+
225
+ # Returns the sequence name for a table's primary key or some other specified key.
226
+ def default_sequence_name(table_name, pk = "id") #:nodoc:
227
+ result = serial_sequence(table_name, pk)
228
+ return nil unless result
229
+ Utils.extract_schema_qualified_name(result).to_s
230
+ rescue ActiveRecord::StatementInvalid
231
+ Materialize::Name.new(nil, "#{table_name}_#{pk}_seq").to_s
232
+ end
233
+
234
+ def serial_sequence(table, column)
235
+ query_value("SELECT pg_get_serial_sequence(#{quote(table)}, #{quote(column)})", "SCHEMA")
236
+ end
237
+
238
+ # Sets the sequence of a table's primary key to the specified value.
239
+ def set_pk_sequence!(table, value) #:nodoc:
240
+ pk, sequence = pk_and_sequence_for(table)
241
+
242
+ if pk
243
+ if sequence
244
+ quoted_sequence = quote_table_name(sequence)
245
+
246
+ query_value("SELECT setval(#{quote(quoted_sequence)}, #{value})", "SCHEMA")
247
+ else
248
+ @logger.warn "#{table} has primary key #{pk} with no default sequence." if @logger
249
+ end
250
+ end
251
+ end
252
+
253
+ # Resets the sequence of a table's primary key to the maximum value.
254
+ def reset_pk_sequence!(table, pk = nil, sequence = nil) #:nodoc:
255
+ unless pk && sequence
256
+ default_pk, default_sequence = pk_and_sequence_for(table)
257
+
258
+ pk ||= default_pk
259
+ sequence ||= default_sequence
260
+ end
261
+
262
+ if @logger && pk && !sequence
263
+ @logger.warn "#{table} has primary key #{pk} with no default sequence."
264
+ end
265
+
266
+ if pk && sequence
267
+ quoted_sequence = quote_table_name(sequence)
268
+ max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA")
269
+ if max_pk.nil?
270
+ if database_version >= 100000
271
+ minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA")
272
+ else
273
+ minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA")
274
+ end
275
+ end
276
+
277
+ query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA")
278
+ end
279
+ end
280
+
281
+ # Returns a table's primary key and belonging sequence.
282
+ def pk_and_sequence_for(table) #:nodoc:
283
+ # First try looking for a sequence with a dependency on the
284
+ # given table's primary key.
285
+ result = query(<<~SQL, "SCHEMA")[0]
286
+ SELECT attr.attname, nsp.nspname, seq.relname
287
+ FROM pg_class seq,
288
+ pg_attribute attr,
289
+ pg_depend dep,
290
+ pg_constraint cons,
291
+ pg_namespace nsp
292
+ WHERE seq.oid = dep.objid
293
+ AND seq.relkind = 'S'
294
+ AND attr.attrelid = dep.refobjid
295
+ AND attr.attnum = dep.refobjsubid
296
+ AND attr.attrelid = cons.conrelid
297
+ AND attr.attnum = cons.conkey[1]
298
+ AND seq.relnamespace = nsp.oid
299
+ AND cons.contype = 'p'
300
+ AND dep.classid = 'pg_class'::regclass
301
+ AND dep.refobjid = #{quote(quote_table_name(table))}::regclass
302
+ SQL
303
+
304
+ if result.nil? || result.empty?
305
+ result = query(<<~SQL, "SCHEMA")[0]
306
+ SELECT attr.attname, nsp.nspname,
307
+ CASE
308
+ WHEN pg_get_expr(def.adbin, def.adrelid) !~* 'nextval' THEN NULL
309
+ WHEN split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2) ~ '.' THEN
310
+ substr(split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2),
311
+ strpos(split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2), '.')+1)
312
+ ELSE split_part(pg_get_expr(def.adbin, def.adrelid), '''', 2)
313
+ END
314
+ FROM pg_class t
315
+ JOIN pg_attribute attr ON (t.oid = attrelid)
316
+ JOIN pg_attrdef def ON (adrelid = attrelid AND adnum = attnum)
317
+ JOIN pg_constraint cons ON (conrelid = adrelid AND adnum = conkey[1])
318
+ JOIN pg_namespace nsp ON (t.relnamespace = nsp.oid)
319
+ WHERE t.oid = #{quote(quote_table_name(table))}::regclass
320
+ AND cons.contype = 'p'
321
+ AND pg_get_expr(def.adbin, def.adrelid) ~* 'nextval|uuid_generate'
322
+ SQL
323
+ end
324
+
325
+ pk = result.shift
326
+ if result.last
327
+ [pk, Materialize::Name.new(*result)]
328
+ else
329
+ [pk, nil]
330
+ end
331
+ rescue
332
+ nil
333
+ end
334
+
335
+ def primary_keys(table_name) # :nodoc:
336
+ query_values(<<~SQL, "SCHEMA")
337
+ SELECT a.attname
338
+ FROM (
339
+ SELECT indrelid, indkey, generate_subscripts(indkey, 1) idx
340
+ FROM pg_index
341
+ WHERE indrelid = #{quote(quote_table_name(table_name))}::regclass
342
+ AND indisprimary
343
+ ) i
344
+ JOIN pg_attribute a
345
+ ON a.attrelid = i.indrelid
346
+ AND a.attnum = i.indkey[i.idx]
347
+ ORDER BY i.idx
348
+ SQL
349
+ end
350
+
351
+ # Renames a table.
352
+ # Also renames a table's primary key sequence if the sequence name exists and
353
+ # matches the Active Record default.
354
+ #
355
+ # Example:
356
+ # rename_table('octopuses', 'octopi')
357
+ def rename_table(table_name, new_name)
358
+ clear_cache!
359
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_table_name(new_name)}"
360
+ pk, seq = pk_and_sequence_for(new_name)
361
+ if pk
362
+ idx = "#{table_name}_pkey"
363
+ new_idx = "#{new_name}_pkey"
364
+ execute "ALTER INDEX #{quote_table_name(idx)} RENAME TO #{quote_table_name(new_idx)}"
365
+ if seq && seq.identifier == "#{table_name}_#{pk}_seq"
366
+ new_seq = "#{new_name}_#{pk}_seq"
367
+ execute "ALTER TABLE #{seq.quoted} RENAME TO #{quote_table_name(new_seq)}"
368
+ end
369
+ end
370
+ rename_table_indexes(table_name, new_name)
371
+ end
372
+
373
+ def add_column(table_name, column_name, type, **options) #:nodoc:
374
+ clear_cache!
375
+ super
376
+ change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment)
377
+ end
378
+
379
+ def change_column(table_name, column_name, type, options = {}) #:nodoc:
380
+ clear_cache!
381
+ sqls, procs = Array(change_column_for_alter(table_name, column_name, type, options)).partition { |v| v.is_a?(String) }
382
+ execute "ALTER TABLE #{quote_table_name(table_name)} #{sqls.join(", ")}"
383
+ procs.each(&:call)
384
+ end
385
+
386
+ # Changes the default value of a table column.
387
+ def change_column_default(table_name, column_name, default_or_changes) # :nodoc:
388
+ execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_default_for_alter(table_name, column_name, default_or_changes)}"
389
+ end
390
+
391
+ def change_column_null(table_name, column_name, null, default = nil) #:nodoc:
392
+ clear_cache!
393
+ unless null || default.nil?
394
+ column = column_for(table_name, column_name)
395
+ 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
396
+ end
397
+ execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_null_for_alter(table_name, column_name, null, default)}"
398
+ end
399
+
400
+ # Adds comment for given table column or drops it if +comment+ is a +nil+
401
+ def change_column_comment(table_name, column_name, comment_or_changes) # :nodoc:
402
+ clear_cache!
403
+ comment = extract_new_comment_value(comment_or_changes)
404
+ execute "COMMENT ON COLUMN #{quote_table_name(table_name)}.#{quote_column_name(column_name)} IS #{quote(comment)}"
405
+ end
406
+
407
+ # Adds comment for given table or drops it if +comment+ is a +nil+
408
+ def change_table_comment(table_name, comment_or_changes) # :nodoc:
409
+ clear_cache!
410
+ comment = extract_new_comment_value(comment_or_changes)
411
+ execute "COMMENT ON TABLE #{quote_table_name(table_name)} IS #{quote(comment)}"
412
+ end
413
+
414
+ # Renames a column in a table.
415
+ def rename_column(table_name, column_name, new_column_name) #:nodoc:
416
+ clear_cache!
417
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}"
418
+ rename_column_indexes(table_name, column_name, new_column_name)
419
+ end
420
+
421
+ def add_index(table_name, column_name, options = {}) #:nodoc:
422
+ index_name, index_type, index_columns_and_opclasses, index_options, index_algorithm, index_using, comment = add_index_options(table_name, column_name, **options)
423
+ execute("CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns_and_opclasses})#{index_options}").tap do
424
+ execute "COMMENT ON INDEX #{quote_column_name(index_name)} IS #{quote(comment)}" if comment
425
+ end
426
+ end
427
+
428
+ def remove_index(table_name, options = {}) #:nodoc:
429
+ table = Utils.extract_schema_qualified_name(table_name.to_s)
430
+
431
+ if options.is_a?(Hash) && options.key?(:name)
432
+ provided_index = Utils.extract_schema_qualified_name(options[:name].to_s)
433
+
434
+ options[:name] = provided_index.identifier
435
+ table = Materialize::Name.new(provided_index.schema, table.identifier) unless table.schema.present?
436
+
437
+ if provided_index.schema.present? && table.schema != provided_index.schema
438
+ raise ArgumentError.new("Index schema '#{provided_index.schema}' does not match table schema '#{table.schema}'")
439
+ end
440
+ end
441
+
442
+ index_to_remove = Materialize::Name.new(table.schema, index_name_for_remove(table.to_s, options))
443
+ algorithm =
444
+ if options.is_a?(Hash) && options.key?(:algorithm)
445
+ index_algorithms.fetch(options[:algorithm]) do
446
+ raise ArgumentError.new("Algorithm must be one of the following: #{index_algorithms.keys.map(&:inspect).join(', ')}")
447
+ end
448
+ end
449
+ execute "DROP INDEX #{algorithm} #{quote_table_name(index_to_remove)}"
450
+ end
451
+
452
+ # Renames an index of a table. Raises error if length of new
453
+ # index name is greater than allowed limit.
454
+ def rename_index(table_name, old_name, new_name)
455
+ validate_index_length!(table_name, new_name)
456
+
457
+ execute "ALTER INDEX #{quote_column_name(old_name)} RENAME TO #{quote_table_name(new_name)}"
458
+ end
459
+
460
+ def foreign_keys(table_name)
461
+ scope = quoted_scope(table_name)
462
+ fk_info = exec_query(<<~SQL, "SCHEMA")
463
+ 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
464
+ FROM pg_constraint c
465
+ JOIN pg_class t1 ON c.conrelid = t1.oid
466
+ JOIN pg_class t2 ON c.confrelid = t2.oid
467
+ JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid
468
+ JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid
469
+ JOIN pg_namespace t3 ON c.connamespace = t3.oid
470
+ WHERE c.contype = 'f'
471
+ AND t1.relname = #{scope[:name]}
472
+ AND t3.nspname = #{scope[:schema]}
473
+ ORDER BY c.conname
474
+ SQL
475
+
476
+ fk_info.map do |row|
477
+ options = {
478
+ column: row["column"],
479
+ name: row["name"],
480
+ primary_key: row["primary_key"]
481
+ }
482
+
483
+ options[:on_delete] = extract_foreign_key_action(row["on_delete"])
484
+ options[:on_update] = extract_foreign_key_action(row["on_update"])
485
+ options[:validate] = row["valid"]
486
+
487
+ ForeignKeyDefinition.new(table_name, row["to_table"], options)
488
+ end
489
+ end
490
+
491
+ def foreign_tables
492
+ query_values(data_source_sql(type: "FOREIGN TABLE"), "SCHEMA")
493
+ end
494
+
495
+ def foreign_table_exists?(table_name)
496
+ query_values(data_source_sql(table_name, type: "FOREIGN TABLE"), "SCHEMA").any? if table_name.present?
497
+ end
498
+
499
+ # Maps logical Rails types to Materialize-specific data types.
500
+ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) # :nodoc:
501
+ sql = \
502
+ case type.to_s
503
+ when "binary"
504
+ # Materialize doesn't support limits on binary (bytea) columns.
505
+ # The hard limit is 1GB, because of a 32-bit size field, and TOAST.
506
+ case limit
507
+ when nil, 0..0x3fffffff; super(type)
508
+ else raise ArgumentError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte."
509
+ end
510
+ when "text"
511
+ # Materialize doesn't support limits on text columns.
512
+ # The hard limit is 1GB, according to section 8.3 in the manual.
513
+ case limit
514
+ when nil, 0..0x3fffffff; super(type)
515
+ else raise ArgumentError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte."
516
+ end
517
+ when "integer"
518
+ case limit
519
+ when 1, 2; "smallint"
520
+ when nil, 3, 4; "integer"
521
+ when 5..8; "bigint"
522
+ else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead."
523
+ end
524
+ else
525
+ super
526
+ end
527
+
528
+ sql = "#{sql}[]" if array && type != :primary_key
529
+ sql
530
+ end
531
+
532
+ # Materialize requires the ORDER BY columns in the select list for distinct queries, and
533
+ # requires that the ORDER BY include the distinct column.
534
+ def columns_for_distinct(columns, orders) #:nodoc:
535
+ order_columns = orders.reject(&:blank?).map { |s|
536
+ # Convert Arel node to string
537
+ s = visitor.compile(s) unless s.is_a?(String)
538
+ # Remove any ASC/DESC modifiers
539
+ s.gsub(/\s+(?:ASC|DESC)\b/i, "")
540
+ .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, "")
541
+ }.reject(&:blank?).map.with_index { |column, i| "#{column} AS alias_#{i}" }
542
+
543
+ (order_columns << super).join(", ")
544
+ end
545
+
546
+ def update_table_definition(table_name, base) # :nodoc:
547
+ Materialize::Table.new(table_name, base)
548
+ end
549
+
550
+ def create_schema_dumper(options) # :nodoc:
551
+ Materialize::SchemaDumper.create(self, options)
552
+ end
553
+
554
+ # Validates the given constraint.
555
+ #
556
+ # Validates the constraint named +constraint_name+ on +accounts+.
557
+ #
558
+ # validate_constraint :accounts, :constraint_name
559
+ def validate_constraint(table_name, constraint_name)
560
+ return unless supports_validate_constraints?
561
+
562
+ at = create_alter_table table_name
563
+ at.validate_constraint constraint_name
564
+
565
+ execute schema_creation.accept(at)
566
+ end
567
+
568
+ # Validates the given foreign key.
569
+ #
570
+ # Validates the foreign key on +accounts.branch_id+.
571
+ #
572
+ # validate_foreign_key :accounts, :branches
573
+ #
574
+ # Validates the foreign key on +accounts.owner_id+.
575
+ #
576
+ # validate_foreign_key :accounts, column: :owner_id
577
+ #
578
+ # Validates the foreign key named +special_fk_name+ on the +accounts+ table.
579
+ #
580
+ # validate_foreign_key :accounts, name: :special_fk_name
581
+ #
582
+ # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key.
583
+ def validate_foreign_key(from_table, to_table = nil, **options)
584
+ return unless supports_validate_constraints?
585
+
586
+ fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name
587
+
588
+ validate_constraint from_table, fk_name_to_validate
589
+ end
590
+
591
+ private
592
+ def schema_creation
593
+ Materialize::SchemaCreation.new(self)
594
+ end
595
+
596
+ def create_table_definition(*args, **options)
597
+ Materialize::TableDefinition.new(self, *args, **options)
598
+ end
599
+
600
+ def create_alter_table(name)
601
+ Materialize::AlterTable.new create_table_definition(name)
602
+ end
603
+
604
+ def new_column_from_field(table_name, field)
605
+ column_name, type, default, notnull, oid, fmod, collation, comment = field
606
+ type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i)
607
+ default_value = extract_value_from_default(default)
608
+ default_function = extract_default_function(default_value, default)
609
+
610
+ if match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/)
611
+ serial = sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name]
612
+ end
613
+
614
+ Materialize::Column.new(
615
+ column_name,
616
+ default_value,
617
+ type_metadata,
618
+ !notnull,
619
+ default_function,
620
+ collation: collation,
621
+ comment: comment.presence,
622
+ serial: serial
623
+ )
624
+ end
625
+
626
+ def fetch_type_metadata(column_name, sql_type, oid, fmod)
627
+ cast_type = get_oid_type(oid, fmod, column_name, sql_type)
628
+ simple_type = SqlTypeMetadata.new(
629
+ sql_type: sql_type,
630
+ type: cast_type.type,
631
+ limit: cast_type.limit,
632
+ precision: cast_type.precision,
633
+ scale: cast_type.scale,
634
+ )
635
+ Materialize::TypeMetadata.new(simple_type, oid: oid, fmod: fmod)
636
+ end
637
+
638
+ def sequence_name_from_parts(table_name, column_name, suffix)
639
+ over_length = [table_name, column_name, suffix].sum(&:length) + 2 - max_identifier_length
640
+
641
+ if over_length > 0
642
+ column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min
643
+ over_length -= column_name.length - column_name_length
644
+ column_name = column_name[0, column_name_length - [over_length, 0].min]
645
+ end
646
+
647
+ if over_length > 0
648
+ table_name = table_name[0, table_name.length - over_length]
649
+ end
650
+
651
+ "#{table_name}_#{column_name}_#{suffix}"
652
+ end
653
+
654
+ def extract_foreign_key_action(specifier)
655
+ case specifier
656
+ when "c"; :cascade
657
+ when "n"; :nullify
658
+ when "r"; :restrict
659
+ end
660
+ end
661
+
662
+ def add_column_for_alter(table_name, column_name, type, **options)
663
+ return super unless options.key?(:comment)
664
+ [super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }]
665
+ end
666
+
667
+ def change_column_for_alter(table_name, column_name, type, options = {})
668
+ td = create_table_definition(table_name)
669
+ cd = td.new_column_definition(column_name, type, **options)
670
+ sqls = [schema_creation.accept(ChangeColumnDefinition.new(cd, column_name))]
671
+ sqls << Proc.new { change_column_comment(table_name, column_name, options[:comment]) } if options.key?(:comment)
672
+ sqls
673
+ end
674
+
675
+ def change_column_default_for_alter(table_name, column_name, default_or_changes)
676
+ column = column_for(table_name, column_name)
677
+ return unless column
678
+
679
+ default = extract_new_default_value(default_or_changes)
680
+ alter_column_query = "ALTER COLUMN #{quote_column_name(column_name)} %s"
681
+ if default.nil?
682
+ # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, Materialize will
683
+ # cast the default to the columns type, which leaves us with a default like "default NULL::character varying".
684
+ alter_column_query % "DROP DEFAULT"
685
+ else
686
+ alter_column_query % "SET DEFAULT #{quote_default_expression(default, column)}"
687
+ end
688
+ end
689
+
690
+ def change_column_null_for_alter(table_name, column_name, null, default = nil)
691
+ "ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL"
692
+ end
693
+
694
+ def add_index_opclass(quoted_columns, **options)
695
+ opclasses = options_for_index_columns(options[:opclass])
696
+ quoted_columns.each do |name, column|
697
+ column << " #{opclasses[name]}" if opclasses[name].present?
698
+ end
699
+ end
700
+
701
+ def add_options_for_index_columns(quoted_columns, **options)
702
+ quoted_columns = add_index_opclass(quoted_columns, **options)
703
+ super
704
+ end
705
+
706
+ def data_source_sql(name = nil, type: nil)
707
+ scope = quoted_scope(name, type: type)
708
+ scope[:type] ||= "'r','v','m','p','f'" # (r)elation/table, (v)iew, (m)aterialized view, (p)artitioned table, (f)oreign table
709
+
710
+ sql = +"SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace"
711
+ sql << " WHERE n.nspname = #{scope[:schema]}"
712
+ sql << " AND c.relname = #{scope[:name]}" if scope[:name]
713
+ sql << " AND c.relkind IN (#{scope[:type]})"
714
+ sql
715
+ end
716
+
717
+ def quoted_scope(name = nil, type: nil)
718
+ schema, name = extract_schema_qualified_name(name)
719
+ type = \
720
+ case type
721
+ when "BASE TABLE"
722
+ "'r','p'"
723
+ when "VIEW"
724
+ "'v','m'"
725
+ when "FOREIGN TABLE"
726
+ "'f'"
727
+ end
728
+ scope = {}
729
+ scope[:schema] = schema ? quote(schema) : "ANY (current_schemas(false))"
730
+ scope[:name] = quote(name) if name
731
+ scope[:type] = type if type
732
+ scope
733
+ end
734
+
735
+ def extract_schema_qualified_name(string)
736
+ name = Utils.extract_schema_qualified_name(string.to_s)
737
+ [name.schema, name.identifier]
738
+ end
739
+ end
740
+ end
741
+ end
742
+ end