activerecord-duckdb 0.1.0 → 0.1.1

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.
@@ -3,9 +3,102 @@
3
3
  module ActiveRecord
4
4
  module ConnectionAdapters
5
5
  module Duckdb
6
- # DuckDB-specific schema dumper functionality for generating schema.rb files
7
- # Provides methods to properly dump DuckDB-specific column types and constraints
8
- module SchemaDumper
6
+ # DuckDB-specific schema dumper class for generating schema.rb files
7
+ # Extends Rails' ConnectionAdapters::SchemaDumper to handle DuckDB-specific
8
+ # column types, constraints, and DuckLake features like partitioning
9
+ class SchemaDumper < ConnectionAdapters::SchemaDumper # :nodoc:
10
+ # Valid DuckLake options that can be set via set_option
11
+ SETTABLE_DUCKLAKE_OPTIONS = %w[parquet_version parquet_compression].freeze
12
+
13
+ # Override table dumping to include DuckLake-specific features
14
+ # @param table [String] The table name
15
+ # @param stream [IO] The output stream
16
+ # @return [void]
17
+ def table(table, stream)
18
+ # Call the parent implementation first
19
+ super
20
+
21
+ # Add DuckLake partitioning if present
22
+ dump_partitioning(table, stream)
23
+
24
+ # Add DuckLake table-level options if present
25
+ dump_table_options(table, stream)
26
+ end
27
+
28
+ private
29
+
30
+ # Override Scenic's defined_views to return empty array for DuckDB
31
+ # This prevents Scenic's MySQL adapter from being called with DuckDB connections
32
+ # @return [Array] Empty array of views
33
+ def defined_views
34
+ []
35
+ end
36
+
37
+ # Override Scenic's dumpable_views_in_database to return empty array for DuckDB
38
+ # This prevents Scenic from calling Scenic.database.views which uses MySQL adapter
39
+ # @return [Array] Empty array of views
40
+ def dumpable_views_in_database
41
+ []
42
+ end
43
+
44
+ # Override extensions to also dump DuckLake options
45
+ # @param stream [IO] The output stream
46
+ # @return [void]
47
+ def extensions(stream)
48
+ super
49
+ dump_ducklake_options(stream)
50
+ end
51
+
52
+ # Dumps DuckLake options (parquet_version, parquet_compression, etc.)
53
+ # @param stream [IO] The output stream
54
+ # @return [void]
55
+ def dump_ducklake_options(stream)
56
+ return unless @connection.respond_to?(:ducklake_options)
57
+
58
+ options = @connection.ducklake_options
59
+ return if options.nil? || options.empty?
60
+
61
+ # Filter to only include settable options (not metadata like 'encrypted')
62
+ settable_options = options.slice(*SETTABLE_DUCKLAKE_OPTIONS)
63
+ return if settable_options.empty?
64
+
65
+ settable_options.sort.each do |name, value|
66
+ stream.puts " set_ducklake_option #{name.inspect}, #{value.inspect}"
67
+ end
68
+ stream.puts
69
+ end
70
+
71
+ # Dumps DuckLake partitioning for a table
72
+ # @param table_name [String] The table name
73
+ # @param stream [IO] The output stream
74
+ # @return [void]
75
+ def dump_partitioning(table_name, stream)
76
+ return unless @connection.respond_to?(:partition_expressions)
77
+
78
+ expressions = @connection.partition_expressions(table_name)
79
+ return if expressions.nil? || expressions.empty?
80
+
81
+ # Format the expressions array as Ruby code
82
+ expressions_code = expressions.map(&:inspect).join(', ')
83
+ stream.puts " set_partitioned_by #{table_name.inspect}, [#{expressions_code}]"
84
+ end
85
+
86
+ # Dumps DuckLake table-level options
87
+ # @param table_name [String] The table name
88
+ # @param stream [IO] The output stream
89
+ # @return [void]
90
+ def dump_table_options(table_name, stream)
91
+ return unless @connection.respond_to?(:ducklake_table_options)
92
+
93
+ options = @connection.ducklake_table_options(table_name)
94
+ return if options.nil? || options.empty?
95
+
96
+ options.sort.each do |name, value|
97
+ stream.puts " set_ducklake_option #{name.inspect}, #{value.inspect}, #{table_name.inspect}"
98
+ end
99
+ stream.puts
100
+ end
101
+
9
102
  # Generates column specification for schema dumping
