From 7742f446676e0a63e37824c57078917877904bbb Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 18:02:01 +0100 Subject: [PATCH] v0.4: Fase 3A complete - Blob I/O, Hooks, Aggregate Functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Blob I/O: Incremental read/write for large BLOBs - Blob.open(), close(), deinit() - Blob.read(), write() with offset support - Blob.bytes(), reopen(), readAll() - Hooks: Monitor database changes - setCommitHook() - called on transaction commit - setRollbackHook() - called on transaction rollback - setUpdateHook() - called on INSERT/UPDATE/DELETE - clearHooks() - remove all hooks - UpdateOperation enum (insert, update, delete) - Aggregate Functions: Custom multi-row aggregates - createAggregateFunction(name, num_args, step_fn, final_fn) - AggregateContext with getAggregateContext() for state management - Support for setNull/Int/Float/Text/Blob/Error results Documentation: - Updated docs/API.md to v0.4 with new features and examples - Updated docs/CGO_PARITY_ANALYSIS.md - Fase 3A marked complete - Updated CLAUDE.md to v0.4 with all new implementations Tests: 28 total (8 new tests for Fase 3A features) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 38 +- docs/API.md | 169 ++++++- docs/CGO_PARITY_ANALYSIS.md | 31 +- src/root.zig | 863 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1077 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0a2da7..a610ac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Ultima actualizacion**: 2025-12-08 > **Lenguaje**: Zig 0.15.2 -> **Estado**: v0.3 - Fase 2B completada +> **Estado**: v0.4 - Fase 3A completada > **Inspiracion**: CGo go-sqlite3, SQLite C API ## Descripcion del Proyecto @@ -22,7 +22,7 @@ ## Estado Actual del Proyecto -### Implementacion v0.3 (Fase 2B Completada) +### Implementacion v0.4 (Fase 3A Completada) | Componente | Estado | Archivo | |------------|--------|---------| @@ -102,6 +102,20 @@ | **Custom Collations** | | | | createCollation() | ✅ | `src/root.zig` | | removeCollation() | ✅ | `src/root.zig` | +| **Aggregate Functions** | | | +| createAggregateFunction() | ✅ | `src/root.zig` | +| AggregateContext | ✅ | `src/root.zig` | +| **Blob I/O** | | | +| Blob.open/openAlloc | ✅ | `src/root.zig` | +| Blob.read/write | ✅ | `src/root.zig` | +| Blob.bytes | ✅ | `src/root.zig` | +| Blob.reopen | ✅ | `src/root.zig` | +| Blob.readAll | ✅ | `src/root.zig` | +| **Hooks** | | | +| setCommitHook | ✅ | `src/root.zig` | +| setRollbackHook | ✅ | `src/root.zig` | +| setUpdateHook | ✅ | `src/root.zig` | +| clearHooks | ✅ | `src/root.zig` | | **Utilidades** | | | | lastInsertRowId | ✅ | `src/root.zig` | | changes/totalChanges | ✅ | `src/root.zig` | @@ -136,7 +150,10 @@ | ATTACH/DETACH | 1 | ✅ | | User-defined functions | 1 | ✅ | | Custom collations | 1 | ✅ | -| **Total** | **20** | ✅ | +| Blob I/O | 3 | ✅ | +| Hooks | 3 | ✅ | +| Aggregate functions | 2 | ✅ | +| **Total** | **28** | ✅ | --- @@ -171,12 +188,19 @@ - [ ] Batch bind con tuples/structs - [ ] Row iterator idiomatico -### Fase 3 - Avanzado (EN PROGRESO) -- [ ] Blob streaming (para archivos grandes) -- [ ] User-defined functions (aggregate) +### Fase 3A - Avanzado (COMPLETADO) +- [x] Blob streaming (para archivos grandes) +- [x] User-defined functions (aggregate) +- [x] Update/Commit/Rollback hooks + +### Fase 3B - Avanzado (EN PROGRESO) - [ ] Authorizer callback - [ ] Progress handler -- [ ] Update/Commit hooks +- [ ] Pre-update hook +- [ ] Window functions +- [ ] Busy handler (custom callback) +- [ ] Batch bind con tuples/structs +- [ ] Row iterator idiomatico - [ ] Connection pooling ### Fase 4 - Extras diff --git a/docs/API.md b/docs/API.md index 1d1772e..930c75e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # zsqlite - API Reference -> **Version**: 0.3 +> **Version**: 0.4 > **Ultima actualizacion**: 2025-12-08 ## Quick Reference @@ -45,9 +45,20 @@ var restored = try sqlite.loadFromFile("backup.db"); // User-defined functions try db.createScalarFunction("double", 1, myDoubleFunc); +try db.createAggregateFunction("sum_squares", 1, stepFn, finalFn); // Custom collations try db.createCollation("NOCASE2", myCaseInsensitiveCompare); + +// Blob I/O +var blob = try sqlite.Blob.open(&db, "main", "table", "column", rowid, true); +defer blob.deinit(); +try blob.write(data, 0); +try blob.read(&buffer, 0); + +// Hooks +try db.setCommitHook(myCommitHook); +try db.setUpdateHook(myUpdateHook); ``` --- @@ -527,7 +538,161 @@ try db.createCollation("ICASE", caseInsensitive); // SELECT * FROM users ORDER BY name COLLATE ICASE ``` +### Aggregate Function: Sum of Squares + +```zig +const SumState = struct { + total: i64 = 0, +}; + +fn sumSquaresStep(ctx: sqlite.AggregateContext, args: []const sqlite.FunctionValue) void { + const state = ctx.getAggregateContext(SumState) orelse return; + if (args.len > 0 and !args[0].isNull()) { + const val = args[0].asInt(); + state.total += val * val; + } +} + +fn sumSquaresFinal(ctx: sqlite.AggregateContext) void { + const state = ctx.getAggregateContext(SumState) orelse { + ctx.setNull(); + return; + }; + ctx.setInt(state.total); +} + +try db.createAggregateFunction("sum_squares", 1, sumSquaresStep, sumSquaresFinal); +// SELECT sum_squares(value) FROM numbers => 1+4+9+16+25 = 55 +``` + +### Blob I/O: Incremental Read/Write + +```zig +// Insert placeholder blob +try db.exec("INSERT INTO files (data) VALUES (zeroblob(1024))"); +const rowid = db.lastInsertRowId(); + +// Open for writing +var blob = try sqlite.Blob.open(&db, "main", "files", "data", rowid, true); +defer blob.deinit(); + +// Write data +try blob.write("Hello, World!", 0); + +// Read data +var buffer: [100]u8 = undefined; +try blob.read(&buffer, 0); +``` + +### Hooks: Monitor Database Changes + +```zig +fn onCommit() bool { + std.debug.print("Transaction committed!\n", .{}); + return true; // Allow commit +} + +fn onUpdate(op: sqlite.UpdateOperation, db_name: []const u8, table: []const u8, rowid: i64) void { + std.debug.print("{s} on {s}.{s} row {d}\n", .{ + @tagName(op), db_name, table, rowid + }); +} + +try db.setCommitHook(onCommit); +try db.setUpdateHook(onUpdate); +// ... operations will trigger hooks ... +db.clearHooks(); // Remove all hooks +``` + --- -**© zsqlite v0.3 - API Reference** +## Blob + +```zig +pub const Blob = struct { + pub fn open(db: *Database, schema: [:0]const u8, table: [:0]const u8, + column: [:0]const u8, rowid: i64, writable: bool) Error!Blob + pub fn openAlloc(db: *Database, allocator: Allocator, ...) !Blob + pub fn close(self: *Blob) Error!void + pub fn deinit(self: *Blob) void + pub fn bytes(self: *Blob) i32 + pub fn read(self: *Blob, buffer: []u8, offset: i32) Error!void + pub fn write(self: *Blob, data: []const u8, offset: i32) Error!void + pub fn reopen(self: *Blob, rowid: i64) Error!void + pub fn readAll(self: *Blob, allocator: Allocator) ![]u8 +}; +``` + +--- + +## Hooks + +### Types + +```zig +pub const ZigCommitHookFn = *const fn () bool; +pub const ZigRollbackHookFn = *const fn () void; +pub const ZigUpdateHookFn = *const fn ( + operation: UpdateOperation, + db_name: []const u8, + table_name: []const u8, + rowid: i64 +) void; + +pub const UpdateOperation = enum(i32) { + insert, + update, + delete, +}; +``` + +### Database Methods + +| Method | Description | +|--------|-------------| +| `setCommitHook(fn)` | Called when transaction commits | +| `setRollbackHook(fn)` | Called when transaction rolls back | +| `setUpdateHook(fn)` | Called on INSERT/UPDATE/DELETE | +| `clearHooks()` | Remove all hooks | + +--- + +## Aggregate Functions + +### Types + +```zig +pub const AggregateStepFn = *const fn (ctx: AggregateContext, args: []const FunctionValue) void; +pub const AggregateFinalFn = *const fn (ctx: AggregateContext) void; +``` + +### AggregateContext + +```zig +pub const AggregateContext = struct { + pub fn getAggregateContext(self: Self, comptime T: type) ?*T + pub fn setNull(self: Self) void + pub fn setInt(self: Self, value: i64) void + pub fn setFloat(self: Self, value: f64) void + pub fn setText(self: Self, value: []const u8) void + pub fn setBlob(self: Self, value: []const u8) void + pub fn setError(self: Self, msg: []const u8) void +}; +``` + +### Database Method + +```zig +pub fn createAggregateFunction( + self: *Database, + name: [:0]const u8, + num_args: i32, + step_fn: AggregateStepFn, + final_fn: AggregateFinalFn, +) !void +``` + +--- + +**© zsqlite v0.4 - API Reference** *2025-12-08* diff --git a/docs/CGO_PARITY_ANALYSIS.md b/docs/CGO_PARITY_ANALYSIS.md index aa07372..07e05b3 100644 --- a/docs/CGO_PARITY_ANALYSIS.md +++ b/docs/CGO_PARITY_ANALYSIS.md @@ -152,9 +152,9 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Funcionalidad | go-sqlite3 | zsqlite | Prioridad | |---------------|------------|---------|-----------| -| Commit hook | ✅ | ⏳ | Media | -| Rollback hook | ✅ | ⏳ | Media | -| Update hook | ✅ | ⏳ | Media | +| Commit hook | ✅ | ✅ | `db.setCommitHook()` | +| Rollback hook | ✅ | ✅ | `db.setRollbackHook()` | +| Update hook | ✅ | ✅ | `db.setUpdateHook()` | | Pre-update hook | ✅ | ⏳ | Baja | | Authorizer | ✅ | ⏳ | Baja | | Progress handler | ✅ | ⏳ | Baja | @@ -168,7 +168,7 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Funcionalidad | go-sqlite3 | zsqlite | Prioridad | |---------------|------------|---------|-----------| | RegisterFunc (scalar) | ✅ | ✅ | `db.createScalarFunction()` | -| RegisterAggregator | ✅ | ⏳ | Media | +| RegisterAggregator | ✅ | ✅ | `db.createAggregateFunction()` | | RegisterCollation | ✅ | ✅ | `db.createCollation()` | | User-defined window func | ✅ | ⏳ | Baja | @@ -190,12 +190,12 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit | Funcionalidad | go-sqlite3 | zsqlite | Prioridad | |---------------|------------|---------|-----------| -| Blob open | ✅ | ⏳ | Media | -| Blob close | ✅ | ⏳ | Media | -| Blob read | ✅ | ⏳ | Media | -| Blob write | ✅ | ⏳ | Media | -| Blob bytes | ✅ | ⏳ | Media | -| Blob reopen | ✅ | ⏳ | Baja | +| Blob open | ✅ | ✅ | `Blob.open()` | +| Blob close | ✅ | ✅ | `blob.close()` | +| Blob read | ✅ | ✅ | `blob.read()` | +| Blob write | ✅ | ✅ | `blob.write()` | +| Blob bytes | ✅ | ✅ | `blob.bytes()` | +| Blob reopen | ✅ | ✅ | `blob.reopen()` | --- @@ -223,18 +223,19 @@ Este documento analiza todas sus funcionalidades para asegurar paridad en zsqlit 3. ✅ Collations personalizadas - `db.createCollation()` 4. ✅ ATTACH/DETACH - `db.attach()`, `db.detach()`, `db.listDatabases()` -### Fase 3A - Prioridad Media (Siguiente) -1. ⏳ Blob I/O streaming -2. ⏳ Hooks (commit, rollback, update) -3. ⏳ Aggregator functions +### Fase 3A - Prioridad Media ✅ COMPLETADA +1. ✅ Blob I/O streaming - `Blob` struct con `read()`, `write()`, `reopen()` +2. ✅ Hooks (commit, rollback, update) - `db.setCommitHook()`, etc. +3. ✅ Aggregator functions - `db.createAggregateFunction()` 4. ⏳ Mas pragmas -### Fase 3B - Prioridad Baja +### 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) --- diff --git a/src/root.zig b/src/root.zig index bb1f96b..046edcb 100644 --- a/src/root.zig +++ b/src/root.zig @@ -512,6 +512,65 @@ pub const Database = struct { } } + /// Registers a custom aggregate function. + /// + /// Aggregate functions process multiple rows and return a single result. + /// They require two callbacks: + /// - step: Called for each row, accumulates the result + /// - final: Called once at the end to produce the final result + /// + /// Example (sum of squares): + /// ```zig + /// const SumSquaresState = struct { + /// total: i64 = 0, + /// }; + /// + /// fn sumSquaresStep(ctx: AggregateContext, args: []const FunctionValue) void { + /// const state = ctx.getAggregateContext(SumSquaresState) orelse return; + /// if (args.len > 0 and !args[0].isNull()) { + /// const val = args[0].asInt(); + /// state.total += val * val; + /// } + /// } + /// + /// fn sumSquaresFinal(ctx: AggregateContext) void { + /// const state = ctx.getAggregateContext(SumSquaresState) orelse { + /// ctx.setNull(); + /// return; + /// }; + /// ctx.setInt(state.total); + /// } + /// + /// try db.createAggregateFunction("sum_squares", 1, sumSquaresStep, sumSquaresFinal); + /// // SELECT sum_squares(value) FROM numbers; + /// ``` + pub fn createAggregateFunction( + self: *Self, + name: [:0]const u8, + num_args: i32, + step_fn: AggregateStepFn, + final_fn: AggregateFinalFn, + ) !void { + const wrapper = try AggregateFnWrapper.create(step_fn, final_fn); + + const result = c.sqlite3_create_function_v2( + self.handle, + name.ptr, + num_args, + c.SQLITE_UTF8, + wrapper, + null, // xFunc (for scalar) + aggregateStepCallback, + aggregateFinalCallback, + aggregateDestructor, + ); + + if (result != c.SQLITE_OK) { + wrapper.destroy(); + return resultToError(result); + } + } + // ======================================================================== // Custom Collations // ======================================================================== @@ -567,6 +626,110 @@ pub const Database = struct { return resultToError(result); } } + + // ======================================================================== + // Hooks + // ======================================================================== + + /// Sets a commit hook callback. + /// + /// The callback is invoked whenever a transaction is committed. + /// Return true to allow the commit, false to force a rollback. + /// + /// Pass null to remove an existing hook. + /// + /// Note: Only one commit hook can be active at a time. + pub fn setCommitHook(self: *Self, func: ?ZigCommitHookFn) !void { + if (func) |f| { + const wrapper = try CommitHookWrapper.create(f); + const old = c.sqlite3_commit_hook(self.handle, commitHookCallback, wrapper); + // Destroy old wrapper if it existed + if (old != null) { + const old_wrapper: *CommitHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } else { + const old = c.sqlite3_commit_hook(self.handle, null, null); + if (old != null) { + const old_wrapper: *CommitHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } + } + + /// Sets a rollback hook callback. + /// + /// The callback is invoked whenever a transaction is rolled back. + /// + /// Pass null to remove an existing hook. + /// + /// Note: Only one rollback hook can be active at a time. + pub fn setRollbackHook(self: *Self, func: ?ZigRollbackHookFn) !void { + if (func) |f| { + const wrapper = try RollbackHookWrapper.create(f); + const old = c.sqlite3_rollback_hook(self.handle, rollbackHookCallback, wrapper); + if (old != null) { + const old_wrapper: *RollbackHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } else { + const old = c.sqlite3_rollback_hook(self.handle, null, null); + if (old != null) { + const old_wrapper: *RollbackHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } + } + + /// Sets an update hook callback. + /// + /// The callback is invoked whenever a row is inserted, updated, or deleted. + /// The callback receives: + /// - operation: .insert, .update, or .delete + /// - db_name: Database name (e.g., "main") + /// - table_name: Table name + /// - rowid: Row ID of the affected row + /// + /// Pass null to remove an existing hook. + /// + /// Note: Only one update hook can be active at a time. + pub fn setUpdateHook(self: *Self, func: ?ZigUpdateHookFn) !void { + if (func) |f| { + const wrapper = try UpdateHookWrapper.create(f); + const old = c.sqlite3_update_hook(self.handle, updateHookCallback, wrapper); + if (old != null) { + const old_wrapper: *UpdateHookWrapper = @ptrCast(@alignCast(old)); + old_wrapper.destroy(); + } + } else { + const old = c.sqlite3_update_hook(self.handle, null, null); + if (old != null) { + const old_wrapper: *UpdateHookWrapper = @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); + if (commit_old != null) { + const wrapper: *CommitHookWrapper = @ptrCast(@alignCast(commit_old)); + wrapper.destroy(); + } + + const rollback_old = c.sqlite3_rollback_hook(self.handle, null, null); + if (rollback_old != null) { + const wrapper: *RollbackHookWrapper = @ptrCast(@alignCast(rollback_old)); + wrapper.destroy(); + } + + const update_old = c.sqlite3_update_hook(self.handle, null, null); + if (update_old != null) { + const wrapper: *UpdateHookWrapper = @ptrCast(@alignCast(update_old)); + wrapper.destroy(); + } + } }; /// Flags for opening a database @@ -1206,6 +1369,139 @@ fn scalarDestructor(ptr: ?*anyopaque) callconv(.c) void { } } +// ============================================================================ +// Aggregate Functions +// ============================================================================ + +/// Type signature for aggregate step function. +/// Called once for each row in the aggregate. +pub const AggregateStepFn = *const fn (ctx: AggregateContext, args: []const FunctionValue) void; + +/// Type signature for aggregate final function. +/// Called once at the end to produce the result. +pub const AggregateFinalFn = *const fn (ctx: AggregateContext) void; + +/// Context for aggregate functions. +/// +/// Provides access to aggregate-specific memory that persists +/// across all step() calls for a single aggregate computation. +pub const AggregateContext = struct { + ctx: *c.sqlite3_context, + + const Self = @This(); + + /// Gets the aggregate context memory. + /// The memory is initialized to zero on first call. + /// `size` is the number of bytes of context needed. + pub fn getAggregateContext(self: Self, comptime T: type) ?*T { + const ptr = c.sqlite3_aggregate_context(self.ctx, @sizeOf(T)); + if (ptr == null) return null; + return @ptrCast(@alignCast(ptr)); + } + + /// Sets the result to NULL. + pub fn setNull(self: Self) void { + c.sqlite3_result_null(self.ctx); + } + + /// Sets the result to an integer. + pub fn setInt(self: Self, value: i64) void { + c.sqlite3_result_int64(self.ctx, value); + } + + /// Sets the result to a float. + pub fn setFloat(self: Self, value: f64) void { + c.sqlite3_result_double(self.ctx, value); + } + + /// Sets the result to text. + pub fn setText(self: Self, value: []const u8) void { + c.sqlite3_result_text( + self.ctx, + value.ptr, + @intCast(value.len), + c.SQLITE_TRANSIENT, + ); + } + + /// Sets the result to a blob. + pub fn setBlob(self: Self, value: []const u8) void { + c.sqlite3_result_blob( + self.ctx, + value.ptr, + @intCast(value.len), + c.SQLITE_TRANSIENT, + ); + } + + /// Sets the result to an error. + pub fn setError(self: Self, msg: []const u8) void { + c.sqlite3_result_error(self.ctx, msg.ptr, @intCast(msg.len)); + } +}; + +/// Wrapper for aggregate function pair. +const AggregateFnWrapper = struct { + step_fn: AggregateStepFn, + final_fn: AggregateFinalFn, + + fn create(step_fn: AggregateStepFn, final_fn: AggregateFinalFn) !*AggregateFnWrapper { + const wrapper = try std.heap.page_allocator.create(AggregateFnWrapper); + wrapper.step_fn = step_fn; + wrapper.final_fn = final_fn; + return wrapper; + } + + fn destroy(self: *AggregateFnWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for aggregate step function. +fn aggregateStepCallback( + ctx: ?*c.sqlite3_context, + argc: c_int, + argv: [*c]?*c.sqlite3_value, +) callconv(.c) void { + const user_data = c.sqlite3_user_data(ctx); + if (user_data == null) return; + + const wrapper: *AggregateFnWrapper = @ptrCast(@alignCast(user_data)); + const agg_ctx = AggregateContext{ .ctx = ctx.? }; + + // Build args array + const args_count: usize = @intCast(argc); + var args: [16]FunctionValue = undefined; // Max 16 args + const actual_count = @min(args_count, 16); + + for (0..actual_count) |i| { + if (argv[i]) |v| { + args[i] = FunctionValue{ .value = v }; + } + } + + wrapper.step_fn(agg_ctx, args[0..actual_count]); +} + +/// C callback trampoline for aggregate final function. +fn aggregateFinalCallback(ctx: ?*c.sqlite3_context) callconv(.c) void { + const user_data = c.sqlite3_user_data(ctx); + if (user_data == null) return; + + const wrapper: *AggregateFnWrapper = @ptrCast(@alignCast(user_data)); + const agg_ctx = AggregateContext{ .ctx = ctx.? }; + + wrapper.final_fn(agg_ctx); +} + +/// Destructor callback for aggregate function user data. +fn aggregateDestructor(ptr: ?*anyopaque) callconv(.c) void { + if (ptr) |p| { + const wrapper: *AggregateFnWrapper = @ptrCast(@alignCast(p)); + wrapper.destroy(); + } +} + // ============================================================================ // Custom Collations // ============================================================================ @@ -1266,6 +1562,304 @@ fn collationDestructor(ptr: ?*anyopaque) callconv(.c) void { } } +// ============================================================================ +// Blob I/O +// ============================================================================ + +/// Blob handle for incremental I/O operations. +/// +/// Allows reading and writing large BLOBs incrementally without +/// loading the entire blob into memory. +pub const Blob = struct { + handle: ?*c.sqlite3_blob, + db: *Database, + + const Self = @This(); + + /// Opens a blob for incremental I/O. + /// + /// Parameters: + /// - `db`: Database connection + /// - `schema`: Database name (usually "main") + /// - `table`: Table name + /// - `column`: Column name + /// - `rowid`: Row ID of the blob + /// - `writable`: If true, opens for read/write; otherwise read-only + pub fn open( + db: *Database, + schema: [:0]const u8, + table: [:0]const u8, + column: [:0]const u8, + rowid: i64, + writable: bool, + ) Error!Self { + var handle: ?*c.sqlite3_blob = null; + const flags: c_int = if (writable) 1 else 0; + + const result = c.sqlite3_blob_open( + db.handle, + schema.ptr, + table.ptr, + column.ptr, + rowid, + flags, + &handle, + ); + + if (result != c.SQLITE_OK) { + return resultToError(result); + } + + return Self{ + .handle = handle, + .db = db, + }; + } + + /// Opens a blob with allocator for runtime strings. + pub fn openAlloc( + db: *Database, + allocator: std.mem.Allocator, + schema: []const u8, + table: []const u8, + column: []const u8, + rowid: i64, + writable: bool, + ) !Self { + // Create null-terminated copies + const schema_z = try allocator.dupeZ(u8, schema); + defer allocator.free(schema_z); + + const table_z = try allocator.dupeZ(u8, table); + defer allocator.free(table_z); + + const column_z = try allocator.dupeZ(u8, column); + defer allocator.free(column_z); + + return Self.open(db, schema_z, table_z, column_z, rowid, writable); + } + + /// Closes the blob handle. + pub fn close(self: *Self) Error!void { + if (self.handle) |h| { + const result = c.sqlite3_blob_close(h); + self.handle = null; + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + } + + /// Closes the blob handle without checking for errors. + pub fn deinit(self: *Self) void { + if (self.handle) |h| { + _ = c.sqlite3_blob_close(h); + self.handle = null; + } + } + + /// Returns the size of the blob in bytes. + pub fn bytes(self: *Self) i32 { + if (self.handle) |h| { + return c.sqlite3_blob_bytes(h); + } + return 0; + } + + /// Reads data from the blob. + /// + /// Parameters: + /// - `buffer`: Buffer to read into + /// - `offset`: Offset within the blob to start reading + pub fn read(self: *Self, buffer: []u8, offset: i32) Error!void { + if (self.handle == null) return error.SqliteError; + + const result = c.sqlite3_blob_read( + self.handle, + buffer.ptr, + @intCast(buffer.len), + offset, + ); + + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + + /// Writes data to the blob. + /// + /// Parameters: + /// - `data`: Data to write + /// - `offset`: Offset within the blob to start writing + /// + /// Note: This cannot change the size of the blob. Use UPDATE to resize. + pub fn write(self: *Self, data: []const u8, offset: i32) Error!void { + if (self.handle == null) return error.SqliteError; + + const result = c.sqlite3_blob_write( + self.handle, + data.ptr, + @intCast(data.len), + offset, + ); + + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + + /// Moves the blob handle to a different row. + /// + /// This allows reusing an open blob handle for a different row + /// in the same table, which is faster than closing and reopening. + pub fn reopen(self: *Self, rowid: i64) Error!void { + if (self.handle == null) return error.SqliteError; + + const result = c.sqlite3_blob_reopen(self.handle, rowid); + if (result != c.SQLITE_OK) { + return resultToError(result); + } + } + + /// Reads the entire blob into a newly allocated buffer. + pub fn readAll(self: *Self, allocator: std.mem.Allocator) ![]u8 { + const size = self.bytes(); + if (size <= 0) return &[_]u8{}; + + const buffer = try allocator.alloc(u8, @intCast(size)); + errdefer allocator.free(buffer); + + try self.read(buffer, 0); + return buffer; + } +}; + +// ============================================================================ +// Hooks (Commit, Rollback, Update) +// ============================================================================ + +/// Type signature for commit hook callback. +/// Return 0 to allow the commit, non-zero to force rollback. +pub const CommitHookFn = *const fn (user_data: ?*anyopaque) i32; + +/// Type signature for rollback hook callback. +pub const RollbackHookFn = *const fn (user_data: ?*anyopaque) void; + +/// Type signature for update hook callback. +/// Parameters are: operation (INSERT/UPDATE/DELETE), database name, table name, rowid +pub const UpdateHookFn = *const fn ( + user_data: ?*anyopaque, + operation: i32, + db_name: [*:0]const u8, + table_name: [*:0]const u8, + rowid: i64, +) void; + +/// Update operation types for update hooks. +pub const UpdateOperation = enum(i32) { + insert = c.SQLITE_INSERT, + update = c.SQLITE_UPDATE, + delete = c.SQLITE_DELETE, + + pub fn fromInt(value: i32) ?UpdateOperation { + return switch (value) { + c.SQLITE_INSERT => .insert, + c.SQLITE_UPDATE => .update, + c.SQLITE_DELETE => .delete, + else => null, + }; + } +}; + +/// Zig-friendly commit hook callback type. +pub const ZigCommitHookFn = *const fn () bool; + +/// Zig-friendly rollback hook callback type. +pub const ZigRollbackHookFn = *const fn () void; + +/// Zig-friendly update hook callback type. +pub const ZigUpdateHookFn = *const fn (operation: UpdateOperation, db_name: []const u8, table_name: []const u8, rowid: i64) void; + +/// Wrapper for Zig commit hook. +const CommitHookWrapper = struct { + func: ZigCommitHookFn, + + fn create(func: ZigCommitHookFn) !*CommitHookWrapper { + const wrapper = try std.heap.page_allocator.create(CommitHookWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *CommitHookWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// Wrapper for Zig rollback hook. +const RollbackHookWrapper = struct { + func: ZigRollbackHookFn, + + fn create(func: ZigRollbackHookFn) !*RollbackHookWrapper { + const wrapper = try std.heap.page_allocator.create(RollbackHookWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *RollbackHookWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// Wrapper for Zig update hook. +const UpdateHookWrapper = struct { + func: ZigUpdateHookFn, + + fn create(func: ZigUpdateHookFn) !*UpdateHookWrapper { + const wrapper = try std.heap.page_allocator.create(UpdateHookWrapper); + wrapper.func = func; + return wrapper; + } + + fn destroy(self: *UpdateHookWrapper) void { + std.heap.page_allocator.destroy(self); + } +}; + +/// C callback trampoline for commit hooks. +fn commitHookCallback(user_data: ?*anyopaque) callconv(.c) c_int { + if (user_data == null) return 0; + + const wrapper: *CommitHookWrapper = @ptrCast(@alignCast(user_data)); + const allow_commit = wrapper.func(); + return if (allow_commit) 0 else 1; +} + +/// C callback trampoline for rollback hooks. +fn rollbackHookCallback(user_data: ?*anyopaque) callconv(.c) void { + if (user_data == null) return; + + const wrapper: *RollbackHookWrapper = @ptrCast(@alignCast(user_data)); + wrapper.func(); +} + +/// C callback trampoline for update hooks. +fn updateHookCallback( + user_data: ?*anyopaque, + operation: c_int, + db_name: [*c]const u8, + table_name: [*c]const u8, + rowid: c.sqlite3_int64, +) callconv(.c) void { + if (user_data == null) return; + + const wrapper: *UpdateHookWrapper = @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); + + wrapper.func(op, db_str, table_str, rowid); +} + // ============================================================================ // Convenience functions // ============================================================================ @@ -1766,3 +2360,272 @@ test "custom collation" { try std.testing.expectEqualStrings("Alice", stmt.columnText(0).?); } } + +test "blob incremental I/O" { + var db = try openMemory(); + defer db.close(); + + // Create table with blob column + try db.exec("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)"); + + // Insert a blob with zeroblob placeholder + var insert = try db.prepare("INSERT INTO blobs (data) VALUES (zeroblob(100))"); + defer insert.finalize(); + _ = try insert.step(); + + const rowid = db.lastInsertRowId(); + + // Open blob for writing + var blob = try Blob.open(&db, "main", "blobs", "data", rowid, true); + defer blob.deinit(); + + // Check size + try std.testing.expectEqual(@as(i32, 100), blob.bytes()); + + // Write some data + const write_data = "Hello, Blob World!"; + try blob.write(write_data, 0); + + // Close and reopen for reading + try blob.close(); + blob = try Blob.open(&db, "main", "blobs", "data", rowid, false); + + // Read back + var read_buffer: [100]u8 = undefined; + try blob.read(&read_buffer, 0); + + try std.testing.expectEqualStrings(write_data, read_buffer[0..write_data.len]); +} + +test "blob read all" { + const allocator = std.testing.allocator; + + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE files (id INTEGER PRIMARY KEY, content BLOB)"); + + // Insert test data + var insert = try db.prepare("INSERT INTO files (content) VALUES (?)"); + defer insert.finalize(); + const test_data = "This is test blob content for readAll test"; + try insert.bindBlob(1, test_data); + _ = try insert.step(); + + const rowid = db.lastInsertRowId(); + + // Read using blob API + var blob = try Blob.open(&db, "main", "files", "content", rowid, false); + defer blob.deinit(); + + const data = try blob.readAll(allocator); + defer allocator.free(data); + + try std.testing.expectEqualStrings(test_data, data); +} + +test "blob reopen" { + var db = try openMemory(); + defer db.close(); + + try db.exec("CREATE TABLE multi (id INTEGER PRIMARY KEY, data BLOB)"); + + // Insert multiple rows + try db.exec("INSERT INTO multi (data) VALUES (X'0102030405')"); // rowid 1 + try db.exec("INSERT INTO multi (data) VALUES (X'0A0B0C0D0E')"); // rowid 2 + + // Open first row + var blob = try Blob.open(&db, "main", "multi", "data", 1, false); + defer blob.deinit(); + + var buffer: [5]u8 = undefined; + try blob.read(&buffer, 0); + try std.testing.expectEqualSlices(u8, &[_]u8{ 1, 2, 3, 4, 5 }, &buffer); + + // Reopen to second row + try blob.reopen(2); + try blob.read(&buffer, 0); + try std.testing.expectEqualSlices(u8, &[_]u8{ 10, 11, 12, 13, 14 }, &buffer); +} + +// Thread-local state for hook tests +var commit_count: u32 = 0; +var rollback_count: u32 = 0; +var update_count: u32 = 0; +var last_update_op: ?UpdateOperation = null; + +fn testCommitHook() bool { + commit_count += 1; + return true; // Allow commit +} + +fn testRollbackHook() void { + rollback_count += 1; +} + +fn testUpdateHook(op: UpdateOperation, _: []const u8, _: []const u8, _: i64) void { + update_count += 1; + last_update_op = op; +} + +test "commit hook" { + commit_count = 0; + + var db = try openMemory(); + defer db.close(); + + try db.setCommitHook(testCommitHook); + + try db.exec("CREATE TABLE test (x INTEGER)"); + try std.testing.expect(commit_count >= 1); + + const before = commit_count; + try db.begin(); + try db.exec("INSERT INTO test VALUES (1)"); + try db.commit(); + try std.testing.expect(commit_count > before); + + // Remove hook + try db.setCommitHook(null); +} + +test "rollback hook" { + rollback_count = 0; + + var db = try openMemory(); + defer db.close(); + + try db.setRollbackHook(testRollbackHook); + + try db.exec("CREATE TABLE test (x INTEGER)"); + + try db.begin(); + try db.exec("INSERT INTO test VALUES (1)"); + try db.rollback(); + + try std.testing.expect(rollback_count >= 1); + + // Remove hook + try db.setRollbackHook(null); +} + +test "update hook" { + update_count = 0; + last_update_op = null; + + var db = try openMemory(); + defer db.close(); + + try db.setUpdateHook(testUpdateHook); + + try db.exec("CREATE TABLE test (x INTEGER)"); + + // Insert + try db.exec("INSERT INTO test VALUES (1)"); + try std.testing.expect(last_update_op == .insert); + + // Update + try db.exec("UPDATE test SET x = 2 WHERE x = 1"); + try std.testing.expect(last_update_op == .update); + + // Delete + try db.exec("DELETE FROM test WHERE x = 2"); + try std.testing.expect(last_update_op == .delete); + + try std.testing.expect(update_count >= 3); + + // Clear all hooks + db.clearHooks(); +} + +// Aggregate function state for sum of squares +const SumSquaresState = struct { + total: i64 = 0, +}; + +fn sumSquaresStep(ctx: AggregateContext, args: []const FunctionValue) void { + const state = ctx.getAggregateContext(SumSquaresState) orelse return; + if (args.len > 0 and !args[0].isNull()) { + const val = args[0].asInt(); + state.total += val * val; + } +} + +fn sumSquaresFinal(ctx: AggregateContext) void { + const state = ctx.getAggregateContext(SumSquaresState) orelse { + ctx.setNull(); + return; + }; + ctx.setInt(state.total); +} + +test "aggregate function - sum of squares" { + var db = try openMemory(); + defer db.close(); + + try db.createAggregateFunction("sum_squares", 1, sumSquaresStep, sumSquaresFinal); + + try db.exec("CREATE TABLE numbers (value INTEGER)"); + try db.exec("INSERT INTO numbers VALUES (1), (2), (3), (4), (5)"); + // sum of squares: 1 + 4 + 9 + 16 + 25 = 55 + + var stmt = try db.prepare("SELECT sum_squares(value) FROM numbers"); + defer stmt.finalize(); + + _ = try stmt.step(); + try std.testing.expectEqual(@as(i64, 55), stmt.columnInt(0)); +} + +// Aggregate function for string concatenation +const ConcatState = struct { + buffer: [256]u8 = undefined, + len: usize = 0, + separator_seen: bool = false, +}; + +fn groupConcatStep(ctx: AggregateContext, args: []const FunctionValue) void { + const state = ctx.getAggregateContext(ConcatState) orelse return; + if (args.len > 0) { + if (args[0].asText()) |text| { + // Add separator if not first + if (state.separator_seen and state.len < state.buffer.len - 1) { + state.buffer[state.len] = ','; + state.len += 1; + } + // Copy text + const remaining = state.buffer.len - state.len; + const copy_len = @min(text.len, remaining); + @memcpy(state.buffer[state.len..][0..copy_len], text[0..copy_len]); + state.len += copy_len; + state.separator_seen = true; + } + } +} + +fn groupConcatFinal(ctx: AggregateContext) void { + const state = ctx.getAggregateContext(ConcatState) orelse { + ctx.setNull(); + return; + }; + if (state.len == 0) { + ctx.setNull(); + } else { + ctx.setText(state.buffer[0..state.len]); + } +} + +test "aggregate function - group concat" { + var db = try openMemory(); + defer db.close(); + + try db.createAggregateFunction("my_group_concat", 1, groupConcatStep, groupConcatFinal); + + try db.exec("CREATE TABLE names (name TEXT)"); + try db.exec("INSERT INTO names VALUES ('Alice'), ('Bob'), ('Charlie')"); + + var stmt = try db.prepare("SELECT my_group_concat(name) FROM names"); + defer stmt.finalize(); + + _ = try stmt.step(); + try std.testing.expectEqualStrings("Alice,Bob,Charlie", stmt.columnText(0).?); +}