Code Generation
Quark maps structs to SQL with reflection by default, and that stays the
permanent default — every struct works with zero build steps. Code generation is
an opt-in layer on top: quark gen reads your model package and writes a
quark_gen.go that registers a typed implementation per model. Your code doesn't
change — quark.For[T] is identical with or without it. What changes is internal:
the runtime can use the generated path instead of reflection.
The read path (List/First/Find) uses a generated row scanner, and Create
uses a generated insert binder for single-integer-PK models. Update,
UpdateFields, batch inserts, composite or non-integer keys, and the per-column
timezone feature still use reflection.
The measured gain is small — within a few percent on in-memory SQLite —
because scanning and binding are a minor slice of a query's cost; the
database/sql and driver round-trip dominate. Generate for correctness and
forward compatibility, not for a dramatic speedup.
Install and generate
go install github.com/jcsvwinston/quark/cmd/quark@latest
Point quark gen at one or more packages (an import path, a directory, or
./...). It writes a quark_gen.go into each package that has models:
quark gen ./...
quark gen --dry-run ./... # print to stdout instead of writing
The idiomatic way to keep it current is a //go:generate directive in your model
package, then go generate ./...:
//go:generate quark gen ./...
What it emits
For a model like Account{ ID int64; Email string }, the generated file
registers the model from an init() and provides a typed scanner and binder
(reading columns by name into field pointers, no reflection):
// Code generated by "quark gen"; DO NOT EDIT.
//quark:gen v3
func init() {
quark.RegisterGeneratedMeta(reflect.TypeOf(Account{}), quark.GeneratedMeta{
ContractVersion: 3,
ModelHash: "…",
})
quark.RegisterTypedScanner(reflect.TypeOf(Account{}), quarkgenScanAccount)
quark.RegisterTypedBinder(reflect.TypeOf(Account{}), quarkgenBindAccount)
}
func quarkgenScanAccount(rows *sql.Rows, dest any) error {
m := dest.(*Account)
// ... maps each result column to quark.ScanTarget(&m.Field) by name ...
}
Each column routes through quark.ScanTarget, so special types (time.Time,
JSON[T], Nullable[T]) scan exactly as they do under reflection. A few
guardrails keep generated and live code from drifting apart:
//quark:gen v3records the contract version. If a newer runtime changes the contract, it ignores older generated files and falls back to reflection — a stale binary never calls incompatible generated code.ModelHashcaptures the model's shape at generation time, soquark.CheckGeneratedDrift(reflect.TypeOf(Account{}))reports when you've changed a model and forgotten to re-runquark gen. The runtime never fails on drift; it just uses reflection.- The file is generated — never hand-edit it; re-run
quark genafter a change.
Compile-time column accessors
The generated file also emits a <Model>Columns value with one typed handle per
column. They let you write WHERE conditions with compile-time checking of
both the column name and the value:
adults, err := quark.For[Account](ctx, c).
WhereP(
AccountColumns.Email.Like("%@example.com"),
AccountColumns.Age.Gte(18),
).
List()
What the compiler now catches that the string API can't:
AccountColumns.Emial.Eq("x") // compile error: no field Emial
AccountColumns.Age.Eq("x") // compile error: Age wants an int, got string
Each handle offers Eq/Neq/Gt/Gte/Lt/Lte/In/NotIn/Between/
IsNull/IsNotNull, typed to the field; string columns add Like/NotLike.
WhereP is pure compile-time sugar — each predicate lowers to exactly what
Where("email", "=", v) produces, so the two forms mix freely on one query. (OR
and grouping aren't on the typed API; use the string Or helper.)
How it works
quark gen reads your package's source with go/packages and go/types —
not reflection — so it can be go installed and driven from //go:generate
without compiling your types into it. It reuses Quark's own column parser, so
generated names can't drift from the runtime, and a conformance test in the repo
asserts the generator and runtime agree on every model's shape.
Limitations
- Only
Createfor single-integer-PK models uses the generated binder;Update, batch inserts, and composite/non-integer keys bind via reflection. - The speedup is small (a few percent) — the driver round-trip dominates.
- Models using the per-column timezone feature fall back to reflection.
- The generated scanner maps only columns whose field has a
dbtag (the reflection path also matches untagged fields by snake-cased name). Give every persisted field adbtag so both paths agree.
Reflection stays the default, and generated code is an accelerator layered on top of it rather than a replacement: the two coexist model by model. That is why every limitation above is a silent fallback to reflection instead of an error — deleting the generated file changes performance, never behaviour.