aontu

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.

$ records 0 fields (3) name "Customer" sql "customer" 1 fields (3) name "Order" sql "order_line"
The model tree: the record definitions every generated target is written from

What is generated

generatorreadsfilegolden
use-cases/15-code-generation/gen-go.aonname, fields.n, fields.t, fields.gotypes.go, Go structs with JSON tagsuse-cases/15-code-generation/expected/types.go
use-cases/15-code-generation/gen-ts.aonname, fields.n, fields.t, fields.reqtypes.ts, TypeScript interfacesuse-cases/15-code-generation/expected/types.ts
use-cases/15-code-generation/gen-sql.aonsql, fields.sql, fields.t, fields.reqschema.sql, CREATE TABLE statementsuse-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. emit visits the selection in the order the model wrote it, which is what a file needs; pack would 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 Line writes 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. Email and credit_cents are written in model.aon rather 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, and all.aon includes 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.

  1. One run, three files: the document names types.go, types.ts and schema.sql, in that order.
  2. 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.
  3. The generated Go compiles: go build in a scratch module, over the bytes jostraca wrote. gofmt reformats the output, aligning the struct tags: the generator’s job is correct code, and layout is the formatter’s.
  4. The generated SQL parses: SQLite executes it, the tables customer and order_line exist, order_line has exactly the columns id, customer_id and total_cents, and no column list ends in a comma.
  5. The slices are real: renaming a go field (Email to EmailAddr) in a copy of the model moves the Go file and leaves the TypeScript file byte-identical.
  6. Generation is deterministic: two runs of the same document are byte-identical.
  7. A file path that climbs out of the output directory is refused.
  8. The check is red when a golden is edited, and names the file.
  9. 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.
  10. 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