querygen

package
v10.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: AGPL-3.0 Imports: 6 Imported by: 0

Documentation

Overview

Package querygen emits sqlc input for tables shaped the way this module's row conventions expect.

The conventions are already load-bearing elsewhere. filtering.QueryFilter is a window over created_at and last_updated_at, a cursor compared against id, and a flag deciding whether archived_at rows count. search/sync's Scanner wants a strictly ordered page of IDs and nothing else. database's soft delete is archived_at rather than DELETE. None of that was ever written down as SQL, so each consumer wrote the SQL themselves, once per table, and the conventions held exactly as long as everyone remembered them — which is to say they held until the first table where someone did not.

This package writes that SQL. It is a generator, not a runtime builder: the whole reason to hand queries to sqlc is that they are checked against the schema at build time and come back as typed Go, and a builder that assembles SQL at runtime gives both of those up. What comes out of here is text, to be written to a .sql file and fed to sqlc alongside the schema.

What a caller supplies

A table name and its column list, in the order the emitted SELECTs should list them. Everything else is read off the column set:

created_at present      → the created_after/created_before window
last_updated_at present → the updated_after/updated_before window
archived_at present     → soft delete, and the include_archived toggle
last_indexed_at present → the reindex scan search/sync reads through
id                      → required; the cursor and every query's key

A query whose column is absent is not emitted, and a predicate whose column is absent is not rendered. That is the point of deriving them: a table without last_updated_at cannot end up with an Update that sets it, and a table with archived_at cannot end up without an Archive.

Argument names

The emitted SQL binds sqlc arguments whose names are neither the Go field names nor the query-parameter names. All three spellings exist and none of them can be guessed from another, so they are written down here:

filtering.QueryFilter    URL parameter      sqlc argument
CreatedAfter             createdAfter       created_after
CreatedBefore            createdBefore      created_before
UpdatedAfter             updatedAfter       updated_after
UpdatedBefore            updatedBefore      updated_before
IncludeArchived          includeArchived    include_archived
Cursor                   cursor             cursor
MaxResponseSize          limit              result_limit

include_archived actually includes archived rows

A filtered list's WHERE clause is FilterConditions in its entirety, not an addendum bolted onto a WHERE the caller opened with archived_at IS NULL. The distinction is the difference between a working toggle and a decorative one: a query reading

WHERE t.archived_at IS NULL
  AND (NOT COALESCE(sqlc.narg(include_archived), false) OR t.archived_at IS NULL)

parses, runs, reports no error, and returns the same rows for either value of the flag, because the first predicate has already decided. Owning the whole clause is what makes that unrepresentable.

Postgres only

The emitted SQL uses COALESCE over sqlc.narg, an INTERVAL cast, a boolean cast, and COLLATE "C" — none of which port unchanged. database/dialect names three dialects and this package serves one of them. A second backend belongs here when there is a second backend to serve, not before: an abstraction shaped around one implementation and a guess is shaped around the guess.

Index

Examples

Constants

View Source
const (
	// IDColumn is the primary key, and also the pagination cursor. Both roles
	// require it to sort by creation time — an xid or a ULID, not a serial and
	// not a UUIDv4 — because a keyset walk over an id that does not sort that
	// way pages in an order nobody asked for.
	IDColumn = "id"
	// CreatedAtColumn carries the row's creation time and bounds the
	// created_after/created_before window.
	CreatedAtColumn = "created_at"
	// LastUpdatedAtColumn is NULL until the row is first updated, which is why
	// every predicate over it admits NULL explicitly.
	LastUpdatedAtColumn = "last_updated_at"
	// ArchivedAtColumn is the soft delete. Rows are archived rather than
	// deleted, so every read filters on it and no write removes a row.
	ArchivedAtColumn = "archived_at"
	// LastIndexedAtColumn records when a row was last written to a search
	// index. Its presence is what marks a table as one search/sync mirrors.
	LastIndexedAtColumn = "last_indexed_at"
	// BelongsToAccountColumn is the conventional owner of a tenant-scoped row.
	// It is a name, not a behavior: scoping queries by it is WithOwnership's
	// job, because whether a table's rows are readable across accounts is a
	// decision about that table and not something to infer from a column.
	BelongsToAccountColumn = "belongs_to_account"
)

