From 98eb4f289db4dfe46eb609954c5733af26107d1e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 3 Oct 2026 16:01:41 +0200 Subject: [PATCH 1/2] Fix Postgres unions with CTEs and float params against int8 Each UNION ALL branch is now parenthesized on Postgres, where a branch with its own WITH, ORDER BY or LIMIT was a syntax error. The placeholder gets the param kind, so float, bool and timestamp params bind with a cast instead of taking an int8 or text type from their context. The exact-int8 recipe now reads a bigint too, which PGlite and postgres.js send. Co-Authored-By: Claude Opus 5.5 --- docs/params-and-compilation.md | 1 + docs/postgres.md | 5 ++++- src/ch/compile.ts | 5 +++-- src/ch/dialect.test.ts | 2 +- src/ch/dialect.ts | 10 +++++++--- src/pg/dialect.ts | 22 ++++++++++++++++++++-- src/pg/types.ts | 3 ++- 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/params-and-compilation.md b/docs/params-and-compilation.md index fc8fe12..f919402 100644 --- a/docs/params-and-compilation.md +++ b/docs/params-and-compilation.md @@ -228,6 +228,7 @@ than `1`/`0`. | `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 | +| `clauses.parenthesizeUnionBranches` | Whether each `UNION ALL` branch is wrapped in parentheses | | `paramCodecs` | Per-kind codec overrides for `param.*` | Params are resolved by rewriting placeholders in the finished SQL, so a dialect's literals must diff --git a/docs/postgres.md b/docs/postgres.md index 324584d..689d059 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -84,7 +84,10 @@ literal that still contained it would fail the compile with `InvalidLiteral`. | `custom(sql, schema, literalSchema?)` | anything | the schema's type | whatever the schema reads | `int8` and `numeric` decode to `number`, so values beyond 2^53 or a double's precision lose -digits. Declare `PG.custom("int8", Schema.String)` where exact digits matter. A `timestamptz` +digits. Where exact digits matter, declare +`PG.custom("int8", Schema.Union([Schema.BigInt, Schema.BigIntFromString]))`: drivers send int8 as a +`bigint` (PGlite, postgres.js with `types.bigint`) or as a string (node-postgres), and this reads +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. diff --git a/src/ch/compile.ts b/src/ch/compile.ts index 08282c5..973440f 100644 --- a/src/ch/compile.ts +++ b/src/ch/compile.ts @@ -1086,7 +1086,8 @@ function compileUnionInner, Params extends Re ? "untenanted" : "cross-tenant" - let sql = subQueries.map((q) => q.sql).join("\nUNION ALL\n") + const parenthesize = currentDialect().clauses.parenthesizeUnionBranches + let sql = subQueries.map((q) => (parenthesize ? `(\n${q.sql}\n)` : q.sql)).join("\nUNION ALL\n") // Wrap in outer SELECT if ordering/pagination is needed const hasOuter = @@ -1184,7 +1185,7 @@ function renderParams( const existing = style.reuse ? bound.get(key) : undefined if (existing !== undefined) return existing parameters.push(value) - const marker = style.placeholder(parameters.length) + const marker = style.placeholder(parameters.length, kind) bound.set(key, marker) return marker }) diff --git a/src/ch/dialect.test.ts b/src/ch/dialect.test.ts index 73ef480..53c6cef 100644 --- a/src/ch/dialect.test.ts +++ b/src/ch/dialect.test.ts @@ -189,7 +189,7 @@ describe("dialect identifiers and clauses", () => { ...CH.clickhouseDialect, name: "quoted", quoteIdent: (name) => `"${name.replace(/"/g, '""')}"`, - clauses: { format: false, derivedTableAlias: true, groupByAlias: true }, + clauses: { format: false, derivedTableAlias: true, groupByAlias: true, parenthesizeUnionBranches: false }, } const services = CH.table("db.services", { OrgId: CH.string, Service: CH.string }, { tenantColumn: "OrgId" }) diff --git a/src/ch/dialect.ts b/src/ch/dialect.ts index 7bc190c..9d401fb 100644 --- a/src/ch/dialect.ts +++ b/src/ch/dialect.ts @@ -27,8 +27,9 @@ export type ParamStyle = | { readonly _tag: "inline" } | { readonly _tag: "bind" - /** The placeholder for the value at 1-based `index`. */ - readonly placeholder: (index: number) => string + /** The placeholder for the value at 1-based `index`, bound for a param + * of `kind` (`string`, `int`, `float`, `bool`, `dateTime`, ...). */ + readonly placeholder: (index: number, kind: string) => string /** * Whether one placeholder may stand for every use of the same param. * @@ -49,6 +50,9 @@ export interface DialectClauses { /** Whether GROUP BY resolves a select alias before an input column of the * same name. Where it does not, keys are written by select-list position. */ readonly groupByAlias: boolean + /** Whether each `UNION ALL` branch is wrapped in parentheses. Postgres needs + * it for a branch with its own WITH, ORDER BY or LIMIT. */ + readonly parenthesizeUnionBranches: boolean } /** @@ -81,7 +85,7 @@ export const clickhouseDialect: Dialect = { literal: sqlLiteral, dateTimeLiteral: (value) => quoteClickHouseString(chDateTimeLiteral(value)), params: { _tag: "inline" }, - clauses: { format: true, derivedTableAlias: false, groupByAlias: true }, + clauses: { format: true, derivedTableAlias: false, groupByAlias: true, parenthesizeUnionBranches: false }, } // The dialect of the enclosing compile, beside the syntax installed for the diff --git a/src/pg/dialect.ts b/src/pg/dialect.ts index 67217ef..ddb8778 100644 --- a/src/pg/dialect.ts +++ b/src/pg/dialect.ts @@ -62,6 +62,20 @@ const literal = (value: unknown, context: string): string => { /** `param.dateTimeSeconds`: the same instant, floored to whole seconds. */ const timestampSeconds = timestampLiteral((epochMillis) => new Date(Math.floor(epochMillis / 1000) * 1000).toISOString()) +/** + * Casts for the param kinds whose Postgres type is unambiguous. Postgres types + * an untyped `$n` from its context, so a float compared with an int8 column + * binds as int8 and rejects `19.5`, and a param in a select list binds as text. + * `string` and `int` stay uncast: a cast would stop them comparing with an enum + * or int4 column. + */ +const placeholderCasts: Readonly> = { + float: "float8", + bool: "boolean", + dateTime: "timestamptz", + dateTimeSeconds: "timestamptz", +} + /** * Postgres: double-quoted identifiers, standard string literals, and params * bound to `$1`, `$2`, … and returned in `CompiledQuery.parameters`. @@ -76,8 +90,12 @@ export const postgresDialect: Dialect = { quoteString, literal, dateTimeLiteral: (value) => `TIMESTAMPTZ ${quoteString(DateTime.formatIso(value))}`, - params: { _tag: "bind", placeholder: (index) => `$${index}`, reuse: true }, - clauses: { format: false, derivedTableAlias: true, groupByAlias: false }, + params: { + _tag: "bind", + placeholder: (index, kind) => (Object.hasOwn(placeholderCasts, kind) ? `$${index}::${placeholderCasts[kind]}` : `$${index}`), + reuse: true, + }, + clauses: { format: false, derivedTableAlias: true, groupByAlias: false, parenthesizeUnionBranches: true }, paramCodecs: { bool: Schema.Boolean, dateTime: PgTimestampLiteral, diff --git a/src/pg/types.ts b/src/pg/types.ts index f5a6cca..7a5d78a 100644 --- a/src/pg/types.ts +++ b/src/pg/types.ts @@ -15,7 +15,8 @@ export type PgType = CHType /** A number as any Postgres driver sends one: a number, a numeric string, or a * `bigint`. Decodes to `number`, so an int8 beyond 2^53 loses precision; declare - * `custom("int8", Schema.String)` where that matters. */ + * `custom("int8", Schema.Union([Schema.BigInt, Schema.BigIntFromString]))` + * where that matters. */ export const PgNumber: Schema.Codec = Schema.Union([ Schema.Finite, Schema.FiniteFromString, From 377de8356781ff921a0396b9603a4e67552d8afc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 3 Oct 2026 16:01:41 +0200 Subject: [PATCH 2/2] Run one builder suite on every dialect and cover the Postgres entry core-cases.ts runs the shared builder surface (query and union methods, operators, params) on live ClickHouse and on PGlite, pins the SQL per dialect in snapshots, and records real differences per target. The coverage manifest now requires a core case for every shared method and a Postgres case for every ./postgres export, as it already did for the ClickHouse catalog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + docs/testing.md | 23 + tests/__snapshots__/core-sql.test.ts.snap | 973 ++++++++++++++++++++++ tests/core-cases.ts | 620 ++++++++++++++ tests/core-sql.test.ts | 26 + tests/core.clickhouse.test.ts | 30 + tests/core.postgres.test.ts | 23 + tests/dialect-cases.postgres.ts | 274 ++++++ tests/dialect-coverage.test.ts | 82 ++ tests/dialect.postgres.test.ts | 20 + tests/postgres-support.ts | 11 + 11 files changed, 2090 insertions(+) create mode 100644 tests/__snapshots__/core-sql.test.ts.snap create mode 100644 tests/core-cases.ts create mode 100644 tests/core-sql.test.ts create mode 100644 tests/core.clickhouse.test.ts create mode 100644 tests/core.postgres.test.ts create mode 100644 tests/dialect-cases.postgres.ts create mode 100644 tests/dialect.postgres.test.ts create mode 100644 tests/postgres-support.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db3aa37..06a1076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Postgres: wrap each `UNION ALL` branch in parentheses (`DialectClauses.parenthesizeUnionBranches`). + A branch with its own `WITH`, `ORDER BY` or `LIMIT` was a syntax error. +- Postgres: bind `param.float` as `$n::float8`, `param.bool` as `$n::boolean`, and the + `dateTime` kinds as `$n::timestamptz`. A float compared with an int8 column was bound as + int8 and rejected, and a param in a select list was bound as text. +- `ParamStyle.placeholder` receives the param kind as a second argument. +- Docs: exact int8 needs a codec that reads a `bigint` as well as a string; PGlite and + postgres.js send `bigint`, so the documented `custom("int8", Schema.String)` failed there. - Rename the package to `@maple-dev/effect-orm` and the repository to `MapleTechLabs/effect-orm`. Imports, error `_tag` prefixes (`@maple-dev/effect-orm/QueryBuilderError`, ...) and the live-test variables (`EFFECT_ORM_CLICKHOUSE_URL`, `_USER`, `_PASSWORD`) change with it. diff --git a/docs/testing.md b/docs/testing.md index 1bd7531..68fbb2b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -66,6 +66,29 @@ suite additionally checks unmatched joins under both settings and DateTime64 bou Fixtures pin the session timezone to UTC, matching the timestamp codecs' wire contract. Both JSON and JSONEachRow response formats are exercised. +## One builder suite, every dialect + +`tests/core-cases.ts` holds the builder cases every dialect runs: selection, the shared +operators, params, grouping, joins on tables and subqueries, CTEs, unions, routing, and +`format`. A case builds its query from a `CoreContext`, which supplies the dialect's column +types, aggregate catalog and `compile`, and reads the same fixture rows on every database +(a `WITH` over `values(...)` on ClickHouse and `VALUES` on Postgres, so nothing is written). + +- `tests/core.clickhouse.test.ts` runs them live, under both `join_use_nulls` settings. +- `tests/core.postgres.test.ts` runs them on PGlite (Postgres 17) on every `vitest run`. +- `tests/core-sql.test.ts` snapshots the exact SQL and `parameters` per dialect. + +Where the databases genuinely disagree, the case says so: `expectedBy` gives a target its +own rows (`/` is integer division on Postgres; ClickHouse fills a missing join row with +defaults unless `join_use_nulls=1`), and `rejects` names a dialect that must refuse to +compile it (`format` on Postgres). A case a dialect cannot run yet goes in `coreSkips` +with a reason. The core manifest in `tests/dialect-coverage.test.ts` requires every query +and union method, expression and condition operator, and param kind to have a core case. + +Postgres functions and types have their own manifest: every export of the `./postgres` +entry is run by a case in `tests/dialect-cases.postgres.ts` (`tests/dialect.postgres.test.ts`) +or exempted with a reason. + Tests preserve documented behavior: arithmetic chains follow SQL precedence, not call order, and `windowFunnel` with `strict_order` rejects intervening events. diff --git a/tests/__snapshots__/core-sql.test.ts.snap b/tests/__snapshots__/core-sql.test.ts.snap new file mode 100644 index 0000000..41969fe --- /dev/null +++ b/tests/__snapshots__/core-sql.test.ts.snap @@ -0,0 +1,973 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`core SQL (clickhouse) > arithmetic 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Amount + 1 AS added, + orders.Amount - 1 AS subtracted, + orders.Amount * 2 AS multiplied, + orders.Amount % 3 AS remainder, + orders.Amount / 2 AS divided + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id = 4", +} +`; + +exports[`core SQL (clickhouse) > comparisons 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status != 'void' + AND orders.Amount > 5 + AND orders.Amount >= 10 + AND orders.Amount < 20 + AND orders.Amount <= 10", +} +`; + +exports[`core SQL (clickhouse) > cross-join 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + c.Name AS customer + FROM orders + CROSS JOIN customers AS c + WHERE orders.OrgId = 'org_1' + AND c.OrgId = 'org_1' + AND orders.Id <= 2 + ORDER BY id ASC, customer ASC", +} +`; + +exports[`core SQL (clickhouse) > cross-join-subquery 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + k.customers AS customers + FROM orders + CROSS JOIN (WITH orders AS ( + +), +customers AS ( + +) +SELECT + count() AS customers + FROM customers + WHERE customers.OrgId = 'org_1') AS k + WHERE orders.OrgId = 'org_1' + AND orders.Id <= 2 + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > cte 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +), +paid AS ( +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Customer AS Customer, + orders.Amount AS Amount + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status = 'paid' +) +SELECT + sum(paid.Amount) AS total + FROM paid", +} +`; + +exports[`core SQL (clickhouse) > format 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id = 1 + FORMAT JSON", +} +`; + +exports[`core SQL (clickhouse) > from-subquery 1`] = ` +{ + "parameters": [], + "sql": "SELECT + t.customer AS customer, + t.total AS total + FROM (WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.OrgId AS OrgId, + orders.Customer AS customer, + sum(orders.Amount) AS total + FROM orders + WHERE orders.OrgId = 'org_1' + GROUP BY OrgId, customer) AS t + WHERE t.total > 6 + ORDER BY total DESC", +} +`; + +exports[`core SQL (clickhouse) > group-having 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Customer AS customer, + count() AS orders, + countIf(orders.Status = 'paid') AS paid, + sum(orders.Amount) AS total, + min(orders.Amount) AS smallest, + max(orders.Amount) AS largest + FROM orders + WHERE orders.OrgId = 'org_1' + GROUP BY customer + HAVING count() >= 2 + ORDER BY customer ASC", +} +`; + +exports[`core SQL (clickhouse) > inner-join 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + c.Tier AS tier + FROM orders + INNER JOIN customers AS c ON (orders.Customer = c.Name AND orders.OrgId = c.OrgId) + WHERE orders.OrgId = 'org_1' + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > join-subqueries 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + customers.Name AS name, + t.tier AS tier, + n.orders AS orders + FROM customers + INNER JOIN (WITH orders AS ( + +), +customers AS ( + +) +SELECT + customers.Name AS name, + customers.Tier AS tier + FROM customers + WHERE customers.OrgId = 'org_1' + AND customers.Tier != 'silver') AS t ON customers.Name = t.name + LEFT JOIN (WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Customer AS customer, + count() AS orders + FROM orders + WHERE orders.OrgId = 'org_1' + GROUP BY customer) AS n ON customers.Name = n.customer + WHERE customers.OrgId = 'org_1' + ORDER BY name ASC", +} +`; + +exports[`core SQL (clickhouse) > left-join 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + c.Tier AS tier + FROM orders + LEFT JOIN customers AS c ON (orders.Customer = c.Name AND orders.OrgId = c.OrgId) + WHERE orders.OrgId = 'org_1' + AND orders.Id >= 3 + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > limit-offset 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + ORDER BY id DESC + LIMIT 2 + OFFSET 1", +} +`; + +exports[`core SQL (clickhouse) > nulls-and-literals 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + orders.Note AS note, + coalesce(orders.Note, '-') AS shown, + 'x\\'y' AS label + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id IN (2, 4) + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > params 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + orders.Created AS created + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Amount >= 7 + AND orders.Amount < 19.5 + AND orders.Created >= '2026-01-01 00:00:00' + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > patterns-and-lists 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND (orders.Customer LIKE '%e%' AND orders.Customer NOT LIKE 'i%') + AND (orders.Status ILIKE 'PA%' OR orders.Status IN ('open')) + AND orders.Id NOT IN (3) + AND NOT (orders.Id = 2) + ORDER BY id ASC", +} +`; + +exports[`core SQL (clickhouse) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + count() AS orders + FROM orders", +} +`; + +exports[`core SQL (clickhouse) > select-columns 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS Id, + orders.Customer AS Customer + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status = 'paid' + ORDER BY Id ASC", +} +`; + +exports[`core SQL (clickhouse) > selected-param 1`] = ` +{ + "parameters": [], + "sql": "WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id, + 1 AS flagged + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id = 1", +} +`; + +exports[`core SQL (clickhouse) > union 1`] = ` +{ + "parameters": [], + "sql": "SELECT * FROM ( +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Status AS status, + sum(orders.Amount) AS total + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status = 'paid' + GROUP BY status +UNION ALL +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Status AS status, + sum(orders.Amount) AS total + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status = 'open' + GROUP BY status +UNION ALL +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Status AS status, + sum(orders.Amount) AS total + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Status = 'void' + GROUP BY status +) +ORDER BY total DESC +LIMIT 2 +OFFSET 1", +} +`; + +exports[`core SQL (clickhouse) > union-format 1`] = ` +{ + "parameters": [], + "sql": "SELECT * FROM ( +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id = 1 +UNION ALL +WITH orders AS ( + +), +customers AS ( + +) +SELECT + orders.Id AS id + FROM orders + WHERE orders.OrgId = 'org_1' + AND orders.Id = 2 +) +ORDER BY id ASC +FORMAT JSON", +} +`; + +exports[`core SQL (postgres) > arithmetic 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Amount" + 1 AS "added", + "orders"."Amount" - 1 AS "subtracted", + "orders"."Amount" * 2 AS "multiplied", + "orders"."Amount" % 3 AS "remainder", + "orders"."Amount" / 2 AS "divided" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" = 4", +} +`; + +exports[`core SQL (postgres) > comparisons 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" != 'void' + AND "orders"."Amount" > 5 + AND "orders"."Amount" >= 10 + AND "orders"."Amount" < 20 + AND "orders"."Amount" <= 10", +} +`; + +exports[`core SQL (postgres) > cross-join 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Name" AS "customer" + FROM "orders" + CROSS JOIN "customers" AS "c" + WHERE "orders"."OrgId" = $1 + AND "c"."OrgId" = 'org_1' + AND "orders"."Id" <= 2 + ORDER BY "id" ASC, "customer" ASC", +} +`; + +exports[`core SQL (postgres) > cross-join-subquery 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "k"."customers" AS "customers" + FROM "orders" + CROSS JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "customers" + FROM "customers" + WHERE "customers"."OrgId" = $1) AS "k" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" <= 2 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > cte 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +), +"paid" AS ( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "Customer", + "orders"."Amount" AS "Amount" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = 'paid' +) +SELECT + sum("paid"."Amount") AS "total" + FROM "paid"", +} +`; + +exports[`core SQL (postgres) > from-subquery 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "SELECT + "t"."customer" AS "customer", + "t"."total" AS "total" + FROM (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."OrgId" AS "OrgId", + "orders"."Customer" AS "customer", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + GROUP BY 1, 2) AS "t" + WHERE "t"."total" > 6 + ORDER BY "total" DESC", +} +`; + +exports[`core SQL (postgres) > group-having 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders", + count(*) FILTER (WHERE "orders"."Status" = 'paid') AS "paid", + sum("orders"."Amount") AS "total", + min("orders"."Amount") AS "smallest", + max("orders"."Amount") AS "largest" + FROM "orders" + WHERE "orders"."OrgId" = $1 + GROUP BY 1 + HAVING count(*) >= 2 + ORDER BY "customer" ASC", +} +`; + +exports[`core SQL (postgres) > inner-join 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + INNER JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = $1 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > join-subqueries 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "t"."tier" AS "tier", + "n"."orders" AS "orders" + FROM "customers" + INNER JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "customers"."Name" AS "name", + "customers"."Tier" AS "tier" + FROM "customers" + WHERE "customers"."OrgId" = $1 + AND "customers"."Tier" != 'silver') AS "t" ON "customers"."Name" = "t"."name" + LEFT JOIN (WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Customer" AS "customer", + count(*) AS "orders" + FROM "orders" + WHERE "orders"."OrgId" = $1 + GROUP BY 1) AS "n" ON "customers"."Name" = "n"."customer" + WHERE "customers"."OrgId" = $1 + ORDER BY "name" ASC", +} +`; + +exports[`core SQL (postgres) > left-join 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "c"."Tier" AS "tier" + FROM "orders" + LEFT JOIN "customers" AS "c" ON ("orders"."Customer" = "c"."Name" AND "orders"."OrgId" = "c"."OrgId") + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" >= 3 + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > limit-offset 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + ORDER BY "id" DESC + LIMIT 2 + OFFSET 1", +} +`; + +exports[`core SQL (postgres) > nulls-and-literals 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Note" AS "note", + coalesce("orders"."Note", '-') AS "shown", + 'x''y' AS "label" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Id" IN (2, 4) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > params 1`] = ` +{ + "parameters": [ + "org_1", + 7, + 19.5, + "2026-01-01T00:00:00.000Z", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + "orders"."Created" AS "created" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Amount" >= $2 + AND "orders"."Amount" < $3::float8 + AND "orders"."Created" >= $4::timestamptz + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > patterns-and-lists 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND ("orders"."Customer" LIKE '%e%' AND "orders"."Customer" NOT LIKE 'i%') + AND ("orders"."Status" ILIKE 'PA%' OR "orders"."Status" IN ('open')) + AND "orders"."Id" NOT IN (3) + AND NOT ("orders"."Id" = 2) + ORDER BY "id" ASC", +} +`; + +exports[`core SQL (postgres) > route-and-cross-tenant 1`] = ` +{ + "parameters": [], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + count(*) AS "orders" + FROM "orders"", +} +`; + +exports[`core SQL (postgres) > select-columns 1`] = ` +{ + "parameters": [ + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "Id", + "orders"."Customer" AS "Customer" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = 'paid' + ORDER BY "Id" ASC", +} +`; + +exports[`core SQL (postgres) > selected-param 1`] = ` +{ + "parameters": [ + true, + "org_1", + ], + "sql": "WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Id" AS "id", + $1::boolean AS "flagged" + FROM "orders" + WHERE "orders"."OrgId" = $2 + AND "orders"."Id" = 1", +} +`; + +exports[`core SQL (postgres) > union 1`] = ` +{ + "parameters": [ + "org_1", + "paid", + "open", + "void", + ], + "sql": "SELECT * FROM ( +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $2 + GROUP BY 1 +) +UNION ALL +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $3 + GROUP BY 1 +) +UNION ALL +( +WITH "orders" AS ( + +), +"customers" AS ( + +) +SELECT + "orders"."Status" AS "status", + sum("orders"."Amount") AS "total" + FROM "orders" + WHERE "orders"."OrgId" = $1 + AND "orders"."Status" = $4 + GROUP BY 1 +) +) AS "__union" +ORDER BY "total" DESC +LIMIT 2 +OFFSET 1", +} +`; diff --git a/tests/core-cases.ts b/tests/core-cases.ts new file mode 100644 index 0000000..56bf88e --- /dev/null +++ b/tests/core-cases.ts @@ -0,0 +1,620 @@ +// The core builder suite: one list of cases, run against every dialect. +// +// Each case builds its query from a `CoreContext`, which hides the only things +// that differ per dialect (column types, the aggregate catalog, `compile`). The +// rows come from the same fixture on every database, so an expected row that +// holds on ClickHouse must hold on Postgres too, or the case says why not +// (`expected` per target, or `rejects` for a clause a dialect refuses). +import { DateTime } from "effect" +import * as CH from "@maple-dev/effect-orm" +import * as T from "@maple-dev/effect-orm/types" +import * as PG from "@maple-dev/effect-orm/postgres" + +export type DialectName = "clickhouse" | "postgres" +/** ClickHouse runs twice: `join_use_nulls=0` fills a missing join row with defaults. */ +export type Target = "clickhouse" | "clickhouse-join-nulls" | "postgres" + +type Col = T.CHType +type Orders = { + readonly OrgId: Col + readonly Id: Col + readonly Customer: Col + readonly Amount: Col + readonly Status: Col + readonly Note: Col + readonly Created: Col +} +type Customers = { readonly OrgId: Col; readonly Name: Col; readonly Tier: Col } + +export interface CoreContext { + readonly dialect: DialectName + readonly orders: CH.Table<"orders", Orders> + readonly customers: CH.Table<"customers", Customers> + /** `from(table)` with the fixture CTEs attached, so every database reads the same rows. */ + readonly from: ( + table: CH.Table, + ) => CH.CHQuery + readonly types: { readonly text: Col; readonly int: Col } + readonly fn: { + readonly count: () => CH.Expr + readonly countIf: (condition: CH.Condition) => CH.Expr + readonly sum: (expr: CH.Expr) => CH.Expr + readonly min: (expr: CH.Expr) => CH.Expr + readonly max: (expr: CH.Expr) => CH.Expr + readonly coalesce: (expr: CH.Expr, fallback: CH.Expr) => CH.Expr + } + readonly compile: (query: CH.CHQuery, params?: Record) => CH.CompiledQuery + readonly compileUnion: (union: CH.CHUnionQuery, params?: Record) => CH.CompiledQuery +} + +export interface CoreCase { + readonly id: string + readonly covers: readonly string[] + readonly build: (ctx: CoreContext) => CH.CompiledQuery + /** Rows every target returns, unless `expectedBy` names the target. */ + readonly expected?: readonly unknown[] + readonly expectedBy?: Partial> + /** ClickHouse output format, for the `format` cases. */ + readonly format?: "JSON" | "JSONEachRow" + readonly metadata?: { readonly route: string; readonly tenantScope: CH.TenantScope } + /** Dialects that must refuse to compile this case, and the error they give. */ + readonly rejects?: Partial> +} + +export const expectedFor = (fixture: CoreCase, target: Target): readonly unknown[] | undefined => + fixture.expectedBy?.[target] ?? fixture.expected + +// Fixture + +interface FixtureColumn { + readonly name: string + readonly clickhouse: string + readonly postgres: string +} +const fixtureColumns = { + orders: [ + { name: "OrgId", clickhouse: "String", postgres: "text" }, + { name: "Id", clickhouse: "UInt32", postgres: "int4" }, + { name: "Customer", clickhouse: "String", postgres: "text" }, + { name: "Amount", clickhouse: "Int64", postgres: "int8" }, + { name: "Status", clickhouse: "String", postgres: "text" }, + { name: "Note", clickhouse: "Nullable(String)", postgres: "text" }, + { name: "Created", clickhouse: "DateTime64(3, 'UTC')", postgres: "timestamptz" }, + ], + customers: [ + { name: "OrgId", clickhouse: "String", postgres: "text" }, + { name: "Name", clickhouse: "String", postgres: "text" }, + { name: "Tier", clickhouse: "String", postgres: "text" }, + ], +} satisfies Record + +type Value = string | number | null +const fixtureRows: Record>> = { + orders: [ + ["org_1", 1, "acme", 10, "paid", "rush", "2026-01-01 00:00:00.000"], + ["org_1", 2, "acme", 20, "paid", null, "2026-01-01 00:10:00.000"], + ["org_1", 3, "globex", 5, "open", null, "2026-01-01 00:20:00.000"], + ["org_1", 4, "initech", 7, "void", "it's", "2026-01-01 00:30:00.000"], + ["org_2", 5, "acme", 99, "paid", null, "2026-01-01 00:40:00.000"], + ], + customers: [ + ["org_1", "acme", "gold"], + ["org_1", "globex", "silver"], + ["org_1", "umbrella", "bronze"], + ["org_2", "acme", "platinum"], + ], +} + +// Fixture values have no backslashes, so doubling quotes is a complete escape on both databases. +const sqlValue = (value: Value): string => + value === null ? "NULL" : typeof value === "number" ? String(value) : `'${value.replaceAll("'", "''")}'` + +/** The fixture table as a CTE body: no DDL, so the ClickHouse suite stays read-only. */ +export const fixtureSql = (dialect: DialectName, table: keyof typeof fixtureColumns): string => { + const columns: readonly FixtureColumn[] = fixtureColumns[table] + const tuples = fixtureRows[table].map((row) => `(${row.map(sqlValue).join(", ")})`).join(", ") + if (dialect === "clickhouse") { + const structure = columns.map((column) => `${column.name} ${column.clickhouse}`).join(", ") + return `SELECT * FROM values('${structure.replaceAll("'", "\\'")}', ${tuples})` + } + const casts = columns.map((column, i) => `CAST("c${i}" AS ${column.postgres}) AS "${column.name}"`).join(", ") + const names = columns.map((_, i) => `"c${i}"`).join(", ") + return `SELECT ${casts} FROM (VALUES ${tuples}) AS v(${names})` +} + +const withFixtures = (dialect: DialectName) => + (table: CH.Table) => + CH.from(table) + .withCTE("orders", fixtureSql(dialect, "orders")) + .withCTE("customers", fixtureSql(dialect, "customers")) as CH.CHQuery + +// Contexts + +export const clickhouseContext: CoreContext = { + dialect: "clickhouse", + orders: CH.table( + "orders", + { + OrgId: T.string, + Id: T.uint32, + Customer: T.string, + Amount: T.int64, + Status: T.string, + Note: T.nullable(T.string), + Created: T.dateTime64, + }, + { tenantColumn: "OrgId" }, + ), + customers: CH.table("customers", { OrgId: T.string, Name: T.string, Tier: T.string }, { tenantColumn: "OrgId" }), + from: withFixtures("clickhouse"), + types: { text: T.string, int: T.int64 }, + fn: { + count: () => CH.count(), + countIf: (condition) => CH.countIf(condition), + sum: (expr) => CH.sum(expr), + min: (expr) => CH.min(expr), + max: (expr) => CH.max(expr), + coalesce: (expr, fallback) => CH.coalesce(expr, fallback), + }, + compile: (query, params = {}) => CH.compileUnsafe(query, params), + compileUnion: (union, params = {}) => CH.compileUnionUnsafe(union, params), +} + +export const postgresContext: CoreContext = { + dialect: "postgres", + orders: CH.table( + "orders", + { + OrgId: PG.text, + Id: PG.int4, + Customer: PG.text, + Amount: PG.int8, + Status: PG.text, + Note: PG.nullable(PG.text), + Created: PG.timestamptz, + }, + { tenantColumn: "OrgId" }, + ), + customers: CH.table("customers", { OrgId: PG.text, Name: PG.text, Tier: PG.text }, { tenantColumn: "OrgId" }), + from: withFixtures("postgres"), + types: { text: PG.text, int: PG.int8 }, + fn: { + count: () => PG.count(), + countIf: (condition) => PG.countIf(condition), + sum: (expr) => PG.sum(expr), + min: (expr) => PG.min(expr), + max: (expr) => PG.max(expr), + coalesce: (expr, fallback) => PG.coalesce(expr, fallback), + }, + compile: (query, params = {}) => PG.compileUnsafe(query, params), + compileUnion: (union, params = {}) => PG.compileUnionUnsafe(union, params), +} + +export const contexts: Record = { + clickhouse: clickhouseContext, + postgres: postgresContext, +} + +// Cases + +const org = { orgId: "org_1" } +const q = (...names: string[]) => names.map((name) => `query:${name}`) +const u = (...names: string[]) => names.map((name) => `union:${name}`) +const e = (...names: string[]) => names.map((name) => `expr:${name}`) +const c = (...names: string[]) => names.map((name) => `condition:${name}`) +const p = (...names: string[]) => names.map((name) => `param:${name}`) + +/** The org's orders, a starting point most cases narrow further. */ +const orgOrders = (ctx: CoreContext) => + ctx.from(ctx.orders).where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + +export const coreCases: readonly CoreCase[] = [ + { + id: "select-columns", + covers: [...q("select", "where", "orderBy"), ...e("eq"), ...p("string"), "function:from"], + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select("Id", "Customer") + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Status.eq("paid")]) + .orderBy(["Id", "asc"]), + org, + ), + expected: [ + { Id: 1, Customer: "acme" }, + { Id: 2, Customer: "acme" }, + ], + }, + { + id: "comparisons", + covers: e("neq", "gt", "gte", "lt", "lte"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ id: $.Id })) + .where(($) => [ + $.OrgId.eq(CH.param.string("orgId")), + $.Status.neq("void"), + $.Amount.gt(5), + $.Amount.gte(10), + $.Amount.lt(20), + $.Amount.lte(10), + ]), + org, + ), + expected: [{ id: 1 }], + }, + { + id: "patterns-and-lists", + covers: [...e("like", "notLike", "ilike", "in_", "notIn"), ...c("and", "or"), "function:not"], + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ id: $.Id })) + .where(($) => [ + $.OrgId.eq(CH.param.string("orgId")), + $.Customer.like("%e%").and($.Customer.notLike("i%")), + $.Status.ilike("PA%").or($.Status.in_("open")), + $.Id.notIn(3), + CH.not($.Id.eq(2)), + ]) + .orderBy(["id", "asc"]), + org, + ), + // acme (1, 2) and globex (3) match the patterns; 3 and 2 are excluded by notIn and not. + expected: [{ id: 1 }], + }, + { + id: "arithmetic", + covers: e("add", "sub", "mul", "mod", "div"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ + added: $.Amount.add(1), + subtracted: $.Amount.sub(1), + multiplied: $.Amount.mul(2), + remainder: $.Amount.mod(3), + divided: $.Amount.div(2), + })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(4)]), + org, + ), + // `/` is float division on ClickHouse and integer division on two Postgres integers. + expectedBy: { + clickhouse: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], + "clickhouse-join-nulls": [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3.5 }], + postgres: [{ added: 8, subtracted: 6, multiplied: 14, remainder: 1, divided: 3 }], + }, + }, + { + id: "nulls-and-literals", + covers: ["function:lit"], + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ id: $.Id, note: $.Note, shown: ctx.fn.coalesce($.Note, CH.lit("-")), label: CH.lit("x'y") })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.in_(2, 4)]) + .orderBy(["id", "asc"]), + org, + ), + expected: [ + { id: 2, note: null, shown: "-", label: "x'y" }, + { id: 4, note: "it's", shown: "it's", label: "x'y" }, + ], + }, + { + id: "group-having", + covers: q("groupBy", "having"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ + customer: $.Customer, + orders: ctx.fn.count(), + paid: ctx.fn.countIf($.Status.eq("paid")), + total: ctx.fn.sum($.Amount), + smallest: ctx.fn.min($.Amount), + largest: ctx.fn.max($.Amount), + })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .groupBy("customer") + .having(() => [ctx.fn.count().gte(2)]) + .orderBy(["customer", "asc"]), + org, + ), + expected: [{ customer: "acme", orders: 2, paid: 2, total: 30, smallest: 10, largest: 20 }], + }, + { + id: "limit-offset", + covers: q("limit", "offset"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ id: $.Id })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .orderBy(["id", "desc"]) + .limit(2) + .offset(1), + org, + ), + expected: [{ id: 3 }, { id: 2 }], + }, + { + id: "params", + covers: p("int", "float", "dateTime", "of"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(($) => ({ id: $.Id, created: $.Created })) + .where(($) => [ + $.OrgId.eq(CH.param.of(ctx.types.text, "orgId")), + $.Amount.gte(CH.param.int("min")), + $.Amount.lt(CH.param.float("max")), + $.Created.gte(CH.param.dateTime("start")), + ]) + .orderBy(["id", "asc"]), + { ...org, min: 7, max: 19.5, start: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z") }, + ), + expected: [ + { id: 1, created: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z") }, + { id: 4, created: DateTime.makeUnsafe("2026-01-01T00:30:00.000Z") }, + ], + }, + { + id: "selected-param", + covers: p("bool"), + build: (ctx) => + ctx.compile( + orgOrders(ctx) + .select(($) => ({ id: $.Id, flagged: CH.param.bool("flag") })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(1)]), + { ...org, flag: true }, + ), + expected: [{ id: 1, flagged: true }], + }, + { + id: "inner-join", + covers: q("innerJoin"), + build: (ctx) => + ctx.compile( + orgOrders(ctx) + .innerJoin(ctx.customers, "c", (o, c) => o.Customer.eq(c.Name).and(o.OrgId.eq(c.OrgId))) + .select(($) => ({ id: $.Id, tier: $.c.Tier })) + .orderBy(["id", "asc"]), + org, + ), + expected: [ + { id: 1, tier: "gold" }, + { id: 2, tier: "gold" }, + { id: 3, tier: "silver" }, + ], + }, + { + id: "left-join", + covers: q("leftJoin"), + build: (ctx) => + ctx.compile( + orgOrders(ctx) + .leftJoin(ctx.customers, "c", (o, c) => o.Customer.eq(c.Name).and(o.OrgId.eq(c.OrgId))) + .select(($) => ({ id: $.Id, tier: $.c.Tier })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.gte(3)]) + .orderBy(["id", "asc"]), + org, + ), + // initech has no customer row: NULL, or the column default without join_use_nulls. + expected: [ + { id: 3, tier: "silver" }, + { id: 4, tier: null }, + ], + expectedBy: { + clickhouse: [ + { id: 3, tier: "silver" }, + { id: 4, tier: "" }, + ], + }, + }, + { + id: "cross-join", + covers: q("crossJoin"), + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .crossJoin(ctx.customers, "c") + .select(($) => ({ id: $.Id, customer: $.c.Name })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.c.OrgId.eq("org_1"), $.Id.lte(2)]) + .orderBy(["id", "asc"], ["customer", "asc"]), + org, + ), + expected: [ + { id: 1, customer: "acme" }, + { id: 1, customer: "globex" }, + { id: 1, customer: "umbrella" }, + { id: 2, customer: "acme" }, + { id: 2, customer: "globex" }, + { id: 2, customer: "umbrella" }, + ], + }, + { + id: "join-subqueries", + covers: q("innerJoinQuery", "leftJoinQuery"), + build: (ctx) => { + const tiers = ctx + .from(ctx.customers) + .select(($) => ({ name: $.Name, tier: $.Tier })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Tier.neq("silver")]) + const counts = ctx + .from(ctx.orders) + .select(($) => ({ customer: $.Customer, orders: ctx.fn.count() })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .groupBy("customer") + return ctx.compile( + ctx + .from(ctx.customers) + .innerJoinQuery(tiers, "t", (c, t) => c.Name.eq(t.name)) + .leftJoinQuery(counts, "n", (c, n) => c.Name.eq(n.customer)) + .select(($) => ({ name: $.Name, tier: $.t.tier, orders: $.n.orders })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .orderBy(["name", "asc"]), + org, + ) + }, + // umbrella has no orders: NULL, or 0 without join_use_nulls. + expected: [ + { name: "acme", tier: "gold", orders: 2 }, + { name: "umbrella", tier: "bronze", orders: null }, + ], + expectedBy: { + clickhouse: [ + { name: "acme", tier: "gold", orders: 2 }, + { name: "umbrella", tier: "bronze", orders: 0 }, + ], + }, + }, + { + id: "cross-join-subquery", + covers: q("crossJoinQuery"), + build: (ctx) => { + const customers = ctx + .from(ctx.customers) + .select(() => ({ customers: ctx.fn.count() })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + return ctx.compile( + orgOrders(ctx) + .crossJoinQuery(customers, "k") + .select(($) => ({ id: $.Id, customers: $.k.customers })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.lte(2)]) + .orderBy(["id", "asc"]), + org, + ) + }, + expected: [ + { id: 1, customers: 3 }, + { id: 2, customers: 3 }, + ], + }, + { + id: "from-subquery", + covers: ["function:fromQuery"], + build: (ctx) => { + const totals = ctx + .from(ctx.orders) + .select(($) => ({ OrgId: $.OrgId, customer: $.Customer, total: ctx.fn.sum($.Amount) })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .groupBy("OrgId", "customer") + return ctx.compile( + CH.fromQuery(totals, "t") + .select(($) => ({ customer: $.customer, total: $.total })) + .where(($) => [$.total.gt(6)]) + .orderBy(["total", "desc"]), + org, + ) + }, + expected: [ + { customer: "acme", total: 30 }, + { customer: "initech", total: 7 }, + ], + }, + { + id: "cte", + covers: q("withCTE"), + build: (ctx) => { + const paid = CH.table("paid", { Customer: ctx.types.text, Amount: ctx.types.int }) + return ctx.compile( + ctx + .from(paid) + .withCTE( + "paid", + ctx + .from(ctx.orders) + .select(($) => ({ Customer: $.Customer, Amount: $.Amount })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Status.eq("paid")]), + ) + .select(($) => ({ total: ctx.fn.sum($.Amount) })), + org, + ) + }, + expected: [{ total: 30 }], + }, + { + id: "union", + covers: [...u("orderBy", "limit", "offset"), "function:unionAll"], + build: (ctx) => { + const status = (name: string) => + ctx + .from(ctx.orders) + .select(($) => ({ status: $.Status, total: ctx.fn.sum($.Amount) })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Status.eq(CH.param.string(name))]) + .groupBy("status") + return ctx.compileUnion( + CH.unionAll(status("first"), status("second"), status("third")) + .orderBy(["total", "desc"]) + .limit(2) + .offset(1), + { ...org, first: "paid", second: "open", third: "void" }, + ) + }, + expected: [ + { status: "void", total: 7 }, + { status: "open", total: 5 }, + ], + }, + { + id: "route-and-cross-tenant", + covers: q("route", "crossTenant"), + metadata: { route: "reports", tenantScope: "cross-tenant" }, + build: (ctx) => + ctx.compile( + ctx + .from(ctx.orders) + .select(() => ({ orders: ctx.fn.count() })) + .route("reports") + .crossTenant(), + ), + expected: [{ orders: 5 }], + }, + { + id: "format", + covers: q("format"), + format: "JSON", + rejects: { postgres: /no FORMAT clause/ }, + build: (ctx) => + ctx.compile( + orgOrders(ctx) + .select(($) => ({ id: $.Id })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(1)]) + .format("JSON"), + org, + ), + expected: [{ id: 1 }], + }, + { + id: "union-format", + covers: u("format"), + format: "JSON", + rejects: { postgres: /no FORMAT clause/ }, + build: (ctx) => { + const one = (id: number) => + orgOrders(ctx) + .select(($) => ({ id: $.Id })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(id)]) + return ctx.compileUnion(CH.unionAll(one(1), one(2)).orderBy(["id", "asc"]).format("JSON"), org) + }, + expected: [{ id: 1 }, { id: 2 }], + }, +] + +/** Cases a dialect cannot run yet, each with the reason. Empty is the goal. */ +export const coreSkips: Record> = { + clickhouse: {}, + postgres: {}, +} diff --git a/tests/core-sql.test.ts b/tests/core-sql.test.ts new file mode 100644 index 0000000..676abf1 --- /dev/null +++ b/tests/core-sql.test.ts @@ -0,0 +1,26 @@ +// The exact SQL and bound parameters each core case compiles to, per dialect. +// The live suites prove the SQL runs; these pin it, so a change in output is a +// reviewed snapshot diff rather than a surprise. Fixture CTE bodies are elided. +import { describe, expect, it } from "vitest" +import { contexts, coreCases, fixtureSql, type DialectName } from "./core-cases" + +const elideFixtures = (dialect: DialectName, sql: string) => + sql + .replaceAll(fixtureSql(dialect, "orders"), "") + .replaceAll(fixtureSql(dialect, "customers"), "") + +for (const dialect of ["clickhouse", "postgres"] as const) { + describe(`core SQL (${dialect})`, () => { + for (const fixture of coreCases) { + it(fixture.id, () => { + const rejects = fixture.rejects?.[dialect] + if (rejects) { + expect(() => fixture.build(contexts[dialect])).toThrow(rejects) + return + } + const compiled = fixture.build(contexts[dialect]) + expect({ sql: elideFixtures(dialect, compiled.sql), parameters: compiled.parameters }).toMatchSnapshot() + }) + } + }) +} diff --git a/tests/core.clickhouse.test.ts b/tests/core.clickhouse.test.ts new file mode 100644 index 0000000..3652e05 --- /dev/null +++ b/tests/core.clickhouse.test.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" +import { FetchHttpClient } from "effect/http" +import { describe, expect, it } from "@effect/vitest" +import { clickhouseContext, coreCases, coreSkips, expectedFor } from "./core-cases" +import { endpoint, execute } from "./clickhouse-support" + +it.layer(FetchHttpClient.layer)("core builder suite on ClickHouse", (it) => { + describe.skipIf(!endpoint)("live", () => { + for (const [target, setting] of [ + ["clickhouse", "0"], + ["clickhouse-join-nulls", "1"], + ] as const) { + for (const fixture of coreCases) { + if (fixture.rejects?.clickhouse || Object.hasOwn(coreSkips.clickhouse, fixture.id)) continue + it.effect(`${fixture.id} (${target})`, () => + Effect.gen(function* () { + const compiled = fixture.build(clickhouseContext) + if (fixture.metadata) expect(compiled).toMatchObject(fixture.metadata) + const { rows } = yield* execute( + compiled, + { output_format_json_quote_64bit_integers: setting, join_use_nulls: setting }, + fixture.format, + ) + expect(rows, compiled.sql).toEqual(expectedFor(fixture, target)) + }), + ) + } + } + }) +}) diff --git a/tests/core.postgres.test.ts b/tests/core.postgres.test.ts new file mode 100644 index 0000000..9b55ffa --- /dev/null +++ b/tests/core.postgres.test.ts @@ -0,0 +1,23 @@ +// The core builder suite on Postgres 17 (PGlite, in-process), so it runs on every `vitest run`. +import { PGlite } from "@electric-sql/pglite" +import { afterAll, describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { coreCases, coreSkips, expectedFor, postgresContext } from "./core-cases" +import { runOn } from "./postgres-support" + +const db = new PGlite() +afterAll(() => db.close()) + +describe("core builder suite on Postgres", () => { + for (const fixture of coreCases) { + if (fixture.rejects?.postgres || Object.hasOwn(coreSkips.postgres, fixture.id)) continue + it.effect(fixture.id, () => + Effect.gen(function* () { + const compiled = fixture.build(postgresContext) + if (fixture.metadata) expect(compiled).toMatchObject(fixture.metadata) + const rows = yield* runOn(db, compiled) + expect(rows, compiled.sql).toEqual(expectedFor(fixture, "postgres")) + }), + ) + } +}) diff --git a/tests/dialect-cases.postgres.ts b/tests/dialect-cases.postgres.ts new file mode 100644 index 0000000..7185544 --- /dev/null +++ b/tests/dialect-cases.postgres.ts @@ -0,0 +1,274 @@ +// Postgres function and type fixtures: every export of the `./postgres` entry is +// run here (or exempted in dialect-coverage.test.ts with a reason). Shared +// builder behaviour lives in core-cases.ts and runs on every dialect. +import { DateTime, Effect, Schema } from "effect" +import { expect } from "vitest" +import * as CH from "@maple-dev/effect-orm" +import * as PG from "@maple-dev/effect-orm/postgres" +import { postgresContext as ctx } from "./core-cases" + +export interface PostgresCase { + readonly id: string + readonly covers: readonly string[] + readonly build: () => CH.CompiledQuery + readonly expected: readonly unknown[] +} + +const pg = (...names: string[]) => names.map((name) => `pg:${name}`) +const org = { orgId: "org_1" } +const orgOrders = () => ctx.from(ctx.orders).where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) +const utc = (iso: string) => DateTime.makeUnsafe(iso) + +// One row of every column type, read back through the declared codecs. The +// `*Text` columns are text on the server, so they decode from the string wire +// form a driver without type parsers sends. +const typed = CH.table("typed", { + Text: PG.text, + Uuid: PG.uuid, + Bool: PG.bool, + Int2: PG.int2, + Int4: PG.int4, + Int8: PG.int8, + Int8Text: PG.int8, + Float4: PG.float4, + Float8: PG.float8, + Numeric: PG.numeric, + Exact: PG.custom("int8", Schema.Union([Schema.BigInt, Schema.BigIntFromString])), + At: PG.timestamptz, + AtText: PG.timestamptz, + // Compared only: string-typed, for the dateTimeString and dateTimeSeconds params. + AtString: PG.custom("timestamptz", Schema.String), + Doc: PG.jsonb(Schema.Struct({ region: Schema.String })), + Tags: PG.array(PG.text), + Missing: PG.nullable(PG.int4), +}) +const typedRow = `SELECT + 'a''b'::text AS "Text", + '00000000-0000-4000-8000-000000000001'::uuid AS "Uuid", + true AS "Bool", + 2::int2 AS "Int2", + 4::int4 AS "Int4", + 8::int8 AS "Int8", + '12'::text AS "Int8Text", + 0.5::float4 AS "Float4", + 0.25::float8 AS "Float8", + 1.125::numeric AS "Numeric", + 9007199254740993::int8 AS "Exact", + '2026-01-01T00:00:00.25Z'::timestamptz AS "At", + '2026-01-01 00:00:00.25+00'::text AS "AtText", + '2026-01-01T00:00:00.25Z'::timestamptz AS "AtString", + '{"region": "eu"}'::jsonb AS "Doc", + ARRAY['x', 'y']::text[] AS "Tags", + NULL::int4 AS "Missing"` +const typedRows = () => CH.from(typed).withCTE("typed", typedRow) + +export const postgresCases: readonly PostgresCase[] = [ + { + id: "aggregates", + covers: pg("count", "countDistinct", "countIf", "sum", "sumIf", "avg", "min", "max", "percentileCont", "compileUnsafe"), + build: () => + PG.compileUnsafe( + orgOrders().select(($) => ({ + count: PG.count(), + customers: PG.countDistinct($.Customer), + paid: PG.countIf($.Status.eq("paid")), + total: PG.sum($.Amount), + paidTotal: PG.sumIf($.Amount, $.Status.eq("paid")), + average: PG.avg($.Amount), + smallest: PG.min($.Amount), + largest: PG.max($.Amount), + median: PG.percentileCont(0.5, $.Amount), + })), + org, + ), + expected: [ + { count: 4, customers: 3, paid: 2, total: 42, paidTotal: 30, average: 10.5, smallest: 5, largest: 20, median: 8.5 }, + ], + }, + { + id: "aggregates-over-no-rows", + covers: pg("arrayAgg"), + build: () => + PG.compileUnsafe( + ctx + .from(ctx.orders) + .select(($) => ({ + count: PG.count(), + total: PG.sum($.Amount), + average: PG.avg($.Amount), + smallest: PG.min($.Amount), + ids: PG.arrayAgg($.Id), + })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]), + { orgId: "org_none" }, + ), + // Unlike ClickHouse's defaults, Postgres aggregates over nothing are NULL. + expected: [{ count: 0, total: null, average: null, smallest: null, ids: null }], + }, + { + id: "array-agg", + covers: pg("arrayAgg"), + build: () => + PG.compileUnsafe( + orgOrders() + .select(($) => ({ customer: $.Customer, ids: PG.arrayAgg($.Id) })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Customer.eq("acme")]) + .groupBy("customer"), + org, + ), + expected: [{ customer: "acme", ids: [1, 2] }], + }, + { + id: "time", + covers: pg("dateTrunc", "dateBin", "now"), + build: () => + PG.compileUnsafe( + orgOrders() + .select(($) => ({ hour: PG.dateTrunc("hour", $.Created), bucket: PG.dateBin(1200, $.Created), orders: PG.count() })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Created.lt(PG.now())]) + .groupBy("hour", "bucket") + .orderBy(["bucket", "asc"]), + org, + ), + expected: [ + { hour: utc("2026-01-01T00:00:00Z"), bucket: utc("2026-01-01T00:00:00Z"), orders: 2 }, + { hour: utc("2026-01-01T00:00:00Z"), bucket: utc("2026-01-01T00:20:00Z"), orders: 2 }, + ], + }, + { + id: "strings", + covers: pg("lower", "upper", "length", "coalesce"), + build: () => + PG.compileUnsafe( + orgOrders() + .select(($) => ({ + lower: PG.lower(CH.lit("ACME")), + upper: PG.upper($.Customer), + length: PG.length($.Customer), + note: PG.coalesce($.Note, CH.lit("none")), + })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(3)]), + org, + ), + expected: [{ lower: "acme", upper: "GLOBEX", length: 6, note: "none" }], + }, + { + id: "types", + covers: pg( + "text", + "uuid", + "bool", + "int2", + "int4", + "int8", + "float4", + "float8", + "numeric", + "custom", + "timestamptz", + "jsonb", + "array", + "nullable", + "pgTimestampToIso", + ), + build: () => + PG.compileUnsafe( + typedRows().select( + "Text", + "Uuid", + "Bool", + "Int2", + "Int4", + "Int8", + "Int8Text", + "Float4", + "Float8", + "Numeric", + "Exact", + "At", + "AtText", + "Doc", + "Tags", + "Missing", + ), + {}, + ), + expected: [ + { + Text: "a'b", + Uuid: "00000000-0000-4000-8000-000000000001", + Bool: true, + Int2: 2, + Int4: 4, + Int8: 8, + Int8Text: 12, + Float4: 0.5, + Float8: 0.25, + Numeric: 1.125, + Exact: 9007199254740993n, + At: utc("2026-01-01T00:00:00.250Z"), + AtText: utc("2026-01-01T00:00:00.250Z"), + Doc: { region: "eu" }, + Tags: ["x", "y"], + Missing: null, + }, + ], + }, + { + id: "typed-literals", + covers: pg("PgTimestampLiteral", "jsonText"), + build: () => + PG.compileUnsafe( + typedRows() + .select(($) => ({ region: PG.jsonText($.Doc, "region"), absent: PG.jsonText($.Doc, "zone") })) + .where(($) => [ + $.Text.eq("a'b"), + $.Bool.eq(true), + $.Int8.eq(8), + $.Numeric.gt(1), + $.At.eq(utc("2026-01-01T00:00:00.250Z")), + $.At.gt(new Date("2025-12-31T23:59:59Z")), + $.At.lt("2026-01-01 00:00:01"), + $.AtString.gte(CH.param.dateTimeString("from")), + // 00:00:00.9 floors to 00:00:00, so only a floored bound admits the 0.25s row. + $.AtString.gte(CH.param.dateTimeSeconds("late")), + $.AtString.lt(CH.param.dateTimeSeconds("to")), + $.Doc.eq({ region: "eu" }), + $.Uuid.in_("00000000-0000-4000-8000-000000000001"), + ]), + { from: "2026-01-01 00:00:00", late: new Date("2026-01-01T00:00:00.900Z"), to: new Date("2026-01-01T00:00:01.500Z") }, + ), + expected: [{ region: "eu", absent: null }], + }, + { + id: "compile-entry-points", + covers: pg("compile", "postgresDialect"), + build: () => { + const query = orgOrders() + .select(($) => ({ total: PG.sum($.Amount) })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + // The root compile with an explicit dialect writes the same statement. + const viaRoot = CH.compileUnsafe(query, org, { dialect: PG.postgresDialect }) + const viaEntry = Effect.runSync(PG.compile(query, org)) + expect(viaRoot.sql).toBe(viaEntry.sql) + return viaEntry + }, + expected: [{ total: 42 }], + }, + { + id: "compile-union-entry-points", + covers: pg("compileUnion", "compileUnionUnsafe"), + build: () => { + const branch = (id: number) => + orgOrders() + .select(($) => ({ id: $.Id })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Id.eq(id)]) + const union = CH.unionAll(branch(1), branch(3)).orderBy(["id", "desc"]) + const unsafe = PG.compileUnionUnsafe(union, org) + const effect = Effect.runSync(PG.compileUnion(union, org)) + expect(unsafe.sql).toBe(effect.sql) + return effect + }, + expected: [{ id: 3 }, { id: 1 }], + }, +] diff --git a/tests/dialect-coverage.test.ts b/tests/dialect-coverage.test.ts index 21b8c95..ebf23f3 100644 --- a/tests/dialect-coverage.test.ts +++ b/tests/dialect-coverage.test.ts @@ -2,7 +2,10 @@ import { readFileSync } from "node:fs" import { describe, expect, it } from "vitest" import * as CH from "@maple-dev/effect-orm" import * as T from "@maple-dev/effect-orm/types" +import * as PG from "@maple-dev/effect-orm/postgres" +import { coreCases, coreSkips } from "./core-cases" import { dialectCases, typeCases } from "./dialect-cases" +import { postgresCases } from "./dialect-cases.postgres" // Discover methods and descriptors from built exports; canonical function names // come from the explicit barrel (the root gives some of these friendly aliases). @@ -64,3 +67,82 @@ describe("dialect coverage manifest", () => { for (const reason of Object.values(exemptions)) expect(reason.length).toBeGreaterThan(20) }) }) + +/** Fails on what is neither covered nor exempt, on stale entries, and on exemptions a case now covers. */ +const expectManifest = ( + inventory: readonly string[], + cases: ReadonlyArray<{ readonly id: string; readonly covers: readonly string[] }>, + exempt: Record, +) => { + const covered = new Set(cases.flatMap((fixture) => fixture.covers)) + expect(new Set(cases.map((c) => c.id)).size, "duplicate case ids").toBe(cases.length) + expect( + inventory.filter((name) => !covered.has(name) && !Object.hasOwn(exempt, name)), + "missing coverage", + ).toEqual([]) + expect( + [...covered, ...Object.keys(exempt)].filter((name) => !inventory.includes(name)), + "stale manifest entries", + ).toEqual([]) + expect( + Object.keys(exempt).filter((name) => covered.has(name)), + "remove exemptions once covered", + ).toEqual([]) + for (const reason of Object.values(exempt)) expect(reason.length).toBeGreaterThan(20) +} + +const functionsOf = (object: object, prefix: string) => + Object.entries(object) + .filter(([name, value]) => typeof value === "function" && name !== "toFragment" && name !== "schema") + .map(([name]) => `${prefix}:${name}`) + +// The builder surface every dialect shares: query and union methods, the +// operators on an expression and a condition, param kinds, and the root +// functions that build a query rather than an expression. +export const coreInventory = [ + ...new Set([ + ...methods(one, "query"), + ...methods(CH.unionAll(one, one), "union"), + ...functionsOf(CH.lit(1), "expr"), + ...functionsOf(CH.lit(1).eq(1), "condition"), + ...Object.keys(CH.param).map((name) => `param:${name}`), + ...["from", "fromQuery", "unionAll", "lit", "not"].map((name) => `function:${name}`), + ]), +].sort() + +const coreExemptions = { + "param:dateTimeString": + "Typed for string-decoded timestamp columns, which the shared fixture does not declare. Live on ClickHouse in deep-codecs; on Postgres in the typed-literals fixture.", + "param:dateTimeSeconds": + "Typed for string-decoded timestamp columns, which the shared fixture does not declare. Live on ClickHouse in deep-codecs; on Postgres in the typed-literals fixture.", +} + +describe("core coverage manifest", () => { + it("runs every shared builder method on every dialect, or records why not", () => { + expectManifest(coreInventory, coreCases, coreExemptions) + for (const [dialect, skips] of Object.entries(coreSkips)) { + for (const [id, reason] of Object.entries(skips)) { + expect(coreCases.some((fixture) => fixture.id === id), `${dialect} skips unknown case ${id}`).toBe(true) + expect(reason.length).toBeGreaterThan(20) + } + } + }) +}) + +// Every runtime export of the ./postgres entry. +export const postgresInventory = Object.keys(PG) + .map((name) => `pg:${name}`) + .sort() + +const postgresExemptions = { + "pg:PgNumber": "Wire codec behind every numeric type; the types fixture decodes it from number, bigint and string.", + "pg:timestampLiteral": + "Factory for timestamp literal codecs; its instances (PgTimestampLiteral, dateTimeSeconds) are exercised.", +} + +describe("postgres coverage manifest", () => { + it("runs every export of the postgres entry, or records why not", () => { + expect(postgresInventory.length).toBeGreaterThan(0) + expectManifest(postgresInventory, postgresCases, postgresExemptions) + }) +}) diff --git a/tests/dialect.postgres.test.ts b/tests/dialect.postgres.test.ts new file mode 100644 index 0000000..09f0c77 --- /dev/null +++ b/tests/dialect.postgres.test.ts @@ -0,0 +1,20 @@ +// Every Postgres function and type fixture, executed on Postgres 17 (PGlite). +import { PGlite } from "@electric-sql/pglite" +import { afterAll, describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { postgresCases } from "./dialect-cases.postgres" +import { runOn } from "./postgres-support" + +const db = new PGlite() +afterAll(() => db.close()) + +describe("postgres dialect fixtures", () => { + for (const fixture of postgresCases) { + it.effect(fixture.id, () => + Effect.gen(function* () { + const compiled = fixture.build() + expect(yield* runOn(db, compiled), compiled.sql).toEqual(fixture.expected) + }), + ) + } +}) diff --git a/tests/postgres-support.ts b/tests/postgres-support.ts new file mode 100644 index 0000000..14457f3 --- /dev/null +++ b/tests/postgres-support.ts @@ -0,0 +1,11 @@ +import type { PGlite } from "@electric-sql/pglite" +import { Effect } from "effect" +import type * as CH from "@maple-dev/effect-orm" + +/** Run the compiled SQL with its bound parameters and decode through the query's own codec. */ +export const runOn = Effect.fn("runOn")(function* (db: PGlite, compiled: CH.CompiledQuery) { + const result = yield* Effect.promise(() => db.query>(compiled.sql, [...compiled.parameters])).pipe( + Effect.tapDefect(() => Effect.logError(compiled.sql)), + ) + return yield* compiled.decodeRows(result.rows) +})