10
103
  # @param column [ActiveRecord::ConnectionAdapters::Column] The column to generate spec for
11
104
  # @return [Array] Array containing column type and options hash
@@ -49,6 +142,27 @@ module ActiveRecord
49
142
  :date
50
143
  when /^TIME$/i
51
144
  :time
145
+ # DuckDB-specific signed integer types
146
+ when /^TINYINT$/i
147
+ :tinyint
148
+ when /^SMALLINT$/i
149
+ :smallint
150
+ when /^HUGEINT$/i
151
+ :hugeint
152
+ # DuckDB-specific unsigned integer types
153
+ when /^UTINYINT$/i
154
+ :utinyint
155
+ when /^USMALLINT$/i
156
+ :usmallint
157
+ when /^UINTEGER$/i
158
+ :uinteger
159
+ when /^UBIGINT$/i
160
+ :ubigint
161
+ when /^UHUGEINT$/i
162
+ :uhugeint
163
+ # DuckDB interval type
164
+ when /^INTERVAL$/i
165
+ :interval
52
166
  else
53
167
  column.type
54
168
  end
@@ -22,6 +22,21 @@ module ActiveRecord
22
22
  result.to_a.map { |row| row[0] }
23
23
  end
24
24
 
25
+ # Returns a list of all views in the database
26
+ # @return [Array<String>] Array of view names
27
+ def views
28
+ result = execute(data_source_sql(type: 'VIEW'), 'SCHEMA')
29
+ result.to_a.map { |row| row[0] }
30
+ end
31
+
32
+ # Returns options for internal primary key columns (schema_migrations, ar_internal_metadata)
33
+ # DuckLake doesn't support PRIMARY KEY constraints, so we omit them in that mode
34
+ # @return [Hash] Options hash for string primary key columns
35
+ def internal_string_options_for_primary_key
36
+ # DuckLake doesn't support PRIMARY KEY/UNIQUE constraints
37
+ ducklake? ? {} : { primary_key: true }
38
+ end
39
+
25
40
  # Creates a DuckDB-specific table definition instance
26
41
  # @param name [String, Symbol] The table name
27
42
  # @return [ActiveRecord::ConnectionAdapters::Duckdb::TableDefinition] Table definition instance
@@ -72,8 +87,8 @@ module ActiveRecord
72
87
  # @return [void]
73
88
  def create_sequence(sequence_name, start_with: 1, increment_by: 1, **options)
74
89
  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
90
+ sql << " START #{start_with.to_i}" if start_with != 1
91
+ sql << " INCREMENT #{increment_by.to_i}" if increment_by != 1
77
92
  execute(sql, 'Create Sequence')
78
93
  end
79
94
 
@@ -115,7 +130,7 @@ module ActiveRecord
115
130
  # @param sequence_name [String] The name of the sequence
116
131
  # @return [String] SQL expression for getting next sequence value
117
132
  def next_sequence_value(sequence_name)
118
- "nextval('#{sequence_name}')"
133
+ "nextval(#{quote(sequence_name)})"
119
134
  end
120
135
 
121
136
  # Resets a sequence to a specific value
@@ -123,7 +138,7 @@ module ActiveRecord
123
138
  # @param value [Integer] The value to reset the sequence to (default: 1)
124
139
  # @return [void]
125
140
  def reset_sequence!(sequence_name, value = 1)
