// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); pub const c = @import("sqlite3"); const log = lp.log; const Sqlite = @This(); pub const Blob = struct { data: []const u8 }; pub const Migration = union(enum) { sql: [:0]const u8, func: struct { ctx: *anyopaque, func: *const fn (conn: Conn, ctx: *anyopaque) anyerror!void, }, }; pub const Migrations = struct { pub fn run(conn: Conn, migrations: []const Migration) !usize { try conn.exec( \\create table if not exists migrations ( \\ id integer primary key, \\ applied_at integer not null \\) strict , .{}); const current = (try conn.scalar( i64, "select max(id) from migrations", .{}, )) orelse 0; const start: usize = @intCast(current); if (start > migrations.len) { log.err(.storage, "migrations removed", .{ .applied = start, .defined = migrations.len, }); return error.MigrationsRemoved; } if (start == migrations.len) { return start; } try conn.begin(.immediate); errdefer conn.rollback() catch {}; for (migrations[start..], start..) |migration, i| { switch (migration) { .sql => |sql| try conn.exec(sql, .{}), .func => |f| try f.func(conn, f.ctx), } try conn.exec( "insert into migrations (id, applied_at) values ($1, $2)", .{ @as(i64, @intCast(i + 1)), std.Io.Clock.now(.real, lp.io).toSeconds() }, ); } try conn.commit(); return migrations.len; } }; pub const Conn = struct { conn: *c.sqlite3, pub fn open(path: [:0]const u8) !Conn { var conn: ?*c.sqlite3 = null; const flags = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE; const rc = c.sqlite3_open_v2(path.ptr, &conn, flags, null); if (rc != c.SQLITE_OK) { if (conn) |connection| { _ = c.sqlite3_close_v2(connection); } return errorFromCode(rc); } return .{ .conn = conn.? }; } pub fn close(self: Conn) void { _ = c.sqlite3_close_v2(self.conn); } pub fn exec(self: Conn, sql: [:0]const u8, values: anytype) !void { if (values.len == 0) { const rc = c.sqlite3_exec(self.conn, sql, null, null, null); if (rc != c.SQLITE_OK) { return errorFromCode(rc); } return; } const stmt = try self.prepare(sql); defer stmt.deinit(); try stmt.bind(values); try stmt.stepToCompletion(); } pub fn scalar(self: Conn, comptime T: type, sql: []const u8, values: anytype) !?T { if (comptime isScalar(T) == false) { @compileError("Cannot use `sqlite.scalar` function with a non-scalar type. Who owns that memory?"); } const stmt = try self.prepare(sql); errdefer stmt.deinit(); try stmt.bind(values); if (try stmt.step() == false) { stmt.deinit(); return null; } var r = Row{ .stmt = stmt }; defer r.deinit(); return r.get(T, 0); } pub fn row(self: Conn, sql: []const u8, values: anytype) !?Row { const stmt = try self.prepare(sql); errdefer stmt.deinit(); try stmt.bind(values); if (try stmt.step() == false) { stmt.deinit(); return null; } return .{ .stmt = stmt }; } pub fn rows(self: Conn, sql: []const u8, values: anytype) !Rows { const stmt = try self.prepare(sql); errdefer stmt.deinit(); try stmt.bind(values); return .{ .stmt = stmt }; } fn prepare(self: Conn, sql: []const u8) !Statement { var stmt: ?*c.sqlite3_stmt = null; var pz_tail: [*:0]const u8 = undefined; const rc = c.sqlite3_prepare_v2(self.conn, sql.ptr, @intCast(sql.len), &stmt, @ptrCast(&pz_tail)); if (rc != c.SQLITE_OK) { return errorFromCode(rc); } return .{ .stmt = stmt.?, .conn = self.conn }; } const BeginKind = enum { deferred, immediate, exclusive }; pub fn begin(self: Conn, kind: BeginKind) !void { switch (kind) { .deferred => try self.exec("begin deferred", .{}), .immediate => try self.exec("begin immediate", .{}), .exclusive => try self.exec("begin exclusive", .{}), } } pub fn changes(self: Conn) i64 { return @intCast(c.sqlite3_changes(self.conn)); } pub fn commit(self: Conn) !void { try self.exec("commit", .{}); } pub fn rollback(self: Conn) !void { try self.exec("rollback", .{}); } pub fn busyTimeout(self: Conn, ms: c_int) !void { const rc = c.sqlite3_busy_timeout(self.conn, ms); if (rc != c.SQLITE_OK) { return errorFromCode(rc); } } pub fn lastError(self: Conn) [:0]const u8 { return std.mem.span(c.sqlite3_errmsg(self.conn)); } }; const Statement = struct { conn: *c.sqlite3, stmt: *c.sqlite3_stmt, pub fn deinit(self: Statement) void { _ = c.sqlite3_finalize(self.stmt); } pub fn bind(self: Statement, values: anytype) !void { const stmt = self.stmt; inline for (values, 0..) |value, i| { try _bind(@TypeOf(value), stmt, value, i + 1); } } pub fn get(self: Statement, comptime T: type, index: usize) T { const stmt = self.stmt; const TT = switch (@typeInfo(T)) { .optional => |opt| blk: { if (c.sqlite3_column_type(stmt, @intCast(index)) == c.SQLITE_NULL) { return null; } break :blk opt.child; }, else => T, }; return switch (TT) { i64 => @intCast(c.sqlite3_column_int64(stmt, @intCast(index))), bool => @as(i64, @intCast(c.sqlite3_column_int64(stmt, @intCast(index)))) == 1, f64 => @floatCast(c.sqlite3_column_double(stmt, @intCast(index))), []const u8 => { const len = c.sqlite3_column_bytes(stmt, @intCast(index)); if (len == 0) { return ""; } const data = c.sqlite3_column_text(stmt, @intCast(index)); return @as([*c]const u8, @ptrCast(data))[0..@intCast(len)]; }, [:0]const u8 => { const len = c.sqlite3_column_bytes(stmt, @intCast(index)); if (len == 0) { return ""; } const data = c.sqlite3_column_text(stmt, @intCast(index)); return @as([*c]const u8, @ptrCast(data))[0..@intCast(len) :0]; }, Blob => { const len = c.sqlite3_column_bytes(stmt, @intCast(index)); if (len == 0) { return Blob{ .data = &.{} }; } const data = c.sqlite3_column_blob(stmt, @intCast(index)); return Blob{ .data = @as([*c]const u8, @ptrCast(data))[0..@intCast(len)] }; }, else => @compileError("unsupported column type: " ++ @typeName(T)), }; } pub fn step(self: Statement) !bool { const s = self.stmt; const rc = c.sqlite3_step(s); if (rc == c.SQLITE_DONE) { return false; } if (rc != c.SQLITE_ROW) { return errorFromCode(rc); } return true; } fn stepToCompletion(self: Statement) !void { const stmt = self.stmt; while (true) { switch (c.sqlite3_step(stmt)) { c.SQLITE_DONE => return, c.SQLITE_ROW => continue, else => |rc| return errorFromCode(rc), } } } pub fn reset(self: Statement) !void { switch (c.sqlite3_reset(self.stmt)) { c.SQLITE_OK => return, else => |rc| return errorFromCode(rc), } } fn _bind(comptime T: type, stmt: *c.sqlite3_stmt, value: anytype, bind_index: c_int) !void { var rc: c_int = 0; switch (@typeInfo(T)) { .null => rc = c.sqlite3_bind_null(stmt, bind_index), .int, .comptime_int => rc = c.sqlite3_bind_int64(stmt, bind_index, @intCast(value)), .float, .comptime_float => rc = c.sqlite3_bind_double(stmt, bind_index, value), .bool => { if (value) { rc = c.sqlite3_bind_int64(stmt, bind_index, @intCast(1)); } else { rc = c.sqlite3_bind_int64(stmt, bind_index, @intCast(0)); } }, .@"struct" => { if (T == Blob) { rc = c.sqlite3_bind_blob(stmt, bind_index, value.data.ptr, @intCast(value.data.len), c.SQLITE_STATIC); } else { bindError(T); } }, .pointer => |ptr| { switch (ptr.size) { .one => switch (@typeInfo(ptr.child)) { .array => |arr| { if (arr.child == u8) { rc = c.sqlite3_bind_text(stmt, bind_index, value.ptr, @intCast(value.len), c.SQLITE_STATIC); } else { bindError(T); } }, else => bindError(T), }, .slice => switch (ptr.child) { u8 => rc = c.sqlite3_bind_text(stmt, bind_index, value.ptr, @intCast(value.len), c.SQLITE_STATIC), else => bindError(T), }, else => bindError(T), } }, .array => |arr| { if (arr.child == u8) { @compileError("Pass a string slice, rather than an array, to bind a text/blob. String arrays will be supported when https://github.com/ziglang/zig/issues/15893#issuecomment-1925092582 is fixed"); // const data: []const u8 = value[0..arr.len]; // rc = c.sqlite3_bind_text(stmt, bind_index, data.ptr, @intCast(data.len), c.SQLITE_TRANSIENT); } else { bindError(T); } }, .optional => |opt| { if (value) |v| { return _bind(opt.child, stmt, v, bind_index); } else { rc = c.sqlite3_bind_null(stmt, bind_index); } }, else => bindError(T), } if (rc != c.SQLITE_OK) { return errorFromCode(rc); } } fn bindError(comptime T: type) void { @compileError("cannot bind value of type " ++ @typeName(T)); } }; const Row = struct { stmt: Statement, pub fn deinit(self: Row) void { self.stmt.deinit(); } pub fn get(self: Row, comptime T: type, index: usize) T { return self.stmt.get(T, index); } }; pub const Rows = struct { stmt: Statement, pub fn deinit(self: Rows) void { self.stmt.deinit(); } pub fn next(self: *Rows) !?Row { const stmt = self.stmt; const has_data = try stmt.step(); if (!has_data) { return null; } return .{ .stmt = stmt }; } }; fn errorFromCode(result: c_int) Error { return switch (result) { c.SQLITE_ABORT => error.Abort, c.SQLITE_AUTH => error.Auth, c.SQLITE_BUSY => error.Busy, c.SQLITE_CANTOPEN => error.CantOpen, c.SQLITE_CONSTRAINT => error.Constraint, c.SQLITE_CORRUPT => error.Corrupt, c.SQLITE_EMPTY => error.Empty, c.SQLITE_ERROR => error.Error, c.SQLITE_FORMAT => error.Format, c.SQLITE_FULL => error.Full, c.SQLITE_INTERNAL => error.Internal, c.SQLITE_INTERRUPT => error.Interrupt, c.SQLITE_IOERR => error.IoErr, c.SQLITE_LOCKED => error.Locked, c.SQLITE_MISMATCH => error.Mismatch, c.SQLITE_MISUSE => error.Misuse, c.SQLITE_NOLFS => error.NoLFS, c.SQLITE_NOMEM => error.NoMem, c.SQLITE_NOTADB => error.NotADB, c.SQLITE_NOTFOUND => error.Notfound, c.SQLITE_NOTICE => error.Notice, c.SQLITE_PERM => error.Perm, c.SQLITE_PROTOCOL => error.Protocol, c.SQLITE_RANGE => error.Range, c.SQLITE_READONLY => error.ReadOnly, c.SQLITE_SCHEMA => error.Schema, c.SQLITE_TOOBIG => error.TooBig, c.SQLITE_WARNING => error.Warning, // Extended codes c.SQLITE_ERROR_MISSING_COLLSEQ => error.ErrorMissingCollseq, c.SQLITE_ERROR_RETRY => error.ErrorRetry, c.SQLITE_ERROR_SNAPSHOT => error.ErrorSnapshot, c.SQLITE_IOERR_READ => error.IoerrRead, c.SQLITE_IOERR_SHORT_READ => error.IoerrShortRead, c.SQLITE_IOERR_WRITE => error.IoerrWrite, c.SQLITE_IOERR_FSYNC => error.IoerrFsync, c.SQLITE_IOERR_DIR_FSYNC => error.IoerrDir_fsync, c.SQLITE_IOERR_TRUNCATE => error.IoerrTruncate, c.SQLITE_IOERR_FSTAT => error.IoerrFstat, c.SQLITE_IOERR_UNLOCK => error.IoerrUnlock, c.SQLITE_IOERR_RDLOCK => error.IoerrRdlock, c.SQLITE_IOERR_DELETE => error.IoerrDelete, c.SQLITE_IOERR_BLOCKED => error.IoerrBlocked, c.SQLITE_IOERR_NOMEM => error.IoerrNomem, c.SQLITE_IOERR_ACCESS => error.IoerrAccess, c.SQLITE_IOERR_CHECKRESERVEDLOCK => error.IoerrCheckreservedlock, c.SQLITE_IOERR_LOCK => error.IoerrLock, c.SQLITE_IOERR_CLOSE => error.IoerrClose, c.SQLITE_IOERR_DIR_CLOSE => error.IoerrDirClose, c.SQLITE_IOERR_SHMOPEN => error.IoerrShmopen, c.SQLITE_IOERR_SHMSIZE => error.IoerrShmsize, c.SQLITE_IOERR_SHMLOCK => error.IoerrShmlock, c.SQLITE_IOERR_SHMMAP => error.IoerrShmmap, c.SQLITE_IOERR_SEEK => error.IoerrSeek, c.SQLITE_IOERR_DELETE_NOENT => error.IoerrDeleteNoent, c.SQLITE_IOERR_MMAP => error.IoerrMmap, c.SQLITE_IOERR_GETTEMPPATH => error.IoerrGetTempPath, c.SQLITE_IOERR_CONVPATH => error.IoerrConvPath, c.SQLITE_IOERR_VNODE => error.IoerrVnode, c.SQLITE_IOERR_AUTH => error.IoerrAuth, c.SQLITE_IOERR_BEGIN_ATOMIC => error.IoerrBeginAtomic, c.SQLITE_IOERR_COMMIT_ATOMIC => error.IoerrCommitAtomic, c.SQLITE_IOERR_ROLLBACK_ATOMIC => error.IoerrRollbackAtomic, c.SQLITE_IOERR_DATA => error.IoerrData, c.SQLITE_IOERR_CORRUPTFS => error.IoerrCorruptFS, c.SQLITE_LOCKED_SHAREDCACHE => error.LockedSharedCache, c.SQLITE_LOCKED_VTAB => error.LockedVTab, c.SQLITE_BUSY_RECOVERY => error.BusyRecovery, c.SQLITE_BUSY_SNAPSHOT => error.BusySnapshot, c.SQLITE_BUSY_TIMEOUT => error.BusyTimeout, c.SQLITE_CANTOPEN_NOTEMPDIR => error.CantOpenNoTempDir, c.SQLITE_CANTOPEN_ISDIR => error.CantOpenIsDir, c.SQLITE_CANTOPEN_FULLPATH => error.CantOpenFullPath, c.SQLITE_CANTOPEN_CONVPATH => error.CantOpenConvPath, c.SQLITE_CANTOPEN_DIRTYWAL => error.CantOpenDirtyWal, c.SQLITE_CANTOPEN_SYMLINK => error.CantOpenSymlink, c.SQLITE_CORRUPT_VTAB => error.CorruptVTab, c.SQLITE_CORRUPT_SEQUENCE => error.CorruptSequence, c.SQLITE_CORRUPT_INDEX => error.CorruptIndex, c.SQLITE_READONLY_RECOVERY => error.ReadonlyRecovery, c.SQLITE_READONLY_CANTLOCK => error.ReadonlyCantlock, c.SQLITE_READONLY_ROLLBACK => error.ReadonlyRollback, c.SQLITE_READONLY_DBMOVED => error.ReadonlyDbMoved, c.SQLITE_READONLY_CANTINIT => error.ReadonlyCantInit, c.SQLITE_READONLY_DIRECTORY => error.ReadonlyDirectory, c.SQLITE_ABORT_ROLLBACK => error.AbortRollback, c.SQLITE_CONSTRAINT_CHECK => error.ConstraintCheck, c.SQLITE_CONSTRAINT_COMMITHOOK => error.ConstraintCommithook, c.SQLITE_CONSTRAINT_FOREIGNKEY => error.ConstraintForeignKey, c.SQLITE_CONSTRAINT_FUNCTION => error.ConstraintFunction, c.SQLITE_CONSTRAINT_NOTNULL => error.ConstraintNotNull, c.SQLITE_CONSTRAINT_PRIMARYKEY => error.ConstraintPrimaryKey, c.SQLITE_CONSTRAINT_TRIGGER => error.ConstraintTrigger, c.SQLITE_CONSTRAINT_UNIQUE => error.ConstraintUnique, c.SQLITE_CONSTRAINT_VTAB => error.ConstraintVTab, c.SQLITE_CONSTRAINT_ROWID => error.ConstraintRowId, c.SQLITE_CONSTRAINT_PINNED => error.ConstraintPinned, c.SQLITE_CONSTRAINT_DATATYPE => error.ConstraintDatatype, c.SQLITE_NOTICE_RECOVER_WAL => error.NoticeRecoverWal, c.SQLITE_NOTICE_RECOVER_ROLLBACK => error.NoticeRecoverRollback, c.SQLITE_WARNING_AUTOINDEX => error.WarningAutoIndex, c.SQLITE_AUTH_USER => error.AuthUser, c.SQLITE_OK_LOAD_PERMANENTLY => error.OkLoadPermanently, else => { log.err(.storage, "unknown error", .{ .engine = "sqlite", .code = result }); return error.Unknown; }, }; } pub const Error = error{ Abort, Auth, Busy, CantOpen, Constraint, Corrupt, Empty, Error, Format, Full, Internal, Interrupt, IoErr, Locked, Mismatch, Misuse, NoLFS, NoMem, NotADB, Notfound, Notice, Perm, Protocol, Range, ReadOnly, Schema, TooBig, Warning, ErrorMissingCollseq, ErrorRetry, ErrorSnapshot, IoerrRead, IoerrShortRead, IoerrWrite, IoerrFsync, IoerrDir_fsync, IoerrTruncate, IoerrFstat, IoerrUnlock, IoerrRdlock, IoerrDelete, IoerrBlocked, IoerrNomem, IoerrAccess, IoerrCheckreservedlock, IoerrLock, IoerrClose, IoerrDirClose, IoerrShmopen, IoerrShmsize, IoerrShmlock, IoerrShmmap, IoerrSeek, IoerrDeleteNoent, IoerrMmap, IoerrGetTempPath, IoerrConvPath, IoerrVnode, IoerrAuth, IoerrBeginAtomic, IoerrCommitAtomic, IoerrRollbackAtomic, IoerrData, IoerrCorruptFS, LockedSharedCache, LockedVTab, BusyRecovery, BusySnapshot, BusyTimeout, CantOpenNoTempDir, CantOpenIsDir, CantOpenFullPath, CantOpenConvPath, CantOpenDirtyWal, CantOpenSymlink, CorruptVTab, CorruptSequence, CorruptIndex, ReadonlyRecovery, ReadonlyCantlock, ReadonlyRollback, ReadonlyDbMoved, ReadonlyCantInit, ReadonlyDirectory, AbortRollback, ConstraintCheck, ConstraintCommithook, ConstraintForeignKey, ConstraintFunction, ConstraintNotNull, ConstraintPrimaryKey, ConstraintTrigger, ConstraintUnique, ConstraintVTab, ConstraintRowId, ConstraintPinned, ConstraintDatatype, NoticeRecoverWal, NoticeRecoverRollback, WarningAutoIndex, AuthUser, OkLoadPermanently, Unknown, }; fn isScalar(comptime T: type) bool { const TT = switch (@typeInfo(T)) { .optional => |opt| opt.child, else => T, }; return TT == i64 or TT == bool or TT == f64; } const testing = @import("../../testing.zig"); test "Sqlite: exec, row and scalar" { var conn = try Sqlite.Conn.open(":memory:"); defer conn.close(); try conn.exec("create table test (id integer primary key, name text, data blob)", .{}); try conn.exec("insert into test (name, data) values (?1, ?2)", .{ "test name", "binary data" }); { var row = (try conn.row("select name, data from test where id = 1", .{})) orelse unreachable; defer row.deinit(); try testing.expectEqual("test name", row.get([]const u8, 0)); } { try testing.expectEqual(1, (try conn.scalar(i64, "select count(*) from test where id = 1", .{})).?); } } test "Sqlite: Migrations - basic" { var conn = try Sqlite.Conn.open(":memory:"); defer conn.close(); const migrations: []const Migration = &.{ .{ .sql = "create table test (id integer primary key, name text)" }, .{ .sql = "alter table test add column email text" }, }; const v1 = try Migrations.run(conn, migrations); try testing.expectEqual(@as(usize, 2), v1); // idempotent - running again should return same version const v2 = try Migrations.run(conn, migrations); try testing.expectEqual(@as(usize, 2), v2); // verify migrations table has correct entries try testing.expectEqual( @as(i64, 2), (try conn.scalar(i64, "select count(*) from migrations", .{})).?, ); } test "Sqlite: Migrations - removed migration" { testing.expectLog(&.{.storage}); var conn = try Sqlite.Conn.open(":memory:"); defer conn.close(); const m1: []const Migration = &.{ .{ .sql = "create table test (id integer primary key, name text)" }, .{ .sql = "alter table test add column email text" }, }; _ = try Migrations.run(conn, m1); // fewer migrations than were applied const m2: []const Migration = &.{ .{ .sql = "create table test (id integer primary key, name text)" }, }; try testing.expectError(error.MigrationsRemoved, Migrations.run(conn, m2)); }