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,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module provides database statements for the DuckDB adapter.
4
+ module ActiveRecord
5
+ module ConnectionAdapters
6
+ module Duckdb
7
+ module DatabaseStatements
8
+ # Determines if a SQL query is a write operation
9
+ # @param _sql [String] The SQL query to check (unused in DuckDB implementation)
10
+ # @return [Boolean] always returns false for DuckDB
11
+ def write_query?(_sql)
12
+ false
13
+ end
14
+
15
+ # Executes a SQL statement against the DuckDB database
16
+ # @param sql [String] The SQL statement to execute
17
+ # @param name [String, nil] Optional name for logging purposes
18
+ # @return [DuckDB::Result] The result of the query execution
19
+ def execute(sql, name = nil) # :nodoc:
20
+ # In case a query is being executed before the connection is open, reconnect.
21
+ reconnect unless raw_connection
22
+
23
+ log(sql, name) do
24
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
25
+ raw_connection.query(sql)
26
+ end
27
+ end
28
+ end
29
+
30
+ # Performs a query with optional bind parameters
31
+ # @param raw_connection [DuckDB::Connection] The raw database connection
32
+ # @param sql [String] The SQL query to execute
33
+ # @param binds [Array] Array of bind parameters
34
+ # @param type_casted_binds [Array] Type-casted bind parameters
35
+ # @param prepare [Boolean] Whether to prepare the statement (unused)
36
+ # @param notification_payload [Hash, nil] Payload for notifications (unused)
37
+ # @param batch [Boolean] Whether this is a batch operation (unused)
38
+ # @param async [Boolean] Whether to execute asynchronously (unused)
39
+ # @param kwargs [Hash] Additional keyword arguments
40
+ # @return [DuckDB::Result] The query result
41
+ def perform_query(raw_connection,
42
+ sql,
43
+ binds,
44
+ type_casted_binds,
45
+ prepare: false,
46
+ notification_payload: nil,
47
+ batch: false,
48
+ async: false,
49
+ **kwargs)
50
+ result = if binds.any?
51
+ # Use the modern parameter binding approach
52
+ exec_query_with_binds(sql, binds)
53
+ else
54
+ # Fallback to direct execution with quoted values
55
+ # Your existing quote method will handle any unquoted values
56
+ raw_connection.query(sql)
57
+ end
58
+ result
59
+ end
60
+
61
+ # Casts a DuckDB result to ActiveRecord::Result format
62
+ # @param result [DuckDB::Result, nil] The DuckDB result to cast
63
+ # @return [ActiveRecord::Result] The ActiveRecord-compatible result
64
+ def cast_result(result)
65
+ return ActiveRecord::Result.empty(affected_rows: @affected_rows_before_warnings) if result.nil?
66
+
67
+ columns = result.columns.map do |col|
68
+ if col.respond_to?(:name)
69
+ col.name
70
+ elsif col.respond_to?(:column_name)
71
+ col.column_name
72
+ else
73
+ col.to_s
74
+ end
75
+ end
76
+
77
+ ActiveRecord::Result.new(columns, result.to_a)
78
+ end
79
+
80
+ # Executes a query and returns an ActiveRecord::Result
81
+ # @param sql [String] The SQL query to execute
82
+ # @param name [String, nil] Optional name for logging
83
+ # @param binds [Array] Array of bind parameters
84
+ # @param prepare [Boolean] Whether to prepare the statement (unused)
85
+ # @return [ActiveRecord::Result] The query result
86
+ def exec_query(sql, name = nil, binds = [], prepare: false)
87
+ reconnect unless raw_connection
88
+
89
+ log(sql, name, binds) do
90
+ result = if binds.any?
91
+ # Use the modern parameter binding approach
92
+ exec_query_with_binds(sql, binds)
93
+ else
94
+ # Fallback to direct execution with quoted values
95
+ # Your existing quote method will handle any unquoted values
96
+ raw_connection.query(sql)
97
+ end
98
+
99
+ build_result(result)
100
+ end
101
+ end
102
+
103
+ # Executes a DELETE statement and returns the number of affected rows
104
+ # @param sql [String] The DELETE SQL statement
105
+ # @param name [String, nil] Optional name for logging
106
+ # @param binds [Array] Array of bind parameters
107
+ # @return [Integer] Number of rows affected by the delete
108
+ def exec_delete(sql, name = nil, binds = []) # :nodoc:
109
+ reconnect unless raw_connection
110
+
111
+ if binds.any?
112
+ # For bound queries, handle them with proper logging
113
+ log(sql, name, binds) do
114
+ bind_values = binds.map(&:value_for_database)
115
+ raw_connection.query(sql, *bind_values)
116
+ end.rows_changed
117
+ else
118
+ # For non-bound queries, use execute directly
119
+ result = execute(sql, name)
120
+ result.rows_changed
121
+ end
122
+ end
123
+ alias exec_update exec_delete
124
+
125
+ # Executes an INSERT statement with optional RETURNING clause
126
+ # @param sql [String] The INSERT SQL statement
127
+ # @param name [String, nil] Optional name for logging
128
+ # @param binds [Array] Array of bind parameters
129
+ # @param pk [String, nil] Primary key column name
130
+ # @param sequence_name [String, nil] Sequence name (unused)
131
+ # @param returning [Array, nil] Columns to return after insert
132
+ # @return [ActiveRecord::Result] The insert result with returned values
133
+ def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil)
134
+ # Rails 8 will pass returning: [pk] when it wants the ID back
135
+ if returning&.any?
136
+ returning_columns = returning.map { |c| quote_column_name(c) }.join(', ')
137
+ sql = "#{sql} RETURNING #{returning_columns}" unless sql.include?('RETURNING')
138
+ elsif pk && !sql.include?('RETURNING')
139
+ # Add RETURNING for the primary key
140
+ sql = "#{sql} RETURNING #{quote_column_name(pk)}"
141
+ end
142
+
143
+ # Execute the query and return the result
144
+ # Rails 8 will handle extracting the ID from the result
145
+ exec_query(sql, name, binds)
146
+ end
147
+
148
+ # Returns columns that should be included in INSERT statements
149
+ # @param table_name [String] The name of the table
150
+ # @return [Array<ActiveRecord::ConnectionAdapters::Column>] Columns to include in INSERT
151
+ def columns_for_insert(table_name)
152
+ columns(table_name).reject do |column|
153
+ # Exclude columns that have a default function (like nextval)
154
+ column.default_function.present?
155
+ end
156
+ end
157
+
158
+ # Determines if a column value should be returned after insert
159
+ # This is crucial - it tells Rails which columns should use RETURNING
160
+ # @param column [ActiveRecord::ConnectionAdapters::Column] The column to check
161
+ # @return [Boolean] true if column value should be returned after insert
162
+ def return_value_after_insert?(column)
163
+ # Return true for any column with a sequence default
164
+ column.default_function&.include?('nextval') || super
165
+ end
166
+
167
+ # Extracts the last inserted ID from an insert result
168
+ # @param result [ActiveRecord::Result] The result from an insert operation
169
+ # @return [Object] The last inserted ID value
170
+ def last_inserted_id(result)
171
+ # Handle ActiveRecord::Result from RETURNING clause
172
+ if result.is_a?(ActiveRecord::Result) && result.rows.any?
173
+ id_value = result.rows.first.first
174
+ return id_value
175
+ end
176
+ super
177
+ end
178
+
179
+ # Builds an ActiveRecord::Result from a DuckDB result
180
+ # @param result [DuckDB::Result, nil] The DuckDB result to convert
181
+ # @return [ActiveRecord::Result] The converted ActiveRecord result
182
+ def build_result(result)
183
+ # Handle DuckDB result format
184
+ return ActiveRecord::Result.empty if result.nil?
185
+
186
+ # DuckDB results have .columns and .to_a, not .rows
187
+ columns = result.columns.map do |col|
188
+ if col.respond_to?(:name)
189
+ col.name
190
+ elsif col.respond_to?(:column_name)
191
+ col.column_name
192
+ else
193
+ col.to_s
194
+ end
195
+ end
196
+
197
+ rows = result.to_a
198
+ ActiveRecord::Result.new(columns, rows)
199
+ end
200
+
201
+ # Fetches type metadata for a SQL type string
202
+ # @param sql_type [String] The SQL type to get metadata for
203
+ # @return [ActiveRecord::ConnectionAdapters::SqlTypeMetadata] The type metadata
204
+ def fetch_type_metadata(sql_type)
205
+ # Parse DuckDB types and map to Rails types
206
+ type, limit, precision, scale = parse_type_info(sql_type)
207
+
208
+ ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
209
+ sql_type: sql_type,
210
+ type: type.to_sym,
211
+ limit: limit,
212
+ precision: precision,
213
+ scale: scale
214
+ )
215
+ end
216
+
217
+ private
218
+
219
+ # Executes a query with bind parameters using positional parameters
220
+ # @param sql [String] The SQL query with bind placeholders
221
+ # @param binds [Array] Array of bind parameters
222
+ # @return [DuckDB::Result] The query result
223
+ def exec_query_with_binds(sql, binds)
224
+ # For DuckDB, use positional parameters instead of named parameters
225
+ bind_values = binds.map do |bind|
226
+ if bind.respond_to?(:value_for_database)
227
+ bind.value_for_database
228
+ elsif bind.is_a?(Symbol)
229
+ bind.to_s
230
+ else
231
+ bind
232
+ end
233
+ end
234
+ raw_connection.query(sql, *bind_values)
235
+ end
236
+
237
+ # Parses SQL type information to extract Rails type, limit, precision, and scale
238
+ # @param sql_type [String] The SQL type string to parse
239
+ # @return [Array] Array containing [type, limit, precision, scale]
240
+ def parse_type_info(sql_type)
241
+ case sql_type.to_s.upcase
242
+ when /^INTEGER(\((\d+),(\d+)\))?/i
243
+ precision, scale = ::Regexp.last_match(2)&.to_i, ::Regexp.last_match(3)&.to_i
244
+ [:integer, nil, precision, scale]
245
+ when /^VARCHAR(\((\d+)\))?/i, /^TEXT/i
246
+ limit = ::Regexp.last_match(2)&.to_i
247
+ [:string, limit, nil, nil]
248
+ when /^DOUBLE/i, /^REAL/i
249
+ [:float, nil, nil, nil]
250
+ when /^BOOLEAN/i
251
+ [:boolean, nil, nil, nil]
252
+ when /^DATE$/i
253
+ [:date, nil, nil, nil]
254
+ when /^TIMESTAMP/i, /^DATETIME/i
255
+ [:datetime, nil, nil, nil]
256
+ when /^DECIMAL(\((\d+),(\d+)\))?/i, /^NUMERIC(\((\d+),(\d+)\))?/i
257
+ precision, scale = ::Regexp.last_match(2)&.to_i, ::Regexp.last_match(3)&.to_i
258
+ [:decimal, nil, precision, scale]
259
+ else
260
+ [:string, nil, nil, nil] # Default fallback
261
+ end
262
+ end
263
+
264
+ # Extracts and converts default values from DuckDB column defaults
265
+ # @param default [String, nil] The default value from column definition
266
+ # @return [Object, nil] The converted default value
267
+ def extract_value_from_default(default)
268
+ return nil if default.nil?
269
+
270
+ # IMPORTANT: Return nil for sequence defaults so Rails doesn't set id=0
271
+ return nil if default.to_s.include?('nextval(')
272
+
273
+ # Handle DuckDB default value formats
274
+ case default.to_s
275
+ when /^'(.*)'$/
276
+ ::Regexp.last_match(1) # Remove quotes from string defaults
277
+ when 'NULL'
278
+ nil
279
+ when /^\d+$/
280
+ default.to_i # Integer defaults
281
+ when /^\d+\.\d+$/
282
+ default.to_f # Float defaults
283
+ else
284
+ default
285
+ end
286
+ end
287
+
288
+ # Substitutes bind parameters in SQL with quoted values
289
+ # @param sql [String] The SQL string with bind placeholders
290
+ # @param binds [Array] Array of bind parameters
291
+ # @return [String] SQL with substituted values
292
+ def substitute_binds(sql, binds)
293
+ bind_index = 0
294
+ sql.gsub('?') do
295
+ value = quote(binds[bind_index].value)
296
+ bind_index += 1
297
+ value
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Duckdb
6
+ # DuckDB-specific database tasks for database lifecycle management
7
+ # Provides stub implementations for database creation and destruction operations
8
+ module DatabaseTasks
9
+ # Creates a DuckDB database (stub implementation)
10
+ # @param database [String] The database name or path
11
+ # @param options [Hash] Additional options for database creation (unused)
12
+ # @return [void]
13
+ def create_database(database, options = {}); end
14
+
15
+ # Drops a DuckDB database (stub implementation)
16
+ # @param database [String] The database name or path
17
+ # @return [void]
18
+ def drop_database(database); end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Duckdb
6
+ module Quoting
7
+ extend ActiveSupport::Concern
8
+
9
+ module ClassMethods
10
+ # Quotes a column name for use in SQL statements
11
+ # @param name [String, Symbol] The column name to quote
12
+ # @return [String] The quoted column name wrapped in double quotes
13
+ def quote_column_name(name)
14
+ %("#{name}")
15
+ end
16
+ end
17
+
18
+ # Quotes a table name for use in SQL statements
19
+ # @param name [String, Symbol] The table name to quote
20
+ # @return [String] The quoted table name (delegates to quote_column_name)
21
+
22
+ def quote_table_name(name)
23
+ quote_column_name(name)
24
+ end
25
+
26
+ # Quotes a column name for use in SQL statements
27
+ # @param name [String, Symbol] The column name to quote
28
+ # @return [String] The quoted column name wrapped in double quotes
29
+ def quote_column_name(name)
30
+ %("#{name}")
31
+ end
32
+
33
+ # Quotes a value for safe inclusion in SQL statements
34
+ # @param value [Object] The value to quote
35
+ # @return [String] The appropriately quoted value for SQL
36
+ def quote(value)
37
+ case value
38
+ when String
39
+ "'#{value.gsub("'", "''")}'"
40
+ when nil
41
+ 'NULL'
42
+ when true
43
+ 'TRUE'
44
+ when false
45
+ 'FALSE'
46
+ when Numeric
47
+ value.to_s
48
+ when Time, DateTime
49
+ "'#{value.utc.strftime("%Y-%m-%d %H:%M:%S")}'"
50
+ when Date
51
+ "'#{value.strftime("%Y-%m-%d")}'"
52
+ else
53
+ "'#{value.to_s.gsub("'", "''")}'"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Duckdb
6
+ # DuckDB-specific schema creation functionality
7
+ # Extends Rails' SchemaCreation to handle DuckDB-specific SQL generation
8
+ class SchemaCreation < SchemaCreation
9
+ private
10
+
11
+ # Indicates whether DuckDB supports USING clause in index creation
12
+ # No USING clause is supported - DuckDB automatically determines the appropriate index type.
13
+ # https://duckdb.org/docs/stable/sql/statements/create_index.html
14
+ # https://duckdb.org/docs/stable/sql/indexes.html
15
+ # @return [Boolean] always returns false as DuckDB doesn't support USING clause
16
+ def supports_index_using?
17
+ false
18
+ end
19
+
20
+ # Adds column options to SQL, with special handling for DuckDB sequence defaults
21
+ # Override to handle nextval() defaults properly for DuckDB sequences
22
+ # @param sql [String] The SQL string being built
23
+ # @param options [Hash] Column options hash
24
+ # @return [void]
25
+ def add_column_options!(sql, options)
26
+ # Handle nextval() function calls - don't quote them
27
+ if options[:default]&.to_s&.include?('nextval(')
28
+ # Extract the sequence name and add it properly
29
+ default_value = options[:default].to_s
30
+ # Remove any extra quotes that might have been added
31
+ default_value = default_value.gsub(/^['"]|['"]$/, '')
32
+ sql << " DEFAULT #{default_value}"
33
+ # Create a copy of options without the default to prevent Rails from processing it again
34
+ options = options.except(:default)
35
+ end
36
+
37
+ # Let Rails handle all other column options normally
38
+ super
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/abstract/schema_definitions'
4
+
5
+ module ActiveRecord
6
+ module ConnectionAdapters
7
+ module Duckdb
8
+ # DuckDB-specific column method definitions for table creation
9
+ # Provides both standard Rails column types and DuckDB-specific data types
10
+ module ColumnMethods
11
+ extend ActiveSupport::Concern
12
+ extend ConnectionAdapters::ColumnMethods::ClassMethods
13
+
14
+ define_column_methods(
15
+ # binary
16
+ :blob,
17
+ # Integer variants
18
+ :tinyint, # TINYINT (1 byte: -128 to 127)
19
+ :smallint, # SMALLINT (2 bytes: -32,768 to 32,767)
20
+ :hugeint, # HUGEINT (16 bytes: very large integers)
21
+ :varint, # VARINT (variable precision integer, up to 1.2M digits)
22
+ # Unsigned integer variants
23
+ :utinyint, # UTINYINT (1 byte: 0 to 255)
24
+ :usmallint, # USMALLINT (2 bytes: 0 to 65,535)
25
+ :uinteger, # UINTEGER (4 bytes: 0 to 4,294,967,295)
26
+ :ubigint, # UBIGINT (8 bytes: 0 to 18,446,744,073,709,551,615)
27
+ :uhugeint, # UHUGEINT (16 bytes: 0 to 2^128-1)
28
+ # Special data types
29
+ :uuid, # UUID type for unique identifiers
30
+ :interval, # INTERVAL for time periods
31
+ :bit, # BIT for bit strings
32
+ # Complex/nested types
33
+ :list, # LIST (variable-length array)
34
+ :struct, # STRUCT (composite type with named fields)
35
+ :map, # MAP (key-value pairs)
36
+ :union, # UNION (value can be one of several types)
37
+ :enum # ENUM (predefined set of values)
38
+ # JSON can't be a column type, but can be a queried type of data
39
+ # :json # JSON documents (check DuckDB version compatibility)
40
+ )
41
+
42
+ alias binary blob
43
+
44
+ # Creates a LIST column with specified element type
45
+ # @param name [String, Symbol] The column name
46
+ # @param element_type [Symbol] The type of elements in the list (default: :string)
47
+ # @return [void]
48
+ # @example Create a list of strings
49
+ # t.list :tags, element_type: :string
50
+ def list(name, element_type: :string, **)
51
+ column(name, "#{element_type.to_s.upcase}[]", **)
52
+ end
53
+
54
+ # Creates a STRUCT column with named fields
55
+ # @param name [String, Symbol] The column name
56
+ # @param fields [Hash] Hash mapping field names to their types (default: {})
57
+ # @return [void]
58
+ # @example Create an address struct
59
+ # t.struct :address, fields: { street: :string, city: :string, zip: :integer }
60
+ def struct(name, fields: {}, **)
61
+ field_definitions = fields.map { |field_name, field_type| "#{field_name} #{field_type.to_s.upcase}" }
62
+ column(name, "STRUCT(#{field_definitions.join(", ")})", **)
63
+ end
64
+
65
+ # Creates a MAP column with specified key and value types
66
+ # @param name [String, Symbol] The column name
67
+ # @param key_type [Symbol] The type of map keys (default: :string)
68
+ # @param value_type [Symbol] The type of map values (default: :string)
69
+ # @return [void]
70
+ # @example Create a string-to-string map
71
+ # t.map :metadata, key_type: :string, value_type: :string
72
+ def map(name, key_type: :string, value_type: :string, **)
73
+ column(name, "MAP(#{key_type.to_s.upcase}, #{value_type.to_s.upcase})", **)
74
+ end
75
+
76
+ # Creates an ENUM column with predefined values
77
+ # @param name [String, Symbol] The column name
78
+ # @param values [Array] Array of allowed enum values (default: [])
79
+ # @return [void]
80
+ # @example Create a status enum
81
+ # t.enum :status, values: ['active', 'inactive', 'pending']
82
+ def enum(name, values: [], **)
83
+ enum_values = values.map { |v| "'#{v}'" }.join(', ')
84
+ column(name, "ENUM(#{enum_values})", **)
85
+ end
86
+ end
87
+
88
+ # DuckDB-specific table definition for CREATE TABLE statements
89
+ # Extends Rails' TableDefinition with DuckDB column types and features
90
+ class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
91
+ include Duckdb::ColumnMethods
92
+
93
+ # Initialize a new DuckDB table definition
94
+ # @param conn [ActiveRecord::ConnectionAdapters::DuckdbAdapter] The database adapter
95
+ # @param name [String, Symbol] The table name
96
+ # @param temporary [Boolean] Whether this is a temporary table
97
+ # @param if_not_exists [Boolean] Whether to use IF NOT EXISTS clause
98
+ # @param options [Hash, nil] Additional table options
99
+ # @param as [String, nil] SELECT statement for CREATE TABLE AS
100
+ # @param comment [String, nil] Table comment
101
+ # @param table_options [Hash] Additional keyword table options
102
+ def initialize(conn, name, temporary: false, if_not_exists: false,
103
+ options: nil, as: nil, comment: nil, **table_options)
104
+ super
105
+ @conn = conn
106
+ @table_name = name
107
+ end
108
+
109
+ # Creates a column definition for the table
110
+ # Note: sequence defaults are handled by ALTER TABLE after table creation
111
+ # @param name [String, Symbol] The column name
112
+ # @param type [Symbol] The column type
113
+ # @param index [Boolean, Hash, nil] Whether to create an index on this column
114
+ # @param options [Hash] Additional column options
115
+ # @return [void]
116
+ def column(name, type, index: nil, **options)
117
+ # Don't set sequence defaults here - they're handled in create_table via ALTER TABLE
118
+ super
119
+ end
120
+
121
+ # Creates a primary key column definition
122
+ # Note: sequence defaults are handled by ALTER TABLE after table creation
123
+ # @param name [String, Symbol] The primary key column name
124
+ # @param type [Symbol] The primary key column type (default: :primary_key)
125
+ # @param options [Hash] Additional column options
126
+ # @return [void]
127
+ def primary_key(name, type = :primary_key, **options)
128
+ # Don't set sequence defaults here - they're handled in create_table via ALTER TABLE
129
+ super
130
+ end
131
+ end
132
+
133
+ # DuckDB-specific table modification for ALTER TABLE statements
134
+ # Extends Rails' Table with DuckDB column types and features
135
+ class Table < ActiveRecord::ConnectionAdapters::Table
136
+ include Duckdb::ColumnMethods
137
+ end
138
+
139
+ # DuckDB-specific table alteration functionality
140
+ # Extends Rails' AlterTable for DuckDB-specific schema changes
141
+ class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable
142
+ end
143
+ end
144
+ end
145
+ end