computed

package
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Aug 2, 2026 License: MIT Imports: 3 Imported by: 0

README

Derived values: five techniques, and which one to reach for

A project has a due date and two task counters. A list page wants six things the row does not literally contain: totalTasks, completedTasks, openTasks, isOverdue, progress %, and isStarred.

Those six are not one problem. They are four kinds of problem, and picking the wrong technique for one of them is the difference between an index scan and a subquery per row.

Run it:

go test ./example/computed/

Nothing here needs a database — every test asserts the compiled SQL, which is what SQL() exists for.

Pick by where the value comes from

The value depends on Technique Filterable Sortable Indexable
the same row, immutably GENERATED ALWAYS AS … STORED yes yes yes
other rows a trigger-maintained counter yes yes yes
the same row + now() schema.Computed yes no¹ no
the same row, stably schema.Computed yes yes no
who is asking schema.Computed + Needs yes yes no

¹ a keyset cursor pages on the sort column, so an expression that reads now() cannot be one — the declaration is refused rather than the request. Technique 3 is the same SQL written per call site, which is still what a one-query expression wants; §5 is the declared form and what it buys.

The first two produce ordinary columns. sqlb needs to be told nothing about them: they are Filterable, Sortable, and reachable from the REST filter grammar, the TypeScript client and the CLI exactly like a column somebody typed in by hand. If a value can be one of these, make it one of these. The rest of this example is about the two rows that cannot.

1 · Postgres computes it on write

open_tasks int GENERATED ALWAYS AS (total_tasks - completed_tasks) STORED

openTasks is arithmetic over two columns of the same row, so Postgres can keep it. It is then a real column: CREATE INDEX ... (org_id, open_tasks) works, and ORDER BY open_tasks DESC over a million rows is an index scan rather than a sort of the whole table.

The restriction is the reason isOverdue cannot join it: a generated column's expression must be IMMUTABLE, and isOverdue reads current_date.

There is no schema.Generated() yet, so this is a hand-written migration and the column is declared to sqlb as an ordinary one — which is all it is, from above.

2 · A trigger keeps a counter

totalTasks and completedTasks count rows in another table. Recomputing them per read is a correlated subquery on every request; a trigger on tasks pays the cost once per write instead, and the columns are again ordinary columns.

This is the technique most often skipped in favour of something cleverer, and it is usually the right answer. The trade is explicit: writes to tasks get slower and can drift if the trigger has a bug — which is not hypothetical, and the counter needs a backfill when it happens.

3 · Project an expression

sqlb.RawSel("(due_date IS NOT NULL AND due_date < current_date AND open_tasks > 0)").
    As("is_overdue")

sqlb.RawSel("EXISTS (SELECT 1 FROM project_stars s "+
    "WHERE s.project_id = projects.id AND s.member_id = ?)", viewer).
    As("is_starred")

isOverdue and progress are volatile or cheap enough to evaluate per read. isStarred has no other option at all: whether a project is starred depends on who is asking, so no column and no view can hold it — there is no the answer.

The ? is the point. RawSel's placeholders are renumbered into $N along with every other bind in the statement, so the viewer arrives as a parameter and cannot become SQL. It also means the fragment composes: the projection's bind, a predicate's bind and a ?search term are written at three call sites that cannot see each other, and only the compiler knows what position each lands in.

ProjectView embeds Project and adds the three fields, and sqlb.Collect scans into it exactly — a field no result column filled is an error naming the field, not a silent zero.

4 · A view

Not shown in code, because it needs no code: CREATE VIEW the whole thing, and Describe[T]().Table("project_rows"). The entire generated read path — filter grammar, cursor paging, TypeScript, CLI — works over it, because sqlb never asked whether the relation was a table.

Reach for it when the derived set is large enough that the projection stops being readable. The cost is that writes go somewhere else, so rest exposes reads only, and the view becomes a second place a schema change has to land.

The ceiling, stated plainly

Here is what the example actually compiles for a starred-and-overdue list:

SELECT "id", …, "open_tasks",
       (due_date IS NOT NULL AND due_date < current_date AND open_tasks > 0) AS "is_overdue",
       (completed_tasks * 100 / NULLIF(total_tasks, 0)) AS "progress",
       EXISTS (SELECT 1 FROM project_stars s
               WHERE s.project_id = projects.id AND s.member_id = $1) AS "is_starred"
