Vet and the catalogue from Go

On this page

On SQLite:

  • INTEGER PRIMARY KEY is bigint in the analysis, as the rowid, and other INTEGER columns integer; both map to int64
  • a column from the outer side of the LEFT JOIN is nullable even though tags.name is NOT NULL
  • error diagnostics arrive through Report.Err as a *DiagnosticsError; configuration and limit problems are errors from New, Generate and Vet

To generate code, Seagull first works out everything about each query: its parameters, its output columns, their types and nullability, whether it writes. Generate turns that into Go and keeps the analysis to itself. Vet hands the analysis back instead, and Catalogue hands back the schema the migrations build. With those two you can write your own checks, an editor integration, or documentation that is always in step with the SQL. This example is a small linter.

The queries

A notes table and a tags table, and four queries. The last one is the kind of mistake a team might want a rule for: an UPDATE with no WHERE, so it archives every note.

db/queries/notes.sql

-- sgl.query(GetNote, one)
SELECT id, title, body FROM notes WHERE id = ?;

-- sgl.query(ListNotesWithTags, many)
SELECT notes.id, notes.title, tags.name AS tag
FROM notes
LEFT JOIN tags ON tags.note_id = notes.id
WHERE notes.archived = 0
ORDER BY notes.id, tags.name;

-- sgl.query(ArchiveNote, execrows)
UPDATE notes SET archived = 1 WHERE id = ?;

-- sgl.query(ArchiveEverything, execrows)
UPDATE notes SET archived = 1;

The program

main.go passes the working directory and standard output to run, which does the work:

example.go

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"maps"
	"slices"
	"strings"
	"testing/fstest"

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

// maxFileBytes is a file size limit smaller than any file of the broken project.
const maxFileBytes = 32

// sentinels are the errors a program embedding the generator matches with errors.Is. Some
// wrap others: ErrNoSource and ErrInvalidPrefix are ErrInvalidConfig, and ErrFileTooLarge
// is ErrLimit. The generator reports SQL it cannot parse as a Q010 diagnostic; the
// resulting DiagnosticsError also matches ErrParse. ErrMalformedSQL identifies malformed
// SQL returned directly by the migration runner.
var sentinels = []struct {
	err  error
	name string
}{
	{err: seagull.ErrDiagnostics, name: "ErrDiagnostics"},
	{err: seagull.ErrInvalidConfig, name: "ErrInvalidConfig"},
	{err: seagull.ErrNoSource, name: "ErrNoSource"},
	{err: seagull.ErrInvalidPrefix, name: "ErrInvalidPrefix"},
	{err: seagull.ErrLimit, name: "ErrLimit"},
	{err: seagull.ErrFileTooLarge, name: "ErrFileTooLarge"},
}

// run is a small linter built on Seagull's analysis. It prints what Vet learnt about each
// query, applies one rule of its own, prints the tables of the schema catalogue, and
// shows which error sentinels match the ways a run can fail.
//
// Takes project (fs.FS) which holds db/migrations and db/queries.
// Takes output (io.Writer) which receives the report.
//
// Returns error when the project cannot be analysed.
func run(ctx context.Context, project fs.FS, output io.Writer) error {
	generator, err := seagull.New(dialect_engine_sqlite.New())
	if err != nil {
		return err
	}
	input := seagull.Input{
		Migrations:   project,
		Queries:      nil,
		Package:      "",
		MigrationDir: "db/migrations",
		QueryDir:     "db/queries",
	}

	report, err := generator.Vet(ctx, input)
	if err != nil {
		return err
	}
	if err := report.Err(); err != nil {
		return err
	}
	findings := 0
	for _, query := range report.Queries {
		describeQuery(output, query)
		if finding, found := lint(query); found {
			fmt.Fprintf(output, "  lint: %s\n", finding)
			findings++
		}
	}
	fmt.Fprintf(output, "%d queries, %d lint finding(s)\n\n", len(report.Queries), findings)

	catalogue, _, err := generator.Catalogue(ctx, input)
	if err != nil {
		return err
	}
	describeCatalogue(output, catalogue)

	return describeFailures(ctx, generator, output)
}