The columns this module has opinions about. A table is free to hold any others it likes; these are the ones whose presence changes what gets emitted, and whose names are spelled here rather than in each generator so that a table calling its soft-delete column something else is a table this package does not claim to serve.

View Source
const (
	CursorArg          = "cursor"
	LimitArg           = "result_limit"
	IncludeArchivedArg = "include_archived"
	CreatedAfterArg    = "created_after"
	CreatedBeforeArg   = "created_before"
	UpdatedAfterArg    = "updated_after"
	UpdatedBeforeArg   = "updated_before"
)

The sqlc argument names the emitted queries bind. They are the SQL-side spelling of filtering.QueryFilter — see the package comment for the mapping between these, the struct fields, and the URL parameters.

View Source
const NowExpression = "NOW()"

NowExpression is how the emitted SQL asks for the current time.

The server's clock, never the application's. A row's created_at and a filter's created_after are compared against each other, so they have to come from the same clock; two application instances whose clocks differ by a second would otherwise write rows that a window excludes at random.

Variables

View Source
var ErrDuplicateQueryName = platformerrors.New("two standard queries share a name")

ErrDuplicateQueryName indicates two emitted queries sharing a name. sqlc turns a query name into a Go method name across a whole package, so a duplicate is a compile error in generated code, reported against a file nobody wrote.

View Source
var ErrMissingIDColumn = platformerrors.New("column set has no id column")

ErrMissingIDColumn indicates a column set without an id. Every query StandardCRUD emits keys on it, and the cursor walk orders by it, so there is nothing useful to emit for a table that has none.

Functions

func CursorCondition

func CursorCondition(table string) string

CursorCondition renders the keyset predicate: rows strictly after the cursor.

An absent cursor coalesces to the empty string rather than being handled by a second query, which is what keeps the first page and the fiftieth page the same statement. It works because id sorts by creation time and no id is empty.

func CursorLimitClause

func CursorLimitClause(table string) string

CursorLimitClause renders the ordering and page size a keyset walk needs.

The ORDER BY is not decoration. A cursor names a position in an order, so a paginated query without the matching ORDER BY returns rows in whatever order the planner found convenient, and the next page's cursor names a position in an order that no longer holds — pages that skip rows and repeat others, with nothing reporting an error.

func CursorPaginationFragment

func CursorPaginationFragment(table string) string

CursorPaginationFragment renders the cursor predicate and the ordering together, for a query that does its own filtering and only wants the keyset half.

The predicate arrives prefixed with AND, because the only place it belongs is the tail of a WHERE clause that already has one.

func FilterConditions

func FilterConditions(table string, columns []string, conditions ...string) string

FilterConditions renders a filtered list query's WHERE clause: the filtering.QueryFilter window over whichever of the convention columns the table has, then any conditions the caller adds, then the cursor predicate.

It is the whole clause, not an addendum. A caller that opens its own WHERE with archived_at IS NULL and appends this one gets a query where include_archived cannot do anything, since the first predicate has already excluded every row the flag would admit — and nothing about such a query looks wrong. Owning the clause is what keeps that from being expressible.

conditions are rendered verbatim, one per line. They are the caller's SQL: this package does not parse them and cannot vet them.

Example

The fragment builders are there for the queries a table needs beyond the standard set — a search, a scoped list — so that those agree with the standard ones about what a filter means.

package main

import (
	"fmt"

	"github.com/primandproper/platform-go/v10/database/querygen"
)

