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.
- checksums.yaml +4 -4
- data/.rubocop.yml +20 -1
- data/.tool-versions +1 -1
- data/Appraisals +19 -0
- data/CHANGELOG.md +12 -0
- data/README.md +133 -1
- data/docs/QUERY_EXECUTION_CALL_GRAPH.md +386 -0
- data/docs/RAILS_QUERY_EXECUTION.md +131 -0
- data/gemfiles/rails_7.2.gemfile +30 -0
- data/gemfiles/rails_7.2.gemfile.lock +196 -0
- data/gemfiles/rails_8.0.gemfile +30 -0
- data/gemfiles/rails_8.0.gemfile.lock +198 -0
- data/gemfiles/rails_8.1.gemfile +30 -0
- data/gemfiles/rails_8.1.gemfile.lock +197 -0
- data/lib/active_record/connection_adapters/duckdb/database_statements.rb +69 -201
- data/lib/active_record/connection_adapters/duckdb/database_statements_rails72.rb +53 -0
- data/lib/active_record/connection_adapters/duckdb/database_statements_rails8.rb +39 -0
- data/lib/active_record/connection_adapters/duckdb/schema_definitions.rb +7 -2
- data/lib/active_record/connection_adapters/duckdb/schema_dumper.rb +117 -3
- data/lib/active_record/connection_adapters/duckdb/schema_statements.rb +310 -91
- data/lib/active_record/connection_adapters/duckdb/schema_statements_rails80.rb +32 -0
- data/lib/active_record/connection_adapters/duckdb/schema_statements_rails81.rb +34 -0
- data/lib/active_record/connection_adapters/duckdb/timestamp_monkey_patch.rb +114 -0
- data/lib/active_record/connection_adapters/duckdb/type/interval.rb +174 -0
- data/lib/active_record/connection_adapters/duckdb_adapter.rb +404 -42
- data/lib/activerecord/duckdb/version.rb +1 -1
- metadata +19 -4
|
@@ -3,9 +3,102 @@
|
|
|
3
3
|
module ActiveRecord
|
|
4
4
|
module ConnectionAdapters
|
|
5
5
|
module Duckdb
|
|
6
|
-
# DuckDB-specific schema dumper
|
|
7
|
-
#
|
|
8
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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,
|
|
488
|
-
#
|
|
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,
|
|
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 /^
|
|
515
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|