White-label presets

On this page

On SQLite:

  • the preset starts from DefaultPreset; sgl becomes a foreign prefix, so an unconverted directive draws Q051
  • helpers become acme..., sentinel errors ErrAcme..., and error text starts acme:
  • query_names.go was renamed acme_query_names.go, so only WithLegacyHeaders lets WriteFiles prune the old file
  • the acme package is written to a temporary directory, since its header is not sgl's

A framework or code generator that embeds Seagull may not want its users to see Seagull at all. Its queries should say acme.query, not sgl.query; its generated files should say they were generated by Acme; its helpers and errors should carry Acme's name. A seagull.Preset holds all of that vocabulary in one value, and seagull.WithPreset hands it to the generator. This example moves an existing sgl project to a fictional tool called Acme, from its query files to the generated package on disk.

The project

The queries take a Go slice and build a filter at run time, chosen because both make Seagull generate helper code, and helper names are part of the vocabulary:

db/queries/parcels.sql

-- sgl.query(ListParcelsByStatus, many)
-- ?1 as sgl.param(statuses, kind: slice)
SELECT id, recipient, status FROM parcels WHERE status IN (?) ORDER BY id;

-- sgl.query(SearchParcels, many, dynamic: runtime)
SELECT id, recipient, status, weight_grams FROM parcels

sgl.yaml turns on query_names, so the package also has a file mapping each SQL string to its query name. That file's name and function are part of the preset too.

The preset

acmePreset starts from seagull.DefaultPreset(), the vocabulary sgl uses, and replaces each part:

  • Directives makes acme the canonical prefix with no aliases. It also lists sgl as Foreign: another tool's prefix, so a directive nobody converted draws a Q051 warning instead of being silently ignored.
  • Header is the first line of every generated file. WriteFiles uses it to recognise the files it may prune.
  • HelperPrefix and ErrorPrefix name the generated helpers and errors.
  • QueryNames renames the query-names file and its function.

seagull.New checks the preset and refuses one it cannot use, such as a prefix of the wrong shape, with an error wrapping ErrInvalidConfig.

The program

example.go

package main

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"testing/fstest"

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

var (
	// acmeIdentifier matches the helper and error names the acme preset produces.
	acmeIdentifier = regexp.MustCompile(`\b(?:acme|ErrAcme)[A-Za-z]+`)

	// stockVocabulary matches any trace of the stock vocabulary.
	stockVocabulary = regexp.MustCompile(`(?i)sgl`)
)

// acmePreset is the vocabulary of Acme's code generator, which embeds Seagull: its own
// directive prefix, generated-file header, helper and error prefixes and query-names
// file. sgl becomes a foreign prefix, so a directive nobody converted is reported with
// Q051 rather than ignored.
//
// Returns seagull.Preset which is the acme preset.
func acmePreset() seagull.Preset {
	preset := seagull.DefaultPreset()
	preset.Directives = seagull.Directives{
		Canonical:        "acme",
		Aliases:          nil,
		Foreign:          []string{"sgl"},
		RequireCanonical: false,
	}
	preset.Header = "// Code generated by acme-gen. DO NOT EDIT."
	preset.HelperPrefix = "acme"
	preset.ErrorPrefix = "acme"
	preset.QueryNames = seagull.QueryNames{Enabled: true, File: "acme_query_names.go", Function: "QueryName"}
	return preset
}

