sequelizer 0.4.0 → 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: 9cda95a3cd5386a1d2e0ce1402793044ba78a07f457a265efe7a45e930095cb0
4
- data.tar.gz: 5cbfe2a32b9b65f9922d47fa3a9e894e2335874dfed53fd972bc9d9483ee9dd5
3
+ metadata.gz: 50510a32c25ed7c0218c60a7ba9d615a2ccf909685cc719dc1110a3fe43a5baf
4
+ data.tar.gz: a11d4852a6f18ffa2fca2fb5a9a75b67fd6c605d8d38321cb2b7a042e7ed8427
5
5
  SHA512:
6
- metadata.gz: 3657c7dfd5f9a040cf0b38caa910c1e5526ff874367fc261eaa2da3c3cebfaba9ffc9ca511e183466b10d019d822cfecd9e4b12e3fcbe07e59fc8075608a98de
7
- data.tar.gz: 29dd4df1852d028fea492621110a08a65cf186fe672a18645c6f4832aa89c5803c514db68bcd125dc8341e892d24273e0d11eee9f16c0f154cc10c59a967a02a
6
+ metadata.gz: 4f859dc2afb3bf840d8f2a53215ba7c2437a511eafdd23467cdab868092c45a3e4a8772159ca9098f1b808eda15041d357b05d7fd2f0e7c0f4acc7ec82c68df5
7
+ data.tar.gz: 69bb86b1fc5965eef2fa4a5449618050cb984d285c93d99fa2e56269c9d2967573f2b8b918c0a992880f7278a0c55bdd48c165a2e744ccf814f356a8565befdb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.1] - 2026-10-29
6
+
7
+ ### Added
8
+
9
+ - **sequelizer:** Add Sequelizer.dataset_db, a read-only DuckDB dataset connection ([acf8ac8](https://github.com/outcomesinsights/sequelizer/commit/acf8ac8f130d3db87dd3e99374d1d44de4a214f4))
10
+
5
11
  ## [0.4.0] - 2026-10-25
6
12
 
7
13
  ### Breaking changes
@@ -173,3 +179,4 @@ All notable changes to this project will be documented in this file.
173
179
  - The project itself
174
180
 
175
181
  [0.4.0]: https://github.com/outcomesinsights/sequelizer/compare/v0.3.0...v0.4.0
182
+ [0.4.1]: https://github.com/outcomesinsights/sequelizer/compare/v0.4.0...v0.4.1
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Sequelizer [![Build Status](https://travis-ci.org/outcomesinsights/sequelizer.svg?branch=master)](https://travis-ci.org/outcomesinsights/sequelizer)
1
+ # Sequelizer [![CI](https://github.com/outcomesinsights/sequelizer/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/outcomesinsights/sequelizer/actions/workflows/test.yml)
2
2
 
3
3
  I was tired of writing the code to bootstrap a connection to my databases.
4
4
 
@@ -78,6 +78,56 @@ For PostgreSQL, `search_path`, `schema_search_path` and `schema` are three names
78
78
 
79
79
  Take a look at the examples directory for a few ways you can specify your database configuration options. Copy a `sequelizer*.yml` example to `config/sequelizer.yml` to use it.
80
80
 
81
+ ## Read-only DuckDB dataset connections
82
+
83
+ `Sequelizer.dataset_db` returns a read-only connection to one DuckDB dataset. It needs the `sequel-duckdb` and `duckdb` gems.
84
+
85
+ ```ruby
86
+ db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf",
87
+ memory_limit: "51GB", temp_directory: "/scratch/duckdb",
88
+ max_temp_directory_size: "500GB")
89
+ db[:patients].count
90
+ ```
91
+
92
+ - `url:` is `duckdb://<directory>` or `duckdb://<file>`. For a directory, the dataset file is the one `*.duckdb` file in `<directory>/<schema>/`. A URL that names an existing DuckDB file uses that file.
93
+ - `schema:` is the schema in the file that holds the dataset's tables. A table name without a schema reads that schema.
94
+ - The connection is an in-memory DuckDB database that attaches the dataset file read-only, so a write to a dataset table fails. A `CREATE` without a catalog goes to the in-memory database.
95
+ - `memory_limit:`, `temp_directory:`, `max_temp_directory_size:` and `threads:` are DuckDB settings. A setting that you do not pass keeps DuckDB's default. DuckDB's default `memory_limit` is 80% of the memory that the container may use, for each DuckDB database in the process.
96
+ - `readonly:` must be true. `readonly: false` raises an `ArgumentError`, because read-write connections are not supported yet.
97
+ - Each process caches one connection for each (`url`, `schema`), and every thread gets the same one. A second call for a cached (`url`, `schema`) with other DuckDB settings raises `Sequelizer::DatasetSettingsConflictError`.
98
+ - The .env file, environment variables and YAML files are not read.
99
+
100
+ ## Loading Sequel extensions
101
+
102
+ An option named `extension_<name>` loads the Sequel extension `<name>` on each connection that Sequelizer makes. The option's value does not matter. For example, this line in .env loads `funky`:
103
+
104
+ ```
105
+ SEQUELIZER_EXTENSION_FUNKY=1
106
+ ```
107
+
108
+ In config/sequelizer.yml, write `extension_funky: 1`. You can also load an extension yourself with `db.extension :funky`.
109
+
110
+ An option named `<database type>_db_opt_<setting>` becomes a `SET <setting>=<value>` statement on each new connection. For example, `postgres_db_opt_work_mem: 256MB` runs `SET work_mem=256MB`.
111
+
112
+ ## Sequel extensions
113
+
114
+ Sequelizer ships these Sequel extensions. You can load each one on any Sequel connection, not only on one that Sequelizer made.
115
+
116
+ - **cold_col**: gives the columns of a dataset without a query to the database. It reads table schemas from a YAML file (`db.load_schema`), from `db.add_table_schema`, and from the tables and views that the session creates.
117
+ - **db_opts**: applies the `<database type>_db_opt_<setting>` options above. Sequelizer loads it on every connection.
118
+ - **funky**: functions that are written differently on Spark and on DuckDB, through `db.funky`. It supports only those two databases, and it raises an error on any other.
119
+ - `db.funky.hash(*parts)` returns a signed 64-bit integer that hashes the parts. The default strategy, `:md5`, gives the same id on DuckDB and on Spark when each part turns into the same string on both. That is true for strings, integers, booleans, decimals, dates and timestamps without a time zone. It is false for doubles of 10,000,000 or more, or under 0.001, in absolute value, and for DuckDB's TIMESTAMPTZ. Cast such a part to a decimal or a timestamp first.
120
+ - `db.funky.hash(*parts, strategy: :legacy)` gives the ids that sequelizer 0.3 and earlier built. Use it only to reproduce those ids, because on DuckDB it gives duplicate ids. An unknown strategy raises `Sequel::Funky::UnknownHashStrategyError`.
121
+ - `str_to_date(value, format)`, `collect_list(column)` and `make_json_column(dataset, key_column, value_column)` write the same operation for each database.
122
+ - **make_readyable**: `db.make_ready(use_schema:, search_path:, only:, except:)` prepares a Spark or DuckDB database for queries. It creates temporary views for the tables in a list of schemas, in order of precedence, and for external Parquet, ORC or other files given as `Pathname`s.
123
+ - **more_sql**: `db.create_schema(name, if_not_exists: true)`.
124
+ - **platform**: `db.platform` answers what the database supports and prefers (`supports?(:cte)`, `prefers?(:parquet)`, `[:interim_schema]`), and translates functions such as `date_diff(from, to)` and `days_between(from, to)`. The answers come from the CSV files in config/platforms/.
125
+ - **settable**: `db.set(timezone: "UTC")` runs `SET timezone=UTC`. A value is written into the statement as it is. Wrap a value in `Sequel.expr` when the database needs a quoted literal. `db.set_sql` returns the statements and runs nothing.
126
+ - **smart_select_remove**: replaces Sequel's `select_remove`. It finds the columns from the dataset's own SELECT list, or through `from_self` chains, so it does not need to query the database. It raises `Sequel::SmartSelectRemove::NoColumnsError` when it cannot find the columns, and `Sequel::SmartSelectRemove::AllColumnsRemovedError` when every column would be removed. In both cases Sequel's own `select_remove` would produce `SELECT *`, which still contains the removed columns.
127
+ - **sql_recorder**: `db.sql_recorder` is an array of every SQL statement sent to the database. Call `db.sql_recorder.clear` to empty it.
128
+ - **unionize**: `db.unionize(datasets, chunk_size: 100, all: true)` combines many datasets with UNION, one chunk at a time, for databases that cannot run one very large UNION. Each chunk goes into a temporary view on Spark and a temporary table on DuckDB. It supports only those two databases.
129
+ - **usable**: `db.use(:my_schema)` runs a USE statement that switches to that schema.
130
+
81
131
  ## Frustrations
82
132
 
83
133
  I can't seem to figure out a way to avoid having to specify the database gem in the a user's bundler file. If anyone has ideas on how to automagically load the correct database gem based on the database options fed to Sequelizer, please let me know (#1)!
@@ -28,7 +28,17 @@ module Sequel
28
28
  # strategy: :md5 (the default) casts each part to a string, writes a NULL
29
29
  # part as chr(30), joins the parts with chr(31), and reads the first 16
30
30
  # hex characters of their md5 as a signed integer. It gives the same id
31
- # on DuckDB and on Spark.
31
+ # on DuckDB and on Spark when each part turns into the same string on
32
+ # both. Measured on DuckDB 1.5 and Spark 3.5, that is true for strings,
33
+ # integers, booleans, decimals, dates, timestamps without a time zone,
34
+ # and doubles that are zero or from 0.001 up to (not including)
35
+ # 10,000,000 in absolute value. It is false for other doubles, which
36
+ # Spark writes in E notation (1.0E7) and DuckDB does not (10000000.0),
37
+ # and for DuckDB's TIMESTAMPTZ, whose text ends in a UTC offset.
38
+ # Before you pass such a part, cast a TIMESTAMPTZ to a timestamp, and
39
+ # cast a double to a decimal. The decimal gives the same text for
40
+ # 123456789.5, but not for 1e20 at DECIMAL(38,6), where DuckDB keeps
41
+ # the double's rounding error.
32
42
  #
33
43
  # strategy: :legacy is each database's form before :md5 existed:
34
44
  # hash(concat(...)) on DuckDB and xxhash64(...) on Spark. Use it only to
@@ -0,0 +1,157 @@
1
+ require "pathname"
2
+ require_relative "connection_maker"
3
+
4
+ module Sequelizer
5
+ # Raised when a dataset URL is not a duckdb:// URL.
6
+ class UnsupportedDatasetURLError < ArgumentError
7
+ def initialize(url)
8
+ super("dataset_db supports only duckdb:// URLs, not #{url.inspect}")
9
+ end
10
+ end
11
+
12
+ # Raised when a dataset connection is asked to write.
13
+ class ReadWriteDatasetError < ArgumentError
14
+ def initialize
15
+ super("dataset_db supports only readonly: true; read-write dataset connections are not supported yet")
16
+ end
17
+ end
18
+
19
+ # Raised when a directory URL has no dataset directory for the schema, or
20
+ # when that directory does not hold exactly one DuckDB file.
21
+ class MissingDatasetError < ArgumentError
22
+ def initialize(directory, schema, files)
23
+ super("#{directory}/#{schema} must hold exactly one *.duckdb file to be the dataset #{schema}, " \
24
+ "but it holds #{files.size}#{": #{files.join(", ")}" unless files.empty?}")
25
+ end
26
+ end
27
+
28
+ # Raised when dataset_db is called again for a cached (url, schema) with
29
+ # other DuckDB settings than the cached connection uses.
30
+ class DatasetSettingsConflictError < ArgumentError
31
+ def initialize(url, schema, held, requested)
32
+ super("the dataset connection for url #{url.inspect}, schema #{schema.inspect} is already open " \
33
+ "in this process with the DuckDB settings #{held.inspect}, not the requested settings " \
34
+ "#{requested.inspect}. The first dataset_db call for a (url, schema) fixes its settings.")
35
+ end
36
+ end
37
+
38
+ # = DuckDBDataset
39
+ #
40
+ # A read-only connection to one dataset in DuckDB. The connection is an
41
+ # in-memory DuckDB database, which attaches the dataset's file read-only
42
+ # as the catalog +dataset+. The search path is <tt>memory.main</tt>, then
43
+ # <tt>dataset.<schema></tt>, so a table name without a schema reads the
44
+ # dataset, and a CREATE without a catalog goes to the in-memory database.
45
+ #
46
+ # The URL is <tt>duckdb://<path></tt>. When +path+ is a file, it is the
47
+ # dataset file. Otherwise +path+ is a directory of datasets, and the dataset
48
+ # file is the one <tt>*.duckdb</tt> file in <tt><path>/<schema></tt>.
49
+ #
50
+ # Use Sequelizer.dataset_db, which caches one connection per (url, schema).
51
+ #
52
+ # @example
53
+ # DuckDBDataset.new(url: "duckdb:///data/datasets", schema: "synpuf").connection
54
+ class DuckDBDataset
55
+ URL_PREFIX = "duckdb://".freeze
56
+ MEMORY_URL = "duckdb::memory:".freeze
57
+
58
+ # The dataset file is attached under this fixed catalog name. A catalog
59
+ # named like its file can equal the schema inside it (seer_x.duckdb holds
60
+ # the schema seer_x), and DuckDB then stops with "Ambiguous reference to
61
+ # catalog or schema".
62
+ DATASET_CATALOG = "dataset".freeze
63
+
64
+ # The catalog of the in-memory database. It stays first on the search
65
+ # path: the first entry is the default database, which DuckDB cannot
66
+ # detach, and a read-only default database makes every CREATE without a
67
+ # catalog fail.
68
+ MEMORY_CATALOG = "memory".freeze
69
+
70
+ # The DuckDB settings that dataset_db accepts.
71
+ SETTINGS = %i[memory_limit temp_directory max_temp_directory_size threads].freeze
72
+
73
+ # Whether +url+ names a directory of datasets: a duckdb:// URL whose path
74
+ # is not an existing file.
75
+ #
76
+ # @param url [String] a database URL
77
+ # @return [Boolean]
78
+ def self.directory_url?(url)
79
+ url.to_s.start_with?(URL_PREFIX) && !Pathname(url.to_s.delete_prefix(URL_PREFIX)).file?
80
+ end
81
+
82
+ # The settings Hash in the one form that dataset_db compares: String
83
+ # names and String values, without the settings that are nil.
84
+ #
85
+ # @param settings [Hash{Symbol => Object}] DuckDB settings
86
+ # @return [Hash{String => String}]
87
+ def self.normalize_settings(settings)
88
+ settings.compact.to_h { |name, value| [ name.to_s, value.to_s ] }
89
+ end
90
+
91
+ # @!attribute [r] url
92
+ # @return [String] the duckdb:// URL of the dataset file or directory
93
+ # @!attribute [r] schema
94
+ # @return [String] the dataset's schema name
95
+ # @!attribute [r] settings
96
+ # @return [Hash{String => String}] the DuckDB settings
97
+ attr_reader :url, :schema, :settings
98
+
99
+ # @param url [String] a duckdb:// URL of a dataset file or of a directory
100
+ # of datasets
101
+ # @param schema [String, Symbol] the schema that holds the dataset's tables
102
+ # @param settings [Hash{String => String}] DuckDB settings, already
103
+ # normalized (see normalize_settings)
104
+ # @raise [UnsupportedDatasetURLError] if +url+ is not a duckdb:// URL
105
+ def initialize(url:, schema:, settings: {})
106
+ raise UnsupportedDatasetURLError, url unless url.to_s.start_with?(URL_PREFIX)
107
+
108
+ @url = url.to_s
109
+ @schema = schema.to_s
110
+ @settings = settings
111
+ end
112
+
113
+ # The DuckDB file that holds the dataset.
114
+ #
115
+ # @return [Pathname] the absolute path of the dataset file
116
+ # @raise [MissingDatasetError] if a directory URL has no single dataset
117
+ # file for the schema
118
+ def dataset_file
119
+ path = Pathname(url.delete_prefix(URL_PREFIX)).expand_path
120
+ return path unless self.class.directory_url?(url)
121
+
122
+ dir = path + schema
123
+ files = dir.directory? ? dir.glob("*.duckdb").sort : []
124
+ raise MissingDatasetError.new(path, schema, files) unless files.one?
125
+
126
+ files.first
127
+ end
128
+
129
+ # Opens the connection. Every new pool connection attaches the dataset
130
+ # file read-only and sets its search path, in :after_connect. DuckDB keeps
131
+ # an ATTACH for the whole database until its last connection closes, but
132
+ # the search path is per connection, so both run on each connection.
133
+ # :after_connect gets the driver's connection, which runs only SQL
134
+ # strings, so it runs the SQL of sequel-duckdb's attach, not db.attach.
135
+ #
136
+ # The configuration sources (.env, environment variables and the YAML
137
+ # files) are ignored: the URL and the settings passed here decide the
138
+ # connection.
139
+ #
140
+ # @return [Sequel::Database] the connection, already tested
141
+ # @raise [MissingDatasetError] if the dataset file cannot be found
142
+ # @raise [Sequel::DatabaseConnectionError] if DuckDB cannot attach the file
143
+ def connection
144
+ file = dataset_file
145
+ sqls = nil
146
+ after_connect = ->(conn) { sqls.each { |sql| conn.query(sql) } }
147
+ db = ConnectionMaker.new(url: MEMORY_URL, config: settings, after_connect: after_connect,
148
+ test: false, ignore_yaml: true, ignore_env: true).connection
149
+ sqls = [
150
+ db.attach_sql(file.to_s, as: DATASET_CATALOG, read_only: true, if_not_exists: true),
151
+ "SET search_path = #{db.literal("#{MEMORY_CATALOG}.main,#{DATASET_CATALOG}.#{schema}")}"
152
+ ]
153
+ db.test_connection
154
+ db
155
+ end
156
+ end
157
+ end
@@ -1,4 +1,4 @@
1
1
  module Sequelizer
2
2
  # Version for the gem
3
- VERSION = "0.4.0".freeze
3
+ VERSION = "0.4.1".freeze
4
4
  end
data/lib/sequelizer.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require_relative "sequelizer/version"
2
2
  require_relative "sequelizer/connection_maker"
3
+ require_relative "sequelizer/duckdb_dataset"
3
4
  require_relative "sequel/extensions/db_opts"
4
5
  require_relative "sequel/extensions/settable"
5
6
 
@@ -48,6 +49,68 @@ module Sequelizer
48
49
  Options.new.to_hash
49
50
  end
50
51
 
52
+ @dataset_dbs = {}
53
+ @dataset_dbs_mutex = Mutex.new
54
+
55
+ # Returns a read-only connection to one DuckDB dataset, cached per process
56
+ # by (url, schema). Every thread that asks for the same (url, schema) gets
57
+ # the same Sequel::Database.
58
+ #
59
+ # The connection is an in-memory DuckDB database that attaches the dataset
60
+ # file read-only, so a write to a dataset table fails. Its search path puts
61
+ # the dataset's schema after the in-memory database, so a table name without
62
+ # a schema reads the dataset (<tt>db[:patients]</tt>), and a CREATE without
63
+ # a catalog goes to the in-memory database. See DuckDBDataset.
64
+ #
65
+ # The DuckDB settings apply to the in-memory database when it opens. A
66
+ # setting that is nil keeps DuckDB's default. DuckDB's default memory_limit
67
+ # is 80% of the memory that the container may use, for each DuckDB database
68
+ # in the process, so pass memory_limit where more than one runs.
69
+ #
70
+ # The .env file, environment variables and YAML files are not read.
71
+ #
72
+ # @param url [String] <tt>duckdb://<directory></tt>, a directory that holds
73
+ # one directory for each dataset, <tt><directory>/<schema>/</tt>, with
74
+ # exactly one <tt>*.duckdb</tt> file in it; or <tt>duckdb://<file></tt>,
75
+ # an existing DuckDB file, which is attached directly
76
+ # @param schema [String, Symbol] the schema in the DuckDB file that holds the
77
+ # dataset's tables
78
+ # @param readonly [Boolean] must be true; read-write connections are not
79
+ # supported yet
80
+ # @param memory_limit [String, nil] DuckDB's memory_limit, for example "51GB"
81
+ # @param temp_directory [String, nil] DuckDB's temp_directory, where it
82
+ # spills data that does not fit in memory
83
+ # @param max_temp_directory_size [String, nil] DuckDB's
84
+ # max_temp_directory_size, for example "500GB"
85
+ # @param threads [Integer, String, nil] DuckDB's threads
86
+ # @return [Sequel::Database] the cached read-only connection
87
+ # @raise [ReadWriteDatasetError] if +readonly+ is false
88
+ # @raise [UnsupportedDatasetURLError] if +url+ is not a duckdb:// URL
89
+ # @raise [MissingDatasetError] if a directory URL has no single DuckDB file
90
+ # for +schema+
91
+ # @raise [DatasetSettingsConflictError] if (url, schema) is cached with other
92
+ # DuckDB settings
93
+ #
94
+ # @example
95
+ # db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf",
96
+ # memory_limit: "51GB", temp_directory: "/scratch/duckdb",
97
+ # max_temp_directory_size: "500GB")
98
+ # db[:patients].count
99
+ def self.dataset_db(url:, schema:, readonly: true, memory_limit: nil, temp_directory: nil,
100
+ max_temp_directory_size: nil, threads: nil)
101
+ raise ReadWriteDatasetError unless readonly
102
+
103
+ settings = DuckDBDataset.normalize_settings(memory_limit:, temp_directory:, max_temp_directory_size:, threads:)
104
+ key = [ url.to_s, schema.to_s ]
105
+
106
+ @dataset_dbs_mutex.synchronize do
107
+ cached = @dataset_dbs[key] ||= { db: DuckDBDataset.new(url:, schema:, settings:).connection, settings: }
108
+ raise DatasetSettingsConflictError.new(*key, cached[:settings], settings) unless cached[:settings] == settings
109
+
110
+ cached[:db]
111
+ end
112
+ end
113
+
51
114
  # Instantiates and memoizes a database connection. The +db+ method instantiates
52
115
  # the connection on the first call and then memoizes itself so only a single
53
116
  # connection is used on repeated calls.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequelizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Duryea
@@ -285,6 +285,7 @@ files:
285
285
  - lib/sequelizer.rb
286
286
  - lib/sequelizer/cli.rb
287
287
  - lib/sequelizer/connection_maker.rb
288
+ - lib/sequelizer/duckdb_dataset.rb
288
289
  - lib/sequelizer/env_config.rb
289
290
  - lib/sequelizer/options.rb
290
291
  - lib/sequelizer/version.rb