Skip to content

Commit 549a4ae

Browse files
committed
Update core DuckDB adapter implementation
- Update database_statements.rb transaction support and query execution, default to using @raw_connection, and add support for binding params - Add schema_statements.rb DDL operations (CREATE/ALTER/DROP table support) - BREAKING: Remove legacy Railtie approach and use modern ActiveRecord::ConnectionAdapters.register approach for registering gem - Add YARD documentation to make it clear which files were implementing necessary methods - Default to writing DuckDB file to disk - Validated lifecycle of creating and deploying databases and tested bundle exec rails db:drop db:create db:migrate db:seed db:reset - Use DuckDB's information_schema for accessing meta data - Default primary ids as bigints
1 parent b247666 commit 549a4ae

File tree

6 files changed

+804
-116
lines changed

6 files changed

+804
-116
lines changed

lib/active_record/connection_adapters/duckdb/database_statements.rb

Lines changed: 270 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,289 @@
33
module ActiveRecord
44
module ConnectionAdapters
55
module Duckdb
6-
module DatabaseStatements # :nodoc:
7-
def write_query?(sql) # :nodoc:
8-
false
6+
module DatabaseStatements
7+
8+
# @override
9+
# @note Implements AbstractAdapter interface method
10+
# @param [String] sql SQL to execute
11+
# @param [String, nil] name Query name for logging
12+
# @param [Boolean] allow_retry Whether to allow retry on failure
13+
# @return [Object] Query result
14+
def execute(sql, name = nil, allow_retry: false)
15+
internal_execute(sql, name, allow_retry: allow_retry)
16+
end
17+
18+
# @note internal execution wrapper for DuckDB
19+
# @param [String] sql SQL to execute
20+
# @param [String] name Query name for logging
21+
# @param [Array] binds Bind parameters
22+
# @param [Boolean] prepare Whether to prepare statement
23+
# @param [Boolean] async Whether to execute asynchronously
24+
# @param [Boolean] allow_retry Whether to allow retry on failure
25+
# @param [Boolean] materialize_transactions Whether to materialize transactions
26+
# @return [Object] Query result
27+
def internal_execute(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, &block)
28+
raw_execute(sql, name, binds, prepare: prepare, async: async, allow_retry: allow_retry, materialize_transactions: materialize_transactions, &block)
929
end
1030

11-
def execute(sql, name = nil) # :nodoc:
12-
sql = transform_query(sql)
31+
# @override
32+
# @note Implements AbstractAdapter interface method - These methods need to return integers for update_all and delete_all
33+
# @param [Object] arel Arel object or SQL string
34+
# @param [String, nil] name Query name for logging
35+
# @param [Array] binds Bind parameters
36+
# @return [Integer] Number of affected rows
37+
def update(arel, name = nil, binds = [])
38+
sql, binds = to_sql_and_binds(arel, binds)
39+
result = internal_execute(sql, name, binds)
40+
extract_row_count(result, sql)
41+
end
42+
43+
# @override
44+
# @note Implements AbstractAdapter interface method - These methods need to return integers for update_all and delete_all
45+
# @param [Object] arel Arel object or SQL string
46+
# @param [String, nil] name Query name for logging
47+
# @param [Array] binds Bind parameters
48+
# @return [Integer] Number of affected rows
49+
def delete(arel, name = nil, binds = [])
50+
sql, binds = to_sql_and_binds(arel, binds)
51+
result = internal_execute(sql, name, binds)
52+
extract_row_count(result, sql)
53+
end
1354

14-
log(sql, name) do
15-
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
16-
@connection.query(sql)
55+
# @override
56+
# @note Implements AbstractAdapter interface method
57+
# @param [String] sql SQL to execute
58+
# @param [String] name Query name for logging
59+
# @param [Array] binds Bind parameters
60+
# @param [Boolean] prepare Whether to prepare statement
61+
# @param [Boolean] async Whether to execute asynchronously
62+
# @param [Boolean] allow_retry Whether to allow retry on failure
63+
# @param [Boolean] materialize_transactions Whether to materialize transactions
64+
# @return [ActiveRecord::Result] Query result as ActiveRecord::Result
65+
def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true)
66+
result = internal_execute(sql, name, binds, prepare: prepare, async: async, allow_retry: allow_retry, materialize_transactions: materialize_transactions)
67+
68+
# Convert DuckDB result to ActiveRecord::Result
69+
raw_cols = result.columns || []
70+
cols = raw_cols.map { |col| col.respond_to?(:name) ? col.name : col.to_s }
71+
rows = result.to_a || []
72+
73+
ActiveRecord::Result.new(cols, rows)
74+
end
75+
76+
# @note raw execution for DuckDB
77+
# @param [String] sql SQL to execute
78+
# @param [String, nil] name Query name for logging
79+
# @param [Array] binds Bind parameters
80+
# @param [Boolean] prepare Whether to prepare statement
81+
# @param [Boolean] async Whether to execute asynchronously
82+
# @param [Boolean] allow_retry Whether to allow retry on failure
83+
# @param [Boolean] materialize_transactions Whether to materialize transactions
84+
# @param [Boolean] batch Whether to execute in batch mode
85+
# @return [Object] Query result
86+
def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, batch: false)
87+
type_casted_binds = type_casted_binds(binds)
88+
log(sql, name, binds, type_casted_binds, async: async) do |notification_payload|
89+
with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
90+
perform_query(conn, sql, binds, type_casted_binds, prepare: prepare, notification_payload: notification_payload, batch: batch)
1791
end
1892
end
1993
end
2094