// lint is the linter's own rule: a query that writes but takes no parameter changes every
// row it touches, which is rarely what its author meant.
//
// Takes query (*emit.AnalysedQuery) which is an analysed query.
//
// Returns string which describes the finding.
// Returns bool which is true when the rule found something.
func lint(query *emit.AnalysedQuery) (string, bool) {
	if query.ReadOnly || len(query.Parameters) > 0 {
		return "", false
	}
	return fmt.Sprintf("%s writes without a parameter, so it changes every row", query.Name), true
}

// describeQuery prints a query's command, whether it only reads, its parameters and its
// output columns with their types and nullability.
//
// Takes output (io.Writer) which receives the description.
// Takes query (*emit.AnalysedQuery) which is an analysed query.
func describeQuery(output io.Writer, query *emit.AnalysedQuery) {
	access := "writes"
	if query.ReadOnly {
		access = "reads"
	}
	fmt.Fprintf(output, "%s (%s, %s) %s:%d\n", query.Name, query.Command, access, query.Filename, query.Line)
	for index := range query.Parameters {
		parameter := &query.Parameters[index]
		fmt.Fprintf(output, "  param  %-6s %-6s %s\n", parameter.Name, parameter.SQLType.EngineName, nullability(parameter.Nullable))
	}
	for index := range query.OutputColumns {
		column := &query.OutputColumns[index]
		source := "expression"
		if column.SourceTable != "" {
			source = column.SourceTable + "." + column.SourceColumn
		}
		fmt.Fprintf(output, "  column %-6s %-6s %-8s from %s\n", column.Name, column.SQLType.EngineName, nullability(column.Nullable), source)
	}
}

// describeCatalogue prints every table of the schema the migrations build.
//
// Takes output (io.Writer) which receives the description.
// Takes catalogue (*dialect.Catalogue) which is the schema.
func describeCatalogue(output io.Writer, catalogue *dialect.Catalogue) {
	schema := catalogue.Schemas[catalogue.DefaultSchema]
	for _, name := range slices.Sorted(maps.Keys(schema.Tables)) {
		table := schema.Tables[name]
		columns := make([]string, 0, len(table.Columns))
		for index := range table.Columns {
			column := &table.Columns[index]
			columns = append(columns, fmt.Sprintf("%s %s %s", column.Name, column.SQLType.EngineName, nullability(column.Nullable)))
		}
		fmt.Fprintf(output, "table %s.%s (primary key %s)\n  %s\n", schema.Name, table.Name,
			strings.Join(table.PrimaryKey, ", "), strings.Join(columns, "\n  "))
	}
	fmt.Fprintln(output)
}

// describeFailures provokes each way a run can fail and prints the sentinels the error
// matches: error diagnostics come back from Result.Err and Report.Err, the rest from New,
// Generate and Vet themselves.
//
// Takes generator (*seagull.Generator) which is a working generator.
// Takes output (io.Writer) which receives the description.
//
// Returns error when a run that should fail succeeds.
func describeFailures(ctx context.Context, generator *seagull.Generator, output io.Writer) error {
	broken := fstest.MapFS{
		"migrations/0001_init.up.sql": {Data: []byte("CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL);")},
		"queries/notes.sql":           {Data: []byte("-- sgl.query(GetNote, one, readonly: maybe)\nSELECT id, title FROM notes WHERE id = ?;\n")},
	}
	brokenInput := seagull.Input{Migrations: broken, Queries: nil, Package: "store", MigrationDir: "migrations", QueryDir: "queries"}

	report, err := generator.Vet(ctx, brokenInput)
	if err != nil {
		return err
	}
	diagnosticsErr := report.Err()
	if diagnostics, ok := errors.AsType[*seagull.DiagnosticsError](diagnosticsErr); ok {
		fmt.Fprintln(output, "Vet of a query with readonly: maybe")
		for _, diagnostic := range diagnostics.Diagnostics {
			fmt.Fprintf(output, "  %s:%d:%d: %s[%s]: %s\n", diagnostic.Filename, diagnostic.Line, diagnostic.Column,
				diagnostic.Severity, diagnostic.Code, diagnostic.Message)
		}
	}

	_, noDialectErr := seagull.New(dialect.Dialect{})

	badPackage := brokenInput
	badPackage.Package = "my-store"
	_, badPackageErr := generator.Generate(ctx, badPackage)

	_, badPrefixErr := seagull.New(dialect_engine_sqlite.New(), seagull.WithDirectivePrefixes("Acme"))

	limited, err := seagull.New(dialect_engine_sqlite.New(), seagull.WithMaxFileBytes(maxFileBytes))
	if err != nil {
		return err
	}
	_, tooLargeErr := limited.Vet(ctx, brokenInput)

	failures := []struct {
		err  error
		name string
	}{
		{err: diagnosticsErr, name: "Report.Err with an error diagnostic"},
		{err: noDialectErr, name: "New without a dialect"},
		{err: badPrefixErr, name: "New with the prefix Acme"},
		{err: badPackageErr, name: "Generate into package my-store"},
		{err: tooLargeErr, name: fmt.Sprintf("Vet with WithMaxFileBytes(%d)", maxFileBytes)},
	}
	for _, failure := range failures {
		if failure.err == nil {
			return fmt.Errorf("%s did not fail", failure.name)
		}
		var matched []string
		for _, sentinel := range sentinels {
			if errors.Is(failure.err, sentinel.err) {
				matched = append(matched, sentinel.name)
			}
		}
		fmt.Fprintf(output, "%s: %s\n", failure.name, strings.Join(matched, ", "))
	}
	return nil
}

