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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Add `update(table).set(...).where(...)` and `deleteFrom(table).where(...)` (see
`docs/updates-and-deletes.md`), with `returning` on Postgres and `settings` on ClickHouse,
where they compile to an `ALTER TABLE ... UPDATE` mutation and a lightweight `DELETE`. A write
with no `where()` is refused unless `allRows()` says so, and one whose conditions all came out
`undefined` fails. Tenant scope is derived from the WHERE. `CompiledQuery.kind` gains `update`
and `delete`; `DialectClauses.insertSettings` is renamed `writeSettings` (unreleased), and
`DialectClauses.alterTableUpdate` is added.
- `returning()` with no arguments returns every column, as in Drizzle. `insertInto(table)` now
returns `CHInsertStart`, which offers only `values` and `select`, so an insert without rows
no longer type-checks. Add `TableOptions.computed` for generated columns. `INSERT ... SELECT`
Expand All @@ -24,7 +31,7 @@
against the table at the type level. Tenant scope follows the read and where the written
tenant comes from.
- Add `settings(record)` to an insert (ClickHouse): `INSERT ... SETTINGS name = value`. Add
`DialectClauses.insertSettings`, optional.
`DialectClauses.writeSettings`, optional.
- Add `CompiledQuery.kind` (`"select"` or `"insert"`); `rawCompiledQuery` takes it as an option.
- Add `ParamStyle.maxParameters`; Postgres sets 65535, and a statement over it fails to compile.
- Add `@maple-dev/effect-orm/database`, opt-in (see `docs/database.md`): a `Database` over the
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Full guides live in [`docs/`](./docs/README.md):
| [Joins and subqueries](./docs/joins-and-subqueries.md) | The join family, `fromQuery`, correlated subqueries |
| [Unions and CTEs](./docs/unions-and-ctes.md) | `unionAll`, `fromUnion`, `withCTE` |
| [Inserting rows](./docs/inserts.md) | `insertInto`, the insert row type, `DEFAULT`, binding |
| [Updating and deleting](./docs/updates-and-deletes.md) | `update`, `deleteFrom`, `allRows`, ClickHouse mutations |
| [Params and compilation](./docs/params-and-compilation.md) | `param.*`, how values reach the SQL, `CompiledQuery` |
| [Decoding results](./docs/decoding-results.md) | `rowSchema`, `decodeRows`, decode errors |
| [Running a query](./docs/running-queries.md) | Executing the SQL with a real client, wire settings, `SETTINGS` |
Expand Down
4 changes: 2 additions & 2 deletions design/gap-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ builder; **P1** commonly used; **P2** niche.

| Gap | Maple | Effort |
| --- | --- | --- |
| UPDATE builder: SET values and expressions, WHERE, RETURNING | ~120 | M |
| DELETE builder: WHERE, RETURNING | ~79 | S |
| ~~UPDATE builder: SET values and expressions, WHERE, RETURNING~~ (built) | ~120 | M |
| ~~DELETE builder: WHERE, RETURNING~~ (built) | ~79 | S |
| A typed, value-binding `sql` template usable inside expressions; `sql.join` / `raw` / `empty` on `Db.sql` | ~163 | M |
| Postgres column types: `timestamptz` as `Date`, `timestamp`, `date`, `interval`, `varchar(n)`, serial / identity | 226 timestamp columns | S |
| DISTINCT (and DISTINCT ON) | ~10 | S |
Expand Down
24 changes: 22 additions & 2 deletions design/writes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Writes: INSERT

Status: phases 1 to 4 built (`values`, `returning`, `onConflictDoNothing` /
`onConflictDoUpdate`, `select`, `settings`; see `docs/inserts.md`). `encodeInsertRows` (§7) is
`onConflictDoUpdate`, `select`, `settings`; see `docs/inserts.md`), and UPDATE and DELETE (§12,
`docs/updates-and-deletes.md`). `encodeInsertRows` (§7) is
not built: no consumer has asked for it. Section 11 lists where the build differs from the plan. UPDATE and DELETE come later and
will reuse what this note sets up (the write-statement state, the `RETURNING` path, value
encoding).
Expand Down Expand Up @@ -257,4 +258,23 @@ note, reusing `kind: "write"`, the returning path and the `set` record type from
Its tenant scope is the SELECT's for an untenanted target (the read), and for a tenant target
single-tenant only when the read is and each row takes its tenant from a source tenant column
or the same param.
- **Settings are a dialect clause**, `DialectClauses.insertSettings`, like the others.
- **Settings are a dialect clause**, `DialectClauses.writeSettings`, like the others.

## 12. UPDATE and DELETE

