|
3 | 3 | module ActiveRecord
|
4 | 4 | module ConnectionAdapters
|
5 | 5 | 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) |
9 | 29 | end
|
10 | 30 |
|
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 |
13 | 54 |
|
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) |
17 | 91 | end
|
18 | 92 | end
|
19 | 93 | end
|
20 | 94 |
|
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 |
23 | 114 |
|
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) |
30 | 148 | 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([], []) |
32 | 153 | end
|
33 | 154 | end
|
34 | 155 |
|
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 |
38 | 171 | 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 | + |
40 | 289 | end
|
41 | 290 | end
|
42 | 291 | end
|
|
0 commit comments