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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +96 -0
- data/lib/sqlite_shards/bound.rb +39 -0
- data/lib/sqlite_shards/version.rb +5 -0
- data/lib/sqlite_shards.rb +164 -0
- metadata +80 -0
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,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: []
|