zig-sqlite

repository·master·Indexed 20 days ago

https://github.com/vrischmann/zig-sqlite

A thin, high-level wrapper around the SQLite C API for the Zig programming language. It features type-safe, comptime-checked SQL execution, data mapping between Zig types and SQLite types, and support for custom scalar and aggregate SQL functions.

Tokens
3.7K
Snippets
12
Records
13
Agent score
20%

What's inside zig-sqlite

  1. Read query results in one go

    master

    You can fetch query results directly into Zig types using several methods on sqlite.Statement. These methods take a type as the first parameter representing a 'row'.

    A row type can be:

    • A struct where fields map to columns (field 0 maps to column 1, etc.).
    • A single type (e.g., usize, []const u8) if the resultset has exactly one column.

    Available methods:

    • Statement.one(Type, params, ...): Does not allocate memory (except what SQLite allocates). Returns an optional ?Type.
    • Statement.all(Type, allocator, ...): Allocates an array of Type using the provided allocator.
    • Statement.oneAlloc(Type, allocator, ...): Allocates a single Type using the provided allocator.

    Note: When reading TEXT into an array, you must use a sentinel-terminated array (e.g., [128:0]u8) to handle variable lengths.

    // Using Statement.one with a struct
    const row = try stmt.one(
        struct {
            name: [128:0]u8,
            age: usize,
        },
        .{},
        .{ .id = 20 },
    );
    
    // Using Statement.all with an allocator
    const names = try stmt.all([]const u8, allocator, .{}, .{ .age1 = 20, .age2 = 40 });
    
    // Using Statement.oneAlloc with an allocator
    const row = try stmt.oneAlloc([]const u8, allocator, .{}, .{ .id = 200 });
  2. Use Comptime checks for bind parameters

    master

    Prepared statements in zig-sqlite use compile-time metadata to validate queries. This provides two levels of safety:

    1. Parameter Count Validation: The compiler ensures the number of bind markers (?) in your SQL matches the number of fields provided in the arguments object.
    2. Type Validation: By default, SQLite types are unknown at compile time. You can enforce type safety by using the syntax ?{Type} in your SQL string. This forces the compiler to check that the values provided in your Zig code match the specified type.

    Supported Types for ?{Type}:

    • All integer types
    • All arbitrary bit-width integer types
    • All float types
    • bool
    • Strings ([]const u8 or []u8)
    • sqlite.Text
    • sqlite.Blob

    Note: This is CPU intensive; you may need to increase @setEvalBranchQuota if compilation fails.

    // Enforcing that the 'weight' parameter must be a usize
    var stmt = try db.prepare("SELECT id FROM user WHERE age > ? AND weight > ?{usize}");
    defier stmt.deinit();
    
    const rows = try stmt.all(User, allocator, .{}, .{ 
        .age_1 = 10, 
        .weight = @as(usize, 200) 
    });
  3. Implement custom type binding and reading

    master

    If you need to change how a type is stored in SQLite (e.g., storing an enum as a string or a byte array as an integer), you can define a wrapper struct with bindField and readField methods.

    • bindField(self: T, allocator: std.mem.Allocator) !BaseType: Defines how to convert your type into a SQLite-compatible base type.
    • readField(allocator: std.mem.Allocator, value: BaseType) !T: Defines how to reconstruct your type from a SQLite value.

    CRITICAL: Because zig-sqlite does not track allocations made within these custom methods, you must use a std.heap.ArenaAllocator to prevent memory leaks.

    pub const MyArray = struct {
        data: [4]u8,
    
        pub const BaseType = u32;
    
        pub fn bindField(self: MyArray, _: std.mem.Allocator) !BaseType {
            return std.mem.readIntNative(BaseType, &self.data);
        }
    
        pub fn readField(_: std.mem.Allocator, value: BaseType) !MyArray {
            var arr: MyArray = undefined;
            std.mem.writeIntNative(BaseType, &arr.data, value);
            return arr;
        }
    };
  4. Manage complex allocations with ArenaAllocator

    master

    When fetching rows into structs that contain slices (like []const u8), zig-sqlite performs multiple allocations: one for each slice field and one for the resulting slice of structs. To prevent memory leaks and simplify cleanup, use a std.heap.ArenaAllocator to manage the lifetime of the fetched data.

    const allocator = std.heap.page_allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    
    // Pass the arena allocator to the query method
    const users = try stmt.all(User, arena.allocator(), .{}, .{ .id = 20 });
  5. Install zig-sqlite

    master

    To add zig-sqlite to your project, use the zig fetch command to download the dependency and then configure your build.zig to import the module.

    zig fetch --save git+https://github.com/vrischmann/zig-sqlite

    In your build.zig:

    const sqlite = b.dependency("sqlite", .{
        .target = target,
        .optimize = optimize,
    });
    exe.root_module.addImport("sqlite", sqlite.module("sqlite"));
  6. Reuse a prepared statement

    master

    To improve performance when running the same query multiple times with different parameters, call stmt.reset() before each subsequent execution.

    const query = "UPDATE employees SET salary = ? WHERE id = ?";
    var stmt = try db.prepare(query);
    defer stmt.deinit();
    
    var id: usize = 0;
    while (id < 20) : (id += 1) {
        stmt.reset();
        try stmt.exec(.{}, .{
            .salary = 2000,
            .id = id,
        });
    }
  7. Initialize a sqlite.Db instance

    master

    To use the library, import sqlite and initialize a sqlite.Db instance using sqlite.Db.init. You must provide an InitOptions struct. The mode field is mandatory. Other fields like open_flags and threading_mode have sane defaults.

    const sqlite = @import("sqlite");
    
    var db = try sqlite.Db.init(.{
        .mode = sqlite.Db.Mode{ .File = "/home/vincent/mydata.db" },
        .open_flags = .{
            .write = true,
            .create = true,
        },
        .threading_mode = .MultiThread,
    });
  8. Prepare and execute SQL statements

    master

    SQLite works via prepared statements. Use db.prepare(query) to create a sqlite.Statement. The query string is evaluated at comptime.

    For queries that do not return data (e.g., INSERT, UPDATE), use stmt.exec(.{}, params) to execute the statement with bound parameters.

    try db.exec("CREATE TABLE IF NOT EXISTS employees(id integer primary key, name text, age integer, salary integer)", .{}, .{});
    
    const query = "SELECT id, name, age, salary FROM employees WHERE age > ? AND age < ?";
    var stmt = try db.prepare(query);
    defer stmt.deinit();
    
    // For an INSERT:
    const insert_query = "INSERT INTO employees(name, age, salary) VALUES(?, ?, ?)";
    var ins_stmt = try db.prepare(insert_query);
    defer ins_stmt.deinit();
    try ins_stmt.exec(.{}, .{
        .name = "José",
        .age = 40,
        .salary = 20000,
    });
  9. Register a scalar SQL function

    master

    You can extend SQLite by defining custom scalar functions using db.createScalarFunction. The function receives input arguments which are converted to Zig values based on the SQLite type (e.g., TEXT to []const u8, INTEGER to Zig integers).

    try db.createScalarFunction(
        "blake3",
        struct {
            fn run(input: []const u8) [std.crypto.hash.Blake3.digest_length]u8 {
                var hash: [std.crypto.hash.Blake3.digest_length]u8 = undefined;
                std.crypto.hash.Blake3.hash(input, &hash, .{});
                return hash;
            }
        }.run,
        .{},
    );
    
    // Usage in SQL
    const hash = try db.one([std.crypto.hash.Blake3.digest_length]u8, "SELECT blake3('hello')", .{}, .{});
  10. Get SQL diagnostics on statement preparation

    master

    If db.prepare fails and you need detailed error information, use prepareWithDiags. You must provide a pointer to a sqlite.Diagnostics instance.

    var diags = sqlite.Diagnostics{};
    var stmt = db.prepareWithDiags(query, .{ .diags = &diags }) catch |err| {
        std.log.err("unable to prepare statement, got error {}. diagnostics: {s}", .{ err, diags });
        return err;
    };
    defer stmt.deinit();
  11. Iterate over resultset rows

    master

    For large resultsets, use sqlite.Iterator to process rows one by one. You obtain an iterator by calling stmt.iterator(Type, params).

    • Iterator.next(params): Returns an optional ?Type. Does not allocate memory.
    • Iterator.nextAlloc(allocator, params): Returns an optional ?Type. Allocates memory using the provided allocator.
    // Using Iterator.next (no allocation)
    var iter = try stmt.iterator(usize, .{ .age = 20 });
    while (try iter.next(.{})) |age| {
        std.debug.print("age: {}
    ", .{age});
    }
    
    // Using Iterator.nextAlloc (with allocation)
    var iter = try stmt.iterator([]const u8, .{ .age = 20 });
    const allocator = std.heap.page_allocator;
    while (true) {
        var arena = std.heap.ArenaAllocator.init(allocator);
        defer arena.deinit();
    
        const name = (try iter.nextAlloc(arena.allocator(), .{})) orelse break;
        std.debug.print("name: {s}\n", .{name});
    }
  12. Register an aggregate SQL function

    master

    Aggregate functions (like SUM or AVG) allow you to maintain state across multiple rows. Use db.createAggregateFunction to register them.

    An aggregate function requires:

    1. A context object: A struct that holds the state for the aggregation.
    2. A step function: Called for every row. Its first argument must be the context type.
    3. A finalize function: Called once at the end to return the final result. Its first argument must be the context type.

    Use fctx.userContext(*ContextType) inside the functions to access your state.

    const MyContext = struct {
        sum: u32,
    };
    var my_ctx = MyContext{ .sum = 0 };
    
    try db.createAggregateFunction(
        "mySum",
        &my_ctx,
        struct {
            fn step(fctx: sqlite.FunctionContext, input: u32) void {
                var ctx = fctx.userContext(*MyContext) orelse return;
                ctx.sum += input;
            }
        }.step,
        struct {
            fn finalize(fctx: sqlite.FunctionContext) u32 {
                const ctx = fctx.userContext(*MyContext) orelse return 0;
                return ctx.sum;
            }
        }.finalize,
        .{},
    );
    
    const result = try db.one(usize, "SELECT mySum(nb) FROM foobar", .{}, .{});