querygen

package
v13.0.0 Latest Latest
Warning

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

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

Documentation

Overview

Package querygen emits sqlc input for tables shaped the way this module's row conventions expect, in the dialect of whichever of the three databases this module supports will run it.

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. Its first consumer is sqlc: the whole reason to hand queries to a generator is that they are checked against the schema at build time and come back as typed Go, so what comes out of Generator.StandardCRUD and the fragment methods is text, to be written to a .sql file and fed to sqlc alongside the schema. Nothing here assembles a query per request, and none of it reads a schema.

There is one consumer, and it is that pipeline: render the corpus, check it with sqlc, execute the querier sqlc-gen-unison generates from it. Nothing here renders a statement for a driver, and a store executing SQL this package never emitted is a store outside the guarantee — see "Porting a store onto this package" below.

What a caller supplies

A dialect, through For, which returns the Generator every emitter hangs off:

queries := querygen.For(dialect.Postgres).StandardCRUD("widgets", columns)

Then 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, and
                          the bulk stamp that maintains it
id                      → required by StandardCRUD; the cursor its list
                          pages by, 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.

last_indexed_at is the one that took two rounds to get right. Its presence has always decided the reindex scan, and the column has always been database-owned — excluded from the create and the update, so no caller can supply it. What was missing was anything that wrote it: the scan walked a column the convention forbade everyone from maintaining. MarkXAsIndexed is that write, emitted from the same column list as the scan, and a searchsync.Syncer flushes ids into it through searchsync.NewStampBuffer. The column, the query that reads it, and the write that maintains it are one feature rather than three-quarters of one.

WithOmitted subtracts from that set, for a table whose rows are not addressable the way it assumes — a child row written with its parent and never read on its own. It cannot add: what comes out stays a subset of what the columns justify, so the properties above survive a caller who reaches for it.

Two things a column list cannot say are said with options rather than guessed at. WithNullable names the columns a write may set to NULL, which lives in the schema this package never reads; WithDatabaseOwned and WithImmutable name the columns a caller may not assign, which lives in the application. Guessing either produces SQL that generates, compiles, and is wrong at runtime.

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             page_cursor
MaxResponseSize          limit              result_limit

Two statements bind an argument that is not a filter field at all: ids, a whole set bound at once — the rows the bulk stamp marks as indexed, and the keys a batched read answers for. A SetKey can name it something else where the set is not of ids.

One filter field binds nothing, and its absence from that table is the point. SortBy names a direction, and a direction is which way the ORDER BY runs and which way the cursor comparison points — statement text on all three servers, with no expression that takes a bound value and orders by it. So a paged list is emitted twice, under a name and DescendingName of it, and what a store does with SortBy is choose between them. See Direction and filtering.QueryFilter.SortsDescending, which is the one reading of that field there is.

The keyset position is page_cursor rather than cursor because CURSOR is a reserved word in MySQL — see filtering's own constants for why the name moved rather than the dialect being special-cased. MySQL is also the one dialect whose page size is bound through no name at all: its grammar takes a bare placeholder after LIMIT, so the emitted SQL spells the marker directly and the generated parameter is still named for result_limit — see identity's unison.yaml, where the name converges.

The keyed variants

Generator.StandardCRUD emits the set a conventional table gets: keyed on the row's own id and, where the caller named one, on an ownership column. A store's corpus is that set plus the statements its own reads need — a get keyed on a natural key, a list keyed on a reference, an update guarded by the value it is replacing, a read that projects one column.

Generator.InsertQuery, Generator.GetQuery, Generator.ReadQuery, Generator.ExistsQuery, Generator.ListQueries, Generator.UpdateQuery and Generator.ArchiveQuery render those, each named and annotated for a query file:

list := querygen.For(dialect.Postgres).ListQueries(
	"ListInvitationsByFromUser", "identity_invitations", columns,
	querygen.Match{Column: "scope"},
	querygen.Match{Column: "from_user"})

Each calls the statement function StandardCRUD calls, with the matches where WithOwnership's column goes, so a variant is the standard statement with more predicates rather than a second rendering of one. The filter window, the archived toggle, the cursor and the two counts are the same code path: a keyed read filters exactly as an unkeyed one does because there is nothing that could make it not.

The list is the one of them that is plural, because a paged list is two statements: ListInvitationsByFromUser and ListInvitationsByFromUserDescending, identical but for the cursor comparison and the ORDER BY. Emitting the pair from one call is what keeps a corpus from carrying only the direction somebody happened to think of — a store answering sortBy=desc with an ascending page is not a failure any test of the ascending statement can see.

Match is the predicate — a tenancy scope, an owner, the reference a child row hangs off — and it is a column name rather than finished SQL, because the statements it lands in render it more than once. A list carries its predicates in the SELECT and again in each of the two count subqueries beside it; a caller handing over finished SQL would have to know how many times its argument was about to appear. A Match can also exclude rather than include, for the read looking for another row like this one.

Generator.ReadQuery is the one the standard get cannot express: a Read says what the SELECT lists and — where the key admits more than one row — the column whose order decides which one answers. The column list stays the table's shape, which is what the id and archived predicates are derived from, so a table carrying an id it does not key on leaves the column out of that list and names it in Read.Projection.

The recursive one

Generator.ClosureQuery is the only shape here that is not a statement over a fixed number of rows. It seeds from a bound set, walks an Edge — a mapping table read in one direction — to whatever depth the data has, and reads through a second edge into the table the answer comes out of:

resolve := querygen.For(dialect.Postgres).ClosureQuery(
	"ResolvePermissionsForRoles", "authz_roles", roleColumns,
	&querygen.Closure{
		Alias:      "role_closure",
		Walk:       querygen.Edge{Table: "authz_role_hierarchy", From: "child_role_id", To: "parent_role_id"},
		Reach:      querygen.Edge{Table: "authz_role_permissions", From: "role_id", To: "permission_id"},
		Table:      "authz_permissions",
		Columns:    permissionColumns,
		Projection: []string{"name"},
	},
	querygen.SetKey{Column: "name", Arg: "role_names"})

It is a shape rather than a statement a consumer writes out, which is the opposite ruling from the one operations and saga got, and the reason is that its two properties are the whole of its correctness and neither is visible in a diff of the SQL. UNION rather than UNION ALL is what makes it terminate on a cycle a hand-edited table can hold, and the archived predicate at every join rather than only at the seed is what stops an archived intermediate row from going on granting what it reached. Both are rendered unconditionally, so a corpus holding one and not the other is not something anybody can write — where an authored statement is one where a reviewer has to notice.

The archived predicates come off the two column lists exactly as they do everywhere else here. The edges carry none, and cannot: a mapping table has no column list, because an edge is live exactly when the rows at both of its ends are.

Guarded writes

The update is not only the conventional whole-row one. It assigns the columns it is handed, so a store's field-specific writes — the password and the stamp that goes with it, a status and its explanation, a verification token — are that same statement with a shorter SET list, and last_updated_at stamps by convention in every one of them.

What turns a field-specific write into a safe one is a predicate naming the value the row must still hold:

update := querygen.For(dialect.Postgres).UpdateQuery("TransferAccount",
	"accounts", columns, []string{"owner_user_id"}, nil,
	querygen.Match{Column: "scope"},
	querygen.Match{Column: "owner_user_id", Arg: "current_owner_user_id"})

Two concurrent transfers there cannot both succeed: the second finds the owner already moved, matches nothing, and its row count says so. That is the whole mechanism, and it needs the guard and the assignment to be two arguments — Match.Arg is what separates them, since both halves are the same column and one name would set it to the value it was requiring it to already hold. The statement is annotated :execrows for that reason: the count is the answer.

What a predicate compares against

Not every guard is an equality against a value the caller has. A write that must happen exactly once guards on the stamp recording that it already did, and a caller has no value to bind for "has not happened yet"; a token is spent while it is still live, and the value that decides is the server's clock. Match.Against names what the column is compared against, and the closed set of answers is Comparand:

verify := querygen.For(dialect.Postgres).UpdateQuery(
	"MarkUserTwoFactorSecretVerified", "users", columns,
	[]string{"two_factor_secret_verified_at"}, nullable,
	querygen.Match{Column: "scope"},
	querygen.Match{Column: "two_factor_secret", Against: querygen.EmptyString, Exclude: true},
	querygen.Match{Column: "two_factor_secret_verified_at", Against: querygen.NoValue})

A secret that exists and has not been proven — and a replayed verification matches nothing, writes nothing, and reports the zero rows its caller reads as "not there" rather than moving the timestamp forward.

Comparand is a closed set. BoundArgument is the zero value and the equality every keyed read wants. NoValue is IS NULL, which is how this module records that something has not happened yet — an unredeemed token, an unproven secret, a key not yet shredded. EmptyString is the sentinel a TEXT NOT NULL column holds when it holds nothing, so its excluded form is "this fact exists". CurrentTime is the server's clock, which is the expiry sweep uninverted and the still-live guard inverted. OptionalArgument and OptionalNarrowing are the two readings of an equality a caller may leave unset, and they differ in what an absent argument means. And AtMostArgument is the ceiling a caller computed — everything recorded before this instant, everything sequenced at or below this number — which is what a retention sweep is keyed on, since the horizon it runs to is now less a window the configuration carries and interval arithmetic is the arithmetic the three dialects spell three ways.

Match.Exclude inverts every one of them rather than only the first, and every inversion is a complement: IS NULL against IS NOT NULL, the empty-string equality against the not-empty guard, "at or before now" against "after now", at or below the ceiling against above it. So the sweep that collects expired rows and the guard that refuses to spend them are one Match with one bool between them, and there is no second spelling of the boundary to disagree with the first.

Three of them bind nothing at all, and that is what makes them guards rather than predicates: the value compared against belongs to the statement, so there is no argument a caller could leave unset to relax it. Naming a Match.Arg beside one of them is ErrArgumentlessMatch rather than a field quietly ignored.

The presence-conditional predicate is one static statement rather than SQL assembled per call:

free := querygen.For(dialect.Postgres).ReadQuery(
	"GetUserIDByUsername", "users", nil,
	querygen.Read{Projection: []string{querygen.IDColumn}},
	querygen.Match{Column: "username"},
	querygen.Match{Column: "scope"},
	querygen.Match{
		Column:  querygen.IDColumn,
		Against: querygen.OptionalArgument,
		Arg:     "except_user_id",
		Exclude: true,
	})

That renders

id <> COALESCE(sqlc.narg(except_user_id), '')

which excludes the row being updated when the caller names one and excludes an id no row has when it does not — so the collision check a user's own profile save runs and the one a registration runs are the same checked statement. It rests on the same fact Generator.CursorCondition rests on: no id is empty.

OptionalNarrowing is the other reading, and it is the one a filter wants:

(sqlc.narg(owner)::text IS NULL OR t.owner = sqlc.narg(owner))

An absent argument narrows nothing rather than narrowing to the sentinel, which is what "this owner's rows, or everybody's" needs. Read as OptionalArgument the same filter answers with the rows whose owner is the empty string, which is a query that runs and returns a set nobody asked for.

Written as `owner = COALESCE(sqlc.narg(owner), owner)` it would mean the same thing and be unplannable, since the column appears on both sides and no index can serve it; the disjunction's second arm is an equality against a parameter, which a server planning with the value in hand walks an index for. Only the NULL test carries a cast, per dialect — Generator.unsetArgument says why that one needs one, and why the equality beside it must not have one.

Reads that cross a junction

Everything above projects one table. The read that does not is the one a many-to-many brings with it: an account's roster is the membership rows with the member's own columns beside them, and a user's account list is the accounts reached through those same memberships. Both were hand-written for as long as this package was single-table, and the roster in particular kept a hand-paired two-entity scanner alive — a projection in one file and a list of scan targets in another, where a mismatch is a runtime scan error rather than a failed build.

Generator.JunctionListQueries renders them, and Generator.JunctionListAllQuery renders the unpaged form. What a caller adds to a list is a Junction: the table joined in, the two columns the join matches, whatever key the far side carries, and — where the caller wants the joined row's columns too — the prefix they are aliased under.

roster := querygen.For(dialect.Postgres).JunctionListQueries(
	"ListAccountMembers", "memberships", membershipColumns,
	&querygen.Junction{
		Table:    "users",
		Column:   querygen.IDColumn,
		OnColumn: "belongs_to_user",
		Columns:  userColumns,
		Prefix:   "user",
	},
	querygen.Match{Column: querygen.BelongsToAccountColumn})

Three things about it are worth knowing before writing one.

The listed table is the one the page is a page of, and it is the caller's decision rather than a property of the schema. The cursor walks its id and the filter window bounds its timestamps, so a roster lists memberships and joins users, while a user's account list lists accounts and joins the very same memberships. Getting it the wrong way round produces a working query that pages over an id the caller never sees.

The join contributes predicates rather than sharing the filter. The listed table's archived_at is what include_archived admits rows through; the joined table's is required to be NULL outright, because the filter window describes the rows being listed and the joined row is a reference those rows hold. A roster asked for archived memberships wants the memberships that ended, not the users who were deleted.

And a projection spanning two tables is aliased or it is not projected. Two tables following these conventions share most of their column names, so an unaliased pair has two columns called id and two called created_at, and what a generator downstream makes of that depends on the order the SELECT happened to list its tables in. Junction.Prefix is what names them apart; leaving it empty projects the listed table alone, which is what the accounts-through-memberships direction wants.

Tables with no id

Generator.StandardCRUD requires an id column and the keyed forms above do not, and the asymmetry is the one place the two halves of this package genuinely disagree about what a table has to look like.

StandardCRUD emits the list, and the list pages by keyset over the id: the cursor predicate compares against that column, so it has to sort by creation time, and a composite key is not a cursor without machinery this package does not have. The single-row statements need no such thing. They need to address one row, and a table whose primary key is (subject_type, subject_id) addresses one exactly by naming both — which is what Match has always been for, an equality predicate on a column, bound rather than interpolated. So the id predicate is rendered when the column list has an id and not when it does not, exactly as the archived_at predicate is, and Generator.GetQuery, Generator.ExistsQuery, Generator.UpdateQuery and Generator.ArchiveQuery key a row on whatever it actually keys on:

get := querygen.For(dialect.Postgres).GetQuery("GetSubjectKey",
	"shredding_subject_keys", columns,
	querygen.Match{Column: "subject_type"},
	querygen.Match{Column: "subject_id"})

Four tables in this module are in that position, each with a natural key that carries a meaning a surrogate id would not: audit_log_chains keys on its scope, shredding_subject_keys on (subject_type, subject_id) — which is the constraint enforcing one live key per subject, and so the difference between a shred that works and one leaving half the ciphertext readable — metering_totals on (subject, meter, period_start), and scheduled_timers on (timer_set, timer_key).

Generator.InsertQuery is here for the same table rather than for variety. An INSERT keys on nothing, so it is the one statement such a table wants unchanged from the standard set while wanting every other one keyed on its natural key — and StandardCRUD, which would otherwise have emitted it, cannot serve the table at all because of the list beside it. Without InsertQuery a natural-key corpus would be five statements sqlc checks and a sixth nobody could render.

A statement that keys on nothing at all — no id in the column list and no Match — is ErrUnaddressableRow rather than a statement whose WHERE clause is the archived predicate alone. Reading one row by reading all of them is not a degenerate read; it is a different query, and archiving through one empties a table.

The writes those tables are written with

A table with no id could be read and updated long before it could be created or destroyed. Generator.StandardCRUD is where the create lived, and StandardCRUD refuses a table with no id outright, so a child row keyed on its parent — (membership_id, role), (user_id, role) — had no emitted insert at all; and nothing here rendered a DELETE, so the hard deletes stayed hand-written in the one place a consumer least wants hand-written SQL, the erasure a right-to-be-forgotten request runs.

Three statements close that, and all three are corpus forms — named, annotated, rendered into a .sql — with no [Bound] counterpart:

roles := querygen.For(dialect.Postgres)

insert := roles.InsertQuery("InsertMembershipRole", "membership_roles",
	[]string{"membership_id", "role"}, nil)

clear := roles.DeleteQuery("DeleteMembershipRoles", "membership_roles",
	[]string{"membership_id", "role"},
	querygen.Match{Column: "membership_id"})

Generator.InsertQuery is the create with the id requirement lifted off it, which is the only thing StandardCRUD's version had that an INSERT does not need — the id is required there because the list pages by keyset over it, and an insert has no list. A set of child rows is written one statement per element rather than one statement with a VALUES list assembled per call: the multi-row form's shape is the caller's cardinality, so it has no static text for sqlc to check or for this package to emit, and the cardinalities are single-digit inside a transaction the parent's write already opened.

Generator.DeleteQuery is the single-row machinery with a different verb. It keys on the column list and the matches exactly as the get, the update and the archive do, refuses ErrUnaddressableRow the same way, and is annotated :execrows because the count is the answer. What it does not render is the archived predicate, and that absence is the point: an erasure runs against a subject who was archived first, and a role set is cleared whether or not its parent has been, so a delete excluding archived rows would be the one write unable to reach the rows it exists for. Its key need not name a single row — clearing every grant a membership holds is one statement keyed on the membership — which is the other half of what separates it from the archive.

Generator.InsertIgnoreQuery is the third shape, and it is not an upsert whose conflict branch is empty: ErrDegenerateUpsert refuses that, correctly, because an upsert that assigns nothing is an INSERT failing on its second call. This one neither fails nor converges. The row already there wins, unchanged, and the count says so — which is what a key mint wants, since the loser of a race between two replicas has generated a key it must throw away. The key is the conflict target under the upsert's rule, and the three dialects spell the shape three ways: Postgres appends ON CONFLICT (…) DO NOTHING, MySQL and SQLite take a modifier before INTO and name no target, so MySQL's skips a collision on any unique key rather than on the one named — the same caveat the upsert carries.

