Typed SQLite access for Zig with a small ORM layer and explicit ownership rules.
Important
This project targets Zig 0.16-dev (master) on main and is not compatible with Zig 0.15.x.
- Strong, explicit memory ownership for text/blob data (
OwnedText,OwnedBlob). - Simple ORM mapping via
struct+Meta. - Low-level statement API for direct SQL.
- Compact schema generation helpers.
This repo is a Zig module. Add it via the Zig package manager, then integrate it in build.zig
and link sqlite.
zig fetch --save git+https://github.com/MysticalDevil/zite.gitThis creates an entry in build.zig.zon under .dependencies.zite.
const exe = b.addExecutable(.{
.name = "app",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
const zite_dep = b.dependency("zite", .{
.target = target,
.optimize = optimize,
});
const zite = zite_dep.module("zite");
exe.root_module.addImport("zite", zite);
exe.root_module.linkSystemLibrary("sqlite3", .{ .needed = true });- Requires system
sqlite3development headers and library. - The consuming executable/test must set
link_libc = true. - Add
linkSystemLibrary("sqlite3")to the final executable/test target.
| Area | Entry Point | Notes |
|---|---|---|
| Database | zite.Db.open / db.deinit |
Opens/closes a SQLite connection. |
| Transactions (types) | zite.TxMode, zite.Tx |
Public transaction mode/handle types. |
| Statements | zite.Stmt.init / st.deinit |
Prepared statement wrapper. |
| Async execution | zite.AsyncPool.init |
Experimental std.Io-based execution layer. |
| ORM repository | zite.orm.repository(User, &db, a) |
Creates a typed repository for User. |
| ORM insert | repo.insert |
Returns last insert rowid. |
| ORM insert many | repo.insertMany |
Inserts multiple rows and returns count. |
| ORM update | repo.update |
Returns rows changed. |
| ORM upsert | repo.upsert |
Returns .inserted or .updated. |
| Query builder | repo.query() |
Builder with whereEq/whereSql/orderBy/limit/offset. |
| Guarded raw fragments | repo.findOneSql, repo.findManySql, repo.deleteWhereSql |
Rejects unsafe fragments with error.UnsafeSqlFragment. |
| Explicit unchecked raw fragments | repo.findOneSqlUnsafe, repo.findManySqlUnsafe, repo.deleteWhereSqlUnsafe |
Caller guarantees SQL fragment trustworthiness. |
| Find by id | repo.findByIdOwned |
OwnedRow(T) or null. |
| Row handle | repo.findByIdHandle |
RowHandle(T) for single-row zero-copy access. |
| Row cursor | q.iterateViews() |
RowCursor(T) for zero-copy row views. |
| Transactions | repo.beginTx(.deferred) |
commit() / rollback() or automatic rollback on deinit(). |
| Schema | zite.schema.createTableSqlFromMeta |
CREATE TABLE from Meta. |
| Errors | zite.errors.*Error |
Layered error sets by subsystem (DbError, StmtError, OrmError, ...). |
- Stable:
Db,Stmt,orm,schema,types,errors. - Experimental:
AsyncPool/async_pool. Built for Zig0.16/masterstd.Io; API may change. - Advanced/Low-level:
raw,sqlutil,meta. These are exposed for power users but may change when internals evolve. - Internal:
src/orm/engine.zigandsrc/orm/engine/*are implementation details used byormand are not part of the public API contract.
const std = @import("std");
const zite = @import("zite");
const OwnedText = zite.types.OwnedText;
const EpochMillis = zite.types.EpochMillis;
const User = struct {
id: i64,
name: OwnedText,
created_at: EpochMillis,
pub const Meta = .{
.table = "users",
.primary_key = "id",
};
};
pub fn main() !void {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const a = debug_allocator.allocator();
var db = try zite.Db.open(a, ":memory:");
defer db.deinit();
var repo = zite.orm.repository(User, &db, a);
const ddl = try zite.schema.createTableSqlFromMeta(a, User);
defer a.free(ddl);
try db.exec(ddl);
var user = User{
.id = 1,
.name = try OwnedText.fromConst(a, "Alice"),
.created_at = .{ .value = 1700000000000 },
};
defer repo.freeOwnedRow(&user);
_ = try repo.insert(user);
if (try repo.findByIdOwned(@as(i64, 1))) |row| {
var owned = row;
defer owned.deinit();
std.debug.print("name={s}\n", .{owned.value.name.value});
}
}- Debug/test scenarios: prefer
std.heap.DebugAllocator(.{}). - Throughput-oriented runtime paths: consider
std.heap.smp_allocator. - Public APIs in this library accept
std.mem.Allocator, so allocator policy stays with the caller.
Meta controls table/column mapping and SQL generation.
pub const Meta = .{
.table = "users",
.primary_key = "id",
// true => PK omitted from INSERT and CREATE TABLE emits AUTOINCREMENT for integer PKs.
// false => PK must be provided by caller and CREATE TABLE omits AUTOINCREMENT.
.skip_primary_key_on_insert = true,
.order_by = "\"id\" DESC",
.rename = &.{
.{ .field = "created_at", .column = "createdAt" },
},
.skip = &.{ "transient_field" },
.unique = &.{
&.{ "email" },
},
};Use repo.query() for typed query building. Meta.order_by is used as the
default order when builder order is not explicitly set.
whereSql rebases placeholders relative to the current query parameter count.
That means ?1, ?2, or bare ? inside the raw fragment are local to that
fragment, even when mixed with earlier whereEq calls.
Guarded raw APIs reject unsafe fragments such as statement separators, SQL comments, and statement-level keywords.
- Query builder:
whereSql(unsafe counterpart:whereSqlUnsafe) - Repository:
findOneSql,findOneHandleSql,findManySql,findManySqlWithOptions,findManyOwnedSql,deleteWhereSql - Repository unsafe counterparts:
findOneSqlUnsafe,findOneHandleSqlUnsafe,findManySqlUnsafe,findManySqlWithOptionsUnsafe,findManyOwnedSqlUnsafe,deleteWhereSqlUnsafe
findManySqlWithOptions validates both where_clause and opts.order_by.
var q = repo.query();
try q.whereEq("id", @as(i64, 1));
try q.whereSql("\"name\"=?1", .{"alice"});
try q.orderBy("id", .asc);
q.setLimit(20);
q.setOffset(40);
var rows = try q.iterateOwned();
defer rows.deinit();Use row views when you want zero-copy access to the current statement row.
q.iterateViews()returns aRowCursor(T).cursor.next()returns aRowView(T)for the current row.repo.findByIdHandle(...)andrepo.findOneHandleSql(...)return aRowHandle(T)for single-row access.
Lifecycle rules:
RowViewis valid only until the cursor advances or is deinitialized.RowHandleowns the underlying statement and remains valid untildeinit().- Access after cursor advance returns
error.RowViewStale. - Access after handle/cursor teardown returns
error.StatementFinalized.
var q = repo.query();
defer q.deinit();
try q.orderBy("id", .asc);
var cursor = try q.iterateViews();
defer cursor.deinit();
while (try cursor.next()) |row| {
std.debug.print("{s}\n", .{try row.get("name")});
}Use insertMany to insert multiple rows with one prepared statement.
const inserted = try repo.insertMany(&[_]User{
.{ .id = 0, .name = n1, .age = 20 },
.{ .id = 0, .name = n2, .age = null },
});
_ = inserted; // 2var tx = try repo.beginTx(.deferred);
defer tx.deinit(); // auto rollback if not committed
_ = try repo.insert(.{ .id = 0, .name = n1, .age = 20 });
try tx.commit();
// try tx.rollback(); // explicit rollback is also supportedFor multi-connection write contention, prefer wrapping related writes (including
upsert) in an explicit transaction to make behavior easier to reason about.
This is especially important when Meta.skip_primary_key_on_insert = true,
because that compatibility path still uses an existence check before INSERT.
That path is only safe for serial callers; concurrent access, even through a
shared connection, can still observe a race.
AsyncPool is an experimental execution layer for Zig 0.16/master. It uses
std.Io.concurrent to run blocking sqlite work on independent connections.
Important constraints:
- It does not make sqlite itself non-blocking; it schedules blocking work.
- Each task opens its own
Db. RowView/RowHandle/RowCursorvalues are not allowed across the async boundary.AsyncPool.findOnereturns an owned value; free it withzite.AsyncPool.freeOwnedRow.- SQLite still behaves like SQLite: a single file-backed database supports many readers, but write concurrency is still limited by database locking.
pub fn main(init: std.process.Init) !void {
var pool = try zite.AsyncPool.init(init.gpa, "app.sqlite", .{});
defer pool.deinit();
_ = try pool.insert(init.io, User, .{ .id = 1, .name = name });
if (try pool.findByIdOwned(init.io, User, init.gpa, @as(i64, 1))) |row| {
var owned = row;
defer owned.deinit();
std.debug.print("{s}\n", .{owned.value.name.value});
}
}OwnedText/OwnedBlob carry owned buffers and must be freed. ORM mapping and
bindOne only accept these types for text/blob fields.
var name = try zite.types.OwnedText.fromConst(a, "Alice");
defer name.deinit(a);var st = try zite.Stmt.init(&db, "SELECT body FROM notes WHERE id=?1;");
defer st.deinit();
try st.bindInt(1, 1);
if (try st.step() == .row) {
if (try st.colTextOwned(a, 0)) |body| {
defer a.free(body);
std.debug.print("{s}\n", .{body});
}
}colTextOwned / colBlobOwned return null only for SQL NULL. Empty text or
blob values return a non-null empty slice.
Stmt enforces lifecycle validity. After deinit()/finalize(), all statement
operations return error.StatementFinalized.
Repository raw-fragment helpers use paired APIs:
- Guarded:
findOneSql,findManySql,findManySqlWithOptions,findManyOwnedSql,deleteWhereSql - Explicit escape hatch: corresponding
...SqlUnsafevariants
Guarded variants validate raw SQL fragments and return error.UnsafeSqlFragment
when unsafe constructs are detected. findManySqlWithOptions validates both
where_clause and opts.order_by.
Use ...SqlUnsafe only when the fragment is fully trusted (for example,
internal constant SQL that cannot be influenced by external input).
Public APIs now return layered error sets instead of one catch-all set.
errors.DbError: database open/exec/transaction/lifecycle failures.errors.StmtError: statement prepare/bind/step/finalize failures.errors.RowReadError: row-view lifecycle and column decoding failures.errors.OrmError: ORM/query invariants layered on top of row/statement failures.errors.AsyncOrmError: async boundary failures plus propagated ORM errors.errors.SchemaError: schema SQL generation allocation failures.
SQLite return codes are still mapped to specific errors such as
error.SqliteBusy, error.SqliteConstraint, and error.SqliteIo.
Notable behavior-specific errors:
error.StatementFinalized: statement or row-backed handle used afterdeinit()/finalize().error.RowViewStale: aRowViewwas accessed after its cursor advanced.error.UnsafeSqlFragment: guarded raw SQL fragment rejected by ORM safety checks.error.EmptyWhereClause:deleteWhereSqlcalled with empty WHERE.error.UnexpectedExtraRows:findOne/findByIdobserved more rows than expected.
zig build testruns unit tests.zig build itestruns integration tests.tests/integration/async_pool.zigcoversinsert,update,deleteById,upsert, concurrent reads, empty-result paths, and representative error propagation.
- The project targets Zig
0.16-dev(master) on themainbranch.
examples/orm_basic.zigexamples/orm_find_many.zigexamples/orm_find_one.zigexamples/orm_raw_sql_guard.zigexamples/orm_meta_options.zigexamples/stmt_bind_all.zigexamples/stmt_basic.zigexamples/process_init_full.zig(main(init: std.process.Init))examples/process_init_minimal.zig(main(init: std.process.Init.Minimal))examples/process_init_env.zig(init.environ_map)examples/async_pool_basic.zig(AsyncPoolwithinit.io)
The examples are standalone Zig files. Run them with a module mapping and link sqlite3:
zig run --dep zite -Mroot=examples/orm_basic.zig -Mzite=src/root.zig -lc -lsqlite3See examples/README.md for more commands.
Generate a small file-backed SQLite database for manual testing:
./scripts/generate_test_db.sh /tmp/zite-test.sqlite- Detailed architecture doc:
docs/ARCHITECTURE.md
src/raw/low-level sqlite3 bindings.src/db/DB/statement wrappers.src/core/types/meta/sql helpers.src/orm/repository/query ORM and schema.src/orm/engine.ziginternal engine namespace entrypoint.src/orm/engine/internal ORM engine (SQL assembly, row decoding, exec helpers).scripts/generate_test_db.shgenerates a small file-backed SQLite database for manual testing.tests/integration tests.