// run moves a store package from the stock vocabulary to Acme's. It generates the package
// as sgl would into directory, rewrites the query files to the acme prefix with
// FormatDirectives, generates again with the acme preset, checks the result never says
// sgl, and writes it over the old package, pruning the files the old header marked.
//
// Takes project (fs.FS) which holds db/migrations and db/queries, written for sgl.
// Takes directory (string) which receives the generated package.
// Takes output (io.Writer) which receives each step.
//
// Returns error when a step fails.
func run(ctx context.Context, project fs.FS, directory string, output io.Writer) error {
	stock := seagull.DefaultPreset()
	stock.QueryNames.Enabled = true
	before, err := generate(ctx, &stock, project)
	if err != nil {
		return err
	}
	if err := seagull.WriteFiles(directory, before); err != nil {
		return err
	}
	fmt.Fprintln(output, "generated with the stock preset:")
	if err := list(output, directory); err != nil {
		return err
	}

	converted, err := convert(project, output)
	if err != nil {
		return err
	}
	after, err := generate(ctx, new(acmePreset()), converted)
	if err != nil {
		return err
	}

	var helpers []string
	for _, file := range after.Files {
		if stockVocabulary.Match(file.Content) {
			return fmt.Errorf("%s still mentions sgl", file.Name)
		}
		helpers = append(helpers, acmeIdentifier.FindAllString(string(file.Content), -1)...)
	}
	slices.Sort(helpers)
	fmt.Fprintf(output, "\nno file of the acme generation mentions sgl; its helpers are\n  %s\n",
		strings.Join(slices.Compact(helpers), "\n  "))

	if err := seagull.WriteFiles(directory, after, seagull.WithLegacyHeaders(stock.Header)); err != nil {
		return err
	}
	fmt.Fprintln(output, "\nwritten with the acme preset, pruning files with the stock header:")
	return list(output, directory)
}

// generate runs the generator with a preset over a project.
//
// Takes preset (*seagull.Preset) which is the vocabulary.
// Takes project (fs.FS) which holds db/migrations and db/queries.
//
// Returns *seagull.Result which holds the generated files.
// Returns error when generation fails or a query has an error diagnostic.
func generate(ctx context.Context, preset *seagull.Preset, project fs.FS) (*seagull.Result, error) {
	generator, err := seagull.New(dialect_engine_sqlite.New(), seagull.WithPreset(*preset))
	if err != nil {
		return nil, err
	}
	result, err := generator.Generate(ctx, seagull.Input{
		Migrations:   project,
		Queries:      nil,
		Package:      "store",
		MigrationDir: "db/migrations",
		QueryDir:     "db/queries",
	})
	if err != nil {
		return nil, err
	}
	if err := result.Err(); err != nil {
		return nil, err
	}
	return result, nil
}

// convert rewrites the directives of every query file from sgl to acme, as Acme's tool
// would when a project adopts it, and prints the first converted file.
//
// Takes project (fs.FS) which holds the sgl project.
// Takes output (io.Writer) which receives the converted file.
//
// Returns fs.FS which holds the migrations unchanged and the converted queries.
// Returns error when a file cannot be read or formatted.
func convert(project fs.FS, output io.Writer) (fs.FS, error) {
	converted := fstest.MapFS{}
	err := fs.WalkDir(project, "db", func(name string, entry fs.DirEntry, err error) error {
		if err != nil || entry.IsDir() {
			return err
		}
		content, err := fs.ReadFile(project, name)
		if err != nil {
			return err
		}
		if path.Dir(name) == "db/queries" {
			if content, err = seagull.FormatDirectives(content, "acme", "sgl"); err != nil {
				return err
			}
			fmt.Fprintf(output, "\n%s, rewritten with FormatDirectives:\n%s", name, content)
		}
		converted[name] = &fstest.MapFile{Data: content}
		return nil
	})
	return converted, err
}

// list prints each file in directory with its first line, the header that marks a
// generated file.
//
// Takes output (io.Writer) which receives the listing.
// Takes directory (string) which is the package directory.
//
// Returns error when the directory cannot be read.
func list(output io.Writer, directory string) error {
	entries, err := os.ReadDir(directory)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		file, err := os.Open(filepath.Join(directory, entry.Name())) //nolint:gosec // a file of the package directory
		if err != nil {
			return err
		}
		scanner := bufio.NewScanner(file)
		scanner.Scan()
		_ = file.Close()
		fmt.Fprintf(output, "  %-22s %s\n", entry.Name(), scanner.Text())
	}
	return nil
}

run does the move in four steps.