21-
def exec_query(sql, name = nil, binds = [], prepare: false, async: false) # :nodoc:
22-
result = execute_and_clear(sql, name, binds, prepare: prepare, async: async)
95+
# @note DuckDB-specific query execution
96+
# @param [Object] raw_connection Raw database connection
97+
# @param [String] sql SQL to execute
98+
# @param [Array] binds Bind parameters
99+
# @param [Array] type_casted_binds Type-casted bind parameters
100+
# @param [Boolean] prepare Whether to prepare statement
101+
# @param [Object] notification_payload Notification payload for logging
102+
# @param [Boolean] batch Whether to execute in batch mode
103+
# @return [Object] Query result
104+
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false)
105+
# Use DuckDB's native parameter binding - clean and secure
106+
bind_values = extract_bind_values(type_casted_binds, binds)
107+
108+
if bind_values&.any?
109+
@raw_connection.query(sql, *bind_values)
110+
else
111+
@raw_connection.query(sql)
112+
end
113+
end
23114

24-
# TODO: https://github.com/suketa/ruby-duckdb/issues/168
25-
# build_result(columns: result.columns, rows: result.to_a)
26-
if result.to_a.first&.size == 1
27-
build_result(columns: ['count'], rows: result.to_a)
28-
elsif result.to_a.first&.size == 2
29-
build_result(columns: ['id', 'name'], rows: result.to_a)
115+
# @override
116+
# @note Implements AbstractAdapter interface method
117+
# @param [String] sql SQL to execute
118+
# @param [String, nil] name Query name for logging
119+
# @return [Object] Query result
120+
def query(sql, name = nil)
121+
result = internal_execute(sql, name)
122+
result
123+
end
124+
125+
# @override
126+
# @note Implements AbstractAdapter interface method
127+
# @param [String] sql SQL to explain
128+
# @return [String] Pretty-printed explanation
129+
def explain(sql)
130+
result = internal_exec_query(sql, "EXPLAIN")
131+
Duckdb::ExplainPrettyPrinter.new.pp(result)
132+
end
133+
134+
# @override
135+
# @note Implements AbstractAdapter interface method - Executes an INSERT statement and returns the ID of the newly inserted record
136+
# @param [String] sql INSERT SQL to execute
137+
# @param [String, nil] name Query name for logging
138+
# @param [Array] binds Bind parameters
139+
# @param [String, nil] pk Primary key column name
140+
# @param [String, nil] sequence_name Sequence name for auto-increment
141+
# @param [String, nil] returning RETURNING clause
142+
# @return [ActiveRecord::Result] Result containing inserted ID
143+
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil)
144+
if pk && supports_insert_returning?
145+
# Use INSERT...RETURNING to get the inserted ID
146+
returning_sql = sql.sub(/\bINSERT\b/i, "INSERT").concat(" RETURNING #{quote_column_name(pk)}")
147+
internal_exec_query(returning_sql, name, binds)
30148
else
31-
build_result(columns: ['id', 'author', 'title', 'body', 'count'], rows: result.to_a)
149+
# Regular insert - return result from internal_execute
150+
internal_execute(sql, name, binds)
151+
# Return an empty result since we don't have the ID
152+
ActiveRecord::Result.new([], [])
32153
end
33154
end
34155

