Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ch-array-literal-cast.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 2 additions & 2 deletions src/ch/deep-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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)
}
})

Expand Down
3 changes: 2 additions & 1 deletion src/ch/dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SqlSyntax, "dialect"> {
/** Shown in errors. */
readonly name: string
readonly params: ParamStyle
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 16 additions & 1 deletion src/ch/expr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any, any>, 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))
Expand Down Expand Up @@ -598,7 +613,7 @@ export function makeColumnRef<Name extends string, ColType extends CHType<string
columnType?.schema as Schema.Codec<InferTS<ColType>, 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) {
Expand Down
22 changes: 19 additions & 3 deletions src/ch/literal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"]))
Expand Down
2 changes: 2 additions & 0 deletions src/sql/sql-syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
15 changes: 1 addition & 14 deletions tests/fuzz/known-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,7 @@ export interface LiteralInput {
}

const literalIssues: ReadonlyArray<KnownIssue<LiteralInput>> = [
{
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 <column type>) 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:
Expand Down
Loading