Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

178 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sqlb

Go Reference CI Go Version License: MIT

A schema-first data layer for Go and Postgres: declare your tables once, get typed composable queries, a validated REST filter grammar, and domain hooks — without hand-writing the HTTP-to-SQL layer for every dynamic view.

Documentation · Quickstart · API reference · Decision records

Why

Static query generators cannot express "this WHERE clause exists only when the user typed something in the search box." The usual workaround is string concatenation, which is why the HTTP layer of a filter/sort/search page is mostly boilerplate.

PostgREST solves that by making the database the API, but there is then nowhere to put Go domain logic, and the whole schema sits one policy mistake away from being public.

sqlb takes the middle path. A query is a value, so predicates can be added conditionally:

q := sqlb.Query[Post]().Where(sqlb.F("status").Eq("published"))
if search != "" {
    q = q.Where(sqlb.F("title").Contains(search))
}
posts, err := q.OrderBy(sqlb.F("created_at").Desc()).Limit(50).All(ctx, db)

and the REST filter grammar compiles into that same predicate AST. One compiler, one bind-parameter discipline, one set of hooks — two producers.

What that buys

  • Capabilities are opt-in per column. Filterable, Sortable, Searchable, Hidden. A column that does not declare a capability cannot be reached through it — ever, and the failure is a 400 naming what would have been accepted, not a leak. This is the difference between this and exposing the database.
  • Hooks are the domain seam. BeforeQuery receives the query itself, so one registration constrains every read of a model — including the reads that generated REST handlers issue. Tenant scoping stops being something each call site has to remember.
  • Paging that survives a write. ?cursor= names the position of the last row rather than counting to it, so page 500 costs what page 1 costs and a concurrent insert cannot make a client read a row twice. Every list response carries the cursor for the next page, so adopting it needs no flag.
  • Nothing runs unasked. SQL() renders text and args without executing. Explain plans against the live schema without running it, so it also fails on the migration that was written and never applied — which a compile-time column check cannot. Diff returns migration changes as values; your runner applies them.
  • The clients are generated from the schema too. A TypeScript client, emitted into the repository that consumes it, where where admits only filterable columns with the operators their type accepts, select narrows the response type, and a hidden column has no spelling at all. The OpenAPI document cannot say any of that — ?status=eq.published documents as array<string> — so it is generated from the model instead (guide). The same vocabulary reaches a Flutter app as Dart — plus the cursor pager an infinite-scrolling list needs, which is the piece a mobile client otherwise rebuilds out of has_more and an offset counter (guide) — and Go, as a typed client that imports the standard library and nothing else, plus an optional cobra command tree over it: one flag per filterable column, its operators in the usage string, so --help states what a resource accepts without a request — which is the form the guarantee has to take for a caller with no compile step, such as an agent (guide).
  • One dependency, and it is the one you already have. The engine is written on pgx and takes nothing else; a CI gate fails on anything that is not pgx or something pgx itself pulls in. That is a deliberate reversal — sqlb used to depend on the standard library alone, and ADR-0040 says what it bought: sqlb writes join a pgx.Tx your own code opened, arrays need no codec, and pgvector's binary format is reachable. Only the REST adapter pulls in Huma, and only if you use it. The generated TypeScript, the generated Dart and the generated CLI are separate toolchains and separate opt-ins; the emitters produce text, so codegen itself takes nothing.

Install

go get github.com/jryannel/sqlb

Go 1.25 or newer, and Postgres. Quickstart goes from here to a running server.

The generator is a command, and the loop is one line each way:

go install github.com/jryannel/sqlb/cmd/sqlb@latest

sqlb generate ./schema                # models, typed columns, REST bodies, manifest, clients
sqlb check ./schema                   # the CI drift gate: writes nothing, fails if stale
sqlb migrate -name adds_slug ./schema # the migration that closes the gap
sqlb eject ./schema                   # the way out: the schema as SQL, the resources as
                                      # plain net/http handlers over pgx, importing no sqlb