35-
def exec_delete(sql, name = nil, binds = []) # :nodoc:
36-
result = execute_and_clear(sql, name, binds)
37-
result.rows_changed
156+
private
157+
158+
# @note extract row count from DuckDB result
159+
# @param [Object] result Query result
160+
# @param [String] sql Original SQL query
161+
# @return [Integer] Number of affected rows
162+
def extract_row_count(result, sql)
163+
if result.respond_to?(:to_a)
164+
rows = result.to_a
165+
if rows.length == 1 && rows[0].length == 1
166+
count = rows[0][0]
167+
return count.is_a?(Integer) ? count : count.to_i
168+
end
169+
end
170+
0
38171
end
39-
alias :exec_update :exec_delete
172+
173+
# @note convert Arel to SQL string
174+
# @param [Object] arel Arel object or SQL string
175+
# @param [Array] binds Bind parameters (unused)
176+
# @return [String] SQL string
177+
def to_sql(arel, binds = [])
178+
if arel.respond_to?(:to_sql)
179+
arel.to_sql
180+
else
181+
arel.to_s
182+
end
183+
end
184+
185+
# @note Convert Arel to SQL and extract bind parameters
186+
# @param [Object] arel_or_sql_string Arel object or SQL string
187+
# @param [Array] binds Bind parameters
188+
# @param [Array] args Additional arguments
189+
# @return [Array] Array containing SQL string and bind parameters
190+
def to_sql_and_binds(arel_or_sql_string, binds = [], *args)
191+
# For simple cases, delegate to parent implementation if it exists
192+
if defined?(super)
193+
begin
194+
return super(arel_or_sql_string, binds, *args)
195+
rescue NoMethodError
196+
# Fall through to our implementation
197+
end
198+
end
199+
200+
# Our simplified implementation for basic cases
201+
if arel_or_sql_string.respond_to?(:ast)
202+
# For Arel objects, visit the AST to get SQL and collect binds
203+
visitor = arel_visitor
204+
collector = Arel::Collectors::SQLString.new
205+
visitor.accept(arel_or_sql_string.ast, collector)
206+
sql = collector.value
207+
208+
# Extract binds from the visitor if it collected them
209+
visitor_binds = if visitor.respond_to?(:binds)
210+
visitor.binds
211+
else
212+
[]
213+
end
214+
215+
result = [sql, binds + visitor_binds]
216+
# Add any additional args back to maintain signature compatibility
217+
args.each { |arg| result << arg }
218+
result
219+
elsif arel_or_sql_string.respond_to?(:to_sql)
220+
# For objects with to_sql method, use it directly
221+
result = [arel_or_sql_string.to_sql, binds]
222+
args.each { |arg| result << arg }
223+
result
224+
else
225+
# For plain strings, return as-is
226+
result = [arel_or_sql_string.to_s, binds]
227+
args.each { |arg| result << arg }
228+
result
229+
end
230+
end
231+
232+
# @note get Arel visitor for SQL generation
233+
# @return [Object] Arel visitor instance
234+
def arel_visitor
235+
connection_pool.get_schema_cache(connection).arel_visitor
236+
rescue
237+
# Fallback for older ActiveRecord versions or if schema cache is not available
238+
Arel::Visitors::ToSql.new(self)
239+
end
240+
241+
# @override
242+
# @note Implements AbstractAdapter interface method - ActiveRecord calls this method to get properly type-cast bind parameters
243+
# @param [Array] binds Array of bind parameters
244+
# @return [Array] Array of type-cast values
245+
def type_casted_binds(binds)
246+
if binds.empty?
247+
[]
248+
else
249+
binds.map do |attr|
250+
if attr.respond_to?(:value_for_database)
251+
value = attr.value_for_database
252+
# Handle ActiveRecord timestamp value objects that DuckDB doesn't understand
253+
if value.class.name == 'ActiveRecord::Type::Time::Value'
254+
# Convert to a proper Time object that DuckDB can handle
255+
Time.parse(value.to_s)
256+
else
257+
value
258+
end
259+
else
260+
type_cast(attr)
261+
end
262+
end
263+
end
264+
end
265+
266+
# @note extract bind values for DuckDB parameter binding
267+
# @param [Array] type_casted_binds Type-casted bind parameters
268+
# @param [Array] binds Original bind parameters
269+
# @return [Array, nil] Array of bind values or nil if none
270+
def extract_bind_values(type_casted_binds, binds)
271+
# Prefer type_casted_binds as they are pre-processed by ActiveRecord
272+
return type_casted_binds if type_casted_binds&.any?
273+
274+
# Extract values from bind objects if no type_casted_binds
275+
return nil unless binds&.any?
276+
277+
binds.map do |bind|
278+
case bind
279+
when Array
280+
# [column, value] format
281+
bind[1]
282+
else
283+
# Extract value from attribute objects or use direct value
284+
bind.respond_to?(:value) ? bind.value : bind
285+
end
286+
end
287+
end
288+
40289
end
41290
end
42291
end

0 commit comments

Comments
 (0)