activerecord-duckdb 0.1.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.
- checksums.yaml +7 -0
- data/.fasterer.yml +31 -0
- data/.rspec +3 -0
- data/.rubocop.yml +105 -0
- data/.simplecov +18 -0
- data/.tool-versions +1 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +168 -0
- data/Rakefile +19 -0
- data/TODO.md +16 -0
- data/docs/TABLE_DEFINITION.md +322 -0
- data/lib/active_record/connection_adapters/duckdb/column.rb +67 -0
- data/lib/active_record/connection_adapters/duckdb/database_limits.rb +54 -0
- data/lib/active_record/connection_adapters/duckdb/database_statements.rb +303 -0
- data/lib/active_record/connection_adapters/duckdb/database_tasks.rb +22 -0
- data/lib/active_record/connection_adapters/duckdb/quoting.rb +59 -0
- data/lib/active_record/connection_adapters/duckdb/schema_creation.rb +43 -0
- data/lib/active_record/connection_adapters/duckdb/schema_definitions.rb +145 -0
- data/lib/active_record/connection_adapters/duckdb/schema_dumper.rb +165 -0
- data/lib/active_record/connection_adapters/duckdb/schema_statements.rb +599 -0
- data/lib/active_record/connection_adapters/duckdb_adapter.rb +436 -0
- data/lib/active_record/tasks/duckdb_database_tasks.rb +170 -0
- data/lib/activerecord/duckdb/version.rb +11 -0
- data/lib/activerecord-duckdb.rb +60 -0
- data/sig/activerecord/duckdb.rbs +6 -0
- data/tmp/.gitkeep +0 -0
- metadata +107 -0
@@ -0,0 +1,599 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record/connection_adapters/abstract/schema_statements'
|
4
|
+
require 'duckdb'
|
5
|
+
|
6
|
+
module ActiveRecord
|
7
|
+
module ConnectionAdapters
|
8
|
+
module Duckdb
|
9
|
+
# DuckDB-specific schema statement implementations
|
10
|
+
# Provides DuckDB-specific functionality for table, sequence, and index management
|
11
|
+
module SchemaStatements
|
12
|
+
# Returns a DuckDB-specific schema creation instance
|
13
|
+
# @return [ActiveRecord::ConnectionAdapters::Duckdb::SchemaCreation] Schema creation helper
|
14
|
+
def schema_creation
|
15
|
+
SchemaCreation.new(self)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns a list of all tables in the database
|
19
|
+
# @return [Array<String>] Array of table names
|
20
|
+
def tables
|
21
|
+
result = execute(data_source_sql(type: 'BASE TABLE'), 'SCHEMA')
|
22
|
+
result.to_a.map { |row| row[0] }
|
23
|
+
end
|
24
|
+
|
25
|
+
# Creates a DuckDB-specific table definition instance
|
26
|
+
# @param name [String, Symbol] The table name
|
27
|
+
# @return [ActiveRecord::ConnectionAdapters::Duckdb::TableDefinition] Table definition instance
|
28
|
+
def create_table_definition(name, **)
|
29
|
+
TableDefinition.new(self, name, **)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Creates a table with DuckDB-specific handling for sequences and primary keys
|
33
|
+
# @param table_name [String, Symbol] The name of the table to create
|
34
|
+
# @param id [Symbol, Boolean] The primary key type or false for no primary key
|
35
|
+
# @param primary_key [String, Symbol, nil] Custom primary key column name
|
36
|
+
# @param options [Hash] Additional table creation options
|
37
|
+
# @return [void]
|
38
|
+
def create_table(table_name, id: :primary_key, primary_key: nil, **options)
|
39
|
+
# Handle sequence creation for integer primary keys BEFORE table creation
|
40
|
+
sequence_name = nil
|
41
|
+
pk_column_name = nil
|
42
|
+
needs_sequence_default = false
|
43
|
+
if id != false && id != :uuid && id != :string
|
44
|
+
pk_column_name = primary_key || 'id'
|
45
|
+
sequence_name = "#{table_name}_#{pk_column_name}_seq"
|
46
|
+
needs_sequence_default = true
|
47
|
+
# Extract sequence start value from options
|
48
|
+
start_with = options.dig(:sequence, :start_with) || options[:start_with] || 1
|
49
|
+
create_sequence_safely(sequence_name, table_name, start_with: start_with)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Store sequence info for later use during table creation
|
53
|
+
@pending_sequence_default = ({ table: table_name, column: pk_column_name, sequence: sequence_name } if needs_sequence_default && sequence_name && pk_column_name)
|
54
|
+
|
55
|
+
begin
|
56
|
+
# Now create the table with Rails handling the standard creation
|
57
|
+
super do |td|
|
58
|
+
# If block given, let user define columns
|
59
|
+
yield td if block_given?
|
60
|
+
end
|
61
|
+
ensure
|
62
|
+
# Clear the pending sequence default
|
63
|
+
@pending_sequence_default = nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Creates a sequence in DuckDB
|
68
|
+
# @param sequence_name [String] The name of the sequence to create
|
69
|
+
# @param start_with [Integer] The starting value for the sequence (default: 1)
|
70
|
+
# @param increment_by [Integer] The increment value for the sequence (default: 1)
|
71
|
+
# @param options [Hash] Additional sequence options
|
72
|
+
# @return [void]
|
73
|
+
def create_sequence(sequence_name, start_with: 1, increment_by: 1, **options)
|
74
|
+
sql = "CREATE SEQUENCE #{quote_table_name(sequence_name)}"
|
75
|
+
sql << " START #{start_with}" if start_with != 1
|
76
|
+
sql << " INCREMENT #{increment_by}" if increment_by != 1
|
77
|
+
execute(sql, 'Create Sequence')
|
78
|
+
end
|
79
|
+
|
80
|
+
# Drops a sequence from the database
|
81
|
+
# @param sequence_name [String] The name of the sequence to drop
|
82
|
+
# @param if_exists [Boolean] Whether to use IF EXISTS clause (default: false)
|
83
|
+
# @return [void]
|
84
|
+
def drop_sequence(sequence_name, if_exists: false)
|
85
|
+
sql = +'DROP SEQUENCE'
|
86
|
+
sql << ' IF EXISTS' if if_exists
|
87
|
+
sql << " #{quote_table_name(sequence_name)}"
|
88
|
+
execute(sql, 'Drop Sequence')
|
89
|
+
end
|
90
|
+
|
91
|
+
# Checks if a sequence exists in the database
|
92
|
+
# @param sequence_name [String] The name of the sequence to check
|
93
|
+
# @return [Boolean] true if the sequence exists, false otherwise
|
94
|
+
def sequence_exists?(sequence_name)
|
95
|
+
# Try to get next value from sequence in a way that doesn't consume it
|
96
|
+
# Use a transaction that we can rollback to avoid side effects
|
97
|
+
transaction do
|
98
|
+
execute("SELECT nextval(#{quote(sequence_name)})", 'SCHEMA')
|
99
|
+
raise ActiveRecord::Rollback # Rollback to avoid consuming the sequence value
|
100
|
+
end
|
101
|
+
true
|
102
|
+
rescue ActiveRecord::StatementInvalid, DuckDB::Error => e
|
103
|
+
# If the sequence doesn't exist, nextval will fail with a specific error
|
104
|
+
raise unless e.message.include?('does not exist') || e.message.include?('Catalog Error')
|
105
|
+
|
106
|
+
false
|
107
|
+
|
108
|
+
# Re-raise other types of errors
|
109
|
+
rescue StandardError
|
110
|
+
# For any other error, assume sequence doesn't exist
|
111
|
+
false
|
112
|
+
end
|
113
|
+
|
114
|
+
# Returns SQL expression to get the next value from a sequence
|
115
|
+
# @param sequence_name [String] The name of the sequence
|
116
|
+
# @return [String] SQL expression for getting next sequence value
|
117
|
+
def next_sequence_value(sequence_name)
|
118
|
+
"nextval('#{sequence_name}')"
|
119
|
+
end
|
120
|
+
|
121
|
+
# Resets a sequence to a specific value
|
122
|
+
# @param sequence_name [String] The name of the sequence to reset
|
123
|
+
# @param value [Integer] The value to reset the sequence to (default: 1)
|
124
|
+
# @return [void]
|
125
|
+
def reset_sequence!(sequence_name, value = 1)
|
126
|
+
execute("ALTER SEQUENCE #{quote_table_name(sequence_name)} RESTART WITH #{value}", 'Reset Sequence')
|
127
|
+
end
|
128
|
+
|
129
|
+
# Returns a list of all sequences in the database
|
130
|
+
# @return [Array<String>] Array of sequence names (currently returns empty array)
|
131
|
+
def sequences
|
132
|
+
# For now, return empty array since DuckDB sequence introspection is limited
|
133
|
+
[]
|
134
|
+
end
|
135
|
+
|
136
|
+
# Returns indexes for a specific table
|
137
|
+
# @param table_name [String, Symbol] The name of the table
|
138
|
+
# @return [Array<ActiveRecord::ConnectionAdapters::IndexDefinition>] Array of index definitions
|
139
|
+
def indexes(table_name)
|
140
|
+
indexes = []
|
141
|
+
begin
|
142
|
+
result = execute("SELECT * FROM duckdb_indexes() WHERE table_name = #{quote(table_name.to_s)}", 'SCHEMA')
|
143
|
+
# Store result as array immediately to avoid consumption issues
|
144
|
+
result_array = result.to_a
|
145
|
+
result_array.each_with_index do |index_row, _idx|
|
146
|
+
# DuckDB duckdb_indexes() returns array with structure:
|
147
|
+
# [database_name, database_oid, schema_name, schema_oid, index_name, index_oid, table_name, table_oid, nil, {}, is_unique, is_primary, column_names, sql]
|
148
|
+
index_name = index_row[4]
|
149
|
+
is_unique = index_row[10]
|
150
|
+
is_primary = index_row[11]
|
151
|
+
column_names_str = index_row[12]
|
152
|
+
# Skip primary key indexes as they're handled separately
|
153
|
+
next if is_primary
|
154
|
+
|
155
|
+
# Skip if we don't have essential information
|
156
|
+
next unless index_name && column_names_str
|
157
|
+
|
158
|
+
# Parse column names from string format like "[name]" or "['name']"
|
159
|
+
columns = parse_index_columns(column_names_str)
|
160
|
+
next if columns.empty?
|
161
|
+
|
162
|
+
# Clean up column names - remove extra quotes
|
163
|
+
cleaned_columns = columns.map { |col| col.gsub(/^"|"$/, '') }
|
164
|
+
|
165
|
+
# Create IndexDefinition with correct Rails 8.0 signature
|
166
|
+
index_def = ActiveRecord::ConnectionAdapters::IndexDefinition.new(
|
167
|
+
table_name.to_s, # table
|
168
|
+
index_name.to_s, # name
|
169
|
+
!is_unique.nil?, # unique
|
170
|
+
cleaned_columns # columns
|
171
|
+
)
|
172
|
+
indexes << index_def
|
173
|
+
end
|
174
|
+
rescue StandardError => e
|
175
|
+
Rails.logger&.warn("Could not retrieve indexes for table #{table_name}: #{e.message}") if defined?(Rails)
|
176
|
+
end
|
177
|
+
indexes
|
178
|
+
end
|
179
|
+
|
180
|
+
# Generates SQL for querying data sources (tables/views) with optional filtering
|
181
|
+
# @param name [String, nil] Optional table name to filter by
|
182
|
+
# @param type [String, nil] Optional table type filter ('BASE TABLE', 'VIEW', etc.)
|
183
|
+
# @return [String] SQL query string for retrieving table information
|
184
|
+
def data_source_sql(name = nil, type: nil)
|
185
|
+
scope = quoted_scope(name, type: type)
|
186
|
+
|
187
|
+
sql = 'SELECT table_name FROM information_schema.tables'
|
188
|
+
|
189
|
+
conditions = []
|
190
|
+
conditions << "table_schema = #{scope[:schema]}" if scope[:schema]
|
191
|
+
conditions << "table_name = #{scope[:name]}" if scope[:name]
|
192
|
+
conditions << scope[:type] if scope[:type] # This now contains the full condition
|
193
|
+
|
194
|
+
sql += " WHERE #{conditions.join(" AND ")}" if conditions.any?
|
195
|
+
sql += ' ORDER BY table_name'
|
196
|
+
sql
|
197
|
+
end
|
198
|
+
|
199
|
+
# Creates a new Column object from DuckDB field information
|
200
|
+
# @param table_name [String] The name of the table
|
201
|
+
# @param field [Array] Array containing column field information from PRAGMA table_info
|
202
|
+
# @param definitions [Hash] Additional column definitions (unused)
|
203
|
+
# @return [ActiveRecord::ConnectionAdapters::Duckdb::Column] The created column object
|
204
|
+
def new_column_from_field(table_name, field, definitions)
|
205
|
+
column_name, formatted_type, column_default, not_null, _type_id, _type_modifier, collation_name, comment, _identity, _generated, pk = field
|
206
|
+
|
207
|
+
# Ensure we have required values with proper defaults
|
208
|
+
column_name = 'unknown_column' if column_name.nil? || column_name.empty?
|
209
|
+
|
210
|
+
formatted_type = formatted_type.to_s if formatted_type
|
211
|
+
formatted_type = 'VARCHAR' if formatted_type.nil? || formatted_type.empty?
|
212
|
+
|
213
|
+
# Create proper SqlTypeMetadata object
|
214
|
+
sql_type_metadata = fetch_type_metadata(formatted_type)
|
215
|
+
|
216
|
+
# For primary keys with integer types, check if sequence exists and set default_function
|
217
|
+
default_value = nil
|
218
|
+
default_function = nil
|
219
|
+
|
220
|
+
if pk && [true,
|
221
|
+
1].include?(pk) && %w[INTEGER BIGINT].include?(formatted_type.to_s.upcase) && column_name == 'id'
|
222
|
+
# This is an integer primary key named 'id' - assume sequence exists
|
223
|
+
sequence_name = "#{table_name}_#{column_name}_seq"
|
224
|
+
default_function = "nextval('#{sequence_name}')"
|
225
|
+
default_value = nil
|
226
|
+
elsif column_default&.to_s&.include?('nextval(')
|
227
|
+
# This is a sequence - store it as default_function, not default_value
|
228
|
+
default_function = column_default.to_s
|
229
|
+
default_value = nil
|
230
|
+
else
|
231
|
+
default_value = extract_value_from_default(column_default)
|
232
|
+
default_function = nil
|
233
|
+
end
|
234
|
+
|
235
|
+
# Ensure boolean values are properly converted for null constraint
|
236
|
+
# In DuckDB PRAGMA: not_null=1 means NOT NULL, not_null=0 means NULL allowed
|
237
|
+
is_null = case not_null
|
238
|
+
when 1, true
|
239
|
+
false # Column does NOT allow NULL
|
240
|
+
else
|
241
|
+
true # Default to allowing NULL for unknown values
|
242
|
+
end
|
243
|
+
|
244
|
+
# Clean up parameters for Column constructor
|
245
|
+
clean_column_name = column_name.to_s
|
246
|
+
clean_default_value = default_value
|
247
|
+
clean_default_function = default_function&.to_s
|
248
|
+
clean_collation = collation_name&.to_s
|
249
|
+
clean_comment = comment&.to_s
|
250
|
+
|
251
|
+
ActiveRecord::ConnectionAdapters::Duckdb::Column.new(
|
252
|
+
clean_column_name, # name
|
253
|
+
clean_default_value, # default (should be nil for sequences!)
|
254
|
+
sql_type_metadata, # sql_type_metadata
|
255
|
+
is_null, # null (boolean - true if column allows NULL)
|
256
|
+
clean_default_function, # default_function (this is where nextval goes!)
|
257
|
+
collation: clean_collation.presence,
|
258
|
+
comment: clean_comment.presence,
|
259
|
+
auto_increment: pk && %w[INTEGER BIGINT].include?(formatted_type.to_s.upcase),
|
260
|
+
rowid: pk && column_name == 'id'
|
261
|
+
)
|
262
|
+
end
|
263
|
+
|
264
|
+
# Looks up the appropriate cast type for a column based on SQL type metadata
|
265
|
+
# @param sql_type_metadata [ActiveRecord::ConnectionAdapters::SqlTypeMetadata] The SQL type metadata
|
266
|
+
# @return [ActiveRecord::Type::Value] The appropriate cast type
|
267
|
+
def lookup_cast_type_from_column(sql_type_metadata)
|
268
|
+
lookup_cast_type(sql_type_metadata.sql_type)
|
269
|
+
end
|
270
|
+
|
271
|
+
# Creates a quoted scope hash for table/schema queries with type filtering
|
272
|
+
# @param name [String, nil] Optional table name (may include schema prefix)
|
273
|
+
# @param type [String, nil] Optional table type filter
|
274
|
+
# @return [Hash] Hash containing quoted schema, name, and type condition
|
275
|
+
def quoted_scope(name = nil, type: nil)
|
276
|
+
schema, name = extract_schema_qualified_name(name)
|
277
|
+
|
278
|
+
type_condition = case type
|
279
|
+
when 'BASE TABLE'
|
280
|
+
"table_type = 'BASE TABLE'"
|
281
|
+
when 'VIEW'
|
282
|
+
"table_type = 'VIEW'"
|
283
|
+
else
|
284
|
+
"table_type IN ('BASE TABLE', 'VIEW')"
|
285
|
+
end
|
286
|
+
|
287
|
+
{
|
288
|
+
schema: schema ? quote(schema) : nil,
|
289
|
+
name: name ? quote(name) : nil,
|
290
|
+
type: type_condition
|
291
|
+
}
|
292
|
+
end
|
293
|
+
|
294
|
+
# Converts ActiveRecord type to DuckDB SQL type string
|
295
|
+
# @param type [Symbol, String] The ActiveRecord type to convert
|
296
|
+
# @param limit [Integer, nil] Optional column size limit
|
297
|
+
# @param precision [Integer, nil] Optional decimal precision
|
298
|
+
# @param scale [Integer, nil] Optional decimal scale
|
299
|
+
# @param options [Hash] Additional type options
|
300
|
+
# @return [String] The DuckDB SQL type string
|
301
|
+
def type_to_sql(type, limit: nil, precision: nil, scale: nil, **options)
|
302
|
+
case type.to_s
|
303
|
+
when 'primary_key'
|
304
|
+
# Use the configured primary key type
|
305
|
+
primary_key_type_definition
|
306
|
+
when 'string', 'text'
|
307
|
+
if limit
|
308
|
+
"VARCHAR(#{limit})"
|
309
|
+
else
|
310
|
+
'VARCHAR'
|
311
|
+
end
|
312
|
+
when 'integer'
|
313
|
+
integer_to_sql(limit)
|
314
|
+
when 'bigint'
|
315
|
+
'BIGINT'
|
316
|
+
when 'float'
|
317
|
+
'REAL'
|
318
|
+
when 'decimal', 'numeric'
|
319
|
+
if precision && scale
|
320
|
+
"DECIMAL(#{precision},#{scale})"
|
321
|
+
elsif precision
|
322
|
+
"DECIMAL(#{precision})"
|
323
|
+
else
|
324
|
+
'DECIMAL'
|
325
|
+
end
|
326
|
+
when 'datetime', 'timestamp'
|
327
|
+
'TIMESTAMP'
|
328
|
+
when 'time'
|
329
|
+
'TIME'
|
330
|
+
when 'date'
|
331
|
+
'DATE'
|
332
|
+
when 'boolean'
|
333
|
+
'BOOLEAN'
|
334
|
+
when 'binary', 'blob'
|
335
|
+
# TODO: Add blob size limits
|
336
|
+
# Postgres has limits set on blob sized
|
337
|
+
# https://github.com/rails/rails/blob/82e9029bbf63a33b69f007927979c5564a6afe9e/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb#L855
|
338
|
+
# Duckdb has a 4g size limit as well - https://duckdb.org/docs/stable/sql/data_types/blob
|
339
|
+
'BLOB'
|
340
|
+
when 'uuid'
|
341
|
+
'UUID'
|
342
|
+
else
|
343
|
+
super
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# Override execute to intercept CREATE TABLE statements and inject sequence defaults
|
348
|
+
# @param sql [String] The SQL statement to execute
|
349
|
+
# @param name [String, nil] Optional name for logging purposes
|
350
|
+
# @return [DuckDB::Result] The result of the query execution
|
351
|
+
def execute(sql, name = nil)
|
352
|
+
# Check if this is a CREATE TABLE statement and we have a pending sequence default
|
353
|
+
if @pending_sequence_default && sql.match?(/\A\s*CREATE TABLE/i)
|
354
|
+
pending = @pending_sequence_default
|
355
|
+
table_pattern = /CREATE TABLE\s+"?#{Regexp.escape(pending[:table])}"?\s*\(/i
|
356
|
+
|
357
|
+
if sql.match?(table_pattern)
|
358
|
+
# Find the PRIMARY KEY column definition and inject the sequence default
|
359
|
+
# This pattern specifically looks for the primary key column with PRIMARY KEY constraint
|
360
|
+
pk_column_pattern = /"?#{Regexp.escape(pending[:column])}"?\s+\w+\s+PRIMARY\s+KEY(?!\s+DEFAULT)/i
|
361
|
+
|
362
|
+
# Only replace the first occurrence (the actual primary key)
|
363
|
+
sql = sql.sub(pk_column_pattern) do |match|
|
364
|
+
# Inject the sequence default before PRIMARY KEY
|
365
|
+
match.sub(/(\s+)PRIMARY\s+KEY/i, "\\1DEFAULT nextval('#{pending[:sequence]}') PRIMARY KEY")
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
super
|
371
|
+
end
|
372
|
+
|
373
|
+
private
|
374
|
+
|
375
|
+
# Parses index column names from DuckDB's string representation
|
376
|
+
# @param column_names_str [String] The string representation of column names
|
377
|
+
# @return [Array<String>] Array of cleaned column names
|
378
|
+
def parse_index_columns(column_names_str)
|
379
|
+
columns = []
|
380
|
+
if column_names_str.is_a?(String)
|
381
|
+
# Remove outer brackets and split on comma
|
382
|
+
cleaned = column_names_str.gsub(/^\[|\]$/, '')
|
383
|
+
columns = if cleaned.include?(',')
|
384
|
+
# Multiple columns - split and clean each
|
385
|
+
cleaned.split(',').map { |col| col.strip.gsub(/^['"]|['"]$/, '') }
|
386
|
+
else
|
387
|
+
# Single column - just clean it
|
388
|
+
[cleaned.gsub(/^['"]|['"]$/, '')]
|
389
|
+
end
|
390
|
+
end
|
391
|
+
columns
|
392
|
+
end
|
393
|
+
|
394
|
+
# Safely creates a sequence with proper error handling
|
395
|
+
# @param sequence_name [String] The name of the sequence to create
|
396
|
+
# @param table_name [String] The table name for logging purposes
|
397
|
+
# @param start_with [Integer] The starting value for the sequence (default: 1)
|
398
|
+
# @return [void]
|
399
|
+
def create_sequence_safely(sequence_name, table_name, start_with: 1)
|
400
|
+
return if sequence_exists?(sequence_name)
|
401
|
+
|
402
|
+
begin
|
403
|
+
create_sequence(sequence_name, start_with: start_with)
|
404
|
+
Rails.logger&.debug("Created sequence #{sequence_name} for table #{table_name} starting at #{start_with}") if defined?(Rails)
|
405
|
+
rescue ActiveRecord::StatementInvalid, DuckDB::Error => e
|
406
|
+
if e.message.include?('already exists') || e.message.include?('Object already exists')
|
407
|
+
# Sequence already exists, which is fine
|
408
|
+
Rails.logger&.debug("Sequence #{sequence_name} already exists") if defined?(Rails)
|
409
|
+
elsif defined?(Rails)
|
410
|
+
# Log the error but don't fail the migration
|
411
|
+
Rails.logger&.warn("Could not create sequence #{sequence_name} for table #{table_name}: #{e.message}") if defined?(Rails)
|
412
|
+
end
|
413
|
+
rescue StandardError => e
|
414
|
+
# Catch any other errors and log them, but don't fail the migration
|
415
|
+
Rails.logger&.warn("Unexpected error creating sequence #{sequence_name}: #{e.message}") if defined?(Rails)
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
# Fetches type metadata by parsing DuckDB SQL type strings
|
420
|
+
# @param sql_type [String] The SQL type string to parse
|
421
|
+
# @return [ActiveRecord::ConnectionAdapters::SqlTypeMetadata] The parsed type metadata
|
422
|
+
def fetch_type_metadata(sql_type)
|
423
|
+
# Parse DuckDB types and map to Rails types
|
424
|
+
sql_type_str = sql_type.to_s
|
425
|
+
type, limit, precision, scale = parse_type_info(sql_type_str)
|
426
|
+
|
427
|
+
# Ensure all parameters are properly set with defaults
|
428
|
+
type = (type || :string).to_sym
|
429
|
+
limit = nil unless limit&.positive?
|
430
|
+
precision = nil unless precision&.positive?
|
431
|
+
scale = nil unless scale&.>=(0)
|
432
|
+
|
433
|
+
ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
|
434
|
+
sql_type: sql_type_str,
|
435
|
+
type: type.to_sym,
|
436
|
+
limit: limit,
|
437
|
+
precision: precision,
|
438
|
+
scale: scale
|
439
|
+
)
|
440
|
+
end
|
441
|
+
|
442
|
+
# Extracts and converts default values from DuckDB column defaults
|
443
|
+
# @param default [String, nil] The default value from column definition
|
444
|
+
# @return [Object, nil] The converted default value
|
445
|
+
def extract_value_from_default(default)
|
446
|
+
return nil if default.nil?
|
447
|
+
|
448
|
+
# IMPORTANT: Return nil for sequence defaults so Rails doesn't set id=0
|
449
|
+
return nil if default.to_s.include?('nextval(')
|
450
|
+
|
451
|
+
# Handle DuckDB default value formats
|
452
|
+
default_str = default&.to_s&.strip
|
453
|
+
|
454
|
+
case default_str
|
455
|
+
when /^'(.*)'$/
|
456
|
+
# Remove outer quotes from string defaults
|
457
|
+
::Regexp.last_match(1)
|
458
|
+
when 'NULL', ''
|
459
|
+
nil
|
460
|
+
when /^CAST\('([tf])' AS BOOLEAN\)$/i
|
461
|
+
# Handle DuckDB boolean format: CAST('t' AS BOOLEAN) or CAST('f' AS BOOLEAN)
|
462
|
+
# Return string representation to avoid ActiveRecord deduplication issues
|
463
|
+
::Regexp.last_match(1).downcase == 't' ? 'true' : 'false'
|
464
|
+
when /^-?\d+$/, /^\d+$/
|
465
|
+
# Integer defaults (handle both positive and negative)
|
466
|
+
default_str.to_i
|
467
|
+
when /^-?\d+\.\d+$/, /^\d+\.\d+$/
|
468
|
+
# Float defaults (handle both positive and negative)
|
469
|
+
# Positive float defaults
|
470
|
+
default_str.to_f
|
471
|
+
else
|
472
|
+
# For any other format, return the string as-is
|
473
|
+
default_str
|
474
|
+
end
|
475
|
+
end
|
476
|
+
|
477
|
+
# Parses DuckDB SQL type strings into Rails type components
|
478
|
+
# @param sql_type [String] The SQL type string to parse
|
479
|
+
# @return [Array] Array containing [type, limit, precision, scale]
|
480
|
+
def parse_type_info(sql_type)
|
481
|
+
return [:string, nil, nil, nil] if sql_type.nil? || sql_type.empty?
|
482
|
+
|
483
|
+
# https://duckdb.org/docs/stable/sql/data_types/overview.html
|
484
|
+
case sql_type&.to_s&.upcase
|
485
|
+
when /^INTEGER(\((\d+),(\d+)\))?/i
|
486
|
+
precision, scale = ::Regexp.last_match(2)&.to_i, ::Regexp.last_match(3)&.to_i
|
487
|
+
[:integer, nil, precision, scale]
|
488
|
+
# Map HUGEINT to bigint for Rails compatibility
|
489
|
+
when /^BIGINT/i, /^HUGEINT/i
|
490
|
+
[:bigint, nil, nil, nil]
|
491
|
+
when /^VARCHAR(\((\d+)\))?/i, /^CHAR(\((\d+)\))?/i
|
492
|
+
# Extract limit from VARCHAR(n) format
|
493
|
+
limit = ::Regexp.last_match(2)&.to_i
|
494
|
+
[:string, limit, nil, nil]
|
495
|
+
when /^TEXT/i
|
496
|
+
[:text, nil, nil, nil]
|
497
|
+
when /^JSON/i
|
498
|
+
[:json, nil, nil, nil]
|
499
|
+
when /^DOUBLE/i, /^REAL/i, /^FLOAT/i
|
500
|
+
[:float, nil, nil, nil]
|
501
|
+
when /^BOOLEAN/i, /^BOOL/i, /^LOGICAL/i
|
502
|
+
[:boolean, nil, nil, nil]
|
503
|
+
when /^DATE$/i
|
504
|
+
[:date, nil, nil, nil]
|
505
|
+
when /^TIME/i
|
506
|
+
[:time, nil, nil, nil]
|
507
|
+
when /^TIMESTAMP/i, /^DATETIME/i
|
508
|
+
[:datetime, nil, nil, nil]
|
509
|
+
when /^DECIMAL(\((\d+),(\d+)\))?/i, /^NUMERIC(\((\d+),(\d+)\))?/i
|
510
|
+
precision, scale = ::Regexp.last_match(2)&.to_i, ::Regexp.last_match(3)&.to_i
|
511
|
+
[:decimal, nil, precision, scale]
|
512
|
+
when /^UUID/i
|
513
|
+
[:uuid, nil, nil, nil]
|
514
|
+
when /^TINYINT/i, /^SMALLINT/i
|
515
|
+
# TODO: Determine if integer or smallint should be used here
|
516
|
+
[:integer, nil, nil, nil]
|
517
|
+
when /^BLOB/i, /^BYTEA/i, /^BINARY/i
|
518
|
+
[:binary, nil, nil, nil]
|
519
|
+
else
|
520
|
+
[:string, nil, nil, nil] # Default fallback
|
521
|
+
end
|
522
|
+
end
|
523
|
+
|
524
|
+
# Generates a default sequence name for a table and column
|
525
|
+
# @param table_name [String] The name of the table
|
526
|
+
# @param column_name [String] The name of the column (default: 'id')
|
527
|
+
# @return [String] The generated sequence name
|
528
|
+
def default_sequence_name(table_name, column_name = 'id')
|
529
|
+
"#{table_name}_#{column_name}_seq"
|
530
|
+
end
|
531
|
+
|
532
|
+
# Gets the default integer type for primary keys
|
533
|
+
# @return [Symbol] The primary key type (:integer or :bigint)
|
534
|
+
def integer_primary_key_type
|
535
|
+
case self.class.primary_key_type
|
536
|
+
when :integer
|
537
|
+
:integer
|
538
|
+
else
|
539
|
+
:bigint # Default to bigint for modern Rails apps
|
540
|
+
end
|
541
|
+
end
|
542
|
+
|
543
|
+
# Indicates whether DuckDB supports sequences
|
544
|
+
# @return [Boolean] always returns true
|
545
|
+
def supports_sequences?
|
546
|
+
true
|
547
|
+
end
|
548
|
+
|
549
|
+
# Extracts schema and table name from a qualified name string
|
550
|
+
# @param string [String, nil] The qualified name string to parse
|
551
|
+
# @return [Array] Array containing [schema, name]
|
552
|
+
def extract_schema_qualified_name(string)
|
553
|
+
return [nil, nil] if string.nil?
|
554
|
+
|
555
|
+
schema, name = string.to_s.scan(/[^".\s]+|"[^"]*"/)[0, 2]
|
556
|
+
schema, name = nil, schema unless name
|
557
|
+
[schema, name]
|
558
|
+
end
|
559
|
+
|
560
|
+
# Returns the default primary key type definition for DuckDB
|
561
|
+
# @return [String] SQL definition for the primary key type
|
562
|
+
def primary_key_type_definition
|
563
|
+
case self.class.primary_key_type
|
564
|
+
when :uuid
|
565
|
+
'UUID PRIMARY KEY'
|
566
|
+
when :bigint
|
567
|
+
'BIGINT PRIMARY KEY'
|
568
|
+
when :string
|
569
|
+
'VARCHAR PRIMARY KEY'
|
570
|
+
else
|
571
|
+
'INTEGER PRIMARY KEY' # fallback
|
572
|
+
end
|
573
|
+
end
|
574
|
+
|
575
|
+
# Converts integer limit to appropriate DuckDB integer SQL type
|
576
|
+
# @param limit [Integer, nil] The byte limit for the integer type
|
577
|
+
# @return [String] The DuckDB SQL integer type
|
578
|
+
# @raise [ArgumentError] if limit is not supported
|
579
|
+
# @see https://duckdb.org/docs/stable/sql/data_types/numeric
|
580
|
+
def integer_to_sql(limit)
|
581
|
+
case limit
|
582
|
+
when 1
|
583
|
+
'TINYINT' # 1 byte: -128 to 127
|
584
|
+
when 2
|
585
|
+
'SMALLINT' # 2 bytes: -32,768 to 32,767
|
586
|
+
when nil, 3, 4
|
587
|
+
'INTEGER' # 4 bytes: -2,147,483,648 to 2,147,483,647
|
588
|
+
when 5..8
|
589
|
+
'BIGINT' # 8 bytes: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
|
590
|
+
when 9..16
|
591
|
+
'HUGEINT' # 16 bytes: -2^127 to 2^127-1
|
592
|
+
else
|
593
|
+
raise ArgumentError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead."
|
594
|
+
end
|
595
|
+
end
|
596
|
+
end
|
597
|
+
end
|
598
|
+
end
|
599
|
+
end
|