The bounded prune

A retention pass is a delete with a horizon, and the horizon is the easy half. The hard half is the bound. A table nobody has swept for a month holds a month of rows past its horizon, and the DELETE that clears them in one statement holds locks for minutes, replicates as one transaction, and times out somewhere in the middle — after which the next attempt starts from the beginning. Generator.PruneQuery is that delete capped:

sweep := querygen.For(dialect.Postgres).PruneQuery(
	"PruneMeteringEvents", "metering_events",
	querygen.Prune{
		Key:   []string{"meter", "idempotency_key"},
		Order: []querygen.Order{{Column: "recorded_at"}},
	},
	querygen.Match{Column: "recorded_at", Arg: "horizon", Against: querygen.AtMostArgument})

The pass takes as many rows as it was allowed, reports how many that was, and runs again while the count says there are more — which is why the annotation is :execrows. The count here is the loop's condition rather than a courtesy. The cap binds under result_limit, the same name a page size does, because "how many rows may this statement touch" is one question however the statement got there; unlike a page size it has no default, since an absent cap is the unbounded DELETE the shape exists to make unspellable.

This is the third place the three dialects disagree about a statement's shape rather than about an expression inside one, and the widest of the three. MySQL caps the DELETE itself, with the ORDER BY and LIMIT its grammar takes. Postgres and SQLite have no DELETE … LIMIT, so the bound goes on a read: a capped SELECT names the doomed rows and the DELETE removes what it named, through an aliased self-reference — unaliased, SQLite cannot say which occurrence a column belongs to, and it says so when the statement runs rather than when it is parsed. A key of more than one column compares as a row value, which is the queue tables' shape, where (queue_name, item_key) names a row and neither half of it does.

Only one half of that is forced. Postgres does not parse DELETE … LIMIT at all, and SQLite parses it only in builds compiled with an option most are not, which makes it a failure that waits for run time — so the doomed subquery is the only bounded delete those two have. MySQL is the one with a choice: it refuses a subquery over the table being deleted from (ER_UPDATE_TABLE_USED), and accepts the identical rows once that scan is materialized through a derived table, which is what Generator.SweepDeleteQuery renders there. The prune declines the derived table because the native arm is strictly better for a statement with no read to keep in step with, and the three spellings and the servers that take them are written down once, in querygen's boundedWriteForm, which both shapes derive their arm from. So the divergence is confined to Generator.boundedDelete the way the upsert's is to Generator.conflictHeader, and the corpus above is authored once and rendered three times: one name, one signature, one set of arguments.

The capped read takes FOR UPDATE SKIP LOCKED on Postgres, so a fleet of reapers takes disjoint batches instead of queueing behind each other; a row another pass holds is still past the horizon next time, which is what a reaper can afford and a claim cannot. SQLite has no FOR UPDATE and needs none — one writer at a time is its storage model, so the unlocked read is correct there rather than missing — and MySQL's arm has nowhere to put a lock clause, since the DELETE itself carries the bound, so two pruners racing there serialize on the rows they both chose. Every pass stays bounded and correct on all three; what the grammar decides is throughput under contention.

The horizon is a Match like any other predicate, and a prune handed none is ErrDegeneratePrune rather than a truncate run a batch at a time. There is no archived predicate, for the hard delete's reason: the row is being destroyed rather than hidden, and a row archived a year ago is precisely the row a retention pass exists to remove.

One doom is not a comparison at all, and Prune.Conditions is where it goes. metering may destroy an event row only once the period it was folded into owes the provider nothing, which is a correlated NOT EXISTS over a second table — an expression, and so a thing the closed Comparand set refuses. Sending that caller away to write the whole statement out would send them away to write down which of the three spellings above their server takes, which is the one fact this section exists to hold; so the predicate is theirs and the statement is still this one, cap, ordering, arm and count alike. A condition names the pruned table through Generator.PruneQualifier, because the two arms call it different things — the alias where the bound is on a read, the table where the DELETE carries it — and a condition qualified with the wrong one resolves against its own subquery's table and dooms rows nobody chose.

The claim that is not here

The queue stores' other statement is the claim — a bounded, ordered SELECT … FOR UPDATE SKIP LOCKED, leasing what it selected and returning it — and this package deliberately does not emit one.

sqlc is not the obstacle: all three analyzers parse the shape, the Postgres one including the lock-ordering CTE, the interval arithmetic and the multi-column RETURNING. The obstacle is that the statement means three different things. Postgres claims in one statement, MySQL has no such statement and claims with a SELECT followed by an UPDATE — a different concurrency shape with a different failure model — and SQLite, a single writer, has no row locks to skip at all.

workqueue and timers answer that by being Postgres-only packages rather than by running three claims that promise three things, so their claims belong in their own single-dialect corpora, where RETURNING is legal and a roster of one cannot diverge. A shape emitted from here would have to promise something on all three, and there is nothing here it could promise.

outbox is the third queue store and serves all three dialects, and it answers the same question the other way: its claim is three statements rather than one — a bounded ordered select, an update that leases what the select named, and a read back of the leased ids — inside one transaction, which is a shape every engine here has. That is what a portable claim costs, and the cost is exactly the reason this package still emits none: the decomposition is a concurrency decision the store makes, and two of the three statements it produces are authored for reasons of their own — a correlated self-join, and `attempts = attempts + 1`. What outbox does take from here is the third, its reap, which is the bounded prune.

Generator.PrefixSearchQueries is the one read shape that is not a filtered list: a page of rows whose column begins with what somebody typed, and the count of everything that prefix matched.

search := querygen.For(dialect.Postgres).PrefixSearchQueries("users", columns,
	querygen.PrefixSearch{
		Column:    "username",
		Name:      "SearchUsersByUsername",
		CountName: "CountSearchUsersByUsername",
	},
	querygen.Match{Column: "scope"})

Three things about it are not the standard list's, and each is why it is its own shape rather than a Match on that one. One column is matched, ordered by, and paged over, where a list orders by the id — a cursor names a position in an order, so the search's is a keyset walk over the searched column. The count is a second statement rather than a subquery riding on the rows, because the number a caller wants is of everything the pattern matched rather than of what remains after the cursor. And archived rows are excluded outright rather than through include_archived: a name search is a lookup somebody is about to act on.

The page comes in both directions like every other paged read here, and what a direction means is this statement's order rather than creation order: the descending half walks the searched column backwards. That is the only reading available to a statement that never orders by the id, and it is the one that keeps the cursor and the ORDER BY naming the same order. The count is emitted once, since a count does not depend on the order its rows would have arrived in.

The pattern is an argument rather than something the SQL assembles, and PrefixPattern is what builds it — the wildcards escaped, a trailing % added, and LikeEscape as the escape character the emitted ESCAPE clause names. Both halves are here because they are one decision: a caller that binds a raw prefix leaves whatever wildcard somebody typed a wildcard, so a prefix of "%" returns every row — which reads as a working search returning too much rather than as a bug.

The batched read

Every N+1 read has the same shape underneath: a page of rows, and a second table holding what hangs off each of them. Read one key at a time it is thirty round trips returning two rows each — a roster page whose members' roles are fetched inside the loop that converts rows. Generator.SetReadQuery is that read done once:

roles := querygen.For(dialect.Postgres).SetReadQuery(
	"ListMembershipRolesByMembershipIDs", "identity_membership_roles",
	[]string{"membership_id", "role"},
	querygen.Read{Order: "role"},
	querygen.SetKey{Column: "membership_id"})

The set is one argument on Postgres, which has arrays, and a sqlc.slice expansion on the other two, which do not — the same divergence the bulk stamp carries, and the same []string on either side of it. What the caller writes does not move.

Three things about it are the statement's rather than a caller's.

The ordering is the keyed column's, so a consumer walks the rows once and sees one key's rows together. Read.Order is the tie-break inside a group rather than the order of the page.

The set is rendered after every Match, and that is a requirement rather than a layout choice: an expanded set is a run of bare markers, SQLite numbers a bare marker one past the highest it has seen, and an argument bound after one collides with an element of the set — matching nothing, quietly. Rendering it last is what keeps one argument order right on all three engines.

And the empty batch is the caller's to answer before the query runs. There is no text to emit for a zero-length set — `IN ()` is a syntax error on two dialects — so what an empty slice does is a convention of whatever generated the Go, and the conventional answer is a NULL that matches no row. That is a round trip whose answer was known before it was sent, on a path whose whole purpose is saving round trips. Nothing here can enforce the contract, because the arity is the caller's and this package emits text.

Whether archived rows come back is decided the way every other predicate here is decided — by the column list. A read whose columns carry archived_at excludes them; a hydration read naming rows that other rows already point at hands over a column list without it, and keeps them, because hiding a soft-deleted user turns "created by a departed colleague" into "created by nobody".

It is corpus-only like everything else here — rendered into a consumer's .sql, checked by sqlc, and executed through the method sqlc generates — but for this shape that was never going to be a choice: a set reference has no fixed number of markers, because sqlc expands it per call, so the statement's arity belongs to the values and only the generated method can hold it.

The list narrowed by a set

Generator.SetListQueries is that predicate inside a filtered page, for the read that narrows over a closed domain: an operation listing scoped to the failed and cancelled states, under the same window, cursor and pair of counts every other list here carries. The alternative is one statement per subset of the filters — eight for three optional narrowings, sixteen once each is emitted in both directions — and a store choosing between sixteen nominal row types converts rows to its own type sixteen times.

It is Postgres's alone, and ErrPositionalSetInList is what it raises elsewhere. A list carries every predicate three times, once in the WHERE and once in each count subquery, and only an array-typed argument can be bound three times: the sqlc.slice expansion the other two dialects take is substituted at its first marker and leaves the other two standing. That is a fact about those engines rather than a decision here, and it is raised rather than degraded, because a list that had quietly stopped narrowing would be a list of everything.

The set is required and the empty set matches nothing, exactly as it is for the batched read. What makes that workable is the shape's own precondition: the domains it suits are closed, so a caller whose filter is "any of them" binds the whole domain. A caller whose domain is not closed wants OptionalNarrowing on a single value instead.

The sweeps

Everything above answers somebody: a page a caller is reading, a row a request named, a write a caller asked for. The three shapes here answer nobody. They are the background passes a durable-state table needs — the artifacts whose expiry has come, the confirmation windows that lapsed, the records past their retention — and what they have in common is that the rows are chosen by having become due rather than by anything a caller said.

That is why they are not the list with different predicates. A list carries the filter window, which describes what a caller asked to see, and a cursor, which is where that caller had got to. A sweep has neither: there is no reader whose date range should decide which expired artifacts get collected, and no position to hold between passes, because the rows collected last time are no longer due. What it has instead is an ordering saying which rows are most overdue and a limit saying how much to do in one pass — both of which a list would need anyway, and neither of which means what a list means by them.

Generator.SweepQuery is the read:

expiring := querygen.For(dialect.Postgres).SweepQuery(
	"ListExpiringArtifacts", "dataprivacy_requests", columns,
	querygen.Sweep{Order: []querygen.Order{{Column: "expires_at"}, {Column: querygen.IDColumn}}},
	querygen.Match{Column: "status"},
	querygen.Match{Column: "expires_at", Against: querygen.AtMostArgument, Arg: "expires_before"})

Generator.SweepDeleteQuery and Generator.SweepUpdateQuery are that same scan with a verb on it: the rows it names, deleted or assigned, in one statement. One statement rather than a scan whose ids are written afterwards, because the predicate deciding which rows move is then evaluated by the server at the moment they move — a scan followed by writes decides on rows read a round trip earlier, and what changes in between is precisely what the predicate was asking about.

The choice between the read and the writes is not a matter of taste. A sweep whose subject is entirely inside the database is one bounded write and its count is the answer. A sweep with something outside — an object in a bucket, a message to send — is the read, because the outside thing has to go first: a bulk UPDATE marking rows expired would be one round trip and would leave every artifact in the bucket, which is the outcome an expiry state exists to prevent.

Two things about the writes are the statement's rather than a caller's. The rows are named through a subquery, because two of the three dialects have no LIMIT on a DELETE and the third's — which the prune does take — has no read in it for the sweep's own read to be rendered from; and the outer key is qualified, because SQLite resolves a bare id against both the statement's target and the subquery's table and calls that ambiguous. MySQL is the one shape that differs: it refuses a subquery reading the table being written (ER_UPDATE_TABLE_USED) and accepts the identical rows once materialized through a derived table, so its rendering wraps the scan in one — the same boundedWriteForm table the prune reads its arm off. The Generator carries the dialect, so a Postgres statement cannot acquire the wrapper or a MySQL one lose it.

All three take their predicates the way the batched read does rather than the way the single-row statements do: the archived clause where the column list carries archived_at, one predicate per Match, and no id predicate — a sweep addresses a set, so a statement keyed on the row's own id would be a sweep of exactly one row. A sweep with no Match is ErrUnpredicatedStatement rather than a bounded truncate, and one naming no ordering is ErrUnorderedBoundedStatement rather than "whichever N rows the server produced first", which is a set that can differ between two runs over the same rows and can pass over the oldest row forever. The prune refuses an unordered pass under the same error, for the same reason, argued once where both shapes point at it.

Choosing between the prune and the sweep

Two shapes above render a bounded write, and a store porting its reaper has to pick one. They are not variants of each other, and neither is a superset of the other:

  • Generator.SweepDeleteQuery and Generator.SweepUpdateQuery address rows by id, leave archived rows alone wherever the column list carries archived_at, and render from the same scan Generator.SweepQuery renders — so a caller can look at the rows a pass is about to take.
  • Generator.PruneQuery addresses rows by any key, a natural key of several columns included; dooms archived rows like any others, because retention destroys rather than hides; and takes Postgres's FOR UPDATE SKIP LOCKED, so a fleet of reapers divides one backlog instead of queueing on it. It renders no read, which is what lets it take MySQL's native DELETE … ORDER BY … LIMIT.

So: a pass over a soft-deleting table whose rows a caller could also be listing is the sweep. A retention pass over an append-only table, or any pass keyed on something other than an id, is the prune. Where both would render, the prune is the cheaper statement and the sweep is the one whose rows something can read first. Both are ordered and both are capped; that part is not a choice, for ErrUnorderedBoundedStatement's reason.

The reapers this module already has, and which shape each takes:

  • dataprivacy is the sweep, and is already on it: an expiry read whose artifacts leave a bucket before the row may say they are gone, a bounded stamp for the confirmation windows that lapsed, and a bounded delete for the requests past retention — one scan, three statements, archived requests left alone.
  • metering's ReapEvents is the prune, and is already on it. Its rows are addressed by the compound natural key the events table is keyed on, (meter, idempotency_key), which is the row-value comparison Prune.Key exists for. Its NOT EXISTS over the totals table — the guard that keeps retention from destroying the evidence behind an unflushed total — is not a Match, and it is what Prune.Conditions was added for: the predicate is written out in metering's own corpus and the three arms above are not.
  • outbox's reap is the prune. Its table carries no archived_at by design, so the sweep's one advantage does not apply, and a fleet of relays reaping concurrently is exactly what SKIP LOCKED is for.
  • audit's prune is the prune, keyed on the pair (scope, seq). The two reads computing its horizon are aggregates over a chain rather than statements this package emits, and stayed authored; what the port changed is that the DELETE they feed gained the cap it did not have.
  • retention's Table is the prune's shape and cannot be a prune's caller. Its table, its age column and its key column arrive from a policy a consumer writes at run time, and everything here is rendered from string literals at generate time against a schema sqlc has read. What it takes from this section is the rules — ordered, capped, native arm on MySQL — rather than the rendering. It is exempt rather than porting for that reason; "The packages that are not on this tier" carries the ruling, and the one rule it takes that is a fact about a server rather than about a shape — dialect.SupportsWriteLimit — is shared rather than restated.

The count

Generator.CountQuery is the third read that is not a page of rows, beside the existence check and the sweep, and it is the one a gauge wants: how many requests are still owed past their deadline, how many jobs are still waiting. Those are numbers somebody watches over time rather than pages somebody reads, and answering them by draining the rows and counting them in Go makes the cost of the measurement grow with the thing being measured.

It is not the count a filtered list carries. Those ride on the page as scalar subqueries, so the number and the rows it describes come from one snapshot of the table — see Generator.FilterCountSelect. This one has no page to ride on, and asking it is the whole round trip.

It takes the sweep's predicates for the sweep's reason, and refuses ErrUnpredicatedStatement for a different one: a count over no predicate is a number about every row a database holds for everybody, which is the one number a tenancy-scoped schema has no caller for. A caller counting rows in a table addressed by id therefore hands over a column list without the id, the same idiom every read keyed on something else uses.

The table registry

Some of what a consumer needs per table is not a query. The TRUNCATE an integration suite runs between tests is a list of table names; so is a schema inventory, or a check that every table has a migration. The list has to be complete, because the symptom of a missing entry is not a failure where the mistake was made — a table left out of that TRUNCATE is a test somewhere else failing later, on rows the previous test left behind.

The obvious place to get the list is wherever the per-table code lives, and that is the trap. A generator with one builder per table doubles as a table list right up until one table stops needing a builder — because its SQL now comes from somewhere else, or because it never came from a generator at all — and then the list is short by one with nothing to say so. The list survives only if it is fed by the table existing rather than by something choosing to emit its queries.

So Generator.StandardCRUD registers every table it emits for, RegisterTable takes the ones it does not, and RegisteredTables reads the union back:

querygen.RegisterTable("sessions", "webauthn_credentials")

tables := querygen.RegisteredTables()

Two sources, one list. A consumer reading that list does not have to know which tables came from where, and a table moving from one source to the other does not change what comes out.