`update(table).set(...).where(...)` and `deleteFrom(table).where(...)`, in `src/ch/update.ts`,
compiled beside INSERT and sharing its value encoding (`valueCells`), SET record
(`setAssignments`, also used by `onConflictDoUpdate`), RETURNING (`returningOf`) and settings.

- **No WHERE is refused.** No `where()` is a defect; a `where()` whose conditions are all
`undefined` is a failure, since optional filters come from data and that case would widen the
write to every row. `allRows()` opts in.
- **ClickHouse**: UPDATE is an `ALTER TABLE ... UPDATE` mutation (`DialectClauses.alterTableUpdate`),
the one form every supported server takes; DELETE is the lightweight `DELETE FROM`. Both need
a WHERE, so `allRows()` writes `WHERE 1`. Settings go last (`mutations_sync`,
`lightweight_deletes_sync`), and `DialectClauses.insertSettings` became `writeSettings`.
Checked on 26.2.19.43 and 26.8.2.7.
- **Tenant scope** is derived from the WHERE as for a query over the table; an UPDATE that sets
the tenant column to anything but the pinned value is cross-tenant.
- Not built: UPDATE ... FROM / joins, DELETE USING, ORDER BY / LIMIT, a VALUES source for bulk
updates. See `design/gap-review.md`.
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ You do not need a Maple account, Maple's schema, or tenant columns. Tenant analy
optional feature for applications that share tables between tenants.

The root builder does not manage connections, create tables, or run migrations. It builds
SELECTs and [INSERTs](./inserts.md); UPDATE and DELETE are not built yet. Opt-in [schema and migration entry points](./migrations.md) add DDL and migrations for
SELECTs, [INSERTs](./inserts.md), and [UPDATEs and DELETEs](./updates-and-deletes.md). Opt-in [schema and migration entry points](./migrations.md) add DDL and migrations for
ClickHouse. It does not validate SQL against a live server, choose query plans, enforce authorization,
or supply retries. Existing ClickHouse tables and your executor own those responsibilities.
[Getting started](./getting-started.md) covers npm installation and building from source.
Expand Down Expand Up @@ -47,6 +47,7 @@ Roughly in reading order.
| [Joins and subqueries](./joins-and-subqueries.md) | The join family, `fromQuery`, correlated subqueries |
| [Unions and CTEs](./unions-and-ctes.md) | `unionAll`, `fromUnion`, `withCTE` |
| [Inserting rows](./inserts.md) | `insertInto`, the insert row type, `DEFAULT`, binding |
| [Updating and deleting](./updates-and-deletes.md) | `update`, `deleteFrom`, `allRows`, ClickHouse mutations |
| [Params and compilation](./params-and-compilation.md) | `param.*`, how values reach the SQL, `CompiledQuery` |
| [Decoding results](./decoding-results.md) | `rowSchema`, `decodeRows`, `decodeFirstRow`, decode errors |
| [Running a query](./running-queries.md) | Executing the SQL with a real client, wire settings, `SETTINGS` |
Expand Down
14 changes: 8 additions & 6 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@ Calling `withdraw(1, 30)` outside `transfer` does not compile: `requireTransacti

### Queries and statements

`run` takes the query you built, a `unionAll`, an `insertInto`, or a query compiled elsewhere. It compiles with
`run` takes the query you built, a `unionAll`, an `insertInto`, `update` or `deleteFrom`, or a
query compiled elsewhere. It compiles with
the database's dialect, so you never pick a `compile`; `params` fills the query's `param.*`
markers, and a missing one fails with `QueryBuilderError`. A query compiled elsewhere must
have been compiled for the same dialect, or `run` dies: the root `compile` is ClickHouse's.

`sql` writes the statements the builder does not have yet (UPDATE, DELETE, DDL, advisory
`sql` writes the statements the builder does not have yet (DDL, bulk `UPDATE ... FROM`, advisory
locks). Each `${value}` is bound, as `$1, $2, ...` on Postgres and as an escaped literal on
ClickHouse, so nothing in a value becomes SQL. A `sql` inside another is spliced, so
statements compose. Names go through `sql.identifier`, which accepts only plain identifiers
Expand Down Expand Up @@ -247,7 +248,8 @@ fails at BEGIN today: through the query path with a syntax error, and through `a

## Writes

