Skip to content
Merged
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
45 changes: 33 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ 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

| Area | Responsibility |
| --- | --- |
| `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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Original file line number Diff line number Diff line change
@@ -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<PlanNode> 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<PlanNode> 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<PlanNode> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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));
}
}

@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);
}
}
}
Loading
Loading