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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

- Add `Dialect`: how a compiled query writes identifiers and literals, binds params, and
which clauses exist. `compile` and friends take `options.dialect`; `clickhouseDialect` is the
default and its output is unchanged.
- Add `CompiledQuery.parameters`: the values a binding dialect sends beside `sql`, empty for
ClickHouse. Code that builds a `CompiledQuery` by hand must now supply it.
- Add the `./postgres` entry point: `postgresDialect` (quoted identifiers, `$n` binding),
Postgres column types and functions, and a `compile` that defaults to Postgres.
- A literal that would contain the param marker `__PARAM_` now fails the compile with
`InvalidLiteral` instead of relying on each dialect's escaping.
- `compileUnionUnsafe` no longer accepts the internal `enclosingCtes` option.

## 0.2.0

- Require Effect `^4.0.0`. Effect 4.0.0 moved `effect/unstable/*` to `effect/*`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Full guides live in [`docs/`](./docs/README.md):
| [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` |
| [Tenant scoping](./docs/tenant-scoping.md) | `tenantColumn`, what marks a query scoped, `crossTenant()` |
| [Postgres](./docs/postgres.md) | The Postgres dialect, its column types and functions |
| [Extending the DSL](./docs/extending.md) | `defineFn`, raw escape hatches, handwritten SQL |
| [API reference](./docs/reference.md) | Full export catalog by module, plus error types |

Expand All @@ -166,6 +167,7 @@ regressions live in [`src/docs-examples.test.ts`](./src/docs-examples.test.ts).
| `@maple-dev/effect-clickhouse/types` | Column-type constructors (`string`, `uint64`, `dateTime`, `map`, `array`, `nullable`, …) and the `CH*` type descriptors. |
| `@maple-dev/effect-clickhouse/expr` | Kitchen-sink namespace: every expression helper plus all ClickHouse functions under their raw names (`min_`, `toString_`, `toStartOfInterval`, `dynamicColumn`, …). Handy for `import * as CH`. |
| `@maple-dev/effect-clickhouse/sql` | The low-level `SqlFragment` AST (`raw`, `ident`, `compile`, …) for hand-rolling fragments. |
| `@maple-dev/effect-clickhouse/postgres` | The Postgres dialect: `postgresDialect`, Postgres column types and functions, and a `compile` that defaults to Postgres. |

## Extending with custom functions

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 40 additions & 21 deletions design/dialects.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Dialects: one builder, several databases

Status: steps 1 and 2 landed (params and literals go through a `Dialect`). Steps 3 to 6 are open.
Status: done. ClickHouse and Postgres both compile from the same builder; see
[`docs/postgres.md`](../docs/postgres.md). Steps 4 and 5 landed differently from the plan, as
noted below.

## Goal

Expand All @@ -15,14 +17,14 @@ Both were read from source (Kysely 0.28.17, Drizzle 1.0.0-rc.5).

| Idea | Where it comes from | How it lands here |
| --- | --- | --- |
| Builders produce a config object; a dialect turns it into SQL | Drizzle `PgDialect.buildSelectQuery(config)` | `CHQueryState` already is that config. `compile.ts` becomes the ClickHouse dialect's `buildSelect` |
| SQL as a chunk tree rendered at the end with `escapeName` / `escapeParam` / `escapeString` | Drizzle `SQL` chunks + `BuildQueryConfig` | `SqlFragment` gets a real `Param` chunk; `Str`/`Lazy` stop rendering ClickHouse text early |
| Builders produce a config object; a dialect turns it into SQL | Drizzle `PgDialect.buildSelectQuery(config)` | `CHQueryState` already is that config; `compile.ts` reads the dialect where clauses differ (step 5) |
| SQL as a chunk tree rendered at the end with `escapeName` / `escapeParam` / `escapeString` | Drizzle `SQL` chunks + `BuildQueryConfig` | `Str` and `Ident` render through the installed dialect; params stay text placeholders resolved once per statement |
| Inline vs bound params as a switch | Drizzle `inlineParams` | `ParamStyle`: `inline` (ClickHouse today) or `bind` |
| A small override surface per dialect | Kysely `DefaultQueryCompiler` hooks (MySQL overrides ~10 methods) | The `Dialect` interface grows hook by hook, never a copy of the compiler |
| Capability flags instead of dialect checks | Kysely `DialectAdapter` (`supportsReturning`, ...) | Flags such as `supportsFilterClause`, `aliasInWhere`, `limitBy` |
| Capability flags instead of dialect checks | Kysely `DialectAdapter` (`supportsReturning`, ...) | `Dialect.clauses`: `format`, `derivedTableAlias`, `groupByAlias` |
| A compiled query that keeps its structure | Kysely `CompiledQuery.query` | `CompiledQuery` already carries tenant scope and row schema; `parameters` added in step 1 |
| Rewrites as passes over the tree | Kysely plugins (`transformQuery` / `transformResult`) | Tenant-scope proof and empty-`IN` handling as passes over the state |
| Logical type separate from how a driver sends it | Drizzle v1 codecs, `refineGenericPgCodecs` per driver | `CHType` splits into a type and a transport codec (step 3) |
| Rewrites as passes over the tree | Kysely plugins (`transformQuery` / `transformResult`) | Not adopted yet; tenant scope is still derived inside `compile` |
| Logical type separate from how a driver sends it | Drizzle v1 codecs, `refineGenericPgCodecs` per driver | Per-dialect type sets on the shared descriptor, codecs tolerant of every common driver (step 4); `Dialect.paramCodecs` for params |
| Shared operators, per-dialect function catalogs | Drizzle `sql/expressions` vs `pg-core` / `mysql-core` | `eq`, `and`, `in_` in core; `countIf`, `percentileCont` per dialect |

What we deliberately do not copy:
Expand All @@ -47,23 +49,40 @@ What we deliberately do not copy:
have no dialect argument. The `__PARAM_` safety is now enforced rather than assumed: a
literal that contains the marker fails with `InvalidLiteral`, so a dialect with weaker
escaping cannot turn a value into a placeholder.
3. **Identifier quoting.** Postgres folds unquoted names to lower case, so `OrgId` must be
written `"OrgId"`. Column refs, aliases, qualified names, `groupBy` keys and `orderBy`
specs are raw text today (`raw(name)` in `expr.ts`, `orderByClause` in `compile.ts`), so
this is its own step: column refs become `Ident` fragments that carry their qualifier.
4. **Split `CHType`.** A logical type (`sql` name, TS type) plus a codec for the transport. The
UInt64-as-string rule belongs to ClickHouse's `FORMAT JSON` over HTTP, not to ClickHouse;
the native client sends something else, and Postgres drivers send int8 as a string and
timestamptz as a `Date`.
5. **Move `compile.ts` into `ClickHouseDialect.buildSelect(state)`.** `compileQuery` and the
terminal clauses (`FORMAT`, `SETTINGS`, `LIMIT BY`) become ClickHouse-only.
6. **Postgres dialect.** `pg.T` column types, `$n` binding, `"ident"` quoting, a function
catalog covering the common cases (`FILTER (WHERE ...)`, `date_bin`, `percentile_cont`,
jsonb access), and capability flags for what ClickHouse allows and Postgres does not
(select aliases in `WHERE`/`HAVING`, default values instead of `NULL` in outer joins).
3. **Identifier quoting (done).** Column refs are `Ident` fragments carrying their qualifier
(`ident(column, "alias")`), so a ClickHouse `Nested` column such as `Events.Name` is never
split while `db.table` qualifiers are quoted per segment. Tables, aliases, CTE names, and
group/order keys go through `Dialect.quoteIdent` too. Untyped boolean and DateTime
literals moved behind the dialect in the same step (`dateTimeLiteral`).
4. **Column types (done, differently).** The plan was to split `CHType` into a logical type
plus a transport codec. Postgres turned out not to need the split: its types reuse the
`CHType` descriptor (`PgType` is an alias) with codecs that accept every wire form a common
driver sends (int8 as number, string or `bigint`; timestamptz as `Date` or text), the same
approach `CHNumber` already takes for quoted 64-bit integers. Portable param kinds that
must encode differently (`param.bool`, `param.dateTime`) are re-encoded per dialect by
`Dialect.paramCodecs`. A per-driver codec axis can come later if a consumer needs one.
5. **Clauses (done, differently).** The plan was to move `compile.ts` into a
`ClickHouseDialect.buildSelect(state)`. The clauses Postgres needed to differ were few:
no `FORMAT`, an alias on a wrapped union, and GROUP BY by position (Postgres reads a bare
name as an input column before a select alias). Those are `Dialect.clauses` flags read at
three places in `compile.ts`, which kept ClickHouse output byte-identical without
relocating ~1100 lines. A dialect that needs a structurally different statement is the
point to revisit this.
6. **Postgres dialect (done).** `@maple-dev/effect-clickhouse/postgres`: `postgresDialect`
(double-quoted identifiers, standard strings with `E'…'` only to escape the param marker,
`$n` binding), column types, a function catalog (`count(*)`, `FILTER (WHERE …)`,
`percentile_cont`, `date_trunc(…, 'UTC')`, `date_bin`, `array_agg`, `->>`), and a
`compile` that defaults to Postgres. `src/pg/postgres.test.ts` runs every query on PGlite
(Postgres 17 in WASM), so a test passes only if Postgres accepts the SQL and the rows decode.

## Open questions

- ClickHouse also supports server-side binding (`{name:Type}` with `query_params`). Adding it
needs the param kind to name a ClickHouse type, which `param.of` already has.
- Whether the package splits (`core`, `clickhouse`, `postgres` entry points) at step 4 or 5.
- The package is still named for ClickHouse and the root entry still exports the ClickHouse
function catalog. Renaming, or a `core` entry without ClickHouse functions, is a packaging
decision for a later release.
- The ClickHouse function catalog writes ClickHouse SQL whatever the dialect. Functions could
refuse to compile under another dialect instead of producing SQL that fails at the server.
- MySQL or SQLite would need `?` placeholders (`reuse: false` already exists) and their own
types and functions; nothing in the core should need to change.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Roughly in reading order.
| [Agent benchmark playbook](./benchmark-agent.md) | Repeatable optimization workflow and evidence checklist |
| [Tenant scoping](./tenant-scoping.md) | `tenantScope`, what marks a query scoped, `crossTenant()` |
| [Extending the DSL](./extending.md) | `defineFn`, raw escape hatches, handwritten SQL |
| [Postgres](./postgres.md) | The Postgres dialect, its column types and functions |

## Reference

Expand Down
35 changes: 24 additions & 11 deletions docs/params-and-compilation.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,16 @@ Use `decodeRows` to validate wire values against the row schema.

## Dialects

A `Dialect` decides how literals are written and how resolved params reach the server. Its
`quoteString` and `literal` write every string fragment, every value compared against a column,
and every inline param, for the length of the compile. The default, `clickhouseDialect`,
writes each value into the SQL as a ClickHouse literal and leaves
`parameters` empty. A dialect whose `params` style is `bind` leaves a placeholder instead and
returns the encoded values in `parameters`, numbered once across the whole statement, unions
and subqueries included:
A `Dialect` is the database a query is compiled for: how identifiers and literals are written,
how resolved params reach the server, and which clauses exist. It is installed for the length of
the compile, so every column reference, string fragment, compared value and inline param goes
through it. The default, `clickhouseDialect`, writes names bare and each param value into the SQL
as a ClickHouse literal, and leaves `parameters` empty. `postgresDialect`, from the `/postgres`
entry point, is the other built-in one; see [Postgres](./postgres.md).

A dialect whose `params` style is `bind` leaves a placeholder instead and returns the encoded
values in `parameters`, numbered once across the whole statement, unions and subqueries
included:

```ts
const numbered: CH.Dialect = {
Expand All @@ -212,15 +215,25 @@ const compiled = CH.compileUnsafe(query, { orgId: "org_1" }, { dialect: numbered
`reuse: true` lets one numbered placeholder stand for every use of a param; set it to `false`
for positional `?` placeholders, which bind a value each time they appear. Either way a bound
value is the column codec's wire form, the same value an inline literal is written from, and a
missing or ill-typed param still fails the compile.
missing or ill-typed param still fails the compile. `paramCodecs` re-encodes a portable param
kind where the database wants another form: Postgres binds `param.bool` as a boolean rather
than `1`/`0`.

| Member | Purpose |
| --- | --- |
| `quoteIdent` | One identifier: a column, alias, table or schema name |
| `quoteString`, `literal` | A string, or any encoded wire value, as a literal |
| `dateTimeLiteral` | A point in time compared against an expression with no declared type |
| `params` | `inline`, or `bind` with a placeholder function |
| `clauses.format` | Whether `FORMAT` exists; `.format()` fails to compile where it does not |
| `clauses.derivedTableAlias` | Whether a subquery in FROM needs an alias |
| `clauses.groupByAlias` | Whether GROUP BY resolves select aliases; if not, keys are written by position |
| `paramCodecs` | Per-kind codec overrides for `param.*` |

Params are resolved by rewriting placeholders in the finished SQL, so a dialect's literals must
never spell the param marker `__PARAM_`. ClickHouse writes it as `\x5F_PARAM_`; a literal that
does contain the marker fails the compile with `InvalidLiteral` rather than being rewritten.

Identifiers, clauses, and functions are still ClickHouse SQL; a dialect changes literals and
param binding today.

## Handwritten SQL

When you need SQL the builder cannot express, `rawCompiledQuery` wraps a string in the same
Expand Down
Loading
Loading