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);
}
-