Custom inferers early version

This commit is contained in:
Matt Aitken
2025-12-18 12:39:49 +00:00
parent 7db252310c
commit a714de779b
4 changed files with 122 additions and 21 deletions
@@ -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<string, unknown>[] }) {
if (!rows.length) return null;
// Infer column metadata from the rows
const columns = inferColumnMetadata(rows);
export function TSQLResultsTable({
rows,
columns,
}: {
rows: Record<string, unknown>[];
columns: ColumnMetadata[];
}) {
if (!rows.length || !columns.length) return null;
return (
<Table fullWidth>
@@ -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() {
</div>
) : results?.error ? (
<pre className="whitespace-pre-wrap p-4 text-sm text-red-400">{results.error}</pre>
) : results?.rows ? (
<TSQLResultsTable rows={results.rows} />
) : results?.rows && results?.columns ? (
<TSQLResultsTable rows={results.rows} columns={results.columns} />
) : (
<Paragraph variant="small" className="p-4 text-text-dimmed">
Run a query to see results here.
+54 -4
View File
@@ -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<string, unknown>[]): ColumnMetadata[] {
export function inferColumnMetadata(
rows: Record<string, unknown>[],
inferers?: ColumnInferer[]
): ColumnMetadata[] {
if (rows.length === 0) {
return [];
}
@@ -231,7 +265,20 @@ export function inferColumnMetadata(rows: Record<string, unknown>[]): 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<string, unknown>[]): ColumnMeta
});
}
function getColumnData(key: string, rows: Record<string, unknown>[]): unknown[] {
let data: unknown[] = [];
/**
* 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) {
+42 -1
View File
@@ -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
*/