Skip to main content
Version: 1.6.0

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 own code doesn't change — quark.For[T] is identical with or without it. What changes is internal: the runtime can take the generated path instead of reflecting.

It's for type safety, not speed

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 v3 records 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.
  • ModelHash captures the model's shape at generation time, so quark.CheckGeneratedDrift(reflect.TypeOf(Account{})) reports when you've changed a model and forgotten to re-run quark gen. The runtime never fails on drift; it just uses reflection.
  • The file is generated — never hand-edit it; re-run quark gen after 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. That is what lets it be go installed and driven from //go:generate without compiling your types into it.

It also reuses Quark's own column parser, so generated names can't drift from the runtime, and a conformance test in the repository asserts that generator and runtime agree on every model's shape.

Limitations

  • Only Create for 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 db tag (the reflection path also matches untagged fields by snake-cased name). Give every persisted field a db tag so both paths agree.

Reflection stays the default, and generated code is a layer 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.