Do the type inference inside the tsql engine

This commit is contained in:
Matt Aitken
2025-12-18 13:39:01 +00:00
parent 28505a84c2
commit 0883123dcc
9 changed files with 639 additions and 432 deletions
@@ -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 <span className="text-text-dimmed"></span>;
}
// Render based on the column's render type
switch (column.renderType) {
case "runStatus":
if (isTaskRunStatus(value)) {
return <TaskRunStatusCombo status={value} />;
}
// Fall back to string if not a valid status
return <span>{String(value)}</span>;
// First check customRenderType for special rendering
if (column.customRenderType) {
switch (column.customRenderType) {
case "runStatus":
if (isTaskRunStatus(value)) {
return <TaskRunStatusCombo status={value} />;
}
return <span>{String(value)}</span>;
case "datetime":
if (typeof value === "string") {
return <DateTime date={value} />;
}
return <span>{String(value)}</span>;
case "duration":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "duration":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "cost":
if (typeof value === "number") {
// Assume cost values are in cents
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
}
return <span>{String(value)}</span>;
case "cost":
if (typeof value === "number") {
// Assume cost values are in cents
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
}
return <span>{String(value)}</span>;
case "boolean":
// Handle both actual booleans and 0/1 numbers
if (typeof value === "boolean") {
return <span className="text-text-dimmed">{value ? "true" : "false"}</span>;
}
if (typeof value === "number") {
return <span className="text-text-dimmed">{value === 1 ? "true" : "false"}</span>;
}
return <span>{String(value)}</span>;
case "json":
case "array":
return <span className="font-mono text-xs text-text-dimmed">{JSON.stringify(value)}</span>;
case "number":
if (typeof value === "number") {
return <span className="tabular-nums">{formatNumber(value)}</span>;
}
return <span>{String(value)}</span>;
case "string":
default:
return <span>{String(value)}</span>;
// 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 <DateTime date={value} />;
}
return <span>{String(value)}</span>;
}
// JSON type
if (type === "JSON") {
return <span className="font-mono text-xs text-text-dimmed">{JSON.stringify(value)}</span>;
}
// Array types
if (type.startsWith("Array")) {
return <span className="font-mono text-xs text-text-dimmed">{JSON.stringify(value)}</span>;
}
// Boolean-like types (UInt8 is commonly used for booleans in ClickHouse)
if (isBooleanType(type)) {
if (typeof value === "boolean") {
return <span className="text-text-dimmed">{value ? "true" : "false"}</span>;
}
if (typeof value === "number") {
return <span className="text-text-dimmed">{value === 1 ? "true" : "false"}</span>;
}
return <span>{String(value)}</span>;
}
// Numeric types (excluding UInt8 which is handled as boolean above)
if (isNumericType(type) && type !== "UInt8" && type !== "Nullable(UInt8)") {
if (typeof value === "number") {
return <span className="tabular-nums">{formatNumber(value)}</span>;
}
return <span>{String(value)}</span>;
}
// Default to string rendering
return <span>{String(value)}</span>;
}
export function TSQLResultsTable({
@@ -92,7 +145,7 @@ export function TSQLResultsTable({
columns,
}: {
rows: Record<string, unknown>[];
columns: ColumnMetadata[];
columns: OutputColumnMetadata[];
}) {
if (!rows.length || !columns.length) return null;
@@ -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 });
-309
View File
@@ -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<string, unknown>[], 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<string, unknown>[], 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<string, unknown>[], 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<string, unknown>[]
): 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<string, unknown>[],
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<string, unknown>[]): 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);
}
+14 -45
View File
@@ -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
*/
@@ -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<TOut extends z.ZodSchema> {
transformValues?: boolean;
}
/**
* Successful result from TSQL query execution
*/
export interface TSQLQuerySuccess<T> {
rows: T[];
columns: OutputColumnMetadata[];
}
/**
* Result type for TSQL query execution
*/
export type TSQLQueryResult<T> = [QueryError, null] | [null, T[]];
export type TSQLQueryResult<T> = [QueryError, null] | [null, TSQLQuerySuccess<T>];
/**
* Execute a TSQL query against ClickHouse
@@ -83,7 +91,7 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
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<TOut extends z.ZodSchema>(
rows as Record<string, unknown>[],
options.tableSchema
);
return [null, transformedRows as z.output<TOut>[]];
return [null, { rows: transformedRows as z.output<TOut>[], columns }];
}
return [null, rows];
return [null, { rows: rows ?? [], columns }];
} catch (error) {
if (error instanceof Error) {
return [new QueryError(error.message, { query: options.query }), null];
@@ -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?: {
+1
View File
@@ -64,6 +64,7 @@ export {
type TenantColumnConfig,
type SchemaRegistry,
type ClickHouseType,
type OutputColumnMetadata,
createSchemaRegistry,
findTable,
findColumn,
+450 -7
View File
@@ -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<string, unknown>;
/** 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<string, TableSchema> = 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<string | number>): {
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";
}
/**
+47 -3
View File
@@ -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);
}