It generates with the stock preset, as sgl would for this sgl.yaml, and writes the package. Every file starts with sgl's header.

It converts the queries with seagull.FormatDirectives(content, "acme", "sgl"): the first prefix is the canonical one to write, and the rest are the prefixes to rewrite to it. This is the function behind sgl fmt. It changes only directives, never the SQL, and running it twice changes nothing more.

It generates again with the Acme preset, from the converted queries, and checks that no generated file mentions sgl in any case. It collects the helper and error names to print.

It writes over the old package with seagull.WithLegacyHeaders(stock.Header). Most files keep their names and are simply replaced. The query-names file does not: it is now acme_query_names.go, so the old query_names.go is left over, and it carries a header the Acme preset does not use. Naming the old header as a legacy one tells WriteFiles to prune files that carry it too.

What it prints

generated with the stock preset:
  bind_limits.go         // Code generated by sgl. DO NOT EDIT.
  models.go              // Code generated by sgl. DO NOT EDIT.
  parcels.sql.go         // Code generated by sgl. DO NOT EDIT.
  prepared.go            // Code generated by sgl. DO NOT EDIT.
  querier.go             // Code generated by sgl. DO NOT EDIT.
  query_names.go         // Code generated by sgl. DO NOT EDIT.
  runtime_helpers.go     // Code generated by sgl. DO NOT EDIT.
  slice_helpers.go       // Code generated by sgl. DO NOT EDIT.

db/queries/parcels.sql, rewritten with FormatDirectives:
-- acme.query(ListParcelsByStatus, many)
-- ?1 as acme.param(statuses, kind: slice)
SELECT id, recipient, status FROM parcels WHERE status IN (?) ORDER BY id;

-- acme.query(SearchParcels, many, dynamic: runtime)
SELECT id, recipient, status, weight_grams FROM parcels

no file of the acme generation mentions sgl; its helpers are
  ErrAcmeTooManyBindVariables
  ErrAcmeUnknownColumn
  ErrAcmeUnknownDirection
  ErrAcmeUnknownOperator
  acmeAllowedDirections
  acmeAllowedOperators
  acmeAt
  acmeBoxSlice
  acmeCastAfter
  acmeCastBefore
  acmeCastMiddle
  acmeCastPatterns
  acmeCastTypes
  acmeCheckBinds
  acmeCloseStatements
  acmeColumnExpressionRegex
  acmeEdit
  acmeEmptyExclusion
  acmeEmptyMembership
  acmeExtractColumnRoot
  acmeFilter
  acmeFilterCount
  acmeFilterInto
  acmeIsMultiOperator
  acmeIsUnaryOperator
  acmeLimitAfter
  acmeLimitBefore
  acmeList
  acmeListMark
  acmeMaxBindVariables
  acmeMaxColumnExpressionLength
  acmeMultiValueLen
  acmeNewOrder
  acmeNewPredicate
  acmeNormaliseDirection
  acmeOffsetAfter
  acmeOffsetBefore
  acmeOrder
  acmePagination
  acmePaginationCount
  acmePaginationInto
  acmePredicate
  acmeReflectSlice
  acmeResolveColumn
  acmeSimpleColumn
  acmeSplice
  acmeSplitCast
  acmeUnboundedLimit
  acmeWhereClose
  acmeWhereOpen
  acmeWriteBindPlaceholder

written with the acme preset, pruning files with the stock header:
  acme_query_names.go    // Code generated by acme-gen. DO NOT EDIT.
  bind_limits.go         // Code generated by acme-gen. DO NOT EDIT.
  models.go              // Code generated by acme-gen. DO NOT EDIT.
  parcels.sql.go         // Code generated by acme-gen. DO NOT EDIT.
  prepared.go            // Code generated by acme-gen. DO NOT EDIT.
  querier.go             // Code generated by acme-gen. DO NOT EDIT.
  runtime_helpers.go     // Code generated by acme-gen. DO NOT EDIT.
  slice_helpers.go       // Code generated by acme-gen. DO NOT EDIT.

The first listing is the package sgl would write. Then comes the converted query file, with acme. directives and the SQL untouched. The helper names follow the preset: helpers start acme, unexported errors errAcme and exported ones ErrAcme, such as ErrAcmeUnknownColumn, which the run-time filter builder returns for a column it does not allow. The last listing has Acme's header on every file and no query_names.go: the legacy header let it go.

sgl offers the same to a project arriving from another generator: list that generator's header under go.legacy_headers in sgl.yaml, and sgl generate prunes its old files as well. Embedding Seagull describes every field of a preset, including the type slots that replace whole categories of Go types.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/embedding && go run ./049_presets

Its test runs it too, and checks what it prints against output.txt:

cd seagull/examples/embedding && go test ./049_presets

The files

Every file of the example, as it is in the Seagull repository.

sgl.yaml

# Copyright 2026 PolitePixels Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# This project stands against fascism, authoritarianism, and all forms of
# oppression. We built this to empower people, not to enable those who would
# strip others of their rights and dignity.

version: 1

databases:
  - name: parcels
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store
      query_names:
        enabled: true

db/migrations/0001_init.up.sql

CREATE TABLE parcels (
    id           INTEGER PRIMARY KEY,
    recipient    TEXT    NOT NULL,
    status       TEXT    NOT NULL,
    weight_grams INTEGER NOT NULL
);

db/migrations/0001_init.down.sql

DROP TABLE parcels;

db/queries/parcels.sql

-- sgl.query(ListParcelsByStatus, many)
-- ?1 as sgl.param(statuses, kind: slice)
SELECT id, recipient, status FROM parcels WHERE status IN (?) ORDER BY id;

-- sgl.query(SearchParcels, many, dynamic: runtime)
SELECT id, recipient, status, weight_grams FROM parcels

example.go

package main

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"testing/fstest"

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

var (
	// acmeIdentifier matches the helper and error names the acme preset produces.
	acmeIdentifier = regexp.MustCompile(`\b(?:acme|ErrAcme)[A-Za-z]+`)

	// stockVocabulary matches any trace of the stock vocabulary.
	stockVocabulary = regexp.MustCompile(`(?i)sgl`)
)

// acmePreset is the vocabulary of Acme's code generator, which embeds Seagull: its own
// directive prefix, generated-file header, helper and error prefixes and query-names
// file. sgl becomes a foreign prefix, so a directive nobody converted is reported with
// Q051 rather than ignored.
//
// Returns seagull.Preset which is the acme preset.
func acmePreset() seagull.Preset {
	preset := seagull.DefaultPreset()
	preset.Directives = seagull.Directives{
		Canonical:        "acme",
		Aliases:          nil,
		Foreign:          []string{"sgl"},
		RequireCanonical: false,
	}
	preset.Header = "// Code generated by acme-gen. DO NOT EDIT."
	preset.HelperPrefix = "acme"
	preset.ErrorPrefix = "acme"
	preset.QueryNames = seagull.QueryNames{Enabled: true, File: "acme_query_names.go", Function: "QueryName"}
	return preset
}

// run moves a store package from the stock vocabulary to Acme's. It generates the package
// as sgl would into directory, rewrites the query files to the acme prefix with
// FormatDirectives, generates again with the acme preset, checks the result never says
// sgl, and writes it over the old package, pruning the files the old header marked.
//
// Takes project (fs.FS) which holds db/migrations and db/queries, written for sgl.
// Takes directory (string) which receives the generated package.
// Takes output (io.Writer) which receives each step.
//
// Returns error when a step fails.
func run(ctx context.Context, project fs.FS, directory string, output io.Writer) error {
	stock := seagull.DefaultPreset()
	stock.QueryNames.Enabled = true
	before, err := generate(ctx, &stock, project)
	if err != nil {
		return err
	}
	if err := seagull.WriteFiles(directory, before); err != nil {
		return err
	}
	fmt.Fprintln(output, "generated with the stock preset:")
	if err := list(output, directory); err != nil {
		return err
	}

	converted, err := convert(project, output)
	if err != nil {
		return err
	}
	after, err := generate(ctx, new(acmePreset()), converted)
	if err != nil {
		return err
	}

	var helpers []string
	for _, file := range after.Files {
		if stockVocabulary.Match(file.Content) {
			return fmt.Errorf("%s still mentions sgl", file.Name)
		}
		helpers = append(helpers, acmeIdentifier.FindAllString(string(file.Content), -1)...)
	}
	slices.Sort(helpers)
	fmt.Fprintf(output, "\nno file of the acme generation mentions sgl; its helpers are\n  %s\n",
		strings.Join(slices.Compact(helpers), "\n  "))

	if err := seagull.WriteFiles(directory, after, seagull.WithLegacyHeaders(stock.Header)); err != nil {
		return err
	}
	fmt.Fprintln(output, "\nwritten with the acme preset, pruning files with the stock header:")
	return list(output, directory)
}

// generate runs the generator with a preset over a project.
//
// Takes preset (*seagull.Preset) which is the vocabulary.
// Takes project (fs.FS) which holds db/migrations and db/queries.
//
// Returns *seagull.Result which holds the generated files.
// Returns error when generation fails or a query has an error diagnostic.
func generate(ctx context.Context, preset *seagull.Preset, project fs.FS) (*seagull.Result, error) {
	generator, err := seagull.New(dialect_engine_sqlite.New(), seagull.WithPreset(*preset))
	if err != nil {
		return nil, err
	}
	result, err := generator.Generate(ctx, seagull.Input{
		Migrations:   project,
		Queries:      nil,
		Package:      "store",
		MigrationDir: "db/migrations",
		QueryDir:     "db/queries",
	})
	if err != nil {
		return nil, err
	}
	if err := result.Err(); err != nil {
		return nil, err
	}
	return result, nil
}

// convert rewrites the directives of every query file from sgl to acme, as Acme's tool
// would when a project adopts it, and prints the first converted file.
//
// Takes project (fs.FS) which holds the sgl project.
// Takes output (io.Writer) which receives the converted file.
//
// Returns fs.FS which holds the migrations unchanged and the converted queries.
// Returns error when a file cannot be read or formatted.
func convert(project fs.FS, output io.Writer) (fs.FS, error) {
	converted := fstest.MapFS{}
	err := fs.WalkDir(project, "db", func(name string, entry fs.DirEntry, err error) error {
		if err != nil || entry.IsDir() {
			return err
		}
		content, err := fs.ReadFile(project, name)
		if err != nil {
			return err
		}
		if path.Dir(name) == "db/queries" {
			if content, err = seagull.FormatDirectives(content, "acme", "sgl"); err != nil {
				return err
			}
			fmt.Fprintf(output, "\n%s, rewritten with FormatDirectives:\n%s", name, content)
		}
		converted[name] = &fstest.MapFile{Data: content}
		return nil
	})
	return converted, err
}

// list prints each file in directory with its first line, the header that marks a
// generated file.
//
// Takes output (io.Writer) which receives the listing.
// Takes directory (string) which is the package directory.
//
// Returns error when the directory cannot be read.
func list(output io.Writer, directory string) error {
	entries, err := os.ReadDir(directory)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		file, err := os.Open(filepath.Join(directory, entry.Name())) //nolint:gosec // a file of the package directory
		if err != nil {
			return err
		}
		scanner := bufio.NewScanner(file)
		scanner.Scan()
		_ = file.Close()
		fmt.Fprintf(output, "  %-22s %s\n", entry.Name(), scanner.Text())
	}
	return nil
}

main.go

// Command 049_presets white-labels Seagull: it generates this example's store package
// with the stock vocabulary, converts the queries to the acme. prefix, and generates
// again with Acme's preset, its own header, helper names and query-names file.
//
// Run it with `go run .`; it writes into a temporary directory, or into the directory
// given with -out.
package main