The builder compiles SELECTs and [INSERTs](./inserts.md). `run` runs an insert and returns its
`returning` rows, decoded, or none without `returning`; an insert without it goes through
`command`, as `execute` does. Write UPDATE and DELETE with `sql`, as above, and read `RETURNING`
with `query` and a schema.
The builder compiles SELECTs, [INSERTs](./inserts.md) and
[UPDATEs and DELETEs](./updates-and-deletes.md). `run` runs a write and returns its `returning`
rows, decoded, or none without `returning`; a write without it goes through `command`, as
`execute` does. Write other statements with `sql`, as above, and read `RETURNING` with `query`
and a schema.
4 changes: 3 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Note `/sql` exports a `compile` (fragment → string) distinct from the root `co
| `fromQuery` | `(query, alias) => CHQuery` |
| `fromUnion` | `(union, alias) => CHQuery` |
| `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)` |
| `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) |

### `CHQuery` methods
Expand Down Expand Up @@ -279,7 +281,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`, `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`, `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`.
Expand Down
86 changes: 86 additions & 0 deletions docs/updates-and-deletes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Updating and deleting rows

`update(table)` and `deleteFrom(table)` build UPDATE and DELETE from the same table definitions,
like [`insertInto`](./inserts.md). They are immutable values; `Database.run` compiles them for
its database's dialect and runs them.

```ts
import * as CH from "@maple-dev/effect-orm"
import * as PG from "@maple-dev/effect-orm/postgres"

const Tickets = CH.table(
"tickets",
{ id: PG.int4, org: PG.text, seats: PG.int4, tags: PG.array(PG.text) },
{ tenantColumn: "org" },
)

const bump = CH.update(Tickets)
.set(($) => ({ seats: $.seats.add(1) }))
.where(($) => [$.org.eq(CH.param.string("org")), $.seats.lt(5)])
.returning("id", "seats")

const revoke = CH.deleteFrom(Tickets).where(($) => [$.id.eq(CH.param.int("id"))])

// yield* Db.run(bump, { org }) // [{ id, seats }]
// yield* Db.run(revoke, { id }) // []
```

## SET

`set` takes a record of values, params or expressions, or a callback that gets the row's
columns as `$`. A key left out (or `undefined`) keeps the existing value. Values are encoded
through the column's codec and bound on Postgres, as in an insert. A computed column (see
[`computed`](./inserts.md#which-columns-have-defaults)) cannot be set. `UpdateSetOf<typeof T>`
names the record type.

`update(table)` offers only `set` until it has one (its type is `CHUpdateStart`).

## WHERE, and writing every row

`where` works as in a query: a list of conditions, AND-joined, with an `undefined` one skipped,
so optional filters compose. A write with no `where` would change every row, so:

- compiling an UPDATE or DELETE with no `where()` is a `QueryBuilderDefect`;
- a `where()` whose conditions all came out `undefined` (or render to nothing) is a
`QueryBuilderError`, because that happens with data (every optional filter absent) and would
otherwise widen a filtered write to the whole table;
- `allRows()` says a write over every row is meant.

## RETURNING

On Postgres, `returning` works as on an insert: no arguments for every column, column names, or
a callback. `Database.run` returns the changed or deleted rows, decoded. Without it, a write
returns no rows.

## ClickHouse

ClickHouse has no `UPDATE ... SET` on every server, so `update` compiles to a mutation, and
`deleteFrom` to a lightweight delete. Both need a WHERE, so `allRows()` writes `WHERE 1`:

```sql
ALTER TABLE spans UPDATE Name = 'x'
WHERE OrgId = 'o' SETTINGS mutations_sync = 2

DELETE FROM spans
WHERE OrgId = 'o' SETTINGS lightweight_deletes_sync = 2
```

A mutation runs in the background: without `mutations_sync`, `Database.run` returns before the
rows change. Pass `settings({ mutations_sync: 2 })` when the caller reads its own write.
Mutations rewrite whole parts, so they suit occasional corrections, not per-request updates;
model frequently changing state with a `ReplacingMergeTree` and inserts instead. ClickHouse
cannot update a column of the sorting key. `returning` is refused on ClickHouse
(`QueryBuilderDefect`), and `settings` on Postgres.

## Tenant scope

An UPDATE or DELETE has the scope a query over the table with the same WHERE would have:
`"single-tenant"` when the WHERE pins the tenant column, `"cross-tenant"` otherwise (including
`allRows()`). An UPDATE that sets the tenant column to another value moves rows out of the
tenant, so it is `"cross-tenant"` too. A subquery in the SET or WHERE counts as it would in a
query: one that reads another tenant (or every tenant) makes the write `"cross-tenant"`, and a
write into a table without a tenant column takes the scope of what its subqueries read. The
same holds for a subquery in an insert's values or `onConflictDoUpdate`.

_(Backed by `src/ch/update.test.ts`, `src/database/database.test.ts` and
`tests/database.clickhouse.test.ts`.)_
Loading
Loading