func main() {
	columns := []string{querygen.IDColumn, querygen.CreatedAtColumn, querygen.ArchivedAtColumn}

	fmt.Println(querygen.FilterConditions("things", columns, "things.name "+querygen.ILIKECondition("name_query")))

}
Output:
things.created_at > COALESCE(sqlc.narg(created_after), (SELECT NOW() - '999 years'::INTERVAL))
	AND things.created_at < COALESCE(sqlc.narg(created_before), (SELECT NOW() + '999 years'::INTERVAL))
	AND (COALESCE(sqlc.narg(include_archived), false)::boolean OR things.archived_at IS NULL)
	AND things.name ILIKE '%' || sqlc.arg(name_query)::text || '%'
	AND things.id > COALESCE(sqlc.narg(cursor), '')

func FilterCountSelect

func FilterCountSelect(table string, columns, joins []string, conditions ...string) string

FilterCountSelect renders the scalar subquery counting the rows the same filter matches, aliased filtered_count.

It is a subquery in the SELECT list rather than a second round trip because filtering.QueryFilteredResult wants the page and its counts together, and a count issued separately counts a table that has moved on since the page was read.

The cursor predicate is deliberately absent: filtered_count answers "how many rows match this filter", which does not change as the caller walks through them. Including it would count the rows remaining after the cursor, and a total that shrinks with every page is a progress bar that never fills.

Because the count rides on the rows, a page with no rows carries no count. A caller reporting counts for an empty page has to supply the zero itself, which is what filtering.NewQueryFilteredResult taking them as arguments allows.

func ForInsert

func ForInsert(columns []string, exceptions ...string) []string

ForInsert returns the columns an INSERT takes values for: everything but the database-owned ones, and anything else the caller names.

Order is preserved, because an INSERT's column list and its VALUES list are positional and have to be rendered from the same slice.

func ForUpdate

func ForUpdate(columns []string, exceptions ...string) []string

ForUpdate returns the columns an UPDATE assigns: ForInsert's set, less the id.

The id is excluded because an UPDATE keys on it. A SET that assigns the column the WHERE matches on is a row that changes its own identity mid-statement, which is legal SQL and never what anyone meant.

func ILIKECondition

func ILIKECondition(argument string) string

ILIKECondition renders a case-insensitive substring match against a bound argument, for a search query's own WHERE predicate.

The argument is bound and the wildcards are concatenated around it, rather than the caller passing '%term%' — a caller assembling the pattern is a caller who can forget to escape a literal '%' in a user's search term, which turns a search for "50%" into a search for everything.

func Qualify

func Qualify(table, column string) string

Qualify renders a column as table.column.

func QualifyAll

func QualifyAll(table string, columns []string) []string

QualifyAll renders every column as table.column, preserving order.

func ReindexScanQuery

func ReindexScanQuery(table string) string

ReindexScanQuery builds the keyset walk a search reindex reads its source through.

It returns IDs rather than rows on purpose. A Scanner and a Fetcher both have to produce the same document for the same row, and the cheapest way to guarantee that is to have one of them call the other: the scan names the next page of IDs and the fetch — the same one the change feed uses — turns them into documents. Selecting rows here would be a second row-to-document transform, and two transforms that are supposed to agree are two transforms that can drift.

The ordering is a byte comparison, COLLATE "C", not the database's default collation. search/sync requires ascending byte order because the pruning half of a reindex merges this stream against the index's own stream of IDs, and Postgres's en_US.UTF-8 sorts case-insensitively and ignores punctuation — a different order. Two ordered streams merged under disagreeing orders do not fail; they conclude that live documents are absent from the source and delete them. The Reindexer verifies the order it is given for the same reason.

func RenderFile

func RenderFile(queries []*Query) string

RenderFile assembles queries into the byte-exact contents of one sqlc input file: each rendered query, one blank line between them, one trailing newline.

Trailing whitespace is stripped from every line. That is not cosmetic. A generator is usually run twice — once to write the files and once, in CI, to check that the committed files still match — and the check is a byte comparison. Composing fragments into a statement is exactly the operation that leaves a stray space at the end of a line, so normalizing here is what keeps the check answering a question about SQL rather than about whitespace.

