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
- cereal — the ORM built on ASTQL
- sentinel — struct metadata extraction
- Schema Validation guide — DBML integration details
- Providers — database dialect support