126
- execute("ALTER SEQUENCE #{quote_table_name(sequence_name)} RESTART WITH #{value}", 'Reset Sequence')
141
+ execute("ALTER SEQUENCE #{quote_table_name(sequence_name)} RESTART WITH #{value.to_i}", 'Reset Sequence')
127
142
  end
128
143
 
129
144
  # Returns a list of all sequences in the database
@@ -133,6 +148,179 @@ module ActiveRecord
133
148
  []
134
149
  end
135
150
 
151
+ # Sets partitioning for a DuckLake table
152
+ # DuckLake supports time-based partitioning using SQL functions
153
+ # @param table_name [String, Symbol] The name of the table to partition
154
+ # @param expressions [Array<String>] Array of SQL expressions for partitioning
155
+ # @return [void]
156
+ # @example Partition by year, month, day
157
+ # set_partitioned_by(:datapoints, ['year(created_at)', 'month(created_at)', 'day(created_at)'])
158
+ # @see https://ducklake.select/docs/stable/sql/statements/alter_table.html
159
+ def set_partitioned_by(table_name, expressions)
160
+ sql = "ALTER TABLE #{quote_table_name(table_name)} SET PARTITIONED BY (#{expressions.join(", ")})"
161
+ execute(sql, 'Set Partitioned By')
162
+ end
163
+
164
+ # NOTE: DuckLake does not support removing partitioning after it has been set.
165
+ # Partitioning is a one-way operation. To change partitioning, you must recreate the table.
166
+
167
+ # Returns the partition expressions for a DuckLake table
168
+ # @param table_name [String, Symbol] The name of the table
169
+ # @return [Array<String>] Array of partition expressions (e.g., ['month(created_at)'])
170
+ # @return [nil] If the table is not partitioned or not in DuckLake mode
171
+ def partition_expressions(table_name)
172
+ return nil unless ducklake?
173
+
174
+ # Find the metadata schema for the current database
175
+ metadata_schema = ducklake_metadata_schema
176
+ return nil unless metadata_schema
177
+
178
+ # Query partition info from DuckLake metadata tables
179
+ # Schema:
180
+ # ducklake_partition_column: partition_id, table_id, partition_key_index, column_id, transform
181
+ # ducklake_column: column_id, table_id, column_name, ...
182
+ # ducklake_table: table_id, table_name, ...
183
+ sql = <<~SQL
184
+ SELECT pc.partition_key_index, c.column_name, pc.transform
185
+ FROM #{quote_table_name(metadata_schema)}.ducklake_partition_column pc
186
+ JOIN #{quote_table_name(metadata_schema)}.ducklake_table t ON pc.table_id = t.table_id
187
+ JOIN #{quote_table_name(metadata_schema)}.ducklake_column c ON pc.column_id = c.column_id AND c.table_id = t.table_id
188
+ WHERE t.table_name = #{quote(table_name.to_s)}
189
+ ORDER BY pc.partition_key_index
190
+ SQL
191
+
192
+ result = execute(sql, 'Get Partition Expressions')
193
+ expressions = result.map do |row|
194
+ _index, column_name, transform = row
195
+ if transform && !transform.to_s.empty?
196
+ "#{transform}(#{column_name})"
197
+ else
198
+ column_name.to_s
199
+ end
200
+ end
201
+
202
+ expressions.empty? ? nil : expressions
203
+ rescue StandardError
204
+ # If metadata tables don't exist or query fails, return nil
205
+ nil
206
+ end
207
+
208
+ # Returns the DuckLake metadata schema name for the current database
209
+ # @return [String, nil] The metadata schema name or nil if not found
210
+ def ducklake_metadata_schema
211
+ # Don't memoize - current_database can change after USE DATABASE
212
+ # DuckLake metadata is stored in __ducklake_metadata_<database_name>
213
+ result = execute('SELECT current_database()', 'Get Current Database')
214
+ db_name = result.first&.first
215
+ return nil if db_name.nil? || db_name.empty?
216
+
217
+ "__ducklake_metadata_#{db_name}"
218
+ rescue StandardError
219
+ nil
220
+ end
221
+
222
+ # Sets a DuckLake-specific option using CALL set_option
223
+ # @param option_name [String] The name of the option to set
224
+ # @param value [String, Integer] The value to set
225
+ # @param table_name [String, Symbol, nil] Optional table name for table-scoped options
226
+ # @return [void]
227
+ # @example Set global parquet version
228
+ # set_ducklake_option('parquet_version', '2')
229
+ # @example Set table-level parquet compression
230
+ # set_ducklake_option('parquet_compression', 'zstd', :events)
231
+ def set_ducklake_option(option_name, value, table_name = nil)
232
+ formatted_value = value.is_a?(Integer) ? value.to_s : quote(value)
233
+ sql = if table_name
234
+ "CALL set_option(#{quote(option_name)}, #{formatted_value}, #{quote(table_name.to_s)})"
235
+ else
236
+ "CALL set_option(#{quote(option_name)}, #{formatted_value})"
237
+ end
238
+ execute(sql, 'Set DuckLake Option')
239
+ end
240
+
241
+ # DuckLake options that should be included in schema dumps
242
+ # These are user-configurable options, not internal metadata like 'version' or 'created_by'
243
+ # See: https://ducklake.select/docs/stable/specification/tables/ducklake_metadata.html
244
+ DUMPABLE_DUCKLAKE_OPTIONS = %w[
245
+ data_inlining_row_limit
246
+ target_file_size
247
+ parquet_row_group_size_bytes
248
+ parquet_row_group_size
249
+ parquet_compression
250
+ parquet_compression_level
251
+ parquet_version
252
+ hive_file_pattern
253
+ require_commit_message
254
+ rewrite_delete_threshold
255
+ delete_older_than
256
+ expire_older_than
257
+ per_thread_output
258
+ encrypted
259
+ ].freeze
260
+
261
+ # Returns DuckLake options for schema dumping
262
+ # @return [Hash] Hash of option_name => value for user-configurable options
263
+ # @return [nil] If not in DuckLake mode or no options set
264
+ def ducklake_options
265
+ return nil unless ducklake?
266
+
267
+ metadata_schema = ducklake_metadata_schema
268
+ return nil unless metadata_schema
269
+
270
+ # Query the ducklake_metadata table for global options (scope IS NULL)
271
+ sql = <<~SQL
272
+ SELECT key, value
273
+ FROM #{quote_table_name(metadata_schema)}.ducklake_metadata
274
+ WHERE scope IS NULL
275
+ SQL
276
+
277
+ result = execute(sql, 'Get DuckLake Options')
278
+ options = {}
279
+ result.each do |row|
280
+ key, value = row
281
+ # Only include user-configurable options, not internal metadata
282
+ options[key] = value if DUMPABLE_DUCKLAKE_OPTIONS.include?(key)
283
+ end
284
+
285
+ options.empty? ? nil : options
286
+ rescue StandardError
287
+ nil
288
+ end
289
+
290
+ # Returns DuckLake options for a specific table
291
+ # @param table_name [String, Symbol] The table name
292
+ # @return [Hash] Hash of option_name => value for table-scoped options
293
+ # @return [nil] If not in DuckLake mode or no table options set
294
+ def ducklake_table_options(table_name)
295
+ return nil unless ducklake?
296
+
297
+ metadata_schema = ducklake_metadata_schema
298
+ return nil unless metadata_schema
299
+
300
+ # Get the table_id first
301
+ table_sql = <<~SQL
302
+ SELECT table_id FROM #{quote_table_name(metadata_schema)}.ducklake_table
303
+ WHERE table_name = #{quote(table_name.to_s)}
304
+ SQL
305
+ table_result = execute(table_sql, 'Get Table ID')
306
+ table_id = table_result.first&.first
307
+ return nil unless table_id
308
+
309
+ # Query the ducklake_metadata table for table-scoped options
310
+ sql = <<~SQL
311
+ SELECT key, value
312
+ FROM #{quote_table_name(metadata_schema)}.ducklake_metadata
313
+ WHERE scope = 'table' AND scope_id = #{table_id.to_i}
314
+ SQL
315
+
316
+ result = execute(sql, 'Get DuckLake Table Options')
317
+ options = result.to_h { |key, value| DUMPABLE_DUCKLAKE_OPTIONS.include?(key) ? [key, value] : [] }
318
+
319
+ options.presence
320
+ rescue StandardError
321
+ nil
322
+ end
323
+
136
324
  # Returns indexes for a specific table
