On this page

Generator API

The sgl command is a thin layer over the seagull.sh/seagull package. Build systems, frameworks and editors can call the same package directly: give it a dialect and the SQL, get the generated files and every diagnostic back, and decide what to do with them.

The library never needs cgo or a running database to generate code. Its dependencies are listed under dependencies.

Generate code

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "seagull.sh/seagull"
    "seagull.sh/seagull/sdk/dialect/dialect_engine_postgres"
)

func main() {
    generator, err := seagull.New(dialect_engine_postgres.New())
    if err != nil {
        log.Fatal(err)
    }

    project := os.DirFS(".")
    result, err := generator.Generate(context.Background(), seagull.Input{
        Package:      "store",
        Migrations:   project,
        MigrationDir: "db/migrations",
        QueryDir:     "db/queries",
    })
    if err != nil {
        log.Fatal(err) // unreadable files, a limit exceeded, cancellation
    }
    for _, diagnostic := range result.Diagnostics {
        fmt.Printf("%s:%d:%d: [%s] %s\n", diagnostic.Filename, diagnostic.Line,
            diagnostic.Column, diagnostic.Code, diagnostic.Message)
    }
    if err := result.Err(); err != nil {
        os.Exit(1) // at least one diagnostic has error severity
    }
    if err := seagull.WriteFiles("internal/store", result); err != nil {
        log.Fatal(err)
    }
}

seagull.New takes the dialect as its first argument, so forgetting one is a compile error. Other settings are options with defaults listed below.

Input names where the SQL lives. Migrations and Queries are any fs.FS, so an embed.FS, os.DirFS or fstest.MapFS all work; leave Queries nil to read the queries from the same file system as the migrations. Directories are slash-separated paths inside the file system, and an empty directory means its root. Package names the generated package, and PackagePath, its import path, lets an override name a Go type declared in that package without the package importing itself.

A directory listing leaves out named pipes, sockets and devices, even one named *.sql, so a run never waits on a file another process writes. A symbolic link is followed. A path that is not a regular file once followed, or a file larger than WithMaxFileBytes, stops the run with an error; the size is checked before the file is read.

Generate code and inspect the analysis

GenerateDetailed(ctx, input) performs one catalogue build and one analysis pass and returns a DetailedResult. Its Result is the ordinary generation result; Report contains the first vet report, Catalogue is the adopted schema before emission, and CatalogueDiagnostics contains diagnostics from the schema source. The snapshots are independent of later emitter changes and retries. This is useful for editors that need generated code, query metadata and a schema browser together.

Use Generate, Vet or Catalogue individually when only that output is needed. The ordinary generation path does not construct the additional snapshots.

Errors and diagnostics

Generate returns an error only for problems that stop the run: invalid input, a file it cannot read, a limit exceeded, cancellation, an emitter failure, a recovered panic, or a Generator not made by New. An emitter failure reads emitting package <name>: ... and still matches the emitter's own error with errors.Is. Problems in your SQL are diagnostics, not errors. They come back in Result.Diagnostics, in source order, each with a stable code (Q001 and up, which seagull.Codes lists). When any diagnostic has error severity, Result.Files is empty and Result.Err() returns a *seagull.DiagnosticsError that matches seagull.ErrDiagnostics.

A run reports at most 10,000 diagnostics, or the number WithMaxDiagnostics sets. The rest are left out and summarised in one Q066 diagnostic, with no location, at the highest severity among them, so a run whose errors were left out still fails. Smaller caps apply within a run: a query block keeps its first 1,000 diagnostics, a directive call 32 and the directives of one migration file 256, each followed by a Q066 of its own.

A panic in code the run calls (the dialect, or an emitter, schema source or hook the host supplies) does not crash the process. Seagull recovers it and logs its stack once at warning level through the run's logger (WithLogger, the context's logger or slog.Default()); the stack is never part of an error or a diagnostic. A panic while a migration statement or a query is handled, or in an emitter's Check or DefaultTypes, is a Q070 error diagnostic, and the run goes on to report the rest. A panic in Emit, BindsNullElements, a schema source's BuildCatalogue or any other dialect call stops the run with an error matching ErrPanic. New returns one for a panic in the dialect's Info or the emitter's naming reservation, and FormatDirectivesFor for a panic in the dialect's Info.

