quack-ruby 0.1.0-aarch64-linux-gnu
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/lib/quack/3.2/quack_native.so +0 -0
- data/lib/quack/3.3/quack_native.so +0 -0
- data/lib/quack/3.4/quack_native.so +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 +92 -0
data/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# quack-ruby
|
|
2
|
+
|
|
3
|
+
A direct Ruby client for DuckDB's Quack remote protocol. It uses Magnus and a
|
|
4
|
+
packaged, patched [quack_protocol 0.4.1](https://github.com/bnjjj/quack_protocol_rs)
|
|
5
|
+
over HTTP(S). The client does not embed DuckDB or use ADBC.
|
|
6
|
+
|
|
7
|
+
Supports DuckDB 1.5.5 / protocol v1 and DuckDB 2.0.0 / protocol v3. For Rails,
|
|
8
|
+
use [activerecord-quack-adapter](https://github.com/sidequery/activerecord-quack-adapter).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Add to your Gemfile:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "quack-ruby", "~> 0.1.0"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Precompiled gems cover Ruby 3.2, 3.3 and 3.4 on macOS (Apple Silicon and Intel)
|
|
19
|
+
and Linux glibc (ARM64 and x86-64). Bundler selects the matching platform gem;
|
|
20
|
+
the Quack extension installs without running Cargo or a compiler. The package
|
|
21
|
+
is named `quack-ruby`; its Ruby entry point remains `require "quack"`.
|
|
22
|
+
|
|
23
|
+
Linux binaries target glibc, not Alpine/musl. A source gem is also available
|
|
24
|
+
for other platforms and Ruby versions. Building that extension requires Rust,
|
|
25
|
+
a C toolchain, Ruby development headers and libclang. Installing directly from
|
|
26
|
+
Git also builds from source. To select the source package explicitly, use
|
|
27
|
+
`gem install quack-ruby --platform ruby`.
|
|
28
|
+
|
|
29
|
+
For a checkout:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bundle install
|
|
33
|
+
CARGO="$PWD/script/cached-cargo" bundle exec rake compile
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A Quack server must already be running. Provision databases on that server.
|
|
37
|
+
The client initializes its session timezone to UTC, also after reconnect.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "quack"
|
|
43
|
+
|
|
44
|
+
Quack.connect(ENV.fetch("QUACK_URI"), auth_token: ENV.fetch("QUACK_TOKEN"), timeout: 30) do |connection|
|
|
45
|
+
result = connection.query("select 42 as answer")
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For a parameterized query, choose positional or named parameters:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
connection = Quack.connect(ENV.fetch("QUACK_URI"), auth_token: ENV.fetch("QUACK_TOKEN"))
|
|
53
|
+
begin
|
|
54
|
+
result = connection.query("select ?::bigint as answer", [42])
|
|
55
|
+
result.columns # => ["answer"]
|
|
56
|
+
result.rows # => [[42]]
|
|
57
|
+
result.to_a # => [{"answer" => 42}]
|
|
58
|
+
|
|
59
|
+
connection.first("select :answer::bigint as answer", answer: 42)
|
|
60
|
+
connection.one("select 42 as answer") # Requires exactly one row.
|
|
61
|
+
connection.values("select 42") # => [42]
|
|
62
|
+
connection.execute("create temp table scratch (id integer)")
|
|
63
|
+
|
|
64
|
+
connection.stream("select i from range(1000000) t(i)") do |stream|
|
|
65
|
+
stream.each { |row| puts row.fetch("i") }
|
|
66
|
+
# each_row yields arrays; each_batch yields arrays of row arrays.
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
statement = connection.prepare("select ?::bigint as answer")
|
|
70
|
+
statement.query([42])
|
|
71
|
+
connection.transaction { |c| c.execute("insert into scratch values (?)", [1]) }
|
|
72
|
+
connection.info # Negotiated protocol version and server metadata.
|
|
73
|
+
ensure
|
|
74
|
+
connection.close
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Parameters/templates use client-side SQL substitution, not server-side bind
|
|
79
|
+
messages or a server statement cache. Quoting supports Unicode, binary strings,
|
|
80
|
+
NUL text, exact decimals/integers, temporal values, lists and structs. The lexer
|
|
81
|
+
preserves SQL strings, quoted identifiers, escaped strings, dollar-quoted
|
|
82
|
+
strings and nested comments. Identifiers must be quoted separately with
|
|
83
|
+
`Quack::SQL.identifier`.
|
|
84
|
+
|
|
85
|
+
`query` materializes all rows. `stream` fetches chunks on demand; block iteration
|
|
86
|
+
closes results even on early exit or exceptions. When keeping an unconsumed
|
|
87
|
+
stream, close it explicitly. A session permits one active result; an overlapping
|
|
88
|
+
operation raises `Quack::BusyError` rather than waiting indefinitely. Use a
|
|
89
|
+
connection per concurrent worker or an ordinary Ruby connection pool.
|
|
90
|
+
Duplicate result column names are preserved in `rows`; hash enumeration keeps
|
|
91
|
+
the last value for a repeated name.
|
|
92
|
+
|
|
93
|
+
Result conversions:
|
|
94
|
+
|
|
95
|
+
| DuckDB value | Ruby value |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| Signed/unsigned integers, including 128-bit and BIGNUM | `Integer`, without precision loss |
|
|
98
|
+
| Decimal | `BigDecimal` |
|
|
99
|
+
| Boolean, floating point, text, NULL | `true`/`false`, `Float`, `String`, `nil` |
|
|
100
|
+
| BLOB / other byte payloads | Binary-encoded `String` |
|
|
101
|
+
| Date | Proleptic Gregorian `Date` |
|
|
102
|
+
| Timestamp | `Quack::Timestamp < Time`, retaining SQL units, timezone kind and nanoseconds |
|
|
103
|
+
| Time / time with offset | `Quack::TimeOfDay` (supports `24:00:00`) |
|
|
104
|
+
| Interval | `Quack::Interval(months, days, microseconds)` |
|
|
105
|
+
| Infinite date/timestamp | `Quack::InfiniteTemporal` |
|
|
106
|
+
| List/array, struct | `Array`, string-keyed `Hash` |
|
|
107
|
+
| JSON, UUID, ENUM | `String` (Rails casts JSON to Ruby objects) |
|
|
108
|
+
| MAP | `Quack::Map`, with `entries`, `[]`, `each`, `to_h` |
|
|
109
|
+
| BIT | `Quack::BitString(bits)`, a string of logical 0/1 bits |
|
|
110
|
+
| GEOMETRY | `Quack::Geometry(wkb, crs)`, standard WKB and optional CRS |
|
|
111
|
+
| UNION | `Quack::Union(tag, value, members)`; tag distinguishes a NULL member from SQL NULL |
|
|
112
|
+
| VARIANT | `Quack::Variant`, with a typed `node` tree and logical Ruby `value` |
|
|
113
|
+
| TUPLE / unnamed STRUCT | `Quack::Tuple(values)`, preserving every ordinal field |
|
|
114
|
+
|
|
115
|
+
These logical values support SQL parameters, model assignment and native v1
|
|
116
|
+
append, including values nested in lists, arrays and structs. BIGNUM decoding,
|
|
117
|
+
DuckDB 2.0 type IDs and unnamed field handling are fixed in the packaged crate;
|
|
118
|
+
no separate fork download is needed to build the gem.
|
|
119
|
+
|
|
120
|
+
Read timestamps retain their SQL kind when rebound. Construct one explicitly
|
|
121
|
+
with `Quack::Timestamp.from(time, "TIMESTAMP_NS")` or, on DuckDB 2.0,
|
|
122
|
+
`Quack::Timestamp.from(time, "TIMESTAMPTZ_NS")`. Plain `Time`/`DateTime` values
|
|
123
|
+
use TIMESTAMPTZ when microseconds suffice and TIMESTAMP_NS when nanoseconds are
|
|
124
|
+
needed. Connections initialize `TimeZone = 'UTC'`, including after reconnect.
|
|
125
|
+
Subsequent explicit timezone changes follow DuckDB's normal cast semantics.
|
|
126
|
+
Values beyond a destination's precision are rejected by native append rather
|
|
127
|
+
than silently truncated. Rails writes preserve nine fractional digits.
|
|
128
|
+
|
|
129
|
+
`Quack::Variant.new({"items" => [1, nil, "é"]})` builds a heterogeneous value.
|
|
130
|
+
Use `Quack::Variant.new(value, type: "UBIGINT")` to specify a scalar type.
|
|
131
|
+
`variant.value` returns a Ruby tree; `variant.node` preserves each nested scalar
|
|
132
|
+
type. Assign a new Variant to edit it. DuckDB normalizes a root VARIANT null to
|
|
133
|
+
SQL NULL (`nil`); nulls inside containers remain nested nodes. Geometry uses
|
|
134
|
+
standard WKB rather than a client geometry engine, preserving Z/M, empty values,
|
|
135
|
+
collections and CRS metadata.
|
|
136
|
+
|
|
137
|
+
All storable public type families are exercised on the supported server versions.
|
|
138
|
+
DuckDB 1.5 cannot persist unnamed STRUCT/ROW tuples (even regular SQL INSERT fails);
|
|
139
|
+
their query/parameter roundtrips work, and tuple persistence is tested on 2.0.
|
|
140
|
+
TIMESTAMPTZ_NS is a DuckDB 2.0 type and is unavailable on 1.5. Internal/binder-only
|
|
141
|
+
types and types supplied by optional server extensions are not universal targets.
|
|
142
|
+
|
|
143
|
+
For protocol v1 only, the native append path is exposed:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
connection.append_rows("events", [{id: 1, name: "signup"}],
|
|
147
|
+
columns: {id: "BIGINT", name: "VARCHAR"}, batch_size: 1024)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Append resolves destination metadata from the server, including nested types,
|
|
151
|
+
all-null columns, empty lists, ENUM dictionaries, case-sensitive field names and
|
|
152
|
+
array sizes. Optional `columns` accepts full DuckDB SQL type declarations and
|
|
153
|
+
must name every destination column; hash order does not change column placement.
|
|
154
|
+
Rows must have exactly those names. Values are checked against destination
|
|
155
|
+
integer ranges, decimal scale/precision and temporal units before sending any
|
|
156
|
+
batch. Native SEND_DATA carries the values; append does not fall back to INSERT.
|
|
157
|
+
Schema-qualified table names and a `schema:` option are supported in the current
|
|
158
|
+
catalog. Select another catalog with `USE` before appending.
|
|
159
|
+
Append batches are separate operations; wrap them in a transaction if atomicity
|
|
160
|
+
is required.
|
|
161
|
+
|
|
162
|
+
## Lifecycle and protocol limits
|
|
163
|
+
|
|
164
|
+
- v3 is preferred, with v1 fallback on a rejected handshake. Require v3 by
|
|
165
|
+
setting both protocol-version bounds to 3.
|
|
166
|
+
- Native v3 SEND_DATA is unavailable in the underlying transport. `append_rows`
|
|
167
|
+
raises `Quack::UnsupportedTypeError` on v3; SQL INSERT works on both versions.
|
|
168
|
+
- Sessions retain temp tables, settings and transactions. Statements are never
|
|
169
|
+
replayed after a failure. Reconnect explicitly; session state is lost.
|
|
170
|
+
- Native network waits release the GVL but block the calling thread. They do
|
|
171
|
+
not integrate with a Fiber scheduler. HTTP timeouts bound waits; there is no
|
|
172
|
+
server-side cancellation API. Thread interrupts arrive when a call returns.
|
|
173
|
+
- Connections cannot cross forks. Create them after forking, or explicitly
|
|
174
|
+
reconnect in the child. Close explicitly for deterministic cleanup; GC
|
|
175
|
+
performs a bounded best-effort disconnect.
|
|
176
|
+
- One session permits one active result. Close or consume it before another
|
|
177
|
+
operation. Use a connection per concurrent worker.
|
|
178
|
+
- Nested transactions/savepoints are unavailable. Append batches are separate
|
|
179
|
+
operations; wrap them in a transaction when atomicity is needed.
|
|
180
|
+
- Errors inherit from `Quack::Error`: ConnectionError, TimeoutError, ServerError,
|
|
181
|
+
ProtocolError, UnsupportedTypeError, ProxyError, BusyError and ForkError.
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
CARGO="$PWD/script/cached-cargo" bundle exec rake compile
|
|
187
|
+
script/test
|
|
188
|
+
cargo fmt --manifest-path ext/quack/Cargo.toml --check
|
|
189
|
+
cargo fmt --manifest-path ext/quack/vendor/quack_protocol/Cargo.toml --check
|
|
190
|
+
mbx clippy --manifest-path ext/quack/Cargo.toml -- -D warnings
|
|
191
|
+
bundle exec rake package
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`script/test` uses uv to start disposable DuckDB 1.5.5 and 2.0.0 servers, run
|
|
195
|
+
the integration suite and stop them. Use `DUCKDB_TEST_VERSIONS=2.0.0 script/test`
|
|
196
|
+
for one server version. `bundle exec rake test` without a server runs local
|
|
197
|
+
checks and explicitly skips integration cases.
|
|
198
|
+
|
|
199
|
+
Tests cover all storable public type families, nested logical values, exact
|
|
200
|
+
integers/decimals, nonfinite floats and negative zero, timestamp units/TZ,
|
|
201
|
+
geometry dimensions/CRS, typed NULLs, empty results, parameter rebinding,
|
|
202
|
+
streaming, auth/TLS, timeouts, GVL release, fork/reconnect and native v1 append.
|
|
203
|
+
The append matrix includes batches, all-null/empty nested values, column
|
|
204
|
+
ordering and rejection of invalid values before any batch is written. VARIANT
|
|
205
|
+
leaf families also run through SQL and native v1 append. Tests distinguish
|
|
206
|
+
server-version restrictions from client support.
|
|
207
|
+
|
|
208
|
+
Development CI covers Linux/macOS and Ruby 3.2, 3.3 and 3.4 using Boxington.
|
|
209
|
+
The `Native gems` workflow builds fresh release binaries with rb-sys-dock,
|
|
210
|
+
packages all three Ruby ABIs into each platform gem, and runs the live suite
|
|
211
|
+
on all twelve OS/architecture/Ruby combinations with build tools blocked.
|
|
212
|
+
Release builds do not restore or publish compiler caches.
|
|
213
|
+
|
|
214
|
+
To release, dispatch `Release` from main and choose a patch, minor or major
|
|
215
|
+
bump. It updates the Ruby/Rust versions and locks, commits and tags the release,
|
|
216
|
+
builds all five packages, and runs the twelve installed-binary test combinations.
|
|
217
|
+
Only after every check passes does it publish those exact artifacts through
|
|
218
|
+
RubyGems Trusted Publishing and create a GitHub Release with the gem assets.
|
|
219
|
+
An optional `release_tag` publishes an existing tag without another bump; its
|
|
220
|
+
source version must match the tag. Source and binary gems use the same version.
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT. The vendored Rust crate retains its own license and provenance in
|
|
225
|
+
`ext/quack/vendor/quack_protocol/`. The type codecs follow DuckDB's public
|
|
226
|
+
[BIGNUM](https://github.com/duckdb/duckdb/blob/v2.0.0/src/common/types/bignum.cpp),
|
|
227
|
+
[BIT](https://github.com/duckdb/duckdb/blob/v2.0.0/src/common/types/bit.cpp) and
|
|
228
|
+
[VARIANT](https://github.com/duckdb/duckdb/blob/v2.0.0/src/include/duckdb/common/types/variant_visitor.hpp)
|
|
229
|
+
formats; DuckDB's MIT notice is included in LICENSE-DuckDB.
|
|
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
|