Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/make-repository.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maple-dev/effect-orm": minor
---

`makeRepository(table, { idColumn, tenantColumn })` in `/database` builds the plain CRUD of a table on the `Database` in context, as `SqlModel.makeRepository` does for a `Model.Class`: `insert`, `insertVoid`, `update`, `updateVoid`, `findById` and `delete`. `findById` and `update` return an `Option`. Inserts take the `tableSchemas(table).insert` row and updates its `update` row. A table with a tenant column gets a `TenantRepository`: every method takes the tenant first, writes or filters the tenant column from it, and compiles as `"single-tenant"`, so a call cannot read or write another tenant's rows. A patch cannot change the id or the tenant column. On ClickHouse, which has no `RETURNING`, use `insertVoid`, `updateVoid` (a mutation that waits), `findById` and `delete` (a lightweight delete that waits).
5 changes: 5 additions & 0 deletions .changeset/table-row-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maple-dev/effect-orm": minor
---

`tableSchemas(table)` derives the Effect Schemas of a table's rows from its columns, on both `/clickhouse` and `/postgres`: `select` (every column, decoded from the wire), `insert` (computed columns left out, defaulted and nullable ones optional), `update` (every writable column optional), and `json`, `jsonCreate` and `jsonUpdate`, the same rows as plain JSON for an HTTP API. The names follow `Model.Class`. A decoded `insert` or `update` row is a row `insertInto(...).values` or `update(...).set` takes, so request bodies and repositories no longer need Schemas written by hand next to the table.
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Roughly in reading order.
| [Extending the DSL](./extending.md) | `defineFn`, raw escape hatches, handwritten SQL |
| [Postgres](./postgres.md) | The Postgres dialect, its column types and functions |
| [Schema and migrations](./migrations.md) | DDL from `CH.table` / `PG.table`, `materializedView`, `effect-orm generate`, applying migrations, adopting drizzle-kit |
| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry |
| [Statements and transactions](./database.md) | `Database` over your `SqlClient`: `run`, `execute`, `transaction`, retry, repositories |

## Reference

Expand Down
75 changes: 75 additions & 0 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,81 @@ Db.run(query, params).pipe(Db.withQuerySummary("dashboard.revenue_by_day"))

The table is also on the compiled query, as `compiled.collection`.

## Repositories