The convention for a package in this module that ships a schema, which identity is the worked example of: its generator registers every table it owns — the whole list, not the subset Generator.StandardCRUD happens to emit for — and its migrations subpackage exports a Tables(prefix) beside SQL and Statements for the consumer half. The two halves answer the same question for different callers. This one is for a generator binary reading back what it generated across schemas, at the canonical unprefixed names; Tables is for the consumer, at theirs, and reads the DDL, so neither depends on the other staying in step by anybody's memory.

Porting a store onto this package

There is one runtime tier, and every store that owns SQL is on it or is on its way onto it. A store does not render statements for a driver; it renders a corpus, sqlc checks the corpus against the schema, and sqlc-gen-unison generates the typed querier the store calls. A column renamed in a migration is then a failed generate rather than a runtime error, on every table, in all three dialects.

identity is the worked example, and the shape a new store copies is four pieces:

<pkg>/internal/queries      the schema as data: table names, each table's
                            columns in projection order, and the subsets a
                            write may assign — spelled once, because the
                            corpus and the store both read them
<pkg>/internal/queriesgen   a main that renders that data through this
                            package into one .sql per dialect, and prints
                            the DDL sqlc reads them against
<pkg>/unison.yaml           the dialect roster, the generated package's
                            name, and the type overrides
<pkg>/internal/<pkg>db      the generated querier, committed

cryptography/shredding is the second, and the shorter read: one table, three statements, and no id anywhere in it. What it demonstrates is that a natural key costs a port nothing beyond naming the key — the pair goes in Match values, the same values become the insert's conflict target, and the id predicate is absent because the column list has no id to render one from. It is the pattern the three remaining natural-key tables above follow.

The rendered .sql is committed and nothing imports it: it exists so `sqlc compile` can check it with no database running, and so the generated-files job can diff it. `make generate` writes it, through a go:generate line on the package; `make unison` renders the per-dialect schema beside it and runs the emitter over the pair. Both scripts name the components they walk and the dialects each one serves, and a new store is a line in each list.

A roster of one is first class, and operations is the worked example of it. The roster is the keys of unison.yaml's schemas map, so a Postgres-only package renders a Postgres-only corpus and gets the same checked guarantee — with the added freedom that a shape cannot diverge across a roster it is alone in, which is what makes RETURNING available there and not elsewhere. What a single-dialect roster is not is an exemption from the tier.

What a store writes by hand is which statements it wants, in the internal queries package: Generator.StandardCRUD for a table whose reads are the conventional set, and the keyed forms above for everything else.

There is one class of statement that stays written out, and operations is where it landed. Everything here assigns a bound value — a column and the argument it takes, with last_updated_at stamped by convention — and a queue store's transitions do not: they assign expressions, a revision counter incremented, a lease horizon computed from a bound duration, a monotonic floor under a progress counter, a cancellation resolved by a CASE in the statement that requests it. Rendering those would mean an expression language in this package, which is the thing the closed Comparand set exists to refuse.

So they are written out in that package's own internal queries package, as complete statements in the same committed corpus — checked by sqlc against the same schema, executed through the same generated querier. What such a statement gives up is a generator's guarantee that its predicates were derived rather than remembered. What it does not give up is the tier.

Not every statement a store runs is a shape this package has, and a corpus is allowed to hold the rest. saga is the worked example of that half: its transitions assign expressions rather than bound values — an attempt counter incremented server-side, a lease dropped outright — and seven of them guard on a *set* of statuses, which a Match cannot say. Those are written out as complete named statements in that package's own internal queries, where they keep the whole guarantee: same committed corpus, same `sqlc compile` against the same schema, same generated querier, so a renamed column fails the same generate.

What such a statement must not do is restate a dialect fact. The fragments are exported for that reason — Generator.FilterConditions and the two count selects, Generator.CursorCondition and Generator.CursorLimitClause, Generator.LimitClause, Generator.SetCondition, Generator.MatchConditions and Generator.WindowConditions — so an authored statement is this module's shape written out with this package's spelling of each server's differences, rather than a second copy of them that can drift.

The last two are what an authored *list* needs, and audit is why they are here. Its entries table has no created_at: what a reader filters on is recorded_at, which the hash covers and the caller assigns, so the window every other list gets derived from its column list has to be named instead — and the sentinel an absent bound coalesces to is three spellings of one interval. Its six selectors are then OptionalNarrowing matches, whose NULL arm carries a cast that is three spellings again. Neither is a shape; both are the same predicate this package already renders, handed to a statement it does not.

The packages that are not on this tier

The registry's problem has a larger version one level up. One tier executes this module's SQL: a package renders its statements into a canonical .sql, sqlc checks them against that package's own schema on each dialect it serves, and the store executes the querier the generator emits. Every package here that owns tables is on that tier or is being ported onto it — and "every package" is a claim about a set, which is checkable only if the exceptions to it are named. An exemption nobody wrote down is indistinguishable from a package somebody missed: both look like a package that simply never comes up. The reader who notices reconstructs the list by grepping the module for SELECT, which is a survey with a shelf life of one branch, and the survey that produced this section had to do exactly that.

So the boundary is stated here, and internal/sqltier is where a build checks it is still where this says: it walks the module for the packages holding SQL and fails on one no ruling covers, in either direction — a package that grew a statement and a ruling that outlived one.

Four packages hold SQL that is not table SQL, and a corpus has nothing to say about any of it:

  • database/postgres/tableaccess and database/mysql/tableaccess create users, grant privileges, and read the server's own catalogs. sqlc generates queries against a schema; it has no spelling for CREATE USER or GRANT, and pg_roles is not in any schema this module ships. Each is single-dialect because its statements are, rather than because nobody reached the other two.

  • distributedlock/postgres calls pg_try_advisory_lock and its siblings. The lock is a number the server holds for a session: no table, no schema, and no projection for a generator to type.

  • database/migrate asks a connection which schema its search path resolves to, so migrations of one schema serialize against each other rather than against the whole server. goose owns the bookkeeping table and ships its DDL; this package owns one session-scoped question.

search/vector/pgvector is exempt for a different reason, and the reason is not the one that looked likely. Its operators were worth checking rather than assuming, and sqlc accepts them: `embedding <=> $1::vector` parses, generates, and comes back typed as interface{} on both sides, because a vector is an extension type the analyzer resolves nothing for — and a column type override reaches the stored column while leaving the distance an ANN search exists to return untyped. That alone would only weaken the guarantee. What removes it is that the index table is a runtime product of configuration: its name, its dimension, and its metadata column's name are all values a caller supplies, and the manager issues the CREATE TABLE itself at startup. There is no committed DDL for sqlc to check a statement against, and no fixed column name for a statement to project.

retention is exempt for the same reason, arrived at from the other end. Its statements are ordinary table SQL — a bounded delete and a saturating count — and the table is the part that is not ordinary: its name, the column age is measured from, and the key a batch is bounded by all arrive from a Policy an application writes at run time, against tables this module ships no migrations for. Everything here renders from string literals at generate time against a schema sqlc has read, so there is no corpus to put those two statements in and nothing for sqlc to check them against. What retention takes from this package instead is the rules — ordered, capped, and the native arm where a server caps the write itself, which is dialect.SupportsWriteLimit rather than a second reading of MySQL's grammar — and, in place of the check a corpus would have given it, a container suite that runs both statements against a real server on each of the three dialects it serves.

filtering holds no SQL at all. It was surveyed at one keyword and the keyword is a word in a comment: what the package supplies is the argument names a rendered statement binds — the seven above — and the conversions that bind them, which is why every statement here says created_after rather than inventing a spelling of its own. That it holds none is recorded as an assertion rather than left as an absence, because an absence goes on reading true after the package stops deserving it.

The remaining one was a port rather than an exemption, and it has landed.

dataprivacy/auditerasure owns no table. Its three statements — two deletes of a subject's audit scopes and the count of what the hash chain will not let go of — address the audit log's tables, which the audit package ships the migrations for. So they live in that package's corpus rather than in one of its own: a second corpus over somebody else's schema would be a second place a column rename has to be noticed. What crosses the package boundary is not the querier, which is internal to audit, but audit.Erasure — the two writes and the count as methods taking the eraser's own transaction, with which scopes belong to a subject and what basis the rest are kept under left where they belong.

audit is on the tier as of that port, and it is the one that added two fragments rather than a shape. Its entries table has no created_at — recorded_at is the caller's fact and the hash covers it — so its paged read names the filter window instead of deriving it, through Generator.WindowConditions; and its six selectors are OptionalNarrowing matches rendered through Generator.MatchConditions, which is the first use of that comparand on a dialect without arrays. Everything else it needed already existed: the chain's natural key is Match values, its genesis row is Generator.InsertIgnoreQuery, and its retention pass is the prune above.

dataprivacy itself is on the tier. It is the second package to arrive, and the three shapes it needed are the sweeps above: its statements were previously assembled in Go, including two whose SQL differed by dialect at run time and one whose SET list was chosen per call. The transitions it renders now are named rather than parameterized — a confirmation and a cancellation, which differ by the column they assign rather than by the status they came from — which is the same substitution Generator.UpdateQuery made for identity's field-specific writes: a builder whose branches are the cases becomes one statement per case, each of them checked.

authorization/database is on it too, and it is the one port that added a shape rather than reusing them. A survey counting functions that return a query and its arguments read it as zero builders; it had thirteen, which returned the query alone and assembled the arguments at each call site. Twelve of its fourteen statements turned out to be shapes that already existed — the mapping rows between its tables are the id-less child tables Generator.DeleteQuery and Generator.InsertQuery serve, its seed's lookups are Generator.SetReadQuery, and its two named tables converge through Generator.UpsertQuery, whose conflict branch clearing archived_at is exactly what makes a re-seed revive a reserved name. The thirteenth is Generator.ClosureQuery above, and it is the one statement in this module with a recursive term.

authentication/passwordreset is the last one, and it arrived from outside the survey rather than from a ruling: it landed after the survey that produced this section, so no roster ever listed it and its five fmt.Sprintf builders were a violation of a claim nobody had checked it against. Its port needed no new shape. The issuance is Generator.InsertQuery with created_at named in its column list rather than left to the database, which is cryptography/shredding's argument reaching a table that does have an id: nothing pages by it, so there is no cursor walk for a caller-supplied creation time to disagree with. The lookup is Generator.ReadQuery over a list with no id and a projection that drops the digest, the redemption is Generator.UpdateQuery guarded on NoValue, and the revocation and the sweep are Generator.DeleteQuery — the second of them with AtMostArgument rather than CurrentTime, because that table's deadline is stamped by the store's own injected clock and the server's would be the wrong one rather than merely the inexpressible one.

With it, the set of packages composing SQL in Go is empty. internal/sqltier is where that is a check rather than a sentence here: every package in the module holding a statement is on the tier, exempt with a reason, or ruled to hold none, and a store that grew a hand-built statement back fails a test rather than reading as a package nobody had got to yet.

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.

The three dialects

Postgres, MySQL and SQLite each get SQL their own server parses, and almost all of the difference is a handful of expressions: the case-insensitive substring match, the pattern a prefix search's LIKE binds, the byte-ordered comparison the reindex scan walks, the sentinel an unset time bound coalesces to, the precision the current time is stored at, the nullable boolean the archived toggle binds, the page-size clause, and the set membership the bulk stamp and the batched read key on. They live together in generator.go, as unexported methods, so that what this package assumes about a server is one screen rather than a grep for casts. The statement shapes those land in, the query names, and which queries a column list justifies are the same on all three.

Two statements are the exception, and both are an INSERT that has to do something about a row already there — which is the one thing the three engines never agreed on. An upsert is two grammars rather than one grammar with a substituted expression: Postgres and SQLite name the conflict target and read the incoming row through the EXCLUDED alias, and MySQL names no target at all — its ON DUPLICATE KEY UPDATE fires on whichever unique key was violated — and spells the incoming value VALUES(column). The insert-ignore divides them differently again: Postgres alone has no modifier for it and takes a trailing ON CONFLICT … DO NOTHING, while MySQL and SQLite each spell it before INTO, as INSERT IGNORE and INSERT OR IGNORE. Every half of both is in generator.go with the rest, so the one-screen property survives; what a consumer sees is still one query name with one signature, rendered per dialect by Generator.UpsertQuery and Generator.InsertIgnoreQuery.

The set is closed at the type. For takes a dialect.Dialect and rejects one outside dialect.Valid rather than emitting a plausible default, and the dialect binds to the Generator rather than to each call, so a Postgres fragment cannot be spliced into a MySQL statement. That matters more than it sounds: the failures are asymmetric. COLLATE "C" in MySQL is a parse error, which is the good case; ILIKE has no SQLite spelling at all, and the substitute folds a narrower set of characters, which is a search that quietly misses rows.

What a consumer sees is one set of sqlc methods with one set of signatures whichever dialect generated them, so the application code above them is written once. Two exceptions, both from sqlc's own inference rather than from anything here: the archived toggle carries a ::boolean on Postgres and cannot elsewhere, because MySQL and SQLite have no boolean type to cast to; and a bound set — the bulk stamp's ids, a batched read's keys — is an array on Postgres and a sqlc.slice expansion on the other two, which changes what reaches the server and not the []string a caller passes.

What each dialect asks of a schema

A table generated for SQLite has to store its timestamps the way SQLite's own CURRENT_TIMESTAMP writes them — YYYY-MM-DD HH:MM:SS, UTC. SQLite has no date type, so the filter window's comparisons are lexicographic over text, and text in any other shape compares in an order that is not chronological. The other two have real timestamp types and no such requirement.

A table generated for MySQL needs its id column to be something MySQL will index as a key: TEXT cannot be a primary key there without a prefix length, so ids belong in a VARCHAR. Nothing in this package enforces either of these; both are schema decisions, and this package never reads the schema.

The one place a dialect changes a signature

Everything above is a difference in SQL under a Go API that does not move. LIMIT is the exception, and it is worth knowing about before choosing MySQL.

Postgres and SQLite take an expression after LIMIT, so an absent page size coalesces to filtering.DefaultQueryFilterLimit and the generated parameter is a pointer a caller may leave nil. MySQL takes an integer literal or a placeholder and nothing else — COALESCE there is a parse error rather than a slower plan — so its LIMIT binds the size and the generated parameter is a value. Leveling the other two down to match would take a working default away from the dialects that can express one in order to make a limitation uniform, which is the wrong way round.

Nothing drifts by leaving them different: the default is filtering's constant rather than a number written here, so the SQL and filtering.QueryFilter.Normalize read the same one. What a MySQL consumer owes its queries is that Normalize call — it turns an absent or zero page size into that constant and clamps an oversized one, the same treatment the URL parameter gets. A MySQL query handed a zero returns no rows, which is loud, rather than a page of some other size.

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 nothing in the standard set
	// removes a row.
	//
	// The hard delete [Generator.DeleteQuery] renders is the named exception
	// rather than a second convention, and it carries no predicate over this
	// column at all: an erasure runs against a subject who was archived first,
	// so a delete that excluded archived rows would be the one write unable to
	// reach the rows it exists for.
	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,
	// and it brings two statements with it: the scan a reindex walks, and the
	// bulk stamp that maintains the column — see IndexStampQuery.
	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          = filtering.ArgCursor
	LimitArg           = filtering.ArgResultLimit
	IncludeArchivedArg = filtering.ArgIncludeArchived
	CreatedAfterArg    = filtering.ArgCreatedAfter
	CreatedBeforeArg   = filtering.ArgCreatedBefore
	UpdatedAfterArg    = filtering.ArgUpdatedAfter
	UpdatedBeforeArg   = filtering.ArgUpdatedBefore
)

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.

They are aliases rather than literals because filtering.ToSQLArgs produces the values these arguments take, and a name is only useful if the statement and the binding agree on it. Spelled in both places they could disagree, and the failure would be silent: a value bound under a name no statement mentions binds nothing and filters nothing, which is what a filter nobody set looks like. Spelled once, adding a window argument is one edit and both halves follow.

View Source
const DescendingSuffix = "Descending"

DescendingSuffix is what a paged list's descending half is named with: the ascending statement's name and this.

It is derived rather than taken as a second argument because a query name is a generated Go method name, and two names for one list is two things a consumer can get inconsistent — a corpus whose descending statements are named by hand is a corpus where one of them is called ListUsersDesc. Derived, the pair is one decision, and a caller reading ListUsers in a store knows what the other one is called without looking.

View Source
const IDsArg = "ids"

IDsArg is the sqlc argument the bulk stamp binds its id list through. It is not one of the filter arguments above — nothing in filtering.QueryFilter takes a set of ids — so it is spelled separately rather than smuggled into their block.

View Source
const LikeEscape = "!"

LikeEscape is the character a prefix search's pattern escapes wildcards with, and the character the emitted ESCAPE clause names.

Deliberately not a backslash. A backslash is itself an escape inside a string literal on MySQL and MariaDB unless NO_BACKSLASH_ESCAPES is set, so ESCAPE '\' is a syntax error there and ESCAPE '\\' is one on a server that has the mode set — there is no spelling of it that is right on both. An exclamation mark is ordinary in every dialect's string literal, and PrefixPattern escapes it in the pattern like any other special character.

View Source
const NowExpression = "CURRENT_TIMESTAMP"

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.

It is a constant because all three dialects accept the standard spelling: Postgres and MySQL both treat CURRENT_TIMESTAMP as the same function they spell NOW(), and SQLite has only this one. Arithmetic on it is where they part company — see Generator.timeHorizon.

What it is not is the expression a statement stores. MySQL's bare CURRENT_TIMESTAMP is second-granular whatever the column holds, which is a difference this constant cannot carry — see Generator.storedNow, which is what the assignments use.

Variables

View Source
var ErrArgumentlessMatch = platformerrors.New("match names an argument its comparand cannot bind")

ErrArgumentlessMatch indicates a Match naming an argument its comparand has nowhere to put: an Arg beside IS NULL, beside the empty-string guard, or beside a clock comparison, none of which bind anything.

