diff --git a/src/database.zig b/src/database.zig
index 33ab6ef..aa2405e 100644
--- a/src/database.zig
+++ b/src/database.zig
@@ -792,4 +792,110 @@ pub const Database = struct {
pub fn setLimit(self: *Self, limit_type: Limit, new_value: i32) i32 {
return c.sqlite3_limit(self.handle, @intFromEnum(limit_type), new_value);
}
+
+ // ========================================================================
+ // File Control
+ // ========================================================================
+
+ /// File control operation codes.
+ pub const FileControlOp = enum(c_int) {
+ lockstate = 1,
+ get_lockproxyfile = 2,
+ set_lockproxyfile = 3,
+ last_errno = 4,
+ size_hint = 5,
+ chunk_size = 6,
+ file_pointer = 7,
+ sync_omitted = 8,
+ win32_av_retry = 9,
+ persist_wal = 10,
+ overwrite = 11,
+ vfsname = 12,
+ powersafe_overwrite = 13,
+ pragma = 14,
+ busyhandler = 15,
+ tempfilename = 16,
+ mmap_size = 18,
+ trace = 19,
+ has_moved = 20,
+ sync = 21,
+ commit_phasetwo = 22,
+ win32_set_handle = 23,
+ wal_block = 24,
+ zipvfs = 25,
+ rbu = 26,
+ vfs_pointer = 27,
+ journal_pointer = 28,
+ win32_get_handle = 29,
+ pdb = 30,
+ begin_atomic_write = 31,
+ commit_atomic_write = 32,
+ rollback_atomic_write = 33,
+ lock_timeout = 34,
+ data_version = 35,
+ size_limit = 36,
+ ckpt_done = 37,
+ reserve_bytes = 38,
+ ckpt_start = 39,
+ external_reader = 40,
+ cksm_file = 41,
+ reset_cache = 42,
+ };
+
+ /// Performs a file control operation with an integer value.
+ ///
+ /// This is a low-level interface to the sqlite3_file_control() function.
+ /// Most applications should use the higher-level pragma interface instead.
+ ///
+ /// Parameters:
+ /// - db_name: The database name ("main", "temp", or attached db name)
+ /// - op: The file control operation code
+ /// - value: Pointer to the integer value (input/output depending on operation)
+ ///
+ /// Returns error on failure.
+ pub fn setFileControlInt(self: *Self, db_name: [:0]const u8, op: FileControlOp, value: *i32) Error!void {
+ const result = c.sqlite3_file_control(self.handle, db_name.ptr, @intFromEnum(op), value);
+ if (result != c.SQLITE_OK) {
+ return resultToError(result);
+ }
+ }
+
+ /// Gets persist_wal setting.
+ pub fn getPersistWal(self: *Self, db_name: [:0]const u8) Error!bool {
+ var value: i32 = -1;
+ try self.setFileControlInt(db_name, .persist_wal, &value);
+ return value != 0;
+ }
+
+ /// Sets persist_wal setting.
+ pub fn setPersistWal(self: *Self, db_name: [:0]const u8, persist: bool) Error!void {
+ var value: i32 = if (persist) 1 else 0;
+ try self.setFileControlInt(db_name, .persist_wal, &value);
+ }
+
+ /// Gets powersafe_overwrite setting.
+ pub fn getPowersafeOverwrite(self: *Self, db_name: [:0]const u8) Error!bool {
+ var value: i32 = -1;
+ try self.setFileControlInt(db_name, .powersafe_overwrite, &value);
+ return value != 0;
+ }
+
+ /// Sets powersafe_overwrite setting.
+ pub fn setPowersafeOverwrite(self: *Self, db_name: [:0]const u8, enabled: bool) Error!void {
+ var value: i32 = if (enabled) 1 else 0;
+ try self.setFileControlInt(db_name, .powersafe_overwrite, &value);
+ }
+
+ /// Sets the chunk size for file growth.
+ pub fn setChunkSize(self: *Self, db_name: [:0]const u8, size: i32) Error!void {
+ var value: i32 = size;
+ try self.setFileControlInt(db_name, .chunk_size, &value);
+ }
+
+ /// Gets the data version (incremented each time the database is modified).
+ pub fn getDataVersion(self: *Self, db_name: [:0]const u8) Error!u32 {
+ var value: i32 = 0;
+ try self.setFileControlInt(db_name, .data_version, &value);
+ return @intCast(value);
+ }
};
diff --git a/src/fts5.zig b/src/fts5.zig
new file mode 100644
index 0000000..cd03768
--- /dev/null
+++ b/src/fts5.zig
@@ -0,0 +1,229 @@
+//! FTS5 Full-Text Search Helpers
+//!
+//! Provides convenient wrappers for SQLite's FTS5 full-text search extension.
+//! FTS5 is compiled into SQLite by default with -DSQLITE_ENABLE_FTS5.
+
+const std = @import("std");
+const Database = @import("database.zig").Database;
+const Statement = @import("statement.zig").Statement;
+const Error = @import("errors.zig").Error;
+
+/// FTS5 tokenizer options.
+pub const TokenizerOption = enum {
+ unicode61,
+ ascii,
+ porter,
+ trigram,
+
+ pub fn name(self: TokenizerOption) []const u8 {
+ return switch (self) {
+ .unicode61 => "unicode61",
+ .ascii => "ascii",
+ .porter => "porter",
+ .trigram => "trigram",
+ };
+ }
+};
+
+/// FTS5 helper functions for a database connection.
+pub const Fts5 = struct {
+ db: *Database,
+ allocator: std.mem.Allocator,
+
+ const Self = @This();
+
+ /// Creates a new FTS5 helper bound to a database connection.
+ pub fn init(db: *Database, allocator: std.mem.Allocator) Self {
+ return .{ .db = db, .allocator = allocator };
+ }
+
+ /// Creates a simple FTS5 table with specified columns.
+ pub fn createSimpleTable(self: *Self, table_name: []const u8, columns: []const []const u8) !void {
+ try self.createTableWithTokenizer(table_name, columns, .unicode61);
+ }
+
+ /// Creates an FTS5 table with a specific tokenizer.
+ pub fn createTableWithTokenizer(
+ self: *Self,
+ table_name: []const u8,
+ columns: []const []const u8,
+ tokenizer: TokenizerOption,
+ ) !void {
+ // Build column list
+ var col_buf: [4096]u8 = undefined;
+ var col_len: usize = 0;
+
+ for (columns, 0..) |col, i| {
+ if (i > 0) {
+ col_buf[col_len] = ',';
+ col_buf[col_len + 1] = ' ';
+ col_len += 2;
+ }
+ @memcpy(col_buf[col_len..][0..col.len], col);
+ col_len += col.len;
+ }
+
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "CREATE VIRTUAL TABLE {s} USING fts5({s}, tokenize='{s}')\x00",
+ .{ table_name, col_buf[0..col_len], tokenizer.name() },
+ );
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Drops an FTS5 table.
+ pub fn dropTable(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "DROP TABLE IF EXISTS {s}\x00", .{table_name});
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Performs a full-text search query.
+ pub fn search(
+ self: *Self,
+ table_name: []const u8,
+ query: []const u8,
+ result_columns: []const []const u8,
+ limit: ?u32,
+ ) !Statement {
+ // Build column list
+ var col_buf: [4096]u8 = undefined;
+ var col_len: usize = 0;
+
+ if (result_columns.len == 0) {
+ col_buf[0] = '*';
+ col_len = 1;
+ } else {
+ for (result_columns, 0..) |col, i| {
+ if (i > 0) {
+ col_buf[col_len] = ',';
+ col_buf[col_len + 1] = ' ';
+ col_len += 2;
+ }
+ @memcpy(col_buf[col_len..][0..col.len], col);
+ col_len += col.len;
+ }
+ }
+
+ const sql = if (limit) |l|
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT {s} FROM {s} WHERE {s} MATCH ? ORDER BY rank LIMIT {d}\x00",
+ .{ col_buf[0..col_len], table_name, table_name, l },
+ )
+ else
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT {s} FROM {s} WHERE {s} MATCH ? ORDER BY rank\x00",
+ .{ col_buf[0..col_len], table_name, table_name },
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ try stmt.bindText(1, query);
+ return stmt;
+ }
+
+ /// Gets highlighted snippet from search results.
+ pub fn searchWithHighlight(
+ self: *Self,
+ table_name: []const u8,
+ query: []const u8,
+ column_index: u32,
+ before_match: []const u8,
+ after_match: []const u8,
+ limit: ?u32,
+ ) !Statement {
+ const sql = if (limit) |l|
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT highlight({s}, {d}, '{s}', '{s}') FROM {s} WHERE {s} MATCH ? ORDER BY rank LIMIT {d}\x00",
+ .{ table_name, column_index, before_match, after_match, table_name, table_name, l },
+ )
+ else
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT highlight({s}, {d}, '{s}', '{s}') FROM {s} WHERE {s} MATCH ? ORDER BY rank\x00",
+ .{ table_name, column_index, before_match, after_match, table_name, table_name },
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ try stmt.bindText(1, query);
+ return stmt;
+ }
+
+ /// Gets a snippet (context around matches) from search results.
+ pub fn searchWithSnippet(
+ self: *Self,
+ table_name: []const u8,
+ query: []const u8,
+ column_index: i32,
+ before_match: []const u8,
+ after_match: []const u8,
+ ellipsis: []const u8,
+ max_tokens: u32,
+ limit: ?u32,
+ ) !Statement {
+ const sql = if (limit) |l|
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT snippet({s}, {d}, '{s}', '{s}', '{s}', {d}) FROM {s} WHERE {s} MATCH ? ORDER BY rank LIMIT {d}\x00",
+ .{ table_name, column_index, before_match, after_match, ellipsis, max_tokens, table_name, table_name, l },
+ )
+ else
+ try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT snippet({s}, {d}, '{s}', '{s}', '{s}', {d}) FROM {s} WHERE {s} MATCH ? ORDER BY rank\x00",
+ .{ table_name, column_index, before_match, after_match, ellipsis, max_tokens, table_name, table_name },
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ try stmt.bindText(1, query);
+ return stmt;
+ }
+
+ /// Rebuilds the FTS5 index.
+ pub fn rebuild(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "INSERT INTO {s}({s}) VALUES('rebuild')\x00", .{ table_name, table_name });
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Optimizes the FTS5 index by merging segments.
+ pub fn optimize(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "INSERT INTO {s}({s}) VALUES('optimize')\x00", .{ table_name, table_name });
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Runs integrity check on the FTS5 index.
+ pub fn integrityCheck(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "INSERT INTO {s}({s}) VALUES('integrity-check')\x00", .{ table_name, table_name });
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Deletes all content from the FTS5 table.
+ pub fn deleteAll(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "DELETE FROM {s}\x00", .{table_name});
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Gets the total number of rows in the FTS5 table.
+ pub fn count(self: *Self, table_name: []const u8) !i64 {
+ const sql = try std.fmt.allocPrint(self.allocator, "SELECT COUNT(*) FROM {s}\x00", .{table_name});
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ if (try stmt.step()) {
+ return stmt.columnInt(0);
+ }
+ return 0;
+ }
+};
diff --git a/src/json.zig b/src/json.zig
new file mode 100644
index 0000000..0db5ff4
--- /dev/null
+++ b/src/json.zig
@@ -0,0 +1,437 @@
+//! JSON1 Extension Helpers
+//!
+//! Provides convenient wrappers for SQLite's JSON1 extension functions.
+//! JSON1 is compiled into SQLite by default with -DSQLITE_ENABLE_JSON1.
+
+const std = @import("std");
+const Database = @import("database.zig").Database;
+const Statement = @import("statement.zig").Statement;
+const Error = @import("errors.zig").Error;
+
+/// JSON helper functions for a database connection.
+pub const Json = struct {
+ db: *Database,
+ allocator: std.mem.Allocator,
+
+ const Self = @This();
+
+ /// Creates a new JSON helper bound to a database connection.
+ pub fn init(db: *Database, allocator: std.mem.Allocator) Self {
+ return .{ .db = db, .allocator = allocator };
+ }
+
+ // ========================================================================
+ // JSON Validation and Parsing
+ // ========================================================================
+
+ /// Validates and minifies a JSON string.
+ pub fn validate(self: *Self, json_text: []const u8) !?[]u8 {
+ var stmt = try self.db.prepare("SELECT json(?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return null;
+ }
+
+ /// Checks if a string is valid JSON.
+ pub fn isValid(self: *Self, json_text: []const u8) !bool {
+ var stmt = try self.db.prepare("SELECT json_valid(?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+
+ if (try stmt.step()) {
+ return stmt.columnInt(0) == 1;
+ }
+ return false;
+ }
+
+ /// Returns the JSON type of a value at a path.
+ pub fn getType(self: *Self, json_text: []const u8, path: []const u8) !?[]u8 {
+ var stmt = try self.db.prepare("SELECT json_type(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return null;
+ }
+
+ // ========================================================================
+ // JSON Extraction
+ // ========================================================================
+
+ /// Extracts a value from JSON at the given path.
+ pub fn extract(self: *Self, json_text: []const u8, path: []const u8) !?[]u8 {
+ var stmt = try self.db.prepare("SELECT json_extract(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return null;
+ }
+
+ /// Extracts an integer from JSON at the given path.
+ pub fn extractInt(self: *Self, json_text: []const u8, path: []const u8) !?i64 {
+ var stmt = try self.db.prepare("SELECT json_extract(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+
+ if (try stmt.step()) {
+ if (!stmt.columnIsNull(0)) {
+ return stmt.columnInt(0);
+ }
+ }
+ return null;
+ }
+
+ /// Extracts a float from JSON at the given path.
+ pub fn extractFloat(self: *Self, json_text: []const u8, path: []const u8) !?f64 {
+ var stmt = try self.db.prepare("SELECT json_extract(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+
+ if (try stmt.step()) {
+ if (!stmt.columnIsNull(0)) {
+ return stmt.columnFloat(0);
+ }
+ }
+ return null;
+ }
+
+ /// Extracts a boolean from JSON at the given path.
+ pub fn extractBool(self: *Self, json_text: []const u8, path: []const u8) !?bool {
+ const result = try self.extractInt(json_text, path);
+ if (result) |v| {
+ return v != 0;
+ }
+ return null;
+ }
+
+ // ========================================================================
+ // JSON Modification
+ // ========================================================================
+
+ /// Inserts a value into JSON at the given path (only if path doesn't exist).
+ pub fn insert(self: *Self, json_text: []const u8, path: []const u8, value: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_insert(?, ?, json(?))");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindText(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Replaces a value in JSON at the given path (only if path exists).
+ pub fn replace(self: *Self, json_text: []const u8, path: []const u8, value: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_replace(?, ?, json(?))");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindText(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Sets a value in JSON at the given path (insert or replace).
+ pub fn set(self: *Self, json_text: []const u8, path: []const u8, value: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_set(?, ?, json(?))");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindText(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Sets a string value in JSON at the given path.
+ pub fn setString(self: *Self, json_text: []const u8, path: []const u8, value: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_set(?, ?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindText(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Sets an integer value in JSON at the given path.
+ pub fn setInt(self: *Self, json_text: []const u8, path: []const u8, value: i64) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_set(?, ?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindInt(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Sets a float value in JSON at the given path.
+ pub fn setFloat(self: *Self, json_text: []const u8, path: []const u8, value: f64) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_set(?, ?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindFloat(3, value);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Sets a boolean value in JSON at the given path.
+ pub fn setBool(self: *Self, json_text: []const u8, path: []const u8, value: bool) ![]u8 {
+ const json_bool = if (value) "true" else "false";
+ var stmt = try self.db.prepare("SELECT json_set(?, ?, json(?))");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+ try stmt.bindText(3, json_bool);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ /// Removes a value from JSON at the given path.
+ pub fn remove(self: *Self, json_text: []const u8, path: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_remove(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, path);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, json_text);
+ }
+
+ // ========================================================================
+ // JSON Array Operations
+ // ========================================================================
+
+ /// Returns the length of a JSON array.
+ pub fn arrayLength(self: *Self, json_text: []const u8, path: ?[]const u8) !?i64 {
+ if (path) |p| {
+ var stmt = try self.db.prepare("SELECT json_array_length(?, ?)");
+ defer stmt.finalize();
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, p);
+ if (try stmt.step()) {
+ if (!stmt.columnIsNull(0)) {
+ return stmt.columnInt(0);
+ }
+ }
+ } else {
+ var stmt = try self.db.prepare("SELECT json_array_length(?)");
+ defer stmt.finalize();
+ try stmt.bindText(1, json_text);
+ if (try stmt.step()) {
+ if (!stmt.columnIsNull(0)) {
+ return stmt.columnInt(0);
+ }
+ }
+ }
+ return null;
+ }
+
+ /// Creates a JSON array from string values.
+ pub fn createArray(self: *Self, values: []const []const u8) ![]u8 {
+ if (values.len == 0) {
+ return try self.allocator.dupe(u8, "[]");
+ }
+
+ // Build placeholders
+ var placeholders: [256]u8 = undefined;
+ var ph_len: usize = 0;
+ for (0..values.len) |i| {
+ if (i > 0) {
+ placeholders[ph_len] = ',';
+ placeholders[ph_len + 1] = ' ';
+ ph_len += 2;
+ }
+ placeholders[ph_len] = '?';
+ ph_len += 1;
+ }
+
+ const sql = try std.fmt.allocPrint(self.allocator, "SELECT json_array({s})\x00", .{placeholders[0..ph_len]});
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ for (values, 0..) |v, i| {
+ try stmt.bindText(@intCast(i + 1), v);
+ }
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, "[]");
+ }
+
+ /// Creates a JSON object from key-value pairs.
+ pub fn createObject(self: *Self, keys: []const []const u8, values: []const []const u8) ![]u8 {
+ if (keys.len != values.len) {
+ return error.OutOfMemory; // Use a generic error
+ }
+
+ if (keys.len == 0) {
+ return try self.allocator.dupe(u8, "{}");
+ }
+
+ // Build placeholders
+ var placeholders: [512]u8 = undefined;
+ var ph_len: usize = 0;
+ for (0..keys.len) |i| {
+ if (i > 0) {
+ placeholders[ph_len] = ',';
+ placeholders[ph_len + 1] = ' ';
+ ph_len += 2;
+ }
+ placeholders[ph_len] = '?';
+ placeholders[ph_len + 1] = ',';
+ placeholders[ph_len + 2] = ' ';
+ placeholders[ph_len + 3] = '?';
+ ph_len += 4;
+ }
+
+ const sql = try std.fmt.allocPrint(self.allocator, "SELECT json_object({s})\x00", .{placeholders[0..ph_len]});
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ var param_idx: u32 = 1;
+ for (keys, values) |k, v| {
+ try stmt.bindText(param_idx, k);
+ param_idx += 1;
+ try stmt.bindText(param_idx, v);
+ param_idx += 1;
+ }
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, "{}");
+ }
+
+ /// Applies a JSON patch (RFC 7396) to a JSON document.
+ pub fn patch(self: *Self, target: []const u8, patch_doc: []const u8) ![]u8 {
+ var stmt = try self.db.prepare("SELECT json_patch(?, ?)");
+ defer stmt.finalize();
+
+ try stmt.bindText(1, target);
+ try stmt.bindText(2, patch_doc);
+
+ if (try stmt.step()) {
+ if (stmt.columnText(0)) |text| {
+ return try self.allocator.dupe(u8, text);
+ }
+ }
+ return try self.allocator.dupe(u8, target);
+ }
+
+ /// Returns a statement for iterating over JSON elements with json_each.
+ pub fn each(self: *Self, json_text: []const u8, path: ?[]const u8) !Statement {
+ if (path) |p| {
+ var stmt = try self.db.prepare(
+ "SELECT key, value, type, atom, id, parent, fullkey, path FROM json_each(?, ?)",
+ );
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, p);
+ return stmt;
+ } else {
+ var stmt = try self.db.prepare(
+ "SELECT key, value, type, atom, id, parent, fullkey, path FROM json_each(?)",
+ );
+ try stmt.bindText(1, json_text);
+ return stmt;
+ }
+ }
+
+ /// Returns a statement for iterating over all JSON elements with json_tree.
+ pub fn tree(self: *Self, json_text: []const u8, path: ?[]const u8) !Statement {
+ if (path) |p| {
+ var stmt = try self.db.prepare(
+ "SELECT key, value, type, atom, id, parent, fullkey, path FROM json_tree(?, ?)",
+ );
+ try stmt.bindText(1, json_text);
+ try stmt.bindText(2, p);
+ return stmt;
+ } else {
+ var stmt = try self.db.prepare(
+ "SELECT key, value, type, atom, id, parent, fullkey, path FROM json_tree(?)",
+ );
+ try stmt.bindText(1, json_text);
+ return stmt;
+ }
+ }
+};
diff --git a/src/root.zig b/src/root.zig
index 0650d16..0df1d51 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -34,6 +34,12 @@ const functions_mod = @import("functions.zig");
const backup_mod = @import("backup.zig");
const pool_mod = @import("pool.zig");
+// Extension helpers
+pub const fts5 = @import("fts5.zig");
+pub const json = @import("json.zig");
+pub const rtree = @import("rtree.zig");
+pub const vtable = @import("vtable.zig");
+
// Re-export C bindings (for advanced users)
pub const c = c_mod.c;
@@ -57,6 +63,18 @@ pub const Backup = backup_mod.Backup;
pub const Blob = backup_mod.Blob;
pub const ConnectionPool = pool_mod.ConnectionPool;
+// Re-export iterator types
+pub const RowIterator = statement_mod.RowIterator;
+pub const Row = statement_mod.Row;
+
+// Re-export extension helpers (convenience aliases)
+pub const Fts5 = fts5.Fts5;
+pub const Json = json.Json;
+pub const RTree = rtree.RTree;
+pub const BoundingBox2D = rtree.BoundingBox2D;
+pub const BoundingBox3D = rtree.BoundingBox3D;
+pub const GeoCoord = rtree.GeoCoord;
+
// Re-export function types
pub const FunctionContext = functions_mod.FunctionContext;
pub const FunctionValue = functions_mod.FunctionValue;
@@ -1091,3 +1109,280 @@ test "connection pool reuse" {
pool.release(conn2);
}
+
+// ============================================================================
+// New feature tests: Batch bind, Row iterator, FTS5, JSON, R-Tree
+// ============================================================================
+
+test "batch bind with tuple" {
+ var db = try openMemory();
+ defer db.close();
+
+ try db.exec("CREATE TABLE users (name TEXT, age INTEGER, score REAL)");
+
+ var stmt = try db.prepare("INSERT INTO users VALUES (?, ?, ?)");
+ defer stmt.finalize();
+
+ // Bind all values from a tuple
+ try stmt.bindAll(.{ "Alice", @as(i64, 30), @as(f64, 95.5) });
+ _ = try stmt.step();
+
+ // Rebind for second insert
+ try stmt.rebind(.{ "Bob", @as(i64, 25), @as(f64, 88.0) });
+ _ = try stmt.step();
+
+ // Verify
+ var query = try db.prepare("SELECT name, age, score FROM users ORDER BY name");
+ defer query.finalize();
+
+ _ = try query.step();
+ try std.testing.expectEqualStrings("Alice", query.columnText(0).?);
+ try std.testing.expectEqual(@as(i64, 30), query.columnInt(1));
+ try std.testing.expectApproxEqAbs(@as(f64, 95.5), query.columnFloat(2), 0.01);
+
+ _ = try query.step();
+ try std.testing.expectEqualStrings("Bob", query.columnText(0).?);
+}
+
+test "batch bind with optional null" {
+ var db = try openMemory();
+ defer db.close();
+
+ try db.exec("CREATE TABLE test (a TEXT, b INTEGER)");
+
+ var stmt = try db.prepare("INSERT INTO test VALUES (?, ?)");
+ defer stmt.finalize();
+
+ // Bind with optional null
+ const optional_text: ?[]const u8 = null;
+ try stmt.bindAll(.{ optional_text, @as(i64, 42) });
+ _ = try stmt.step();
+
+ var query = try db.prepare("SELECT a, b FROM test");
+ defer query.finalize();
+
+ _ = try query.step();
+ try std.testing.expect(query.columnIsNull(0));
+ try std.testing.expectEqual(@as(i64, 42), query.columnInt(1));
+}
+
+test "row iterator" {
+ var db = try openMemory();
+ defer db.close();
+
+ try db.exec("CREATE TABLE items (id INTEGER, name TEXT)");
+ try db.exec("INSERT INTO items VALUES (1, 'Apple'), (2, 'Banana'), (3, 'Cherry')");
+
+ var stmt = try db.prepare("SELECT id, name FROM items ORDER BY id");
+ defer stmt.finalize();
+
+ var iter = stmt.iterator();
+ var count: i32 = 0;
+
+ while (try iter.next()) |row| {
+ count += 1;
+ const id = row.int(0);
+ const name = row.text(1).?;
+
+ switch (count) {
+ 1 => {
+ try std.testing.expectEqual(@as(i64, 1), id);
+ try std.testing.expectEqualStrings("Apple", name);
+ },
+ 2 => {
+ try std.testing.expectEqual(@as(i64, 2), id);
+ try std.testing.expectEqualStrings("Banana", name);
+ },
+ 3 => {
+ try std.testing.expectEqual(@as(i64, 3), id);
+ try std.testing.expectEqualStrings("Cherry", name);
+ },
+ else => {},
+ }
+ }
+
+ try std.testing.expectEqual(@as(i32, 3), count);
+}
+
+test "FTS5 basic search" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var fts = Fts5.init(&db, allocator);
+
+ // Create FTS5 table
+ try fts.createSimpleTable("documents", &.{ "title", "body" });
+
+ // Insert documents
+ try db.exec("INSERT INTO documents VALUES ('Hello World', 'This is a test document')");
+ try db.exec("INSERT INTO documents VALUES ('Zig Programming', 'Zig is a systems programming language')");
+ try db.exec("INSERT INTO documents VALUES ('SQLite Guide', 'Learn about SQLite database')");
+
+ // Search
+ var stmt = try fts.search("documents", "programming", &.{ "title", "body" }, 10);
+ defer stmt.finalize();
+
+ var found = false;
+ while (try stmt.step()) {
+ const title = stmt.columnText(0).?;
+ if (std.mem.eql(u8, title, "Zig Programming")) {
+ found = true;
+ }
+ }
+ try std.testing.expect(found);
+}
+
+test "FTS5 highlight" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var fts = Fts5.init(&db, allocator);
+ try fts.createSimpleTable("docs", &.{"content"});
+
+ try db.exec("INSERT INTO docs VALUES ('The quick brown fox jumps over the lazy dog')");
+
+ var stmt = try fts.searchWithHighlight("docs", "quick", 0, "", "", 10);
+ defer stmt.finalize();
+
+ if (try stmt.step()) {
+ const highlighted = stmt.columnText(0).?;
+ try std.testing.expect(std.mem.indexOf(u8, highlighted, "quick") != null);
+ }
+}
+
+test "JSON validate and extract" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var js = Json.init(&db, allocator);
+
+ // Validate JSON
+ const valid = try js.isValid("{\"name\": \"Alice\", \"age\": 30}");
+ try std.testing.expect(valid);
+
+ const invalid = try js.isValid("{invalid json}");
+ try std.testing.expect(!invalid);
+
+ // Extract values
+ const json_data = "{\"name\": \"Bob\", \"age\": 25, \"active\": true}";
+
+ if (try js.extract(json_data, "$.name")) |name| {
+ defer allocator.free(name);
+ try std.testing.expectEqualStrings("Bob", name);
+ }
+
+ const age = try js.extractInt(json_data, "$.age");
+ try std.testing.expectEqual(@as(?i64, 25), age);
+}
+
+test "JSON modify" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var js = Json.init(&db, allocator);
+
+ const original = "{\"name\": \"Alice\"}";
+
+ // Set a new field
+ const modified = try js.setInt(original, "$.age", 30);
+ defer allocator.free(modified);
+
+ // Verify the modification
+ const age = try js.extractInt(modified, "$.age");
+ try std.testing.expectEqual(@as(?i64, 30), age);
+}
+
+test "JSON array operations" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var js = Json.init(&db, allocator);
+
+ // Create array
+ const arr = try js.createArray(&.{ "1", "2", "3" });
+ defer allocator.free(arr);
+
+ // Check length
+ const len = try js.arrayLength(arr, null);
+ try std.testing.expectEqual(@as(?i64, 3), len);
+}
+
+test "R-Tree basic spatial query" {
+ const allocator = std.testing.allocator;
+ var db = try openMemory();
+ defer db.close();
+
+ var rt = RTree.init(&db, allocator);
+
+ // Create R-Tree table
+ try rt.createSimpleTable2D("locations");
+
+ // Insert some points
+ try rt.insertPoint2D("locations", 1, 10.0, 20.0);
+ try rt.insertPoint2D("locations", 2, 15.0, 25.0);
+ try rt.insertPoint2D("locations", 3, 100.0, 200.0); // Far away
+
+ // Query points near (12, 22) with radius 10
+ const ids = try rt.getIntersectingIds2D("locations", BoundingBox2D{
+ .min_x = 5.0,
+ .max_x = 25.0,
+ .min_y = 15.0,
+ .max_y = 35.0,
+ });
+ defer rt.freeIds(ids);
+
+ // Should find points 1 and 2, but not 3
+ try std.testing.expectEqual(@as(usize, 2), ids.len);
+}
+
+test "R-Tree bounding box operations" {
+ const box1 = BoundingBox2D{
+ .min_x = 0.0,
+ .max_x = 10.0,
+ .min_y = 0.0,
+ .max_y = 10.0,
+ };
+
+ const box2 = BoundingBox2D{
+ .min_x = 5.0,
+ .max_x = 15.0,
+ .min_y = 5.0,
+ .max_y = 15.0,
+ };
+
+ const box3 = BoundingBox2D{
+ .min_x = 20.0,
+ .max_x = 30.0,
+ .min_y = 20.0,
+ .max_y = 30.0,
+ };
+
+ // box1 and box2 intersect
+ try std.testing.expect(box1.intersects(box2));
+
+ // box1 and box3 do not intersect
+ try std.testing.expect(!box1.intersects(box3));
+
+ // Point containment
+ try std.testing.expect(box1.containsPoint(5.0, 5.0));
+ try std.testing.expect(!box1.containsPoint(15.0, 15.0));
+
+ // Area
+ try std.testing.expectApproxEqAbs(@as(f64, 100.0), box1.area(), 0.01);
+}
+
+test "R-Tree geographic distance" {
+ // Test Haversine distance calculation
+ const london = GeoCoord{ .latitude = 51.5074, .longitude = -0.1278 };
+ const paris = GeoCoord{ .latitude = 48.8566, .longitude = 2.3522 };
+
+ const distance = london.distanceKm(paris);
+
+ // London to Paris is approximately 343 km
+ try std.testing.expect(distance > 340.0 and distance < 350.0);
+}
diff --git a/src/rtree.zig b/src/rtree.zig
new file mode 100644
index 0000000..79e7125
--- /dev/null
+++ b/src/rtree.zig
@@ -0,0 +1,564 @@
+//! R-Tree Spatial Index Helpers
+//!
+//! Provides convenient wrappers for SQLite's R-Tree extension for spatial indexing.
+//! R-Tree is compiled into SQLite by default with -DSQLITE_ENABLE_RTREE.
+//!
+//! R-Tree is useful for:
+//! - Geographic data (latitude/longitude bounding boxes)
+//! - 2D/3D spatial queries
+//! - Range queries on multiple dimensions
+//! - Game collision detection
+//! - Any multi-dimensional range search
+
+const std = @import("std");
+const Database = @import("database.zig").Database;
+const Statement = @import("statement.zig").Statement;
+const Error = @import("errors.zig").Error;
+
+/// A 2D bounding box (rectangle).
+pub const BoundingBox2D = struct {
+ min_x: f64,
+ max_x: f64,
+ min_y: f64,
+ max_y: f64,
+
+ /// Creates a bounding box from center point and size.
+ pub fn fromCenter(center_x: f64, center_y: f64, width: f64, height: f64) BoundingBox2D {
+ const half_w = width / 2.0;
+ const half_h = height / 2.0;
+ return .{
+ .min_x = center_x - half_w,
+ .max_x = center_x + half_w,
+ .min_y = center_y - half_h,
+ .max_y = center_y + half_h,
+ };
+ }
+
+ /// Creates a bounding box from a point (zero-size box).
+ pub fn fromPoint(x: f64, y: f64) BoundingBox2D {
+ return .{
+ .min_x = x,
+ .max_x = x,
+ .min_y = y,
+ .max_y = y,
+ };
+ }
+
+ /// Checks if this box intersects with another box.
+ pub fn intersects(self: BoundingBox2D, other: BoundingBox2D) bool {
+ return self.min_x <= other.max_x and
+ self.max_x >= other.min_x and
+ self.min_y <= other.max_y and
+ self.max_y >= other.min_y;
+ }
+
+ /// Checks if this box contains a point.
+ pub fn containsPoint(self: BoundingBox2D, x: f64, y: f64) bool {
+ return x >= self.min_x and x <= self.max_x and
+ y >= self.min_y and y <= self.max_y;
+ }
+
+ /// Checks if this box fully contains another box.
+ pub fn contains(self: BoundingBox2D, other: BoundingBox2D) bool {
+ return other.min_x >= self.min_x and
+ other.max_x <= self.max_x and
+ other.min_y >= self.min_y and
+ other.max_y <= self.max_y;
+ }
+
+ /// Returns the area of the bounding box.
+ pub fn area(self: BoundingBox2D) f64 {
+ return (self.max_x - self.min_x) * (self.max_y - self.min_y);
+ }
+
+ /// Returns the center point of the bounding box.
+ pub fn center(self: BoundingBox2D) struct { x: f64, y: f64 } {
+ return .{
+ .x = (self.min_x + self.max_x) / 2.0,
+ .y = (self.min_y + self.max_y) / 2.0,
+ };
+ }
+
+ /// Expands this box to include another box.
+ pub fn expand(self: *BoundingBox2D, other: BoundingBox2D) void {
+ self.min_x = @min(self.min_x, other.min_x);
+ self.max_x = @max(self.max_x, other.max_x);
+ self.min_y = @min(self.min_y, other.min_y);
+ self.max_y = @max(self.max_y, other.max_y);
+ }
+};
+
+/// A 3D bounding box.
+pub const BoundingBox3D = struct {
+ min_x: f64,
+ max_x: f64,
+ min_y: f64,
+ max_y: f64,
+ min_z: f64,
+ max_z: f64,
+
+ /// Creates a 3D bounding box from center point and size.
+ pub fn fromCenter(center_x: f64, center_y: f64, center_z: f64, width: f64, height: f64, depth: f64) BoundingBox3D {
+ const half_w = width / 2.0;
+ const half_h = height / 2.0;
+ const half_d = depth / 2.0;
+ return .{
+ .min_x = center_x - half_w,
+ .max_x = center_x + half_w,
+ .min_y = center_y - half_h,
+ .max_y = center_y + half_h,
+ .min_z = center_z - half_d,
+ .max_z = center_z + half_d,
+ };
+ }
+
+ /// Creates a bounding box from a point (zero-size box).
+ pub fn fromPoint(x: f64, y: f64, z: f64) BoundingBox3D {
+ return .{
+ .min_x = x,
+ .max_x = x,
+ .min_y = y,
+ .max_y = y,
+ .min_z = z,
+ .max_z = z,
+ };
+ }
+
+ /// Checks if this box intersects with another box.
+ pub fn intersects(self: BoundingBox3D, other: BoundingBox3D) bool {
+ return self.min_x <= other.max_x and
+ self.max_x >= other.min_x and
+ self.min_y <= other.max_y and
+ self.max_y >= other.min_y and
+ self.min_z <= other.max_z and
+ self.max_z >= other.min_z;
+ }
+
+ /// Returns the volume of the bounding box.
+ pub fn volume(self: BoundingBox3D) f64 {
+ return (self.max_x - self.min_x) * (self.max_y - self.min_y) * (self.max_z - self.min_z);
+ }
+};
+
+/// Geographic coordinates (latitude/longitude).
+pub const GeoCoord = struct {
+ latitude: f64,
+ longitude: f64,
+
+ /// Creates a bounding box around this point with a given radius in degrees.
+ pub fn boundingBox(self: GeoCoord, radius_degrees: f64) BoundingBox2D {
+ return .{
+ .min_x = self.longitude - radius_degrees,
+ .max_x = self.longitude + radius_degrees,
+ .min_y = self.latitude - radius_degrees,
+ .max_y = self.latitude + radius_degrees,
+ };
+ }
+
+ /// Approximate distance in kilometers to another coordinate (Haversine formula).
+ pub fn distanceKm(self: GeoCoord, other: GeoCoord) f64 {
+ const earth_radius_km = 6371.0;
+ const lat1 = self.latitude * std.math.pi / 180.0;
+ const lat2 = other.latitude * std.math.pi / 180.0;
+ const delta_lat = (other.latitude - self.latitude) * std.math.pi / 180.0;
+ const delta_lon = (other.longitude - self.longitude) * std.math.pi / 180.0;
+
+ const a = std.math.sin(delta_lat / 2.0) * std.math.sin(delta_lat / 2.0) +
+ std.math.cos(lat1) * std.math.cos(lat2) *
+ std.math.sin(delta_lon / 2.0) * std.math.sin(delta_lon / 2.0);
+ const c = 2.0 * std.math.atan2(std.math.sqrt(a), std.math.sqrt(1.0 - a));
+
+ return earth_radius_km * c;
+ }
+};
+
+/// R-Tree helper functions for a database connection.
+pub const RTree = struct {
+ db: *Database,
+ allocator: std.mem.Allocator,
+
+ const Self = @This();
+
+ /// Creates a new R-Tree helper bound to a database connection.
+ pub fn init(db: *Database, allocator: std.mem.Allocator) Self {
+ return .{ .db = db, .allocator = allocator };
+ }
+
+ // ========================================================================
+ // Table Management
+ // ========================================================================
+
+ /// Creates a 2D R-Tree virtual table.
+ ///
+ /// Example:
+ /// ```zig
+ /// var rtree = RTree.init(&db, allocator);
+ /// try rtree.createTable2D("locations", "min_x", "max_x", "min_y", "max_y");
+ /// ```
+ pub fn createTable2D(
+ self: *Self,
+ table_name: []const u8,
+ min_x_name: []const u8,
+ max_x_name: []const u8,
+ min_y_name: []const u8,
+ max_y_name: []const u8,
+ ) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "CREATE VIRTUAL TABLE {s} USING rtree(id, {s}, {s}, {s}, {s})\x00",
+ .{ table_name, min_x_name, max_x_name, min_y_name, max_y_name },
+ );
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Creates a 3D R-Tree virtual table.
+ pub fn createTable3D(
+ self: *Self,
+ table_name: []const u8,
+ min_x_name: []const u8,
+ max_x_name: []const u8,
+ min_y_name: []const u8,
+ max_y_name: []const u8,
+ min_z_name: []const u8,
+ max_z_name: []const u8,
+ ) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "CREATE VIRTUAL TABLE {s} USING rtree(id, {s}, {s}, {s}, {s}, {s}, {s})\x00",
+ .{ table_name, min_x_name, max_x_name, min_y_name, max_y_name, min_z_name, max_z_name },
+ );
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ /// Creates a simple 2D R-Tree with default column names.
+ pub fn createSimpleTable2D(self: *Self, table_name: []const u8) !void {
+ try self.createTable2D(table_name, "min_x", "max_x", "min_y", "max_y");
+ }
+
+ /// Creates a simple 3D R-Tree with default column names.
+ pub fn createSimpleTable3D(self: *Self, table_name: []const u8) !void {
+ try self.createTable3D(table_name, "min_x", "max_x", "min_y", "max_y", "min_z", "max_z");
+ }
+
+ /// Creates a geographic R-Tree (using latitude/longitude).
+ pub fn createGeoTable(self: *Self, table_name: []const u8) !void {
+ try self.createTable2D(table_name, "min_lon", "max_lon", "min_lat", "max_lat");
+ }
+
+ /// Drops an R-Tree table.
+ pub fn dropTable(self: *Self, table_name: []const u8) !void {
+ const sql = try std.fmt.allocPrint(self.allocator, "DROP TABLE IF EXISTS {s}\x00", .{table_name});
+ defer self.allocator.free(sql);
+ try self.db.exec(sql[0 .. sql.len - 1 :0]);
+ }
+
+ // ========================================================================
+ // Insert Operations
+ // ========================================================================
+
+ /// Inserts a 2D bounding box into an R-Tree.
+ pub fn insert2D(self: *Self, table_name: []const u8, id: i64, box: BoundingBox2D) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "INSERT INTO {s} VALUES (?, ?, ?, ?, ?)\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ try stmt.bindInt(1, id);
+ try stmt.bindFloat(2, box.min_x);
+ try stmt.bindFloat(3, box.max_x);
+ try stmt.bindFloat(4, box.min_y);
+ try stmt.bindFloat(5, box.max_y);
+
+ _ = try stmt.step();
+ }
+
+ /// Inserts a 3D bounding box into an R-Tree.
+ pub fn insert3D(self: *Self, table_name: []const u8, id: i64, box: BoundingBox3D) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "INSERT INTO {s} VALUES (?, ?, ?, ?, ?, ?, ?)\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ try stmt.bindInt(1, id);
+ try stmt.bindFloat(2, box.min_x);
+ try stmt.bindFloat(3, box.max_x);
+ try stmt.bindFloat(4, box.min_y);
+ try stmt.bindFloat(5, box.max_y);
+ try stmt.bindFloat(6, box.min_z);
+ try stmt.bindFloat(7, box.max_z);
+
+ _ = try stmt.step();
+ }
+
+ /// Inserts a point (as a zero-size bounding box) into a 2D R-Tree.
+ pub fn insertPoint2D(self: *Self, table_name: []const u8, id: i64, x: f64, y: f64) !void {
+ try self.insert2D(table_name, id, BoundingBox2D.fromPoint(x, y));
+ }
+
+ /// Inserts a geographic coordinate into a geo R-Tree.
+ pub fn insertGeo(self: *Self, table_name: []const u8, id: i64, coord: GeoCoord) !void {
+ try self.insert2D(table_name, id, .{
+ .min_x = coord.longitude,
+ .max_x = coord.longitude,
+ .min_y = coord.latitude,
+ .max_y = coord.latitude,
+ });
+ }
+
+ // ========================================================================
+ // Update Operations
+ // ========================================================================
+
+ /// Updates a 2D bounding box in an R-Tree.
+ pub fn update2D(self: *Self, table_name: []const u8, id: i64, box: BoundingBox2D) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "UPDATE {s} SET min_x=?, max_x=?, min_y=?, max_y=? WHERE id=?\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ try stmt.bindFloat(1, box.min_x);
+ try stmt.bindFloat(2, box.max_x);
+ try stmt.bindFloat(3, box.min_y);
+ try stmt.bindFloat(4, box.max_y);
+ try stmt.bindInt(5, id);
+
+ _ = try stmt.step();
+ }
+
+ /// Deletes an entry from an R-Tree.
+ pub fn delete(self: *Self, table_name: []const u8, id: i64) !void {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "DELETE FROM {s} WHERE id=?\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ try stmt.bindInt(1, id);
+ _ = try stmt.step();
+ }
+
+ // ========================================================================
+ // Query Operations
+ // ========================================================================
+
+ /// Queries for all entries that intersect with a bounding box.
+ /// Returns a prepared statement with results: id, min_x, max_x, min_y, max_y
+ pub fn queryIntersects2D(self: *Self, table_name: []const u8, box: BoundingBox2D) !Statement {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT * FROM {s} WHERE min_x <= ? AND max_x >= ? AND min_y <= ? AND max_y >= ?\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ errdefer stmt.finalize();
+
+ try stmt.bindFloat(1, box.max_x);
+ try stmt.bindFloat(2, box.min_x);
+ try stmt.bindFloat(3, box.max_y);
+ try stmt.bindFloat(4, box.min_y);
+
+ return stmt;
+ }
+
+ /// Queries for all entries contained within a bounding box.
+ pub fn queryContained2D(self: *Self, table_name: []const u8, box: BoundingBox2D) !Statement {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT * FROM {s} WHERE min_x >= ? AND max_x <= ? AND min_y >= ? AND max_y <= ?\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ errdefer stmt.finalize();
+
+ try stmt.bindFloat(1, box.min_x);
+ try stmt.bindFloat(2, box.max_x);
+ try stmt.bindFloat(3, box.min_y);
+ try stmt.bindFloat(4, box.max_y);
+
+ return stmt;
+ }
+
+ /// Queries for all entries near a point (within a given radius).
+ pub fn queryNearPoint2D(self: *Self, table_name: []const u8, x: f64, y: f64, radius: f64) !Statement {
+ const box = BoundingBox2D{
+ .min_x = x - radius,
+ .max_x = x + radius,
+ .min_y = y - radius,
+ .max_y = y + radius,
+ };
+ return self.queryIntersects2D(table_name, box);
+ }
+
+ /// Queries for all entries near a geographic coordinate.
+ /// radius is in degrees (approximately: 1 degree ≈ 111km at equator)
+ pub fn queryNearGeo(self: *Self, table_name: []const u8, coord: GeoCoord, radius_degrees: f64) !Statement {
+ return self.queryNearPoint2D(table_name, coord.longitude, coord.latitude, radius_degrees);
+ }
+
+ /// Gets all entry IDs that intersect with a bounding box.
+ pub fn getIntersectingIds2D(self: *Self, table_name: []const u8, box: BoundingBox2D) ![]i64 {
+ var stmt = try self.queryIntersects2D(table_name, box);
+ defer stmt.finalize();
+
+ var ids: std.ArrayListUnmanaged(i64) = .empty;
+ errdefer ids.deinit(self.allocator);
+
+ while (try stmt.step()) {
+ try ids.append(self.allocator, stmt.columnInt(0));
+ }
+
+ return ids.toOwnedSlice(self.allocator);
+ }
+
+ /// Frees an array of IDs returned by getIntersectingIds2D.
+ pub fn freeIds(self: *Self, ids: []i64) void {
+ self.allocator.free(ids);
+ }
+
+ /// Counts entries that intersect with a bounding box.
+ pub fn countIntersects2D(self: *Self, table_name: []const u8, box: BoundingBox2D) !i64 {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT COUNT(*) FROM {s} WHERE min_x <= ? AND max_x >= ? AND min_y <= ? AND max_y >= ?\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ try stmt.bindFloat(1, box.max_x);
+ try stmt.bindFloat(2, box.min_x);
+ try stmt.bindFloat(3, box.max_y);
+ try stmt.bindFloat(4, box.min_y);
+
+ if (try stmt.step()) {
+ return stmt.columnInt(0);
+ }
+ return 0;
+ }
+
+ /// Gets the total count of entries in an R-Tree.
+ pub fn count(self: *Self, table_name: []const u8) !i64 {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT COUNT(*) FROM {s}\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ if (try stmt.step()) {
+ return stmt.columnInt(0);
+ }
+ return 0;
+ }
+
+ /// Gets the bounding box that encompasses all entries in the R-Tree.
+ pub fn getBounds2D(self: *Self, table_name: []const u8) !?BoundingBox2D {
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT MIN(min_x), MAX(max_x), MIN(min_y), MAX(max_y) FROM {s}\x00",
+ .{table_name},
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ defer stmt.finalize();
+
+ if (try stmt.step()) {
+ if (stmt.columnIsNull(0)) return null;
+ return BoundingBox2D{
+ .min_x = stmt.columnFloat(0),
+ .max_x = stmt.columnFloat(1),
+ .min_y = stmt.columnFloat(2),
+ .max_y = stmt.columnFloat(3),
+ };
+ }
+ return null;
+ }
+
+ // ========================================================================
+ // Join Operations (R-Tree with regular tables)
+ // ========================================================================
+
+ /// Performs a spatial join between an R-Tree and a regular table.
+ /// Returns entries from both tables where the R-Tree bbox intersects the query box.
+ ///
+ /// Example:
+ /// ```zig
+ /// var stmt = try rtree.spatialJoin2D(
+ /// "locations", // R-Tree table
+ /// "places", // Regular table
+ /// "id", // Join column in places
+ /// &.{ "name", "type" }, // Columns to select from places
+ /// query_box,
+ /// );
+ /// ```
+ pub fn spatialJoin2D(
+ self: *Self,
+ rtree_table: []const u8,
+ data_table: []const u8,
+ join_column: []const u8,
+ select_columns: []const []const u8,
+ box: BoundingBox2D,
+ ) !Statement {
+ // Build select columns
+ var col_buf: [2048]u8 = undefined;
+ var col_len: usize = 0;
+
+ @memcpy(col_buf[col_len..][0..4], "r.id");
+ col_len += 4;
+
+ for (select_columns) |col| {
+ @memcpy(col_buf[col_len..][0..4], ", d.");
+ col_len += 4;
+ @memcpy(col_buf[col_len..][0..col.len], col);
+ col_len += col.len;
+ }
+
+ const sql = try std.fmt.allocPrint(
+ self.allocator,
+ "SELECT {s} FROM {s} r JOIN {s} d ON r.id = d.{s} WHERE r.min_x <= ? AND r.max_x >= ? AND r.min_y <= ? AND r.max_y >= ?\x00",
+ .{ col_buf[0..col_len], rtree_table, data_table, join_column },
+ );
+ defer self.allocator.free(sql);
+
+ var stmt = try self.db.prepare(sql[0 .. sql.len - 1 :0]);
+ errdefer stmt.finalize();
+
+ try stmt.bindFloat(1, box.max_x);
+ try stmt.bindFloat(2, box.min_x);
+ try stmt.bindFloat(3, box.max_y);
+ try stmt.bindFloat(4, box.min_y);
+
+ return stmt;
+ }
+};
diff --git a/src/statement.zig b/src/statement.zig
index e872acb..4678ca6 100644
--- a/src/statement.zig
+++ b/src/statement.zig
@@ -375,4 +375,317 @@ pub const Statement = struct {
c.sqlite3_free(expanded);
return result;
}
+
+ // ========================================================================
+ // Batch binding with tuples/structs
+ // ========================================================================
+
+ /// Binds all values from a tuple or struct to positional parameters.
+ /// Parameters are bound starting at index 1.
+ ///
+ /// Example:
+ /// ```zig
+ /// var stmt = try db.prepare("INSERT INTO users (name, age, score) VALUES (?, ?, ?)");
+ /// try stmt.bindAll(.{ "Alice", @as(i64, 30), @as(f64, 95.5) });
+ /// ```
+ ///
+ /// Supported types:
+ /// - `[]const u8`, `[:0]const u8` -> bindText
+ /// - `i64`, `i32`, `i16`, `i8`, `u32`, `u16`, `u8` -> bindInt
+ /// - `f64`, `f32` -> bindFloat
+ /// - `bool` -> bindBool
+ /// - `@TypeOf(null)` -> bindNull
+ /// - `?T` (optionals) -> bindNull if null, otherwise bind inner value
+ pub fn bindAll(self: *Self, values: anytype) Error!void {
+ const T = @TypeOf(values);
+ const info = @typeInfo(T);
+
+ if (info == .@"struct") {
+ const fields = info.@"struct".fields;
+ inline for (fields, 0..) |field, i| {
+ const value = @field(values, field.name);
+ try self.bindValue(@intCast(i + 1), value);
+ }
+ } else {
+ @compileError("bindAll expects a tuple or struct, got " ++ @typeName(T));
+ }
+ }
+
+ /// Binds a single value of any supported type to a parameter index.
+ pub fn bindValue(self: *Self, index: u32, value: anytype) Error!void {
+ const T = @TypeOf(value);
+ const info = @typeInfo(T);
+
+ // Handle optionals
+ if (info == .optional) {
+ if (value) |v| {
+ return self.bindValue(index, v);
+ } else {
+ return self.bindNull(index);
+ }
+ }
+
+ // Handle null type
+ if (T == @TypeOf(null)) {
+ return self.bindNull(index);
+ }
+
+ // Handle pointers to arrays (string literals)
+ if (info == .pointer) {
+ const child = info.pointer.child;
+ if (info.pointer.size == .slice) {
+ // []const u8 or []u8
+ if (child == u8) {
+ return self.bindText(index, value);
+ }
+ } else if (info.pointer.size == .one) {
+ // *const [N]u8 (string literal)
+ const child_info = @typeInfo(child);
+ if (child_info == .array and child_info.array.child == u8) {
+ return self.bindText(index, value);
+ }
+ }
+ }
+
+ // Handle arrays
+ if (info == .array and info.array.child == u8) {
+ return self.bindText(index, &value);
+ }
+
+ // Handle integers
+ if (info == .int or info == .comptime_int) {
+ return self.bindInt(index, @intCast(value));
+ }
+
+ // Handle floats
+ if (info == .float or info == .comptime_float) {
+ return self.bindFloat(index, @floatCast(value));
+ }
+
+ // Handle booleans
+ if (T == bool) {
+ return self.bindBool(index, value);
+ }
+
+ @compileError("Unsupported type for bindValue: " ++ @typeName(T));
+ }
+
+ /// Resets the statement and binds new values in one call.
+ /// Useful for executing the same statement multiple times with different values.
+ ///
+ /// Example:
+ /// ```zig
+ /// var stmt = try db.prepare("INSERT INTO users (name, age) VALUES (?, ?)");
+ /// try stmt.rebind(.{ "Alice", @as(i64, 30) });
+ /// _ = try stmt.step();
+ /// try stmt.rebind(.{ "Bob", @as(i64, 25) });
+ /// _ = try stmt.step();
+ /// ```
+ pub fn rebind(self: *Self, values: anytype) Error!void {
+ try self.reset();
+ try self.clearBindings();
+ try self.bindAll(values);
+ }
+
+ // ========================================================================
+ // Row Iterator
+ // ========================================================================
+
+ /// Returns an iterator over result rows.
+ /// This provides a more idiomatic Zig interface for iterating over query results.
+ ///
+ /// Example:
+ /// ```zig
+ /// var stmt = try db.prepare("SELECT id, name FROM users");
+ /// defer stmt.finalize();
+ ///
+ /// var iter = stmt.iterator();
+ /// while (try iter.next()) |row| {
+ /// const id = row.int(0);
+ /// const name = row.text(1) orelse "(null)";
+ /// std.debug.print("User {}: {s}\n", .{ id, name });
+ /// }
+ /// ```
+ pub fn iterator(self: *Self) RowIterator {
+ return RowIterator{ .stmt = self };
+ }
+
+ /// Convenience method to iterate with a callback.
+ /// The callback receives a Row for each result row.
+ ///
+ /// Example:
+ /// ```zig
+ /// try stmt.forEach(struct {
+ /// fn call(row: Row) void {
+ /// std.debug.print("{}: {s}\n", .{ row.int(0), row.text(1) orelse "" });
+ /// }
+ /// }.call);
+ /// ```
+ pub fn forEach(self: *Self, callback: *const fn (Row) void) Error!void {
+ while (try self.step()) {
+ callback(Row{ .stmt = self });
+ }
+ }
+
+ /// Executes the statement and collects all rows into a slice.
+ /// Each row is represented as an array of column values.
+ /// Caller owns the returned memory.
+ pub fn collectAll(self: *Self, allocator: std.mem.Allocator) ![]Row.Values {
+ var rows = std.ArrayList(Row.Values).init(allocator);
+ errdefer {
+ for (rows.items) |*r| r.deinit(allocator);
+ rows.deinit();
+ }
+
+ while (try self.step()) {
+ const row = Row{ .stmt = self };
+ try rows.append(try row.toValues(allocator));
+ }
+
+ return rows.toOwnedSlice();
+ }
+};
+
+/// Row iterator for idiomatic iteration over query results.
+pub const RowIterator = struct {
+ stmt: *Statement,
+
+ const Self = @This();
+
+ /// Advances to the next row.
+ /// Returns a Row if there's data available, null if iteration is complete.
+ pub fn next(self: *Self) Error!?Row {
+ if (try self.stmt.step()) {
+ return Row{ .stmt = self.stmt };
+ }
+ return null;
+ }
+
+ /// Resets the iterator to the beginning.
+ pub fn reset(self: *Self) Error!void {
+ try self.stmt.reset();
+ }
+};
+
+/// Represents a single row in a query result.
+/// Provides convenient access to column values.
+pub const Row = struct {
+ stmt: *Statement,
+
+ const Self = @This();
+
+ /// Returns the number of columns.
+ pub fn columnCount(self: Self) u32 {
+ return self.stmt.columnCount();
+ }
+
+ /// Returns an integer column value.
+ pub fn int(self: Self, index: u32) i64 {
+ return self.stmt.columnInt(index);
+ }
+
+ /// Returns a float column value.
+ pub fn float(self: Self, index: u32) f64 {
+ return self.stmt.columnFloat(index);
+ }
+
+ /// Returns a text column value.
+ pub fn text(self: Self, index: u32) ?[]const u8 {
+ return self.stmt.columnText(index);
+ }
+
+ /// Returns a blob column value.
+ pub fn blob(self: Self, index: u32) ?[]const u8 {
+ return self.stmt.columnBlob(index);
+ }
+
+ /// Returns a boolean column value.
+ pub fn boolean(self: Self, index: u32) bool {
+ return self.stmt.columnBool(index);
+ }
+
+ /// Returns true if the column is NULL.
+ pub fn isNull(self: Self, index: u32) bool {
+ return self.stmt.columnIsNull(index);
+ }
+
+ /// Returns the column type.
+ pub fn columnType(self: Self, index: u32) ColumnType {
+ return self.stmt.columnType(index);
+ }
+
+ /// Returns the column name.
+ pub fn columnName(self: Self, index: u32) ?[]const u8 {
+ return self.stmt.columnName(index);
+ }
+
+ /// A value that can hold any SQLite column type.
+ pub const Value = union(ColumnType) {
+ integer: i64,
+ float: f64,
+ text: []const u8,
+ blob: []const u8,
+ null_value: void,
+ };
+
+ /// Owned values for a complete row.
+ pub const Values = struct {
+ items: []Value,
+ text_copies: [][]u8,
+ allocator: std.mem.Allocator,
+
+ pub fn deinit(self: *Values, allocator: std.mem.Allocator) void {
+ for (self.text_copies) |copy| {
+ allocator.free(copy);
+ }
+ allocator.free(self.text_copies);
+ allocator.free(self.items);
+ }
+ };
+
+ /// Converts the current row to owned Values.
+ /// Text and blob values are copied so they persist after iteration.
+ pub fn toValues(self: Self, allocator: std.mem.Allocator) !Values {
+ const count = self.columnCount();
+ const items = try allocator.alloc(Value, count);
+ errdefer allocator.free(items);
+
+ var text_copies = std.ArrayList([]u8).init(allocator);
+ errdefer {
+ for (text_copies.items) |copy| allocator.free(copy);
+ text_copies.deinit();
+ }
+
+ for (0..count) |i| {
+ const idx: u32 = @intCast(i);
+ const col_type = self.columnType(idx);
+ items[i] = switch (col_type) {
+ .integer => .{ .integer = self.int(idx) },
+ .float => .{ .float = self.float(idx) },
+ .text => blk: {
+ if (self.text(idx)) |t| {
+ const copy = try allocator.dupe(u8, t);
+ try text_copies.append(copy);
+ break :blk .{ .text = copy };
+ }
+ break :blk .{ .null_value = {} };
+ },
+ .blob => blk: {
+ if (self.blob(idx)) |b| {
+ const copy = try allocator.dupe(u8, b);
+ try text_copies.append(copy);
+ break :blk .{ .blob = copy };
+ }
+ break :blk .{ .null_value = {} };
+ },
+ .null_value => .{ .null_value = {} },
+ };
+ }
+
+ return Values{
+ .items = items,
+ .text_copies = try text_copies.toOwnedSlice(),
+ .allocator = allocator,
+ };
+ }
};
diff --git a/src/vtable.zig b/src/vtable.zig
new file mode 100644
index 0000000..cb1ac4b
--- /dev/null
+++ b/src/vtable.zig
@@ -0,0 +1,321 @@
+//! Virtual Table API
+//!
+//! Provides the foundation for creating custom SQLite virtual tables in Zig.
+//! Virtual tables allow you to expose any data source (files, APIs, computed data)
+//! as if it were a regular SQL table.
+//!
+//! This is an advanced feature - most users should use the higher-level helpers
+//! like FTS5, JSON, or R-Tree modules instead.
+
+const std = @import("std");
+const c = @import("c.zig").c;
+const Database = @import("database.zig").Database;
+const errors = @import("errors.zig");
+
+const Error = errors.Error;
+const resultToError = errors.resultToError;
+
+/// Index constraint operators.
+pub const ConstraintOp = enum(u8) {
+ eq = 2, // SQLITE_INDEX_CONSTRAINT_EQ
+ gt = 4, // SQLITE_INDEX_CONSTRAINT_GT
+ le = 8, // SQLITE_INDEX_CONSTRAINT_LE
+ lt = 16, // SQLITE_INDEX_CONSTRAINT_LT
+ ge = 32, // SQLITE_INDEX_CONSTRAINT_GE
+ match = 64, // SQLITE_INDEX_CONSTRAINT_MATCH
+ like = 65, // SQLITE_INDEX_CONSTRAINT_LIKE
+ glob = 66, // SQLITE_INDEX_CONSTRAINT_GLOB
+ regexp = 67, // SQLITE_INDEX_CONSTRAINT_REGEXP
+ ne = 68, // SQLITE_INDEX_CONSTRAINT_NE
+ isnot = 69, // SQLITE_INDEX_CONSTRAINT_ISNOT
+ isnotnull = 70, // SQLITE_INDEX_CONSTRAINT_ISNOTNULL
+ isnull = 71, // SQLITE_INDEX_CONSTRAINT_ISNULL
+ is = 72, // SQLITE_INDEX_CONSTRAINT_IS
+ limit = 73, // SQLITE_INDEX_CONSTRAINT_LIMIT
+ offset = 74, // SQLITE_INDEX_CONSTRAINT_OFFSET
+ function = 150, // SQLITE_INDEX_CONSTRAINT_FUNCTION
+};
+
+/// Constraint information for query planning.
+pub const IndexConstraint = struct {
+ column: i32,
+ op: ConstraintOp,
+ usable: bool,
+};
+
+/// Order by information for query planning.
+pub const IndexOrderBy = struct {
+ column: i32,
+ desc: bool,
+};
+
+/// Index info passed to xBestIndex.
+pub const IndexInfo = struct {
+ constraints: []IndexConstraint,
+ order_by: []IndexOrderBy,
+ // Output fields set by xBestIndex
+ idx_num: i32 = 0,
+ idx_str: ?[]const u8 = null,
+ order_by_consumed: bool = false,
+ estimated_cost: f64 = 1000000.0,
+ estimated_rows: i64 = 1000000,
+};
+
+/// Column type declaration for virtual table schema.
+pub const ColumnDef = struct {
+ name: []const u8,
+ col_type: []const u8,
+ constraints: ?[]const u8 = null,
+};
+
+/// Virtual table module definition.
+/// Implement this interface to create a custom virtual table.
+pub fn VTableModule(comptime Context: type, comptime Cursor: type) type {
+ return struct {
+ /// Called when the virtual table is created.
+ /// Should return the column definitions for CREATE TABLE.
+ create_fn: *const fn (db: *Database, args: []const []const u8) anyerror!Context,
+
+ /// Called when the virtual table is connected (opened).
+ connect_fn: *const fn (db: *Database, args: []const []const u8) anyerror!Context,
+
+ /// Called to query the best index for a query.
+ best_index_fn: *const fn (ctx: *Context, info: *IndexInfo) anyerror!void,
+
+ /// Called when the virtual table is destroyed.
+ destroy_fn: *const fn (ctx: *Context) void,
+
+ /// Called to open a cursor for scanning.
+ open_fn: *const fn (ctx: *Context) anyerror!Cursor,
+
+ /// Called to close a cursor.
+ close_fn: *const fn (cursor: *Cursor) void,
+
+ /// Called to start a query with the chosen index.
+ filter_fn: *const fn (cursor: *Cursor, idx_num: i32, idx_str: ?[]const u8, args: []const ?*c.sqlite3_value) anyerror!void,
+
+ /// Called to advance to the next row.
+ next_fn: *const fn (cursor: *Cursor) anyerror!void,
+
+ /// Called to check if we've reached end of data.
+ eof_fn: *const fn (cursor: *Cursor) bool,
+
+ /// Called to get a column value.
+ column_fn: *const fn (cursor: *Cursor, col_idx: i32, result_ctx: *c.sqlite3_context) anyerror!void,
+
+ /// Called to get the rowid.
+ rowid_fn: *const fn (cursor: *Cursor) anyerror!i64,
+
+ /// Optional: Called to update a row.
+ update_fn: ?*const fn (ctx: *Context, argc: i32, argv: [*]?*c.sqlite3_value, rowid: *i64) anyerror!void = null,
+
+ /// Returns the schema for this virtual table.
+ schema_fn: *const fn (ctx: *Context) []const ColumnDef,
+ };
+}
+
+/// Simple read-only virtual table helper.
+/// Easier to use than the full VTableModule for simple cases.
+pub fn SimpleVTable(comptime Row: type) type {
+ return struct {
+ data: []const Row,
+ columns: []const ColumnDef,
+
+ const Self = @This();
+
+ pub fn init(data: []const Row, columns: []const ColumnDef) Self {
+ return .{
+ .data = data,
+ .columns = columns,
+ };
+ }
+
+ /// Cursor for iterating over rows.
+ pub const Cursor = struct {
+ table: *const Self,
+ current_row: usize,
+
+ pub fn next(self: *Cursor) void {
+ self.current_row += 1;
+ }
+
+ pub fn eof(self: *const Cursor) bool {
+ return self.current_row >= self.table.data.len;
+ }
+
+ pub fn currentRow(self: *const Cursor) ?Row {
+ if (self.eof()) return null;
+ return self.table.data[self.current_row];
+ }
+
+ pub fn rowid(self: *const Cursor) i64 {
+ return @intCast(self.current_row + 1);
+ }
+ };
+
+ pub fn openCursor(self: *const Self) Cursor {
+ return .{
+ .table = self,
+ .current_row = 0,
+ };
+ }
+ };
+}
+
+/// Generates CREATE TABLE statement for a virtual table schema.
+pub fn generateSchema(allocator: std.mem.Allocator, table_name: []const u8, columns: []const ColumnDef) ![]u8 {
+ var sql = std.ArrayList(u8).init(allocator);
+ errdefer sql.deinit();
+
+ try sql.appendSlice("CREATE TABLE ");
+ try sql.appendSlice(table_name);
+ try sql.appendSlice("(");
+
+ for (columns, 0..) |col, i| {
+ if (i > 0) try sql.appendSlice(", ");
+ try sql.appendSlice(col.name);
+ try sql.append(' ');
+ try sql.appendSlice(col.col_type);
+ if (col.constraints) |cons| {
+ try sql.append(' ');
+ try sql.appendSlice(cons);
+ }
+ }
+
+ try sql.appendSlice(")");
+ return sql.toOwnedSlice();
+}
+
+/// Helper to set a result value in a sqlite3_context.
+pub const ResultHelper = struct {
+ ctx: *c.sqlite3_context,
+
+ const Self = @This();
+
+ pub fn init(ctx: *c.sqlite3_context) Self {
+ return .{ .ctx = ctx };
+ }
+
+ pub fn setNull(self: Self) void {
+ c.sqlite3_result_null(self.ctx);
+ }
+
+ pub fn setInt(self: Self, value: i64) void {
+ c.sqlite3_result_int64(self.ctx, value);
+ }
+
+ pub fn setFloat(self: Self, value: f64) void {
+ c.sqlite3_result_double(self.ctx, value);
+ }
+
+ pub fn setText(self: Self, value: []const u8) void {
+ c.sqlite3_result_text(self.ctx, value.ptr, @intCast(value.len), c.SQLITE_TRANSIENT);
+ }
+
+ pub fn setBlob(self: Self, value: []const u8) void {
+ c.sqlite3_result_blob(self.ctx, value.ptr, @intCast(value.len), c.SQLITE_TRANSIENT);
+ }
+
+ pub fn setError(self: Self, msg: []const u8) void {
+ c.sqlite3_result_error(self.ctx, msg.ptr, @intCast(msg.len));
+ }
+
+ pub fn setErrorCode(self: Self, code: c_int) void {
+ c.sqlite3_result_error_code(self.ctx, code);
+ }
+};
+
+/// Helper to read values from sqlite3_value.
+pub const ValueHelper = struct {
+ value: *c.sqlite3_value,
+
+ const Self = @This();
+
+ pub fn init(value: *c.sqlite3_value) Self {
+ return .{ .value = value };
+ }
+
+ pub fn getType(self: Self) c_int {
+ return c.sqlite3_value_type(self.value);
+ }
+
+ pub fn isNull(self: Self) bool {
+ return self.getType() == c.SQLITE_NULL;
+ }
+
+ pub fn getInt(self: Self) i64 {
+ return c.sqlite3_value_int64(self.value);
+ }
+
+ pub fn getFloat(self: Self) f64 {
+ return c.sqlite3_value_double(self.value);
+ }
+
+ pub fn getText(self: Self) ?[]const u8 {
+ const len = c.sqlite3_value_bytes(self.value);
+ const text = c.sqlite3_value_text(self.value);
+ if (text) |t| {
+ return t[0..@intCast(len)];
+ }
+ return null;
+ }
+
+ pub fn getBlob(self: Self) ?[]const u8 {
+ const len = c.sqlite3_value_bytes(self.value);
+ const blob = c.sqlite3_value_blob(self.value);
+ if (blob) |b| {
+ const ptr: [*]const u8 = @ptrCast(b);
+ return ptr[0..@intCast(len)];
+ }
+ return null;
+ }
+};
+
+/// Eponymous virtual table - appears automatically without CREATE VIRTUAL TABLE.
+/// Useful for table-valued functions.
+pub const EponymousModule = struct {
+ name: []const u8,
+ num_args: i32,
+
+ /// Generates SQL to query an eponymous module.
+ pub fn query(self: *const EponymousModule, allocator: std.mem.Allocator, args: []const []const u8) ![]u8 {
+ var sql = std.ArrayList(u8).init(allocator);
+ errdefer sql.deinit();
+
+ try sql.appendSlice("SELECT * FROM ");
+ try sql.appendSlice(self.name);
+ try sql.append('(');
+
+ for (args, 0..) |arg, i| {
+ if (i > 0) try sql.appendSlice(", ");
+ try sql.append('\'');
+ try sql.appendSlice(arg);
+ try sql.append('\'');
+ }
+
+ try sql.append(')');
+ return sql.toOwnedSlice();
+ }
+};
+
+// Note: Full virtual table registration requires complex C callback trampolines
+// similar to what we do for UDFs. The helpers above provide the building blocks
+// but the actual registration would need additional C interop code.
+//
+// For most use cases, the built-in virtual tables (FTS5, R-Tree, JSON) are
+// sufficient. Custom virtual tables are an advanced feature that few applications
+// need.
+
+/// Common virtual table modules available in SQLite.
+pub const BuiltinModules = struct {
+ /// FTS5 full-text search
+ pub const fts5 = "fts5";
+ /// R-Tree spatial index
+ pub const rtree = "rtree";
+ /// CSV virtual table (if compiled with)
+ pub const csv = "csv";
+ /// Generate series (built-in table-valued function)
+ pub const generate_series = "generate_series";
+ /// PRAGMA function as table
+ pub const pragma = "pragma";
+};