FROM "projects"
WHERE EXISTS (SELECT 1 FROM project_stars s
              WHERE s.project_id = projects.id AND s.member_id = $2)
ORDER BY (due_date IS NOT NULL AND due_date < current_date AND open_tasks > 0) DESC,
         "due_date" ASC

Correct, parameterised, and one round trip. Two things are wrong with it anyway:

The expression is written three times — once to project it, once to filter, once to sort — and the viewer is bound twice, as $1 and $2. Nothing keeps the three copies agreeing.

sqlb can already bind one value once across all three positions: that is what sqlb.Near does for a query vector, which is twenty kilobytes and would otherwise be sent three times per search. The sharedValue behind it is unexported and reachable only through Nearness, so RawSel cannot ask for it.

Nothing above Go knows the field exists. is_overdue is not a declared column, so it is not in the filter grammar, not in the generated TypeScript type, not in the CLI's flags, and not in the OpenAPI document. A REST client cannot ask for ?filter=isOverdue.eq.true no matter what the SQL can do. That is not a gap in what sqlb can express — the SQL above is proof it can — it is a gap in what sqlb can declare, and the declaration is what the emitters read.

Both of those are what ADR-0041 and issue #17 argued for, and both are closed below.

5 · Declare it

schema.Computed("is_overdue", schema.TypeBool,
    schema.FromSQL("(due_date IS NOT NULL AND due_date < current_date AND open_tasks > 0)")).
    Filterable()

schema.Computed("is_starred", schema.TypeBool,
    schema.FromSQL("EXISTS (SELECT 1 FROM project_stars s "+
        "WHERE s.project_id = projects.id AND s.member_id = ?)")).
    Needs("viewer").Filterable()

declared.go is the same three values written this way, and its tests assert what the declaration buys over technique 3:

  • The expression is written once. The compiler substitutes it wherever the column is named, so ?filter=is_overdue.eq.true, ?sort=-progress and the projection all render from one string.
  • The viewer binds once. Bind("viewer", …) is $1 in the projection and in the predicate — the facility sqlb.Near proved worth having for a query vector, now reachable by any declared column.
  • Every emitter sees the field. It is in the row type, the JSON, the TypeScript and Dart types, the CLI's column set and the OpenAPI document, because to all of them it is a column.
  • An unsupplied bind fails loudly. rest.Resource refuses to mount a resource whose Needs no BeforeQuery hook satisfies, so a per-viewer field nobody wired up is a startup error rather than false for every row forever.

And what it costs, stated as plainly:

  • No index, ever. A declared expression is evaluated per candidate row. That is why techniques 1 and 2 come first in this file and are not deprecated by this one — Lint says so too, once per filterable computed column.
  • Sortable is refused on a volatile expression. is_overdue reads current_date, so a keyset cursor paging on it would compare this page's boundary against next page's value. progress is arithmetic over two stored columns and may be sorted.
  • The SQL is unchecked until a query runs. It is raw text in the schema, so a typo surfaces at the database rather than at generate time.
  • An expansion does not carry one. ?expand=project joins the target under an alias, and a raw fragment cannot be requalified onto it with certainty — the same refusal qualify.go already makes for RawPred. An expanded row carries the target's stored columns; its derived ones come from its own endpoint.

Techniques 1, 2 and 4 survive unchanged. Technique 3 remains the answer for an expression only one query wants, since a declaration is a property of the table.

Documentation

Overview

Package computed shows how to get a derived value — one the row does not store — out of Postgres through sqlb.

schema.Computed now declares one, and declared.go is this package's three derived values written that way. The four techniques below are still here because three of them did not go away when it landed: a column Postgres maintains is cheaper than an expression evaluated per read, and it is the right answer often enough that a declaration slot should not be allowed to hide it. ADR-0041 says which tier is which.

The four, in the order to reach for them:

  1. A STORED generated column. Postgres computes it on write, it is a real column, and it can be indexed. Restricted to IMMUTABLE expressions over the same row, so nothing involving now() or another table qualifies.
  2. A trigger-maintained counter. For values that come from other rows — "how many tasks does this project have" — where recomputing per read would be a correlated subquery on every request.
  3. A projected expression, via sqlb.RawSel and sqlb.Collect. Costs nothing on write, evaluated per read, and it is the only one of the four that can take a bind parameter — which is what a per-viewer field like "did *I* star this" requires.
  4. A database VIEW, described with Table(). One hand-written object, and the whole generated read path works over it.

