From c18fa843a1ab4229a2c004283474b8f05c826f98 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:05:07 +0200 Subject: [PATCH 1/5] feat(query): make CHQuery Pipeable --- src/ch/query.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ch/query.ts b/src/ch/query.ts index 36752b4..61a96ef 100644 --- a/src/ch/query.ts +++ b/src/ch/query.ts @@ -29,6 +29,7 @@ import type { Table } from "./table" import type { Expr, Condition, ColumnRef, ParamsIn, Widen } from "./expr" import { makeColumnRef } from "./expr" import type { TenantScope } from "./compile" +import { type Pipeable, pipeArguments } from "effect/Pipeable" // Type utilities @@ -63,7 +64,7 @@ export type JoinedColumnAccessor< readonly [A in keyof Joins & string]: ColumnAccessor } -type SelectRecord = Record> +export type SelectRecord = Record> /** * Read each selected expression's output type off its `_phantom` property @@ -231,7 +232,7 @@ export interface CHQuery< Route extends string | undefined = string | undefined, /** The `param.*` placeholders the query uses, as `ParamEntry`s. */ Params = never, -> { +> extends Pipeable { /** @internal — runtime query state */ readonly _state: CHQueryState /** phantom */ @@ -574,6 +575,10 @@ function makeQuery< return { _state: state, + pipe() { + return pipeArguments(this, arguments) + }, + select(...args: any[]): any { if (args.length === 0) { return state.fromQuery !== undefined || state.fromUnion !== undefined From a39af62a5c5ca19cec0a0ba1db8fc89da384d293 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:05:07 +0200 Subject: [PATCH 2/5] feat(query): add a data-last Query API as dual functions over the fluent builder --- src/ch/query-pipe.test-d.ts | 96 +++++++++++++ src/ch/query-pipe.test.ts | 104 ++++++++++++++ src/ch/query-pipe.ts | 266 ++++++++++++++++++++++++++++++++++++ src/core.ts | 4 + 4 files changed, 470 insertions(+) create mode 100644 src/ch/query-pipe.test-d.ts create mode 100644 src/ch/query-pipe.test.ts create mode 100644 src/ch/query-pipe.ts diff --git a/src/ch/query-pipe.test-d.ts b/src/ch/query-pipe.test-d.ts new file mode 100644 index 0000000..e02d364 --- /dev/null +++ b/src/ch/query-pipe.test-d.ts @@ -0,0 +1,96 @@ +// Type-level tests: the pipeable, data-last query API types its callbacks and +// its result as the fluent builder does. + +import { expectTypeOf } from "expect-type" +import { pipe } from "effect" +import * as CH from "./index" +import type { InferQueryOutput } from "./query" + +const Tours = CH.table("tours", { OrgId: CH.string, Id: CH.string, StartsAt: CH.dateTime64, Notes: CH.nullable(CH.string) }) +const Units = CH.table("units", { Id: CH.string, TourId: CH.string, Name: CH.string }) + +const { Query } = CH + +// The issue's example: `$` is typed from the query `pipe` carries. +const piped = Query.from(Tours).pipe( + Query.select(($) => ({ id: $.Id, at: $.StartsAt })), + Query.where(($) => [$.OrgId.eq(CH.param.string("orgId"))]), + Query.orderBy(["at", "asc"]), + Query.limit(10), +) +const fluent = CH.from(Tours) + .select(($) => ({ id: $.Id, at: $.StartsAt })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId"))]) + .orderBy(["at", "asc"]) + .limit(10) +expectTypeOf(piped).toEqualTypeOf(fluent) +expectTypeOf>().toEqualTypeOf<{ readonly id: string; readonly at: InferQueryOutput["at"] }>() + +// The param a data-last `where` adds is a param of the query. +// @ts-expect-error -- `orgId` is missing +CH.compileUnsafe(piped, {}) +CH.compileUnsafe(piped, { orgId: "org_1" }) + +// Data-first gives the same type. +expectTypeOf(Query.where(CH.from(Tours).select("Id"), ($) => [$.OrgId.eq("o")])).toEqualTypeOf( + CH.from(Tours) + .select("Id") + .where(($) => [$.OrgId.eq("o")]), +) + +// A reusable part is a plain function. Its `$` is typed from the table it names, +// and the query keeps every column, so a later step can read any of them. +const byOrg = (orgId: string) => Query.where(($: CH.ColumnAccessor) => [$.OrgId.eq(orgId)]) +const reused = Query.from(Tours).pipe(byOrg("org_1"), Query.select("Id", "StartsAt")) +const reusedFluent = CH.from(Tours) + .where(($) => [$.OrgId.eq("org_1")]) + .select("Id", "StartsAt") +export const reusedIsFluent: typeof reusedFluent = reused +export const fluentIsReused: typeof reused = reusedFluent + +// `pipe` from effect works too. +expectTypeOf(pipe(CH.from(Tours), Query.select("Id", "Notes"))).toEqualTypeOf(CH.from(Tours).select("Id", "Notes")) +expectTypeOf(pipe(CH.from(Tours), Query.select())).toEqualTypeOf(CH.from(Tours).select()) + +// An unknown column in a data-last callback is a type error. +CH.from(Tours).pipe( + // @ts-expect-error -- `Nope` is not a column of `tours` + Query.where(($) => [$.Nope.eq("x")]), +) +// @ts-expect-error -- `Nope` is not a column of `tours` +CH.from(Tours).pipe(Query.select("Nope")) + +// orderBy by a key the query does not select is a type error. +CH.from(Tours).pipe( + Query.select("Id"), + // @ts-expect-error -- `at` is not selected + Query.orderBy(["at", "asc"]), +) +// @ts-expect-error -- `asc` or `desc` only +CH.from(Tours).select("Id").pipe(Query.orderBy(["Id", "up"])) +// @ts-expect-error -- a negative limit +CH.from(Tours).select("Id").pipe(Query.limit(-1)) + +// Joins: `$.` reads the joined table; leftJoin makes it nullable. +const joined = CH.from(Tours).pipe( + Query.innerJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)), + Query.select(($) => ({ id: $.Id, unit: $.u.Name })), +) +expectTypeOf(joined).toEqualTypeOf( + CH.from(Tours) + .innerJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)) + .select(($) => ({ id: $.Id, unit: $.u.Name })), +) +const left = CH.from(Tours).pipe( + Query.leftJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)), + Query.select(($) => ({ unit: $.u.Name })), +) +expectTypeOf>().toEqualTypeOf<{ readonly unit: string | null }>() +const crossed = CH.from(Tours).pipe(Query.crossJoin(Units, "u"), Query.select(($) => ({ unit: $.u.Name }))) +expectTypeOf>().toEqualTypeOf<{ readonly unit: string }>() + +CH.from(Tours).pipe( + Query.innerJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)), + // @ts-expect-error -- the alias `u` is in use + Query.innerJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)), +) diff --git a/src/ch/query-pipe.test.ts b/src/ch/query-pipe.test.ts new file mode 100644 index 0000000..aa6ab0d --- /dev/null +++ b/src/ch/query-pipe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" +import * as CH from "./index" +import { compileCHUnsafe } from "./compile" +import * as PG from "../postgres" + +const Users = CH.table( + "users", + { OrgId: CH.string, Id: CH.string, Name: CH.string, Age: CH.uint64 }, + { tenantColumn: "OrgId" }, +) + +// Typed loosely: the point is the SQL text, and the type tests are in query-pipe.test-d.ts. +const sqlOf = (query: { readonly _state: unknown }): string => compileCHUnsafe(query as any, {} as any).sql + +describe("pipeable queries", () => { + it("a query has pipe", () => { + const query = CH.from(Users).select("Id") + expect(query.pipe()).toBe(query) + expect(query.pipe((q) => q.limit(1))._state.limitValue).toBe(1) + }) +}) + +describe("Query.where", () => { + it("data-last and data-first write the fluent SQL", () => { + const base = CH.from(Users).select("Id") + const fluent = base.where(($) => [$.OrgId.eq("org_1")]) + const piped = base.pipe(CH.Query.where(($) => [$.OrgId.eq("org_1")])) + const dataFirst = CH.Query.where(base, ($) => [$.OrgId.eq("org_1")]) + expect(sqlOf(piped)).toBe(sqlOf(fluent)) + expect(sqlOf(dataFirst)).toBe(sqlOf(fluent)) + }) +}) + +describe("Query.select", () => { + it("every form writes the fluent SQL, data-last and data-first", () => { + const base = CH.from(Users) + const callback = ($: any) => ({ id: $.Id, name: $.Name }) + expect(sqlOf(base.pipe(CH.Query.select()))).toBe(sqlOf(base.select())) + expect(sqlOf(CH.Query.select(base))).toBe(sqlOf(base.select())) + expect(sqlOf(base.pipe(CH.Query.select("Id", "Name")))).toBe(sqlOf(base.select("Id", "Name"))) + expect(sqlOf(CH.Query.select(base, "Id", "Name"))).toBe(sqlOf(base.select("Id", "Name"))) + expect(sqlOf(base.pipe(CH.Query.select(($) => ({ id: $.Id }))))).toBe(sqlOf(base.select(($) => ({ id: $.Id })))) + expect(sqlOf(CH.Query.select(base, callback))).toBe(sqlOf(base.select(callback))) + }) +}) + +describe("Query.orderBy and Query.limit", () => { + it("write the fluent SQL, data-last and data-first", () => { + const base = CH.from(Users).select(($) => ({ id: $.Id, age: $.Age })) + const byKey = base.orderBy(["age", "desc"], ["id", "asc"]) + expect(sqlOf(base.pipe(CH.Query.orderBy(["age", "desc"], ["id", "asc"])))).toBe(sqlOf(byKey)) + expect(sqlOf(CH.Query.orderBy(base, ["age", "desc"], ["id", "asc"]))).toBe(sqlOf(byKey)) + const byExpr = base.orderBy(($) => [[$.Name, "asc"]]) + expect(sqlOf(base.pipe(CH.Query.orderBy(($) => [[$.Name, "asc"]])))).toBe(sqlOf(byExpr)) + expect(sqlOf(CH.Query.orderBy(base, ($) => [[$.Name, "asc"]]))).toBe(sqlOf(byExpr)) + expect(sqlOf(base.pipe(CH.Query.limit(10)))).toBe(sqlOf(base.limit(10))) + expect(sqlOf(CH.Query.limit(base, 10))).toBe(sqlOf(base.limit(10))) + }) +}) + +const Orders = CH.table("orders", { OrgId: CH.string, UserId: CH.string, Amount: CH.uint64 }, { tenantColumn: "OrgId" }) + +describe("Query joins", () => { + it("innerJoin, leftJoin and crossJoin write the fluent SQL, data-last and data-first", () => { + const base = CH.from(Users) + const pick = ($: any) => ({ id: $.Id, amount: $.o.Amount }) + const on = (u: any, o: any) => u.Id.eq(o.UserId) + expect(sqlOf(base.pipe(CH.Query.innerJoin(Orders, "o", (u, o) => u.Id.eq(o.UserId)), CH.Query.select(pick)))).toBe( + sqlOf(base.innerJoin(Orders, "o", on).select(pick)), + ) + expect(sqlOf(CH.Query.innerJoin(base, Orders, "o", on).select(pick))).toBe(sqlOf(base.innerJoin(Orders, "o", on).select(pick))) + expect(sqlOf(base.pipe(CH.Query.leftJoin(Orders, "o", (u, o) => u.Id.eq(o.UserId)), CH.Query.select(pick)))).toBe( + sqlOf(base.leftJoin(Orders, "o", on).select(pick)), + ) + expect(sqlOf(CH.Query.leftJoin(base, Orders, "o", on).select(pick))).toBe(sqlOf(base.leftJoin(Orders, "o", on).select(pick))) + expect(sqlOf(base.pipe(CH.Query.crossJoin(Orders, "o"), CH.Query.select(pick)))).toBe(sqlOf(base.crossJoin(Orders, "o").select(pick))) + expect(sqlOf(CH.Query.crossJoin(base, Orders, "o").select(pick))).toBe(sqlOf(base.crossJoin(Orders, "o").select(pick))) + }) +}) + +describe("a piped chain", () => { + it("compiles to the fluent SQL and params on ClickHouse and Postgres", () => { + const piped = CH.Query.from(Users).pipe( + CH.Query.innerJoin(Orders, "o", (u, o) => u.Id.eq(o.UserId)), + CH.Query.select(($) => ({ id: $.Id, amount: $.o.Amount })), + CH.Query.where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Age.gt(18)]), + CH.Query.orderBy(["amount", "desc"]), + CH.Query.limit(20), + ) + const fluent = CH.from(Users) + .innerJoin(Orders, "o", (u, o) => u.Id.eq(o.UserId)) + .select(($) => ({ id: $.Id, amount: $.o.Amount })) + .where(($) => [$.OrgId.eq(CH.param.string("orgId")), $.Age.gt(18)]) + .orderBy(["amount", "desc"]) + .limit(20) + const params = { orgId: "org_1" } + const ch = CH.compileUnsafe(piped, params) + const chFluent = CH.compileUnsafe(fluent, params) + expect(ch.sql).toBe(chFluent.sql) + expect(ch.parameters).toEqual(chFluent.parameters) + expect(ch.tenantScope).toBe(chFluent.tenantScope) + expect(PG.compileUnsafe(piped, params).sql).toBe(PG.compileUnsafe(fluent, params).sql) + }) +}) diff --git a/src/ch/query-pipe.ts b/src/ch/query-pipe.ts new file mode 100644 index 0000000..4813c56 --- /dev/null +++ b/src/ch/query-pipe.ts @@ -0,0 +1,266 @@ +// The pipeable, data-last query API: `Query.where`, `Query.orderBy`, … next to +// the fluent builder. Each function is a `dual` that calls the fluent method, so +// both styles share one implementation and write the same SQL. +// +// Query.from(Users).pipe( +// Query.select(($) => ({ id: $.Id })), +// Query.where(($) => [$.OrgId.eq(orgId)]), +// Query.orderBy(["id", "asc"]), +// ) +// +// A data-last function learns the query's column types from where `pipe` puts +// it, so `$` in a callback is typed as in the fluent form. + +import { dual } from "effect/Function" +import type { Condition, Expr, ParamsIn } from "./expr" +import type { CHQuery, FreshAlias, InferOutput, JoinedColumnAccessor, JoinOnCallback, RowCount, SelectRecord } from "./query" +import type { Table } from "./table" +import type { ColumnDefs, InferTS, NullableColumnDefs } from "./types" + +export { from, fromQuery, fromUnion } from "./query" + +/** `true` for a query, the first argument of a data-first call. */ +const isQuery = (u: unknown): u is CHQuery => + typeof u === "object" && u !== null && "_state" in u && typeof (u as { pipe?: unknown }).pipe === "function" + +/** `query.where(fn)`: conditions AND-joined with the earlier ones. */ +export const where: { + , const C extends ReadonlyArray>( + fn: ($: JoinedColumnAccessor) => C, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + const C extends ReadonlyArray, + >( + self: CHQuery, + fn: ($: JoinedColumnAccessor) => C, + ): CHQuery> +} = dual(2, (self: CHQuery, fn: any) => self.where(fn)) + +/** + * `query.select(...)`, in its three forms: every column (`select()`), columns by + * name (`select("Id", "Name")`) or a callback (`select(($) => ({ ... }))`). + * Replaces an earlier projection. + */ +export const select: { + (): , Joins extends Record, Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery }, Joins, Route, Params> + , S extends SelectRecord>( + fn: ($: JoinedColumnAccessor) => S, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery, Joins, Route, Params | ParamsIn> + ( + ...columns: [K, ...Array] + ): , Joins extends Record, Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery }, Joins, Route, Params> + , Joins extends Record, Route extends string | undefined, Params>( + self: CHQuery, + ): CHQuery }, Joins, Route, Params> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + S extends SelectRecord, + >( + self: CHQuery, + fn: ($: JoinedColumnAccessor) => S, + ): CHQuery, Joins, Route, Params | ParamsIn> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + K extends keyof Cols & string, + >( + self: CHQuery, + ...columns: [K, ...Array] + ): CHQuery }, Joins, Route, Params> +} = dual( + (args) => isQuery(args[0]), + (self: CHQuery, ...args: Array) => (self.select as (...args: Array) => unknown)(...args), +) + +type Direction = "asc" | "desc" + +/** + * `query.orderBy(...)`: by output keys (`orderBy(["at", "desc"])`) or by + * expressions of the source's columns (`orderBy(($) => [[$.CreatedAt, "desc"]])`). + * Replaces an earlier order. Data-last, a key the query does not select is a + * type error where the function meets the query in `pipe`. + */ +export const orderBy: { + , Q = never>( + fn: ($: JoinedColumnAccessor) => ReadonlyArray, Direction]>, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery + ( + ...specs: [readonly [K, Direction], ...Array] + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + Q = never, + >( + self: CHQuery, + fn: ($: JoinedColumnAccessor) => ReadonlyArray, Direction]>, + ): CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ...specs: [readonly [keyof Output & string, Direction], ...Array] + ): CHQuery +} = dual( + (args) => isQuery(args[0]), + (self: CHQuery, ...args: Array) => (self.orderBy as (...args: Array) => unknown)(...args), +) + +/** `query.limit(n)`: at most `n` rows, a non-negative integer. Replaces an earlier limit. */ +export const limit: { + ( + n: RowCount, + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + N extends number, + >( + self: CHQuery, + n: RowCount, + ): CHQuery +} = dual(2, (self: CHQuery, n: number) => self.limit(n)) + +/** `query.innerJoin(table, alias, on)`: `$.` reads the joined table. */ +export const innerJoin: { + < + Cols extends ColumnDefs, + Joins extends Record, + JName extends string, + JCols extends ColumnDefs, + Alias extends string, + OnParams = never, + >( + table: Table, + alias: FreshAlias, + on: JoinOnCallback, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JName extends string, + JCols extends ColumnDefs, + Alias extends string, + OnParams = never, + >( + self: CHQuery, + table: Table, + alias: FreshAlias, + on: JoinOnCallback, + ): CHQuery +} = dual(4, (self: CHQuery, table: Table, alias: any, on: any) => + self.innerJoin(table, alias, on), +) + +/** `query.leftJoin(table, alias, on)`: as `innerJoin`, with the joined columns nullable. */ +export const leftJoin: { + < + Cols extends ColumnDefs, + Joins extends Record, + JName extends string, + JCols extends ColumnDefs, + Alias extends string, + OnParams = never, + >( + table: Table, + alias: FreshAlias, + on: JoinOnCallback, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery }, Route, Params | OnParams> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JName extends string, + JCols extends ColumnDefs, + Alias extends string, + OnParams = never, + >( + self: CHQuery, + table: Table, + alias: FreshAlias, + on: JoinOnCallback, + ): CHQuery }, Route, Params | OnParams> +} = dual(4, (self: CHQuery, table: Table, alias: any, on: any) => + self.leftJoin(table, alias, on), +) + +/** `query.crossJoin(table, alias)`: every pair of rows. */ +export const crossJoin: { + , JName extends string, JCols extends ColumnDefs, Alias extends string>( + table: Table, + alias: FreshAlias, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JName extends string, + JCols extends ColumnDefs, + Alias extends string, + >( + self: CHQuery, + table: Table, + alias: FreshAlias, + ): CHQuery +} = dual(3, (self: CHQuery, table: Table, alias: any) => self.crossJoin(table, alias)) diff --git a/src/core.ts b/src/core.ts index b32adcf..daa84a6 100644 --- a/src/core.ts +++ b/src/core.ts @@ -158,3 +158,7 @@ export { } from "./ch/dialect" export { QueryBuilderError, QueryBuilderDefect } from "./ch/errors" + +// The pipeable, data-last query API: `Query.where(...)` and friends, each the +// fluent method as a `dual`. +export * as Query from "./ch/query-pipe" From 1ea9f27b61d1b9c1618aeb3daa3d49cec065ae18 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:08:04 +0200 Subject: [PATCH 3/5] test(core): run a piped data-last chain live on every dialect --- tests/__snapshots__/core-sql.test.ts.snap | 42 +++++++++++++++++++++++ tests/core-cases.ts | 21 ++++++++++++ tests/dialect-coverage.test.ts | 1 + 3 files changed, 64 insertions(+) diff --git a/tests/__snapshots__/core-sql.test.ts.snap b/tests/__snapshots__/core-sql.test.ts.snap index fba9e28..b2e4f94 100644 --- a/tests/__snapshots__/core-sql.test.ts.snap +++ b/tests/__snapshots__/core-sql.test.ts.snap @@ -442,6 +442,26 @@ SELECT } `; +exports[`core SQL (clickhouse) > pipe-data-last 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 + LIMIT 2", +} +`; + exports[`core SQL (clickhouse) > route-and-cross-tenant 1`] = ` { "parameters": [], @@ -1117,6 +1137,28 @@ SELECT } `; +exports[`core SQL (postgres) > pipe-data-last 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 + LIMIT 2", +} +`; + exports[`core SQL (postgres) > route-and-cross-tenant 1`] = ` { "parameters": [], diff --git a/tests/core-cases.ts b/tests/core-cases.ts index 3698368..799f1a2 100644 --- a/tests/core-cases.ts +++ b/tests/core-cases.ts @@ -397,6 +397,27 @@ export const coreCases: readonly CoreCase[] = [ { id: 3, tier: "silver" }, ], }, + { + // The data-last `Query` functions are the fluent methods, so this is + // `inner-join` written with `pipe`, and it returns the same rows. + id: "pipe-data-last", + covers: q("pipe"), + build: (ctx) => + ctx.compile( + ctx.from(ctx.orders).pipe( + CH.Query.where(($) => [$.OrgId.eq(CH.param.string("orgId"))]), + CH.Query.innerJoin(ctx.customers, "c", (o, c) => o.Customer.eq(c.Name).and(o.OrgId.eq(c.OrgId))), + CH.Query.select(($) => ({ id: $.Id, tier: $.c.Tier })), + CH.Query.orderBy(["id", "asc"]), + CH.Query.limit(2), + ), + org, + ), + expected: [ + { id: 1, tier: "gold" }, + { id: 2, tier: "gold" }, + ], + }, { id: "left-join", covers: q("leftJoin"), diff --git a/tests/dialect-coverage.test.ts b/tests/dialect-coverage.test.ts index 4bb5901..8877879 100644 --- a/tests/dialect-coverage.test.ts +++ b/tests/dialect-coverage.test.ts @@ -61,6 +61,7 @@ const exemptions = { "query:forNoKeyUpdate": "Postgres row lock; ClickHouse refuses it at compile (core case `locking`), so there is no live ClickHouse run.", "query:forShare": "Postgres row lock; ClickHouse refuses it at compile (core case `locking`), so there is no live ClickHouse run.", "query:forKeyShare": "Postgres row lock; ClickHouse refuses it at compile (core case `locking`), so there is no live ClickHouse run.", + "query:pipe": "Dialect-neutral: it only calls the functions it is given. Live on every dialect in core case `pipe-data-last`.", } export const dialectInventory = [ From 6aeaecb8b980afb81675c995b7db4ecdeb7ffe38 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Sun, 25 Oct 2026 17:08:04 +0200 Subject: [PATCH 4/5] docs(query): document the pipeable, data-last Query API --- .changeset/pipeable-query-api.md | 5 +++++ README.md | 2 +- docs/README.md | 2 +- docs/queries.md | 33 ++++++++++++++++++++++++++++++++ docs/reference.md | 17 ++++++++++++++++ src/docs-examples.test.ts | 29 ++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .changeset/pipeable-query-api.md diff --git a/.changeset/pipeable-query-api.md b/.changeset/pipeable-query-api.md new file mode 100644 index 0000000..ffe0744 --- /dev/null +++ b/.changeset/pipeable-query-api.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": minor +--- + +A query is now `Pipeable`, and a new `Query` namespace on `/clickhouse` and `/postgres` gives a data-last form of `select`, `where`, `orderBy`, `limit`, `innerJoin`, `leftJoin` and `crossJoin`, next to `from`, `fromQuery` and `fromUnion`: `CH.Query.from(Events).pipe(CH.Query.select(...), CH.Query.where(...))`. Each function is a `dual` that calls the fluent method, so both styles write the same SQL, and each also has a data-first form. `$` in a data-last callback is typed from the query that `pipe` carries. diff --git a/README.md b/README.md index 4863efe..1e1118e 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Full guides live in [`docs/`](./docs/README.md): | ---------------------------------------------------------- | --------------------------------------------------------------- | | [Getting started](./docs/getting-started.md) | Install, define a table, build → compile → decode | | [Tables and column types](./docs/tables-and-types.md) | `table()`, column options, external tables, column types | -| [Building queries](./docs/queries.md) | `select`, `where`, `groupBy`, `orderBy`, `limit`, immutability | +| [Building queries](./docs/queries.md) | `select`, `where`, `groupBy`, `orderBy`, `limit`, immutability, `pipe` | | [Expressions and conditions](./docs/expressions.md) | Comparisons, arithmetic, optional predicates, aggregates | | [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` | diff --git a/docs/README.md b/docs/README.md index 7a61d11..ba631f4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ Roughly in reading order. | ----------------------------------------------------- | ----------------------------------------------------------------------------------- | | [Getting started](./getting-started.md) | Install, define a table, build → compile → decode | | [Tables and column types](./tables-and-types.md) | `table()`, column options, external tables, the column-type constructors | -| [Building queries](./queries.md) | `select`, `where`, `groupBy`, `orderBy`, `limit`, `format`, immutability | +| [Building queries](./queries.md) | `select`, `where`, `groupBy`, `orderBy`, `limit`, `format`, immutability, `pipe` | | [Expressions and conditions](./expressions.md) | Comparisons, arithmetic, optional predicates, aggregates | | [Joins and subqueries](./joins-and-subqueries.md) | The join family, `fromQuery`, correlated subqueries | | [Unions and CTEs](./unions-and-ctes.md) | `unionAll`, `fromUnion`, `withCTE` | diff --git a/docs/queries.md b/docs/queries.md index 802db79..a4e6311 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -202,6 +202,39 @@ See [Unions and CTEs](./unions-and-ctes.md#ctes). `.route("ingest")` and `.crossTenant()` attach metadata to the compiled result rather than changing the SQL. Both are covered in [Tenant scoping](./tenant-scoping.md). +## Pipeable, data-last queries + +A query is `Pipeable`, and `Query` has a data-last form of the most used methods, as `Effect`, +`Stream` and `Schema` do. Each `Query` function is a `dual` that calls the fluent method, so +both styles write the same SQL: + +```ts +CH.Query.from(Events).pipe( + CH.Query.select(($) => ({ name: $.Name, ms: $.DurationMs })), + CH.Query.where(($) => [$.OrgId.eq("org_123")]), + CH.Query.orderBy(["ms", "desc"]), + CH.Query.limit(10), +) +// the same SQL as CH.from(Events).select(…).where(…).orderBy(…).limit(10) +``` + +`$` in a data-last callback is typed from the query that `pipe` carries, and a wrong column or +an unselected `orderBy` key is a type error at that step. Each function also has a data-first +form: `CH.Query.where(query, ($) => […])`. + +`Query` has `from`, `fromQuery`, `fromUnion`, `select`, `where`, `orderBy`, `limit`, +`innerJoin`, `leftJoin` and `crossJoin`. Use the fluent method for the others. + +A reusable part is a plain function. Type its `$` with the table's columns: + +```ts +const byOrg = (orgId: string) => CH.Query.where(($: CH.ColumnAccessor) => [$.OrgId.eq(orgId)]) + +CH.Query.from(Events).pipe(byOrg("org_123"), CH.Query.select("Name")) +``` + +_(Backed by `docs/queries.md > Pipeable, data-last queries`.)_ + ## Compiling ```ts diff --git a/docs/reference.md b/docs/reference.md index abc2d46..001001c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -72,6 +72,23 @@ Tables come from the dialect's `table`: [`CH.table`](#tables-and-ddl) or `CHUnionQuery` offers only `orderBy`, `limit`, `offset`, `format`. +A `CHQuery` is `Pipeable`: `query.pipe(f, g)` is `g(f(query))`. + +### `Query` (data-last) + +`Query` is a namespace of `dual` functions over the `CHQuery` methods above. Each one is +data-last (`query.pipe(Query.where(fn))`) and data-first (`Query.where(query, fn)`), calls the +fluent method, and writes the same SQL. See [Pipeable, data-last queries](./queries.md#pipeable-data-last-queries). + +| Export | Same as | +| --------------------------------------------------------- | ------------------------------- | +| `Query.from` / `Query.fromQuery` / `Query.fromUnion` | `from` / `fromQuery` / `fromUnion` | +| `Query.select()` / `Query.select(...names)` / `Query.select(fn)` | `select` | +| `Query.where(fn)` | `where` | +| `Query.orderBy(...[key, dir])` / `Query.orderBy(fn)` | `orderBy` | +| `Query.limit(n)` | `limit` | +| `Query.innerJoin` / `Query.leftJoin` / `Query.crossJoin` | `(table, alias, on?)` | + ### Compilation Each entry exports `compile`, `compileUnsafe`, `compileUnion` and `compileUnionUnsafe`, defaulting diff --git a/src/docs-examples.test.ts b/src/docs-examples.test.ts index ef673be..23d7c9d 100644 --- a/src/docs-examples.test.ts +++ b/src/docs-examples.test.ts @@ -228,6 +228,35 @@ describe("docs/queries.md", () => { expect(oneLine(compileCHUnsafe(query, {}).sql)).toContain("ORDER BY count DESC, name ASC") }) + it("Pipeable, data-last queries", () => { + const piped = CH.Query.from(Events).pipe( + CH.Query.select(($) => ({ name: $.Name, ms: $.DurationMs })), + CH.Query.where(($) => [$.OrgId.eq("org_123")]), + CH.Query.orderBy(["ms", "desc"]), + CH.Query.limit(10), + ) + const fluent = CH.from(Events) + .select(($) => ({ name: $.Name, ms: $.DurationMs })) + .where(($) => [$.OrgId.eq("org_123")]) + .orderBy(["ms", "desc"]) + .limit(10) + expect(compileCHUnsafe(piped, {}).sql).toBe(compileCHUnsafe(fluent, {}).sql) + expect(compileCHUnsafe(CH.Query.where(CH.from(Events).select("Name"), ($) => [$.OrgId.eq("org_123")]), {}).sql).toBe( + compileCHUnsafe( + CH.from(Events) + .select("Name") + .where(($) => [$.OrgId.eq("org_123")]), + {}, + ).sql, + ) + + const byOrg = (orgId: string) => CH.Query.where(($: CH.ColumnAccessor) => [$.OrgId.eq(orgId)]) + const reused = CH.Query.from(Events).pipe(byOrg("org_123"), CH.Query.select("Name")) + expect(oneLine(compileCHUnsafe(reused, {}).sql)).toBe( + "SELECT events.Name AS Name FROM events WHERE events.OrgId = 'org_123'", + ) + }) + it("orderBy rejects a bare string", () => { const query = CH.from(Events).select(($) => ({ name: $.Name })) From cd185b47156fa80643723bd0a9bbaf973391801a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 2 Nov 2026 16:40:46 +0100 Subject: [PATCH 5/5] feat(query): a data-last form for every CHQuery method Adds Query.groupBy, having, offset, format, distinct, distinctOn, the four row locks, route, crossTenant, innerJoinQuery / leftJoinQuery / crossJoinQuery and withCTE, as the issue asks for every builder method. The subquery joins take a query first, so they tell data-first from data-last by arity. A test checks that every fluent method has a Query function, and that piped, data-first and fluent forms compile to the same SQL (or the same refusal) on ClickHouse, Postgres, SQLite and D1. Records the sqlite and d1 snapshots of the live pipe-data-last case. Co-Authored-By: Claude Opus 5.5 --- .changeset/pipeable-query-api.md | 2 +- docs/queries.md | 12 +- docs/reference.md | 11 +- src/ch/query-pipe.test-d.ts | 39 +++ src/ch/query-pipe.test.ts | 109 ++++++ src/ch/query-pipe.ts | 400 +++++++++++++++++++++- tests/__snapshots__/core-sql.test.ts.snap | 44 +++ 7 files changed, 610 insertions(+), 7 deletions(-) diff --git a/.changeset/pipeable-query-api.md b/.changeset/pipeable-query-api.md index ffe0744..2bdc583 100644 --- a/.changeset/pipeable-query-api.md +++ b/.changeset/pipeable-query-api.md @@ -2,4 +2,4 @@ "@maple-dev/effect-orm": minor --- -A query is now `Pipeable`, and a new `Query` namespace on `/clickhouse` and `/postgres` gives a data-last form of `select`, `where`, `orderBy`, `limit`, `innerJoin`, `leftJoin` and `crossJoin`, next to `from`, `fromQuery` and `fromUnion`: `CH.Query.from(Events).pipe(CH.Query.select(...), CH.Query.where(...))`. Each function is a `dual` that calls the fluent method, so both styles write the same SQL, and each also has a data-first form. `$` in a data-last callback is typed from the query that `pipe` carries. +A query is now `Pipeable`, and a new `Query` namespace on `/clickhouse`, `/postgres` and `/sqlite` gives a data-last form of every `CHQuery` method (`select`, `where`, `groupBy`, `having`, `orderBy`, `limit`, `offset`, `format`, `distinct`, `distinctOn`, the row locks, `route`, `crossTenant`, the table and subquery joins, `withCTE`), next to `from`, `fromQuery` and `fromUnion`: `CH.Query.from(Events).pipe(CH.Query.select(...), CH.Query.where(...))`. Each function is a `dual` that calls the fluent method, so both styles write the same SQL on every dialect, and each also has a data-first form. `$` in a data-last callback is typed from the query that `pipe` carries. diff --git a/docs/queries.md b/docs/queries.md index a4e6311..2315865 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -204,7 +204,7 @@ changing the SQL. Both are covered in [Tenant scoping](./tenant-scoping.md). ## Pipeable, data-last queries -A query is `Pipeable`, and `Query` has a data-last form of the most used methods, as `Effect`, +A query is `Pipeable`, and `Query` has a data-last form of each builder method, as `Effect`, `Stream` and `Schema` do. Each `Query` function is a `dual` that calls the fluent method, so both styles write the same SQL: @@ -222,8 +222,14 @@ CH.Query.from(Events).pipe( an unselected `orderBy` key is a type error at that step. Each function also has a data-first form: `CH.Query.where(query, ($) => […])`. -`Query` has `from`, `fromQuery`, `fromUnion`, `select`, `where`, `orderBy`, `limit`, -`innerJoin`, `leftJoin` and `crossJoin`. Use the fluent method for the others. +`Query` has `from`, `fromQuery` and `fromUnion` to start, and a function for every `CHQuery` +method: `select`, `where`, `groupBy`, `having`, `orderBy`, `limit`, `offset`, `format`, +`distinct`, `distinctOn`, the row locks (`forUpdate`, `forNoKeyUpdate`, `forShare`, +`forKeyShare`), `route`, `crossTenant`, the joins (`innerJoin`, `leftJoin`, `crossJoin` and +their `…Query` forms) and `withCTE`. A form a dialect lacks (a row lock on ClickHouse or SQLite, +`format` outside ClickHouse, `distinctOn` on SQLite) is refused at compile, as the fluent method +is. Unions (`CHUnionQuery`) and writes (`insertInto`, `update`, `deleteFrom`) are not pipeable +yet; use their fluent methods. A reusable part is a plain function. Type its `$` with the table's columns: diff --git a/docs/reference.md b/docs/reference.md index c549437..425b688 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -87,8 +87,17 @@ fluent method, and writes the same SQL. See [Pipeable, data-last queries](./quer | `Query.select()` / `Query.select(...names)` / `Query.select(fn)` | `select` | | `Query.where(fn)` | `where` | | `Query.orderBy(...[key, dir])` / `Query.orderBy(fn)` | `orderBy` | -| `Query.limit(n)` | `limit` | +| `Query.limit(n)` / `Query.offset(n)` | `limit` / `offset` | +| `Query.groupBy(...keys)` / `Query.having(fn)` | `groupBy` / `having` | +| `Query.distinct()` / `Query.distinctOn(...keys)` | `distinct` / `distinctOn` | +| `Query.format(fmt)` | `format` | +| `Query.forUpdate` / `Query.forNoKeyUpdate` / `Query.forShare` / `Query.forKeyShare` | `(options?)`, the row locks | +| `Query.route(route)` / `Query.crossTenant()` | `route` / `crossTenant` | | `Query.innerJoin` / `Query.leftJoin` / `Query.crossJoin` | `(table, alias, on?)` | +| `Query.innerJoinQuery` / `Query.leftJoinQuery` / `Query.crossJoinQuery` | `(query, alias, on?)` | +| `Query.withCTE(name, query)` / `Query.withCTE(name, sql, options?)` | `withCTE` | + +`CHUnionQuery` and the writes are not `Pipeable`; use their methods. ### Compilation diff --git a/src/ch/query-pipe.test-d.ts b/src/ch/query-pipe.test-d.ts index e02d364..89dcd11 100644 --- a/src/ch/query-pipe.test-d.ts +++ b/src/ch/query-pipe.test-d.ts @@ -94,3 +94,42 @@ CH.from(Tours).pipe( // @ts-expect-error -- the alias `u` is in use Query.innerJoin(Units, "u", (t, u) => t.Id.eq(u.TourId)), ) + +// The rest of the builder: each data-last form gives the fluent type. +const ids = CH.from(Tours).select(($) => ({ id: $.Id, at: $.StartsAt })) +expectTypeOf(ids.pipe(Query.groupBy("id"), Query.having(($) => [$.OrgId.eq(CH.param.string("orgId"))]))).toEqualTypeOf( + ids.groupBy("id").having(($) => [$.OrgId.eq(CH.param.string("orgId"))]), +) +expectTypeOf(ids.pipe(Query.offset(5), Query.distinct(), Query.format("JSON"))).toEqualTypeOf(ids.offset(5).distinct().format("JSON")) +expectTypeOf(ids.pipe(Query.distinctOn("id"), Query.crossTenant())).toEqualTypeOf(ids.distinctOn("id").crossTenant()) +expectTypeOf(ids.pipe(Query.forUpdate(), Query.forShare({ skipLocked: true }))).toEqualTypeOf(ids.forUpdate().forShare({ skipLocked: true })) +expectTypeOf(Query.forKeyShare(ids)).toEqualTypeOf(ids.forKeyShare()) +expectTypeOf(ids.pipe(Query.route("replica"))).toEqualTypeOf(ids.route("replica")) +// @ts-expect-error -- `nope` is not selected +ids.pipe(Query.groupBy("nope")) +// @ts-expect-error -- `nope` is not selected +ids.pipe(Query.distinctOn("nope")) +// @ts-expect-error -- a negative offset +ids.pipe(Query.offset(-1)) + +// Subquery joins and CTEs carry the inner query's params. +const units = CH.from(Units) + .select(($) => ({ tour: $.TourId, name: $.Name })) + .where(($) => [$.Name.eq(CH.param.string("unit"))]) +const viaUnits = CH.from(Tours).pipe( + Query.leftJoinQuery(units, "u", (t, u) => t.Id.eq(u.tour)), + Query.select(($) => ({ id: $.Id, unit: $.u.name })), +) +expectTypeOf>().toEqualTypeOf<{ readonly id: string; readonly unit: string | null }>() +// @ts-expect-error -- `unit` is missing +CH.compileUnsafe(viaUnits, {}) +expectTypeOf(CH.from(Tours).pipe(Query.innerJoinQuery(units, "u", (t, u) => t.Id.eq(u.tour)))).toEqualTypeOf( + CH.from(Tours).innerJoinQuery(units, "u", (t, u) => t.Id.eq(u.tour)), +) +expectTypeOf(CH.from(Tours).pipe(Query.crossJoinQuery(units, "u"))).toEqualTypeOf(CH.from(Tours).crossJoinQuery(units, "u")) +expectTypeOf(ids.pipe(Query.withCTE("u", units))).toEqualTypeOf(ids.withCTE("u", units)) +expectTypeOf(ids.pipe(Query.withCTE("one", "SELECT 1"))).toEqualTypeOf(ids.withCTE("one", "SELECT 1")) +CH.from(Tours).pipe( + // @ts-expect-error -- the subquery selects no `nope` + Query.innerJoinQuery(units, "u", (t, u) => t.Id.eq(u.nope)), +) diff --git a/src/ch/query-pipe.test.ts b/src/ch/query-pipe.test.ts index aa6ab0d..6af377a 100644 --- a/src/ch/query-pipe.test.ts +++ b/src/ch/query-pipe.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import * as CH from "./index" import { compileCHUnsafe } from "./compile" import * as PG from "../postgres" +import * as SQLite from "../sqlite" const Users = CH.table( "users", @@ -102,3 +103,111 @@ describe("a piped chain", () => { expect(PG.compileUnsafe(piped, params).sql).toBe(PG.compileUnsafe(fluent, params).sql) }) }) + +// What each entry writes for a query, or the message it refuses it with: a +// form one dialect lacks (row locks, FORMAT, DISTINCT ON) must be refused the +// same way piped as fluent. +const dialects = { + clickhouse: (query: any, params: any) => CH.compileUnsafe(query, params), + postgres: (query: any, params: any) => PG.compileUnsafe(query, params), + sqlite: (query: any, params: any) => SQLite.compileUnsafe(query, params), + d1: (query: any, params: any) => SQLite.compileUnsafe(query, params, { dialect: SQLite.d1Dialect }), +} +const outcome = (compileWith: (query: any, params: any) => CH.CompiledQuery, query: unknown) => { + try { + const compiled = compileWith(query, { orgId: "org_1" }) + return { sql: compiled.sql, parameters: compiled.parameters, tenantScope: compiled.tenantScope } + } catch (error) { + return { refused: String((error as Error).message) } + } +} +const expectSameEverywhere = (fluent: unknown, ...others: ReadonlyArray) => { + for (const [name, compileWith] of Object.entries(dialects)) { + const expected = outcome(compileWith, fluent) + for (const other of others) expect(outcome(compileWith, other), name).toEqual(expected) + } +} + +describe("every builder method", () => { + it("has a Query function", () => { + const methods = Object.keys(CH.from(Users)).filter((key) => key !== "_state" && key !== "pipe") + expect(methods.length).toBeGreaterThan(20) + expect(methods.filter((method) => typeof (CH.Query as Record)[method] !== "function")).toEqual([]) + }) +}) + +describe("the remaining methods, on every dialect", () => { + // No aggregate: each dialect has its own `count`, and these must compile on all four. + const grouped = CH.from(Users).select(($) => ({ org: $.OrgId })) + const selected = CH.from(Users).select(($) => ({ id: $.Id, age: $.Age })) + const scoped = ($: any) => [$.OrgId.eq(CH.param.string("orgId"))] + + it("groupBy and having", () => { + const notOrg = ($: any) => [$.OrgId.neq(CH.param.string("orgId"))] + expectSameEverywhere( + grouped.groupBy("org").having(notOrg), + grouped.pipe(CH.Query.groupBy("org"), CH.Query.having(($) => [$.OrgId.neq(CH.param.string("orgId"))])), + CH.Query.having(CH.Query.groupBy(grouped, "org"), notOrg), + ) + }) + + it("offset, distinct, distinctOn, format", () => { + expectSameEverywhere(selected.limit(5).offset(10), selected.pipe(CH.Query.limit(5), CH.Query.offset(10)), CH.Query.offset(selected.limit(5), 10)) + expectSameEverywhere(selected.distinct(), selected.pipe(CH.Query.distinct()), CH.Query.distinct(selected)) + expectSameEverywhere(selected.distinctOn("id"), selected.pipe(CH.Query.distinctOn("id")), CH.Query.distinctOn(selected, "id")) + expectSameEverywhere(selected.format("JSON"), selected.pipe(CH.Query.format("JSON")), CH.Query.format(selected, "JSON")) + }) + + it("row locks, with and without options", () => { + const base = selected.where(scoped) + expectSameEverywhere(base.forUpdate(), base.pipe(CH.Query.forUpdate()), CH.Query.forUpdate(base)) + expectSameEverywhere( + base.forUpdate({ skipLocked: true }), + base.pipe(CH.Query.forUpdate({ skipLocked: true })), + CH.Query.forUpdate(base, { skipLocked: true }), + ) + expectSameEverywhere(base.forNoKeyUpdate(), base.pipe(CH.Query.forNoKeyUpdate()), CH.Query.forNoKeyUpdate(base)) + expectSameEverywhere(base.forShare({ noWait: true }), base.pipe(CH.Query.forShare({ noWait: true })), CH.Query.forShare(base, { noWait: true })) + expectSameEverywhere(base.forKeyShare(), base.pipe(CH.Query.forKeyShare()), CH.Query.forKeyShare(base)) + }) + + it("route and crossTenant", () => { + expectSameEverywhere(selected.crossTenant(), selected.pipe(CH.Query.crossTenant()), CH.Query.crossTenant(selected)) + expect(selected.pipe(CH.Query.route("replica"))._state).toEqual(selected.route("replica")._state) + expect(CH.Query.route(selected, "replica")._state).toEqual(selected.route("replica")._state) + }) + + it("subquery joins: the first data-last argument is a query, and still data-last", () => { + const totals = CH.from(Orders).select(($) => ({ user: $.UserId, amount: $.Amount })) + const on = (u: any, t: any) => u.Id.eq(t.user) + const pick = ($: any) => ({ id: $.Id, amount: $.t.amount }) + const base = CH.from(Users).where(scoped) + expectSameEverywhere( + base.innerJoinQuery(totals, "t", on).select(pick), + base.pipe(CH.Query.innerJoinQuery(totals, "t", (u, t) => u.Id.eq(t.user)), CH.Query.select(pick)), + CH.Query.innerJoinQuery(base, totals, "t", on).select(pick), + ) + expectSameEverywhere( + base.leftJoinQuery(totals, "t", on).select(pick), + base.pipe(CH.Query.leftJoinQuery(totals, "t", (u, t) => u.Id.eq(t.user)), CH.Query.select(pick)), + CH.Query.leftJoinQuery(base, totals, "t", on).select(pick), + ) + expectSameEverywhere( + base.crossJoinQuery(totals, "t").select(pick), + base.pipe(CH.Query.crossJoinQuery(totals, "t"), CH.Query.select(pick)), + CH.Query.crossJoinQuery(base, totals, "t").select(pick), + ) + }) + + it("withCTE, from a query and from SQL", () => { + const recent = CH.from(Orders).select(($) => ({ user: $.UserId })).where(scoped) + const base = selected.where(scoped) + expectSameEverywhere(base.withCTE("recent", recent), base.pipe(CH.Query.withCTE("recent", recent)), CH.Query.withCTE(base, "recent", recent)) + const sql = "SELECT 1 AS one" + expectSameEverywhere( + base.withCTE("one", sql, { tenantScope: "cross-tenant" }), + base.pipe(CH.Query.withCTE("one", sql, { tenantScope: "cross-tenant" })), + CH.Query.withCTE(base, "one", sql, { tenantScope: "cross-tenant" }), + ) + }) +}) diff --git a/src/ch/query-pipe.ts b/src/ch/query-pipe.ts index 4813c56..12efd8f 100644 --- a/src/ch/query-pipe.ts +++ b/src/ch/query-pipe.ts @@ -13,9 +13,20 @@ import { dual } from "effect/Function" import type { Condition, Expr, ParamsIn } from "./expr" -import type { CHQuery, FreshAlias, InferOutput, JoinedColumnAccessor, JoinOnCallback, RowCount, SelectRecord } from "./query" +import type { TenantScope } from "./compile" +import type { + CHQuery, + FreshAlias, + InferOutput, + JoinedColumnAccessor, + JoinOnCallback, + LockOptions, + NeedsSelect, + RowCount, + SelectRecord, +} from "./query" import type { Table } from "./table" -import type { ColumnDefs, InferTS, NullableColumnDefs } from "./types" +import type { ColumnDefs, InferTS, NullableColumnDefs, OutputToColumnDefs } from "./types" export { from, fromQuery, fromUnion } from "./query" @@ -264,3 +275,388 @@ export const crossJoin: { alias: FreshAlias, ): CHQuery } = dual(3, (self: CHQuery, table: Table, alias: any) => self.crossJoin(table, alias)) + +/** `query.having(fn)`: post-aggregation conditions, AND-joined with the earlier ones. */ +export const having: { + , const C extends ReadonlyArray>( + fn: ($: JoinedColumnAccessor) => C, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + const C extends ReadonlyArray, + >( + self: CHQuery, + fn: ($: JoinedColumnAccessor) => C, + ): CHQuery> +} = dual(2, (self: CHQuery, fn: any) => self.having(fn)) + +// The data-last half of a method that keeps the query's type. +type Same = < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, +>( + self: CHQuery, +) => CHQuery + +/** + * `query.groupBy(...keys)`: group by output keys. Data-last, a key the query + * does not select is a type error where the function meets the query in `pipe`. + */ +export const groupBy: { + ( + ...keys: Array + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ...keys: Array + ): CHQuery +} = dual( + (args) => isQuery(args[0]), + (self: CHQuery, ...keys: Array) => self.groupBy(...keys), +) + +/** `query.distinctOn(...keys)`: the first row of each group of these output keys. */ +export const distinctOn: { + ( + ...keys: [K, ...Array] + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ...keys: [keyof Output & string, ...Array] + ): CHQuery +} = dual( + (args) => isQuery(args[0]), + (self: CHQuery, ...keys: [string, ...Array]) => self.distinctOn(...keys), +) + +/** `query.offset(n)`: skip `n` rows, a non-negative integer. */ +export const offset: { + (n: RowCount): Same + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + N extends number, + >( + self: CHQuery, + n: RowCount, + ): CHQuery +} = dual(2, (self: CHQuery, n: number) => self.offset(n)) + +/** `query.format(fmt)`: ClickHouse's `FORMAT` clause. As with the fluent method, other dialects refuse it at compile. */ +export const format: { + (fmt: "JSON" | "JSONEachRow"): Same + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + fmt: "JSON" | "JSONEachRow", + ): CHQuery +} = dual(2, (self: CHQuery, fmt: "JSON" | "JSONEachRow") => self.format(fmt)) + +// A method with no argument but the query: `Query.distinct()` in `pipe`, +// `Query.distinct(query)` data-first. +type Nullary = { + (): Same + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ): CHQuery +} + +/** `query.distinct()`: `SELECT DISTINCT`. */ +export const distinct: Nullary = dual( + (args) => isQuery(args[0]), + (self: CHQuery) => self.distinct(), +) + +/** `query.crossTenant()`: declare a deliberate read across every tenant. */ +export const crossTenant: Nullary = dual( + (args) => isQuery(args[0]), + (self: CHQuery) => self.crossTenant(), +) + +// A row lock: `Query.forUpdate()` or `Query.forUpdate({ skipLocked: true })` in +// `pipe`, `Query.forUpdate(query, options?)` data-first. As with the fluent +// methods, a dialect without row locks refuses them at compile. +type Lock = { + (options?: LockOptions): Same + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + options?: LockOptions, + ): CHQuery +} + +/** `query.forUpdate(options?)`. */ +export const forUpdate: Lock = dual( + (args) => isQuery(args[0]), + (self: CHQuery, options?: LockOptions) => self.forUpdate(options), +) +/** `query.forNoKeyUpdate(options?)`. */ +export const forNoKeyUpdate: Lock = dual( + (args) => isQuery(args[0]), + (self: CHQuery, options?: LockOptions) => self.forNoKeyUpdate(options), +) +/** `query.forShare(options?)`. */ +export const forShare: Lock = dual( + (args) => isQuery(args[0]), + (self: CHQuery, options?: LockOptions) => self.forShare(options), +) +/** `query.forKeyShare(options?)`. */ +export const forKeyShare: Lock = dual( + (args) => isQuery(args[0]), + (self: CHQuery, options?: LockOptions) => self.forKeyShare(options), +) + +/** `query.route(route)`: tag the query with an execution route. */ +export const route: { + ( + route: NewRoute, + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + NewRoute extends string, + >( + self: CHQuery, + route: NewRoute, + ): CHQuery +} = dual(2, (self: CHQuery, route: string) => self.route(route)) + +// The subquery joins take a query as their first data-last argument, so they +// tell the two forms apart by arity, not with `isQuery`. + +/** `query.innerJoinQuery(subquery, alias, on)`: `$.` reads the subquery's output. */ +export const innerJoinQuery: { + < + Cols extends ColumnDefs, + Joins extends Record, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + OnParams = never, + >( + query: CHQuery & NeedsSelect, + alias: FreshAlias, + on: JoinOnCallback, OnParams>, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery }, Route, Params | JParams | OnParams> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + OnParams = never, + >( + self: CHQuery, + query: CHQuery & NeedsSelect, + alias: FreshAlias, + on: JoinOnCallback, OnParams>, + ): CHQuery }, Route, Params | JParams | OnParams> +} = dual(4, (self: CHQuery, query: any, alias: any, on: any) => self.innerJoinQuery(query, alias, on)) + +/** `query.leftJoinQuery(subquery, alias, on)`: as `innerJoinQuery`, with the subquery's columns nullable. */ +export const leftJoinQuery: { + < + Cols extends ColumnDefs, + Joins extends Record, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + OnParams = never, + >( + query: CHQuery & NeedsSelect, + alias: FreshAlias, + on: JoinOnCallback, OnParams>, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery< + Cols, + Output, + Joins & { readonly [K in Alias]: NullableColumnDefs> }, + Route, + Params | JParams | OnParams + > + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + OnParams = never, + >( + self: CHQuery, + query: CHQuery & NeedsSelect, + alias: FreshAlias, + on: JoinOnCallback, OnParams>, + ): CHQuery< + Cols, + Output, + Joins & { readonly [K in Alias]: NullableColumnDefs> }, + Route, + Params | JParams | OnParams + > +} = dual(4, (self: CHQuery, query: any, alias: any, on: any) => self.leftJoinQuery(query, alias, on)) + +/** `query.crossJoinQuery(subquery, alias)`: every pair of rows with the subquery's output. */ +export const crossJoinQuery: { + < + Cols extends ColumnDefs, + Joins extends Record, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + >( + query: CHQuery & NeedsSelect, + alias: FreshAlias, + ): , Route extends string | undefined, Params>( + self: CHQuery, + ) => CHQuery }, Route, Params | JParams> + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + JCols extends ColumnDefs, + JOutput extends Record, + JJoins extends Record, + Alias extends string, + JParams = never, + >( + self: CHQuery, + query: CHQuery & NeedsSelect, + alias: FreshAlias, + ): CHQuery }, Route, Params | JParams> +} = dual(3, (self: CHQuery, query: any, alias: any) => self.crossJoinQuery(query, alias)) + +/** + * `query.withCTE(name, query)` or `query.withCTE(name, sql, options?)`. The + * data-last form starts with the CTE's name, so a query first is data-first. + */ +export const withCTE: { + , CTEParams = never>( + name: string, + query: CHQuery & NeedsSelect, + ): < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + ) => CHQuery + (name: string, sql: string, options?: { readonly tenantScope?: TenantScope }): Same + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + CTEOutput extends Record, + CTEParams = never, + >( + self: CHQuery, + name: string, + query: CHQuery & NeedsSelect, + ): CHQuery + < + Cols extends ColumnDefs, + Output extends Record, + Joins extends Record, + Route extends string | undefined, + Params, + >( + self: CHQuery, + name: string, + sql: string, + options?: { readonly tenantScope?: TenantScope }, + ): CHQuery +} = dual( + (args) => isQuery(args[0]), + (self: CHQuery, ...args: Array) => (self.withCTE as (...args: Array) => unknown)(...args), +) diff --git a/tests/__snapshots__/core-sql.test.ts.snap b/tests/__snapshots__/core-sql.test.ts.snap index a7cdbda..dcdf8a9 100644 --- a/tests/__snapshots__/core-sql.test.ts.snap +++ b/tests/__snapshots__/core-sql.test.ts.snap @@ -1197,6 +1197,28 @@ SELECT } `; +exports[`core SQL (d1) > pipe-data-last 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" = ? + ORDER BY "id" ASC + LIMIT 2", +} +`; + exports[`core SQL (d1) > route-and-cross-tenant 1`] = ` { "parameters": [], @@ -2618,6 +2640,28 @@ SELECT } `; +exports[`core SQL (sqlite) > pipe-data-last 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" = ? + ORDER BY "id" ASC + LIMIT 2", +} +`; + exports[`core SQL (sqlite) > route-and-cross-tenant 1`] = ` { "parameters": [],