feat: add advanced features - batch bind, row iterator, FTS5, JSON, R-Tree, virtual tables

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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 20:30:10 +01:00
parent 5e28cbe4bf
commit 167e54530f
7 changed files with 2265 additions and 0 deletions

View file

@ -792,4 +792,110 @@ pub const Database = struct {
pub fn setLimit(self: *Self, limit_type: Limit, new_value: i32) i32 { pub fn setLimit(self: *Self, limit_type: Limit, new_value: i32) i32 {
return c.sqlite3_limit(self.handle, @intFromEnum(limit_type), new_value); 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);
}
}; };

229
src/fts5.zig Normal file
View file

@ -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;
}
};

437
src/json.zig Normal file
View file

@ -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;
}
}
};

View file

@ -34,6 +34,12 @@ const functions_mod = @import("functions.zig");
const backup_mod = @import("backup.zig"); const backup_mod = @import("backup.zig");
const pool_mod = @import("pool.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) // Re-export C bindings (for advanced users)
pub const c = c_mod.c; pub const c = c_mod.c;
@ -57,6 +63,18 @@ pub const Backup = backup_mod.Backup;
pub const Blob = backup_mod.Blob; pub const Blob = backup_mod.Blob;
pub const ConnectionPool = pool_mod.ConnectionPool; 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 // Re-export function types
pub const FunctionContext = functions_mod.FunctionContext; pub const FunctionContext = functions_mod.FunctionContext;
pub const FunctionValue = functions_mod.FunctionValue; pub const FunctionValue = functions_mod.FunctionValue;
@ -1091,3 +1109,280 @@ test "connection pool reuse" {
pool.release(conn2); 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, "<b>", "</b>", 10);
defer stmt.finalize();
if (try stmt.step()) {
const highlighted = stmt.columnText(0).?;
try std.testing.expect(std.mem.indexOf(u8, highlighted, "<b>quick</b>") != 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);
}

564
src/rtree.zig Normal file
View file

@ -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;
}
};

View file

@ -375,4 +375,317 @@ pub const Statement = struct {
c.sqlite3_free(expanded); c.sqlite3_free(expanded);
return result; 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,
};
}
}; };

321
src/vtable.zig Normal file
View file

@ -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";
};