// nullability names whether a value can be NULL.
//
// Takes nullable (bool) which is true when it can.
//
// Returns string which is null or not null.
func nullability(nullable bool) string {
	if nullable {
		return "null"
	}
	return "not null"
}

Vet takes the same seagull.Input as Generate, but writes nothing, so it needs no package name. Its Report has two fields. Diagnostics holds every diagnostic, as Generate's result does, and Report.Err() is non-nil when any is an error. Queries holds one *emit.AnalysedQuery per query, in file order.

An analysed query is fully typed. The program reads its Name, Command, Filename and Line, and ReadOnly, which is true when the query only reads. Each of its Parameters and OutputColumns has a name, a SQLType and Nullable, and an output column that comes straight from a table carries its SourceTable and SourceColumn. The sdk/emit documentation lists the rest, including the directives' settings.

The rule is a few lines of ordinary Go: a query that writes and takes no parameter changes every row it touches. Any rule you can express over those fields works the same way, such as a naming convention for the queries that write.

Catalogue stops before the queries and returns the schema: a map of schemas, each with its tables, each table with its columns and primary key. It also returns the diagnostics found while replaying the migrations, which this program ignores.

Failures. The last part of the program breaks things on purpose to show how each kind of failure reaches you. Problems in the SQL are diagnostics: Report.Err() returns a *seagull.DiagnosticsError that holds them, which errors.AsType unwraps. Everything else is an error returned by New, Generate or Vet themselves, and each wraps sentinels you can test with errors.Is. Some sentinels wrap others, so a check for ErrInvalidConfig also catches ErrNoSource and ErrInvalidPrefix, and one for ErrLimit catches ErrFileTooLarge.

What it prints

GetNote (one, reads) notes.sql:1
  param  id     bigint not null
  column id     bigint not null from notes.id
  column title  text   not null from notes.title
  column body   text   null     from notes.body
ListNotesWithTags (many, reads) notes.sql:4
  column id     bigint not null from notes.id
  column title  text   not null from notes.title
  column tag    text   null     from tags.name
ArchiveNote (execrows, writes) notes.sql:11
  param  id     bigint not null
ArchiveEverything (execrows, writes) notes.sql:14
  lint: ArchiveEverything writes without a parameter, so it changes every row
4 queries, 1 lint finding(s)

table main.notes (primary key id)
  id bigint not null
  title text not null
  body text null
  archived integer not null
table main.tags (primary key id)
  id bigint not null
  note_id integer not null
  name text not null

Vet of a query with readonly: maybe
  notes.sql:1:38: error[Q028]: invalid value "maybe" for keyword argument "readonly"; allowed: true, false
Report.Err with an error diagnostic: ErrDiagnostics
New without a dialect: ErrInvalidConfig, ErrNoSource
New with the prefix Acme: ErrInvalidConfig, ErrInvalidPrefix
Generate into package my-store: ErrInvalidConfig
Vet with WithMaxFileBytes(32): ErrLimit, ErrFileTooLarge

