codegen

package
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package codegen renders a schema declaration into Go source.

It is driven from a small program in the target project rather than by a CLI that compiles your schema behind your back, because the schema is ordinary Go and the simplest way to read it is to import it:

//go:generate go run ./gen

// gen/main.go
package main

import (
    _ "myapp/billing/schema"   // registers its tables
    "github.com/jryannel/sqlb/codegen"
    "github.com/jryannel/sqlb/schema"
)

func main() {
    codegen.Must(codegen.Generate(codegen.Options{
        Registry: schema.DefaultRegistry(),
        Dir:      "billing",
        Package:  "billing",
    }))
}

Output is deterministic: tables are sorted, columns keep declaration order, and every file is run through go/format, so a generator bug that produces invalid Go fails here rather than at the consumer's next build.

Three further artefacts are opt-in, and all are emitted into the repository that consumes them rather than published: TSDir writes a typed TypeScript client (ADR-0028), DartDir writes a typed Dart client for a Flutter app (ADR-0031), and CLIDir writes a cobra command-line client (ADR-0029). Each belongs to a toolchain this module does not have, which is why none is emitted unless asked for, and why asking costs the consuming repository a gate rather than this one.

Index

Constants

View Source
const ProjectFunc = "SqlbProject"

ProjectFunc is the name cmd/sqlb looks for in a schema package.

Exported so that the command, the error message it prints when the function is missing, and the documentation are all reading the same string. A convention spelled out in three places is a convention that will eventually be spelled two ways.

Variables

This section is empty.

Functions

func Check

func Check(opts Options) ([]string, error)

Check reports which generated files are missing or out of date, without writing anything.

Generated code is committed, so it drifts: someone edits the schema, forgets to regenerate, and the committed models silently describe a table that no longer exists. Run it as a CI gate — an empty result means the tree is current.

func Eject added in v0.5.0

func Eject(opts EjectOptions) ([]string, error)

Eject writes the ejected package and returns the paths it wrote.

func EjectCheck added in v0.5.0

func EjectCheck(opts EjectOptions) ([]string, error)

EjectCheck reports which ejected files are missing or out of date.

The exit is only worth having if it still works, and a committed one rots the same way generated code does — someone adds a column, the ejected handlers keep serving the old shape, and nobody finds out until the day they are needed. So this is `check` for the way out, and it belongs in CI beside it.

func EnumConst added in v0.9.0

func EnumConst(value string) string

EnumConst is the constant-name suffix for one enum value: the value with every run of characters that cannot appear in a Go identifier treated as a word boundary.

draft          → Draft
task.assigned  → TaskAssigned
image/png      → ImagePng

The value itself is never touched — it stays verbatim on the right of the `=`, because it is data and the constant name is not. Deriving the name by title-casing the value whole emitted `NotificationTypeTask.assigned`, which does not parse, so a value set spelled with the ordinary dotted namespacing convention could not be declared at all (issue #138).

`_` was already the word boundary, so this is GoName over a value normalised to underscores: the initialism table still reaches api.key and the rule stays one rule rather than two.

A leading digit needs no escape here. The name is always emitted with the enum's type name in front of it, so `2fa.enabled` becomes NotificationType2faEnabled and starts with an N. An empty value has no word in it at all, and takes a name rather than colliding with the bare type name.

func Generate

func Generate(opts Options) ([]string, error)

Generate writes the generated files and returns their paths.

The schema is validated first. Generating from a schema with a known authoring error would produce plausible-looking Go that encodes the mistake, which is harder to debug than refusing.

func GoName

func GoName(s string) string

GoName converts a snake_case SQL identifier to an exported Go name.

org_id        → OrgID
created_at    → CreatedAt
password_hash → PasswordHash

func Main

func Main(p Project)

Main runs the driver program cmd/sqlb generates, and exits.

The verb and its flags arrive in os.Args because one driver serves every verb; baking the verb into the emitted source instead would mean compiling once per thing CI does on a push.

func Must

func Must(files []string, err error) []string

Must panics if generation failed, for use in a generator main where there is nothing useful to do with the error.

func RenderSchema

func RenderSchema(r *schema.Registry, opts SchemaOptions) ([]byte, error)

RenderSchema renders a registry as the Go source that would declare it.

It is the other half of adoption. introspect turns a database into a *schema.Registry; this turns that registry into the schema.go a project edits from then on, which is what makes "point sqlb at an existing database" a complete story rather than an intermediate data structure (ADR-0014).

This output is meant to be edited

Every other artefact in this package is generated on every build and carries a DO NOT EDIT header. This one is the opposite: it is written once, at the moment of adoption, and then becomes the source of truth that everything else is generated *from*. The header says so, because a reader who has met the other files will otherwise assume their habits apply here.

Nothing is exposed, and no capabilities are set

Capabilities cannot be inferred from DDL. A filterable column is a decision about what the outside world may ask, and a database schema does not record decisions — so everything renders with none, and nothing is exposed over REST. Widening it is then a deliberate, reviewable edit rather than something that happened because a generator felt confident (ADR-0006, ADR-0014).

Table names are not singularised

A table called orgs renders as `var Orgs`, not `var Org`. Singularising is a guess that is wrong often enough to matter — status, address, series — and a wrong Go identifier is a compile error at best and a confusing rename at worst. Renaming the variable afterwards is a one-line edit that the compiler checks; this generator does not do it for you.

func Run

func Run(p Project, args []string, stdout, stderr io.Writer) int

Run is Main without the exit, which is what makes it testable.

It returns a process exit code rather than an error because "the tree is stale" is not an error — it is the answer `check` exists to give, and it has to be distinguishable from a schema that would not compile.

func Singular

func Singular(s string) string

Singular is a deliberately small English singulariser, the inverse of the pluraliser in the runtime.

It only has to produce a readable Go type name: correctness does not depend on it, because every generated model carries an explicit TableName method, so a wrong guess is cosmetic rather than a mapping bug.

func TypeName

func TypeName(localName string) string

TypeName is the Go type name for a table: the singular of its local name, exported. A module prefix is deliberately not included — billing_invoices yields Invoice, because the prefix is a storage concern and the package already provides the namespace in Go.

Types

type EjectOptions added in v0.5.0

type EjectOptions struct {
	// Registry supplies the tables. Required.
	Registry *schema.Registry
	// Dir is the output directory. Required. Everything lands directly in it:
	// the emitted package is one package.
	Dir string
	// Package is the package clause. Empty takes the directory's base name.
	Package string
	// MinPostgres declares the oldest Postgres major version the emitted DDL
	// must run on, exactly as it does for a migration — so the SQL in the exit
	// is the SQL the project was already applying.
	MinPostgres int
}

EjectOptions configures an eject run.

type Options

type Options struct {
	// Registry supplies the tables. Required.
	Registry *schema.Registry
	// Dir is the output directory. Required.
	Dir string
	// Package is the package clause for generated Go. Required.
	Package string

	// ModelsFile, ColumnsFile, ManifestFile and RestFile override the default
	// names. Set one to "-" to skip that artefact.
	//
	// RestFile is written only when the schema exposes at least one table, so a
	// package with no REST surface does not acquire a dependency on huma.
	ModelsFile   string
	ColumnsFile  string
	ManifestFile string
	RestFile     string

	// TSDir emits the TypeScript client, into a directory relative to Dir —
	// "web/src/api" in a repository whose frontend lives beside its server.
	// Empty means no client is emitted at all, which is the right default for a
	// project that has no TypeScript consumer.
	//
	// Three files land there. The runtime and the client are dependency-free;
	// the queries file takes @tanstack/react-query as a peer dependency, so a
	// project that does not use it sets TSQueriesFile to "-" and keeps the
	// rest.
	TSDir         string
	TSClientFile  string
	TSQueriesFile string

	// TSRuntimeFile names the file holding the part of the client that does
	// not depend on the schema — the envelopes, the problem document, the
	// transport signature and the filter encoder. Defaults to runtime.gen.ts.
	//
	// It is a separate file because a second module in the same application
	// otherwise ships a second copy of all of it, and asks the application to
	// wire one Transport per module (#110). Point two projects at one path and
	// they share it: the content is derived from nothing schema-specific, so
	// the second writer produces the same bytes and `check` stays meaningful.
	TSRuntimeFile string

	// CLIDir emits a cobra command-line client, into a directory relative to
	// Dir — "cli" in a repository whose binary lives beside its server. Empty
	// means no CLI is emitted, which is the right default for a project that
	// has no use for one.
	//
	// The emitted package depends on github.com/spf13/cobra and nothing else
	// beyond the standard library. It does not import sqlb or the generated
	// models: it speaks to the API over HTTP, so it holds no database
	// credential and needs no build tag to keep one out.
	//
	// CLIName is the binary's name, which is what appears in usage lines and,
	// upper-cased, as the prefix of the environment variables the root command
	// reads: "taskctl" gives TASKCTL_BASE_URL and TASKCTL_TOKEN. It defaults to
	// Package.
	CLIDir     string
	CLIPackage string
	CLIName    string
	CLIFile    string

	// ClientDir emits the transport-only Go client — Request, Transport,
	// Client, Do, Run and the typed problem document — into a directory
	// relative to Dir. The emitted package imports the standard library and
	// nothing else.
	//
	// It is a separate package from the CLI because it is a separate artefact.
	// A sync job, a server-to-server caller, or an admin tool that already has
	// a command tree of its own wants the typed encoder and not a command-line
	// framework, and while the two shared a package it could not have one
	// without the other (#97).
	//
	// Setting CLIDir and leaving this empty emits the client into a "client"
	// subdirectory of CLIDir, because the command tree has to import it from
	// somewhere. Setting this and leaving CLIDir empty emits the client alone,
	// which is the server-to-server case.
	ClientDir     string
	ClientPackage string
	ClientFile    string

	// ClientImportPath is the path the generated CLI imports the generated
	// client under. Empty derives it: the module path out of the nearest
	// go.mod, joined with Dir and the client's directory.
	//
	// Deriving it is right for a repository generating into itself, which is
	// every project using sqlb generate. It cannot be right for a caller whose
	// Dir is an absolute path, or who generates into a module it is not inside
	// — so the derivation is a default rather than the mechanism.
	ClientImportPath string

	// DartDir emits a typed Dart client, into a directory relative to Dir —
	// "mobile/lib/api" in a repository whose Flutter app lives beside its
	// server. Empty means no client is emitted, which is the right default for
	// a project that has no Dart consumer.
	//
	// Two files land there — the client and the runtime library it exports —
	// and neither imports anything: not a pub package, not even dart:io. There
	// is no framework layer to make optional, because the mobile ecosystem has
	// no equivalent of TanStack Query to bind to — the cursor pager it emits
	// instead is plain Dart (ADR-0031).
	DartDir  string
	DartFile string

	// DartRuntimeFile names the shared Dart library, defaulting to
	// runtime.gen.dart. It holds the response envelopes, the problem document
	// and the transport signature — the types an application names when it
	// writes one pager or wires one transport across two modules (#110).
	DartRuntimeFile string

	// SkillDir emits the project-specific agent skill, into a directory
	// relative to Dir — ".claude/skills" in a repository whose agents read from
	// there. Empty means no skill is emitted, and that is the default on
	// purpose: this is the one emitter that writes into a directory sqlb does
	// not own, beside files a project wrote itself, so it is opted into rather
	// than arrived at (ADR-0049).
	//
	// One file lands there, at <SkillDir>/<SkillName>/SKILL.md — so
	// ".claude/skills/sqlb-schema/SKILL.md" unless SkillName says otherwise. It
	// describes what this schema exposes and what each resource accepts, which is
	// the answer no static document can carry, because capabilities are opt-in and
	// therefore per-project. Being covered by `sqlb check` is the load-bearing
	// half: a skill that has drifted from the schema is worse than no skill,
	// since it is confidently wrong about the one thing it exists to know.
	//
	// It carries structure — names, types, capability flags, paths — and not
	// comments. See skill.go for why that is a trust boundary and not a style
	// choice.
	//
	// # Where to point it, and what the agent tooling does with it
	//
	// ".claude/skills" relative to the module root is the answer for an ordinary
	// single-module project, because that is a *project* skill and the tooling
	// reads those when the session starts.
	//
	// **A repository with more than one registry wants one SkillDir per
	// registry**, and module-local placement is how to get it: point each
	// module's SkillDir at a `.claude/skills` beside that module. The skill
	// answers per-registry questions — which columns are filterable *here* — so a
	// module-local one is the right scope as well as the safe one, and a nested
	// `.claude/skills` is directory-scoped by the tooling, meaning sixteen skills
	// all named `sqlb-schema` are sixteen correctly-scoped skills rather than
	// sixteen collisions.
	//
	// Pointing two registries at one SkillDir under one SkillName is
	// last-writer-wins, and it is order-dependent: whichever module generated
	// second is current and the other's `sqlb check` is red, with "run: sqlb
	// generate" as advice that cannot work, because running it reddens the first
	// (#142). SkillName is the way to share a directory deliberately.
	//
	// One consequence worth knowing before wiring this up, and it is not sqlb's
	// to fix: a skills directory that did not exist when the session started may
	// not be watched, so the first `sqlb generate` that creates one can emit a
	// skill that is not offered until the session restarts. Observed once to be
	// picked up immediately, so treat it as a possibility rather than a rule.
	// After the directory exists, edits to it are picked up live — which is what
	// makes the `sqlb check` gate worth having.
	//
	// A nested module is discovered later than the root: a `.claude/skills` below
	// the repository root is read once a file in that subtree has been, rather
	// than at startup. It works; it arrives late. A **single-registry** project
	// that wants the skill offered from the first turn can point SkillDir at the
	// repository root's `.claude/skills` even when the schema lives in a nested
	// module. With more than one registry that placement is the clobber above,
	// and it needs a distinct SkillName per registry.
	SkillDir string

	// SkillName is the skill's directory and the name in its frontmatter,
	// defaulting to "sqlb-schema". It is the second half of
	// <SkillDir>/<SkillName>/SKILL.md.
	//
	// It exists so that a repository with several registries can share one
	// SkillDir — "sqlb-schema-waitlist" and "sqlb-schema-tenants" under the
	// repository root, which is what makes root placement available to a
	// multi-module repository at all (#142). A project whose skills are
	// module-local wants the default: the directory already distinguishes them,
	// and one name across sixteen modules is a name a reader learns once.
	//
	// Keep the `sqlb-` prefix on anything you set. This file lands in a directory
	// sqlb does not own, beside skills the project wrote itself and skills it
	// installed, and a collision there is a silently shadowed instruction.
	// Refused if it is not a single lowercase kebab-case path segment, because
	// the agent tooling names a skill by its directory and an unloadable skill
	// fails by being quietly absent rather than by erroring.
	SkillName string

	// SkillSchemaPackage is how the emitted skill spells this project in the
	// commands it tells an agent to run: "./taskschema", the same argument
	// `sqlb generate` takes.
	//
	// Empty falls back to `go generate ./...`, which is correct for any project
	// whose schema package carries the directive and is the reason this is not
	// required. It cannot be derived here: the package pattern is an argument to
	// cmd/sqlb, and the emitters are given a registry rather than the pattern
	// that produced one.
	SkillSchemaPackage string

	// Types replaces the Go type emitted for the columns each override
	// matches — the sqlc `overrides:` equivalent, and the reason a codebase
	// whose ids are uuid.UUID rather than string can generate its models
	// rather than describing hand-written ones.
	//
	// An override reaches the models, the typed column facade, the REST bodies
	// and the manifest, and reaches nothing else. It does not change the SQL
	// type, and it does not change the wire: the TypeScript and Dart clients,
	// the CLI and the OpenAPI document all map from the schema type, so an
	// override is invisible to them. ADR-0035 records why that split is the
	// load-bearing part.
	Types []TypeOverride
}

Options configures a generation run.

type Project

type Project struct {
	// Options configures the emitters.
	//
	// Registry may be left nil, and usually is: Main fills it from
	// schema.DefaultRegistry(), which is what declaring a table populates. Set
	// it only if the project builds a registry of its own instead of using the
	// default one.
	Options Options

	// MigrationsDir is where `sqlb migrate` writes, relative to the module
	// root. Empty means the project does not generate migrations with sqlb, and
	// the command says so rather than picking a directory.
	MigrationsDir string

	// ContractFile is where `sqlb impact` records the REST contract snapshot it
	// diffs against, relative to the module root. Empty defaults to
	// "restcontract.json" beside the generated code (Options.Dir). It is a
	// committed artefact — the answer to "backward compatible relative to what?"
	// (ADR-0039) — so it belongs in the repository like the migration history.
	ContractFile string

	// EjectDir is where `sqlb eject` writes, relative to the module root.
	//
	// Empty defaults to "ejected" beside the generated code, which is the
	// answer for the case this verb exists for: a repository that wants the way
	// out committed and checked, rather than discovered on the day it is
	// needed. Set it to move the package; set EjectPackage if the directory's
	// base name is not the package name you want.
	EjectDir     string
	EjectPackage string

	// MigrationFormat names the runner's file layout: "goose" (the default),
	// "golang-migrate", or "plain". Resolved by migrate.ByName, so an unknown
	// name is refused with the list of the ones that exist.
	//
	// A string rather than a migrate.Format because a Format is an interface
	// with unexported methods on the other side of this package, and a project
	// that wants a custom one is writing its own generator anyway.
	MigrationFormat string

	// MinPostgres declares the oldest Postgres major version the generated DDL
	// must run on. Zero means unset, which is what every migration generated
	// before the option existed already assumes — see migrate.MinPostgres, and
	// pass it consistently or don't pass it at all.
	MinPostgres int

	// PostgresSchema is the Postgres schema the shadow replay reads back.
	// Empty means "public".
	PostgresSchema string

	// Module scopes the tables read back from the shadow database to one sqlb
	// module (ADR-0015). Empty means unscoped, which is right unless the
	// project's tables carry a module prefix — in which case leaving it empty
	// gives a `current` that disagrees with the declaration about every table
	// name, and a diff that proposes recreating all of them.
	Module string

	// ShadowDB opens a connection to an **empty** scratch database, which is
	// what migrate.Diff needs to be given a trustworthy `current`: the schema
	// the checked-in history builds, rather than whatever production drifted
	// into (ADR-0014).
	//
	// It is a function in your code, not a DSN in ours, for two reasons that
	// point the same way.
	//
	// The first is that the DSN is the project's. sqlb depends on pgx and could
	// dial one itself (ADR-0040), but it has no idea which database is safe to
	// replay into, and that question has exactly one right answerer.
	//
	// The second is that the database has to be *empty*, and shadow.Build will
	// not empty it: creating and dropping databases needs credentials the rest
	// of sqlb never asks for, and dropping the wrong one is unrecoverable. So
	// the destructive half stays with the caller who knows which database is
	// scratch — and this function is exactly where that caller lives. Doing it
	// here means the statement that wipes a database is written out, by name,
	// in a file in your repository, against a DSN you chose.
	//
	//	ShadowDB: func(ctx context.Context) (*pgxpool.Pool, error) {
	//		pool, err := pgxpool.New(ctx, os.Getenv("SQLB_SHADOW_DSN"))
	//		if err != nil {
	//			return nil, err
	//		}
	//		// Scratch, and this line is the assertion that it is.
	//		_, err = pool.Exec(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public")
	//		return pool, err
	//	}
	//
	// The command closes what this returns. It is not called at all when the
	// migration directory is empty, because a baseline diffs against nothing
	// and there is no history to replay.
	ShadowDB func(context.Context) (*pgxpool.Pool, error)
}

Project is everything `sqlb` needs to know about a repository that it cannot work out for itself.

A schema package declares one by exporting a function of this name:

// taskschema/sqlb.go
func SqlbProject() codegen.Project {
	return codegen.Project{
		Options: codegen.Options{
			Package: "tasks",
			TSDir:   "web/src/api",
			DartDir: "mobile/lib/api",
			CLIDir:  "cli",
			CLIName: "taskctl",
		},
	}
}

Paths are relative to the module root

Every directory in Options is resolved against the directory holding go.mod, not against the schema package and not against wherever the command was invoked. That is the one rule that makes `sqlb generate ./taskschema` mean the same thing from a shell, from a //go:generate directive, and from CI — the three places the old hand-written generator had to be told `-dir ..` and got it wrong if any of them disagreed.

Why this wraps Options rather than being it

Options is the emitters. Project is the repository, and the repository has more in it than emitter output — everything from MigrationsDir down exists for `sqlb migrate`, and landed after the type did without changing what any project's SqlbProject returns. That was the point of the wrapper.

func (Project) Validate

func (p Project) Validate() error

Validate reports what is wrong with a Project before anything is compiled against it.

Options.validate covers the emitters; this covers the one thing it cannot, which is that a path means something different here. Options is happy with an absolute Dir — a caller writing its own generator may well want one — and a Project is not, because a path that resolves against the module root cannot be absolute and still mean the same thing on another machine.

type SchemaOptions

type SchemaOptions struct {
	// Package is the package clause. Required.
	Package string

	// File names the output in error messages and in the generated header. It
	// is not written to; the caller decides where the bytes go.
	File string

	// RegistryVar overrides the variable a module registry is held in.
	// Ignored for a registry with no module.
	RegistryVar string
}

SchemaOptions configures RenderSchema.

type TypeOverride

type TypeOverride struct {
	// Type matches every column of a logical type.
	Type schema.Type
	// Table narrows to one table, by its storage name — the name including any
	// module prefix, which is what the registry holds.
	Table string
	// Column narrows to one column name.
	Column string

	// GoType is the type as it should appear in the generated source,
	// qualified by package where it needs to be: "uuid.UUID". Required.
	GoType string
	// Import is the package path providing GoType, or empty when it needs none.
	// It is emitted verbatim into the import block and is not resolved — a
	// wrong one fails to compile in the consuming repository, one command
	// later.
	Import string
}

TypeOverride replaces the Go type codegen emits for the columns it matches.

At least one matcher must be set. More specific wins: a Table+Column override beats a Column one, which beats a Type one.

{Type: schema.TypeUUID, GoType: "uuid.UUID", Import: "github.com/google/uuid"}
{Table: "invoices", Column: "amount", GoType: "decimal.Decimal",
 Import: "github.com/shopspring/decimal"}

func (TypeOverride) String

func (o TypeOverride) String() string

String renders an override for a diagnostic, naming only the fields it set.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL