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
3 changes: 2 additions & 1 deletion docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ Each `${value}` renders as the rest of the builder renders it:
| a column, expression, or another template | its SQL |
| a `param.*` | a placeholder: bound on Postgres, a literal on ClickHouse |
| a builder query | `(subquery)`, compiled with the outer query, its tenant scope counted |
| a string, number, boolean, `Date`, `DateTime.Utc`, `null` | the dialect's escaped literal |
| a string | bound on Postgres (`$n`), the escaped literal on ClickHouse |
| a number, boolean, `Date`, `DateTime.Utc`, `null` | the dialect's escaped literal |
| `CH.sql.ident(name)` | the name quoted by the dialect; plain names only, dotted for `schema.table` |
| `CH.sql.raw(text)` | the text as-is — never from input |
| `CH.sql.join(values, separator?)` | each value rendered, joined by `", "` or `separator`; not parenthesized, so it fits `IN (${…})`; an empty list fails the compile |
Expand Down
4 changes: 3 additions & 1 deletion docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ are not modeled yet; write them in a `--custom` migration, and declare a view yo
(`ALTER COLUMN`), and a primary key, an index or a foreign key by dropping and re-creating it,
so `generate` reports nothing as unsupported. A type change is labeled `rewrite`: Postgres
rewrites the table under an exclusive lock. Drops still need confirmation, and renames still
read as a drop and an add. Generated files carry `"dialect": "postgres"`.
read as a drop and an add. Generated files carry `"dialect": "postgres"`. With `emit: "sql"` in
the config, `generate` writes the rendered statements to `migration.sql` instead, each under a
comment naming its label, so a tool that applies drizzle-kit folders applies the migration too.

**Applying.** Each migration runs in one transaction with its ledger row, under a
transaction-scoped advisory lock, so concurrent deploys wait for each other rather than
Expand Down
24 changes: 22 additions & 2 deletions docs/postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ const query = PG.from(Requests)
.orderBy(["count", "desc"])

export const compiled = PG.compileUnsafe(query, { orgId: "org_1", since: new Date("2026-01-01T00:00:00Z") })
// compiled.sql: ... WHERE "requests"."OrgId" = $1 AND "requests"."At" >= $2 ...
// compiled.parameters: ["org_1", "2026-01-01T00:00:00.000Z"]
// compiled.sql: ... FILTER (WHERE "requests"."DurationMs" >= $1) ...
// ... WHERE "requests"."OrgId" = $2 AND "requests"."At" >= $3 ...
// compiled.parameters: [500, "org_1", "2026-01-01T00:00:00.000Z"]

const db = new PGlite()
// The CREATE TABLE comes from the definition itself; migrations.md shows the managed way.
Expand Down Expand Up @@ -67,6 +68,7 @@ insert may leave it out: `PG.InsertRowOf<typeof Requests>` makes it optional. Se
| Identifiers | Bare: `events.OrgId` | Quoted: `"events"."OrgId"` |
| String literals | Backslash escapes: `'it\'s'` | Doubled quotes: `'it''s'` |
| Params | Written in as literals; `parameters` empty | Bound as `$1`, `$2`, … in `parameters` |
| Values compared with a column, `LIKE` patterns | Written in as literals | Bound too, one param per distinct value |
| `param.bool` | `1` / `0` | `true` / `false` |
| `param.dateTime` | `'2026-01-01 00:00:00'` (UTC, zoneless) | `'2026-01-01T00:00:00.000Z'` |
| `GROUP BY` keys | Select aliases | Select-list positions (`GROUP BY 1`) |
Expand All @@ -77,6 +79,11 @@ Postgres reads a bare name in `GROUP BY` as an input column before a select alia
`select({ Service: lower($.Service) }).groupBy("Service")` would group by the raw column. Writing
the position instead keeps ClickHouse's meaning.

A value compared with a column (`$.email.eq(email)`, `in_`, `between`, a `LIKE` pattern) is
bound like a param, so the statement text carries no values: it stays out of logs and traces, and
one query shape is one statement. Two exceptions stay literals: an `onConflict*` `targetWhere`,
which Postgres matches against a partial index's predicate as written, and DDL.