Each query's analysis comes first. ListNotesWithTags shows nullability at work: tags.name is NOT NULL, but the LEFT JOIN gives a note with no tags a NULL tag, so the column is null. The rule flags ArchiveEverything and nothing else, because ArchiveNote takes an id.

The catalogue lists both tables as the migrations left them.

The failures, in order: a query header with readonly: maybe is an error diagnostic, Q028, so it arrives through Report.Err and matches ErrDiagnostics. A generator without a dialect matches ErrNoSource. Acme is not a valid directive prefix, which must start with a lower-case letter and hold only lower-case letters, digits and underscores, so it matches ErrInvalidPrefix. my-store is not a Go package name, which is invalid configuration. A file larger than WithMaxFileBytes allows matches ErrFileTooLarge. These last four are errors, not diagnostics: the run stops instead of reporting on the SQL.

Embedding Seagull has the full table of sentinels, and sgl describe prints the same analysis for one query from the command line.

Run it locally

From the directory you cloned the Seagull repository into:

cd seagull/examples/embedding && go run ./047_library_vet_and_catalogue

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

cd seagull/examples/embedding && go test ./047_library_vet_and_catalogue

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: notes
    dialect: sqlite
    migrations: db/migrations
    queries: db/queries
    go:
      package: store
      out: internal/store

db/migrations/0001_init.up.sql

