Skip to content

Code Generation

Axel can generate type-safe code from your ASL schema and compiled AQL queries. Two generators are built in — Go and TypeScript — and you can write your own in any language.


Terminal window
# Go
axel -d ./myproject codegen -g go -o ./gen
# TypeScript
axel -d ./myproject codegen -g ts -o ./gen

Axel auto-discovers all *.aql files under the project directory and compiles them together with the schema.


axel codegen [flags] [query-files...]
Flag Short Default Description
--generator -g Built-in generator name (go or ts)
--plugin -p Path to an external generator binary
--out-dir -o . Directory to write generated files into
--query -q AQL file or glob pattern (repeatable)
--schema-path Schema file (default: from config or axel/schema.asl)
--option key=value passed to the generator (repeatable)

--generator and --plugin are mutually exclusive.

Codegen settings can be configured directly in your axel.yaml configuration file under the codegen key. Flags passed via the CLI always take precedence and override settings in the config file.

axel.yaml
schema-path: ./axel/schema.asl
rel-load-strategy: query # query | join
codegen:
generator: go # go | ts
out-dir: ./db/generated
queries:
- ./queries/*.aql
options:
package: generated

With this configured, running axel codegen will automatically use your specified generator, output directory, query files, and options without needing extra CLI arguments.

Query files are resolved in this priority order:

  1. -q / --query patterns passed via CLI — Axel expands these (supports **/*.aql)
  2. Positional arguments — shell-expanded paths
  3. codegen.queries in axel.yaml
  4. Auto-discovery — all *.aql files under --dir when nothing else is given
Terminal window
# Explicit list
axel codegen -g go -o ./gen -q 'queries/**/*.aql'
# Auto-discover from project dir
axel -d ./myproject codegen -g go -o ./gen
# Mix: all queries plus one extra
axel codegen -g go -o ./gen -q 'queries/*.aql' extra.aql

Directives are @<name> <value> declarations placed before a query. They carry codegen metadata and are parsed as part of the AQL AST (not comments). Recognized directives:

Directive Effect
@name <Name> Sets the query/function name (overrides the filename-derived default)
@request <Name> Names the params struct/interface (default: <Query>Params)
@response <Name> Names the row struct/interface (default: <Query>Row)
@rel_load_strategy <join|query> Overrides the relation loading strategy (join or query) for this query
@name CreateUser
@request CreateUserInput
@response User
@rel_load_strategy join
insert User { email := $email, name := $name };

@name replaces the older # @name comment annotation, which is no longer recognized.

Directive-named types are shared and deduplicated across query files: a name used by more than one query (or one matching an existing schema type) is emitted once and reused. If two queries claim the same name but describe different fields, codegen aborts with an error naming both sources — so a shared type can never silently diverge. All parsed directives are also exposed to external generators as the directives object on each query descriptor.


File Contents
models.ts One interface per concrete ASL type; one type alias per enum
<query_name>.ts Typed async function per AQL query with params and row interfaces
runner.ts Runner class, Queries class, builder infrastructure, embedded schema
AQL type TypeScript type Nullable TypeScript type
str string string | null
int16/32/64 number number | null
float32/64 number number | null
bool boolean boolean | null
uuid string string | null
datetime Date Date | null
json unknown unknown

An enum-backed column or parameter generates as its enum union type (e.g. Role, or Role | null when nullable) rather than string, and is imported from models.ts.

Option Default Description
client bun Database driver the generated code targets: bun (Bun’s SQL class) or pg (node-postgres)
Terminal window
axel codegen -g ts -o ./gen --option client=pg

The default client targets Bun’s SQL class. The generated DB interface is:

export interface DB {
unsafe<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
}

Bun’s SQL class satisfies this directly:

import { SQL } from "bun";
import { Runner } from "./gen/runner.ts";
const sql = new SQL({ url: "postgres://user:pass@localhost:5432/mydb" });
const runner = new Runner(sql);

Any other client works as long as it implements the DB interface.

Setup — node-postgres (--option client=pg)

Section titled “Setup — node-postgres (--option client=pg)”

With client=pg the generated query functions and Runner take a node-postgres Pool directly and read rows off db.query(...).rows — there is no DB interface. Install pg and its types (bun add pg @types/pg), then:

import { Pool } from "pg";
import { Runner } from "./gen/runner.ts";
const pool = new Pool({ connectionString: "postgres://user:pass@localhost:5432/mydb" });
const runner = new Runner(pool);

The typed query functions accept the same Pool:

import { getUser } from "./gen/get_user.ts";
const user = await getUser(pool, { id: "..." }); // GetUserRow | null

