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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e465f675eb8331927ee6bef47ac89ddf5c1b740ebe901874b57860b14d5d5d4
4
- data.tar.gz: 62ca18b7870e68e85e3f557a7ff8697e59f0023659005227e257877b51e3e5b2
3
+ metadata.gz: f9ed53934b345068605d21e881ef98049be3dcbb07213c6d5238ce6566c15cdf
4
+ data.tar.gz: 92a1cdd312058eb4459084f5eb9069b7780cf54f56ccfbc15ae7f7f931e0fbfd
5
5
  SHA512:
6
- metadata.gz: 2882f239ed7c4dd659c63352571d740338659a3b18a4df14bded7ecb240961cbabd89f2245eb350c4768f3ac501672d90b4b9af7243650ccf74a467d4ef8e2e9
7
- data.tar.gz: 6ac18ddcefc0de413b62e83fe41650ddcda96b9fea09c361e482a6e4597443318ba8f13bd1f8f29c16452bc8065f1cff341dce95b7665d3a25a1fec1b9ecb54f
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](https://github.com/outcomesinsights/sequel-duckdb/compare/v0.2.0...v0.2.1) (2026-10-01)
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](https://github.com/outcomesinsights/sequel-duckdb/compare/v0.1.0...v0.2.0) (2026-09-30)
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
- 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
+ @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
- @duckdb_database.connect
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
- row_hash[output_identifier(col_name)] = row_array[i]
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