quack-ruby 0.1.0

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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/LICENSE-DuckDB +7 -0
  4. data/LICENSE-QuackProtocol +21 -0
  5. data/LICENSE-Rust-dependencies.json +2057 -0
  6. data/README.md +229 -0
  7. data/ext/quack/Cargo.lock +1803 -0
  8. data/ext/quack/Cargo.toml +17 -0
  9. data/ext/quack/extconf.rb +4 -0
  10. data/ext/quack/src/lib.rs +647 -0
  11. data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
  12. data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
  13. data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
  14. data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
  15. data/ext/quack/vendor/quack_protocol/README.md +272 -0
  16. data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
  17. data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
  18. data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
  19. data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
  20. data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
  21. data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
  22. data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
  23. data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
  24. data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
  25. data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
  26. data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
  27. data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
  28. data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
  29. data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
  30. data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
  31. data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
  32. data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
  33. data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
  34. data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
  35. data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
  36. data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
  37. data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
  38. data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
  39. data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
  40. data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
  41. data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
  42. data/lib/quack/codec.rb +263 -0
  43. data/lib/quack/connection.rb +131 -0
  44. data/lib/quack/logical_types.rb +72 -0
  45. data/lib/quack/result.rb +64 -0
  46. data/lib/quack/sql.rb +183 -0
  47. data/lib/quack/types.rb +40 -0
  48. data/lib/quack/variant.rb +298 -0
  49. data/lib/quack.rb +34 -0
  50. metadata +136 -0
