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