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.
@@ -0,0 +1,197 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ activerecord-duckdb (0.1.0)
5
+ activerecord (>= 7.0.0)
6
+ duckdb (>= 1.3, < 2.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ activemodel (8.1.2)
12
+ activesupport (= 8.1.2)
13
+ activerecord (8.1.2)
14
+ activemodel (= 8.1.2)
15
+ activesupport (= 8.1.2)
16
+ timeout (>= 0.4.0)
17
+ activesupport (8.1.2)
18
+ base64
19
+ bigdecimal
20
+ concurrent-ruby (~> 1.0, >= 1.3.1)
21
+ connection_pool (>= 2.2.5)
22
+ drb
23
+ i18n (>= 1.6, < 2)
24
+ json
25
+ logger (>= 1.4.2)
26
+ minitest (>= 5.1)
27
+ securerandom (>= 0.3)
28
+ tzinfo (~> 2.0, >= 2.0.5)
29
+ uri (>= 0.13.1)
30
+ appraisal (2.5.0)
31
+ bundler
32
+ rake
33
+ thor (>= 0.14.0)
34
+ ast (2.4.3)
35
+ base64 (0.3.0)
36
+ bigdecimal (4.0.1)
37
+ commander (5.0.0)
38
+ highline (~> 3.0.0)
39
+ concurrent-ruby (1.3.6)
40
+ connection_pool (3.0.2)
41
+ date (3.5.1)
42
+ debug (1.11.1)
43
+ irb (~> 1.10)
44
+ reline (>= 0.3.8)
45
+ diff-lcs (1.6.2)
46
+ docile (1.4.1)
47
+ drb (2.2.3)
48
+ duckdb (1.4.3.0)
49
+ bigdecimal (>= 3.1.4)
50
+ erb (6.0.1)
51
+ fasterer (0.11.0)
52
+ ruby_parser (>= 3.19.1)
53
+ fuubar (2.5.1)
54
+ rspec-core (~> 3.0)
55
+ ruby-progressbar (~> 1.4)
56
+ highline (3.0.1)
57
+ i18n (1.14.8)
58
+ concurrent-ruby (~> 1.0)
59
+ io-console (0.8.2)
60
+ irb (1.16.0)
61
+ pp (>= 0.6.0)
62
+ rdoc (>= 4.0.0)
63
+ reline (>= 0.4.2)
64
+ json (2.18.0)
65
+ language_server-protocol (3.17.0.5)
66
+ lint_roller (1.1.0)
67
+ logger (1.7.0)
68
+ minitest (6.0.1)
69
+ prism (~> 1.5)
70
+ parallel (1.27.0)
71
+ parlour (9.1.2)
72
+ commander (~> 5.0)
73
+ parser
74
+ rainbow (~> 3.0)
75
+ sorbet-runtime (>= 0.5)
76
+ parser (3.3.10.0)
77
+ ast (~> 2.4.1)
78
+ racc
79
+ pp (0.6.3)
80
+ prettyprint
81
+ prettyprint (0.2.0)
82
+ prism (1.8.0)
83
+ psych (5.3.1)
84
+ date
85
+ stringio
86
+ racc (1.8.1)
87
+ rainbow (3.1.1)
88
+ rake (13.3.1)
89
+ rbs (3.10.2)
90
+ logger
91
+ rdoc (7.1.0)
92
+ erb
93
+ psych (>= 4.0.0)
94
+ tsort
95
+ regexp_parser (2.11.3)
96
+ reline (0.6.3)
97
+ io-console (~> 0.5)
98
+ rspec (3.13.2)
99
+ rspec-core (~> 3.13.0)
100
+ rspec-expectations (~> 3.13.0)
101
+ rspec-mocks (~> 3.13.0)
102
+ rspec-core (3.13.6)
103
+ rspec-support (~> 3.13.0)
104
+ rspec-expectations (3.13.5)
105
+ diff-lcs (>= 1.2.0, < 2.0)
106
+ rspec-support (~> 3.13.0)
107
+ rspec-mocks (3.13.7)
108
+ diff-lcs (>= 1.2.0, < 2.0)
109
+ rspec-support (~> 3.13.0)
110
+ rspec-support (3.13.6)
111
+ rubocop (1.82.1)
112
+ json (~> 2.3)
113
+ language_server-protocol (~> 3.17.0.2)
114
+ lint_roller (~> 1.1.0)
115
+ parallel (~> 1.10)
116
+ parser (>= 3.3.0.2)
117
+ rainbow (>= 2.2.2, < 4.0)
118
+ regexp_parser (>= 2.9.3, < 3.0)
119
+ rubocop-ast (>= 1.48.0, < 2.0)
120
+ ruby-progressbar (~> 1.7)
121
+ unicode-display_width (>= 2.4.0, < 4.0)
122
+ rubocop-ast (1.49.0)
123
+ parser (>= 3.3.7.2)
124
+ prism (~> 1.7)
125
+ rubocop-performance (1.26.1)
126
+ lint_roller (~> 1.1)
127
+ rubocop (>= 1.75.0, < 2.0)
128
+ rubocop-ast (>= 1.47.1, < 2.0)
129
+ rubocop-rake (0.7.1)
130
+ lint_roller (~> 1.1)
131
+ rubocop (>= 1.72.1)
132
+ rubocop-rspec (3.9.0)
133
+ lint_roller (~> 1.1)
134
+ rubocop (~> 1.81)
135
+ rubocop-thread_safety (0.7.3)
136
+ lint_roller (~> 1.1)
137
+ rubocop (~> 1.72, >= 1.72.1)
138
+ rubocop-ast (>= 1.44.0, < 2.0)
139
+ ruby-progressbar (1.13.0)
140
+ ruby_parser (3.22.0)
141
+ racc (~> 1.5)
142
+ sexp_processor (~> 4.16)
143
+ securerandom (0.4.1)
144
+ sexp_processor (4.17.5)
145
+ simplecov (0.22.0)
146
+ docile (~> 1.1)
147
+ simplecov-html (~> 0.11)
148
+ simplecov_json_formatter (~> 0.1)
149
+ simplecov-html (0.13.2)
150
+ simplecov-tailwindcss (2.3.0)
151
+ simplecov (~> 0.16)
152
+ simplecov_json_formatter (0.1.4)
153
+ sorbet-runtime (0.6.12885)
154
+ sord (7.1.0)
155
+ commander (~> 5.0)
156
+ parlour (~> 9.1)
157
+ parser
158
+ rbs (>= 3.0, < 5)
159
+ sorbet-runtime
160
+ yard
161
+ stringio (3.2.0)
162
+ thor (1.5.0)
163
+ timeout (0.6.0)
164
+ tsort (0.2.0)
165
+ tzinfo (2.0.6)
166
+ concurrent-ruby (~> 1.0)
167
+ unicode-display_width (3.2.0)
168
+ unicode-emoji (~> 4.1)
169
+ unicode-emoji (4.2.0)
170
+ uri (1.1.1)
171
+ yard (0.9.38)
172
+
173
+ PLATFORMS
174
+ arm64-darwin-23
175
+ ruby
176
+
177
+ DEPENDENCIES
178
+ activerecord (~> 8.1.0)
179
+ activerecord-duckdb!
180
+ appraisal
181
+ debug
182
+ fasterer
183
+ fuubar
184
+ irb
185
+ rake (~> 13.0)
186
+ rspec (~> 3.13)
187
+ rubocop (~> 1.76)
188
+ rubocop-performance (~> 1.25)
189
+ rubocop-rake (~> 0.7)
190
+ rubocop-rspec (~> 3.6)
191
+ rubocop-thread_safety
192
+ simplecov
193
+ simplecov-tailwindcss
194
+ sord
195
+
196
+ BUNDLED WITH
197
+ 2.6.9
@@ -5,64 +5,71 @@ module ActiveRecord
5
5
  module ConnectionAdapters
6
6
  module Duckdb
7
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
8
+ # SQL statements that are considered read-only (SELECT, EXPLAIN, etc.)
9
+ READ_QUERY_PATTERN = /\A\s*(SELECT|SHOW|DESCRIBE|EXPLAIN|PRAGMA)\b/i
10
+
11
+ # Begins a database transaction.
12
+ # @return [void]
13
+ def begin_db_transaction
14
+ log('BEGIN', 'TRANSACTION') do
15
+ with_raw_connection do |conn|
16
+ conn.query('BEGIN TRANSACTION')
17
+ end
18
+ end
13
19
  end
14
20
 
15
- # Executes a SQL statement against the DuckDB database
21
+ # Commits the current database transaction.
22
+ # @return [void]
23
+ def commit_db_transaction
24
+ log('COMMIT', 'TRANSACTION') do
25
+ with_raw_connection do |conn|
26
+ conn.query('COMMIT')
27
+ end
28
+ end
29
+ end
30
+
31
+ # Rolls back the current database transaction.
32
+ # @return [void]
33
+ def exec_rollback_db_transaction
34
+ log('ROLLBACK', 'TRANSACTION') do
35
+ with_raw_connection do |conn|
36
+ conn.query('ROLLBACK')
37
+ end
38
+ end
39
+ end
40
+
41
+ # Determines if a SQL query is a write operation (INSERT, UPDATE, DELETE, etc.)
42
+ # Used for read replica support and transaction tracking.
43
+ # @param sql [String] The SQL query to check
44
+ # @return [Boolean] true if the query modifies data
45
+ def write_query?(sql)
46
+ !READ_QUERY_PATTERN.match?(sql)
47
+ end
48
+
49
+ # Executes a SQL statement against the DuckDB database.
50
+ # Used for DDL and raw SQL execution.
16
51
  # @param sql [String] The SQL statement to execute
17
52
  # @param name [String, nil] Optional name for logging purposes
18
53
  # @return [DuckDB::Result] The result of the query execution
19
54
  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
55
+ # Check for write queries on read-only connections (replica support)
56
+ # Rails 8.1+ uses ensure_writes_are_allowed, earlier versions use check_if_write_query
57
+ ensure_write_query_allowed(sql)
22
58
 
23
59
  log(sql, name) do
24
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
25
- raw_connection.query(sql)
60
+ with_raw_connection do |conn|
61
+ conn.query(sql)
26
62
  end
27
63
  end
28
64
  end
29
65
 
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
66
+ # Casts a DuckDB result to ActiveRecord::Result format.
67
+ # Used by Rails 8.0+ internal_exec_query which calls cast_result(raw_execute(...)).
68
+ # Also called by Rails 7.2's internal_exec_query implementation in DatabaseStatementsRails72.
62
69
  # @param result [DuckDB::Result, nil] The DuckDB result to cast
63
70
  # @return [ActiveRecord::Result] The ActiveRecord-compatible result
64
71
  def cast_result(result)
65
- return ActiveRecord::Result.empty(affected_rows: @affected_rows_before_warnings) if result.nil?
72
+ return ActiveRecord::Result.empty if result.nil?
66
73
 
67
74
  columns = result.columns.map do |col|
68
75
  if col.respond_to?(:name)
@@ -77,72 +84,13 @@ module ActiveRecord
77
84
  ActiveRecord::Result.new(columns, result.to_a)
78
85
  end
79
86
 
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)
87
+ # Returns the number of affected rows from a raw DuckDB result.
88
+ # Required by Rails 8.0+ for exec_delete/exec_update via internal_execute.
89
+ # The base class calls affected_rows(raw_execute(...)) for DELETE/UPDATE operations.
90
+ # @param raw_result [DuckDB::Result] The raw DuckDB result
91
+ # @return [Integer] Number of rows affected
92
+ def affected_rows(raw_result)
93
+ raw_result.rows_changed
146
94
  end