WithSkipFailingQueries() changes that for errors a query has of its own, such as SQL the dialect cannot analyse or a query the emitter cannot generate: the query is left out, Result.Files holds the rest of the package, Result.Partial is true, and Result.Err() still reports the errors, so a caller that fails on them keeps failing. Any other error, from a migration, the schema, two queries of one name or the package's names, still leaves Result.Files empty. sgl generate runs with this option.

The errors New and Generate return wrap a small set of sentinels you can test with errors.Is:

SentinelMeaning
ErrInvalidConfigAn option, the preset or the input cannot be used, an option is nil, or the Generator was not made by New (a nil or zero one).
ErrNoSourceNo dialect, no migrations or schema source, or no file system for queries. Wraps ErrInvalidConfig.
ErrInvalidPrefixA directive, helper or error prefix has the wrong shape or repeats. Wraps ErrInvalidConfig.
ErrLimitInput exceeds a configured limit.
ErrFileTooLargeA file exceeds WithMaxFileBytes. Wraps ErrLimit.
ErrPanicA panic Seagull recovered from code it called, such as a dialect, an emitter or a schema source. Its stack is logged once at warning level and is not part of the error. When the panic value was itself an error, the error matches that value too.
ErrDiagnosticsError-severity diagnostics, through Result.Err or Report.Err.
ErrParseSQL that cannot be parsed. Generate and Vet report it as a Q010 diagnostic rather than an error, so it comes back through Result.Err or Report.Err, which then match both ErrDiagnostics and ErrParse.
ErrMalformedSQLA script with an unterminated string, quoted identifier or block comment, or an unbalanced block, that cannot be split into statements. The migration runner returns an error matching it; the generator reports the same problem as Q010. Wraps ErrParse.

Check without generating

Vet runs the same analysis but emits nothing. Its Report holds the diagnostics and the analysed queries (parameters, output columns, nullability, directives), which is what an editor integration or a CI check needs:

report, err := generator.Vet(ctx, seagull.Input{
    Migrations: project, Package: "store",
    MigrationDir: "db/migrations", QueryDir: "db/queries",
})
if err != nil {
    return err
}
if err := report.Err(); err != nil {
    return err
}

A valid Input.Package enables naming and emitter checks. An empty or invalid package name skips those checks, so it does not establish that the project can generate.

Catalogue returns the schema and its diagnostics without analysing queries. It ignores query and package fields. Check its operational error, then its returned diagnostics, as the catalogue how-to demonstrates.

Choose a dialect by name

When the dialect comes from configuration, use the registry instead of importing every engine provider yourself:

import "seagull.sh/seagull/sdk/dialect/dialect_registry"

chosen, err := dialect_registry.New(config.Dialect, dialect_registry.Options{MaxParseDepth: 256})
if err != nil {
    return err // wraps dialect_registry.ErrUnknownDialect and lists dialect_registry.Names()
}
generator, err := seagull.New(chosen)

dialect_registry.Options is plain data, holding what sgl.yaml's dialect_options sets plus a display Name. The Name and the parser limits (MaxParseDepth, MaxTokensPerStatement) apply to every dialect; SearchPath, the schemas a session starts with, to PostgreSQL, CockroachDB and TimescaleDB; Returning and ANSIQuotes to MySQL and MariaDB; and JoinUseNulls, GroupByUseNulls and MaxTypeParseDepth to ClickHouse. A zero field keeps the dialect's default. An option that does not apply to the named dialect, a negative limit, a limit above its ceiling (4096 for MaxParseDepth, 1,000,000 for MaxTokensPerStatement, 64 for MaxTypeParseDepth), or a Name that is blank or names a built-in dialect of another family, is an error wrapping dialect_registry.ErrInvalidOption rather than silently ignored.

Options

