diff --git a/README.md b/README.md index c49f12f..b1740ee 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Explore how PostgreSQL turns SQL into scans, joins, sorts, and result estimates. ## Value -This supporting project turns bundled PostgreSQL plan snapshots into navigable trees with cost and row evidence. +This supporting project turns PostgreSQL plan snapshots into navigable trees with cost and row evidence. The interface links each node to a short explanation rule. It keeps planner choices visible during database study. -The first release stays compact. It loads fixtures, explains common operators, and needs no credentials or live database. +The browser loads bundled fixtures or one local PostgreSQL EXPLAIN JSON file. Imports stay in memory. ## Architecture @@ -16,12 +16,17 @@ The first release stays compact. It loads fixtures, explains common operators, a | --- | --- | | `PlanCatalog` | Loads validated plan fixtures from the classpath. | | `PlanExplainer` | Walks each tree and applies deterministic rules. | -| `PlanController` | Serves summaries and detailed plan views. | -| `static/` | Provides the browser tree and node inspector. | +| `PostgresqlPlanImporter` | Validates standard PostgreSQL JSON and maps nodes to the shared domain model. | +| `PlanController` | Serves fixtures and transient import results. | +| `static/` | Provides the browser tree, node inspector, and file import control. | | `db/` | Defines optional PostgreSQL tables, indexes, and sample rows. | The API separates fixture loading from explanation rules. The browser consumes the same JSON used by tests. +PostgresqlPlanImporter validates standard PostgreSQL JSON before it reaches the shared explanation pipeline. + +PlanController serves bundled fixtures and transient import results. + ## Setup ### Prerequisites @@ -40,6 +45,8 @@ Open `http://localhost:8080` in a browser. The primary demo reads classpath fixtures. PostgreSQL is not required to run the application. +Choose a PostgreSQL EXPLAIN JSON file in the Import panel. The selected plan replaces the current view for this session. + Start the optional PostgreSQL fixture database with this command. ```powershell @@ -92,6 +99,17 @@ explained nodes: 3 root evidence: Cost 0.42..12.77 | estimated rows 5 | actual rows 5 | actual time 0.21 ms ``` +Import one PostgreSQL plan with POST /api/plans/import and a JSON request body. + +An imported response keeps the same tree and explanation fields. + + id: imported-plan + root rule: Nested loop join + explained nodes: 2 + sql text: not included in EXPLAIN JSON + +The endpoint accepts one standard PostgreSQL statement result. It accepts planner estimates without ANALYZE fields. + ## Test status Run the full verification command. @@ -100,26 +118,29 @@ Run the full verification command. mvn verify ``` -Status: Core compilation, fixture parsing, JavaScript syntax, and a manual rule check passed. +Status: JavaScript syntax check passed. Maven verification remains pending because Maven is unavailable in this workspace. -Full Maven verification was not run because Maven is unavailable in this workspace. +The deterministic suite covers fixture loading, tree traversal, scan rules, join rules, and PostgreSQL JSON import validation. -The test suite covers fixture loading, tree traversal order, scan rules, join rules, and stable evidence formatting. +CI runs mvn verify on Java 21 for pushes and pull requests. ## Limitations -- The release reads checked-in snapshots. It does not run `EXPLAIN` against a live database. +- Bundled mode reads checked-in snapshots. Import mode does not run `EXPLAIN` against a live database. - Rules cover common scan, join, sort, hash, and aggregate nodes. - Rules do not replace PostgreSQL planner documentation. - Fixture timings are sample values. They are not benchmark results. +- Imports support one statement result and do not persist after a restart. - The browser does not yet compare two plans side by side. ## Roadmap -- Release 2: Import a PostgreSQL `EXPLAIN (FORMAT JSON)` file. -- Release 3: Highlight estimate and actual-row differences. -- Release 4: Add an optional read-only live database adapter. -- Release 5: Compare alternative plans with shared node paths. +Status: Release 2 is complete. Releases 3, 4, and 5 remain. + +- [x] Release 2: Import one PostgreSQL `EXPLAIN (FORMAT JSON)` result. +- [ ] Release 3: Highlight estimate and actual-row differences. +- [ ] Release 4: Add an optional read-only live database adapter. +- [ ] Release 5: Compare alternative plans with shared node paths. Each roadmap item remains an independent release. diff --git a/src/main/java/com/example/queryplayground/service/PostgresqlPlanImporter.java b/src/main/java/com/example/queryplayground/service/PostgresqlPlanImporter.java new file mode 100644 index 0000000..0bc7954 --- /dev/null +++ b/src/main/java/com/example/queryplayground/service/PostgresqlPlanImporter.java @@ -0,0 +1,134 @@ +package com.example.queryplayground.service; + +import com.example.queryplayground.domain.PlanFixture; +import com.example.queryplayground.domain.PlanNode; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public final class PostgresqlPlanImporter { + + private static final String IMPORTED_ID = "imported-plan"; + + public PlanFixture importDocument(JsonNode document) { + JsonNode result = singleResult(document); + JsonNode plan = requiredObject(result, "Plan", "result"); + + return new PlanFixture( + IMPORTED_ID, + "Imported PostgreSQL plan", + "Temporary plan imported from PostgreSQL EXPLAIN (FORMAT JSON).", + null, + readNode(plan, "Plan") + ); + } + + private PlanNode readNode(JsonNode node, String context) { + if (node == null || !node.isObject()) { + throw invalid(context + " must be a JSON object"); + } + String nodeType = requiredText(node, "Node Type", context); + List children = readChildren(node, context); + + return new PlanNode( + nodeType, + optionalText(node, "Relation Name"), + optionalText(node, "Index Name"), + optionalText(node, "Join Type"), + optionalDouble(node, "Startup Cost", context), + optionalDouble(node, "Total Cost", context), + optionalLong(node, "Plan Rows", context), + optionalLong(node, "Actual Rows", context), + optionalDouble(node, "Actual Total Time", context), + optionalLong(node, "Actual Loops", context), + optionalText(node, "Filter"), + children + ); + } + + private List readChildren(JsonNode node, String context) { + JsonNode plans = node.get("Plans"); + if (plans == null || plans.isNull()) { + return List.of(); + } + if (!plans.isArray()) { + throw invalid(context + " field 'Plans' must be an array"); + } + + List children = new ArrayList<>(); + for (int index = 0; index < plans.size(); index++) { + children.add(readNode(plans.get(index), context + ".Plans[" + index + "]")); + } + return List.copyOf(children); + } + + private JsonNode singleResult(JsonNode document) { + if (document == null || !document.isArray()) { + throw invalid("the document must be a JSON array"); + } + if (document.size() != 1) { + throw invalid("the document must contain one statement result"); + } + JsonNode result = document.get(0); + if (result == null || !result.isObject()) { + throw invalid("the statement result must be a JSON object"); + } + return result; + } + + private JsonNode requiredObject(JsonNode parent, String field, String context) { + JsonNode value = parent.get(field); + if (value == null || !value.isObject()) { + throw invalid(context + " must contain an object field named '" + field + "'"); + } + return value; + } + + private String requiredText(JsonNode parent, String field, String context) { + String value = optionalText(parent, field); + if (value == null || value.isBlank()) { + throw invalid(context + " must contain a non-blank text field named '" + field + "'"); + } + return value; + } + + private String optionalText(JsonNode parent, String field) { + JsonNode value = parent.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isTextual()) { + throw invalid("field '" + field + "' must be text"); + } + return value.textValue(); + } + + private Double optionalDouble(JsonNode parent, String field, String context) { + JsonNode value = parent.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isNumber()) { + throw invalid(context + " field '" + field + "' must be a number"); + } + return value.doubleValue(); + } + + private Long optionalLong(JsonNode parent, String field, String context) { + JsonNode value = parent.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isIntegralNumber()) { + throw invalid(context + " field '" + field + "' must be an integer"); + } + return value.longValue(); + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("Invalid PostgreSQL EXPLAIN JSON: " + message); + } +} diff --git a/src/main/java/com/example/queryplayground/web/PlanController.java b/src/main/java/com/example/queryplayground/web/PlanController.java index 320f102..765b2f6 100644 --- a/src/main/java/com/example/queryplayground/web/PlanController.java +++ b/src/main/java/com/example/queryplayground/web/PlanController.java @@ -5,9 +5,15 @@ import com.example.queryplayground.domain.PlanView; import com.example.queryplayground.service.PlanCatalog; import com.example.queryplayground.service.PlanExplainer; +import com.example.queryplayground.service.PostgresqlPlanImporter; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -19,10 +25,17 @@ public final class PlanController { private final PlanCatalog catalog; private final PlanExplainer explainer; + private final PostgresqlPlanImporter importer; public PlanController(PlanCatalog catalog, PlanExplainer explainer) { + this(catalog, explainer, new PostgresqlPlanImporter()); + } + + @Autowired + public PlanController(PlanCatalog catalog, PlanExplainer explainer, PostgresqlPlanImporter importer) { this.catalog = catalog; this.explainer = explainer; + this.importer = importer; } @GetMapping @@ -36,4 +49,14 @@ public PlanView get(@PathVariable String id) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan fixture not found")); return new PlanView(plan, explainer.explain(plan)); } -} \ No newline at end of file + + @PostMapping(value = "/import", consumes = MediaType.APPLICATION_JSON_VALUE) + public PlanView importPlan(@RequestBody JsonNode document) { + try { + PlanFixture plan = importer.importDocument(document); + return new PlanView(plan, explainer.explain(plan)); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception); + } + } +} diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 7c16e10..19f541a 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -6,20 +6,47 @@ const state = { const byId = (id) => document.getElementById(id); -async function fetchJson(url) { - const response = await fetch(url); +async function requestJson(url, options = {}) { + const response = await fetch(url, options); if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); + let message = `Request failed with status ${response.status}`; + try { + const body = await response.json(); + if (body.message) { + message = body.message; + } + } catch { + // Keep the HTTP status when the server has no JSON error body. + } + throw new Error(message); } return response.json(); } +async function fetchJson(url) { + return requestJson(url); +} + +function setStatusLabel(label) { + const status = byId("status-label"); + status.replaceChildren(); + const dot = document.createElement("span"); + dot.className = "status-dot"; + status.append(dot, document.createTextNode(label)); +} + +function setImportStatus(message, isError = false) { + const status = byId("import-status"); + status.classList.toggle("error", isError); + status.textContent = message; +} + function formatNumber(value) { - return value === null || value === undefined ? "—" : new Intl.NumberFormat().format(value); + return value === null || value === undefined ? "โ€”" : new Intl.NumberFormat().format(value); } function formatDecimal(value) { - return value === null || value === undefined ? "—" : Number(value).toFixed(2); + return value === null || value === undefined ? "โ€”" : Number(value).toFixed(2); } function renderPlanList() { @@ -45,13 +72,13 @@ function renderMetrics(root) { byId("metric-estimated").textContent = formatNumber(root.planRows); byId("metric-actual").textContent = formatNumber(root.actualRows); byId("metric-time").textContent = root.actualTotalTimeMs === null || root.actualTotalTimeMs === undefined - ? "—" + ? "โ€”" : `${Number(root.actualTotalTimeMs).toFixed(3)} ms`; } function nodeMeta(node) { - const relation = node.relation ? ` ท ${node.relation}` : ""; - return `cost ${formatDecimal(node.totalCost)} ท ${formatNumber(node.planRows)} rows${relation}`; + const relation = node.relation ? ` ยท ${node.relation}` : ""; + return `cost ${formatDecimal(node.totalCost)} ยท ${formatNumber(node.planRows)} rows${relation}`; } function createTreeNode(node, path) { @@ -115,15 +142,45 @@ function renderPlan(view) { state.explanations = new Map(view.explanations.map((item) => [item.path, item])); byId("plan-name").textContent = view.plan.name; byId("plan-summary").textContent = view.plan.summary; - byId("plan-sql").textContent = view.plan.sql; + byId("plan-sql").textContent = view.plan.sql || "No SQL text was included in this EXPLAIN file."; renderMetrics(view.plan.root); renderTree(view.plan.root); selectNode("0"); + setStatusLabel(view.plan.id === "imported-plan" ? "Imported plan" : "Bundled fixtures"); document.querySelectorAll(".plan-option").forEach((button) => { button.classList.toggle("active", button.dataset.planId === view.plan.id); }); } +async function importSelectedPlan(event) { + event.preventDefault(); + const file = byId("plan-file").files[0]; + if (!file) { + setImportStatus("Choose a JSON file first.", true); + return; + } + + const button = byId("import-button"); + button.disabled = true; + setImportStatus("Reading the plan..."); + try { + const view = await requestJson("/api/plans/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: await file.text() + }); + renderPlan(view); + byId("loading").hidden = true; + byId("plan-view").hidden = false; + byId("error").hidden = true; + setImportStatus("Imported for this session."); + } catch (error) { + setImportStatus(error.message, true); + } finally { + button.disabled = false; + } +} + async function loadPlan(id) { try { const view = await fetchJson(`/api/plans/${encodeURIComponent(id)}`); @@ -155,4 +212,10 @@ async function start() { } } -start(); \ No newline at end of file +byId("import-form").addEventListener("submit", importSelectedPlan); +byId("plan-file").addEventListener("change", (event) => { + const file = event.target.files[0]; + byId("file-label").textContent = file ? file.name : "Choose JSON file"; +}); + +start(); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index bcf7b20..cacb9cc 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -14,7 +14,7 @@ QP Query Plan Playground - Bundled fixtures + Bundled fixtures
@@ -24,6 +24,19 @@ 0 plans
+
+
+ Import + JSON +
+

