zoobzio December 17, 2025 Edit this page

ORM Foundation

Recipe: Use ASTQL as the query building layer for type-safe ORMs.

ASTQL provides the foundation for building ORMs that are structurally safe from SQL injection. This cookbook shows how cereal uses ASTQL to deliver a simple query API with full schema validation.

The Result

Define your model:

type User struct {
    ID    int64  `db:"id" type:"bigserial" constraints:"primarykey"`
    Email string `db:"email" type:"text" constraints:"unique,notnull"`
    Name  string `db:"name" type:"text"`
}

Get a type-safe query API:

users, _ := cereal.New[User](db, "users")

// Simple queries hide ASTQL complexity
user, err := users.Select().
    Where("email", "=", "user_email").
    Exec(ctx, map[string]any{"user_email": "test@example.com"})

// Schema validation happens at initialization, not runtime
users.Select().Where("emai", "=", "x")  // Error: field "emai" not in schema

Three lines to set up. Zero reflection on the query path. Full SQL injection protection.

The Architecture

Cereal wraps three libraries:

+------------------+
|     cereal       |  Simple query API (.Select(), .Insert(), etc.)
+------------------+
        |
+------------------+
|      astql       |  Schema validation + AST building
+------------------+
        |
+------------------+
|     sentinel     |  Struct metadata extraction
+------------------+
        |
+------------------+
|      sqlx        |  Database execution
+------------------+

Each layer has a single responsibility:

  • Sentinel extracts struct metadata (field names, tags, types)
  • ASTQL validates queries against a DBML schema
  • Cereal provides the user-facing API and execution

How ASTQL Enables This

When you call cereal.New[T]():

1. Sentinel Extracts Metadata

metadata := sentinel.Inspect[User]()
// metadata.Fields contains:
// - {Name: "ID", Tags: {"db": "id", "type": "bigserial", "constraints": "primarykey"}}
// - {Name: "Email", Tags: {"db": "email", "type": "text", "constraints": "unique,notnull"}}
// - {Name: "Name", Tags: {"db": "name", "type": "text"}}

2. Cereal Builds DBML Schema

project := dbml.NewProject("app")
table := dbml.NewTable("users")
table.AddColumn(dbml.NewColumn("id", "bigserial"))
table.AddColumn(dbml.NewColumn("email", "text"))
table.AddColumn(dbml.NewColumn("name", "text"))
project.AddTable(table)

3. ASTQL Creates Validated Instance

instance, err := astql.NewFromDBML(project)
// instance now validates all table/field references against this schema

4. Queries Use ASTQL Builders

// cereal.Select() internally calls:
builder := astql.Select(instance.T("users"))

// cereal.Where("email", "=", "user_email") internally calls:
field, _ := instance.TryF("email")      // Validates field exists
param, _ := instance.TryP("user_email") // Validates param name
builder = builder.Where(instance.C(field, astql.EQ, param))

// cereal.Exec() internally calls:
result, _ := builder.Render(postgres.New())
// result.SQL: SELECT ... FROM "users" WHERE "email" = :user_email

The Security Model

User input can only provide values, never structure:

// User provides: {"user_email": "test@example.com"}
// This becomes a parameter value, not part of the SQL structure

users.Select().
    Where("email", "=", "user_email").  // "email" is validated against schema
    Exec(ctx, map[string]any{
        "user_email": userInput,  // User input is a value, safely parameterized
    })

Even if userInput contains "'; DROP TABLE users; --", it's treated as a literal string value, not SQL.

The field name "email" must exist in the struct. The table name "users" must match the schema. Injection vectors are eliminated by construction.

Escape Hatch to ASTQL

For queries beyond cereal's simple API, access ASTQL directly:

instance := users.Instance()

// Build complex queries with full ASTQL power
query := astql.Select(instance.T("users")).
    Fields(
        instance.F("id"),
        instance.F("email"),
        astql.Count(instance.F("id")).As("order_count"),
    ).
    Join(instance.T("orders", "o"), astql.CF(
        instance.WithTable(instance.F("id"), "users"),
        astql.EQ,
        instance.WithTable(instance.F("user_id"), "o"),
    )).
    GroupBy(instance.F("id"), instance.F("email")).
    Having(instance.CAgg(astql.Count(instance.F("id")), astql.GT, instance.P("min_orders")))

result, _ := query.Render(postgres.New())

Same schema validation. Same injection protection. Full query expressiveness.

Building Your Own ORM

To build an ASTQL-backed ORM:

1. Extract Schema from Your Source

// From structs (like cereal)
metadata := sentinel.Inspect[YourModel]()
project := buildDBMLFromMetadata(metadata)

// Or from existing DBML files
project, _ := dbml.Parse(dbmlContent)

// Or programmatically
project := dbml.NewProject("app")
// ... add tables and columns

2. Create ASTQL Instance

instance, err := astql.NewFromDBML(project)
if err != nil {
    return err  // Schema validation failed
}

3. Wrap Query Builders

type YourORM struct {
    instance *astql.ASTQL
    table    string
}

func (o *YourORM) Select(fields ...string) *YourSelectBuilder {
    t := o.instance.T(o.table)
    builder := astql.Select(t)

    for _, name := range fields {
        f := o.instance.F(name)  // Panics if invalid
        builder = builder.Fields(f)
    }

    return &YourSelectBuilder{builder: builder, instance: o.instance}
}

4. Render with Provider

func (b *YourSelectBuilder) SQL() (string, error) {
    result, err := b.builder.Render(postgres.New())
    if err != nil {
        return "", err
    }
    return result.SQL, nil
}

See Also