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.
@@ -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