137
325
  # @param table_name [String, Symbol] The name of the table
138
326
  # @return [Array<ActiveRecord::ConnectionAdapters::IndexDefinition>] Array of index definitions
@@ -196,71 +384,6 @@ module ActiveRecord
196
384
  sql
197
385
  end
198
386
 
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
387
  # Looks up the appropriate cast type for a column based on SQL type metadata
265
388
  # @param sql_type_metadata [ActiveRecord::ConnectionAdapters::SqlTypeMetadata] The SQL type metadata
266
389
  # @return [ActiveRecord::Type::Value] The appropriate cast type
@@ -275,6 +398,10 @@ module ActiveRecord
275
398
  def quoted_scope(name = nil, type: nil)
276
399
  schema, name = extract_schema_qualified_name(name)
277
400
 
401
+ # Default to 'main' schema if no schema specified to avoid returning
402
+ # tables from information_schema, pg_catalog, or other attached databases
403
+ schema ||= 'main'
404
+
278
405
  type_condition = case type
279
406
  when 'BASE TABLE'
280
407
  "table_type = 'BASE TABLE'"
@@ -285,7 +412,7 @@ module ActiveRecord
285
412
  end
286
413
 
287
414
  {
288
- schema: schema ? quote(schema) : nil,
415
+ schema: quote(schema),
289
416
  name: name ? quote(name) : nil,
290
417
  type: type_condition
291
418
  }