Compiled .aql files are exposed as typed methods under runner.query:

// list_post.aql → listPost
const posts = await runner.query.listPost();
// posts: ListPostRow[]
// get_user.aql with params
const user = await runner.query.getUser({ id: "..." });
// user: GetUserRow | null

Each method’s param and row types live in the corresponding <query_name>.ts file and are re-exported from runner.ts.

For ad-hoc queries, runner.select() returns a typed builder. The shape argument controls which fields are returned and is inferred at compile time.

// Select specific fields — return type is inferred from the shape
const users = await runner
.select("User", { id: true, email: true, name: true })
.all();
// users: Array<{ id: string; email: string; name: string | null }>

.where() returns a FilterChain. Chain .and() and .or() on it:

const users = await runner
.select("User", { id: true, email: true })
.where("active", "=", true)
.and("age", ">=", 18)
.or("email", "=", "admin@example.com")
.all();

.and() and .or() are only available after .where() — calling them directly on runner.select() is a compile-time error.

Pass another builder as a shape value to pull related rows as a JSON array in a single query:

const users = await runner
.select("User", {
id: true,
email: true,
posts: runner.select("Post", { title: true, content: true }),
})
.all();
// users: Array<{ id: string; email: string; posts: Array<{ title: string; content: string }> }>

To filter the sub-select, call .where() on the inner builder before passing it:

const users = await runner
.select("User", {
id: true,
posts: runner
.select("Post", { title: true })
.where("authorId", "=", "`User.id`"), // backtick = outer-query reference
})
.all();

The backtick syntax ("`User.id`") is a correlated reference — Axel resolves it to the outer query’s alias at SQL-build time, producing a WHERE p.author = u.id condition with no extra round-trips.

const all = await runner.select("User", { id: true }).all(); // User[]
const one = await runner.select("User", { id: true }).where("id", "=", id).one(); // User | null
const user = await runner
.insert("User", { email: "alice@example.com", age: 30 })
.one();
// user: User

When the schema declares globals, the generator emits two ways to set them. Both run the wrapped queries in a transaction that first applies set_config('app.<name>', …).

The Runner gets a with<Name> method that scopes a block of queries:

await runner.withCurrentUser(userId, async (q) => {
return q.listDocs({ /* … */ });
});

The standalone query functions take an optional trailing options object — useful when you’re not going through the Runner:

import { createDoc } from "./gen/create_doc.ts";
const doc = await createDoc(db, params, { currentUser: userId });

Transactions & Custom Connections — withDb()

Section titled “Transactions & Custom Connections — withDb()”

To run generated query methods inside an existing transaction or custom connection, use withDb(db) on Runner or Queries. You can obtain a Queries instance directly or pass an async callback:

// Direct call:
const q = runner.withDb(tx);
const doc = await q.createDoc(params);
// Callback style:
await runner.withDb(tx, async (q) => {
const user = await q.createUser(userParams);
return q.createDoc({ ...docParams, authorId: user.id });
});

File Contents
models.go One struct per concrete ASL type; enum const blocks
<query_name>.go Typed function, params struct, and row struct per AQL query
runner.go Runner + Queries structs with schema embedded for dynamic Run()
AQL type Go type Nullable Go type
str string *string
int16 int16 *int16
int32 int32 *int32
int64 int64 *int64
float32 float32 *float32
float64 float64 *float64
bool bool *bool
uuid string *string
datetime time.Time *time.Time
json interface{} interface{}

An enum-backed column or parameter generates as its enum type (e.g. Role, or *Role when nullable) rather than string. The type is defined in models.go in the same package.

Option Default Description
package generated Package name for all generated files
Terminal window
axel codegen -g go -o ./gen --option package=myapp

The generated Go uses pgx. NewRunner takes a *pgxpool.Pool; the typed query functions take a DBTX interface (satisfied by both *pgxpool.Pool and pgx.Tx), so a pool still works everywhere.

import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
gen "myapp/gen"
)
db, _ := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/mydb?sslmode=disable")
defer db.Close()
runner := gen.NewRunner(db)

Rows are scanned into the generated structs with pgx.RowToStructByName (matched via the db struct tag), and nested json_agg/row_to_json columns decode straight into nested struct/slice fields.

Compiled .aql files are exposed as typed methods under runner.Query:

// list_post.aql → Query.ListPost
posts, err := runner.Query.ListPost(ctx)
// posts: []ListPostRow
// get_user.aql with params
user, err := runner.Query.GetUser(ctx, GetUserParams{ID: "..."})
// user: *GetUserRow

