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 parses your model
package and emits a quark_gen.go that registers a typed implementation per
model. Your code does not change — quark.For[T] is identical with or
without generation. The only thing generation changes is internal: the
runtime can use the generated path instead of reflection.
:::note What's generated so far
The read path uses generated code: quark gen emits a typed row scanner
per model, and List/First/Find use it instead of reflection. The
insert path also has a generated binder for models with a single
integer primary key — Create builds its columns and args without
reflection. Update/UpdateFields/batch inserts, models with composite or
non-integer keys, and any query using the per-column timezone feature still
take the reflection path. Generated files carry a versioned contract, so
older files keep working (falling back to reflection) when the contract
changes.
The measured gain is small: on in-memory SQLite the generated read and
insert paths run within a few percent of the reflection path (the insert
binder shaves a handful of allocations). The reason is structural — scanning
and binding are a minor fraction of a query's cost; the database/sql and
driver round-trip dominate, and the generated code still allocates the
column/arg and scan-target slices. Generate for correctness and forward
compatibility, not for a dramatic speedup — removing reflection from these
paths is not, on its own, where Quark's per-operation cost lives.
:::
The design is recorded in ADR-0014 (coexistence mechanism) and ADR-0002 (reflect default, codegen opt-in).
Install
go install github.com/jcsvwinston/quark/cmd/quark@latest
Generate
Point quark gen at one or more packages (go/packages patterns — an import
path, a directory, or ./...):
quark gen ./...
It writes a quark_gen.go into each package that contains models (structs
with db: tags). The idiomatic way to keep it current is a //go:generate
directive in your model package:
//go:generate quark gen ./...
then go generate ./.... To preview without writing files:
quark gen --dry-run ./...
What it emits
For a package with a model like:
type Account struct {
ID int64 `db:"id" pk:"true"`
Email string `db:"email"`
}
quark gen writes a file that registers the model from an init() and a
typed row scanner:
// Code generated by "quark gen"; DO NOT EDIT.
//quark:gen v3
package models
import (
"database/sql"
"reflect"
"strings"
"github.com/jcsvwinston/quark"
)
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)
cols, err := rows.Columns()
if err != nil {
return err
}
scanDest := make([]any, len(cols))
for i, c := range cols {
switch strings.ToLower(c) {
case "id":
scanDest[i] = quark.ScanTarget(&m.ID)
case "email":
scanDest[i] = quark.ScanTarget(&m.Email)
default:
var discard any
scanDest[i] = &discard
}
}
return rows.Scan(scanDest...)
}
The scanner maps result columns to field pointers by name (no reflection) and
routes each through quark.ScanTarget, so special types (time.Time,
JSON[T], Nullable[T], …) scan exactly as the reflection path does. For a
single-integer-PK model the file also emits quarkgenBindAccount, a binder
that returns the insert columns and args without reflection (skipping the PK
when zero); Create uses it. Models with composite or non-integer keys
register quark.StubBinder instead and bind via reflection.
- The
//quark:gen v3header records the contract version the file was generated against. If a newer runtime changes the contract, it ignores generated code from an older version and transparently falls back to reflection — a stale binary never calls into incompatible generated code. ModelHashis a hash of the model's shape at generation time. It lets you detect drift: if you change a model and forget to re-runquark gen,quark.CheckGeneratedDrift(reflect.TypeOf(Account{}))reports it. The runtime never fails on drift — it just falls back to reflection.- The file is generated; never edit it by hand. Re-run
quark genafter changing a model.
Typed column accessors
The generated file also emits, per model, a <Model>Columns value with one
typed handle per db-tagged column. They let you build WHERE conditions
without magic column strings and with compile-time checking of both the
column name and the bound value:
// Generated in quark_gen.go:
var AccountColumns = struct {
ID quark.TypedColumn[int64]
Email quark.TypedStringColumn
Age quark.TypedColumn[int]
}{
ID: quark.NewTypedColumn[int64]("id"),
Email: quark.NewTypedStringColumn("email"),
Age: quark.NewTypedColumn[int]("age"),
}
Use them through WhereP:
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 cannot:
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, all typed to the field. String columns useTypedStringColumn, which addsLike/NotLike. WherePis pure compile-time sugar: each predicate lowers to exactly the conditionWhere("email", "=", v)produces (ADR-0014), so the two APIs are interchangeable and mix on one query (.WhereP(AccountColumns.Age.Gte(18)).Where("active", "=", true)). The stringWhereform stays fully valid.- The accessors register nothing with the runtime — they neither speed up nor change query execution. Their only benefit is compile-time safety. Without codegen they simply do not exist.
ORand grouping are not offered on the typed API; use the stringOr/ group helpers for those.
How it works
quark gen reads your model package's source with go/packages and
go/types (the AST), not reflection — so the tool can be go installed and
driven from //go:generate without compiling your types into it. It finds
exported fields with a db: tag, reuses Quark's own column parser so column
names cannot drift from the runtime, and resolves Go types (including
generics such as quark.JSON[T] and the quark.Nullable[T] alias) to the
same form reflection produces. A conformance test in the Quark repo asserts
the generator and the runtime agree on a model's shape, guarding the one
risk of having two tag interpreters.
Limitations
- On the write path only
Createfor single-integer-PK models uses the generated binder.Update/UpdateFields, batch inserts, and composite or non-integer keys bind via reflection. - The speedup is small at this stage (a few percent on both read and insert — see the note above), because the generated code still allocates the scan-target and column/arg slices and the driver round-trip dominates.
- Queries that use the per-column timezone feature fall back to reflection (the generated scanner carries no runtime timezone state).
- The generated scanner maps only columns whose model field has a
dbtag. The reflection path additionally matches a column to an untagged field by its snake-cased name; the generated scanner does not. Give every persisted field adbtag (the recommended convention) so the two paths agree. - A type alias nested under a pointer or slice in a model field may render
differently between the generator and the runtime and show up as spurious
drift. Top-level alias fields (the common case, e.g.
quark.Nullable[T]) are handled.