@@ -298,6 +425,7 @@ module ActiveRecord
298
425
  # @param scale [Integer, nil] Optional decimal scale
299
426
  # @param options [Hash] Additional type options
300
427
  # @return [String] The DuckDB SQL type string
428
+ # @see https://duckdb.org/docs/stable/sql/data_types/overview.html
301
429
  def type_to_sql(type, limit: nil, precision: nil, scale: nil, **options)
302
430
  case type.to_s
303
431
  when 'primary_key'
@@ -313,8 +441,10 @@ module ActiveRecord
313
441
  integer_to_sql(limit)
314
442
  when 'bigint'
315
443
  'BIGINT'
316
- when 'float'
444
+ when 'float', 'real'
317
445
  'REAL'
446
+ when 'double'
447
+ 'DOUBLE'
318
448
  when 'decimal', 'numeric'
319
449
  if precision && scale
320
450
  "DECIMAL(#{precision},#{scale})"
@@ -339,6 +469,27 @@ module ActiveRecord
339
469
  'BLOB'
340
470
  when 'uuid'
341
471
  'UUID'
472
+ # DuckDB-specific signed integer types
473
+ when 'tinyint'
474
+ 'TINYINT'
475
+ when 'smallint'
476
+ 'SMALLINT'
477
+ when 'hugeint'
478
+ 'HUGEINT'
479
+ # DuckDB-specific unsigned integer types
480
+ when 'utinyint'
481
+ 'UTINYINT'
482
+ when 'usmallint'
483
+ 'USMALLINT'
484
+ when 'uinteger'
485
+ 'UINTEGER'
486
+ when 'ubigint'
487
+ 'UBIGINT'
488
+ when 'uhugeint'
489
+ 'UHUGEINT'
490
+ # DuckDB interval type for time periods
491
+ when 'interval'
492
+ 'INTERVAL'
342
493
  else