Run compiles and executes any AQL string at runtime, returning []map[string]any. JSON columns (nested shapes, json_agg results) are automatically decoded.

rows, err := runner.Run(ctx, `select User { id, email } filter .active = true`, map[string]any{})
if err != nil {
log.Fatal(err)
}
for _, row := range rows {
fmt.Println(row["id"].(string), row["email"].(string))
}

Pass parameters by name; they are matched to $name placeholders in the AQL:

rows, err := runner.Run(ctx,
`select User { id, email } filter .email = $email`,
map[string]any{"email": "alice@example.com"},
)

When the schema declares globals, the generator emits two ways to set them; both run the wrapped queries in a transaction that first applies set_config('app.<name>', …).

A Runner method scopes a block of queries:

err := runner.WithCurrentUser(ctx, userID, func(q *gen.Queries) error {
_, err := q.ListDocs(ctx, gen.ListDocsParams{ /* … */ })
return err
})

The standalone query functions take functional options — for when you’re not using the Runner:

doc, err := gen.CreateDoc(ctx, db, params, gen.WithCurrentUser(userID))

Transactions & Custom Connections — WithDB() and NewQueries()

Section titled “Transactions & Custom Connections — WithDB() and NewQueries()”

To execute generated query methods inside an existing pgx.Tx or custom connection, use WithDB() on Runner or Queries, or construct a Queries directly with NewQueries():

tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Via Runner or Queries.WithDB:
q := runner.WithDB(tx)
// or: q := gen.NewQueries(tx)
user, err := q.CreateUser(ctx, userParams)
if err != nil {
return err
}
doc, err := q.CreateDoc(ctx, docParams)
if err != nil {
return err
}
return tx.Commit(ctx)

Generators can be written in any language. Axel invokes an external binary, sends a CodegenRequest as JSON on stdin, and expects a CodegenResponse as JSON on stdout.

Terminal window
axel codegen --plugin ./my-generator -o ./gen

Stdin → CodegenRequest

{
"schema": { ... },
"queries": [ ... ],
"config": {
"out_dir": "./gen",
"options": { "key": "value" }
}
}

Stdout ← CodegenResponse

{
"files": [
{ "path": "models.py", "content": "..." },
{ "path": "queries.py", "content": "..." }
]
}

All path values are relative to out_dir. Axel writes the files after the subprocess exits. Anything written to stderr is forwarded to the terminal. A non-zero exit code is treated as an error.

interface CodegenRequest {
schema: SchemaDescriptor;
queries: QueryDescriptor[];
config: {
out_dir: string;
options: Record<string, string>;
};
}
interface SchemaDescriptor {
scalars: ScalarDescriptor[];
enums: EnumDescriptor[];
types: TypeDescriptor[];
}
interface ScalarDescriptor {
name: string; // e.g. "EmailStr"
base: string; // e.g. "str"
sql_type: string; // e.g. "TEXT"
}
interface EnumDescriptor {
name: string;
values: string[];
}
interface TypeDescriptor {
name: string;
table: string; // SQL table name; empty for abstract types
is_abstract: boolean;
extends?: string[];
properties?: PropertyDescriptor[];
links?: LinkDescriptor[];
computed?: ComputedDescriptor[];
indexes?: IndexDescriptor[];
}
interface PropertyDescriptor {
name: string;
column: string;
aql_type: string; // e.g. "str", "int32", "datetime"
sql_type: string; // e.g. "TEXT", "INTEGER", "TIMESTAMPTZ"
is_required: boolean;
is_multi: boolean;
default?: string;
constraints?: { name: string; args?: string[] }[];
}
interface LinkDescriptor {
name: string;
target_type: string;
join_column?: string; // FK column name (single link)
junction_table?: string; // Junction table name (multi link)
is_required: boolean;
is_multi: boolean;
}
interface ComputedDescriptor {
name: string;
expr: string; // SQL expression template
}
interface IndexDescriptor {
columns: string[];
}
interface QueryDescriptor {
name: string; // camelCase function name, e.g. "listPost"
file: string; // source .aql file path
sql: string; // compiled parameterized SQL
operation: "select" | "insert" | "update" | "delete";
params?: ParamDescriptor[];
result: ResultDescriptor;
}
interface ParamDescriptor {
name: string; // e.g. "email"
aql_type: string; // e.g. "str"
enum_type?: string; // enum type name when the param is enum-backed
sql_pos: number; // 1-based $N position in the SQL string
}
interface ResultDescriptor {
fields?: ResultField[];
is_multiple: boolean; // true → array result
is_scalar: boolean; // true → count/aggregate, no fields
}
interface ResultField {
name: string;
aql_type?: string;
sql_type?: string;
enum_type?: string; // enum type name when the column is enum-backed
is_nullable: boolean;
is_multiple: boolean; // true → JSON array (multi-link or computed sub-select)
target_type?: string; // set for link fields
sub_fields?: ResultField[];
}
#!/usr/bin/env python3
import json, sys
req = json.load(sys.stdin)
schema = req["schema"]
queries = req["queries"]
files = []
# Generate models
lines = ["# Auto-generated by axel\nfrom typing import Optional, Any\n"]
for typ in schema["types"]:
if typ["is_abstract"]:
continue
lines.append(f"class {typ['name']}:")
for prop in typ.get("properties", []):
py_type = {"str": "str", "int32": "int", "bool": "bool"}.get(prop["aql_type"], "Any")
if not prop["is_required"]:
py_type = f"Optional[{py_type}]"
lines.append(f" {prop['name']}: {py_type}")
lines.append("")
files.append({"path": "models.py", "content": "\n".join(lines)})
# Generate query stubs
for q in queries:
params = ", ".join(p["name"] for p in q.get("params", []))
lines = [
"# Auto-generated by axel",
f"SQL = \"\"\"\n{q['sql']}\n\"\"\"",
"",
f"def {q['name']}(db{', ' + params if params else ''}):",
f" return db.execute(SQL{', [' + params + ']' if params else ''})",
]
files.append({"path": f"{q['name']}.py", "content": "\n".join(lines)})
json.dump({"files": files}, sys.stdout)