Every string that reaches the SQL as a literal is escaped for Postgres. A value that spells the
param marker `__PARAM_` is written as an `E'…'` string with the marker hex-escaped, and a
literal that still contained it would fail the compile with `InvalidLiteral`.
Expand All @@ -89,6 +96,7 @@ literal that still contained it would fail the compile with `InvalidLiteral`.
| `bool` | `boolean` | `boolean` | boolean |
| `int2`, `int4`, `int8`, `float4`, `float8`, `numeric` | same | `number` | number, numeric string, `bigint` |
| `timestamptz` | `timestamptz` | `DateTime.Utc` | `Date`, or text such as `2026-01-01 00:00:00+00` |
| `timestamptzMillis` | `timestamptz` | epoch milliseconds (`number`) | the same |
| `jsonb(schema?)` | `jsonb` | the schema's type (`unknown` by default) | a parsed value |
| `array(type)` | `type[]` | `ReadonlyArray` | array |
| `nullable(type)` | the same type | `T \| null` | the same, or `null` |
Expand All @@ -103,6 +111,10 @@ both as a `bigint`. A `timestamptz`
compared against a `Date`, a `DateTime.Utc` or a string is written as an ISO-8601 instant,
which no session time zone can reinterpret; a zoneless string is read as UTC.

Comparisons with a literal-union column take only its members: with `status` typed
`"open" | "closed"`, `$.status.eq("opne")` is a type error. A param of the primitive
(`param.string`) still compares, for a value known only at run time.

## Functions

| Function | SQL | Notes |
Expand All @@ -118,6 +130,14 @@ which no session time zone can reinterpret; a zoneless string is read as UTC.
| `lower`, `upper`, `length` | same | |
| `coalesce(x, fallback)` | `coalesce(x, fallback)` | No longer nullable |
| `jsonText(x, key)` | `(x ->> key)` | `null` when absent |
| `greatest(a, ...)`, `least(a, ...)` | same | NULL arguments are skipped |
| `caseWhen([[c, v], ...], otherwise)` | `CASE WHEN c THEN v ... ELSE otherwise END` | |
| `asBoolean(c)` | `(c)` | A condition as a value, to select or `set` |
| `typedValue(type, value)` | a bound param | Encoded as `type` writes it: `typedValue(T.columns.at, ms)` |

`undecoded($.column)` reads a column without its codec: a jsonb document as `unknown`, a branded
id as its string. Use it where the reader decodes stored values itself (to tolerate an older
document shape, or to name the bad row in its own error); every other read stays typed.

The shared operators (`eq`, `in_`, `like`, `ilike`, `and`, `or`, `not`, arithmetic, `lit`) work
unchanged. `/postgres` exports only functions Postgres has, plus `nullIf`, which renders the
Expand Down
21 changes: 19 additions & 2 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ _(Backed by `docs/queries.md > Queries are immutable`.)_

## `select`

Two forms.
Three forms.

**Every column** — `select()` with no arguments reads every column of the FROM table, under
its key, as drizzle's bare `select()` does:

```ts
CH.from(Events).select()
// SELECT Name AS Name, DurationMs AS DurationMs, ... FROM events
```

**By column name** — output keys match the column names:

Expand All @@ -36,7 +44,9 @@ CH.from(Events).select(($) => ({
}))
```

The object keys become the SQL aliases _and_ the keys of the output row type. `select` is
The object keys become the SQL aliases _and_ the keys of the output row type. Spreading `$`
gives every column of the FROM table under its key, so `select(($) => ({ ...$, lowered:
CH.lower($.Name) }))` reads them all plus one more; `returning` takes the same. `select` is
required: compiling without one raises `QueryBuilderDefect`, which stays a defect — no request
value can remove a `select()`.

Expand Down Expand Up @@ -116,6 +126,13 @@ Takes `[column, direction]` tuples, one per sort key:
// ORDER BY count DESC, name ASC
```

To sort by something not selected, pass a callback returning `[expression, direction]` pairs;
it reads the source's columns as `where` does:

```ts
.orderBy(($) => [[$.Timestamp, "desc"], [CH.lower($.Name), "asc"]])
```

Passing two bare strings, `.orderBy("count", "desc")`, is a type error.
Untyped callers receive `QueryBuilderDefect`; use a tuple for each sort key.