343
494
  super
344
495
  end
@@ -362,7 +513,7 @@ module ActiveRecord
362
513
  # Only replace the first occurrence (the actual primary key)
363
514
  sql = sql.sub(pk_column_pattern) do |match|
364
515
  # Inject the sequence default before PRIMARY KEY
365
- match.sub(/(\s+)PRIMARY\s+KEY/i, "\\1DEFAULT nextval('#{pending[:sequence]}') PRIMARY KEY")
516
+ match.sub(/(\s+)PRIMARY\s+KEY/i, "\\1DEFAULT nextval(#{quote(pending[:sequence])}) PRIMARY KEY")
366
517
  end
367
518
  end
368
519
  end
@@ -372,6 +523,59 @@ module ActiveRecord
372
523
 
373
524
  private
374
525
 
526
+ # Parses DuckDB field information and returns a hash with all values needed
527
+ # to construct a Column object. Used by version-specific new_column_from_field.
528
+ # @param table_name [String] The name of the table
529
+ # @param field [Array] Array from PRAGMA table_info
530
+ # @return [Hash] Parsed column information
531
+ def column_info_from_field(table_name, field)
532
+ column_name, formatted_type, column_default, not_null, _type_id, _type_modifier,
533
+ collation_name, comment, _identity, _generated, pk = field
534
+
535
+ # Normalize values
536
+ column_name = column_name.presence || 'unknown_column'
537
+ formatted_type = formatted_type.to_s.presence || 'VARCHAR'
538
+ sql_type_metadata = fetch_type_metadata(formatted_type)
539
+
540
+ # Determine default value vs function
541
+ default_value, default_function = parse_column_default(
542
+ table_name, column_name, column_default, formatted_type, pk
543
+ )
544
+
545
+ # Parse null constraint (DuckDB: not_null=1 means NOT NULL)
546
+ is_null = !not_null.in?([1, true])
547
+
548
+ # Detect auto-increment columns
549
+ is_integer_pk = pk && formatted_type.upcase.in?(%w[INTEGER BIGINT])
550
+
551
+ {
552
+ name: column_name.to_s,
553
+ sql_type_metadata: sql_type_metadata,
554
+ default: default_value,
555
+ default_function: default_function&.to_s,
556
+ null: is_null,
557
+ collation: collation_name.to_s.presence,
558
+ comment: comment.to_s.presence,
559
+ auto_increment: is_integer_pk,
560
+ rowid: pk && column_name == 'id'
561
+ }
562
+ end
563
+
564
+ # Parses column default, separating static values from sequence functions.
565
+ # @return [Array<Object, String>] [default_value, default_function]
566
+ def parse_column_default(table_name, column_name, column_default, formatted_type, pk)
567
+ # Integer primary key named 'id' - assume sequence exists
568
+ if pk&.in?([true, 1]) && formatted_type.upcase.in?(%w[INTEGER BIGINT]) && column_name == 'id'
569
+ # Build the sequence name and quote it properly for the nextval expression
570
+ seq_name = "#{table_name}_#{column_name}_seq"
571
+ [nil, "nextval(#{quote(seq_name)})"]
572
+ elsif column_default.to_s.include?('nextval(')
573
+ [nil, column_default.to_s]
574
+ else
575
+ [extract_value_from_default(column_default), nil]
576
+ end
577
+ end
578
+
375
579
  # Parses index column names from DuckDB's string representation
376
580
  # @param column_names_str [String] The string representation of column names
377
581
  # @return [Array<String>] Array of cleaned column names
@@ -477,17 +681,33 @@ module ActiveRecord
477
681
  # Parses DuckDB SQL type strings into Rails type components
478
682
  # @param sql_type [String] The SQL type string to parse
