Skip to content

Compile Time SQL Validation

Stefan R. Steiner edited this page Jun 3, 2026 · 2 revisions

Compile-Time SQL Validation

New in v0.4.0. Entirely opt-in.

Please note, this is experimental. If you enable this, you might find in the VC/VS editor that after you fix a SQL query/statement, the squiggly line error sticks in certain cases even after it's fixed. This appears to be caused by some caching done by the editor but if you compile after fixing the SQL, compile will work fine. I will fix this when I get some time.

The hyperdb-api-derive crate can validate your SQL at build time — typos, unknown columns, and struct/SQL mismatches become cargo build errors (with red squigglies in VS Code) instead of runtime failures you only discover when the query runs.

It's off by default. Without the feature flag the macros are pure pass-throughs — zero validation, zero extra dependencies, no hyperd needed at build time. You opt in per-project by enabling a feature.


How it works

Three pieces work together:

  1. #[derive(Table)] on a struct generates a CREATE TABLE schema and (with #[hyperdb(register)]) registers that schema with the compile-time validator.
  2. query_as!(T, "sql") / query_scalar!(T, "sql") are typed query builders. When the compile-time feature is on, they check the SQL against the registered schemas during macro expansion.
  3. At build time the validator spins up an embedded Hyper instance, seeds it with the registered CREATE TABLE statements, and asks Hyper to plan (not run) your query. If Hyper rejects it — unknown column, unknown table — that becomes a compile_error! pointing at your SQL string.

Because it's the real Hyper SQL planner doing the checking, it understands joins, aliases, expressions, and the actual SQL dialect — not a regex approximation.


Setup

Add hyperdb-api-derive directly to your [dependencies] with the feature enabled:

[dependencies]
hyperdb-api = "0.4"
hyperdb-api-derive = { version = "0.4", features = ["compile-time"] }

The build needs to find a hyperd binary. Point HYPERD_PATH at one (the hyperdb-bootstrap CLI can download a matching release into .hyperd/current/):

cargo install hyperdb-bootstrap
hyperdb-bootstrap download                 # installs ./.hyperd/current/hyperd
export HYPERD_PATH="$PWD/.hyperd/current"

Example

use hyperdb_api::{Connection, Table};
use hyperdb_api_derive::{query_as, query_scalar, FromRow, Table};

// derive(Table) generates the CREATE TABLE schema and — with `register` —
// registers it so query_as! can validate against it at build time.
// derive(FromRow) lets query_as! map result rows back into the struct.
#[derive(Debug, FromRow, Table)]
#[hyperdb(table = "users", register)]
struct User {
    #[hyperdb(primary_key)]
    id: i64,
    name: String,
    email: Option<String>,
    score: f64,
}

fn main() -> hyperdb_api::Result<()> {
    let conn = Connection::connect(
        "localhost:7483",
        "app.hyper",
        hyperdb_api::CreateMode::CreateIfNotExists,
    )?;

    // Use the derived schema to create the table at runtime.
    conn.execute_command(User::CREATE_SQL)?;

    // ✅ Validated at build time: every column exists, and every field of
    //    `User` is projected by the SELECT.
    let users: Vec<User> =
        query_as!(User, "SELECT id, name, email, score FROM users ORDER BY id")
            .fetch_all(&conn)?;

    let alice: Option<User> =
        query_as!(User, "SELECT id, name, email, score FROM users WHERE id = 1")
            .fetch_optional(&conn)?;

    // query_scalar! validates single-column queries.
    let count: i64 = query_scalar!(i64, "SELECT COUNT(*) FROM users").fetch_one(&conn)?;

    println!("{count} users, first batch of {}", users.len());
    let _ = alice;
    Ok(())
}

Builder methods on query_as!: .fetch_all(&conn), .fetch_one(&conn), .fetch_optional(&conn).

What gets caught

A typo in a column name fails the build instead of returning a runtime error:

// ❌ compile error — "emai1" is not a column
let users: Vec<User> = query_as!(User, "SELECT id, name, emai1 FROM users").fetch_all(&conn)?;
error: column "emai1" does not exist on any table in the query;
       check for a typo or a renamed/dropped column

Forgetting to project a field the struct needs also fails:

// ❌ compile error — `User` needs `email` but the SELECT omits it
let users: Vec<User> = query_as!(User, "SELECT id, name, score FROM users").fetch_all(&conn)?;
error: `User` requires column "email" but the query does not project it;
       add it to the SELECT list or remove the field from `User`

Joins across multiple registered structs work too — register each table's struct and the validator seeds all of them:

#[derive(FromRow, Table)]
#[hyperdb(table = "orders", register)]
struct Order { id: i64, user_id: i64, total: f64 }

// ✅ validated against both `users` and `orders`
let rows: Vec<OrderView> = query_as!(
    OrderView,
    "SELECT u.name, o.total FROM orders o JOIN users u ON u.id = o.user_id"
).fetch_all(&conn)?;

VS Code: red squigglies on bad SQL

To see compile-time errors live in the editor:

1. Add HYPERD_PATH to your shell (~/.zshrc / ~/.bashrc), then restart your terminal and VS Code:

export HYPERD_PATH=/path/to/your/project/.hyperd/current

2. Add .vscode/settings.json in your workspace root:

{
  "rust-analyzer.cargo.features": ["hyperdb-api-derive/compile-time"],
  "rust-analyzer.server.extraEnv": {
    "HYPERD_PATH": "${workspaceFolder}/.hyperd/current"
  }
}

⚠️ rust-analyzer.cargo.features must be a flat array of "package/feature" strings, as shown. rust-analyzer silently ignores the JSON-object form and builds with no features — so validation never fires and you get no squigglies.

3. Reload the window (Cmd/Ctrl+Shift+PDeveloper: Reload Window).

After RA finishes indexing, bad SQL string literals get squigglies and the errors appear in the Problems panel. The first macro expansion starts an embedded Hyper instance (~150 ms); later expansions reuse it.

Disabling on a machine without hyperd

If a teammate's machine has no hyperd, the proc-macro can be turned off locally so RA doesn't error:

{
  "rust-analyzer.procMacro.ignored": {
    "hyperdb-api-derive": ["query_as", "query_scalar"]
  }
}

Module ordering constraint

#[derive(Table)] registers a struct at macro-expansion time. Within a single file this is never a problem — struct derives always expand before function-body macros.

Across files, the module containing your #[derive(Table)] structs must be declared (mod schema;) before the module containing query_as! calls in your lib.rs / main.rs:

mod schema;   // ← declare registered-struct module FIRST
mod queries;  // ← query_as! calls live here

If you get a spurious StructNotRegistered error, reorder the mod declarations.


Known limitations

  • Column names only — no type checking yet. A SELECTed column whose SQL type doesn't match the struct field still compiles; it surfaces at runtime as Error::Column { kind: TypeMismatch }.
  • No bind-parameter type checking. $1, $2, … are opaque at compile time.
  • Validates struct-vs-SQL, not SQL-vs-production-DB. If your registered struct drifts from the real production schema, that mismatch is still a runtime error.
  • INSERT / UPDATE / DELETE without RETURNING aren't supported by query_as!; use Connection::execute_command for those.

When to use it

Reach for compile-time validation on projects with many hand-written SQL queries against stable, struct-modeled tables — it turns a class of runtime bugs (typos, dropped columns, struct/query drift) into build failures. For quick scripts, exploratory queries, or dynamic SQL, the plain runtime APIs (fetch_all_as and friends) are simpler and need no build-time hyperd.

See also: Row Mapping for the runtime struct-mapping forms that pair with these macros.