Skip to content

Commit a3bd763

Browse files
Makisuoclaude
andauthored
test(fuzz): fuzz unions and writes on every dialect (#122)
* test(fuzz): fuzz unions and writes on every dialect Unions: `unionAll` of two or three row plans that select the same aliases (each alias one sort in every branch), each branch with its own join, filter, DISTINCT, ORDER BY and LIMIT, under the union's ORDER BY, LIMIT and OFFSET. The model concatenates the branches' rows and pages them; the union pages only when it orders by every alias, so ties are equal rows. New properties `unions match the model (<target>, …)`. Writes: a new suite. Each input gets a fresh scratch table on the dialect's first target and runs a sequence of builder writes: multi-row inserts leaving defaulted or nullable columns out, INSERT … SELECT, ON CONFLICT DO NOTHING / DO UPDATE (with the existing and the excluded row), UPDATE and DELETE with a filter or allRows(), and RETURNING. After every write the table is read back and compared with a JS model of its rows, and RETURNING with the rows the write touched. A write the dialect does not support is still generated and must be refused at compile time. `FuzzDialect.writes` declares the capabilities and the scratch table (query `Table`, create/drop DDL, `exec`); `DialectModule` gains unionAll, compileUnion, insertInto, update and deleteFrom. ClickHouse targets pass mutations_sync=2 and lightweight_deletes_sync=2 so an ALTER TABLE UPDATE or DELETE is visible to the next read. Found: a ClickHouse 26 UNION ALL column whose branches have numeric types with no common supertype (UInt64 and Int64, Int64 and Float64) becomes a Variant, and ORDER BY on it fails. Recorded in known-issues (clickhouse-union-variant-order-by) and pinned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(clickhouse): cast a union's mixed numeric column to Float64 A UNION ALL column of numbers whose branches have no common ClickHouse type (`Id + 1` is UInt64 and `Id - 1` Int64; Int64 and Float64) becomes a Variant, and an ORDER BY on it fails ("Variant/Dynamic are not allowed in ORDER BY keys"); with use_variant_as_common_type = 0 the union itself is refused. The union fuzzer found it. On a dialect with `unionNumberCast` (ClickHouse), a column is selected as toFloat64(…) in every branch when every branch selects a number and their types may differ: different declared types, or different computations. Every numeric ClickHouse type decodes to a JS number, so Float64 loses nothing the decode keeps. The known-issues entry is gone; the pinned counterexample now passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(fuzz): a query pinned to one org by its own filter is single-tenant for that org The tenant oracle took "single-tenant" to mean the orgId param's tenant. `OrgId IN ('org_2')` pins a query to org_2, which the builder rightly reports as single-tenant; the oracle called reading org_2 a leak. Where the plan's WHERE filters OrgId itself, any one org is right; otherwise it must still be the param's. Found by a 20x soak on D1; pinned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(clickhouse): cast a union's numeric column around each branch, not inside it Inside a branch the cast ran before that branch's DISTINCT, so two UInt64 values past 2^53 (one Float64) became one row. The cast now wraps each branch, SELECT toFloat64(x) AS x, … FROM (<branch>), so DISTINCT, GROUP BY and LIMIT see exact values (CodeRabbit). The writes suite now accepts only a QueryBuilderError or a QueryBuilderDefect as the refusal of a clause a dialect lacks, not any defect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(clickhouse): order a union's mixed numeric column by exact keys, never cast its values The previous fix cast such a column to Float64, which an independent review showed corrupts integers past 2^53 wherever the values stay on the server: INSERT ... SELECT from the union stored 9007199254740992 for 9007199254740993, and an IN over it matched nothing. Only the union's ORDER BY was ever broken (a Variant is not a valid sort key), so only the sort key changes now: toFloat64(x), then accurateCastOrNull(x, 'Int128'), which keeps integers past 2^53 in exact order. Checked on ClickHouse 26.8 with UInt64/Int64 values around 2^53 and 2^64 - 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(clickhouse): order union numbers with CAST, which ClickHouse 26.2 accepts on a Variant toFloat64 refuses a Variant on ClickHouse 26.2 (CI); CAST(x AS Nullable(Float64)) works on 26.2 and 26.8, Variant or not. Checked both versions with the full suite and every fuzz property at 20x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(fuzz): rerun ClickHouse's one-argument and() bug without the rewrite, rows still checked The known-issue matcher for this ClickHouse server bug covered two shapes; a 20x soak on 26.2 found a third (`(a OR 0 OR a) AND (a OR 0)`). Rather than chase shapes, a ClickHouse target that gets exactly this error runs the query again with optimize_extract_common_expressions = 0, and its rows are compared with the model as usual: these inputs are now checked, not skipped. The matcher and its helpers are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(fuzz): pin the and() server-bug shape ClickHouse 26.2 hit Fails with TOO_FEW_ARGUMENTS_FOR_FUNCTION if the rerun without the rewrite is removed; passes, rows checked, with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7d93e39 commit a3bd763

16 files changed

Lines changed: 769 additions & 76 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@maple-dev/effect-orm": patch
3+
---
4+
5+
On ClickHouse, a `unionAll` ordered by a numeric column whose branches may differ in type (`Id + 1`
6+
and `Id - 1`, or a `UInt32` and an `Int64` column) no longer fails with "Data types Variant/Dynamic
7+
are not allowed in ORDER BY keys". The union's ORDER BY sorts such a column by
8+
`CAST(x AS Nullable(Float64)), accurateCastOrNull(x, 'Int128')`, which keeps integers past 2^53 in exact order; the
9+
values themselves are not converted.

‎docs/unions-and-ctes.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,15 @@ themselves keep whatever ordering they declared.
2828

2929
Unions compile with **`compileUnion`**, not `compile`.
3030

31-
> **Watch branch types.** ClickHouse unifies column types across `UNION ALL` branches, and a
32-
> branch pairing (say) a `Float64` with a `UInt64` can be rejected outright depending on server
33-
> settings. Cast explicitly in each branch when the types are not already identical.
31+
> **Numeric branch types.** ClickHouse has no common type for some numeric pairs (`UInt64` and
32+
> `Int64`, `Int64` and `Float64`): it makes the column a `Variant`, which ORDER BY refuses, or,
33+
> with `use_variant_as_common_type = 0`, rejects the union. So on ClickHouse the union's `orderBy`
34+
> sorts a numeric column whose branches may differ in type (different declared types, or different
35+
> arithmetic: `Id + 1` is `UInt64`, `Id - 1` is `Int64`) by `CAST(x AS Nullable(Float64))` and then
36+
> `accurateCastOrNull(x, 'Int128')`, which keeps integers past 2^53 in exact order. The values
37+
> themselves are not converted, so an `INSERT ... SELECT` or an `IN` over the union keeps them
38+
> exact. A query over the union (`fromUnion`) that orders by such a column, or any other use that
39+
> needs one type, should cast it in each branch.
3440
3541
_(Backed by `docs/unions-and-ctes.md > unionAll with an outer ORDER BY`.)_
3642

‎src/ch/compile.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,22 @@ describe("CompiledQuery.decodeRows", () => {
159159
expect(compiled.rowSchemaMismatch).toEqual({ undeclared: [], unselected: ["missing"] })
160160
})
161161

162+
it("orders a ClickHouse union's mixed numeric column by exact keys, leaving its values alone", () => {
163+
// `Id + 1` is UInt64 and `Id - 1` Int64: ClickHouse makes the column a Variant, which ORDER BY refuses.
164+
const table = CH.table("orders", { Id: CH.uint32, Amount: CH.int64, Name: CH.string })
165+
const branch = (offset: number) => CH.from(table).select(($) => ({ id: $.Id, shifted: $.Id.add(offset), total: $.Amount, name: $.Name }))
166+
const compiled = compileUnionUnsafe(CH.unionAll(branch(1), branch(-1)).orderBy(["shifted", "desc"], ["id", "asc"], ["name", "asc"]), {})
167+
// The values are not cast: an INSERT ... SELECT or IN over the union keeps integers past 2^53 exact.
168+
expect(compiled.sql).not.toContain("toFloat64(orders")
169+
expect(compiled.sql).toContain("ORDER BY CAST(shifted AS Nullable(Float64)) DESC, accurateCastOrNull(shifted, 'Int128') DESC, id ASC, name ASC")
170+
// Different declared types are mixed too; one declared type in every branch is not.
171+
const mixed = compileUnionUnsafe(
172+
CH.unionAll(CH.from(table).select(($) => ({ n: $.Id, m: $.Amount })), CH.from(table).select(($) => ({ n: $.Amount, m: $.Amount }))).orderBy(["n", "asc"], ["m", "asc"]),
173+
{},
174+
)
175+
expect(mixed.sql).toContain("ORDER BY CAST(n AS Nullable(Float64)) ASC, accurateCastOrNull(n, 'Int128') ASC, m ASC")
176+
})
177+
162178
it.effect("decodes rows with the declared schema for handwritten SQL", () =>
163179
Effect.gen(function* () {
164180
const compiled = rawCompiledQuery<{ readonly name: string; readonly count: number }>({

‎src/ch/compile.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// 4. Assembling into SqlQuery and calling the existing compileQuery()
99

1010
import { compiled, compiledUnsafe, fail } from "./failure"
11-
import { columnSqlName, custom, dateTime, dateTime64, type CHType, type ColumnDefs } from "./types"
11+
import { CHFloatResult, CHNumber, columnSqlName, custom, dateTime, dateTime64, type CHType, type ColumnDefs } from "./types"
1212
import type { CHQuery, CHQueryState, NeedsSelect } from "./query"
1313
import type { CHUnionQuery } from "./union"
1414
import { isInsert, type AnyInsert, type CHInsert } from "./insert"
@@ -22,7 +22,7 @@ import { activeLiteralBinder, withLiteralBinder } from "../sql/literal-binder"
2222
import { compileQuery, type SqlQuery } from "../sql/sql-query"
2323
import { track, untracked, type RenderTrack } from "../sql/render-tracker"
2424
import { PARAM_MARKER_PREFIX, PARAM_PLACEHOLDER_PATTERN, param, paramColumnTypeOf, paramSchema, type ParamKind } from "./param"
25-
import { mergeResultSchemas } from "./define-fn"
25+
import { mergeResultSchemas, schemaOf } from "./define-fn"
2626
import { encodeValue } from "./literal"
2727
import { checkedLiteral, clickhouseDialect, currentDialect, withDialect, type Dialect } from "./dialect"
2828
import { Effect, Option, Schema } from "effect"
@@ -1391,6 +1391,39 @@ export function compileUnionRaw<Output extends Record<string, any>>(
13911391
}
13921392

13931393
/** The recursion behind {@link compileUnionUnsafe}; see {@link compileInner}. */
1394+
/** A JS-number schema: every numeric ClickHouse type and arithmetic result decodes through one of these. */
1395+
const isNumberSchema = (schema: unknown): boolean => {
1396+
if (schema === CHNumber || schema === CHFloatResult) return true
1397+
// NullOr(CHNumber): a nullable column or arithmetic over one.
1398+
const members = (schema as { members?: ReadonlyArray<unknown> } | undefined)?.members
1399+
return Array.isArray(members) && members.some((m) => m === CHNumber || m === CHFloatResult) && members.every((m) => m === CHNumber || m === CHFloatResult || m === Schema.Null)
1400+
}
1401+
1402+
/**
1403+
* The union columns a dialect with `unionNumberOrderKeys` orders through those
1404+
* keys: every branch selects a number, and their types may differ, because the
1405+
* branches declare different column types or compute it differently
1406+
* (arithmetic has no declared type: `Id + 1` is UInt64, `Id - 1` Int64).
1407+
*/
1408+
function mixedNumberColumns(queries: ReadonlyArray<CHQuery<any, any, any, any>>, selectKeys: ReadonlyArray<string>): ReadonlySet<string> {
1409+
const casts = new Set<string>()
1410+
if (currentDialect().unionNumberOrderKeys === undefined || queries.length < 2) return casts
1411+
for (const alias of selectKeys) {
1412+
const exprs = queries.map((q) => (selectExprsOf(q) as Record<string, unknown> | undefined)?.[alias])
1413+
const kinds = exprs.map((expr) => {
1414+
if (!isExprLike(expr)) return undefined
1415+
const declared = columnTypeOf(expr as Expr<any>)
1416+
if (declared !== undefined) return isNumberSchema(declared.schema) ? declared.sql : undefined
1417+
return isNumberSchema(schemaOf(expr)) ? "computed" : undefined
1418+
})
1419+
if (kinds.some((kind) => kind === undefined)) continue
1420+
if (new Set(kinds).size > 1) casts.add(alias)
1421+
// The same computation in every branch (a `sum` over one column) has one type; different ones may not.
1422+
else if (kinds[0] === "computed" && new Set(exprs.map((expr) => compileSqlFragment((expr as Expr<any>).toFragment()))).size > 1) casts.add(alias)
1423+
}
1424+
return casts
1425+
}
1426+
13941427
function compileUnionInner<Output extends Record<string, any>, Params extends Record<string, any>>(
13951428
union: CHUnionQuery<Output>,
13961429
params: Params,
@@ -1419,6 +1452,7 @@ function compileUnionInner<Output extends Record<string, any>, Params extends Re
14191452
const subQueries = state.queries.map((q) =>
14201453
compileInner(q, params, { skipFormat: true, deferParams, nested: true, selectKeys, enclosingCtes }),
14211454
)
1455+
const mixedNumbers = mixedNumberColumns(state.queries, selectKeys)
14221456
const bounds = new Set(
14231457
subQueries.flatMap((q) => {
14241458
const bound = tenantBoundOf(q)
@@ -1451,7 +1485,14 @@ function compileUnionInner<Output extends Record<string, any>, Params extends Re
14511485
if (hasOuter) {
14521486
sql = `SELECT * FROM (\n${sql}\n)${currentDialect().clauses.derivedTableAlias ? ` AS ${quoteIdent("__union")}` : ""}`
14531487
if (state.outerOrderBySpecs.length > 0) {
1454-
sql += `\nORDER BY ${orderByClause(state.outerOrderBySpecs).join(", ")}`
1488+
const orderKeys = currentDialect().unionNumberOrderKeys
1489+
const clause = orderByClause(state.outerOrderBySpecs).map((rendered, i) => {
1490+
const [column, direction] = state.outerOrderBySpecs[i]!
1491+
return orderKeys !== undefined && mixedNumbers.has(column)
1492+
? orderKeys(quoteIdent(column)).map((key) => `${key} ${direction.toUpperCase()}`).join(", ")
1493+
: rendered
1494+
})
1495+
sql += `\nORDER BY ${clause.join(", ")}`
14551496
}
14561497
if (state.outerLimitValue != null) {
14571498
sql += `\nLIMIT ${compileSqlFragment(rowCount("limit", state.outerLimitValue)!)}`

‎src/ch/dialect.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ export interface Dialect extends Omit<SqlSyntax, "dialect"> {
151151
* unchecked: a custom dialect says which set it renders, if either.
152152
*/
153153
readonly functions?: string
154+
/**
155+
* The ORDER BY keys for a numeric UNION ALL column whose branches may have
156+
* different types. ClickHouse has no common type for `UInt64` and `Int64`
157+
* (or `Int64` and `Float64`): the column is a `Variant`, which ORDER BY
158+
* refuses as a key. The values are left as they are (an INSERT ... SELECT or
159+
* an IN over the union keeps them exact); only the sort key is converted.
160+
* Absent: the dialect orders such a column as it is.
161+
*/
162+
readonly unionNumberOrderKeys?: (column: string) => ReadonlyArray<string>
154163
}
155164

156165
/** ClickHouse, with params written into the SQL as literals. The default. */
@@ -173,6 +182,8 @@ export const clickhouseDialect: Dialect = {
173182
},
174183
transactions: noTransactions,
175184
functions: "clickhouse",
185+
// CAST, not toFloat64: ClickHouse 26.2's toFloat64 refuses a Variant. Float64 orders every number; the Int128 tiebreak keeps integers past 2^53 (one Float64) in exact order.
186+
unionNumberOrderKeys: (column) => [`CAST(${column} AS Nullable(Float64))`, `accurateCastOrNull(${column}, 'Int128')`],
176187
}
177188

178189
// The dialect of the enclosing compile, beside the syntax installed for the

‎tests/fuzz/README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ and are skipped otherwise. Postgres runs in-process on PGlite.
1717

1818
| File | Property | Oracle |
1919
| --- | --- | --- |
20-
| `queries.fuzz.test.ts` | random plans return the rows `model.ts` computes | a JS model of SQL, per target |
20+
| `queries.fuzz.test.ts` | random plans, and `unionAll`s of them (branch and union ORDER BY / LIMIT / OFFSET), return the rows `model.ts` computes | a JS model of SQL, per target |
21+
| `writes.fuzz.test.ts` | random sequences of inserts (multi-row, defaulted cells, INSERT … SELECT, ON CONFLICT), updates and deletes, with RETURNING, leave a scratch table as the model says; unsupported writes are refused | a JS model of the table's rows |
2122
| `literals.fuzz.test.ts` | every column type's values survive as a literal and as a param | the database's own JSON parser |
2223
| `compile.fuzz.test.ts` | plans compile deterministically, with no leaked JS or misaligned placeholders; hostile values are refused with a typed error | invariants |
2324
| `registry.fuzz.test.ts` | every `Dialect` in `src/` is registered here | — |
@@ -36,6 +37,14 @@ A `FuzzDialect` gives:
3637
- `strings` and `ilike`: what the generators may produce for it.
3738
- `literals`: its column types, each with a way to build the reference row
3839
*without* the builder (usually the database parsing JSON).
40+
- `writes`: which writes its builder compiles (`insertSelect`, `update`, `delete`,
41+
`onConflict`, `returning`, `defaultCells` for a multi-row VALUES that leaves a
42+
defaulted column out of some rows), and a `scratch` table: its query `Table`,
43+
the DDL that creates and drops it, and `exec` to run that DDL on the first
44+
target. Set a capability from what the builder does, not what the database
45+
could: the suite still generates an unsupported write and requires the builder
46+
to refuse it. A database whose writes are asynchronous (ClickHouse mutations)
47+
must make them visible to the next read (`mutations_sync=2`).
3948
- `targets`: the databases it runs on. Each target states its `Semantics`, the
4049
points where databases legitimately disagree (NULL ordering, integer division,
4150
what an unmatched LEFT JOIN column holds, LIKE case rules, …). The model reads

‎tests/fuzz/build.ts‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { DateTime, Effect, Exit } from "effect"
88
import type * as CH from "@maple-dev/effect-orm/clickhouse"
99
import type { CoreContext } from "../core-cases"
1010
import type { FuzzDialect } from "./dialects/types"
11-
import type { Agg, Cond, Expr, Operand, Plan } from "./plan"
11+
import type { Agg, Cond, Expr, Operand, Plan, UnionPlan } from "./plan"
1212

1313
const pad = (n: number, width = 2) => String(n).padStart(width, "0")
1414
/** `YYYY-MM-DD hh:mm:ss.sss` in UTC: the string form a DateTime comparison accepts. */
@@ -18,13 +18,13 @@ export const timeString = (ms: number): string => {
1818
}
1919

2020
/** Every param operand in the plan, in a fixed walk order, with the name it compiles as. */
21-
const nameParams = (plan: Plan): { readonly names: Map<Operand, string>; readonly params: Record<string, unknown> } => {
21+
const nameParams = (plan: Plan, prefix: string): { readonly names: Map<Operand, string>; readonly params: Record<string, unknown> } => {
2222
const names = new Map<Operand, string>()
2323
const params: Record<string, unknown> = { orgId: plan.orgId }
2424
const operand = (op: Operand) => {
2525
if (op.k === "expr") return expr(op.e)
2626
if (op.k !== "param") return
27-
const name = `p${names.size}`
27+
const name = `${prefix}p${names.size}`
2828
names.set(op, name)
2929
params[name] = op.kind === "dateTime" ? DateTime.makeUnsafe(op.v as number) : op.v
3030
}
@@ -74,9 +74,10 @@ export interface Built {
7474
readonly params: Record<string, unknown>
7575
}
7676

77-
export const buildPlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan): Built => {
77+
/** `prefix` keeps the params of union branches apart: each branch names its own from `<prefix>p0`. */
78+
export const buildPlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan, prefix = ""): Built => {
7879
const { lit, param, and, or, not, rawCond, fromQuery } = dialect.module
79-
const { names, params } = nameParams(plan)
80+
const { names, params } = nameParams(plan, prefix)
8081

8182
const expr = ($: any, e: Expr): any => {
8283
switch (e.k) {
@@ -196,10 +197,9 @@ export type Compiled =
196197
* `QueryBuilderError` is `Refused`; a die is a `Defect`; an exception that
197198
* escapes `compile` — or the builder calls before it — is `Threw`.
198199
*/
199-
export const compilePlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan): Compiled => {
200+
export const classify = (compile: () => Effect.Effect<CH.CompiledQuery<any>, unknown>): Compiled => {
200201
try {
201-
const { query, params } = buildPlan(dialect, ctx, plan)
202-
const exit = Effect.runSyncExit(dialect.module.compile(query, params))
202+
const exit = Effect.runSyncExit(compile())
203203
if (Exit.isSuccess(exit)) return { _tag: "Compiled", compiled: exit.value }
204204
const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail")
205205
return failure !== undefined
@@ -209,3 +209,26 @@ export const compilePlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan):
209209
return { _tag: "Threw", error: error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error) }
210210
}
211211
}
212+
213+
export const compilePlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan): Compiled =>
214+
classify(() => {
215+
const { query, params } = buildPlan(dialect, ctx, plan)
216+
return dialect.module.compile(query, params)
217+
})
218+
219+
/** A union plan as `unionAll(...)` of its branches, each with its own params. */
220+
export const buildUnion = (dialect: FuzzDialect, ctx: CoreContext, plan: UnionPlan): Built => {
221+
const built = plan.branches.map((branch, i) => buildPlan(dialect, ctx, branch, `b${i}`))
222+
let union: any = (dialect.module.unionAll as (...queries: ReadonlyArray<unknown>) => unknown)(...built.map((b) => b.query))
223+
if (plan.orderBy.length > 0) union = union.orderBy(...plan.orderBy.map(([alias, dir]) => [alias, dir]))
224+
if (plan.limit !== undefined) union = union.limit(plan.limit)
225+
if (plan.offset !== undefined) union = union.offset(plan.offset)
226+
if (plan.crossTenant) union = union.crossTenant()
227+
return { query: union, params: Object.assign({}, ...built.map((b) => b.params)) }
228+
}
229+
230+
export const compileUnionPlan = (dialect: FuzzDialect, ctx: CoreContext, plan: UnionPlan): Compiled =>
231+
classify(() => {
232+
const { query, params } = buildUnion(dialect, ctx, plan)
233+
return dialect.module.compileUnion(query, params)
234+
})

‎tests/fuzz/dialects/clickhouse.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ export const clickhouseText = async (
3737
}
3838
}
3939

40+
/**
41+
* A ClickHouse server bug, not the builder's: the common-expression rewrite can
42+
* leave a one-argument and() (`a AND ((b AND a) OR a)`, `(a OR 0 OR a) AND (a OR 0)`,
43+
* and more shapes than a matcher keeps up with), which fails with this error.
44+
* The query is run again with the rewrite off, and its rows still checked.
45+
*/
46+
const SERVER_AND_BUG = /Number of arguments for function "and" should be at least 2/
47+
4048
const runClickHouse = async (compiled: CH.CompiledQuery<any>, settings: Record<string, string>): Promise<Outcome> => {
41-
const { ok, text } = await clickhouseText(compiled.sql, settings)
49+
let { ok, text } = await clickhouseText(compiled.sql, settings)
50+
if (!ok && SERVER_AND_BUG.test(text)) ({ ok, text } = await clickhouseText(compiled.sql, { ...settings, optimize_extract_common_expressions: "0" }))
4251
if (!ok) return { _tag: "DatabaseError", message: text.trim() }
4352
const wire = text
4453
.split("\n")
@@ -58,12 +67,20 @@ const semantics: Semantics = {
5867
likeIgnoresCase: false,
5968
}
6069

70+
/**
71+
* An UPDATE is an `ALTER TABLE … UPDATE` mutation, which runs in the background
72+
* unless asked to wait, and a lightweight DELETE may too. Waiting makes a write
73+
* visible to the read that follows it, as the writes suite needs.
74+
*/
75+
const syncWrites = { mutations_sync: "2", lightweight_deletes_sync: "2" }
76+
6177
const target = (name: string, joinUseNulls: boolean): FuzzTarget => ({
6278
name,
6379
available: clickhouseEndpoint !== undefined,
6480
semantics: joinUseNulls ? { ...semantics, missingJoinColumn: "null" } : semantics,
6581
run: (compiled) =>
6682
runClickHouse(compiled, {
83+
...syncWrites,
6784
join_use_nulls: joinUseNulls ? "1" : "0",
6885
output_format_json_quote_64bit_integers: joinUseNulls ? "1" : "0",
6986
}),
@@ -132,5 +149,26 @@ export const clickhouse: FuzzDialect = {
132149
strings,
133150
ilike: true,
134151
literals,
152+
writes: {
153+
insertSelect: true,
154+
update: true,
155+
delete: true,
156+
onConflict: false,
157+
returning: false,
158+
defaultCells: true,
159+
scratch: {
160+
table: (name) =>
161+
CH.table(name, {
162+
external: true,
163+
columns: { Id: CH.int64, OrgId: CH.string, Name: CH.string, Note: CH.nullable(CH.string), Qty: CH.column(CH.int64, { default: 5 }) },
164+
}),
165+
create: (name) => [`CREATE TABLE ${name} (Id Int64, OrgId String, Name String, Note Nullable(String), Qty Int64 DEFAULT 5) ENGINE = MergeTree ORDER BY Id`],
166+
drop: (name) => `DROP TABLE IF EXISTS ${name}`,
167+
exec: async (sql) => {
168+
const { ok, text } = await clickhouseText(sql, syncWrites)
169+
if (!ok) throw new Error(text.trim())
170+
},
171+
},
172+
},
135173
targets: [target("clickhouse", false), target("clickhouse-join-nulls", true)],
136174
}

‎tests/fuzz/dialects/postgres.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,26 @@ export const postgres: FuzzDialect = {
6969
: `placeholders ${[...used].sort((a, b) => a - b).join(",")} do not match ${count} parameters`
7070
},
7171
literals,
72+
writes: {
73+
insertSelect: true,
74+
update: true,
75+
delete: true,
76+
onConflict: true,
77+
returning: true,
78+
defaultCells: true,
79+
scratch: {
80+
table: (name) =>
81+
PG.table(name, {
82+
external: true,
83+
columns: { Id: PG.int8, OrgId: PG.text, Name: PG.text, Note: PG.nullable(PG.text), Qty: PG.column(PG.int8, { default: 5 }) },
84+
}),
85+
create: (name) => [`CREATE TABLE "${name}" ("Id" int8 PRIMARY KEY, "OrgId" text NOT NULL, "Name" text NOT NULL, "Note" text, "Qty" int8 NOT NULL DEFAULT 5)`],
86+
drop: (name) => `DROP TABLE IF EXISTS "${name}"`,
87+
exec: async (sql) => {
88+
await database().exec(sql)
89+
},
90+
},
91+
},
7292
targets: [
7393
{
7494
name: "postgres",

0 commit comments

Comments
 (0)