From 167e54530fdf1fb24fb63134a81f01f2efcab6b8 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 20:30:10 +0100 Subject: [PATCH] feat: add advanced features - batch bind, row iterator, FTS5, JSON, R-Tree, virtual tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Batch binding: stmt.bindAll(.{ "Alice", 30, 95.5 }), stmt.rebind() - Row iterator: stmt.iterator(), Row struct with convenient accessors - File control: setFileControlInt(), getPersistWal(), setChunkSize() - FTS5 helpers: Fts5 struct with createSimpleTable(), search(), highlight() - JSON helpers: Json struct with extract(), set(), createArray(), patch() - R-Tree helpers: RTree struct with insert2D(), queryIntersects2D(), spatial joins - Virtual table foundations: vtable.zig with helper types - BoundingBox2D/3D, GeoCoord with distance calculations Total: 5861 lines across 13 modules All 54 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/database.zig | 106 +++++++++ src/fts5.zig | 229 +++++++++++++++++++ src/json.zig | 437 +++++++++++++++++++++++++++++++++++ src/root.zig | 295 ++++++++++++++++++++++++ src/rtree.zig | 564 ++++++++++++++++++++++++++++++++++++++++++++++ src/statement.zig | 313 +++++++++++++++++++++++++ src/vtable.zig | 321 ++++++++++++++++++++++++++ 7 files changed, 2265 insertions(+) create mode 100644 src/fts5.zig create mode 100644 src/json.zig create mode 100644 src/rtree.zig create mode 100644 src/vtable.zig 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"; +};