Skip to content

Commit 663ea74

Browse files
committed
whereTransform allows adding prefixes etc for where clauses
1 parent 980b2ff commit 663ea74

4 files changed

Lines changed: 172 additions & 24 deletions

File tree

apps/webapp/app/v3/querySchemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ export const runsSchema: TableSchema = {
105105
...column("String", {
106106
description: "Batch ID (if part of a batch)",
107107
example: "batch_5678efgh",
108+
expression: "if(batch_id = '', NULL, 'batch_' || batch_id)",
108109
}),
110+
whereTransform: (value: string) => value.replace(/^batch_/, ""),
109111
},
110112

111113
// Related runs

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

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,135 @@ describe("Value mapping (valueMap)", () => {
841841
});
842842
});
843843

844+
describe("WHERE transform (whereTransform)", () => {
845+
/**
846+
* Schema with whereTransform for batch_id column (strips prefix)
847+
*/
848+
const prefixedIdSchema: TableSchema = {
849+
name: "runs",
850+
clickhouseName: "trigger_dev.task_runs_v2",
851+
columns: {
852+
id: { name: "id", ...column("String") },
853+
batch_id: {
854+
name: "batch_id",
855+
...column("String"),
856+
// Transform strips the "batch_" prefix from user input
857+
whereTransform: (value: string) => value.replace(/^batch_/, ""),
858+
// Expression adds the prefix back in SELECT
859+
expression: "if(batch_id = '', NULL, concat('batch_', batch_id))",
860+
},
861+
schedule_id: {
862+
name: "schedule_id",
863+
...column("String"),
864+
// Transform strips the "sched_" prefix
865+
whereTransform: (value: string) => value.replace(/^sched_/, ""),
866+
},
867+
organization_id: { name: "organization_id", ...column("String") },
868+
project_id: { name: "project_id", ...column("String") },
869+
environment_id: { name: "environment_id", ...column("String") },
870+
},
871+
tenantColumns: {
872+
organizationId: "organization_id",
873+
projectId: "project_id",
874+
environmentId: "environment_id",
875+
},
876+
};
877+
878+
function createPrefixedContext() {
879+
const schema = createSchemaRegistry([prefixedIdSchema]);
880+
return createPrinterContext({
881+
organizationId: "org_test123",
882+
projectId: "proj_test456",
883+
environmentId: "env_test789",
884+
schema,
885+
});
886+
}
887+
888+
it("should strip prefix from value in equality comparison", () => {
889+
const ctx = createPrefixedContext();
890+
const { params } = printQuery("SELECT * FROM runs WHERE batch_id = 'batch_abc123'", ctx);
891+
892+
// The "batch_" prefix should be stripped, leaving just "abc123"
893+
expect(Object.values(params)).toContain("abc123");
894+
expect(Object.values(params)).not.toContain("batch_abc123");
895+
});
896+
897+
it("should strip prefix from values in IN clause", () => {
898+
const ctx = createPrefixedContext();
899+
const { params } = printQuery(
900+
"SELECT * FROM runs WHERE batch_id IN ('batch_abc', 'batch_def', 'batch_ghi')",
901+
ctx
902+
);
903+
904+
// All prefixes should be stripped
905+
expect(Object.values(params)).toContain("abc");
906+
expect(Object.values(params)).toContain("def");
907+
expect(Object.values(params)).toContain("ghi");
908+
expect(Object.values(params)).not.toContain("batch_abc");
909+
});
910+
911+
it("should strip prefix from value in NOT IN clause", () => {
912+
const ctx = createPrefixedContext();
913+
const { params } = printQuery("SELECT * FROM runs WHERE batch_id NOT IN ('batch_xyz')", ctx);
914+
915+
expect(Object.values(params)).toContain("xyz");
916+
expect(Object.values(params)).not.toContain("batch_xyz");
917+
});
918+
919+
it("should strip prefix from value in != comparison", () => {
920+
const ctx = createPrefixedContext();
921+
const { params } = printQuery("SELECT * FROM runs WHERE batch_id != 'batch_test'", ctx);
922+
923+
expect(Object.values(params)).toContain("test");
924+
expect(Object.values(params)).not.toContain("batch_test");
925+
});
926+
927+
it("should handle values without the prefix (pass through unchanged)", () => {
928+
const ctx = createPrefixedContext();
929+
const { params } = printQuery("SELECT * FROM runs WHERE batch_id = 'raw_value'", ctx);
930+
931+
// If no prefix to strip, the value passes through unchanged
932+
expect(Object.values(params)).toContain("raw_value");
933+
});
934+
935+
it("should not transform values for columns without whereTransform", () => {
936+
const ctx = createPrefixedContext();
937+
const { params } = printQuery("SELECT * FROM runs WHERE id = 'batch_abc123'", ctx);
938+
939+
// 'id' column has no whereTransform, so value passes through unchanged
940+
expect(Object.values(params)).toContain("batch_abc123");
941+
});
942+
943+
it("should use expression for SELECT output (virtual column)", () => {
944+
const ctx = createPrefixedContext();
945+
const { sql } = printQuery("SELECT batch_id FROM runs", ctx);
946+
947+
// The expression should be used in SELECT
948+
expect(sql).toContain("if(batch_id = '', NULL, concat('batch_', batch_id))");
949+
});
950+
951+
it("should work with different prefix patterns", () => {
952+
const ctx = createPrefixedContext();
953+
const { params } = printQuery("SELECT * FROM runs WHERE schedule_id = 'sched_xyz789'", ctx);
954+
955+
// The "sched_" prefix should be stripped
956+
expect(Object.values(params)).toContain("xyz789");
957+
expect(Object.values(params)).not.toContain("sched_xyz789");
958+
});
959+
960+
it("should transform values in tuple/array for IN expressions", () => {
961+
const ctx = createPrefixedContext();
962+
const { params } = printQuery(
963+
"SELECT * FROM runs WHERE batch_id IN ['batch_a', 'batch_b']",
964+
ctx
965+
);
966+
967+
// Both prefixes should be stripped
968+
expect(Object.values(params)).toContain("a");
969+
expect(Object.values(params)).toContain("b");
970+
});
971+
});
972+
844973
describe("Edge cases", () => {
845974
it("should handle empty string values", () => {
846975
const { sql, params } = printQuery("SELECT * FROM task_runs WHERE status = ''");
@@ -1180,10 +1309,7 @@ describe("Expression columns with division (cost/invocation_cost pattern)", () =
11801309

11811310
it("should expand compute_cost in BETWEEN correctly", () => {
11821311
const ctx = createCostExpressionContext();
1183-
const { sql } = printQuery(
1184-
"SELECT * FROM runs WHERE compute_cost BETWEEN 1.0 AND 10.0",
1185-
ctx
1186-
);
1312+
const { sql } = printQuery("SELECT * FROM runs WHERE compute_cost BETWEEN 1.0 AND 10.0", ctx);
11871313

11881314
expect(sql).toContain("(cost_in_cents / 100.0) BETWEEN 1 AND 10");
11891315
});
@@ -1564,10 +1690,7 @@ describe("Column metadata", () => {
15641690

15651691
it("should generate implicit names for multiple aggregations without aliases", () => {
15661692
const ctx = createMetadataTestContext();
1567-
const { columns } = printQuery(
1568-
"SELECT COUNT(), status FROM runs GROUP BY status",
1569-
ctx
1570-
);
1693+
const { columns } = printQuery("SELECT COUNT(), status FROM runs GROUP BY status", ctx);
15711694

15721695
expect(columns).toHaveLength(2);
15731696
expect(columns[0].name).toBe("count");
@@ -1578,10 +1701,7 @@ describe("Column metadata", () => {
15781701

15791702
it("should generate implicit name for arithmetic expressions", () => {
15801703
const ctx = createMetadataTestContext();
1581-
const { columns } = printQuery(
1582-
"SELECT usage_duration_ms + 100 FROM runs",
1583-
ctx
1584-
);
1704+
const { columns } = printQuery("SELECT usage_duration_ms + 100 FROM runs", ctx);
15851705

15861706
expect(columns).toHaveLength(1);
15871707
expect(columns[0].name).toBe("plus");
@@ -1611,10 +1731,7 @@ describe("Column metadata", () => {
16111731

16121732
it("should add AS clause to generated SQL for implicit names", () => {
16131733
const ctx = createMetadataTestContext();
1614-
const { sql, columns } = printQuery(
1615-
"SELECT COUNT(), status FROM runs GROUP BY status",
1616-
ctx
1617-
);
1734+
const { sql, columns } = printQuery("SELECT COUNT(), status FROM runs GROUP BY status", ctx);
16181735

16191736
// The SQL should include an explicit AS clause for the COUNT()
16201737
expect(sql).toContain("count() AS count");
@@ -1731,10 +1848,7 @@ describe("Field Mapping Value Transformation", () => {
17311848
it("should pass through unmapped values unchanged", () => {
17321849
const ctx = createFieldMappingContext();
17331850
// Using a value that's not in the mapping
1734-
const { params } = printQuery(
1735-
"SELECT run_id FROM runs WHERE project_ref = 'unknown-ref'",
1736-
ctx
1737-
);
1851+
const { params } = printQuery("SELECT run_id FROM runs WHERE project_ref = 'unknown-ref'", ctx);
17381852

17391853
// Value should remain unchanged since it's not in the mapping
17401854
expect(Object.values(params)).toContain("unknown-ref");

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,11 +1431,16 @@ export class ClickHousePrinter {
14311431
}
14321432

14331433
/**
1434-
* Transform a single string value using either valueMap or fieldMapping
1434+
* Transform a single string value using whereTransform, valueMap, or fieldMapping
14351435
* Returns the transformed value, or the original value if no transformation applies
14361436
*/
14371437
private transformSingleValue(columnSchema: ColumnSchema, value: string): string {
1438-
// First try static valueMap
1438+
// First try whereTransform function (highest priority)
1439+
if (columnSchema.whereTransform) {
1440+
return columnSchema.whereTransform(value);
1441+
}
1442+
1443+
// Then try static valueMap
14391444
if (columnSchema.valueMap) {
14401445
const internalValue = getInternalValue(columnSchema, value);
14411446
if (internalValue !== value) {
@@ -1459,7 +1464,7 @@ export class ClickHousePrinter {
14591464
}
14601465

14611466
/**
1462-
* Transform an expression's values using the column's valueMap or fieldMapping if applicable
1467+
* Transform an expression's values using the column's whereTransform, valueMap, or fieldMapping if applicable
14631468
* Returns the original expression if no transformation is needed
14641469
*/
14651470
private transformValueMapExpression(
@@ -1472,9 +1477,10 @@ export class ClickHousePrinter {
14721477
}
14731478

14741479
// Check if column has any transformation mechanism
1480+
const hasWhereTransform = columnSchema.whereTransform !== undefined;
14751481
const hasValueMap = columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0;
14761482
const hasFieldMap = hasFieldMapping(columnSchema);
1477-
if (!hasValueMap && !hasFieldMap) {
1483+
if (!hasWhereTransform && !hasValueMap && !hasFieldMap) {
14781484
return expr;
14791485
}
14801486

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,32 @@ export interface ColumnSchema {
154154
* ```
155155
*/
156156
fieldMapping?: string;
157+
/**
158+
* Transform function for user input values in WHERE clauses.
159+
*
160+
* When set, this function is called to transform user-provided values before
161+
* they are used in comparisons. This is useful for columns where:
162+
* - Users query with prefixed IDs (e.g., "batch_xyz") but the column stores raw values ("xyz")
163+
* - Values need normalization before comparison
164+
*
165+
* The function receives the user's input string and returns the transformed value
166+
* to use in the actual ClickHouse query.
167+
*
168+
* For output transformation (adding prefixes in SELECT), use `expression` instead.
169+
*
170+
* @example
171+
* ```typescript
172+
* {
173+
* name: "batch_id",
174+
* type: "String",
175+
* // Strip "batch_" prefix from user input in WHERE clauses
176+
* whereTransform: (value) => value.replace(/^batch_/, ""),
177+
* // Add prefix back in SELECT output
178+
* expression: "if(batch_id = '', NULL, concat('batch_', batch_id))",
179+
* }
180+
* ```
181+
*/
182+
whereTransform?: (value: string) => string;
157183
}
158184

159185
/**

0 commit comments

Comments
 (0)