storm

package module
v0.4.2-0...-925b3e0 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 15 Imported by: 0

README

storm

Every other Go ORM builds SQL at runtime. storm builds it at compile time — including the dynamic queries.

An embeddable ORM for PostgreSQL and Go. Generated, not reflected. Imported, not deployed. Zero CGO; the driver lives behind a four-method port.

Model-first: one plain Go struct per table — no tags, no DSL. Everything the type cannot say goes in a Schema method using field pointers, so the editor enforces names and refactors follow them. storm emits reviewable migrations and never applies DDL.

Renamed from raorm (2026-08-27). The module path is now github.com/gsoultan/storm, and v0.2.0 is the first usable version under it — GitHub's redirect makes the older tags visible under this path, but their go.mod declares the old one and Go rejects the mismatch. v0.1.x remains available as github.com/gsoultan/raorm. See CHANGELOG.md for the two-step migration.

Status

v0.5.0 is tagged. The read path, migrations, relations, writes, the typed escape hatch and the tooling gate are built, benchmarked and hardened; the first adopter migrated a whole bounded context (M6) and runs on the published module. v0.3.0 added model discovery, declared aggregations and joins, full-text search, range types and typed constraint errors — and a MySQL DDL back end that is not a runtime target (ADR-0007). v0.4.0 makes every supported column type filterable — arrays and jsonb round-tripped but could only be tested for NULL — and fixed a silent wrong answer: through v0.3.0, two list predicates in one query bound the same list twice and returned the wrong rows without erroring. v0.4.1 fixes another, in every version that has had numeric: a Decimal whose scale is not a multiple of four and whose value passes roughly 9.2e15 was encoded as zero, or as a wrapped value that looks plausible.

v0.5.0 closes the escape hatch's injection vector — a storm.SQL statement now runs only if storm generate PREPAREd its exact text, so upgrading requires regeneration — and adds declared unions, declared parameters, the anti-join, many-to-many, top-N by a measure, and AnyOf for OR across whole conditions. It carries one more silent wrong answer: through v0.4.1 a table past 512 filterable columns built its predicates from a wrapped child's fragment table in a composed statement. The milestone log with every exit gate is docs/PLAN.md, what would still stop a team adopting this is written down, with gates, in docs/PRODUCTION-READINESS.md, and where the declared surface ends is docs/COMPLEX-QUERIES.md.

Every claim below is a test or a benchmark in this repository. The quickstart is executable: examples/blog runs as a test in CI, so it cannot drift from the library the way prose does.

The sixty-second tour

// The model: a plain struct. *T = nullable, Xxx Yyy = foreign key,
// []T = has-many. Field pointers declare the rest.
type Author struct {
    storm.Model            // uuid id + created_at/updated_at
    Name     string
    Email    string
    Articles []Article
}

func (a *Author) Schema(t *storm.Table)           { t.Unique(&a.Email) }
func (a *Author) Plans(p *storm.Plans)            { p.Named("Feed").With(&a.Articles) }
func (a *Author) Projections(p *storm.Projections) { p.Named("Card", &a.Name, &a.Email) }
// Typed queries. A dynamic query has a bounded set of shapes; each compiles
// once, and a warm call allocates NOTHING to build its SQL.
rows, err := article.New().
    Where(article.AuthorID.Eq(id), article.PublishedAt.IsNotNull()).
    Order(article.PublishedAt.Desc()).
    All(ctx, ex, nil)

// The semi-join: authors who HAVE a published article. Child predicates are
// typed by the child's package; one EXISTS probe per row.
store.AuthorHavingArticles(author.New(), article.PublishedAt.IsNotNull())

// The named plan: authors WITH their articles — exactly two round trips
// whatever the row count, and reading an unloaded relation DOES NOT COMPILE.
feed, err := store.AuthorFeed().Limit(10).All(ctx, ex)

// Many-to-many: a slice on BOTH sides, and storm generates the join table.
// Three round trips — parents, links, far side — at any counts, not per parent.
type Post struct { storm.Model; Tags []Tag }
type Tag  struct { storm.Model; Posts []Post }   // → post_tags(post_id, tag_id)
rows, err := store.PostWithTags().All(ctx, ex)

// Writes are masked: unset columns take their database defaults, and an
// UPDATE writes only what was assigned. A version column makes stale writers
// lose loudly. Graph writes flush in FK order, one batch, atomic.
n := author.Create(); n.SetName("Ada"); n.SetEmail("ada@example.com")
ada, err := n.Insert(ctx, ex)

// Declared aggregations: GROUP BY without dropping to SQL. The result types
// are PostgreSQL's, not the input's — count is int64, sum(numeric) is a
// NULLABLE Decimal, because over zero rows it IS null. Each declaration hands
// back a handle, so a later clause refers to it by value, not by string.
func (o *Order) Aggregates(a *storm.Aggregates) {
    b := a.Named("ByStatus")
    b.By(&o.Status)
    orders := b.Count("Orders")
    b.Sum(&o.Total, "Revenue")
    b.Having(a.Gt(orders, 0))
}
rows, err := order.New().Where(order.PlacedAt.Gte(t)).AllByStatus(ctx, ex)

// Joins project ACROSS tables, declared with field pointers into a local var.
// LEFT is in the type: anything from the right side is Null[T].
func (o *Order) Joins(j *storm.Joins) {
    var c Customer
    j.Named("WithCustomer").Inner(&c, &o.Customer).
        Take(&o.ID, "OrderID").Take(&c.Email, "Email").OrderDesc(&o.PlacedAt)
}
rows, err := order.New().Where(order.Status.Eq("paid")).AllWithCustomer(ctx, ex)

// Every supported type is filterable, and the handle's TYPE decides how:
// Age.Like(...) does not compile. Arrays and jsonb get the operators a GIN
// index answers — never equality, which on both is a trap dressed as a feature.
product.New().Where(product.Tags.Overlaps("sale", "clearance"))         // &&
product.New().Where(product.Attrs.Contains(runtime.JSON(`{"c":"red"}`))) // @>
product.New().Where(product.Attrs.HasAllKeys("colour", "size_cm"))       // ?&
stockitem.New().Where(stockitem.OnHand.NotIn(0, 1))                      // <> ALL
customer.New().Where(customer.Email.ILike("ada@%"))                      // ILIKE

// Declared reports: a GROUP BY, the expressions over it, and the window
// frame a moving average needs. Predicates still compose at the call site.
func (o *Order) Aggregates(a *storm.Aggregates) {
    t     := a.Named("Trend")
    day   := t.ByExpr("Day", a.DateTrunc("day", &o.PlacedAt))
    since := t.Param("Since")            // "the last N days", fixed shape
    n     := t.Count("Orders").Filter(a.Gte(&o.PlacedAt, since))
    paid  := t.Count("Paid").Filter(a.Eq(&o.Status, "paid"))
    rev   := t.Sum(&o.Total, "Revenue")

    t.CountDistinct(&o.Customer, "Buyers")
    t.Compute("PaidRate", a.Div(paid, a.NullIf(n, a.Lit(0))))   // numeric, not 0 or 1
    t.AvgOver(rev, "Revenue7d", a.Over().OrderByAsc(day).
        Rows(a.Preceding(6), a.CurrentRow()))                   // a MOVING average
}

// The anti-join, and both halves in one statement: bought this, never that.
store.CustomerHavingOrders(customer.New(), coffee).AndNotHaving(equipment)

// A UNION: several tables merged, ordered and capped as a MERGE — twenty rows
// is the twenty most recent THINGS, not twenty of each. It has no driving
// table, so it is declared as a var rather than a method.
var Activity = storm.Union("Activity", func(u *storm.UnionSpec) { /* ... */ })
recent, err := store.Activity(ctx, ex, actorID, 20)

// Anything PostgreSQL can run, typed, validated against the model at
// generate time — mismatches fail the build naming the column and the fix.
// Only these DECLARED statements run: a statement assembled at run time is
// refused before it reaches the server, so the escape hatch is not one.
var Top = storm.SQL[TopRow](`WITH ranked AS (...) SELECT ... LIMIT $1`)
var Purge = storm.SQLExec(`DELETE FROM sessions WHERE expires_at < now()`)

The numbers

Measured, never quoted from memory — the methodology and every caveat live in bench/RESULTS.md. At 1,000 rows, allocations per query:

storm raw pgx sqlc Bun Ent GORM
6 5,012 5,022 13,899 23,016 23,934

Measured on Go 1.26.6. The allocation counts above were re-checked on 1.27 and are unchanged — they are what this table is about. The wall-clock figures have not been, and one offline benchmark did move; the note at the top of bench/RESULTS.md has the detail.

Wall clock is round-trip-dominated for every ORM — the honest claims are allocations, GC pressure (storm 21 GCs vs pgx's 102 on the 2M-row workload), and the plans: Exists() is a LIMIT 1 probe, relation loads carry no useless ORDER BY, per-parent limits lower to LATERAL (measured 33× over row_number() at 100 parents), and projections make index-only scans possible.

Tooling

Install it, run it, write no bootstrap. There is no registry to maintain and no main to keep in step with your models — storm finds them by parsing (ADR-0006):

go install github.com/gsoultan/storm/cmd/storm@latest
go get github.com/gsoultan/storm/tool     # once per module

storm generate [dir]    # one package per table + the context package
      watch <dir>       # regenerate on save; leave it running while you edit
      models            # what discovery found, and which rule matched
      diff <name>       # a reviewable migration; never applied by storm
      verify            # drift: model vs database
      verify -stale     # generated code vs model (no database, unless you declare storm.SQL)
      verify -pending   # model vs migrations — "forgot to diff" fails CI
      lint              # every named plan costed in round trips, budgeted
      explain           # every statement planned by the server; large seq scans flagged
      import            # an existing database, written back as a model draft
      portable <engine> # what in this model does NOT cross to another engine

A type is a model when it embeds storm.Model, or declares a Schema, Plans or Projections method, or carries //storm:model; //storm:ignore opts out. A type embedded in another struct is a mixin, not a table. storm models prints the verdict and the rule behind it.

Field pointers still resolve at runtime — storm writes the bootstrap it used to ask you for, runs it, and removes it. The hand-written tool.Main(model.All(), model.Queries()) is still supported and generates byte-identical code (EXAMPLE §2).

You do not have to remember to regenerate. The step cannot be removed — Go has no build hook — but two things remove the remembering. storm watch keeps the tree current as you save. And generated code carries a shape assertion, so a model that gained, lost, renamed or reordered a field stops the build naming that field, instead of silently missing it:

store/shape.gen.go:57:2: too few values in struct literal of type model.Product

Changes inside Schema, Plans or Projections are method bodies the type system cannot see; storm verify -stale is still the check for those.

A worked service

examples/orders is a Go kit microservice on storm, in its own module: catalogue, checkout that reserves stock under concurrency, order retrieval, and a finance report. Its concurrency test puts 12 goroutines against a stock of 20 and asserts nothing is oversold.

$ cd examples/orders && storm generate store && go test ./orders/

Design documents

CONCEPT · ARCHITECTURE · DIALECTS · COMPARISON · PLAN — the ADRs, including the rejected-ideas list, are under docs/adr. Some API sketches in the design docs describe planned surface (joins with cross-table rows, aggregate projections); the example and the generated code are the as-built truth.

Scheduling without a race

During storm.TstzRange                       // in the model
t.Exclude(storm.With(&b.Room, storm.OpEq),
          storm.With(&b.During, storm.OpOverlaps))

booking.New().Where(booking.During.Overlaps(window)).All(ctx, ex, nil)

Half-open by default, so [09:00, 11:00) and [11:00, 12:00) abut rather than clash. The overlap is enforced by a GiST exclusion constraint — two concurrent bookings for one room cannot both commit — and the loser gets runtime.ErrExclusionViolation. No other Go ORM models exclusion constraints, and they are the correct answer to booking, scheduling and rate-plan overlap.

Search storm.TSVector          // in the model
t.Col(&p.Search).Generated(storm.RawSQL(`to_tsvector('english', name)`)).Index()

product.New().Where(product.Search.WebSearch(q)).All(ctx, ex, nil)

Filterable, never readable: a tsvector is index support, so it is absent from Row and from writes. The term is bound, so a search for '); DROP TABLE -- is a search for those words. Optional filters compose without a nil check: q = product.WhenSet(q, f.MinPrice, product.Price.Gte).

Errors you can switch on

Constraint violations are typed at the driver boundary, so a handler never decodes a SQLSTATE:

_, err := n.Insert(ctx, ex)
switch {
case errors.Is(err, runtime.ErrUniqueViolation):     // 409, and ce.Constraint says which
case errors.Is(err, runtime.ErrForeignKeyViolation): // 400
case runtime.Retryable(err):                          // 40001/40P01 — run it again
}

ConstraintError names the constraint, table and column and carries no bound value; PostgreSQL's own diagnostic does, and it stays reachable through Unwrap. Anything storm has no opinion about comes back unchanged.

Boundaries

No applied DDL. No lazy loading — ever; unloaded relations do not compile. No runtime dialect branch. No reflection under runtime/. Every "no" is a year of maintenance not spent; the reasoning lives in the ADRs.

Documentation

Overview

Package storm is the public surface: the types you declare a model with, and the builder that turns those models into a schema.

Index

Constants

View Source
const (
	OpEq       = "="
	OpOverlaps = "&&"
	OpAdjacent = "-|-"
)

Exclusion operators.

View Source
const (
	BTree = "btree"
	GIN   = "gin"
	GiST  = "gist"
	Hash  = "hash"
	BRIN  = "brin"
)

Index methods.

View Source
const MaxTimeOfDay = runtime.MaxTimeOfDay

MaxTimeOfDay is 24:00:00, which PostgreSQL accepts as a `time`.

Variables

This section is empty.

Functions

func ArgsOf added in v0.5.0

func ArgsOf(d RawDecl) int

ArgsOf reports how many arguments a declaration will demand at the call.

The count comes from scanning the statement for the highest $n, which is a guess: `$1` inside a string literal or a dollar-quoted body is text to PostgreSQL and a placeholder to a scanner. The generator PREPAREs every declaration and the server reports the real number, so the two are compared at generate time and a disagreement fails the build — which is the whole reason this is reachable from outside the package.

func Build

func Build(models ...any) (*schema.Schema, error)

Build turns a set of model structs into a schema. Pass pointers to zero values: Build(&User{}, &Org{}, &Post{}).

Every declaration problem is collected and reported together, because failing on the first one would mean N build cycles to find N mistakes.

func DeclOf

func DeclOf(d RawDecl) (reflect.Type, string)

DeclOf reads a registered query's row type and SQL; the generate command uses it and nothing else should.

func RegisterScanner

func RegisterScanner[T any](fn func([][]byte, *T, *runtime.Slab) error)

RegisterScanner is called by generated code from an init(). One scanner per row type: two queries sharing a row type share its scanner, which is safe because the generator validated both against the same descriptor shape.

func RegisterStatement added in v0.5.0

func RegisterStatement(sql string)

RegisterStatement records a statement that `storm generate` PREPAREd and validated against the model. Generated code calls it from an init().

It takes the statement TEXT rather than a digest so the generated init is reviewable: what a reader needs to check is which statements are allowed to run, and a list of hex is not that.

Types

type Action

type Action string

Action is a referential action for OnDelete/OnUpdate.

const (
	Restrict   Action = "RESTRICT"
	Cascade    Action = "CASCADE"
	SetNull    Action = "SET NULL"
	SetDefault Action = "SET DEFAULT"
	NoAction   Action = "NO ACTION"
)

type AggregateBuilder added in v0.3.0

type AggregateBuilder struct {
	// contains filtered or unexported fields
}

AggregateBuilder accumulates one declaration.

func (*AggregateBuilder) Avg added in v0.3.0

func (b *AggregateBuilder) Avg(x any, as string) Out

func (*AggregateBuilder) AvgOver added in v0.5.0

func (b *AggregateBuilder) AvgOver(x any, as string, w *WindowSpec) Out

func (*AggregateBuilder) By added in v0.3.0

func (b *AggregateBuilder) By(fieldPtrs ...any) *AggregateBuilder

By groups by columns. The field name is derived from the column name.

func (*AggregateBuilder) ByExpr added in v0.3.0

func (b *AggregateBuilder) ByExpr(as string, t Term) Out

ByExpr groups by an expression, which needs a name because date_trunc('day', placed_at) has no obvious one.

func (*AggregateBuilder) Compute added in v0.5.0

func (b *AggregateBuilder) Compute(as string, t Term) Out

Compute adds an output that is an EXPRESSION over the group — the ratio, the difference, the share — rather than a single aggregate:

orders := b.Count("Orders")
paid   := b.Count("Paid").Filter(a.Eq(&o.Status, "paid"))
b.Compute("PaidRate", a.Div(paid, a.NullIf(orders, a.Lit(0))))

The NullIf is not decoration. It is the division-by-zero guard, and it sits in the expression instead of in a comment above it.

The result type is whatever the expression resolves to, so a ratio over two counts is numeric — not the truncated integer PostgreSQL's `/` would give.

func (*AggregateBuilder) Count added in v0.3.0

func (b *AggregateBuilder) Count(as string) Out

Count adds count(*): rows per group, never NULL.

func (*AggregateBuilder) CountDistinct added in v0.5.0

func (b *AggregateBuilder) CountDistinct(fieldPtr any, as string) Out

CountDistinct adds count(DISTINCT col): how many DIFFERENT values the column takes, which is a third question again from Count (rows) and CountOf (rows where it is not null).

DISTINCT is offered for count and nothing else. `sum(DISTINCT x)` and `avg(DISTINCT x)` are legal SQL and almost always a bug — the sum of the distinct values of a price column is not a number anyone wanted — so having them would cost more than it bought.

It cannot be combined with a window: PostgreSQL rejects DISTINCT in an aggregate used as a window function, and storm refuses it at declaration rather than emitting SQL the server will not plan.

func (*AggregateBuilder) CountOf added in v0.3.0

func (b *AggregateBuilder) CountOf(fieldPtr any, as string) Out

CountOf adds count(col), which counts rows where the column is NOT NULL — a different question from Count, and a common bug, so a different method.

func (*AggregateBuilder) Cube added in v0.3.0

Cube is ROLLUP's every-combination sibling: 2ⁿ grouping sets.

func (*AggregateBuilder) CumeDist added in v0.5.0

func (b *AggregateBuilder) CumeDist(as string, w *WindowSpec) Out

func (*AggregateBuilder) DenseRank added in v0.3.0

func (b *AggregateBuilder) DenseRank(as string, w *WindowSpec) Out

func (*AggregateBuilder) FirstValue added in v0.3.0

func (b *AggregateBuilder) FirstValue(x any, as string, w *WindowSpec) Out

FirstValue is the first row's value in the window.

func (*AggregateBuilder) GroupingOf added in v0.3.0

func (b *AggregateBuilder) GroupingOf(as string, fieldPtrs ...any) Out

GroupingOf adds GROUPING(cols...) — 1 when the row is a subtotal over those columns, 0 when the value is real.

func (*AggregateBuilder) Having added in v0.3.0

func (b *AggregateBuilder) Having(c Cond) *AggregateBuilder

func (*AggregateBuilder) Lag added in v0.3.0

func (b *AggregateBuilder) Lag(x any, as string, w *WindowSpec) Out

Lag and Lead read the previous or next row in the window. Both are NULL at the partition edge however non-null the column is, so both produce a nullable field.

func (*AggregateBuilder) LastValue added in v0.5.0

func (b *AggregateBuilder) LastValue(x any, as string, w *WindowSpec) Out

LastValue reads the last row of the window frame — and the frame, not the partition, is the point. PostgreSQL's default frame ends at the CURRENT ROW, so `last_value` without a frame returns the current row's own value, which is the single most reported surprise in window functions. Give it a frame that reaches the end:

a.Over().OrderByAsc(day).Rows(a.UnboundedPreceding(), a.UnboundedFollowing())

func (*AggregateBuilder) Lead added in v0.3.0

func (b *AggregateBuilder) Lead(x any, as string, w *WindowSpec) Out

func (*AggregateBuilder) Max added in v0.3.0

func (b *AggregateBuilder) Max(x any, as string) Out

func (*AggregateBuilder) MaxOver added in v0.5.0

func (b *AggregateBuilder) MaxOver(x any, as string, w *WindowSpec) Out

func (*AggregateBuilder) Min added in v0.3.0

func (b *AggregateBuilder) Min(x any, as string) Out

func (*AggregateBuilder) MinOver added in v0.5.0

func (b *AggregateBuilder) MinOver(x any, as string, w *WindowSpec) Out

func (*AggregateBuilder) OrderAsc added in v0.5.0

func (b *AggregateBuilder) OrderAsc(o Out) *AggregateBuilder

Having filters the GROUPS, after aggregation. A call-site Where filters the rows that go INTO the groups; these are different questions and mixing them up silently changes the answer. OrderAsc and OrderDesc order the result by a declared output, replacing the default ordering — the grouping columns, in declaration order.

This is what makes a top-N report one statement. A grouped read can only be ordered by something in its select list, so before an output could be the sort key the only orderings available were the ones the grouping already gave; "the ten products by revenue" meant fetching every product and sorting in Go, which is a LIMIT the database never sees.

b := a.Named("TopProducts")
b.By(&p.SKU)
rev := b.Sum(&p.Amount, "Revenue")
b.OrderDesc(rev)

rows, err := product.New().Limit(10).AllTopProducts(ctx, ex)

The handle has to come from THIS aggregation: ordering by an output of another one is a build error naming both, not SQL that fails on first use.

func (*AggregateBuilder) OrderDesc added in v0.5.0

func (b *AggregateBuilder) OrderDesc(o Out) *AggregateBuilder

OrderDesc orders descending — see OrderAsc.

func (*AggregateBuilder) Param added in v0.5.0

func (b *AggregateBuilder) Param(name string) Term

Param declares a value the CALL supplies, for use inside a Filter or a Having:

since := b.Param("Since")
b.Count("Recent").Filter(a.Gte(&e.OccurredAt, since))

rows, err := event.New().AllRates(ctx, ex, time.Now().Add(-30*24*time.Hour))

A FILTER is part of the declaration, so its condition is fixed at generate time — which makes "the last thirty days" unsayable, because that is relative to when the query runs. This is the narrow answer: the aggregation still has ONE shape, one compiled statement and one scanner; only a value varies.

The type is inferred from the column the parameter is first compared with, so the generated signature cannot disagree with what it filters. Declared parameters are numbered before the call-site predicates, and appear in the generated function in declaration order.

func (*AggregateBuilder) PercentRank added in v0.5.0

func (b *AggregateBuilder) PercentRank(as string, w *WindowSpec) Out

PercentRank and CumeDist are the fractional ranks: where a row sits in its window as a number between 0 and 1, which is what a percentile report wants and what Rank cannot give without knowing the partition size.

Both are float8 and never NULL — an empty window produces no rows to rank.

func (*AggregateBuilder) Rank added in v0.3.0

func (b *AggregateBuilder) Rank(as string, w *WindowSpec) Out

func (*AggregateBuilder) Rollup added in v0.3.0

func (b *AggregateBuilder) Rollup() *AggregateBuilder

Rollup turns the grouping into ROLLUP(...): every prefix of the grouping columns plus a grand total, in one pass instead of one query per level.

Every grouping column becomes NULLABLE in the row type, because a subtotal row carries NULL for the columns it aggregated over. Use storm.Grouping to tell that NULL from one that was in the data.

func (*AggregateBuilder) RowNumber added in v0.3.0

func (b *AggregateBuilder) RowNumber(as string, w *WindowSpec) Out

RowNumber, Rank and DenseRank number rows within the window.

func (*AggregateBuilder) Sets added in v0.3.0

func (b *AggregateBuilder) Sets(sets ...[]string) *AggregateBuilder

Sets declares explicit grouping sets by the names given to By/ByExpr. An empty set is the grand total row.

This is the answer to N+1 queries per facet: one pass over the table produces every facet count, instead of one query per facet.

func (*AggregateBuilder) Sum added in v0.3.0

func (b *AggregateBuilder) Sum(x any, as string) Out

Sum, Avg, Min and Max are the ordinary aggregates. All four are NULL over zero rows, so all four produce a nullable field.

func (*AggregateBuilder) SumOver added in v0.5.0

func (b *AggregateBuilder) SumOver(x any, as string, w *WindowSpec) Out

SumOver, AvgOver, MinOver and MaxOver aggregate ACROSS THE GROUPS — the running total, the moving average, the high-water mark:

rev := b.Sum(&o.Total, "Revenue")
b.AvgOver(rev, "Moving7", a.Over().OrderByAsc(day).
    Rows(a.Preceding(6), a.CurrentRow()))

The argument is a declared output, and that is the whole distinction. A grouped query has already collapsed its rows, so `sum(total) OVER (...)` reads a column that no longer exists per output row and PostgreSQL refuses it; `sum(sum(total)) OVER (...)` is the form that means "across the groups", and passing the handle is what produces it.

Give them a frame. Without one PostgreSQL's default reaches from the start of the partition to the current row, which makes every one of these a RUNNING figure rather than a moving one.

type Aggregates added in v0.3.0

type Aggregates struct {
	// Exprs is the declaration-time expression vocabulary: a.Eq, a.DateTrunc,
	// a.Over and the rest. Embedded rather than exported at package level so a
	// declaration constructor cannot be reached from a query context.
	Exprs
	// contains filtered or unexported fields
}

Named aggregations: a GROUP BY and the expressions over it, declared once.

func (o *Order) Aggregates(a *storm.Aggregates) {
    a.Named("Daily").
        ByExpr("Day", storm.DateTrunc("day", &o.PlacedAt)).
        Count("Orders").
        Count("Paid").Filter(storm.Eq(&o.Status, StatusPaid)).
        Sum(&o.Total, "Revenue").
        RowNumber("Rank", storm.Over().OrderByDesc(&o.Total))
}

rows, err := order.New().
    Where(order.PlacedAt.Gte(since)).   // call-site predicates still compose
    AllDaily(ctx, ex)                   // []order.DailyRow

**Declared, not composed at the call site**, for the reason the library exists: a `GroupBy(...).Select(...)` chain assembled at run time has an unbounded set of result shapes, and a shape storm has not seen can have neither a generated scanner nor a compiled statement. Naming it keeps the whole thing inside the compilation thesis. The call-site predicates stay dynamic because those ARE bounded.

func (*Aggregates) Named added in v0.3.0

func (a *Aggregates) Named(name string) *AggregateBuilder

Named starts an aggregation. The generated type is this name plus "Row", so "Daily" becomes DailyRow in the table's package.

type Aggregator added in v0.3.0

type Aggregator interface {
	Aggregates(*Aggregates)
}

Aggregator is implemented by models that declare aggregations. Optional.

type AnyRef added in v0.5.0

type AnyRef struct{}

AnyRef is the DISCRIMINATOR form of polymorphism: a (type, id) pair naming a row in any table at all.

Two columns — `<field>_type` and `<field>_id` — and no foreign key, because no database can constrain one. Nothing stops the id naming a row that does not exist, or the type naming a table that does not either. That is not a gap in storm; it is what the shape costs, and it is why OneOf is the default.

storm will not generate it silently. A model declaring an AnyRef without calling AcknowledgeNoFK fails Build, naming the field and the two ways out. "We gave up referential integrity" belongs in a diff, and a required call is the only place a reviewer is guaranteed to see it.

The variants are unbounded, which is the one thing OneOf cannot offer past about eight. When integrity matters and the variant count does not fit a column each, the answer is a supertype table — full integrity, no arity limit, one extra insert.

Zero-sized, like every OneOfN, and for the same reason: the model is a DECLARATION. The two columns it stands for appear in the generated Row as SubjectType and SubjectID, exactly as an arc's variants appear as their own key columns — the row carries columns, not the declaration that produced them.

type ColBuilder

type ColBuilder struct {
	// contains filtered or unexported fields
}

ColBuilder configures one column.

func (*ColBuilder) AcknowledgeNoFK added in v0.5.0

func (b *ColBuilder) AcknowledgeNoFK(reason string) *ColBuilder

AcknowledgeNoFK records why this AnyRef gives up referential integrity.

Required: Build refuses an AnyRef without one. The reason travels into the schema and out through `storm diff`, so the decision is visible where it is reviewed rather than only where it was made.

func (*ColBuilder) Cidr

func (b *ColBuilder) Cidr() *ColBuilder

Cidr narrows a netip.Prefix column from inet to cidr — the database then rejects host bits, which is the entire difference between the two types.

func (*ColBuilder) Comment

func (b *ColBuilder) Comment(s string) *ColBuilder

func (*ColBuilder) Date

func (b *ColBuilder) Date() *ColBuilder

Date narrows a time.Time column to a calendar date. The Go type stays time.Time (there is no stdlib date), decoded as midnight UTC.

func (*ColBuilder) Default

func (b *ColBuilder) Default(e Expr) *ColBuilder

func (*ColBuilder) Generated

func (b *ColBuilder) Generated(e Expr) *ColBuilder

func (*ColBuilder) Immutable

func (b *ColBuilder) Immutable() *ColBuilder

func (*ColBuilder) Index

func (b *ColBuilder) Index() *ColBuilder

Index adds a single-column index.

func (*ColBuilder) Named

func (b *ColBuilder) Named(n string) *ColBuilder

func (*ColBuilder) NotNull

func (b *ColBuilder) NotNull() *ColBuilder

func (*ColBuilder) Nullable

func (b *ColBuilder) Nullable() *ColBuilder

func (*ColBuilder) Numeric

func (b *ColBuilder) Numeric(p, s int) *ColBuilder

func (*ColBuilder) OnDelete

func (b *ColBuilder) OnDelete(a Action) *ColBuilder

OnDelete and OnUpdate set the referential action of this column's foreign key.

func (*ColBuilder) OnUpdate

func (b *ColBuilder) OnUpdate(a Action) *ColBuilder

func (*ColBuilder) Raw

func (b *ColBuilder) Raw(sqlType string) *ColBuilder

Raw forces a database type storm does not model.

func (*ColBuilder) Size

func (b *ColBuilder) Size(n int) *ColBuilder

func (*ColBuilder) Unique

func (b *ColBuilder) Unique() *ColBuilder

Unique adds a single-column unique constraint. On a foreign key this is what turns one-to-many into one-to-one.

func (*ColBuilder) Version

func (b *ColBuilder) Version() *ColBuilder

type Cond added in v0.3.0

type Cond struct {
	// contains filtered or unexported fields
}

Cond is a DECLARED predicate, used by Filter, Having and a join's Where.

Distinct from a call-site Where, which is dynamic: that varies per call and is a token stream spliced into a cached statement with bound arguments. A declared predicate never varies, so it is rendered into the text.

type Decimal

type Decimal = runtime.Decimal

Decimal is an exact fixed-point number for a numeric column.

An alias, not a wrapper: the model declares storm.Decimal and generated code reads runtime.Decimal, and those must be the same type or every value would need converting at the boundary storm exists to remove.

float64 is not offered for numeric. It cannot represent 0.10, and an accounting system that rounds is a defect rather than a tolerance — so the choice is made once, here, instead of by whoever writes the model.

func ParseDecimal

func ParseDecimal(s string) (Decimal, error)

ParseDecimal reads a decimal from its text form.

type Enumer

type Enumer interface {
	EnumValues() []string
}

Enumer marks a named type as a Postgres enum. Constants are not discoverable through reflection, so the type has to list them.

type Status string
const (StatusActive Status = "active"; StatusBanned Status = "banned")
func (Status) EnumValues() []string { return []string{"active", "banned"} }

type ExcludeBuilder

type ExcludeBuilder struct {
	// contains filtered or unexported fields
}

ExcludeBuilder configures an exclusion constraint.

func (*ExcludeBuilder) Named

func (b *ExcludeBuilder) Named(n string) *ExcludeBuilder

func (*ExcludeBuilder) Using

func (b *ExcludeBuilder) Using(method string) *ExcludeBuilder

func (*ExcludeBuilder) Where

func (b *ExcludeBuilder) Where(e Expr) *ExcludeBuilder

type ExcludeSpec

type ExcludeSpec struct {
	// contains filtered or unexported fields
}

ExcludeSpec is one `<column-or-expression> WITH <operator>` part.

func With

func With(field any, op string) ExcludeSpec

With pairs a field with an exclusion operator.

func WithExpr

func WithExpr(e RawSQL, op string) ExcludeSpec

WithExpr pairs an expression with an exclusion operator, for the range overlap that scalar columns cannot express:

t.Exclude(storm.With(&b.Room, storm.OpEq),
          storm.WithExpr("tstzrange(starts_at, ends_at)", storm.OpOverlaps))

type Expr deprecated

type Expr = RawSQL

Expr is the former name of RawSQL.

Deprecated: use RawSQL. Kept as an alias so models written against v0.1–v0.3 keep compiling; it will not be removed before v2.

type Exprs added in v0.4.0

type Exprs struct{}

Exprs is the declaration-time expression vocabulary, reached through the `*Aggregates` or `*Joins` value a declaration is handed.

Methods rather than package functions, and deliberately. These were `storm.Eq`, `storm.And`, `storm.Col` and nineteen more at the top level, where they sat beside the generated query API meaning something different: `order.Status.Eq(x)` filters rows at run time, `storm.Eq(&o.Status, x)` described a filter at declaration time. Two `Eq`s in scope with different semantics is a question every reader has to answer once. Hanging them off the builder answers it structurally — a declaration constructor cannot be reached from a query, because the builder is not in scope there.

func (Exprs) Abs added in v0.4.0

func (Exprs) Abs(x any) Term

Abs is the absolute value.

func (Exprs) Add added in v0.5.0

func (Exprs) Add(l, r any) Term

Add, Sub, Mul and Div are arithmetic over two terms — the ratio a report asks for, written where the division is.

Div on two integers resolves to NUMERIC, not to an integer. PostgreSQL's `/` truncates, so `Div(paid, total)` over two counts would otherwise be 0 for every group that is not entirely paid — a plausible-looking wrong answer, which is the only kind that matters.

Division by zero is still an error at the server, and NullIf is the guard:

a.Div(recent, a.NullIf(prior, a.Lit(0)))   // NULL, not a failed query

func (Exprs) And added in v0.4.0

func (Exprs) And(cs ...Cond) Cond

And, Or and Not compose conditions. Always parenthesised when rendered: AND/OR precedence is a classic source of silently wrong predicates and the brackets cost nothing.

func (Exprs) Coalesce added in v0.4.0

func (Exprs) Coalesce(args ...any) Term

Coalesce returns the first non-null argument.

func (Exprs) Col added in v0.4.0

func (Exprs) Col(fieldPtr any) Term

Col is an explicit column reference. Rarely needed: a field pointer is accepted directly wherever a Term is.

func (Exprs) CurrentRow added in v0.5.0

func (Exprs) CurrentRow() FrameBound

func (Exprs) DateTrunc added in v0.4.0

func (Exprs) DateTrunc(unit string, ts any) Term

DateTrunc buckets a timestamp — the reason grouping takes an expression at all. `unit` is a PostgreSQL field name: "hour", "day", "month", "year".

func (Exprs) Div added in v0.5.0

func (Exprs) Div(l, r any) Term

func (Exprs) DivScale added in v0.5.0

func (Exprs) DivScale(l, r any, scale int) Term

DivScale is Div with the scale said out loud — money that needs more than six places, or a percentage that wants two.

func (Exprs) Eq added in v0.4.0

func (Exprs) Eq(l, r any) Cond

Eq and friends compare a column, a declared output or an expression against a literal.

func (Exprs) Following added in v0.5.0

func (Exprs) Following(n int) FrameBound

func (Exprs) Grouping added in v0.4.0

func (Exprs) Grouping(fieldPtrs ...any) Term

Grouping reports, per row, whether a grouping set aggregated over these columns. It is how a ROLLUP's subtotal NULL is told apart from a NULL that was in the data — without it a subtotal row and a real NULL group are indistinguishable, which is a wrong answer that looks like a right one.

func (Exprs) Gt added in v0.4.0

func (Exprs) Gt(l, r any) Cond

func (Exprs) Gte added in v0.4.0

func (Exprs) Gte(l, r any) Cond

func (Exprs) IsNotNull added in v0.4.0

func (Exprs) IsNotNull(x any) Cond

func (Exprs) IsNull added in v0.4.0

func (Exprs) IsNull(x any) Cond

IsNull and IsNotNull test for NULL, which `= NULL` does not.

func (Exprs) Lit added in v0.4.0

func (Exprs) Lit(v any) Term

Lit is a declaration-time constant.

Rendered into the statement rather than bound, because it comes from the declaration and never varies — which is what keeps a filtered aggregate one cached statement instead of one per value.

func (Exprs) Lower added in v0.5.0

func (Exprs) Lower(x any) Term

Lower and Upper case-fold text. Useful in a grouping expression, where "Ada" and "ada" are one group or two and the answer has to be said.

func (Exprs) Lt added in v0.4.0

func (Exprs) Lt(l, r any) Cond

func (Exprs) Lte added in v0.4.0

func (Exprs) Lte(l, r any) Cond

func (Exprs) Mul added in v0.5.0

func (Exprs) Mul(l, r any) Term

func (Exprs) Ne added in v0.4.0

func (Exprs) Ne(l, r any) Cond

func (Exprs) Not added in v0.4.0

func (Exprs) Not(c Cond) Cond

func (Exprs) NullIf added in v0.4.0

func (Exprs) NullIf(a, b any) Term

NullIf returns NULL when the two arguments are equal — the division-by-zero guard, written where the division is rather than in a comment above it.

func (Exprs) OnCols added in v0.4.0

func (Exprs) OnCols(alias, column string, fieldPtr any) JoinOn

OnCols joins on a named column of an aliased scope — a CTE's output column, or a table's column — against a field of the model being attached.

func (Exprs) Or added in v0.4.0

func (Exprs) Or(cs ...Cond) Cond

func (Exprs) Over added in v0.4.0

func (Exprs) Over() *WindowSpec

Over starts a window.

func (Exprs) Preceding added in v0.5.0

func (Exprs) Preceding(n int) FrameBound

func (Exprs) Sub added in v0.5.0

func (Exprs) Sub(l, r any) Term

func (Exprs) UnboundedFollowing added in v0.5.0

func (Exprs) UnboundedFollowing() FrameBound

func (Exprs) UnboundedPreceding added in v0.5.0

func (Exprs) UnboundedPreceding() FrameBound

UnboundedPreceding, CurrentRow and UnboundedFollowing are the fixed frame edges; Preceding and Following take a count of rows.

func (Exprs) Upper added in v0.5.0

func (Exprs) Upper(x any) Term

type FrameBound added in v0.5.0

type FrameBound = schema.FrameBound

FrameBound is one edge of a window frame, built by the methods below.

type IndexBuilder

type IndexBuilder struct {
	// contains filtered or unexported fields
}

IndexBuilder configures an index after Index(...).

func (*IndexBuilder) Named

func (b *IndexBuilder) Named(n string) *IndexBuilder

func (*IndexBuilder) Unique

func (b *IndexBuilder) Unique() *IndexBuilder

func (*IndexBuilder) Using

func (b *IndexBuilder) Using(method string) *IndexBuilder

func (*IndexBuilder) Where

func (b *IndexBuilder) Where(e Expr) *IndexBuilder

type IndexColumn

type IndexColumn struct {
	// contains filtered or unexported fields
}

IndexColumn is a field reference with ordering or an expression applied.

func Asc

func Asc(field any) IndexColumn

Asc is the default; provided for symmetry.

func Desc

func Desc(field any) IndexColumn

Desc orders an index key descending.

func Lower

func Lower(field any) IndexColumn

Lower indexes lower(col) — the usual answer for case-insensitive uniqueness.

func NullsLast

func NullsLast(ic IndexColumn) IndexColumn

NullsLast puts NULLs at the end of an index key.

type Interval

type Interval = runtime.Interval

Interval is a PostgreSQL interval: months, days and microseconds kept separate, because a month has no fixed length and a day is not always 24 hours. An alias for the same reason Decimal is — the model's type and the generated code's type must be one type.

type JoinBuilder added in v0.3.0

type JoinBuilder struct {
	// contains filtered or unexported fields
}

JoinBuilder accumulates one declaration.

func (*JoinBuilder) Inner added in v0.3.0

func (b *JoinBuilder) Inner(model any, on any) *JoinBuilder

Inner and Left attach a table.

`on` is either a relation field pointer on the declaring model — the FK says how to join, so there is nothing to spell — or an explicit condition built with storm.OnCols.

func (*JoinBuilder) InnerWith added in v0.3.0

func (b *JoinBuilder) InnerWith(alias string, on JoinOn) *JoinBuilder

InnerWith and LeftWith attach a CTE that With() put in scope.

With() materialises it; these say how it joins. Two steps because a CTE can be referenced by more than one join condition, and because "declare it" and "attach it" are genuinely different decisions.

func (*JoinBuilder) Left added in v0.3.0

func (b *JoinBuilder) Left(model any, on any) *JoinBuilder

Left keeps every row of the left side. Every column taken from the right becomes nullable in the generated row, which is what a LEFT JOIN means.

func (*JoinBuilder) LeftWith added in v0.3.0

func (b *JoinBuilder) LeftWith(alias string, on JoinOn) *JoinBuilder

LeftWith keeps rows with no matching CTE row. Everything taken from the CTE becomes nullable, which is what a LEFT join means — and for an aggregate CTE it is usually the right choice, because a customer with no orders has no row in a GROUP BY over orders.

func (*JoinBuilder) OrderAsc added in v0.3.0

func (b *JoinBuilder) OrderAsc(fieldPtr any) *JoinBuilder

OrderAsc and OrderDesc order the joined result.

A join has no natural order, and an unordered multi-table result shuffles between requests — the same reason an aggregation orders by its grouping.

func (*JoinBuilder) OrderDesc added in v0.3.0

func (b *JoinBuilder) OrderDesc(fieldPtr any) *JoinBuilder

func (*JoinBuilder) Take added in v0.3.0

func (b *JoinBuilder) Take(fieldPtr any, as string) *JoinBuilder

Take adds a column to the output. The field pointer may be into the declaring model or into any joined one.

func (*JoinBuilder) TakeFrom added in v0.3.0

func (b *JoinBuilder) TakeFrom(alias, column, as string) *JoinBuilder

TakeFrom adds a column from an aliased scope — a CTE's aggregate output.

func (*JoinBuilder) Where added in v0.3.0

func (b *JoinBuilder) Where(c Cond) *JoinBuilder

Where declares a predicate the caller cannot widen. Call-site predicates still compose and are ANDed with it.

func (*JoinBuilder) With added in v0.3.0

func (b *JoinBuilder) With(alias string, model any, aggregate string) *JoinBuilder

With materialises a declared aggregation as a CTE.

var o Order
j.Named("VsSpend").
    With("spend", &o, "ByCustomer").
    Inner(&c, storm.OnCols("spend", "customer_id", &c.ID))

One pass over the aggregated table, reused by the join, instead of a correlated subquery per row.

type JoinOn added in v0.3.0

type JoinOn struct {
	// contains filtered or unexported fields
}

JoinOn is an explicit join condition.

type Joiner added in v0.3.0

type Joiner interface {
	Joins(*Joins)
}

Joiner is implemented by models that declare joins. Optional.

type Joins added in v0.3.0

type Joins struct {
	// Exprs is the declaration-time expression vocabulary: j.Ne, j.OnCols and
	// the rest. Embedded for the same reason Aggregates embeds it — so a
	// declaration constructor cannot be reached from a query context.
	Exprs
	// contains filtered or unexported fields
}

Named joins: one read that projects across tables.

func (o *Order) Joins(j *storm.Joins) {
    var c Customer
    j.Named("WithCustomer").
        Inner(&c, &o.Customer).            // the FK relation says how to join
        Take(&o.ID, "OrderID").
        Take(&o.Total, "Total").
        Take(&c.Email, "Email").
        OrderDesc(&o.PlacedAt)
}

rows, err := order.New().
    Where(order.PlacedAt.Gte(since)).      // call-site predicates still compose
    AllWithCustomer(ctx, ex)               // []order.WithCustomerRow

**A join projects; it does not load entities.** The output is a flat row of scalars, because a join answers a question and materialising two entity types to answer it is the round-tripping this exists to avoid. When you want the entities, that is a Plan — one query per relation, no fan-out.

The joined model is a LOCAL variable. Taking field pointers into it is what makes `&c.Email` a checked reference rather than a string, so a rename of Customer.Email is a compile error here too.

func (*Joins) Named added in v0.3.0

func (j *Joins) Named(name string) *JoinBuilder

Named starts a join. The generated type is this name plus "Row".

type Model

type Model struct {
	ID        UUID
	CreatedAt time.Time
	UpdatedAt time.Time
}

Model is the conventional embedded primary key and timestamps. Embedding it is optional — declare your own key if you want a natural one.

It carries its own Schema method, so embedding it is the whole declaration: the key gets a default and is immutable, and both timestamps default to now().

func (*Model) Schema

func (m *Model) Schema(t *Table)

type Nested

type Nested struct {
	// contains filtered or unexported fields
}

Nested names a relation on a table OTHER than the one declaring the plan — a post's comments, reached through a user's posts.

It exists because a field pointer needs an instance to point into, and the declaring model has no Post to hand. Into supplies one: the builder already allocated a zero value of every registered model, so the closure is called with that and the offset resolves against the right table.

func Into

func Into[T any](pick func(*T) any, nested ...Nested) Nested

Into names a relation on T, for use inside With.

p.Named("Feed").With(&u.Posts, storm.Into(func(p *Post) any { return &p.Comments }))

The type parameter is what says which table the field belongs to, so a pointer into the wrong model is a compile error rather than a mis-resolved offset.

type Null

type Null[T any] struct {
	V     T
	Valid bool
}

Null is the allocation-free nullable. A `*T` in a model becomes a Null[T] in the generated row type: a pointer would cost one allocation per non-nil field per row, and rows are the hot path.

func None

func None[T any]() Null[T]

func Some

func Some[T any](v T) Null[T]

Some and None construct a Null[T].

func (Null[T]) Get

func (n Null[T]) Get() (T, bool)

type OneOf2

type OneOf2[A, B any] struct {
	// contains filtered or unexported fields
}

OneOf2 is an exclusive arc over two variants.

type OneOf3

type OneOf3[A, B, C any] struct {
	// contains filtered or unexported fields
}

OneOf3 is an exclusive arc over three variants.

type OneOf4

type OneOf4[A, B, C, D any] struct {
	// contains filtered or unexported fields
}

OneOf4 is an exclusive arc over four variants.

type OneOf5

type OneOf5[A, B, C, D, E any] struct {
	// contains filtered or unexported fields
}

OneOf5 is an exclusive arc over five variants.

type OneOf6

type OneOf6[A, B, C, D, E, F any] struct {
	// contains filtered or unexported fields
}

OneOf6 is an exclusive arc over six variants.

type OneOf7

type OneOf7[A, B, C, D, E, F, G any] struct {
	// contains filtered or unexported fields
}

OneOf7 is an exclusive arc over seven variants.

type OneOf8

type OneOf8[A, B, C, D, E, F, G, H any] struct {
	// contains filtered or unexported fields
}

OneOf8 is an exclusive arc over eight variants.

type Out added in v0.3.0

type Out struct {
	// contains filtered or unexported fields
}

Out is a reference to a declared output, returned by the method that declared it.

It replaces a string lookup. `Having(a.Gt(a.Out("Orders"), 0))` named an output that storm checked at build time; a handle is checked by the Go compiler, and it cannot name an output that has not been declared yet because you do not have one until it has.

func (Out) Filter added in v0.4.0

func (o Out) Filter(c Cond) Out

Filter restricts THIS aggregate to the rows matching cond — `count(*) FILTER (WHERE status = 'paid')`, which is both clearer and faster than `count(CASE WHEN ...)`.

Attached to the output it filters rather than to "the last one declared", so moving a line cannot silently move the filter with it.

func (Out) OverWindow added in v0.4.0

func (o Out) OverWindow(w *WindowSpec) Out

OverWindow attaches a window to THIS aggregate — a moving total, or the classic "share of the group" without a self-join.

type PlanBuilder

type PlanBuilder struct {
	// contains filtered or unexported fields
}

PlanBuilder collects the relations one plan loads.

func (*PlanBuilder) With

func (b *PlanBuilder) With(relPtr any, nested ...Nested) *PlanBuilder

With adds a relation to the plan, addressed by field pointer. Anything passed after it is loaded THROUGH it and costs one more round trip each.

type Planner

type Planner interface {
	Plans(*Plans)
}

Planner is implemented by models that declare fetch plans. Optional: a model with no Plans method gets the one-plan-per-relation tier, which is finite by construction and needs no declaration.

type Plans

type Plans struct {
	// contains filtered or unexported fields
}

Named fetch plans.

A plan says which relations are loaded together, and the generator emits a distinct type per plan whose fields are exactly those relations. Reading an unloaded relation is then a compile error rather than an empty slice.

You name the plans. That is the whole answer to the projection-type explosion: generating a type per `With(...)` combination is 2ⁿ per entity, and the fix is not a cleverer generator but a shorter list — the one the developer actually uses.

It also makes `plans.go` the single reviewable file listing every load pattern in a system, which is a thing no other Go ORM has, and which a linter can cost in round trips.

func (u *User) Plans(p *storm.Plans) {
    p.Named("Feed").With(&u.Posts).With(&u.Org)
}

Relations are named by FIELD POINTER, like everything else in the declaration API, so the editor enforces them and a rename follows.

func (*Plans) Named

func (p *Plans) Named(name string) *PlanBuilder

Named starts a plan. The generated type is the model name plus this name, so "Feed" on User becomes UserFeed.

type Projections

type Projections struct {
	// contains filtered or unexported fields
}

Named projections: read less than the whole row, by name.

func (u *User) Projections(p *storm.Projections) {
    p.Named("Contact", &u.Email, &u.Name)
}

rows, err := user.New().Where(...).AllContact(ctx, ex)   // []user.ContactRow

The full-row read is the safe default and the expensive one: every column travels, TOAST'd values are fetched whether or not anyone looks, and an index-only scan is impossible by construction. A named projection is the declared, reviewable subset — same predicates, same ordering, same keyset machinery, narrower tuple, its own generated row type and scanner.

Named rather than a Select(cols...) builder for R3's reason: a type per combination is 2ⁿ per entity, and the fix is a shorter list — the one the code actually uses.

func (*Projections) Named

func (p *Projections) Named(name string, fieldPtrs ...any)

Named declares one projection over the given column fields.

type Projector

type Projector interface {
	Projections(*Projections)
}

Projector is implemented by models that declare projections. Optional.

type RawDecl

type RawDecl interface {
	// contains filtered or unexported methods
}

RawDecl is implemented by every SQLQuery, so a bootstrap can register them as a plain []any the way it registers models.

type RawSQL added in v0.4.0

type RawSQL string

RawSQL is a raw SQL fragment. It is the one deliberate escape from the typed API: conspicuous, reported by `storm lint --expr`, and validated against the database at generate time.

Named for what it is. It was `Expr`, which is what someone reaching for "an expression" types — and they would land on the untyped escape hatch rather than on the checked expression vocabulary, which lives on the declaration builder (a.DateTrunc, a.Coalesce, a.Eq).

func GenRandomUUID

func GenRandomUUID() RawSQL

GenRandomUUID renders gen_random_uuid(), built in since PostgreSQL 13. This is the default for an embedded storm.Model because it works everywhere the rest of storm does.

func Now

func Now() RawSQL

Now renders the SQL now() default.

func UUIDv7

func UUIDv7() RawSQL

UUIDv7 renders a uuidv7() default. Requires PostgreSQL 18+.

type SQLQuery

type SQLQuery[T any] struct {
	// contains filtered or unexported fields
}

The typed escape hatch (M5).

Anything PostgreSQL can run is expressible here — CTEs, windows, lateral joins — years before the native IR grows each construct. What storm adds is the part hand-rolled SQL always loses: the RESULT is typed, the scanner is generated, and the statement was validated against the model at GENERATE time, so a query whose columns drifted from its row type fails the build naming the column, not the 3am page.

var TopEarners = storm.SQL[EarnerRow](`
    WITH ranked AS (...)
    SELECT ... WHERE tenant_id = $1 ... LIMIT $2`)

rows, err := TopEarners.Query(ctx, db, tid, 3)   // []EarnerRow

How the scanner arrives

`storm generate` PREPAREs the statement, matches the result descriptor against T's fields, and emits a scanner that registers itself by type in an init(). The first Query looks it up once and caches it in the value; the warm path is an atomic load. Running a query nothing generated for is an error naming the fix, not a reflective fallback — one reflection path becomes THE path.

Only a declared statement runs

The same generate step emits a RegisterStatement for every statement it PREPAREd, and a declaration whose text is not among them is refused before the executor is reached. That is what keeps the escape hatch from being one: a scanner is keyed by ROW TYPE and would otherwise answer for a statement assembled at run time. See the statement-pinning note further down this file.

func SQL

func SQL[T any](sql string) *SQLQuery[T]

SQL declares a raw query returning rows of T.

func (*SQLQuery[T]) One

func (q *SQLQuery[T]) One(ctx context.Context, ex runtime.Executor, args ...any) (T, bool, error)

One runs the statement and returns the first row, if any.

func (*SQLQuery[T]) Query

func (q *SQLQuery[T]) Query(ctx context.Context, ex runtime.Executor, args ...any) ([]T, error)

Query runs the statement and scans every row.

Args are variadic and checked against the statement's placeholder count before anything reaches the server; the ROW is where the typing lives, which is the half hand-rolled SQL cannot have.

type SQLStmt

type SQLStmt struct {
	// contains filtered or unexported fields
}

SQLStmt is the no-rows half of the escape hatch: DELETEs, junction-table INSERTs, `SELECT maintenance_fn(...)` calls. It carries no row type — and the generator enforces that, failing generation if the statement's result descriptor has columns, so "I meant to read those rows" cannot compile into silently dropping them.

func SQLExec

func SQLExec(sql string) *SQLStmt

SQLExec declares a raw statement executed for its effect.

func (*SQLStmt) Exec

func (q *SQLStmt) Exec(ctx context.Context, ex runtime.Executor, args ...any) (int64, error)

Exec runs the statement and reports rows affected.

type Schemer

type Schemer interface {
	Schema(t *Table)
}

Schemer is implemented by models that need more than their Go types can say.

The receiver MUST be a pointer. With a value receiver Go copies the struct before the method runs, so &u.Email points into the copy and cannot be resolved back to a field — the builder rejects that at Build time rather than producing a silently wrong schema.

type TSVector added in v0.3.0

type TSVector struct{}

TSVector is a full-text search column.

Declare one and PostgreSQL gets a `tsvector`; the generated query API gets Matches and WebSearch on it. It is deliberately an EMPTY struct: a tsvector is index support, not data, so it never appears in a Row, never travels on a read, and cannot be written from Go. The usual declaration makes the database maintain it:

Search storm.TSVector

t.Col(&p.Search).
    Generated(storm.RawSQL(`to_tsvector('english', coalesce(name,''))`)).
    Index()

Nothing else in storm has this shape, and that is the point: a column you can only ask questions of.

type Table

type Table struct {
	// contains filtered or unexported fields
}

Table is the builder handed to a model's Schema method. Every reference to a field is a *field pointer* (&u.Email), so a rename is a compile error and a typo never compiles.

func (*Table) Check

func (t *Table) Check(e Expr) *Table

Check adds a CHECK constraint.

func (*Table) Col

func (t *Table) Col(fieldPtr any) *ColBuilder

Col addresses a column by field pointer and returns a builder for it.

t.Col(&u.Email).Unique().Size(320)

func (*Table) Comment

func (t *Table) Comment(s string) *Table

Comment sets a table comment.

func (*Table) Exclude

func (t *Table) Exclude(parts ...ExcludeSpec) *ExcludeBuilder

Exclude adds an exclusion constraint — the correct answer to booking and scheduling overlap, and reachable from no other Go ORM.

t.Exclude(storm.With(&b.Room, storm.OpEq), storm.With(&b.Period, storm.OpOverlaps))

func (*Table) Index

func (t *Table) Index(cols ...any) *IndexBuilder

Index adds a secondary index. Wrap a field in Desc(...) or Lower(...) to order or transform it.

func (*Table) Name

func (t *Table) Name(n string) *Table

Name overrides the table name inferred from the Go type.

func (*Table) PrimaryKey

func (t *Table) PrimaryKey(fields ...any) *Table

PrimaryKey overrides the inferred key. Pass several field pointers for a composite key.

func (*Table) Through added in v0.5.0

func (t *Table) Through(fieldPtr any, join any) *Table

Through declares a many-to-many that runs over a join model the adopter wrote, rather than a table storm generated.

The reason to write the join yourself is that it carries columns of its own — when a role was granted, when it expires — and those columns are the point. A generated join table has nowhere to put them.

func (u *User) Schema(t *storm.Table) {
    t.Through(&u.Roles, UserRole{})
}

`join` is a VALUE of the join model, not a pointer and not a field pointer: it names a type, and the type is all storm needs. The join model must carry exactly one foreign key to each end, which is what makes the two directions unambiguous — a join with two keys to the same table is a self-referential shape that has to name its own columns.

The plan row carries the join row and the far row together, so the payload is reachable: `u.Roles[i].GrantedAt` alongside `u.Roles[i].Role.Name`.

func (*Table) Unique

func (t *Table) Unique(cols ...any) *Table

Unique adds a table-level uniqueness constraint.

Postgres UNIQUE constraints cannot contain expressions, so a Unique over one (Lower(&u.Email)) is emitted as a UNIQUE INDEX instead. Same guarantee, different object — and the alternative is DDL that does not parse.

type Term added in v0.3.0

type Term struct {
	// contains filtered or unexported fields
}

Term is an expression inside a declaration: a column, a literal, a scalar function, or a reference to another declared output.

Resolved and TYPED at generation time, which is the point — the generated row's field type is whatever the Term turns out to be.

Field pointers are accepted anywhere a Term is, so the common case reads as itself: a.DateTrunc("day", &o.PlacedAt).

type TimeOfDay

type TimeOfDay = runtime.TimeOfDay

TimeOfDay is a PostgreSQL `time` — microseconds since midnight, no date and no zone. See runtime.TimeOfDay for why it is not a time.Time.

func NewTimeOfDay

func NewTimeOfDay(hour, min, sec, micro int) (TimeOfDay, bool)

NewTimeOfDay builds a time of day from its parts, reporting false for parts out of range rather than normalising them — 25:00 is a mistake, not 01:00 tomorrow.

type TstzRange added in v0.3.0

type TstzRange = runtime.TstzRange

TstzRange is a PostgreSQL tstzrange: an interval of time with explicit bounds, so "do these two bookings overlap" is a question the database answers with an index rather than four comparisons in Go that get the boundary cases wrong.

An alias, not a wrapper, for the same reason Decimal is: the model declares storm.TstzRange and generated code reads runtime.TstzRange, and those must be the same type or every value would need converting at the boundary storm exists to remove.

func NewTstzRange added in v0.3.0

func NewTstzRange(lower, upper time.Time) TstzRange

NewTstzRange builds the half-open range [lower, upper) — the one scheduling wants, because adjacent slots then do not collide on the instant they touch.

type UUID

type UUID [16]byte

UUID keeps the core dependency-free. Map it to your preferred package once with a codec rather than importing one here.

func (UUID) String

func (u UUID) String() string

type UnionBranchSpec added in v0.5.0

type UnionBranchSpec struct {
	// contains filtered or unexported fields
}

UnionBranchSpec is one branch of a union under construction.

func (*UnionBranchSpec) Const added in v0.5.0

func (b *UnionBranchSpec) Const(as string, v any) *UnionBranchSpec

Const projects a literal, which is how a merged feed carries the tag saying which branch a row came from. Without it the rows are indistinguishable once they are merged, and the caller is left inferring the source from which fields happen to be set.

func (*UnionBranchSpec) Take added in v0.5.0

func (b *UnionBranchSpec) Take(fieldPtr any, as string) *UnionBranchSpec

Take projects a column into the output column named as.

Every branch must project the same names in the same order. That is what union-compatible means, and storm checks it rather than trusting the declaration: two branches whose third column is `Text` in one and `Kind` in the other produce a row type where half the values are in the wrong field, and PostgreSQL will not object as long as the types line up.

func (*UnionBranchSpec) Where added in v0.5.0

func (b *UnionBranchSpec) Where(c Cond) *UnionBranchSpec

Where filters this branch. Declared, so it is fixed in the statement text — there is no call-site predicate on a union, because a predicate over several branches would have to say which one it filtered.

type UnionDecl added in v0.5.0

type UnionDecl struct {
	// contains filtered or unexported fields
}

UnionDecl is a declared union, before it has been resolved against a schema. Pass it to Build alongside the models.

func Union added in v0.5.0

func Union(name string, fn func(*UnionSpec)) *UnionDecl

A declared UNION: several tables projected into one row shape.

var Feed = storm.Union("Feed", func(u *storm.UnionSpec) {
    var c Comment
    b := u.From(&c)
    b.Take(&c.CreatedAt, "OccurredAt")
    b.Take(&c.Body, "Text")
    b.Const("Kind", "comment")

    var r Release
    b2 := u.From(&r)
    b2.Take(&r.PublishedAt, "OccurredAt")
    b2.Take(&r.Notes, "Text")
    b2.Const("Kind", "release")

    u.OrderDesc("OccurredAt")
})

Why this is a package-level var and not a method

Every other cross-table read hangs off a DRIVING table: a declared join is a method on the model that declares it, and its row type lives in that model's package. A union has no such centre. In a feed of comments, follows and releases none of the three is the one the others attach to, and declaring it on whichever sorted first would put the row type in a package with no more claim to it than the other two. So a union is registered against the schema (ADR-0008).

Why a closure

The branches need LOCAL model instances to take field pointers into, exactly as a Joins method does — and a package-level var cannot have locals. The closure runs during Build, when the tables exist and the pointers can be resolved; nothing is evaluated at package init but the name.

func (*UnionDecl) Name added in v0.5.0

func (d *UnionDecl) Name() string

Name reports the declared name; the generate command uses it.

type UnionSpec added in v0.5.0

type UnionSpec struct {
	// contains filtered or unexported fields
}

UnionSpec is a union under construction.

func (*UnionSpec) Distinct added in v0.5.0

func (s *UnionSpec) Distinct() *UnionSpec

Distinct selects UNION over UNION ALL.

ALL is the default, inverting SQL's. De-duplicating means sorting or hashing the whole result before a single row comes back, and a feed never wants it; a caller who does says so here.

func (*UnionSpec) From added in v0.5.0

func (s *UnionSpec) From(model any) *UnionBranchSpec

From starts a branch reading model's table.

func (*UnionSpec) OrderAsc added in v0.5.0

func (s *UnionSpec) OrderAsc(col string) *UnionSpec

OrderAsc and OrderDesc order the MERGED rows. They name output columns: after the branches are unioned the source tables' own names are gone, and an output alias is the only thing left in scope.

func (*UnionSpec) OrderDesc added in v0.5.0

func (s *UnionSpec) OrderDesc(col string) *UnionSpec

func (*UnionSpec) Param added in v0.5.0

func (s *UnionSpec) Param(name string) Term

Param declares a value the CALL supplies, and returns a handle to use in a branch filter:

actor := u.Param("Actor")
orders.Where(storm.Exprs{}.Eq(&o.CustomerID, actor))

The parameter has no type of its own — it takes the type of the column it is first compared with, so the generated function's signature cannot disagree with the column it filters. A parameter that is never used is refused: it would sit in the signature demanding an argument that reaches no statement.

The same parameter used in two branches is ONE argument and one placeholder. That is the point: "this actor's feed" means the same actor in every branch, and making the caller pass it once per branch invites passing two different values.

type WindowSpec added in v0.3.0

type WindowSpec struct {
	// contains filtered or unexported fields
}

WindowSpec is an OVER clause under construction.

func (*WindowSpec) OrderByAsc added in v0.3.0

func (w *WindowSpec) OrderByAsc(xs ...any) *WindowSpec

OrderByAsc and OrderByDesc order rows WITHIN the partition.

Named this way rather than taking Asc/Desc because those already mean index ordering, and one word meaning two things in one declaration is how a wrong index gets built.

func (*WindowSpec) OrderByDesc added in v0.3.0

func (w *WindowSpec) OrderByDesc(xs ...any) *WindowSpec

func (*WindowSpec) PartitionBy added in v0.3.0

func (w *WindowSpec) PartitionBy(xs ...any) *WindowSpec

PartitionBy restarts the window for each distinct value.

func (*WindowSpec) Range added in v0.5.0

func (w *WindowSpec) Range(start, end FrameBound) *WindowSpec

Range frames by PEERS — rows the ORDER BY cannot tell apart count as one.

Offsets are refused here. `RANGE 7 PRECEDING` needs exactly one ORDER BY column of a type that can be subtracted, and the failure is a server error at the first call rather than anything storm could name; ROWS expresses the same intent with a rule that always holds. Use Range for the unbounded edges, which is what it is actually good for.

func (*WindowSpec) Rows added in v0.5.0

func (w *WindowSpec) Rows(start, end FrameBound) *WindowSpec

Rows frames the window by COUNTED ROWS: `Rows(a.Preceding(6), a.CurrentRow())` is a seven-row moving window, which is the moving average everyone wants and the default frame cannot express.

Without a frame PostgreSQL uses RANGE from the partition start to the current row — so a running total is the default and a moving one is not, and last_value() reads the current row rather than the last.

Directories

Path Synopsis
bench
entbench/schema
Package schema is the Ent declaration for the shared bench table.
Package schema is the Ent declaration for the shared bench table.
cmd
genbench command
genspike command
Command genspike generates the two table packages the M3 plan-type spike sits on top of.
Command genspike generates the two table packages the M3 plan-type spike sits on top of.
storm command
Command storm is the tool: install it once, run it in your module, write no bootstrap.
Command storm is the tool: install it once, run it in your module, write no bootstrap.
Package codegen emits Go from the schema IR.
Package codegen emits Go from the schema IR.
compile
myddl
Package myddl renders a schema as MySQL 8 DDL.
Package myddl renders a schema as MySQL 8 DDL.
pgddl
Package pgddl lowers the schema IR to PostgreSQL DDL.
Package pgddl lowers the schema IR to PostgreSQL DDL.
pgsql
Package pgsql lowers query structure to PostgreSQL text.
Package pgsql lowers query structure to PostgreSQL text.
examples
blog/gen command
Command gen regenerates the example's store from its model.
Command gen regenerates the example's store from its model.
blog/model
Package model is the quickstart's schema: two tables, one relation, and the declared plans and projections this example reads with.
Package model is the quickstart's schema: two tables, one relation, and the declared plans and projections this example reads with.
internal
aliasrow
Package aliasrowx deliberately declares a name that differs from its directory (aliasrow).
Package aliasrowx deliberately declares a name that differs from its directory (aliasrow).
benchmodel
Package benchmodel is the M0 benchmark table, declared as a model so the generator can be measured against the hand-written spike on identical SQL.
Package benchmodel is the M0 benchmark table, declared as a model so the generator can be measured against the hand-written spike on identical SQL.
testmodel
Package testmodel is the fixture domain used by M1's round-trip tests.
Package testmodel is the fixture domain used by M1's round-trip tests.
Package migrate diffs two schemas and emits a reviewable migration.
Package migrate diffs two schemas and emits a reviewable migration.
Package runtime is what generated code calls at query time.
Package runtime is what generated code calls at query time.
mydec
Package mydec decodes MySQL's binary result protocol.
Package mydec decodes MySQL's binary result protocol.
pgxdrv
Package pgxdrv is the only package in storm that knows pgx exists.
Package pgxdrv is the only package in storm that knows pgx exists.
Package schema is storm's schema IR: the single representation every front end produces and every back end consumes.
Package schema is storm's schema IR: the single representation every front end produces and every back end consumes.
pg
Package pg reads a live PostgreSQL database into storm's schema IR.
Package pg reads a live PostgreSQL database into storm's schema IR.
Package tool is the developer-facing command set — render DDL, diff a migration, generate packages, and verify that model, generated code, migrations and the live database all still agree — as a LIBRARY.
Package tool is the developer-facing command set — render DDL, diff a migration, generate packages, and verify that model, generated code, migrations and the live database all still agree — as a LIBRARY.
bootstrap
Package toolbootstrap writes and runs the bootstrap main that adopters used to keep in their own repositories.
Package toolbootstrap writes and runs the bootstrap main that adopters used to keep in their own repositories.
discover
Package tooldiscover finds an adopter's models in their source, so the tool does not have to be handed them.
Package tooldiscover finds an adopter's models in their source, so the tool does not have to be handed them.

Jump to

Keyboard shortcuts

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