Search titles and sections of every page. ↑↓ to move, ↵ to open

Building

SQL queries

2 min read Edit on GitHub

Write queries in SQL, and Tern writes the Go that runs them: typed functions, checked against the schema your migrations build, with every value sent as a bound parameter (ADR 0028). There's no ORM and nothing at run time but database/sql.

#Writing queries

Put .sql files in queries/, at the root of the app. Each query starts with a line that names it and says what it returns:

-- A user's links, the newest first.
-- name: LinksOf :many
SELECT id, url, title, read, added_at FROM links
WHERE user_id = :user_id
ORDER BY id DESC;

-- name: AddLink :one
INSERT INTO links (user_id, url, title) VALUES (:user_id, :url, :title)
RETURNING id, added_at;

-- name: MarkRead :execrows
UPDATE links SET read = :read WHERE id = :id AND user_id = :user_id;

tern gen writes queries/queries.gen.go, and so do tern dev, tern build and tern check; under tern dev, saving a query or a migration regenerates it. Commit it with the queries.

q, err := queries.New(db) // SQLite or PostgreSQL

links, err := q.LinksOf(ctx, user.ID)               // []queries.LinksOfRow
added, err := q.AddLink(ctx, user.ID, url, title)   // queries.AddLinkRow
n, err := q.MarkRead(ctx, true, id, user.ID)        // rows changed
Kind Returns
:one the row, or queries.ErrNotFound
:many a slice of rows, empty rather than nil
:exec an error
:execrows how many rows changed

A query of one column returns its values: SELECT COUNT(*) AS n … as :one is an int64. q.WithTx(tx) runs the queries in a transaction.

#Parameters

Parameters are named, :name, and always sent apart from the SQL, so nothing in them can change the query. They become the function's arguments, in the order they first appear, or a struct, AddLinkParams, when there are more than three. A name used twice is one argument.

Each parameter takes the type of the column it's compared with, inserted into or set to; LIMIT :n is an int64, and :show = 'all' a string. Inserting into or setting a column that allows NULL takes a pointer. Where the SQL doesn't tell, say it:

-- name: Since :many
-- param since time.Time
SELECT id FROM links WHERE julianday(added_at) > julianday(:since);

#Rows

A query's columns become a struct, <Name>Row, with the columns' names in Go's case (added_at is AddedAt, user_id is UserID) and in camelCase for JSON (addedAt, userId), so a load can return rows to a page as they are. Columns that allow NULL are pointers. Queries that return the same columns can share a type: -- row Link names it.

Columns come from the database, so an expression needs a name, AS total, and sometimes a type, when the query returns no row to tell it from:

-- name: NextID :one
-- column next *int64
SELECT max(id) + 1 AS next FROM links;
Column type Go type
INTEGER, BIGINT, SERIAL int64
TEXT, VARCHAR, UUID string
REAL, DOUBLE, NUMERIC float64
BOOLEAN bool
TIMESTAMP, DATETIME, DATE time.Time
BLOB, BYTEA []byte
JSON, JSONB json.RawMessage

#How queries are checked

Code generation applies the migrations in migrations/ to a scratch SQLite database in memory, and runs each query there in a transaction it rolls back. A column that doesn't exist, a table renamed in a migration, or SQL that doesn't parse stops the build with the query's file and line:

queries/links.sql:7: LinksOf: no such column: titel

Queries are written once with :name and run on SQLite or PostgreSQL: the package sends ? to one and $1 to the other. They're checked against SQLite, so on PostgreSQL write SQL both accept. For a query only PostgreSQL runs (ILIKE, jsonb operators), mark it -- unchecked and give each parameter and column its type; it runs as written:

-- name: Find :many
-- unchecked
-- param q string
-- column id int64
-- column title string
SELECT id, title FROM links WHERE title ILIKE '%' || :q || '%';

Queries and hand-written SQL can live side by side: nothing needs to move at once.