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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50510a32c25ed7c0218c60a7ba9d615a2ccf909685cc719dc1110a3fe43a5baf
4
- data.tar.gz: a11d4852a6f18ffa2fca2fb5a9a75b67fd6c605d8d38321cb2b7a042e7ed8427
3
+ metadata.gz: 99fc6e668db9f60787a98b20cb018fbfe3efe4281a77be3717a418e91d213193
4
+ data.tar.gz: df0939af163eff7f74e4cd19593b7e4a29cadac5800dcf821e9d727cc663730c
5
5
  SHA512:
6
- metadata.gz: 4f859dc2afb3bf840d8f2a53215ba7c2437a511eafdd23467cdab868092c45a3e4a8772159ca9098f1b808eda15041d357b05d7fd2f0e7c0f4acc7ec82c68df5
7
- data.tar.gz: 69bb86b1fc5965eef2fa4a5449618050cb984d285c93d99fa2e56269c9d2967573f2b8b918c0a992880f7278a0c55bdd48c165a2e744ccf814f356a8565befdb
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 dataset connections
81
+ ## Read-only DuckDB datasets
82
82
 
83
- `Sequelizer.dataset_db` returns a read-only connection to one DuckDB dataset. It needs the `sequel-duckdb` and `duckdb` gems.
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
- 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
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
- - `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.
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
- - 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`.
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 a directory URL has no dataset directory for the schema, or
20
- # when that directory does not hold exactly one DuckDB file.
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
- 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?}")
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 is called again for a cached (url, schema) with
29
- # other DuckDB settings than the cached connection uses.
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(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.")
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
- # 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
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
- # Use Sequelizer.dataset_db, which caches one connection per (url, schema).
74
+ # Sequelizer.dataset_db attaches it; see DuckDBDatasets.
51
75
  #
52
76
  # @example
53
- # DuckDBDataset.new(url: "duckdb:///data/datasets", schema: "synpuf").connection
77
+ # DuckDBDataset.new(url: "duckdb:///data/datasets", schema: "synpuf").dataset_file
54
78
  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
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.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 ] }
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
- # @!attribute [r] settings
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:, settings: {})
106
- raise UnsupportedDatasetURLError, url unless url.to_s.start_with?(URL_PREFIX)
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 absolute path of the dataset file
116
- # @raise [MissingDatasetError] if a directory URL has no single dataset
117
- # file for the schema
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
- path = Pathname(url.delete_prefix(URL_PREFIX)).expand_path
120
- return path unless self.class.directory_url?(url)
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
@@ -1,4 +1,4 @@
1
1
  module Sequelizer
2
2
  # Version for the gem
3
- VERSION = "0.4.1".freeze
3
+ VERSION = "0.5.0".freeze
4
4
  end
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/duckdb_dataset"
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
- @dataset_dbs = {}
53
- @dataset_dbs_mutex = Mutex.new
52
+ @duckdb_datasets = DuckDBDatasets.new
54
53
 
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.
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
- # 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.
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 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.
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
- # @return [Sequel::Database] the cached read-only connection
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 a directory URL has no single DuckDB file
90
- # for +schema+
91
- # @raise [DatasetSettingsConflictError] if (url, schema) is cached with other
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:, readonly: true, memory_limit: nil, temp_directory: nil,
100
- max_temp_directory_size: nil, threads: nil)
101
- raise ReadWriteDatasetError unless readonly
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
- settings = DuckDBDataset.normalize_settings(memory_limit:, temp_directory:, max_temp_directory_size:, threads:)
104
- key = [ url.to_s, schema.to_s ]
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
- @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
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
- cached[:db]
111
- end
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.1
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