Documentation
¶
Overview ¶
Package query turns a tree of conditions into parameterized SQL.
Callers never build these types by hand. They fill in a generated query struct — q.Params.Equals.Status, q.Params.In.FixtureID — and the generated repository translates it into the tree this package renders. Keeping the translation in generated code rather than doing it by reflection is what lets the caller's side stay fully typed: asking for "created_at contains 3" is not expressible, rather than merely rejected at runtime.
Column names are never taken from user input. They come from generated constants, so this package concatenates them into SQL directly while every value goes through a placeholder.
Index ¶
- func JoinSQL(joins []Join, args *Args) string
- func OrderSQL(terms []Order) string
- type Args
- type Cond
- func Eq(column string, v any) Cond
- func Gt(column string, v any) Cond
- func Gte(column string, v any) Cond
- func In(column string, v any) Cond
- func IsNull(column string) Cond
- func Like(column string, v any) Cond
- func Lt(column string, v any) Cond
- func Lte(column string, v any) Cond
- func Ne(column string, v any) Cond
- func NotIn(column string, v any) Cond
- func NotLike(column string, v any) Cond
- func NotNull(column string) Cond
- func Related(e Exists) Cond
- type Exists
- type Group
- type Join
- type Op
- type Order
- type Page
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func JoinSQL ¶
JoinSQL renders the joins, returning empty when there are none.
It has to be rendered before the statement's WHERE clause, because Postgres numbers placeholders in the order they appear in the text and a join stands earlier in the statement than the conditions do.
func OrderSQL ¶
OrderSQL renders an ordering, returning empty when there is none.
Nulls are left where Postgres puts them: last when ascending, first when descending. A row ordered by a column of a related row it does not have sorts to one end rather than disappearing, which is the whole point of reaching it through a left join.
Types ¶
type Args ¶
type Args struct {
// contains filtered or unexported fields
}
Args accumulates placeholder values while SQL is rendered.
Postgres numbers its placeholders, so they have to be allocated in the order the fragments are written. Threading one of these through the rendering is simpler than counting after the fact and getting it wrong once.
type Cond ¶
type Cond struct {
// Table qualifies the column. It is empty at the top level, where only one
// table is in scope, and set inside a subquery, where two are: an
// unqualified name there resolves to whichever scope happens to have it,
// which is a silent correlation to the wrong row rather than an error.
Table string
Column string
Op Op
// Value is the operand. It is nil for the null checks, and a slice for the
// set-membership ones.
Value any
// Exists is a condition on a related table. When it is set, the column and
// the operator are not used.
Exists *Exists
}
Cond is one comparison against one column, or one correlated subquery.
type Exists ¶
type Exists struct {
// From is the table and alias, or a join chain for a many-to-many. It is
// generated, never built from anything a client sent.
From string
// On correlates the subquery to the outer row, and is generated the same way.
On string
// Where is the caller's condition on the related table, its columns
// qualified with the alias From declares.
Where Group
// Not inverts it: no such row rather than at least one.
//
// Which is not the same as negating a condition inside Where, and the
// difference is the one callers get wrong. A negated comparison still needs
// the row to be there — "there is a related row whose name differs" — so a
// row with no related row fails it. This asks whether a matching row exists
// at all, so a row with none satisfies it.
Not bool
}
Exists is a condition on rows of another table, correlated to this one.
EXISTS (SELECT 1 FROM <From> WHERE <On> AND <Where>)
A subquery rather than a join, because a join to the far side of a has-many multiplies rows: the count would be wrong, the page would be wrong, and putting DISTINCT over the top would make the ordering wrong too. EXISTS asks the only question a filter is asking — is there such a row — and leaves the shape of the result alone.
type Group ¶
type Group struct {
// Or joins this group's members with OR instead of AND. It applies to the
// group, not to a member, which is why mixing the two needs a nested group
// rather than a flag per condition.
Or bool
Conds []Cond
Groups []Group
}
Group is a set of conditions combined with AND or OR, plus nested groups. Nesting is what lets the two be mixed to any depth.
Example ¶
A generated repository builds one of these from the caller's typed query struct and renders it into the WHERE clause. Every value becomes a numbered placeholder; only the column names, which come from generated constants, are written into the SQL.
A set membership renders as `= ANY` against a single array parameter rather than an expanded placeholder list, so the statement text does not change with the length of the list.
package main
import (
"fmt"
"github.com/simonjanss/rig/runtime/query"
)
func main() {
g := query.And(
query.Eq("status", "active"),
query.Gte("score", 10),
query.In("fixture_id", []int{1, 2, 3}),
)
args := query.NewArgs()
fmt.Println(g.SQL(args))
fmt.Println(args.Values())
}
Output: status = $1 AND score >= $2 AND fixture_id = ANY($3) [active 10 [1 2 3]]
func (*Group) Add ¶
Add appends conditions, skipping any that are zero.
Skipping is what lets a caller add a condition it may or may not have — the tenant predicate of a table with no tenant column, for instance — without asking first. A subquery has no column of its own, so it is judged by whether it is there.
Example ¶
Add skips a zero condition, which is what lets a repository add a predicate it may or may not have — the tenant filter of a table with no tenant column, say — without asking first.
package main
import (
"fmt"
"github.com/simonjanss/rig/runtime/query"
)
func main() {
var g query.Group
g.Add(query.Eq("tenant_id", "t-1"))
g.Add(query.Cond{}) // a filter this table does not have
g.Add(query.NotNull("published_at"))
args := query.NewArgs()
fmt.Println(g.SQL(args))
}
Output: tenant_id = $1 AND published_at IS NOT NULL
func (*Group) Nest ¶
Nest appends a sub-group, ignoring an empty one.
Example ¶
Mixing AND with OR is what nesting is for: the flag belongs to a group, so the two are combined by putting one inside the other rather than by a precedence rule nobody can see at the call site.
package main
import (
"fmt"
"github.com/simonjanss/rig/runtime/query"
)
func main() {
g := query.And(query.Eq("tenant_id", "t-1"))
g.Nest(query.Or(
query.Eq("status", "draft"),
query.IsNull("published_at"),
))
args := query.NewArgs()
fmt.Println(g.SQL(args))
fmt.Println(args.Values())
}
Output: tenant_id = $1 AND (status = $2 OR published_at IS NULL) [t-1 draft]
type Join ¶
type Join struct {
// Table is the table and its alias, and On correlates it to the outer row.
// Both are generated from the schema, never built from anything a client
// sent.
Table string
On string
// Where is what the far side is scoped by: the tenant, and whatever
// lifecycle predicates it has.
//
// It renders into the ON clause rather than the statement's WHERE, and that
// is not a stylistic choice. A predicate on the joined table in WHERE is
// false for a row that matched nothing, so it would discard exactly the rows
// the left join was chosen to keep — an inner join with extra steps.
Where Group
}
Join is a related table brought into a statement so that its columns can be ordered by.
LEFT and never INNER, because the join is here to reach a value rather than to decide anything: an inner join would drop every row whose foreign key is null, silently turning "order these by their team's name" into "and hide the ones with no team". A filter across a relation is a Cond with an Exists, not one of these — see Exists for why that one is a subquery.
type Op ¶
type Op string
Op is a comparison.
const ( OpEq Op = "=" OpNe Op = "<>" OpGt Op = ">" OpGte Op = ">=" OpLt Op = "<" OpLte Op = "<=" OpIn Op = "IN" OpNotIn Op = "NOT IN" OpLike Op = "ILIKE" OpNotLike Op = "NOT ILIKE" OpIsNull Op = "IS NULL" OpNotNull Op = "IS NOT NULL" )
The operators, spelled the way they go into the SQL. Two are not quite what their names say: OpLike and OpNotLike render as ILIKE, because a filter a person typed into a search box is not a statement about letter case, and the null checks take no value at all.
type Order ¶
type Order struct {
// Table qualifies the column, and has to whenever the statement joins: two
// tables with a created_at make an unqualified one ambiguous, and Postgres
// is right to refuse it.
Table string
Column string
Desc bool
}
Order is one term of an ORDER BY.
type Page ¶
Page bounds a result set.
func (Page) Clamp ¶
Clamp applies the defaults and the ceiling.
A limit is always applied. An unbounded list is a production incident waiting for the table to grow, and the caller who omitted it did not mean "all of them" — they just did not think about it.
Example ¶
Clamp is why a generated list cannot return an unbounded result set: the caller who omitted a limit did not mean "all of them", and a table that grows would turn that omission into an incident.
package main
import (
"fmt"
"github.com/simonjanss/rig/runtime/query"
)
func main() {
fmt.Println(query.Page{}.Clamp(50, 200))
fmt.Println(query.Page{Limit: 1000, Offset: 20}.Clamp(50, 200))
}
Output: {50 0} {200 20}