sequel-duckdb 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +174 -2
- data/README.md +121 -0
- data/lib/sequel/adapters/duckdb.rb +111 -14
- data/lib/sequel/adapters/shared/duckdb.rb +286 -149
- data/lib/sequel/duckdb/helpers/copier.rb +30 -7
- data/lib/sequel/duckdb/helpers/pathifier.rb +1 -1
- data/lib/sequel/duckdb/instance_registry.rb +114 -0
- data/lib/sequel/duckdb/sql_time_tz.rb +31 -0
- data/lib/sequel/duckdb/version.rb +1 -1
- data/lib/sequel/extensions/duckdb_array.rb +149 -0
- data/lib/sequel/extensions/duckdb_json.rb +130 -0
- data/lib/sequel/extensions/duckdb_map.rb +93 -0
- data/lib/sequel/extensions/duckdb_ops.rb +219 -0
- data/lib/sequel/extensions/duckdb_struct.rb +92 -0
- metadata +26 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9ed53934b345068605d21e881ef98049be3dcbb07213c6d5238ce6566c15cdf
|
|
4
|
+
data.tar.gz: 92a1cdd312058eb4459084f5eb9069b7780cf54f56ccfbc15ae7f7f931e0fbfd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c372ff00e47bb0680b7e2601fbc5ab61a281e0cd7c68c4764c6f2355d8701014b99d54ad364d77f6f8becba32ed9e9dc6b09d9ae73c9f53d179abbbc70bdbd96
|
|
7
|
+
data.tar.gz: 5dce4902a7eb0f5b25ec86e129afc8f0c9962cdd2f960dea8d352827401303d9cc5bb189e1879369891cbaf0764facc9f8c726ef5d3b3ecac51ec9b7a21f0d71
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,171 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-10-25
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
|
|
12
|
+
- **adapter:** TIME and TIME_NS result values are now `Sequel::SQLTime`, not
|
|
13
|
+
`Time`. Sequel's postgres and sqlite adapters use the same class for a time of day. The
|
|
14
|
+
value keeps the hour, minute, second and fraction, and TIME_NS keeps its nanoseconds. A
|
|
15
|
+
value assigned to a TIME model column is also a `Sequel::SQLTime` on today's date. It
|
|
16
|
+
was a `Time` on 1970-01-01 before. So a model that is assigned the time it already has
|
|
17
|
+
no longer reports the column as changed. A `Sequel::SQLTime` in a query is written as
|
|
18
|
+
`'HH:MM:SS.ffffff'`, so writes keep the fraction. `Sequel::SQLTime` is a subclass of
|
|
19
|
+
`Time`, so `is_a?(Time)` is still true. Code that compares the class exactly with
|
|
20
|
+
`Time`, or that expects the date 1970-01-01, must change.
|
|
21
|
+
- **adapter:** TIMETZ columns keep their UTC offset. `db.schema` reports
|
|
22
|
+
them as `:time_timezone`, not `:time`. Result values are the new class
|
|
23
|
+
`Sequel::DuckDB::SQLTimeTZ`, a subclass of `Sequel::SQLTime` on today's date that
|
|
24
|
+
keeps the offset. A value assigned to a TIMETZ model column becomes the same class,
|
|
25
|
+
so assigning a row its own value no longer reports the column as changed. A
|
|
26
|
+
`SQLTimeTZ` in a query is written as `'HH:MM:SS.ffffff+HH:MM'`. Before, a model wrote
|
|
27
|
+
a TIMETZ value without its offset, so DuckDB applied the session time zone:
|
|
28
|
+
08:09:10.5+02:00 was stored as 08:09:10.5-07:00. Two values with the same instant
|
|
29
|
+
and different offsets are not equal. A plain `Time` in a dataset query is still
|
|
30
|
+
written as a timestamp without an offset.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **copy:** `Database#copy_to` and the new `Dataset#copy_to_sql` write partition columns
|
|
35
|
+
as quoted identifiers. `partition_by: "year"` gave `PARTITION_BY YEAR` and now gives
|
|
36
|
+
`PARTITION_BY ("year")`. DuckDB runs both forms, so the result does not change. Tests
|
|
37
|
+
that compare COPY strings must update their expected strings. Word values are
|
|
38
|
+
unchanged, for example `FORMAT PARQUET` and `COMPRESSION ZSTD`. Values that are not a
|
|
39
|
+
single word, such as `delimiter: "|"`, are now quoted strings. The old text for those
|
|
40
|
+
values was a DuckDB syntax error.
|
|
41
|
+
- **adapter:** a `Time` dated 1970-01-01 in a query is now written as a timestamp, for
|
|
42
|
+
example `'1970-01-01 09:30:00'`, like any other `Time`. It was written as the time of
|
|
43
|
+
day `'09:30:00'` before. DuckDB casts both forms to the same TIME value, so a
|
|
44
|
+
comparison with a TIME column gives the same result. Use `Sequel::SQLTime` for a time
|
|
45
|
+
of day.
|
|
46
|
+
- **adapter:** Remove the typecast_value override and its schema cache ([f75e08b](https://github.com/outcomesinsights/sequel-duckdb/commit/f75e08b4a507f9a933d60d72cdf80ae44be23d26))
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **capabilities:** the adapter now declares Sequel's flags for DISTINCT ON, GROUP BY
|
|
51
|
+
ROLLUP, CUBE and GROUPING SETS, LATERAL subqueries, MERGE, the WINDOW clause, regular
|
|
52
|
+
expressions, and `CREATE TABLE IF NOT EXISTS` / `DROP TABLE IF EXISTS`. Sequel refused
|
|
53
|
+
most of these features before, or left the WINDOW clause out of the SQL. Negated
|
|
54
|
+
regexps (`exclude(name: /x/)`) are now written as `NOT regexp_matches(...)`.
|
|
55
|
+
`create_table?` and `drop_table?` now emit `IF NOT EXISTS` / `IF EXISTS`. Other SQL is
|
|
56
|
+
unchanged.
|
|
57
|
+
- **adapter:** `Database#add_conversion_proc(type, callable = nil, &block)` registers a
|
|
58
|
+
converter for result values of one DuckDB column type. Dataset fetches pass each
|
|
59
|
+
non-NULL value of a column of that type to the converter. The converter receives the
|
|
60
|
+
whole value, so a LIST of STRUCT arrives as one Array. `type` is the type name that
|
|
61
|
+
ruby-duckdb reports, for example `:time` or `:list`. A column with a type alias is
|
|
62
|
+
looked up by its alias first. JSON columns report the type `:varchar` with the alias
|
|
63
|
+
`JSON`, so you register them as `:json`. A column type without a converter keeps the
|
|
64
|
+
value that ruby-duckdb returns. The adapter registers converters for `:time`,
|
|
65
|
+
`:time_ns` and `:time_tz`, and a registration of your own replaces them.
|
|
66
|
+
- **extensions:** the opt-in `duckdb_array` extension writes and reads DuckDB LIST and
|
|
67
|
+
ARRAY columns, as Sequel's `pg_array` does for PostgreSQL. `Sequel.duckdb_array([1, 2])`
|
|
68
|
+
returns a `Sequel::DuckDB::DuckDBArray`, which Sequel writes as the list `[1, 2]`. A
|
|
69
|
+
plain Ruby Array still means an IN list or a condition. Load the extension with
|
|
70
|
+
`DB.extension :duckdb_array`. LIST and ARRAY result values are then `DuckDBArray`
|
|
71
|
+
objects. A value nested inside another list, struct or map is not wrapped. `db.schema`
|
|
72
|
+
reports an `INTEGER[]` or `INTEGER[3]` column as `:integer_array`. A list of an element
|
|
73
|
+
type that has no Sequel type, for example a STRUCT, is reported as `:array`. A model
|
|
74
|
+
typecasts an assigned Array to a `DuckDBArray` and typecasts each element to the
|
|
75
|
+
element type. Without the extension, the SQL and the values do not change.
|
|
76
|
+
- **extensions:** the opt-in `duckdb_struct` extension writes and reads DuckDB STRUCT
|
|
77
|
+
columns. Sequel's `pg_row` is the PostgreSQL counterpart. DuckDB structs have no type
|
|
78
|
+
name, so you do not register a type. `Sequel.duckdb_struct(a: 1, b: "x")` returns a
|
|
79
|
+
`Sequel::DuckDB::DuckDBStruct` with Symbol keys. Sequel writes it as the struct
|
|
80
|
+
`{'a': 1, 'b': 'x'}`. A plain Ruby Hash still means a condition. A struct or list
|
|
81
|
+
inside a struct is written only when it is a wrapper too. Load the extension with
|
|
82
|
+
`DB.extension :duckdb_struct`. STRUCT result values are then `DuckDBStruct` objects.
|
|
83
|
+
`db.schema` reports a STRUCT column as `:struct`. A model typecasts an assigned Hash to
|
|
84
|
+
a `DuckDBStruct`. With `duckdb_array` also loaded, a list of structs is reported as
|
|
85
|
+
`:struct_array`. Without the extension, the SQL and the values do not change.
|
|
86
|
+
- **extensions:** the opt-in `duckdb_map` extension writes and reads DuckDB MAP columns.
|
|
87
|
+
Sequel's `pg_hstore` is the PostgreSQL counterpart, but a DuckDB MAP has typed keys
|
|
88
|
+
and values. `Sequel.duckdb_map("k" => 1)` returns a `Sequel::DuckDB::DuckDBMap`, which
|
|
89
|
+
Sequel writes as the map `MAP {'k': 1}`. An empty Hash gives `MAP {}`. The keys keep
|
|
90
|
+
their Ruby type, so an INTEGER-keyed map has Integer keys. A plain Ruby Hash still
|
|
91
|
+
means a condition. Load the extension with `DB.extension :duckdb_map`. MAP result
|
|
92
|
+
values are then `DuckDBMap` objects. `db.schema` reports a MAP column as `:map`. A
|
|
93
|
+
model typecasts an assigned Hash to a `DuckDBMap`. With `duckdb_array` also loaded, a
|
|
94
|
+
list of maps is reported as `:map_array`. Without the extension, the SQL and the
|
|
95
|
+
values do not change.
|
|
96
|
+
- **extensions:** the opt-in `duckdb_json` extension writes and reads DuckDB JSON
|
|
97
|
+
columns, as Sequel's `pg_json` does for PostgreSQL. `Sequel.duckdb_json("a" => 1)`
|
|
98
|
+
returns a `Sequel::DuckDB::DuckDBJSONHash`, and an Array gives a `DuckDBJSONArray`.
|
|
99
|
+
Sequel writes each one as JSON text cast to JSON, for example `'{"a":1}'::JSON`. A
|
|
100
|
+
plain Ruby Hash or Array still means a condition or an IN list. Load the extension
|
|
101
|
+
with `DB.extension :duckdb_json`. JSON result values are then parsed. A JSON object
|
|
102
|
+
becomes a `DuckDBJSONHash` with String keys, and a JSON array becomes a
|
|
103
|
+
`DuckDBJSONArray`. A JSON string, number, boolean or null becomes the plain Ruby
|
|
104
|
+
value. A VARCHAR column is not parsed, even when it holds JSON text. `db.schema`
|
|
105
|
+
reports a JSON column as `:json`. A model typecasts an assigned Hash or Array to the
|
|
106
|
+
wrapper and parses an assigned String as JSON. With `duckdb_array` also loaded, a
|
|
107
|
+
JSON list is reported as `:json_array`. Without the extension, the SQL and the values
|
|
108
|
+
do not change.
|
|
109
|
+
- **duckdb_ops:** Add opt-in duckdb_ops extension with Sequel.try_cast ([a8c0815](https://github.com/outcomesinsights/sequel-duckdb/commit/a8c081531c2bb071bb1a8d07752254346e39f32f))
|
|
110
|
+
- **duckdb_ops:** Add named-argument builder and read\_\* helpers ([5409d10](https://github.com/outcomesinsights/sequel-duckdb/commit/5409d10f508452dd3b945b65bf0417767296a9f0))
|
|
111
|
+
- **duckdb_ops:** Add Sequel.duckdb_lambda for DuckDB lambda expressions ([5de2362](https://github.com/outcomesinsights/sequel-duckdb/commit/5de2362580467f518de0c5ba3fe6253d655633a5))
|
|
112
|
+
- **copy:** Add Dataset#copy_to_sql; Database#copy_to runs it ([a972d8f](https://github.com/outcomesinsights/sequel-duckdb/commit/a972d8f66ec6a3713f5c7fa612e0172f284d9332))
|
|
113
|
+
- **dataset:** Declare supports_is_distinct_from? so IS DISTINCT FROM is native ([d8a1397](https://github.com/outcomesinsights/sequel-duckdb/commit/d8a13978a9c12e895d28bcf9fa2aea69006e7c2f))
|
|
114
|
+
|
|
115
|
+
### Fixed
|
|
116
|
+
|
|
117
|
+
- **dataset:** a CTE now works in INSERT, UPDATE and DELETE. `supports_cte?` took no
|
|
118
|
+
argument, so an INSERT from a dataset with a CTE raised `ArgumentError`. An UPDATE or a
|
|
119
|
+
DELETE on a dataset with a CTE left the WITH clause out of the SQL, so DuckDB could not
|
|
120
|
+
find the CTE's table. All three statements now start with the WITH clause.
|
|
121
|
+
- **connection:** a frozen `Sequel::Database` can open and close connections and drop a
|
|
122
|
+
schema. Sequel recommends `Database#freeze` for production, but every disconnect and
|
|
123
|
+
every `drop_schema` raised `FrozenError` after it, and later queries failed.
|
|
124
|
+
- **connection:** the `config:` option was documented but ignored. Its DuckDB settings,
|
|
125
|
+
for example `config: { threads: 4, memory_limit: "2GB" }`, now reach DuckDB. A file that
|
|
126
|
+
is already open in this process keeps the settings of the first `Sequel::Database` that
|
|
127
|
+
opened it. Opening it with different settings raises `Sequel::DatabaseConnectionError`,
|
|
128
|
+
as DuckDB's Python, Java and R clients do. No `config:` and some `config:` count as
|
|
129
|
+
different.
|
|
130
|
+
- **schema:** `table_exists?` returned false for some tables that exist: a
|
|
131
|
+
`Sequel.identifier` name, an unqualified name on the `search_path`, a table in an
|
|
132
|
+
attached catalog, and a `Sequel.identifier` name with the `schema:` option. DuckDB now
|
|
133
|
+
resolves the name, as it does for a query. `schema` and `indexes` raised "does not
|
|
134
|
+
exist" for the same names. They now read the table or view that a query with that
|
|
135
|
+
name reads, including a temporary table that has the name of a `main` table.
|
|
136
|
+
- **schema:** `db.schema` reported type `:string` for FLOAT and REAL, unsigned and
|
|
137
|
+
128-bit integer, VARINT, TIMESTAMPTZ, TIMESTAMP_S/MS/NS, TIME_NS and INTERVAL
|
|
138
|
+
columns. Sequel models then typecast an assigned value to a String, for example 1.5 to
|
|
139
|
+
"1.5" for a FLOAT column. These columns now report `:float`, `:integer`, `:bigint`,
|
|
140
|
+
`:datetime`, `:time` or `:interval`. List, struct, map, union, enum, JSON and BIT
|
|
141
|
+
columns still report `:string`.
|
|
142
|
+
- **schema:** a schema, table or catalog name with a single quote in it broke
|
|
143
|
+
`tables`, `views`, `table_exists?`, `schema`, `indexes`, `schemas` and
|
|
144
|
+
`schema_exists?` with a syntax error. `set_pragma` had the same defect for a value that
|
|
145
|
+
is not a String, Boolean or Number.
|
|
146
|
+
- **date_arithmetic:** a query that held both `Sequel.date_add(d, days: 30)` and
|
|
147
|
+
`Sequel.date_add(d, months: 1)` returned the same date for both. DuckDB judges
|
|
148
|
+
`INTERVAL 30 DAY` equal to `INTERVAL 1 MONTH`, and its optimizer computed only one of
|
|
149
|
+
the two expressions. The same happened with 360 days and 1 year, and inside aggregates.
|
|
150
|
+
Year and month intervals are now written as a subtraction of the negated value:
|
|
151
|
+
`months: 1` gives `"d" - INTERVAL (-1) MONTH`, and `months: -1` gives
|
|
152
|
+
`"d" - INTERVAL 1 MONTH`. The same merge happened between days and hours of equal
|
|
153
|
+
length, for example 1 day and 24 hours, which differ for a TIMESTAMPTZ across a
|
|
154
|
+
daylight-saving change. Hour, minute and second intervals are now written with the
|
|
155
|
+
interval first: `hours: 24` gives `INTERVAL 24 HOUR + "d"`. Day intervals are
|
|
156
|
+
unchanged. Tests that compare date arithmetic SQL strings must update their expected
|
|
157
|
+
strings for every unit except days.
|
|
158
|
+
- **connection:** two `Sequel::Database` objects on one DuckDB file in one process opened
|
|
159
|
+
two DuckDB instances, and writes through one did not reliably reach the other. Every
|
|
160
|
+
`Sequel::Database` on a file now shares one DuckDB instance. `Database#disconnect` now
|
|
161
|
+
gives the file back once that database has no open connection. The file closes when no
|
|
162
|
+
`Sequel::Database` in the process uses it, and the next query opens it again.
|
|
163
|
+
In-memory databases are unchanged: each is separate and stays open after `disconnect`.
|
|
164
|
+
- **dataset:** Declare supports_filtered_aggregates? so FILTER is native ([c7aad9c](https://github.com/outcomesinsights/sequel-duckdb/commit/c7aad9c9cfad8eaf28c0825004a23c2a6e52bc31))
|
|
165
|
+
- **copy:** Escape paths, quote COPY options, infer-or-raise FORMAT ([13efc23](https://github.com/outcomesinsights/sequel-duckdb/commit/13efc239c0d491b6302af6207d19ab86a022f160))
|
|
166
|
+
|
|
167
|
+
## [0.3.1] - 2026-10-02
|
|
168
|
+
|
|
169
|
+
### Fixed
|
|
170
|
+
|
|
171
|
+
- Point homepage and changelog links at outcomesinsights/sequel-duckdb ([69fa0db](https://github.com/outcomesinsights/sequel-duckdb/commit/69fa0db723f6a579cc5451df450f568452968ce0))
|
|
172
|
+
|
|
8
173
|
## [0.3.0] - 2026-10-01
|
|
9
174
|
|
|
10
175
|
### Breaking changes
|
|
@@ -15,12 +180,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
15
180
|
|
|
16
181
|
- Ship only lib/ and three docs; stop tracking the beads key ([c45dfc4](https://github.com/outcomesinsights/sequel-duckdb/commit/c45dfc4636917269f6333da1be2b21ed8855a0c2))
|
|
17
182
|
|
|
18
|
-
## [0.2.1]
|
|
183
|
+
## [0.2.1] - 2026-10-01
|
|
19
184
|
|
|
20
185
|
No changes to the gem's behaviour. The first release published by the
|
|
21
186
|
tag-based release workflow.
|
|
22
187
|
|
|
23
|
-
## [0.2.0]
|
|
188
|
+
## [0.2.0] - 2026-09-30
|
|
24
189
|
|
|
25
190
|
### ⚠ BREAKING CHANGES
|
|
26
191
|
|
|
@@ -176,3 +341,10 @@ tag-based release workflow.
|
|
|
176
341
|
- Connection string sanitization
|
|
177
342
|
- File path validation for database files
|
|
178
343
|
- Read-only database connection support
|
|
344
|
+
|
|
345
|
+
[0.1.0]: https://github.com/outcomesinsights/sequel-duckdb/releases/tag/v0.1.0
|
|
346
|
+
[0.2.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.1.0...v0.2.0
|
|
347
|
+
[0.2.1]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.0...v0.2.1
|
|
348
|
+
[0.3.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.1...v0.3.0
|
|
349
|
+
[0.3.1]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.3.0...v0.3.1
|
|
350
|
+
[0.4.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.3.1...v0.4.0
|
data/README.md
CHANGED
|
@@ -520,6 +520,127 @@ db[:users].multi_insert(users_data)
|
|
|
520
520
|
db[:large_table].select(:id, :name, :created_at).where(active: true)
|
|
521
521
|
```
|
|
522
522
|
|
|
523
|
+
### Exporting with COPY
|
|
524
|
+
|
|
525
|
+
`Database#copy_to` runs a DuckDB `COPY` that writes a dataset (or a SQL string) to a file.
|
|
526
|
+
FORMAT comes from the `format:` option, or else from the file extension. A path with
|
|
527
|
+
neither, such as a directory for a partitioned export, raises `Sequel::Error`.
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
db.copy_to(db[:users].where(active: true), "/data/users.parquet")
|
|
531
|
+
db.copy_to(db[:events], "/data/events", format: :parquet, partition_by: [:year, :month])
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
`Dataset#copy_to_sql` returns the same `COPY` statement without running it, for example to
|
|
535
|
+
write a plan file:
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
db[:users].copy_to_sql("/data/users.csv", header: true, delimiter: "|")
|
|
539
|
+
# => COPY (SELECT * FROM "users") TO '/data/users.csv' (FORMAT CSV, HEADER, DELIMITER '|')
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Lists
|
|
543
|
+
|
|
544
|
+
Sequel writes a Ruby Array as a parenthesized list, such as `(1, 2, 3)`, for `IN`
|
|
545
|
+
conditions. DuckDB does not read that as a list value. To build a DuckDB list from
|
|
546
|
+
expressions, call DuckDB's `list_value` function:
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
DB.literal([1, 2, 3])
|
|
550
|
+
# => (1, 2, 3)
|
|
551
|
+
DB.literal(Sequel.function(:list_value, :a, Sequel[:b] + 1))
|
|
552
|
+
# => list_value("a", ("b" + 1))
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### IS DISTINCT FROM
|
|
556
|
+
|
|
557
|
+
The adapter declares that DuckDB supports `IS DISTINCT FROM`. Sequel's `is_distinct_from`
|
|
558
|
+
extension then writes it natively. Load the extension on the Database. With only
|
|
559
|
+
`Sequel.extension :is_distinct_from`, the builder exists, but the dataset cannot write it
|
|
560
|
+
and raises `NoMethodError`. Write `IS NOT DISTINCT FROM` by negating the expression with
|
|
561
|
+
`~`:
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
DB.extension :is_distinct_from
|
|
565
|
+
DB[:t].where(Sequel.is_distinct_from(:a, :b))
|
|
566
|
+
# => SELECT * FROM "t" WHERE ("a" IS DISTINCT FROM "b")
|
|
567
|
+
DB[:t].where(~Sequel.is_distinct_from(:a, :b))
|
|
568
|
+
# => SELECT * FROM "t" WHERE NOT ("a" IS DISTINCT FROM "b")
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### duckdb_ops extension
|
|
572
|
+
|
|
573
|
+
The `duckdb_ops` extension adds DuckDB-specific expression builders to `Sequel`. It is
|
|
574
|
+
opt-in. If you do not load it, Sequel generates the same SQL as before. Load it with
|
|
575
|
+
either form. Both forms make the builders available globally.
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
Sequel.extension :duckdb_ops
|
|
579
|
+
# or
|
|
580
|
+
DB.extension :duckdb_ops
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`Sequel.try_cast(expr, type)` builds a DuckDB `TRY_CAST`. It returns NULL when the cast
|
|
584
|
+
fails, where `CAST` raises an error. The type works as in `Sequel.cast`: a Ruby class
|
|
585
|
+
maps to the adapter's type name, and a Symbol or String passes through unchanged.
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
DB[:raw].select(Sequel.try_cast(:amount, :DOUBLE).as(:amount))
|
|
589
|
+
# => SELECT TRY_CAST("amount" AS DOUBLE) AS "amount" FROM "raw"
|
|
590
|
+
Sequel.try_cast(:amount, Float)
|
|
591
|
+
# => TRY_CAST("amount" AS double precision)
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
DuckDB table functions take named parameters. `Sequel.duckdb_named(**params)` builds them
|
|
595
|
+
for the last argument of `Sequel.function`. `Sequel.duckdb_read_parquet`,
|
|
596
|
+
`Sequel.duckdb_read_csv` and `Sequel.duckdb_read_json` take a path or an array of paths,
|
|
597
|
+
plus named parameters. An Array value becomes a DuckDB list. A Hash value becomes a
|
|
598
|
+
struct, in which a Symbol is a bare type name and a String is a string literal.
|
|
599
|
+
|
|
600
|
+
```ruby
|
|
601
|
+
parts = Sequel.duckdb_read_parquet("/data/events/**/*.parquet",
|
|
602
|
+
hive_partitioning: true, hive_types: { year: :BIGINT })
|
|
603
|
+
DB.from(parts.as(:events)).where(year: 2024)
|
|
604
|
+
# => SELECT * FROM read_parquet(['/data/events/**/*.parquet'], hive_partitioning = TRUE,
|
|
605
|
+
# hive_types = {'year': BIGINT}) AS "events" WHERE ("year" = 2024)
|
|
606
|
+
|
|
607
|
+
Sequel.function(:read_json, "/data/a.json", Sequel.duckdb_named(columns: { a: "INTEGER" }))
|
|
608
|
+
# => read_json('/data/a.json', columns = {'a': 'INTEGER'})
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
#### Lambda expressions
|
|
612
|
+
|
|
613
|
+
`Sequel.duckdb_lambda` builds a DuckDB lambda expression for list functions such as
|
|
614
|
+
`list_transform`, `list_filter` and `list_reduce`. Call those functions through
|
|
615
|
+
`Sequel.function`. The builder has two forms. In the block form, every argument is a
|
|
616
|
+
parameter name, and the block gets one identifier for each parameter. In the body form,
|
|
617
|
+
the first argument is a parameter name or an Array of names, and the second argument is
|
|
618
|
+
the body. The body names the parameters as Symbols or with `Sequel[:x]`.
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x }
|
|
622
|
+
Sequel.duckdb_lambda([:acc, :x], Sequel[:acc] + :x)
|
|
623
|
+
# => lambda "acc", "x": ("acc" + "x")
|
|
624
|
+
|
|
625
|
+
Sequel.function(:list_reduce, :amounts,
|
|
626
|
+
Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x },
|
|
627
|
+
Sequel.cast(0, :BIGINT))
|
|
628
|
+
# => list_reduce("amounts", lambda "acc", "x": ("acc" + "x"), CAST(0 AS BIGINT))
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
DuckDB has no `let`. To compute a value once and use it more than once, wrap it in a
|
|
632
|
+
one-element list, transform it, and take element 1 with `Sequel.subscript`:
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
square_plus = Sequel.duckdb_lambda(:v) { |v| (v * v) + v }
|
|
636
|
+
Sequel.subscript(Sequel.function(:list_transform, Sequel.function(:list_value, :n), square_plus), 1)
|
|
637
|
+
# => (list_transform(list_value("n"), lambda "v": (("v" * "v") + "v")))[1]
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
The builder emits DuckDB's `lambda` keyword syntax, never the `x -> body` arrow, which
|
|
641
|
+
DuckDB deprecated. It needs a DuckDB version that accepts the `lambda` keyword. Parameter
|
|
642
|
+
names are quoted identifiers, the same as the references to them in the body.
|
|
643
|
+
|
|
523
644
|
## Model Integration
|
|
524
645
|
|
|
525
646
|
### Sequel::Model Usage
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "sequel"
|
|
4
4
|
require "duckdb"
|
|
5
5
|
require_relative "shared/duckdb"
|
|
6
|
+
require_relative "../duckdb/instance_registry"
|
|
6
7
|
|
|
7
8
|
# Sequel is a database toolkit for Ruby that provides a powerful ORM and database abstraction layer.
|
|
8
9
|
# This module extends Sequel to support DuckDB, a high-performance analytical database engine.
|
|
@@ -54,36 +55,111 @@ module Sequel
|
|
|
54
55
|
# @since 0.1.0
|
|
55
56
|
module DuckDB
|
|
56
57
|
module DriverDatabaseMethods
|
|
58
|
+
# The open ::DuckDB::Database and the number of open connections to it. Connecting
|
|
59
|
+
# and disconnecting change both, so they live in this object and not in instance
|
|
60
|
+
# variables, which a frozen Sequel::Database cannot change.
|
|
61
|
+
ConnectionState = Struct.new(:database, :connections)
|
|
62
|
+
|
|
57
63
|
private
|
|
58
64
|
|
|
59
65
|
# Open the DuckDB::Database once during Sequel::Database initialization.
|
|
60
66
|
# DuckDB's ATTACH/schema state is per-Database, not per-Connection, so all
|
|
61
|
-
# pool connections must share one Database instance.
|
|
67
|
+
# pool connections must share one Database instance. A database file is also
|
|
68
|
+
# shared with every other Sequel::Database on that file in this process
|
|
69
|
+
# (InstanceRegistry), and #disconnect gives it back once no connection is open.
|
|
62
70
|
def adapter_initialize
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
@conversion_procs = {}
|
|
72
|
+
@primary_keys = {}
|
|
73
|
+
@primary_key_sequences = {}
|
|
74
|
+
register_default_conversion_procs
|
|
75
|
+
@duckdb_mutex = Mutex.new
|
|
76
|
+
@duckdb_file = duckdb_file_path(opts[:database])
|
|
77
|
+
@duckdb_settings = InstanceRegistry.normalize_settings(opts[:config])
|
|
78
|
+
@duckdb_state = ConnectionState.new(open_duckdb_database, 0)
|
|
79
|
+
rescue Sequel::DatabaseConnectionError
|
|
80
|
+
raise
|
|
73
81
|
rescue ::DuckDB::Error => e
|
|
74
82
|
raise Sequel::DatabaseConnectionError, "Failed to connect to DuckDB database: #{e.message}"
|
|
75
83
|
rescue StandardError => e
|
|
76
84
|
raise Sequel::DatabaseConnectionError, "Unexpected error connecting to DuckDB: #{e.message}"
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
# ruby-duckdb returns TIME, TIME_NS and TIMETZ values as a Time on today's date.
|
|
88
|
+
# Sequel's convention for a time of day is Sequel::SQLTime, which is also what
|
|
89
|
+
# Sequel typecasts a value assigned to a TIME model column to. TIMETZ values become
|
|
90
|
+
# Sequel::DuckDB::SQLTimeTZ, which keeps the value's UTC offset.
|
|
91
|
+
def register_default_conversion_procs
|
|
92
|
+
to_sqltime = ->(value) { Sequel::SQLTime.create(value.hour, value.min, value.sec, Rational(value.nsec, 1000)) }
|
|
93
|
+
add_conversion_proc(:time, to_sqltime)
|
|
94
|
+
add_conversion_proc(:time_ns, to_sqltime)
|
|
95
|
+
add_conversion_proc(:time_tz, SQLTimeTZ.method(:from_time))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# nil for an in-memory database, which is never shared.
|
|
99
|
+
def duckdb_file_path(database_path)
|
|
100
|
+
return nil if database_path.nil? || database_path == ":memory:"
|
|
101
|
+
return "/#{database_path}" if database_path.match?(/^[a-zA-Z]/) && !database_path.start_with?(":")
|
|
102
|
+
|
|
103
|
+
database_path
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# opts[:config] holds DuckDB settings, for example { threads: 4, memory_limit: "2GB" }.
|
|
107
|
+
def open_duckdb_database
|
|
108
|
+
if @duckdb_file
|
|
109
|
+
InstanceRegistry.acquire(@duckdb_file, @duckdb_settings)
|
|
110
|
+
else
|
|
111
|
+
InstanceRegistry.open(":memory:", @duckdb_settings)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
79
115
|
public
|
|
80
116
|
|
|
117
|
+
# Registers a converter for result values of one DuckDB column type. Dataset
|
|
118
|
+
# fetches pass each non-NULL value of a column of that type to the converter and
|
|
119
|
+
# return what it gives back. A converter receives the whole column value, so a
|
|
120
|
+
# LIST of STRUCT gives it the complete Array.
|
|
121
|
+
#
|
|
122
|
+
# +type+ is the type name that ruby-duckdb reports, for example :time or :list.
|
|
123
|
+
# A column with a type alias is looked up by its alias first: JSON columns report
|
|
124
|
+
# the type :varchar with the alias "JSON", so register them as :json. Register
|
|
125
|
+
# converters before you run queries. A later registration for the same type
|
|
126
|
+
# replaces the earlier one, including the built-in converters for :time and
|
|
127
|
+
# :time_ns, which return Sequel::SQLTime, and for :time_tz, which returns
|
|
128
|
+
# Sequel::DuckDB::SQLTimeTZ.
|
|
129
|
+
#
|
|
130
|
+
# @example
|
|
131
|
+
# db.add_conversion_proc(:json) { |value| JSON.parse(value) }
|
|
132
|
+
def add_conversion_proc(type, callable = nil, &block)
|
|
133
|
+
@conversion_procs[conversion_proc_key(type)] = callable || block
|
|
134
|
+
end
|
|
135
|
+
|
|
81
136
|
def connect(_server)
|
|
82
|
-
@
|
|
137
|
+
@duckdb_mutex.synchronize do
|
|
138
|
+
state = @duckdb_state
|
|
139
|
+
state.database ||= open_duckdb_database
|
|
140
|
+
conn = state.database.connect
|
|
141
|
+
state.connections += 1
|
|
142
|
+
conn
|
|
143
|
+
end
|
|
83
144
|
rescue ::DuckDB::Error => e
|
|
84
145
|
raise Sequel::DatabaseConnectionError, "Failed to connect to DuckDB database: #{e.message}"
|
|
85
146
|
end
|
|
86
147
|
|
|
148
|
+
# Closes the pool's idle connections. When no connection of this Sequel::Database
|
|
149
|
+
# is still open, it also gives back its reference to the shared database file, so
|
|
150
|
+
# the file closes once no Sequel::Database in the process uses it. The next query
|
|
151
|
+
# opens it again. An in-memory database stays open, so its data survives.
|
|
152
|
+
def disconnect(opts = OPTS)
|
|
153
|
+
super
|
|
154
|
+
@duckdb_mutex.synchronize do
|
|
155
|
+
state = @duckdb_state
|
|
156
|
+
next unless @duckdb_file && state.database && state.connections.zero?
|
|
157
|
+
|
|
158
|
+
InstanceRegistry.release(@duckdb_file)
|
|
159
|
+
state.database = nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
87
163
|
def disconnect_connection(conn)
|
|
88
164
|
return unless conn
|
|
89
165
|
|
|
@@ -91,6 +167,7 @@ module Sequel
|
|
|
91
167
|
conn.close
|
|
92
168
|
rescue ::DuckDB::Error # rubocop:disable Lint/SuppressedException -- best-effort cleanup on disconnect
|
|
93
169
|
end
|
|
170
|
+
@duckdb_mutex.synchronize { @duckdb_state.connections -= 1 }
|
|
94
171
|
end
|
|
95
172
|
|
|
96
173
|
def valid_connection?(conn)
|
|
@@ -123,7 +200,7 @@ module Sequel
|
|
|
123
200
|
private
|
|
124
201
|
|
|
125
202
|
def database_error_classes
|
|
126
|
-
[::DuckDB::Error]
|
|
203
|
+
[ ::DuckDB::Error ]
|
|
127
204
|
end
|
|
128
205
|
|
|
129
206
|
def _execute(type, sql, opts, &block)
|
|
@@ -164,6 +241,22 @@ module Sequel
|
|
|
164
241
|
column.respond_to?(:name) ? column.name.to_s : column.to_s
|
|
165
242
|
end
|
|
166
243
|
end
|
|
244
|
+
|
|
245
|
+
def conversion_proc_key(type)
|
|
246
|
+
type.to_s.downcase.to_sym
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The converter for each result column, in column order, or nil for a column
|
|
250
|
+
# with none. Empty when nothing is registered, so rows stay exactly as
|
|
251
|
+
# ruby-duckdb returns them.
|
|
252
|
+
def result_conversion_procs(result)
|
|
253
|
+
return [] if @conversion_procs.empty?
|
|
254
|
+
|
|
255
|
+
result.columns.map do |column|
|
|
256
|
+
type_alias = column.logical_type.alias
|
|
257
|
+
(type_alias && @conversion_procs[conversion_proc_key(type_alias)]) || @conversion_procs[column.type]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
167
260
|
end
|
|
168
261
|
|
|
169
262
|
module DriverDatasetMethods
|
|
@@ -173,11 +266,15 @@ module Sequel
|
|
|
173
266
|
|
|
174
267
|
col_names = db.send(:result_column_names, result)
|
|
175
268
|
self.columns = col_names.map { |c| output_identifier(c) }
|
|
269
|
+
converters = db.send(:result_conversion_procs, result)
|
|
176
270
|
|
|
177
271
|
result.each do |row_array|
|
|
178
272
|
row_hash = {}
|
|
179
273
|
col_names.each_with_index do |col_name, i|
|
|
180
|
-
|
|
274
|
+
value = row_array[i]
|
|
275
|
+
converter = converters[i]
|
|
276
|
+
value = converter.call(value) if converter && !value.nil?
|
|
277
|
+
row_hash[output_identifier(col_name)] = value
|
|
181
278
|
end
|
|
182
279
|
yield row_hash
|
|
183
280
|
end
|