Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ uploaded file — and hands them over untouched. Drivers for engines these
parsers do not know (plugin drivers) supply a fully-parsed `ExplainPlan`
instead; both shapes flow through `resolveExplainOutput`.

## Adding a parser

Parser packages register one descriptor for each engine-specific wire format.
The optional `sniff` function enables source detection; callers with a known
engine can pass it to `parseExplainFor`.

```ts
import { registerExplainParser } from "@tabularis/explain";

registerExplainParser({
engine: "acme-db",
format: "acme-plan-json",
label: "Acme DB JSON",
parse: parseAcmePlan,
sniff: (payload) => payload.trimStart().startsWith('{"acme_plan":'),
});
```

Import the parser package before calling `parseRawExplain` or source detection.
Registering the same format again replaces it, which supports plugin upgrades;
call `unregisterExplainParser(format)` when unloading it.

## Host requirements for the React entry point

The views are presentational but not self-contained. A host must provide:
Expand Down
2 changes: 1 addition & 1 deletion packages/explain/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tabularis/explain",
"version": "0.1.0",
"version": "0.2.0",
"description": "Parse, analyse and visualise a database EXPLAIN plan. Takes raw EXPLAIN output; never runs a query.",
"license": "Apache-2.0",
"homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/explain",
Expand Down
11 changes: 11 additions & 0 deletions packages/explain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
export type { ExplainNode, ExplainPlan } from "./types";