Make the script executable and point --plugin at it:

Terminal window
chmod +x ./gen.py
axel -d ./myproject codegen --plugin ./gen.py -o ./gen

Native Go generators implement the codegen.Generator interface and self-register via init(). This is how the built-in go and ts generators work.

package mygen
import (
"fmt"
"bytes"
"github.com/struckchure/axel/core/codegen"
)
func init() {
codegen.Register(&MyGenerator{})
}
type MyGenerator struct {
buf bytes.Buffer
}
func (g *MyGenerator) Name() string { return "mygen" }
func (g *MyGenerator) BeginSchema(_ *codegen.Context, _ codegen.SchemaDescriptor) error {
g.buf.Reset()
return nil
}
func (g *MyGenerator) BeginType(_ *codegen.Context, t codegen.TypeDescriptor) error {
if !t.IsAbstract {
fmt.Fprintf(&g.buf, "type %s struct {\n", t.Name)
}
return nil
}
func (g *MyGenerator) OnProperty(_ *codegen.Context, p codegen.PropertyDescriptor) error {
fmt.Fprintf(&g.buf, "\t%s string\n", p.Name)
return nil
}
func (g *MyGenerator) EndType(_ *codegen.Context) error {
g.buf.WriteString("}\n\n")
return nil
}
func (g *MyGenerator) EndSchema(ctx *codegen.Context) error {
return ctx.WriteFile("models.xyz", g.buf.Bytes())
}
// Unused hooks — must still be implemented.
func (g *MyGenerator) OnScalar(_ *codegen.Context, _ codegen.ScalarDescriptor) error { return nil }
func (g *MyGenerator) OnEnum(_ *codegen.Context, _ codegen.EnumDescriptor) error { return nil }
func (g *MyGenerator) OnLink(_ *codegen.Context, _ codegen.LinkDescriptor) error { return nil }
func (g *MyGenerator) OnComputed(_ *codegen.Context, _ codegen.ComputedDescriptor) error { return nil }
func (g *MyGenerator) OnIndex(_ *codegen.Context, _ codegen.IndexDescriptor) error { return nil }
func (g *MyGenerator) OnQuery(_ *codegen.Context, _ codegen.QueryDescriptor) error { return nil }

Register it with a blank import in your cmd/ package (after forking the repo or embedding Axel as a library):

import _ "myapp/generators/mygen"

Then use it like any built-in:

Terminal window
axel -d ./myproject codegen -g mygen -o ./gen
BeginSchema
OnScalar (each custom scalar, alphabetical)
OnEnum (each enum, alphabetical)
BeginType (each type, alphabetical — abstract types included)
OnProperty / OnLink / OnComputed / OnIndex (each member, declaration order)
EndType
OnQuery (each AQL query, in discovery order)
EndSchema

Use BeginSchema to reset state, BeginType/EndType to open and close type-level buffers, and EndSchema to flush everything to files via ctx.WriteFile.

ctx.WriteFile(path, content) writes content to <out_dir>/<path>, creating parent directories as needed. Paths are relative to out_dir.