Example

RenderFile produces the bytes a .sql file holds, which is what a generator writes and what its --check mode compares against.

package main

import (
	"fmt"
	"strings"

	"github.com/primandproper/platform-go/v10/database/querygen"
)

func main() {
	queries := querygen.StandardCRUD("things", []string{querygen.IDColumn, querygen.ArchivedAtColumn},
		querygen.WithEntity("Thing", "Things"))

	file := querygen.RenderFile(queries)

	// Just the annotations, to keep the example short.
	for line := range strings.SplitSeq(file, "\n") {
		if strings.HasPrefix(line, "-- name:") {
			fmt.Println(line)
		}
	}

}
Output:
-- name: CreateThing :exec
-- name: GetThing :one
-- name: CheckThingExistence :one
-- name: ListThings :many
-- name: ArchiveThing :execrows

func TotalCountSelect

func TotalCountSelect(table string, columns, joins []string, conditions ...string) string

TotalCountSelect renders the scalar subquery counting the rows in scope regardless of the filter window, aliased total_count.

It applies the same archived handling as the filter — not an unconditional archived_at IS NULL — so that filtered_count can never exceed total_count. A pair of counts where the subset is larger than the set is the kind of number that gets noticed a week later by whoever is reconciling them.

Types

type JoinStatement

type JoinStatement struct {
	// JoinTarget is the table being joined in.
	JoinTarget string
	// TargetColumn is the column on JoinTarget the join matches.
	TargetColumn string
	// OnTable and OnColumn name the side already in the query.
	OnTable  string
	OnColumn string
}

JoinStatement is one join in a filtered count's FROM clause: the table being joined in, the column on it, and the already-present table and column it is matched against.

func (JoinStatement) String

func (j JoinStatement) String() string

String renders the join clause.

type Option

type Option func(*settings)

Option adjusts what StandardCRUD emits.

func WithDatabaseOwned

func WithDatabaseOwned(columns ...string) Option

WithDatabaseOwned names further columns the database fills in, beyond the four this package already knows about, excluding them from both INSERT and UPDATE.

func WithEntity

func WithEntity(singular, plural string) Option

WithEntity sets the singular and plural entity names the default query names are built from — WithEntity("valid instrument", "valid instruments") is written as WithEntity("ValidInstrument", "ValidInstruments").

Both default to the table name in upper camel case, which makes the default names correct but plural throughout: GetValidInstruments reads one row. The singular is not derived from the table, because deriving it means guessing whether the table is statuses, indices, or data, and a generator that guesses its callers' method names is a generator whose output has to be read to be trusted.

func WithImmutable

func WithImmutable(columns ...string) Option

WithImmutable names columns that are set once at insert and never assigned again — the row's creator, the parent it hangs off — excluding them from UPDATE only.

func WithOwnership

func WithOwnership(column string) Option

WithOwnership scopes the single-row queries and the list to an owner column — BelongsToAccountColumn, conventionally — so that every one of them takes the owner as an argument and a row belonging to someone else is not found rather than found and returned.

It is opt-in rather than inferred from the column set. Inferring it would mean that renaming a column, or building a table's generator from a column list that happens to omit one, silently widens who can read every row — the class of change that looks like nothing in a diff.

The column is also excluded from UPDATE, since a row that can reassign its own owner makes the scope on every other query a formality.

func WithQueryName

func WithQueryName(query StandardQuery, name string) Option

WithQueryName renames one query, for a consumer whose existing generated code spells it differently.

type Query

type Query struct {
	Content    string
	Annotation QueryAnnotation
}

Query is one annotated statement: the SQL, and the annotation that tells sqlc what to make of it.

func StandardCRUD

func StandardCRUD(table string, columns []string, opts ...Option) []*Query

StandardCRUD emits the queries every table following this module's row conventions needs: create, get, exists, list, update, archive, and the id scan a search reindex walks.