The argument is the package that declares your schema, and the package says what to emit and where by exporting one function. Because the schema is Go, sqlb compiles a driver against your module to read it — see ADR-0032 for why that is forced and what it costs.

generate and check need no database. migrate works out the current schema by replaying your committed migrations into a scratch Postgres, because reading a live one tells you what the database looks like rather than whether the migrations produce it — so it needs an empty database, except for the very first migration, which diffs against nothing.

The schema DSL and code generation are both optional: sqlb.Describe[T]() layers the same capabilities over structs you already have, including stock sqlc output, without editing them. Moving one endpoint across is worked in four stages, each a place to stop, with a test that requires all four to return the same rows.

And the way out is generated too. sqlb eject writes a package that depends on pgx and the standard library — your schema as DDL, your statements as SQL you can read, your endpoints as net/http handlers — with everything it does not carry refused by name rather than quietly missing. example/blog/ejected is a committed one, and a test serves it beside the generated resources it came from and compares the answers request by request. See the way out.

Status

Pre-1.0, one author, no observed consumers. That is the honest starting position, and no amount of feature work substitutes for elapsed time under real traffic. Compatibility says what v0.1.0 freezes and which surfaces are expected to move.

What is proven, and re-checked on every run rather than asserted: CI applies the generated DDL to a real Postgres 18, reads it back with introspect, and requires the round trip to be a fixpoint; the query path runs through a real PgBouncer in transaction pooling, because that is the deployed topology; and the blog example is generated from its schema, so every behaviour test in it is also a test of the generator's output.

Postgres only. LISTEN/NOTIFY, jsonb aggregation and RETURNING are all load-bearing; multi-dialect support would cost the best features.

Not built yet, in the order they matter: a durable change feed, and an MCP server over the manifest. Vision has the detail.

Documentation

Start here Overview, quickstart, a worked first app, structs-first adoption
Concepts The five ideas the rest of it rests on
Schema · Queries · REST · TypeScript · Dart · CLI · Migrations One section per surface
Examples Six worked applications, and what each one proves
Reference Filter operators, column types, capabilities, codegen options, CLI, rejections
Architecture How the pieces fit, the request path, where safety lives
Decision records What was decided, why, and what would change our mind
example/recipes Eighty-odd small examples, one file per aspect — the place to look when you know what you are building and need to know how one piece is spelled
example/blog A worked schema and everything codegen emits from it
example/tasks A multi-tenant task manager: auth, migrations, a runnable server, and a generated TypeScript client, Dart client and CLI
example/fxapp The same pieces assembled by uber-go/fx on the fxkit glue — copyable, not importable: hooks arriving through a value group, a pluggable auth module behind the principal seam, and a resource that refuses to mount without its hooks
example/computed Four ways to get a derived value out of Postgres — generated columns, trigger counters, projected expressions, views — and where sqlb's ceiling is today (ADR-0041)
example/evolve A schema that changed five times: what is free, what destroys data, and the rename that is a clean migration and a broken client at once (the walkthrough)
example/withsqlc sqlb and sqlc over one schema, plus one list endpoint in four stages from static SQL to a generated resource

Development

mise run test    # the inner loop; no Docker or Postgres needed
mise run ci      # the full gate, same as .github/workflows/ci.yml
mise tasks       # everything else

Tool versions are pinned in mise.toml, so a green run locally and a green run in CI use the same Go and the same linter. The engine's tests run against an in-memory executor rather than a database, which keeps the inner loop fast; test-pg answers what that cannot — whether the generated SQL is valid rather than merely expected — and is part of ci.

CONTRIBUTING.md has what a change is expected to carry, and where to argue with a decision record rather than around it.

License

MIT — see LICENSE.

About

Schema-first data layer for Go and Postgres: composable typed queries, a validated REST filter grammar, and domain hooks.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages