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,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