Documentation
¶
Overview ¶
Package withsqlc demonstrates sqlb and sqlc over one schema.
The claim in the README — that sqlb can be layered over structs it did not generate, so adoption need not be all-or-nothing — is easy to assert and easy to be wrong about. This package tests it against real sqlc output rather than against structs written to make it pass.
The pipeline, and what each step proves:
blogschema/schema.go one schema declaration → gen → schema.sql sqlb renders the DDL; -check keeps it current → sqlc → sqlcgen/models.go sqlc types its queries against that DDL → sqlb.Describe sqlb reads those same structs
See docs/with-sqlc.md for which queries belong on which side.
The other half of the story is what moving one query across actually costs, and stage1.go through stage4.go are the worked version: one list endpoint in four spellings, from static SQL to a generated REST resource, each a place a project can stop. docs/refactoring-from-sqlc.md narrates them.
The two test files divide the claims by what can honestly be asserted where. refactor_test.go runs against a stub and covers what each stage *sends* and *refuses*; an equivalence asserted there would pass no matter what SQL the stages produced, since the stub answers everything identically. That claim — the four return the same rows — needs a real planner and lives in pgtest/refactor_test.go.
Regenerating is two steps because they are two tools, and only the first is a go:generate directive:
go generate ./example/withsqlc/... renders schema.sql from the declaration cd example/withsqlc && sqlc generate retypes sqlcgen against it
The second is manual on purpose. Behind a directive it made `go generate ./...` — and so `mise run heal`, which CONTRIBUTING.md hands a new contributor first — fail on every checkout without sqlc installed. Pinning sqlc in mise.toml would fix that by making it a build dependency of a library whose whole argument is that it imposes none, which is the same reason the sqlc step is absent from `mise run generate-check`.
The cost is that nothing regenerates or gates sqlcgen: after a schema change, run the second step by hand. That cost is not new — no gate ever covered it — and the drift that is covered still is, because the directive below renders schema.sql and `go run ./gen -check` fails in CI when it is stale.
Index ¶
- Variables
- func ListPostsStage1(ctx context.Context, db sqlcgen.DBTX, orgID string, query url.Values) ([]sqlcgen.Post, error)
- func ListPostsStage2(ctx context.Context, db sqlb.Executor, orgID string, query url.Values) ([]sqlcgen.Post, error)
- func ListPostsStage3(ctx context.Context, db sqlb.Executor, orgID string, query url.Values) ([]blog.Post, error)
- func RegisterStage4Hooks() *sqlb.Registry
- func ServerStage4(exec sqlb.Executor) (http.Handler, error)
- func WithOrg(ctx context.Context, orgID string) context.Context
Constants ¶
This section is empty.
Variables ¶
var ErrNoOrg = errors.New("no tenant on the context")
ErrNoOrg is what the hook returns for a context no middleware scoped. It fails the query rather than serving one, which is the only safe direction: a multi-tenant read whose tenant predicate went missing returns every tenant's rows.
ErrSortUnavailable is what stage 1 returns when asked for an ordering its query does not have. It is not a validation failure — the request is perfectly reasonable — it is the shape of static SQL surfacing as a runtime refusal, and the only fix is another query.
Functions ¶
func ListPostsStage1 ¶
func ListPostsStage1(ctx context.Context, db sqlcgen.DBTX, orgID string, query url.Values) ([]sqlcgen.Post, error)
ListPostsStage1 serves a filterable list of posts with sqlc alone.
The generated function does the typed part well: ListPostsParams is checked against the real schema at build time, and a column that does not exist fails `sqlc generate` rather than a request. Everything around it is hand-written, and that is the part this stage exists to show.
Three costs, none of which are sqlc doing something wrong:
- **Every optional filter is unpacked by hand** into the null-carrying type its arm expects, and the query sends all three on every request whether or not they mean anything.
- **The sort is not a parameter.** It is in the query text, so "sort by view_count" is a second entry in query.sql, a second generated function, and a branch here to choose between them. n sortable columns in two directions is 2n queries.
- **This function is the security boundary**, and nothing marks it as one. That `status` is filterable and `password_hash` is not is a fact about which lines were written here, so reviewing the API surface means reading the handler rather than reading the schema (ADR-0006).
The ordering of the arms in query.sql and the ordering of the assignments below have to agree, and nothing checks that they do.
func ListPostsStage2 ¶
func ListPostsStage2(ctx context.Context, db sqlb.Executor, orgID string, query url.Values) ([]sqlcgen.Post, error)
ListPostsStage2 does stage 1's job with the query builder, over the structs sqlc generated. Nothing in sqlcgen changes, and nothing in it knows sqlb exists.
What this buys, in order of how much it matters:
- **Only what was asked reaches Postgres.** A predicate exists because a branch added it, so the three-armed NULL check is gone rather than optimised away.
- **The sort is a value.** `?sort=-view_count` is an Order, not a second entry in query.sql, so the 2n queries collapse back to one.
- **One transaction still carries both sides.** This function takes an sqlb.Executor and the generated one takes a DBTX; a pgx.Tx is both, so the dashboard query in query.sql and this list can run inside one unit of work (ADR-0040, and adopt_test.go asserts it at compile time).
What it does not buy, and what stage 3 is for: the request-to-predicate translation below is still hand-written, and so is the allow-list above.
func ListPostsStage3 ¶
func ListPostsStage3(ctx context.Context, db sqlb.Executor, orgID string, query url.Values) ([]blog.Post, error)
ListPostsStage3 is stage 2's job again, with the model's capabilities coming from the schema declaration rather than from a Describe call and a map.
The type changed — blog.Post rather than sqlcgen.Post — and that is the whole move. blog.Post is generated from example/blog/blogschema, which already states which columns are filterable, sortable and searchable, so:
- **The allow-list is gone.** filter.Parse checks every parameter against the declared capabilities, so `?sort=body` is refused because body did not declare Sortable, not because a map in this file happens to omit it. The rejection names the columns that would have worked (ADR-0011), which stage2Order could not do without a second list.
- **The hand-written parameter unpacking is gone**, and with it the three `if v := query.Get(...)` blocks. One grammar covers every column the schema opened, including the operators — `?view_count=gte.100` needed a branch of its own in both earlier stages.
- **A misspelled column stops compiling.** blog.PostCols.OrgID is a Col[string]; sqlb.F("org_id") was a string that had to be right. That is the typed column facade (ADR-0009), and it is generated from the same declaration.
Still hand-written, and the reason stage 4 exists: this function, its route, its pagination envelope, its OpenAPI entry, and the tenant scope that has been an argument since stage 1.
Note the query string changes here. Stages 1 and 2 read an ad-hoc spelling this handler invented (`?status=published&min_views=100&limit=20`); from here it is the documented filter grammar (`?status=eq.published&view_count=gte.100 &per_page=20`). The rows are the same and refactor_test.go asserts that, but the wire format is not, so this is the release where a client changes.
func RegisterStage4Hooks ¶
RegisterStage4Hooks installs the two predicates that were arguments and hand-written Where clauses in stages 1 through 3.
This is the move stage 4 is really about, and it is worth more than the deleted handler below. One registration constrains *every* read of Post — the generated list, the generated read, the ones the expand machinery issues, and any query written by hand later — so scoping is no longer something each call site has to remember (ADR-0008). Stage 3's handler could have forgotten the org predicate and would have compiled, tested green against a single-tenant fixture, and leaked in production.
example/blog/hooks.go registers the soft-delete half and says tenant scoping belongs on the same hook, left out there only because that example has no authentication to read a tenant from. This is that hook with the missing half supplied.
It returns the registry it registered into, and the handle carries it (ADR-0047). With no ambient registry to write to, "the hook is installed" and "the handle runs it" become one statement instead of two that can drift.
func ServerStage4 ¶
ServerStage4 is the whole of stage 4: there is no ListPosts function, because nothing here writes one.
blog.Register is generated from example/blog/blogschema and mounts every resource the schema exposes. For posts that is list, read, create and update at /posts, with the filter grammar, the sort, the search, the pagination envelope and the OpenAPI entry all derived from the capabilities the columns declared. The list endpoint stage 3 hand-wrote is one of them.
What is left to write is what is genuinely this application's: the hook above, and the soft delete below, which serves DELETE /posts/{id} as an update to deleted_at because the schema deliberately does not expose OpDelete.
The honest cost, stated where someone deciding can see it: this is the step that takes the dependency. rest is an adapter onto huma, so a project that stops at stage 3 keeps sqlb's engine on pgx and nothing else, and a project that takes stage 4 accepts a web framework it did not choose. That is the trade ADR-0007 argues, and the reason each stage here is a stopping point rather than a step on the way to a mandatory destination.
Types ¶
This section is empty.
Directories
¶
| Path | Synopsis |
|---|---|
|
Command gen renders the blog schema as the plain `schema.sql` that sqlc reads, which is the mechanical half of the sqlb/sqlc pairing story: one schema declaration, two consumers.
|
Command gen renders the blog schema as the plain `schema.sql` that sqlc reads, which is the mechanical half of the sqlb/sqlc pairing story: one schema declaration, two consumers. |