From 28484942efc8f129cd134bbb0383d224bdb5280e Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:27:26 +0200 Subject: [PATCH 1/2] feat(schema): column names on ClickHouse and a casing table option CH.column takes name, as PG.column does: DDL, keys, statements and the tenant scope write the database name, and rows decode under the key. A casing option (snake_case or camelCase, drizzle-orm's rule) names every column that gives no name, on both dialects and on external tables. A materialized view whose target renames a column records a problem. Co-Authored-By: Claude Opus 5.5 --- src/ch/column-names.test.ts | 149 ++++++++++++++++++++++++++ src/clickhouse.ts | 1 + src/docs-examples.test.ts | 21 ++++ src/pg/column-names.test.ts | 81 ++++++++++++++ src/postgres.ts | 1 + src/schema/casing.ts | 32 ++++++ src/schema/define.ts | 83 +++++++++++--- src/schema/pg-define.ts | 19 ++-- tests/column-names.clickhouse.test.ts | 132 +++++++++++++++++++++++ 9 files changed, 496 insertions(+), 23 deletions(-) create mode 100644 src/ch/column-names.test.ts create mode 100644 src/schema/casing.ts create mode 100644 tests/column-names.clickhouse.test.ts diff --git a/src/ch/column-names.test.ts b/src/ch/column-names.test.ts new file mode 100644 index 0000000..3560d28 --- /dev/null +++ b/src/ch/column-names.test.ts @@ -0,0 +1,149 @@ +// ClickHouse columns declared under a key other than their database name: +// `orgId: CH.column(CH.string, { name: "org_id" })`. The DDL, every statement +// and the tenant scope write the database name; rows decode under the key. +// tests/column-names.clickhouse.test.ts runs the same table on a live server. + +import { Effect } from "effect" +import { describe, expect, it } from "vitest" +import * as CH from "../clickhouse" +import * as S from "../schema" + +const Requests = CH.table("requests", { + columns: { + orgId: CH.column(CH.string, { name: "org_id" }), + at: CH.column(CH.dateTime, { name: "created_at", defaultExpr: "now()" }), + route: CH.string, + status: CH.column(CH.uint16, { name: "status_code", default: 200 }), + hour: CH.column(CH.dateTime, { name: "created_hour", materialized: ($) => CH.toStartOfHour($.at) }), + }, + engine: CH.engine.mergeTree(), + orderBy: ["orgId", "route", "at"], + primaryKey: ["orgId", "route"], + partitionBy: ($) => CH.toStartOfHour($.at), + indexes: [CH.index("idx_status", ($) => $.status, "minmax")], + tenantColumn: "orgId", +}) + +describe("ClickHouse column names", () => { + it("writes the database names into the DDL and keeps keys out of it", () => { + const [ddl] = S.renderSchema(Effect.runSync(S.entitiesOf([Requests]))) + expect(ddl).toContain("\torg_id String,") + expect(ddl).toContain("\tcreated_at DateTime DEFAULT now(),") + expect(ddl).toContain("\tstatus_code UInt16 DEFAULT 200,") + expect(ddl).toContain("\tcreated_hour DateTime MATERIALIZED toStartOfHour(created_at),") + expect(ddl).toContain("INDEX idx_status status_code TYPE minmax") + expect(ddl).toContain("PARTITION BY toStartOfHour(created_at)") + expect(ddl).toContain("ORDER BY (org_id, route, created_at)") + expect(ddl).toContain("PRIMARY KEY (org_id, route)") + expect(ddl).not.toMatch(/\b(orgId|at|status|hour)\b/) + }) +}) + +describe("ClickHouse statements over renamed columns", () => { + it("selects, filters, orders and scopes by the database names and reads rows under the keys", async () => { + const compiled = CH.compileUnsafe( + CH.from(Requests) + .select(($) => ({ orgId: $.orgId, route: $.route, status: $.status, hour: $.hour })) + .where(($) => [$.orgId.eq(CH.param.string("orgId")), $.status.gte(500)]) + .orderBy(($) => [[$.at, "desc"]]), + { orgId: "o1" }, + ) + expect(compiled.sql).toContain("requests.org_id AS orgId") + expect(compiled.sql).toContain("requests.status_code AS status") + expect(compiled.sql).toContain("requests.created_hour AS hour") + expect(compiled.sql).toContain("WHERE requests.org_id = 'o1'") + expect(compiled.sql).toContain("ORDER BY requests.created_at DESC") + expect(compiled.tenantScope).toBe("single-tenant") + const rows = await Effect.runPromise( + compiled.decodeRows([{ orgId: "o1", route: "/a", status: 503, hour: "2026-01-01 10:00:00" }]), + ) + expect(rows.map((row) => [row.orgId, row.route, row.status])).toEqual([["o1", "/a", 503]]) + }) + + it("inserts, updates and deletes by the database names", () => { + const insert = CH.compileUnsafe(CH.insertInto(Requests).values({ orgId: "o1", route: "/a", status: 201 })) + expect(insert.sql).toContain("INSERT INTO requests (org_id, route, status_code)") + const update = CH.compileUnsafe( + CH.update(Requests).set({ status: 500 }).where(($) => [$.orgId.eq("o1"), $.route.eq("/a")]), + ) + expect(update.sql).toContain("status_code = 500") + expect(update.sql).toContain("org_id = 'o1'") + expect(update.tenantScope).toBe("single-tenant") + const remove = CH.compileUnsafe(CH.deleteFrom(Requests).where(($) => [$.orgId.eq("o1")])) + expect(remove.sql).toContain("org_id = 'o1'") + expect(remove.sql).not.toMatch(/\borgId\b/) + }) + + it("leaves a renamed column without a default required on insert", () => { + // @ts-expect-error route has no default, and orgId's name gives it none + CH.insertInto(Requests).values({ route: "/a" }) + CH.insertInto(Requests).values({ orgId: "o1", route: "/a" }) + // @ts-expect-error hour is MATERIALIZED, so an insert may not write it + CH.insertInto(Requests).values({ orgId: "o1", route: "/a", hour: new Date() }) + }) +}) + +describe("materialized views and renamed columns", () => { + const Counts = CH.table("route_counts", { + columns: { orgId: CH.string, route: CH.string, hits: CH.uint64 }, + engine: CH.engine.summingMergeTree(), + orderBy: ["orgId", "route"], + }) + + it("reads a renamed source column by its database name", () => { + const view = CH.materializedView("route_counts_mv", { + to: Counts, + as: CH.from(Requests) + .select(($) => ({ orgId: $.orgId, route: $.route, hits: CH.count() })) + .groupBy("orgId", "route"), + }) + expect(view.problems).toEqual([]) + expect(view.ddl.select).toContain("requests.org_id AS orgId") + }) + + it("records a problem for a target with renamed columns, which the view would miss", () => { + const Renamed = CH.table("route_counts_renamed", { + columns: { orgId: CH.column(CH.string, { name: "org_id" }), route: CH.string, hits: CH.uint64 }, + engine: CH.engine.summingMergeTree(), + orderBy: ["orgId", "route"], + }) + const view = CH.materializedView("route_counts_renamed_mv", { + to: Renamed, + as: CH.from(Requests) + .select(($) => ({ orgId: $.orgId, route: $.route, hits: CH.count() })) + .groupBy("orgId", "route"), + }) + expect(view.problems.map((problem) => problem.message)).toEqual([ + "route_counts_renamed renames orgId to org_id; a materialized view writes its output by database name, so give the target column no name", + ]) + }) +}) + +describe("ClickHouse casing", () => { + it("names each column without a name by drizzle's snake_case rule", () => { + const Cased = CH.table("cased", { + casing: "snake_case", + columns: { + orgId: CH.string, + OrgName: CH.string, + HTTPStatus: CH.uint16, + userID: CH.string, + address2: CH.string, + already_snake: CH.string, + renamed: CH.column(CH.string, { name: "kept" }), + }, + engine: CH.engine.mergeTree(), + orderBy: ["orgId"], + }) + expect(Cased.ddl.columns.map((column) => column.name)).toEqual([ + "org_id", + "org_name", + "http_status", + "user_id", + "address2", + "already_snake", + "kept", + ]) + expect(CH.compileUnsafe(CH.from(Cased).select("HTTPStatus")).sql).toContain("cased.http_status AS HTTPStatus") + }) +}) diff --git a/src/clickhouse.ts b/src/clickhouse.ts index 680e86d..048c063 100644 --- a/src/clickhouse.ts +++ b/src/clickhouse.ts @@ -78,6 +78,7 @@ export { type TableDefinition, type ExternalTableDefinition, } from "./schema/define" +export { type Casing } from "./schema/casing" // ClickHouse functions (from category modules) export { diff --git a/src/docs-examples.test.ts b/src/docs-examples.test.ts index ef673be..6f8af45 100644 --- a/src/docs-examples.test.ts +++ b/src/docs-examples.test.ts @@ -18,6 +18,7 @@ import { Effect, Exit, Option, Schema } from "effect" import * as CH from "./ch/index" import * as T from "./ch/types" import * as CHE from "./clickhouse" +import * as PG from "./postgres" import { parseStatement, renderStatement, withSettings } from "./sql/statement" import { compileCHUnsafe, compileUnionUnsafe } from "./ch/compile" import { raw as rawFragment, compile as compileFragment } from "./sql/sql-fragment" @@ -192,6 +193,26 @@ describe("docs/tables-and-types.md", () => { "SELECT counters.Hits AS hits FROM counters WHERE counters.OrgId = 'org_123' AND counters.Live = 1", ) }) + + it("Column names", () => { + const Members = PG.table("members", { + casing: "snake_case", + columns: { + orgId: PG.text, + displayName: PG.column(PG.text, { name: "label" }), + createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["orgId", "displayName"], + tenantColumn: "orgId", + }) + expect(Members.ddl.columns.map((column) => column.name)).toEqual(["org_id", "label", "created_at"]) + const compiled = PG.compileUnsafe( + PG.from(Members).select("displayName").where(($) => [$.orgId.eq(PG.param.string("orgId"))]), + { orgId: "o1" }, + ) + expect(oneLine(compiled.sql)).toBe(`SELECT "members"."label" AS "displayName" FROM "members" WHERE "members"."org_id" = $1`) + expect(compiled.tenantScope).toBe("single-tenant") + }) }) // queries.md diff --git a/src/pg/column-names.test.ts b/src/pg/column-names.test.ts index 84c9a47..b28d827 100644 --- a/src/pg/column-names.test.ts +++ b/src/pg/column-names.test.ts @@ -189,3 +189,84 @@ describe("undecoded", () => { expect(Exit.isFailure(await Effect.runPromiseExit(strict.decodeRows([{ id: "a", body: { old: "shape" } }])))).toBe(true) }) }) + +describe("casing", () => { + const Accounts = PG.table("accounts", { + casing: "snake_case", + columns: { + orgId: PG.text, + accountId: PG.column(PG.int4, { identity: "always" }), + displayName: PG.column(PG.text, { name: "label" }), + HTTPStatus: PG.column(PG.int4, { default: 200 }), + createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }), + }, + primaryKey: ["orgId", "accountId"], + indexes: [PG.uniqueIndex("accounts_label_unq", ["orgId", "displayName"])], + tenantColumn: "orgId", + }) + const Grants = PG.table("grants", { + casing: "snake_case", + columns: { orgId: PG.text, accountId: PG.int4, grantedAt: PG.nullable(PG.timestamptz) }, + primaryKey: ["orgId", "accountId"], + foreignKeys: [ + PG.foreignKey({ columns: ["orgId", "accountId"], references: Accounts, foreignColumns: ["orgId", "accountId"] }), + ], + }) + + it("names every column without a name from its key, the way drizzle's casing does", () => + Effect.runPromise( + Effect.gen(function* () { + const ddl = S.renderPgSchema(yield* S.pgEntitiesOf([Accounts, Grants])).join("\n") + expect(ddl).toContain(`"org_id" text NOT NULL`) + expect(ddl).toContain(`"account_id" integer NOT NULL GENERATED ALWAYS AS IDENTITY`) + expect(ddl).toContain(`"label" text NOT NULL`) + expect(ddl).toContain(`"http_status" integer NOT NULL DEFAULT 200`) + expect(ddl).toContain(`PRIMARY KEY ("org_id", "account_id")`) + expect(ddl).toContain(`USING btree ("org_id", "label")`) + expect(ddl).toContain(`FOREIGN KEY ("org_id", "account_id") REFERENCES "accounts" ("org_id", "account_id")`) + expect(ddl).not.toMatch(/"(orgId|accountId|displayName|HTTPStatus|createdAt|grantedAt)"/) + }), + )) + + it("writes camelCase names from snake_case keys", () => { + const Legacy = PG.table("legacy", { + casing: "camelCase", + columns: { org_id: PG.text, created_at: PG.timestamptz }, + primaryKey: ["org_id"], + }) + const { sql } = PG.compileUnsafe(PG.from(Legacy).select("org_id", "created_at")) + expect(sql).toContain(`"legacy"."orgId" AS "org_id"`) + expect(sql).toContain(`"legacy"."createdAt" AS "created_at"`) + }) + + it("applies to an external table", () => { + const View = PG.table("account_view", { external: true, casing: "snake_case", columns: { orgId: PG.text } }) + expect(PG.compileUnsafe(PG.from(View).select("orgId")).sql).toContain(`"account_view"."org_id" AS "orgId"`) + }) + + layer(Live)((it) => { + it.effect("reads, writes and scopes under the keys", () => + Effect.gen(function* () { + for (const statement of S.renderPgSchema(yield* S.pgEntitiesOf([Accounts, Grants]))) { + yield* Db.execute({ sql: statement }) + } + const inserted = yield* Db.run( + PG.insertInto(Accounts).values({ orgId: "o1", displayName: "Core" }).returning(), + ) + expect(inserted.map((row) => [row.orgId, row.displayName, row.HTTPStatus])).toEqual([["o1", "Core", 200]]) + const upserted = yield* Db.run( + PG.insertInto(Accounts) + .values({ orgId: "o1", displayName: "Core", HTTPStatus: 404 }) + .onConflictDoUpdate({ target: ["orgId", "displayName"], set: ($, excluded) => ({ HTTPStatus: excluded.HTTPStatus }) }) + .returning("displayName", "HTTPStatus"), + ) + expect(upserted).toEqual([{ displayName: "Core", HTTPStatus: 404 }]) + const query = PG.from(Accounts) + .select("displayName", "HTTPStatus") + .where(($) => [$.orgId.eq(PG.param.string("orgId"))]) + expect(PG.compileUnsafe(query, { orgId: "o1" }).tenantScope).toBe("single-tenant") + expect(yield* Db.run(query, { orgId: "o1" })).toEqual([{ displayName: "Core", HTTPStatus: 404 }]) + }), + ) + }) +}) diff --git a/src/postgres.ts b/src/postgres.ts index 1610096..d4a40e0 100644 --- a/src/postgres.ts +++ b/src/postgres.ts @@ -53,6 +53,7 @@ export { type ExternalTableDefinition, } from "./schema/pg-define" export { SchemaDefinitionError, type DefinitionProblem, type DdlExpr, type DdlKey } from "./schema/define" +export { type Casing } from "./schema/casing" /** `compile`, for Postgres unless `options.dialect` says otherwise. */ // Typed through `any` and cast: `compileCH` is overloaded (queries and inserts), diff --git a/src/schema/casing.ts b/src/schema/casing.ts new file mode 100644 index 0000000..0d1f390 --- /dev/null +++ b/src/schema/casing.ts @@ -0,0 +1,32 @@ +// Column casing: a table's database column names derived from its keys. +// +// The rule is drizzle-orm's (`casing: "snake_case"`), word for word, so a +// schema drizzle-kit created keeps its names: `orgId` and `OrgId` are +// `org_id`, `HTTPStatus` is `http_status`, `address2` stays `address2`. It runs +// once, where the table is defined, and lands on each column as its `sqlName`; +// the query builder never sees a casing, only names, so decoding still reads +// every row under the exact alias it wrote. + +/** + * How a table names a column in the database when the column gives no `name`: + * `"snake_case"` stores `orgId` as `org_id`; `"camelCase"` stores `org_id` as + * `orgId`. A column's own `name` wins. + */ +export type Casing = "snake_case" | "camelCase" + +const words = (key: string): ReadonlyArray => + key.replace(/['’]/g, "").match(/[\da-z]+|[A-Z]+(?![a-z])|[A-Z][\da-z]+/g) ?? [] + +const toSnakeCase = (key: string): string => words(key).map((word) => word.toLowerCase()).join("_") + +const toCamelCase = (key: string): string => + words(key) + .map((word, position) => (position === 0 ? word.toLowerCase() : `${word[0]!.toUpperCase()}${word.slice(1)}`)) + .join("") + +/** The database name `casing` gives `key`, or `undefined` when it is the key itself. */ +export const casedName = (key: string, casing: Casing | undefined): string | undefined => { + if (casing === undefined) return undefined + const name = casing === "snake_case" ? toSnakeCase(key) : toCamelCase(key) + return name === "" || name === key ? undefined : name +} diff --git a/src/schema/define.ts b/src/schema/define.ts index 58839a9..bdb2009 100644 --- a/src/schema/define.ts +++ b/src/schema/define.ts @@ -14,7 +14,7 @@ import type { Expr } from "../ch/expr" import { encodeColumnLiteral } from "../ch/literal" import { createColumnAccessor, type CHQuery, type ColumnAccessor, type NeedsSelect } from "../ch/query" import { table, type Table } from "../ch/table" -import type { CHType, ColumnDefs, InferTS } from "../ch/types" +import { columnSqlName, type CHType, type ColumnDefs, type InferTS } from "../ch/types" import { compile as compileFragment } from "../sql/sql-fragment" import type { ColumnDefault, @@ -24,6 +24,7 @@ import type { MaterializedViewEntity, TableEntity, } from "./entities" +import { casedName, type Casing } from "./casing" import { checkIdentifier, withRenderProblems, type DefinitionProblem, type ProblemSink } from "./problems" export { SchemaDefinitionError, type DefinitionProblem } from "./problems" @@ -45,13 +46,22 @@ const renderExpr = (expr: DdlExpr, columns: Cols) typeof expr === "string" ? expr : renderExprValue(expr(createColumnAccessor(columns))) const renderKey = (key: DdlKey, columns: Cols): string => { - const parts = typeof key === "function" ? key(createColumnAccessor(columns)).map(renderExprValue) : [...key] + const parts = + typeof key === "function" + ? key(createColumnAccessor(columns)).map(renderExprValue) + : key.map((column) => columnSqlName(columns, column)) return parts.length === 1 ? parts[0]! : `(${parts.join(", ")})` } // Columns export interface ColumnOptions> { + /** + * The column's name in the database, when it differs from its key in + * `columns`: `orgId: CH.column(CH.string, { name: "org_id" })` is read and + * written as `$.orgId` and stored as `org_id`. + */ + readonly name?: string /** A literal default, encoded through the column's own type. */ readonly default?: InferTS /** `DEFAULT `, for a computed default. */ @@ -122,6 +132,20 @@ export type ComputedColumnsOf> = { const isColumnSpec = (input: ColumnInput): input is ColumnSpec, any> => "_tag" in input && input._tag === "ColumnSpec" +/** + * A column type that carries its database name as `sqlName` when that differs + * from its key: the column's own `name`, else the one the table's `casing` gives. + */ +export const namedColumnType = ( + key: string, + type: CHType, + name: string | undefined, + casing: Casing | undefined, +): CHType => { + const sqlName = name ?? casedName(key, casing) + return sqlName === undefined || sqlName === key ? type : { ...type, sqlName } +} + // Engines const engineOf = (family: string, ...params: ReadonlyArray): EngineSpec => ({ @@ -189,6 +213,8 @@ export interface TableDefinition> { readonly comment?: string /** The column carrying row-level tenancy; see docs/tenant-scoping.md. */ readonly tenantColumn?: keyof Columns & string + /** Database names for the columns that give no `name`: `"snake_case"` stores `orgId` as `org_id`. */ + readonly casing?: Casing } /** @@ -201,6 +227,7 @@ export interface ExternalTableDefinition( name: Name, - definition: { readonly columns: Record; readonly tenantColumn?: string }, + definition: { readonly columns: Record; readonly tenantColumn?: string; readonly casing?: Casing }, isSpec: (input: unknown) => input is { readonly type: CHType; readonly options: object }, computedOptions: ReadonlyArray, - /** The query-side type of a spec; the Postgres one carries the column's `name`. */ - typeOf: (spec: { readonly type: CHType; readonly options: object }) => CHType = (spec) => spec.type, ): Table => { const inputs = Object.entries(definition.columns) const specs = inputs.filter((entry): entry is [string, { readonly type: CHType; readonly options: object }] => @@ -225,7 +250,14 @@ export const externalTable = ( Object.entries(options).some(([key, value]) => key !== "name" && value !== undefined) return table( name, - Object.fromEntries(inputs.map(([column, input]) => [column, isSpec(input) ? typeOf(input) : input])) as ColumnDefs, + Object.fromEntries( + inputs.map(([column, input]) => [ + column, + isSpec(input) + ? namedColumnType(column, input.type, (input.options as { readonly name?: string }).name, definition.casing) + : namedColumnType(column, input as CHType, undefined, definition.casing), + ]), + ) as ColumnDefs, { ...(definition.tenantColumn !== undefined ? { tenantColumn: definition.tenantColumn } : undefined), defaults: specs.filter(([, spec]) => given(spec.options) && !isComputed(spec.options)).map(([column]) => column), @@ -318,8 +350,14 @@ function buildTable [column, isColumnSpec(input) ? input.type : input]), + inputs.map(([column, input]) => [ + column, + isColumnSpec(input) + ? namedColumnType(column, input.type, input.options.name, definition.casing) + : namedColumnType(column, input, undefined, definition.casing), + ]), ) as ColumnsOf + const sqlName = (column: string) => columnSqlName(types, column) if (isMergeTreeFamily(definition.engine) && definition.orderBy === undefined) { problems.push({ object: name, message: `${definition.engine.family} needs orderBy (use [] for ORDER BY tuple())` }) @@ -334,14 +372,14 @@ function buildTable { - checkIdentifier(problems, `${name}.${column}`, column) + checkIdentifier(problems, `${name}.${column}`, sqlName(column)) const spec: ColumnSpec> = isColumnSpec(input) ? input : { _tag: "ColumnSpec", type: input as CHType, options: {} } return { kind: "column", table: name, - name: column, + name: sqlName(column), position, type: spec.type.sql, default: columnDefault(problems, name, column, spec, types), @@ -381,10 +419,11 @@ function buildTable c.default?.kind === "DEFAULT").map((c) => c.name) - const computed = columnEntities - .filter((c) => c.default?.kind === "MATERIALIZED" || c.default?.kind === "ALIAS") - .map((c) => c.name) + // Keys, not database names: the insert row type speaks keys. + const keysWhere = (kinds: ReadonlyArray) => + inputs.flatMap(([column], position) => (kinds.includes(columnEntities[position]!.default?.kind ?? "") ? [column] : [])) + const defaults = keysWhere(["DEFAULT"]) + const computed = keysWhere(["MATERIALIZED", "ALIAS"]) return { _tag: "Table", name, @@ -440,12 +479,26 @@ export function materializedView< ? unknown : { readonly targetCannotTake: MisfitColumns }), ): MaterializedView { - return withRenderProblems(name, () => buildView(name, options.to.name, options.as)) + return withRenderProblems(name, () => buildView(name, options.to, options.as)) } -function buildView(name: Name, to: string, as: CHQuery): MaterializedView { +function buildView( + name: Name, + target: Table, + as: CHQuery, +): MaterializedView { const problems: ProblemSink = [] + const to = target.name checkIdentifier(problems, name, name) + // The view's SELECT names its output by key, and ClickHouse fills the target by database name. + for (const [column, type] of Object.entries(target.columns)) { + if (type.sqlName !== undefined && type.sqlName !== column) { + problems.push({ + object: name, + message: `${to} renames ${column} to ${type.sqlName}; a materialized view writes its output by database name, so give the target column no name`, + }) + } + } const source = leftmostTable(as) if (source === undefined) { problems.push({ object: name, message: "a materialized view cannot read FROM a union; define one view per branch" }) diff --git a/src/schema/pg-define.ts b/src/schema/pg-define.ts index 956d461..f9c0247 100644 --- a/src/schema/pg-define.ts +++ b/src/schema/pg-define.ts @@ -17,7 +17,8 @@ import type { Table } from "../ch/table" import { columnSqlName, type CHType, type ColumnDefs, type InferTS } from "../ch/types" import type { PgType } from "../pg/types" import { postgresDialect } from "../pg/dialect" -import { externalTable, type DdlExpr, type DdlKey } from "./define" +import type { Casing } from "./casing" +import { externalTable, namedColumnType, type DdlExpr, type DdlKey } from "./define" import { withRenderProblems, type DefinitionProblem, type ProblemSink } from "./problems" import { canonicalPgType, @@ -121,11 +122,9 @@ export type DefaultedColumnsOf> = { const isColumnSpec = (input: ColumnInput): input is ColumnSpec, any> => "_tag" in input && input._tag === "PgColumnSpec" -/** The query-side type of a column input, carrying its `name` as `sqlName`. */ -export const columnTypeOfInput = (input: ColumnInput): CHType => { - if (!isColumnSpec(input)) return input - return input.options.name === undefined ? input.type : { ...input.type, sqlName: input.options.name } -} +/** The query-side type of a column input, carrying its database name as `sqlName`. */ +const columnTypeOfInput = (key: string, input: ColumnInput, casing: Casing | undefined): CHType => + isColumnSpec(input) ? namedColumnType(key, input.type, input.options.name, casing) : namedColumnType(key, input, undefined, casing) // Indexes and foreign keys @@ -319,6 +318,8 @@ export interface TableDefinition> { readonly foreignKeys?: ReadonlyArray> /** The column carrying row-level tenancy; see docs/tenant-scoping.md. */ readonly tenantColumn?: keyof Columns & string + /** Database names for the columns that give no `name`: `"snake_case"` stores `orgId` as `org_id`. */ + readonly casing?: Casing } /** A table this schema does not own: a view, a catalog table, one another tool migrates. No DDL. */ @@ -326,6 +327,7 @@ export interface ExternalTableDefinition> => isColumnSpec(input as ColumnInput), [], - (spec) => columnTypeOfInput(spec as ColumnSpec>), ) } return withRenderProblems(name, () => buildTable(name, definition)) @@ -453,7 +454,9 @@ function buildTable [column, columnTypeOfInput(input)])) as ColumnsOf + const types = Object.fromEntries( + inputs.map(([column, input]) => [column, columnTypeOfInput(column, input, definition.casing)]), + ) as ColumnsOf const sqlName = (column: string) => columnSqlName(types, column) const has = (column: string) => Object.hasOwn(types, column) diff --git a/tests/column-names.clickhouse.test.ts b/tests/column-names.clickhouse.test.ts new file mode 100644 index 0000000..68776d5 --- /dev/null +++ b/tests/column-names.clickhouse.test.ts @@ -0,0 +1,132 @@ +// Renamed and cased columns on a live ClickHouse: the table is created from its +// own DDL, and every statement runs, so a test passes only if each place that +// writes an identifier wrote the database name and every row decodes under its +// key. Creates its tables in a throwaway database. + +import { ClickhouseClient } from "@effect/sql-clickhouse" +import { DateTime, Effect } from "effect" +import { describe, expect, it } from "vitest" +import * as CH from "@maple-dev/effect-orm/clickhouse" +import * as Db from "@maple-dev/effect-orm/database" +import * as S from "@maple-dev/effect-orm/schema" +import { endpoint } from "./clickhouse-support" + +const user = process.env.EFFECT_ORM_CLICKHOUSE_USER ?? "default" +const password = process.env.EFFECT_ORM_CLICKHOUSE_PASSWORD ?? "" + +const withDatabase = (body: (db: Db.DatabaseApi, database: string) => Effect.Effect) => + Effect.gen(function* () { + const database = `eo_names_${Date.now()}_${Math.floor(Math.random() * 1e6)}` + const admin = yield* ClickhouseClient.ClickhouseClient + yield* admin.asCommand(admin.unsafe(`CREATE DATABASE ${database}`)) + return yield* Effect.gen(function* () { + const client = yield* ClickhouseClient.ClickhouseClient + return yield* body(Db.fromSqlClient(client, { dialect: CH.clickhouseDialect, command: client.asCommand }), database) + }).pipe( + Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database })), + Effect.ensuring(Effect.orDie(admin.asCommand(admin.unsafe(`DROP DATABASE IF EXISTS ${database} SYNC`)))), + ) + }).pipe(Effect.provide(ClickhouseClient.layer({ url: endpoint!, username: user, password, database: "default" }))) + +const SystemColumns = CH.table("system.columns", { + external: true, + columns: { database: CH.string, table: CH.string, name: CH.string, position: CH.uint64 }, +}) + +const Requests = CH.table("requests", { + casing: "snake_case", + columns: { + orgId: CH.string, + requestId: CH.uint32, + route: CH.column(CH.string, { name: "path" }), + statusCode: CH.column(CH.uint16, { default: 200 }), + createdAt: CH.column(CH.dateTime, { defaultExpr: "now()" }), + createdDay: CH.column(CH.string, { materialized: ($) => CH.formatDateTime($.createdAt, "%F") }), + }, + engine: CH.engine.mergeTree(), + orderBy: ["orgId", "requestId"], + tenantColumn: "orgId", +}) + +const RouteHits = CH.table("route_hits", { + columns: { orgId: CH.string, route: CH.string, hits: CH.uint64 }, + engine: CH.engine.summingMergeTree(), + orderBy: ["orgId", "route"], +}) + +const RouteHitsMv = CH.materializedView("route_hits_mv", { + to: RouteHits, + as: CH.from(Requests) + .select(($) => ({ orgId: $.orgId, route: $.route, hits: CH.count() })) + .groupBy("orgId", "route"), +}) + +describe("column names", () => { + describe.skipIf(!endpoint)("live ClickHouse", () => { + it("creates, writes, reads, updates and deletes under the keys", async () => { + const result = await Effect.runPromise( + withDatabase((db, database) => + Effect.gen(function* () { + for (const statement of S.renderSchema(yield* S.entitiesOf([Requests, RouteHits, RouteHitsMv]))) { + yield* db.execute({ sql: statement }) + } + yield* db.run( + CH.insertInto(Requests).values([ + { orgId: "o1", requestId: 1, route: "/a", createdAt: new Date("2026-01-02T03:04:05Z") }, + { orgId: "o1", requestId: 2, route: "/a", statusCode: 503 }, + { orgId: "o2", requestId: 3, route: "/b" }, + ]), + ) + yield* db.run( + CH.update(Requests) + .set({ statusCode: 500 }) + .where(($) => [$.orgId.eq(CH.param.string("org")), $.requestId.eq(1)]) + .settings({ mutations_sync: 2 }), + { org: "o1" }, + ) + yield* db.run( + CH.deleteFrom(Requests).where(($) => [$.orgId.eq("o2")]).settings({ lightweight_deletes_sync: 2 }), + ) + const query = CH.from(Requests) + .select("requestId", "route", "statusCode", "createdAt", "createdDay") + .where(($) => [$.orgId.eq(CH.param.string("org"))]) + .orderBy(["requestId", "asc"]) + const rows = yield* db.run(query, { org: "o1" }) + const hits = yield* db.run( + CH.from(RouteHits) + .select(($) => ({ orgId: $.orgId, route: $.route, hits: CH.sum($.hits) })) + .groupBy("orgId", "route") + .orderBy(["orgId", "asc"]), + ) + const columns = yield* db.run( + CH.from(SystemColumns) + .select("name") + .where(($) => [$.database.eq(database), $.table.eq("requests")]) + .orderBy(($) => [[$.position, "asc"]]), + ) + return { rows, hits, columns, scope: CH.compileUnsafe(query, { org: "o1" }).tenantScope } + }), + ), + ) + expect(result.columns.map((column) => column.name)).toEqual([ + "org_id", + "request_id", + "path", + "status_code", + "created_at", + "created_day", + ]) + expect(result.rows.map(({ requestId, route, statusCode }) => ({ requestId, route, statusCode }))).toEqual([ + { requestId: 1, route: "/a", statusCode: 500 }, + { requestId: 2, route: "/a", statusCode: 503 }, + ]) + expect(DateTime.formatIso(result.rows[0]!.createdAt)).toBe("2026-01-02T03:04:05.000Z") + expect(result.rows[0]!.createdDay).toBe("2026-01-02") + expect(result.hits).toEqual([ + { orgId: "o1", route: "/a", hits: 2 }, + { orgId: "o2", route: "/b", hits: 1 }, + ]) + expect(result.scope).toBe("single-tenant") + }) + }) +}) From 24aff1d5d79f26978f891f67030cf42774521d11 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:27:26 +0200 Subject: [PATCH 2/2] docs: column names and casing Co-Authored-By: Claude Opus 5.5 --- .changeset/column-names-and-casing.md | 12 ++++++++ design/gap-review.md | 4 +-- docs/reference.md | 17 +++++++---- docs/tables-and-types.md | 42 +++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 .changeset/column-names-and-casing.md diff --git a/.changeset/column-names-and-casing.md b/.changeset/column-names-and-casing.md new file mode 100644 index 0000000..ef50b57 --- /dev/null +++ b/.changeset/column-names-and-casing.md @@ -0,0 +1,12 @@ +--- +"@maple-dev/effect-orm": minor +--- + +Column names apart from keys, on both dialects. `CH.column(type, { name: "org_id" })` now does +what `PG.column` already did: the column is stored as `org_id` and read, written, filtered and +indexed as its key. A new `casing` table option (`"snake_case"` or `"camelCase"`, the `Casing` +type) names every column that gives no `name` from its key, by drizzle-orm's rule: `orgId` is +`org_id`, `HTTPStatus` is `http_status`. The DDL, migrations, inserts, updates, upsert targets, +`returning` and tenant scope use the database name; rows decode under the key. A ClickHouse +materialized view whose target renames a column records a problem, since ClickHouse fills the +target by database name. diff --git a/design/gap-review.md b/design/gap-review.md index 852c5eb..11edb57 100644 --- a/design/gap-review.md +++ b/design/gap-review.md @@ -55,8 +55,8 @@ builder; **P1** commonly used; **P2** niche. Lateral joins, recursive and materialized CTEs, ROLLUP / CUBE, FETCH, MERGE, `db.batch`, TRUNCATE, DELETE USING, `DEFAULT VALUES`, expression conflict targets, schemas / namespaces, enums, -views, relations and a relational query API, casing (has to live in the builder: decoding reads -aliases exactly), a query cache, EXPLAIN on `Database`, controlled transactions, seeding, a studio, +views, relations and a relational query API, ~~casing (has to live in the builder: decoding reads +aliases exactly)~~ (built: the `casing` table option resolves names where the table is defined), a query cache, EXPLAIN on `Database`, controlled transactions, seeding, a studio, more dialects. ## Where effect-orm is ahead diff --git a/docs/reference.md b/docs/reference.md index abc2d46..a7118b3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -284,10 +284,15 @@ const Events = CH.table("events", { **`TableDefinition`** — `columns` (required), `engine` (required), `orderBy` (required for the MergeTree family; `[]` for `ORDER BY tuple()`), `partitionBy`, `primaryKey`, `ttl`, `settings`, -`indexes`, `comment`, `tenantColumn`. Key and expression options are `DdlKey` / `DdlExpr`: SQL +`indexes`, `comment`, `tenantColumn`, `casing`. Key and expression options are `DdlKey` / `DdlExpr`: SQL text, or a callback building it from the column accessor. -**`ExternalTableDefinition`** — `{ external: true, columns, tenantColumn? }`. A table the schema +**`Casing`** — `"snake_case"` or `"camelCase"`, the `casing` table option: the database name of +each column that gives no `name`, derived from its key by drizzle-orm's rule (`orgId` and +`HTTPStatus` become `org_id` and `http_status`). See +[Column names](./tables-and-types.md#column-names). + +**`ExternalTableDefinition`** — `{ external: true, columns, tenantColumn?, casing? }`. A table the schema does not own: a system table, a table function, a subquery, a CTE name, a table another tool migrates. No DDL, so `generate` never sees it; the name is written verbatim as the FROM target; `columns` may be empty. @@ -298,7 +303,8 @@ const Numbers = CH.table("numbers(10)", { external: true, columns: { number: CH. ``` **`ColumnOptions`** — at most one of `default` (a literal, encoded through the column's type), -`defaultExpr`, `materialized`, `alias`; plus `codec` and `comment`. They type inserts: a column +`defaultExpr`, `materialized`, `alias`; plus `name` (the column's name in SQL), `codec` and +`comment`. They type inserts: a column with `default` or `defaultExpr` may be left out (`DefaultedColumnsOf`), a `materialized` or `alias` column may not be written (`ComputedColumnsOf`). @@ -447,9 +453,10 @@ const Users = PG.table("users", { | `defaultForeignKeyName` | `(table, columns, foreignTable, foreignColumns) => string`: the name drizzle-kit gives, shortened past 63 characters as drizzle-kit does | **`TableDefinition`** — `columns` (required), `primaryKey` (column names, or -`{ columns, name? }`), `indexes`, `uniqueConstraints`, `foreignKeys`, `tenantColumn`. +`{ columns, name? }`), `indexes`, `uniqueConstraints`, `foreignKeys`, `tenantColumn`, `casing` +(a `Casing`, as on `/clickhouse`). -**`ExternalTableDefinition`** — `{ external: true, columns, tenantColumn? }`, as on +**`ExternalTableDefinition`** — `{ external: true, columns, tenantColumn?, casing? }`, as on `/clickhouse`: a view, a catalog table, one another tool migrates. No DDL. **`ColumnOptions`** — one of `default`, `defaultExpr`, `identity` (`"always"` or diff --git a/docs/tables-and-types.md b/docs/tables-and-types.md index 23b0795..0749298 100644 --- a/docs/tables-and-types.md +++ b/docs/tables-and-types.md @@ -56,15 +56,50 @@ const Users = PG.table("users", { }) ``` -A column's key is its name in SQL unless `PG.column` gives it another: `orgId: PG.column(PG.text, -{ name: "org_id" })` is stored as `org_id` and read, written, filtered and indexed as `orgId` -everywhere else, as drizzle's `text("org_id")` is. Rows decode under the key. +A column's key is its name in SQL unless the column or the table gives it another; see +[Column names](#column-names). A Postgres column is `NOT NULL` unless its type is `PG.nullable(...)`. An enum column's type is a `PG.enumType("status", ["open", "closed"])`, which `generate` creates as a type of its own. See [Postgres](./postgres.md) for its types and enums, and [migrations](./migrations.md#postgres) for its indexes, unique constraints and foreign keys. +## Column names + +A table's first argument is its name in the database, and the variable that holds it is its name in +TypeScript, so a Prisma `@@map("users")` model is `const User = PG.table("users", …)`. A column's +key is its name in SQL unless the column or the table gives it another: + +- `name` on `column` sets one column's database name, as Prisma's `@map` and drizzle's + `text("org_id")` do. `orgId: PG.column(PG.text, { name: "org_id" })` (or `CH.column(CH.string, + { name: "org_id" })`) is stored as `org_id`, and read, written, filtered and indexed as `orgId`. +- `casing` on the table names every column that gives no `name`. `"snake_case"` stores `orgId` as + `org_id` and `HTTPStatus` as `http_status`, by drizzle-orm's rule, so a schema drizzle-kit made + with `casing: "snake_case"` keeps its names. `"camelCase"` goes the other way. + +```ts +const Members = PG.table("members", { + casing: "snake_case", + columns: { + orgId: PG.text, // "org_id" + displayName: PG.column(PG.text, { name: "label" }), // "label": `name` wins + createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }), // "created_at" + }, + primaryKey: ["orgId", "displayName"], + tenantColumn: "orgId", +}) +``` + +Every definition option (keys, indexes, foreign keys, `tenantColumn`) and every query, insert, +update, upsert target and `returning` takes the key; the SQL, the DDL and the migrations +`generate` writes use the database name. Rows decode under the key: a select writes +`"org_id" AS "orgId"`, so decoding reads the alias it wrote. The names are fixed where the table +is defined, so there is no global casing; a project that wants one wraps `table` in its own +function. A ClickHouse materialized view fills its target by database name, so its target table +cannot rename a column: the view records a problem if it does. + +_(Backed by `docs/tables-and-types.md > Column names`.)_ + ## Keys, defaults, and tenancy `column(type, options)` wraps a column type with what a bare type cannot say. The options also @@ -77,6 +112,7 @@ decide the insert row type, so it is derived from the DDL rather than declared t | `materialized: "…"` | `MATERIALIZED …` | not writable | | `alias: "…"` | `ALIAS …` | not writable | | `codec`, `comment` | `CODEC(…)`, `COMMENT` | unchanged | +| `name: "org_id"` | the column's name | unchanged | Postgres columns take `default`, `defaultExpr`, and `identity` (`"always"` or `"by default"`); each makes the column optional on insert. They also take `unique` (`true`, a constraint name, or