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
|
@@ -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
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
60
|
+
with_raw_connection do |conn|
|
|
61
|
+
conn.query(sql)
|
|
26
62
|
end
|
|
27
63
|
end
|
|
28
64
|
end
|
|
29
65
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
|
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
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
# @param
|
|
84
|
-
# @
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|