activerecord8-redshift-adapter 1.0.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.
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Redshift
6
+ class SchemaCreation < ::ActiveRecord::ConnectionAdapters::SchemaCreation
7
+ private
8
+
9
+ def visit_ColumnDefinition(o)
10
+ o.sql_type = type_to_sql(o.type, limit: o.limit, precision: o.precision, scale: o.scale)
11
+ super
12
+ end
13
+
14
+ def add_column_options!(sql, options)
15
+ column = options.fetch(:column) { return super }
16
+ if column.type == :uuid && options[:default] =~ /\(\)/
17
+ sql << " DEFAULT #{options[:default]}"
18
+ else
19
+ super
20
+ end
21
+ end
22
+ end
23
+
24
+ module SchemaStatements
25
+ # Drops the database specified on the +name+ attribute
26
+ # and creates it again using the provided +options+.
27
+ def recreate_database(name, **options) # :nodoc:
28
+ drop_database(name)
29
+ create_database(name, options)
30
+ end
31
+
32
+ # Create a new Redshift database. Options include <tt>:owner</tt>, <tt>:template</tt>,
33
+ # <tt>:encoding</tt> (defaults to utf8), <tt>:collation</tt>, <tt>:ctype</tt>,
34
+ # <tt>:tablespace</tt>, and <tt>:connection_limit</tt> (note that MySQL uses
35
+ # <tt>:charset</tt> while Redshift uses <tt>:encoding</tt>).
36
+ #
37
+ # Example:
38
+ # create_database config[:database], config
39
+ # create_database 'foo_development', encoding: 'unicode'
40
+ def create_database(name, **options)
41
+ options = { encoding: 'utf8' }.merge!(options.symbolize_keys)
42
+
43
+ option_string = options.inject('') do |memo, (key, value)|
44
+ next memo unless key == :owner
45
+
46
+ memo + " OWNER = \"#{value}\""
47
+ end
48
+
49
+ execute "CREATE DATABASE #{quote_table_name(name)}#{option_string}"
50
+ end
51
+
52
+ # Drops a Redshift database.
53
+ #
54
+ # Example:
55
+ # drop_database 'matt_development'
56
+ def drop_database(name) # :nodoc:
57
+ execute "DROP DATABASE #{quote_table_name(name)}"
58
+ end
59
+
60
+ # Returns the list of all tables in the schema search path or a specified schema.
61
+ def tables(name = nil)
62
+ if name
63
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
64
+ Passing arguments to #tables is deprecated without replacement.
65
+ MSG
66
+ end
67
+
68
+ select_values('SELECT tablename FROM pg_tables WHERE schemaname = ANY(current_schemas(false))', 'SCHEMA')
69
+ end
70
+
71
+ # :nodoc
72
+ def data_sources
73
+ select_values(<<-SQL, 'SCHEMA')
74
+ SELECT c.relname
75
+ FROM pg_class c
76
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
77
+ WHERE c.relkind IN ('r', 'v','m') -- (r)elation/table, (v)iew, (m)aterialized view
78
+ AND n.nspname = ANY (current_schemas(false))
79
+ SQL
80
+ end
81
+
82
+ # Returns true if table exists.
83
+ # If the schema is not specified as part of +name+ then it will only find tables within
84
+ # the current schema search path (regardless of permissions to access tables in other schemas)
85
+ def table_exists?(name)
86
+ ActiveSupport::Deprecation.warn(<<-MSG.squish)
87
+ #table_exists? currently checks both tables and views.
88
+ This behavior is deprecated and will be changed with Rails 5.1 to only check tables.
89
+ Use #data_source_exists? instead.
90
+ MSG
91
+
92
+ data_source_exists?(name)
93
+ end
94
+
95
+ def data_source_exists?(name)
96
+ name = Utils.extract_schema_qualified_name(name.to_s)
97
+ return false unless name.identifier
98
+
99
+ select_value(<<-SQL, 'SCHEMA').to_i > 0
100
+ SELECT COUNT(*)
101
+ FROM pg_class c
102
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
103
+ WHERE c.relkind IN ('r','v','m') -- (r)elation/table, (v)iew, (m)aterialized view
104
+ AND c.relname = '#{name.identifier}'
105
+ AND n.nspname = #{name.schema ? "'#{name.schema}'" : 'ANY (current_schemas(false))'}
106
+ SQL
107
+ end
108
+
109
+ def views # :nodoc:
110
+ select_values(<<-SQL, 'SCHEMA')
111
+ SELECT c.relname
112
+ FROM pg_class c
113
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
114
+ WHERE c.relkind IN ('v','m') -- (v)iew, (m)aterialized view
115
+ AND n.nspname = ANY (current_schemas(false))
116
+ SQL
117
+ end
118
+
119
+ def view_exists?(view_name) # :nodoc:
120
+ name = Utils.extract_schema_qualified_name(view_name.to_s)
121
+ return false unless name.identifier
122
+
123
+ select_values(<<-SQL, 'SCHEMA').any?
124
+ SELECT c.relname
125
+ FROM pg_class c
126
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
127
+ WHERE c.relkind IN ('v','m') -- (v)iew, (m)aterialized view
128
+ AND c.relname = '#{name.identifier}'
129
+ AND n.nspname = #{name.schema ? "'#{name.schema}'" : 'ANY (current_schemas(false))'}
130
+ SQL
131
+ end
132
+
133
+ def drop_table(table_name, **options)
134
+ execute "DROP TABLE #{quote_table_name(table_name)}#{' CASCADE' if options[:force] == :cascade}"
135
+ end
136
+
137
+ # Returns true if schema exists.
138
+ def schema_exists?(name)
139
+ select_value("SELECT COUNT(*) FROM pg_namespace WHERE nspname = '#{name}'", 'SCHEMA').to_i > 0
140
+ end
141
+
142
+ def index_name_exists?(_table_name, _index_name, _default)
143
+ false
144
+ end
145
+
146
+ # Returns an array of indexes for the given table.
147
+ def indexes(_table_name, _name = nil)
148
+ []
149
+ end
150
+
151
+ # Returns the list of all column definitions for a table.
152
+ def columns(table_name)
153
+ column_definitions(table_name.to_s).map do |column_name, type, default, notnull, oid, fmod|
154
+ oid = oid.to_i
155
+ fmod = fmod.to_i
156
+ default_value = extract_value_from_default(default)
157
+ type_metadata = fetch_type_metadata(column_name, type, oid, fmod)
158
+ default_function = extract_default_function(default_value, default)
159
+ new_column(
160
+ column_name,
161
+ get_oid_type(oid, fmod, column_name, type),
162
+ default_value,
163
+ type_metadata,
164
+ notnull == 'f',
165
+ table_name,
166
+ default_function
167
+ )
168
+ end
169
+ end
170
+
171
+ def new_column(name, cast_type, default, sql_type_metadata = nil, null = true, _table_name = nil, default_function = nil) # :nodoc:
172
+ RedshiftColumn.new(name, cast_type, default, sql_type_metadata, null, default_function)
173
+ end
174
+
175
+ # Returns the current database name.
176
+ def current_database
177
+ select_value('select current_database()', 'SCHEMA')
178
+ end
179
+
180
+ # Returns the current schema name.
181
+ def current_schema
182
+ select_value('SELECT current_schema', 'SCHEMA')
183
+ end
184
+
185
+ # Returns the current database encoding format.
186
+ def encoding
187
+ select_value(
188
+ "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname LIKE '#{current_database}'", 'SCHEMA'
189
+ )
190
+ end
191
+
192
+ def collation; end
193
+
194
+ def ctype; end
195
+
196
+ # Returns an array of schema names.
197
+ def schema_names
198
+ select_value(<<-SQL, 'SCHEMA')
199
+ SELECT nspname
200
+ FROM pg_namespace
201
+ WHERE nspname !~ '^pg_.*'
202
+ AND nspname NOT IN ('information_schema')
203
+ ORDER by nspname;
204
+ SQL
205
+ end
206
+
207
+ # Creates a schema for the given schema name.
208
+ def create_schema(schema_name)
209
+ execute "CREATE SCHEMA #{quote_schema_name(schema_name)}"
210
+ end
211
+
212
+ # Drops the schema for the given schema name.
213
+ def drop_schema(schema_name, **options)
214
+ execute "DROP SCHEMA#{' IF EXISTS' if options[:if_exists]} #{quote_schema_name(schema_name)} CASCADE"
215
+ end
216
+
217
+ # Sets the schema search path to a string of comma-separated schema names.
218
+ # Names beginning with $ have to be quoted (e.g. $user => '$user').
219
+ # See: http://www.postgresql.org/docs/current/static/ddl-schemas.html
220
+ #
221
+ # This should be not be called manually but set in database.yml.
222
+ def schema_search_path=(schema_csv)
223
+ return unless schema_csv
224
+
225
+ execute("SET search_path TO #{schema_csv}", 'SCHEMA')
226
+ @schema_search_path = schema_csv
227
+ end
228
+
229
+ # Returns the active schema search path.
230
+ def schema_search_path
231
+ @schema_search_path ||= select_value('SHOW search_path', 'SCHEMA')
232
+ end
233
+
234
+ # Returns the sequence name for a table's primary key or some other specified key.
235
+ def default_sequence_name(table_name, pk = nil) # :nodoc:
236
+ result = serial_sequence(table_name, pk || 'id')
237
+ return nil unless result
238
+
239
+ Utils.extract_schema_qualified_name(result).to_s
240
+ rescue ActiveRecord::StatementInvalid
241
+ Redshift::Name.new(nil, "#{table_name}_#{pk || 'id'}_seq").to_s
242
+ end
243
+
244
+ def serial_sequence(table, column)
245
+ select_value("SELECT pg_get_serial_sequence('#{table}', '#{column}')", 'SCHEMA')
246
+ end
247
+
248
+ def set_pk_sequence!(table, value); end
249
+
250
+ def reset_pk_sequence!(table, pk = nil, sequence = nil); end
251
+
252
+ def pk_and_sequence_for(_table) # :nodoc:
253
+ [nil, nil]
254
+ end
255
+
256
+ # Returns just a table's primary key
257
+ def primary_keys(table)
258
+ pks = query(<<-END_SQL, 'SCHEMA')
259
+ SELECT DISTINCT attr.attname
260
+ FROM pg_attribute attr
261
+ INNER JOIN pg_depend dep ON attr.attrelid = dep.refobjid AND attr.attnum = dep.refobjsubid
262
+ INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey)
263
+ WHERE cons.contype = 'p'
264
+ AND dep.refobjid = '#{quote_table_name(table)}'::regclass
265
+ END_SQL
266
+ pks.present? ? pks[0] : pks
267
+ end
268
+
269
+ # Renames a table.
270
+ # Also renames a table's primary key sequence if the sequence name exists and
271
+ # matches the Active Record default.
272
+ #
273
+ # Example:
274
+ # rename_table('octopuses', 'octopi')
275
+ def rename_table(table_name, new_name)
276
+ clear_cache!
277
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_table_name(new_name)}"
278
+ end
279
+
280
+ def add_column(table_name, column_name, type, **options) # :nodoc:
281
+ clear_cache!
282
+ super
283
+ end
284
+
285
+ # Changes the column of a table.
286
+ def change_column(table_name, column_name, type, **options)
287
+ clear_cache!
288
+ quoted_table_name = quote_table_name(table_name)
289
+ sql_type = type_to_sql(type, limit: options[:limit], precision: options[:precision], scale: options[:scale])
290
+ sql = "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quote_column_name(column_name)} TYPE #{sql_type}"
291
+ sql << " USING #{options[:using]}" if options[:using]
292
+ if options[:cast_as]
293
+ sql << " USING CAST(#{quote_column_name(column_name)} AS #{type_to_sql(options[:cast_as],
294
+ limit: options[:limit], precision: options[:precision], scale: options[:scale])})"
295
+ end
296
+ execute sql
297
+
298
+ change_column_default(table_name, column_name, options[:default]) if options_include_default?(options)
299
+ change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null)
300
+ end
301
+
302
+ # Changes the default value of a table column.
303
+ def change_column_default(table_name, column_name, default_or_changes)
304
+ clear_cache!
305
+ column = column_for(table_name, column_name)
306
+ return unless column
307
+
308
+ default = extract_new_default_value(default_or_changes)
309
+ alter_column_query = "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} %s"
310
+ if default.nil?
311
+ # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, PostgreSQL will
312
+ # cast the default to the columns type, which leaves us with a default like "default NULL::character varying".
313
+ execute alter_column_query % 'DROP DEFAULT'
314
+ else
315
+ execute alter_column_query % "SET DEFAULT #{quote_default_value(default, column)}"
316
+ end
317
+ end
318
+
319
+ def change_column_null(table_name, column_name, null, default = nil)
320
+ clear_cache!
321
+ unless null || default.nil?
322
+ column = column_for(table_name, column_name)
323
+ if column
324
+ execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_value(
325
+ default, column
326
+ )} WHERE #{quote_column_name(column_name)} IS NULL")
327
+ end
328
+ end
329
+ execute("ALTER TABLE #{quote_table_name(table_name)} ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL")
330
+ end
331
+
332
+ # Renames a column in a table.
333
+ def rename_column(table_name, column_name, new_column_name) # :nodoc:
334
+ clear_cache!
335
+ execute "ALTER TABLE #{quote_table_name(table_name)} RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}"
336
+ end
337
+
338
+ def add_index(table_name, column_name, **options); end
339
+
340
+ def remove_index!(table_name, index_name); end
341
+
342
+ def rename_index(table_name, old_name, new_name); end
343
+
344
+ def foreign_keys(table_name)
345
+ fk_info = select_all(<<-SQL.strip_heredoc, 'SCHEMA')
346
+ SELECT t2.relname 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
347
+ FROM pg_constraint c
348
+ JOIN pg_class t1 ON c.conrelid = t1.oid
349
+ JOIN pg_class t2 ON c.confrelid = t2.oid
350
+ JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid
351
+ JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid
352
+ JOIN pg_namespace t3 ON c.connamespace = t3.oid
353
+ WHERE c.contype = 'f'
354
+ AND t1.relname = #{quote(table_name)}
355
+ AND t3.nspname = ANY (current_schemas(false))
356
+ ORDER BY c.conname
357
+ SQL
358
+
359
+ fk_info.map do |row|
360
+ options = {
361
+ column: row['column'],
362
+ name: row['name'],
363
+ primary_key: row['primary_key']
364
+ }
365
+
366
+ options[:on_delete] = extract_foreign_key_action(row['on_delete'])
367
+ options[:on_update] = extract_foreign_key_action(row['on_update'])
368
+
369
+ ForeignKeyDefinition.new(table_name, row['to_table'], options)
370
+ end
371
+ end
372
+
373
+ FOREIGN_KEY_ACTIONS = {
374
+ 'c' => :cascade,
375
+ 'n' => :nullify,
376
+ 'r' => :restrict
377
+ }.freeze
378
+
379
+ def extract_foreign_key_action(specifier)
380
+ FOREIGN_KEY_ACTIONS[specifier]
381
+ end
382
+
383
+ def index_name_length
384
+ 63
385
+ end
386
+
387
+ # Maps logical Rails types to PostgreSQL-specific data types.
388
+ def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
389
+ case type.to_s
390
+ when 'integer'
391
+ return 'integer' unless limit
392
+
393
+ case limit
394
+ when 1, 2 then 'smallint'
395
+ when nil, 3, 4 then 'integer'
396
+ when 5..8 then 'bigint'
397
+ else raise(ActiveRecordError,
398
+ "No integer type has byte size #{limit}. Use a numeric with precision 0 instead.")
399
+ end
400
+ else
401
+ super
402
+ end
403
+ end
404
+
405
+ # PostgreSQL requires the ORDER BY columns in the select list for distinct queries, and
406
+ # requires that the ORDER BY include the distinct column.
407
+ def columns_for_distinct(columns, orders) # :nodoc:
408
+ order_columns = orders.reject(&:blank?).map do |s|
409
+ # Convert Arel node to string
410
+ s = s.to_sql unless s.is_a?(String)
411
+ # Remove any ASC/DESC modifiers
412
+ s.gsub(/\s+(?:ASC|DESC)\b/i, '')
413
+ .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, '')
414
+ end
415
+
416
+ order_columns = order_columns
417
+ .reject(&:blank?)
418
+ .map.with_index { |column, i| "#{column} AS alias_#{i}" }
419
+
420
+ [super, *order_columns].join(', ')
421
+ end
422
+
423
+ def fetch_type_metadata(column_name, sql_type, oid, fmod)
424
+ cast_type = get_oid_type(oid.to_i, fmod.to_i, column_name, sql_type)
425
+ simple_type = SqlTypeMetadata.new(
426
+ sql_type: sql_type,
427
+ type: cast_type.type,
428
+ limit: cast_type.limit,
429
+ precision: cast_type.precision,
430
+ scale: cast_type.scale
431
+ )
432
+ TypeMetadata.new(simple_type, oid: oid, fmod: fmod)
433
+ end
434
+ end
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Redshift
6
+ class TypeMetadata < DelegateClass(SqlTypeMetadata)
7
+ attr_reader :oid, :fmod, :array
8
+
9
+ def initialize(type_metadata, oid: nil, fmod: nil)
10
+ super(type_metadata)
11
+ @type_metadata = type_metadata
12
+ @oid = oid
13
+ @fmod = fmod
14
+ @array = /\[\]$/ === type_metadata.sql_type
15
+ end
16
+
17
+ def sql_type
18
+ super.gsub(/\[\]$/, '')
19
+ end
20
+
21
+ def ==(other)
22
+ other.is_a?(Redshift::TypeMetadata) &&
23
+ attributes_for_hash == other.attributes_for_hash
24
+ end
25
+ alias eql? ==
26
+
27
+ def hash
28
+ attributes_for_hash.hash
29
+ end
30
+
31
+ protected
32
+
33
+ def attributes_for_hash
34
+ [self.class, @type_metadata, oid, fmod]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Redshift
6
+ # Value Object to hold a schema qualified name.
7
+ # This is usually the name of a PostgreSQL relation but it can also represent
8
+ # schema qualified type names. +schema+ and +identifier+ are unquoted to prevent
9
+ # double quoting.
10
+ class Name # :nodoc:
11
+ SEPARATOR = '.'
12
+ attr_reader :schema, :identifier
13
+
14
+ def initialize(schema, identifier)
15
+ @schema = unquote(schema)
16
+ @identifier = unquote(identifier)
17
+ end
18
+
19
+ def to_s
20
+ parts.join SEPARATOR
21
+ end
22
+
23
+ def quoted
24
+ if schema
25
+ PG::Connection.quote_ident(schema) << SEPARATOR << PG::Connection.quote_ident(identifier)
26
+ else
27
+ PG::Connection.quote_ident(identifier)
28
+ end
29
+ end
30
+
31
+ def ==(other)
32
+ other.class == self.class && other.parts == parts
33
+ end
34
+ alias eql? ==
35
+
36
+ def hash
37
+ parts.hash
38
+ end
39
+
40
+ protected
41
+
42
+ def unquote(part)
43
+ if part&.start_with?('"')
44
+ part[1..-2]
45
+ else
46
+ part
47
+ end
48
+ end
49
+
50
+ def parts
51
+ @parts ||= [@schema, @identifier].compact
52
+ end
53
+ end
54
+
55
+ module Utils # :nodoc:
56
+ module_function
57
+
58
+ # Returns an instance of <tt>ActiveRecord::ConnectionAdapters::PostgreSQL::Name</tt>
59
+ # extracted from +string+.
60
+ # +schema+ is nil if not specified in +string+.
61
+ # +schema+ and +identifier+ exclude surrounding quotes (regardless of whether provided in +string+)
62
+ # +string+ supports the range of schema/table references understood by PostgreSQL, for example:
63
+ #
64
+ # * <tt>table_name</tt>
65
+ # * <tt>"table.name"</tt>
66
+ # * <tt>schema_name.table_name</tt>
67
+ # * <tt>schema_name."table.name"</tt>
68
+ # * <tt>"schema_name".table_name</tt>
69
+ # * <tt>"schema.name"."table name"</tt>
70
+ def extract_schema_qualified_name(string)
71
+ schema, table = string.scan(/[^".\s]+|"[^"]*"/)
72
+ if table.nil?
73
+ table = schema
74
+ schema = nil
75
+ end
76
+ Redshift::Name.new(schema, table)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end