@@ -0,0 +1,263 @@
1
+ module Quack
2
+ # DuckDB's logical metadata drives both directions, including nested values.
3
+ module Codec
4
+ module_function
5
+
6
+ SQL_NAMES = {
7
+ "TimestampSec" => "TIMESTAMP_S", "TimestampMs" => "TIMESTAMP_MS",
8
+ "TimestampNs" => "TIMESTAMP_NS", "TimestampTz" => "TIMESTAMPTZ",
9
+ "TimestampTzNs" => "TIMESTAMPTZ_NS", "TimeNs" => "TIME_NS",
10
+ "TimeTz" => "TIMETZ", "SqlNull" => "INTEGER"
11
+ }.freeze
12
+ INTEGER_BITS = {"TinyInt" => 8, "SmallInt" => 16, "Integer" => 32, "BigInt" => 64, "HugeInt" => 128,
13
+ "UTinyInt" => 8, "USmallInt" => 16, "UInteger" => 32, "UBigInt" => 64, "UHugeInt" => 128}.freeze
14
+
15
+ def copy(value)
16
+ case value
17
+ when Array then value.map { |item| copy(item) }
18
+ when Hash then value.to_h { |key, item| [copy(key), copy(item)] }
19
+ when Map then Map.new(copy(value.entries), value.key_type, value.value_type)
20
+ when Union then Union.new(value.tag, copy(value.value), value.members)
21
+ when Tuple then Tuple.new(copy(value.values))
22
+ when Variant then Variant.new(copy(value.node))
23
+ when Variant::Node then Variant::Node.new(value.type.dup, copy(value.value))
24
+ when String, Time then value.dup
25
+ else value
26
+ end
27
+ end
28
+
29
+ def sql_type(type)
30
+ id = type.fetch("id")
31
+ return "JSON" if type["alias"]&.casecmp?("JSON")
32
+ case id
33
+ when "Decimal" then "DECIMAL(#{type.fetch('width')},#{type.fetch('scale')})"
34
+ when "List" then "#{sql_type(type.fetch('child'))}[]"
35
+ when "Array" then "#{sql_type(type.fetch('child'))}[#{type.fetch('size')}]"
36
+ when "Map"
37
+ children = type.fetch("child").fetch("children")
38
+ "MAP(#{children.map { |child| sql_type(child.fetch('type')) }.join(', ')})"
39
+ when "Struct", "Tuple", "Union"
40
+ children = type.fetch("children")
41
+ children = children.drop(1) if id == "Union"
42
+ fields = children.map do |child|
43
+ name = child.fetch("name")
44
+ "#{name.empty? ? '' : SQL.identifier(name) + ' '}#{sql_type(child.fetch('type'))}"
45
+ end
46
+ "#{id.upcase}(#{fields.join(', ')})"
47
+ when "Enum" then "ENUM (#{type.fetch('values').map { |value| SQL.literal(value) }.join(', ')})"
48
+ when "Geometry"
49
+ type["crs"] ? "GEOMETRY(#{SQL.literal(type.fetch('crs'))})" : "GEOMETRY"
50
+ else SQL_NAMES.fetch(id, id.upcase)
51
+ end
52
+ end
53
+
54
+ # cast_to_type binds its second argument's type without evaluating it. A
55
+ # typed expression also represents query-only unnamed v1 STRUCTs, whose
56
+ # typeof() spelling cannot be parsed as an explicit SQL type declaration.
57
+ def type_expression(type)
58
+ case type.fetch("id")
59
+ when "Struct", "Tuple"
60
+ children = type.fetch("children")
61
+ if type.fetch("id") == "Tuple" || children.all? { |child| child.fetch("name").empty? }
62
+ "row(#{children.map { |child| type_expression(child.fetch('type')) }.join(', ')})"
63
+ else
64
+ "{#{children.map { |child| "#{SQL.literal(child.fetch('name'))}: #{type_expression(child.fetch('type'))}" }.join(', ')}}"
65
+ end
66
+ when "List" then "[#{type_expression(type.fetch('child'))}]"
67
+ when "Array" then "array_value(#{Array.new(type.fetch('size'), type_expression(type.fetch('child'))).join(', ')})"
68
+ when "Map"
69
+ key_type, value_type = type.fetch("child").fetch("children").map { |child| child.fetch("type") }
70
+ "map([#{type_expression(key_type)}][0:0], [#{type_expression(value_type)}][0:0])"
71
+ else "NULL::#{sql_type(type)}"
72
+ end
73
+ end
74
+
75
+ def decode(value, type)
76
+ return nil if value.nil?
77
+ id = type.fetch("id")
78
+ case id
79
+ when "BigNum" then decode_bignum(value)
80
+ when "Bit" then decode_bit(value)
81
+ when "Geometry" then Geometry.new(value, type["crs"])
82
+ when "List", "Array" then value.map { |item| decode(item, type.fetch("child")) }
83
+ when "Map"
84
+ fields = type.fetch("child").fetch("children")
85
+ Map.new(value.to_h { |pair| [decode(pair.fetch('key'), fields[0].fetch('type')), decode(pair.fetch('value'), fields[1].fetch('type'))] }, fields[0].fetch("type"), fields[1].fetch("type"))
86
+ when "Union"
87
+ members = type.fetch("children").drop(1).to_h { |child| [child.fetch("name"), child.fetch("type")] }
88
+ tag = members.keys.fetch(value.fetch(""))
89
+ Union.new(tag, decode(value.fetch(tag), members.fetch(tag)), members)
90
+ when "Variant" then Variant.from_physical(value)
91
+ when "Struct", "Tuple"
92
+ children = type.fetch("children")
93
+ if id == "Tuple" || children.all? { |child| child.fetch("name").empty? }
94
+ Tuple.new(children.each_with_index.map { |child, index| decode(value.fetch(index.to_s), child.fetch("type")) })
95
+ else
96
+ children.to_h { |child| [child.fetch("name"), decode(value.fetch(child.fetch("name")), child.fetch("type"))] }
97
+ end
98
+ when /\ATimestamp/
99
+ value.is_a?(InfiniteTemporal) ? InfiniteTemporal.new(sql_type(type), value.sign) : Timestamp.from(value, sql_type(type))
100
+ else value
101
+ end
102
+ end
103
+
104
+ def pack(value, type)
105
+ return ["null"] if value.nil?
106
+ id = type.fetch("id")
107
+ if type["alias"]&.casecmp?("JSON")
108
+ text = value.is_a?(String) ? value : JSON.generate(value)
109
+ JSON.parse(text) # Validate without changing whitespace or numeric text.
110
+ return ["string", text]
111
+ end
112
+ if INTEGER_BITS.key?(id)
113
+ raise TypeError, "#{id} append value must be an Integer" unless value.is_a?(Integer)
114
+ bits = INTEGER_BITS.fetch(id)
115
+ unsigned = id.start_with?("U")
116
+ range = unsigned ? (0...2**bits) : (-(2**(bits - 1))...2**(bits - 1))
117
+ raise RangeError, "#{value} is out of range for #{id}" unless range.cover?(value)
118
+ return ["int", value.to_s]
119
+ end
120
+ case id
121
+ when "BigNum" then ["bytes", encode_bignum(value)]
122
+ when "Bit" then ["bytes", encode_bit(value)]
123
+ when "Geometry"
124
+ raise TypeError, "GEOMETRY append value must be a Quack::Geometry" unless value.is_a?(Geometry)
125
+ ["bytes", value.wkb]
126
+ when "Decimal"
127
+ number = BigDecimal(value.to_s)
128
+ raise ArgumentError, "non-finite decimal append value" unless number.finite?
129
+ scale = type.fetch("scale")
130
+ scaled = number * 10**scale
131
+ raise RangeError, "decimal exceeds destination scale" unless scaled.frac.zero?
132
+ raise RangeError, "decimal exceeds destination precision" unless scaled.abs < 10**type.fetch("width")
133
+ ["decimal", number.to_s("F"), type.fetch("width"), scale]
134
+ when "Time", "TimeNs", "TimeTz"
135
+ raise TypeError, "#{id} append value must be a Quack::TimeOfDay" unless value.is_a?(TimeOfDay)
136
+ raise RangeError, "time of day out of range" unless (0..86_400_000_000_000).cover?(value.nanoseconds)
137
+ if id == "TimeTz"
138
+ raise ArgumentError, "TIMETZ needs a UTC offset" if value.utc_offset.nil?
139
+ raise RangeError, "TIMETZ offset out of range" unless (-57_599..57_599).cover?(value.utc_offset)
140
+ require_microseconds(value.nanoseconds)
141
+ bits = (value.nanoseconds / 1000 << 24) | (57_599 - value.utc_offset)
142
+ ["timetz", bits >= 2**63 ? bits - 2**64 : bits]
143
+ else
144
+ raise ArgumentError, "timezone offset requires a TIMETZ column" unless value.utc_offset.nil?
145
+ require_microseconds(value.nanoseconds) if id == "Time"
146
+ ["time", id == "Time" ? value.nanoseconds / 1000 : value.nanoseconds, id == "Time" ? "Micros" : "Nanos"]
147
+ end
148
+ when /\ATimestamp/
149
+ units = {"TimestampSec" => [1, "Seconds"], "TimestampMs" => [1000, "Millis"], "TimestampNs" => [1_000_000_000, "Nanos"], "TimestampTzNs" => [1_000_000_000, "Nanos"]}
150
+ divisor, unit = units.fetch(id, [1_000_000, "Micros"])
151
+ count = if value.is_a?(InfiniteTemporal)
152
+ value.sign.negative? ? -(2**63 - 1) : 2**63 - 1
153
+ else
154
+ seconds = value.is_a?(DateTime) ? (value.new_offset(0) - DateTime.new(1970, 1, 1)) * 86_400 : value.to_r
155
+ scaled = seconds * divisor
156
+ raise RangeError, "timestamp exceeds destination precision" unless scaled.denominator == 1
157
+ raise RangeError, "timestamp out of range" unless (-(2**63 - 2)..2**63 - 2).cover?(scaled)
158
+ scaled.to_i
159
+ end
160
+ ["timestamp", count, unit, %w[TimestampTz TimestampTzNs].include?(id)]
161
+ when "Date"
162
+ days = value.is_a?(InfiniteTemporal) ? value.sign * (2**31 - 1) : (Date.iso8601(value.iso8601, Date::GREGORIAN) - Date.new(1970, 1, 1, Date::GREGORIAN)).to_i
163
+ raise RangeError, "date out of range" unless (-(2**31 - 1)..2**31 - 1).cover?(days)
164
+ ["date", days]
165
+ when "List", "Array"
166
+ raise TypeError, "#{id} append value must be an Array" unless value.is_a?(Array)
167
+ raise ArgumentError, "fixed array length mismatch" if id == "Array" && value.length != type.fetch("size")
168
+ ["list", value.map { |item| pack(item, type.fetch("child")) }]
169
+ when "Map"
170
+ entries = value.is_a?(Map) ? value.entries : value
171
+ raise TypeError, "MAP append value must be a Quack::Map or Hash" unless entries.is_a?(Hash)
172
+ raise ArgumentError, "MAP keys cannot be NULL" if entries.key?(nil)
173
+ fields = type.fetch("child").fetch("children")
174
+ ["list", entries.map { |key, item| ["struct", {"key" => pack(key, fields[0].fetch("type")), "value" => pack(item, fields[1].fetch("type"))}] }]
175
+ when "Union"
176
+ raise TypeError, "UNION append value must be a Quack::Union" unless value.is_a?(Union)
177
+ children = type.fetch("children").drop(1)
178
+ index = children.index { |child| child.fetch("name") == value.tag }
179
+ raise ArgumentError, "unknown UNION member #{value.tag}" unless index
180
+ fields = {"" => ["int", index.to_s]}
181
+ children.each { |child| fields[child.fetch("name")] = child.fetch("name") == value.tag ? pack(value.value, child.fetch("type")) : ["null"] }
182
+ ["struct", fields]
183
+ when "Variant"
184
+ variant = value.is_a?(Variant) ? value : Variant.new(value)
185
+ pack(variant.to_physical, {"id" => "Struct", "children" => type.fetch("children")})
186
+ when "Struct", "Tuple"
187
+ children = type.fetch("children")
188
+ unnamed = id == "Tuple" || children.all? { |child| child.fetch("name").empty? }
189
+ if unnamed
190
+ values = value.is_a?(Tuple) ? value.values : value
191
+ raise ArgumentError, "tuple length mismatch" unless values.is_a?(Array) && values.length == children.length
192
+ ["struct", children.each_with_index.to_h { |child, index| [index.to_s, pack(values[index], child.fetch("type"))] }]
193
+ else
194
+ raise TypeError, "STRUCT append value must be a Hash" unless value.is_a?(Hash)
195
+ fields = value.transform_keys(&:to_s)
196
+ raise ArgumentError, "STRUCT field names mismatch" unless fields.keys.sort == children.map { |child| child.fetch("name") }.sort
197
+ ["struct", children.to_h { |child| [child.fetch("name"), pack(fields.fetch(child.fetch("name")), child.fetch("type"))] }]
198
+ end
199
+ when "Boolean"
200
+ raise TypeError, "BOOLEAN append value must be true or false" unless value == true || value == false
201
+ ["bool", value]
202
+ when "Float", "Double"
203
+ number = Float(value)
204
+ raise RangeError, "value exceeds #{id} range" if number.infinite? && value.respond_to?(:finite?) && value.finite?
205
+ raise RangeError, "value exceeds FLOAT range" if id == "Float" && number.finite? && number.abs > 3.4028234663852886e38
206
+ ["double", number]
207
+ when "Enum"
208
+ raise ArgumentError, "unknown ENUM value" unless type.fetch("values").include?(value)
209
+ ["string", value]
210
+ when "Varchar", "Char", "Uuid"
211
+ raise TypeError, "#{id} append value must be a String" unless value.is_a?(String)
212
+ text = value.encode(Encoding::UTF_8)
213
+ raise EncodingError, "invalid UTF-8 text" unless text.valid_encoding?
214
+ ["string", text]
215
+ when "Blob"
216
+ raise TypeError, "BLOB append value must be a String" unless value.is_a?(String)
217
+ ["bytes", value.b]
218
+ when "Interval"
219
+ raise TypeError, "INTERVAL append value must be a Quack::Interval" unless value.is_a?(Interval)
220
+ ["interval", value.months, value.days, value.microseconds]
221
+ else raise UnsupportedTypeError, "unsupported append logical type #{id}"
222
+ end
223
+ end
224
+
225
+ def require_microseconds(nanoseconds)
226
+ raise RangeError, "value exceeds microsecond precision" unless (nanoseconds % 1000).zero?
227
+ end
228
+
229
+ def decode_bignum(bytes)
230
+ # DuckDB stores a three-byte length header and big-endian magnitude;
231
+ # negative values complement every byte, including the header.
232
+ bytes = bytes.bytes
233
+ raise ProtocolError, "invalid BIGNUM payload" if bytes.length < 4
234
+ negative = bytes[0] < 128
235
+ bytes.map! { |byte| byte ^ 255 } if negative
236
+ length = ((bytes[0] & 127) << 16) | (bytes[1] << 8) | bytes[2]
237
+ raise ProtocolError, "invalid BIGNUM length" unless length == bytes.length - 3
238
+ magnitude = bytes.drop(3).pack("C*").unpack1("H*").to_i(16)
239
+ negative ? -magnitude : magnitude
240
+ end
241
+
242
+ def encode_bignum(value)
243
+ raise TypeError, "BIGNUM append value must be an Integer" unless value.is_a?(Integer)
244
+ hex = value.abs.to_s(16)
245
+ bytes = [hex.length.odd? ? "0#{hex}" : hex].pack("H*")
246
+ raise RangeError, "BIGNUM payload exceeds 23-bit length" if bytes.bytesize >= 2**23
247
+ bytes = [bytes.bytesize | 0x800000].pack("N").byteslice(1, 3) + bytes
248
+ value.negative? ? bytes.bytes.map { |byte| byte ^ 255 }.pack("C*") : bytes
249
+ end
250
+
251
+ def decode_bit(bytes)
252
+ padding = bytes.getbyte(0)
253
+ raise ProtocolError, "invalid BIT payload" unless padding && padding < 8 && bytes.bytesize > 1
254
+ BitString.new(bytes.byteslice(1..).unpack1("B*").byteslice(padding..))
255
+ end
256
+
257
+ def encode_bit(value)
258
+ value = BitString.new(value) unless value.is_a?(BitString)
259
+ padding = (8 - value.bits.length % 8) % 8
260
+ [padding].pack("C") + ["1" * padding + value.bits].pack("B*")
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,131 @@
1
+ module Quack
2
+ class Connection
3
+ OPTIONS = %w[auth_token ssl ssl_fingerprint timeout heartbeat_timeout headers client_id client_platform client_duckdb_version min_supported_quack_version max_supported_quack_version].freeze
4
+
5
+ def initialize(uri, **options)
6
+ @uri = String(uri).dup.freeze
7
+ @options = options.transform_keys(&:to_s)
8
+ unknown = @options.keys - OPTIONS
9
+ raise ArgumentError, "unknown connection options: #{unknown.join(', ')}" unless unknown.empty?
10
+ @options = @options.reject { |_key, value| value.nil? }
11
+ open_native
12
+ @pid = Process.pid
13
+ @in_transaction = false
14
+ end
15
+ def inspect = "#<#{self.class} connected=#{connected?}>"
16
+ def info = @native.info
17
+ def connected? = @native.connected?
18
+ def stream(sql, parameters = nil)
19
+ result = Stream.new(@native.query(SQL.bind(sql, parameters)))
20
+ return result unless block_given?
21
+ begin
22
+ yield result
23
+ ensure
24
+ result.close
25
+ end
26
+ end
27
+ def query(sql, parameters = nil) = stream(sql, parameters) { |result| result.to_result }
28
+ def execute(sql, parameters = nil) = query(sql, parameters).affected_rows
29
+ def first(sql, parameters = nil) = query(sql, parameters).first
30
+ def one(sql, parameters = nil)
31
+ result = query(sql, parameters)
32
+ raise Error, "expected one row, got #{result.rows.length}" unless result.rows.length == 1
33
+ result.first
34
+ end
35
+ def values(sql, parameters = nil) = query(sql, parameters).rows.map(&:first)
36
+ def prepare(sql) = Statement.new(self, String(sql).dup.freeze)
37
+ def append_rows(table, rows, schema: nil, columns: nil, batch_size: 1024)
38
+ raise ArgumentError, "batch_size must be a positive Integer" unless batch_size.is_a?(Integer) && batch_size.positive?
39
+ unless info.fetch("quack_version") == 1
40
+ raise UnsupportedTypeError, "Quack v3 SEND_DATA is not implemented by quack_protocol; use SQL INSERT or insert_all"
41
+ end
42
+ return nil if rows.empty?
43
+ table_parts = SQL.identifier_parts(table)
44
+ raise UnsupportedTypeError, "v1 append uses the current catalog; select it with USE before appending" if table_parts.length == 3
45
+ if table_parts.length == 2 && schema && String(schema) != table_parts[0]
46
+ raise ArgumentError, "conflicting append schema names"
47
+ end
48
+ table_parts.unshift(String(schema)) if schema && table_parts.length == 1
49
+ target = table_parts.map { |part| SQL.identifier(part) }.join(".")
50
+ destination_sql = "select * from #{target} where false"
51
+ destination = query(destination_sql).column_metadata
52
+ schema_sql = if columns
53
+ definitions = columns.transform_keys(&:to_s)
54
+ unless definitions.keys.sort == destination.map { |column| column.fetch("name") }.sort
55
+ raise ArgumentError, "explicit append types must name every destination column"
56
+ end
57
+ "select #{destination.map { |column| name = column.fetch('name'); "NULL::#{definitions.fetch(name)} AS #{SQL.identifier(name)}" }.join(', ')} where false"
58
+ else
59
+ destination_sql
60
+ end
61
+ metadata = columns ? query(schema_sql).column_metadata : destination
62
+ expected = metadata.map { |column| column.fetch("name") }.sort
63
+ packed = rows.map do |row|
64
+ raise ArgumentError, "each row must be a Hash" unless row.is_a?(Hash)
65
+ values = row.transform_keys(&:to_s)
66
+ raise ArgumentError, "append rows must have the destination column names: #{expected.join(', ')}" unless values.keys.sort == expected
67
+ metadata.to_h { |column| [column.fetch("name"), Quack.__pack(values.fetch(column.fetch("name")), column.fetch("duckdb_type"))] }
68
+ end
69
+ @native.append_rows(table_parts.last, table_parts.length == 2 ? table_parts.first : nil, packed, schema_sql, batch_size)
70
+ nil
71
+ end
72
+ def close = @native.close
73
+ alias disconnect close
74
+ def reconnect
75
+ close if @pid == Process.pid && connected?
76
+ open_native
77
+ @pid = Process.pid
78
+ @in_transaction = false
79
+ self
80
+ end
81
+ def transaction
82
+ raise Error, "nested transactions/savepoints are not supported" if @in_transaction
83
+ execute("BEGIN TRANSACTION")
84
+ @in_transaction = true
85
+ committed = false
86
+ begin
87
+ value = yield self
88
+ execute("COMMIT")
89
+ committed = true
90
+ value
91
+ ensure
92
+ unless committed
93
+ begin
94
+ execute("ROLLBACK") if connected?
95
+ rescue Error
96
+ # Preserve the original failure; a transport failure never authorizes replay.
97
+ end
98
+ end
99
+ @in_transaction = false
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def open_native
106
+ @native = NativeConnection.new(@uri, @options)
107
+ begin
108
+ # Ruby timestamps are UTC values. Make implicit timestamp/TZ casts
109
+ # consistent with Rails and independent of the server host timezone.
110
+ @native.query("SET TimeZone = 'UTC'").close
111
+ rescue Exception
112
+ begin
113
+ @native.close
114
+ rescue Error
115
+ # Preserve the configuration failure rather than a cleanup failure.
116
+ end
117
+ raise
118
+ end
119
+ end
120
+ end
121
+
122
+ # Reusable client-side SQL template; Quack's prepare request carries SQL text.
123
+ class Statement
124
+ def initialize(connection, sql)
125
+ @connection, @sql = connection, sql
126
+ end
127
+ def query(parameters = nil) = @connection.query(@sql, parameters)
128
+ def execute(parameters = nil) = @connection.execute(@sql, parameters)
129
+ def stream(parameters = nil, &block) = @connection.stream(@sql, parameters, &block)
130
+ end
131
+ end
@@ -0,0 +1,72 @@
1
+ module Quack
2
+ # Preserve the SQL timestamp kind as well as Ruby's nanosecond-precision instant.
3
+ # Time equality/arithmetic continue to use Ruby's standard behavior.
4
+ class Timestamp < Time
5
+ attr_reader :duckdb_type
6
+
7
+ def self.from(value, type)
8
+ seconds = value.is_a?(DateTime) ? (value.new_offset(0) - DateTime.new(1970, 1, 1)) * 86_400 : value.to_r
9
+ at(seconds).utc.tap { |time| time.instance_variable_set(:@duckdb_type, String(type).freeze) }
10
+ end
11
+
12
+ def getutc
13
+ super.tap { |time| time.instance_variable_set(:@duckdb_type, duckdb_type) }
14
+ end
15
+
16
+ def getlocal(*args)
17
+ super.tap { |time| time.instance_variable_set(:@duckdb_type, duckdb_type) }
18
+ end
19
+
20
+ def +(seconds) = self.class.from(super, duckdb_type)
21
+ def -(other)
22
+ result = super
23
+ result.is_a?(Time) ? self.class.from(result, duckdb_type) : result
24
+ end
25
+ end
26
+
27
+ BitString = Data.define(:bits) do
28
+ def initialize(bits:)
29
+ bits = String(bits)
30
+ raise ArgumentError, "BIT values need at least one 0 or 1 bit" unless bits.match?(/\A[01]+\z/)
31
+ super(bits: bits.dup.freeze)
32
+ end
33
+ def to_s = bits
34
+ end
35
+
36
+ # Standard WKB is a lossless geometry value, including empty/Z/M geometries.
37
+ Geometry = Data.define(:wkb, :crs) do
38
+ def initialize(wkb:, crs: nil)
39
+ super(wkb: String(wkb).b.freeze, crs: crs&.dup&.freeze)
40
+ end
41
+ end
42
+
43
+ Map = Data.define(:entries, :key_type, :value_type) do
44
+ include Enumerable
45
+ def initialize(entries:, key_type: nil, value_type: nil)
46
+ super(entries: entries.to_h.freeze, key_type: key_type, value_type: value_type)
47
+ end
48
+ def each(&block) = entries.each(&block)
49
+ def [](key) = entries[key]
50
+ def to_h = entries.dup
51
+ def ==(other) = other.is_a?(Map) && entries == other.entries
52
+ alias eql? ==
53
+ def hash = [self.class, entries].hash
54
+ end
55
+
56
+ Union = Data.define(:tag, :value, :members) do
57
+ def initialize(tag:, value:, members: nil)
58
+ super(tag: String(tag).freeze, value: value, members: members)
59
+ end
60
+ def ==(other) = other.is_a?(Union) && tag == other.tag && value == other.value
61
+ alias eql? ==
62
+ def hash = [self.class, tag, value].hash
63
+ end
64
+
65
+ Tuple = Data.define(:values) do
66
+ def initialize(values:)
67
+ super(values: values.to_a.freeze)
68
+ end
69
+ def [](index) = values[index]
70
+ def to_a = values.dup
71
+ end
72
+ end
@@ -0,0 +1,64 @@
1
+ module Quack
2
+ class Result
3
+ include Enumerable
4
+ attr_reader :columns, :rows, :column_metadata
5
+
6
+ def initialize(metadata, rows)
7
+ @column_metadata = metadata.freeze
8
+ @columns = metadata.map { |col| col.fetch("name") }.freeze
9
+ @rows = rows
10
+ end
11
+ def each
12
+ return enum_for(__method__) unless block_given?
13
+ rows.each { |row| yield columns.zip(row).to_h }
14
+ self
15
+ end
16
+ def affected_rows
17
+ columns == ["Count"] && rows.first&.first.is_a?(Integer) ? rows.first.first : 0
18
+ end
19
+ end
20
+
21
+ class Stream
22
+ include Enumerable
23
+ attr_reader :column_metadata, :columns
24
+
25
+ def initialize(native)
26
+ @native = native
27
+ @column_metadata = native.columns.freeze
28
+ @columns = column_metadata.map { |col| col.fetch("name") }.freeze
29
+ @closed = false
30
+ end
31
+ def each_batch
32
+ return enum_for(__method__) unless block_given?
33
+ begin
34
+ while (batch = @native.next_batch)
35
+ batch.each do |row|
36
+ row.each_index { |index| row[index] = Codec.decode(row[index], column_metadata[index].fetch("duckdb_type")) }
37
+ end
38
+ yield batch
39
+ end
40
+ ensure
41
+ close
42
+ end
43
+ self
44
+ end
45
+ def each_row
46
+ return enum_for(__method__) unless block_given?
47
+ each_batch { |batch| batch.each { |row| yield row } }
48
+ self
49
+ end
50
+ def each
51
+ return enum_for(__method__) unless block_given?
52
+ each_row { |row| yield columns.zip(row).to_h }
53
+ self
54
+ end
55
+ def close
56
+ return if @closed
57
+ @native.close
58
+ @closed = true
59
+ nil
60
+ end
61
+ def closed? = @closed
62
+ def to_result = Result.new(column_metadata, each_row.to_a)
63
+ end
64
+ end