Expand Down
7 changes: 5 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ time; see [Params and compilation](./params-and-compilation.md#what-each-kind-ac
| `when(value, fn)` | `Condition \| undefined`; skips `undefined`/`null`/`false` |
| `whenTrue(flag, fn)` | Boolean-gated variant |
| `inList(expr, values)` | `expr IN ('a', 'b')` |
| `undecoded(column)` | the column as the driver sends it, typed as its wire form |
| `inExprList(expr, exprs)` | Same for expression lists |
| `notInList(expr, values)` | `expr NOT IN ('a', 'b')` |
| `not(condition)` | `NOT (…)` |
Expand Down Expand Up @@ -450,7 +451,7 @@ const Users = PG.table("users", {
### Column types

`text`, `uuid`, `bool`, `int2`, `int4`, `int8`, `float4`, `float8`, `numeric`, `timestamptz`,
`jsonb(schema?)`, `array(type)`, `nullable(type)`, `custom(sql, schema, literalSchema?)`,
`timestamptzMillis` (a timestamptz read as epoch milliseconds), `jsonb(schema?)`, `array(type)`, `nullable(type)`, `custom(sql, schema, literalSchema?)`,
`brand(type, schema)`. See [Postgres column types](./postgres.md#column-types).

Types: `PgType` (a Postgres column type; a `CHType`), `PgArray`, `PgNullable`. Codecs:
Expand All @@ -463,7 +464,9 @@ which normalizes Postgres timestamp text to ISO-8601.
`count()`, `countDistinct(x)`, `countIf(c)`, `sum(x)`, `sumIf(x, c)`, `avg(x)`, `min(x)`,
`max(x)`, `percentileCont(f, x)`, `arrayAgg(x)`, `dateTrunc(unit, ts)` (`DateTruncUnit` is the
unit union), `dateBin(seconds, ts)`, `now()`, `lower(x)`, `upper(x)`, `length(x)`,
`coalesce(x, fallback)`, `nullIf(x, value)`, `jsonText(x, key)`. See
`coalesce(x, fallback)`, `nullIf(x, value)`, `jsonText(x, key)`, `greatest(a, ...)`, `least(a, ...)`,
`caseWhen([[c, v], ...], otherwise)`, `asBoolean(c)` (a condition as a value) and
`typedValue(type, value)` (a value bound as a column type writes it). See
[Postgres functions](./postgres.md#functions) for the SQL each writes.

### Dialect
Expand Down
4 changes: 4 additions & 0 deletions docs/tables-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ 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 Postgres column is `NOT NULL` unless its type is `PG.nullable(...)`. See
[Postgres](./postgres.md) for its types and [migrations](./migrations.md#postgres) for its
indexes and foreign keys.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@maple-dev/effect-orm",
"version": "0.4.0",
"version": "0.5.0",
"description": "Type-safe ClickHouse and Postgres queries, result decoding, and reproducible benchmarks for Effect and TypeScript",
"keywords": [
"clickhouse",
Expand Down Expand Up @@ -116,4 +116,4 @@
"patchedDependencies": {
"@effect/vitest@4.0.0": "patches/@effect%2Fvitest@4.0.0.patch"
}
}
}
4 changes: 2 additions & 2 deletions scripts/check-doc-examples.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ const fragments = await import("@maple-dev/effect-orm/sql")
assert.equal(escapedSql.predicate, "Name = " + fragments.compile(fragments.str("O'Reilly")))
assert.doesNotMatch(escapedSql.predicate, /\\[object Object\\]/)
const postgres = await import("./postgres-quickstart")
assert.deepEqual(postgres.compiled.parameters, ["org_1", "2026-01-01T00:00:00.000Z"])
assert.ok(sql(postgres.compiled).includes('"requests"."OrgId" = $1 AND "requests"."At" >= $2'))
assert.deepEqual(postgres.compiled.parameters, [500, "org_1", "2026-01-01T00:00:00.000Z"])
assert.ok(sql(postgres.compiled).includes('"requests"."OrgId" = $2 AND "requests"."At" >= $3'))
assert.deepEqual(postgres.rows, [
{ route: "/checkout", count: 2, slow: 1, p50: 510 },
{ route: "/search", count: 1, slow: 0, p50: 40 },
Expand Down
6 changes: 5 additions & 1 deletion src/ch/brand.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ 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.
// A literal union compares against its own members, or a param of its primitive checked by the server.
CH.from(Dashboards).select("id").where(($) => [$.status.eq("open"), $.status.eq(CH.param.string("s"))])
// @ts-expect-error not a member of the union
CH.from(Dashboards).select("id").where(($) => [$.status.eq("opne")])
// @ts-expect-error nor in a list
CH.from(Dashboards).select("id").where(($) => [$.status.in_("open", "opne")])
// String operators still work on a branded string.
CH.from(Dashboards).select("id").where(($) => [$.org_id.like("org_%")])

Expand Down
Loading
Loading