Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,72 @@ The predicate is part of the index's identity: change the `where` clause and the

---

## PostgreSQL EXCLUDE constraints

`EXCLUDE` is a table-level constraint that generalises `UNIQUE`: instead of equality between rows, you specify any commutative SQL operator. The flagship case is **range-overlap exclusion** — a booking system that needs to guarantee no two reservations for the same room can overlap in time. Without the constraint, the same invariant has to be enforced in application code with all the race conditions that come with it.

Declare on the table:

```ts
const bookings = defineTable(
"bookings",
{
id: serial().primaryKey(),
room: text().notNull(),
during: new ColumnBuilder<string>("tstzrange").notNull(),
},
{
constraints: {
excludes: [
{
name: "no_overlap",
method: "gist",
elements: [
{ expr: "room", operator: "=" },
{ expr: "during", operator: "&&" },
],
},
],
},
},
)
```

Emitted SQL (PG):

```sql
CREATE TABLE "bookings" (
"id" SERIAL PRIMARY KEY,
"room" text NOT NULL,
"during" tstzrange NOT NULL,
CONSTRAINT "no_overlap" EXCLUDE USING gist ("room" WITH =, "during" WITH &&)
)
```

Reads as "no two rows may share a `room` AND have overlapping `during`." The `&&` is PG's range-overlap operator. The composite form `(room WITH =, during WITH &&)` requires the `btree_gist` extension (most managed PG providers ship it; install with `CREATE EXTENSION btree_gist`); a single-element exclude on a range column works on stock PG out of the box.

For a partial exclude — "at most one row per priority among active rows" — add a `where` predicate:

```ts
constraints: {
excludes: [
{
name: "one_active_per_priority",
elements: [{ expr: "priority", operator: "=" }],
where: "active = true",
},
],
}
```

The constraint's identity covers the method, the elements, and the `where` predicate. Change any one of them and the migration diff emits a drop + add — there is no in-place `ALTER` for an `EXCLUDE` constraint. The `where` predicate accepts raw SQL (schema-author controlled — never user input) or any `Expression<boolean>`, mirroring the partial-index API.

The operator token is spliced verbatim into the emitted DDL, so sumak runs it through a whitelist (1-4 ASCII punctuation characters from PG's operator alphabet — `+ - * / < > = ~ ! @ # % ^ & | ? ` plus backtick). Anything outside that set raises a `SecurityError` at print time; in practice every common operator (`=`, `<>`, `&&`, `@>`, `<@`, `->>`, etc.) is on the allow-list. The method name goes through `validateFunctionName` (same identifier check as `CREATE INDEX … USING <method>`).

**Dialect support.** PostgreSQL only. MySQL, SQLite, and MSSQL have no equivalent table-constraint grammar — the closest fit on those dialects is a unique partial index, but that only supports equality and so doesn't cover the range-overlap case at all. `compileDDL` throws `UnsupportedDialectFeatureError` (`EXCLUDE_CONSTRAINTS` feature flag) on every non-PG dialect rather than emit SQL the engine will reject.

---

## Schema comments

Schema-level prose lives in the database, not just the code. PostgreSQL and MySQL both expose comments on tables and columns; sumak surfaces both via the schema DSL and threads the value through `diffSchemas` so a comment edit shows up as a normal additive migration step.
Expand Down
48 changes: 48 additions & 0 deletions src/ast/ddl-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,59 @@ export interface ForeignKeyConstraintNode {
}
}

/**
* Single element of a PG `EXCLUDE` constraint — a column reference (or
* arbitrary expression) paired with the operator that must NOT hold
* between the row's value and any other row's value. The classic
* range-overlap case uses `column WITH &&`; the equality use case (a
* UNIQUE that names its index method explicitly) uses `column WITH =`.
*
* `expr` is an arbitrary {@link ExpressionNode}; the schema-DSL layer
* always materializes a `column_ref` node for the simple "named column"
* case, but plain expressions (function calls, casts) flow through too.
*/
export interface ExcludeElement {
expr: ExpressionNode
operator: string
}

/**
* PG-only table-level constraint. Generalizes UNIQUE — instead of
* equality, each element pairs a column (or expression) with a
* commutative operator. The classic case is range-overlap exclusion
* for booking systems: `EXCLUDE USING gist (room WITH =, during WITH
* &&)`. The constraint is backed by an index whose access method is
* controlled by `method` (defaults to `gist`).
*
* The optional `where` predicate makes this a **partial exclude**:
* the constraint only applies to rows where the predicate is true,
* mirroring `CREATE INDEX … WHERE` semantics.
*
* Refused on MySQL / SQLite / MSSQL via `EXCLUDE_CONSTRAINTS` —
* none have an equivalent table-constraint grammar. See
* {@link FEATURES.EXCLUDE_CONSTRAINTS}.
*/
export interface ExcludeConstraintNode {
type: "exclude_constraint"
name?: string
/** Index access method. Defaults to `gist` at print time when unset. */
method?: string
elements: ExcludeElement[]
/**
* Optional partial-exclude predicate. Same grammar as a partial-index
* `WHERE` clause — limits the constraint to rows matching the
* predicate. Useful for "at most one active row per priority" via
* `EXCLUDE (priority WITH =) WHERE (active = true)`.
*/
where?: ExpressionNode
}

export type TableConstraintNode =
| PrimaryKeyConstraintNode
| UniqueConstraintNode
| CheckConstraintNode
| ForeignKeyConstraintNode
| ExcludeConstraintNode

// ── CREATE TABLE ──

Expand Down
11 changes: 11 additions & 0 deletions src/dialect/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ export const FEATURES = {
GIN_INDEX: { label: "GIN index", dialects: ["pg"] },
GIST_INDEX: { label: "GIST index", dialects: ["pg"] },
PARTIAL_INDEX: { label: "partial index (WHERE)", dialects: ["pg", "sqlite"] },
/**
* PG `EXCLUDE` table-level constraint — a generalization of UNIQUE
* where each element specifies its own commutative operator (`room
* WITH =, during WITH &&` for range-overlap exclusion). Backed by a
* GiST / SP-GiST / btree index depending on the operators used.
* MySQL / SQLite / MSSQL have no equivalent grammar; the closest
* fit is a unique partial index, which is a different shape (only
* supports equality) and is best expressed via the partial-index
* API. The printer refuses on every non-PG dialect.
*/
EXCLUDE_CONSTRAINTS: { label: "EXCLUDE constraint", dialects: ["pg"] },
CASCADE_DROP: { label: "DROP ... CASCADE", dialects: ["pg"] },
/**
* `COMMENT ON TABLE` / `COMMENT ON COLUMN` (PG syntax) and the
Expand Down
59 changes: 56 additions & 3 deletions src/migrate/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
DDLNode,
DropIndexNode,
DropTableNode,
ExcludeConstraintNode,
TableConstraintNode,
UniqueConstraintNode,
} from "../ast/ddl-nodes.ts"
Expand All @@ -16,12 +17,14 @@ import { tableRef } from "../ast/nodes.ts"
import type { ColumnBuilder, ColumnDef } from "../schema/column.ts"
import {
isTableDefinition,
normalizeExcludeDef,
normalizeKeyDef,
normalizeUniqueDef,
resolveCheckExpression,
} from "../schema/table.ts"
import type {
CheckDef,
ExcludeDef,
ForeignKeyDef,
IndexDef,
NormalizedTable,
Expand Down Expand Up @@ -712,6 +715,7 @@ function materializeConstraints(constraints: TableConstraints | undefined): Tabl
for (const u of constraints.uniques ?? []) out.push(materializeUnique(u))
for (const c of constraints.checks ?? []) out.push(materializeCheck(c))
for (const fk of constraints.foreignKeys ?? []) out.push(materializeForeignKey(fk))
for (const ex of constraints.excludes ?? []) out.push(materializeExclude(ex))
return out
}

Expand Down Expand Up @@ -749,6 +753,33 @@ function materializeForeignKey(def: ForeignKeyDef): TableConstraintNode {
return def.name === undefined ? base : { ...base, name: def.name }
}

/**
* Lower an {@link ExcludeDef} to an {@link ExcludeConstraintNode}.
* Each element's `expr` is resolved to an {@link ExpressionNode} —
* bare column names lower to a `column_ref`; pre-built sumak
* `Expression<T>` values keep their AST node verbatim so the printer's
* dialect-aware quoting still applies. Method and where clauses are
* carried through unchanged.
*/
function materializeExclude(def: ExcludeDef): ExcludeConstraintNode {
const norm = normalizeExcludeDef(def)
const elements = norm.elements.map((e) => {
const expr: ExpressionNode =
typeof e.expr === "string"
? { type: "column_ref", column: e.expr }
: (e.expr as unknown as { node: ExpressionNode }).node
return { expr, operator: e.operator }
})
const out: ExcludeConstraintNode = { type: "exclude_constraint", elements }
if (norm.name !== undefined) out.name = norm.name
if (norm.method !== undefined) out.method = norm.method
if (norm.where !== undefined) {
const resolved = resolveCheckExpression(norm.where)
out.where = resolved.node ?? { type: "raw", sql: resolved.sql, params: [] }
}
return out
}

/**
* Deep-compare two constraint sets and split the delta into "to drop"
* and "to add". We key each constraint by a canonical signature; any
Expand Down Expand Up @@ -785,12 +816,18 @@ function signConstraint(node: TableConstraintNode): string {
if (node.name) {
// Named UNIQUE constraints mix the `nullsNotDistinct` flag into the
// signature so a flip of the flag on a same-named constraint
// surfaces as drop + add. Other named constraints still key on name
// alone — same name in both before & after is treated as the same
// logical constraint regardless of body changes.
// surfaces as drop + add. Named EXCLUDE constraints fold the body
// (method + elements + where) into the signature for the same
// reason — a change to any of those is a different constraint and
// must replay as drop + add. Other named constraints still key on
// name alone — same name in both before & after is treated as the
// same logical constraint regardless of body changes.
if (node.type === "unique_constraint" && node.nullsNotDistinct) {
return `${node.type}:${node.name}|nnd`
}
if (node.type === "exclude_constraint") {
return `${node.type}:${node.name}|${signExcludeBody(node)}`
}
return `${node.type}:${node.name}`
}
switch (node.type) {
Expand All @@ -802,9 +839,25 @@ function signConstraint(node: TableConstraintNode): string {
return `check:${JSON.stringify(node.expression)}`
case "fk_constraint":
return `fk:${node.columns.join(",")}->${node.references.table}(${node.references.columns.join(",")})`
case "exclude_constraint":
return `exclude:${signExcludeBody(node)}`
}
}

/**
* Canonical signature for the body of an EXCLUDE constraint —
* method + each element's expression node + operator + the optional
* WHERE predicate. Captures everything the DDL printer would observe
* so changes to any field surface as drop + add through the diff
* engine.
*/
function signExcludeBody(node: ExcludeConstraintNode): string {
const method = node.method ?? ""
const elems = node.elements.map((e) => `${JSON.stringify(e.expr)}@${e.operator}`).join(";")
const where = node.where === undefined ? "" : JSON.stringify(node.where)
return `m:${method}|e:${elems}|w:${where}`
}

// ── Input normalization ────────────────────────────────────────────────

/**
Expand Down
43 changes: 42 additions & 1 deletion src/printer/ddl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
DropSchemaNode,
DropTableNode,
DropViewNode,
ExcludeConstraintNode,
ForeignKeyConstraintNode,
TableConstraintNode,
TruncateTableNode,
Expand All @@ -20,7 +21,12 @@ import { assertFeature } from "../dialect/features.ts"
import { UnsupportedDialectFeatureError } from "../errors.ts"
import type { CompiledQuery, SQLDialect } from "../types.ts"
import { quoteIdentifier, quoteTableRef } from "../utils/identifier.ts"
import { escapeStringLiteral, validateDataType, validateFunctionName } from "../utils/security.ts"
import {
escapeStringLiteral,
validateDataType,
validateFunctionName,
validateOperator,
} from "../utils/security.ts"

/**
* Optional callback used by CREATE TABLE ... AS SELECT and CREATE VIEW ... AS
Expand Down Expand Up @@ -249,9 +255,44 @@ export class DDLPrinter {
return `${namePrefix}CHECK (${this.printExpr(c.expression)})`
case "fk_constraint":
return this.printForeignKeyConstraint(c, namePrefix)
case "exclude_constraint":
return this.printExcludeConstraint(c, namePrefix)
}
}

/**
* Emit a PG `EXCLUDE` constraint:
*
* EXCLUDE [USING <method>] (<expr> WITH <op>, <expr> WITH <op>, …)
* [WHERE (<predicate>)]
*
* Method defaults to `gist` when unset (the only access method that
* supports the range-overlap operator `&&`). Operator tokens are
* passed through `validateOperator` so an attacker-controlled AST
* built via `{ type: "exclude_constraint", elements: [...] }` cannot
* smuggle in extra DDL through the per-element `WITH <op>` slot.
*
* Refused on every non-PG dialect — none of MySQL / SQLite / MSSQL
* have an equivalent constraint grammar; the closest match is a
* unique partial index, which is a different shape and best
* expressed via the partial-index API.
*/
private printExcludeConstraint(c: ExcludeConstraintNode, namePrefix: string): string {
assertFeature(this.dialect, "EXCLUDE_CONSTRAINTS")
const method = c.method ?? "gist"
// Method is an identifier — same shape as `CREATE INDEX … USING <method>`.
validateFunctionName(method)
const elements = c.elements.map((e) => {
validateOperator(e.operator)
return `${this.printExpr(e.expr)} WITH ${e.operator}`
})
let out = `${namePrefix}EXCLUDE USING ${method} (${elements.join(", ")})`
if (c.where) {
out += ` WHERE (${this.printExpr(c.where)})`
}
return out
}

private printForeignKeyConstraint(c: ForeignKeyConstraintNode, namePrefix: string): string {
const cols = c.columns.map((col) => quoteIdentifier(col, this.dialect)).join(", ")
const refCols = c.references.columns.map((col) => quoteIdentifier(col, this.dialect)).join(", ")
Expand Down
2 changes: 2 additions & 0 deletions src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type { ColumnDef } from "./column.ts"
export { defineTable } from "./table.ts"
export type {
CheckDef,
ExcludeDef,
ExcludeElementDef,
ForeignKeyDef,
IndexColumn,
IndexDef,
Expand Down
Loading
Loading