sequelizer 0.4.1 → 0.5.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 +17 -0
- data/README.md +35 -11
- data/lib/sequelizer/duckdb_dataset.rb +65 -90
- data/lib/sequelizer/duckdb_datasets.rb +171 -0
- data/lib/sequelizer/version.rb +1 -1
- data/lib/sequelizer.rb +116 -33
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99fc6e668db9f60787a98b20cb018fbfe3efe4281a77be3717a418e91d213193
|
|
4
|
+
data.tar.gz: df0939af163eff7f74e4cd19593b7e4a29cadac5800dcf821e9d727cc663730c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f090d45429c11b44d00e3b13cd15ee7c96756dc4492d20d113992270d01619611568f49e3271226ef4823bd704d8cb4da570b423dde2f18351d78c982b58fe2
|
|
7
|
+
data.tar.gz: 38f2a9007b8829bce8b8ac4a7e5c8d08d862562639a1c8f1fe0926ce523f77c31cad5656853181e74c7ad07c8d6683f4ebfa38807f869f30cdaebd4db29877b2
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
### Breaking changes
|
|
8
|
+
|
|
9
|
+
- **dataset_db:** Attach every DuckDB dataset to one database per process ([4c81a9f](https://github.com/outcomesinsights/sequelizer/commit/4c81a9fb557674ab0b539798185ec3fee9f27385)): Sequelizer::DuckDBDataset::DATASET_CATALOG and the catalog name "dataset" are gone; use Sequelizer.dataset_catalog(schema). dataset_db returns one Sequel::Database for every dataset, and an unqualified table name reads a dataset only inside with_dataset. The DuckDB settings are per process, not per dataset. DuckDBDataset#connection and DuckDBDataset.normalize_settings are removed.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **dataset_db:** Add search_path_prefix to with_dataset ([4cda3f4](https://github.com/outcomesinsights/sequelizer/commit/4cda3f4e3fd0e56426ecf3bc4497f3580eae8ed4))
|
|
14
|
+
- **dataset_db:** Add Sequelizer.close_dataset_dbs ([a309e22](https://github.com/outcomesinsights/sequelizer/commit/a309e22fa48d5492f1f3c2193f7723bb02ffa53b))
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **dataset_db:** Key the connection cache on the resolved dataset file ([50f6242](https://github.com/outcomesinsights/sequelizer/commit/50f6242a07017f4401a789d78a961213e193726c))
|
|
19
|
+
- **dataset_db:** Say when the URL's path is neither a file nor a directory ([5182707](https://github.com/outcomesinsights/sequelizer/commit/51827072d29181859216cbf89d1f6121ab5630da))
|
|
20
|
+
|
|
5
21
|
## [0.4.1] - 2026-10-29
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -180,3 +196,4 @@ All notable changes to this project will be documented in this file.
|
|
|
180
196
|
|
|
181
197
|
[0.4.0]: https://github.com/outcomesinsights/sequelizer/compare/v0.3.0...v0.4.0
|
|
182
198
|
[0.4.1]: https://github.com/outcomesinsights/sequelizer/compare/v0.4.0...v0.4.1
|
|
199
|
+
[0.5.0]: https://github.com/outcomesinsights/sequelizer/compare/v0.4.1...v0.5.0
|
data/README.md
CHANGED
|
@@ -78,23 +78,47 @@ 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
|
|
81
|
+
## Read-only DuckDB datasets
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Sequelizer attaches DuckDB datasets read-only to one in-memory DuckDB database for each process. It needs the `sequel-duckdb` and `duckdb` gems.
|
|
84
84
|
|
|
85
85
|
```ruby
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
opts = { memory_limit: "51GB", temp_directory: "/scratch/duckdb", max_temp_directory_size: "500GB",
|
|
87
|
+
max_connections: 5 }
|
|
88
|
+
|
|
89
|
+
# Name a dataset's tables with its catalog and schema anywhere:
|
|
90
|
+
db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf", **opts)
|
|
91
|
+
db[Sequel[Sequelizer.dataset_catalog(:synpuf)][:synpuf][:patients]].count
|
|
92
|
+
|
|
93
|
+
# Or read them without a schema, as ConceptQL's SQL does, inside with_dataset:
|
|
94
|
+
Sequelizer.with_dataset(url: "duckdb:///data/datasets", schema: "synpuf", **opts) do |db|
|
|
95
|
+
db[:patients].count
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `url:` is `duckdb://<directory>` or `duckdb://<file>`, with the scheme in any case. 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.
|
|
100
|
+
- `schema:` is the schema in the file that holds the dataset's tables.
|
|
101
|
+
- `dataset_db` attaches the dataset file at the first call for it and returns the process's one `Sequel::Database`. Every dataset uses the same one. The file is attached under the catalog `dataset_<schema>`, which `Sequelizer.dataset_catalog(schema)` returns as a Symbol. A write to a dataset table fails.
|
|
102
|
+
- Outside `with_dataset`, name a table with all three parts: catalog, schema and table. DuckDB does not find `synpuf.patients` or `patients` there, so a query that misses `with_dataset` fails with an error instead of reading another dataset.
|
|
103
|
+
- `with_dataset` runs the block on one connection whose search path is `memory.main`, then the dataset's schema. When the block ends, the search path is what it was before. A lazy dataset that runs after the block, or a query in a thread that the block starts, uses no dataset path and fails.
|
|
104
|
+
- A `CREATE` without a catalog goes to the in-memory database. Inside `with_dataset`, a table created there hides the dataset table of the same name. The in-memory database is shared by every dataset and thread in the process.
|
|
105
|
+
- `with_dataset(search_path_prefix:)` puts other search path entries first, for example a read-write catalog that you attached to the dataset database. It takes one entry or a list. An entry is a catalog, which DuckDB reads as `<catalog>.main`, or `catalog.schema`. A `CREATE` without a catalog then goes to the first entry. DuckDB raises a Catalog Error before the block runs if an entry is not attached. An empty entry, or one with a comma, raises `Sequelizer::SearchPathEntryError`.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf")
|
|
109
|
+
db.attach("/scratch/working.duckdb", as: "working")
|
|
110
|
+
Sequelizer.with_dataset(url: "duckdb:///data/datasets", schema: "synpuf", search_path_prefix: "working.main") do |d|
|
|
111
|
+
d.create_table(:results) { Integer :person_id } # creates working.main.results
|
|
112
|
+
end
|
|
90
113
|
```
|
|
91
114
|
|
|
92
|
-
-
|
|
93
|
-
- `
|
|
94
|
-
-
|
|
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.
|
|
115
|
+
- A schema name can come from only one file in a process. A second file with the same schema raises `Sequelizer::DatasetSchemaConflictError`. Two URLs for one file (`duckdb:///data/datasets`, `duckdb:///data/datasets/` and the file's own URL) are one dataset.
|
|
116
|
+
- `memory_limit:`, `temp_directory:`, `max_temp_directory_size:` and `threads:` are DuckDB settings of the one database, so the limits are for all the datasets together. `max_connections:` is the size of its connection pool (Sequel's default is 4). The first call fixes all of them, and a later call with other values raises `Sequelizer::DatasetSettingsConflictError`. 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.
|
|
117
|
+
- Two processes must not share one `temp_directory`. DuckDB gives the spill files the same names in every database, so the processes corrupt each other's spills.
|
|
96
118
|
- `readonly:` must be true. `readonly: false` raises an `ArgumentError`, because read-write connections are not supported yet.
|
|
97
|
-
-
|
|
119
|
+
- A call that fails, for example because a file is not a DuckDB file, attaches nothing. When no dataset is attached, the database closes too, so a later call starts clean.
|
|
120
|
+
- An attached file stays attached, so a file that is replaced on disk is not read, even after `disconnect`. `Sequelizer.close_dataset_dbs` detaches every file and closes the database, so the next call opens the files that are on disk then. Call it when no thread uses the database, for example between tests or after a dataset file is replaced. Do not use a database that `dataset_db` returned before the call.
|
|
121
|
+
- A forked child process opens its own database.
|
|
98
122
|
- The .env file, environment variables and YAML files are not read.
|
|
99
123
|
|
|
100
124
|
## Loading Sequel extensions
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
-
require_relative "connection_maker"
|
|
3
2
|
|
|
4
3
|
module Sequelizer
|
|
5
4
|
# Raised when a dataset URL is not a duckdb:// URL.
|
|
@@ -16,59 +15,68 @@ module Sequelizer
|
|
|
16
15
|
end
|
|
17
16
|
end
|
|
18
17
|
|
|
19
|
-
# Raised when
|
|
20
|
-
#
|
|
18
|
+
# Raised when the URL's path is neither a file nor a directory, when a
|
|
19
|
+
# directory URL has no dataset directory for the schema, or when that
|
|
20
|
+
# directory does not hold exactly one DuckDB file.
|
|
21
21
|
class MissingDatasetError < ArgumentError
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
# @param path [Pathname] the path in the URL
|
|
23
|
+
# @param schema [String] the dataset's schema
|
|
24
|
+
# @param files [Array<Pathname>, nil] the DuckDB files in
|
|
25
|
+
# <tt><path>/<schema></tt>, or nil when +path+ is neither a file nor a
|
|
26
|
+
# directory
|
|
27
|
+
def initialize(path, schema, files)
|
|
28
|
+
if files.nil?
|
|
29
|
+
super("#{path} is neither a DuckDB file nor a directory, so it cannot hold the dataset #{schema}")
|
|
30
|
+
else
|
|
31
|
+
super("#{path}/#{schema} must hold exactly one *.duckdb file to be the dataset #{schema}, " \
|
|
32
|
+
"but it holds #{files.size}#{": #{files.join(", ")}" unless files.empty?}")
|
|
33
|
+
end
|
|
25
34
|
end
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
# Raised when dataset_db
|
|
29
|
-
#
|
|
37
|
+
# Raised when a dataset_db call asks for other DuckDB settings or another
|
|
38
|
+
# max_connections than the process's dataset database has.
|
|
30
39
|
class DatasetSettingsConflictError < ArgumentError
|
|
31
|
-
def initialize(
|
|
32
|
-
super("the dataset
|
|
33
|
-
"
|
|
34
|
-
"
|
|
40
|
+
def initialize(held, requested)
|
|
41
|
+
super("the dataset database is already open in this process with #{held.inspect}, not the " \
|
|
42
|
+
"requested #{requested.inspect}. The first dataset_db call fixes them until " \
|
|
43
|
+
"Sequelizer.close_dataset_dbs.")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised when a with_dataset search_path_prefix entry is empty or holds a
|
|
48
|
+
# comma. DuckDB separates search path entries with commas, so it would
|
|
49
|
+
# read such an entry as two entries.
|
|
50
|
+
class SearchPathEntryError < ArgumentError
|
|
51
|
+
def initialize(entries)
|
|
52
|
+
super("each search_path_prefix entry must be one catalog or catalog.schema with no comma, " \
|
|
53
|
+
"but #{entries.inspect} is not")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Raised when a dataset's schema is already attached from another file.
|
|
58
|
+
# The catalog name comes from the schema, so two files with one schema
|
|
59
|
+
# would need one catalog.
|
|
60
|
+
class DatasetSchemaConflictError < ArgumentError
|
|
61
|
+
def initialize(schema, attached_file, requested_file)
|
|
62
|
+
super("the schema #{schema.inspect} is already attached from #{attached_file}, so " \
|
|
63
|
+
"#{requested_file} cannot be attached for it in this process")
|
|
35
64
|
end
|
|
36
65
|
end
|
|
37
66
|
|
|
38
67
|
# = DuckDBDataset
|
|
39
68
|
#
|
|
40
|
-
#
|
|
41
|
-
# in
|
|
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
|
|
69
|
+
# The dataset that a duckdb:// URL and a schema name identify. The URL is
|
|
70
|
+
# <tt>duckdb://<path></tt>, in any case. When +path+ is a file, it is the
|
|
47
71
|
# dataset file. Otherwise +path+ is a directory of datasets, and the dataset
|
|
48
72
|
# file is the one <tt>*.duckdb</tt> file in <tt><path>/<schema></tt>.
|
|
49
73
|
#
|
|
50
|
-
#
|
|
74
|
+
# Sequelizer.dataset_db attaches it; see DuckDBDatasets.
|
|
51
75
|
#
|
|
52
76
|
# @example
|
|
53
|
-
# DuckDBDataset.new(url: "duckdb:///data/datasets", schema: "synpuf").
|
|
77
|
+
# DuckDBDataset.new(url: "duckdb:///data/datasets", schema: "synpuf").dataset_file
|
|
54
78
|
class DuckDBDataset
|
|
55
|
-
URL_PREFIX =
|
|
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
|
|
79
|
+
URL_PREFIX = %r{\Aduckdb://}i
|
|
72
80
|
|
|
73
81
|
# Whether +url+ names a directory of datasets: a duckdb:// URL whose path
|
|
74
82
|
# is not an existing file.
|
|
@@ -76,48 +84,43 @@ module Sequelizer
|
|
|
76
84
|
# @param url [String] a database URL
|
|
77
85
|
# @return [Boolean]
|
|
78
86
|
def self.directory_url?(url)
|
|
79
|
-
url.to_s.
|
|
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 ] }
|
|
87
|
+
url.to_s.match?(URL_PREFIX) && !Pathname(url.to_s.sub(URL_PREFIX, "")).file?
|
|
89
88
|
end
|
|
90
89
|
|
|
91
90
|
# @!attribute [r] url
|
|
92
91
|
# @return [String] the duckdb:// URL of the dataset file or directory
|
|
93
92
|
# @!attribute [r] schema
|
|
94
93
|
# @return [String] the dataset's schema name
|
|
95
|
-
|
|
96
|
-
# @return [Hash{String => String}] the DuckDB settings
|
|
97
|
-
attr_reader :url, :schema, :settings
|
|
94
|
+
attr_reader :url, :schema
|
|
98
95
|
|
|
99
96
|
# @param url [String] a duckdb:// URL of a dataset file or of a directory
|
|
100
97
|
# of datasets
|
|
101
98
|
# @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
99
|
# @raise [UnsupportedDatasetURLError] if +url+ is not a duckdb:// URL
|
|
105
|
-
def initialize(url:, schema
|
|
106
|
-
raise UnsupportedDatasetURLError, url unless url.to_s.
|
|
100
|
+
def initialize(url:, schema:)
|
|
101
|
+
raise UnsupportedDatasetURLError, url unless url.to_s.match?(URL_PREFIX)
|
|
107
102
|
|
|
108
103
|
@url = url.to_s
|
|
109
104
|
@schema = schema.to_s
|
|
110
|
-
@settings = settings
|
|
111
105
|
end
|
|
112
106
|
|
|
113
|
-
# The DuckDB file that holds the dataset.
|
|
107
|
+
# The DuckDB file that holds the dataset. It is found once, on the first
|
|
108
|
+
# call.
|
|
114
109
|
#
|
|
115
|
-
# @return [Pathname] the
|
|
116
|
-
# @raise [MissingDatasetError] if
|
|
117
|
-
# file for the
|
|
110
|
+
# @return [Pathname] the real path of the dataset file
|
|
111
|
+
# @raise [MissingDatasetError] if the URL's path is neither a file nor a
|
|
112
|
+
# directory, or if a directory URL has no single dataset file for the
|
|
113
|
+
# schema
|
|
118
114
|
def dataset_file
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
@dataset_file ||= find_dataset_file.realpath
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def find_dataset_file
|
|
121
|
+
path = Pathname(url.sub(URL_PREFIX, "")).expand_path
|
|
122
|
+
return path if path.file?
|
|
123
|
+
raise MissingDatasetError.new(path, schema, nil) unless path.directory?
|
|
121
124
|
|
|
122
125
|
dir = path + schema
|
|
123
126
|
files = dir.directory? ? dir.glob("*.duckdb").sort : []
|
|
@@ -125,33 +128,5 @@ module Sequelizer
|
|
|
125
128
|
|
|
126
129
|
files.first
|
|
127
130
|
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
131
|
end
|
|
157
132
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require_relative "connection_maker"
|
|
2
|
+
require_relative "duckdb_dataset"
|
|
3
|
+
|
|
4
|
+
module Sequelizer
|
|
5
|
+
# = DuckDBDatasets
|
|
6
|
+
#
|
|
7
|
+
# The read-only DuckDB datasets of one process. The process has one
|
|
8
|
+
# in-memory DuckDB database, so memory_limit, temp_directory,
|
|
9
|
+
# max_temp_directory_size and threads apply once, and the datasets use one
|
|
10
|
+
# set of spill files. Each dataset file is attached READ_ONLY once, under
|
|
11
|
+
# the catalog <tt>dataset_<schema></tt>.
|
|
12
|
+
#
|
|
13
|
+
# A table named with its catalog, schema and table
|
|
14
|
+
# (<tt>Sequel[:dataset_synpuf][:synpuf][:patients]</tt>) reads the dataset
|
|
15
|
+
# on any connection. A name with only a schema and a table, or with only a
|
|
16
|
+
# table, reads a dataset only inside with_dataset, which sets the
|
|
17
|
+
# connection's search path for the block.
|
|
18
|
+
#
|
|
19
|
+
# The attached files stay attached until close, so a dataset file that is
|
|
20
|
+
# replaced on disk is not read until then.
|
|
21
|
+
#
|
|
22
|
+
# Use Sequelizer.dataset_db, Sequelizer.with_dataset and
|
|
23
|
+
# Sequelizer.close_dataset_dbs.
|
|
24
|
+
class DuckDBDatasets
|
|
25
|
+
MEMORY_URL = "duckdb::memory:".freeze
|
|
26
|
+
|
|
27
|
+
# The schema of the in-memory database. It comes first on a dataset's
|
|
28
|
+
# search path: the first entry is where a CREATE without a catalog goes,
|
|
29
|
+
# and a read-only dataset there makes every such CREATE fail.
|
|
30
|
+
MEMORY_SCHEMA = "memory.main".freeze
|
|
31
|
+
|
|
32
|
+
# The catalog that the dataset with +schema+ is attached as.
|
|
33
|
+
#
|
|
34
|
+
# @param schema [String, Symbol] the dataset's schema
|
|
35
|
+
# @return [Symbol] <tt>:dataset_<schema></tt>
|
|
36
|
+
def self.catalog_for(schema) = :"dataset_#{schema}"
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
reset
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Attaches the dataset, if it is not attached yet.
|
|
44
|
+
#
|
|
45
|
+
# @param url [String] a duckdb:// URL (see DuckDBDataset)
|
|
46
|
+
# @param schema [String, Symbol] the dataset's schema
|
|
47
|
+
# @param options [Hash] <tt>{ settings: Hash{String => String},
|
|
48
|
+
# max_connections: Integer or nil }</tt>
|
|
49
|
+
# @return [Array(Sequel::Database, String)] the process's dataset database
|
|
50
|
+
# and the dataset's catalog
|
|
51
|
+
# @raise [DatasetSettingsConflictError] if +options+ differ from the open
|
|
52
|
+
# database's
|
|
53
|
+
# @raise [DatasetSchemaConflictError] if the schema is attached from
|
|
54
|
+
# another file
|
|
55
|
+
def attach(url:, schema:, options:)
|
|
56
|
+
dataset = DuckDBDataset.new(url:, schema:)
|
|
57
|
+
file = dataset.dataset_file.to_s
|
|
58
|
+
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
forget_parent_process
|
|
61
|
+
raise DatasetSettingsConflictError.new(@options, options) if @db && @options != options
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
@db ||= connect(options)
|
|
65
|
+
@options = options
|
|
66
|
+
[ @db, attach_file(file, dataset.schema) ]
|
|
67
|
+
rescue StandardError
|
|
68
|
+
close_db if @schemas.empty?
|
|
69
|
+
raise
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Attaches the dataset, holds one connection for the block, and sets the
|
|
75
|
+
# connection's search path to the +search_path_prefix+ entries, then
|
|
76
|
+
# <tt>memory.main,<catalog>.<schema></tt>, so a table name without a
|
|
77
|
+
# schema reads the dataset. At the end of the block, the search path is
|
|
78
|
+
# what it was before.
|
|
79
|
+
#
|
|
80
|
+
# @param search_path_prefix [Array<String, Symbol>] DuckDB search path
|
|
81
|
+
# entries to put first
|
|
82
|
+
# @yieldparam db [Sequel::Database] the process's dataset database
|
|
83
|
+
# @return [Object] the block's value
|
|
84
|
+
# @raise [SearchPathEntryError] if an entry is empty or holds a comma
|
|
85
|
+
def with_dataset(url:, schema:, options:, search_path_prefix: [])
|
|
86
|
+
prefix = search_path_prefix.map(&:to_s)
|
|
87
|
+
bad = prefix.select { |entry| entry.empty? || entry.include?(",") }
|
|
88
|
+
raise SearchPathEntryError, bad unless bad.empty?
|
|
89
|
+
|
|
90
|
+
db, catalog = attach(url:, schema:, options:)
|
|
91
|
+
path = [ *prefix, MEMORY_SCHEMA, "#{catalog}.#{schema}" ].join(",")
|
|
92
|
+
|
|
93
|
+
db.synchronize do
|
|
94
|
+
previous = db.get(Sequel.function(:current_setting, "search_path"))
|
|
95
|
+
db.run("SET search_path = #{db.literal(path)}")
|
|
96
|
+
begin
|
|
97
|
+
yield db
|
|
98
|
+
ensure
|
|
99
|
+
db.run("SET search_path = #{db.literal(previous)}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Detaches every dataset file and disconnects the database. The next
|
|
105
|
+
# attach opens a new database.
|
|
106
|
+
#
|
|
107
|
+
# @return [void]
|
|
108
|
+
def close
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
forget_parent_process
|
|
111
|
+
close_db
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def reset
|
|
118
|
+
@pid = Process.pid
|
|
119
|
+
@db = nil
|
|
120
|
+
@options = nil
|
|
121
|
+
@catalogs = {}
|
|
122
|
+
@schemas = {}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A forked child inherits the parent's database, but its DuckDB instance
|
|
126
|
+
# belongs to the parent. The child opens its own.
|
|
127
|
+
def forget_parent_process
|
|
128
|
+
reset unless @pid == Process.pid
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The catalog of +file+, which this attaches the first time. The catalog
|
|
132
|
+
# name has a prefix, because a catalog named like a schema in its file
|
|
133
|
+
# makes DuckDB stop with "Ambiguous reference to catalog or schema".
|
|
134
|
+
def attach_file(file, schema)
|
|
135
|
+
if (held = @schemas[schema])
|
|
136
|
+
raise DatasetSchemaConflictError.new(schema, held[:file], file) unless held[:file] == file
|
|
137
|
+
|
|
138
|
+
return held[:catalog]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
catalog = @catalogs[file]
|
|
142
|
+
unless catalog
|
|
143
|
+
catalog = self.class.catalog_for(schema).to_s
|
|
144
|
+
@db.attach(file, as: catalog, read_only: true)
|
|
145
|
+
@catalogs[file] = catalog
|
|
146
|
+
end
|
|
147
|
+
@schemas[schema] = { file:, catalog: }
|
|
148
|
+
catalog
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def close_db
|
|
152
|
+
if @db
|
|
153
|
+
@catalogs.each_value { |catalog| @db.detach(catalog, if_exists: true) }
|
|
154
|
+
@db.disconnect
|
|
155
|
+
end
|
|
156
|
+
reset
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The in-memory database. The configuration sources (.env, environment
|
|
160
|
+
# variables and the YAML files) are ignored, and Sequel::DATABASES does
|
|
161
|
+
# not hold it, so a closed database can be garbage collected.
|
|
162
|
+
def connect(options)
|
|
163
|
+
opts = { url: MEMORY_URL, config: options[:settings], test: false, keep_reference: false,
|
|
164
|
+
ignore_yaml: true, ignore_env: true }
|
|
165
|
+
opts[:max_connections] = options[:max_connections] if options[:max_connections]
|
|
166
|
+
db = ConnectionMaker.new(opts).connection
|
|
167
|
+
db.test_connection
|
|
168
|
+
db
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/sequelizer/version.rb
CHANGED
data/lib/sequelizer.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require_relative "sequelizer/version"
|
|
2
2
|
require_relative "sequelizer/connection_maker"
|
|
3
|
-
require_relative "sequelizer/
|
|
3
|
+
require_relative "sequelizer/duckdb_datasets"
|
|
4
4
|
require_relative "sequel/extensions/db_opts"
|
|
5
5
|
require_relative "sequel/extensions/settable"
|
|
6
6
|
|
|
@@ -49,30 +49,43 @@ module Sequelizer
|
|
|
49
49
|
Options.new.to_hash
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
@
|
|
53
|
-
@dataset_dbs_mutex = Mutex.new
|
|
52
|
+
@duckdb_datasets = DuckDBDatasets.new
|
|
54
53
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
# the
|
|
54
|
+
# Attaches one DuckDB dataset read-only and returns the process's dataset
|
|
55
|
+
# database: one in-memory DuckDB database that every dataset in the process
|
|
56
|
+
# shares. The dataset file is attached at the first call for it, under the
|
|
57
|
+
# catalog <tt>dataset_<schema></tt>, and stays attached until
|
|
58
|
+
# close_dataset_dbs. Every call returns the same Sequel::Database.
|
|
58
59
|
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
60
|
+
# Name a dataset's tables with the catalog, the schema and the table:
|
|
61
|
+
# <tt>Sequel[Sequelizer.dataset_catalog(:synpuf)][:synpuf][:patients]</tt>.
|
|
62
|
+
# A name with only a schema and a table, or with only a table, reads a
|
|
63
|
+
# dataset only inside with_dataset. Outside it, DuckDB raises a Catalog
|
|
64
|
+
# Error. A write to a dataset table fails.
|
|
64
65
|
#
|
|
65
|
-
# The DuckDB settings apply to the
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# The DuckDB settings and +max_connections+ apply once, to the one
|
|
67
|
+
# database, so memory_limit and max_temp_directory_size are limits for all
|
|
68
|
+
# the datasets together. The first call fixes them until close_dataset_dbs.
|
|
69
|
+
# A setting that is nil keeps DuckDB's default. DuckDB's default
|
|
70
|
+
# memory_limit is 80% of the memory that the container may use, for each
|
|
71
|
+
# DuckDB database. Two processes must not share one temp_directory: DuckDB
|
|
72
|
+
# gives the spill files the same names in every database.
|
|
73
|
+
#
|
|
74
|
+
# Each schema can come from one file in a process, because the catalog name
|
|
75
|
+
# comes from the schema. The dataset file is the real path of the file that +url+
|
|
76
|
+
# resolves to, so two URLs for one file are one dataset.
|
|
77
|
+
#
|
|
78
|
+
# A file that is replaced on disk after it is attached is not read, even
|
|
79
|
+
# after +disconnect+. Call close_dataset_dbs, or restart the process, to
|
|
80
|
+
# read the new file.
|
|
69
81
|
#
|
|
70
82
|
# The .env file, environment variables and YAML files are not read.
|
|
71
83
|
#
|
|
72
84
|
# @param url [String] <tt>duckdb://<directory></tt>, a directory that holds
|
|
73
85
|
# one directory for each dataset, <tt><directory>/<schema>/</tt>, with
|
|
74
86
|
# exactly one <tt>*.duckdb</tt> file in it; or <tt>duckdb://<file></tt>,
|
|
75
|
-
# an existing DuckDB file, which is attached directly
|
|
87
|
+
# an existing DuckDB file, which is attached directly. The scheme can be
|
|
88
|
+
# in any case.
|
|
76
89
|
# @param schema [String, Symbol] the schema in the DuckDB file that holds the
|
|
77
90
|
# dataset's tables
|
|
78
91
|
# @param readonly [Boolean] must be true; read-write connections are not
|
|
@@ -83,33 +96,103 @@ module Sequelizer
|
|
|
83
96
|
# @param max_temp_directory_size [String, nil] DuckDB's
|
|
84
97
|
# max_temp_directory_size, for example "500GB"
|
|
85
98
|
# @param threads [Integer, String, nil] DuckDB's threads
|
|
86
|
-
# @
|
|
99
|
+
# @param max_connections [Integer, nil] the size of the database's
|
|
100
|
+
# connection pool, for example the number of threads that use it; nil
|
|
101
|
+
# keeps Sequel's default of 4
|
|
102
|
+
# @return [Sequel::Database] the process's dataset database
|
|
87
103
|
# @raise [ReadWriteDatasetError] if +readonly+ is false
|
|
88
104
|
# @raise [UnsupportedDatasetURLError] if +url+ is not a duckdb:// URL
|
|
89
|
-
# @raise [MissingDatasetError] if
|
|
90
|
-
# for +schema+
|
|
91
|
-
# @raise [DatasetSettingsConflictError] if
|
|
92
|
-
# DuckDB settings
|
|
105
|
+
# @raise [MissingDatasetError] if the URL's path is neither a file nor a
|
|
106
|
+
# directory, or if a directory URL has no single DuckDB file for +schema+
|
|
107
|
+
# @raise [DatasetSettingsConflictError] if the database is open with other
|
|
108
|
+
# DuckDB settings or another max_connections
|
|
109
|
+
# @raise [DatasetSchemaConflictError] if +schema+ is attached from another
|
|
110
|
+
# file
|
|
93
111
|
#
|
|
94
112
|
# @example
|
|
95
113
|
# db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf",
|
|
96
114
|
# memory_limit: "51GB", temp_directory: "/scratch/duckdb",
|
|
97
|
-
# max_temp_directory_size: "500GB")
|
|
98
|
-
# db[:patients].count
|
|
99
|
-
def self.dataset_db(url:, schema:,
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
# max_temp_directory_size: "500GB", max_connections: 5)
|
|
116
|
+
# db[Sequel[Sequelizer.dataset_catalog(:synpuf)][:synpuf][:patients]].count
|
|
117
|
+
def self.dataset_db(url:, schema:, **options)
|
|
118
|
+
@duckdb_datasets.attach(url:, schema:, options: dataset_options(**options)).first
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Attaches the dataset like dataset_db, and runs the block on one
|
|
122
|
+
# connection whose search path is <tt>memory.main</tt>, then the dataset's
|
|
123
|
+
# schema. A table name without a schema reads the dataset in the block,
|
|
124
|
+
# as ConceptQL's SQL needs (<tt>db[:patients]</tt>). The search path is
|
|
125
|
+
# restored when the block ends, so a query outside the block, or a lazy
|
|
126
|
+
# dataset that runs after it, finds no dataset table and fails.
|
|
127
|
+
#
|
|
128
|
+
# A CREATE without a catalog goes to the in-memory database, which comes
|
|
129
|
+
# first on the search path, so a created table hides the dataset table of
|
|
130
|
+
# the same name in the block. The in-memory database is shared by every
|
|
131
|
+
# dataset and thread in the process.
|
|
132
|
+
#
|
|
133
|
+
# +search_path_prefix+ puts other DuckDB search path entries first, for
|
|
134
|
+
# example a read-write catalog that the caller attached to the dataset
|
|
135
|
+
# database. A CREATE without a catalog then goes to the first entry. An
|
|
136
|
+
# entry is a catalog (DuckDB reads +working+ as <tt>working.main</tt>) or
|
|
137
|
+
# <tt>catalog.schema</tt>. DuckDB raises a Catalog Error, before the block
|
|
138
|
+
# runs, for an entry that is not attached.
|
|
139
|
+
#
|
|
140
|
+
# @param (see dataset_db)
|
|
141
|
+
# @param search_path_prefix [String, Symbol, Array<String, Symbol>] the
|
|
142
|
+
# search path entries that come before <tt>memory.main</tt> and the
|
|
143
|
+
# dataset, in order
|
|
144
|
+
# @yieldparam db [Sequel::Database] the process's dataset database
|
|
145
|
+
# @return [Object] the block's value
|
|
146
|
+
# @raise [SearchPathEntryError] if a search_path_prefix entry is empty or
|
|
147
|
+
# holds a comma
|
|
148
|
+
#
|
|
149
|
+
# @example
|
|
150
|
+
# Sequelizer.with_dataset(url: "duckdb:///data/datasets", schema: "synpuf") do |db|
|
|
151
|
+
# db[:patients].count
|
|
152
|
+
# end
|
|
153
|
+
#
|
|
154
|
+
# @example A read-write catalog first
|
|
155
|
+
# db = Sequelizer.dataset_db(url: "duckdb:///data/datasets", schema: "synpuf")
|
|
156
|
+
# db.attach("/scratch/working.duckdb", as: "working")
|
|
157
|
+
# Sequelizer.with_dataset(url: "duckdb:///data/datasets", schema: "synpuf",
|
|
158
|
+
# search_path_prefix: "working.main") do |d|
|
|
159
|
+
# d.create_table(:results) { Integer :person_id } # working.main.results
|
|
160
|
+
# end
|
|
161
|
+
def self.with_dataset(url:, schema:, search_path_prefix: [], **options, &)
|
|
162
|
+
@duckdb_datasets.with_dataset(url:, schema:, options: dataset_options(**options),
|
|
163
|
+
search_path_prefix: Array(search_path_prefix), &)
|
|
164
|
+
end
|
|
102
165
|
|
|
103
|
-
|
|
104
|
-
|
|
166
|
+
# The catalog that the dataset with +schema+ is attached as, for a table
|
|
167
|
+
# name outside with_dataset:
|
|
168
|
+
# <tt>Sequel[Sequelizer.dataset_catalog(:synpuf)][:synpuf][:patients]</tt>.
|
|
169
|
+
#
|
|
170
|
+
# @param schema [String, Symbol] the dataset's schema
|
|
171
|
+
# @return [Symbol] <tt>:dataset_<schema></tt>
|
|
172
|
+
def self.dataset_catalog(schema) = DuckDBDatasets.catalog_for(schema)
|
|
105
173
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
174
|
+
# Detaches every dataset file and closes the process's dataset database,
|
|
175
|
+
# so the next dataset_db call opens a new database with the files that are
|
|
176
|
+
# on disk then.
|
|
177
|
+
#
|
|
178
|
+
# Call it when no thread uses the dataset database, for example between
|
|
179
|
+
# tests, at shutdown, or after a dataset file is replaced. Do not use a
|
|
180
|
+
# Sequel::Database that dataset_db returned before the call.
|
|
181
|
+
#
|
|
182
|
+
# @return [void]
|
|
183
|
+
def self.close_dataset_dbs
|
|
184
|
+
@duckdb_datasets.close
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.dataset_options(readonly: true, memory_limit: nil, temp_directory: nil, max_temp_directory_size: nil,
|
|
188
|
+
threads: nil, max_connections: nil)
|
|
189
|
+
raise ReadWriteDatasetError unless readonly
|
|
109
190
|
|
|
110
|
-
|
|
111
|
-
|
|
191
|
+
settings = { memory_limit:, temp_directory:, max_temp_directory_size:, threads: }
|
|
192
|
+
.compact.to_h { |name, value| [ name.to_s, value.to_s ] }
|
|
193
|
+
{ settings:, max_connections: }
|
|
112
194
|
end
|
|
195
|
+
private_class_method :dataset_options
|
|
113
196
|
|
|
114
197
|
# Instantiates and memoizes a database connection. The +db+ method instantiates
|
|
115
198
|
# the connection on the first call and then memoizes itself so only a single
|
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
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ryan Duryea
|
|
@@ -286,6 +286,7 @@ files:
|
|
|
286
286
|
- lib/sequelizer/cli.rb
|
|
287
287
|
- lib/sequelizer/connection_maker.rb
|
|
288
288
|
- lib/sequelizer/duckdb_dataset.rb
|
|
289
|
+
- lib/sequelizer/duckdb_datasets.rb
|
|
289
290
|
- lib/sequelizer/env_config.rb
|
|
290
291
|
- lib/sequelizer/options.rb
|
|
291
292
|
- lib/sequelizer/version.rb
|