import (
	"context"
	"flag"
	"log"
	"os"
)

func main() {
	out := flag.String("out", "", "directory to write the generated package into; a temporary one when empty")
	flag.Parse()
	if err := execute(context.Background(), *out); err != nil {
		log.Fatal(err)
	}
}

// execute runs the example, writing into directory or a temporary one when it is empty.
//
// Takes directory (string) which is the output directory, or empty.
//
// Returns error when the example fails.
func execute(ctx context.Context, directory string) error {
	if directory == "" {
		temporary, err := os.MkdirTemp("", "seagull-example-")
		if err != nil {
			return err
		}
		defer func() { _ = os.RemoveAll(temporary) }()
		directory = temporary
	}
	return run(ctx, os.DirFS("."), directory, os.Stdout)
}

output.txt

generated with the stock preset:
  bind_limits.go         // Code generated by sgl. DO NOT EDIT.
  models.go              // Code generated by sgl. DO NOT EDIT.
  parcels.sql.go         // Code generated by sgl. DO NOT EDIT.
  prepared.go            // Code generated by sgl. DO NOT EDIT.
  querier.go             // Code generated by sgl. DO NOT EDIT.
  query_names.go         // Code generated by sgl. DO NOT EDIT.
  runtime_helpers.go     // Code generated by sgl. DO NOT EDIT.
  slice_helpers.go       // Code generated by sgl. DO NOT EDIT.

db/queries/parcels.sql, rewritten with FormatDirectives:
-- acme.query(ListParcelsByStatus, many)
-- ?1 as acme.param(statuses, kind: slice)
SELECT id, recipient, status FROM parcels WHERE status IN (?) ORDER BY id;

-- acme.query(SearchParcels, many, dynamic: runtime)
SELECT id, recipient, status, weight_grams FROM parcels

no file of the acme generation mentions sgl; its helpers are
  ErrAcmeTooManyBindVariables
  ErrAcmeUnknownColumn
  ErrAcmeUnknownDirection
  ErrAcmeUnknownOperator
  acmeAllowedDirections
  acmeAllowedOperators
  acmeAt
  acmeBoxSlice
  acmeCastAfter
  acmeCastBefore
  acmeCastMiddle
  acmeCastPatterns
  acmeCastTypes
  acmeCheckBinds
  acmeCloseStatements
  acmeColumnExpressionRegex
  acmeEdit
  acmeEmptyExclusion
  acmeEmptyMembership
  acmeExtractColumnRoot
  acmeFilter
  acmeFilterCount
  acmeFilterInto
  acmeIsMultiOperator
  acmeIsUnaryOperator
  acmeLimitAfter
  acmeLimitBefore
  acmeList
  acmeListMark
  acmeMaxBindVariables
  acmeMaxColumnExpressionLength
  acmeMultiValueLen
  acmeNewOrder
  acmeNewPredicate
  acmeNormaliseDirection
  acmeOffsetAfter
  acmeOffsetBefore
  acmeOrder
  acmePagination
  acmePaginationCount
  acmePaginationInto
  acmePredicate
  acmeReflectSlice
  acmeResolveColumn
  acmeSimpleColumn
  acmeSplice
  acmeSplitCast
  acmeUnboundedLimit
  acmeWhereClose
  acmeWhereOpen
  acmeWriteBindPlaceholder

written with the acme preset, pruning files with the stock header:
  acme_query_names.go    // Code generated by acme-gen. DO NOT EDIT.
  bind_limits.go         // Code generated by acme-gen. DO NOT EDIT.
  models.go              // Code generated by acme-gen. DO NOT EDIT.
  parcels.sql.go         // Code generated by acme-gen. DO NOT EDIT.
  prepared.go            // Code generated by acme-gen. DO NOT EDIT.
  querier.go             // Code generated by acme-gen. DO NOT EDIT.
  runtime_helpers.go     // Code generated by acme-gen. DO NOT EDIT.
  slice_helpers.go       // Code generated by acme-gen. DO NOT EDIT.