Custom inferers early version
This commit is contained in:
@@ -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>
|
||||
|
||||
+17
-10
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user