diff --git a/.changeset/ch-array-literal-cast.md b/.changeset/ch-array-literal-cast.md new file mode 100644 index 0000000..0f187c9 --- /dev/null +++ b/.changeset/ch-array-literal-cast.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-orm": patch +--- + +On ClickHouse, an array or map compared against a column is cast to the column's type: `$.Ids.eq([0, 4294967296])` writes `CAST([0, 4294967296] AS Array(Nullable(Int64)))`. Uncast, ClickHouse inferred the literal's element type from its values (`Array(UInt64)` here) and could refuse the comparison with `ILLEGAL_TYPE_OF_ARGUMENT`. diff --git a/src/ch/deep-builder.test.ts b/src/ch/deep-builder.test.ts index b3d579b..c1b9d4d 100644 --- a/src/ch/deep-builder.test.ts +++ b/src/ch/deep-builder.test.ts @@ -25,7 +25,7 @@ describe("source column identity", () => { .where(($) => [$.Timestamp.gte(new Date("2026-01-01T00:00:00Z")), $.Attributes.eq({ a: "b" })]) const compiled = CH.compileUnsafe(query, {}) expect(compiled.sql).toContain("b.Timestamp >= '2026-01-01 00:00:00'") - expect(compiled.sql).toContain("b.Attributes = map('a', 'b')") + expect(compiled.sql).toContain("b.Attributes = CAST(map('a', 'b') AS Map(String, String))") }) it("keeps built-in timestamp comparison codecs after computed projections", () => { @@ -42,7 +42,7 @@ describe("source column identity", () => { CH.compileUnsafe(CH.fromQuery(inner, "q").innerJoin(Events, "e", (main, e) => main.Attributes.eq({ a: "b" }).and(e.Attributes.eq({ a: "b" }))).select(() => ({ Id: CH.lit(1) })), {}), ]) { expect(compiled.sql).not.toContain("[object Object]") - expect(compiled.sql.match(/= map\('a', 'b'\)/g)).toHaveLength(2) + expect(compiled.sql.match(/= CAST\(map\('a', 'b'\) AS Map\(String, String\)\)/g)).toHaveLength(2) } }) diff --git a/src/ch/dialect.ts b/src/ch/dialect.ts index d59372a..17b6c97 100644 --- a/src/ch/dialect.ts +++ b/src/ch/dialect.ts @@ -130,7 +130,7 @@ export const noTransactions: DialectTransactions = { * finished text, so a value that spelled one would be rewritten too. A literal * that does is refused at compile time rather than trusted. */ -export interface Dialect extends SqlSyntax { +export interface Dialect extends Omit { /** Shown in errors. */ readonly name: string readonly params: ParamStyle @@ -205,6 +205,7 @@ export const checkedLiteral = (dialect: Dialect, value: unknown, context: string checked(dialect, dialect.literal(value, context), context) const checkedSyntax = (dialect: Dialect): SqlSyntax => ({ + dialect: dialect.name, quoteIdent: dialect.quoteIdent, quoteString: (value) => checked(dialect, dialect.quoteString(value), "a string literal"), literal: (value, context) => checkedLiteral(dialect, value, context), diff --git a/src/ch/expr.ts b/src/ch/expr.ts index 9de1b38..68d238a 100644 --- a/src/ch/expr.ts +++ b/src/ch/expr.ts @@ -334,6 +334,21 @@ const describeUntyped = (value: unknown): string => /** A value with no column type to encode it, in the active dialect's syntax. * ClickHouse writes booleans as `1`/`0`, which is also the rendering outside * a compile. */ +/** + * A value compared against a column. On ClickHouse an array or map literal is + * cast to the column's type: otherwise its element type is inferred from the + * values (`[0, 4294967296]` is Array(UInt64)), which may not compare with the + * column at all. A bound value needs no cast; the driver sends it typed. + */ +const comparisonLiteral = (columnType: CHType, value: unknown, column: string): string => { + const literal = encodeColumnLiteral(columnType, value, column) + const compound = columnType._tag === "Array" || columnType._tag === "Map" + // Outside a compile, ClickHouse is the default dialect. + return compound && activeLiteralBinder() === undefined && (activeSqlSyntax()?.dialect ?? "clickhouse") === "clickhouse" + ? `CAST(${literal} AS ${columnType.sql})` + : literal +} + /** A number as SQL that reads back exactly. A subnormal goes through the dialect: ClickHouse cannot parse one as written. */ const numberFragment = (value: number): SqlFragment => isSubnormal(value) ? known(() => (activeSqlSyntax()?.literal ?? sqlLiteral)(value, "a number literal")) : raw(numberText(value)) @@ -598,7 +613,7 @@ export function makeColumnRef, any> | undefined, columnType === undefined ? undefined - : (value) => known(() => encodeColumnLiteral(columnType, value, columnName ?? name)), + : (value) => known(() => comparisonLiteral(columnType, value, columnName ?? name)), ) // Without `sqlName`: a query reading this column as a subquery's output sees it under its alias. if (columnType !== undefined) { diff --git a/src/ch/literal.test.ts b/src/ch/literal.test.ts index 304c0d0..2d26878 100644 --- a/src/ch/literal.test.ts +++ b/src/ch/literal.test.ts @@ -37,13 +37,13 @@ const whereSql = ( describe("literals encode through the column's type", () => { it("writes a Map as ClickHouse's map() rather than [object Object]", () => { expect(whereSql(($) => [$.Attributes.eq({ "http.method": "GET" })])).toBe( - "events.Attributes = map('http.method', 'GET')", + "events.Attributes = CAST(map('http.method', 'GET') AS Map(String, String))", ) }) it("writes an Array with brackets and quoted elements", () => { - expect(whereSql(($) => [$.Tags.eq(["a", "b"])])).toBe("events.Tags = ['a', 'b']") - expect(whereSql(($) => [$.Tags.in_(["a"], ["b"])])).toBe("events.Tags IN (['a'], ['b'])") + expect(whereSql(($) => [$.Tags.eq(["a", "b"])])).toBe("events.Tags = CAST(['a', 'b'] AS Array(String))") + expect(whereSql(($) => [$.Tags.in_(["a"], ["b"])])).toBe("events.Tags IN (CAST(['a'] AS Array(String)), CAST(['b'] AS Array(String)))") }) it("writes a Bool as 1/0, which is what ClickHouse compares against", () => { @@ -144,6 +144,22 @@ describe("sqlLiteral", () => { }) }) +describe("comparison literals", () => { + const Tagged = CH.table("tagged", { Ids: T.array(T.nullable(T.int64)), Attrs: T.map(T.string, T.string), Name: T.string }) + + it("casts an array or map literal to the column's type on ClickHouse", () => { + const { sql } = compileCHUnsafe( + CH.from(Tagged) + .select(($) => ({ name: $.Name })) + .where(($) => [$.Ids.eq([0, 4294967296]), $.Attrs.eq({ a: "b" }), $.Name.eq("x")]), + {}, + ) + expect(sql).toContain("tagged.Ids = CAST([0, 4294967296] AS Array(Nullable(Int64)))") + expect(sql).toContain("tagged.Attrs = CAST(map('a', 'b') AS Map(String, String))") + expect(sql).toContain("tagged.Name = 'x'") + }) +}) + describe("param.of", () => { it("resolves a param of a custom column type", () => { const Level = T.custom("Enum8", Schema.Literals(["warn", "error"])) diff --git a/src/sql/sql-syntax.ts b/src/sql/sql-syntax.ts index a4e6af0..bf0fe19 100644 --- a/src/sql/sql-syntax.ts +++ b/src/sql/sql-syntax.ts @@ -9,6 +9,8 @@ import type { DateTime } from "effect" export interface SqlSyntax { + /** The dialect's name (`clickhouse`, `postgres`, …), for the few choices expression code makes per dialect. */ + readonly dialect: string /** One identifier (a column, alias, table or schema name), quoted as needed. */ readonly quoteIdent: (name: string) => string /** A string as a quoted literal. */ diff --git a/tests/fuzz/known-issues.ts b/tests/fuzz/known-issues.ts index ffb0997..54647cc 100644 --- a/tests/fuzz/known-issues.ts +++ b/tests/fuzz/known-issues.ts @@ -25,20 +25,7 @@ export interface LiteralInput { } const literalIssues: ReadonlyArray> = [ - { - id: "ch-array-literal-untyped", - reason: - "An array literal is written without a cast, so ClickHouse infers its element type from the values: " + - "`[0, 4294967296]` is Array(UInt64), and comparing it with an Array(Nullable(Int64)) column is " + - "ILLEGAL_TYPE_OF_ARGUMENT. CAST(… AS ) would fix it.", - matches: ({ literal, value, path }) => - literal.dialect === "clickhouse" && - path === "compare" && - literal.type.sql === "Array(Nullable(Int64))" && - Array.isArray(value) && - value.every((n) => n === null || n >= 0) && - value.some((n) => n !== null && n >= 2 ** 32), - }, + { id: "pg-untyped-select-param", reason: