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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/LICENSE-DuckDB +7 -0
- data/LICENSE-QuackProtocol +21 -0
- data/LICENSE-Rust-dependencies.json +2057 -0
- data/README.md +229 -0
- data/ext/quack/Cargo.lock +1803 -0
- data/ext/quack/Cargo.toml +17 -0
- data/ext/quack/extconf.rb +4 -0
- data/ext/quack/src/lib.rs +647 -0
- data/ext/quack/vendor/quack_protocol/Cargo.lock +1907 -0
- data/ext/quack/vendor/quack_protocol/Cargo.toml +41 -0
- data/ext/quack/vendor/quack_protocol/LICENSE +21 -0
- data/ext/quack/vendor/quack_protocol/PATCHES.md +16 -0
- data/ext/quack/vendor/quack_protocol/README.md +272 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/columns.rs +399 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/mod.rs +134 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/schema.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/tests.rs +851 -0
- data/ext/quack/vendor/quack_protocol/src/arrow/values.rs +150 -0
- data/ext/quack/vendor/quack_protocol/src/binary.rs +504 -0
- data/ext/quack/vendor/quack_protocol/src/builders.rs +217 -0
- data/ext/quack/vendor/quack_protocol/src/client.rs +1486 -0
- data/ext/quack/vendor/quack_protocol/src/constants.rs +11 -0
- data/ext/quack/vendor/quack_protocol/src/errors.rs +103 -0
- data/ext/quack/vendor/quack_protocol/src/inspection.rs +250 -0
- data/ext/quack/vendor/quack_protocol/src/json.rs +225 -0
- data/ext/quack/vendor/quack_protocol/src/lib.rs +54 -0
- data/ext/quack/vendor/quack_protocol/src/logical_types.rs +977 -0
- data/ext/quack/vendor/quack_protocol/src/messages.rs +843 -0
- data/ext/quack/vendor/quack_protocol/src/pool.rs +741 -0
- data/ext/quack/vendor/quack_protocol/src/protocol_tests.rs +759 -0
- data/ext/quack/vendor/quack_protocol/src/proxy_error.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/sql.rs +359 -0
- data/ext/quack/vendor/quack_protocol/src/tls.rs +354 -0
- data/ext/quack/vendor/quack_protocol/src/values.rs +77 -0
- data/ext/quack/vendor/quack_protocol/src/vector.rs +1364 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/key.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/spki.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v1-cert.der +0 -0
- data/ext/quack/vendor/quack_protocol/testdata/tls/v3-cert.der +0 -0
- data/lib/quack/codec.rb +263 -0
- data/lib/quack/connection.rb +131 -0
- data/lib/quack/logical_types.rb +72 -0
- data/lib/quack/result.rb +64 -0
- data/lib/quack/sql.rb +183 -0
- data/lib/quack/types.rb +40 -0
- data/lib/quack/variant.rb +298 -0
- data/lib/quack.rb +34 -0
- metadata +136 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/quack/codec.rb
ADDED
|
@@ -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
|
data/lib/quack/result.rb
ADDED
|
@@ -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
|