Allow mapping values to user friendly names

This commit is contained in:
Matt Aitken
2025-12-17 12:10:13 +00:00
parent 66992f33f8
commit 8ca6cecbe9
10 changed files with 946 additions and 12 deletions
@@ -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];
+12
View File
@@ -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 = ''");
+119 -6
View File
@@ -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);
});
});
+153
View File
@@ -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(
", "