Your own sgl

On this page

On SQLite:

  • it replaces the stock sqlite dialect and database/sql emitter by registering the same names, so sgl.yaml stays dialect: sqlite with the default driver
  • the dialect factory receives dialect_options from sgl.yaml as dialects.Options; max_parse_depth: 128 overrides this sgl's default of 64
  • the emitter factory receives go.prepared through cli.EmitterRequest; the stock emitter picks its driver profile from the dialect it is given
  • cli links no database driver; importing modernc.org/sqlite and WithDriver("sqlite", "sqlite") let migrate, seed and schema diff connect
  • the test runs generate --check and vet --strict through cli.MainContext with the same options

The stock sgl is a one-line program: it calls cli.Main from seagull.sh/seagull/cmd/sgl/cli and registers the database drivers it ships with. Your team can write that program too, and pass its own pieces in as options. The result is an sgl with every command, flag and diagnostic of the stock one, reading the same sgl.yaml, plus whatever you added: a dialect of your own, an emitter of your own, or a database driver the stock binary cannot link, such as one that needs cgo.

This example builds an sgl with three changes. Its SQLite parser has tighter limits by default, its database/sql emitter also writes the query_index.go from A custom emitter, and it links the pure-Go SQLite driver so that migrate, seed and schema diff can connect.

The program

The whole binary is one main package. main.go holds the options:

main.go

// Command 053_custom_sgl_binary is sgl with a team's own additions, built on
// seagull.sh/seagull/cmd/sgl/cli: SQLite with tighter parser limits by default, a
// database/sql emitter that also writes query_index.go, and the pure-Go SQLite driver for
// migrate, seed and schema diff.
//
// Build it with `go build -o sgl .` and use it as you would the stock sgl, or run it in
// place with a command: `go run . generate`, `go run . vet`, `go run . migrate up`. Like
// sgl itself, plain `go run .` with no command only prints the usage.
package main

import (
	"os"

	_ "modernc.org/sqlite"

	"seagull.sh/seagull/cmd/sgl/cli"
	"seagull.sh/seagull/sdk/dialect"
	"seagull.sh/seagull/sdk/dialect/dialect_registry"
	"seagull.sh/seagull/sdk/emit"
	"seagull.sh/seagull/sdk/emit/emit_driver_databasesql"
)

const (
	// sqlite is both the dialect name sgl.yaml selects and the name modernc.org/sqlite
	// registers its driver under.
	sqlite = "sqlite"

	// defaultMaxParseDepth is this sgl's expression nesting limit when sgl.yaml sets none.
	defaultMaxParseDepth = 64

	// defaultMaxTokensPerStatement is this sgl's statement size limit when sgl.yaml sets
	// none.
	defaultMaxTokensPerStatement = 20_000
)

func main() {
	os.Exit(cli.Main(os.Args[1:], options()...))
}

// options are what this sgl adds to the stock one. A name the stock sgl already knows,
// such as the sqlite dialect or the database/sql driver, is replaced.
//
// Returns []cli.Option which registers the dialect, the emitter and the driver.
func options() []cli.Option {
	return []cli.Option{
		cli.WithDialect(sqlite, strictSQLite),
		cli.WithEmitter("database/sql", indexedDatabaseSQL),
		cli.WithDriver(sqlite, sqlite),
	}
}

// strictSQLite builds the SQLite dialect for `dialect: sqlite`, with tighter parser
// limits than the stock defaults unless sgl.yaml's dialect_options sets them.
//
// Takes options (dialect_registry.Options) which holds the database's dialect_options.
//
// Returns dialect.Dialect which is the configured dialect.
// Returns error when an option does not apply to SQLite.
func strictSQLite(options dialect_registry.Options) (dialect.Dialect, error) {
	if options.MaxParseDepth == 0 {
		options.MaxParseDepth = defaultMaxParseDepth
	}
	if options.MaxTokensPerStatement == 0 {
		options.MaxTokensPerStatement = defaultMaxTokensPerStatement
	}
	return dialect_registry.New(sqlite, options)
}

// indexedDatabaseSQL builds the emitter for `go.driver: database/sql`: the stock
// database/sql emitter, honouring go.prepared, wrapped so it also writes query_index.go.
//
// Takes request (*cli.EmitterRequest) which describes the database's dialect and
// settings.
//
// Returns emit.EmitterPort which generates the package.
// Returns error which is always nil.
func indexedDatabaseSQL(request *cli.EmitterRequest) (emit.EmitterPort, error) {
	base := emit_driver_databasesql.New(emit_driver_databasesql.WithPrepared(request.Prepared))
	return indexEmitter{base: base}, nil
}