OptionDefaultPurpose
WithEmitter(emit.EmitterPort)emit_driver_databasesql.New()Which driver API the code targets: emit_driver_databasesql, emit_driver_pgx, emit_driver_clickhouse, or your own.
WithPreset(Preset)DefaultPreset()The vocabulary of directives and generated code.
WithDirectivePrefixes(canonical, aliases...)sgl, seagullThe directive prefixes, replacing the preset's.
WithForeignDirectivePrefixes(...)one legacy prefixPrefixes of other tools, reported (Q051) rather than parsed.
WithTypeOverrides(...TypeOverride)noneMap a SQL type, or one column, to a Go type. A column override that names no column of the schema is an error (Q037), and a type override that matches no type a warning (Q061).
WithPackageNames(map[string]string)noneThe name each Go package an override or type slot names declares, keyed by import path, for a package whose name is not the one its path implies; the generated code imports it under that name.
WithCustomFunctions(...CustomFunction)noneDeclare functions the dialect does not know, such as those of a loadable extension.
WithSchemaSource(SchemaSourcePort)replay migrationsBuild the catalogue another way, such as from a live database.
WithRuntimeTables(...string)the sgl_ history, seed and lock tablesThe tables a schema source's catalogue leaves out, matched in every schema regardless of case; pass the table names a backend's Names method reports when its namespace or history table differ. No effect without WithSchemaSource.
WithLogger(*slog.Logger)context, then slog.Default()Where log records go.
WithObserver(observe.ObserverPort)context, then noneSpans for each stage, for tracing and metrics.
WithClock(observe.Clock)observe.SystemClock()Times each run for the trace log.
WithMaxFileBytes(int64)4 MiBCap on each migration and query file, at most 1 TiB; a larger file stops the run with ErrFileTooLarge.
WithMaxFiles(int)10,000Cap on the entries in each directory.
WithMaxQueries(int)100,000Cap on the query blocks one run reads, a block whose header is malformed included; past it the run fails with ErrLimit.
WithMaxTotalBytes(int64)64 MiBCap on the bytes one run reads across every migration and query file; past it the run fails with ErrLimit. Files are read and processed one at a time.
WithMaxDiagnostics(int)10,000Cap on the diagnostics one run reports; the rest are summarised in one Q066 diagnostic at the highest severity among them.
WithSkipFailingQueries()offLeave out each query with an error of its own and generate the rest, as Result.Partial reports.

WithDirectivePrefixes and WithForeignDirectivePrefixes apply on top of the preset whatever order the options come in. Every limit must be positive; New rejects a non-positive one, a file cap above 1 TiB and a nil option with ErrInvalidConfig. New copies the type overrides and custom functions it is given, so changing them afterwards does not change the generator.

Emitters

WithEmitter chooses the driver API the generated code calls. Each stock emitter is a provider package under sdk/emit: emit_driver_databasesql.New(options...), emit_driver_pgx.New() and emit_driver_clickhouse.New(). The emit API lists them with the tools for writing your own.

import (
    "seagull.sh/seagull"
    "seagull.sh/seagull/sdk/dialect/dialect_engine_postgres"
    "seagull.sh/seagull/sdk/emit/emit_driver_databasesql"
)

generator, err := seagull.New(dialect_engine_postgres.New(),
    seagull.WithEmitter(emit_driver_databasesql.New(emit_driver_databasesql.WithPrepared(false))),
)
emit_driver_databasesqlemit_driver_pgxemit_driver_clickhouse (native)
Dialectsevery dialectthe PostgreSQL familyClickHouse
Commandsevery command, except execrows and execresult on ClickHouseevery commandevery command except execrows and execresult
execresult returnssql.Result; refused on ClickHousepgconn.CommandTagrefused
batchchunked multi-row INSERTs, returning errora pgx.Batch, returning errora PrepareBatch, returning error
copyfromthe same as batchCOPY FROM, returning (int64, error)the same as batch
Sliceskind: slice expands IN ($1) at call time; on ClickHouse, an Array parameterkind: slice binds one array to = ANY($1)an Array parameter
prepared.goyes by default, except on ClickHousenono
Runtime builder (dynamic: runtime)yesyesyes
NewWithReplica, WithTx, RunInTxall threeWithTx and RunInTxnone
A DBTX that wraps transactions (WrapDBTX)yesnono

