Skip to content

Commit 1498cd6

Browse files
committed
feat: refactor tx API + add backing functions
- Consolidated transaction API to address a design flaw - Calling BEGIN and then ceding the write conn could leave an open transaction dangling - Added backing functions to wrapper.rs - Added decoder and tests - Added tests for error types
1 parent 0536341 commit 1498cd6

9 files changed

Lines changed: 903 additions & 111 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ futures-core = "0.3.31"
2323
time = "0.3.44"
2424
tokio = { version = "1.48.0", features = ["sync"] }
2525
indexmap = { version = "2.12.1", features = ["serde"] }
26+
base64 = "0.22.1"
2627

2728
# SQLx for types and queries (time feature enables datetime type decoding)
2829
sqlx = { version = "0.8.6", features = ["sqlite", "json", "time", "runtime-tokio"] }

README.md

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -304,45 +304,32 @@ if (user) {
304304
305305
### Using Transactions
306306

307-
Transactions ensure that multiple operations either all succeed or all fail together,
308-
maintaining data consistency:
307+
Execute multiple database operations atomically using `executeTransaction()`. All
308+
statements either succeed together or fail together, maintaining data consistency:
309309

310310
```typescript
311-
// Begin a transaction
312-
await db.beginTransaction();
313-
314-
try {
315-
// Execute multiple operations atomically
316-
await db.execute(
317-
'INSERT INTO users (name, email) VALUES ($1, $2)',
318-
['Alice', 'alice@example.com']
319-
);
320-
321-
await db.execute(
322-
'INSERT INTO audit_log (action, user) VALUES ($1, $2)',
323-
['user_created', 'Alice']
324-
);
325-
326-
// Commit if all operations succeed
327-
await db.commitTransaction();
328-
console.log('Transaction completed successfully');
329-
330-
} catch (error) {
331-
// Rollback if any operation fails
332-
await db.rollbackTransaction();
333-
console.error('Transaction failed, rolled back:', error);
334-
throw error;
335-
}
336-
```
337-
338-
**Important Notes:**
339-
340-
* All operations between `beginTransaction()` and
341-
`commitTransaction()`/`rollbackTransaction()` are executed as a single atomic unit
342-
* If an error occurs, call `rollbackTransaction()` to discard all changes
343-
* Nested transactions are not supported
344-
* Always ensure transactions are either committed or rolled back to avoid locking
345-
issues
311+
// Execute multiple inserts atomically
312+
await db.executeTransaction([
313+
['INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com']],
314+
['INSERT INTO audit_log (action, user) VALUES ($1, $2)', ['user_created', 'Alice']]
315+
]);
316+
317+
// Bank transfer example - all operations succeed or all fail
318+
await db.executeTransaction([
319+
['UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]],
320+
['UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]],
321+
['INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)', [1, 2, 100]]
322+
]);
323+
```
324+
325+
**How it works:**
326+
327+
* Automatically executes `BEGIN` before running statements
328+
* Executes all statements in order
329+
* Commits with `COMMIT` if all statements succeed
330+
* Rolls back with `ROLLBACK` if any statement fails
331+
* The write connection is held for the entire transaction, ensuring atomicity
332+
* Errors are thrown after rollback, preserving the original error message
346333

347334
### Closing Connections
348335

build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
fn main() {
2+
// TODO: Add commands to the plugin
23
tauri_plugin::Builder::new(&["hello"]).build();
34
}

guest-js/index.ts

Lines changed: 30 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -212,74 +212,44 @@ export default class Database {
212212
}
213213

214214
/**
215-
* **beginTransaction**
215+
* **executeTransaction**
216216
*
217-
* Begins a new database transaction. All subsequent operations will be
218-
* part of this transaction until `commitTransaction()` or `rollbackTransaction()`
219-
* is called.
217+
* Executes multiple statements atomically within a transaction.
218+
* All statements either succeed together or fail together.
220219
*
221-
* Transactions provide atomicity - either all operations succeed or all are rolled back.
220+
* The function automatically:
221+
* - Begins a transaction (BEGIN)
222+
* - Executes all statements in order
223+
* - Commits on success (COMMIT)
224+
* - Rolls back on any error (ROLLBACK)
222225
*
223-
* @example
224-
* ```ts
225-
* await db.beginTransaction();
226-
* try {
227-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
228-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
229-
* await db.commitTransaction();
230-
* } catch (error) {
231-
* await db.rollbackTransaction();
232-
* throw error;
233-
* }
234-
* ```
235-
*/
236-
async beginTransaction(): Promise<void> {
237-
await invoke('plugin:sqlite|begin_transaction', {
238-
db: this.path
239-
})
240-
}
241-
242-
/**
243-
* **commitTransaction**
244-
*
245-
* Commits the current transaction, making all changes permanent.
246-
*
247-
* @example
248-
* ```ts
249-
* await db.beginTransaction();
250-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
251-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
252-
* await db.commitTransaction();
253-
* ```
254-
*/
255-
async commitTransaction(): Promise<void> {
256-
await invoke('plugin:sqlite|commit_transaction', {
257-
db: this.path
258-
})
259-
}
260-
261-
/**
262-
* **rollbackTransaction**
263-
*
264-
* Rolls back the current transaction, discarding all changes made since
265-
* `beginTransaction()` was called.
226+
* @param statements - Array of [query, values?] tuples to execute
227+
* @returns Promise that resolves when all statements complete successfully
228+
* @throws SqliteError if any statement fails (after rollback)
266229
*
267230
* @example
268231
* ```ts
269-
* await db.beginTransaction();
270-
* try {
271-
* await db.execute('INSERT INTO users (name) VALUES ($1)', ['Alice']);
272-
* await db.execute('INSERT INTO logs (action) VALUES ($1)', ['user_created']);
273-
* await db.commitTransaction();
274-
* } catch (error) {
275-
* await db.rollbackTransaction();
276-
* throw error;
277-
* }
232+
* // Execute multiple inserts atomically
233+
* await db.executeTransaction([
234+
* ['INSERT INTO users (name, email) VALUES ($1, $2)', ['Alice', 'alice@example.com']],
235+
* ['INSERT INTO audit_log (action, user) VALUES ($1, $2)', ['user_created', 'Alice']]
236+
* ]);
237+
*
238+
* // Mixed operations
239+
* await db.executeTransaction([
240+
* ['UPDATE accounts SET balance = balance - $1 WHERE id = $2', [100, 1]],
241+
* ['UPDATE accounts SET balance = balance + $1 WHERE id = $2', [100, 2]],
242+
* ['INSERT INTO transfers (from_id, to_id, amount) VALUES ($1, $2, $3)', [1, 2, 100]]
243+
* ]);
278244
* ```
279245
*/
280-
async rollbackTransaction(): Promise<void> {
281-
await invoke('plugin:sqlite|rollback_transaction', {
282-
db: this.path
246+
async executeTransaction(statements: Array<[string, SqlValue[]?]>): Promise<void> {
247+
await invoke('plugin:sqlite|execute_transaction', {
248+
db: this.path,
249+
statements: statements.map(([query, values]) => ({
250+
query,
251+
values: values ?? []
252+
}))
283253
})
284254
}
285255

src/decode.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use serde_json::Value as JsonValue;
2+
use sqlx::sqlite::SqliteValueRef;
3+
use sqlx::{TypeInfo, Value, ValueRef};
4+
use time::PrimitiveDateTime;
5+
6+
use crate::Error;
7+
8+
/// Convert a SQLite value to a JSON value.
9+
///
10+
/// This function handles the type conversion from SQLite's native types
11+
/// to JSON-compatible representations.
12+
pub fn to_json(value: SqliteValueRef) -> Result<JsonValue, Error> {
13+
if value.is_null() {
14+
return Ok(JsonValue::Null);
15+
}
16+
17+
let column_type = value.type_info();
18+
19+
// Handle types based on SQLite's type affinity
20+
let result = match column_type.name() {
21+
"TEXT" => {
22+
if let Ok(v) = value.to_owned().try_decode::<String>() {
23+
JsonValue::String(v)
24+
} else {
25+
JsonValue::Null
26+
}
27+
}
28+
29+
"REAL" => {
30+
if let Ok(v) = value.to_owned().try_decode::<f64>() {
31+
JsonValue::from(v)
32+
} else {
33+
JsonValue::Null
34+
}
35+
}
36+
37+
"INTEGER" | "NUMERIC" => {
38+
if let Ok(v) = value.to_owned().try_decode::<i64>() {
39+
JsonValue::Number(v.into())
40+
} else {
41+
JsonValue::Null
42+
}
43+
}
44+
45+
"BOOLEAN" => {
46+
if let Ok(v) = value.to_owned().try_decode::<bool>() {
47+
JsonValue::Bool(v)
48+
} else {
49+
JsonValue::Null
50+
}
51+
}
52+
53+
"DATE" => {
54+
// SQLite stores dates as TEXT in ISO 8601 format
55+
if let Ok(v) = value.to_owned().try_decode::<String>() {
56+
JsonValue::String(v)
57+
} else {
58+
JsonValue::Null
59+
}
60+
}
61+
62+
"TIME" => {
63+
// SQLite stores time as TEXT in HH:MM:SS format
64+
if let Ok(v) = value.to_owned().try_decode::<String>() {
65+
JsonValue::String(v)
66+
} else {
67+
JsonValue::Null
68+
}
69+
}
70+
71+
"DATETIME" => {
72+
// Try to decode as PrimitiveDateTime
73+
if let Ok(dt) = value.to_owned().try_decode::<PrimitiveDateTime>() {
74+
JsonValue::String(dt.to_string())
75+
} else if let Ok(v) = value.to_owned().try_decode::<String>() {
76+
// Fall back to string representation
77+
JsonValue::String(v)
78+
} else {
79+
JsonValue::Null
80+
}
81+
}
82+
83+
"BLOB" => {
84+
if let Ok(blob) = value.to_owned().try_decode::<Vec<u8>>() {
85+
// Encode binary data as base64 for JSON serialization
86+
JsonValue::String(base64_encode(&blob))
87+
} else {
88+
JsonValue::Null
89+
}
90+
}
91+
92+
"NULL" => JsonValue::Null,
93+
94+
_ => {
95+
// For unknown types, try to decode as text
96+
if let Ok(text) = value.to_owned().try_decode::<String>() {
97+
JsonValue::String(text)
98+
} else {
99+
return Err(Error::UnsupportedDatatype(format!(
100+
"Unknown SQLite type: {}",
101+
column_type.name()
102+
)));
103+
}
104+
}
105+
};
106+
107+
Ok(result)
108+
}
109+
110+
/// Base64 encode binary data for JSON serialization.
111+
///
112+
/// SQLite BLOB columns are encoded as base64 strings when serialized to JSON,
113+
/// as JSON does not have a native binary type.
114+
fn base64_encode(data: &[u8]) -> String {
115+
use std::io::Write;
116+
117+
let mut buf = Vec::new();
118+
{
119+
let mut encoder =
120+
base64::write::EncoderWriter::new(&mut buf, &base64::engine::general_purpose::STANDARD);
121+
encoder.write_all(data).unwrap();
122+
}
123+
String::from_utf8(buf).unwrap()
124+
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use super::*;
129+
130+
#[test]
131+
fn test_base64_encode() {
132+
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
133+
assert_eq!(base64_encode(&[1, 2, 3, 4, 5]), "AQIDBAU=");
134+
assert_eq!(base64_encode(&[]), "");
135+
}
136+
137+
#[test]
138+
fn test_base64_encode_binary() {
139+
// Test with binary data including null bytes
140+
assert_eq!(base64_encode(&[0, 0, 0]), "AAAA");
141+
assert_eq!(base64_encode(&[255, 255, 255]), "////");
142+
}
143+
144+
#[test]
145+
fn test_base64_encode_large() {
146+
// Test with larger binary data
147+
let data: Vec<u8> = (0..255).collect();
148+
let encoded = base64_encode(&data);
149+
assert!(!encoded.is_empty());
150+
// Verify it's valid base64 (only contains valid chars)
151+
assert!(
152+
encoded
153+
.chars()
154+
.all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
155+
);
156+
}
157+
}

0 commit comments

Comments
 (0)