sqlite_shards 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 70840b03f18bb37414203a8218c534e498a0b9a249663d1b010b56c88f6948d8
4
+ data.tar.gz: d3c83ebabc64f00d8ec71bd24b007618628f3e18b33bafd91a3ea5845cbe432a
5
+ SHA512:
6
+ metadata.gz: 667dfce81ea40beeac34d1a31f96c1cec3b965c441bd05843649db65a46a23e916ded2711e444d37e4bafde6f0a4a4b3a30c4828ca232acdc713f5459ef51fba
7
+ data.tar.gz: 7510233606657377dc206baf3aafab9e764c9151b5e76c37537b42f5a4ffd932daf4133ad9b1e667310212e59fe5f3209a5d6f92c796c63088750a5bebd4cc01
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First version, extracted from the onlylogs gem, where it keeps saved searches next to each log directory:
6
+ `include SqliteShards`, `database_migrations`, `with_database(path) { ... }`, `bound_to(path) { scope }` and
7
+ `clear_database_connections`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Renuo AG
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # SqliteShards
2
+
3
+ A gem to connect your ActiveRecord models to a separate sqlite file.
4
+
5
+ Some data does not belong into the app's main database.
6
+ For example, on [onlylogs](https://github.com/renuo/onlylogs) we persist saved queries into a small sqlite file,
7
+ without interfering with the main app database.
8
+
9
+ ## Usage
10
+
11
+ Include `SqliteShards` in an abstract class and list its migrations:
12
+
13
+ ```ruby
14
+ class NotesRecord < ActiveRecord::Base
15
+ self.abstract_class = true
16
+ include SqliteShards
17
+
18
+ database_migrations(
19
+ ->(connection) {
20
+ connection.create_table(:notes) do |t|
21
+ t.string :title, null: false
22
+ t.timestamps
23
+ end
24
+ },
25
+ ->(connection) {
26
+ connection.add_column(:notes, :body, :text)
27
+ }
28
+ )
29
+ end
30
+
31
+ class Note < NotesRecord
32
+ end
33
+ ```
34
+
35
+ Then pick a file:
36
+
37
+ ```ruby
38
+ NotesRecord.with_database("storage/projects/acme/notes.db") do
39
+ Note.create!(title: "Hello")
40
+ Note.where(title: "Hello").count
41
+ end
42
+ ```
43
+
44
+ Inside the block every model under `NotesRecord` reads and writes that file.
45
+ Outside it, queries raise `ActiveRecord::ConnectionNotDefined`, so data can't end up in the wrong place by accident.
46
+
47
+ Wrapping it in the object that owns the file reads well:
48
+
49
+ ```ruby
50
+ class Project < ApplicationRecord
51
+ def with_database(create: true, &) = NotesRecord.with_database(root_path.join("notes.db"), create:, &)
52
+ end
53
+
54
+ project.with_database { Note.count }
55
+ ```
56
+
57
+ ### Without a block
58
+
59
+ `bound_to` returns a relation you can keep, chain and write through outside a block.
60
+ Every call on it, and on the relations and records it returns, runs inside the database:
61
+
62
+ ```ruby
63
+ class Project < ApplicationRecord
64
+ def notes = NotesRecord.bound_to(root_path.join("notes.db")) { Note.order(created_at: :desc) }
65
+ end
66
+
67
+ note = project.notes.create!(title: "Hello")
68
+ note.update!(title: "Hi")
69
+ project.notes.where(title: "Hi").pluck(:id)
70
+ ```
71
+
72
+ The records it returns are wrappers that forward every call, so `note.class` is `SqliteShards::Bound`, not `Note`.
73
+
74
+ ### Options
75
+
76
+ - `with_database(path, create: false)` yields nothing and returns `nil` when the file doesn't exist, instead of
77
+ creating it. Useful for reading without leaving empty databases behind.
78
+ - `NotesRecord.transaction { ... }` inside `with_database` spans all models of that file: they share one connection.
79
+ - `with_database` calls nest, for the same class or different ones.
80
+ - `clear_database_connections` closes every connection the class opened.
81
+
82
+ ## Requirements
83
+
84
+ Active Record 8.0 or newer and the `sqlite3` gem.
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ bin/setup
90
+ bin/check # tests
91
+ bin/fastcheck # standardrb
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module SqliteShards
6
+ # A relation or record whose every call runs inside its database; what those calls return is bound the same way.
7
+ class Bound < Delegator
8
+ def initialize(object, &connect)
9
+ super(object)
10
+ @connect = connect
11
+ end
12
+
13
+ def __getobj__ = @object
14
+
15
+ def __setobj__(object)
16
+ @object = object
17
+ end
18
+
19
+ def method_missing(name, *args, **kwargs, &block)
20
+ @connect.call { bind(@object.public_send(name, *args, **kwargs, &block)) }
21
+ end
22
+
23
+ def respond_to_missing?(name, include_private = false) = @object.respond_to?(name, include_private)
24
+
25
+ def ==(other)
26
+ @object == (other.is_a?(Bound) ? other.__getobj__ : other)
27
+ end
28
+
29
+ private
30
+
31
+ def bind(result)
32
+ case result
33
+ when ::ActiveRecord::Relation, ::ActiveRecord::Base then Bound.new(result, &@connect)
34
+ when ::Array then result.map { |element| bind(element) }
35
+ else result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqliteShards
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "digest"
5
+ require "fileutils"
6
+ require_relative "sqlite_shards/version"
7
+ require_relative "sqlite_shards/bound"
8
+
9
+ # Keeps the models under an abstract class in SQLite files picked at runtime, a database per file:
10
+ #
11
+ # class NotesRecord < ActiveRecord::Base
12
+ # self.abstract_class = true
13
+ # include SqliteShards
14
+ #
15
+ # database_migrations ->(connection) { connection.create_table(:notes) { |t| t.string :title } }
16
+ # end
17
+ #
18
+ # class Note < NotesRecord; end
19
+ #
20
+ # NotesRecord.with_database("path/to/notes.db") { Note.all.to_a }
21
+ #
22
+ # Outside with_database the models have no connection, so Note.all raises. Their schema is known anyway, since every
23
+ # file is migrated the same way: it is read from an in-memory database migrated like the files, so Note.new and a
24
+ # controller's parameter wrapping work without a file.
25
+ #
26
+ # bound_to gives a relation that can be kept and used outside a block: every call on it, and on the relations and
27
+ # records it returns, runs inside with_database.
28
+ #
29
+ # notes = NotesRecord.bound_to("path/to/notes.db") { Note.all }
30
+ # note = notes.create!(title: "Hello")
31
+ # note.update!(title: "Hi")
32
+ #
33
+ # Each file is a shard of the class, connected on first use. A file deleted or replaced since (a purged or renamed
34
+ # directory, say) has another inode and so a shard of its own: the old shard's idle connections are reaped, and a
35
+ # connection in use, like a test transaction's, is never pulled away.
36
+ #
37
+ # Migrations are blocks run in order, tracked by SQLite's user_version: append new ones, never edit or reorder
38
+ # shipped ones.
39
+ module SqliteShards
40
+ extend ActiveSupport::Concern
41
+
42
+ class Error < StandardError; end
43
+
44
+ class UnavailableError < Error; end
45
+
46
+ CONNECTION_TIMEOUT = 5_000
47
+
48
+ included do
49
+ self.connection_class = true
50
+ self.connection_specification_name = name
51
+
52
+ @database_migrations = []
53
+ @database_shards = Set.new
54
+ @database_lock = Mutex.new
55
+ end
56
+
57
+ # Every file is migrated the same way, so the schema is known without one: see schema_cache.
58
+ SCHEMA_SHARD = :schema
59
+
60
+ class_methods do
61
+ def database_migrations(*migrations)
62
+ @database_migrations = migrations
63
+ end
64
+
65
+ # Without create, a file that does not exist yields nothing and returns nil.
66
+ def with_database(path, create: true, &)
67
+ path = ::File.expand_path(path.to_s)
68
+ if create
69
+ create_database_file(path)
70
+ elsif !::File.exist?(path)
71
+ return
72
+ end
73
+
74
+ connected_to(role: ActiveRecord.writing_role, shard: database_shard(path), &)
75
+ end
76
+
77
+ def bound_to(path, &scope)
78
+ connect = ->(&block) { with_database(path, &block) }
79
+ connect.call { Bound.new(scope.call, &connect) }
80
+ end
81
+
82
+ # Active Record reads a model's schema through these, all memoized on the class: the table and its primary key,
83
+ # the columns, and their types. Outside a file they read the in-memory schema database instead.
84
+ %i[schema_cache load_schema _default_attributes].each do |method|
85
+ define_method(method) do
86
+ return super() unless current_shard == default_shard
87
+
88
+ connection_class_for_self.send(:with_schema_database) { super() }
89
+ end
90
+ end
91
+
92
+ def clear_database_connections
93
+ @database_lock.synchronize do
94
+ @database_shards.each do |shard|
95
+ connection_handler.remove_connection_pool(connection_specification_name, role: ActiveRecord.writing_role, shard: shard)
96
+ end
97
+ @database_shards.clear
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # An in-memory database is lost when the reaper closes its one connection, so it is migrated on every use;
104
+ # Active Record keeps the schema once read, so that is rare.
105
+ def with_schema_database(&)
106
+ @database_lock.synchronize do
107
+ unless @database_shards.include?(SCHEMA_SHARD)
108
+ connection_handler.establish_connection(
109
+ {adapter: "sqlite3", database: ":memory:", pool: 1},
110
+ owner_name: self, role: ActiveRecord.writing_role, shard: SCHEMA_SHARD
111
+ )
112
+ @database_shards << SCHEMA_SHARD
113
+ end
114
+ end
115
+
116
+ connected_to(role: ActiveRecord.writing_role, shard: SCHEMA_SHARD) do
117
+ connection_pool.with_connection { |connection| migrate_database(connection) }
118
+ yield
119
+ end
120
+ end
121
+
122
+ def create_database_file(path)
123
+ FileUtils.mkdir_p(::File.dirname(path))
124
+ FileUtils.touch(path) unless ::File.exist?(path)
125
+ rescue SystemCallError
126
+ raise UnavailableError, "#{::File.basename(path)} cannot be created"
127
+ end
128
+
129
+ def database_shard(path)
130
+ stat = ::File.stat(path)
131
+ # Digested, so the path never leaks into error messages.
132
+ shard = :"file_#{Digest::SHA256.hexdigest([path, stat.dev, stat.ino].join(":"))[0, 16]}"
133
+
134
+ @database_lock.synchronize do
135
+ unless @database_shards.include?(shard)
136
+ pool = connection_handler.establish_connection(
137
+ {adapter: "sqlite3", database: path, timeout: CONNECTION_TIMEOUT},
138
+ owner_name: self, role: ActiveRecord.writing_role, shard: shard
139
+ )
140
+ pool.with_connection { |connection| migrate_database(connection) }
141
+ @database_shards << shard
142
+ end
143
+ end
144
+
145
+ shard
146
+ end
147
+
148
+ # Transactions are IMMEDIATE, so the version is read under the write lock and
149
+ # concurrent workers cannot run the same migration twice.
150
+ def migrate_database(connection)
151
+ return if database_version(connection) >= @database_migrations.size
152
+
153
+ connection.transaction do
154
+ version = database_version(connection)
155
+ @database_migrations.drop(version).each { |migration| migration.call(connection) }
156
+ connection.execute("PRAGMA user_version = #{@database_migrations.size}")
157
+ end
158
+ end
159
+
160
+ def database_version(connection)
161
+ connection.select_value("PRAGMA user_version").to_i
162
+ end
163
+ end
164
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sqlite_shards
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alessandro Rodi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Include SqliteShards in an abstract Active Record class and its models
41
+ read and write the SQLite file you pass to with_database, each file a shard of its
42
+ own, migrated on first use.
43
+ email:
44
+ - alessandro.rodi@renuo.ch
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - lib/sqlite_shards.rb
53
+ - lib/sqlite_shards/bound.rb
54
+ - lib/sqlite_shards/version.rb
55
+ homepage: https://github.com/renuo/sqlite_shards
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/renuo/sqlite_shards
60
+ source_code_uri: https://github.com/renuo/sqlite_shards
61
+ changelog_uri: https://github.com/renuo/sqlite_shards/blob/main/CHANGELOG.md
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.3'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.21
77
+ specification_version: 4
78
+ summary: Active Record models whose data lives in SQLite files picked at runtime,
79
+ one database per file.
80
+ test_files: []