emit_driver_databasesql.New() writes prepared.go unless given emit_driver_databasesql.WithPrepared(false). sgl does the opposite: its go.prepared setting defaults to false. The difference is deliberate. The library keeps the default it has always had, so programs that already call it generate the same files, while sgl makes the cache opt-in. ClickHouse never gets the file because clickhouse-go prepares only INSERT statements. The generated code uses the cache only once the program calls Prepare; see directives.

The placeholders the generated code writes come from the dialect's Info().Placeholders. How the code binds and scans values follows a per-driver profile inside the emitter, chosen from the first module in the dialect's Info().Drivers the emitter knows, such as pgx's database/sql adapter for PostgreSQL. emit_driver_databasesql.WithDriver(module) pins the profile to one driver module, such as github.com/mattn/go-sqlite3, for a program that uses a driver other than the dialect's first.

Presets

A Preset defines directive vocabulary, generated headers and helper names, type slots and optional output features. Start from DefaultPreset and pass the changed value to WithPreset. New validates names and prefixes before analysing input. DefaultPreset turns ModernIdioms on, so stream methods return iter.Seq2, and leaves WrapErrors, which labels each returned error with its method's name, off.

preset := seagull.DefaultPreset()
preset.Directives = seagull.Directives{Canonical: "acme"}
preset.Header = "// Code generated by acme. DO NOT EDIT."
preset.HelperPrefix = "acme"
preset.ErrorPrefix = "acme"
generator, err := seagull.New(dialect_engine_postgres.New(), seagull.WithPreset(preset))

See presets for fields and defaults, type mapping for type-slot precedence and customising a preset for adoption steps.

Write the files

WriteFiles(directory, result, options...) publishes generated files. It replaces each result filename atomically, regardless of the existing file's header. It then removes stale regular files whose first line matches the selected generated or legacy header, never opening a symbolic link, named pipe, socket or device. Use a dedicated generated directory to avoid collisions with hand-written filenames.

The writer rejects an ordinary result with error diagnostics, but accepts a marked partial result. It may remove methods and stale files belonging to rejected queries. See output writing for options and publication policy.

Format directives

FormatDirectivesFor is what sgl fmt runs. It rewrites directives written with an alias prefix to the canonical one and normalises the spacing inside directive comments, leaving SQL bodies, prose comments and quoted values untouched. It reads the file as the dialect writes SQL, so a directive-like line inside one of the dialect's strings, or a -- the dialect does not read as a comment, is left alone:

formatted, err := seagull.FormatDirectivesFor(dialect_engine_mysql.New(), source, "sgl", "seagull")

FormatDirectives(source, "sgl", "seagull") does the same with neutral lexical rules, for a caller with no dialect at hand. Both are idempotent, so running them in a pre-commit hook is safe. Neither should run over applied migrations: their checksums are recorded, so any change makes the next migration run fail.

Generate from a live database

WithSchemaSource replaces migration replay with a BuildCatalogue implementation. Built-in sources use database/sql to inspect PostgreSQL-family or SQLite databases. Pass the same dialect as the source's type normaliser and the generator's dialect.

The source retains ownership of its catalogue. The generator clones it before applying overrides, custom functions and runtime-table filtering. A shared source must keep the returned object stable while it may be read.

See the schema API for the interface and live schema generation for connection setup and error handling. Input.Migrations may be nil with a source; Input.Queries supplies the query file system.

Logging and observability

WithLogger and WithObserver provide a logger and stage observer. Without explicit options, the generator uses context values, then the default logger and a discard observer. An observer receives a stage's context and attributes and returns a context and end callback.

See errors and observability for precedence, stages and callback behaviour, and observe generator work for a complete example.

Run migrations

Migrations are a separate, much smaller run time that never links the analyser; see Migrations for migrate.NewMigrator and migrate.NewMigratorFor, and the migrate API for the sdk/migrate package.

Dependencies

Besides the standard library, the packages an embedder imports (the root package and the sdk/... packages, with the implementation under internal/ they forward to) link exactly these modules. A test in the repository lists them and fails when one is added or is no longer needed.

