From f37c3ff3c6e55a9d78ee524d144464e6e58d9548 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 4 Oct 2026 23:52:43 +0200 Subject: [PATCH] Make branded column types first class and strict Brands were an annotation: rows carried OrgId, but comparisons widened a branded column to plain string, so `$.org_id.eq(userId)` compiled. A brand is the claim that a value is one kind of id and not another; that has to hold where values are compared and written. - Widen keeps branded types: comparisons, IN, insert/update values and INSERT ... SELECT take the brand (a value, a same-brand column, or a param.of(type, name)); plain strings, other brands, param.string and unbranded columns are type errors. Literal unions still widen. - brand(type, schema): narrow any column type with an Effect schema while keeping its SQL type and wire codec (brand(PG.int8, Cents) still reads node-postgres's strings). - SelectRowOf: the whole decoded row, brands kept. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ docs/reference.md | 3 +- docs/tables-and-types.md | 55 +++++++++++++++++ src/ch/brand.test-d.ts | 106 ++++++++++++++++++++++++++++++++ src/ch/brand.test.ts | 92 +++++++++++++++++++++++++++ src/ch/compile.test.ts | 13 ++-- src/ch/expr.ts | 30 +++++---- src/ch/index.ts | 3 +- src/ch/insert.test-d.ts | 13 +++- src/ch/insert.ts | 11 ++-- src/ch/query.ts | 4 +- src/ch/table.ts | 10 ++- src/ch/types.ts | 25 ++++++++ src/pg/types.ts | 4 +- src/types.ts | 1 + tests/dialect-cases.postgres.ts | 6 ++ tests/dialect-cases.ts | 4 +- 17 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 src/ch/brand.test-d.ts create mode 100644 src/ch/brand.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb5eb9..6bf7bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, 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 diff --git a/docs/reference.md b/docs/reference.md index a6c7c97..c2f27f2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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` | @@ -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`. diff --git a/docs/tables-and-types.md b/docs/tables-and-types.md index 1f30111..9107d03 100644 --- a/docs/tables-and-types.md +++ b/docs/tables-and-types.md @@ -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 +// { 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` 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. diff --git a/src/ch/brand.test-d.ts b/src/ch/brand.test-d.ts new file mode 100644 index 0000000..edb1ec3 --- /dev/null +++ b/src/ch/brand.test-d.ts @@ -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>().toEqualTypeOf<{ + readonly org_id: OrgId + readonly id: string + readonly owner: UserId | null + readonly editors: ReadonlyArray + readonly budget: Cents + readonly status: "open" | "closed" + readonly created_at: DateTime.Utc +}>() +const selected = CH.from(Dashboards).select("org_id", "owner") +expectTypeOf>().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)]) diff --git a/src/ch/brand.test.ts b/src/ch/brand.test.ts new file mode 100644 index 0000000..98a7e2d --- /dev/null +++ b/src/ch/brand.test.ts @@ -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)), + ) +}) diff --git a/src/ch/compile.test.ts b/src/ch/compile.test.ts index 7a76034..e889029 100644 --- a/src/ch/compile.test.ts +++ b/src/ch/compile.test.ts @@ -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") diff --git a/src/ch/expr.ts b/src/ch/expr.ts index 25081ea..d4d260d 100644 --- a/src/ch/expr.ts +++ b/src/ch/expr.ts @@ -6,7 +6,7 @@ // Typed expressions that compile to SqlFragment. Every Expr 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" @@ -27,17 +27,23 @@ import { markTenantColumn, markTenantPredicate, tenantColumnOf, tenantPredicates export type Comparable = 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 extends string ? string : TSType extends number ? number : TSType +export type Widen = TSType extends Brand.Brand + ? TSType + : TSType extends string + ? string + : TSType extends number + ? number + : TSType // Params in the type // @@ -119,8 +125,8 @@ export interface Expr { toFragment(): SqlFragment // Comparison — returns Condition. `Expr` 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` inference would prefer — the reason `InferOutput` // reads the `_phantom` property instead of structurally inferring T. diff --git a/src/ch/index.ts b/src/ch/index.ts index 8e042f0..730f3c7 100644 --- a/src/ch/index.ts +++ b/src/ch/index.ts @@ -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 { diff --git a/src/ch/insert.test-d.ts b/src/ch/insert.test-d.ts index 9fac5cb..ec8680b 100644 --- a/src/ch/insert.test-d.ts +++ b/src/ch/insert.test-d.ts @@ -39,11 +39,16 @@ const Plain = CH.table("plain", { A: CH.string, B: CH.uint32 }) // @ts-expect-error B is required CH.insertInto(Plain).values({ A: "a" }) -// A branded column takes its branded value and a plain-string param. +// A branded column takes its branded value or a param of its type, not a plain string. const OrgId = Schema.String.pipe(Schema.brand("OrgId")) -const Branded = CH.table("branded", { OrgId: CH.custom("String", OrgId) }) +const OrgIdType = CH.custom("String", OrgId) +const Branded = CH.table("branded", { OrgId: OrgIdType }) CH.insertInto(Branded).values({ OrgId: OrgId.make("o") }) +CH.insertInto(Branded).values({ OrgId: CH.param.of(OrgIdType, "org") }) +// @ts-expect-error a plain-string param is not an OrgId CH.insertInto(Branded).values({ OrgId: CH.param.string("org") }) +// @ts-expect-error nor is a plain string +CH.insertInto(Branded).values({ OrgId: "o" }) // defineTable: defaults are optional, computed columns are not in the row. const Spans = S.defineTable("spans", { @@ -98,8 +103,10 @@ expectTypeOf>().toEqualTypeOf<"id">() CH.insertInto(Docs).values({ id: 1, search: "x" }) CH.from(Docs).select("search") -// INSERT ... SELECT follows the comparison rule: a branded column takes a plain string. +// INSERT ... SELECT follows the comparison rule: a branded column takes its brand, not a plain string. const BrandedTarget = CH.table("branded_target", { OrgId: CH.custom("String", OrgId) }) +CH.insertInto(BrandedTarget).select(CH.from(Branded).select(($) => ({ OrgId: $.OrgId }))) +// @ts-expect-error a plain-string column is not an OrgId CH.insertInto(BrandedTarget).select(CH.from(Plain).select(($) => ({ OrgId: $.A }))) // RETURNING: column names or a callback, as in select. diff --git a/src/ch/insert.ts b/src/ch/insert.ts index 98fc6b2..c30df76 100644 --- a/src/ch/insert.ts +++ b/src/ch/insert.ts @@ -25,8 +25,9 @@ import type { CHType, ColumnDefs, InferTS } from "./types" * * A value is the decoded type (a branded id stays branded), plus the extra * forms a comparison accepts (`Date` or a string for a `DateTime`). A param or - * expression may be of the widened primitive, as in a comparison, so a branded - * column takes `param.string`. + * expression may be of the widened type, as in a comparison: a literal-union + * column takes `param.string`, a branded one only a param of its own type + * (`param.of(type, name)`). */ export type InsertValue> = | Comparable> @@ -72,9 +73,9 @@ type SelectedRow = Q extends { readonly _phantom?: { readonly output: infer O /** Selected columns the table cannot take: not an insertable column, or of another type. */ export type InsertSelectMisfits = { - // The rule `values` and comparisons use: a column takes its widened - // primitive, so a branded column takes a plain string. That also lets a plain - // string into a literal-union column; the server checks those values. + // The rule `values` and comparisons use: a column takes its widened type. A + // literal-union column takes a plain string (the server checks the value); a + // branded column takes only its brand. [K in keyof Output]: K extends Exclude> ? [Output[K]] extends [InferTS | Widen>] ? never diff --git a/src/ch/query.ts b/src/ch/query.ts index 9520232..cbabe7f 100644 --- a/src/ch/query.ts +++ b/src/ch/query.ts @@ -50,8 +50,8 @@ type SelectRecord = Record> * Read each selected expression's output type off its `_phantom` property * rather than `S[K] extends Expr`. Structural inference prefers the * contravariant candidates in the comparison methods, and those are widened - * (`Widen`) so branded columns accept plain params — inferring through - * them resolved a branded column's output to the bare primitive. The indexed + * (`Widen`) so literal-union columns accept plain params — inferring + * through them resolved such a column's output to the bare primitive. The indexed * read is exact; `Exclude` only strips the `undefined` that `_phantom`'s * optionality adds, so a `Nullable(...)` column's `| null` survives. */ diff --git a/src/ch/table.ts b/src/ch/table.ts index d610391..4867fc6 100644 --- a/src/ch/table.ts +++ b/src/ch/table.ts @@ -3,7 +3,7 @@ // A Table carries its name and column definitions at both the type level // (for inference) and runtime (for SQL generation). -import type { ColumnDefs } from "./types" +import type { ColumnDefs, InferTS } from "./types" /** * `Defaulted` and `Computed` describe inserts: the columns an insert may leave @@ -79,3 +79,11 @@ export function table< ...(options?.computed !== undefined && options.computed.length > 0 ? { computed: [...options.computed] } : undefined), } } + +/** + * A whole row of a table, as a `SELECT` of every column decodes it: branded + * columns stay branded, nullable ones carry `| null`, computed ones are + * included. `SelectRowOf`, drizzle's `$inferSelect`. + */ +export type SelectRowOf = + T extends Table ? { readonly [K in keyof Cols]: InferTS } : never diff --git a/src/ch/types.ts b/src/ch/types.ts index 4e7f17a..8101035 100644 --- a/src/ch/types.ts +++ b/src/ch/types.ts @@ -351,6 +351,31 @@ export const custom = ( literalSchema?: Schema.Codec, ): CHType => chType(sql, sql, schema, literalSchema) +/** + * A column type narrowed by a schema: a branded id, a literal union, a refined + * number. `brand(PG.text, OrgId)` keeps the base's SQL type and wire codec and + * decodes on through `schema`, so rows hold an `OrgId`, inserts and comparisons + * take one, and `param.of(type, name)` is a param of it. + * + * `schema` encodes to the base's value (`OrgId` encodes to `string`). Its + * checks run both ways: a row that fails them is a decode error, and a literal + * or param value that fails them is a `QueryBuilderError` at compile. + * + * Wrap a nullable or array column from the inside: `nullable(brand(text, OrgId))`, + * `array(brand(text, OrgId))`. + */ +export const brand = ( + base: CHType, + schema: Schema.Codec, +): CHType => + chType( + base._tag, + base.sql, + base.schema.pipe(Schema.decodeTo(schema)) as Schema.Codec, + base.literalSchema.pipe(Schema.decodeTo(schema as Schema.Codec)) as Schema.Codec, + base.element, + ) + /** * An `AggregateFunction(fn, args…)` state column. * diff --git a/src/pg/types.ts b/src/pg/types.ts index 7a5d78a..1d30818 100644 --- a/src/pg/types.ts +++ b/src/pg/types.ts @@ -8,7 +8,7 @@ // form a common driver sends, the way `CHNumber` accepts a quoted 64-bit integer. import { DateTime, Schema, SchemaGetter } from "effect" -import { chDateTimeToIso, custom, type CHType, type InferEncoded, type InferTS } from "../ch/types" +import { brand, chDateTimeToIso, custom, type CHType, type InferEncoded, type InferTS } from "../ch/types" /** A Postgres column type. The ClickHouse descriptor under a dialect-neutral name. */ export type PgType = CHType @@ -154,4 +154,4 @@ export const nullable = >(t: T): PgNullable -export { custom } +export { brand, custom } diff --git a/src/types.ts b/src/types.ts index 6c0c74d..9a97b91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,7 @@ export { * because a `custom()` type of your own almost always wants it. */ CHNumber, + brand, custom, dateTime, dateTime64, diff --git a/tests/dialect-cases.postgres.ts b/tests/dialect-cases.postgres.ts index 7185544..fad05f7 100644 --- a/tests/dialect-cases.postgres.ts +++ b/tests/dialect-cases.postgres.ts @@ -41,6 +41,8 @@ const typed = CH.table("typed", { Doc: PG.jsonb(Schema.Struct({ region: Schema.String })), Tags: PG.array(PG.text), Missing: PG.nullable(PG.int4), + // A brand over int8, read from text: the base codec still parses the string. + Branded: PG.brand(PG.int8, Schema.Number.pipe(Schema.brand("Count"))), }) const typedRow = `SELECT 'a''b'::text AS "Text", @@ -58,6 +60,7 @@ const typedRow = `SELECT '2026-01-01 00:00:00.25+00'::text AS "AtText", '2026-01-01T00:00:00.25Z'::timestamptz AS "AtString", '{"region": "eu"}'::jsonb AS "Doc", + '12'::text AS "Branded", ARRAY['x', 'y']::text[] AS "Tags", NULL::int4 AS "Missing"` const typedRows = () => CH.from(typed).withCTE("typed", typedRow) @@ -165,6 +168,7 @@ export const postgresCases: readonly PostgresCase[] = [ "float8", "numeric", "custom", + "brand", "timestamptz", "jsonb", "array", @@ -190,6 +194,7 @@ export const postgresCases: readonly PostgresCase[] = [ "Doc", "Tags", "Missing", + "Branded", ), {}, ), @@ -211,6 +216,7 @@ export const postgresCases: readonly PostgresCase[] = [ Doc: { region: "eu" }, Tags: ["x", "y"], Missing: null, + Branded: 12, }, ], }, diff --git a/tests/dialect-cases.ts b/tests/dialect-cases.ts index 9a6311c..30d0a89 100644 --- a/tests/dialect-cases.ts +++ b/tests/dialect-cases.ts @@ -1,6 +1,6 @@ // Fixtures use only public entry points, resolved through the package's built dist. // Raw SQL supplies deterministic input rows; the operation under test uses the DSL. -import { DateTime } from "effect" +import { DateTime, Schema } from "effect" import * as CH from "@maple-dev/effect-orm" import * as F from "@maple-dev/effect-orm/expr" import * as T from "@maple-dev/effect-orm/types" @@ -730,6 +730,8 @@ const typeFixtures = [ ["array", T.array(T.nullable(T.int64)), "[toNullable(toInt64(42)), NULL]", [42, null]], ["map", T.map(T.string, T.array(T.uint64)), "map('key', [toUInt64(42)])", { key: [42] }], ["nullable", T.nullable(T.string), "CAST(NULL AS Nullable(String))", null], + // A brand over UInt64 keeps the base codec, so a quoted 64-bit value still decodes. + ["brand", T.brand(T.uint64, Schema.Number.pipe(Schema.brand("Count"))), "toUInt64(42)", 42], ] as const export const typeCases: readonly DialectCase[] = typeFixtures.map(([name, type, sql, expected]) => ({