Database
Parsley has built-in database support with dedicated connection literals, query operators, and a parameterized <SQL> tag. You connect to a database, run queries with arrow-style operators, and get dictionaries and tables back — no ORM, no driver imports.
Connections
Inline Connections
Create a connection by calling a driver literal with a DSN string:
let db = @sqlite("./myapp.sqlite") // SQLite file
let db = @sqlite(":memory:") // SQLite in-memory
let db = @postgres("postgres://user:pass@localhost/mydb")
let db = @mysql("user:pass@tcp(localhost:3306)/mydb")
Each driver takes an optional second argument — an options dictionary:
let db = @sqlite("./myapp.sqlite", {
maxOpenConns: 10,
maxIdleConns: 5
})
| Option | Type | Description |
|---|---|---|
maxOpenConns |
integer | Maximum open connections |
maxIdleConns |
integer | Maximum idle connections |
Connections are cached by DSN. Calling @sqlite("./myapp.sqlite") twice returns the same connection.
Managed Connections (@DB)
Inside a Basil server handler, @DB returns the server's configured database connection:
let user = @DB <=?=> <GetUser id={params.id} />
@DB is only available in server context. Using it in a standalone script produces a state error.
⚠️ Managed connections cannot be closed by Parsley code. Calling
.close()on a managed connection raisesDB-0009.
Query Operators
Three operators handle all SQL execution. The left side is always a connection; the right side is a query string or <SQL> tag.
| Operator | Mnemonic | Returns | Use for |
|---|---|---|---|
<=?=> |
query-one | dictionary or null |
SELECT expecting 0–1 rows |
<=??=> |
query-many | Table | SELECT expecting multiple rows |
<=!=> |
execute | {affected, lastId} |
INSERT, UPDATE, DELETE, DDL |
Query One (<=?=>)
Returns a dictionary for the first matching row, or null if no rows match:
let db = @sqlite(":memory:")
let _ = db <=!=> "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
let _ = db <=!=> "INSERT INTO users (name) VALUES ('Alice')"
let name = "Alice"
let user = db <=?=> <SQL name={name}>SELECT * FROM users WHERE name = :name</SQL>
user.name // "Alice"
let nobody = db <=?=> <SQL name="Nobody">SELECT * FROM users WHERE name = :name</SQL>
nobody // null
Query Many (<=??=>)
Returns a Table (with column metadata), one row per entry:
let _ = db <=!=> "INSERT INTO users (name) VALUES ('Bob')"
let users = db <=??=> <SQL>SELECT * FROM users</SQL>
users.count() // 2
users.columns // ["id", "name"]
for (u in users.rows) {
u.name
}
The result is a
Table, not an array. Use.count()for the row count,.columnsfor column names, and.rowsto iterate. Table methods like.where(),.orderBy(), and.toCSV()work directly on the result.
Execute (<=!=>)
Returns a dictionary with affected (rows changed) and lastId (last inserted row ID):
let result = db <=!=> <SQL name="Carol">INSERT INTO users (name) VALUES (:name)</SQL>
result.affected // 1
result.lastId // 3
Use execute for any statement that modifies data or schema — CREATE, INSERT, UPDATE, DELETE, DROP.
The <SQL> Tag
For parameterized queries, use the <SQL> tag. It produces a dictionary with sql and params keys that the query operators understand.
let name = "Alice"
let query = <SQL name={name}>
SELECT * FROM users WHERE name = :name
</SQL>
let user = db <=?=> query
The content of a <SQL> tag is raw text — no quotes needed around the SQL. This works like <style> and <script> tags, where the tag boundaries define the content. Leading and trailing whitespace is automatically trimmed from SQL content.
Named Parameters (Recommended)
Use :name placeholders in your SQL to reference attributes by name. The attribute order on the tag doesn't matter — parameters are matched by name, not position:
// ✅ Attribute order doesn't matter — :name matches by name
<SQL name={name} age={age}>
INSERT INTO users (age, name) VALUES (:age, :name)
</SQL>
Named parameters are rewritten to driver-native placeholders at execution time ($1, $2 for PostgreSQL/SQLite; ? for MySQL). You don't need to think about driver differences.
A :name can appear multiple times in the same query — the value is bound at each position:
// ✅ :term is bound twice automatically
<SQL term={searchTerm}>
SELECT * FROM users WHERE name LIKE :term OR email LIKE :term
</SQL>
Attributes not referenced by any :name in the SQL are silently ignored. This keeps <SQL> composable — a component can accept extra props that aren't needed for every query variant.
Edge Cases
Parsley correctly handles common SQL syntax that uses : for other purposes:
- Postgres type casts (
::) are not treated as parameters:SELECT id::text FROM users WHERE id = :idonly matches:id. - String literals containing
:nameare not treated as parameters:WHERE status = ':active'has no parameters. - Dollar-quoted strings (
$$...$$) and SQL comments (--,/* */) are also skipped.
Positional Parameters
Bare ? placeholders still work for backward compatibility. With ?, attributes are bound in declaration order (left-to-right):
// Attribute order must match ? placeholder order
<SQL name={name} age={age}>
INSERT INTO users (name, age) VALUES (?, ?)
</SQL>
⚠️ Don't mix
?and:namein the same query. Mixing positional and named placeholders produces an error (SQL-0006).
⚠️ Interpolation is blocked inside
<SQL>tags. Unlike<style>and<script>, you cannot use@{expr}inside SQL content. All dynamic values must come through attributes. This is intentional — it enforces safe parameterized queries and prevents SQL injection.
// ❌ ERROR — interpolation not allowed
<SQL>SELECT * FROM users WHERE name = '@{name}'</SQL>
// ✅ SAFE — use attributes for parameters
<SQL name={name}>SELECT * FROM users WHERE name = :name</SQL>
SQL Components
Wrap <SQL> in a component function for reusable queries:
let GetUser = fn(props) {
<SQL id={props.id}>
SELECT * FROM users WHERE id = :id
</SQL>
}
let user = db <=?=> <GetUser id={42} />
let InsertUser = fn(props) {
<SQL name={props.name}>
INSERT INTO users (name) VALUES (:name)
</SQL>
}
let result = db <=!=> <InsertUser name="Carol" />
Multi-line queries work naturally:
let GetActiveUsers = fn(props) {
<SQL status={props.status} limit={props.limit}>
SELECT id, name, email
FROM users
WHERE status = :status
ORDER BY created_at DESC
LIMIT :limit
</SQL>
}
SQL comments are preserved:
<SQL>
-- Get all admin users
SELECT * FROM users WHERE role = 'admin'
</SQL>
You can also pass a plain string directly — useful for DDL or simple queries where parameterization isn't needed:
let _ = db <=!=> "DROP TABLE IF EXISTS temp"
Connection Methods
| Method | Args | Returns | Description |
|---|---|---|---|
.ping() |
none | boolean | Test if connection is alive |
.close() |
none | null | Close the connection (not allowed on managed connections) |
.begin() |
none | boolean | Begin a manual transaction |
.commit() |
none | boolean | Commit a manual transaction |
.rollback() |
none | boolean | Roll back a manual transaction |
.createTable(schema) |
schema, name? | boolean | Create table from schema if not exists |
.bind(schema, name) |
schema, name, opts? | TableBinding | Bind a schema to a table |
.lastInsertId() |
none | integer | Last inserted row ID (SQLite only) |
Manual Transactions
Use .begin(), .commit(), and .rollback() for explicit transaction control:
let _ = db.begin()
let _ = db <=!=> <InsertUser name="Alice" />
let _ = db <=!=> <InsertUser name="Bob" />
db.commit()
Calling .commit() or .rollback() without a prior .begin() raises DB-0006. Calling .begin() when already in a transaction raises DB-0007.
@transaction Blocks
For Query DSL operations, @transaction provides automatic commit/rollback:
@transaction {
@insert(Users |< name: "Alice" .)
@insert(Users |< name: "Bob" .)
}
@transaction commits on success and rolls back if any statement produces an error. It returns the value of the last statement:
let newUser = @transaction {
let order = @insert(Orders |< status: "pending" ?-> *)
order
}
⚠️ Nested transactions are not supported.
@transactionfinds the database connection by inspecting the DSL operations inside the block — at least one database operation must be present.
Table Bindings
A TableBinding connects a schema to a database table, providing high-level CRUD methods. Create one with db.bind():
@schema User {
id: id(auto)
name: string(required)
email: email
}
let db = @sqlite(":memory:")
db.createTable(User, "users")
let users = db.bind(User, "users")
db.createTable(schema, name) generates a CREATE TABLE IF NOT EXISTS statement from the schema. The second argument is the SQL table name.
Binding Options
db.bind() accepts an optional third argument for configuration:
let users = db.bind(User, "users", {soft_delete: "deleted_at"})
| Option | Type | Description |
|---|---|---|
soft_delete |
string | Column name for soft-delete timestamps |
Query Methods
| Method | Returns | Description |
|---|---|---|
.all() |
Table | All rows |
.where(cond) |
TableBinding | Filter (chainable) |
.find(id) |
Record or null | Find by primary key |
.first() |
Record or null | First matching row |
let allUsers = users.all()
let active = users.where({status: "active"}).all()
let user = users.find("abc-123")
Mutation Methods
| Method | Single (Record) | Bulk (Table) |
|---|---|---|
.insert(data) |
Record (with generated ID) | {inserted: N} |
.update(data) |
Record | {updated: N} |
.save(data) |
Record (upsert) | {inserted: N, updated: M} |
.delete(data) |
{deleted: 1} |
{deleted: N} |
// Insert
let user = User({name: "Alice", email: "alice@example.com"})
let inserted = users.insert(user)
inserted.id // generated ID
// Update (requires id)
users.update(inserted.update({name: "Alice Smith"}))
// Save (upsert — inserts if no id, updates if id exists)
users.save(User({name: "New User", email: "new@example.com"}))
// Delete (requires id)
users.delete(inserted)
Update and delete require an id field on the record. Missing id raises DB-0016 (update) or DB-0017 (delete).
Error Handling
Database errors are catchable with try:
let result = try(fn() {
db <=?=> <SQL>SELECT * FROM nonexistent_table</SQL>
})
if (result.error) {
`Query failed: {result.error}`
}
Error Codes
| Code | Description |
|---|---|
DB-0002 |
Query execution failed |
DB-0003 |
Connection failed (driver-level) |
DB-0004 |
Row scan failed |
DB-0005 |
Connection ping or DDL execution failed |
DB-0006 |
Commit/rollback without active transaction |
DB-0007 |
Begin when already in transaction |
DB-0008 |
Failed to read column metadata |
DB-0009 |
Cannot close managed connection |
DB-0010 |
Connection close failed |
DB-0011 |
Execute (mutation) failed |
DB-0012 |
Wrong type for connection operand |
DB-0013 |
Nested transactions not supported |
DB-0014 |
Failed to begin transaction |
DB-0015 |
Transaction commit failed |
DB-0016 |
Update failed — record has no primary key value |
DB-0017 |
Delete failed — record has no primary key value |
DB-0019 |
Transaction rollback failed |
SQL-0005 |
Unknown named parameter :name — no matching attribute on <SQL> tag |
SQL-0006 |
Cannot mix positional ? and named :param placeholders in the same query |
All database errors have class database and are catchable by try. SQL errors (SQL-0005, SQL-0006) have class type or parse and are also catchable.
SQL Security
Parsley validates all SQL identifiers (table and column names) against an allowlist pattern: alphanumeric characters and underscores only, maximum 64 characters. This prevents SQL injection through identifier manipulation.
Always use <SQL> tag attributes for user-provided values — never interpolate them into query strings:
// SAFE — parameterized (attributes become bound parameters)
let user = db <=?=> <SQL name={input}>
SELECT * FROM users WHERE name = :name
</SQL>
// UNSAFE — string interpolation bypasses parameterization
let user = db <=?=> `SELECT * FROM users WHERE name = '${input}'`
Key Differences from Other Languages
- Operators instead of method calls —
<=?=>,<=??=>, and<=!=>replace.query()and.execute(). The arrow syntax makes data flow direction and cardinality visible at a glance. <SQL>tags — parameterized queries use tag syntax with raw text content (no quotes needed) and:namenamed parameters. The tag integrates naturally with Parsley's component model. Queries are composable as components, and@{}interpolation is blocked to enforce safety. Named parameters are automatically translated to driver-native placeholders ($Nor?).- Connection caching — connections are cached by DSN. Repeated calls to
@sqlite()with the same path return the same connection. - No driver imports — SQLite, PostgreSQL, and MySQL are built in. You just use
@sqlite,@postgres, or@mysql. - Schema-driven bindings —
db.bind()connects a schema to a table, giving you typed CRUD methods without writing SQL.
See Also
- Query DSL —
@query,@insert,@update,@deleteexpressions - Data Model — schemas, records, and tables
- Error Handling —
tryand catchable error classes - Tags — tag syntax and components
- Security Model — file, SQL, and command security policies