ModuleWhy
piko.sh/goastutilBuilds the generated Go syntax trees.
piko.sh/safeconvChecked integer conversions.
piko.sh/safediskFile reads and writes confined to a directory.
github.com/cespare/xxhash/v2Short hashes goastutil uses to disambiguate identifiers.
golang.org/x/tools, golang.org/x/mod, golang.org/x/syncLinked through the pinned goastutil revision, whose root package imports x/tools/imports and x/tools/go/packages. Seagull never calls them: it formats generated code with go/format, so generation never runs the go command. goastutil v0.2.0 moves those imports into a separate package, which leaves only golang.org/x/tools/go/ast/astutil.

Generated code needs nothing from Seagull: its helpers are written into the package, so it imports only the standard library, the driver it was generated for, and the packages of the Go types it uses (such as github.com/cockroachdb/apd/v3 for decimals).

The pinned goastutil cache fix limits each dynamic AST cache to 4096 entries and 1 MiB of key text, and bypasses keys larger than 4 KiB. Reset and insertion are synchronised. Seagull pins the immutable commit version v0.1.1-0.20261011054316-ba88dfe3a48e; it uses no local replacement for this dependency. Request-specific SQL render caches end with each emission. Prepared helper-source ASTs are limited to the fixed embedded source files and are immutable between renders.

Exported declarations

seagull

The root package seagull.sh/seagull is a facade over the implementation under internal/, so most of its declarations are aliases; run go doc on a name for its doc comment. Its diagnostics declarations, from diagnostics.go, are listed with the observe API.

const JSONCaseAsWritten = emit.JSONCaseAsWritten ...
const CodegenVersion = "3" ...
var ErrInvalidConfig = fault.ErrInvalidConfig ...
var Version string
func ContextWithLogger(ctx context.Context, logger *slog.Logger) context.Context
func FormatDirectives(source []byte, canonical string, aliases ...string) ([]byte, error)
func FormatDirectivesFor(engine dialect.Dialect, source []byte, canonical string, aliases ...string) ([]byte, error)
func WriteFiles(directory string, result *Result, options ...WriteOption) error
type CustomFunction = sqlmodel.CustomFunctionConfig
type DetailedResult = app.DetailedResult
type DiagnosticsError = app.DiagnosticsError
type Directives = app.Directives
type Generator struct{ ... }
    func New(engine dialect.Dialect, options ...Option) (*Generator, error)
type GoType = sqlmodel.GoType
type Input = app.Input
type Interface = emit.Interface
type JSONCase = emit.JSONCase
type JSONTags = emit.JSONTags
type Option = app.Option
    func WithClock(clock observe.Clock) Option
    func WithCustomFunctions(functions ...CustomFunction) Option
    func WithDirectivePrefixes(canonical string, aliases ...string) Option
    func WithEmitter(emitter emit.EmitterPort) Option
    func WithForeignDirectivePrefixes(prefixes ...string) Option
    func WithLogger(logger *slog.Logger) Option
    func WithMaxDiagnostics(limit int) Option
    func WithMaxFileBytes(limit int64) Option
    func WithMaxFiles(limit int) Option
    func WithMaxQueries(limit int) Option
    func WithMaxTotalBytes(limit int64) Option
    func WithObserver(observer observe.ObserverPort) Option
    func WithPackageNames(names map[string]string) Option
    func WithPreset(chosen Preset) Option
    func WithRuntimeTables(names ...string) Option
    func WithSchemaSource(source SchemaSourcePort) Option
    func WithSkipFailingQueries() Option
    func WithTypeOverrides(overrides ...TypeOverride) Option
type Preset = app.Preset
    func DefaultPreset() Preset
type QueryNames = emit.QueryNames
type Report = app.Report
type Result = app.Result
type SchemaSourcePort = schema.SchemaSourcePort
type Slot = sqlmodel.TypeSlot
type TypeOverride = sqlmodel.TypeOverride
type TypeSlots = sqlmodel.TypeSlots
type WriteOption = app.WriteOption
    func WithHeader(header string) WriteOption
    func WithLegacyHeaders(headers ...string) WriteOption
    func WithPermissions(permissions fs.FileMode) WriteOption
    func WithPrune(enabled bool) WriteOption

Examples for this page