479
683
  # @return [Array] Array containing [type, limit, precision, scale]
684
+ # @see https://duckdb.org/docs/stable/sql/data_types/overview.html
480
685
  def parse_type_info(sql_type)
481
686
  return [:string, nil, nil, nil] if sql_type.nil? || sql_type.empty?
482
687
 
483
- # https://duckdb.org/docs/stable/sql/data_types/overview.html
484
688
  case sql_type&.to_s&.upcase
485
689
  when /^INTEGER(\((\d+),(\d+)\))?/i
486
690
  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
691
+ [:integer, 4, precision, scale]
692
+ # BIGINT: 8 bytes (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807)
693
+ # HUGEINT: 16 bytes - map to bigint with limit 8 for Rails compatibility
694
+ # Note: Rails doesn't support 16-byte integers natively, values may overflow
489
695
  when /^BIGINT/i, /^HUGEINT/i
490
- [:bigint, nil, nil, nil]
696
+ [:bigint, 8, nil, nil]
697
+ # DuckDB-specific signed integer types - use specific type symbols for schema dumping
698
+ when /^SMALLINT/i
699
+ [:smallint, 2, nil, nil]
700
+ when /^TINYINT/i
701
+ [:tinyint, 1, nil, nil]
702
+ # DuckDB-specific unsigned integer types - use specific type symbols for schema dumping
703
+ when /^UBIGINT/i
704
+ [:ubigint, 8, nil, nil]
705
+ when /^UINTEGER/i
706
+ [:uinteger, 4, nil, nil]
707
+ when /^USMALLINT/i
708
+ [:usmallint, 2, nil, nil]
709
+ when /^UTINYINT/i
710
+ [:utinyint, 1, nil, nil]
491
711
  when /^VARCHAR(\((\d+)\))?/i, /^CHAR(\((\d+)\))?/i
492
712
  # Extract limit from VARCHAR(n) format
493
713
  limit = ::Regexp.last_match(2)&.to_i
@@ -502,18 +722,17 @@ module ActiveRecord
502
722
  [:boolean, nil, nil, nil]
503
723
  when /^DATE$/i
504
724
  [:date, nil, nil, nil]
505
- when /^TIME/i
506
- [:time, nil, nil, nil]
507
725
  when /^TIMESTAMP/i, /^DATETIME/i
508
726
  [:datetime, nil, nil, nil]
727
+ when /^TIME$/i
728
+ [:time, nil, nil, nil]
509
729
  when /^DECIMAL(\((\d+),(\d+)\))?/i, /^NUMERIC(\((\d+),(\d+)\))?/i
510
730
  precision, scale = ::Regexp.last_match(2)&.to_i, ::Regexp.last_match(3)&.to_i
511
731
  [:decimal, nil, precision, scale]
512
732
  when /^UUID/i
513
733
  [: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]
734
+ when /^INTERVAL/i
735
+ [:interval, nil, nil, nil]
517
736
  when /^BLOB/i, /^BYTEA/i, /^BINARY/i
518
737
  [:binary, nil, nil, nil]
519
738
  else
@@ -558,18 +777,18 @@ module ActiveRecord
558
777
  end
559
778
 
560
779
  # Returns the default primary key type definition for DuckDB
780
+ # DuckLake doesn't support PRIMARY KEY constraints, so we omit them in that mode
561
781
  # @return [String] SQL definition for the primary key type
562
782
  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
783
+ base_type = case self.class.primary_key_type
784
+ when :uuid then 'UUID'
785
+ when :bigint then 'BIGINT'
786
+ when :string then 'VARCHAR'
787
+ else 'INTEGER'
788
+ end
789
+
790
+ # DuckLake doesn't support PRIMARY KEY/UNIQUE constraints
791
+ ducklake? ? base_type : "#{base_type} PRIMARY KEY"
573
792
  end
574
793
 
575
794
  # Converts integer limit to appropriate DuckDB integer SQL type