Skip to content

Commit 34749d5

Browse files
committed
Transform JSON null checks
1 parent e848f6d commit 34749d5

File tree

4 files changed

+146
-8
lines changed

4 files changed

+146
-8
lines changed

apps/webapp/app/v3/querySchemas.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,15 @@ export const runsSchema: TableSchema = {
315315
},
316316

317317
// Output & error (JSON columns)
318+
// For JSON columns, NULL checks are transformed to check for empty object '{}'
319+
// So `error IS NULL` becomes `error = '{}'` and `error IS NOT NULL` becomes `error != '{}'`
318320
output: {
319321
name: "output",
320322
...column("JSON", {
321323
description: "The data you returned from the task.",
322324
example: '{"result": "success"}',
323325
}),
324-
expression: "if(output = '{}', NULL, output)",
326+
nullValue: "'{}'", // Transform NULL checks to compare against empty object
325327
},
326328
error: {
327329
name: "error",
@@ -330,7 +332,7 @@ export const runsSchema: TableSchema = {
330332
"If a run completely failed (after all attempts) then this error will be populated.",
331333
example: '{"message": "Task failed"}',
332334
}),
333-
expression: "if(error = '{}', NULL, error)",
335+
nullValue: "'{}'", // Transform NULL checks to compare against empty object
334336
},
335337

336338
// Tags & versions

internal-packages/tsql/src/query/printer.test.ts

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,112 @@ describe("ClickHousePrinter", () => {
441441
});
442442
});
443443