cli.Main runs a command line, os.Args[1:], and returns the exit status, which main hands to os.Exit. It cancels the run on the first interrupt and ends the process on the second, as the stock sgl does.

cli.WithDialect registers a dialect under the name sgl.yaml selects it by. Registering sqlite replaces the stock SQLite dialect, so projects keep writing dialect: sqlite and get this one. The factory receives the database's dialect_options from sgl.yaml as dialect_registry.Options. strictSQLite fills in its own limits only where sgl.yaml sets none, then builds the dialect with dialect_registry.New.

cli.WithEmitter registers an emitter under a go.driver value. database/sql is the default driver, so this replaces the emitter every project gets unless it asks for another. The factory receives a cli.EmitterRequest with the dialect's details and the go.prepared setting. indexedDatabaseSQL passes the go.prepared setting on to the stock emitter through emit_driver_databasesql.WithPrepared, and wraps it in the index emitter from emitter.go, the same wrapper A custom emitter explains.

cli.WithDriver names the database/sql driver to open for a dialect or a whole dialect family. The cli package links no driver itself, so the blank import of modernc.org/sqlite is what puts one in the binary, and WithDriver("sqlite", "sqlite") tells sgl to use it.

The project

The project's configuration is ordinary. It sets max_parse_depth, which overrides this sgl's default of 64, and a DSN for migrate:

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: tasks
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    dialect_options:
      max_parse_depth: 128
    migrate:
      dsn: file:tasks.db
    go:
      package: store
      out: internal/store

Built with go build -o sgl ., or run in place with go run . generate, this sgl writes the usual package and the index beside it:

internal/store/query_index.go

// Code generated by sgl. DO NOT EDIT.

package store

// QueryInfo describes one generated query method.
type QueryInfo struct {
	Name     string
	Method   string
	Command  string
	ReadOnly bool
}

// QueryIndex lists every query of the package in source order.
var QueryIndex = []QueryInfo{
	{Name: "CreateTask", Method: "CreateTask", Command: "one", ReadOnly: false},
	{Name: "ListTasks", Method: "ListTasks", Command: "many", ReadOnly: true},
	{Name: "CompleteTask", Method: "CompleteTask", Command: "execrows", ReadOnly: false},
}

What it prints

The example's test drives the binary's commands through cli.MainContext, with the same options and two more for testing: cli.WithDirectory runs it as if started in the example's directory, and cli.WithIO captures what it prints.

$ sgl generate --check
tasks: generated code is up to date
$ sgl vet --strict
0 error(s), 0 warning(s), 0 hint(s)
$ sgl dialects
NAME         FAMILY      PARAMETERS   RETURNING               MIGRATE  INTROSPECT  DRIVER
clickhouse   clickhouse  {name:Type}  no                      yes      no          none
cockroachdb  postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none
duckdb       duckdb      $1           INSERT, UPDATE, DELETE  yes      no          none
mariadb      mysql       ?            INSERT, DELETE          yes      no          none
mysql        mysql       ?            no                      yes      no          none
postgres     postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none
sqlite       sqlite      ?1           INSERT, UPDATE, DELETE  yes      yes         sqlite
timescaledb  postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none

generate --check finds the committed package up to date, query_index.go included. The stock sgl would report it stale and want to remove that file, since its own emitter does not write it. vet --strict is clean. In the dialects table, every other dialect is still there, but only sqlite has a driver, because it is the only one this binary links. The stock sgl links a driver for each family it can connect to.

Because it is ordinary Go, the team's sgl lives in its repository, builds with go build locally and in CI, and runs wherever the stock sgl did. The CLI reference lists the drivers the stock binary links, and the cli package documentation every option.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/embedding/053_custom_sgl_binary && go run . generate

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: tasks
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    dialect_options:
      max_parse_depth: 128
    migrate:
      dsn: file:tasks.db
    go:
      package: store
      out: internal/store

db/migrations/0001_init.up.sql

CREATE TABLE tasks (
    id    INTEGER PRIMARY KEY,
    title TEXT    NOT NULL,
    done  INTEGER NOT NULL DEFAULT 0
);

db/migrations/0001_init.down.sql

DROP TABLE tasks;

db/queries/tasks.sql

-- sgl.query(CreateTask, one)
INSERT INTO tasks (title) VALUES (?) RETURNING id, title, done;

-- sgl.query(ListTasks, many)
SELECT id, title, done FROM tasks ORDER BY id;

-- sgl.query(CompleteTask, execrows)
UPDATE tasks SET done = 1 WHERE id = ?;

emitter.go

package main

import (
	"context"
	"fmt"
	"go/format"
	"strings"

	"seagull.sh/seagull"
	"seagull.sh/seagull/sdk/dialect"
	"seagull.sh/seagull/sdk/emit"
	"seagull.sh/seagull/sdk/emit/emit_kit"
)

const (
	// indexFile is the name of the file the emitter adds.
	indexFile = "query_index.go"

	// infoType is the type the index declares for one query.
	infoType = "QueryInfo"

	// indexVariable is the variable the index declares.
	indexVariable = "QueryIndex"
)

// indexEmitter wraps another emitter and adds query_index.go, which lists every query of
// the package with its method, its command and whether it only reads. 052_custom_emitter
// explains it.
type indexEmitter struct {
	// base generates the package itself.
	base emit.EmitterPort
}

var (
	_ emit.EmitterPort = (*indexEmitter)(nil)

	_ emit.TypeDefaulterPort = (*indexEmitter)(nil)

	_ emit.ReserverPort = (*indexEmitter)(nil)

	_ emit.CheckerPort = (*indexEmitter)(nil)

	_ emit.NullElementBinderPort = (*indexEmitter)(nil)
)

// Emit generates the wrapped emitter's files and appends the index.
//
// Takes request (*emit.Request) which carries the analysed queries, the naming plan and
// the style.
//
// Returns []emit.GeneratedFile which holds the wrapped emitter's files and the index.
// Returns error when either cannot be generated.
func (e indexEmitter) Emit(ctx context.Context, request *emit.Request) ([]emit.GeneratedFile, error) {
	files, err := e.base.Emit(ctx, request)
	if err != nil {
		return nil, err
	}
	var source strings.Builder
	fmt.Fprintf(&source, "%s\n\npackage %s\n\n", request.Style.Header, request.Package)
	fmt.Fprintf(&source, "// %s describes one generated query method.\n", infoType)
	fmt.Fprintf(&source, "type %s struct {\n\tName string\n\tMethod string\n\tCommand string\n\tReadOnly bool\n}\n\n", infoType)
	fmt.Fprintf(&source, "// %s lists every query of the package in source order.\n", indexVariable)
	fmt.Fprintf(&source, "var %s = []%s{\n", indexVariable, infoType)
	for index, query := range request.Queries {
		fmt.Fprintf(&source, "\t{Name: %q, Method: %q, Command: %q, ReadOnly: %t},\n",
			query.Name, request.Naming.Queries[index].Method, query.Command, query.ReadOnly)
	}
	source.WriteString("}\n")
	formatted, err := format.Source([]byte(source.String()))
	if err != nil {
		return nil, fmt.Errorf("formatting %s: %w", indexFile, err)
	}
	return append(files, emit.GeneratedFile{Name: indexFile, Content: formatted}), nil
}

// DefaultTypes passes on the wrapped emitter's type defaults, which the wrapper would
// otherwise hide.
//
// Takes info (*dialect.Facts) which describes the dialect.
//
// Returns seagull.TypeSlots which holds the wrapped emitter's defaults, or none.
func (e indexEmitter) DefaultTypes(info *dialect.Facts) seagull.TypeSlots {
	if defaulter, ok := e.base.(emit.TypeDefaulterPort); ok {
		return defaulter.DefaultTypes(info)
	}
	return seagull.TypeSlots{}
}

// Reserve passes on the names the wrapped emitter's code uses and adds the index's, so
// the naming plan never chooses them for anything else.
//
// Takes info (*dialect.Facts) which describes the dialect.
// Takes style (*emit.Style) which holds the style of the generated code.
//
// Returns emit.Reservation which holds the wrapped emitter's names and the index's.
func (e indexEmitter) Reserve(info *dialect.Facts, style *emit.Style) emit.Reservation {
	reservation := emit_kit.ReservationFor(e.base, info, style)
	reservation.Declarations = append(reservation.Declarations, infoType, indexVariable)
	reservation.Files = append(reservation.Files, indexFile)
	return reservation
}

// Check passes on what the wrapped emitter refuses, such as go.prepared on a driver that
// cannot prepare the package's queries, which sgl reports as diagnostics.
//
// Takes request (*emit.Request) which is the request about to be emitted.
//
// Returns []emit.Problem which holds the wrapped emitter's problems, or none.
func (e indexEmitter) Check(request *emit.Request) []emit.Problem {
	if checker, ok := e.base.(emit.CheckerPort); ok {
		return checker.Check(request)
	}
	return nil
}

// BindsNullElements passes on whether the wrapped emitter's driver binds a NULL element
// of an array parameter. Without it the generator would take the wrapper for one that
// does, and give array parameters nullable elements on a driver that panics on them, such
// as DuckDB's.
//
// Takes info (*dialect.Facts) which describes the dialect.
//
// Returns bool which is the wrapped emitter's answer, or true when it gives none.
func (e indexEmitter) BindsNullElements(info *dialect.Facts) bool {
	if binder, ok := e.base.(emit.NullElementBinderPort); ok {
		return binder.BindsNullElements(info)
	}
	return true
}

main.go

// Command 053_custom_sgl_binary is sgl with a team's own additions, built on
// seagull.sh/seagull/cmd/sgl/cli: SQLite with tighter parser limits by default, a
// database/sql emitter that also writes query_index.go, and the pure-Go SQLite driver for
// migrate, seed and schema diff.
//
// Build it with `go build -o sgl .` and use it as you would the stock sgl, or run it in
// place with a command: `go run . generate`, `go run . vet`, `go run . migrate up`. Like
// sgl itself, plain `go run .` with no command only prints the usage.
package main

import (
	"os"

	_ "modernc.org/sqlite"

	"seagull.sh/seagull/cmd/sgl/cli"
	"seagull.sh/seagull/sdk/dialect"
	"seagull.sh/seagull/sdk/dialect/dialect_registry"
	"seagull.sh/seagull/sdk/emit"
	"seagull.sh/seagull/sdk/emit/emit_driver_databasesql"
)

const (
	// sqlite is both the dialect name sgl.yaml selects and the name modernc.org/sqlite
	// registers its driver under.
	sqlite = "sqlite"

	// defaultMaxParseDepth is this sgl's expression nesting limit when sgl.yaml sets none.
	defaultMaxParseDepth = 64

	// defaultMaxTokensPerStatement is this sgl's statement size limit when sgl.yaml sets
	// none.
	defaultMaxTokensPerStatement = 20_000
)

func main() {
	os.Exit(cli.Main(os.Args[1:], options()...))
}

// options are what this sgl adds to the stock one. A name the stock sgl already knows,
// such as the sqlite dialect or the database/sql driver, is replaced.
//
// Returns []cli.Option which registers the dialect, the emitter and the driver.
func options() []cli.Option {
	return []cli.Option{
		cli.WithDialect(sqlite, strictSQLite),
		cli.WithEmitter("database/sql", indexedDatabaseSQL),
		cli.WithDriver(sqlite, sqlite),
	}
}

// strictSQLite builds the SQLite dialect for `dialect: sqlite`, with tighter parser
// limits than the stock defaults unless sgl.yaml's dialect_options sets them.
//
// Takes options (dialect_registry.Options) which holds the database's dialect_options.
//
// Returns dialect.Dialect which is the configured dialect.
// Returns error when an option does not apply to SQLite.
func strictSQLite(options dialect_registry.Options) (dialect.Dialect, error) {
	if options.MaxParseDepth == 0 {
		options.MaxParseDepth = defaultMaxParseDepth
	}
	if options.MaxTokensPerStatement == 0 {
		options.MaxTokensPerStatement = defaultMaxTokensPerStatement
	}
	return dialect_registry.New(sqlite, options)
}

// indexedDatabaseSQL builds the emitter for `go.driver: database/sql`: the stock
// database/sql emitter, honouring go.prepared, wrapped so it also writes query_index.go.
//
// Takes request (*cli.EmitterRequest) which describes the database's dialect and
// settings.
//
// Returns emit.EmitterPort which generates the package.
// Returns error which is always nil.
func indexedDatabaseSQL(request *cli.EmitterRequest) (emit.EmitterPort, error) {
	base := emit_driver_databasesql.New(emit_driver_databasesql.WithPrepared(request.Prepared))
	return indexEmitter{base: base}, nil
}

internal/store/models.go

// Code generated by sgl. DO NOT EDIT.

package store

type Tasks struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

internal/store/querier.go

// Code generated by sgl. DO NOT EDIT.

package store

import (
	"context"
	"database/sql"
)

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type dbtxWrapper interface {
	WrapDBTX(inner any) any
}

