15. Code generation
The model as the source of the code: Go, TypeScript and SQL from one catalogue, each over a slice
Exercises list-spread + pick line building, join file assembly, backtick target text, match type mapping, both-ports byte parity
Rendered from
use-cases/15-code-generation/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/15-code-generation/.
One model, three generated files, each over a different slice of it,
one document. Go structs, TypeScript interfaces and SQL DDL: every
file is computed by the unifier from use-cases/15-code-generation/model.aon and
nothing else. Each generator is a rule set (emit) whose children fill
one file(...) of a component tree; use-cases/15-code-generation/all.aon holds the
three files, and aontu render writes the bytes through jostraca. The
goldens under expected/ are held by aontu render --check. Nothing
outside the model decides layout,
ordering, or separators, and nothing in the engine writes a file.
What is generated
| generator | reads | file | golden |
|---|---|---|---|
use-cases/15-code-generation/gen-go.aon | name, fields.n, fields.t, fields.go | types.go, Go structs with JSON tags | use-cases/15-code-generation/expected/types.go |
use-cases/15-code-generation/gen-ts.aon | name, fields.n, fields.t, fields.req | types.ts, TypeScript interfaces | use-cases/15-code-generation/expected/types.ts |
use-cases/15-code-generation/gen-sql.aon | sql, fields.sql, fields.t, fields.req | schema.sql, CREATE TABLE statements | use-cases/15-code-generation/expected/schema.sql |
A component tree is data: aontu model get out all.aon prints it, and
aontu render hands it to jostraca, which writes the bytes. check.sh
holds the goldens to what render --check answers, and holds both
ports to the same tree.
The Go generator never reads sql; the SQL generator never reads
go; the TypeScript generator reads neither and keeps the wire name
in n. The SQL generator quotes every identifier, because order is
a reserved word and a generator that emits it bare produces a file
that does not parse: always quoting is simpler than carrying a word
list.
The model tree
model.aon is the whole input to the generators: a list of records,
each with a name, a SQL table name, and its fields. Everything the Go,
TypeScript and SQL targets emit is computed from these three keys, so
the model tree is also the list of things a target must handle.
$
└── records
├── 0
│ ├── fields (3)
│ ├── name "Customer"
│ └── sql "customer"
└── 1
├── fields (3)
├── name "Order"
└── sql "order_line"
aontu view doc --depth 3 model.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
The shape of a generator
%go_field = emit(_, {
match: n: string
body: [
line(
"\t" + .go + " " + match(.t, "string", "string", "integer", "int64")
+ ` \`json:"` + .n + `"\``
)
]
})
%go_record = emit(_, {
match: name: string
body: [
line("")
line("type " + .name + " struct {")
emit(.fields, %go_field)
line("}")
]
})
go: file("types.go", [
line("// Code generated by aontu from model.aon. DO NOT EDIT.")
line("")
line("package acme")
emit($.records, %go_record)
])
Each line of that shape is a decision:
- A rule set walks the records in source order.
emitvisits the selection in the order the model wrote it, which is what a file needs;packwould key by data and emit the records alphabetically, silently wrong output for a file. - Lines, not text. A record contributes a blank line, a head, its
fields and a tail; a field contributes one line. A
Linewrites its own terminator, so no generator spells a newline; the indent is the target’s own,"\t"in the Go generator’s strings and two spaces in the TypeScript one, because aontu knows no languages and has no view about how either indents. - The children are flat. The nested dispatch
emit(.fields, %go_field)splices its lines into the record’s, so one flat list reaches the file. - The file is one value. The banner, the package clause and the
records are children of the same
file(...), in order, so the blank line each record opens with is what separates a struct from what came before. - Names come from the model, not the generator.
Emailandcredit_centsare written inmodel.aonrather than computed: what a type is called in a target is a fact about the model, not a rule in a template: the “symbol provider” split that code generators arrive at. Writing the name down also makes a collision a unification conflict rather than a broken identifier at emit time. - The aliases carry the target’s name (
%go_record,%ts_record), because an alias declaration is a key of the document that includes the file, andall.aonincludes all three. Distinct names prevent the declarations from conflicting.
gen-sql.aon folds its column lines with join and ,\n, so the
separator falls between them and the last column carries no trailing
comma, and carries the block as ONE line whose text holds its own
newlines: a line per column would need to know which one is last.
CREATE TABLE "customer" (
"id" TEXT NOT NULL,
"email" TEXT,
"credit_cents" BIGINT
);
The column table is written inline rather than named, because a named table does not resolve as the argument of a call inside a body.
What check.sh proves
A generator whose output merely looks right is a generator nobody trusts, so the checks compile the Go and parse the SQL rather than inspecting them.
- One run, three files: the document names
types.go,types.tsandschema.sql, in that order. - The three generated files match their goldens byte for byte, each
behind its
Code generated by aontu from model.aon. DO NOT EDIT.banner: a line of the file, not something a script puts in front of it. - The generated Go compiles:
go buildin a scratch module, over the bytes jostraca wrote.gofmtreformats the output, aligning the struct tags: the generator’s job is correct code, and layout is the formatter’s. - The generated SQL parses: SQLite executes it, the tables
customerandorder_lineexist,order_linehas exactly the columnsid,customer_idandtotal_cents, and no column list ends in a comma. - The slices are real: renaming a
gofield (EmailtoEmailAddr) in a copy of the model moves the Go file and leaves the TypeScript file byte-identical. - Generation is deterministic: two runs of the same document are byte-identical.
- A file path that climbs out of the output directory is refused.
- The check is red when a golden is edited, and names the file.
- The Go port builds the same trees as the TypeScript engine for all three files: one model never becomes two different files depending on which engine ran.
- The model tree draws and is pinned, text and SVG.
Check 9 needs a Go toolchain, and skips with a note without one.
Run
./check.sh # ten checks, including both ports byte for byte