Skip to content

Commit 726d4cd

Browse files
committed
feat: support attached databases
- This PR plumbs the feature through (TS -> commands -> wrapper) - Introducing a new builder pattern to chain the attach() fn - The attach() fn can be chained with any query fn - This prevents introducing optional attached db params everywhere - Unfortunately we never set up eslint in this repo so this PR adds that - This has resulted in a lot of changes to the TS files - However this is a good time to do it because the builder pattern meant significant changes to the TS anyway - Also doubled back to add tests for the new attached.rs API which was added in the last PR
1 parent dabf445 commit 726d4cd

21 files changed

Lines changed: 5254 additions & 567 deletions

README.md

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,67 @@ To rollback instead of committing:
289289
await tx.rollback()
290290
```
291291

292+
### Cross-Database Queries
293+
294+
Attach other SQLite databases to run queries across multiple database files.
295+
Each attached database gets a schema name that acts as a namespace for its
296+
tables.
297+
298+
**Builder Pattern:** All query methods (`execute`, `executeTransaction`,
299+
`fetchAll`, `fetchOne`) return builders that support `.attach()` for
300+
cross-database operations.
301+
302+
```typescript
303+
// Join data from multiple databases
304+
const results = await db.fetchAll(
305+
'SELECT u.name, o.total FROM users u JOIN orders.orders o ON u.id = o.user_id',
306+
[]
307+
).attach([
308+
{
309+
databasePath: 'orders.db',
310+
schemaName: 'orders',
311+
mode: 'readOnly'
312+
}
313+
])
314+
315+
// Update main database using data from attached database
316+
await db.execute(
317+
'UPDATE todos SET status = $1 WHERE id IN (SELECT todo_id FROM archive.completed)',
318+
['archived']
319+
).attach([
320+
{
321+
databasePath: 'archive.db',
322+
schemaName: 'archive',
323+
mode: 'readOnly'
324+
}
325+
])
326+
327+
// Atomic writes across multiple databases
328+
await db.executeTransaction([
329+
['INSERT INTO main.orders (user_id, total) VALUES ($1, $2)', [userId, total]],
330+
['UPDATE stats.order_count SET count = count + 1', []]
331+
]).attach([
332+
{
333+
databasePath: 'stats.db',
334+
schemaName: 'stats',
335+
mode: 'readWrite'
336+
}
337+
])
338+
```
339+
340+
**Attached Database Modes:**
341+
342+
* `readOnly` - Read-only access (can be used with read or write operations)
343+
* `readWrite` - Read-write access (requires write operation, holds write
344+
lock)
345+
346+
**Important:**
347+
348+
* Attached database(s) automatically detached after query completion
349+
* Read-write attachments acquire write locks on all involved databases
350+
* Attachments are connection-scoped and don't persist across queries
351+
* Main database is always accessible without a schema prefix
352+
292353
### Error Handling
293354

294355
```typescript
@@ -315,9 +376,9 @@ Common error codes:
315376
### Closing and Removing
316377

317378
```typescript
318-
await db.close() // Close this connection
319-
await Database.closeAll() // Close all connections
320-
await db.remove() // Close and DELETE database file(s) - irreversible!
379+
await db.close() // Close this connection
380+
await Database.close_all() // Close all connections
381+
await db.remove() // Close and DELETE database file(s) - irreversible!
321382
```
322383

323384
## API Reference
@@ -328,7 +389,7 @@ await db.remove() // Close and DELETE database file(s) - irreversible!
328389
| ------ | ----------- |
329390
| `Database.load(path, config?)` | Connect and return Database instance (or existing) |
330391
| `Database.get(path)` | Get instance without connecting (lazy init) |
331-
| `Database.closeAll()` | Close all database connections |
392+
| `Database.close_all()` | Close all database connections |
332393

333394
### Instance Methods
334395

@@ -342,6 +403,16 @@ await db.remove() // Close and DELETE database file(s) - irreversible!
342403
| `close()` | Close connection, returns `true` if was loaded |
343404
| `remove()` | Close and delete database file(s), returns `true` if was loaded |
344405

406+
### Builder Methods
407+
408+
All query methods (`execute`, `executeTransaction`, `fetchAll`, `fetchOne`)
409+
return builders that are directly awaitable and support method chaining:
410+
411+
| Method | Description |
412+
| ------ | ----------- |
413+
| `attach(specs)` | Attach databases for cross-database queries, returns `this` |
414+
| `await builder` | Execute the query (builders implement `PromiseLike`) |
415+
345416
### InterruptibleTransaction Methods
346417

347418
| Method | Description |
@@ -364,6 +435,12 @@ interface CustomConfig {
364435
idleTimeoutSecs?: number // default: 30
365436
}
366437

438+
interface AttachedDatabaseSpec {
439+
databasePath: string // Path relative to app config directory
440+
schemaName: string // Schema name for accessing tables (e.g., 'orders')
441+
mode: 'readOnly' | 'readWrite'
442+
}
443+
367444
interface SqliteError {
368445
code: string
369446
message: string

api-iife.js

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

crates/sqlx-sqlite-conn-mgr/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ times is safe (already-applied migrations are skipped).
9494
9595
### Attached Databases
9696

97-
Attach other SQLite databases to enable cross-database queries. Attachments are
97+
Attach other SQLite databases to enable cross-database queries. Attached databases are
9898
connection-scoped and automatically detached when the guard is dropped.
9999

100100
```rust
@@ -175,8 +175,8 @@ returned to pool on drop.
175175

176176
| Function | Description |
177177
| -------- | ----------- |
178-
| `acquire_reader_with_attached(db, specs)` | Acquire read connection with attached databases |
179-
| `acquire_writer_with_attached(db, specs)` | Acquire writer connection with attached databases |
178+
| `acquire_reader_with_attached(db, specs)` | Acquire read connection with attached database(s) |
179+
| `acquire_writer_with_attached(db, specs)` | Acquire writer connection with attached database(s) |
180180

181181
Returns `AttachedConnection` or `AttachedWriteGuard` respectively. Both guards
182182
deref to `SqliteConnection` and automatically detach databases on drop.

crates/sqlx-sqlite-conn-mgr/src/attached.rs

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ pub enum AttachedMode {
3030
ReadWrite,
3131
}
3232

33-
/// Guard holding a read connection with attached databases
33+
/// Guard holding a read connection with attached database(s)
3434
///
35-
/// **Important**: Call `detach_all()` before dropping to properly clean up attached databases.
35+
/// **Important**: Call `detach_all()` before dropping to properly clean up attached database(s).
3636
/// Without explicit cleanup, attached databases persist on the pooled connection until
3737
/// it's eventually closed. Derefs to `SqliteConnection` for executing queries.
3838
#[must_use = "if unused, the attached connection and locks are immediately dropped"]
@@ -100,7 +100,7 @@ impl Drop for AttachedReadConnection {
100100
}
101101
}
102102

103-
/// Guard holding a write connection with attached databases
103+
/// Guard holding a write connection with attached database(s)
104104
///
105105
/// **Important**: Call `detach_all()` before dropping to properly clean up attached databases.
106106
/// Without explicit cleanup, attached databases persist on the pooled connection until
@@ -189,7 +189,7 @@ fn is_valid_schema_name(name: &str) -> bool {
189189
&& !name.chars().next().unwrap().is_ascii_digit()
190190
}
191191

192-
/// Acquire a read connection with attached databases
192+
/// Acquire a read connection with attached database(s)
193193
///
194194
/// This function:
195195
/// 1. Acquires a read connection from the main database's read pool
@@ -231,7 +231,7 @@ pub async fn acquire_reader_with_attached(
231231
for spec in &specs {
232232
let path = spec.database.path_str();
233233
if !seen_paths.insert(path.clone()) {
234-
return Err(Error::DuplicateDatabaseAttachment(path));
234+
return Err(Error::DuplicateAttachedDatabase(path));
235235
}
236236
}
237237

@@ -261,7 +261,7 @@ pub async fn acquire_reader_with_attached(
261261
Ok(AttachedReadConnection::new(conn, Vec::new(), schema_names))
262262
}
263263

264-
/// Acquire a write connection with attached databases
264+
/// Acquire a write connection with attached database(s)
265265
///
266266
/// This function:
267267
/// 1. Acquires the write connection from the main database
@@ -320,7 +320,7 @@ pub async fn acquire_writer_with_attached(
320320
let mut seen_paths = HashSet::new();
321321
for (path, _) in &db_entries {
322322
if !seen_paths.insert(path.as_str()) {
323-
return Err(Error::DuplicateDatabaseAttachment(path.clone()));
323+
return Err(Error::DuplicateAttachedDatabase(path.clone()));
324324
}
325325
}
326326

@@ -517,19 +517,21 @@ mod tests {
517517
.fetch_one(&mut *conn)
518518
.await
519519
.unwrap();
520+
520521
let value1: String = row1.get(0);
521522
assert_eq!(value1, "test_data");
522523

523524
let row2 = sqlx::query("SELECT value FROM db2.db2 LIMIT 1")
524525
.fetch_one(&mut *conn)
525526
.await
526527
.unwrap();
528+
527529
let value2: String = row2.get(0);
528530
assert_eq!(value2, "test_data");
529531
}
530532

531533
#[tokio::test]
532-
async fn test_readwrite_attachment_holds_writer_lock() {
534+
async fn test_attached_readwrite_holds_writer_lock() {
533535
let temp_dir = TempDir::new().unwrap();
534536
let main_db = create_test_db("main.db", &temp_dir).await;
535537
let other_db = create_test_db("other.db", &temp_dir).await;
@@ -786,8 +788,8 @@ mod tests {
786788

787789
let result = acquire_writer_with_attached(&main_db, specs).await;
788790
assert!(
789-
matches!(result, Err(Error::DuplicateDatabaseAttachment(_))),
790-
"Should reject duplicate database attachment"
791+
matches!(result, Err(Error::DuplicateAttachedDatabase(_))),
792+
"Should reject duplicate attached database"
791793
);
792794
}
793795

@@ -805,7 +807,7 @@ mod tests {
805807

806808
let result = acquire_writer_with_attached(&main_db, specs).await;
807809
assert!(
808-
matches!(result, Err(Error::DuplicateDatabaseAttachment(_))),
810+
matches!(result, Err(Error::DuplicateAttachedDatabase(_))),
809811
"Should reject attaching main database to itself"
810812
);
811813
}

crates/sqlx-sqlite-conn-mgr/src/error.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub enum Error {
3333
InvalidSchemaName(String),
3434

3535
/// Attempted to attach the same database multiple times
36-
#[error("Database '{0}' appears multiple times in attachment list (would cause deadlock)")]
37-
DuplicateDatabaseAttachment(String),
36+
#[error(
37+
"Database '{0}' appears multiple times in attached database list (would cause deadlock)"
38+
)]
39+
DuplicateAttachedDatabase(String),
3840
}

0 commit comments

Comments
 (0)