Saltar al contenido principal
Version: Next

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.

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 v3 header 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.
  • ModelHash is a hash of the model's shape at generation time. It lets you detect drift: if you change a model and forget to re-run quark 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 gen after 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 use TypedStringColumn, which adds Like / NotLike.
  • WhereP is pure compile-time sugar: each predicate lowers to exactly the condition Where("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 string Where form 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.
  • OR and grouping are not offered on the typed API; use the string Or / 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 Create for 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 db tag. 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 a db tag (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.