libsql2 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 +2340 -0
- data/Cargo.toml +7 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/ext/libsql2/Cargo.toml +16 -0
- data/ext/libsql2/extconf.rb +6 -0
- data/ext/libsql2/src/connection.rs +239 -0
- data/ext/libsql2/src/database.rs +241 -0
- data/ext/libsql2/src/error.rs +42 -0
- data/ext/libsql2/src/lib.rs +20 -0
- data/ext/libsql2/src/rows.rs +154 -0
- data/ext/libsql2/src/statement.rs +146 -0
- data/lib/libsql/blob.rb +10 -0
- data/lib/libsql/connection.rb +15 -0
- data/lib/libsql/database.rb +37 -0
- data/lib/libsql/version.rb +5 -0
- data/lib/libsql2.rb +7 -0
- metadata +72 -0
data/Cargo.toml
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 speria-jp
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# libsql2
|
|
2
|
+
|
|
3
|
+
An alternative to [turso_libsql](https://github.com/tursodatabase/libsql-ruby) with a native Rust extension.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Native Rust extension** — no FFI overhead
|
|
8
|
+
- **Full libSQL support** — local files, in-memory, remote (Turso Cloud), and Embedded Replicas
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Add to your Gemfile:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "libsql2"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Opening a Database
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "libsql2"
|
|
30
|
+
|
|
31
|
+
# Local file
|
|
32
|
+
db = Libsql::Database.open("/path/to/file.db")
|
|
33
|
+
|
|
34
|
+
# In-memory
|
|
35
|
+
db = Libsql::Database.open(":memory:")
|
|
36
|
+
|
|
37
|
+
# Remote (Turso Cloud)
|
|
38
|
+
db = Libsql::Database.open("libsql://xxx.turso.io", auth_token: "xxx")
|
|
39
|
+
|
|
40
|
+
# Embedded Replica
|
|
41
|
+
db = Libsql::Database.open("libsql://xxx.turso.io",
|
|
42
|
+
auth_token: "xxx",
|
|
43
|
+
local_path: "/path/to/replica.db",
|
|
44
|
+
sync_interval: 60,
|
|
45
|
+
read_your_writes: true
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Block form (auto-close)
|
|
49
|
+
Libsql::Database.open(":memory:") do |db|
|
|
50
|
+
# ...
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Querying
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
db.connect do |conn|
|
|
58
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
|
|
59
|
+
conn.execute("INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", 30])
|
|
60
|
+
|
|
61
|
+
rows = conn.query("SELECT * FROM users WHERE age > ?", [20])
|
|
62
|
+
rows.columns #=> ["id", "name", "age"]
|
|
63
|
+
rows.types #=> ["INTEGER", "TEXT", "INTEGER"]
|
|
64
|
+
rows.each { |row| puts row["name"] }
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Execute (no result set)
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
conn.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
|
|
72
|
+
conn.changes #=> 1
|
|
73
|
+
conn.last_insert_rowid #=> 42
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Batch Execution
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
conn.execute_batch(<<~SQL)
|
|
80
|
+
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
|
|
81
|
+
CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT);
|
|
82
|
+
SQL
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Prepared Statements
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
stmt = conn.prepare("SELECT * FROM users WHERE name = ?")
|
|
89
|
+
stmt.bind(["Alice"])
|
|
90
|
+
rows = stmt.query
|
|
91
|
+
stmt.close
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Transactions
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
conn.transaction do |tx|
|
|
98
|
+
tx.execute("UPDATE accounts SET balance = balance - 100 WHERE id = ?", [1])
|
|
99
|
+
tx.execute("UPDATE accounts SET balance = balance + 100 WHERE id = ?", [2])
|
|
100
|
+
# Commits on normal exit, rolls back on exception
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# With behavior
|
|
104
|
+
conn.transaction(:immediate) do |tx|
|
|
105
|
+
tx.execute("INSERT INTO orders (item) VALUES (?)", ["widget"])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Nested transactions (SAVEPOINT)
|
|
109
|
+
conn.transaction do |tx|
|
|
110
|
+
tx.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
|
|
111
|
+
tx.transaction do |tx2|
|
|
112
|
+
tx2.execute("INSERT INTO users (name) VALUES (?)", ["Bob"])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Embedded Replica Sync
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
db.sync # Manual sync
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Fork Safety (Puma / Unicorn)
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# puma.rb
|
|
127
|
+
on_worker_boot do
|
|
128
|
+
$db.discard! if $db
|
|
129
|
+
$db = Libsql::Database.open(...)
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Type Mapping
|
|
134
|
+
|
|
135
|
+
| libSQL Type | Ruby Type | Notes |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| INTEGER | `Integer` | Signed 64-bit |
|
|
138
|
+
| REAL | `Float` | Double precision |
|
|
139
|
+
| TEXT | `String` | UTF-8 encoded |
|
|
140
|
+
| BLOB | `Libsql::Blob` | `String` subclass with `ASCII-8BIT` encoding |
|
|
141
|
+
| NULL | `nil` | |
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
Requirements: Ruby >= 3.1, Rust >= 1.74, and a C compiler.
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bundle install # Install Ruby dependencies
|
|
149
|
+
rake compile # Compile the Rust extension
|
|
150
|
+
rake spec # Run tests
|
|
151
|
+
rake lint # Run all linters (RuboCop + Clippy)
|
|
152
|
+
rake fmt # Auto-format all code
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Contributing
|
|
156
|
+
|
|
157
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/speria-jp/libsql-ruby2.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "libsql2"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
crate-type = ["cdylib"]
|
|
9
|
+
|
|
10
|
+
[lints]
|
|
11
|
+
workspace = true
|
|
12
|
+
|
|
13
|
+
[dependencies]
|
|
14
|
+
magnus = "0.8"
|
|
15
|
+
libsql = "0.9"
|
|
16
|
+
tokio = { version = "1", features = ["rt-multi-thread"] }
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
use std::cell::RefCell;
|
|
2
|
+
use std::sync::Arc;
|
|
3
|
+
|
|
4
|
+
use magnus::{prelude::*, Error, RArray, RModule, RString, Ruby, TryConvert, Value};
|
|
5
|
+
use tokio::runtime::Runtime;
|
|
6
|
+
|
|
7
|
+
use crate::error;
|
|
8
|
+
use crate::rows::Rows;
|
|
9
|
+
use crate::statement::Statement;
|
|
10
|
+
|
|
11
|
+
pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
|
|
12
|
+
let class = module.define_class("Connection", ruby.class_object())?;
|
|
13
|
+
class.define_method("execute", magnus::method!(Connection::execute, -1))?;
|
|
14
|
+
class.define_method(
|
|
15
|
+
"execute_batch",
|
|
16
|
+
magnus::method!(Connection::execute_batch, 1),
|
|
17
|
+
)?;
|
|
18
|
+
class.define_method("query", magnus::method!(Connection::query, -1))?;
|
|
19
|
+
class.define_method("changes", magnus::method!(Connection::changes, 0))?;
|
|
20
|
+
class.define_method(
|
|
21
|
+
"last_insert_rowid",
|
|
22
|
+
magnus::method!(Connection::last_insert_rowid, 0),
|
|
23
|
+
)?;
|
|
24
|
+
class.define_method("prepare", magnus::method!(Connection::prepare, 1))?;
|
|
25
|
+
class.define_method("close", magnus::method!(Connection::close, 0))?;
|
|
26
|
+
class.define_method("closed?", magnus::method!(Connection::is_closed, 0))?;
|
|
27
|
+
|
|
28
|
+
Ok(())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Internal state dropped together on close to release file descriptors.
|
|
32
|
+
pub(crate) struct ConnectionInner {
|
|
33
|
+
pub rt: Arc<Runtime>,
|
|
34
|
+
pub conn: libsql::Connection,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[magnus::wrap(class = "Libsql::Connection", free_immediately, size)]
|
|
38
|
+
pub struct Connection {
|
|
39
|
+
inner: RefCell<Option<ConnectionInner>>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl Connection {
|
|
43
|
+
pub fn new(rt: Arc<Runtime>, conn: libsql::Connection) -> Self {
|
|
44
|
+
Self {
|
|
45
|
+
inner: RefCell::new(Some(ConnectionInner { rt, conn })),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn with_inner<F, T>(&self, f: F) -> Result<T, Error>
|
|
50
|
+
where
|
|
51
|
+
F: FnOnce(&ConnectionInner) -> Result<T, Error>,
|
|
52
|
+
{
|
|
53
|
+
let inner = self.inner.borrow();
|
|
54
|
+
let inner = inner
|
|
55
|
+
.as_ref()
|
|
56
|
+
.ok_or_else(|| Error::new(error::closed_error(), "connection is closed"))?;
|
|
57
|
+
f(inner)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fn execute(&self, args: &[Value]) -> Result<u64, Error> {
|
|
61
|
+
if args.is_empty() {
|
|
62
|
+
return Err(Error::new(
|
|
63
|
+
error::libsql_error(),
|
|
64
|
+
"wrong number of arguments (given 0, expected 1..2)",
|
|
65
|
+
));
|
|
66
|
+
}
|
|
67
|
+
let sql: String = TryConvert::try_convert(args[0])?;
|
|
68
|
+
let params = if args.len() > 1 {
|
|
69
|
+
ruby_array_to_params(&args[1])?
|
|
70
|
+
} else {
|
|
71
|
+
vec![]
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
self.with_inner(|inner| {
|
|
75
|
+
inner
|
|
76
|
+
.rt
|
|
77
|
+
.block_on(async { inner.conn.execute(&sql, params_to_libsql(¶ms)).await })
|
|
78
|
+
.map_err(error::to_error)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn execute_batch(&self, sql: String) -> Result<(), Error> {
|
|
83
|
+
self.with_inner(|inner| {
|
|
84
|
+
inner
|
|
85
|
+
.rt
|
|
86
|
+
.block_on(async { inner.conn.execute_batch(&sql).await })
|
|
87
|
+
.map_err(error::to_error)?;
|
|
88
|
+
Ok(())
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn query(&self, args: &[Value]) -> Result<Rows, Error> {
|
|
93
|
+
if args.is_empty() {
|
|
94
|
+
return Err(Error::new(
|
|
95
|
+
error::libsql_error(),
|
|
96
|
+
"wrong number of arguments (given 0, expected 1..2)",
|
|
97
|
+
));
|
|
98
|
+
}
|
|
99
|
+
let sql: String = TryConvert::try_convert(args[0])?;
|
|
100
|
+
let params = if args.len() > 1 {
|
|
101
|
+
ruby_array_to_params(&args[1])?
|
|
102
|
+
} else {
|
|
103
|
+
vec![]
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
self.with_inner(|inner| {
|
|
107
|
+
inner.rt.block_on(async {
|
|
108
|
+
let stmt = inner.conn.prepare(&sql).await.map_err(error::to_error)?;
|
|
109
|
+
let (column_names, column_types) = extract_column_meta(&stmt);
|
|
110
|
+
|
|
111
|
+
let mut rows = stmt
|
|
112
|
+
.query(params_to_libsql(¶ms))
|
|
113
|
+
.await
|
|
114
|
+
.map_err(error::to_error)?;
|
|
115
|
+
|
|
116
|
+
let col_count = column_names.len();
|
|
117
|
+
let mut data = Vec::new();
|
|
118
|
+
while let Some(row) = rows.next().await.map_err(error::to_error)? {
|
|
119
|
+
let mut row_data = Vec::with_capacity(col_count);
|
|
120
|
+
for i in 0..col_count {
|
|
121
|
+
let val = row.get_value(i as i32).map_err(error::to_error)?;
|
|
122
|
+
row_data.push(val.into());
|
|
123
|
+
}
|
|
124
|
+
data.push(row_data);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Ok(Rows::new(column_names, column_types, data))
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn prepare(&self, sql: String) -> Result<Statement, Error> {
|
|
133
|
+
self.with_inner(|inner| {
|
|
134
|
+
inner.rt.block_on(async {
|
|
135
|
+
let stmt = inner.conn.prepare(&sql).await.map_err(error::to_error)?;
|
|
136
|
+
let (column_names, column_types) = extract_column_meta(&stmt);
|
|
137
|
+
|
|
138
|
+
Ok(Statement::new(
|
|
139
|
+
Arc::clone(&inner.rt),
|
|
140
|
+
stmt,
|
|
141
|
+
column_names,
|
|
142
|
+
column_types,
|
|
143
|
+
))
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn changes(&self) -> Result<u64, Error> {
|
|
149
|
+
self.with_inner(|inner| Ok(inner.conn.changes()))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn last_insert_rowid(&self) -> Result<i64, Error> {
|
|
153
|
+
self.with_inner(|inner| Ok(inner.conn.last_insert_rowid()))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn close(&self) -> Result<(), Error> {
|
|
157
|
+
let _ = self.inner.borrow_mut().take();
|
|
158
|
+
Ok(())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fn is_closed(&self) -> bool {
|
|
162
|
+
self.inner.borrow().is_none()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Extract column names and declared types from a prepared statement.
|
|
167
|
+
pub(crate) fn extract_column_meta(stmt: &libsql::Statement) -> (Vec<String>, Vec<Option<String>>) {
|
|
168
|
+
let columns = stmt.columns();
|
|
169
|
+
let mut names = Vec::with_capacity(columns.len());
|
|
170
|
+
let mut types = Vec::with_capacity(columns.len());
|
|
171
|
+
for col in &columns {
|
|
172
|
+
names.push(col.name().to_string());
|
|
173
|
+
types.push(col.decl_type().map(|s| s.to_uppercase()));
|
|
174
|
+
}
|
|
175
|
+
(names, types)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Parameter value holder for conversion
|
|
179
|
+
pub(crate) enum ParamValue {
|
|
180
|
+
Null,
|
|
181
|
+
Integer(i64),
|
|
182
|
+
Real(f64),
|
|
183
|
+
Text(String),
|
|
184
|
+
Blob(Vec<u8>),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
pub(crate) fn ruby_array_to_params(val: &Value) -> Result<Vec<ParamValue>, Error> {
|
|
188
|
+
let array: RArray = TryConvert::try_convert(*val)?;
|
|
189
|
+
let ruby = Ruby::get().expect("called from Ruby");
|
|
190
|
+
let mut params = Vec::with_capacity(array.len());
|
|
191
|
+
for item in array.into_iter() {
|
|
192
|
+
if item.is_nil() {
|
|
193
|
+
params.push(ParamValue::Null);
|
|
194
|
+
} else if item.is_kind_of(ruby.class_integer()) {
|
|
195
|
+
let i: i64 = TryConvert::try_convert(item)?;
|
|
196
|
+
params.push(ParamValue::Integer(i));
|
|
197
|
+
} else if item.is_kind_of(ruby.class_float()) {
|
|
198
|
+
let f: f64 = TryConvert::try_convert(item)?;
|
|
199
|
+
params.push(ParamValue::Real(f));
|
|
200
|
+
} else if is_blob(item) {
|
|
201
|
+
// Blob is a String subclass — must check before String
|
|
202
|
+
let rstr: RString = TryConvert::try_convert(item)?;
|
|
203
|
+
let bytes = unsafe { rstr.as_slice() }.to_vec();
|
|
204
|
+
params.push(ParamValue::Blob(bytes));
|
|
205
|
+
} else if item.is_kind_of(ruby.class_string()) {
|
|
206
|
+
let s: String = TryConvert::try_convert(item)?;
|
|
207
|
+
params.push(ParamValue::Text(s));
|
|
208
|
+
} else {
|
|
209
|
+
return Err(Error::new(
|
|
210
|
+
error::libsql_error(),
|
|
211
|
+
format!("unsupported parameter type: {}", unsafe {
|
|
212
|
+
item.classname()
|
|
213
|
+
}),
|
|
214
|
+
));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
Ok(params)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn is_blob(val: Value) -> bool {
|
|
221
|
+
val.is_kind_of(crate::rows::blob_class())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pub(crate) fn params_to_libsql(params: &[ParamValue]) -> libsql::params::Params {
|
|
225
|
+
if params.is_empty() {
|
|
226
|
+
return libsql::params::Params::None;
|
|
227
|
+
}
|
|
228
|
+
let values: Vec<libsql::Value> = params
|
|
229
|
+
.iter()
|
|
230
|
+
.map(|p| match p {
|
|
231
|
+
ParamValue::Null => libsql::Value::Null,
|
|
232
|
+
ParamValue::Integer(i) => libsql::Value::Integer(*i),
|
|
233
|
+
ParamValue::Real(f) => libsql::Value::Real(*f),
|
|
234
|
+
ParamValue::Text(s) => libsql::Value::Text(s.clone()),
|
|
235
|
+
ParamValue::Blob(b) => libsql::Value::Blob(b.clone()),
|
|
236
|
+
})
|
|
237
|
+
.collect();
|
|
238
|
+
libsql::params::Params::Positional(values)
|
|
239
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
use std::cell::RefCell;
|
|
2
|
+
use std::sync::Arc;
|
|
3
|
+
|
|
4
|
+
use magnus::prelude::*;
|
|
5
|
+
use magnus::{Error, RHash, RModule, Ruby, TryConvert, Value};
|
|
6
|
+
use tokio::runtime::Runtime;
|
|
7
|
+
|
|
8
|
+
use crate::connection::Connection;
|
|
9
|
+
use crate::error;
|
|
10
|
+
|
|
11
|
+
pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
|
|
12
|
+
let class = module.define_class("Database", ruby.class_object())?;
|
|
13
|
+
class.define_singleton_method("open", magnus::function!(Database::open, -1))?;
|
|
14
|
+
class.define_method("close", magnus::method!(Database::close, 0))?;
|
|
15
|
+
class.define_method("closed?", magnus::method!(Database::is_closed, 0))?;
|
|
16
|
+
class.define_method("sync", magnus::method!(Database::sync, 0))?;
|
|
17
|
+
class.define_method("connect", magnus::method!(Database::connect, 0))?;
|
|
18
|
+
class.define_method("discard!", magnus::method!(Database::discard, 0))?;
|
|
19
|
+
|
|
20
|
+
Ok(())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Internal state that gets dropped together on close/discard.
|
|
24
|
+
struct DatabaseInner {
|
|
25
|
+
rt: Arc<Runtime>,
|
|
26
|
+
db: libsql::Database,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[magnus::wrap(class = "Libsql::Database", free_immediately, size)]
|
|
30
|
+
struct Database {
|
|
31
|
+
inner: RefCell<Option<DatabaseInner>>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Extract an optional String value from a Ruby hash by symbol key.
|
|
35
|
+
fn hash_get_string(hash: &RHash, key: &str) -> Result<Option<String>, Error> {
|
|
36
|
+
let ruby = Ruby::get().expect("called from Ruby");
|
|
37
|
+
let sym = ruby.to_symbol(key);
|
|
38
|
+
let val: Option<Value> = hash.lookup(sym)?;
|
|
39
|
+
match val {
|
|
40
|
+
Some(v) if v.is_nil() => Ok(None),
|
|
41
|
+
Some(v) => {
|
|
42
|
+
let s: String = TryConvert::try_convert(v)?;
|
|
43
|
+
Ok(Some(s))
|
|
44
|
+
}
|
|
45
|
+
None => Ok(None),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Extract an optional bool value from a Ruby hash by symbol key.
|
|
50
|
+
fn hash_get_bool(hash: &RHash, key: &str) -> Result<Option<bool>, Error> {
|
|
51
|
+
let ruby = Ruby::get().expect("called from Ruby");
|
|
52
|
+
let sym = ruby.to_symbol(key);
|
|
53
|
+
let val: Option<Value> = hash.lookup(sym)?;
|
|
54
|
+
match val {
|
|
55
|
+
Some(v) if v.is_nil() => Ok(None),
|
|
56
|
+
Some(v) => {
|
|
57
|
+
let b: bool = TryConvert::try_convert(v)?;
|
|
58
|
+
Ok(Some(b))
|
|
59
|
+
}
|
|
60
|
+
None => Ok(None),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn is_remote_url(path: &str) -> bool {
|
|
65
|
+
path.starts_with("libsql://") || path.starts_with("https://")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
impl Database {
|
|
69
|
+
fn from_parts(rt: Runtime, db: libsql::Database) -> Self {
|
|
70
|
+
Self {
|
|
71
|
+
inner: RefCell::new(Some(DatabaseInner {
|
|
72
|
+
rt: Arc::new(rt),
|
|
73
|
+
db,
|
|
74
|
+
})),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn with_inner<F, T>(&self, f: F) -> Result<T, Error>
|
|
79
|
+
where
|
|
80
|
+
F: FnOnce(&DatabaseInner) -> Result<T, Error>,
|
|
81
|
+
{
|
|
82
|
+
let inner = self.inner.borrow();
|
|
83
|
+
let inner = inner
|
|
84
|
+
.as_ref()
|
|
85
|
+
.ok_or_else(|| Error::new(error::closed_error(), "database is closed"))?;
|
|
86
|
+
f(inner)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn open(args: &[Value]) -> Result<Self, Error> {
|
|
90
|
+
if args.is_empty() {
|
|
91
|
+
return Err(Error::new(
|
|
92
|
+
error::libsql_error(),
|
|
93
|
+
"wrong number of arguments (given 0, expected 1+)",
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let path: String = TryConvert::try_convert(args[0])?;
|
|
98
|
+
|
|
99
|
+
// Parse optional keyword arguments hash
|
|
100
|
+
let opts: Option<RHash> = if args.len() > 1 {
|
|
101
|
+
Some(TryConvert::try_convert(args[1])?)
|
|
102
|
+
} else {
|
|
103
|
+
None
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let auth_token = if let Some(ref h) = opts {
|
|
107
|
+
hash_get_string(h, "auth_token")?
|
|
108
|
+
} else {
|
|
109
|
+
None
|
|
110
|
+
};
|
|
111
|
+
let local_path = if let Some(ref h) = opts {
|
|
112
|
+
hash_get_string(h, "local_path")?
|
|
113
|
+
} else {
|
|
114
|
+
None
|
|
115
|
+
};
|
|
116
|
+
let read_your_writes = if let Some(ref h) = opts {
|
|
117
|
+
hash_get_bool(h, "read_your_writes")?
|
|
118
|
+
} else {
|
|
119
|
+
None
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if is_remote_url(&path) {
|
|
123
|
+
let token = auth_token.ok_or_else(|| {
|
|
124
|
+
Error::new(
|
|
125
|
+
error::libsql_error(),
|
|
126
|
+
"auth_token is required for remote connections",
|
|
127
|
+
)
|
|
128
|
+
})?;
|
|
129
|
+
|
|
130
|
+
if let Some(lp) = local_path {
|
|
131
|
+
Self::open_remote_replica(path, token, lp, read_your_writes)
|
|
132
|
+
} else {
|
|
133
|
+
Self::open_remote(path, token)
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
Self::open_local(path)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn open_local(path: String) -> Result<Self, Error> {
|
|
141
|
+
// Validate parent directory exists for file paths
|
|
142
|
+
if path != ":memory:" {
|
|
143
|
+
let p = std::path::Path::new(&path);
|
|
144
|
+
if let Some(parent) = p.parent() {
|
|
145
|
+
if !parent.as_os_str().is_empty() && !parent.exists() {
|
|
146
|
+
return Err(Error::new(
|
|
147
|
+
error::libsql_error(),
|
|
148
|
+
format!("directory does not exist: {}", parent.display()),
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
|
|
155
|
+
|
|
156
|
+
let db = rt
|
|
157
|
+
.block_on(async {
|
|
158
|
+
if path == ":memory:" {
|
|
159
|
+
libsql::Builder::new_local(":memory:").build().await
|
|
160
|
+
} else {
|
|
161
|
+
libsql::Builder::new_local(&path).build().await
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.map_err(error::to_error)?;
|
|
165
|
+
|
|
166
|
+
Ok(Self::from_parts(rt, db))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn open_remote(url: String, auth_token: String) -> Result<Self, Error> {
|
|
170
|
+
let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
|
|
171
|
+
|
|
172
|
+
let db = rt
|
|
173
|
+
.block_on(async { libsql::Builder::new_remote(url, auth_token).build().await })
|
|
174
|
+
.map_err(error::to_error)?;
|
|
175
|
+
|
|
176
|
+
Ok(Self::from_parts(rt, db))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn open_remote_replica(
|
|
180
|
+
url: String,
|
|
181
|
+
auth_token: String,
|
|
182
|
+
local_path: String,
|
|
183
|
+
read_your_writes: Option<bool>,
|
|
184
|
+
) -> Result<Self, Error> {
|
|
185
|
+
let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
|
|
186
|
+
|
|
187
|
+
let db = rt
|
|
188
|
+
.block_on(async {
|
|
189
|
+
let mut builder = libsql::Builder::new_remote_replica(local_path, url, auth_token);
|
|
190
|
+
if let Some(ryw) = read_your_writes {
|
|
191
|
+
builder = builder.read_your_writes(ryw);
|
|
192
|
+
}
|
|
193
|
+
builder.build().await
|
|
194
|
+
})
|
|
195
|
+
.map_err(error::to_error)?;
|
|
196
|
+
|
|
197
|
+
Ok(Self::from_parts(rt, db))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fn close(&self) -> Result<(), Error> {
|
|
201
|
+
// Drop both database and runtime to release file descriptors
|
|
202
|
+
let _ = self.inner.borrow_mut().take();
|
|
203
|
+
Ok(())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn is_closed(&self) -> bool {
|
|
207
|
+
self.inner.borrow().is_none()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn sync(&self) -> Result<(), Error> {
|
|
211
|
+
self.with_inner(|inner| {
|
|
212
|
+
// sync is a no-op for local/in-memory databases.
|
|
213
|
+
// Only embedded replicas actually perform synchronization.
|
|
214
|
+
let result = inner.rt.block_on(async { inner.db.sync().await });
|
|
215
|
+
match result {
|
|
216
|
+
Ok(_) => Ok(()),
|
|
217
|
+
Err(libsql::Error::SyncNotSupported(_)) => Ok(()),
|
|
218
|
+
Err(e) => Err(error::to_error(e)),
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
fn connect(&self) -> Result<Connection, Error> {
|
|
224
|
+
self.with_inner(|inner| {
|
|
225
|
+
let conn = inner
|
|
226
|
+
.rt
|
|
227
|
+
.block_on(async { inner.db.connect() })
|
|
228
|
+
.map_err(error::to_error)?;
|
|
229
|
+
|
|
230
|
+
Ok(Connection::new(Arc::clone(&inner.rt), conn))
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fn discard(&self) -> Result<(), Error> {
|
|
235
|
+
// Drop both database and runtime to release all resources.
|
|
236
|
+
// Intended for use after fork (Puma/Unicorn) to discard
|
|
237
|
+
// inherited connections that are no longer valid.
|
|
238
|
+
let _ = self.inner.borrow_mut().take();
|
|
239
|
+
Ok(())
|
|
240
|
+
}
|
|
241
|
+
}
|