(1) and (2) produce ordinary columns, so sqlb needs to be told nothing: they are Filterable, Sortable and indexable like any other, and the only sqlb-side decision is ReadOnly, which keeps them out of the generated request bodies. (3) is where the ceiling was, and where schema.Computed now sits — see declared.go, which produces the same SQL from a declaration the emitters can read. What (3) still has that a declaration does not is a per-call-site expression: schema.Computed is a property of the table, so a value only one query wants is still a RawSel.

Index

Constants

View Source
const Schema = `` /* 1089-byte string literal not displayed */

Schema is the DDL the techniques below assume. It is written out rather than declared through the schema package because two of the three objects — the generated column and the trigger — have no spelling in the DSL today, so a project using them writes this migration by hand and declares the resulting columns as ordinary ones.

Variables

View Source
var Declared = declaredRegistry()

Technique (5): declare the expression.

The four techniques in computed.go all work and three of them are still the right answer in the cases their doc comments name. What none of them can do is tell the *generator* that a derived value exists — so `is_overdue` is in the SQL and nowhere else: not in the TypeScript type, not in the CLI's columns, not in the OpenAPI response schema, and not in the filter grammar a REST client is allowed to use. That was the gap ADR-0041 set out to close, and this file is the closed version of the same three values.

The declaration

Declared is the schema half. A computed column emits no DDL — the CREATE TABLE this registry produces is the one in Schema minus the three expressions — and it reaches every emitter that describes a row.

View Source
var DeclaredValues = []string{"is_overdue", "progress", "is_starred"}

