Files
lux/docs/guide/11-databases.md
Brandon Lucas 87c1fb1bbd feat: add PostgreSQL driver with Postgres effect
Implements full PostgreSQL support through the Postgres effect:
- connect(connStr): Connect to PostgreSQL database
- close(conn): Close connection
- execute(conn, sql): Execute INSERT/UPDATE/DELETE, return affected rows
- query(conn, sql): Execute SELECT, return all rows as records
- queryOne(conn, sql): Execute SELECT, return first row as Option
- beginTx(conn): Start transaction
- commit(conn): Commit transaction
- rollback(conn): Rollback transaction

Includes:
- Connection tracking with connection IDs
- Row mapping to Lux records with field access
- Transaction support
- Example: examples/postgres_demo.lux
- Documentation in docs/guide/11-databases.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 04:30:44 -05:00

451 lines
11 KiB
Markdown

# Working with Databases
Lux includes built-in support for SQL databases through the `Sql` effect. This guide covers how to connect to databases, execute queries, handle transactions, and best practices for database operations.
## Quick Start
```lux
fn main(): Unit with {Console, Sql} = {
// Open an in-memory SQLite database
let db = Sql.openMemory()
// Create a table
Sql.execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
// Insert data
Sql.execute(db, "INSERT INTO users (name) VALUES ('Alice')")
Sql.execute(db, "INSERT INTO users (name) VALUES ('Bob')")
// Query data
let users = Sql.query(db, "SELECT * FROM users")
Console.print("Found users: " ++ toString(users))
// Clean up
Sql.close(db)
}
run main() with {}
```
## Connecting to Databases
### In-Memory Database
For testing and temporary data:
```lux
let db = Sql.openMemory()
// Database exists only in memory, lost when closed
```
### File-Based Database
For persistent storage:
```lux
let db = Sql.open("./data/app.db")
// Creates file if it doesn't exist
```
### Connection Lifecycle
Always close connections when done:
```lux
fn withDatabase<T>(path: String, f: fn(SqlConn): T with Sql): T with Sql = {
let db = Sql.open(path)
let result = f(db)
Sql.close(db)
result
}
```
## Executing Queries
### Non-Returning Queries (INSERT, UPDATE, DELETE, CREATE)
Use `Sql.execute` for statements that don't return rows:
```lux
// Create table
Sql.execute(db, "CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)")
// Insert
Sql.execute(db, "INSERT INTO posts (title, content) VALUES ('Hello', 'World')")
// Update
Sql.execute(db, "UPDATE posts SET title = 'Updated' WHERE id = 1")
// Delete
Sql.execute(db, "DELETE FROM posts WHERE id = 1")
```
### Queries that Return Rows (SELECT)
Use `Sql.query` to get all matching rows:
```lux
// Returns List<SqlRow>
let users = Sql.query(db, "SELECT * FROM users")
// Each row is a record-like structure
for user in users {
Console.print("User: " ++ user.name)
}
```
Use `Sql.queryOne` to get a single row (or None):
```lux
// Returns Option<SqlRow>
let maybeUser = Sql.queryOne(db, "SELECT * FROM users WHERE id = 1")
match maybeUser {
Some(user) => Console.print("Found: " ++ user.name),
None => Console.print("User not found")
}
```
## Transactions
Transactions ensure multiple operations succeed or fail together.
### Basic Transaction
```lux
// Start transaction
Sql.beginTx(db)
// Do operations
Sql.execute(db, "INSERT INTO accounts (name, balance) VALUES ('Alice', 1000)")
Sql.execute(db, "INSERT INTO accounts (name, balance) VALUES ('Bob', 1000)")
// Commit changes
Sql.commit(db)
```
### Rollback on Error
```lux
Sql.beginTx(db)
let result = transferFunds(db, fromId, toId, amount)
match result {
Ok(_) => Sql.commit(db),
Err(_) => Sql.rollback(db) // Undo all changes
}
```
### Transaction Helper
Here's a pattern for safe transactions:
```lux
fn transaction<T>(db: SqlConn, f: fn(): T with Sql): Result<T, String> with Sql = {
Sql.beginTx(db)
// Execute the function
// In a real implementation, you'd catch errors here
let result = f()
Sql.commit(db)
Ok(result)
}
```
## Working with Results
Query results are returned as `List<SqlRow>` where each row behaves like a record:
```lux
let rows = Sql.query(db, "SELECT id, name, age FROM users")
for row in rows {
// Access columns by name
let id = row.id // Int
let name = row.name // String
let age = row.age // Int or null
Console.print("{name} (age {age})")
}
```
### Handling NULL values
NULL values from the database are represented as options:
```lux
let row = Sql.queryOne(db, "SELECT email FROM users WHERE id = 1")
match row {
Some(r) => {
match r.email {
Some(email) => Console.print("Email: " ++ email),
None => Console.print("No email set")
}
},
None => Console.print("User not found")
}
```
## SQL Injection Prevention
**IMPORTANT**: The current API passes SQL strings directly. For production use, always:
1. Validate and sanitize user input
2. Use parameterized queries (when available)
3. Never concatenate user input into SQL strings
```lux
// DANGEROUS - Never do this!
let query = "SELECT * FROM users WHERE name = '" ++ userInput ++ "'"
// SAFER - Validate input first
fn safeUserLookup(db: SqlConn, userId: Int): Option<SqlRow> with Sql = {
// Integers are safe to interpolate
Sql.queryOne(db, "SELECT * FROM users WHERE id = " ++ toString(userId))
}
// For strings, escape quotes at minimum
fn escapeString(s: String): String = {
String.replace(s, "'", "''")
}
```
## Common Patterns
### Repository Pattern
Encapsulate database operations:
```lux
type User = { id: Int, name: String, email: Option<String> }
fn createUser(db: SqlConn, name: String): Int with Sql = {
Sql.execute(db, "INSERT INTO users (name) VALUES ('" ++ escapeString(name) ++ "')")
// Get last inserted ID
let row = Sql.queryOne(db, "SELECT last_insert_rowid() as id")
match row {
Some(r) => r.id,
None => -1
}
}
fn findUserById(db: SqlConn, id: Int): Option<User> with Sql = {
let row = Sql.queryOne(db, "SELECT * FROM users WHERE id = " ++ toString(id))
match row {
Some(r) => Some({ id: r.id, name: r.name, email: r.email }),
None => None
}
}
fn findAllUsers(db: SqlConn): List<User> with Sql = {
let rows = Sql.query(db, "SELECT * FROM users ORDER BY name")
List.map(rows, fn(r) => { id: r.id, name: r.name, email: r.email })
}
```
### Testing with In-Memory Database
```lux
fn testUserRepository(): Unit with {Test, Sql} = {
// Each test gets a fresh database
let db = Sql.openMemory()
// Set up schema
Sql.execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
// Test
let id = createUser(db, "Test User")
Test.assertTrue(id > 0, "Should return valid ID")
let user = findUserById(db, id)
Test.assertTrue(Option.isSome(user), "Should find created user")
Sql.close(db)
}
```
### Handler for Testing (Mock Database)
The effect system lets you swap database implementations:
```lux
// Production handler uses real SQLite
handler realDatabase(): Sql {
// Uses actual rusqlite implementation
}
// Test handler uses in-memory storage
handler mockDatabase(): Sql {
let storage = ref []
fn execute(conn, sql) = {
// Parse and simulate SQL
}
fn query(conn, sql) = {
// Return mock data
[]
}
}
// Run with mock database in tests
run userService() with {
Sql -> mockDatabase()
}
```
## API Reference
### Types
```lux
type SqlConn // Opaque connection handle
type SqlRow // Row result with named column access
```
### Operations
| Function | Type | Description |
|----------|------|-------------|
| `Sql.open(path)` | `String -> SqlConn` | Open database file |
| `Sql.openMemory()` | `() -> SqlConn` | Open in-memory database |
| `Sql.close(conn)` | `SqlConn -> Unit` | Close connection |
| `Sql.execute(conn, sql)` | `(SqlConn, String) -> Int` | Execute statement, return affected rows |
| `Sql.query(conn, sql)` | `(SqlConn, String) -> List<SqlRow>` | Query, return all rows |
| `Sql.queryOne(conn, sql)` | `(SqlConn, String) -> Option<SqlRow>` | Query, return first row |
| `Sql.beginTx(conn)` | `SqlConn -> Unit` | Begin transaction |
| `Sql.commit(conn)` | `SqlConn -> Unit` | Commit transaction |
| `Sql.rollback(conn)` | `SqlConn -> Unit` | Rollback transaction |
## Error Handling
Database operations can fail. In the current implementation, errors cause runtime failures. Future versions will support returning `Result` types:
```lux
// Future API (not yet implemented)
fn safeQuery(db: SqlConn, sql: String): Result<List<SqlRow>, SqlError> with Sql = {
Sql.tryQuery(db, sql)
}
```
## Performance Tips
1. **Batch inserts in transactions** - Much faster than individual inserts:
```lux
Sql.beginTx(db)
for item in items {
Sql.execute(db, "INSERT INTO data (value) VALUES (" ++ toString(item) ++ ")")
}
Sql.commit(db)
```
2. **Use `queryOne` for single results** - More efficient than `query` when you only need one row
3. **Create indexes** for frequently queried columns:
```lux
Sql.execute(db, "CREATE INDEX idx_users_email ON users(email)")
```
4. **Close connections** when done to free resources
## PostgreSQL Support
Lux also provides native PostgreSQL support through the `Postgres` effect. This is ideal for production applications requiring a full-featured relational database.
### Connecting to PostgreSQL
```lux
fn main(): Unit with {Console, Postgres} = {
// Connect using a connection string
let connStr = "host=localhost user=myuser password=mypass dbname=mydb"
let conn = Postgres.connect(connStr)
Console.print("Connected to PostgreSQL!")
// ... use the connection ...
Postgres.close(conn)
}
```
### PostgreSQL Operations
The PostgreSQL API mirrors the SQLite API:
```lux
// Execute non-returning queries
Postgres.execute(conn, "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)")
Postgres.execute(conn, "INSERT INTO users (name) VALUES ('Alice')")
// Query multiple rows
let users = Postgres.query(conn, "SELECT * FROM users")
for user in users {
Console.print("User: " ++ user.name)
}
// Query single row
match Postgres.queryOne(conn, "SELECT * FROM users WHERE id = 1") {
Some(user) => Console.print("Found: " ++ user.name),
None => Console.print("Not found")
}
```
### PostgreSQL Transactions
```lux
// Start transaction
Postgres.beginTx(conn)
// Make changes
Postgres.execute(conn, "UPDATE accounts SET balance = balance - 100 WHERE id = 1")
Postgres.execute(conn, "UPDATE accounts SET balance = balance + 100 WHERE id = 2")
// Commit or rollback
Postgres.commit(conn)
// Or: Postgres.rollback(conn)
```
### PostgreSQL API Reference
| Function | Type | Description |
|----------|------|-------------|
| `Postgres.connect(connStr)` | `String -> Int` | Connect to PostgreSQL, returns connection ID |
| `Postgres.close(conn)` | `Int -> Unit` | Close connection |
| `Postgres.execute(conn, sql)` | `(Int, String) -> Int` | Execute statement, return affected rows |
| `Postgres.query(conn, sql)` | `(Int, String) -> List<Row>` | Query, return all rows |
| `Postgres.queryOne(conn, sql)` | `(Int, String) -> Option<Row>` | Query, return first row |
| `Postgres.beginTx(conn)` | `Int -> Unit` | Begin transaction |
| `Postgres.commit(conn)` | `Int -> Unit` | Commit transaction |
| `Postgres.rollback(conn)` | `Int -> Unit` | Rollback transaction |
### When to Use SQLite vs PostgreSQL
| Feature | SQLite | PostgreSQL |
|---------|--------|------------|
| Setup | No setup needed | Requires server |
| Deployment | Single file | Server process |
| Concurrency | Limited | Excellent |
| Scale | Small-medium | Large |
| Features | Basic | Full RDBMS |
| Use case | Embedded, testing | Production |
## Limitations
- No connection pooling yet
- No prepared statements / parameterized queries yet
- Limited type mapping (basic Int, String, Float)
## See Also
- [Effects Guide](./05-effects.md) - Understanding the effect system
- [Testing Guide](../testing.md) - Writing tests with mock handlers
- [Roadmap](../ROADMAP.md) - Planned database features