⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

Writing Rows

The problem

Reading was the query builder; writing is the other half. An INSERT must name every column and number every placeholder; an UPDATE must split key columns from data columns. Writing those statements by hand, per table, is exactly the repetitive, drift-prone work the schema already has the information to do.

The pattern

  1. Derive insert_sql at compile time: column list and placeholder list walked from the same fields, so they cannot disagree in length or order.
  2. Derive update_sql with a stated convention: the first field is the key. Conventions a library documents are API; pick them deliberately.
  3. Pair each statement with bind, which turns a row into an array of tagged-union Values, one per placeholder, in the same field order.
const std = @import("std");
const expect = std.testing.expect;

// The driver-facing value type. SQL text and values travel separately;
// this union is the shape of "separately".
const Value = union(enum) {
    int: i64,
    text: []const u8,
    boolean: bool,
};

fn Table(comptime T: type, comptime name: []const u8) type {
    const info = @typeInfo(T).@"struct";
    return struct {
        // "INSERT INTO users (id, name, active) VALUES ($1, $2, $3)"
        pub const insert_sql = blk: {
            var cols: []const u8 = "";
            var params: []const u8 = "";
            for (info.field_names, 0..) |field, i| {
                if (i > 0) {
                    cols = cols ++ ", ";
                    params = params ++ ", ";
                }
                cols = cols ++ field;
                params = params ++ std.fmt.comptimePrint("${d}", .{i + 1});
            }
            break :blk "INSERT INTO " ++ name ++ " (" ++ cols ++ ") VALUES (" ++ params ++ ")";
        };

        // "UPDATE users SET name = $2, active = $3 WHERE id = $1":
        // the first field is the key, by convention stated in the docs.
        pub const update_sql = blk: {
            var sets: []const u8 = "";
            for (info.field_names[1..], 2..) |field, n| {
                if (n > 2) sets = sets ++ ", ";
                sets = sets ++ field ++ std.fmt.comptimePrint(" = ${d}", .{n});
            }
            break :blk "UPDATE " ++ name ++ " SET " ++ sets ++
                " WHERE " ++ info.field_names[0] ++ " = $1";
        };

        // The runtime half: one Value per column, in placeholder order.
        // The inline for resolves every @field while compiling, so this
        // is a fixed sequence of stores at runtime.
        pub fn bind(row: T) [info.field_names.len]Value {
            var values: [info.field_names.len]Value = undefined;
            inline for (info.field_names, 0..) |field, i| {
                values[i] = switch (@TypeOf(@field(row, field))) {
                    i64 => .{ .int = @field(row, field) },
                    []const u8 => .{ .text = @field(row, field) },
                    bool => .{ .boolean = @field(row, field) },
                    else => @compileError("no Value mapping for field " ++ field),
                };
            }
            return values;
        }
    };
}

const User = struct {
    id: i64,
    name: []const u8,
    active: bool,
};
const Users = Table(User, "users");

test "insert statement is derived, not written" {
    try expect(std.mem.eql(
        u8,
        Users.insert_sql,
        "INSERT INTO users (id, name, active) VALUES ($1, $2, $3)",
    ));
}

test "update keys on the first field" {
    try expect(std.mem.eql(
        u8,
        Users.update_sql,
        "UPDATE users SET name = $2, active = $3 WHERE id = $1",
    ));
}

test "bind produces one typed value per placeholder" {
    const values = Users.bind(.{ .id = 7, .name = "ada", .active = true });
    try expect(values.len == 3);
    try expect(values[0].int == 7);
    try expect(std.mem.eql(u8, values[1].text, "ada"));
    try expect(values[2].boolean == true);
}

The statement and its values cannot drift

The deep property here: insert_sql’s placeholders and bind’s output are generated from one walk over one list of fields. Add a column to the struct and both change together at the next compile. The classic ORM bug (a statement with four placeholders fed five values) is not caught here; it is unrepresentable.

comptimePrint does the number formatting during compilation, so even $3 is baked into the constant. At runtime, bind is a fixed sequence of stores; the inline for leaves no loop behind.

The Value union is the wire contract

Drivers do not take Zig structs; they take a statement and a list of typed values. The Value union is that list’s element type, and the compile error in bind’s else arm is the schema’s type system meeting the driver’s: a field type with no wire mapping fails the build, naming the field.

In a full ORM

The draft adds RETURNING id to inserts, skips auto-managed columns (created_at, updated_at get their values from the library, not the caller), and marks the key column explicitly instead of by position. All three are more comptime walking of the same field list.