export type {
BuiltinExplainEngine,
BuiltinExplainSourceFormat,
ExplainEngine,
ExplainSourceFormat,
ExplainSourceParser,
Expand All @@ -37,12 +39,21 @@ export { buildSqliteTree, parseSqliteEqpRows } from "./parsers/sqlite";
export { NodeIdAllocator, hasAnalyzeDataRecursive } from "./parsers/node";

export type {
BuiltinRawExplainFormat,
ExplainQueryOutput,
RawExplainFormat,
RawExplainOutput,
} from "./raw";
export { parseRawExplain, resolveExplainOutput } from "./raw";

export type { RegisteredExplainParser } from "./registry";
export {
getExplainParser,
listExplainParsers,
registerExplainParser,
unregisterExplainParser,
} from "./registry";

export type {
ExplainMetrics,
ExplainNodeMetrics,
Expand Down
70 changes: 70 additions & 0 deletions packages/explain/src/parsers/builtins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ExplainPlan } from "../types";
import { parseMysqlJson, parseMysqlTabularRows, parseMysqlText } from "./mysql";
import type { MysqlTabularRow } from "./mysql";
import { parsePostgresJson, parsePostgresText } from "./postgres";
import { parseSqliteEqpRows } from "./sqlite";
import type { SqliteEqpRow } from "./sqlite";

function parseJsonRows<T>(payload: string, format: string): T[] {
let value: unknown;
try {
value = JSON.parse(payload);
} catch (err) {
throw new Error(`Failed to parse EXPLAIN rows: ${String(err)}`);
}
if (!Array.isArray(value)) {
throw new Error(`EXPLAIN rows payload for '${format}' must be a JSON array`);
}
return value as T[];
}

interface BuiltinExplainParser {
readonly engine: string;
readonly format: string;
parse(payload: string): ExplainPlan;
}

/** Immutable parser baseline shared by raw-driver and source dispatch. */
export const BUILTIN_EXPLAIN_PARSERS: readonly BuiltinExplainParser[] = Object.freeze([
Object.freeze({
engine: "postgres",
format: "postgres-json",
parse: parsePostgresJson,
}),
Object.freeze({
engine: "postgres",
format: "postgres-text",
parse: parsePostgresText,
}),
Object.freeze({
engine: "mysql",
format: "mysql-json",
parse: parseMysqlJson,
}),
Object.freeze({
engine: "mysql",
format: "mysql-text",
parse: parseMysqlText,
}),
Object.freeze({
engine: "mysql",
format: "mysql-analyze-text",
parse: parseMysqlText,
}),
Object.freeze({
engine: "mysql",
format: "mysql-tabular-rows",
parse: (payload: string) =>
parseMysqlTabularRows(
parseJsonRows<MysqlTabularRow>(payload, "mysql-tabular-rows"),
),
}),
Object.freeze({
engine: "sqlite",
format: "sqlite-eqp-rows",
parse: (payload: string) =>
parseSqliteEqpRows(
parseJsonRows<SqliteEqpRow>(payload, "sqlite-eqp-rows"),
),
}),
]);
132 changes: 95 additions & 37 deletions packages/explain/src/parsers/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@
* inspects them.
*/

import {
getExplainParser,
listExplainParsers,
type RegisteredExplainParser,
} from "../registry";
import type { ExplainPlan } from "../types";
import { parseMysqlJson, parseMysqlText } from "./mysql";
import { parsePostgresJson, parsePostgresText } from "./postgres";

/** Engines whose source detection is built into this package. */
export type BuiltinExplainEngine = "postgres" | "mysql" | "sqlite";

/** The engine that produced an EXPLAIN payload, when the caller knows it. */
export type ExplainEngine = "postgres" | "mysql" | "sqlite";
export type ExplainEngine = BuiltinExplainEngine | (string & {});

/** Supported source formats that a host may hand to `parseExplainFor`. */
export type ExplainSourceFormat =
/** Source formats whose detection is built into this package. */
export type BuiltinExplainSourceFormat =
/** Postgres `EXPLAIN (FORMAT JSON [, ANALYZE, BUFFERS])` output. */
| "postgres-json"
/**
Expand All @@ -33,29 +39,25 @@ export type ExplainSourceFormat =
/** MySQL `EXPLAIN ANALYZE` / MariaDB `ANALYZE FORMAT=TEXT` indented tree. */
| "mysql-text";

/**
* A parser for one serialised EXPLAIN payload format.
*
* Every engine exposes its formats through this same interface so dispatch,
* sniffing and hosts handle them uniformly.
*/
export interface ExplainSourceParser {
/** Supported source format supplied by a built-in or registered parser. */
export type ExplainSourceFormat = BuiltinExplainSourceFormat | (string & {});

/** A parser for one serialised EXPLAIN payload format. */
export interface ExplainSourceParser extends RegisteredExplainParser {
readonly engine: ExplainEngine;
readonly format: ExplainSourceFormat;
/** Turn a raw payload into a plan; throws `Error` when the payload does not fit. */
parse(raw: string): ExplainPlan;
}

const SOURCE_PARSERS: readonly ExplainSourceParser[] = [
{ engine: "postgres", format: "postgres-json", parse: parsePostgresJson },
{ engine: "postgres", format: "postgres-text", parse: parsePostgresText },
{ engine: "mysql", format: "mysql-json", parse: parseMysqlJson },
{ engine: "mysql", format: "mysql-text", parse: parseMysqlText },
];
const BUILTIN_SOURCE_FORMATS: ReadonlySet<string> = new Set([
"postgres-json",
"postgres-text",
"mysql-json",
"mysql-text",
]);

function parserFor(format: ExplainSourceFormat): ExplainSourceParser {
const parser = SOURCE_PARSERS.find((candidate) => candidate.format === format);
if (parser === undefined) {
const parser = getExplainParser(format);
if (parser === null) {
throw new Error(`No parser registered for format '${format}'`);
}
return parser;
Expand All @@ -70,7 +72,8 @@ function parserFor(format: ExplainSourceFormat): ExplainSourceParser {
* unrecognised driver degrades to sniffing rather than failing.
*/
export function explainEngineFromDriverName(name: string): ExplainEngine | null {
switch (name.trim().toLowerCase()) {
const normalizedName = name.trim().toLowerCase();
switch (normalizedName) {
case "postgres":
case "postgresql":
case "pg":
Expand All @@ -81,16 +84,18 @@ export function explainEngineFromDriverName(name: string): ExplainEngine | null
case "sqlite":
case "sqlite3":
return "sqlite";
default:
return null;
}

const parser = listExplainParsers().find(
(candidate) => candidate.engine.trim().toLowerCase() === normalizedName,
);
return parser?.engine ?? null;
}

/**
* Detect the format of a payload of unknown origin.
*
* Recognises the two Postgres shapes only; pass an engine to
* `detectFormatFor` to reach the others.
* Recognises the two Postgres shapes before trying registered custom sniffers.
*/
export function detectFormat(raw: string): ExplainSourceFormat {
return detectFormatFor(raw, null);
Expand All @@ -100,23 +105,16 @@ export function detectFormat(raw: string): ExplainSourceFormat {
* Detect the format of a payload, given what the caller knows about its
* origin.
*
* With an engine the choice is between that engine's own formats. Without
* one, behaviour is unchanged from `detectFormat`: JSON is recognised by the
* leading `[` or `{`, and the text form by a Postgres cost header
* (`cost=X..Y rows=N width=W`).
* Built-in hints retain their historical decisions. Custom parsers are tried
* in registry order and only through their side-effect-free sniffers.
*/
export function detectFormatFor(
raw: string,
engine: ExplainEngine | null,
): ExplainSourceFormat {
switch (engine) {
case "postgres":
case null:
if (looksLikeJson(raw)) return "postgres-json";
if (looksLikePostgresText(raw)) return "postgres-text";
throw new Error(
"Unsupported EXPLAIN file format: expected Postgres JSON or text output",
);
return detectPostgresFormat(raw);
case "mysql":
if (looksLikeJson(raw)) return "mysql-json";
if (raw.trim() === "") {
Expand All @@ -128,9 +126,69 @@ export function detectFormatFor(
"SQLite EXPLAIN QUERY PLAN has no text form here: pass its " +
"(id, parent, detail) rows to buildSqliteTree",
);
case null: {
const builtinFormat = detectPostgresFormatOrNull(raw);
if (builtinFormat !== null) return builtinFormat;

const customFormat = sniffRegisteredFormat(raw, null);
if (customFormat !== null) return customFormat;

throw new Error(
"Unsupported EXPLAIN file format: expected Postgres JSON or text output",
);
}
default: {
const format = sniffRegisteredFormat(raw, engine);
if (format !== null) return format;
throw new Error(`Unsupported EXPLAIN file format for engine '${engine}'`);
}
}
}

function detectPostgresFormat(raw: string): BuiltinExplainSourceFormat {
const format = detectPostgresFormatOrNull(raw);
if (format !== null) return format;
throw new Error(
"Unsupported EXPLAIN file format: expected Postgres JSON or text output",
);
}

function detectPostgresFormatOrNull(
raw: string,
): BuiltinExplainSourceFormat | null {
if (looksLikeJson(raw)) return "postgres-json";
if (looksLikePostgresText(raw)) return "postgres-text";
return null;
}

function sniffRegisteredFormat(
raw: string,
engine: ExplainEngine | null,
): ExplainSourceFormat | null {
const normalizedEngine = engine?.trim().toLowerCase() ?? null;

for (const parser of listExplainParsers()) {
if (normalizedEngine === null && BUILTIN_SOURCE_FORMATS.has(parser.format)) {
continue;
}
if (
normalizedEngine !== null &&
parser.engine.trim().toLowerCase() !== normalizedEngine
) {
continue;
}
if (parser.sniff === undefined) continue;

try {
if (parser.sniff(raw)) return parser.format;
} catch {
// A sniffer is advisory; one broken parser must not block later parsers.
}
}

return null;
}

function looksLikeJson(raw: string): boolean {
const trimmed = raw.trimStart();
return trimmed.startsWith("[") || trimmed.startsWith("{");
Expand Down
Loading
Loading