Code Generation
Code Generation
Section titled “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.
Quick start
Section titled “Quick start”# Goaxel -d ./myproject codegen -g go -o ./gen
# TypeScriptaxel -d ./myproject codegen -g ts -o ./genAxel auto-discovers all *.aql files under the project directory and compiles them together with the schema.
axel codegen
Section titled “axel codegen”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.
Configuration in axel.yaml
Section titled “Configuration in axel.yaml”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.
schema-path: ./axel/schema.aslrel-load-strategy: query # query | join
codegen: generator: go # go | ts out-dir: ./db/generated queries: - ./queries/*.aql options: package: generatedWith this configured, running axel codegen will automatically use your specified generator, output directory, query files, and options without needing extra CLI arguments.
Query file discovery
Section titled “Query file discovery”Query files are resolved in this priority order:
-q/--querypatterns passed via CLI — Axel expands these (supports**/*.aql)- Positional arguments — shell-expanded paths
codegen.queriesinaxel.yaml- Auto-discovery — all
*.aqlfiles under--dirwhen nothing else is given
# Explicit listaxel codegen -g go -o ./gen -q 'queries/**/*.aql'
# Auto-discover from project diraxel -d ./myproject codegen -g go -o ./gen
# Mix: all queries plus one extraaxel codegen -g go -o ./gen -q 'queries/*.aql' extra.aqlDirectives
Section titled “Directives”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 };
@namereplaces the older# @namecomment 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.
TypeScript generator (-g ts)
Section titled “TypeScript generator (-g ts)”Generated files
Section titled “Generated files”| 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 |
Type mapping
Section titled “Type mapping”| 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.
Options
Section titled “Options”| Option | Default | Description |
|---|---|---|
client |
bun |
Database driver the generated code targets: bun (Bun’s SQL class) or pg (node-postgres) |
axel codegen -g ts -o ./gen --option client=pgSetup — Bun (default)
Section titled “Setup — Bun (default)”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 | nullTyped AQL queries — runner.query
Section titled “Typed AQL queries — runner.query”Compiled .aql files are exposed as typed methods under runner.query:
// list_post.aql → listPostconst posts = await runner.query.listPost();// posts: ListPostRow[]
// get_user.aql with paramsconst user = await runner.query.getUser({ id: "..." });// user: GetUserRow | nullEach method’s param and row types live in the corresponding <query_name>.ts file and are re-exported from runner.ts.
Fluent select builder — runner.select()
Section titled “Fluent select builder — runner.select()”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 shapeconst users = await runner .select("User", { id: true, email: true, name: true }) .all();// users: Array<{ id: string; email: string; name: string | null }>Filtering
Section titled “Filtering”.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.
Nested shapes (links)
Section titled “Nested shapes (links)”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.
.all() vs .one()
Section titled “.all() vs .one()”const all = await runner.select("User", { id: true }).all(); // User[]const one = await runner.select("User", { id: true }).where("id", "=", id).one(); // User | nullInsert builder — runner.insert()
Section titled “Insert builder — runner.insert()”const user = await runner .insert("User", { email: "alice@example.com", age: 30 }) .one();// user: UserGlobals
Section titled “Globals”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 });});Go generator (-g go)
Section titled “Go generator (-g go)”Generated files
Section titled “Generated files”| 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() |
Type mapping
Section titled “Type mapping”| 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 |
axel codegen -g go -o ./gen --option package=myappThe 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.
Typed AQL queries — runner.Query
Section titled “Typed AQL queries — runner.Query”Compiled .aql files are exposed as typed methods under runner.Query:
// list_post.aql → Query.ListPostposts, err := runner.Query.ListPost(ctx)// posts: []ListPostRow
// get_user.aql with paramsuser, err := runner.Query.GetUser(ctx, GetUserParams{ID: "..."})// user: *GetUserRowDynamic queries — runner.Run()
Section titled “Dynamic queries — runner.Run()”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"},)Globals
Section titled “Globals”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)Writing a custom generator
Section titled “Writing a custom generator”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.
axel codegen --plugin ./my-generator -o ./genProtocol
Section titled “Protocol”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.
CodegenRequest schema
Section titled “CodegenRequest schema”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[];}QueryDescriptor schema
Section titled “QueryDescriptor schema”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[];}Example: Python generator
Section titled “Example: Python generator”#!/usr/bin/env python3import json, sys
req = json.load(sys.stdin)schema = req["schema"]queries = req["queries"]
files = []
# Generate modelslines = ["# 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 stubsfor 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:
chmod +x ./gen.pyaxel -d ./myproject codegen --plugin ./gen.py -o ./genExample: Go native generator
Section titled “Example: Go native generator”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:
axel -d ./myproject codegen -g mygen -o ./genHook call order
Section titled “Hook call order”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)EndSchemaUse 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.