activerecord-libsql 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/Cargo.lock +2406 -0
- data/Cargo.toml +3 -0
- data/README.md +265 -0
- data/activerecord-libsql.gemspec +41 -0
- data/ext/turso_libsql/Cargo.toml +20 -0
- data/ext/turso_libsql/extconf.rb +6 -0
- data/ext/turso_libsql/src/lib.rs +299 -0
- data/lib/active_record/connection_adapters/libsql_adapter.rb +371 -0
- data/lib/activerecord/libsql/railtie.rb +13 -0
- data/lib/activerecord/libsql/version.rb +7 -0
- data/lib/activerecord-libsql.rb +6 -0
- metadata +126 -0
data/Cargo.toml
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# activerecord-libsql
|
|
2
|
+
|
|
3
|
+
ActiveRecord adapter for [Turso](https://turso.tech) (libSQL) database.
|
|
4
|
+
|
|
5
|
+
Connects Rails/ActiveRecord models to Turso via a native Rust extension ([magnus](https://github.com/matsadler/magnus) + [libsql](https://github.com/tursodatabase/libsql)), using the libSQL remote protocol directly — no HTTP client wrapper required.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Ruby >= 3.1
|
|
10
|
+
- Rust >= 1.70 (install via [rustup](https://rustup.rs))
|
|
11
|
+
- ActiveRecord >= 7.0
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "activerecord-libsql"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle install
|
|
22
|
+
bundle exec rake compile
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
### database.yml
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
default: &default
|
|
31
|
+
adapter: turso
|
|
32
|
+
database: <%= ENV["TURSO_DATABASE_URL"] %> # libsql://xxx.turso.io
|
|
33
|
+
token: <%= ENV["TURSO_AUTH_TOKEN"] %>
|
|
34
|
+
|
|
35
|
+
development:
|
|
36
|
+
<<: *default
|
|
37
|
+
|
|
38
|
+
production:
|
|
39
|
+
<<: *default
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> **Note**: Use the `database:` key, not `url:`. ActiveRecord tries to resolve the adapter from the URL scheme when `url:` is used, which causes a lookup failure.
|
|
43
|
+
|
|
44
|
+
### Direct connection
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "activerecord-libsql"
|
|
48
|
+
|
|
49
|
+
ActiveRecord::Base.establish_connection(
|
|
50
|
+
adapter: "turso",
|
|
51
|
+
database: "libsql://your-db.turso.io",
|
|
52
|
+
token: "your-auth-token"
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class User < ActiveRecord::Base
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create
|
|
63
|
+
User.create!(name: "Alice", email: "alice@example.com")
|
|
64
|
+
|
|
65
|
+
# Read
|
|
66
|
+
User.where(name: "Alice").first
|
|
67
|
+
User.find(1)
|
|
68
|
+
User.order(:name).limit(10)
|
|
69
|
+
|
|
70
|
+
# Update
|
|
71
|
+
User.find(1).update!(email: "new@example.com")
|
|
72
|
+
|
|
73
|
+
# Delete
|
|
74
|
+
User.find(1).destroy
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Embedded Replicas
|
|
78
|
+
|
|
79
|
+
Embedded Replicas keep a local SQLite copy of your Turso database on disk, synced from the remote. Reads are served locally (sub-millisecond), writes go to the remote.
|
|
80
|
+
|
|
81
|
+
### Configuration
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
# database.yml
|
|
85
|
+
production:
|
|
86
|
+
adapter: turso
|
|
87
|
+
database: <%= ENV["TURSO_DATABASE_URL"] %> # libsql://xxx.turso.io
|
|
88
|
+
token: <%= ENV["TURSO_AUTH_TOKEN"] %>
|
|
89
|
+
replica_path: /var/data/myapp.db # local replica file path
|
|
90
|
+
sync_interval: 60 # background sync every 60 seconds (0 = manual only)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or via `establish_connection`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
ActiveRecord::Base.establish_connection(
|
|
97
|
+
adapter: "turso",
|
|
98
|
+
database: "libsql://your-db.turso.io",
|
|
99
|
+
token: "your-auth-token",
|
|
100
|
+
replica_path: "/var/data/myapp.db",
|
|
101
|
+
sync_interval: 60
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Manual sync
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Trigger a sync from the remote at any time
|
|
109
|
+
ActiveRecord::Base.connection.sync
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Notes
|
|
113
|
+
|
|
114
|
+
- `replica_path` must point to a clean (empty) file or a previously synced replica. Using an existing SQLite file from another source will cause an error.
|
|
115
|
+
- `sync_interval` is in seconds. Set to `0` or omit to use manual sync only.
|
|
116
|
+
- **Multi-process caution**: Do not share the same `replica_path` across multiple Puma workers. Each worker should use a unique path (e.g. `/var/data/myapp-worker-#{worker_id}.db`).
|
|
117
|
+
- The background sync task runs as long as the `Database` object is alive. The adapter holds the `Database` for the lifetime of the connection.
|
|
118
|
+
|
|
119
|
+
## Schema Management
|
|
120
|
+
|
|
121
|
+
`turso:schema:apply` and `turso:schema:diff` use [sqldef](https://github.com/sqldef/sqldef) (`sqlite3def`) to manage your Turso schema declaratively — no migration files, no version tracking. You define the desired schema in a `.sql` file and the task computes and applies only the diff.
|
|
122
|
+
|
|
123
|
+
### Prerequisites
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# macOS
|
|
127
|
+
brew install sqldef/sqldef/sqlite3def
|
|
128
|
+
|
|
129
|
+
# Other platforms: https://github.com/sqldef/sqldef/releases
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`replica_path` must be configured in `database.yml` (the tasks use the local replica to compute the diff without touching the remote directly).
|
|
133
|
+
|
|
134
|
+
### turso:schema:apply
|
|
135
|
+
|
|
136
|
+
Applies the diff between your desired schema and the current remote schema.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
rake turso:schema:apply[db/schema.sql]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Example output:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
==> [1/4] Pulling latest schema from remote...
|
|
146
|
+
Done.
|
|
147
|
+
==> [2/4] Computing schema diff...
|
|
148
|
+
2 statement(s) to apply:
|
|
149
|
+
ALTER TABLE users ADD COLUMN bio TEXT;
|
|
150
|
+
CREATE INDEX idx_users_email ON users (email);
|
|
151
|
+
==> [3/4] Applying schema to Turso Cloud...
|
|
152
|
+
Done.
|
|
153
|
+
==> [4/4] Pulling to confirm...
|
|
154
|
+
Done.
|
|
155
|
+
==> Schema applied successfully!
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
If the schema is already up to date:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
==> [1/4] Pulling latest schema from remote...
|
|
162
|
+
Done.
|
|
163
|
+
==> [2/4] Computing schema diff...
|
|
164
|
+
Already up to date.
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### turso:schema:diff
|
|
168
|
+
|
|
169
|
+
Shows what would be applied without making any changes (dry-run).
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
rake turso:schema:diff[db/schema.sql]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### schema.sql format
|
|
176
|
+
|
|
177
|
+
Plain SQL `CREATE TABLE` statements. sqldef handles `ALTER TABLE` / `CREATE INDEX` / `DROP` automatically based on the diff.
|
|
178
|
+
|
|
179
|
+
```sql
|
|
180
|
+
CREATE TABLE users (
|
|
181
|
+
id TEXT PRIMARY KEY,
|
|
182
|
+
name TEXT NOT NULL,
|
|
183
|
+
email TEXT NOT NULL
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE TABLE posts (
|
|
187
|
+
id TEXT PRIMARY KEY,
|
|
188
|
+
user_id TEXT NOT NULL,
|
|
189
|
+
title TEXT NOT NULL,
|
|
190
|
+
body TEXT,
|
|
191
|
+
created_at TEXT NOT NULL
|
|
192
|
+
);
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Architecture
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
Rails Model (ActiveRecord)
|
|
199
|
+
↓ Arel → SQL string
|
|
200
|
+
LibsqlAdapter (lib/active_record/connection_adapters/libsql_adapter.rb)
|
|
201
|
+
↓ perform_query / exec_update
|
|
202
|
+
TursoLibsql::Database + Connection (Rust native extension)
|
|
203
|
+
↓ libsql::Database / Connection (async Tokio runtime → block_on)
|
|
204
|
+
|
|
205
|
+
Remote mode: Turso Cloud (libSQL remote protocol over HTTPS)
|
|
206
|
+
Replica mode: Local SQLite file ←sync→ Turso Cloud
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Thread Safety
|
|
210
|
+
|
|
211
|
+
`libsql::Connection` implements `Send + Sync`, making it thread-safe. ActiveRecord's `ConnectionPool` issues a separate `Adapter` instance per thread, so `@raw_connection` is never shared across threads.
|
|
212
|
+
|
|
213
|
+
## Performance
|
|
214
|
+
|
|
215
|
+
Benchmarked against a **Turso cloud database** (remote, over HTTPS) from a MacBook on a home network. All numbers include full round-trip network latency.
|
|
216
|
+
|
|
217
|
+
| Operation | ops/sec | avg latency |
|
|
218
|
+
|-----------|--------:|------------:|
|
|
219
|
+
| INSERT single row | 9.9 | 101.5 ms |
|
|
220
|
+
| SELECT all (100 rows) | 29.1 | 34.3 ms |
|
|
221
|
+
| SELECT WHERE | 35.9 | 27.9 ms |
|
|
222
|
+
| SELECT find by id | 16.2 | 61.9 ms |
|
|
223
|
+
| UPDATE single row | 6.4 | 156.0 ms |
|
|
224
|
+
| DELETE single row | 6.9 | 145.2 ms |
|
|
225
|
+
| Transaction (10 inserts) | 1.9 | 539.0 ms |
|
|
226
|
+
|
|
227
|
+
> **Environment**: Ruby 3.4.8 · ActiveRecord 8.1.2 · Turso cloud (remote) · macOS arm64
|
|
228
|
+
> Run `bundle exec ruby bench/benchmark.rb` to reproduce.
|
|
229
|
+
|
|
230
|
+
Latency is dominated by network round-trips to the Turso cloud endpoint. For lower latency, use [Embedded Replicas](#embedded-replicas) — reads are served from a local SQLite file with sub-millisecond latency.
|
|
231
|
+
|
|
232
|
+
## Feature Support
|
|
233
|
+
|
|
234
|
+
| Feature | Status |
|
|
235
|
+
|---------|--------|
|
|
236
|
+
| SELECT | ✅ |
|
|
237
|
+
| INSERT | ✅ |
|
|
238
|
+
| UPDATE | ✅ |
|
|
239
|
+
| DELETE | ✅ |
|
|
240
|
+
| Transactions | ✅ |
|
|
241
|
+
| Migrations (basic) | ✅ |
|
|
242
|
+
| Schema management (sqldef) | ✅ |
|
|
243
|
+
| Prepared statements | ✅ |
|
|
244
|
+
| BLOB | ✅ |
|
|
245
|
+
| NOT NULL / UNIQUE constraint errors → AR exceptions | ✅ |
|
|
246
|
+
| Embedded Replica | ✅ |
|
|
247
|
+
|
|
248
|
+
## Testing
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# Unit tests only (no credentials needed)
|
|
252
|
+
bundle exec rake spec
|
|
253
|
+
|
|
254
|
+
# Integration tests (requires TURSO_DATABASE_URL and TURSO_AUTH_TOKEN)
|
|
255
|
+
bundle exec rake spec:integration
|
|
256
|
+
|
|
257
|
+
# All tests
|
|
258
|
+
bundle exec rake spec:all
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Set `SKIP_INTEGRATION_TESTS=1` to skip integration tests in CI environments without Turso credentials.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/activerecord/libsql/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'activerecord-libsql'
|
|
7
|
+
spec.version = ActiveRecord::Libsql::VERSION
|
|
8
|
+
spec.authors = ['aileron']
|
|
9
|
+
spec.email = []
|
|
10
|
+
|
|
11
|
+
spec.summary = 'ActiveRecord adapter for Turso (libSQL) database'
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
An ActiveRecord adapter for Turso, the edge SQLite database powered by libSQL.
|
|
14
|
+
Uses a native Rust extension (via magnus) to connect directly to Turso via the
|
|
15
|
+
libSQL remote protocol, without requiring any external HTTP client.
|
|
16
|
+
DESC
|
|
17
|
+
spec.homepage = 'https://github.com/aileron-inc/activerecord-libsql'
|
|
18
|
+
spec.license = 'MIT'
|
|
19
|
+
spec.required_ruby_version = '>= 3.1.0'
|
|
20
|
+
|
|
21
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
22
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
23
|
+
|
|
24
|
+
spec.files = Dir[
|
|
25
|
+
'lib/**/*.rb',
|
|
26
|
+
'ext/**/*.{rs,toml,rb}',
|
|
27
|
+
'*.md',
|
|
28
|
+
'*.gemspec',
|
|
29
|
+
'Cargo.{toml,lock}'
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
spec.require_paths = ['lib']
|
|
33
|
+
spec.extensions = ['ext/turso_libsql/extconf.rb']
|
|
34
|
+
|
|
35
|
+
spec.add_dependency 'activerecord', '>= 7.0'
|
|
36
|
+
spec.add_dependency 'rb_sys', '~> 0.9'
|
|
37
|
+
|
|
38
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
39
|
+
spec.add_development_dependency 'rake-compiler', '~> 1.2'
|
|
40
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "turso_libsql"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
|
|
6
|
+
[lib]
|
|
7
|
+
name = "turso_libsql"
|
|
8
|
+
crate-type = ["cdylib"]
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
magnus = { version = "0.8", features = [] }
|
|
12
|
+
libsql = { version = "0.9.23", features = ["encryption", "sync"] }
|
|
13
|
+
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
|
14
|
+
once_cell = "1"
|
|
15
|
+
|
|
16
|
+
[profile.release]
|
|
17
|
+
opt-level = 3
|
|
18
|
+
lto = true
|
|
19
|
+
codegen-units = 1
|
|
20
|
+
panic = "abort"
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
use magnus::{function, method, prelude::*, Error, Ruby};
|
|
2
|
+
use once_cell::sync::OnceCell;
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
use std::time::Duration;
|
|
5
|
+
use tokio::runtime::Runtime;
|
|
6
|
+
|
|
7
|
+
// グローバル Tokio ランタイム(libsql は async API のため必要)
|
|
8
|
+
static RUNTIME: OnceCell<Runtime> = OnceCell::new();
|
|
9
|
+
|
|
10
|
+
fn runtime() -> &'static Runtime {
|
|
11
|
+
RUNTIME.get_or_init(|| {
|
|
12
|
+
tokio::runtime::Builder::new_multi_thread()
|
|
13
|
+
.enable_all()
|
|
14
|
+
.build()
|
|
15
|
+
.expect("Failed to create Tokio runtime")
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// async ブロック内から Ruby の RuntimeError を生成するヘルパー
|
|
20
|
+
fn mk_err(msg: impl std::fmt::Display) -> Error {
|
|
21
|
+
let ruby = Ruby::get().expect("called outside Ruby thread");
|
|
22
|
+
Error::new(ruby.exception_runtime_error(), msg.to_string())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// -----------------------------------------------------------------------
|
|
26
|
+
// TursoDatabase — Database を保持するラッパー(sync のために必要)
|
|
27
|
+
// -----------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
#[magnus::wrap(class = "TursoLibsql::Database", free_immediately, size)]
|
|
30
|
+
struct TursoDatabase {
|
|
31
|
+
inner: Arc<libsql::Database>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
impl TursoDatabase {
|
|
35
|
+
/// リモート接続用 Database を作成(既存の remote モード)
|
|
36
|
+
fn new_remote(url: String, token: String) -> Result<Self, Error> {
|
|
37
|
+
let db = runtime().block_on(async {
|
|
38
|
+
libsql::Builder::new_remote(url, token)
|
|
39
|
+
.build()
|
|
40
|
+
.await
|
|
41
|
+
.map_err(mk_err)
|
|
42
|
+
})?;
|
|
43
|
+
Ok(Self { inner: Arc::new(db) })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Embedded Replica 用 Database を作成
|
|
47
|
+
/// path: ローカル DB ファイルパス
|
|
48
|
+
/// url: Turso リモート URL (libsql://...)
|
|
49
|
+
/// token: 認証トークン
|
|
50
|
+
/// sync_interval_secs: バックグラウンド自動同期間隔(秒)。0 なら手動のみ
|
|
51
|
+
fn new_remote_replica(
|
|
52
|
+
path: String,
|
|
53
|
+
url: String,
|
|
54
|
+
token: String,
|
|
55
|
+
sync_interval_secs: u64,
|
|
56
|
+
) -> Result<Self, Error> {
|
|
57
|
+
let db = runtime().block_on(async {
|
|
58
|
+
let mut builder = libsql::Builder::new_remote_replica(path, url, token);
|
|
59
|
+
if sync_interval_secs > 0 {
|
|
60
|
+
builder = builder.sync_interval(Duration::from_secs(sync_interval_secs));
|
|
61
|
+
}
|
|
62
|
+
builder.build().await.map_err(mk_err)
|
|
63
|
+
})?;
|
|
64
|
+
Ok(Self { inner: Arc::new(db) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Offline write 用 Database を作成
|
|
68
|
+
/// write はローカルに書いてすぐ返す。sync() でまとめてリモートへ反映する。
|
|
69
|
+
/// path: ローカル DB ファイルパス
|
|
70
|
+
/// url: Turso リモート URL (libsql://...)
|
|
71
|
+
/// token: 認証トークン
|
|
72
|
+
/// sync_interval_secs: バックグラウンド自動同期間隔(秒)。0 なら手動のみ
|
|
73
|
+
fn new_synced(
|
|
74
|
+
path: String,
|
|
75
|
+
url: String,
|
|
76
|
+
token: String,
|
|
77
|
+
sync_interval_secs: u64,
|
|
78
|
+
) -> Result<Self, Error> {
|
|
79
|
+
let db = runtime().block_on(async {
|
|
80
|
+
let mut builder = libsql::Builder::new_synced_database(path, url, token);
|
|
81
|
+
if sync_interval_secs > 0 {
|
|
82
|
+
builder = builder.sync_interval(Duration::from_secs(sync_interval_secs));
|
|
83
|
+
}
|
|
84
|
+
builder.build().await.map_err(mk_err)
|
|
85
|
+
})?;
|
|
86
|
+
Ok(Self { inner: Arc::new(db) })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// リモートから最新フレームを手動で同期する(pull)
|
|
90
|
+
/// offline モードでは write もまとめてリモートへ push される
|
|
91
|
+
fn sync(&self) -> Result<(), Error> {
|
|
92
|
+
let db = Arc::clone(&self.inner);
|
|
93
|
+
runtime().block_on(async move {
|
|
94
|
+
db.sync().await.map_err(mk_err)
|
|
95
|
+
})?;
|
|
96
|
+
Ok(())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// この Database から Connection を取得して TursoConnection を返す
|
|
100
|
+
fn connect(&self) -> Result<TursoConnection, Error> {
|
|
101
|
+
let conn = self.inner.connect().map_err(mk_err)?;
|
|
102
|
+
Ok(TursoConnection {
|
|
103
|
+
inner: Arc::new(conn),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
// TursoConnection — Ruby に公開する接続オブジェクト
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
#[magnus::wrap(class = "TursoLibsql::Connection", free_immediately, size)]
|
|
113
|
+
struct TursoConnection {
|
|
114
|
+
inner: Arc<libsql::Connection>,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
impl TursoConnection {
|
|
118
|
+
/// 新しいリモート接続を作成する(Ruby: TursoLibsql::Connection.new(url, token))
|
|
119
|
+
/// 後方互換のために残す。内部では TursoDatabase を経由する
|
|
120
|
+
fn new(url: String, token: String) -> Result<Self, Error> {
|
|
121
|
+
let db = runtime().block_on(async {
|
|
122
|
+
libsql::Builder::new_remote(url, token)
|
|
123
|
+
.build()
|
|
124
|
+
.await
|
|
125
|
+
.map_err(mk_err)
|
|
126
|
+
})?;
|
|
127
|
+
let conn = db.connect().map_err(mk_err)?;
|
|
128
|
+
Ok(Self {
|
|
129
|
+
inner: Arc::new(conn),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// SQL を実行し、影響を受けた行数を返す(INSERT/UPDATE/DELETE 用)
|
|
134
|
+
fn execute(&self, sql: String) -> Result<u64, Error> {
|
|
135
|
+
let conn = Arc::clone(&self.inner);
|
|
136
|
+
runtime().block_on(async move {
|
|
137
|
+
conn.execute(&sql, ()).await.map_err(mk_err)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// SQL を実行し、結果を Array of Hash で返す(SELECT 用)
|
|
142
|
+
fn query(&self, sql: String) -> Result<magnus::RArray, Error> {
|
|
143
|
+
let conn = Arc::clone(&self.inner);
|
|
144
|
+
|
|
145
|
+
let rows_data: Vec<Vec<(String, libsql::Value)>> =
|
|
146
|
+
runtime().block_on(async move {
|
|
147
|
+
let mut rows = conn.query(&sql, ()).await.map_err(mk_err)?;
|
|
148
|
+
let mut result: Vec<Vec<(String, libsql::Value)>> = Vec::new();
|
|
149
|
+
|
|
150
|
+
while let Some(row) = rows.next().await.map_err(mk_err)? {
|
|
151
|
+
let col_count = rows.column_count();
|
|
152
|
+
let mut record: Vec<(String, libsql::Value)> =
|
|
153
|
+
Vec::with_capacity(col_count as usize);
|
|
154
|
+
|
|
155
|
+
for i in 0..col_count {
|
|
156
|
+
let name = rows.column_name(i).unwrap_or("?").to_string();
|
|
157
|
+
let val = row.get_value(i).map_err(mk_err)?;
|
|
158
|
+
record.push((name, val));
|
|
159
|
+
}
|
|
160
|
+
result.push(record);
|
|
161
|
+
}
|
|
162
|
+
Ok::<_, Error>(result)
|
|
163
|
+
})?;
|
|
164
|
+
|
|
165
|
+
let ruby = Ruby::get().expect("called outside Ruby thread");
|
|
166
|
+
let outer = ruby.ary_new_capa(rows_data.len());
|
|
167
|
+
for record in rows_data {
|
|
168
|
+
let hash = ruby.hash_new();
|
|
169
|
+
for (col, val) in record {
|
|
170
|
+
let ruby_key = ruby.str_new(&col);
|
|
171
|
+
let ruby_val = libsql_value_to_ruby(&ruby, val)?;
|
|
172
|
+
hash.aset(ruby_key, ruby_val)?;
|
|
173
|
+
}
|
|
174
|
+
outer.push(hash)?;
|
|
175
|
+
}
|
|
176
|
+
Ok(outer)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// プリペアドステートメントで SQL を実行(パラメータ付き)
|
|
180
|
+
fn execute_with_params(&self, sql: String, params: Vec<String>) -> Result<u64, Error> {
|
|
181
|
+
let conn = Arc::clone(&self.inner);
|
|
182
|
+
runtime().block_on(async move {
|
|
183
|
+
let params: Vec<libsql::Value> =
|
|
184
|
+
params.into_iter().map(libsql::Value::Text).collect();
|
|
185
|
+
conn.execute(&sql, libsql::params_from_iter(params))
|
|
186
|
+
.await
|
|
187
|
+
.map_err(mk_err)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// トランザクションを開始する
|
|
192
|
+
fn begin_transaction(&self) -> Result<(), Error> {
|
|
193
|
+
let conn = Arc::clone(&self.inner);
|
|
194
|
+
runtime().block_on(async move {
|
|
195
|
+
conn.execute("BEGIN", ()).await.map_err(mk_err)
|
|
196
|
+
})?;
|
|
197
|
+
Ok(())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// トランザクションをコミットする
|
|
201
|
+
fn commit_transaction(&self) -> Result<(), Error> {
|
|
202
|
+
let conn = Arc::clone(&self.inner);
|
|
203
|
+
runtime().block_on(async move {
|
|
204
|
+
conn.execute("COMMIT", ()).await.map_err(mk_err)
|
|
205
|
+
})?;
|
|
206
|
+
Ok(())
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// トランザクションをロールバックする
|
|
210
|
+
fn rollback_transaction(&self) -> Result<(), Error> {
|
|
211
|
+
let conn = Arc::clone(&self.inner);
|
|
212
|
+
runtime().block_on(async move {
|
|
213
|
+
conn.execute("ROLLBACK", ()).await.map_err(mk_err)
|
|
214
|
+
})?;
|
|
215
|
+
Ok(())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// 最後に挿入した行の rowid を返す
|
|
219
|
+
fn last_insert_rowid(&self) -> Result<i64, Error> {
|
|
220
|
+
let conn = Arc::clone(&self.inner);
|
|
221
|
+
runtime().block_on(async move {
|
|
222
|
+
let mut rows = conn
|
|
223
|
+
.query("SELECT last_insert_rowid()", ())
|
|
224
|
+
.await
|
|
225
|
+
.map_err(mk_err)?;
|
|
226
|
+
|
|
227
|
+
if let Some(row) = rows.next().await.map_err(mk_err)? {
|
|
228
|
+
row.get::<i64>(0).map_err(mk_err)
|
|
229
|
+
} else {
|
|
230
|
+
Ok(0)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// -----------------------------------------------------------------------
|
|
237
|
+
// libsql::Value → Ruby Value 変換
|
|
238
|
+
// -----------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
fn libsql_value_to_ruby(ruby: &Ruby, val: libsql::Value) -> Result<magnus::Value, Error> {
|
|
241
|
+
match val {
|
|
242
|
+
libsql::Value::Null => Ok(ruby.qnil().as_value()),
|
|
243
|
+
libsql::Value::Integer(i) => Ok(ruby.integer_from_i64(i).as_value()),
|
|
244
|
+
libsql::Value::Real(f) => Ok(ruby.float_from_f64(f).as_value()),
|
|
245
|
+
libsql::Value::Text(s) => Ok(ruby.str_new(&s).as_value()),
|
|
246
|
+
libsql::Value::Blob(b) => Ok(ruby.str_from_slice(&b).as_value()),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// -----------------------------------------------------------------------
|
|
251
|
+
// Magnus init — Ruby 拡張のエントリポイント
|
|
252
|
+
// -----------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
#[magnus::init]
|
|
255
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
256
|
+
let module = ruby.define_module("TursoLibsql")?;
|
|
257
|
+
|
|
258
|
+
// TursoLibsql::Database
|
|
259
|
+
let db_class = module.define_class("Database", ruby.class_object())?;
|
|
260
|
+
db_class.define_singleton_method("new_remote", function!(TursoDatabase::new_remote, 2))?;
|
|
261
|
+
db_class.define_singleton_method(
|
|
262
|
+
"new_remote_replica",
|
|
263
|
+
function!(TursoDatabase::new_remote_replica, 4),
|
|
264
|
+
)?;
|
|
265
|
+
db_class.define_singleton_method(
|
|
266
|
+
"new_synced",
|
|
267
|
+
function!(TursoDatabase::new_synced, 4),
|
|
268
|
+
)?;
|
|
269
|
+
db_class.define_method("sync", method!(TursoDatabase::sync, 0))?;
|
|
270
|
+
db_class.define_method("connect", method!(TursoDatabase::connect, 0))?;
|
|
271
|
+
|
|
272
|
+
// TursoLibsql::Connection
|
|
273
|
+
let conn_class = module.define_class("Connection", ruby.class_object())?;
|
|
274
|
+
conn_class.define_singleton_method("new", function!(TursoConnection::new, 2))?;
|
|
275
|
+
conn_class.define_method("execute", method!(TursoConnection::execute, 1))?;
|
|
276
|
+
conn_class.define_method("query", method!(TursoConnection::query, 1))?;
|
|
277
|
+
conn_class.define_method(
|
|
278
|
+
"execute_with_params",
|
|
279
|
+
method!(TursoConnection::execute_with_params, 2),
|
|
280
|
+
)?;
|
|
281
|
+
conn_class.define_method(
|
|
282
|
+
"begin_transaction",
|
|
283
|
+
method!(TursoConnection::begin_transaction, 0),
|
|
284
|
+
)?;
|
|
285
|
+
conn_class.define_method(
|
|
286
|
+
"commit_transaction",
|
|
287
|
+
method!(TursoConnection::commit_transaction, 0),
|
|
288
|
+
)?;
|
|
289
|
+
conn_class.define_method(
|
|
290
|
+
"rollback_transaction",
|
|
291
|
+
method!(TursoConnection::rollback_transaction, 0),
|
|
292
|
+
)?;
|
|
293
|
+
conn_class.define_method(
|
|
294
|
+
"last_insert_rowid",
|
|
295
|
+
method!(TursoConnection::last_insert_rowid, 0),
|
|
296
|
+
)?;
|
|
297
|
+
|
|
298
|
+
Ok(())
|
|
299
|
+
}
|