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,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Duckdb
|
|
6
|
+
# Rails 7.2 and 8.0 specific schema statement implementations.
|
|
7
|
+
# Column constructor: (name, default, sql_type_metadata, null, default_function, **options)
|
|
8
|
+
module SchemaStatementsRails80
|
|
9
|
+
# Creates a new Column object from DuckDB field information.
|
|
10
|
+
# @param table_name [String] The name of the table
|
|
11
|
+
# @param field [Array] Array containing column field information from PRAGMA table_info
|
|
12
|
+
# @param definitions [Hash] Additional column definitions (unused)
|
|
13
|
+
# @return [ActiveRecord::ConnectionAdapters::Duckdb::Column] The created column object
|
|
14
|
+
def new_column_from_field(table_name, field, _definitions)
|
|
15
|
+
info = column_info_from_field(table_name, field)
|
|
16
|
+
|
|
17
|
+
Column.new(
|
|
18
|
+
info[:name],
|
|
19
|
+
info[:default],
|
|
20
|
+
info[:sql_type_metadata],
|
|
21
|
+
info[:null],
|
|
22
|
+
info[:default_function],
|
|
23
|
+
collation: info[:collation],
|
|
24
|
+
comment: info[:comment],
|
|
25
|
+
auto_increment: info[:auto_increment],
|
|
26
|
+
rowid: info[:rowid]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Duckdb
|
|
6
|
+
# Rails 8.1+ specific schema statement implementations.
|
|
7
|
+
# Column constructor: (name, cast_type, default, sql_type_metadata, null, default_function, **options)
|
|
8
|
+
module SchemaStatementsRails81
|
|
9
|
+
# Creates a new Column object from DuckDB field information.
|
|
10
|
+
# @param table_name [String] The name of the table
|
|
11
|
+
# @param field [Array] Array containing column field information from PRAGMA table_info
|
|
12
|
+
# @param definitions [Hash] Additional column definitions (unused)
|
|
13
|
+
# @return [ActiveRecord::ConnectionAdapters::Duckdb::Column] The created column object
|
|
14
|
+
def new_column_from_field(table_name, field, _definitions)
|
|
15
|
+
info = column_info_from_field(table_name, field)
|
|
16
|
+
cast_type = lookup_cast_type_from_column(info[:sql_type_metadata])
|
|
17
|
+
|
|
18
|
+
Column.new(
|
|
19
|
+
info[:name],
|
|
20
|
+
cast_type,
|
|
21
|
+
info[:default],
|
|
22
|
+
info[:sql_type_metadata],
|
|
23
|
+
info[:null],
|
|
24
|
+
info[:default_function],
|
|
25
|
+
collation: info[:collation],
|
|
26
|
+
comment: info[:comment],
|
|
27
|
+
auto_increment: info[:auto_increment],
|
|
28
|
+
rowid: info[:rowid]
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monkey patch DuckDB::Converter to respect ActiveRecord.default_timezone.
|
|
4
|
+
# Based on: https://github.com/suketa/ruby-duckdb/blob/master/lib/duckdb/converter.rb
|
|
5
|
+
# Change: Use Time.utc instead of Time.local when ActiveRecord.default_timezone is :utc
|
|
6
|
+
# This fixes an issue where timestamps are interpreted in local timezone instead of UTC.
|
|
7
|
+
#
|
|
8
|
+
# Run `bin/verify_timestamp_patch` to test compatibility with new versions.
|
|
9
|
+
module DuckDBConverterTimestampMonkeyPatch
|
|
10
|
+
# Version range verified by bin/verify_timestamp_patch
|
|
11
|
+
TESTED_VERSION_MIN = '1.0.0.0'
|
|
12
|
+
TESTED_VERSION_MAX = '1.5.4.0'
|
|
13
|
+
|
|
14
|
+
EXPECTED_METHODS = {
|
|
15
|
+
_to_time: { arity: 7 },
|
|
16
|
+
_to_time_from_duckdb_time: { arity: 4 },
|
|
17
|
+
_to_time_from_duckdb_timestamp_s: { arity: 1 },
|
|
18
|
+
_to_time_from_duckdb_timestamp_ms: { arity: 1 },
|
|
19
|
+
_to_time_from_duckdb_timestamp_ns: { arity: 1 }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def apply_patch
|
|
24
|
+
const = find_const
|
|
25
|
+
verify_methods!(const)
|
|
26
|
+
|
|
27
|
+
unless duckdb_version_ok?
|
|
28
|
+
version = Gem.loaded_specs['duckdb']&.version
|
|
29
|
+
puts "WARNING: duckdb gem version #{version} is outside tested range " \
|
|
30
|
+
"(#{TESTED_VERSION_MIN} - #{TESTED_VERSION_MAX}). " \
|
|
31
|
+
'Please run bin/verify_timestamp_patch and update the range if compatible.'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
const.singleton_class.prepend(ClassMethods)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def find_const
|
|
40
|
+
Kernel.const_get('DuckDB::Converter')
|
|
41
|
+
rescue NameError
|
|
42
|
+
raise 'Could not find DuckDB::Converter when applying timestamp patch. Please investigate.'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def verify_methods!(const)
|
|
46
|
+
EXPECTED_METHODS.each do |method_name, expectations|
|
|
47
|
+
mtd = const.method(method_name)
|
|
48
|
+
unless mtd && mtd.arity == expectations[:arity]
|
|
49
|
+
raise "Could not find method #{method_name} with arity #{expectations[:arity]} " \
|
|
50
|
+
'when patching DuckDB::Converter. Please investigate.'
|
|
51
|
+
end
|
|
52
|
+
rescue NameError
|
|
53
|
+
raise "Could not find method #{method_name} when patching DuckDB::Converter. Please investigate."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def duckdb_version_ok?
|
|
58
|
+
version = Gem.loaded_specs['duckdb']&.version
|
|
59
|
+
return false unless version
|
|
60
|
+
|
|
61
|
+
min = Gem::Version.new(TESTED_VERSION_MIN)
|
|
62
|
+
max = Gem::Version.new(TESTED_VERSION_MAX)
|
|
63
|
+
version.between?(min, max)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module ClassMethods
|
|
69
|
+
EPOCH_UTC = Time.utc(1970, 1, 1).freeze
|
|
70
|
+
|
|
71
|
+
def _to_time(year, month, day, hour, minute, second, microsecond)
|
|
72
|
+
if ActiveRecord.default_timezone == :utc
|
|
73
|
+
Time.utc(year, month, day, hour, minute, second, microsecond)
|
|
74
|
+
else
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def _to_time_from_duckdb_time(hour, minute, second, microsecond)
|
|
80
|
+
if ActiveRecord.default_timezone == :utc
|
|
81
|
+
Time.utc(1970, 1, 1, hour, minute, second, microsecond)
|
|
82
|
+
else
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def _to_time_from_duckdb_timestamp_s(time)
|
|
88
|
+
if ActiveRecord.default_timezone == :utc
|
|
89
|
+
EPOCH_UTC + time
|
|
90
|
+
else
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _to_time_from_duckdb_timestamp_ms(time)
|
|
96
|
+
if ActiveRecord.default_timezone == :utc
|
|
97
|
+
tm = EPOCH_UTC + (time / 1000)
|
|
98
|
+
Time.utc(tm.year, tm.month, tm.day, tm.hour, tm.min, tm.sec, time % 1000 * 1000)
|
|
99
|
+
else
|
|
100
|
+
super
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def _to_time_from_duckdb_timestamp_ns(time)
|
|
105
|
+
if ActiveRecord.default_timezone == :utc
|
|
106
|
+
tm = EPOCH_UTC + (time / 1_000_000_000)
|
|
107
|
+
Time.utc(tm.year, tm.month, tm.day, tm.hour, tm.min, tm.sec, time % 1_000_000_000 / 1000)
|
|
108
|
+
else
|
|
109
|
+
super
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
DuckDBConverterTimestampMonkeyPatch.apply_patch
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/duration'
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module ConnectionAdapters
|
|
7
|
+
module Duckdb
|
|
8
|
+
module Type
|
|
9
|
+
# Type for DuckDB INTERVAL columns
|
|
10
|
+
# Converts between DuckDB::Interval and ActiveSupport::Duration
|
|
11
|
+
#
|
|
12
|
+
# @example Reading an interval from the database
|
|
13
|
+
# # DuckDB returns: DuckDB::Interval with months=0, days=1, micros=3600000000
|
|
14
|
+
# # Ruby receives: ActiveSupport::Duration of "1 day and 1 hour"
|
|
15
|
+
#
|
|
16
|
+
# @example Writing an interval to the database
|
|
17
|
+
# # Ruby sends: 2.hours + 30.minutes (ActiveSupport::Duration)
|
|
18
|
+
# # DuckDB receives: "INTERVAL '2 hours 30 minutes'"
|
|
19
|
+
class Interval < ActiveRecord::Type::Value
|
|
20
|
+
def type
|
|
21
|
+
:interval
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deserialize from database value to Ruby object
|
|
25
|
+
# @param value [DuckDB::Interval, String, nil] The database value
|
|
26
|
+
# @return [ActiveSupport::Duration, nil] The Ruby duration
|
|
27
|
+
def deserialize(value)
|
|
28
|
+
return nil if value.nil?
|
|
29
|
+
|
|
30
|
+
case value
|
|
31
|
+
when ::DuckDB::Interval
|
|
32
|
+
interval_to_duration(value)
|
|
33
|
+
when ::ActiveSupport::Duration
|
|
34
|
+
value
|
|
35
|
+
when ::String
|
|
36
|
+
parse_interval_string(value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Cast from user input to Ruby object
|
|
41
|
+
# @param value [Object] The user input
|
|
42
|
+
# @return [ActiveSupport::Duration, nil] The Ruby duration
|
|
43
|
+
def cast(value)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
|
|
46
|
+
case value
|
|
47
|
+
when ::ActiveSupport::Duration
|
|
48
|
+
value
|
|
49
|
+
when ::DuckDB::Interval
|
|
50
|
+
interval_to_duration(value)
|
|
51
|
+
when ::Numeric
|
|
52
|
+
# Treat numeric as seconds
|
|
53
|
+
ActiveSupport::Duration.build(value)
|
|
54
|
+
when ::String
|
|
55
|
+
parse_interval_string(value)
|
|
56
|
+
when ::Hash
|
|
57
|
+
# Allow hash like { hours: 2, minutes: 30 }
|
|
58
|
+
hash_to_duration(value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Serialize from Ruby object to database value
|
|
63
|
+
# @param value [ActiveSupport::Duration, Numeric, nil] The Ruby value
|
|
64
|
+
# @return [String, nil] The SQL interval literal
|
|
65
|
+
def serialize(value)
|
|
66
|
+
return nil if value.nil?
|
|
67
|
+
|
|
68
|
+
case value
|
|
69
|
+
when ::ActiveSupport::Duration
|
|
70
|
+
duration_to_interval_string(value)
|
|
71
|
+
when ::Numeric
|
|
72
|
+
duration_to_interval_string(ActiveSupport::Duration.build(value))
|
|
73
|
+
when ::String
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Convert DuckDB::Interval to ActiveSupport::Duration
|
|
81
|
+
def interval_to_duration(interval)
|
|
82
|
+
parts = []
|
|
83
|
+
|
|
84
|
+
if interval.interval_months != 0
|
|
85
|
+
years, months = interval.interval_months.divmod(12)
|
|
86
|
+
parts << years.years if years != 0
|
|
87
|
+
parts << months.months if months != 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
parts << interval.interval_days.days if interval.interval_days != 0
|
|
91
|
+
|
|
92
|
+
if interval.interval_micros != 0
|
|
93
|
+
total_seconds = interval.interval_micros / 1_000_000.0
|
|
94
|
+
hours, remainder = total_seconds.divmod(3600)
|
|
95
|
+
minutes, seconds = remainder.divmod(60)
|
|
96
|
+
|
|
97
|
+
parts << hours.to_i.hours if hours >= 1
|
|
98
|
+
parts << minutes.to_i.minutes if minutes >= 1
|
|
99
|
+
parts << seconds.seconds if seconds.positive?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return 0.seconds if parts.empty?
|
|
103
|
+
|
|
104
|
+
parts.sum
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert ActiveSupport::Duration to DuckDB interval string
|
|
108
|
+
def duration_to_interval_string(duration)
|
|
109
|
+
parts = []
|
|
110
|
+
remaining = duration.in_seconds
|
|
111
|
+
|
|
112
|
+
# Extract components
|
|
113
|
+
parts << "#{duration.parts[:years]} years" if duration.parts[:years]
|
|
114
|
+
|
|
115
|
+
parts << "#{duration.parts[:months]} months" if duration.parts[:months]
|
|
116
|
+
|
|
117
|
+
parts << "#{duration.parts[:weeks]} weeks" if duration.parts[:weeks]
|
|
118
|
+
|
|
119
|
+
parts << "#{duration.parts[:days]} days" if duration.parts[:days]
|
|
120
|
+
|
|
121
|
+
parts << "#{duration.parts[:hours]} hours" if duration.parts[:hours]
|
|
122
|
+
|
|
123
|
+
parts << "#{duration.parts[:minutes]} minutes" if duration.parts[:minutes]
|
|
124
|
+
|
|
125
|
+
parts << "#{duration.parts[:seconds]} seconds" if duration.parts[:seconds]
|
|
126
|
+
|
|
127
|
+
# If no parts but has value, convert from total seconds
|
|
128
|
+
if parts.empty? && remaining.positive?
|
|
129
|
+
hours, remainder = remaining.divmod(3600)
|
|
130
|
+
minutes, seconds = remainder.divmod(60)
|
|
131
|
+
|
|
132
|
+
parts << "#{hours.to_i} hours" if hours >= 1
|
|
133
|
+
parts << "#{minutes.to_i} minutes" if minutes >= 1
|
|
134
|
+
parts << "#{seconds} seconds" if seconds.positive?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parts.empty? ? '0 seconds' : parts.join(' ')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Parse interval string to Duration
|
|
141
|
+
def parse_interval_string(str)
|
|
142
|
+
return nil if str.nil? || str.empty?
|
|
143
|
+
|
|
144
|
+
# Try ISO8601 format first
|
|
145
|
+
begin
|
|
146
|
+
return ActiveSupport::Duration.parse(str)
|
|
147
|
+
rescue ActiveSupport::Duration::ISO8601Parser::ParsingError
|
|
148
|
+
# Continue to try other formats
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Parse DuckDB-style interval strings like "1 day 2 hours"
|
|
152
|
+
parts = []
|
|
153
|
+
str.scan(/(\d+)\s*(year|month|week|day|hour|minute|second)s?/i) do |amount, unit|
|
|
154
|
+
parts << amount.to_i.public_send(unit.downcase.pluralize)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
parts.empty? ? nil : parts.sum
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Convert hash to Duration
|
|
161
|
+
def hash_to_duration(hash)
|
|
162
|
+
parts = []
|
|
163
|
+
hash.each do |unit, amount|
|
|
164
|
+
next if amount.nil? || amount.zero?
|
|
165
|
+
|
|
166
|
+
parts << amount.public_send(unit.to_s.singularize.pluralize)
|
|
167
|
+
end
|
|
168
|
+
parts.empty? ? 0.seconds : parts.sum
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|