From a714de779b74780f6ae2d94b616d23c84ff736e8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 18 Dec 2025 12:39:49 +0000 Subject: [PATCH] Custom inferers early version --- .../app/components/code/TSQLResultsTable.tsx | 15 +++-- .../route.tsx | 27 +++++---- apps/webapp/app/utils/tsqlColumns.ts | 58 +++++++++++++++++-- apps/webapp/app/v3/querySchemas.ts | 43 +++++++++++++- 4 files changed, 122 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index 155135773..41de435f7 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -10,7 +10,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter"; -import { inferColumnMetadata, type ColumnMetadata } from "~/utils/tsqlColumns"; +import type { ColumnMetadata } from "~/utils/tsqlColumns"; import { allTaskRunStatuses, TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; /** @@ -87,11 +87,14 @@ function CellValue({ value, column }: { value: unknown; column: ColumnMetadata } } } -export function TSQLResultsTable({ rows }: { rows: Record[] }) { - if (!rows.length) return null; - - // Infer column metadata from the rows - const columns = inferColumnMetadata(rows); +export function TSQLResultsTable({ + rows, + columns, +}: { + rows: Record[]; + columns: ColumnMetadata[]; +}) { + if (!rows.length || !columns.length) return null; return ( 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 55c0aba80..367327632 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,7 +25,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { executeQuery } from "~/services/queryService.server"; import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { defaultQuery, querySchemas } from "~/v3/querySchemas"; +import { inferColumnMetadata } from "~/utils/tsqlColumns"; +import { defaultQuery, queryInferers, querySchemas } from "~/v3/querySchemas"; const scopeOptions = [ { value: "environment", label: "Environment" }, @@ -78,19 +79,22 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { // Temporarily admin-only if (!user.admin) { - return typedjson({ error: "Unauthorized", rows: null }, { status: 403 }); + return typedjson({ error: "Unauthorized", rows: null, columns: null }, { status: 403 }); } const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, user.id); if (!project) { - return typedjson({ error: "Project not found", rows: null }, { status: 404 }); + return typedjson({ error: "Project not found", rows: null, columns: null }, { status: 404 }); } const environment = await findEnvironmentBySlug(project.id, envParam, user.id); if (!environment) { - return typedjson({ error: "Environment not found", rows: null }, { status: 404 }); + return typedjson( + { error: "Environment not found", rows: null, columns: null }, + { status: 404 } + ); } const formData = await request.formData(); @@ -101,7 +105,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { if (!parsed.success) { return typedjson( - { error: parsed.error.errors.map((e) => e.message).join(", "), rows: null }, + { error: parsed.error.errors.map((e) => e.message).join(", "), rows: null, columns: null }, { status: 400 } ); } @@ -136,13 +140,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); if (error) { - return typedjson({ error: error.message, rows: null }, { status: 400 }); + return typedjson({ error: error.message, rows: null, columns: null }, { status: 400 }); } - return typedjson({ error: null, rows }); + // Infer column metadata on the server + const columns = inferColumnMetadata(rows, queryInferers); + + return typedjson({ error: null, rows, columns }); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error executing query"; - return typedjson({ error: errorMessage, rows: null }, { status: 500 }); + return typedjson({ error: errorMessage, rows: null, columns: null }, { status: 500 }); } }; @@ -222,8 +229,8 @@ export default function Page() { ) : results?.error ? (
{results.error}
- ) : results?.rows ? ( - + ) : results?.rows && results?.columns ? ( + ) : ( Run a query to see results here. diff --git a/apps/webapp/app/utils/tsqlColumns.ts b/apps/webapp/app/utils/tsqlColumns.ts index 6ad7ffd89..ca5755243 100644 --- a/apps/webapp/app/utils/tsqlColumns.ts +++ b/apps/webapp/app/utils/tsqlColumns.ts @@ -37,6 +37,36 @@ export interface ColumnMetadata { 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 */ @@ -205,6 +235,7 @@ function deriveRenderType( * 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 @@ -214,7 +245,7 @@ function deriveRenderType( * { run_id: "run_456", status: "PENDING", created_at: "2024-01-02T00:00:00Z" }, * ]; * - * const columns = inferColumnMetadata(rows); + * const columns = inferColumnMetadata(rows, [statusInferer]); * // [ * // { name: "run_id", jsType: "string", renderType: "string" }, * // { name: "status", jsType: "string", renderType: "runStatus" }, @@ -222,7 +253,10 @@ function deriveRenderType( * // ] * ``` */ -export function inferColumnMetadata(rows: Record[]): ColumnMetadata[] { +export function inferColumnMetadata( + rows: Record[], + inferers?: ColumnInferer[] +): ColumnMetadata[] { if (rows.length === 0) { return []; } @@ -231,7 +265,20 @@ export function inferColumnMetadata(rows: Record[]): ColumnMeta 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 { @@ -242,8 +289,11 @@ export function inferColumnMetadata(rows: Record[]): ColumnMeta }); } -function getColumnData(key: string, rows: Record[]): unknown[] { - let data: unknown[] = []; +/** + * 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) { diff --git a/apps/webapp/app/v3/querySchemas.ts b/apps/webapp/app/v3/querySchemas.ts index 64d5cce4a..123ef8e5d 100644 --- a/apps/webapp/app/v3/querySchemas.ts +++ b/apps/webapp/app/v3/querySchemas.ts @@ -1,5 +1,10 @@ import { column, type TableSchema } from "@internal/tsql"; -import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus"; +import { + allTaskRunStatuses, + runFriendlyStatus, + runStatusTitleFromStatus, +} from "~/components/runs/v3/TaskRunStatus"; +import type { ColumnInferer } from "~/utils/tsqlColumns"; /** * Environment type values @@ -233,6 +238,42 @@ 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 */