diff --git a/apps/webapp/app/components/code/tsql/tsqlCompletion.ts b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts index 8c90546cb..8c7360003 100644 --- a/apps/webapp/app/components/code/tsql/tsqlCompletion.ts +++ b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts @@ -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 []; } diff --git a/internal-packages/clickhouse/src/client/tsql.ts b/internal-packages/clickhouse/src/client/tsql.ts index aff385255..ec7a3a3f8 100644 --- a/internal-packages/clickhouse/src/client/tsql.ts +++ b/internal-packages/clickhouse/src/client/tsql.ts @@ -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 { clickhouseSettings?: ClickHouseSettings; /** Optional TSQL query settings (maxRows, timezone, etc.) */ querySettings?: Partial; + /** + * 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( reader: ClickhouseReader, options: ExecuteTSQLOptions ): Promise>> { + 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( 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[], + options.tableSchema + ); + return [null, transformedRows as z.output[]]; + } + + return [null, rows]; } catch (error) { if (error instanceof Error) { return [new QueryError(error.message, { query: options.query }), null]; diff --git a/internal-packages/tsql/src/index.ts b/internal-packages/tsql/src/index.ts index a63195176..4dbca6a1c 100644 --- a/internal-packages/tsql/src/index.ts +++ b/internal-packages/tsql/src/index.ts @@ -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 * diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 3361dc23c..9c6cf20e0 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -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 = ''"); diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 0068e1a98..3cbbd9ccf 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -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); diff --git a/internal-packages/tsql/src/query/results.test.ts b/internal-packages/tsql/src/query/results.test.ts new file mode 100644 index 000000000..49216992c --- /dev/null +++ b/internal-packages/tsql/src/query/results.test.ts @@ -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); + }); +}); + diff --git a/internal-packages/tsql/src/query/results.ts b/internal-packages/tsql/src/query/results.ts new file mode 100644 index 000000000..ee2a29a9f --- /dev/null +++ b/internal-packages/tsql/src/query/results.ts @@ -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>( + 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 { + const columnMaps = new Map(); + + 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>( + row: T, + columnValueMaps: Map +): T { + const transformedRow: Record = {}; + 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 = {} +): >(rows: T[]) => T[] { + const columnValueMaps = buildColumnValueMaps(schema); + + return >(rows: T[]): T[] => { + if (columnValueMaps.size === 0) { + return rows; + } + return rows.map((row) => transformRow(row, columnValueMaps)); + }; +} + diff --git a/internal-packages/tsql/src/query/schema.test.ts b/internal-packages/tsql/src/query/schema.test.ts new file mode 100644 index 000000000..9dac7fc9b --- /dev/null +++ b/internal-packages/tsql/src/query/schema.test.ts @@ -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); + }); + }); +}); + diff --git a/internal-packages/tsql/src/query/schema.ts b/internal-packages/tsql/src/query/schema.ts index 01485a9be..5a064211c 100644 --- a/internal-packages/tsql/src/query/schema.ts +++ b/internal-packages/tsql/src/query/schema.ts @@ -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; } /** @@ -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 */ diff --git a/internal-packages/tsql/src/query/validator.ts b/internal-packages/tsql/src/query/validator.ts index 9f9959485..6a9ede319 100644 --- a/internal-packages/tsql/src/query/validator.ts +++ b/internal-packages/tsql/src/query/validator.ts @@ -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( ", "