Allow mapping values to user friendly names
This commit is contained in:
@@ -321,8 +321,20 @@ function findColumnSchema(
|
||||
|
||||
/**
|
||||
* Create completions for enum values
|
||||
* Uses user-friendly values from valueMap when available, showing internal value as detail
|
||||
*/
|
||||
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
|
||||
// Prefer valueMap over allowedValues if available
|
||||
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
|
||||
return Object.entries(columnSchema.valueMap).map(([internalValue, userFriendlyValue]) => ({
|
||||
label: `'${userFriendlyValue}'`,
|
||||
type: "enum",
|
||||
detail: `→ ${internalValue}`,
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to allowedValues
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
import { z } from "zod";
|
||||
import { compileTSQL, type TableSchema, type QuerySettings } from "@internal/tsql";
|
||||
import {
|
||||
compileTSQL,
|
||||
transformResults,
|
||||
type TableSchema,
|
||||
type QuerySettings,
|
||||
} from "@internal/tsql";
|
||||
import type { ClickhouseReader } from "./types.js";
|
||||
import { QueryError } from "./errors.js";
|
||||
|
||||
@@ -36,6 +41,13 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
/** Optional TSQL query settings (maxRows, timezone, etc.) */
|
||||
querySettings?: Partial<QuerySettings>;
|
||||
/**
|
||||
* Whether to transform result values using the schema's valueMap
|
||||
* When enabled, internal ClickHouse values (e.g., 'COMPLETED_SUCCESSFULLY')
|
||||
* are converted to user-friendly display names (e.g., 'Completed')
|
||||
* @default true
|
||||
*/
|
||||
transformValues?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +79,8 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
reader: ClickhouseReader,
|
||||
options: ExecuteTSQLOptions<TOut>
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
const shouldTransformValues = options.transformValues ?? true;
|
||||
|
||||
try {
|
||||
// 1. Compile the TSQL query to ClickHouse SQL
|
||||
const { sql, params } = compileTSQL(options.query, {
|
||||
@@ -86,7 +100,22 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
settings: options.clickhouseSettings,
|
||||
});
|
||||
|
||||
return await queryFn(params);
|
||||
const [error, rows] = await queryFn(params);
|
||||
|
||||
if (error) {
|
||||
return [error, null];
|
||||
}
|
||||
|
||||
// 3. Transform result values if enabled
|
||||
if (shouldTransformValues && rows) {
|
||||
const transformedRows = transformResults(
|
||||
rows as Record<string, unknown>[],
|
||||
options.tableSchema
|
||||
);
|
||||
return [null, transformedRows as z.output<TOut>[]];
|
||||
}
|
||||
|
||||
return [null, rows];
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return [new QueryError(error.message, { query: options.query }), null];
|
||||
|
||||
@@ -73,6 +73,11 @@ export {
|
||||
validateSortColumn,
|
||||
validateGroupColumn,
|
||||
column,
|
||||
// Value mapping utilities
|
||||
getUserFriendlyValue,
|
||||
getInternalValue,
|
||||
getAllowedUserValues,
|
||||
isValidUserValue,
|
||||
} from "./query/schema.js";
|
||||
|
||||
// Re-export printer context
|
||||
@@ -99,6 +104,13 @@ export {
|
||||
type ValidationSeverity,
|
||||
} from "./query/validator.js";
|
||||
|
||||
// Re-export result transformation utilities
|
||||
export {
|
||||
transformResults,
|
||||
createResultTransformer,
|
||||
type TransformResultsOptions,
|
||||
} from "./query/results.js";
|
||||
|
||||
/**
|
||||
* Parse a TSQL SELECT query string into an AST
|
||||
*
|
||||
|
||||
@@ -675,6 +675,120 @@ describe("ClickHousePrinter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Value mapping (valueMap)", () => {
|
||||
/**
|
||||
* Schema with valueMap for status column
|
||||
*/
|
||||
const statusMappedSchema: TableSchema = {
|
||||
name: "runs",
|
||||
clickhouseName: "trigger_dev.task_runs_v2",
|
||||
columns: {
|
||||
id: { name: "id", ...column("String") },
|
||||
status: {
|
||||
name: "status",
|
||||
...column("String"),
|
||||
valueMap: {
|
||||
COMPLETED_SUCCESSFULLY: "Completed",
|
||||
COMPLETED_WITH_ERRORS: "Completed with errors",
|
||||
SYSTEM_FAILURE: "System failure",
|
||||
PENDING: "Pending",
|
||||
EXECUTING: "Running",
|
||||
FAILED: "Failed",
|
||||
},
|
||||
},
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
};
|
||||
|
||||
function createValueMapContext() {
|
||||
const schema = createSchemaRegistry([statusMappedSchema]);
|
||||
return createPrinterContext({
|
||||
organizationId: "org_test",
|
||||
projectId: "proj_test",
|
||||
environmentId: "env_test",
|
||||
schema,
|
||||
});
|
||||
}
|
||||
|
||||
it("should transform user-friendly value to internal value in equality comparison", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { sql, params } = printQuery("SELECT * FROM runs WHERE status = 'Completed'", ctx);
|
||||
|
||||
// The user-friendly value "Completed" should be transformed to "COMPLETED_SUCCESSFULLY"
|
||||
expect(Object.values(params)).toContain("COMPLETED_SUCCESSFULLY");
|
||||
expect(Object.values(params)).not.toContain("Completed");
|
||||
});
|
||||
|
||||
it("should transform user-friendly values in IN clause", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { sql, params } = printQuery(
|
||||
"SELECT * FROM runs WHERE status IN ('Completed', 'Failed', 'Running')",
|
||||
ctx
|
||||
);
|
||||
|
||||
// All user-friendly values should be transformed
|
||||
expect(Object.values(params)).toContain("COMPLETED_SUCCESSFULLY");
|
||||
expect(Object.values(params)).toContain("FAILED");
|
||||
expect(Object.values(params)).toContain("EXECUTING");
|
||||
expect(Object.values(params)).not.toContain("Completed");
|
||||
expect(Object.values(params)).not.toContain("Failed");
|
||||
expect(Object.values(params)).not.toContain("Running");
|
||||
});
|
||||
|
||||
it("should handle case-insensitive value matching", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { params: params1 } = printQuery("SELECT * FROM runs WHERE status = 'completed'", ctx);
|
||||
const { params: params2 } = printQuery("SELECT * FROM runs WHERE status = 'COMPLETED'", ctx);
|
||||
const { params: params3 } = printQuery("SELECT * FROM runs WHERE status = 'Completed'", ctx);
|
||||
|
||||
// All variations should map to the same internal value
|
||||
expect(Object.values(params1)).toContain("COMPLETED_SUCCESSFULLY");
|
||||
expect(Object.values(params2)).toContain("COMPLETED_SUCCESSFULLY");
|
||||
expect(Object.values(params3)).toContain("COMPLETED_SUCCESSFULLY");
|
||||
});
|
||||
|
||||
it("should pass through values without mapping if not in valueMap", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { params } = printQuery("SELECT * FROM runs WHERE status = 'UNKNOWN_STATUS'", ctx);
|
||||
|
||||
// Value not in valueMap should pass through unchanged
|
||||
expect(Object.values(params)).toContain("UNKNOWN_STATUS");
|
||||
});
|
||||
|
||||
it("should transform values in NOT IN clause", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { sql, params } = printQuery(
|
||||
"SELECT * FROM runs WHERE status NOT IN ('Pending', 'System failure')",
|
||||
ctx
|
||||
);
|
||||
|
||||
expect(Object.values(params)).toContain("PENDING");
|
||||
expect(Object.values(params)).toContain("SYSTEM_FAILURE");
|
||||
});
|
||||
|
||||
it("should transform values in != comparison", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { params } = printQuery("SELECT * FROM runs WHERE status != 'Failed'", ctx);
|
||||
|
||||
expect(Object.values(params)).toContain("FAILED");
|
||||
});
|
||||
|
||||
it("should not transform values for columns without valueMap", () => {
|
||||
const ctx = createValueMapContext();
|
||||
const { params } = printQuery("SELECT * FROM runs WHERE id = 'Completed'", ctx);
|
||||
|
||||
// 'id' column has no valueMap, so "Completed" should pass through unchanged
|
||||
expect(Object.values(params)).toContain("Completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty string values", () => {
|
||||
const { sql, params } = printQuery("SELECT * FROM task_runs WHERE status = ''");
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
validateFunctionArgs,
|
||||
} from "./functions";
|
||||
import { PrinterContext } from "./printer_context";
|
||||
import { findTable, validateTable, TableSchema } from "./schema";
|
||||
import { findTable, validateTable, TableSchema, ColumnSchema, getInternalValue } from "./schema";
|
||||
|
||||
/**
|
||||
* Result of printing an AST to ClickHouse SQL
|
||||
@@ -649,15 +649,21 @@ export class ClickHousePrinter {
|
||||
}
|
||||
|
||||
private visitCompareOperation(node: CompareOperation): string {
|
||||
// Check if we need to transform values using valueMap
|
||||
const columnSchema = this.extractColumnSchemaFromExpression(node.left);
|
||||
|
||||
// Transform the right side if it contains user-friendly values
|
||||
const transformedRight = this.transformValueMapExpression(node.right, columnSchema);
|
||||
|
||||
const left = this.visit(node.left);
|
||||
const right = this.visit(node.right);
|
||||
const right = this.visit(transformedRight);
|
||||
|
||||
switch (node.op) {
|
||||
case CompareOperationOp.Eq:
|
||||
// Handle NULL comparison
|
||||
if (
|
||||
(node.right as Constant).expression_type === "constant" &&
|
||||
(node.right as Constant).value === null
|
||||
(transformedRight as Constant).expression_type === "constant" &&
|
||||
(transformedRight as Constant).value === null
|
||||
) {
|
||||
return `isNull(${left})`;
|
||||
}
|
||||
@@ -672,8 +678,8 @@ export class ClickHousePrinter {
|
||||
case CompareOperationOp.NotEq:
|
||||
// Handle NULL comparison
|
||||
if (
|
||||
(node.right as Constant).expression_type === "constant" &&
|
||||
(node.right as Constant).value === null
|
||||
(transformedRight as Constant).expression_type === "constant" &&
|
||||
(transformedRight as Constant).value === null
|
||||
) {
|
||||
return `isNotNull(${left})`;
|
||||
}
|
||||
@@ -722,6 +728,113 @@ export class ClickHousePrinter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column schema from a field expression if it references a known column
|
||||
*/
|
||||
private extractColumnSchemaFromExpression(expr: Expression): ColumnSchema | null {
|
||||
if ((expr as Field).expression_type !== "field") return null;
|
||||
|
||||
const field = expr as Field;
|
||||
const chain = field.chain;
|
||||
|
||||
if (chain.length === 0) return null;
|
||||
|
||||
const firstPart = chain[0];
|
||||
if (typeof firstPart !== "string") return null;
|
||||
|
||||
// Qualified reference: table.column
|
||||
if (chain.length >= 2) {
|
||||
const tableAlias = firstPart;
|
||||
const tableSchema = this.tableContexts.get(tableAlias);
|
||||
if (!tableSchema) return null;
|
||||
|
||||
const columnName = chain[1];
|
||||
if (typeof columnName !== "string") return null;
|
||||
|
||||
return tableSchema.columns[columnName] || null;
|
||||
}
|
||||
|
||||
// Unqualified reference
|
||||
const columnName = firstPart;
|
||||
for (const tableSchema of this.tableContexts.values()) {
|
||||
const columnSchema = tableSchema.columns[columnName];
|
||||
if (columnSchema) {
|
||||
return columnSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an expression's values using the column's valueMap if applicable
|
||||
* Returns the original expression if no transformation is needed
|
||||
*/
|
||||
private transformValueMapExpression(
|
||||
expr: Expression,
|
||||
columnSchema: ColumnSchema | null
|
||||
): Expression {
|
||||
// No column schema or no valueMap, return as-is
|
||||
if (!columnSchema || !columnSchema.valueMap) {
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Handle constant string values
|
||||
if ((expr as Constant).expression_type === "constant") {
|
||||
const constant = expr as Constant;
|
||||
if (typeof constant.value === "string") {
|
||||
const internalValue = getInternalValue(columnSchema, constant.value);
|
||||
if (internalValue !== constant.value) {
|
||||
// Return a new constant with the transformed value
|
||||
return {
|
||||
expression_type: "constant",
|
||||
value: internalValue,
|
||||
} as Constant;
|
||||
}
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Handle arrays (for IN expressions with [...])
|
||||
if ((expr as ASTArray).expression_type === "array") {
|
||||
const array = expr as ASTArray;
|
||||
const transformedExprs = array.exprs.map((e) =>
|
||||
this.transformValueMapExpression(e, columnSchema)
|
||||
);
|
||||
|
||||
// Check if any expressions were actually transformed
|
||||
const hasChanges = transformedExprs.some((e, i) => e !== array.exprs[i]);
|
||||
if (hasChanges) {
|
||||
return {
|
||||
expression_type: "array",
|
||||
exprs: transformedExprs,
|
||||
} as ASTArray;
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Handle tuples (for IN expressions with (...))
|
||||
if ((expr as Tuple).expression_type === "tuple") {
|
||||
const tuple = expr as Tuple;
|
||||
const transformedExprs = tuple.exprs.map((e) =>
|
||||
this.transformValueMapExpression(e, columnSchema)
|
||||
);
|
||||
|
||||
// Check if any expressions were actually transformed
|
||||
const hasChanges = transformedExprs.some((e, i) => e !== tuple.exprs[i]);
|
||||
if (hasChanges) {
|
||||
return {
|
||||
expression_type: "tuple",
|
||||
exprs: transformedExprs,
|
||||
} as Tuple;
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Other expression types, return as-is
|
||||
return expr;
|
||||
}
|
||||
|
||||
private visitBetweenExpr(node: BetweenExpr): string {
|
||||
const expr = this.visit(node.expr);
|
||||
const low = this.visit(node.low);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { transformResults, createResultTransformer } from "./results.js";
|
||||
import { column, type TableSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Test schema with valueMap
|
||||
*/
|
||||
const taskRunsSchema: TableSchema = {
|
||||
name: "task_runs",
|
||||
clickhouseName: "trigger_dev.task_runs_v2",
|
||||
columns: {
|
||||
id: { name: "id", ...column("String") },
|
||||
status: {
|
||||
name: "status",
|
||||
...column("String"),
|
||||
valueMap: {
|
||||
COMPLETED_SUCCESSFULLY: "Completed",
|
||||
COMPLETED_WITH_ERRORS: "Completed with errors",
|
||||
SYSTEM_FAILURE: "System failure",
|
||||
PENDING: "Pending",
|
||||
EXECUTING: "Running",
|
||||
FAILED: "Failed",
|
||||
CANCELLED: "Cancelled",
|
||||
},
|
||||
},
|
||||
environment_type: {
|
||||
name: "environment_type",
|
||||
...column("String"),
|
||||
valueMap: {
|
||||
DEVELOPMENT: "Development",
|
||||
STAGING: "Staging",
|
||||
PRODUCTION: "Production",
|
||||
},
|
||||
},
|
||||
task_identifier: { name: "task_identifier", ...column("String") },
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema without valueMap
|
||||
*/
|
||||
const simpleSchema: TableSchema = {
|
||||
name: "simple",
|
||||
clickhouseName: "trigger_dev.simple",
|
||||
columns: {
|
||||
id: { name: "id", ...column("String") },
|
||||
name: { name: "name", ...column("String") },
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
};
|
||||
|
||||
describe("transformResults", () => {
|
||||
it("should transform internal values to user-friendly values", () => {
|
||||
const rows = [
|
||||
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" },
|
||||
{ id: "run_2", status: "PENDING", task_identifier: "other-task" },
|
||||
{ id: "run_3", status: "FAILED", task_identifier: "my-task" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
expect(transformed[0].status).toBe("Completed");
|
||||
expect(transformed[1].status).toBe("Pending");
|
||||
expect(transformed[2].status).toBe("Failed");
|
||||
});
|
||||
|
||||
it("should transform multiple columns with valueMaps", () => {
|
||||
const rows = [
|
||||
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", environment_type: "PRODUCTION" },
|
||||
{ id: "run_2", status: "PENDING", environment_type: "DEVELOPMENT" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
expect(transformed[0].status).toBe("Completed");
|
||||
expect(transformed[0].environment_type).toBe("Production");
|
||||
expect(transformed[1].status).toBe("Pending");
|
||||
expect(transformed[1].environment_type).toBe("Development");
|
||||
});
|
||||
|
||||
it("should not modify columns without valueMap", () => {
|
||||
const rows = [
|
||||
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
// id and task_identifier should be unchanged
|
||||
expect(transformed[0].id).toBe("run_1");
|
||||
expect(transformed[0].task_identifier).toBe("my-task");
|
||||
});
|
||||
|
||||
it("should pass through values not in valueMap unchanged", () => {
|
||||
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS", task_identifier: "my-task" }];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
// UNKNOWN_STATUS is not in the valueMap, should be passed through
|
||||
expect(transformed[0].status).toBe("UNKNOWN_STATUS");
|
||||
});
|
||||
|
||||
it("should return original rows if no columns have valueMap", () => {
|
||||
const rows = [
|
||||
{ id: "run_1", name: "test" },
|
||||
{ id: "run_2", name: "other" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [simpleSchema]);
|
||||
|
||||
// Should return the same array (reference equality)
|
||||
expect(transformed).toBe(rows);
|
||||
});
|
||||
|
||||
it("should handle empty rows array", () => {
|
||||
const rows: Array<{ id: string; status: string }> = [];
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
expect(transformed).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive internal value matching", () => {
|
||||
const rows = [
|
||||
{ id: "run_1", status: "completed_successfully" },
|
||||
{ id: "run_2", status: "COMPLETED_SUCCESSFULLY" },
|
||||
{ id: "run_3", status: "Completed_Successfully" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
// All should map to "Completed"
|
||||
expect(transformed[0].status).toBe("Completed");
|
||||
expect(transformed[1].status).toBe("Completed");
|
||||
expect(transformed[2].status).toBe("Completed");
|
||||
});
|
||||
|
||||
it("should preserve non-string column values", () => {
|
||||
const rows = [{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", count: 42, active: true }];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
expect(transformed[0].count).toBe(42);
|
||||
expect(transformed[0].active).toBe(true);
|
||||
expect(transformed[0].status).toBe("Completed");
|
||||
});
|
||||
|
||||
it("should preserve row reference if no changes made", () => {
|
||||
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS" }];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema]);
|
||||
|
||||
// The row has status that doesn't match any valueMap entry
|
||||
// But the column does have a valueMap, so we still check it
|
||||
// Since the value doesn't change, the row reference should be preserved
|
||||
expect(transformed[0]).toBe(rows[0]);
|
||||
});
|
||||
|
||||
it("should handle multiple table schemas", () => {
|
||||
const anotherSchema: TableSchema = {
|
||||
name: "events",
|
||||
clickhouseName: "trigger_dev.events",
|
||||
columns: {
|
||||
id: { name: "id", ...column("String") },
|
||||
event_type: {
|
||||
name: "event_type",
|
||||
...column("String"),
|
||||
valueMap: {
|
||||
TASK_START: "Started",
|
||||
TASK_END: "Ended",
|
||||
},
|
||||
},
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
};
|
||||
|
||||
const rows = [
|
||||
{ status: "COMPLETED_SUCCESSFULLY", event_type: "TASK_START" },
|
||||
{ status: "PENDING", event_type: "TASK_END" },
|
||||
];
|
||||
|
||||
const transformed = transformResults(rows, [taskRunsSchema, anotherSchema]);
|
||||
|
||||
expect(transformed[0].status).toBe("Completed");
|
||||
expect(transformed[0].event_type).toBe("Started");
|
||||
expect(transformed[1].status).toBe("Pending");
|
||||
expect(transformed[1].event_type).toBe("Ended");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResultTransformer", () => {
|
||||
it("should create a reusable transformer function", () => {
|
||||
const transform = createResultTransformer([taskRunsSchema]);
|
||||
|
||||
const rows1 = [{ id: "1", status: "COMPLETED_SUCCESSFULLY" }];
|
||||
const rows2 = [{ id: "2", status: "FAILED" }];
|
||||
|
||||
const transformed1 = transform(rows1);
|
||||
const transformed2 = transform(rows2);
|
||||
|
||||
expect(transformed1[0].status).toBe("Completed");
|
||||
expect(transformed2[0].status).toBe("Failed");
|
||||
});
|
||||
|
||||
it("should return original rows if no valueMap columns exist", () => {
|
||||
const transform = createResultTransformer([simpleSchema]);
|
||||
|
||||
const rows = [{ id: "1", name: "test" }];
|
||||
const transformed = transform(rows);
|
||||
|
||||
expect(transformed).toBe(rows);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Result transformation utilities for TSQL queries
|
||||
*
|
||||
* Transforms query result values from internal ClickHouse values
|
||||
* to user-friendly display names using the column valueMap.
|
||||
*/
|
||||
|
||||
import type { TableSchema, ColumnSchema } from "./schema.js";
|
||||
import { getUserFriendlyValue } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Options for transforming query results
|
||||
*/
|
||||
export interface TransformResultsOptions {
|
||||
/**
|
||||
* If true, transform values even if the column was aliased (e.g., SELECT status AS s)
|
||||
* Default: false (aliased columns are not transformed since the user explicitly chose a different name)
|
||||
*/
|
||||
transformAliased?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform query result rows, mapping internal values to user-friendly display names
|
||||
*
|
||||
* This function iterates over result rows and transforms any column values that have
|
||||
* a `valueMap` defined in their schema, converting internal ClickHouse values
|
||||
* (e.g., 'COMPLETED_SUCCESSFULLY') back to user-friendly display names (e.g., 'Completed').
|
||||
*
|
||||
* @param rows - Array of result rows to transform
|
||||
* @param schema - Array of table schemas containing column definitions with valueMaps
|
||||
* @param options - Optional transformation options
|
||||
* @returns New array of rows with transformed values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const schema: TableSchema[] = [{
|
||||
* name: "task_runs",
|
||||
* clickhouseName: "trigger_dev.task_runs_v2",
|
||||
* columns: {
|
||||
* status: {
|
||||
* name: "status",
|
||||
* type: "String",
|
||||
* valueMap: {
|
||||
* "COMPLETED_SUCCESSFULLY": "Completed",
|
||||
* "PENDING": "Pending",
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* tenantColumns: { organizationId: "organization_id", projectId: "project_id", environmentId: "environment_id" },
|
||||
* }];
|
||||
*
|
||||
* const results = [{ status: "COMPLETED_SUCCESSFULLY", run_id: "run_123" }];
|
||||
* const transformed = transformResults(results, schema);
|
||||
* // transformed = [{ status: "Completed", run_id: "run_123" }]
|
||||
* ```
|
||||
*/
|
||||
export function transformResults<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
schema: TableSchema[],
|
||||
options: TransformResultsOptions = {}
|
||||
): T[] {
|
||||
// Build a map of column names to their schemas (for columns that have valueMaps)
|
||||
const columnValueMaps = buildColumnValueMaps(schema);
|
||||
|
||||
// If no columns have valueMaps, return the original rows unchanged
|
||||
if (columnValueMaps.size === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Transform each row
|
||||
return rows.map((row) => transformRow(row, columnValueMaps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of column names to their schemas for columns that have valueMaps
|
||||
*/
|
||||
function buildColumnValueMaps(schema: TableSchema[]): Map<string, ColumnSchema> {
|
||||
const columnMaps = new Map<string, ColumnSchema>();
|
||||
|
||||
for (const table of schema) {
|
||||
for (const [columnName, columnSchema] of Object.entries(table.columns)) {
|
||||
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
|
||||
// Use the TSQL-exposed column name (not the ClickHouse name)
|
||||
columnMaps.set(columnName, columnSchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return columnMaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single row's values using the column valueMaps
|
||||
*/
|
||||
function transformRow<T extends Record<string, unknown>>(
|
||||
row: T,
|
||||
columnValueMaps: Map<string, ColumnSchema>
|
||||
): T {
|
||||
const transformedRow: Record<string, unknown> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const columnSchema = columnValueMaps.get(key);
|
||||
|
||||
if (columnSchema && typeof value === "string") {
|
||||
const transformedValue = getUserFriendlyValue(columnSchema, value);
|
||||
transformedRow[key] = transformedValue;
|
||||
if (transformedValue !== value) {
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
transformedRow[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Return original row if no changes were made (preserves reference equality)
|
||||
return hasChanges ? (transformedRow as T) : row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result transformer bound to a specific schema
|
||||
*
|
||||
* Useful when you need to transform multiple result sets with the same schema.
|
||||
*
|
||||
* @param schema - Array of table schemas
|
||||
* @param options - Optional transformation options
|
||||
* @returns A function that transforms result rows
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const transform = createResultTransformer(schema);
|
||||
*
|
||||
* const results1 = await query1();
|
||||
* const transformed1 = transform(results1);
|
||||
*
|
||||
* const results2 = await query2();
|
||||
* const transformed2 = transform(results2);
|
||||
* ```
|
||||
*/
|
||||
export function createResultTransformer(
|
||||
schema: TableSchema[],
|
||||
options: TransformResultsOptions = {}
|
||||
): <T extends Record<string, unknown>>(rows: T[]) => T[] {
|
||||
const columnValueMaps = buildColumnValueMaps(schema);
|
||||
|
||||
return <T extends Record<string, unknown>>(rows: T[]): T[] => {
|
||||
if (columnValueMaps.size === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.map((row) => transformRow(row, columnValueMaps));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
column,
|
||||
getUserFriendlyValue,
|
||||
getInternalValue,
|
||||
getAllowedUserValues,
|
||||
isValidUserValue,
|
||||
type ColumnSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
describe("Value mapping helper functions", () => {
|
||||
const columnWithValueMap: ColumnSchema = {
|
||||
name: "status",
|
||||
...column("String"),
|
||||
valueMap: {
|
||||
COMPLETED_SUCCESSFULLY: "Completed",
|
||||
COMPLETED_WITH_ERRORS: "Completed with errors",
|
||||
SYSTEM_FAILURE: "System failure",
|
||||
PENDING: "Pending",
|
||||
EXECUTING: "Running",
|
||||
FAILED: "Failed",
|
||||
},
|
||||
};
|
||||
|
||||
const columnWithAllowedValues: ColumnSchema = {
|
||||
name: "status",
|
||||
...column("String"),
|
||||
allowedValues: ["completed", "pending", "failed"],
|
||||
};
|
||||
|
||||
const columnWithNoRestrictions: ColumnSchema = {
|
||||
name: "task_identifier",
|
||||
...column("String"),
|
||||
};
|
||||
|
||||
describe("getUserFriendlyValue", () => {
|
||||
it("should return user-friendly value for internal value", () => {
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "PENDING")).toBe("Pending");
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "EXECUTING")).toBe("Running");
|
||||
});
|
||||
|
||||
it("should be case-insensitive for internal value lookup", () => {
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "completed_successfully")).toBe("Completed");
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "Completed_Successfully")).toBe("Completed");
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
|
||||
});
|
||||
|
||||
it("should return original value if no mapping exists", () => {
|
||||
expect(getUserFriendlyValue(columnWithValueMap, "UNKNOWN_STATUS")).toBe("UNKNOWN_STATUS");
|
||||
});
|
||||
|
||||
it("should return original value if column has no valueMap", () => {
|
||||
expect(getUserFriendlyValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInternalValue", () => {
|
||||
it("should return internal value for user-friendly value", () => {
|
||||
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
|
||||
expect(getInternalValue(columnWithValueMap, "Pending")).toBe("PENDING");
|
||||
expect(getInternalValue(columnWithValueMap, "Running")).toBe("EXECUTING");
|
||||
});
|
||||
|
||||
it("should be case-insensitive for user-friendly value lookup", () => {
|
||||
expect(getInternalValue(columnWithValueMap, "completed")).toBe("COMPLETED_SUCCESSFULLY");
|
||||
expect(getInternalValue(columnWithValueMap, "COMPLETED")).toBe("COMPLETED_SUCCESSFULLY");
|
||||
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
|
||||
});
|
||||
|
||||
it("should return original value if no mapping exists", () => {
|
||||
expect(getInternalValue(columnWithValueMap, "Unknown")).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("should return original value if column has no valueMap", () => {
|
||||
expect(getInternalValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
|
||||
});
|
||||
|
||||
it("should handle multi-word user-friendly values", () => {
|
||||
expect(getInternalValue(columnWithValueMap, "Completed with errors")).toBe(
|
||||
"COMPLETED_WITH_ERRORS"
|
||||
);
|
||||
expect(getInternalValue(columnWithValueMap, "completed with errors")).toBe(
|
||||
"COMPLETED_WITH_ERRORS"
|
||||
);
|
||||
expect(getInternalValue(columnWithValueMap, "System failure")).toBe("SYSTEM_FAILURE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedUserValues", () => {
|
||||
it("should return user-friendly values from valueMap", () => {
|
||||
const values = getAllowedUserValues(columnWithValueMap);
|
||||
|
||||
expect(values).toContain("Completed");
|
||||
expect(values).toContain("Pending");
|
||||
expect(values).toContain("Running");
|
||||
expect(values).toContain("Failed");
|
||||
expect(values).toContain("Completed with errors");
|
||||
expect(values).toContain("System failure");
|
||||
expect(values).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should return allowedValues if no valueMap exists", () => {
|
||||
const values = getAllowedUserValues(columnWithAllowedValues);
|
||||
|
||||
expect(values).toEqual(["completed", "pending", "failed"]);
|
||||
});
|
||||
|
||||
it("should prefer valueMap over allowedValues", () => {
|
||||
const columnWithBoth: ColumnSchema = {
|
||||
name: "status",
|
||||
...column("String"),
|
||||
allowedValues: ["internal1", "internal2"],
|
||||
valueMap: {
|
||||
internal1: "User 1",
|
||||
internal2: "User 2",
|
||||
},
|
||||
};
|
||||
|
||||
const values = getAllowedUserValues(columnWithBoth);
|
||||
|
||||
expect(values).toEqual(["User 1", "User 2"]);
|
||||
});
|
||||
|
||||
it("should return empty array for column with no restrictions", () => {
|
||||
const values = getAllowedUserValues(columnWithNoRestrictions);
|
||||
|
||||
expect(values).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidUserValue", () => {
|
||||
it("should return true for valid user-friendly values", () => {
|
||||
expect(isValidUserValue(columnWithValueMap, "Completed")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "Pending")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "Running")).toBe(true);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
expect(isValidUserValue(columnWithValueMap, "completed")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "COMPLETED")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "running")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid values", () => {
|
||||
expect(isValidUserValue(columnWithValueMap, "Unknown")).toBe(false);
|
||||
expect(isValidUserValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe(false); // internal value, not user-friendly
|
||||
});
|
||||
|
||||
it("should return true for any value if column has no restrictions", () => {
|
||||
expect(isValidUserValue(columnWithNoRestrictions, "any_value")).toBe(true);
|
||||
expect(isValidUserValue(columnWithNoRestrictions, "another")).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate against allowedValues if no valueMap", () => {
|
||||
expect(isValidUserValue(columnWithAllowedValues, "completed")).toBe(true);
|
||||
expect(isValidUserValue(columnWithAllowedValues, "COMPLETED")).toBe(true);
|
||||
expect(isValidUserValue(columnWithAllowedValues, "unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle multi-word values", () => {
|
||||
expect(isValidUserValue(columnWithValueMap, "Completed with errors")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "completed with errors")).toBe(true);
|
||||
expect(isValidUserValue(columnWithValueMap, "System failure")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,15 @@ export interface ColumnSchema {
|
||||
description?: string;
|
||||
/** Allowed values for this column (for enum-like columns) */
|
||||
allowedValues?: string[];
|
||||
/**
|
||||
* Map of internal values to user-friendly display names (for enum-like columns)
|
||||
* Key: internal ClickHouse value (e.g., "COMPLETED_SUCCESSFULLY")
|
||||
* Value: user-friendly display name (e.g., "Completed")
|
||||
*
|
||||
* When set, users can write queries using the user-friendly names,
|
||||
* and results will display user-friendly names instead of internal values.
|
||||
*/
|
||||
valueMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +283,91 @@ export function getClickHouseColumnName(col: ColumnSchema): string {
|
||||
return col.clickhouseName ?? col.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user-friendly display value for an internal value (case-insensitive)
|
||||
* Used for transforming query results back to user-friendly format
|
||||
*
|
||||
* @param col - The column schema
|
||||
* @param internalValue - The internal ClickHouse value
|
||||
* @returns The user-friendly display value, or the original value if no mapping exists
|
||||
*/
|
||||
export function getUserFriendlyValue(col: ColumnSchema, internalValue: string): string {
|
||||
if (!col.valueMap) {
|
||||
return internalValue;
|
||||
}
|
||||
|
||||
// Direct lookup first (case-sensitive for exact match)
|
||||
if (col.valueMap[internalValue] !== undefined) {
|
||||
return col.valueMap[internalValue];
|
||||
}
|
||||
|
||||
// Case-insensitive fallback
|
||||
const lowerValue = internalValue.toLowerCase();
|
||||
for (const [internal, friendly] of Object.entries(col.valueMap)) {
|
||||
if (internal.toLowerCase() === lowerValue) {
|
||||
return friendly;
|
||||
}
|
||||
}
|
||||
|
||||
return internalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the internal ClickHouse value for a user-friendly value (case-insensitive)
|
||||
* Used for transforming user queries to internal format
|
||||
*
|
||||
* @param col - The column schema
|
||||
* @param userValue - The user-friendly display value
|
||||
* @returns The internal ClickHouse value, or the original value if no mapping exists
|
||||
*/
|
||||
export function getInternalValue(col: ColumnSchema, userValue: string): string {
|
||||
if (!col.valueMap) {
|
||||
return userValue;
|
||||
}
|
||||
|
||||
const lowerUserValue = userValue.toLowerCase();
|
||||
|
||||
// Search for matching user-friendly value (case-insensitive)
|
||||
for (const [internal, friendly] of Object.entries(col.valueMap)) {
|
||||
if (friendly.toLowerCase() === lowerUserValue) {
|
||||
return internal;
|
||||
}
|
||||
}
|
||||
|
||||
return userValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allowed user-friendly values for a column
|
||||
* Used for validation and autocomplete
|
||||
*
|
||||
* @param col - The column schema
|
||||
* @returns Array of allowed user-friendly values, or allowedValues if no valueMap exists
|
||||
*/
|
||||
export function getAllowedUserValues(col: ColumnSchema): string[] {
|
||||
if (col.valueMap) {
|
||||
return Object.values(col.valueMap);
|
||||
}
|
||||
return col.allowedValues ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user-provided value is valid for a column (case-insensitive)
|
||||
*
|
||||
* @param col - The column schema
|
||||
* @param userValue - The user-provided value to validate
|
||||
* @returns true if the value is valid, false otherwise
|
||||
*/
|
||||
export function isValidUserValue(col: ColumnSchema, userValue: string): boolean {
|
||||
const allowedValues = getAllowedUserValues(col);
|
||||
if (allowedValues.length === 0) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
|
||||
const lowerUserValue = userValue.toLowerCase();
|
||||
return allowedValues.some((v) => v.toLowerCase() === lowerUserValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all column names available for autocomplete
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
Array as ASTArray,
|
||||
} from "./ast.js";
|
||||
import type { TableSchema, ColumnSchema } from "./schema.js";
|
||||
import { getAllowedUserValues, isValidUserValue } from "./schema.js";
|
||||
import { CompareOperationOp } from "./ast.js";
|
||||
|
||||
/**
|
||||
@@ -360,8 +361,9 @@ function validateCompareOperation(op: CompareOperation, context: ValidationConte
|
||||
|
||||
const { columnSchema, columnName, tableName } = columnInfo;
|
||||
|
||||
// Only validate if the column has allowedValues
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) return;
|
||||
// Only validate if the column has allowedValues or valueMap
|
||||
const allowedValues = getAllowedUserValues(columnSchema);
|
||||
if (allowedValues.length === 0) return;
|
||||
|
||||
// Check the comparison type
|
||||
switch (op.op) {
|
||||
@@ -432,6 +434,7 @@ function extractColumnFromExpression(
|
||||
|
||||
/**
|
||||
* Validate that a value matches the allowed enum values for a column
|
||||
* Supports both allowedValues and valueMap, with case-insensitive matching
|
||||
*/
|
||||
function validateEnumValue(
|
||||
expr: Expression,
|
||||
@@ -446,10 +449,12 @@ function validateEnumValue(
|
||||
if (typeof constant.value !== "string") return;
|
||||
|
||||
const value = constant.value;
|
||||
const allowedValues = columnSchema.allowedValues!;
|
||||
|
||||
if (!allowedValues.includes(value)) {
|
||||
// Use isValidUserValue for case-insensitive validation against user-friendly values
|
||||
if (!isValidUserValue(columnSchema, value)) {
|
||||
const columnRef = tableName ? `${tableName}.${columnName}` : columnName;
|
||||
// Show user-friendly values in the error message
|
||||
const allowedValues = getAllowedUserValues(columnSchema);
|
||||
context.issues.push({
|
||||
message: `Invalid value "${value}" for column "${columnRef}". Allowed values: ${allowedValues.join(
|
||||
", "
|
||||
|
||||
Reference in New Issue
Block a user