It is a panic rather than a silently ignored field because the two readings a caller could have had are both wrong in a way nothing downstream would report. A caller who meant `column = sqlc.arg(other)` and reached for NoValue gets a statement with one fewer argument than they are about to bind, which sqlc turns into a params struct missing a field; a caller who meant the guard and named an argument out of habit has written a name that no marker will ever carry, which is a name their argument map can hold forever without anything noticing.

View Source
var ErrDegenerateInsert = platformerrors.New("insert would not write a row")

ErrDegenerateInsert indicates an insert that is not one, in either of the two ways a caller can ask for that: no columns, so there is no row to write and the statement is a syntax error rather than an empty row; or, for the insert-ignore, no conflict target, so nothing says which collision is the one being skipped.

Each is a programming error rather than a caller's — nothing on a request path decides which columns a statement writes — so it panics like the rest of this package's misuse. The wrapped message says which of the two it was.

View Source
var ErrDegeneratePrune = platformerrors.New("prune would doom rows it cannot name")

ErrDegeneratePrune indicates a prune that is not one, in either of the two ways a caller can ask for that: no key, so nothing names the rows the capped read chose and the DELETE has no comparison to make; or no predicate, so every row in the table is past the horizon and the statement is a truncate run a batch at a time.

Each is a programming error rather than a caller's — nothing on a request path decides what a statement dooms — so it panics like the rest of this package's misuse. The wrapped message says which of the two it was.

View Source
var ErrDegenerateUpsert = platformerrors.New("upsert would not converge")

ErrDegenerateUpsert indicates an upsert that is not one, in any of the three ways a caller can ask for that: no conflict target, so nothing decides which row a collision found; no inserted columns, so there is no row to write; or nothing assigned on collision, so the statement is an INSERT that fails on the second call rather than a write that converges.

Each is a programming error rather than a caller's — nothing on a request path decides which columns a statement writes — so it panics like the rest of this package's misuse. The wrapped message says which of the three it was.

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 ErrIncompleteClosure = platformerrors.New("closure describes half a walk")

ErrIncompleteClosure indicates a Closure describing half a walk: no CTE name, an edge missing a table or one of its two columns, no table to read from, or nothing to project out of it.

Every one of them renders SQL a server rejects rather than SQL that quietly answers the wrong question, but they are refused here anyway: the message says which half is missing, where a parse error says a token was unexpected eleven lines into a statement nobody wrote by hand.

It is a programming error rather than a caller's — a generator binary names its tables and columns as literals — so it panics like the rest of this package's misuse.

View Source
var ErrIncompleteJunction = platformerrors.New("junction describes half a join")

ErrIncompleteJunction indicates a Junction that describes half a join: no table, or a table with no pair of columns to match it on.

It is rejected rather than ignored because ignoring it fails quietly. A Junction whose Table was left empty still carries its Matches, and dropping them silently is a keyed read that lost its key — every row in the table, returned without an error, under a query name that says otherwise. A list with no join says so by passing no Junction at all.

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

ErrMissingIDColumn indicates a column set without an id, handed to StandardCRUD.

It is StandardCRUD's requirement rather than the package's, and the two halves differ on purpose. StandardCRUD emits the list query, and the list pages by keyset over the id — CursorCondition compares against it and IDColumn's own comment records that the column has to sort by creation time for that walk to page in a sensible order. A composite key is not a cursor, so there is nothing useful to emit for a table that has none.

The keyed Query forms carry no such requirement. A corpus knows what its tables key on, and says so with Match values; a table whose primary key is (subject_type, subject_id) passes two of them and addresses a row exactly. What it does not get is a paged list — see the package comment.

View Source
var ErrMissingSetColumn = platformerrors.New("batched read names no column to key on")

ErrMissingSetColumn indicates a batched read whose SetKey names no column.

What it would render has nothing on the left of the comparison — `table. IN (...)` — which is a syntax error on every dialect. It is a programming error rather than a caller's, since nothing on a request path decides what a statement keys on, so it panics like the rest of this package's misuse.

View Source
var ErrNilRegistry = platformerrors.New("registry is nil")

ErrNilRegistry indicates WithRegistry was handed no registry. Registering nowhere is the failure this registry exists to prevent, so it is rejected rather than treated as an absent option.

View Source
var ErrPositionalSetInList = platformerrors.New("a filtered list cannot bind a set on this dialect")

ErrPositionalSetInList indicates a filtered list narrowed by a bound set, on a dialect with no array type.

A list carries every predicate three times — once in the WHERE and once in each of the two count subqueries beside it — and on MySQL and SQLite a bound set is a sqlc.slice expansion rather than one argument. Three expansions of one set in one statement is a shape neither sqlc's own generated Go nor sqlc-gen-unison's renders: each substitutes the first marker it finds and leaves the other two standing, so the statement reaches the server with a placeholder count that no argument list matches.

So the shape is Postgres's, where the set is a single bound array and the three references are three readings of one named argument. That is a fact about the other two engines rather than a decision taken here, and it is raised rather than worked around because both workarounds are worse: a consumer whose list silently stopped narrowing by state would be a consumer listing everything, and a set materialized into a temporary table would be a second statement this package has no way to make the caller run.

A three-dialect consumer wanting this read has a portable statement available and it is a different one: Generator.ListQueries with a Match per value, for a set whose membership is fixed, or Generator.SetReadQuery where the page is not what is wanted.

It is a programming error rather than a caller's — a generator binary names its dialect and its shapes as literals — so it panics like the rest of this package's misuse.

View Source
var ErrUnaddressableRow = platformerrors.New("single-row statement keys on nothing")

ErrUnaddressableRow indicates a single-row statement with nothing to key on: no id column, no ownership column, and no Match. The statement it would otherwise render is one whose WHERE clause is the archived predicate and nothing else, which reads one row from a table by reading all of them, and updates or archives every row in it. The hard delete has not even that much — it renders no archived predicate — so what it would otherwise be is a truncate.

It is a programming error rather than a caller's — nothing on a request path decides which columns a statement keys on — so it panics like the rest of this package's misuse.

A guard counts as something said about which rows, and that is deliberate rather than an oversight in the check. A sweep — every live row past its deadline, archived in one statement — keys on the clock and on nothing a caller binds, and it is a statement somebody means: expiring sessions is not a single-row write that forgot its key. So a lone Match{Against: CurrentTime} renders, and what the check still refuses is a statement that says nothing at all about which rows it is for.

The corollary is worth stating, because it is the one way a guard can widen rather than narrow. A lone Match{Column: ArchivedAtColumn, Against: NoValue} says exactly what the archived predicate already said, so the statement it renders is the whole-table write this error is named for, and nothing here will stop it. A guard is a narrowing only when it narrows.

View Source
var ErrUnknownSearchColumn = platformerrors.New("prefix search names a column the table does not have")

ErrUnknownSearchColumn indicates a prefix search over a column the table does not have. The statement it would render names a column three times — the pattern match, the ordering, and the cursor — so the mistake is one a reader of the generated file would have to catch by eye.

It is a programming error rather than a caller's, and panics like the rest of this package's misuse.

View Source
var ErrUnorderedBoundedStatement = platformerrors.New("bounded statement names no ordering")

ErrUnorderedBoundedStatement indicates a bounded statement that names no ordering.

A LIMIT without an ORDER BY takes whichever rows the server produced first, which is a set that may differ between two runs of the same statement against the same rows. For a read that is a page nobody can walk; for a write it is a pass that can go over the oldest row forever while newer ones behind it are collected. The ordering is what makes "the next N" mean something, so it is required rather than defaulted — this package has no basis for choosing which column a caller's pass should drain in.

It is required of Generator.PruneQuery as well as of the sweeps, and that is a ruling rather than a symmetry. The argument for letting a horizon pass go unordered is a good one as far as it goes: every row the pass could have taken instead is still past the horizon on the next pass, so nothing is lost by taking them in whatever order the plan produced. That is true of the rows and false of everything around them. A reaper sharing its table with keyed writers takes row locks in whatever order the plan produced, which is how a pass that could not deadlock becomes one that does. A pass that drains oldest-first has a backlog age somebody can watch; an unordered one has a count that says nothing about how far behind it is. And a statement whose rows differ between two runs over the same table is one whose test can assert a count and never a row. What the caller pays for all three is an ORDER BY over a column the horizon predicate already reads, served by the index that predicate wanted anyway.

View Source
var ErrUnpredicatedStatement = platformerrors.New("statement selects rows by nothing")

ErrUnpredicatedStatement indicates a statement whose WHERE clause would name nothing about the rows it acts on.

A sweep with no Match reads, updates, or deletes whatever the planner reached first, and the LIMIT beside it makes that look deliberate: a bounded DELETE over no predicate is a truncate paid for in installments. So it is a programming error rather than a statement, in the manner of ErrUnaddressableRow one shape over — the difference being that a sweep addresses a set rather than a row, and what it needs is therefore a filter rather than a key.

Functions

func DescendingName

func DescendingName(name string) string

DescendingName is the name the descending half of a paged list is emitted under.

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 PrefixArg

func PrefixArg(column string) string

PrefixArg is the sqlc argument a prefix search binds its pattern through, derived from the searched column so that a table with two of them names them apart.

It is the pattern rather than the prefix, and the distinction is the whole reason PrefixPattern exists: what the caller has is a literal the user typed, and what the statement binds is that literal with its wildcards escaped and a trailing % appended.

func PrefixPattern

func PrefixPattern(prefix string) string

PrefixPattern turns a literal prefix into the LIKE pattern the emitted statement binds, escaping the two wildcards and the escape character itself.

It is here rather than at each caller because the pattern and the ESCAPE clause are one decision written in two places. The clause is rendered above, naming LikeEscape; a caller escaping with anything else — or not escaping at all — leaves a user's typed % or _ a wildcard rather than a character, so a prefix of "%" returns every row and one of "a_" matches "ab" as readily as "a_". That reads as a working search returning too much rather than as a bug, which is why it is not left to be remembered.

The wildcards are escaped rather than the pattern being assembled in SQL, because escaping is a fact about the value and the concatenation is not: there is no portable spelling of REPLACE nesting across the three dialects, and Generator.substringMatch's SQL-side concatenation escapes nothing inside the term.

strings.NewReplacer scans the input once and never re-examines what it has written, so the escape character's own rule cannot double the escapes the other two rules introduce — which a sequence of Replace calls would.

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 RegisterTable

func RegisterTable(tables ...string)

RegisterTable adds tables to the package-level registry RegisteredTables reads, for a table whose SQL something other than Generator.StandardCRUD produces — a hand-written store, a resource declaration, a migration that carries a table nothing generates queries for.

StandardCRUD calls it for every table it emits, so a generator that emits for all of its tables needs no calls of its own. The point of the function is the tables it does not emit for: they land in the same list, so a consumer reading that list back does not have to know which tables came from where.

It panics on a name that is not a valid identifier — see Registry.Register.

func RegisteredTables

func RegisteredTables() []string

RegisteredTables returns the sorted contents of the package-level registry.

It is a snapshot: a table registered afterwards is not in the slice already returned. A generator binary should read it once every source has run, which in practice means after the emitting is done rather than beside it.

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/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/querygen"
)

