Do the type inference inside the tsql engine
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
+3
-7
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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?: {
|
||||
|
||||
@@ -64,6 +64,7 @@ export {
|
||||
type TenantColumnConfig,
|
||||
type SchemaRegistry,
|
||||
type ClickHouseType,
|
||||
type OutputColumnMetadata,
|
||||
createSchemaRegistry,
|
||||
findTable,
|
||||
findColumn,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user