Status: done. ClickHouse and Postgres both compile from the same builder; see
docs/postgres.md. Steps 4 and 5 landed differently from the plan, as
noted below.
Keep one query builder, one tenant-scope analysis, and one row-decoding model, and let the
database-specific parts (SQL syntax, literals, param binding, column wire formats, the
function catalog) vary per dialect. ClickHouse output stays byte-identical at every step; the
exact-SQL tests in src/ch/compile.test.ts are the guard.
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 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, ...) |
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) |
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:
- Kysely decodes nothing at runtime; its row types are a promise. Our schema-backed
decodeRowsstays. - Drizzle copies the whole query builder per dialect package. We share the builder and vary only types and functions.
- Tenant scoping stays a proof, not a plugin that injects
OrgId = .... Injection would hide a missing tenant condition instead of reporting it.
- Params through a dialect (done).
renderParamsresolves placeholders once, at the top of the statement, so a binding dialect can number them across unions and subqueries.CompiledQuery.parametersholds the bound values. Tenant bounds still render as ClickHouse literals, since they are compared as text and never sent. - Literals behind the dialect (done).
Dialect.quoteStringandDialect.literalwrite everyStrfragment, column literal, and inline param.compileinstalls the dialect for the length of the compile (withDialect, the same save/restore pattern aswithSubqueryCompiler), because literals are written inside the query callbacks, which have no dialect argument. The installed values live in the compiling fiber's context (src/sql/fiber-local.ts), not in module state, so two compiles in two fibers cannot see each other's dialect. The dialect isCurrentDialect, aContext.Referencea caller can also provide. The__PARAM_safety is now enforced rather than assumed: a literal that contains the marker fails withInvalidLiteral, so a dialect with weaker escaping cannot turn a value into a placeholder. - Identifier quoting (done). Column refs are
Identfragments carrying their qualifier (ident(column, "alias")), so a ClickHouseNestedcolumn such asEvents.Nameis never split whiledb.tablequalifiers are quoted per segment. Tables, aliases, CTE names, and group/order keys go throughDialect.quoteIdenttoo. Untyped boolean and DateTime literals moved behind the dialect in the same step (dateTimeLiteral). - Column types (done, differently). The plan was to split
CHTypeinto a logical type plus a transport codec. Postgres turned out not to need the split: its types reuse theCHTypedescriptor (PgTypeis an alias) with codecs that accept every wire form a common driver sends (int8 as number, string orbigint; timestamptz asDateor text), the same approachCHNumberalready takes for quoted 64-bit integers. Portable param kinds that must encode differently (param.bool,param.dateTime) are re-encoded per dialect byDialect.paramCodecs. A per-driver codec axis can come later if a consumer needs one. - Clauses (done, differently). The plan was to move
compile.tsinto aClickHouseDialect.buildSelect(state). The clauses Postgres needed to differ were few: noFORMAT, 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 areDialect.clausesflags read at three places incompile.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. - Postgres dialect (done).
@maple-dev/effect-orm/postgres:postgresDialect(double-quoted identifiers, standard strings withE'…'only to escape the param marker,$nbinding), column types, a function catalog (count(*),FILTER (WHERE …),percentile_cont,date_trunc(…, 'UTC'),date_bin,array_agg,->>), and acompilethat defaults to Postgres.src/pg/postgres.test.tsruns every query on PGlite (Postgres 17 in WASM), so a test passes only if Postgres accepts the SQL and the rows decode.
- ClickHouse also supports server-side binding (
{name:Type}withquery_params). Adding it needs the param kind to name a ClickHouse type, whichparam.ofalready has. - The package is still named for ClickHouse and the root entry still exports the ClickHouse
function catalog. Renaming, or a
coreentry without ClickHouse functions, is a packaging decision for a later release. - The ClickHouse function catalog writes ClickHouse SQL whatever the dialect. A built-in
function now refuses to compile under another dialect, and is a type error there too: its
result carries a
DialectEntrybeside its params, whichcompileandDatabaseApi<Functions>.runcheck against the target. TheDatabasein context has no dialect in its type, so it checks only that a query does not mix two. ACompiledQuerydoes not carry its dialect in the type yet. A clause only some dialects write rides the same channel as aClauseEntry(the row locks,RowLock, Postgres only).formatanddistinctOncould use it too; they are refused only at compile time for now. - JSON paths split the same way.
src/ch/json-path.tsowns what no dialect changes: the path types (JsonPath,JsonPathValue,JsonContained) andjsonPathSchema, which reads the decoder at a path off the column's Schema.src/pg/functions.tsowns only the SQL (->,->>,@>,jsonb_set). A SQLite or MySQL catalog adds its ownjsonPath,jsonSetand friends over the same keys:json_extract(x, '$.a[0]')/x ->> '$.a'andjson_set(x, '$.a', json(?))on SQLite;JSON_EXTRACT,JSON_UNQUOTE,JSON_CONTAINS(x, CAST(? AS JSON))andJSON_SETon MySQL. The one new piece is a key list written as a$.a[0]path string, with each key quoted ($."a b"). - MySQL or SQLite would need
?placeholders (reuse: falsealready exists) and their own types and functions; nothing in the core should need to change.