func main() {
	queries := querygen.For(dialect.SQLite).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: ListThingsDescending :many
-- name: ArchiveThing :execrows

func TableRegistered

func TableRegistered(table string) bool

TableRegistered reports whether table is in the package-level registry.

Types

type Closure

type Closure struct {
	// Alias names the recursive CTE the walk accumulates into. It is required:
	// a common table expression is addressed by name, and there is no name this
	// package could pick that would not eventually collide with a table in the
	// schema it is rendered against.
	Alias string

	// Key is the column of the walked table an edge's endpoints name, and the
	// one column the CTE carries. It defaults to [IDColumn].
	Key string

	// Walk is the mapping table the recursion follows. Both of its columns name
	// rows of the walked table — a parent and a child, a container and what it
	// contains — and From is the end the walk moves away from: an edge whose
	// From matches a row already reached adds the row its To names.
	//
	// Which end is which is the direction of the closure and this package will
	// not guess it. Reversing the pair answers the opposite question, and both
	// questions are ones somebody wants: what a role inherits, and what
	// inherits from it.
	Walk Edge

	// Reach is the mapping table joining a row the walk reached to the rows the
	// statement reads. Its From matches the accumulated rows and its To names a
	// row of Table.
	Reach Edge

	// Table is the table Reach lands in, and Columns is its column list — what
	// the archived predicate on the far side of the walk is derived from, in
	// the manner of every other statement here.
	Table   string
	Columns []string

	// TableKey is the column of Table that Reach.To names, defaulting to
	// [IDColumn].
	TableKey string

	// Projection is what the statement selects out of Table. It is required and
	// it is separate from Columns for the reason [Read.Projection] is: the
	// column list is what predicates are derived from, and a resolution that
	// wants one column back should not be handed six.
	Projection []string
}

Closure is a recursive walk over one mapping table and the read taken across everything it reached.

It is taken by pointer, as Junction is, because it describes a set of joins rather than carrying a value. What a nil one means is where the two part company: a nil Junction is a list with no join, which is a statement somebody wants, and a nil Closure is a recursive read with no recursion in it, which is the plain read Generator.SetReadQuery already emits. So nil here is ErrIncompleteClosure rather than a second spelling of a statement that exists.

Three tables are involved and each has a different job. The table Generator.ClosureQuery is called on is the one the walk moves through — roles, in the schema this was written for — and both of Closure.Walk's columns name rows of it. Closure.Reach hangs off the rows the walk accumulated, and Closure.Table is what it hangs them off: the permissions the resolution is actually asking about.

type Comparand

type Comparand int

Comparand is what a Match compares its column against.

The zero value is a bound argument, which is the predicate this package started with and still the one nearly every statement wants. The rest are the guard vocabulary — the things a statement owns rather than takes from its caller — and the set is closed on purpose. Each member is a shape whose meaning is the same on all three dialects and whose spelling this package can therefore promise; a caller needing something outside it is describing a statement that has to be checked by a person, not one this package should learn to guess at.

A guard is not decoration. The reason MarkUserTwoFactorSecretVerified names EmptyString and NoValue is that a replayed verification must write nothing, and the reason a token consumption names CurrentTime and NoValue is that an expired or already-redeemed token must not be spendable. Each reports zero rows when it loses, which is the answer the caller acts on.

const (
	// BoundArgument compares the column against a value the caller binds:
	// `column = sqlc.arg(name)`, or `<>` under [Match.Exclude].
	BoundArgument Comparand = iota
	// NoValue compares the column against NULL: `column IS NULL`, or IS NOT
	// NULL under [Match.Exclude]. It binds nothing.
	//
	// It is spelled as its own comparand rather than as a bound NULL because
	// `column = NULL` is not false, it is unknown — the predicate every SQL
	// dialect agrees matches no row, including the rows it was meant to match.
	// A nullable stamp is how this module records that something has not
	// happened yet: an unproven secret, an unredeemed token, a key not yet
	// shredded, a row not yet archived. Guarding on it is what makes the write
	// that does the thing happen exactly once.
	NoValue
	// EmptyString compares the column against the empty string: `column = ”`,
	// or the not-empty guard `column <> ”` under [Match.Exclude]. It binds
	// nothing.
	//
	// The empty string is this module's sentinel for a TEXT NOT NULL column
	// holding nothing yet — an outstanding verification token that has been
	// cleared, a two-factor secret that was never issued — so the not-empty
	// guard is "this fact exists" without a second column to record it in.
	//
	// The literal is the statement's own rather than a bound value on purpose:
	// there is exactly one empty string, so binding it would be an argument
	// every caller had to supply and none could get right in more than one way,
	// and a guard that took its own sentinel from its caller would be one a
	// caller could disarm by leaving the argument unset.
	EmptyString
	// CurrentTime compares the column against the server's clock: `column <=
	// CURRENT_TIMESTAMP`, or `column > CURRENT_TIMESTAMP` under
	// [Match.Exclude]. It binds nothing.
	//
	// The uninverted form is the sweep — expired, elapsed, due — and the
	// inverted one is the guard a consumption puts on itself: still live at the
	// moment the row is claimed. Both are the server's clock rather than the
	// application's, for the reason [NowExpression] gives: a row's timestamps
	// and the comparison against them have to come from one clock, or two
	// application instances a second apart decide differently about the same
	// row.
	//
	// The boundary is inclusive on the expired side, so a row whose deadline is
	// exactly now is past it. That is the reading that leaves no instant at
	// which a row is neither live nor expired.
	CurrentTime
	// OptionalArgument compares the column against an argument the caller may
	// leave unset: `column = COALESCE(sqlc.narg(name), ”)`, or `<>` under
	// [Match.Exclude].
	//
	// It is the presence-conditional predicate, and it is one static statement
	// rather than two texts assembled per call. The excluded form is the one
	// with callers: a uniqueness check that must not collide with the row it is
	// about to update excludes that row's id, and the same check at creation
	// time excludes nothing — so the argument is absent, the COALESCE yields
	// the empty string, and the predicate excludes an id no row has.
	//
	// That correctness rests on the same fact [Generator.CursorCondition]
	// rests on: no id is empty. A column whose domain includes the empty string
	// is a column this comparand cannot speak for, because an unset argument
	// would then name a row.
	OptionalArgument
	// OptionalNarrowing compares the column against an argument the caller may
	// leave unset, where leaving it unset narrows nothing:
	// `(sqlc.narg(name) IS NULL OR column = sqlc.narg(name))`, or `<>` under
	// [Match.Exclude].
	//
	// It is the other reading of an absent argument, and the two are not
	// interchangeable. [OptionalArgument] answers "compare against the value,
	// or against the sentinel no row holds", which is the collision check
	// excluding a row that may not exist yet. This one answers "compare against
	// the value, or do not compare at all", which is a filter a caller may
	// leave off — one owner's rows or everybody's, one kind of work or every
	// kind. Rendering that as OptionalArgument would filter an absent owner
	// down to the rows whose owner is the empty string, which is a working
	// query returning a set nobody asked for.
	//
	// The predicate is written as the disjunction rather than as
	// `column = COALESCE(sqlc.narg(name), column)`, and the difference is a
	// plan rather than a semantic. The COALESCE form mentions the column on
	// both sides of the comparison, so no index can serve it whatever the
	// argument turns out to be; the disjunction's second arm is an equality
	// against a parameter, which is what a server planning the statement with
	// the value in hand can walk an index for. A filter that quietly stops
	// using the index the schema ships for it is the kind of regression that
	// surfaces as a support ticket rather than as a failing test.
	//
	// Both arms name the same argument, so a caller binds one nullable value
	// and the statement reads it twice. sqlc types it from the equality, which
	// is why the IS NULL arm is never written on its own.
	OptionalNarrowing
	// AtMostArgument compares the column against a bound ceiling: `column <=
	// sqlc.arg(name)`, or `column > sqlc.arg(name)` under [Match.Exclude].
	//
	// It is [CurrentTime]'s bound sibling — the horizon a caller computed
	// rather than the one the server reads off its own clock — and it is what a
	// retention sweep is keyed on: everything recorded before this instant,
	// everything sequenced at or below this number. The clock form cannot
	// express either, because the horizon a sweep runs to is now less a window
	// the configuration carries, and interval arithmetic is the arithmetic the
	// three dialects spell three ways.
	//
	// So the subtraction happens in Go and arrives bound, which is the same
	// thing a filter window's created_before already does. The skew that
	// introduces is the application's clock against the server's, and it is
	// bounded by the window: a cutoff a second early deletes a row a second
	// early, against a horizon measured in days.
	//
	// It is also the comparand for a deadline the application stamped from a
	// clock it was handed, where [CurrentTime] would be the wrong clock rather
	// than merely the inexpressible one: a deadline written as now-plus-a-TTL
	// from an injected clock, compared against the server's, is two clocks
	// deciding one row — and under a test clock that only moves when a test
	// moves it, the two are years apart. Binding the same clock's reading puts
	// the comparison back inside one clock, which is the property CurrentTime's
	// doc asks for rather than an exception to it.
	//
	// The boundary is inclusive on the doomed side, for [CurrentTime]'s reason
	// — that is the reading which leaves no value at which a row is neither
	// past the horizon nor short of it — and Exclude is its complement rather
	// than a different question.
	AtMostArgument
)

func (Comparand) String

func (c Comparand) String() string

String names the comparand, for the panic messages the misuse checks raise.

type Direction

type Direction int

Direction is which way a keyset walk pages: oldest first, or newest first.

It is a parameter rather than a value a statement binds, and that is the whole shape of this feature. A direction decides which way the ORDER BY runs and which way the cursor comparison points, and both of those are statement text — there is no expression that takes a bound argument and orders by it in either direction on all three of these servers, and assembling one per request is the dynamic SQL this package exists to replace. So a paged list is two statements, written down in both directions, and what a store does with filtering.QueryFilter.SortBy is choose between them.

The zero value is Ascending, which is what filtering.DefaultQueryFilter asks for and what every list in this module answered before the descending half existed.

const (
	// Ascending pages oldest first: rows after the cursor, in increasing order.
	Ascending Direction = iota
	// Descending pages newest first: rows before the cursor, in decreasing
	// order.
	Descending
)

func DirectionOf

func DirectionOf(filter *filtering.QueryFilter) Direction

DirectionOf returns the direction filter asks for.

It is here rather than in filtering for the reason [BindFilter] is: filtering owns the field and its vocabulary, and the translation into what a statement is shaped like belongs to whatever renders the statement. A nil filter is Ascending, as an absent or unrecognized SortBy is — see filtering.QueryFilter.SortsDescending, which is the one reading of that field there is.

func (Direction) String

func (d Direction) String() string

String names the direction, for error messages and test failures.

type Edge

type Edge struct {
	// Table is the mapping table.
	Table string
	// From is the column matched against a row already in the working set.
	From string
	// To is the column naming the row the edge leads to.
	To string
}

Edge is a mapping table read in one direction: the column matching a row already reached, and the column naming the row reached from it.

It carries no column list, so it can carry no predicates, and that is the schema's shape rather than an omission. The mapping tables this reads are the id-less child tables — two foreign keys and a primary key over the pair, with none of the convention triple — because nothing lists, filters or soft-deletes an edge on its own. An edge is live exactly when both of its endpoints are, and the endpoints are tables the closure does carry column lists for.

type Generator

type Generator struct {
	// contains filtered or unexported fields
}

Generator emits sqlc input for one SQL dialect.

The dialect is bound to the value rather than passed to each call, because a fragment and the statement it lands in have to agree about which server will parse them. A Postgres COLLATE "C" inside MySQL is a syntax error, which is the good case; a Postgres ILIKE has no SQLite spelling at all and the substitute differs in what it folds, which is the bad one. Binding the dialect to the value is what makes a mixed pair unrepresentable rather than merely discouraged.

Every method that emits SQL hangs off this type, including the ones whose output is currently identical on all three dialects. A caller should not have to know which fragments happen to be portable this week, and a divergence found later — the archived-row toggle was portable until sqlc's type inference wanted a cast — should be a change to one method body rather than a change to the package's surface.

func For

func For(d dialect.Dialect) *Generator

For returns a Generator emitting d's SQL.

It panics on a dialect outside the supported set, in the manner of the rest of this package: the argument is a constant in a generator binary, so an unsupported dialect is a typo a build should stop for rather than a condition a caller could do anything with. The panic value is an error wrapping dialect.ErrUnsupported. A caller holding a dialect that came from configuration rather than a literal can ask dialect.Dialect.Valid first, and report the rejection in whatever terms its own users understand.

Example

The dialect decides the SQL and not the shape: the same table yields the same query names with the same arguments on all three, so the application code over the generated methods is written once.

package main

import (
	"fmt"
	"strings"

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

func main() {
	for _, d := range []dialect.Dialect{dialect.Postgres, dialect.MySQL, dialect.SQLite} {
		for line := range strings.SplitSeq(querygen.For(d).ReindexScanQuery("things"), "\n") {
			if strings.HasPrefix(line, "ORDER BY") {
				fmt.Printf("%s: %s\n", d, line)
			}
		}
	}

}
Output:
postgres: ORDER BY things.id COLLATE "C"
mysql: ORDER BY CAST(things.id AS BINARY)
sqlite: ORDER BY things.id COLLATE BINARY

func (*Generator) ArchiveQuery

func (g *Generator) ArchiveQuery(name, table string, columns []string, extra ...Match) *Query

ArchiveQuery renders the soft delete of one row by id, plus any extra predicate columns.

It takes the column list the other single-row statements take, because its predicates are derived from one like theirs: the id predicate appears only for a table that has an id, and the archived_at IS NULL that makes archiving idempotent appears only for a table whose column list says the column is there. A list omitting archived_at therefore yields an archive that restamps an already-archived row and reports it as a write — so a caller passes the table's columns rather than a subset chosen for this call.

func (*Generator) ClosureQuery

func (g *Generator) ClosureQuery(name, table string, columns []string, closure *Closure, key SetKey, matches ...Match) *Query

ClosureQuery renders the recursive closure read: the rows key selects, plus everything reachable from them along Closure.Walk, read through Closure.Reach into Closure.Table.

It answers "what does this principal's roles grant", which is the one question in this module whose answer depends on a depth nothing knows in advance:

resolve := querygen.For(dialect.Postgres).ClosureQuery(
	"ResolvePermissionsForRoles", "authz_roles", roleColumns,
	&querygen.Closure{
		Alias:      "role_closure",
		Walk:       querygen.Edge{Table: "authz_role_hierarchy", From: "child_role_id", To: "parent_role_id"},
		Reach:      querygen.Edge{Table: "authz_role_permissions", From: "role_id", To: "permission_id"},
		Table:      "authz_permissions",
		Columns:    permissionColumns,
		Projection: []string{"name"},
	},
	querygen.SetKey{Column: "name", Arg: "role_names"})

UNION, never UNION ALL

The recursive term is UNION, which is what makes the statement terminate on a hierarchy that contains a cycle: a row already in the working set is not added a second time, so the walk runs out of new rows rather than running forever. UNION ALL is the faster spelling and it is the one that hangs.

A store that writes these edges rejects cycles before they are written — that is where the error message a person can act on belongs — but a table an operator edited by hand has no such guard, and the failure this refuses is a query that never returns on the path that decides whether a request is allowed. The choice is the shape's rather than the caller's, so a corpus cannot carry a resolution that has it the other way round.

Archived rows are excluded at every join

Both column lists render the archived predicate wherever their table appears: the seed, the recursive term, and the read on the far side. So archiving a role stops the walk at it rather than merely refusing it as a seed, and archiving a permission revokes it everywhere on the next resolution without touching a mapping row.

Excluding archived rows only at the seed is the mistake this forecloses, and it is a comfortable one to make: the statement still looks keyed, still returns rows, and still passes a test that archives the role it asks about. What it does is keep granting through an archived intermediary.

The mapping tables carry no such predicate and cannot — see Edge — which is the same fact from the other side: an edge is live exactly when the rows at both of its ends are.

The seed

The seed is a bound set rather than one value, because the question is always asked of the roles a principal holds and a principal holds several. matches narrow it further, on the walked table's own columns; there is nothing to narrow the far side by beyond the archived predicate, because a caller filtering the permissions a resolution returns is a caller asking a different question.

The set is bound last in the seed's WHERE clause, as every bound set this package renders is: an expansion is a run of bare markers on two of the three dialects, and an argument numbered after one collides with an element of it.

The answer

DISTINCT, and ordered by the projection. Two roles granting one permission is the ordinary case rather than an anomaly, so the duplicate is the walk's arithmetic showing through rather than an answer; and a set whose order is whichever the planner found convenient is a set two identical calls can return differently. The ORDER BY names exactly the projected columns, which is what a SELECT DISTINCT is allowed to order by on all three servers.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

It panics rather than returning an error, in the manner of the rest of this package: its arguments are string literals in a generator binary. The panic value is an error wrapping dialect.ErrInvalidIdentifier, ErrIncompleteClosure or ErrMissingSetColumn.

func (*Generator) ContainsCondition

func (g *Generator) ContainsCondition(column, argument string) string

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

It takes the column rather than returning an operator for the caller to prefix, because only two of the three dialects have an operator that folds case on its own. The other two fold both sides explicitly, which is a predicate rather than a suffix — see Generator.substringMatch for what each dialect gets and for the one input where they disagree about the answer.

func (*Generator) CountQuery

func (g *Generator) CountQuery(name, table string, columns []string, matches ...Match) *Query

CountQuery renders the read that answers how many rows a predicate names, and nothing about what is in them.

It is the third of the reads that are not a page of rows, beside Generator.ExistsQuery and Generator.SweepQuery, and it is the one a gauge wants: the number of requests still owed past their deadline, of jobs still waiting, of rows a retention pass has left to collect. Every one of those is a number somebody watches over time rather than a page somebody reads, and answering it by draining the rows and counting them in Go makes the cost of the measurement grow with the thing being measured — which is exactly when a gauge is most wanted and least affordable.

It is not the count a list carries. Those two are scalar subqueries riding on the page, so the number and the rows describing it come from one snapshot of the table — see Generator.FilterCountSelect. This one has no page to ride on, and asking it is the whole round trip.

The predicates are the sweep's rather than the single-row statements': the archived clause where the column list carries archived_at, then one per match, and no id predicate at all. That last one is not derived from the column list the way it is for a get — a count keyed on the row's own id answers one or zero, which is Generator.ExistsQuery with more steps, so the shape declines it rather than leaving a caller to decline it by handing over a shorter list.

A count over no Match at all is ErrUnpredicatedStatement rather than a count of the table. The unpredicated form is a number about every row a database holds for everybody, which is the one number a tenancy-scoped schema has no caller for — and a statement that omits the scope column is precisely the statement this module's read rule exists to keep unspellable.

It counts rows rather than a column, because that is the question: COUNT over a nullable column answers how many of them are set, and a projection chosen here would decide that on the caller's behalf.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) CursorCondition

func (g *Generator) CursorCondition(table string, direction Direction) string

CursorCondition renders the keyset predicate: the rows on the far side of the cursor, in whichever direction the walk runs.

An absent cursor is the first page rather than a second query, which is what keeps the first page and the fiftieth the same statement. The two directions say that differently, and the difference is not cosmetic.

Ascending coalesces an absent cursor to the empty string and compares greater: no id is empty, so every row is after it. Descending has no such value to reach for, and no string this package could write down is one. A sentinel of high characters is a sentinel in one collation and a sentinel in no other — glibc's en_US.UTF-8 orders punctuation before letters and lowercase before uppercase, so 'zzz' is above every id under C and below an uppercase ULID under a linguistic collation, which is a first page missing its first rows on one server and not on another. Over a searched column it is worse still, since the values there are whatever somebody typed.

So the descending arm coalesces to the row's own key instead, which is the one value that is always in range: an absent cursor makes the first comparison the row against itself, admitting every row, and the second comparison is what keeps the walk strict without needing a sentinel to be strictly above anything. A cursor that was supplied reads both times, and the pair is "at most the cursor, and not the cursor".

Both references being comparisons against the column is also what keeps the statement analyzable. sqlc types an argument from what it is used against, and a reference that is only ever compared to a literal is one MySQL's analyzer reports no type for at all — the same problem the archived toggle solves with a cast, solved here by never writing the reference except beside the column it filters. See Generator.includeArchivedFlag.

func (*Generator) CursorLimitClause

func (g *Generator) CursorLimitClause(table string, direction Direction) 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 (*Generator) CursorPaginationFragment

func (g *Generator) CursorPaginationFragment(table string, direction Direction) 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 (*Generator) DeleteQuery

func (g *Generator) DeleteQuery(name, table string, columns []string, extra ...Match) *Query

DeleteQuery renders the hard delete of the rows one key names: the row gone rather than stamped, which is what a right-to-be-forgotten erasure means and what a set of child rows being rewritten wholesale needs.

It is the standard single-row machinery with a different verb. The key is the column list and the matches, exactly as it is for the get, the update and the archive: the id predicate is rendered when the column list has an id, each Match adds its own equality, and a statement with neither is ErrUnaddressableRow rather than a DELETE whose WHERE clause is empty and whose effect is a truncate.

What it does not render is the archived predicate, and its absence is the one thing that distinguishes this from every other statement built on that key. A hard delete of an archived row is still a delete — an erasure runs against a subject who was archived first, and a child row is cleared whether or not its parent has been — so a predicate excluding archived rows here would make the erasure the one statement that cannot reach the rows it exists for.

It is annotated :execrows, like the archive, because the count is the answer: a caller learns from it whether the row was there, and an erasure reports how much it destroyed.

The key is not required to name one row. Clearing every role a membership holds is one statement keyed on the membership, and the count is how many grants went — so this is "the rows this key names" rather than "the row", which is the other half of what separates it from the archive.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) Dialect

func (g *Generator) Dialect() dialect.Dialect

Dialect returns the dialect g emits for.

func (*Generator) ExistsQuery

func (g *Generator) ExistsQuery(name, table string, columns []string, extra ...Match) *Query

ExistsQuery renders the existence check for one row by id, plus any extra predicate columns. It reports what GetQuery's statement would find without reading it.

func (*Generator) FilterConditions

func (g *Generator) FilterConditions(table string, columns []string, direction Direction, 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 — nor, therefore, can it tell whether they are the dialect g emits for.

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, and speak the same dialect while they do it.

package main

import (
	"fmt"

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

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

	fmt.Println(g.FilterConditions("things", columns, querygen.Ascending, g.ContainsCondition("things.name", "name_query")))

}
Output:
things.created_at > COALESCE(sqlc.narg(created_after), (SELECT CURRENT_TIMESTAMP - '999 years'::INTERVAL))
	AND things.created_at < COALESCE(sqlc.narg(created_before), (SELECT CURRENT_TIMESTAMP + '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(page_cursor), '')

func (*Generator) FilterCountSelect

func (g *Generator) 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 — and a caller must not report the resulting zero as one. Nothing distinguishes it from "no rows match this filter", so a keyset walk that reports it sees filtered_count go 5, 5, 0, and a client renders "0 results" on the page after the last one. filtering.NewQueryFilteredResultWithoutCounts is what such a caller returns instead; filtering.NewQueryFilteredResult is for the page that had rows to scan the counts off.

func (*Generator) GetQuery

func (g *Generator) GetQuery(name, table string, columns []string, extra ...Match) *Query

GetQuery renders the read of one row by id, plus any extra predicate columns.

func (*Generator) IndexStampQuery

func (g *Generator) IndexStampQuery(table string) string

IndexStampQuery builds the write that maintains last_indexed_at: one UPDATE stamping every id it is handed.

It is the other half of ReindexScanQuery. The column is what marks a table as one search/sync mirrors and what the reindex scan reads, and until something wrote it the scan walked a column nothing maintained — so the statement that maintains it is emitted from the same column list, rather than being left to each consumer to hand-write once per indexed table.

The ids arrive as a set bound in one argument rather than one statement per id, because the caller is a batching.Buffer flushing a coalesced set: one statement per flush is the entire reason the write is buffered. See searchsync.NewStampBuffer, which is what a Syncer stamps through. How that set reaches the server differs by dialect and the Go signature does not — see Generator.setPredicate.

There is no owner predicate and no archived_at predicate, and both omissions are deliberate. This is the search sync's own machinery servicing itself — it stamps the rows an index accepted, which it named explicitly — rather than a consumer read that owes a tenancy scope. And a row whose archived_at is set is a row the Syncer deleted from the index rather than stamped, so a predicate excluding it would be one that never fires while making the statement unemittable for a table that has no soft delete.

func (*Generator) InsertIgnoreQuery

func (g *Generator) InsertIgnoreQuery(name, table string, insertColumns, nullable []string, key ...Match) *Query

InsertIgnoreQuery renders the write that adds a row unless one for the same key is already there, in the dialect this Generator emits.

It is a named shape rather than an upsert whose conflict branch assigns nothing — ErrDegenerateUpsert refuses that, and correctly, since an upsert is a write that converges and one assigning nothing is an INSERT that fails on the second call. This one does not fail on the second call and does not converge either: the row that is already there wins, unchanged, and the count is how the caller learns it lost. That is the write a key mint wants — the loser of a race between two replicas has generated a key it must throw away, because a second live key for one subject is a shred that leaves half the ciphertext readable — and it is why the statement is annotated :execrows while the plain insert is :exec.

The three renderings differ in shape rather than in an expression, as the upsert's do, and confined the same way: [Generator.ignoreSpelling] is the whole of it. Postgres takes a trailing ON CONFLICT (…) DO NOTHING; MySQL and SQLite take a modifier between the verb and INTO — INSERT IGNORE and INSERT OR IGNORE — and name no target at all.

The key is the conflict target, given as Match values, and the rule is the upsert's rule: those columns have to be exactly the columns of a unique index the table actually has, or Postgres rejects the statement at sqlc's analysis rather than at the first collision. The same caveat [Generator.conflictHeader] carries applies here — MySQL's IGNORE fires on whichever unique key was violated, primary key included, rather than on the one named. Nothing follows from that here the way it does for the upsert, since this statement assigns nothing to the row it found, but a table with a second unique index skips a collision on Postgres that it would have raised on.

The matches are the target and not a predicate: an INSERT has no WHERE, so nothing binds them and they contribute no arguments. The arguments are one per inserted column, as the plain insert's are.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) InsertQuery

func (g *Generator) InsertQuery(name, table string, insertColumns, nullable []string) *Query

InsertQuery renders the write that adds a row, under a name of the caller's choosing.

It is Generator.StandardCRUD's create with the id requirement lifted off it, which is the whole reason it is here. StandardCRUD needs an id because it emits the list, and the list pages by keyset over that column; an INSERT needs no such thing — a create is "these columns, these bindings" whatever the table keys on. So the child tables keyed on their parent, whose primary key is (parent_id, value) and whose whole set StandardCRUD refuses, get their create from here instead of from a hand-written statement. A natural-key table is the same case one shape earlier: an INSERT keys on nothing, so it is the one statement such a table wants unchanged from the standard set while every other one it wants keyed on that natural key — without this, its corpus would be five statements sqlc checks and a sixth nobody could render.

insertColumns is what the caller supplies — ForInsert over the table's columns — and nullable names those whose value may be NULL, exactly as they mean for the standard create. There is one argument per column, bound by column name.

A set of child rows is written one statement per element rather than one statement with a VALUES list per call. The multi-row form has no static text: its shape is the caller's cardinality, so there is nothing for sqlc to check and nothing for this package to emit. The cardinalities that reach it are single-digit — the roles one membership holds — and inside the transaction the parent's write already opened, so what it costs is a round trip each.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) JunctionListAllQuery

func (g *Generator) JunctionListAllQuery(name, table string, columns []string, junction *Junction, order []Order, matches ...Match) *Query

JunctionListAllQuery renders the unpaged junction list: every row the matches select, in the order the caller names.

It is the paged form with everything a page implies removed — no filter window, no cursor, no LIMIT, and no counts, because a caller reading every row counts them by looking at what came back. What survives is the projection, the join, the matches and the archived predicate.

Archived rows are excluded outright rather than through include_archived. An unpaged list takes no filtering.QueryFilter — that is what unpaged means here — so there is no flag to read, and a caller who wants archived rows back wants the paged form rather than an argument on this one.

order is the caller's, and may be empty; see listOrderClause for what an empty one means. The terms name columns on table, not on the junction.

func (*Generator) JunctionListQueries

func (g *Generator) JunctionListQueries(name, table string, columns []string, junction *Junction, matches ...Match) []*Query

JunctionListQueries renders the paged junction list, in both directions: a page of table's rows reached through junction, under the same filter window, archived toggle, cursor and pair of counts every other list in this package carries.

It is [Generator.listStatement] with a join spliced into its FROM — the same function StandardCRUD's list comes from — so there is one filtered read in this package rather than two that could come to disagree about what a filter means. The counts carry the join too, which is what keeps filtered_count a count of the rows the page is drawn from rather than of the listed table entire.

The cursor pages over table's id, so table is the entity being listed and junction is what it is reached through. Which of the two is which is the one decision a caller has to make, and it is decided by what a page of results is a page of: an account's roster is a page of memberships with a user attached, where a user's account list is a page of accounts reached through memberships.

The name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

A nil junction renders no join, which is exactly Generator.ListQueries's pair under names of the caller's choosing. The unpaged form is where a nil junction is the ordinary case.

It returns both directions, under name and DescendingName of it, as every paged list in this package does — a roster answering sortBy=desc with an ascending page is the same failure on two tables as on one. The join, the projection, the window and the counts are identical on both; the cursor comparison and the ORDER BY are what differ.

Example

A junction list is the one read here that spans two tables. What decides which of them is listed is what a page is a page of: a roster is a page of memberships with the member attached, so memberships is listed, the cursor walks its id, and the user's columns arrive beside them under a prefix.

package main

import (
	"fmt"
	"strings"

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

func main() {
	roster := querygen.For(dialect.Postgres).JunctionListQueries(
		"ListAccountMembers", "memberships",
		[]string{querygen.IDColumn, querygen.BelongsToAccountColumn, "belongs_to_user", querygen.ArchivedAtColumn},
		&querygen.Junction{
			Table:    "users",
			Column:   querygen.IDColumn,
			OnColumn: "belongs_to_user",
			Columns:  []string{querygen.IDColumn, "username", querygen.ArchivedAtColumn},
			Prefix:   "user",
		},
		querygen.Match{Column: querygen.BelongsToAccountColumn},
	)

	// Two statements come back rather than one: a page has a direction, and a
	// direction is statement text rather than a bound value. The ORDER BY is
	// where they part company.
	for _, query := range roster {
		fmt.Println(query.Annotation.Name)

		for line := range strings.SplitSeq(query.Content, "\n") {
			if strings.HasPrefix(line, "FROM") || strings.HasPrefix(line, "JOIN") ||
				strings.HasPrefix(line, "ORDER BY") || strings.Contains(line, " AS user_") {
				fmt.Println(strings.TrimSpace(line))
			}
		}
	}

}
Output:
ListAccountMembers
users.id AS user_id,
users.username AS user_username,
users.archived_at AS user_archived_at,
FROM memberships
JOIN users ON memberships.belongs_to_user=users.id
ORDER BY memberships.id ASC
ListAccountMembersDescending
users.id AS user_id,
users.username AS user_username,
users.archived_at AS user_archived_at,
FROM memberships
JOIN users ON memberships.belongs_to_user=users.id
ORDER BY memberships.id DESC

func (*Generator) LimitClause

func (g *Generator) LimitClause() string

LimitClause renders the page-size clause, for a read a consumer writes out rather than one this package renders.

It is the LIMIT Generator.CursorLimitClause ends with, exported on its own because a keyset walk is not the only paged read: a claim reads a bounded batch in an order of its own, and that read still owes its dialect the page size that dialect accepts. Postgres and SQLite take an expression, so an absent size coalesces to filtering.DefaultQueryFilterLimit; MySQL takes a bare placeholder and nothing else, which is the one place a dialect changes the generated signature rather than only the SQL — see this package's comment, under "The one place a dialect changes a signature".

A MySQL statement using it therefore has to place it last, since a bare marker is positional and the generated parameter is named for whatever position it landed in.

func (*Generator) ListQueries

func (g *Generator) ListQueries(name, table string, columns []string, matches ...Match) []*Query

ListQueries renders both directions of a list query carrying extra equality predicates.

It is listStatement — the same function StandardCRUD's list query comes from, with the matches where WithOwnership's column goes — so the filter window, the archived toggle, the cursor and the two counts are not merely the same ones a generated list gets, they are the same code path. A keyed read filters exactly as an unkeyed one does because there is nothing that could make it not.

It returns both directions, under name and DescendingName of it, for the reason StandardCRUD's list is one entry in its enum: a corpus carrying only the ascending half of a list is a store that answers sortBy=desc with an ascending page, which is the failure this pair exists to make unspellable. The two statements differ in their cursor comparison and their ORDER BY and in nothing else — same projection, same predicates, same counts.

Both names must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) MatchConditions

func (g *Generator) MatchConditions(table string, matches ...Match) []string

MatchConditions renders the predicates a set of Match values makes, table-qualified, for a statement a consumer writes out rather than one this package renders.

It is the same rendering every keyed statement here gets its predicates from, exported for Generator.FilterConditions's reason: an authored statement narrowed the way a generated one is narrowed must not spell the narrowing a second time. Two of the comparands are dialect facts rather than shapes — OptionalNarrowing's NULL arm carries a cast whose spelling differs on all three engines, and CurrentTime asks for the clock in the units a statement stores it in — so a consumer writing those out by hand would be writing down what Generator.unsetArgument and Generator.storedNow already say, in a place nothing checks against them.

The predicates are qualified because the statements that take them are reads: a list carries its predicates in the SELECT and again in each count subquery beside it, where an unqualified column is ambiguous. An authored UPDATE or DELETE wants the unqualified form, which is what the shapes that render one already emit.

func (*Generator) PrefixSearchQueries

func (g *Generator) PrefixSearchQueries(table string, columns []string, search PrefixSearch, matches ...Match) []*Query

PrefixSearchQueries renders a prefix search: a page of rows whose column begins with a bound pattern, and the count of everything that pattern matches.

It emits a set because a search is one. The standard list carries its two counts as scalar subqueries in its own SELECT list, so the page and the numbers describing it come from one statement at one moment; that does not carry over here, because a search's page is cut by a cursor over the same column the pattern filters and the count a caller wants is of everything the pattern matched rather than of what is left after the cursor. So the count is a separate statement, and it is emitted from the same call as the page it counts — a consumer that emitted one and hand-wrote the other would have half its search checked by sqlc and half of it not, which is the gap the canonical corpus exists to close.

The page is emitted in both directions, under Name and DescendingName of it, because a search takes a filtering.QueryFilter like any other paged read and that filter carries a direction. What the direction means here is this statement's own order rather than creation order: a search is ordered by the column it searched, so its descending half walks that column backwards — which is the reading that keeps the cursor and the ORDER BY agreeing, and the only one available to a statement that never orders by the id. The count is direction-independent and is emitted once.

The statements share every predicate but one. The count is a page's WHERE clause without the cursor, for the same reason filtered_count omits it: a count that shrank with every page is a progress bar that never fills.

The cursor predicate is always rendered, and an absent cursor is the first page — so the first page and the fiftieth are one statement, the same way the standard list's keyset walk is. See Generator.CursorCondition for how each direction says that.

Archived rows are excluded outright rather than through the include_archived toggle a filtered list carries. A prefix search is a lookup — somebody is typing a name in order to act on whoever comes back — and a soft-deleted row surfacing in one is a deleted account offered up for a new membership. A caller who wants archived rows wants a different query rather than a flag on this one, which is the same reading the single-row statements take.

matches are the equality predicates the search is keyed on beyond the pattern — the tenancy scope, conventionally — and they land in both statements, so a page and its count cannot come to disagree about whose rows they are.

It panics rather than returning an error, in the manner of the rest of this package: its arguments are string literals in a generator binary. The panic value is an error wrapping dialect.ErrInvalidIdentifier, ErrUnknownSearchColumn, or ErrDuplicateQueryName.

func (*Generator) PruneQualifier

func (g *Generator) PruneQualifier(table string) string

PruneQualifier names the pruned table the way Generator.PruneQuery's own predicates name it on this dialect, so that a Prune.Conditions entry and the predicates beside it address one table under one name.

The two arms disagree about what that name is, and neither is a preference. Where the bound goes on a read, the doomed rows are scanned under an alias — SQLite resolves a bare column against both the DELETE's target and the subquery's table and calls it ambiguous at run time — so a condition names the alias. Where the DELETE carries the bound itself there is no second occurrence of the table and nothing to alias, so a condition names the table.

A condition that got this wrong would not usually fail to parse. It would resolve against whatever other table the condition's own subquery names, which is a predicate that runs, returns rows, and dooms the wrong ones — so the name is asked for here rather than assumed there.

func (*Generator) PruneQuery

func (g *Generator) PruneQuery(name, table string, prune Prune, matches ...Match) *Query

PruneQuery renders the delete a retention pass runs: the rows its predicates doom, capped so that one pass touches a bounded number of them.

The cap is the whole shape. A table nobody has swept for a month holds a month of rows past its horizon, and the unbounded DELETE that clears them is one statement holding locks for minutes, replicating as one transaction, and timing out somewhere in the middle — after which the next attempt starts from the beginning. A capped pass is a loop the caller owns instead: it deletes as many rows as it was allowed, reports how many that was, and runs again while the count says there are more. That is why this is annotated :execrows. The count is not a courtesy here, it is the loop's condition.

Two grammars, and one of them is a choice

MySQL caps the DELETE itself, with the ORDER BY and LIMIT its own grammar takes. Postgres and SQLite have no DELETE … LIMIT, so the bound goes on a read instead: a capped SELECT names the doomed rows and the DELETE removes whatever it named.

One of those arms is forced and the other is not, and it is worth being exact about which. Postgres does not parse DELETE … LIMIT at all, and SQLite parses it only in builds compiled with SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which most are not — a failure that waits until run time — so the doomed subquery is the only bounded delete those two have. MySQL is the one with a choice: it refuses a subquery that reads the table being deleted from (ER_UPDATE_TABLE_USED, error 1093), but it accepts the identical rows once that scan is materialized through a derived table, which is the spelling Generator.SweepDeleteQuery renders there and dataprivacy's MySQL corpus executes. boundedWriteForm is where the three spellings and the servers that take them are written down.

This shape declines the derived table because the native arm is strictly better for what a prune is: no materialization, no second projection, and the key columns — which on MySQL are never rendered at all — cost nothing. What it gives up is the property the sweep is buying, that one scan serves a read and two writes; a prune has no read to keep in step with. So the divergence is confined to Generator.boundedDelete the way the upsert's is to Generator.conflictHeader, and the fact underneath it is not confined at all — it is one table both shapes derive from.

Locked where the dialect locks

The capped read takes FOR UPDATE SKIP LOCKED on Postgres, which is what lets a fleet prune one table at once: each pass locks the batch it chose and skips whatever another holds, so two pruners take disjoint batches instead of queueing behind each other. A row skipped is still past the horizon on the next pass, which is what a reaper can afford and a claim cannot — it is the one writer with nothing to prove.

SQLite has no FOR UPDATE, and its absence there is correct rather than missing: one writer at a time is the whole storage model, so there is nothing to skip and the capped read is the degenerate unlocked one. MySQL's arm has nowhere to put a lock clause, since the DELETE itself carries the bound — so two pruners racing there serialize on the rows they both chose rather than dividing them. Every pass stays bounded and correct on all three; what the grammar decides is throughput under contention.

What it dooms

The matches are the horizon — a timestamp at or before a bound cutoff, a completed_at that is not null, the queue whose backlog is being reaped — and the shape refuses to be handed none of them. A prune with no predicate dooms every row in the table, which is a truncate run a batch at a time, so it is ErrDegeneratePrune rather than a statement that empties a table one pass at a time until somebody notices.

There is no archived predicate, for Generator.DeleteQuery's reason: the row is being destroyed rather than hidden, and a row archived a year ago is precisely the row a retention pass exists to remove.

The cap binds under LimitArg on all three dialects, and it is required. An absent cap is the unbounded statement this shape exists to make unspellable, so it has no default the way a page size does — see Generator.capClause, and Generator.boundedLimit beneath it for the one thing that does differ, which is that MySQL's grammar takes a bare placeholder after LIMIT and has nowhere to put the name.

The ordering is required for the reason ErrUnorderedBoundedStatement gives, which is the reason the sweeps require theirs: one answer, argued in one place, for every bounded statement this package renders.

This or the sweep

Generator.SweepDeleteQuery is the other bounded delete here. Take this one for a retention pass over an append-only table, or for any pass whose rows are addressed by something other than an id: the key may be a natural key of several columns, archived rows are doomed like any others, and Postgres gets the lock clause that lets a fleet of reapers divide a backlog. Take the sweep where the rows are addressed by id, archived rows must be left alone, and the same scan has to serve a read or an update as well. The package comment's "Choosing between the prune and the sweep" works through the reapers this module already has.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

It panics rather than returning an error, in the manner of the rest of this package: its arguments are string literals in a generator binary. The panic value is an error wrapping dialect.ErrInvalidIdentifier or ErrDegeneratePrune.

Example

The bounded prune is the one shape whose three renderings are three statements. The call is the same on every dialect — one name, one key, one horizon, one cap — and what differs is where the grammar will accept the bound.

package main

import (
	"fmt"

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

func main() {
	prune := querygen.Prune{
		Key:   []string{"idempotency_key"},
		Order: []querygen.Order{{Column: "recorded_at"}},
	}
	horizon := querygen.Match{Column: "recorded_at", Arg: "horizon", Against: querygen.AtMostArgument}

	for _, d := range []dialect.Dialect{dialect.Postgres, dialect.MySQL} {
		query := querygen.For(d).PruneQuery("PruneMeteringEvents", "metering_events", prune, horizon)

		fmt.Printf("-- %s\n%s\n", d, query.Content)
	}

}
Output:
-- postgres
DELETE FROM metering_events
WHERE idempotency_key IN (
	SELECT doomed.idempotency_key
	FROM metering_events AS doomed
	WHERE doomed.recorded_at <= sqlc.arg(horizon)
	ORDER BY doomed.recorded_at ASC
	LIMIT sqlc.arg(result_limit)
	FOR UPDATE SKIP LOCKED
);
-- mysql
DELETE FROM metering_events
WHERE recorded_at <= sqlc.arg(horizon)
ORDER BY recorded_at ASC
LIMIT ?;

func (*Generator) ReadQuery

func (g *Generator) ReadQuery(name, table string, columns []string, read Read, extra ...Match) *Query

ReadQuery renders a keyed read that is not the standard get: one that returns a narrower projection than the table, or that keys on something other than the row's own id, or both.

columns stays the table's shape — what the id and archived predicates are derived from — and read says what comes back. A table keyed on a natural key while still carrying an id leaves the id out of columns and names it in read.Projection, which is the same idiom a table with no id at all already uses, with the projection now able to say so.

func (*Generator) ReindexScanQuery

func (g *Generator) 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 rather than the database's default collation, on every dialect, for a reason the merge in search/sync's pruner makes unforgiving — see Generator.byteOrdered.

func (*Generator) SetCondition

func (g *Generator) SetCondition(column, argument string) string

SetCondition renders a column matched against a whole set of values bound as one argument, for a statement a consumer writes out rather than one this package renders.

It is the same predicate Generator.SetReadQuery keys on, and it is exported for the same reason Generator.FilterConditions is: a corpus that authors a statement this package has no shape for still has to spell the set the way its dialect spells one. Postgres takes the whole set as an array argument and the other two take a sqlc.slice expansion, which is a difference in what reaches the server rather than in the []string a caller binds — and a second copy of that fact in a consumer's generator is a copy that can drift.

Where the predicate may sit in the statement is the caller's to get right, and it is not free: an expansion is a run of bare markers, SQLite numbers a bare marker one past the highest it has seen, and an argument bound after one collides with an element of the set. So an authored statement renders its set after every other bound value, exactly as SetReadQuery does.

func (*Generator) SetListQueries

func (g *Generator) SetListQueries(name, table string, columns []string, key SetKey, matches ...Match) []*Query

SetListQueries renders both directions of a paged list narrowed by a bound set: the rows whose keyed column holds any of the values the caller binds, under the same filter window, cursor and pair of counts every other list here carries.

It is the read behind "show me this owner's failed and cancelled operations": a filter over a closed domain, where what the caller has is a set of values rather than one. Expressed as Match values it would be one statement per subset — eight of them for three optional narrowings, sixteen once each is emitted in both directions — and a store choosing between sixteen generated row types converts rows to its own type sixteen times.

The set is not optional and the empty set matches nothing, which is the same contract Generator.SetReadQuery carries and the same reason: the arity belongs to the values. A caller whose filter is "any of them" binds the whole domain rather than binding nothing — which is expressible precisely because the domains this shape suits are closed ones — and a caller whose domain is not closed wants OptionalNarrowing on a single value instead.

Postgres only

The set is bound three times in one statement, and only an array-typed argument can be. See ErrPositionalSetInList, which is what this panics with elsewhere.

Both names must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) SetReadQuery

func (g *Generator) SetReadQuery(name, table string, columns []string, read Read, key SetKey, matches ...Match) *Query

SetReadQuery renders the read a batched consumer needs: every row whose keyed column is in a bound set, ordered by that column.

It is the shape every N+1 read collapses into. A roster page of thirty members whose roles are fetched inside the loop that converts rows is thirty round trips returning two rows each; the same page reading all thirty members' roles through one of these is one. What the caller does with what comes back is group it by the keyed column, which is why the ordering is the key's rather than the id's — a consumer walking the rows in order sees each key's rows together, and Read.Order breaks the tie inside one key's group.

The empty batch is the caller's to answer

A batch of nothing has no statement here. `IN ()` is a syntax error on MySQL and SQLite, so there is no text to emit for a zero-length set; what happens instead is a convention of whatever generates the Go, and both sqlc and sqlc-gen-unison substitute a NULL that matches no row. So an empty batch is not a failure — it is a round trip whose answer was known before it was sent, on a path that is already there to save round trips.

The contract, then, is that the caller answers it: no keys, no query, no rows. It belongs in the caller because the arity does — this package emits text, and the length of a set is not a fact about text.

The set binds last

The set predicate is rendered after every Match, and that is a requirement rather than a layout choice. On the dialects with no array type the set is a sqlc.slice expansion — one placeholder per element, each a bare `?` — and SQLite numbers a bare marker one past the highest index it has seen, so an argument bound after an expansion collides with an element of the set, matches nothing, and reports no error. Rendering the set last is what keeps the shared argument order the same on all three engines.

What the column list decides

columns is the table's shape, exactly as it is for the single-row reads: the archived predicate is rendered when the list carries archived_at and not otherwise, and read.Projection is what the SELECT lists. A hydration read — "who created each of these rows" — is a read that wants the archived ones too, and it says so by handing over a column list without archived_at in it, the same idiom a read keyed on something other than the id uses to leave the id predicate off.

The keyed column is not required to be in that list and is not required to be projected, though a consumer grouping the rows by it will want it back.

The key is text

The bound set is a set of text values on every dialect: Postgres casts the argument to text[], which is the array type its ANY() reads. That is this module's key convention rather than a limitation discovered here — ids are xids and natural keys are strings — but a set over an integer column is a statement this package does not render.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

It panics rather than returning an error, in the manner of the rest of this package: its arguments are string literals in a generator binary. The panic value is an error wrapping dialect.ErrInvalidIdentifier or ErrMissingSetColumn.

func (*Generator) StandardCRUD

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

StandardCRUD emits the queries every table following this module's row conventions needs: create, get, exists, the paged list in both directions, update, archive, the id scan a search reindex walks, and the stamp that maintains the column the scan reads.

The list is two statements because a page has a direction and a direction is statement text — see Direction. They differ in their cursor comparison and their ORDER BY and in nothing else, and the descending one is named with DescendingSuffix, so a store holding a filtering.QueryFilter picks between two generated methods rather than assembling an ORDER BY.

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 neither the reindex scan nor the stamp; 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.

It also registers the table — see Registry. That is the half of this call a consumer needs when it stops making it: a table's queries can move somewhere else, but the table still exists and still has rows in it, and the list a consumer reads back should not shorten because something else started producing the SQL. WithRegistry chooses where the name lands.

Which queries appear does not depend on the dialect, and neither do their names. A table generated for Postgres and the same table generated for SQLite yield the same set of sqlc methods with the same signatures — bar the two places sqlc's own type inference differs, which the package comment names — so the application code above them is written once. What differs is the SQL under each name.

Example

A table's generator names the dialect, 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/v13/database/dialect"
	"github.com/primandproper/platform-go/v13/database/querygen"
)

func main() {
	queries := querygen.For(dialect.Postgres).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
ListWebhooksDescending :many
UpdateWebhook :execrows
ArchiveWebhook :execrows

func (*Generator) StoredNow

func (g *Generator) StoredNow() string

StoredNow renders the current time as a statement should store it, which is not the same as NowExpression on every dialect — MySQL's bare CURRENT_TIMESTAMP is second-granular whatever precision the column declares.

It is what the generated writes assign last_updated_at from, exported for the hand-authored ones beside them: a corpus with a generated update stamping CURRENT_TIMESTAMP(6) and an authored one stamping CURRENT_TIMESTAMP has two answers to the same question, and the difference only shows up as MySQL reporting zero rows changed for a write that was correct — see Generator.storedNow, which is the whole of the reasoning.

func (*Generator) SweepDeleteQuery

func (g *Generator) SweepDeleteQuery(name, table string, columns []string, order []Order, matches ...Match) *Query

SweepDeleteQuery renders the bounded hard delete: the rows a predicate names, oldest first, no more than a limit of them, gone in one statement.

It is the retention pass, and it is one statement rather than a scan followed by deletes for the reason every guarded write in this module is one statement: the predicate that decides which rows go is evaluated by the server at the moment they go. A scan whose ids are deleted afterwards decides on rows read earlier, and what changes in between is precisely what the predicate was asking about.

The rows are named through a subquery rather than by a LIMIT on the DELETE itself. Two of the three dialects have no such clause; the third, MySQL, does, and this shape declines it — see boundedWriteForm for what each server accepts. What the subquery is, is Generator.SweepQuery's statement projecting the id — the same predicates, the same ordering, the same limit clause — so the rows this deletes are the rows that read would have returned, and that identity is worth more here than one dialect's cheaper grammar.

It carries no archived predicate of its own, exactly as Generator.DeleteQuery carries none: an erasure runs against a subject who was archived first. What the inner scan does with archived_at is still the column list's decision, so a caller that means "the live ones" says so by handing over a column list that has the column in it.

It is annotated :execrows because the count is the answer: a sweep reports how much it collected, and a pass that came back full is a pass that is not keeping up.

This or the prune

Generator.PruneQuery is the other bounded delete here, and the two are not interchangeable. This one addresses rows by id, respects archived_at wherever the column list carries it, and renders the same scan a read and an update also render from. The prune addresses rows by any key — an id or every column of a natural key — never excludes an archived row, and renders MySQL's native bound and a Postgres lock clause that this shape has nowhere to put.

So: a pass over a soft-deleting table whose rows a caller could also be listing takes this one; a retention pass over an append-only table, or one keyed on something that is not an id, takes the prune. The package comment's "Choosing between the prune and the sweep" works through the reapers this module already has.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) SweepQuery

func (g *Generator) SweepQuery(name, table string, columns []string, sweep Sweep, matches ...Match) *Query

SweepQuery renders the bounded read a background pass runs: the rows a predicate names, most due first, no more than a limit of them.

It is the one read here that is neither a get nor a list, and the two reasons are the two things a list has that a sweep must not. A list carries the filter window, which describes what a caller asked to see; a sweep's predicate describes what has become due, and a window over it would let a caller's unrelated date range decide which expired artifacts get collected. And a list pages by keyset over the id, which is a position a caller holds between round trips; a sweep holds no position at all — each pass starts at the most overdue row, because the rows it collected last time are no longer due.

The limit is the page-size argument every other bounded statement here binds, so a caller's batch size reaches all three dialects the one way — see [Generator.limitClause] for the one place that changes a generated signature.

Whether archived rows come back is decided the way it is everywhere else in this package: by the column list. A sweep over a table that soft-deletes excludes the archived rows; one whose column list omits archived_at collects them too.

A sweep with no Match is ErrUnpredicatedStatement, and one whose Sweep names no ordering is ErrUnorderedBoundedStatement.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) SweepUpdateQuery

func (g *Generator) SweepUpdateQuery(
	name, table string,
	columns, updateColumns, nullable []string,
	order []Order,
	matches ...Match,
) *Query

SweepUpdateQuery renders the bounded stamp: the same set of rows Generator.SweepDeleteQuery would remove, assigned instead.

It is the sweep whose subject has nothing outside the database to clean up — a confirmation window that lapsed touches no bucket and no domain — so the whole pass is one statement and the count of what moved is what it returns. The sweep that does have something outside is Generator.SweepQuery: the object goes first, and the row is stamped afterwards, one at a time, because a bulk write there would leave every artifact in the bucket with nothing left pointing at it.

updateColumns is what this statement assigns, exactly as it is for Generator.UpdateQuery, and last_updated_at stamps by convention where the column list carries it. The predicates are the inner scan's rather than the UPDATE's own, so the rows assigned are the rows the same predicate named when the server evaluated it.

It is annotated :execrows because the count is the answer, in the sense the bounded delete's is: how many rows this pass collected.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) TotalCountSelect

func (g *Generator) 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.

func (*Generator) UpdateQuery

func (g *Generator) UpdateQuery(name, table string, columns, updateColumns, nullable []string, extra ...Match) *Query

UpdateQuery renders the update: the named columns assigned, last_updated_at stamped, keyed on the id and any extra predicate columns.

updateColumns is what this statement assigns rather than what the table lets anyone assign, which is what makes it the field-specific writes as well as the conventional one. A password change names the hash, the forced-change flag and the stamp that goes with them; a status move names the status and its explanation. Each is one statement whose SET list is written down, rather than a whole-row write a caller has to remember not to reach for with a struct whose credential fields it blanked.

The extra matches are the guard. A write that must not race another one names the value it requires the row to still hold — the token a verification link carries, the owner a transfer is moving away from, the pending status an answer replaces — and the row count reports whether it was the one that won. Where such a guard names a column the SET list also assigns, the two ends need two argument names or the statement sets the column to the value it is requiring it to already hold; that is what Match.Arg is for.

A caller wanting to move a row between owners without guarding also wants that column out of updateColumns, which is what ForUpdate's exceptions are for — and a table keyed on a natural key wants every column of that key out of it, since ForUpdate subtracts the id and knows nothing of the rest.

It is annotated :execrows rather than :exec, like the standard update, because the count is the answer: a guarded write that matched nothing is how a caller learns it lost the race, and an unguarded one that matched nothing is how it learns the row was already gone.

func (*Generator) UpsertQuery

func (g *Generator) UpsertQuery(name, table string, columns, insertColumns, updateColumns, nullable []string, key ...Match) *Query

UpsertQuery renders the write that inserts a row or, when a row for the same key already exists, brings that row up to date — under one name, in the dialect this Generator emits.

It is the one statement in this package whose three renderings differ beyond their placeholders. Postgres and SQLite name the conflict target and assign through the EXCLUDED alias; MySQL names no target and spells the incoming value VALUES(column). The divergence is confined to Generator.conflictHeader and Generator.insertedValue, and everything else — the INSERT, the column order, the assignments, the arguments, the row that comes out — is the same on all three. A consumer generating Go from these files gets one signature.

The key is the conflict target, given as Match values, and there is no second way to say it: a conflict target declared separately from the key is a pair of facts that can disagree, and a conflict target that disagrees with the key is only ever a bug. On Postgres and SQLite those columns have to be exactly the columns of a unique index the table actually has, or the server rejects the statement — "there is no unique or exclusion constraint matching the ON CONFLICT specification" — which is the good failure, since it happens at sqlc's analysis rather than at the first collision.

What the conflict branch assigns

updateColumns, less any column the key names, plus two the column list decides:

  • archived_at is cleared where the table has one, because an upsert onto a soft-deleted row that left it archived would be a write that reports success and leaves the row invisible to every read. Reviving is what an upsert on a soft-deleting table means; the alternative is a silent no-op.
  • last_updated_at is stamped where the table has one, from the server's clock, exactly as the standard update stamps it.

A key column named in updateColumns is dropped rather than assigned. On Postgres and SQLite assigning it would be a no-op — the row was found by matching it — but on MySQL the collision may have been on some *other* unique key, primary key included, and the assignment would then move the row onto the incoming key rather than restate it. Dropping is the reading that is right on all three.

created_at is in neither list, because ForInsert excludes it and no caller supplies it: the row keeps the creation time the database gave it the first time, which is what makes a revived row an old relationship rather than a new one.

The arguments

One per inserted column, bound by column name, and none for the conflict branch — every assignment there reads a value the INSERT already carried. So an upsert takes exactly the arguments the equivalent create takes, and a caller that can build the create's params can build these.

name must be unique across the consumer's whole sqlc package, as every QueryAnnotation.Name must.

func (*Generator) WindowConditions

func (g *Generator) WindowConditions(column, afterArg, beforeArg string) []string

WindowConditions renders both halves of the filter window over a column that is not the convention's own created_at: the rows recorded after the lower bound and before the upper one, with an absent bound admitting everything.

Generator.FilterConditions derives the window from the column list, which is right for a table following the convention and silent for one that does not. A table whose "when did this happen" column is its own — an audit entry's recorded_at, which is the caller's fact rather than the row's creation time, and which a hash covers — gets no window from that derivation and still owes its reader one.

So this renders the same predicate pair against a column the caller names, and the reason it is here rather than in that caller is the sentinel: an absent bound coalesces to a timestamp 999 years away rather than dropping the predicate, so that every subset of the two bounds is one statement, and the arithmetic producing that timestamp is spelled three ways — see Generator.timeHorizon, which is the whole of the reasoning.

The argument names are the caller's because the window's meaning is. A filtering.QueryFilter binds CreatedAfterArg and CreatedBeforeArg whatever column they land on, so a paged read over such a table names those; a range a method takes as two parameters of its own is not that filter, and names something its reader recognizes.

column is rendered as given, so a caller qualifies it the way the statement it lands in needs — see Qualify.

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. An inner join on an equality is the one piece of SQL in this package that all three dialects spell identically, so it is a plain String rather than something a Generator has to render.

type Junction

type Junction struct {
	// Table is the table joined in, and it is required — a list with no join
	// passes no Junction rather than an empty one.
	Table string

	// Column is the column on Table the join matches, and OnColumn is the
	// column on the listed table it is matched against. Both are required when
	// Table is set: a join needs two sides, and this package will not guess
	// which column of one table points at the other.
	Column   string
	OnColumn string

	// Prefix is the alias every projected column of Table carries — a Prefix of
	// "user" renders users.id AS user_id — and an empty Prefix projects none of
	// them.
	//
	// It is not decoration and it is not optional for a projection. Two tables
	// following this module's row conventions share most of their column names,
	// so an unaliased two-table projection has two columns called id, two called
	// scope, and two called created_at. What a generator downstream makes of
	// that is its own business — sqlc suffixes the repeats with an ordinal — and
	// the result is a row type whose field names depend on the order the SELECT
	// happened to list its tables in. Naming the prefix is what keeps the row
	// type readable and stable; requiring it for a projection is what makes the
	// unaliased case unrepresentable rather than merely discouraged.
	Prefix string

	// Columns is Table's full column list, in the order a projection should
	// list them.
	//
	// It is the whole list rather than the projected subset, because the
	// predicates the join contributes are derived from it the way every other
	// statement here derives its predicates: a joined table with an archived_at
	// is required to be live, and one without it is not asked to be. A caller
	// that wants the join's predicates and none of its columns supplies the
	// columns and no Prefix.
	Columns []string

	// Matches are equality predicates on Table's columns — the key a junction
	// list is read by when the key lives on the far side of the join, which is
	// what "the accounts this user belongs to" is.
	//
	// Each binds under its own column name, as the listed table's matches do, so
	// a column named on both sides of the join binds one argument to both. Two
	// tables that genuinely need separate values for a shared column name are
	// outside what this shape expresses.
	Matches []Match
}

Junction is the second table a junction list reads through: how it is joined, what it contributes to the WHERE clause, and whether its columns are projected beside the listed table's.

A nil *Junction is no join at all. That is what a read of the junction's own rows takes — a user's memberships, keyed on the user — and it is why Generator.JunctionListAllQuery takes one rather than assuming one.

type Match

type Match struct {
	// Column is the column matched. It is bound, never interpolated, so its
	// value needs no escaping; the name itself is interpolated and is therefore
	// restricted — see dialect.ValidIdentifier.
	Column string
	// Arg names the argument the column is compared against, for a predicate
	// whose value is not simply "this column's value". It defaults to Column,
	// which is what every keyed read wants: a get by account binds the account
	// under belongs_to_account and nothing is clearer than that.
	//
	// A guarded write is what needs the other spelling. Naming the current
	// owner in a transfer's predicate as well as the new one in its SET is the
	// whole mechanism that stops two concurrent transfers from both succeeding,
	// and both halves are the owner column — so under one argument name the
	// statement would set the column to the value it was requiring it to
	// already hold, which is legal SQL that guards nothing. Arg is what makes
	// the two ends of that comparison two arguments.
	//
	// It is a name rather than a value, and it is interpolated into the
	// statement the way Column is, so it is restricted the same way.
	//
	// Only the two comparands that bind anything read it — see [Comparand].
	// Naming an argument that a NULL, empty-string or clock comparison has
	// nowhere to put is ErrArgumentlessMatch rather than dead text in a
	// statement.
	Arg string
	// Against is what Column is compared against. The zero value is the bound
	// argument, which is what every keyed read wants; the rest are the guard
	// forms — see [Comparand].
	Against Comparand
	// Exclude inverts the predicate: the rows matched are the ones the
	// uninverted form would have left out.
	//
	// It is a field on Match rather than a second type because the two are the
	// same predicate over the same comparand, differing in one operator, and a
	// caller assembling a mixed key writes one slice either way. The read that
	// wants it against a bound value is the one looking for another row like
	// this one — the remaining live membership when the default is being
	// removed — where the excluded value is as much a part of the key as the
	// included ones.
	//
	// It inverts every comparand rather than only the bound one, and each
	// inversion is a complement rather than a different question: IS NULL
	// becomes IS NOT NULL, `= ”` becomes the not-empty guard, and a clock
	// comparison flips from "at or before now" to "after now". So a guard and
	// the rows it refuses are one Match with one bool between them, which is
	// what keeps "unexpired" and "expired" from being two spellings that can
	// come to disagree about the boundary.
	Exclude bool
}

Match is a predicate on one column, for a read keyed on something other than the row's own id — comments on one reference, signups for one waitlist, or the whole key of a table whose primary key is natural rather than a surrogate id — and for the guards a write puts its own correctness on.

It is a column name rather than rendered SQL because the statements it lands in render it more than once: a list query carries its predicates in the SELECT and again in each of the two count subqueries beside it. A caller handing over finished SQL would have to know how many times its argument was about to appear, which is a property of the assembled statement rather than of the predicate. Handing over the column instead leaves that to whatever renders the finished text.

What the column is compared against is Match.Against — a bound argument by default, and one of a small closed set of things a statement owns otherwise. See Comparand.

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 WithNullable

func WithNullable(columns ...string) Option

WithNullable names columns an INSERT or an UPDATE may set to NULL, binding them with sqlc.narg rather than sqlc.arg so the generated Go parameter is a pointer instead of a value.

It cannot be derived. A column list is names, and whether the column behind one is NOT NULL lives in the schema this package never reads. Nor does getting it wrong stop a build: sqlc generates against the schema, so an omitted nullable column yields a parameter that cannot express the NULL the column accepts, and a column named here that is NOT NULL yields one that can express a NULL the database will reject at runtime. Both are quiet, which is why they are declared at the table rather than inferred from one.

Reads are unaffected — a SELECT lists the column either way.

func WithOmitted

func WithOmitted(queries ...StandardQuery) Option

WithOmitted drops queries from the set, for a table whose rows are not addressable the way the whole set assumes.

Not every table following these conventions is a resource. A child row written as part of its parent and only ever read through it has no caller for a get by id, an exists, or a list, and emitting them anyway produces generated methods nobody calls next to a read path that answers without whatever scoping the parent's own queries apply — the sort of query that is found later by someone looking for a convenient way to fetch a row.

It only subtracts. What StandardCRUD emits stays a subset of what the column list justifies, so a table without archived_at still cannot acquire an Archive and this option cannot conjure a query the columns do not support. Naming a query the columns already exclude is not an error; it says the same thing twice.

Omitting everything yields an empty slice, which RenderFile renders as the empty string rather than a file with no queries in it.

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.

func WithRegistry

func WithRegistry(r *Registry) Option

WithRegistry registers the table in r rather than in the package-level registry.

The default is the point — one list, whatever produced a given table's SQL — so this is for the two cases where one list is wrong: a binary generating for schemas that are genuinely separate databases, and a test that wants to assert on exactly what it registered.

It panics on a nil registry, wrapping ErrNilRegistry, rather than reading it as "do not register". Dropping a table quietly out of the list is the failure this whole mechanism exists to prevent, so it is not something an option can ask for.

type Order

type Order struct {
	// Column is the column sorted on.
	Column string
	// Descending sorts the column the other way — the flag a default-first
	// ordering puts on the flag column.
	Descending bool
}

Order is one ORDER BY term of an unpaged list: a column on the listed table, and which way it sorts.

The direction is spelled in the emitted SQL either way rather than leaning on the server's default, so that reading the statement answers the question the reader has.

func (Order) String

func (o Order) String() string

String renders the term as an ORDER BY spells it, unqualified.

type PrefixSearch

type PrefixSearch struct {
	// Column is the column the pattern matches, the page is ordered by, and the
	// cursor pages over. It has to be in the table's column list.
	Column string
	// Name is the paged search's query name and CountName is the count's. Both
	// must be unique across the consumer's whole sqlc package, as every
	// QueryAnnotation.Name must — and so must [DescendingName] of Name, which
	// is what the descending half of the page is emitted under.
	Name      string
	CountName string
}

PrefixSearch is a paged search over the leading characters of one column: the column, and the names its statements take.

One column does three jobs here, which is why the shape names it once rather than three times. It is what the pattern matches, what the page is ordered by, and what the cursor compares against — and those three have to be the same column or the walk pages through an order the cursor does not name, which skips rows and repeats others with nothing reporting an error. A search ordered by id would page in creation order while the caller reads a list sorted by name.

type Prune

type Prune struct {
	// Key names the columns that address a doomed row — the id of a
	// conventional table, or every column of a natural key.
	//
	// It is what the capped read projects and what the DELETE compares
	// against, and it is required on every dialect including the one that
	// renders neither. MySQL caps the DELETE itself and never names a key at
	// all; a corpus is authored once and rendered three times, so a field one
	// arm ignores is still the field that decides whether the other two have a
	// statement.
	//
	// More than one column renders a row-value comparison — `(a, b) IN (SELECT
	// d.a, d.b …)` — which is the queue tables' shape, where (queue_name,
	// item_key) names a row and neither half of it does.
	//
	// The names are interpolated, so they are restricted rather than escaped —
	// see dialect.ValidIdentifier.
	Key []string
	// Order is the order the doomed rows are chosen in, and it is required —
	// see [ErrUnorderedBoundedStatement], which is the same requirement the
	// sweeps carry and is argued there once for both.
	//
	// Two orderings have callers here. A pass ordered by the column its horizon
	// compares against takes the oldest rows first, so a backlog drains in the
	// order it accumulated and its age is a number somebody can watch. And a
	// reaper sharing its table with keyed writers wants the primary key's
	// order, because taking row locks in the order every other writer takes
	// them is what keeps a pass out of a deadlock.
	Order []Order
	// Conditions are predicates this shape has no spelling for, rendered
	// beside the ones [Match] renders and joined to them by AND.
	//
	// A doom is usually a horizon or an equality, which a Match says. metering's
	// retention pass is the one that is not: an event row may be destroyed only
	// once the period it was folded into owes the provider nothing, and that is
	// a correlated NOT EXISTS over a second table rather than a comparison of a
	// column against a value. Rendering it would need an expression language
	// here, which the closed [Comparand] set exists to refuse — but the shape
	// around it is a bounded delete like any other, and a caller sent away to
	// write the whole statement out would be a caller writing down which of the
	// three spellings their server takes.
	//
	// So the predicate is the caller's and the statement is still this one:
	// same cap, same ordering, same per-dialect arm, same :execrows count the
	// pass loops on. What a condition gives up is the guarantee that its
	// predicate was derived rather than remembered, which is what every
	// authored statement in a corpus gives up.
	//
	// They are rendered verbatim, so a condition naming a column of the pruned
	// table qualifies it with [Generator.PruneQualifier] — which name that is,
	// is the dialect's answer rather than the caller's, since one arm bounds a
	// read of the table under an alias and the other bounds the DELETE itself.
	//
	// A condition is a narrowing beside the horizon rather than a substitute
	// for one: a prune whose only predicates were authored is still
	// [ErrDegeneratePrune], because what makes a pass a retention pass is a
	// horizon this package can see.
	Conditions []string
}

Prune is what a bounded delete needs beyond the predicates that doom a row: the columns naming a doomed row, and the order the capped pass takes them in.

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 (*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 Read

type Read struct {
	// Order names a column the read sorts ascending by.
	//
	// In a single-row read it is what picks the row: the statement orders by it
	// and takes the first, which is for the key that admits more than one row —
	// "another live membership for this user" — where without it the row
	// answered is whichever the planner reached first, and a :one statement
	// discards the rest after dragging them across the wire. Empty is a key
	// that identifies a row, which needs neither.
	//
	// In a batched read it is the tie-break inside one key's rows, since that
	// statement's first ordering term is the keyed column itself — see
	// [Generator.SetReadQuery].
	Order string
	// Projection is the columns the SELECT lists, in order. Empty projects the
	// column list the statement was rendered from.
	Projection []string
}

Read is what a keyed read returns, and how it chooses when the key admits more than one row.

It exists because a keyed read's projection and its predicates come from different lists, which the standard get's do not. The get projects the table and keys on the table's id, so one column list says both things. A read of the creation time the database assigned projects one column and keys on the id; a read of the membership between a user and an account projects every column and keys on neither its own id nor a filter. Deriving both from one list would mean a narrow projection silently dropping the archived predicate that the same list carries.

The zero value is the standard get: the whole column list projected, and no ordering, because the key names one row.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the set of table names a generator knows about.

It answers one question, and the question is not about SQL: which tables does this application have? A consumer needs that list for the things that are per-table but not per-query — the TRUNCATE an integration suite runs between tests, a schema inventory, a migration audit — and the list has to be complete or the symptom is not a failure where it was made. A table missing from a maintenance TRUNCATE is a test somewhere else failing later because the previous test's rows are still there.

The reason it lives here rather than in the consumer is that a table's SQL and a table's existence are separate facts, and a consumer that derives the second from the first loses a table the moment something else starts producing its SQL. That is not hypothetical: a query builder per table doubles as a table list right up until one table stops needing a builder, and then the list is short by one with nothing to say so. Registering the name is what survives the thing that emitted the queries going away.

So Generator.StandardCRUD registers every table it emits for, and anything else that owns a table registers it too — by hand, from a declaration, from whatever produces its SQL — into the same registry. Two sources, one list, and no rule anybody has to remember.

A Registry is safe for concurrent use. Registering the same table twice is how the ordinary case works rather than a mistake to guard against: a generator emitting for more than one dialect calls StandardCRUD once per dialect for each table, so the second and third registrations are the same name arriving again.

Example

The registry is the list of tables an application has, which is not the same list as the tables something generates SQL for. StandardCRUD feeds it, and a table whose SQL is written by hand — or by something else entirely — feeds the same list, so whoever truncates them between integration tests reads one list rather than remembering there are two.

The package-level RegisterTable and RegisteredTables are the usual pair; this example uses an explicit registry so its output does not depend on what else in the process has registered.

package main

import (
	"fmt"
	"strings"

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

func main() {
	registry := querygen.NewRegistry()

	querygen.For(dialect.Postgres).StandardCRUD("webhooks",
		[]string{querygen.IDColumn, "url", querygen.ArchivedAtColumn},
		querygen.WithEntity("Webhook", "Webhooks"),
		querygen.WithRegistry(registry),
	)

	// No queries come from here, and the rows still have to go somewhere.
	registry.Register("sessions", "webauthn_credentials")

	fmt.Println(strings.Join(registry.Tables(), ", "))

}
Output:
sessions, webauthn_credentials, webhooks

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry.

Most callers want the package-level RegisterTable and RegisteredTables instead, because one list is the whole point — a registry a consumer has to be handed is a registry a caller can fail to be handed. This is for a binary generating for genuinely separate schemas, and for tests.

func (*Registry) Has

func (r *Registry) Has(table string) bool

Has reports whether table is registered.

func (*Registry) Register

func (r *Registry) Register(tables ...string)

Register adds tables to r, ignoring names it already holds.

It panics on a name that is not a valid identifier, in the manner of the rest of this package: the argument is a string literal in a generator binary, so an invalid name is a typo a build should stop for. It matters more here than it looks, because a registered name is not merely stored — a consumer reads this list back and interpolates every entry into statement text, which is a place arbitrary strings do not belong. The panic value is an error wrapping dialect.ErrInvalidIdentifier.

func (*Registry) Tables

func (r *Registry) Tables() []string

Tables returns the registered table names, sorted, as a copy.

Sorted rather than in registration order, because registration order is not a fact about the application: it is the order the generator's calls happen to appear in, and a generator emitting for three dialects interleaves three passes over the same tables. A consumer that writes this list into a file — or asserts the committed one still matches, which is how a generator is usually checked in CI — wants the list to move when the schema does and not when somebody reorders a switch statement.

It is deliberately not an ordering a caller can delete in. Foreign keys make deletion order a fact about the schema, which a set of names cannot express, so a consumer truncating these tables wants the dialect's own way of ignoring the constraints — TRUNCATE ... CASCADE, a disabled FK check — rather than a sequence inferred from this slice.

type SetKey

type SetKey struct {
	// Column is the column matched against the bound set. It is interpolated,
	// so it is restricted rather than escaped — see dialect.ValidIdentifier.
	Column string
	// Arg names the argument the set binds through, defaulting to [IDsArg].
	//
	// A read whose set is not of ids says so — a batch of usernames, a batch
	// of email addresses — and the name is what a caller's params struct spells
	// it as. There is one set per statement, so the default collides with
	// nothing; the name is for the reader rather than for the compiler.
	Arg string
}

SetKey is the set-membership predicate a batched read is keyed on: one column, matched against a whole set of values bound as a single argument.

It is its own type rather than a flag on Match because the two are not the same predicate wearing different operators. A Match binds one value and can appear anywhere in a statement; a set binds a list whose length is not known until the call, which is a bound array on Postgres and a placeholder expansion on the other two — and that difference decides where in the statement it may sit. See Generator.SetReadQuery.

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.
	//
	// It names two emitted statements rather than one: the ascending page and
	// the descending one, the second under [DescendingName] of the first. They
	// are one entry here because they are one decision — a caller renaming the
	// list renames both, and a caller omitting it omits both, so a table cannot
	// end up with half a paged list and a filter direction that answers with
	// the other half. See [Direction].
	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
	// MarkAsIndexedQuery stamps last_indexed_at on every id it is handed, which
	// is what a search/sync Syncer flushes through once the index has accepted
	// those documents.
	MarkAsIndexedQuery
)

func (StandardQuery) String

func (s StandardQuery) String() string

String names the query, for error messages.

type Sweep

type Sweep struct {
	// Order is the columns the scan walks, most significant first. It is
	// required — see [ErrUnorderedBoundedStatement].
	//
	// The convention the sweeps in this module follow is the column the
	// deadline lives in, then the id: the most overdue rows first, and a
	// deterministic tie-break among the rows that came due in the same instant.
	Order []Order
	// Projection is the columns the SELECT lists, in order. Empty projects the
	// column list the statement was rendered from.
	//
	// A sweep that hands its rows to something outside the database — the
	// artifact expiry, which deletes an object before the row may say it is
	// gone — projects the whole row. One whose next step is a statement wants
	// the id and nothing else, and that one is [Generator.SweepDeleteQuery] or
	// [Generator.SweepUpdateQuery] rather than a scan a caller loops over.
	Projection []string
}

Sweep is what a bounded scan lists and the order it drains rows in.

It is the shape a background pass over a table has, and it is not the paged list's. A list is a caller walking rows they will look at, so it carries the filter window, a cursor naming where that caller had got to, and the counts a page is rendered with. A sweep is a process collecting the rows that have become due — an artifact past its expiry, a confirmation window that lapsed, a record past its retention — and none of those has a reader, a position, or a total worth computing. What it has instead is an order that says which rows are most overdue and a limit that says how much to do in one pass.

Jump to

Keyboard shortcuts

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