columns is the table's full column list, in the order the emitted SELECTs should list them, and it decides which queries appear. A table without archived_at gets no archive; one without last_indexed_at gets no reindex scan; one with nothing a caller may assign gets no create and no update. The alternative — emitting a query that references a column the table does not have — is SQL that fails at sqlc generate for a reason that reads as a schema problem.

It panics rather than returning an error, in the manner of regexp.MustCompile. Its arguments are string literals in a generator binary, so every way it can fail is a typo that a build should stop for, and there is no caller who could do anything with an error that the panic does not do more loudly. The panic value is an error wrapping dialect.ErrInvalidIdentifier, ErrMissingIDColumn, or ErrDuplicateQueryName.

Example

A table's generator names the table and its columns, and the standard set follows from them. What a consumer writes per table is the schema; what this package writes is the conventions.

package main

import (
	"fmt"

	"github.com/primandproper/platform-go/v10/database/querygen"
)

func main() {
	queries := querygen.StandardCRUD("webhooks", []string{
		querygen.IDColumn,
		"name",
		"url",
		querygen.BelongsToAccountColumn,
		querygen.CreatedAtColumn,
		querygen.LastUpdatedAtColumn,
		querygen.ArchivedAtColumn,
	},
		querygen.WithEntity("Webhook", "Webhooks"),
		querygen.WithOwnership(querygen.BelongsToAccountColumn),
	)

	for _, query := range queries {
		fmt.Printf("%s %s\n", query.Annotation.Name, query.Annotation.Type)
	}

}
Output:
CreateWebhook :exec
GetWebhook :one
CheckWebhookExistence :one
ListWebhooks :many
UpdateWebhook :execrows
ArchiveWebhook :execrows

func (*Query) Render

func (q *Query) Render() string

Render returns the query as sqlc reads it — the annotation comment, then the statement, terminated.

The terminator is appended only when the content lacks one, so a statement that already ends in a semicolon does not acquire a second, empty one.

type QueryAnnotation

type QueryAnnotation struct {
	Name string
	Type QueryType
}

QueryAnnotation is the `-- name: X :one` line sqlc reads above a query. Name becomes the generated Go method's name, so it has to be unique across every file in a sqlc package, not merely within its own file.

type QueryType

type QueryType string

QueryType is the sqlc annotation suffix declaring what a query returns. It is the half of the annotation sqlc reads to decide the generated method's signature, so a mismatch between it and the SQL is a compile error in the generated package rather than a runtime surprise.

const (
	// ExecType returns nothing. It is the annotation for an INSERT whose caller
	// does not need to know whether a row was written, because a failed one
	// raises rather than returning zero.
	ExecType QueryType = ":exec"
	// ExecRowsType returns the number of rows affected. It is the annotation for
	// the writes whose row count is the answer — an UPDATE or an archival that
	// matched nothing is how a caller learns the row was already gone.
	ExecRowsType QueryType = ":execrows"
	// ManyType returns a slice of rows.
	ManyType QueryType = ":many"
	// OneType returns exactly one row, and an error when there is none.
	OneType QueryType = ":one"
)

type StandardQuery

type StandardQuery int

StandardQuery names one of the queries StandardCRUD emits, for renaming it.

const (
	// CreateQuery inserts a row, taking a value for every column the database
	// does not own.
	CreateQuery StandardQuery = iota
	// GetQuery reads one unarchived row by id.
	GetQuery
	// ExistsQuery reports whether GetQuery would find a row, without reading it.
	ExistsQuery
	// ListQuery reads a filtered, cursor-paginated page along with the two
	// counts filtering.QueryFilteredResult carries.
	ListQuery
	// UpdateQuery assigns every mutable column and stamps last_updated_at.
	UpdateQuery
	// ArchiveQuery soft-deletes a row.
	ArchiveQuery
	// ScanIDsForReindexQuery walks ids in byte order for a search reindex.
	ScanIDsForReindexQuery
)

func (StandardQuery) String

func (s StandardQuery) String() string

String names the query, for error messages.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL