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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

- **Breaking:** branded column types compare strictly. A comparison, an insert or update value,
and an `INSERT ... SELECT` into a column that decodes to a branded type (`Brand<...>`) take
that brand: a value of it, a column of the same brand, or a param declared with the type
(`param.of(type, name)`). A plain string, another brand (`UserId` for `OrgId`), `param.string`
and unbranded columns are type errors. Literal unions still compare against their primitive.
Migrate `$.OrgId.eq(param.string("orgId"))` to `$.OrgId.eq(param.of(orgIdType, "orgId"))`.
- Add `brand(type, schema)` (root, `/types`, `/postgres`): a column type narrowed by a schema,
keeping the base type's SQL type and wire codec. Add `SelectRowOf<typeof table>`, the whole row
a table decodes to.
- Add Postgres schema definitions and migrations. `S.pg.table` (with `S.pg.column`, `S.pg.index`,
`S.pg.uniqueIndex`, `S.pg.foreignKey`) defines tables with primary keys, partial and expression
indexes, foreign keys, defaults and identity columns; `dialect: "postgres"` in the kit config
Expand Down
3 changes: 2 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Note `/sql` exports a `compile` (fragment → string) distinct from the root `co
| ----------- | ----------------------------------------- |
| `table` | `(name, columns, options?) => Table` |
| `custom` | `(sql, schema, literalSchema?) => CHType` |
| `brand` | `(type, schema) => CHType`: the type narrowed by `schema` (a branded id, a literal union); see [Branded columns](./tables-and-types.md#branded-columns) |
| `from` | `(table, alias?) => CHQuery` |
| `fromQuery` | `(query, alias) => CHQuery` |
| `fromUnion` | `(union, alias) => CHQuery` |
Expand Down Expand Up @@ -286,7 +287,7 @@ Types: `WindowSpec`, `CompiledWindowSpec`, `WindowFrameBound`, `WindowRowsFrame`

**Everything else** — `Table`, `TableOptions`, `Expr`, `ColumnRef`, `Condition`, `Comparable`
(what a value of a type may be compared against), `MapValueOf`, `Subquery`, `ParamMarker`,
`ParamKind`, `CHQuery`, `CHUnionQuery`, `CHInsert`, `CHInsertStart`, `CHUpdate`, `CHUpdateStart`, `CHDelete`, `CHWrite`, `UpdateSet`, `UpdateSetOf`, `InsertRow`, `InsertRowOf`, `InsertValue`, `InsertSelectMisfits`, `InsertSelectMissing`, `InsertSettingValue`, `ConflictTarget`, `ConflictSet`, `OnConflictDoNothing`, `OnConflictDoUpdate`, `ColumnAccessor`, `JoinedColumnAccessor`,
`ParamKind`, `CHQuery`, `CHUnionQuery`, `CHInsert`, `CHInsertStart`, `CHUpdate`, `CHUpdateStart`, `CHDelete`, `CHWrite`, `UpdateSet`, `UpdateSetOf`, `InsertRow`, `InsertRowOf`, `SelectRowOf`, `InsertValue`, `InsertSelectMisfits`, `InsertSelectMissing`, `InsertSettingValue`, `ConflictTarget`, `ConflictSet`, `OnConflictDoNothing`, `OnConflictDoUpdate`, `ColumnAccessor`, `JoinedColumnAccessor`,
`JoinOnCallback`, `CompiledQuery`, `CompiledQueryInput`, `CompiledQueryRowSchema`, `RowSchemaMismatch`, `TenantScope`, `Dialect`, `DialectClauses`, `DialectTransactions`, `IsolationLevel`, `TransactionSettings`, `ParamStyle`, `FnResult`,
`WindowFunnelMode`, `WindowSpec`, `WindowRowsFrame`, `WindowFrameBound`,
`WindowOrderDirection`, `CompiledWindowSpec`.
Expand Down
55 changes: 55 additions & 0 deletions docs/tables-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,61 @@ their JSON representation. Match your existing database schema rather than redes
physical table to fit this library's constructors. For a `LowCardinality(String)` column, for
example, `T.custom("LowCardinality(String)", Schema.String)` decodes the ordinary string it emits.

## Branded columns

`T.brand(type, schema)` narrows a column type with an Effect schema: a branded id, a literal
union, a refined number. It keeps the base type's SQL type and wire codec, so `brand(PG.int8,
Cents)` still reads the string node-postgres sends, and wraps like any type:
`nullable(brand(...))`, `array(brand(...))`.

```ts title="branded-columns.ts"
import { Schema } from "effect"
import * as CH from "@maple-dev/effect-orm"
import * as PG from "@maple-dev/effect-orm/postgres"

const OrgId = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("OrgId"))
const UserId = Schema.String.pipe(Schema.brand("UserId"))

// Declare the column type once; tables and params both use it.
const orgId = PG.brand(PG.text, OrgId)

const Dashboards = CH.table("dashboards", {
org_id: orgId,
id: PG.text,
owner: PG.nullable(PG.brand(PG.text, UserId)),
})

export type Dashboard = CH.SelectRowOf<typeof Dashboards>
// { readonly org_id: OrgId; readonly id: string; readonly owner: UserId | null }

export const byOrg = CH.from(Dashboards)
.select("id", "owner")
.where(($) => [$.org_id.eq(CH.param.of(orgId, "orgId"))])

export const compiled = PG.compileUnsafe(byOrg, { orgId: OrgId.make("org_1") })

declare const userId: typeof UserId.Type
// @ts-expect-error a UserId is not an OrgId
CH.from(Dashboards).select("id").where(($) => [$.org_id.eq(userId)])
```

A brand is strict everywhere it is written or compared:

- **Rows** decode to the brand, and `SelectRowOf<typeof table>` names the whole row.
- **Comparisons** (`eq`, `in_`, `between`, joins) take a value of the brand, a column of the same
brand, or a param declared with the type: `CH.param.of(orgId, "orgId")`, whose value
`compile` then requires to be an `OrgId`. A plain string, another brand, `param.string`, or an
unbranded column is a type error.
- **Inserts and updates** take the brand, a param of it, or an expression of it.
- **Checks run both ways.** A row that fails the schema's checks is a decode error; a literal or
param value that fails them is a `QueryBuilderError` from `compile`.

A literal union (`brand(PG.text, Schema.Literals(["open", "closed"]))`) is not a brand: it
compares against any string, and the database checks the value.

`T.custom("String", OrgId)` brands the same way, but replaces the wire codec with `OrgId` itself;
prefer `brand` over a built-in type whose codec does work (numbers, timestamps).

`T.untyped(sqlType)` accepts an unknown field without validating it. Unlike `CH.untypedExpr`, it
supplies a `Schema.Unknown` codec, so other selected fields can still be validated. The unknown
field itself has no guarantee. Prefer a real custom codec where you know the wire representation.
Expand Down
106 changes: 106 additions & 0 deletions src/ch/brand.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Branded columns are strict: a brand is the claim that a value is one kind of
// id and not another, so everything that writes or compares a branded column
// has to hold that brand.

import { DateTime, Schema } from "effect"
import { expectTypeOf } from "vitest"
import * as CH from "./index"
import * as PG from "../postgres"
import * as S from "../schema"
import type { RowOf } from "../database"

const OrgId = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("@maple/OrgId"))
type OrgId = typeof OrgId.Type
const UserId = Schema.String.pipe(Schema.brand("@maple/UserId"))
type UserId = typeof UserId.Type
const Cents = Schema.Number.pipe(Schema.brand("Cents"))
type Cents = typeof Cents.Type
const Status = Schema.Literals(["open", "closed"])

const orgId = PG.brand(PG.text, OrgId)
const userId = PG.brand(PG.text, UserId)

const Dashboards = S.pg.table("dashboards", {
columns: {
org_id: orgId,
id: PG.text,
owner: PG.nullable(userId),
editors: PG.array(userId),
budget: PG.brand(PG.int8, Cents),
status: S.pg.column(PG.brand(PG.text, Status), { default: "open" }),
created_at: PG.timestamptz,
},
primaryKey: ["org_id", "id"],
})
const Plain = CH.table("plain", { org_id: PG.text })

declare const org: OrgId
declare const user: UserId

// Rows carry the brands, through nullable and array.
expectTypeOf<CH.SelectRowOf<typeof Dashboards>>().toEqualTypeOf<{
readonly org_id: OrgId
readonly id: string
readonly owner: UserId | null
readonly editors: ReadonlyArray<UserId>
readonly budget: Cents
readonly status: "open" | "closed"
readonly created_at: DateTime.Utc
}>()
const selected = CH.from(Dashboards).select("org_id", "owner")
expectTypeOf<RowOf<typeof selected>>().toEqualTypeOf<{ readonly org_id: OrgId; readonly owner: UserId | null }>()

// Comparisons take the brand: a value, a branded column, or a param of the type.
CH.from(Dashboards).select("id").where(($) => [
$.org_id.eq(org),
$.org_id.eq(CH.param.of(orgId, "orgId")),
$.owner.eq(user),
$.org_id.in_(org, org),
$.budget.gt(Cents.make(100)),
])
CH.from(Dashboards)
.innerJoin(Dashboards, "d2", (main, joined) => main.org_id.eq(joined.org_id))
.select("id")
// @ts-expect-error a plain string is not an OrgId
CH.from(Dashboards).select("id").where(($) => [$.org_id.eq("org_1")])
// @ts-expect-error a UserId is not an OrgId: the mistake brands exist to catch
CH.from(Dashboards).select("id").where(($) => [$.org_id.eq(user)])
// @ts-expect-error param.string is a plain string
CH.from(Dashboards).select("id").where(($) => [$.org_id.eq(CH.param.string("orgId"))])
// @ts-expect-error nor in a list
CH.from(Dashboards).select("id").where(($) => [$.org_id.in_("a", "b")])
// @ts-expect-error a plain number is not Cents
CH.from(Dashboards).select("id").where(($) => [$.budget.gt(100)])
// @ts-expect-error a plain-string column is not an OrgId column
CH.from(Dashboards).innerJoin(Plain, "p", (main, joined) => main.org_id.eq(joined.org_id)).select("id")

// A literal union stays a comparison on its primitive; the server checks the value.
CH.from(Dashboards).select("id").where(($) => [$.status.eq("open"), $.status.eq(CH.param.string("s"))])
// String operators still work on a branded string.
CH.from(Dashboards).select("id").where(($) => [$.org_id.like("org_%")])

// The param's value is the brand too.
const byOrg = CH.from(Dashboards)
.select("id")
.where(($) => [$.org_id.eq(CH.param.of(orgId, "orgId"))])
PG.compileUnsafe(byOrg, { orgId: org })
// @ts-expect-error the param wants an OrgId
PG.compileUnsafe(byOrg, { orgId: "org_1" })

// Inserts and updates take the brand.
CH.insertInto(Dashboards).values({ org_id: org, id: "d", editors: [user], budget: Cents.make(0), created_at: new Date() })
CH.insertInto(Dashboards).values({
// @ts-expect-error a plain string is not an OrgId
org_id: "o",
id: "d",
editors: [],
budget: Cents.make(0),
created_at: new Date(),
})
CH.update(Dashboards)
.set({ owner: user })
.where(($) => [$.org_id.eq(org)])
CH.update(Dashboards)
// @ts-expect-error an OrgId is not a UserId
.set({ owner: org })
.where(($) => [$.org_id.eq(org)])
92 changes: 92 additions & 0 deletions src/ch/brand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { PgliteClient } from "@effect/sql-pglite"
import { describe, expect, it } from "@effect/vitest"
import { Effect, Exit, Layer, Schema } from "effect"
import * as CH from "./index"
import * as Db from "../database"
import * as PG from "../postgres"
import * as S from "../schema"
import { QueryBuilderError } from "./errors"

const OrgId = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("@maple/OrgId"))
const Cents = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.brand("Cents"))

const orgId = PG.brand(PG.text, OrgId)

const Accounts = S.pg.table("accounts", {
columns: {
org_id: orgId,
id: PG.text,
balance: PG.brand(PG.int8, Cents),
owner: PG.nullable(orgId),
},
primaryKey: ["org_id", "id"],
})

const Live = Db.layerSqlClient({ dialect: PG.postgresDialect }).pipe(
Layer.provideMerge(PgliteClient.layer({ postgresqlconf: "timezone = 'UTC'" })),
)

describe("brand", () => {
it("keeps the base type's SQL, wrapper tag and wire codec", () => {
const balance = PG.brand(PG.int8, Cents)
expect(balance.sql).toBe("int8")
expect(PG.nullable(orgId)._tag).toBe("Nullable")
expect(Accounts.ddl.columns.map((c) => [c.name, c.type, c.notNull])).toEqual([
["org_id", "text", true],
["id", "text", true],
["balance", "bigint", true],
["owner", "text", false],
])
// int8 arrives as a string from node-postgres; the base codec still reads it.
expect(Schema.decodeUnknownSync(balance.schema)("1250")).toBe(1250)
expect(() => Schema.decodeUnknownSync(balance.schema)("-1")).toThrow()
})

it("refuses a literal or a param value that fails the brand's checks", () => {
const literal = Effect.runSync(
Effect.exit(PG.compile(CH.from(Accounts).select("id").where(($) => [$.org_id.eq("" as typeof OrgId.Type)]), {})),
)
expect(Exit.isFailure(literal) && Exit.findErrorOption(literal)._tag === "Some").toBe(true)
const param = Effect.runSync(
Effect.exit(
PG.compile(
CH.from(Accounts)
.select("id")
.where(($) => [$.org_id.eq(CH.param.of(orgId, "orgId"))]),
{ orgId: "" as typeof OrgId.Type },
),
),
)
expect(Exit.isFailure(param)).toBe(true)
if (Exit.isFailure(param)) {
const error = Exit.findErrorOption(param)
expect(error._tag === "Some" && error.value instanceof QueryBuilderError).toBe(true)
}
})

it.effect("round-trips branded values through Postgres", () =>
Effect.gen(function* () {
for (const statement of S.renderPgSchema(S.pgEntitiesOf([Accounts]))) yield* Db.execute(Db.sql.raw(statement))
const org = OrgId.make("org_1")
const inserted = yield* Db.run(
CH.insertInto(Accounts)
.values({ org_id: org, id: "a", balance: Cents.make(1250) })
.returning("org_id", "balance", "owner"),
)
expect(inserted).toEqual([{ org_id: "org_1", balance: 1250, owner: null }])

const rows = yield* Db.run(
CH.from(Accounts)
.select("id", "balance")
.where(($) => [$.org_id.eq(CH.param.of(orgId, "orgId"))]),
{ orgId: org },
)
expect(rows).toEqual([{ id: "a", balance: 1250 }])

// A row that breaks the brand's checks is a decode error, not a silently wrong value.
yield* Db.execute(Db.sql`INSERT INTO accounts (org_id, id, balance) VALUES ('', 'b', 5)`)
const exit = yield* Effect.exit(Db.run(CH.from(Accounts).select("org_id")))
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(Effect.provide(Live)),
)
})
13 changes: 5 additions & 8 deletions src/ch/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,17 @@ describe("CompiledQuery.decodeRows", () => {

// `T.custom("String", branded)` is how a caller brands an id column. The
// brand must survive derivation — it is the whole reason to declare it — and
// the column must still compare against a plain-string param.
// the column compares against a param of its own type.
it.effect("a branded custom column derives a branded row schema", () =>
Effect.gen(function* () {
const OrgId = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand("OrgId"))
const table = CH.table(
"events",
{ OrgId: T.custom("String", OrgId), Count: CH.uint64 },
{ tenantColumn: "OrgId" },
)
const OrgIdType = T.custom("String", OrgId)
const table = CH.table("events", { OrgId: OrgIdType, Count: CH.uint64 }, { tenantColumn: "OrgId" })
const compiled = compileCHUnsafe(
CH.from(table)
.select(($) => ({ orgId: $.OrgId }))
.where(($) => [$.OrgId.eq(CH.param.string("orgId"))]),
{ orgId: "org_1" },
.where(($) => [$.OrgId.eq(CH.param.of(OrgIdType, "orgId"))]),
{ orgId: OrgId.make("org_1") },
)

expect(compiled.rowSchemaSource).toBe("derived")
Expand Down
30 changes: 18 additions & 12 deletions src/ch/expr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Typed expressions that compile to SqlFragment. Every Expr<T> carries a
// phantom TSType so TypeScript can infer output row types from SELECT clauses.

import { DateTime, Result, Schema } from "effect"
import { type Brand, DateTime, Result, Schema } from "effect"
import type { SqlFragment } from "../sql/sql-fragment"
import { raw, str, ident, compile, as_ as sqlAs, known } from "../sql/sql-fragment"
import { activeSqlSyntax } from "../sql/sql-syntax"
Expand All @@ -27,17 +27,23 @@ import { markTenantColumn, markTenantPredicate, tenantColumnOf, tenantPredicates
export type Comparable<TSType> = TSType extends DateTime.Utc ? DateTime.Utc | Date | string : TSType

/**
* A branded primitive compares as the primitive it brands.
* What a comparison widens a column's type to: a literal union to its
* primitive (`"open" | "closed"` compares against any `string`; the server
* checks the value), but a branded type stays branded.
*
* A column may decode to a branded type (`T.custom("String", OrgId)`), but the
* wire value it is compared against is the plain primitive — a param, another
* column, a literal. Without widening, `$.OrgId.eq(param.string("orgId"))`
* stops compiling the moment the column's schema brands its decoded type,
* which would make branding a breaking change instead of an annotation. The
* type-level mirror of `literalSchema`: comparisons may accept more than the
* column decodes to.
* A brand is the claim that a value is one kind of id and not another, so an
* `OrgId` column compares against an `OrgId`: a value, another `OrgId` column,
* or a param declared with the column's type (`param.of(OrgIdColumn, "orgId")`).
* A plain `string`, a `UserId`, or `param.string` is a type error, which is
* what catches `$.OrgId.eq(userId)`.
*/
export type Widen<TSType> = TSType extends string ? string : TSType extends number ? number : TSType
export type Widen<TSType> = TSType extends Brand.Brand<any>
? TSType
: TSType extends string
? string
: TSType extends number
? number
: TSType

// Params in the type
//
Expand Down Expand Up @@ -119,8 +125,8 @@ export interface Expr<TSType, P = never> {
toFragment(): SqlFragment

// Comparison — returns Condition. `Expr<TSType>` is listed alongside the
// widened form because `Expr` is invariant: a branded column must accept
// both its own refs and plain-primitive exprs (params, other columns).
// widened form because `Expr` is invariant: a literal-union column must
// accept both its own refs and plain-primitive exprs (params, other columns).
// The widened arms sit in contravariant positions, which TypeScript's
// `extends Expr<infer T>` inference would prefer — the reason `InferOutput`
// reads the `_phantom` property instead of structurally inferring T.
Expand Down
3 changes: 2 additions & 1 deletion src/ch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ export {
array,
nullable,
custom,
brand,
} from "./types"

// Table
export { type Table, type TableOptions, table } from "./table"
export { type SelectRowOf, type Table, type TableOptions, table } from "./table"

// Core expression primitives
export {
Expand Down
Loading
Loading