CREATE TABLE notes (
    id       INTEGER PRIMARY KEY,
    title    TEXT    NOT NULL,
    body     TEXT,
    archived INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE tags (
    id      INTEGER PRIMARY KEY,
    note_id INTEGER NOT NULL REFERENCES notes (id),
    name    TEXT    NOT NULL
);

db/migrations/0001_init.down.sql

DROP TABLE tags;
DROP TABLE notes;

db/queries/notes.sql

-- sgl.query(GetNote, one)
SELECT id, title, body FROM notes WHERE id = ?;

-- sgl.query(ListNotesWithTags, many)
SELECT notes.id, notes.title, tags.name AS tag
FROM notes
LEFT JOIN tags ON tags.note_id = notes.id
WHERE notes.archived = 0
ORDER BY notes.id, tags.name;

-- sgl.query(ArchiveNote, execrows)
UPDATE notes SET archived = 1 WHERE id = ?;

-- sgl.query(ArchiveEverything, execrows)
UPDATE notes SET archived = 1;

example.go

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"maps"
	"slices"
	"strings"
	"testing/fstest"

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

// maxFileBytes is a file size limit smaller than any file of the broken project.
const maxFileBytes = 32

// sentinels are the errors a program embedding the generator matches with errors.Is. Some
// wrap others: ErrNoSource and ErrInvalidPrefix are ErrInvalidConfig, and ErrFileTooLarge
// is ErrLimit. The generator reports SQL it cannot parse as a Q010 diagnostic; the
// resulting DiagnosticsError also matches ErrParse. ErrMalformedSQL identifies malformed
// SQL returned directly by the migration runner.
var sentinels = []struct {
	err  error
	name string
}{
	{err: seagull.ErrDiagnostics, name: "ErrDiagnostics"},
	{err: seagull.ErrInvalidConfig, name: "ErrInvalidConfig"},
	{err: seagull.ErrNoSource, name: "ErrNoSource"},
	{err: seagull.ErrInvalidPrefix, name: "ErrInvalidPrefix"},
	{err: seagull.ErrLimit, name: "ErrLimit"},
	{err: seagull.ErrFileTooLarge, name: "ErrFileTooLarge"},
}

// run is a small linter built on Seagull's analysis. It prints what Vet learnt about each
// query, applies one rule of its own, prints the tables of the schema catalogue, and
// shows which error sentinels match the ways a run can fail.
//
// Takes project (fs.FS) which holds db/migrations and db/queries.
// Takes output (io.Writer) which receives the report.
//
// Returns error when the project cannot be analysed.
func run(ctx context.Context, project fs.FS, output io.Writer) error {
	generator, err := seagull.New(dialect_engine_sqlite.New())
	if err != nil {
		return err
	}
	input := seagull.Input{
		Migrations:   project,
		Queries:      nil,
		Package:      "",
		MigrationDir: "db/migrations",
		QueryDir:     "db/queries",
	}

	report, err := generator.Vet(ctx, input)
	if err != nil {
		return err
	}
	if err := report.Err(); err != nil {
		return err
	}
	findings := 0
	for _, query := range report.Queries {
		describeQuery(output, query)
		if finding, found := lint(query); found {
			fmt.Fprintf(output, "  lint: %s\n", finding)
			findings++
		}
	}
	fmt.Fprintf(output, "%d queries, %d lint finding(s)\n\n", len(report.Queries), findings)

	catalogue, _, err := generator.Catalogue(ctx, input)
	if err != nil {
		return err
	}
	describeCatalogue(output, catalogue)

	return describeFailures(ctx, generator, output)
}

// lint is the linter's own rule: a query that writes but takes no parameter changes every
// row it touches, which is rarely what its author meant.
//
// Takes query (*emit.AnalysedQuery) which is an analysed query.
//
// Returns string which describes the finding.
// Returns bool which is true when the rule found something.
func lint(query *emit.AnalysedQuery) (string, bool) {
	if query.ReadOnly || len(query.Parameters) > 0 {
		return "", false
	}
	return fmt.Sprintf("%s writes without a parameter, so it changes every row", query.Name), true
}

// describeQuery prints a query's command, whether it only reads, its parameters and its
// output columns with their types and nullability.
//
// Takes output (io.Writer) which receives the description.
// Takes query (*emit.AnalysedQuery) which is an analysed query.
func describeQuery(output io.Writer, query *emit.AnalysedQuery) {
	access := "writes"
	if query.ReadOnly {
		access = "reads"
	}
	fmt.Fprintf(output, "%s (%s, %s) %s:%d\n", query.Name, query.Command, access, query.Filename, query.Line)
	for index := range query.Parameters {
		parameter := &query.Parameters[index]
		fmt.Fprintf(output, "  param  %-6s %-6s %s\n", parameter.Name, parameter.SQLType.EngineName, nullability(parameter.Nullable))
	}
	for index := range query.OutputColumns {
		column := &query.OutputColumns[index]
		source := "expression"
		if column.SourceTable != "" {
			source = column.SourceTable + "." + column.SourceColumn
		}
		fmt.Fprintf(output, "  column %-6s %-6s %-8s from %s\n", column.Name, column.SQLType.EngineName, nullability(column.Nullable), source)
	}
}

// describeCatalogue prints every table of the schema the migrations build.
//
// Takes output (io.Writer) which receives the description.
// Takes catalogue (*dialect.Catalogue) which is the schema.
func describeCatalogue(output io.Writer, catalogue *dialect.Catalogue) {
	schema := catalogue.Schemas[catalogue.DefaultSchema]
	for _, name := range slices.Sorted(maps.Keys(schema.Tables)) {
		table := schema.Tables[name]
		columns := make([]string, 0, len(table.Columns))
		for index := range table.Columns {
			column := &table.Columns[index]
			columns = append(columns, fmt.Sprintf("%s %s %s", column.Name, column.SQLType.EngineName, nullability(column.Nullable)))
		}
		fmt.Fprintf(output, "table %s.%s (primary key %s)\n  %s\n", schema.Name, table.Name,
			strings.Join(table.PrimaryKey, ", "), strings.Join(columns, "\n  "))
	}
	fmt.Fprintln(output)
}

// describeFailures provokes each way a run can fail and prints the sentinels the error
// matches: error diagnostics come back from Result.Err and Report.Err, the rest from New,
// Generate and Vet themselves.
//
// Takes generator (*seagull.Generator) which is a working generator.
// Takes output (io.Writer) which receives the description.
//
// Returns error when a run that should fail succeeds.
func describeFailures(ctx context.Context, generator *seagull.Generator, output io.Writer) error {
	broken := fstest.MapFS{
		"migrations/0001_init.up.sql": {Data: []byte("CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL);")},
		"queries/notes.sql":           {Data: []byte("-- sgl.query(GetNote, one, readonly: maybe)\nSELECT id, title FROM notes WHERE id = ?;\n")},
	}
	brokenInput := seagull.Input{Migrations: broken, Queries: nil, Package: "store", MigrationDir: "migrations", QueryDir: "queries"}

	report, err := generator.Vet(ctx, brokenInput)
	if err != nil {
		return err
	}
	diagnosticsErr := report.Err()
	if diagnostics, ok := errors.AsType[*seagull.DiagnosticsError](diagnosticsErr); ok {
		fmt.Fprintln(output, "Vet of a query with readonly: maybe")
		for _, diagnostic := range diagnostics.Diagnostics {
			fmt.Fprintf(output, "  %s:%d:%d: %s[%s]: %s\n", diagnostic.Filename, diagnostic.Line, diagnostic.Column,
				diagnostic.Severity, diagnostic.Code, diagnostic.Message)
		}
	}

	_, noDialectErr := seagull.New(dialect.Dialect{})

	badPackage := brokenInput
	badPackage.Package = "my-store"
	_, badPackageErr := generator.Generate(ctx, badPackage)

	_, badPrefixErr := seagull.New(dialect_engine_sqlite.New(), seagull.WithDirectivePrefixes("Acme"))

	limited, err := seagull.New(dialect_engine_sqlite.New(), seagull.WithMaxFileBytes(maxFileBytes))
	if err != nil {
		return err
	}
	_, tooLargeErr := limited.Vet(ctx, brokenInput)

	failures := []struct {
		err  error
		name string
	}{
		{err: diagnosticsErr, name: "Report.Err with an error diagnostic"},
		{err: noDialectErr, name: "New without a dialect"},
		{err: badPrefixErr, name: "New with the prefix Acme"},
		{err: badPackageErr, name: "Generate into package my-store"},
		{err: tooLargeErr, name: fmt.Sprintf("Vet with WithMaxFileBytes(%d)", maxFileBytes)},
	}
	for _, failure := range failures {
		if failure.err == nil {
			return fmt.Errorf("%s did not fail", failure.name)
		}
		var matched []string
		for _, sentinel := range sentinels {
			if errors.Is(failure.err, sentinel.err) {
				matched = append(matched, sentinel.name)
			}
		}
		fmt.Fprintf(output, "%s: %s\n", failure.name, strings.Join(matched, ", "))
	}
	return nil
}

// nullability names whether a value can be NULL.
//
// Takes nullable (bool) which is true when it can.
//
// Returns string which is null or not null.
func nullability(nullable bool) string {
	if nullable {
		return "null"
	}
	return "not null"
}

main.go

// Command 047_library_vet_and_catalogue is a small linter built on Seagull's analysis: it
// vets this example's queries from Go, prints what it learnt about each one, applies a
// rule of its own and prints the schema catalogue.
//
// Run it from this directory with `go run .`.
package main

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

func main() {
	if err := run(context.Background(), os.DirFS("."), os.Stdout); err != nil {
		log.Fatal(err)
	}
}

output.txt

GetNote (one, reads) notes.sql:1
  param  id     bigint not null
  column id     bigint not null from notes.id
  column title  text   not null from notes.title
  column body   text   null     from notes.body
ListNotesWithTags (many, reads) notes.sql:4
  column id     bigint not null from notes.id
  column title  text   not null from notes.title
  column tag    text   null     from tags.name
ArchiveNote (execrows, writes) notes.sql:11
  param  id     bigint not null
ArchiveEverything (execrows, writes) notes.sql:14
  lint: ArchiveEverything writes without a parameter, so it changes every row
4 queries, 1 lint finding(s)

table main.notes (primary key id)
  id bigint not null
  title text not null
  body text null
  archived integer not null
table main.tags (primary key id)
  id bigint not null
  note_id integer not null
  name text not null

Vet of a query with readonly: maybe
  notes.sql:1:38: error[Q028]: invalid value "maybe" for keyword argument "readonly"; allowed: true, false
Report.Err with an error diagnostic: ErrDiagnostics
New without a dialect: ErrInvalidConfig, ErrNoSource
New with the prefix Acme: ErrInvalidConfig, ErrInvalidPrefix
Generate into package my-store: ErrInvalidConfig
Vet with WithMaxFileBytes(32): ErrLimit, ErrFileTooLarge