sequel-duckdb 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1a28241665b1678903381a6230815dc1d8bc03b54f0778dfa86ee1042f04edb
4
- data.tar.gz: a855f1bcac746a4f495ac1d003f015da38afac13f0663675c65d2df704baef66
3
+ metadata.gz: 7d728d2d4748d8ace682454b06155cea6ae570a8fbfbf888efa7d2a314077e5c
4
+ data.tar.gz: 525588b5baceefd4e7b0e3e61f429efb1c505d03250be98ca109bb9567bfce63
5
5
  SHA512:
6
- metadata.gz: c26aef724f16dd8f29b396f2e63338fbcfd72ab9d739614638038e46a41e2ea051dbca9bd6a6cbb2d935d1aec99853651019cc8b803958eedc63c0cc7ccdb984
7
- data.tar.gz: fa3cc4c38ae41d572095bb09ccd16547519645f935beae228866591180997e7846cbd274817c486bc95d1c5edbbe333ce592e1926322e0366f18020d1db98a8a
6
+ metadata.gz: 63164b64d30f71edcb66edb7e3a01ed07074de9312673b51b823e264268f702609fd6fcf98481535c7fc9165f49fa6eb38aa28adfd4311f129ef10d3b9a6bb8b
7
+ data.tar.gz: 76f6fb5c6e9d2684a013176f3ff7b9414ba6e0a9e67e88d36312e5cb57df5ddda4805ad84f4506ce7c60576461748194e24c2727596493b735eb434bec00cfc7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,185 @@ 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.1] - 2026-10-26
9
+
10
+ ### Added
11
+
12
+ - Support for the DuckDB C library 2.x and the duckdb gem 2.x, alongside 1.5.x. CI
13
+ tests library 1.5.6 and 2.0.0, and the duckdb gem 1.5.x and 2.x. Support for library
14
+ 1.5.x ends when DuckDB 1.5.x reaches end of life in March 2027. sequel-duckdb still
15
+ does not depend on the duckdb gem, so each project picks its own version.
16
+ - **database:** Add attach and detach for DuckDB catalogs ([f82169b](https://github.com/outcomesinsights/sequel-duckdb/commit/f82169b0ad4cd66abe588dfdad431053535151e1))
17
+
18
+ ### Changed
19
+
20
+ - **adapter:** Remove the unused primary key caches ([9a14254](https://github.com/outcomesinsights/sequel-duckdb/commit/9a14254abd8494e71af9e5478d081ff048b46701))
21
+
22
+ ### Fixed
23
+
24
+ - **connection:** Honor the readonly option ([e8e94c5](https://github.com/outcomesinsights/sequel-duckdb/commit/e8e94c5a5a210aacac0a53cc8190f3dc76b3bbdf))
25
+ - **schema:** Keep the transaction when a name lookup misses on DuckDB 2.x ([b3c02c6](https://github.com/outcomesinsights/sequel-duckdb/commit/b3c02c6029095e11fb048ef2b3c0e84a6c62e3ec))
26
+ - **schema:** Let a mock Database drop and create schemas ([f9aad74](https://github.com/outcomesinsights/sequel-duckdb/commit/f9aad74f94fc9d4ed024accd9ccb4bc6ecdb628e))
27
+
28
+ ## [0.4.0] - 2026-10-25
29
+
30
+ ### Breaking changes
31
+
32
+ - **adapter:** TIME and TIME_NS result values are now `Sequel::SQLTime`, not
33
+ `Time`. Sequel's postgres and sqlite adapters use the same class for a time of day. The
34
+ value keeps the hour, minute, second and fraction, and TIME_NS keeps its nanoseconds. A
35
+ value assigned to a TIME model column is also a `Sequel::SQLTime` on today's date. It
36
+ was a `Time` on 1970-01-01 before. So a model that is assigned the time it already has
37
+ no longer reports the column as changed. A `Sequel::SQLTime` in a query is written as
38
+ `'HH:MM:SS.ffffff'`, so writes keep the fraction. `Sequel::SQLTime` is a subclass of
39
+ `Time`, so `is_a?(Time)` is still true. Code that compares the class exactly with
40
+ `Time`, or that expects the date 1970-01-01, must change.
41
+ - **adapter:** TIMETZ columns keep their UTC offset. `db.schema` reports
42
+ them as `:time_timezone`, not `:time`. Result values are the new class
43
+ `Sequel::DuckDB::SQLTimeTZ`, a subclass of `Sequel::SQLTime` on today's date that
44
+ keeps the offset. A value assigned to a TIMETZ model column becomes the same class,
45
+ so assigning a row its own value no longer reports the column as changed. A
46
+ `SQLTimeTZ` in a query is written as `'HH:MM:SS.ffffff+HH:MM'`. Before, a model wrote
47
+ a TIMETZ value without its offset, so DuckDB applied the session time zone:
48
+ 08:09:10.5+02:00 was stored as 08:09:10.5-07:00. Two values with the same instant
49
+ and different offsets are not equal. A plain `Time` in a dataset query is still
50
+ written as a timestamp without an offset.
51
+
52
+ ### Changed
53
+
54
+ - **copy:** `Database#copy_to` and the new `Dataset#copy_to_sql` write partition columns
55
+ as quoted identifiers. `partition_by: "year"` gave `PARTITION_BY YEAR` and now gives
56
+ `PARTITION_BY ("year")`. DuckDB runs both forms, so the result does not change. Tests
57
+ that compare COPY strings must update their expected strings. Word values are
58
+ unchanged, for example `FORMAT PARQUET` and `COMPRESSION ZSTD`. Values that are not a
59
+ single word, such as `delimiter: "|"`, are now quoted strings. The old text for those
60
+ values was a DuckDB syntax error.
61
+ - **adapter:** a `Time` dated 1970-01-01 in a query is now written as a timestamp, for
62
+ example `'1970-01-01 09:30:00'`, like any other `Time`. It was written as the time of
63
+ day `'09:30:00'` before. DuckDB casts both forms to the same TIME value, so a
64
+ comparison with a TIME column gives the same result. Use `Sequel::SQLTime` for a time
65
+ of day.
66
+ - **adapter:** Remove the typecast_value override and its schema cache ([f75e08b](https://github.com/outcomesinsights/sequel-duckdb/commit/f75e08b4a507f9a933d60d72cdf80ae44be23d26))
67
+
68
+ ### Added
69
+
70
+ - **capabilities:** the adapter now declares Sequel's flags for DISTINCT ON, GROUP BY
71
+ ROLLUP, CUBE and GROUPING SETS, LATERAL subqueries, MERGE, the WINDOW clause, regular
72
+ expressions, and `CREATE TABLE IF NOT EXISTS` / `DROP TABLE IF EXISTS`. Sequel refused
73
+ most of these features before, or left the WINDOW clause out of the SQL. Negated
74
+ regexps (`exclude(name: /x/)`) are now written as `NOT regexp_matches(...)`.
75
+ `create_table?` and `drop_table?` now emit `IF NOT EXISTS` / `IF EXISTS`. Other SQL is
76
+ unchanged.
77
+ - **adapter:** `Database#add_conversion_proc(type, callable = nil, &block)` registers a
78
+ converter for result values of one DuckDB column type. Dataset fetches pass each
79
+ non-NULL value of a column of that type to the converter. The converter receives the
80
+ whole value, so a LIST of STRUCT arrives as one Array. `type` is the type name that
81
+ ruby-duckdb reports, for example `:time` or `:list`. A column with a type alias is
82
+ looked up by its alias first. JSON columns report the type `:varchar` with the alias
83
+ `JSON`, so you register them as `:json`. A column type without a converter keeps the
84
+ value that ruby-duckdb returns. The adapter registers converters for `:time`,
85
+ `:time_ns` and `:time_tz`, and a registration of your own replaces them.
86
+ - **extensions:** the opt-in `duckdb_array` extension writes and reads DuckDB LIST and
87
+ ARRAY columns, as Sequel's `pg_array` does for PostgreSQL. `Sequel.duckdb_array([1, 2])`
88
+ returns a `Sequel::DuckDB::DuckDBArray`, which Sequel writes as the list `[1, 2]`. A
89
+ plain Ruby Array still means an IN list or a condition. Load the extension with
90
+ `DB.extension :duckdb_array`. LIST and ARRAY result values are then `DuckDBArray`
91
+ objects. A value nested inside another list, struct or map is not wrapped. `db.schema`
92
+ reports an `INTEGER[]` or `INTEGER[3]` column as `:integer_array`. A list of an element
93
+ type that has no Sequel type, for example a STRUCT, is reported as `:array`. A model
94
+ typecasts an assigned Array to a `DuckDBArray` and typecasts each element to the
95
+ element type. Without the extension, the SQL and the values do not change.
96
+ - **extensions:** the opt-in `duckdb_struct` extension writes and reads DuckDB STRUCT
97
+ columns. Sequel's `pg_row` is the PostgreSQL counterpart. DuckDB structs have no type
98
+ name, so you do not register a type. `Sequel.duckdb_struct(a: 1, b: "x")` returns a
99
+ `Sequel::DuckDB::DuckDBStruct` with Symbol keys. Sequel writes it as the struct
100
+ `{'a': 1, 'b': 'x'}`. A plain Ruby Hash still means a condition. A struct or list
101
+ inside a struct is written only when it is a wrapper too. Load the extension with
102
+ `DB.extension :duckdb_struct`. STRUCT result values are then `DuckDBStruct` objects.
103
+ `db.schema` reports a STRUCT column as `:struct`. A model typecasts an assigned Hash to
104
+ a `DuckDBStruct`. With `duckdb_array` also loaded, a list of structs is reported as
105
+ `:struct_array`. Without the extension, the SQL and the values do not change.
106
+ - **extensions:** the opt-in `duckdb_map` extension writes and reads DuckDB MAP columns.
107
+ Sequel's `pg_hstore` is the PostgreSQL counterpart, but a DuckDB MAP has typed keys
108
+ and values. `Sequel.duckdb_map("k" => 1)` returns a `Sequel::DuckDB::DuckDBMap`, which
109
+ Sequel writes as the map `MAP {'k': 1}`. An empty Hash gives `MAP {}`. The keys keep
110
+ their Ruby type, so an INTEGER-keyed map has Integer keys. A plain Ruby Hash still
111
+ means a condition. Load the extension with `DB.extension :duckdb_map`. MAP result
112
+ values are then `DuckDBMap` objects. `db.schema` reports a MAP column as `:map`. A
113
+ model typecasts an assigned Hash to a `DuckDBMap`. With `duckdb_array` also loaded, a
114
+ list of maps is reported as `:map_array`. Without the extension, the SQL and the
115
+ values do not change.
116
+ - **extensions:** the opt-in `duckdb_json` extension writes and reads DuckDB JSON
117
+ columns, as Sequel's `pg_json` does for PostgreSQL. `Sequel.duckdb_json("a" => 1)`
118
+ returns a `Sequel::DuckDB::DuckDBJSONHash`, and an Array gives a `DuckDBJSONArray`.
119
+ Sequel writes each one as JSON text cast to JSON, for example `'{"a":1}'::JSON`. A
120
+ plain Ruby Hash or Array still means a condition or an IN list. Load the extension
121
+ with `DB.extension :duckdb_json`. JSON result values are then parsed. A JSON object
122
+ becomes a `DuckDBJSONHash` with String keys, and a JSON array becomes a
123
+ `DuckDBJSONArray`. A JSON string, number, boolean or null becomes the plain Ruby
124
+ value. A VARCHAR column is not parsed, even when it holds JSON text. `db.schema`
125
+ reports a JSON column as `:json`. A model typecasts an assigned Hash or Array to the
126
+ wrapper and parses an assigned String as JSON. With `duckdb_array` also loaded, a
127
+ JSON list is reported as `:json_array`. Without the extension, the SQL and the values
128
+ do not change.
129
+ - **duckdb_ops:** Add opt-in duckdb_ops extension with Sequel.try_cast ([a8c0815](https://github.com/outcomesinsights/sequel-duckdb/commit/a8c081531c2bb071bb1a8d07752254346e39f32f))
130
+ - **duckdb_ops:** Add named-argument builder and read\_\* helpers ([5409d10](https://github.com/outcomesinsights/sequel-duckdb/commit/5409d10f508452dd3b945b65bf0417767296a9f0))
131
+ - **duckdb_ops:** Add Sequel.duckdb_lambda for DuckDB lambda expressions ([5de2362](https://github.com/outcomesinsights/sequel-duckdb/commit/5de2362580467f518de0c5ba3fe6253d655633a5))
132
+ - **copy:** Add Dataset#copy_to_sql; Database#copy_to runs it ([a972d8f](https://github.com/outcomesinsights/sequel-duckdb/commit/a972d8f66ec6a3713f5c7fa612e0172f284d9332))
133
+ - **dataset:** Declare supports_is_distinct_from? so IS DISTINCT FROM is native ([d8a1397](https://github.com/outcomesinsights/sequel-duckdb/commit/d8a13978a9c12e895d28bcf9fa2aea69006e7c2f))
134
+
135
+ ### Fixed
136
+
137
+ - **dataset:** a CTE now works in INSERT, UPDATE and DELETE. `supports_cte?` took no
138
+ argument, so an INSERT from a dataset with a CTE raised `ArgumentError`. An UPDATE or a
139
+ DELETE on a dataset with a CTE left the WITH clause out of the SQL, so DuckDB could not
140
+ find the CTE's table. All three statements now start with the WITH clause.
141
+ - **connection:** a frozen `Sequel::Database` can open and close connections and drop a
142
+ schema. Sequel recommends `Database#freeze` for production, but every disconnect and
143
+ every `drop_schema` raised `FrozenError` after it, and later queries failed.
144
+ - **connection:** the `config:` option was documented but ignored. Its DuckDB settings,
145
+ for example `config: { threads: 4, memory_limit: "2GB" }`, now reach DuckDB. A file that
146
+ is already open in this process keeps the settings of the first `Sequel::Database` that
147
+ opened it. Opening it with different settings raises `Sequel::DatabaseConnectionError`,
148
+ as DuckDB's Python, Java and R clients do. No `config:` and some `config:` count as
149
+ different.
150
+ - **schema:** `table_exists?` returned false for some tables that exist: a
151
+ `Sequel.identifier` name, an unqualified name on the `search_path`, a table in an
152
+ attached catalog, and a `Sequel.identifier` name with the `schema:` option. DuckDB now
153
+ resolves the name, as it does for a query. `schema` and `indexes` raised "does not
154
+ exist" for the same names. They now read the table or view that a query with that
155
+ name reads, including a temporary table that has the name of a `main` table.
156
+ - **schema:** `db.schema` reported type `:string` for FLOAT and REAL, unsigned and
157
+ 128-bit integer, VARINT, TIMESTAMPTZ, TIMESTAMP_S/MS/NS, TIME_NS and INTERVAL
158
+ columns. Sequel models then typecast an assigned value to a String, for example 1.5 to
159
+ "1.5" for a FLOAT column. These columns now report `:float`, `:integer`, `:bigint`,
160
+ `:datetime`, `:time` or `:interval`. List, struct, map, union, enum, JSON and BIT
161
+ columns still report `:string`.
162
+ - **schema:** a schema, table or catalog name with a single quote in it broke
163
+ `tables`, `views`, `table_exists?`, `schema`, `indexes`, `schemas` and
164
+ `schema_exists?` with a syntax error. `set_pragma` had the same defect for a value that
165
+ is not a String, Boolean or Number.
166
+ - **date_arithmetic:** a query that held both `Sequel.date_add(d, days: 30)` and
167
+ `Sequel.date_add(d, months: 1)` returned the same date for both. DuckDB judges
168
+ `INTERVAL 30 DAY` equal to `INTERVAL 1 MONTH`, and its optimizer computed only one of
169
+ the two expressions. The same happened with 360 days and 1 year, and inside aggregates.
170
+ Year and month intervals are now written as a subtraction of the negated value:
171
+ `months: 1` gives `"d" - INTERVAL (-1) MONTH`, and `months: -1` gives
172
+ `"d" - INTERVAL 1 MONTH`. The same merge happened between days and hours of equal
173
+ length, for example 1 day and 24 hours, which differ for a TIMESTAMPTZ across a
174
+ daylight-saving change. Hour, minute and second intervals are now written with the
175
+ interval first: `hours: 24` gives `INTERVAL 24 HOUR + "d"`. Day intervals are
176
+ unchanged. Tests that compare date arithmetic SQL strings must update their expected
177
+ strings for every unit except days.
178
+ - **connection:** two `Sequel::Database` objects on one DuckDB file in one process opened
179
+ two DuckDB instances, and writes through one did not reliably reach the other. Every
180
+ `Sequel::Database` on a file now shares one DuckDB instance. `Database#disconnect` now
181
+ gives the file back once that database has no open connection. The file closes when no
182
+ `Sequel::Database` in the process uses it, and the next query opens it again.
183
+ In-memory databases are unchanged: each is separate and stays open after `disconnect`.
184
+ - **dataset:** Declare supports_filtered_aggregates? so FILTER is native ([c7aad9c](https://github.com/outcomesinsights/sequel-duckdb/commit/c7aad9c9cfad8eaf28c0825004a23c2a6e52bc31))
185
+ - **copy:** Escape paths, quote COPY options, infer-or-raise FORMAT ([13efc23](https://github.com/outcomesinsights/sequel-duckdb/commit/13efc239c0d491b6302af6207d19ab86a022f160))
186
+
8
187
  ## [0.3.1] - 2026-10-02
9
188
 
10
189
  ### Fixed
@@ -21,12 +200,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
200
 
22
201
  - Ship only lib/ and three docs; stop tracking the beads key ([c45dfc4](https://github.com/outcomesinsights/sequel-duckdb/commit/c45dfc4636917269f6333da1be2b21ed8855a0c2))
23
202
 
24
- ## [0.2.1](https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.0...v0.2.1) (2026-10-01)
203
+ ## [0.2.1] - 2026-10-01
25
204
 
26
205
  No changes to the gem's behaviour. The first release published by the
27
206
  tag-based release workflow.
28
207
 
29
- ## [0.2.0](https://github.com/outcomesinsights/sequel-duckdb/compare/v0.1.0...v0.2.0) (2026-09-30)
208
+ ## [0.2.0] - 2026-09-30
30
209
 
31
210
  ### ⚠ BREAKING CHANGES
32
211
 
@@ -182,3 +361,11 @@ tag-based release workflow.
182
361
  - Connection string sanitization
183
362
  - File path validation for database files
184
363
  - Read-only database connection support
364
+
365
+ [0.1.0]: https://github.com/outcomesinsights/sequel-duckdb/releases/tag/v0.1.0
366
+ [0.2.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.1.0...v0.2.0
367
+ [0.2.1]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.0...v0.2.1
368
+ [0.3.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.1...v0.3.0
369
+ [0.3.1]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.3.0...v0.3.1
370
+ [0.4.0]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.3.1...v0.4.0
371
+ [0.4.1]: https://github.com/outcomesinsights/sequel-duckdb/compare/v0.4.0...v0.4.1
data/README.md CHANGED
@@ -286,6 +286,12 @@ db = Sequel.connect('duckdb://relative/path/to/database.duckdb')
286
286
  db = Sequel.connect('duckdb:///path/to/database.duckdb?readonly=true')
287
287
  ```
288
288
 
289
+ `readonly` opens the file with DuckDB's `access_mode` set to `READ_ONLY`, so writes
290
+ raise `Sequel::DatabaseError`. All `Sequel::Database` objects on one file in one
291
+ process share one DuckDB instance, so the first one to open the file sets its
292
+ settings. Opening the same file again with other settings, such as read-write after
293
+ read-only, raises `Sequel::DatabaseConnectionError`.
294
+
289
295
  ### Connection Hash
290
296
 
291
297
  ```ruby
@@ -520,6 +526,159 @@ db[:users].multi_insert(users_data)
520
526
  db[:large_table].select(:id, :name, :created_at).where(active: true)
521
527
  ```
522
528
 
529
+ ### Exporting with COPY
530
+
531
+ `Database#copy_to` runs a DuckDB `COPY` that writes a dataset (or a SQL string) to a file.
532
+ FORMAT comes from the `format:` option, or else from the file extension. A path with
533
+ neither, such as a directory for a partitioned export, raises `Sequel::Error`.
534
+
535
+ ```ruby
536
+ db.copy_to(db[:users].where(active: true), "/data/users.parquet")
537
+ db.copy_to(db[:events], "/data/events", format: :parquet, partition_by: [:year, :month])
538
+ ```
539
+
540
+ `Dataset#copy_to_sql` returns the same `COPY` statement without running it, for example to
541
+ write a plan file:
542
+
543
+ ```ruby
544
+ db[:users].copy_to_sql("/data/users.csv", header: true, delimiter: "|")
545
+ # => COPY (SELECT * FROM "users") TO '/data/users.csv' (FORMAT CSV, HEADER, DELIMITER '|')
546
+ ```
547
+
548
+ ### Attaching database files
549
+
550
+ `Database#attach` runs a DuckDB `ATTACH`, and `Database#detach` runs `DETACH DATABASE`.
551
+ The catalog name `as:` is required. A name that DuckDB derives from the file name can
552
+ equal a schema name inside the file, and then `schema.table` is ambiguous. Other DuckDB
553
+ `ATTACH` options pass through, for example `type: :sqlite`.
554
+
555
+ ```ruby
556
+ db.attach("/data/dataset.duckdb", as: :dataset, read_only: true)
557
+ db[Sequel[:dataset][:people]].count
558
+ db.detach(:dataset, if_exists: true)
559
+
560
+ db.attach_sql("/data/dataset.duckdb", as: :dataset, read_only: true, if_not_exists: true)
561
+ # => ATTACH IF NOT EXISTS '/data/dataset.duckdb' AS "dataset" (READ_ONLY)
562
+ ```
563
+
564
+ An attached catalog belongs to the DuckDB database, which all pool connections of a
565
+ `Sequel::Database` share. It lasts until `Database#disconnect` closes the last
566
+ connection. After that, attach it again; `if_not_exists: true` makes a repeat safe.
567
+
568
+ `search_path` is different: DuckDB keeps it per connection. Set it on every new
569
+ connection with Sequel's `connect_sqls` option. Put the writable catalog first, because
570
+ `CREATE TABLE` without a schema goes to the first entry, and a read-only catalog there
571
+ makes it fail.
572
+
573
+ ```ruby
574
+ db = Sequel.connect("duckdb:///exports/42/work.duckdb",
575
+ connect_sqls: [ "SET search_path = 'work.main'" ])
576
+ db.attach("/data/dataset.duckdb", as: :dataset, read_only: true)
577
+ db.create_table(:summary) { Integer :n } # created in work.main
578
+ ```
579
+
580
+ ### Lists
581
+
582
+ Sequel writes a Ruby Array as a parenthesized list, such as `(1, 2, 3)`, for `IN`
583
+ conditions. DuckDB does not read that as a list value. To build a DuckDB list from
584
+ expressions, call DuckDB's `list_value` function:
585
+
586
+ ```ruby
587
+ DB.literal([1, 2, 3])
588
+ # => (1, 2, 3)
589
+ DB.literal(Sequel.function(:list_value, :a, Sequel[:b] + 1))
590
+ # => list_value("a", ("b" + 1))
591
+ ```
592
+
593
+ ### IS DISTINCT FROM
594
+
595
+ The adapter declares that DuckDB supports `IS DISTINCT FROM`. Sequel's `is_distinct_from`
596
+ extension then writes it natively. Load the extension on the Database. With only
597
+ `Sequel.extension :is_distinct_from`, the builder exists, but the dataset cannot write it
598
+ and raises `NoMethodError`. Write `IS NOT DISTINCT FROM` by negating the expression with
599
+ `~`:
600
+
601
+ ```ruby
602
+ DB.extension :is_distinct_from
603
+ DB[:t].where(Sequel.is_distinct_from(:a, :b))
604
+ # => SELECT * FROM "t" WHERE ("a" IS DISTINCT FROM "b")
605
+ DB[:t].where(~Sequel.is_distinct_from(:a, :b))
606
+ # => SELECT * FROM "t" WHERE NOT ("a" IS DISTINCT FROM "b")
607
+ ```
608
+
609
+ ### duckdb_ops extension
610
+
611
+ The `duckdb_ops` extension adds DuckDB-specific expression builders to `Sequel`. It is
612
+ opt-in. If you do not load it, Sequel generates the same SQL as before. Load it with
613
+ either form. Both forms make the builders available globally.
614
+
615
+ ```ruby
616
+ Sequel.extension :duckdb_ops
617
+ # or
618
+ DB.extension :duckdb_ops
619
+ ```
620
+
621
+ `Sequel.try_cast(expr, type)` builds a DuckDB `TRY_CAST`. It returns NULL when the cast
622
+ fails, where `CAST` raises an error. The type works as in `Sequel.cast`: a Ruby class
623
+ maps to the adapter's type name, and a Symbol or String passes through unchanged.
624
+
625
+ ```ruby
626
+ DB[:raw].select(Sequel.try_cast(:amount, :DOUBLE).as(:amount))
627
+ # => SELECT TRY_CAST("amount" AS DOUBLE) AS "amount" FROM "raw"
628
+ Sequel.try_cast(:amount, Float)
629
+ # => TRY_CAST("amount" AS double precision)
630
+ ```
631
+
632
+ DuckDB table functions take named parameters. `Sequel.duckdb_named(**params)` builds them
633
+ for the last argument of `Sequel.function`. `Sequel.duckdb_read_parquet`,
634
+ `Sequel.duckdb_read_csv` and `Sequel.duckdb_read_json` take a path or an array of paths,
635
+ plus named parameters. An Array value becomes a DuckDB list. A Hash value becomes a
636
+ struct, in which a Symbol is a bare type name and a String is a string literal.
637
+
638
+ ```ruby
639
+ parts = Sequel.duckdb_read_parquet("/data/events/**/*.parquet",
640
+ hive_partitioning: true, hive_types: { year: :BIGINT })
641
+ DB.from(parts.as(:events)).where(year: 2024)
642
+ # => SELECT * FROM read_parquet(['/data/events/**/*.parquet'], hive_partitioning = TRUE,
643
+ # hive_types = {'year': BIGINT}) AS "events" WHERE ("year" = 2024)
644
+
645
+ Sequel.function(:read_json, "/data/a.json", Sequel.duckdb_named(columns: { a: "INTEGER" }))
646
+ # => read_json('/data/a.json', columns = {'a': 'INTEGER'})
647
+ ```
648
+
649
+ #### Lambda expressions
650
+
651
+ `Sequel.duckdb_lambda` builds a DuckDB lambda expression for list functions such as
652
+ `list_transform`, `list_filter` and `list_reduce`. Call those functions through
653
+ `Sequel.function`. The builder has two forms. In the block form, every argument is a
654
+ parameter name, and the block gets one identifier for each parameter. In the body form,
655
+ the first argument is a parameter name or an Array of names, and the second argument is
656
+ the body. The body names the parameters as Symbols or with `Sequel[:x]`.
657
+
658
+ ```ruby
659
+ Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x }
660
+ Sequel.duckdb_lambda([:acc, :x], Sequel[:acc] + :x)
661
+ # => lambda "acc", "x": ("acc" + "x")
662
+
663
+ Sequel.function(:list_reduce, :amounts,
664
+ Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x },
665
+ Sequel.cast(0, :BIGINT))
666
+ # => list_reduce("amounts", lambda "acc", "x": ("acc" + "x"), CAST(0 AS BIGINT))
667
+ ```
668
+
669
+ DuckDB has no `let`. To compute a value once and use it more than once, wrap it in a
670
+ one-element list, transform it, and take element 1 with `Sequel.subscript`:
671
+
672
+ ```ruby
673
+ square_plus = Sequel.duckdb_lambda(:v) { |v| (v * v) + v }
674
+ Sequel.subscript(Sequel.function(:list_transform, Sequel.function(:list_value, :n), square_plus), 1)
675
+ # => (list_transform(list_value("n"), lambda "v": (("v" * "v") + "v")))[1]
676
+ ```
677
+
678
+ The builder emits DuckDB's `lambda` keyword syntax, never the `x -> body` arrow, which
679
+ DuckDB deprecated. It needs a DuckDB version that accepts the `lambda` keyword. Parameter
680
+ names are quoted identifiers, the same as the references to them in the body.
681
+
523
682
  ## Model Integration
524
683
 
525
684
  ### Sequel::Model Usage
@@ -644,10 +803,17 @@ db[:users].where(active: true).all
644
803
 
645
804
  ### Version Compatibility
646
805
 
647
- - **Ruby**: 3.1.0 or higher
806
+ - **Ruby**: 3.3 or higher (CI tests 3.3, 3.4 and 4.0)
648
807
  - **Sequel**: 5.0 or higher
649
- - **DuckDB**: 0.8.0 or higher
650
- - **ruby-duckdb**: 1.0.0 or higher
808
+ - **DuckDB C library**: 1.5.x and 2.x. Support for 1.5.x ends when DuckDB 1.5.x
809
+ reaches end of life in March 2027.
810
+ - **ruby-duckdb (the `duckdb` gem)**: 1.5.x and 2.x. sequel-duckdb does not depend on
811
+ it, so add `gem "duckdb"` to your own Gemfile. ruby-duckdb 2.x builds against
812
+ library 1.4 or newer, so it works with library 1.5.x.
813
+
814
+ DuckDB 2.x aborts an open transaction after any failed statement, including a query
815
+ on a missing table; 1.5.x does not. `table_exists?` and `schema` keep the transaction
816
+ on both versions.
651
817
 
652
818
  ### Getting Help
653
819
 
@@ -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,122 @@ 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
- database_path = opts[:database]
64
-
65
- @duckdb_database = if database_path == ":memory:" || database_path.nil?
66
- ::DuckDB::Database.open(":memory:")
67
- else
68
- if database_path.match?(/^[a-zA-Z]/) && !database_path.start_with?(":")
69
- database_path = "/#{database_path}"
70
- end
71
- ::DuckDB::Database.open(database_path)
72
- end
71
+ @conversion_procs = {}
72
+ register_default_conversion_procs
73
+ @duckdb_mutex = Mutex.new
74
+ @duckdb_file = duckdb_file_path(opts[:database])
75
+ @duckdb_settings = InstanceRegistry.normalize_settings(duckdb_config)
76
+ @duckdb_state = ConnectionState.new(open_duckdb_database, 0)
77
+ rescue Sequel::DatabaseConnectionError
78
+ raise
73
79
  rescue ::DuckDB::Error => e
74
80
  raise Sequel::DatabaseConnectionError, "Failed to connect to DuckDB database: #{e.message}"
75
81
  rescue StandardError => e
76
82
  raise Sequel::DatabaseConnectionError, "Unexpected error connecting to DuckDB: #{e.message}"
77
83
  end
78
84
 
85
+ # ruby-duckdb returns TIME, TIME_NS and TIMETZ values as a Time on today's date.
86
+ # Sequel's convention for a time of day is Sequel::SQLTime, which is also what
87
+ # Sequel typecasts a value assigned to a TIME model column to. TIMETZ values become
88
+ # Sequel::DuckDB::SQLTimeTZ, which keeps the value's UTC offset.
89
+ def register_default_conversion_procs
90
+ to_sqltime = ->(value) { Sequel::SQLTime.create(value.hour, value.min, value.sec, Rational(value.nsec, 1000)) }
91
+ add_conversion_proc(:time, to_sqltime)
92
+ add_conversion_proc(:time_ns, to_sqltime)
93
+ add_conversion_proc(:time_tz, SQLTimeTZ.method(:from_time))
94
+ end
95
+
96
+ # nil for an in-memory database, which is never shared.
97
+ # The DuckDB settings: the config: option, plus access_mode READ_ONLY when the
98
+ # readonly option (readonly: true, or ?readonly=true in the URL) is true.
99
+ def duckdb_config
100
+ config = opts[:config] || {}
101
+ return config unless typecast_value_boolean(opts[:readonly])
102
+
103
+ mode = config.find { |name, _| name.to_s == "access_mode" }&.last
104
+ return config.merge(access_mode: "READ_ONLY") if mode.nil?
105
+ return config if mode.to_s.casecmp?("READ_ONLY")
106
+
107
+ raise Sequel::DatabaseConnectionError, "readonly: true conflicts with config access_mode #{mode.inspect}"
108
+ end
109
+
110
+ def duckdb_file_path(database_path)
111
+ return nil if database_path.nil? || database_path == ":memory:"
112
+ return "/#{database_path}" if database_path.match?(/^[a-zA-Z]/) && !database_path.start_with?(":")
113
+
114
+ database_path
115
+ end
116
+
117
+ # opts[:config] holds DuckDB settings, for example { threads: 4, memory_limit: "2GB" }.
118
+ def open_duckdb_database
119
+ if @duckdb_file
120
+ InstanceRegistry.acquire(@duckdb_file, @duckdb_settings)
121
+ else
122
+ InstanceRegistry.open(":memory:", @duckdb_settings)
123
+ end
124
+ end
125
+
79
126
  public
80
127
 
128
+ # Registers a converter for result values of one DuckDB column type. Dataset
129
+ # fetches pass each non-NULL value of a column of that type to the converter and
130
+ # return what it gives back. A converter receives the whole column value, so a
131
+ # LIST of STRUCT gives it the complete Array.
132
+ #
133
+ # +type+ is the type name that ruby-duckdb reports, for example :time or :list.
134
+ # A column with a type alias is looked up by its alias first: JSON columns report
135
+ # the type :varchar with the alias "JSON", so register them as :json. Register
136
+ # converters before you run queries. A later registration for the same type
137
+ # replaces the earlier one, including the built-in converters for :time and
138
+ # :time_ns, which return Sequel::SQLTime, and for :time_tz, which returns
139
+ # Sequel::DuckDB::SQLTimeTZ.
140
+ #
141
+ # @example
142
+ # db.add_conversion_proc(:json) { |value| JSON.parse(value) }
143
+ def add_conversion_proc(type, callable = nil, &block)
144
+ @conversion_procs[conversion_proc_key(type)] = callable || block
145
+ end
146
+
81
147
  def connect(_server)
82
- @duckdb_database.connect
148
+ @duckdb_mutex.synchronize do
149
+ state = @duckdb_state
150
+ state.database ||= open_duckdb_database
151
+ conn = state.database.connect
152
+ state.connections += 1
153
+ conn
154
+ end
83
155
  rescue ::DuckDB::Error => e
84
156
  raise Sequel::DatabaseConnectionError, "Failed to connect to DuckDB database: #{e.message}"
85
157
  end
86
158
 
159
+ # Closes the pool's idle connections. When no connection of this Sequel::Database
160
+ # is still open, it also gives back its reference to the shared database file, so
161
+ # the file closes once no Sequel::Database in the process uses it. The next query
162
+ # opens it again. An in-memory database stays open, so its data survives.
163
+ def disconnect(opts = OPTS)
164
+ super
165
+ @duckdb_mutex.synchronize do
166
+ state = @duckdb_state
167
+ next unless @duckdb_file && state.database && state.connections.zero?
168
+
169
+ InstanceRegistry.release(@duckdb_file)
170
+ state.database = nil
171
+ end
172
+ end
173
+
87
174
  def disconnect_connection(conn)
88
175
  return unless conn
89
176
 
@@ -91,6 +178,7 @@ module Sequel
91
178
  conn.close
92
179
  rescue ::DuckDB::Error # rubocop:disable Lint/SuppressedException -- best-effort cleanup on disconnect
93
180
  end
181
+ @duckdb_mutex.synchronize { @duckdb_state.connections -= 1 }
94
182
  end
95
183
 
96
184
  def valid_connection?(conn)
@@ -122,6 +210,11 @@ module Sequel
122
210
 
123
211
  private
124
212
 
213
+ # DuckDB 2.0 made binder and catalog errors abort an open transaction.
214
+ def bind_errors_abort_transactions?
215
+ ::DuckDB::LIBRARY_VERSION.to_i >= 2
216
+ end
217
+
125
218
  def database_error_classes
126
219
  [ ::DuckDB::Error ]
127
220
  end
@@ -164,6 +257,22 @@ module Sequel
164
257
  column.respond_to?(:name) ? column.name.to_s : column.to_s
165
258
  end
166
259
  end
260
+
261
+ def conversion_proc_key(type)
262
+ type.to_s.downcase.to_sym
263
+ end
264
+
265
+ # The converter for each result column, in column order, or nil for a column
266
+ # with none. Empty when nothing is registered, so rows stay exactly as
267
+ # ruby-duckdb returns them.
268
+ def result_conversion_procs(result)
269
+ return [] if @conversion_procs.empty?
270
+
271
+ result.columns.map do |column|
272
+ type_alias = column.logical_type.alias
273
+ (type_alias && @conversion_procs[conversion_proc_key(type_alias)]) || @conversion_procs[column.type]
274
+ end
275
+ end
167
276
  end
168
277
 
169
278
  module DriverDatasetMethods
@@ -173,11 +282,15 @@ module Sequel
173
282
 
174
283
  col_names = db.send(:result_column_names, result)
175
284
  self.columns = col_names.map { |c| output_identifier(c) }
285
+ converters = db.send(:result_conversion_procs, result)
176
286
 
177
287
  result.each do |row_array|
178
288
  row_hash = {}
179
289
  col_names.each_with_index do |col_name, i|
180
- row_hash[output_identifier(col_name)] = row_array[i]
290
+ value = row_array[i]
291
+ converter = converters[i]
292
+ value = converter.call(value) if converter && !value.nil?
293
+ row_hash[output_identifier(col_name)] = value
181
294
  end
182
295
  yield row_hash
183
296
  end