diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index 41de435f7..2d2c45e03 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -1,5 +1,6 @@ import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import type { TaskRunStatus } from "@trigger.dev/database"; +import type { OutputColumnMetadata } from "@internal/clickhouse"; import { DateTime } from "~/components/primitives/DateTime"; import { Table, @@ -10,7 +11,6 @@ import { TableRow, } from "~/components/primitives/Table"; import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter"; -import type { ColumnMetadata } from "~/utils/tsqlColumns"; import { allTaskRunStatuses, TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; /** @@ -21,70 +21,123 @@ function isTaskRunStatus(value: unknown): value is TaskRunStatus { } /** - * Render a cell value based on its render type + * Check if a ClickHouse type is a DateTime type */ -function CellValue({ value, column }: { value: unknown; column: ColumnMetadata }) { +function isDateTimeType(type: string): boolean { + return ( + type === "DateTime" || + type === "DateTime64" || + type === "Date" || + type === "Date32" || + type.startsWith("Nullable(DateTime") || + type.startsWith("Nullable(Date") + ); +} + +/** + * Check if a ClickHouse type is a numeric type + */ +function isNumericType(type: string): boolean { + return ( + type.startsWith("Int") || + type.startsWith("UInt") || + type.startsWith("Float") || + type.startsWith("Nullable(Int") || + type.startsWith("Nullable(UInt") || + type.startsWith("Nullable(Float") + ); +} + +/** + * Check if a ClickHouse type is a boolean type + */ +function isBooleanType(type: string): boolean { + return ( + type === "Bool" || type === "UInt8" || type === "Nullable(Bool)" || type === "Nullable(UInt8)" + ); +} + +/** + * Render a cell value based on its type and optional customRenderType + */ +function CellValue({ value, column }: { value: unknown; column: OutputColumnMetadata }) { // Handle null/undefined values if (value === null || value === undefined) { return ; } - // Render based on the column's render type - switch (column.renderType) { - case "runStatus": - if (isTaskRunStatus(value)) { - return ; - } - // Fall back to string if not a valid status - return {String(value)}; + // First check customRenderType for special rendering + if (column.customRenderType) { + switch (column.customRenderType) { + case "runStatus": + if (isTaskRunStatus(value)) { + return ; + } + return {String(value)}; - case "datetime": - if (typeof value === "string") { - return ; - } - return {String(value)}; + case "duration": + if (typeof value === "number") { + return ( + + {formatDurationMilliseconds(value, { style: "short" })} + + ); + } + return {String(value)}; - case "duration": - if (typeof value === "number") { - return ( - - {formatDurationMilliseconds(value, { style: "short" })} - - ); - } - return {String(value)}; + case "cost": + if (typeof value === "number") { + // Assume cost values are in cents + return {formatCurrencyAccurate(value / 100)}; + } + return {String(value)}; - case "cost": - if (typeof value === "number") { - // Assume cost values are in cents - return {formatCurrencyAccurate(value / 100)}; - } - return {String(value)}; - - case "boolean": - // Handle both actual booleans and 0/1 numbers - if (typeof value === "boolean") { - return {value ? "true" : "false"}; - } - if (typeof value === "number") { - return {value === 1 ? "true" : "false"}; - } - return {String(value)}; - - case "json": - case "array": - return {JSON.stringify(value)}; - - case "number": - if (typeof value === "number") { - return {formatNumber(value)}; - } - return {String(value)}; - - case "string": - default: - return {String(value)}; + // Add more custom render types as needed + } } + + // Fall back to rendering based on ClickHouse type + const { type } = column; + + // DateTime types + if (isDateTimeType(type)) { + if (typeof value === "string") { + return ; + } + return {String(value)}; + } + + // JSON type + if (type === "JSON") { + return {JSON.stringify(value)}; + } + + // Array types + if (type.startsWith("Array")) { + return {JSON.stringify(value)}; + } + + // Boolean-like types (UInt8 is commonly used for booleans in ClickHouse) + if (isBooleanType(type)) { + if (typeof value === "boolean") { + return {value ? "true" : "false"}; + } + if (typeof value === "number") { + return {value === 1 ? "true" : "false"}; + } + return {String(value)}; + } + + // Numeric types (excluding UInt8 which is handled as boolean above) + if (isNumericType(type) && type !== "UInt8" && type !== "Nullable(UInt8)") { + if (typeof value === "number") { + return {formatNumber(value)}; + } + return {String(value)}; + } + + // Default to string rendering + return {String(value)}; } export function TSQLResultsTable({ @@ -92,7 +145,7 @@ export function TSQLResultsTable({ columns, }: { rows: Record[]; - columns: ColumnMetadata[]; + columns: OutputColumnMetadata[]; }) { if (!rows.length || !columns.length) return null; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 2f5e104bf..5445a8e37 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -25,8 +25,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { executeQuery } from "~/services/queryService.server"; import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { inferColumnMetadata } from "~/utils/tsqlColumns"; -import { defaultQuery, queryInferers, querySchemas } from "~/v3/querySchemas"; +import { defaultQuery, querySchemas } from "~/v3/querySchemas"; const scopeOptions = [ { value: "environment", label: "Environment" }, @@ -130,7 +129,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } try { - const [error, rows] = await executeQuery({ + const [error, result] = await executeQuery({ name: "query-page", query, schema: z.record(z.any()), @@ -143,10 +142,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return typedjson({ error: error.message, rows: null, columns: null }, { status: 400 }); } - // Infer column metadata on the server - const columns = inferColumnMetadata(rows, queryInferers); - - return typedjson({ error: null, rows, columns }); + return typedjson({ error: null, rows: result.rows, columns: result.columns }); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error executing query"; return typedjson({ error: errorMessage, rows: null, columns: null }, { status: 500 }); diff --git a/apps/webapp/app/utils/tsqlColumns.ts b/apps/webapp/app/utils/tsqlColumns.ts deleted file mode 100644 index 14c721f32..000000000 --- a/apps/webapp/app/utils/tsqlColumns.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * TSQL Column Metadata Inference - * - * Utilities for inferring column types and render types from query results. - * This enables the UI to render values with appropriate components based on - * column names and inferred types. - */ - -/** - * JavaScript types that can be inferred from values - */ -export type JSType = "string" | "number" | "boolean" | "object" | "array" | "null"; - -/** - * Render types that determine how values should be displayed in the UI - */ -export type RenderType = - | "string" - | "number" - | "boolean" - | "datetime" - | "json" - | "array" - | "runStatus" - | "duration" - | "cost"; - -/** - * Metadata for a single column in query results - */ -export interface ColumnMetadata { - /** Column name as it appears in the result */ - name: string; - /** Inferred JavaScript type */ - jsType: JSType; - /** Render type for UI display */ - renderType: RenderType; -} - -/** - * A custom column inferer function that can detect specific column types. - * - * Inferers are called in order before falling back to basic type inference. - * Return `ColumnMetadata` if the column matches, or `false` to pass to the next inferer. - * - * @param columnName - The name of the column - * @param values - Non-null values from the column (via getColumnData) - * @param basicType - The basic JS type inferred from the values - * @returns ColumnMetadata if matched, false otherwise - * - * @example - * ```typescript - * const statusInferer: ColumnInferer = (name, values, basicType) => { - * if (name === "status" && basicType === "string") { - * const isValid = values.every(v => VALID_STATUSES.includes(v as string)); - * if (isValid) { - * return { name, jsType: "string", renderType: "runStatus" }; - * } - * } - * return false; - * }; - * ``` - */ -export type ColumnInferer = ( - columnName: string, - values: unknown[], - basicType: JSType -) => ColumnMetadata | false; - -/** - * Check if a string looks like an ISO 8601 date - */ -function isISODateString(value: string): boolean { - // Match ISO 8601 date formats: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss - const isoDateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/; - if (!isoDateRegex.test(value)) return false; - const date = new Date(value); - return !isNaN(date.getTime()); -} - -/** - * Infer the JavaScript type from a value - */ -function inferJSType(value: unknown): JSType { - if (value === null || value === undefined) { - return "null"; - } - if (Array.isArray(value)) { - return "array"; - } - if (typeof value === "object") { - return "object"; - } - if (typeof value === "boolean") { - return "boolean"; - } - if (typeof value === "number") { - return "number"; - } - return "string"; -} - -/** - * Sample values from rows to get a non-null type - * Returns the first non-null type found, or "null" if all values are null - */ -function sampleJSType(rows: Record[], columnName: string): JSType { - for (const row of rows) { - const value = row[columnName]; - if (value !== null && value !== undefined) { - return inferJSType(value); - } - } - return "null"; -} - -/** - * Check if all sampled string values look like ISO dates - */ -function allStringsAreDates(rows: Record[], columnName: string): boolean { - let foundString = false; - for (const row of rows) { - const value = row[columnName]; - if (typeof value === "string") { - foundString = true; - if (!isISODateString(value)) { - return false; - } - } - } - return foundString; -} - -/** - * Check if all sampled number values are 0 or 1 (boolean-like) - */ -function allNumbersAreBooleanLike(rows: Record[], columnName: string): boolean { - let foundNumber = false; - for (const row of rows) { - const value = row[columnName]; - if (typeof value === "number") { - foundNumber = true; - if (value !== 0 && value !== 1) { - return false; - } - } - } - return foundNumber; -} - -/** - * Derive the render type from column name and JS type - * - * Rules: - * - Column named "status" with string type → "runStatus" - * - ISO date strings → "datetime" - * - is_* columns with 0/1 values → "boolean" - * - *_duration_ms, *_duration columns → "duration" - * - *_cost*, *_in_cents columns → "cost" - * - Arrays → "array" - * - Objects → "json" - * - Numbers → "number" - * - Default → "string" - */ -function deriveRenderType( - columnName: string, - jsType: JSType, - rows: Record[] -): RenderType { - const lowerName = columnName.toLowerCase(); - - // Check for datetime strings - if (jsType === "string" && allStringsAreDates(rows, columnName)) { - return "datetime"; - } - - // Check for boolean-like columns (is_* pattern with 0/1 values) - if ( - jsType === "number" && - lowerName.startsWith("is_") && - allNumbersAreBooleanLike(rows, columnName) - ) { - return "boolean"; - } - - // Duration columns - if ( - jsType === "number" && - (lowerName.endsWith("_duration_ms") || - lowerName.endsWith("_duration") || - lowerName === "duration_ms" || - lowerName === "duration") - ) { - return "duration"; - } - - // Cost columns - if ( - jsType === "number" && - (lowerName.includes("cost") || lowerName.endsWith("_in_cents") || lowerName === "in_cents") - ) { - return "cost"; - } - - // Arrays - if (jsType === "array") { - return "array"; - } - - // Objects (JSON) - if (jsType === "object") { - return "json"; - } - - // Numbers - if (jsType === "number") { - return "number"; - } - - // Boolean - if (jsType === "boolean") { - return "boolean"; - } - - // Default to string - return "string"; -} - -/** - * Infer column metadata from query result rows - * - * @param rows - Array of result rows from the query - * @param inferers - Optional array of custom inferers to run before basic inference - * @returns Array of column metadata in the order columns appear - * - * @example - * ```typescript - * const rows = [ - * { run_id: "run_123", status: "COMPLETED_SUCCESSFULLY", created_at: "2024-01-01T00:00:00Z" }, - * { run_id: "run_456", status: "PENDING", created_at: "2024-01-02T00:00:00Z" }, - * ]; - * - * const columns = inferColumnMetadata(rows, [statusInferer]); - * // [ - * // { name: "run_id", jsType: "string", renderType: "string" }, - * // { name: "status", jsType: "string", renderType: "runStatus" }, - * // { name: "created_at", jsType: "string", renderType: "datetime" }, - * // ] - * ``` - */ -export function inferColumnMetadata( - rows: Record[], - inferers?: ColumnInferer[] -): ColumnMetadata[] { - if (rows.length === 0) { - return []; - } - - // Extract unique column names from all rows (preserving order from first row) - const columnNames = [...new Set(rows.flatMap((row) => Object.keys(row)))]; - - return columnNames.map((name) => { - const values = getColumnData(name, rows); - const jsType = sampleJSType(rows, name); - - // Try custom inferers first, in order - if (inferers) { - for (const inferer of inferers) { - const result = inferer(name, values, jsType); - if (result !== false) { - return result; - } - } - } - - // Fall back to basic type derivation - const renderType = deriveRenderType(name, jsType, rows); - - return { - name, - jsType, - renderType, - }; - }); -} - -/** - * Extract non-null values from a specific column across all rows - */ -export function getColumnData(key: string, rows: Record[]): unknown[] { - const data: unknown[] = []; - for (const row of rows) { - const value = row[key]; - if (value !== null && value !== undefined) { - data.push(value); - } - } - return data; -} - -/** - * Get column metadata by name from an array of column metadata - */ -export function getColumnByName( - columns: ColumnMetadata[], - name: string -): ColumnMetadata | undefined { - return columns.find((col) => col.name === name); -} diff --git a/apps/webapp/app/v3/querySchemas.ts b/apps/webapp/app/v3/querySchemas.ts index 123ef8e5d..c4fdd767a 100644 --- a/apps/webapp/app/v3/querySchemas.ts +++ b/apps/webapp/app/v3/querySchemas.ts @@ -1,10 +1,5 @@ import { column, type TableSchema } from "@internal/tsql"; -import { - allTaskRunStatuses, - runFriendlyStatus, - runStatusTitleFromStatus, -} from "~/components/runs/v3/TaskRunStatus"; -import type { ColumnInferer } from "~/utils/tsqlColumns"; +import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus"; /** * Environment type values @@ -89,6 +84,7 @@ export const runsSchema: TableSchema = { description: "Run status", allowedValues: [...runFriendlyStatus], valueMap: runStatusTitleFromStatus, + customRenderType: "runStatus", }), }, @@ -179,15 +175,24 @@ export const runsSchema: TableSchema = { // Cost & usage usage_duration_ms: { name: "usage_duration_ms", - ...column("UInt32", { description: "Usage duration in milliseconds" }), + ...column("UInt32", { + description: "Usage duration in milliseconds", + customRenderType: "duration", + }), }, cost_in_cents: { name: "cost_in_cents", - ...column("Float64", { description: "Cost in cents" }), + ...column("Float64", { + description: "Cost in cents", + customRenderType: "cost", + }), }, base_cost_in_cents: { name: "base_cost_in_cents", - ...column("Float64", { description: "Base cost in cents" }), + ...column("Float64", { + description: "Base cost in cents", + customRenderType: "cost", + }), }, // Output & error (JSON columns) @@ -238,42 +243,6 @@ export const runsSchema: TableSchema = { */ export const querySchemas: TableSchema[] = [runsSchema]; -/** - * Custom column inferers for the query editor - * - * These run in order before falling back to basic type inference. - * Each inferer can detect specific column patterns and return custom metadata. - */ -export const queryInferers: ColumnInferer[] = [ - // TaskRunStatus inferer - detects status columns containing valid run statuses - (columnName, values, basicType) => { - if (basicType !== "string") { - return false; - } - - // Check if the column name suggests it's a status - const lowerName = columnName.toLowerCase(); - if (!lowerName.includes("status")) { - return false; - } - - // Check if all values are valid TaskRunStatus values - const isValidStatus = values.every((v) => - allTaskRunStatuses.includes(v as (typeof allTaskRunStatuses)[number]) - ); - - if (isValidStatus) { - return { - name: columnName, - jsType: "string", - renderType: "runStatus", - }; - } - - return false; - }, -]; - /** * Default query for the query editor */ diff --git a/internal-packages/clickhouse/src/client/tsql.ts b/internal-packages/clickhouse/src/client/tsql.ts index 9a2328f46..421c70dd2 100644 --- a/internal-packages/clickhouse/src/client/tsql.ts +++ b/internal-packages/clickhouse/src/client/tsql.ts @@ -15,8 +15,8 @@ import { } from "@internal/tsql"; import type { ClickhouseReader } from "./types.js"; import { QueryError } from "./errors.js"; +import type { OutputColumnMetadata } from "@internal/tsql"; -// Re-export TableSchema for convenience export type { TableSchema, QuerySettings }; /** @@ -50,10 +50,18 @@ export interface ExecuteTSQLOptions { transformValues?: boolean; } +/** + * Successful result from TSQL query execution + */ +export interface TSQLQuerySuccess { + rows: T[]; + columns: OutputColumnMetadata[]; +} + /** * Result type for TSQL query execution */ -export type TSQLQueryResult = [QueryError, null] | [null, T[]]; +export type TSQLQueryResult = [QueryError, null] | [null, TSQLQuerySuccess]; /** * Execute a TSQL query against ClickHouse @@ -83,7 +91,7 @@ export async function executeTSQL( try { // 1. Compile the TSQL query to ClickHouse SQL - const { sql, params } = compileTSQL(options.query, { + const { sql, params, columns } = compileTSQL(options.query, { organizationId: options.organizationId, projectId: options.projectId, environmentId: options.environmentId, @@ -112,10 +120,10 @@ export async function executeTSQL( rows as Record[], options.tableSchema ); - return [null, transformedRows as z.output[]]; + return [null, { rows: transformedRows as z.output[], columns }]; } - return [null, rows]; + return [null, { rows: rows ?? [], columns }]; } catch (error) { if (error instanceof Error) { return [new QueryError(error.message, { query: options.query }), null]; diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 8119aa7ff..06fe8bcbd 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -38,7 +38,9 @@ export { type ExecuteTSQLOptions, type TableSchema, type TSQLQueryResult, + type TSQLQuerySuccess, } from "./client/tsql.js"; +export type { OutputColumnMetadata } from "@internal/tsql"; export type ClickhouseCommonConfig = { keepAlive?: { diff --git a/internal-packages/tsql/src/index.ts b/internal-packages/tsql/src/index.ts index ca36228d0..abe50e90f 100644 --- a/internal-packages/tsql/src/index.ts +++ b/internal-packages/tsql/src/index.ts @@ -64,6 +64,7 @@ export { type TenantColumnConfig, type SchemaRegistry, type ClickHouseType, + type OutputColumnMetadata, createSchemaRegistry, findTable, findColumn, diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 4151274f6..082e9fee2 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -54,6 +54,8 @@ import { ColumnSchema, getInternalValue, isVirtualColumn, + OutputColumnMetadata, + ClickHouseType, } from "./schema"; /** @@ -64,6 +66,8 @@ export interface PrintResult { sql: string; /** Parameter values for parameterized query execution */ params: Record; + /** Metadata for each column in the SELECT clause, in order */ + columns: OutputColumnMetadata[]; } /** @@ -99,6 +103,8 @@ export class ClickHousePrinter { * Key is the alias/name used in the query, value is the TableSchema */ private tableContexts: Map = new Map(); + /** Column metadata collected during SELECT processing */ + private outputColumns: OutputColumnMetadata[] = []; constructor( private context: PrinterContext, @@ -111,10 +117,12 @@ export class ClickHousePrinter { * Print an AST node to ClickHouse SQL */ print(node: SelectQuery | SelectSetQuery): PrintResult { + this.outputColumns = []; const sql = this.visit(node); return { sql, params: this.context.getParams(), + columns: this.outputColumns, }; } @@ -320,10 +328,14 @@ export class ClickHousePrinter { nextJoin = nextJoin.next_join; } - // Process SELECT columns + // Process SELECT columns and collect metadata let columns: string[]; if (node.select && node.select.length > 0) { - columns = node.select.map((col) => this.visitSelectColumn(col)); + // Only collect metadata for top-level queries (not subqueries) + if (isTopLevelQuery) { + this.outputColumns = []; + } + columns = node.select.map((col) => this.visitSelectColumnWithMetadata(col, isTopLevelQuery)); } else { columns = ["1"]; } @@ -438,7 +450,7 @@ export class ClickHousePrinter { } /** - * Visit a SELECT column expression, handling virtual columns specially + * Visit a SELECT column expression with metadata collection * * For bare Field expressions that reference virtual columns, we need to add * an AS alias to preserve the column name in the result set. @@ -447,9 +459,16 @@ export class ClickHousePrinter { * - `SELECT execution_duration` → `SELECT (expr) AS execution_duration` * - `SELECT execution_duration AS dur` → `SELECT (expr) AS dur` (Alias handles it) * - `SELECT run_id` → `SELECT run_id` (not a virtual column) + * + * @param col - The column expression + * @param collectMetadata - Whether to collect column metadata (only for top-level queries) */ - private visitSelectColumn(col: Expression): string { + private visitSelectColumnWithMetadata(col: Expression, collectMetadata: boolean): string { + // Extract output name and source column before visiting + const { outputName, sourceColumn, inferredType } = this.analyzeSelectColumn(col); + // Check if this is a bare Field (not wrapped in Alias) + let sqlResult: string; if ((col as Field).expression_type === "field") { const field = col as Field; const virtualColumnName = this.getVirtualColumnNameForField(field.chain); @@ -458,12 +477,436 @@ export class ClickHousePrinter { // Visit the field (which will return the expression) const visited = this.visit(col); // Add the alias to preserve the column name - return `${visited} AS ${this.printIdentifier(virtualColumnName)}`; + sqlResult = `${visited} AS ${this.printIdentifier(virtualColumnName)}`; + } else { + sqlResult = this.visit(col); + } + } else { + // For non-virtual columns or expressions already wrapped in Alias, visit normally + sqlResult = this.visit(col); + } + + // Collect metadata for top-level queries + if (collectMetadata && outputName) { + const metadata: OutputColumnMetadata = { + name: outputName, + type: sourceColumn?.type ?? inferredType ?? "String", + }; + + // Only add customRenderType if specified in schema + if (sourceColumn?.customRenderType) { + metadata.customRenderType = sourceColumn.customRenderType; + } + + this.outputColumns.push(metadata); + } + + return sqlResult; + } + + /** + * Analyze a SELECT column expression to extract output name, source column, and type + */ + private analyzeSelectColumn(col: Expression): { + outputName: string | null; + sourceColumn: ColumnSchema | null; + inferredType: ClickHouseType | null; + } { + // Handle Alias - the output name is the alias + if ((col as Alias).expression_type === "alias") { + const alias = col as Alias; + const innerAnalysis = this.analyzeSelectColumn(alias.expr); + return { + outputName: alias.alias, + sourceColumn: innerAnalysis.sourceColumn, + inferredType: innerAnalysis.inferredType, + }; + } + + // Handle Field - the output name is the column name + if ((col as Field).expression_type === "field") { + const field = col as Field; + const columnInfo = this.resolveFieldToColumn(field.chain); + return { + outputName: columnInfo.outputName, + sourceColumn: columnInfo.column, + inferredType: columnInfo.column?.type ?? null, + }; + } + + // Handle Call (function/aggregation) - infer type from function + if ((col as Call).expression_type === "call") { + const call = col as Call; + const inferredType = this.inferCallType(call); + return { + outputName: null, // Computed columns without alias get auto-named by ClickHouse + sourceColumn: null, + inferredType, + }; + } + + // Handle ArithmeticOperation - infer type + if ((col as ArithmeticOperation).expression_type === "arithmetic_operation") { + const arith = col as ArithmeticOperation; + const inferredType = this.inferArithmeticType(arith); + return { + outputName: null, + sourceColumn: null, + inferredType, + }; + } + + // Handle Constant + if ((col as Constant).expression_type === "constant") { + const constant = col as Constant; + const inferredType = this.inferConstantType(constant); + return { + outputName: null, + sourceColumn: null, + inferredType, + }; + } + + // Default for other expression types + return { + outputName: null, + sourceColumn: null, + inferredType: null, + }; + } + + /** + * Resolve a field chain to its column schema and output name + */ + private resolveFieldToColumn(chain: Array): { + outputName: string | null; + column: ColumnSchema | null; + } { + if (chain.length === 0) { + return { outputName: null, column: null }; + } + + // Handle asterisk + if (chain[0] === "*" || (chain.length === 2 && chain[1] === "*")) { + return { outputName: null, column: null }; + } + + const firstPart = chain[0]; + if (typeof firstPart !== "string") { + return { outputName: null, column: null }; + } + + // Case 1: Qualified reference like table.column + if (chain.length >= 2) { + const tableAlias = firstPart; + const tableSchema = this.tableContexts.get(tableAlias); + if (!tableSchema) { + return { outputName: firstPart, column: null }; + } + + const columnName = chain[1]; + if (typeof columnName !== "string") { + return { outputName: null, column: null }; + } + + const columnSchema = tableSchema.columns[columnName]; + return { + outputName: columnName, + column: columnSchema || null, + }; + } + + // Case 2: Unqualified reference like just "column" + const columnName = firstPart; + for (const tableSchema of this.tableContexts.values()) { + const columnSchema = tableSchema.columns[columnName]; + if (columnSchema) { + return { + outputName: columnName, + column: columnSchema, + }; } } - // For non-virtual columns or expressions already wrapped in Alias, visit normally - return this.visit(col); + // Column not found in any table context + return { + outputName: columnName, + column: null, + }; + } + + // ============================================================ + // Type Inference for Computed Expressions + // ============================================================ + + /** + * Infer the ClickHouse type for a function call expression + */ + private inferCallType(call: Call): ClickHouseType { + const name = call.name.toLowerCase(); + + // Count functions always return UInt64 + if (name === "count" || name === "countif" || name === "countdistinct" || name === "countdistinctif") { + return "UInt64"; + } + + // Uniq functions return UInt64 + if (name.startsWith("uniq")) { + return "UInt64"; + } + + // Sum returns Int64 by default (could be more specific based on input) + if (name === "sum" || name === "sumif") { + return "Int64"; + } + + // Avg returns Float64 + if (name === "avg" || name === "avgif") { + return "Float64"; + } + + // Min/Max preserve the input type - try to infer from first arg + if (name === "min" || name === "max" || name === "minif" || name === "maxif") { + if (call.args.length > 0) { + const argType = this.inferExpressionType(call.args[0]); + if (argType) return argType; + } + return "Float64"; // Default + } + + // dateDiff returns Int64 (signed difference) + if (name === "datediff" || name === "date_diff") { + return "Int64"; + } + + // String functions + if ( + name === "concat" || + name === "substring" || + name === "substr" || + name === "lower" || + name === "upper" || + name === "trim" || + name === "replace" || + name === "tostring" + ) { + return "String"; + } + + // Date/DateTime conversion functions + if (name === "todate" || name === "todate32") { + return "Date"; + } + if (name === "todatetime") { + return "DateTime"; + } + if (name === "todatetime64") { + return "DateTime64"; + } + + // Date extraction functions return UInt8/UInt16 + if ( + name === "toyear" || + name === "tomonth" || + name === "todayofmonth" || + name === "todayofweek" || + name === "todayofyear" || + name === "tohour" || + name === "tominute" || + name === "tosecond" + ) { + return "UInt16"; + } + + // toUnixTimestamp returns UInt32 + if (name === "tounixtimestamp") { + return "UInt32"; + } + + // Numeric conversion functions + if (name === "toint8") return "Int8"; + if (name === "toint16") return "Int16"; + if (name === "toint32") return "Int32"; + if (name === "toint64") return "Int64"; + if (name === "touint8") return "UInt8"; + if (name === "touint16") return "UInt16"; + if (name === "touint32") return "UInt32"; + if (name === "touint64") return "UInt64"; + if (name === "tofloat32") return "Float32"; + if (name === "tofloat64") return "Float64"; + + // Boolean functions + if ( + name === "empty" || + name === "notempty" || + name === "isnull" || + name === "isnotnull" || + name === "in" || + name === "notin" + ) { + return "UInt8"; // ClickHouse uses UInt8 for booleans + } + + // If/multiIf - try to infer from result expressions + if (name === "if" && call.args.length >= 2) { + const thenType = this.inferExpressionType(call.args[1]); + if (thenType) return thenType; + } + + // Array functions that return arrays + if (name === "grouparray" || name === "groupuniqarray" || name === "array") { + return "Array(String)"; // Simplified - could be more specific + } + + // Length functions return UInt64 + if (name === "length" || name === "lengthutf8" || name === "char_length") { + return "UInt64"; + } + + // Default to String for unknown functions + return "String"; + } + + /** + * Infer the ClickHouse type for an arithmetic operation + */ + private inferArithmeticType(arith: ArithmeticOperation): ClickHouseType { + const leftType = this.inferExpressionType(arith.left); + const rightType = this.inferExpressionType(arith.right); + + // DateTime minus DateTime could produce an interval/Int64 + if (this.isDateTimeType(leftType) && this.isDateTimeType(rightType)) { + return "Int64"; // Seconds difference + } + + // If either is Float, result is Float + if (this.isFloatType(leftType) || this.isFloatType(rightType)) { + return "Float64"; + } + + // Division always produces Float64 + if (arith.op === ArithmeticOperationOp.Div) { + return "Float64"; + } + + // Integer arithmetic stays integer + if (this.isIntType(leftType) && this.isIntType(rightType)) { + // Return the wider type + return this.widerIntType(leftType, rightType); + } + + // Default to Float64 for mixed or unknown types + return "Float64"; + } + + /** + * Infer the ClickHouse type for a constant value + */ + private inferConstantType(constant: Constant): ClickHouseType { + const value = constant.value; + + if (value === null) { + return "Nullable(String)"; + } + if (typeof value === "boolean") { + return "UInt8"; + } + if (typeof value === "number") { + if (Number.isInteger(value)) { + return "Int64"; + } + return "Float64"; + } + if (typeof value === "string") { + // Check if it looks like a date/datetime + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) { + return "DateTime64"; + } + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return "Date"; + } + return "String"; + } + + return "String"; + } + + /** + * Infer the ClickHouse type for any expression + */ + private inferExpressionType(expr: Expression): ClickHouseType | null { + if ((expr as Field).expression_type === "field") { + const field = expr as Field; + const { column } = this.resolveFieldToColumn(field.chain); + return column?.type ?? null; + } + + if ((expr as Call).expression_type === "call") { + return this.inferCallType(expr as Call); + } + + if ((expr as ArithmeticOperation).expression_type === "arithmetic_operation") { + return this.inferArithmeticType(expr as ArithmeticOperation); + } + + if ((expr as Constant).expression_type === "constant") { + return this.inferConstantType(expr as Constant); + } + + return null; + } + + /** + * Check if a type is a DateTime type + */ + private isDateTimeType(type: ClickHouseType | null): boolean { + if (!type) return false; + return ( + type === "DateTime" || + type === "DateTime64" || + type === "Date" || + type === "Date32" || + type.startsWith("Nullable(DateTime") || + type.startsWith("Nullable(Date") + ); + } + + /** + * Check if a type is a Float type + */ + private isFloatType(type: ClickHouseType | null): boolean { + if (!type) return false; + return ( + type === "Float32" || + type === "Float64" || + type === "Nullable(Float32)" || + type === "Nullable(Float64)" + ); + } + + /** + * Check if a type is an integer type + */ + private isIntType(type: ClickHouseType | null): boolean { + if (!type) return false; + return ( + type.startsWith("Int") || + type.startsWith("UInt") || + type.startsWith("Nullable(Int") || + type.startsWith("Nullable(UInt") + ); + } + + /** + * Return the wider of two integer types + */ + private widerIntType(left: ClickHouseType | null, right: ClickHouseType | null): ClickHouseType { + // Simple heuristic: prefer Int64 for safety + if (left === "Int64" || right === "Int64") return "Int64"; + if (left === "UInt64" || right === "UInt64") return "UInt64"; + if (left === "Int32" || right === "Int32") return "Int32"; + if (left === "UInt32" || right === "UInt32") return "UInt32"; + return "Int64"; } /** diff --git a/internal-packages/tsql/src/query/schema.ts b/internal-packages/tsql/src/query/schema.ts index 3e1b4c9f8..9711cd21e 100644 --- a/internal-packages/tsql/src/query/schema.ts +++ b/internal-packages/tsql/src/query/schema.ts @@ -95,6 +95,47 @@ export interface ColumnSchema { * ``` */ expression?: string; + /** + * Custom render type for UI display. + * + * When set, the UI can use this to render the column with a custom component + * instead of the default renderer based on ClickHouseType. + * + * Common custom render types: + * - "runStatus" - Task run status badges + * - "cost" - Cost formatting (cents to dollars) + * - "duration" - Duration formatting (ms to human-readable) + * + * Custom types can be defined by consumers without modifying this package. + * + * @example + * ```typescript + * { + * name: "status", + * type: "LowCardinality(String)", + * customRenderType: "runStatus", + * } + * ``` + */ + customRenderType?: string; +} + +/** + * Metadata for a column in query results. + * + * This is returned by the TSQL compiler to describe each column in the SELECT clause, + * allowing the UI to render columns appropriately without inspecting result values. + */ +export interface OutputColumnMetadata { + /** Column name in the result set (after AS aliasing) */ + name: string; + /** ClickHouse data type (from schema or inferred for computed expressions) */ + type: ClickHouseType; + /** + * Custom render type from schema, if specified. + * When set, the UI should use a custom renderer instead of the default for the ClickHouseType. + */ + customRenderType?: string; } /** @@ -269,7 +310,9 @@ export function validateSortColumn( throw new QueryError(`Column "${columnName}" does not exist on table "${tableName}"`); } if (col.sortable === false) { - throw new QueryError(`Column "${columnName}" on table "${tableName}" cannot be used in ORDER BY`); + throw new QueryError( + `Column "${columnName}" on table "${tableName}" cannot be used in ORDER BY` + ); } return col; } @@ -289,7 +332,9 @@ export function validateGroupColumn( throw new QueryError(`Column "${columnName}" does not exist on table "${tableName}"`); } if (col.groupable === false) { - throw new QueryError(`Column "${columnName}" on table "${tableName}" cannot be used in GROUP BY`); + throw new QueryError( + `Column "${columnName}" on table "${tableName}" cannot be used in GROUP BY` + ); } return col; } @@ -424,4 +469,3 @@ export function getTableColumnNames(schema: SchemaRegistry, tableName: string): export function getAllTableNames(schema: SchemaRegistry): string[] { return Object.keys(schema.tables); } -