Inspect one PostgreSQL EXPLAIN (FORMAT JSON) file.

+ + +

Imports stay in memory.

+
- SQL sample + SQL text
@@ -97,4 +110,4 @@

Node inspector

- \ No newline at end of file + diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css index 8e8d2a1..1f9be1e 100644 --- a/src/main/resources/static/styles.css +++ b/src/main/resources/static/styles.css @@ -97,6 +97,18 @@ button { color: inherit; } .plan-option strong { display: block; font-size: 13px; line-height: 1.35; } .plan-option span { color: var(--muted); display: block; font-size: 11px; margin-top: 5px; } +.import-panel { border: 1px solid var(--line); border-radius: 9px; margin: 22px 12px 0; padding: 14px; } +.import-heading { align-items: center; display: flex; justify-content: space-between; } +.import-format { color: var(--dim); font-size: 10px; letter-spacing: .12em; } +.import-help { color: var(--muted); font-size: 11px; line-height: 1.5; margin: 10px 0 12px; } +.file-picker { border: 1px dashed #3b5b54; color: var(--muted); cursor: pointer; display: block; font-size: 11px; overflow: hidden; padding: 9px 10px; text-overflow: ellipsis; white-space: nowrap; } +.file-picker input { height: 1px; opacity: 0; position: absolute; width: 1px; } +.file-picker:focus-within { border-color: var(--accent); color: var(--accent); } +.import-button { background: var(--accent); border: 0; border-radius: 6px; color: #0c2420; cursor: pointer; font-size: 11px; font-weight: 800; margin-top: 9px; padding: 10px 12px; width: 100%; } +.import-button:disabled { cursor: wait; opacity: .55; } +.import-status { color: var(--dim); font-size: 10px; line-height: 1.45; margin: 10px 0 0; overflow-wrap: anywhere; } +.import-status.error { color: #f0a59b; } + .sidebar-note { align-items: flex-start; border-top: 1px solid var(--line); @@ -171,4 +183,4 @@ h2 { font-size: 17px; letter-spacing: -.02em; margin: 4px 0 0; } .hero-tag { margin-top: 20px; } .metrics { grid-template-columns: repeat(2, 1fr); } .metric-card strong { font-size: 20px; } -} \ No newline at end of file +} diff --git a/src/test/java/com/example/queryplayground/service/PostgresqlPlanImporterTest.java b/src/test/java/com/example/queryplayground/service/PostgresqlPlanImporterTest.java new file mode 100644 index 0000000..05df613 --- /dev/null +++ b/src/test/java/com/example/queryplayground/service/PostgresqlPlanImporterTest.java @@ -0,0 +1,96 @@ +package com.example.queryplayground.service; + +import com.example.queryplayground.domain.PlanFixture; +import com.example.queryplayground.domain.PlanNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PostgresqlPlanImporterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final PostgresqlPlanImporter importer = new PostgresqlPlanImporter(); + + @Test + void importsStandardPostgresqlPlanFieldsAndChildren() throws Exception { + PlanFixture fixture = importer.importDocument(objectMapper.readTree(""" + [ + { + "Plan": { + "Node Type": "Nested Loop", + "Join Type": "Inner", + "Startup Cost": 0.42, + "Total Cost": 12.77, + "Plan Rows": 5, + "Actual Rows": 5, + "Actual Total Time": 0.214, + "Actual Loops": 1, + "Plans": [ + { + "Node Type": "Index Scan", + "Relation Name": "customers", + "Index Name": "customers_pkey", + "Startup Cost": 0.28, + "Total Cost": 8.29, + "Plan Rows": 1, + "Actual Rows": 1, + "Actual Total Time": 0.031, + "Actual Loops": 1, + "Index Cond": "(id = 42)" + } + ] + }, + "Planning Time": 0.123, + "Execution Time": 0.456 + } + ] + """)); + + PlanNode root = fixture.root(); + PlanNode child = root.children().getFirst(); + + assertEquals("imported-plan", fixture.id()); + assertEquals("Imported PostgreSQL plan", fixture.name()); + assertNull(fixture.sql()); + assertEquals("Nested Loop", root.nodeType()); + assertEquals("Inner", root.joinType()); + assertEquals(12.77, root.totalCost()); + assertEquals(5L, root.actualRows()); + assertEquals("customers", child.relation()); + assertEquals("customers_pkey", child.indexName()); + assertEquals(0.031, child.actualTotalTimeMs()); + } + + @Test + void keepsAnalyzeFieldsAbsentWhenTheInputOnlyHasPlannerEstimates() throws Exception { + PlanFixture fixture = importer.importDocument(objectMapper.readTree(""" + [{ + "Plan": { + "Node Type": "Seq Scan", + "Relation Name": "orders", + "Startup Cost": 0.00, + "Total Cost": 52.10, + "Plan Rows": 2400 + } + }] + """)); + + assertNull(fixture.root().actualRows()); + assertNull(fixture.root().actualTotalTimeMs()); + assertEquals(2_400L, fixture.root().planRows()); + assertEquals("orders", fixture.root().relation()); + } + + @Test + void rejectsDocumentsThatAreNotOneStandardStatementResult() throws Exception { + assertThrows(IllegalArgumentException.class, () -> + importer.importDocument(objectMapper.readTree("{}"))); + assertThrows(IllegalArgumentException.class, () -> + importer.importDocument(objectMapper.readTree("[{\"Plan\":{\"Node Type\":\"Seq Scan\"}},{\"Plan\":{\"Node Type\":\"Sort\"}}]"))); + assertThrows(IllegalArgumentException.class, () -> + importer.importDocument(objectMapper.readTree("[{\"Plan\":{\"Node Type\":\"Seq Scan\",\"Plans\":{}}}]"))); + } +}