type Queries struct {
	reader  DBTX
	writer  DBTX
	wrapper dbtxWrapper
}

func New(db DBTX) *Queries {
	wrapper, _ := db.(dbtxWrapper)
	return &Queries{reader: db, writer: db, wrapper: wrapper}
}

func NewWithReplica(writer DBTX, reader DBTX) *Queries {
	wrapper, _ := writer.(dbtxWrapper)
	return &Queries{reader: reader, writer: writer, wrapper: wrapper}
}

func (queries *Queries) WithTx(transaction *sql.Tx) *Queries {
	if queries == nil {
		return &Queries{reader: transaction, writer: transaction, wrapper: nil}
	}
	db := queries.wrapDBTX(transaction)
	return &Queries{reader: db, writer: db, wrapper: queries.wrapper}
}

func (queries *Queries) wrapDBTX(db DBTX) DBTX {
	if queries.wrapper == nil {
		return db
	}
	wrapped, ok := queries.wrapper.WrapDBTX(db).(DBTX)
	if !ok {
		return db
	}
	return wrapped
}

func (queries *Queries) RunInTx(ctx context.Context, db *sql.DB, fn func(*Queries) error) error {
	transaction, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer transaction.Rollback()
	if err = fn(queries.WithTx(transaction)); err != nil {
		return err
	}
	return transaction.Commit()
}

internal/store/query_index.go

// Code generated by sgl. DO NOT EDIT.

package store

// QueryInfo describes one generated query method.
type QueryInfo struct {
	Name     string
	Method   string
	Command  string
	ReadOnly bool
}

// QueryIndex lists every query of the package in source order.
var QueryIndex = []QueryInfo{
	{Name: "CreateTask", Method: "CreateTask", Command: "one", ReadOnly: false},
	{Name: "ListTasks", Method: "ListTasks", Command: "many", ReadOnly: true},
	{Name: "CompleteTask", Method: "CompleteTask", Command: "execrows", ReadOnly: false},
}

internal/store/tasks.sql.go

// Code generated by sgl. DO NOT EDIT.

package store

import "context"

const completeTaskSQL = `UPDATE tasks SET done = 1 WHERE id = ?1;`

func (queries *Queries) CompleteTask(ctx context.Context, id int64) (int64, error) {
	results, err := queries.writer.ExecContext(ctx, completeTaskSQL, id)
	if err != nil {
		return 0, err
	}
	return results.RowsAffected()
}

const createTaskSQL = `INSERT INTO tasks (title) VALUES (?1) RETURNING id, title, done;`

type CreateTaskRow struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

func (queries *Queries) CreateTask(ctx context.Context, title string) (CreateTaskRow, error) {
	var row CreateTaskRow
	err := queries.writer.QueryRowContext(ctx, createTaskSQL, title).Scan(&row.ID, &row.Title, &row.Done)
	if err != nil {
		return CreateTaskRow{}, err
	}
	return row, nil
}

const listTasksSQL = `SELECT id, title, done FROM tasks ORDER BY id;`

type ListTasksRow struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	Done  int64  `json:"done"`
}

func (queries *Queries) ListTasks(ctx context.Context) ([]ListTasksRow, error) {
	rows, err := queries.reader.QueryContext(ctx, listTasksSQL)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var results []ListTasksRow
	if rows.Next() {
		var row ListTasksRow
		scanArgs := []any{&row.ID, &row.Title, &row.Done}
		for more := true; more; more = rows.Next() {
			row = ListTasksRow{}
			if err := rows.Scan(scanArgs...); err != nil {
				return nil, err
			}
			results = append(results, row)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return results, nil
}

output.txt

$ sgl generate --check
tasks: generated code is up to date
$ sgl vet --strict
0 error(s), 0 warning(s), 0 hint(s)
$ sgl dialects
NAME         FAMILY      PARAMETERS   RETURNING               MIGRATE  INTROSPECT  DRIVER
clickhouse   clickhouse  {name:Type}  no                      yes      no          none
cockroachdb  postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none
duckdb       duckdb      $1           INSERT, UPDATE, DELETE  yes      no          none
mariadb      mysql       ?            INSERT, DELETE          yes      no          none
mysql        mysql       ?            no                      yes      no          none
postgres     postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none
sqlite       sqlite      ?1           INSERT, UPDATE, DELETE  yes      yes         sqlite
timescaledb  postgres    $1           INSERT, UPDATE, DELETE  yes      yes         none