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
12 changes: 12 additions & 0 deletions .changeset/column-names-and-casing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@maple-dev/effect-orm": minor
---

Column names apart from keys, on every dialect. `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), on `CH.table`, `PG.table` and `SQLite.table`, 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. Two keys that map to one database name are a definition problem.
4 changes: 2 additions & 2 deletions design/gap-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,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.
Expand All @@ -299,7 +304,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`).

Expand Down Expand Up @@ -448,9 +454,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
Expand Down
46 changes: 43 additions & 3 deletions docs/tables-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,54 @@ 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" })`, `SQLite.column(SQLite.text, { name: "org_id" })`) is stored as `org_id`,
and read, written, filtered and indexed as `orgId`.
- `casing` on the table (`CH.table`, `PG.table` or `SQLite.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. Two keys that end up with one database name (`orgId` and `OrgId` under `"snake_case"`)
are a definition problem; SQLite compares names without case, so there `Org_Id` and `org_id`
clash too. 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
Expand All @@ -77,6 +116,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
Expand Down
162 changes: 162 additions & 0 deletions src/ch/column-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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")
})

it("records a problem when a name or the casing gives two keys one database name", () => {
const Clash = CH.table("clash", {
casing: "snake_case",
columns: { orgId: CH.string, OrgId: CH.string, other: CH.column(CH.string, { name: "org_id" }) },
engine: CH.engine.mergeTree(),
orderBy: ["orgId"],
})
expect(Clash.problems.map((problem) => problem.message)).toEqual([
"org_id has the same SQL name as orgId",
"org_id has the same SQL name as orgId",
])
})
})
1 change: 1 addition & 0 deletions src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export {
type TableDefinition,
type ExternalTableDefinition,
} from "./schema/define"
export { type Casing } from "./schema/casing"

// ClickHouse functions (from category modules)
export {
Expand Down
21 changes: 21 additions & 0 deletions src/docs-examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/kit/sqlite-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ it("adds a nullable column, and a NOT NULL one with a literal default, with ALTE
await expectMigratedToEqualFresh(v2)
})

it("writes the database names a table's casing and column names give, in the first migration and in ADD COLUMN", async () => {
const cased = (columns: string) => `
export const Links = SQLite.table("links", {
casing: "snake_case",
columns: { linkId: SQLite.text, targetUrl: SQLite.column(SQLite.text, { name: "href" }), ${columns} },
primaryKey: ["linkId"],
indexes: [SQLite.index("links_href", ["targetUrl"])],
})`
const v1 = cased(`createdAt: SQLite.timestamp`)
await generateFrom(v1)
const [first] = folders()
expect(migrationSql(first!)).toContain(`"link_id" text PRIMARY KEY NOT NULL`)
expect(migrationSql(first!)).toContain(`"created_at" text NOT NULL`)
expect(migrationSql(first!)).toContain(`ON "links" ("href")`)
expect(migrationSql(first!)).not.toMatch(/"(linkId|targetUrl|createdAt)"/)
const v2 = cased(`createdAt: SQLite.timestamp, lastSeenAt: SQLite.nullable(SQLite.timestamp)`)
const second = await generateFrom(v2)
expect(second.plan.flatMap((p) => p.sql)).toEqual(['ALTER TABLE "links" ADD COLUMN "last_seen_at" text'])
await expectMigratedToEqualFresh(v2)
expect((await generateFrom(v2)).written).toBeUndefined()
})

const seedLinks = [`INSERT INTO "links" ("id", "url") VALUES ('a', 'https://a'), ('b', 'https://b')`]

it("drops a column only once the data loss is confirmed, and keeps the other columns' rows", async () => {
Expand Down
Loading
Loading