activerecord-materialize-adapter 0.2.0

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 (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