DeclaredValues names the derived columns this screen wants. Computed columns are opt-in per reader: the model declares three, and a reader that wants none of them — Exists, below — pays for none and needs no viewer (#92).

Functions

func DeclaredView added in v0.5.0

func DeclaredView(viewer int64) *sqlb.Builder[DeclaredProject]

DeclaredView is the read. Compare it with View: there is no projection to assemble, no RawSel to parenthesise by hand, and the per-viewer bind is supplied once rather than written at each site that mentions it.

In a REST application the Bind lives in a BeforeQuery hook and WithComputed is rest.Options.Computed, so no handler and no caller passes the viewer around — which is also what makes the mount-time check able to insist on it, and to insist only of the resources that render the column.

func Derived

func Derived(viewer int64) []sqlb.Selectable

Derived is the projection ProjectView expects: every column of the table, then the three expressions.

Select appends to the projection but replaces the default one, so the table's own columns have to be named once something else is added. columnsOf does that from the model rather than by hand, so a column added to Project reaches the view without a second edit.

func Exists added in v0.6.0

func Exists(id int64) *sqlb.Builder[DeclaredProject]

Exists is the query that made the opt-in necessary. It asks whether a row is there; it has no viewer, and there is no sense in which it should need one.

While computed columns were projected by default this could not be written against the same model: the projection carried is_starred, is_starred needed the "viewer" bind, and the query failed before it reached the database.

func OverdueFirst

func OverdueFirst(b *sqlb.Builder[Project]) *sqlb.Builder[Project]

OverdueFirst orders by a derived value without projecting it.

This is the half of technique (3) that has no ceiling problem: an expression in ORDER BY or WHERE is just SQL, and sqlb has never stopped anyone writing it. What it costs is the guarantee — `sqlb.Raw` is not checked against the schema, so a column renamed under it fails at the database rather than at generate time, and a REST client cannot reach it at all because the filter grammar only admits declared columns.

That gap is the argument for ADR-0041: not that this cannot be written, but that writing it here leaves the TypeScript client, the CLI and the OpenAPI document not knowing the field exists.

func StarredBy

func StarredBy(b *sqlb.Builder[Project], viewer int64) *sqlb.Builder[Project]

StarredBy filters on the per-viewer fact, which is the same EXISTS as the projection and the same reason it needs a bind.

func View

func View(viewer int64) *sqlb.Builder[Project]

View builds the read. It is Query[Project] rather than Query[ProjectView] because the table is the same table — ProjectView describes the *result*, and Collect is what pairs the two.

Note what did not have to change: hooks registered on Project still run, so the tenant predicate applies to this query as much as to a plain list. The derived values ride along on a query the domain still constrains.

Types

type DeclaredProject added in v0.5.0

type DeclaredProject struct {
	ID             int64      `db:"id" json:"id" sqlb:"pk"`
	OrgID          int64      `db:"org_id" json:"org_id" sqlb:"filter,readonly,scope"`
	Name           string     `db:"name" json:"name" sqlb:"filter,search,sort"`
	DueDate        *time.Time `db:"due_date" json:"due_date" sqlb:"filter,sort"`
	TotalTasks     int32      `db:"total_tasks" json:"total_tasks" sqlb:"filter,sort,readonly"`
	CompletedTasks int32      `db:"completed_tasks" json:"completed_tasks" sqlb:"filter,sort,readonly"`
	OpenTasks      int32      `db:"open_tasks" json:"open_tasks" sqlb:"filter,sort,readonly"`

	IsOverdue bool   `db:"is_overdue" json:"is_overdue" sqlb:"filter,readonly"`
	Progress  *int32 `db:"progress" json:"progress" sqlb:"filter,sort,readonly"`
	IsStarred bool   `db:"is_starred" json:"is_starred" sqlb:"filter,readonly"`
}

DeclaredProject is what codegen emits from the table above: an ordinary struct whose derived fields are ordinary fields, plus one method carrying the expressions.

The expressions are in a method rather than in the `sqlb` tag because a tag is a comma-separated list of words and SQL is neither. Everything else about these columns is said in the tag exactly as it is for a stored one, which is the property that makes them work everywhere without a second code path.

func (DeclaredProject) ComputedColumns added in v0.5.0

func (DeclaredProject) ComputedColumns() []sqlb.Computed

ComputedColumns carries the expressions the schema declared.

func (DeclaredProject) TableName added in v0.5.0

func (DeclaredProject) TableName() string

type Project

type Project struct {
	ID      int64      `db:"id" sqlb:"pk,default"`
	OrgID   int64      `db:"org_id" sqlb:"filter,scope,readonly"`
	Name    string     `db:"name" sqlb:"filter,sort,search"`
	DueDate *time.Time `db:"due_date" sqlb:"filter,sort"`

	TotalTasks     int `db:"total_tasks" sqlb:"filter,sort,readonly,default"`
	CompletedTasks int `db:"completed_tasks" sqlb:"filter,sort,readonly,default"`
	OpenTasks      int `db:"open_tasks" sqlb:"filter,sort,readonly,default"`
}

Project is the row. The three derived columns are declared exactly like the stored ones, because to everything above Postgres that is what they are.

ReadOnly is the load-bearing word. Without it the generated create and update bodies would accept a total_tasks, and a request could make the counter disagree with the tasks it counts. Postgres would reject a write to open_tasks on its own — it is GENERATED ALWAYS — but as a 500 naming a constraint, where ReadOnly is a 400 naming the field.

func (Project) TableName

func (Project) TableName() string

type ProjectView

type ProjectView struct {
	Project

	// Evaluated per read. IsOverdue reads current_date, so it can change
	// without the row changing — which is exactly why it cannot be technique
	// (1): Postgres requires a generated column's expression to be IMMUTABLE.
	IsOverdue bool `db:"is_overdue"`

	// NULL when the project has no tasks, which is why this is a pointer.
	// NULLIF is doing that: a zero denominator would otherwise be a division
	// error taking the whole request with it, not a zero.
	Progress *int `db:"progress"`

	// Depends on who is asking, not on the row. No column and no view can hold
	// this, because there is no "the" answer — it is a function of the request.
	IsStarred bool `db:"is_starred"`
}

ProjectView is the row plus the values no column holds. Embedding Project keeps one definition of the shared columns — an untagged embedded struct contributes its own fields, so the view maps every column the table has and then some.

It is a separate type from Project on purpose. Query[Project] stays the plain table read that INSERT ... RETURNING and every hook already work against; asking for the derived values is a different, more expensive query, and the type is what says so at the call site.

func (ProjectView) TableName

func (ProjectView) TableName() string

Jump to

Keyboard shortcuts

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