codegen

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 20 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 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 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.
	//
	// Two files land there. The client is 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

	// 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

	// 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.
	//
	// One file lands there, and it imports nothing: 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

	// 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

	// 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 sqlb cannot open a Postgres connection at all. The
	// engine depends on the standard library alone — `mise run deps-check`
	// enforces it — so it has no driver registered, and every project has one.
	// The driver enters through the import in the file that defines this.
	//
	// 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) (*sql.DB, error) {
	//		db, err := sql.Open("pgx", os.Getenv("SQLB_SHADOW_DSN"))
	//		if err != nil {
	//			return nil, err
	//		}
	//		// Scratch, and this line is the assertion that it is.
	//		_, err = db.ExecContext(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public")
	//		return db, 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) (*sql.DB, 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