`makeRepository(table, options)` builds the plain CRUD of a table on the `Database` in
context, as `SqlModel.makeRepository` does for a `Model.Class`. The row types come from
[`tableSchemas(table)`](./tables-and-types.md#row-schemas): `insert` takes its `insert` row and
`update` its `update` row.

```ts
import * as Db from "@maple-dev/effect-orm/database"
import * as PG from "@maple-dev/effect-orm/postgres"
import { Effect, Option } from "effect"

const Tickets = PG.table("tickets", {
columns: {
id: PG.column(PG.int4, { identity: "always" }),
org: PG.text,
title: PG.text,
note: PG.nullable(PG.text),
},
primaryKey: ["id"],
tenantColumn: "org",
})

const program = Effect.gen(function* () {
const tickets = yield* Db.makeRepository(Tickets, { idColumn: "id", tenantColumn: "org" })
const ticket = yield* tickets.insert("acme", { title: "Board" }) // the stored row, with its id
yield* tickets.update("acme", ticket.id, { note: "moved" }) // Option of the changed row
yield* tickets.findById("other", ticket.id) // Option.none(): not this tenant's row
yield* tickets.delete("acme", ticket.id)
})
```

| Method | Does |
| ---------------------------- | ------------------------------------------------------------ |
| `insert(row)` | INSERT ... RETURNING, and gives back the stored row |
| `insertVoid(row)` | INSERT |
| `update(id, patch)` | UPDATE ... RETURNING: `Option` of the changed row |
| `updateVoid(id, patch)` | UPDATE |
| `findById(id)` | SELECT ... LIMIT 1: `Option` of the row |
| `delete(id)` | DELETE |

`idColumn` names the column a row is found by. A patch cannot set it. A key left out of a
patch (or `undefined`) keeps its value, as in [`set`](./updates-and-deletes.md#set). A patch
with no keys is a `QueryBuilderError`.

### Tenant tables

When the table declares a [tenant column](./tenant-scoping.md), the repository is a
`TenantRepository`, and every method takes the tenant first:

- `insert` and `insertVoid` write the tenant column from that argument. The row type has no
tenant column.
- `update`, `updateVoid`, `findById` and `delete` filter on the tenant column and the id. A row
of another tenant is not found, not changed and not deleted. A patch cannot set the tenant
column, so it cannot move a row to another tenant.
- Each statement compiles as `"single-tenant"`, so a `Database` with `requireTenantScope` runs
it.

A method called without the tenant does not type-check. The `Table` type does not carry its
tenant column, so `makeRepository` checks `tenantColumn` against the table when it is built: a
tenant table without it, or a `tenantColumn` on a table without one, is a `QueryBuilderDefect`.

### ClickHouse

ClickHouse has no `RETURNING`, so `insert` and `update` are defects there
(`QueryBuilderDefect`), and nothing is written. Use `insertVoid` and `updateVoid`, and
`findById` to read the row. `updateVoid` is a mutation with `mutations_sync = 2`, and `delete`
a lightweight delete with `lightweight_deletes_sync = 2`: both wait, so a `findById` after them
reads what they wrote. Mutations rewrite whole parts, so keep `updateVoid` for occasional
corrections, as [Updating and deleting rows](./updates-and-deletes.md#clickhouse) says.
`findById` reads without `FINAL`, so on a `ReplacingMergeTree` it can return an older version.

_(Backed by `src/database/repository.test.ts`, `src/database/repository.test-d.ts` and
`tests/repository.clickhouse.test.ts`.)_

## Writes

The builder compiles SELECTs, [INSERTs](./inserts.md) and
Expand Down
5 changes: 4 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Everything in this section is exported, identically, from both `/clickhouse` and
| `unionAll` | `(...queries) => CHUnionQuery` |
| `update` | `(table) => CHUpdateStart`, then `CHUpdate`: `.set(record \| fn)`, `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)`. See [Updating and deleting](./updates-and-deletes.md) |
| `deleteFrom` | `(table) => CHDelete`: `.where(fn)` or `.allRows()`, `.returning(...)`, `.settings(record)` |
| `tableSchemas` | `(table) => TableSchemas`: the `select`, `insert`, `update`, `json`, `jsonCreate` and `jsonUpdate` row Schemas. See [Row Schemas](./tables-and-types.md#row-schemas) |
| `insertInto` | `(table) => CHInsertStart`, then `CHInsert`; `.values(row \| rows)` or `.select(query)` sets its rows, `.settings(record)` ClickHouse `SETTINGS`, `.returning(...)` the RETURNING list, `.onConflictDoNothing(options?)` / `.onConflictDoUpdate(options)` the ON CONFLICT clause (Postgres). See [Inserting rows](./inserts.md) |

Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or
Expand Down Expand Up @@ -179,7 +180,8 @@ decoded type of a column), `InferEncoded` (its wire type), `ColumnDefs`, `Nullab
`OutputToColumnDefs`.

**Inference** — `InferOutput`, `InferQueryOutput`, `InferUnionOutput`, `SelectRowOf`,
`InsertRow`, `InsertRowOf`, `UpdateSet`, `UpdateSetOf`.
`InsertRow`, `InsertRowOf`, `UpdateSet`, `UpdateSetOf`, `TableSchemas` (what `tableSchemas`
returns for a table).

**What `compile` checks** — `NeedsSelect<Output>` (the query has a SELECT list) and
`ParamsSatisfied<Params, Given>` (the params object fills every required param). A helper generic
Expand Down Expand Up @@ -513,6 +515,7 @@ placeholders, double-quoted identifiers.
| `run`, `status`, `verify`, `baseline`, `MigrationDriver` and the migrate errors | `/migrate` |
| `Database`, `run`, `sql`, `query`, `execute`, `transaction`, `requireTransaction`, `retryContention`, `Transaction` | `/database` |
| `DatabaseError`, `TransactionCommitFailed`, `TransactionRollbackFailed` and the other transaction errors | `/database` |
| `makeRepository`, `Repository`, `TenantRepository`, `RepositoryOf`, `RepositoryOptions`, `RepositoryError` — CRUD derived from a table, tenant-pinned | `/database` |
| `withQuerySummary`, `QuerySummary` — the `db.query.summary` of the statements an effect runs, for spans | `/database` |
| `ClickhouseHttpClient`, `layer`, `make`, `ClickhouseHttpClientConfig` — a ClickHouse `SqlClient` over `fetch`, for Workers | `/clickhouse-http` |
| `defineConfig`, `generate`, `check`, `pull`, `loadSchema`, `readMigrations`, `analyze`, `KitError` | `/kit` |
Expand Down
28 changes: 28 additions & 0 deletions docs/tables-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,34 @@ Related utilities: `ColumnDefs` (the shape of a `columns` record), `OutputToColu
(converts a query's output row back into column defs, used by `fromQuery`), and
`NullableColumnDefs` (what `leftJoin` applies to the joined side).

## Row Schemas

`tableSchemas(table)` derives the Effect Schemas of a table's rows from its columns, so an
`HttpApi` endpoint or a repository uses the same definition as the queries. The variant names are
the ones `Model.Class` uses:

| Variant | Columns | Decodes from |
| ------------ | ---------------------------------------------------- | ------------------ |
| `select` | every column | the database wire |
| `insert` | no computed column; defaulted and nullable optional | the database wire |
| `update` | no computed column; every column optional | the database wire |
| `json` | as `select` | JSON |
| `jsonCreate` | as `insert` | JSON |
| `jsonUpdate` | as `update` | JSON |

The JSON variants are `Schema.toCodecJson` of each row's type, so a `DateTime` is an ISO string
and a branded column keeps its brand. An optional key also takes `undefined`, as in an insert row.

```ts
const { select, jsonCreate } = CH.tableSchemas(Requests)

type Request = typeof select.Type // SelectRowOf<typeof Requests>
const body = Schema.decodeUnknownSync(jsonCreate)({ OrgId: "org_1", Route: "/" })
CH.insertInto(Requests).values(body) // a decoded body is an insert row
```

_(Backed by `docs/tables-and-types.md > Row Schemas`.)_

## Map columns

`Map` columns get a `.get(key)` accessor that compiles to ClickHouse's bracket syntax:
Expand Down
65 changes: 65 additions & 0 deletions src/ch/table-schemas.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Type-level tests: the row Schemas of a table

import { type DateTime, Schema } from "effect"
import { expectTypeOf } from "expect-type"
import * as CH from "./index"
import * as CHD from "../clickhouse"

const Spans = CHD.table("spans", {
columns: {
OrgId: CH.string,
Id: CHD.column(CH.uint64, { default: 0 }),
At: CH.dateTime,
Note: CH.nullable(CH.string),
Day: CHD.column(CH.string, { materialized: "toString(toDate(At))" }),
},
engine: CHD.engine.mergeTree(),
orderBy: ["OrgId", "At"],
})

const schemas = CH.tableSchemas(Spans)

// select is the whole decoded row, as SelectRowOf names it.
expectTypeOf<typeof schemas.select.Type>().toEqualTypeOf<CH.SelectRowOf<typeof Spans>>()

// insert: defaulted and nullable columns optional, computed ones absent.
expectTypeOf<typeof schemas.insert.Type>().toEqualTypeOf<{
readonly OrgId: string
readonly At: DateTime.Utc
readonly Id?: number | undefined
readonly Note?: string | null | undefined
}>()

// A decoded insert row is a row `values` takes.
declare const created: typeof schemas.insert.Type
CH.insertInto(Spans).values(created)

// update: every writable column optional.
expectTypeOf<typeof schemas.update.Type>().toEqualTypeOf<{
readonly OrgId?: string | undefined
readonly Id?: number | undefined
readonly At?: DateTime.Utc | undefined
readonly Note?: string | null | undefined
}>()
declare const patch: typeof schemas.update.Type & { readonly Id: number }
CH.update(Spans).set(patch).allRows()

// The json variants keep the row types and encode to JSON.
expectTypeOf<typeof schemas.json.Type>().toEqualTypeOf<CH.SelectRowOf<typeof Spans>>()
expectTypeOf<typeof schemas.json.Encoded>().toEqualTypeOf<Schema.Json>()
expectTypeOf<typeof schemas.jsonCreate.Type>().toEqualTypeOf<typeof schemas.insert.Type>()
expectTypeOf<typeof schemas.jsonUpdate.Type>().toEqualTypeOf<typeof schemas.update.Type>()

// The select Schema's wire side is what the database sends.
expectTypeOf<typeof schemas.select.Encoded.Id>().toEqualTypeOf<number | string>()

// A table written with the bare `table` and no options: every non-null column required.
const Plain = CH.table("plain", { A: CH.string, B: CH.nullable(CH.uint32) })
expectTypeOf<CH.TableSchemas<typeof Plain>["insert"]["Type"]>().toEqualTypeOf<{
readonly A: string
readonly B?: number | null | undefined
}>()

// A computed column is not part of an insert row.
// @ts-expect-error Day is computed
const withDay: typeof schemas.insert.Type = { OrgId: "o", At: {} as DateTime.Utc, Day: "2026-01-01" }
106 changes: 106 additions & 0 deletions src/ch/table-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "@effect/vitest"
import { DateTime, Schema } from "effect"
import * as CH from "./index"
import * as CHD from "../clickhouse"
import * as PG from "../postgres"

const Spans = CHD.table("spans", {
columns: {
OrgId: CH.string,
Id: CHD.column(CH.uint64, { default: 0 }),
At: CH.dateTime,
Note: CH.nullable(CH.string),
Day: CHD.column(CH.string, { materialized: "toString(toDate(At))" }),
},
engine: CHD.engine.mergeTree(),
orderBy: ["OrgId", "At"],
})

describe("tableSchemas", () => {
it("select decodes a whole row from the wire, as a SELECT of every column does", () => {
const { select } = CH.tableSchemas(Spans)
const row = Schema.decodeUnknownSync(select)({
OrgId: "o",
Id: "7",
At: "2026-01-01 00:00:00",
Note: null,
Day: "2026-01-01",
})
expect(row).toEqual({
OrgId: "o",
Id: 7,
At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
Note: null,
Day: "2026-01-01",
})
})

it("insert makes defaulted and nullable columns optional and leaves computed ones out", () => {
const { insert } = CH.tableSchemas(Spans)
expect(Object.keys(insert.fields)).toEqual(["OrgId", "Id", "At", "Note"])
const decode = Schema.decodeUnknownSync(insert)
expect(decode({ OrgId: "o", At: "2026-01-01 00:00:00" })).toEqual({
OrgId: "o",
At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
})
expect(decode({ OrgId: "o", At: "2026-01-01 00:00:00", Id: undefined, Note: null })).toEqual({
OrgId: "o",
At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
Id: undefined,
Note: null,
})
expect(() => decode({ OrgId: "o" })).toThrow(/At/)
})

it("update makes every writable column optional and leaves computed ones out", () => {
const { update } = CH.tableSchemas(Spans)
expect(Object.keys(update.fields)).toEqual(["OrgId", "Id", "At", "Note"])
const decode = Schema.decodeUnknownSync(update)
expect(decode({})).toEqual({})
expect(decode({ Id: "8", Note: null })).toEqual({ Id: 8, Note: null })
expect(() => decode({ OrgId: null })).toThrow(/OrgId/)
})

it("json writes the select row in plain JSON and reads it back", () => {
const { json } = CH.tableSchemas(Spans)
const row = { OrgId: "o", Id: 7, At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), Note: null, Day: "2026-01-01" }
const encoded = Schema.encodeSync(json)(row)
expect(encoded).toEqual({ OrgId: "o", Id: 7, At: "2026-01-01T00:00:00.000Z", Note: null, Day: "2026-01-01" })
expect(Schema.decodeUnknownSync(json)(JSON.parse(JSON.stringify(encoded)))).toEqual(row)
})

it("jsonCreate and jsonUpdate read request bodies with the insert and update keys", () => {
const { jsonCreate, jsonUpdate } = CH.tableSchemas(Spans)
expect(Schema.decodeUnknownSync(jsonCreate)({ OrgId: "o", At: "2026-01-01T00:00:00.000Z", Note: null })).toEqual({
OrgId: "o",
At: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
Note: null,
})
expect(() => Schema.decodeUnknownSync(jsonCreate)({ OrgId: "o" })).toThrow(/At/)
expect(Schema.decodeUnknownSync(jsonUpdate)({ Note: "n" })).toEqual({ Note: "n" })
expect(Schema.decodeUnknownSync(jsonUpdate)({ Day: "2026-01-01" })).toEqual({})
})

it("reads a Postgres table: identity and default columns are optional on insert, keys are the column keys", () => {
const Requests = PG.table("requests", {
columns: {
id: PG.column(PG.int8, { identity: "always" }),
orgId: PG.column(PG.text, { name: "org_id" }),
createdAt: PG.column(PG.timestamptz, { defaultExpr: "now()" }),
body: PG.nullable(PG.jsonb<{ readonly path: string }>()),
},
primaryKey: ["id"],
})
const { insert, json } = PG.tableSchemas(Requests)
expect(Schema.decodeUnknownSync(insert)({ orgId: "o" })).toEqual({ orgId: "o" })
expect(() => Schema.decodeUnknownSync(insert)({})).toThrow(/orgId/)
expect(
Schema.encodeSync(json)({
id: 1,
orgId: "o",
createdAt: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
body: { path: "/" },
}),
).toEqual({ id: 1, orgId: "o", createdAt: "2026-01-01T00:00:00.000Z", body: { path: "/" } })
})
})
Loading