147
95
 
148
96
  # Returns columns that should be included in INSERT statements
@@ -176,88 +124,21 @@ module ActiveRecord
176
124
  super
177
125
  end
178
126
 
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
127
  private
218
128
 
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]
129
+ # Ensures write queries are allowed on the current connection.
130
+ # Handles API differences between Rails versions:
131
+ # - Rails 8.1+: Uses ensure_writes_are_allowed
132
+ # - Rails 7.2-8.0: Uses check_if_write_query + mark_transaction_written_if_write
133
+ # @param sql [String] The SQL query to check
134
+ def ensure_write_query_allowed(sql)
135
+ if respond_to?(:ensure_writes_are_allowed, true)
136
+ # Rails 8.1+
137
+ ensure_writes_are_allowed(sql)
259
138
  else
260
- [:string, nil, nil, nil] # Default fallback
139
+ # Rails 7.2-8.0
140
+ check_if_write_query(sql)
141
+ mark_transaction_written_if_write(sql)
261
142
  end
262
143
  end
263
144
 
@@ -284,19 +165,6 @@ module ActiveRecord
284
165
  default
285
166
  end
286
167
  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
168
  end
301
169
  end
302
170
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails 7.2 specific database statements.
4
+ # In Rails 7.2, the base class internal_exec_query raises NotImplementedError,
5
+ # so we must provide a complete implementation.
6
+ module ActiveRecord
7
+ module ConnectionAdapters
8
+ module Duckdb
9
+ module DatabaseStatementsRails72
10
+ # Executes a query and returns an ActiveRecord::Result.
11
+ # Required for Rails 7.2 where the base class raises NotImplementedError.
12
+ #
13
+ # @param sql [String] The SQL query to execute
14
+ # @param name [String] Query name for logging
15
+ # @param binds [Array] Array of bind parameters
16
+ # @param prepare [Boolean] Whether to prepare the statement (unused - DuckDB doesn't cache)
17
+ # @param async [Boolean] Whether to execute asynchronously (unused)
18
+ # @param allow_retry [Boolean] Whether to allow retrying on failure
19
+ # @return [ActiveRecord::Result] The query result
20
+ def internal_exec_query(sql, name = 'SQL', binds = [], prepare: false, async: false, allow_retry: false)
21
+ # Check for write queries on read-only connections (replica support)
22
+ ensure_write_query_allowed(sql)
23
+
24
+ casted_binds = type_casted_binds(binds)
25
+
26
+ log(sql, name, binds, casted_binds, async:) do
27
+ with_raw_connection(allow_retry:) do |conn|
28
+ result = if casted_binds.empty?
29
+ conn.query(sql)
30
+ else
31
+ conn.query(sql, *casted_binds)
32
+ end
33
+ cast_result(result)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Executes a DELETE statement and returns the number of affected rows.
39
+ # Rails 7.2's base class returns ActiveRecord::Result, but we need an integer.
40
+ # DuckDB's DELETE returns the count in the result set as rows[0][0].
41
+ # @param sql [String] The DELETE SQL statement
42
+ # @param name [String, nil] Optional name for logging
43
+ # @param binds [Array] Array of bind parameters
44
+ # @return [Integer] Number of rows affected by the delete
45
+ def exec_delete(sql, name = nil, binds = [])
46
+ result = internal_exec_query(sql, name, binds)
47
+ result.rows.first&.first || 0
48
+ end
49
+ alias exec_update exec_delete
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails 8.0+ specific database statements.
4
+ # In Rails 8.0+, the base class internal_exec_query delegates to raw_execute + cast_result,
5
+ # so we implement raw_execute and let Rails handle the rest.
6
+ # This allows Rails' built-in query execution infrastructure (logging, retries, etc.) to work.
7
+ module ActiveRecord
8
+ module ConnectionAdapters
9
+ module Duckdb
10
+ module DatabaseStatementsRails8
11
+ # Executes raw SQL and returns the native DuckDB result.
12
+ # Called by Rails 8's internal_execute → raw_execute chain.
13
+ #
14
+ # @param sql [String] The SQL query to execute
15
+ # @param name [String] Query name for logging
16
+ # @param binds [Array] Array of bind parameters
17
+ # @param prepare [Boolean] Whether to use prepared statement (unused by DuckDB)
18
+ # @param async [Boolean] Whether to execute asynchronously (unused by DuckDB)
19
+ # @param allow_retry [Boolean] Whether to allow retrying on failure
20
+ # @param materialize_transactions [Boolean] Whether to materialize transactions
21
+ # @param batch [Boolean] Whether this is a batch operation (unused by DuckDB)
22
+ # @return [DuckDB::Result] The raw DuckDB result
23
+ def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, batch: false)
24
+ casted_binds = type_casted_binds(binds)
25
+
26
+ log(sql, name, binds, casted_binds, async: async) do
27
+ with_raw_connection(allow_retry:, materialize_transactions:) do |conn|
28
+ if casted_binds.empty?
29
+ conn.query(sql)
30
+ else
31
+ conn.query(sql, *casted_binds)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -58,7 +58,11 @@ module ActiveRecord
58
58
  # @example Create an address struct
59
59
  # t.struct :address, fields: { street: :string, city: :string, zip: :integer }
60
60
  def struct(name, fields: {}, **)
61
- field_definitions = fields.map { |field_name, field_type| "#{field_name} #{field_type.to_s.upcase}" }
61
+ # Quote field names as identifiers and validate field types
62
+ field_definitions = fields.map do |field_name, field_type|
63
+ quoted_name = %("#{field_name.to_s.gsub('"', '""')}")
64
+ "#{quoted_name} #{field_type.to_s.upcase}"
65
+ end
62
66
  column(name, "STRUCT(#{field_definitions.join(", ")})", **)
63
67
  end
64
68
 
@@ -80,7 +84,8 @@ module ActiveRecord
80
84
  # @example Create a status enum
81
85
  # t.enum :status, values: ['active', 'inactive', 'pending']
82
86
  def enum(name, values: [], **)
83
- enum_values = values.map { |v| "'#{v}'" }.join(', ')
87
+ # Escape single quotes in enum values to prevent SQL injection
88
+ enum_values = values.map { |v| "'#{v.to_s.gsub("'", "''")}'" }.join(', ')
84
89
  column(name, "ENUM(#{enum_values})", **)
85
90
  end
86
91
  end