From 733533ec83b9fc88bbaf8a44773453dd975c1657 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 18:52:18 +0100 Subject: [PATCH] feat(v0.5): Fase 3B - Callbacks avanzados, limits y timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa todas las funcionalidades restantes de la paridad con go-sqlite3: Callbacks y Hooks: - Authorizer callback para control de operaciones SQL - Pre-update hook con acceso a valores antes/despues del cambio - Progress handler para interrumpir queries largos - Busy handler personalizado (custom callback) APIs adicionales: - Limits API (getLimit/setLimit) para control de limites SQLite - Column metadata extendida (columnDatabaseName, columnTableName, columnOriginName) - Expanded SQL (stmt.expandedSql) - Timestamp binding (bindTimestamp, bindCurrentTime) con formato ISO8601 Build: - Habilitado SQLITE_ENABLE_PREUPDATE_HOOK en build.zig - Definido @cDefine en @cImport para exponer APIs opcionales Tests: - Tests para authorizer, progress handler, limits, expanded SQL - Tests para column metadata y pre-update hook - Tests para timestamp binding Documentacion actualizada con todos los nuevos APIs y ejemplos. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- build.zig | 2 + docs/API.md | 245 ++++++++++- docs/CGO_PARITY_ANALYSIS.md | 39 +- src/root.zig | 788 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1054 insertions(+), 20 deletions(-) diff --git a/build.zig b/build.zig index 961f63b..29c7519 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,8 @@ pub fn build(b: *std.Build) void { "-DSQLITE_ENABLE_JSON1", // JSON functions "-DSQLITE_ENABLE_RTREE", // R-Tree for geospatial "-DSQLITE_OMIT_LOAD_EXTENSION", // No dynamic extensions + "-DSQLITE_ENABLE_COLUMN_METADATA", // Column metadata functions + "-DSQLITE_ENABLE_PREUPDATE_HOOK", // Pre-update hook API }; // zsqlite module - includes SQLite C compilation diff --git a/docs/API.md b/docs/API.md index 930c75e..0062128 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # zsqlite - API Reference -> **Version**: 0.4 +> **Version**: 0.5 > **Ultima actualizacion**: 2025-12-08 ## Quick Reference @@ -59,6 +59,17 @@ try blob.read(&buffer, 0); // Hooks try db.setCommitHook(myCommitHook); try db.setUpdateHook(myUpdateHook); +try db.setPreUpdateHook(myPreUpdateHook); +try db.setAuthorizer(myAuthorizer); +try db.setProgressHandler(1000, myProgress); + +// Timestamp binding +try stmt.bindTimestamp(1, unix_timestamp); +try stmt.bindCurrentTime(1); + +// Limits +const old = db.setLimit(.sql_length, 10000); +const current = db.getLimit(.sql_length); ``` --- @@ -265,6 +276,21 @@ try db.createCollation("REVERSE", reverseOrder); | `interrupt()` | Interrumpe operacion | | `isReadOnly(db_name)` | Si DB es readonly | | `filename(db_name)` | Ruta del archivo | +| `getLimit(limit_type)` | Obtener limite actual | +| `setLimit(limit_type, val)` | Establecer limite | + +### Hooks y Callbacks + +| Funcion | Descripcion | +|---------|-------------| +| `setCommitHook(fn)` | Hook al commit | +| `setRollbackHook(fn)` | Hook al rollback | +| `setUpdateHook(fn)` | Hook a INSERT/UPDATE/DELETE | +| `setPreUpdateHook(fn)` | Hook ANTES de cambios | +| `setAuthorizer(fn)` | Autorizar operaciones SQL | +| `setProgressHandler(n, fn)` | Callback cada N operaciones | +| `setBusyHandler(fn)` | Handler personalizado de busy | +| `clearHooks()` | Eliminar todos los hooks | --- @@ -311,6 +337,10 @@ try db.createCollation("REVERSE", reverseOrder); | `bindTextNamed(name, val)` | | | `bindBlobNamed(name, val)` | | | `bindBoolNamed(name, val)` | | +| `bindTimestamp(idx, ts)` | Unix timestamp como ISO8601 | +| `bindTimestampNamed(name, ts)` | | +| `bindCurrentTime(idx)` | Tiempo actual como ISO8601 | +| `bindCurrentTimeNamed(name)` | | ### Column Access (0-indexed) @@ -327,6 +357,15 @@ try db.createCollation("REVERSE", reverseOrder); | `columnIsNull(idx)` | bool | | `columnBytes(idx)` | Tamano en bytes | | `columnDeclType(idx)` | Tipo declarado | +| `columnDatabaseName(idx)` | Nombre de la base de datos | +| `columnTableName(idx)` | Nombre de la tabla | +| `columnOriginName(idx)` | Nombre original de la columna | + +### Statement Metadata Extended + +| Funcion | Descripcion | +|---------|-------------| +| `expandedSql(allocator)` | SQL con parametros expandidos | --- @@ -694,5 +733,207 @@ pub fn createAggregateFunction( --- -**© zsqlite v0.4 - API Reference** +## Authorizer + +Controla que operaciones SQL estan permitidas. + +### Types + +```zig +pub const AuthAction = enum(i32) { + create_index, create_table, create_temp_index, create_temp_table, + create_temp_trigger, create_temp_view, create_trigger, create_view, + delete, drop_index, drop_table, drop_temp_index, drop_temp_table, + drop_temp_trigger, drop_temp_view, drop_trigger, drop_view, + insert, pragma, read, select, transaction, update, + attach, detach, alter_table, reindex, analyze, + create_vtable, drop_vtable, function, savepoint, recursive, +}; + +pub const AuthResult = enum(i32) { + ok, // Permitir + deny, // Denegar con error + ignore, // Tratar como NULL +}; + +pub const ZigAuthorizerFn = *const fn ( + action: AuthAction, + arg1: ?[]const u8, // tabla/indice + arg2: ?[]const u8, // columna/trigger + arg3: ?[]const u8, // nombre de database + arg4: ?[]const u8, // trigger/view +) AuthResult; +``` + +### Ejemplo + +```zig +fn myAuthorizer(action: AuthAction, arg1: ?[]const u8, _, _, _) AuthResult { + if (action == .drop_table) { + if (arg1) |table| { + if (std.mem.eql(u8, table, "important")) return .deny; + } + } + return .ok; +} + +try db.setAuthorizer(myAuthorizer); +// Ahora DROP TABLE important fallara +try db.setAuthorizer(null); // Remover +``` + +--- + +## Pre-Update Hook + +Hook que se ejecuta ANTES de cambios, permitiendo acceso a valores antiguos y nuevos. + +### Types + +```zig +pub const PreUpdateContext = struct { + pub fn columnCount(self: Self) i32 + pub fn depth(self: Self) i32 // 0=directo, 1=trigger, etc + pub fn oldValue(self: Self, col: u32) ?FunctionValue // UPDATE/DELETE + pub fn newValue(self: Self, col: u32) ?FunctionValue // UPDATE/INSERT +}; + +pub const ZigPreUpdateHookFn = *const fn ( + ctx: PreUpdateContext, + operation: UpdateOperation, + db_name: []const u8, + table_name: []const u8, + old_rowid: i64, + new_rowid: i64, +) void; +``` + +### Ejemplo + +```zig +fn auditHook(ctx: PreUpdateContext, op: UpdateOperation, _, table: []const u8, _, _) void { + if (op == .update) { + // Acceder a valor antes del cambio + if (ctx.oldValue(0)) |old| { + const old_val = old.asInt(); + // Acceder a nuevo valor + if (ctx.newValue(0)) |new| { + const new_val = new.asInt(); + std.debug.print("{s}: {d} -> {d}\n", .{table, old_val, new_val}); + } + } + } +} + +try db.setPreUpdateHook(auditHook); +``` + +--- + +## Progress Handler + +Callback periodico para queries de larga duracion. + +```zig +pub const ZigProgressFn = *const fn () bool; // true=continuar, false=interrumpir +``` + +### Ejemplo + +```zig +var should_cancel = false; + +fn checkCancel() bool { + return !should_cancel; // false interrumpe el query +} + +try db.setProgressHandler(1000, checkCancel); // Cada 1000 operaciones VM +// Para cancelar un query largo: +// should_cancel = true; +``` + +--- + +## Busy Handler + +Handler personalizado para cuando la base de datos esta bloqueada. + +```zig +pub const ZigBusyHandlerFn = *const fn (count: i32) bool; // true=reintentar +``` + +### Ejemplo + +```zig +fn myBusyHandler(count: i32) bool { + if (count > 10) return false; // Fallar despues de 10 reintentos + std.time.sleep(100_000_000); // Esperar 100ms + return true; // Reintentar +} + +try db.setBusyHandler(myBusyHandler); +``` + +--- + +## Limits + +Control de limites de SQLite. + +### Limit Types + +```zig +pub const Limit = enum(i32) { + length, // Tamano maximo de string/blob + sql_length, // Longitud maxima de SQL + column, // Columnas por tabla/query + expr_depth, // Profundidad de expresiones + compound_select, // Terminos en SELECT compuesto + vdbe_op, // Operaciones de VM + function_arg, // Argumentos de funcion + attached, // Databases attached + like_pattern_length, // Patron LIKE + variable_number, // Variables SQL + trigger_depth, // Profundidad de triggers + worker_threads, // Threads de trabajo +}; +``` + +### Ejemplo + +```zig +// Obtener limite actual +const sql_limit = db.getLimit(.sql_length); + +// Establecer nuevo limite (retorna el anterior) +const old_limit = db.setLimit(.sql_length, 10000); +``` + +--- + +## Timestamp Binding + +Bind de timestamps Unix como texto ISO8601 (YYYY-MM-DD HH:MM:SS). + +### Funciones + +```zig +pub fn bindTimestamp(self: *Statement, index: u32, ts: i64) Error!void +pub fn bindTimestampNamed(self: *Statement, name: [:0]const u8, ts: i64) Error!void +pub fn bindCurrentTime(self: *Statement, index: u32) Error!void +pub fn bindCurrentTimeNamed(self: *Statement, name: [:0]const u8) Error!void +``` + +### Ejemplo + +```zig +var stmt = try db.prepare("INSERT INTO events (created_at) VALUES (?)"); +try stmt.bindTimestamp(1, 1705314645); // 2024-01-15 10:30:45 UTC +// O usar tiempo actual +try stmt.bindCurrentTime(1); +``` + +--- + +**© zsqlite v0.5 - API Reference** *2025-12-08* diff --git a/docs/CGO_PARITY_ANALYSIS.md b/docs/CGO_PARITY_ANALYSIS.md index 07e05b3..900403a 100644 --- a/docs/CGO_PARITY_ANALYSIS.md +++ b/docs/CGO_PARITY_ANALYSIS.md @@ -65,7 +65,7 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Bind named ($name) | ✅ | ✅ | `stmt.bindIntNamed("$name", val)` | | Readonly check | ✅ | ✅ | `stmt.isReadOnly()` | | SQL text | ✅ | ✅ | `stmt.sql()` | -| Expanded SQL | ✅ | ⏳ | `sqlite3_expanded_sql()` | +| Expanded SQL | ✅ | ✅ | `stmt.expandedSql()` | --- @@ -79,7 +79,7 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | string/text | ✅ | ✅ | `stmt.bindText()` | | []byte/blob | ✅ | ✅ | `stmt.bindBlob()` | | bool | ✅ | ✅ | `stmt.bindBool()` | -| time.Time | ✅ | ⏳ | Formatear como string ISO8601 | +| time.Time | ✅ | ✅ | `stmt.bindTimestamp()` ISO8601 | | Zeroblob | ✅ | ✅ | `stmt.bindZeroblob()` | --- @@ -98,9 +98,9 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | NULL check | ✅ | ✅ | `stmt.columnIsNull()` | | Column bytes | ✅ | ✅ | `stmt.columnBytes()` | | Declared type | ✅ | ✅ | `stmt.columnDeclType()` | -| Database name | ✅ | ⏳ | `sqlite3_column_database_name()` | -| Table name | ✅ | ⏳ | `sqlite3_column_table_name()` | -| Origin name | ✅ | ⏳ | `sqlite3_column_origin_name()` | +| Database name | ✅ | ✅ | `stmt.columnDatabaseName()` | +| Table name | ✅ | ✅ | `stmt.columnTableName()` | +| Origin name | ✅ | ✅ | `stmt.columnOriginName()` | --- @@ -141,8 +141,8 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Funcionalidad | go-sqlite3 | zsqlite | Prioridad | |---------------|------------|---------|-----------| -| GetLimit | ✅ | ⏳ | Baja | -| SetLimit | ✅ | ⏳ | Baja | +| GetLimit | ✅ | ✅ | `db.getLimit()` | +| SetLimit | ✅ | ✅ | `db.setLimit()` | | SetFileControlInt | ✅ | ⏳ | Baja | | Interrupt | ✅ | ✅ | `db.interrupt()` | @@ -155,10 +155,10 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Commit hook | ✅ | ✅ | `db.setCommitHook()` | | Rollback hook | ✅ | ✅ | `db.setRollbackHook()` | | Update hook | ✅ | ✅ | `db.setUpdateHook()` | -| Pre-update hook | ✅ | ⏳ | Baja | -| Authorizer | ✅ | ⏳ | Baja | -| Progress handler | ✅ | ⏳ | Baja | -| Busy handler | ✅ | ⏳ | Media | +| Pre-update hook | ✅ | ✅ | `db.setPreUpdateHook()` | +| Authorizer | ✅ | ✅ | `db.setAuthorizer()` | +| Progress handler | ✅ | ✅ | `db.setProgressHandler()` | +| Busy handler | ✅ | ✅ | `db.setBusyHandler()` | | Busy timeout | ✅ | ✅ | `db.setBusyTimeout()` | --- @@ -229,13 +229,16 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit 3. ✅ Aggregator functions - `db.createAggregateFunction()` 4. ⏳ Mas pragmas -### Fase 3B - Prioridad Baja (Siguiente) -1. ⏳ Authorizer -2. ⏳ Progress handler -3. ⏳ Pre-update hook -4. ⏳ Window functions -5. ⏳ Limits API -6. ⏳ Busy handler (custom callback) +### Fase 3B - Prioridad Baja ✅ COMPLETADA +1. ✅ Authorizer - `db.setAuthorizer()` +2. ✅ Progress handler - `db.setProgressHandler()` +3. ✅ Pre-update hook - `db.setPreUpdateHook()` +4. ✅ Limits API - `db.getLimit()`, `db.setLimit()` +5. ✅ Busy handler (custom callback) - `db.setBusyHandler()` +6. ✅ Timestamp binding - `stmt.bindTimestamp()` +7. ✅ Column metadata - `stmt.columnDatabaseName()`, etc. +8. ✅ Expanded SQL - `stmt.expandedSql()` +9. ⏳ Window functions (baja prioridad) --- diff --git a/src/root.zig b/src/root.zig index 046edcb..be888c9 100644 --- a/src/root.zig +++ b/src/root.zig @@ -24,6 +24,9 @@ const std = @import("std"); const c = @cImport({ + // Define compile flags needed for all features + @cDefine("SQLITE_ENABLE_COLUMN_METADATA", "1"); + @cDefine("SQLITE_ENABLE_PREUPDATE_HOOK", "1"); @cInclude("sqlite3.h"); }); @@ -710,6 +713,44 @@ pub const Database = struct { } } + /// Sets a pre-update hook callback. + /// + /// The pre-update hook is invoked BEFORE each INSERT, UPDATE, and DELETE operation. + /// Unlike the regular update hook, this allows access to the old and new values + /// through the PreUpdateContext parameter. + /// + /// Pass null to remove an existing hook. + /// + /// Note: Requires SQLITE_ENABLE_PREUPDATE_HOOK compile flag (enabled by default). + /// + /// Example: + /// ```zig + /// fn myPreUpdateHook(ctx: PreUpdateContext, op: UpdateOperation, ...) void { + /// if (op == .update) { + /// const old_val = ctx.oldValue(0); + /// const new_val = ctx.newValue(0); + /// // Log the change... + /// } + /// } + /// try db.setPreUpdateHook(myPreUpdateHook); + /// ``` + pub fn setPreUpdateHook(self: *Self, func: ?ZigPreUpdateHookFn) !void { + if (func) |f| { + const wrapper = try PreUpdateHookWrapper.create(f); + const old = c.sqlite3_preupdate_hook(self.handle, preUpdateHookCallback, wrapper); + if (old != null) { + const old_wrapper: *PreUpdateHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } else { + const old = c.sqlite3_preupdate_hook(self.handle, null, null); + if (old != null) { + const old_wrapper: *PreUpdateHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } + } + /// Removes all hooks (commit, rollback, update). pub fn clearHooks(self: *Self) void { const commit_old = c.sqlite3_commit_hook(self.handle, null, null); @@ -730,6 +771,113 @@ pub const Database = struct { wrapper.destroy(); } } + + // ======================================================================== + // Authorizer + // ======================================================================== + + /// Sets an authorizer callback. + /// + /// The authorizer is invoked for each SQL statement to determine + /// whether the operation should be allowed. + /// + /// Pass null to remove an existing authorizer. + /// + /// Example: + /// ```zig + /// fn myAuthorizer(action: AuthAction, arg1: ?[]const u8, ...) AuthResult { + /// if (action == .drop_table) return .deny; + /// return .ok; + /// } + /// try db.setAuthorizer(myAuthorizer); + /// ``` + pub fn setAuthorizer(self: *Self, func: ?ZigAuthorizerFn) !void { + if (func) |f| { + const wrapper = try AuthorizerWrapper.create(f); + const result = c.sqlite3_set_authorizer(self.handle, authorizerCallback, wrapper); + if (result != c.SQLITE_OK) { + wrapper.destroy(); + return resultToError(result); + } + } else { + const result = c.sqlite3_set_authorizer(self.handle, null, null); + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + } + + // ======================================================================== + // Progress Handler + // ======================================================================== + + /// Sets a progress handler callback. + /// + /// The progress handler is invoked periodically during long-running queries. + /// The `n_ops` parameter specifies how many virtual machine operations + /// should occur between callback invocations. + /// + /// Pass null to remove an existing progress handler. + /// + /// Example: + /// ```zig + /// var should_cancel = false; + /// fn checkCancel() bool { + /// return !should_cancel; // return false to interrupt + /// } + /// try db.setProgressHandler(1000, checkCancel); + /// ``` + pub fn setProgressHandler(self: *Self, n_ops: i32, func: ?ZigProgressFn) !void { + if (func) |f| { + const wrapper = try ProgressWrapper.create(f); + c.sqlite3_progress_handler(self.handle, n_ops, progressCallback, wrapper); + } else { + c.sqlite3_progress_handler(self.handle, 0, null, null); + } + } + + // ======================================================================== + // Busy Handler + // ======================================================================== + + /// Sets a custom busy handler callback. + /// + /// The busy handler is invoked when SQLite cannot acquire a lock. + /// The callback receives the number of times it has been called for + /// the current lock attempt. + /// + /// Note: This replaces any busy timeout set with setBusyTimeout(). + /// + /// Pass null to remove an existing busy handler. + pub fn setBusyHandler(self: *Self, func: ?ZigBusyHandlerFn) !void { + if (func) |f| { + const wrapper = try BusyHandlerWrapper.create(f); + const result = c.sqlite3_busy_handler(self.handle, busyHandlerCallback, wrapper); + if (result != c.SQLITE_OK) { + wrapper.destroy(); + return resultToError(result); + } + } else { + const result = c.sqlite3_busy_handler(self.handle, null, null); + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + } + + // ======================================================================== + // Limits + // ======================================================================== + + /// Gets the current value of a limit. + pub fn getLimit(self: *Self, limit_type: Limit) i32 { + return c.sqlite3_limit(self.handle, @intFromEnum(limit_type), -1); + } + + /// Sets a new value for a limit and returns the previous value. + pub fn setLimit(self: *Self, limit_type: Limit, new_value: i32) i32 { + return c.sqlite3_limit(self.handle, @intFromEnum(limit_type), new_value); + } }; /// Flags for opening a database @@ -943,6 +1091,48 @@ pub const Statement = struct { try self.bindBool(idx, value); } + /// Binds a timestamp as ISO8601 text (YYYY-MM-DD HH:MM:SS). + /// + /// The timestamp is stored as text for SQLite date/time function compatibility. + /// This matches the behavior of go-sqlite3 time.Time binding. + pub fn bindTimestamp(self: *Self, index: u32, ts: i64) Error!void { + // Format as ISO8601: YYYY-MM-DD HH:MM:SS + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(ts) }; + const day_seconds = epoch_seconds.getDaySeconds(); + const year_day = epoch_seconds.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + + var buf: [20]u8 = undefined; + const formatted = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ + year_day.year, + @intFromEnum(month_day.month), + @as(u8, month_day.day_index) + 1, // day_index is 0-based, add 1 for display + day_seconds.getHoursIntoDay(), + day_seconds.getMinutesIntoHour(), + day_seconds.getSecondsIntoMinute(), + }) catch return Error.SqliteError; + + try self.bindText(index, formatted); + } + + /// Binds a timestamp to a named parameter as ISO8601 text. + pub fn bindTimestampNamed(self: *Self, name: [:0]const u8, ts: i64) Error!void { + const idx = self.parameterIndex(name) orelse return Error.Range; + try self.bindTimestamp(idx, ts); + } + + /// Binds the current time as ISO8601 text. + pub fn bindCurrentTime(self: *Self, index: u32) Error!void { + const now = std.time.timestamp(); + try self.bindTimestamp(index, now); + } + + /// Binds the current time to a named parameter. + pub fn bindCurrentTimeNamed(self: *Self, name: [:0]const u8) Error!void { + const idx = self.parameterIndex(name) orelse return Error.Range; + try self.bindCurrentTime(idx); + } + // ======================================================================== // Execution // ======================================================================== @@ -1047,6 +1237,63 @@ pub const Statement = struct { } return null; } + + /// Returns the database name for a column result. + /// + /// Returns the database (e.g., "main", "temp") that the column comes from. + /// Note: Requires SQLITE_ENABLE_COLUMN_METADATA to be defined at compile time. + pub fn columnDatabaseName(self: *Self, index: u32) ?[]const u8 { + const name = c.sqlite3_column_database_name(self.handle, @intCast(index)); + if (name) |n| { + return std.mem.span(n); + } + return null; + } + + /// Returns the table name for a column result. + /// + /// Returns the original table name that the column comes from. + /// Note: Requires SQLITE_ENABLE_COLUMN_METADATA to be defined at compile time. + pub fn columnTableName(self: *Self, index: u32) ?[]const u8 { + const name = c.sqlite3_column_table_name(self.handle, @intCast(index)); + if (name) |n| { + return std.mem.span(n); + } + return null; + } + + /// Returns the origin column name for a column result. + /// + /// Returns the original column name from the table definition. + /// Note: Requires SQLITE_ENABLE_COLUMN_METADATA to be defined at compile time. + pub fn columnOriginName(self: *Self, index: u32) ?[]const u8 { + const name = c.sqlite3_column_origin_name(self.handle, @intCast(index)); + if (name) |n| { + return std.mem.span(n); + } + return null; + } + + /// Returns the SQL text with bound parameters expanded. + /// + /// Returns a string with all bound parameter values substituted into + /// the SQL text. The caller must free the returned string using the + /// provided allocator. + /// + /// Returns null if out of memory or if the statement has no bound parameters. + pub fn expandedSql(self: *Self, allocator: std.mem.Allocator) ?[]u8 { + const expanded = c.sqlite3_expanded_sql(self.handle); + if (expanded == null) return null; + + const len = std.mem.len(expanded); + const result = allocator.alloc(u8, len) catch return null; + @memcpy(result, expanded[0..len]); + + // Free SQLite's string + c.sqlite3_free(expanded); + + return result; + } }; // ============================================================================ @@ -1860,6 +2107,283 @@ fn updateHookCallback( wrapper.func(op, db_str, table_str, rowid); } +// ============================================================================ +// Authorizer +// ============================================================================ + +/// Authorization action codes returned by the authorizer callback. +pub const AuthAction = enum(i32) { + create_index = c.SQLITE_CREATE_INDEX, + create_table = c.SQLITE_CREATE_TABLE, + create_temp_index = c.SQLITE_CREATE_TEMP_INDEX, + create_temp_table = c.SQLITE_CREATE_TEMP_TABLE, + create_temp_trigger = c.SQLITE_CREATE_TEMP_TRIGGER, + create_temp_view = c.SQLITE_CREATE_TEMP_VIEW, + create_trigger = c.SQLITE_CREATE_TRIGGER, + create_view = c.SQLITE_CREATE_VIEW, + delete = c.SQLITE_DELETE, + drop_index = c.SQLITE_DROP_INDEX, + drop_table = c.SQLITE_DROP_TABLE, + drop_temp_index = c.SQLITE_DROP_TEMP_INDEX, + drop_temp_table = c.SQLITE_DROP_TEMP_TABLE, + drop_temp_trigger = c.SQLITE_DROP_TEMP_TRIGGER, + drop_temp_view = c.SQLITE_DROP_TEMP_VIEW, + drop_trigger = c.SQLITE_DROP_TRIGGER, + drop_view = c.SQLITE_DROP_VIEW, + insert = c.SQLITE_INSERT, + pragma = c.SQLITE_PRAGMA, + read = c.SQLITE_READ, + select = c.SQLITE_SELECT, + transaction = c.SQLITE_TRANSACTION, + update = c.SQLITE_UPDATE, + attach = c.SQLITE_ATTACH, + detach = c.SQLITE_DETACH, + alter_table = c.SQLITE_ALTER_TABLE, + reindex = c.SQLITE_REINDEX, + analyze = c.SQLITE_ANALYZE, + create_vtable = c.SQLITE_CREATE_VTABLE, + drop_vtable = c.SQLITE_DROP_VTABLE, + function = c.SQLITE_FUNCTION, + savepoint = c.SQLITE_SAVEPOINT, + recursive = c.SQLITE_RECURSIVE, + + pub fn fromInt(value: i32) ?AuthAction { + inline for (@typeInfo(AuthAction).@"enum".fields) |field| { + if (field.value == value) return @enumFromInt(value); + } + return null; + } +}; + +/// Authorization return codes. +pub const AuthResult = enum(i32) { + ok = c.SQLITE_OK, + deny = c.SQLITE_DENY, + ignore = c.SQLITE_IGNORE, +}; + +/// Zig-friendly authorizer callback type. +/// Parameters: action, arg1 (table/index name), arg2 (column/trigger name), +/// arg3 (database name), arg4 (trigger/view name) +/// Returns: .ok to allow, .deny to abort with error, .ignore to treat as NULL +pub const ZigAuthorizerFn = *const fn ( + action: AuthAction, + arg1: ?[]const u8, + arg2: ?[]const u8, + arg3: ?[]const u8, + arg4: ?[]const u8, +) AuthResult; + +/// Wrapper for authorizer callback. +const AuthorizerWrapper = struct { + func: ZigAuthorizerFn, + + fn create(func: ZigAuthorizerFn) !*AuthorizerWrapper { + const wrapper = try std.heap.page_allocator.create(AuthorizerWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *AuthorizerWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for authorizer. +fn authorizerCallback( + user_data: ?*anyopaque, + action: c_int, + arg1: [*c]const u8, + arg2: [*c]const u8, + arg3: [*c]const u8, + arg4: [*c]const u8, +) callconv(.c) c_int { + if (user_data == null) return c.SQLITE_OK; + + const wrapper: *AuthorizerWrapper = @ptrCast(@alignCast(user_data)); + const auth_action = AuthAction.fromInt(action) orelse return c.SQLITE_OK; + + const s1: ?[]const u8 = if (arg1 != null) std.mem.span(arg1) else null; + const s2: ?[]const u8 = if (arg2 != null) std.mem.span(arg2) else null; + const s3: ?[]const u8 = if (arg3 != null) std.mem.span(arg3) else null; + const s4: ?[]const u8 = if (arg4 != null) std.mem.span(arg4) else null; + + return @intFromEnum(wrapper.func(auth_action, s1, s2, s3, s4)); +} + +// ============================================================================ +// Progress Handler +// ============================================================================ + +/// Zig-friendly progress handler callback type. +/// Called periodically during long-running queries. +/// Return true to continue, false to interrupt the query. +pub const ZigProgressFn = *const fn () bool; + +/// Wrapper for progress handler callback. +const ProgressWrapper = struct { + func: ZigProgressFn, + + fn create(func: ZigProgressFn) !*ProgressWrapper { + const wrapper = try std.heap.page_allocator.create(ProgressWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *ProgressWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for progress handler. +fn progressCallback(user_data: ?*anyopaque) callconv(.c) c_int { + if (user_data == null) return 0; + + const wrapper: *ProgressWrapper = @ptrCast(@alignCast(user_data)); + const should_continue = wrapper.func(); + return if (should_continue) 0 else 1; +} + +// ============================================================================ +// Busy Handler +// ============================================================================ + +/// Zig-friendly busy handler callback type. +/// Called when the database is locked. +/// Parameter: number of times the busy handler has been invoked for this lock. +/// Return true to retry, false to return SQLITE_BUSY error. +pub const ZigBusyHandlerFn = *const fn (count: i32) bool; + +/// Wrapper for busy handler callback. +const BusyHandlerWrapper = struct { + func: ZigBusyHandlerFn, + + fn create(func: ZigBusyHandlerFn) !*BusyHandlerWrapper { + const wrapper = try std.heap.page_allocator.create(BusyHandlerWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *BusyHandlerWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for busy handler. +fn busyHandlerCallback(user_data: ?*anyopaque, count: c_int) callconv(.c) c_int { + if (user_data == null) return 0; + + const wrapper: *BusyHandlerWrapper = @ptrCast(@alignCast(user_data)); + const should_retry = wrapper.func(count); + return if (should_retry) 1 else 0; +} + +// ============================================================================ +// Limits +// ============================================================================ + +/// SQLite limit categories. +pub const Limit = enum(i32) { + length = c.SQLITE_LIMIT_LENGTH, + sql_length = c.SQLITE_LIMIT_SQL_LENGTH, + column = c.SQLITE_LIMIT_COLUMN, + expr_depth = c.SQLITE_LIMIT_EXPR_DEPTH, + compound_select = c.SQLITE_LIMIT_COMPOUND_SELECT, + vdbe_op = c.SQLITE_LIMIT_VDBE_OP, + function_arg = c.SQLITE_LIMIT_FUNCTION_ARG, + attached = c.SQLITE_LIMIT_ATTACHED, + like_pattern_length = c.SQLITE_LIMIT_LIKE_PATTERN_LENGTH, + variable_number = c.SQLITE_LIMIT_VARIABLE_NUMBER, + trigger_depth = c.SQLITE_LIMIT_TRIGGER_DEPTH, + worker_threads = c.SQLITE_LIMIT_WORKER_THREADS, +}; + +// ============================================================================ +// Pre-Update Hook +// ============================================================================ + +/// Pre-update hook context providing access to old/new values. +/// Only valid during the pre-update hook callback execution. +pub const PreUpdateContext = struct { + db: *c.sqlite3, + + /// Returns the number of columns in the row being modified. + pub fn columnCount(self: PreUpdateContext) i32 { + return c.sqlite3_preupdate_count(self.db); + } + + /// Returns the depth of the trigger that caused the pre-update. + /// 0 = direct operation, 1 = top-level trigger, 2 = trigger from trigger, etc. + pub fn depth(self: PreUpdateContext) i32 { + return c.sqlite3_preupdate_depth(self.db); + } + + /// Gets the old value for column N (0-indexed). + /// Only valid for UPDATE and DELETE operations. + pub fn oldValue(self: PreUpdateContext, col: u32) ?FunctionValue { + var value: ?*c.sqlite3_value = null; + const result = c.sqlite3_preupdate_old(self.db, @intCast(col), &value); + if (result != c.SQLITE_OK or value == null) return null; + return FunctionValue{ .value = value.? }; + } + + /// Gets the new value for column N (0-indexed). + /// Only valid for UPDATE and INSERT operations. + pub fn newValue(self: PreUpdateContext, col: u32) ?FunctionValue { + var value: ?*c.sqlite3_value = null; + const result = c.sqlite3_preupdate_new(self.db, @intCast(col), &value); + if (result != c.SQLITE_OK or value == null) return null; + return FunctionValue{ .value = value.? }; + } +}; + +/// Zig-friendly pre-update hook callback type. +/// Parameters: context, operation, database name, table name, old rowid, new rowid +pub const ZigPreUpdateHookFn = *const fn ( + ctx: PreUpdateContext, + operation: UpdateOperation, + db_name: []const u8, + table_name: []const u8, + old_rowid: i64, + new_rowid: i64, +) void; + +/// Wrapper for pre-update hook callback. +const PreUpdateHookWrapper = struct { + func: ZigPreUpdateHookFn, + + fn create(func: ZigPreUpdateHookFn) !*PreUpdateHookWrapper { + const wrapper = try std.heap.page_allocator.create(PreUpdateHookWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *PreUpdateHookWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for pre-update hook. +fn preUpdateHookCallback( + user_data: ?*anyopaque, + db: ?*c.sqlite3, + operation: c_int, + db_name: [*c]const u8, + table_name: [*c]const u8, + old_rowid: c.sqlite3_int64, + new_rowid: c.sqlite3_int64, +) callconv(.c) void { + if (user_data == null or db == null) return; + + const wrapper: *PreUpdateHookWrapper = @ptrCast(@alignCast(user_data)); + const op = UpdateOperation.fromInt(operation) orelse return; + const db_str = std.mem.span(db_name); + const table_str = std.mem.span(table_name); + const ctx = PreUpdateContext{ .db = db.? }; + + wrapper.func(ctx, op, db_str, table_str, old_rowid, new_rowid); +} + // ============================================================================ // Convenience functions // ============================================================================ @@ -2629,3 +3153,267 @@ test "aggregate function - group concat" { _ = try stmt.step(); try std.testing.expectEqualStrings("Alice,Bob,Charlie", stmt.columnText(0).?); } + +// Authorizer test helper +var auth_deny_drop_table = false; + +fn testAuthorizer( + action: AuthAction, + _: ?[]const u8, + _: ?[]const u8, + _: ?[]const u8, + _: ?[]const u8, +) AuthResult { + if (action == .drop_table and auth_deny_drop_table) { + return .deny; + } + return .ok; +} + +test "authorizer callback" { + var db = try openMemory(); + defer db.close(); + + try db.setAuthorizer(testAuthorizer); + + // This should work + try db.exec("CREATE TABLE test (x INTEGER)"); + + // Allow drop table + auth_deny_drop_table = false; + try db.exec("DROP TABLE test"); + + // Create again + try db.exec("CREATE TABLE test (x INTEGER)"); + + // Now deny drop table + auth_deny_drop_table = true; + const result = db.exec("DROP TABLE test"); + try std.testing.expectError(Error.Auth, result); + + // Remove authorizer + try db.setAuthorizer(null); + auth_deny_drop_table = false; +} + +// Progress handler test helper +var progress_call_count: u32 = 0; + +fn testProgressHandler() bool { + progress_call_count += 1; + return true; // Continue +} + +test "progress handler" { + progress_call_count = 0; + + var db = try openMemory(); + defer db.close(); + + try db.setProgressHandler(1, testProgressHandler); + + // Create table and insert data to trigger some VM operations + try db.exec("CREATE TABLE test (x INTEGER)"); + try db.exec("INSERT INTO test VALUES (1), (2), (3), (4), (5)"); + + // Progress handler should have been called multiple times + try std.testing.expect(progress_call_count > 0); + + // Remove handler + try db.setProgressHandler(0, null); +} + +test "limits API" { + var db = try openMemory(); + defer db.close(); + + // Get current SQL length limit + const old_limit = db.getLimit(.sql_length); + try std.testing.expect(old_limit > 0); + + // Set new limit + const prev = db.setLimit(.sql_length, 10000); + try std.testing.expectEqual(old_limit, prev); + + // Verify new limit + const new_limit = db.getLimit(.sql_length); + try std.testing.expectEqual(@as(i32, 10000), new_limit); + + // Restore original + _ = db.setLimit(.sql_length, old_limit); +} + +test "expanded SQL" { + const allocator = std.testing.allocator; + + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE users (id INTEGER, name TEXT)"); + + var stmt = try db.prepare("SELECT * FROM users WHERE id = ? AND name = ?"); + defer stmt.finalize(); + + try stmt.bindInt(1, 42); + try stmt.bindText(2, "Alice"); + + if (stmt.expandedSql(allocator)) |expanded| { + defer allocator.free(expanded); + // The expanded SQL should contain the actual values + try std.testing.expect(std.mem.indexOf(u8, expanded, "42") != null); + try std.testing.expect(std.mem.indexOf(u8, expanded, "'Alice'") != null); + } +} + +test "column metadata" { + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)"); + try db.exec("INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')"); + + var stmt = try db.prepare("SELECT id, name, email FROM users"); + defer stmt.finalize(); + + _ = try stmt.step(); + + // Check column database name + if (stmt.columnDatabaseName(0)) |db_name| { + try std.testing.expectEqualStrings("main", db_name); + } + + // Check column table name + if (stmt.columnTableName(0)) |table_name| { + try std.testing.expectEqualStrings("users", table_name); + } + + // Check column origin name + if (stmt.columnOriginName(0)) |origin_name| { + try std.testing.expectEqualStrings("id", origin_name); + } + if (stmt.columnOriginName(1)) |origin_name| { + try std.testing.expectEqualStrings("name", origin_name); + } +} + +// Pre-update hook test helpers +var preupdate_call_count: u32 = 0; +var preupdate_old_value: ?i64 = null; +var preupdate_new_value: ?i64 = null; +var preupdate_op: ?UpdateOperation = null; + +fn testPreUpdateHook( + ctx: PreUpdateContext, + op: UpdateOperation, + _: []const u8, // db_name + _: []const u8, // table_name + _: i64, // old_rowid + _: i64, // new_rowid +) void { + preupdate_call_count += 1; + preupdate_op = op; + + // Get old/new values depending on operation + if (op == .update or op == .delete) { + if (ctx.oldValue(0)) |old_val| { + preupdate_old_value = old_val.asInt(); + } + } + if (op == .update or op == .insert) { + if (ctx.newValue(0)) |new_val| { + preupdate_new_value = new_val.asInt(); + } + } +} + +test "pre-update hook" { + preupdate_call_count = 0; + preupdate_old_value = null; + preupdate_new_value = null; + preupdate_op = null; + + var db = try openMemory(); + defer db.close(); + + try db.setPreUpdateHook(testPreUpdateHook); + + try db.exec("CREATE TABLE test (x INTEGER)"); + + // Insert + preupdate_call_count = 0; + try db.exec("INSERT INTO test VALUES (10)"); + try std.testing.expect(preupdate_call_count > 0); + try std.testing.expectEqual(UpdateOperation.insert, preupdate_op.?); + try std.testing.expectEqual(@as(i64, 10), preupdate_new_value.?); + + // Update + preupdate_call_count = 0; + preupdate_old_value = null; + preupdate_new_value = null; + try db.exec("UPDATE test SET x = 20 WHERE x = 10"); + try std.testing.expect(preupdate_call_count > 0); + try std.testing.expectEqual(UpdateOperation.update, preupdate_op.?); + try std.testing.expectEqual(@as(i64, 10), preupdate_old_value.?); + try std.testing.expectEqual(@as(i64, 20), preupdate_new_value.?); + + // Delete + preupdate_call_count = 0; + preupdate_old_value = null; + try db.exec("DELETE FROM test WHERE x = 20"); + try std.testing.expect(preupdate_call_count > 0); + try std.testing.expectEqual(UpdateOperation.delete, preupdate_op.?); + try std.testing.expectEqual(@as(i64, 20), preupdate_old_value.?); + + // Remove hook + try db.setPreUpdateHook(null); +} + +test "timestamp binding" { + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT)"); + + // Insert with specific timestamp: 2024-01-15 10:30:45 UTC + // 1705314645 is Unix timestamp for 2024-01-15 10:30:45 UTC + var stmt = try db.prepare("INSERT INTO events (name, created_at) VALUES (?, ?)"); + defer stmt.finalize(); + + try stmt.bindText(1, "test event"); + try stmt.bindTimestamp(2, 1705314645); + _ = try stmt.step(); + + // Read it back + var query = try db.prepare("SELECT created_at FROM events WHERE name = ?"); + defer query.finalize(); + + try query.bindText(1, "test event"); + const has_row = try query.step(); + try std.testing.expect(has_row); + + const created_at = query.columnText(0) orelse ""; + try std.testing.expectEqualStrings("2024-01-15 10:30:45", created_at); +} + +test "timestamp binding named" { + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE events (id INTEGER PRIMARY KEY, created_at TEXT)"); + + var stmt = try db.prepare("INSERT INTO events (created_at) VALUES (:ts)"); + defer stmt.finalize(); + + // 2020-06-15 12:00:00 UTC = 1592222400 + try stmt.bindTimestampNamed(":ts", 1592222400); + _ = try stmt.step(); + + var query = try db.prepare("SELECT created_at FROM events"); + defer query.finalize(); + + const has_row = try query.step(); + try std.testing.expect(has_row); + + const created_at = query.columnText(0) orelse ""; + try std.testing.expectEqualStrings("2020-06-15 12:00:00", created_at); +}