444+
describe("nullValue transformation for JSON columns", () => {
445+
// Create a schema with JSON columns that have nullValue set
446+
const jsonSchema: TableSchema = {
447+
name: "runs",
448+
clickhouseName: "trigger_dev.task_runs_v2",
449+
columns: {
450+
id: { name: "id", ...column("String") },
451+
error: {
452+
name: "error",
453+
...column("JSON"),
454+
nullValue: "'{}'", // Empty object represents NULL
455+
},
456+
output: {
457+
name: "output",
458+
...column("JSON"),
459+
nullValue: "'{}'", // Empty object represents NULL
460+
},
461+
status: { name: "status", ...column("String") },
462+
organization_id: { name: "organization_id", ...column("String") },
463+
project_id: { name: "project_id", ...column("String") },
464+
environment_id: { name: "environment_id", ...column("String") },
465+
},
466+
tenantColumns: {
467+
organizationId: "organization_id",
468+
projectId: "project_id",
469+
environmentId: "environment_id",
470+
},
471+
};
472+
473+
function createJsonContext() {
474+
const schema = createSchemaRegistry([jsonSchema]);
475+
return createPrinterContext({
476+
organizationId: "org_test",
477+
projectId: "proj_test",
478+
environmentId: "env_test",
479+
schema,
480+
});
481+
}
482+
483+
it("should transform IS NULL to equals empty object for JSON columns with nullValue", () => {
484+
const ctx = createJsonContext();
485+
const { sql } = printQuery("SELECT * FROM runs WHERE error IS NULL", ctx);
486+
487+
// Should use equals with '{}' instead of isNull
488+
expect(sql).toContain("equals(error, '{}')");
489+
expect(sql).not.toContain("isNull(error)");
490+
});
491+
492+
it("should transform IS NOT NULL to notEquals empty object for JSON columns with nullValue", () => {
493+
const ctx = createJsonContext();
494+
const { sql } = printQuery("SELECT * FROM runs WHERE error IS NOT NULL", ctx);
495+
496+
// Should use notEquals with '{}' instead of isNotNull
497+
expect(sql).toContain("notEquals(error, '{}')");
498+
expect(sql).not.toContain("isNotNull(error)");
499+
});
500+
501+
it("should transform = NULL to equals empty object for JSON columns with nullValue", () => {
502+
const ctx = createJsonContext();
503+
const { sql } = printQuery("SELECT * FROM runs WHERE error = NULL", ctx);
504+
505+
expect(sql).toContain("equals(error, '{}')");
506+
expect(sql).not.toContain("isNull(error)");
507+
});
508+
509+
it("should transform != NULL to notEquals empty object for JSON columns with nullValue", () => {
510+
const ctx = createJsonContext();
511+
const { sql } = printQuery("SELECT * FROM runs WHERE error != NULL", ctx);
512+
513+
expect(sql).toContain("notEquals(error, '{}')");
514+
expect(sql).not.toContain("isNotNull(error)");
515+
});
516+
517+
it("should not affect regular columns without nullValue", () => {
518+
const ctx = createJsonContext();
519+
const { sql } = printQuery("SELECT * FROM runs WHERE status IS NULL", ctx);
520+
521+
// Regular column should still use isNull
522+
expect(sql).toContain("isNull(status)");
523+
});
524+
525+
it("should work with multiple JSON column NULL checks", () => {
526+
const ctx = createJsonContext();
527+
const { sql } = printQuery(
528+
"SELECT * FROM runs WHERE error IS NOT NULL AND output IS NULL",
529+
ctx
530+
);
531+
532+
expect(sql).toContain("notEquals(error, '{}')");
533+
expect(sql).toContain("equals(output, '{}')");
534+
});
535+
536+
it("should allow GROUP BY on JSON columns without error", () => {
537+
const ctx = createJsonContext();
538+
const { sql } = printQuery(
539+
"SELECT error, count() AS error_count FROM runs WHERE error IS NOT NULL GROUP BY error",
540+
ctx
541+
);
542+
543+
// Should filter with notEquals
544+
expect(sql).toContain("notEquals(error, '{}')");
545+
// Should group by the raw column
546+
expect(sql).toContain("GROUP BY error");
547+
});
548+
});
549+
444550
describe("ORDER BY clauses", () => {
445551
it("should print ORDER BY ASC", () => {
446552
const { sql } = printQuery("SELECT * FROM task_runs ORDER BY created_at ASC");
@@ -1348,9 +1454,7 @@ describe("Virtual columns", () => {
13481454
// GROUP BY should use the alias, not the expression (ClickHouse allows this)
13491455
expect(sql).toContain("GROUP BY is_long_running");
13501456
// Should NOT have the expression in GROUP BY
1351-
expect(sql).not.toContain(
1352-
"GROUP BY (if(completed_at IS NOT NULL AND started_at IS NOT NULL"
1353-
);
1457+
expect(sql).not.toContain("GROUP BY (if(completed_at IS NOT NULL AND started_at IS NOT NULL");
13541458
});
13551459

13561460
it("should use alias for virtual column in GROUP BY with WHERE and aggregation", () => {
@@ -1369,9 +1473,7 @@ describe("Virtual columns", () => {
13691473
// Should NOT have the expression in GROUP BY
13701474
expect(sql).not.toContain("GROUP BY (dateDiff('millisecond'");
13711475
// WHERE should still use the expression
1372-
expect(sql).toContain(
1373-
"isNotNull((dateDiff('millisecond', started_at, completed_at)))"
1374-
);
1476+
expect(sql).toContain("isNotNull((dateDiff('millisecond', started_at, completed_at)))");
13751477
});
13761478
});
13771479

internal-packages/tsql/src/query/printer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,7 @@ export class ClickHousePrinter {
15101510
private visitCompareOperation(node: CompareOperation): string {
15111511
// Check if we need to transform values using valueMap
15121512
const columnSchema = this.extractColumnSchemaFromExpression(node.left);
1513+
const rightColumnSchema = this.extractColumnSchemaFromExpression(node.right);
15131514

15141515
// Transform the right side if it contains user-friendly values
15151516
const transformedRight = this.transformValueMapExpression(node.right, columnSchema);
@@ -1524,12 +1525,20 @@ export class ClickHousePrinter {
15241525
(transformedRight as Constant).expression_type === "constant" &&
15251526
(transformedRight as Constant).value === null
15261527
) {
1528+
// Check if the column has a custom nullValue (e.g., '{}' for JSON columns)
1529+
if (columnSchema?.nullValue) {
1530+
return `equals(${left}, ${columnSchema.nullValue})`;
1531+
}
15271532
return `isNull(${left})`;
15281533
}
15291534
if (
15301535
(node.left as Constant).expression_type === "constant" &&
15311536
(node.left as Constant).value === null
15321537
) {
1538+
// Check if the column has a custom nullValue (e.g., '{}' for JSON columns)
1539+
if (rightColumnSchema?.nullValue) {
1540+
return `equals(${right}, ${rightColumnSchema.nullValue})`;
1541+
}
15331542
return `isNull(${right})`;
15341543
}
15351544
return `equals(${left}, ${right})`;
@@ -1540,12 +1549,20 @@ export class ClickHousePrinter {
15401549
(transformedRight as Constant).expression_type === "constant" &&
15411550
(transformedRight as Constant).value === null
15421551
) {
1552+
// Check if the column has a custom nullValue (e.g., '{}' for JSON columns)
1553+
if (columnSchema?.nullValue) {
1554+
return `notEquals(${left}, ${columnSchema.nullValue})`;
1555+
}
15431556
return `isNotNull(${left})`;
15441557
}
15451558
if (
15461559
(node.left as Constant).expression_type === "constant" &&
15471560
(node.left as Constant).value === null
15481561
) {
1562+
// Check if the column has a custom nullValue (e.g., '{}' for JSON columns)
1563+
if (rightColumnSchema?.nullValue) {
1564+
return `notEquals(${right}, ${rightColumnSchema.nullValue})`;
1565+
}
15491566
return `isNotNull(${right})`;
15501567
}
15511568
return `notEquals(${left}, ${right})`;

internal-packages/tsql/src/query/schema.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,23 @@ export interface ColumnSchema {
180180
* ```
181181
*/
182182
whereTransform?: (value: string) => string;
183+
/**
184+
* Value to use when comparing to NULL for this column.
185+
*
186+
* When set, NULL comparisons (IS NULL, IS NOT NULL, = NULL, != NULL) are
187+
* transformed to compare against this value instead. This is useful for
188+
* JSON/Object columns where "empty" is represented as '{}' rather than NULL.
189+
*
190+
* @example
191+
* ```typescript
192+
* {
193+
* name: "error",
194+
* type: "JSON",
195+
* nullValue: "'{}'", // error IS NULL → error = '{}'
196+
* }
197+
* ```
198+
*/
199+
nullValue?: string;
183200
}
184201

185202
/**

0 commit comments

Comments
 (0)