Skip to content
Open
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.github
src/services/__tests__/**/*.json
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"format": "prettier --check .",
"prepare": "husky",
"test": "LANG=en_US.UTF-8 vitest --run",
"test:visual:run": "docker run --rm -it --init --user \"$(id -u):$(id -g)\" -e HOME=/tmp -e APP_VERSION=TEST -v $PWD:$PWD -w $PWD -p 9323:9323 mcr.microsoft.com/playwright:v1.61.0-noble sh -c \"npx playwright test; npx playwright show-report --host 0.0.0.0\"",
"test:visual:update": "docker run --rm -it --init --user \"$(id -u):$(id -g)\" -e HOME=/tmp -e APP_VERSION=TEST -v $PWD:$PWD -w $PWD -p 9323:9323 mcr.microsoft.com/playwright:v1.61.0-noble npx playwright test --update-snapshots"
"test:visual:run": "docker run --rm -it --init --user \"$(id -u):$(id -g)\" -e HOME=/tmp -e APP_VERSION=TEST -v $PWD:$PWD -w $PWD -p 9323:9323 mcr.microsoft.com/playwright:v1.62.0-noble sh -c \"npx playwright test; npx playwright show-report --host 0.0.0.0\"",
"test:visual:update": "docker run --rm -it --init --user \"$(id -u):$(id -g)\" -e HOME=/tmp -e APP_VERSION=TEST -v $PWD:$PWD -w $PWD -p 9323:9323 mcr.microsoft.com/playwright:v1.62.0-noble npx playwright test --update-snapshots"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
Expand Down
4 changes: 1 addition & 3 deletions src/components/GridRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,7 @@ const isHighlighted = computed(
</template>
<template v-if="node[Property.GROUP_KEY]">
<span class="text-body-tertiary">by</span>
<span
v-html="keysToString(node[Property.GROUP_KEY] as string)"
></span>
<span v-html="keysToString(node[Property.GROUP_KEY])"></span>
</template>
<template v-if="node[Property.SORT_KEY]">
<span class="text-body-tertiary">by</span>
Expand Down
4 changes: 1 addition & 3 deletions src/components/PlanNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,7 @@ function centerCte() {
:class="{ 'line-clamp-2': !showDetails }"
>
<span class="text-body-tertiary">by</span>
<span
v-html="keysToString(node[Property.GROUP_KEY] as string)"
></span>
<span v-html="keysToString(node[Property.GROUP_KEY])"></span>
</div>
<div
v-if="node[Property.SORT_KEY]"
Expand Down
6 changes: 6 additions & 0 deletions src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export enum Property {
ACTUAL_ROWS_FRACTIONAL = "*Actual Rows Is Fractional",
ACTUAL_ROWS_REVISED = "*Actual Rows Revised",
ACTUAL_STARTUP_TIME = "Actual Startup Time",
ACTUAL_STARTUP_TIME_REVISED = "*Actual Startup Time Revised",
ACTUAL_TOTAL_TIME = "Actual Total Time",
ACTUAL_TOTAL_TIME_REVISED = "*Actual Total Time Revised",
ALIAS = "Alias",
ASYNC_CAPABLE = "Async Capable",
AVERAGE_IO_READ_SPEED = "*I/O Read Speed",
Expand Down Expand Up @@ -118,10 +120,14 @@ export enum Property {
FUNCTION_NAME = "Function Name",
GROUP_COUNT = "Group Count",
GROUP_KEY = "Group Key",
GROUP_KEYS = "Group Keys",
GROUPING_SETS = "Grouping Sets",
HASHAGG_BATCHES = "HashAgg Batches",
HASH_BATCHES = "Hash Batches",
HASH_BUCKETS = "Hash Buckets",
HASH_CONDITION = "Hash Cond",
HASH_KEY = "Hash Key",
HASH_KEYS = "Hash Keys",
HEAP_FETCHES = "Heap Fetches",
INDEX_NAME = "Index Name",
IO_READ_TIME = "I/O Read Time",
Expand Down
26 changes: 26 additions & 0 deletions src/filters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _ from "lodash"
import { createApp } from "vue"
import { EstimateDirection, Property, SortSpaceType } from "@/enums"
import type { GroupingSet } from "@/interfaces"
import SortGroup from "@/components/SortGroup.vue"
import JitDetails from "@/components/JitDetails.vue"
import hljs from "highlight.js/lib/core"
Expand Down Expand Up @@ -220,6 +221,30 @@ function formatSortSpaceType(value: unknown): string {
}
}

function formatGroupingSets(value: unknown): string {
const items = (value as GroupingSet[]).map((set) => {
if (_.has(set, Property.SORT_KEY)) {
return `<li>Sort Key: ${set[Property.SORT_KEY]}<br>Group Keys: ${set[Property.GROUP_KEYS]}<br></li>`
}

if (_.has(set, Property.HASH_KEYS)) {
const keys = (set[Property.HASH_KEYS] ?? []).map((key) => key.join(", "))
return `<li>Hash Keys: ${keys}</li>`
}

if (_.has(set, Property.GROUP_KEYS)) {
const keys = (set[Property.GROUP_KEYS] ?? [])
.map((key) => `<li>${key.length ? key.join(", ") : "()"}</li>`)
.join("")
return `<li>Group Keys: <ul>${keys}</ul></li>`
}

return "<li></li>"
})

return `<ul>${items.join("")}</ul>`
}

type Formatter = (value: unknown) => string

const nodePropFormatters: Partial<Record<Property, Formatter>> = {
Expand Down Expand Up @@ -269,6 +294,7 @@ const nodePropFormatters: Partial<Record<Property, Formatter>> = {
[Property.EXCLUSIVE_TEMP_READ_BLOCKS]: formatBlocksHtml,
[Property.EXCLUSIVE_TEMP_WRITTEN_BLOCKS]: formatBlocksHtml,
[Property.FULL_SORT_GROUPS]: formatSortGroups,
[Property.GROUPING_SETS]: formatGroupingSets,
[Property.HEAP_FETCHES]: formatRows,
[Property.HASHAGG_BATCHES]: formatNumber,
[Property.HASH_BATCHES]: formatNumber,
Expand Down
88 changes: 71 additions & 17 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ export type IBlocksStats = {
[key in BufferLocation]: number
}

export type GroupingSet = {
[Property.HASH_KEYS]?: string[][]
[Property.GROUP_KEYS]?: string[][]
[Property.SORT_KEY]?: string[]
}

const STRATEGY_MAP = {
Group: "Sorted",
Hash: "Hashed",
Mixed: "Mixed",
}

export const REVERSE_STRATEGY_MAP = Object.fromEntries(
Object.entries(STRATEGY_MAP).map(([raw, mapped]) => [mapped, raw]),
)

// Class to create nodes when parsing text
export class Node {
nodeId!: number
Expand All @@ -84,7 +100,9 @@ export class Node {
[Property.ACTUAL_ROWS]!: number;
[Property.ACTUAL_ROWS_REVISED]!: number;
[Property.ACTUAL_STARTUP_TIME]?: number;
[Property.ACTUAL_STARTUP_TIME_REVISED]?: number;
[Property.ACTUAL_TOTAL_TIME]?: number;
[Property.ACTUAL_TOTAL_TIME_REVISED]?: number;
[Property.ASYNC_CAPABLE]: boolean = false;
[Property.EXCLUSIVE_COST]!: number;
[Property.EXCLUSIVE_DURATION]!: number;
Expand All @@ -101,6 +119,10 @@ export class Node {
[Property.EXCLUSIVE_TEMP_READ_BLOCKS]!: number;
[Property.EXCLUSIVE_TEMP_WRITTEN_BLOCKS]!: number;
[Property.FILTER]!: string;
[Property.HASH_KEY]!: string[];
[Property.GROUPING_SETS]!: GroupingSet[];
[Property.GROUP_KEY]!: string[];
[Property.PLANNED_PARTITIONS]?: number;
[Property.PLANNER_ESTIMATE_DIRECTION]?: EstimateDirection;
[Property.PLANNER_ESTIMATE_FACTOR]?: number;
[Property.INDEX_NAME]?: string;
Expand Down Expand Up @@ -157,6 +179,7 @@ export class Node {
| SortGroups
| Timing
| Worker[]
| GroupingSet[]
| boolean
| number
| string
Expand All @@ -171,12 +194,13 @@ export class Node {

enum ScanAndOperationMatch {
NodeType = 1,
AsyncCapable,
RelationName,
Alias,
}
// tslint:disable-next-line:max-line-length
const scanAndOperationsRegex =
/^((?:Parallel\s+)?(?:Seq|Tid.*|Bitmap\s+Heap|WorkTable|(?:Async\s+)?Foreign)\s+Scan|Update|Insert|Delete|Merge)\son\s(\S+)(?:\s+(\S+))?$/.exec(
/^((?:Parallel\s+)?(?:Seq|Tid.*|Bitmap\s+Heap|WorkTable|(Async\s+)?Foreign)\s+Scan|Update|Insert|Delete|Merge)\son\s(\S+)(?:\s+(\S+))?$/.exec(
type,
)

Expand Down Expand Up @@ -218,14 +242,28 @@ export class Node {
Alias,
}
const subqueryRegex = /^(Subquery\sScan)\son\s(.+)$/.exec(type)

enum AggregateMatch {
PartialMode = 1,
Strategy = 2,
}
const aggregateRegex =
/^(Partial|Finalize)*\s*(Group|Hash|Mixed|[A-z]*)*Aggregate$/.exec(type)

if (scanAndOperationsRegex) {
this[Property.NODE_TYPE] =
scanAndOperationsRegex[ScanAndOperationMatch.NodeType]
this[Property.RELATION_NAME] =
scanAndOperationsRegex[ScanAndOperationMatch.RelationName]
if (scanAndOperationsRegex[ScanAndOperationMatch.Alias]) {
this[Property.ALIAS] =
scanAndOperationsRegex[ScanAndOperationMatch.Alias]
this[Property.ALIAS] =
scanAndOperationsRegex[ScanAndOperationMatch.Alias] ||
this[Property.RELATION_NAME]
if (scanAndOperationsRegex[ScanAndOperationMatch.AsyncCapable]) {
this[Property.ASYNC_CAPABLE] = true
this[Property.NODE_TYPE] = this[Property.NODE_TYPE].replace(
/Async\s+/,
"",
)
}
} else if (bitmapRegex) {
this[Property.NODE_TYPE] = bitmapRegex[BitmapMatch.NodeType]
Expand Down Expand Up @@ -255,6 +293,27 @@ export class Node {
} else if (subqueryRegex) {
this[Property.NODE_TYPE] = subqueryRegex[SubqueryMatch.NodeType]
this[Property.ALIAS] = subqueryRegex[SubqueryMatch.Alias]
} else if (aggregateRegex) {
this[Property.NODE_TYPE] = "Aggregate"
this[Property.PARTIAL_MODE] =
aggregateRegex[AggregateMatch.PartialMode] || "Simple"

const rawStrategy = aggregateRegex[AggregateMatch.Strategy]
let strategy
if (rawStrategy === undefined) {
strategy = "Plain"
} else if (rawStrategy in STRATEGY_MAP) {
strategy = STRATEGY_MAP[rawStrategy as keyof typeof STRATEGY_MAP]
} else {
strategy = rawStrategy
console.error(`Unsupported Aggregate node strategy: ${rawStrategy}`)
}

if (["Hash", "Mixed"].includes(rawStrategy)) {
this[Property.PLANNED_PARTITIONS] =
this[Property.PLANNED_PARTITIONS] || 0
}
this[Property.STRATEGY] = strategy
}
enum ParallelMatch {
NodeType = 2,
Expand All @@ -278,23 +337,18 @@ export class Node {

enum JoinMatch {
NodeType = 1,
}
const joinRegex = /(.*)\sJoin$/.exec(<string>this[Property.NODE_TYPE])

enum JoinModifierMatch {
NodeType = 1,
JoinType,
}
const joinModifierRegex = /(.*)\s+(Full|Left|Right|Anti)/.exec(
<string>this[Property.NODE_TYPE],
)
const joinRegex =
/^(Nested Loop|Hash|Merge)(?:\s+(Right Semi|Right Anti|Left|Right|Full|Semi|Anti))?\s+Join$/.exec(
<string>this[Property.NODE_TYPE],
)
if (joinRegex) {
this[Property.NODE_TYPE] = joinRegex[JoinMatch.NodeType]
if (joinModifierRegex) {
this[Property.NODE_TYPE] = joinModifierRegex[JoinModifierMatch.NodeType]
this[Property.JOIN_TYPE] = joinModifierRegex[JoinModifierMatch.JoinType]
}
this[Property.NODE_TYPE] += " Join"
this[Property.JOIN_TYPE] = joinRegex[JoinMatch.JoinType] || "Inner"
// Re-add "Join" suffix to nodes with type != "Nested Loop"
this[Property.NODE_TYPE] +=
this[Property.NODE_TYPE] == "Nested Loop" ? "" : " Join"
}
}
}
Expand Down
32 changes: 27 additions & 5 deletions src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import _ from "lodash"
import { computed, onBeforeMount, ref, watch } from "vue"
import type { Node, Worker, ViewOptions } from "@/interfaces"
import { REVERSE_STRATEGY_MAP } from "@/interfaces"
import {
BufferLocation,
Property,
Expand Down Expand Up @@ -84,18 +85,39 @@ export default function useNode(node: Node, viewOptions: ViewOptions) {
const nodeName = computed((): string => {
let nodeName = isParallelAware.value ? "Parallel " : ""
nodeName += node[Property.ASYNC_CAPABLE] ? "Async " : ""
nodeName += node[Property.PARTIAL_MODE]
? node[Property.PARTIAL_MODE] + " "
: ""

nodeName += node[Property.NODE_TYPE]

if (node[Property.STRATEGY]) {
nodeName =
(REVERSE_STRATEGY_MAP[
node[Property.STRATEGY] as keyof typeof REVERSE_STRATEGY_MAP
] || "") + nodeName
}

if (
node[Property.PARTIAL_MODE] &&
node[Property.PARTIAL_MODE] != "Simple"
) {
nodeName = `${node[Property.PARTIAL_MODE]} ${nodeName}`
}
if (
node[Property.SCAN_DIRECTION] &&
node[Property.SCAN_DIRECTION] !== "Forward"
) {
nodeName += " " + node[Property.SCAN_DIRECTION]
}
if (node[Property.JOIN_TYPE]) {
nodeName = nodeName.replace("Join", `${node[Property.JOIN_TYPE]} Join`)
if (node[Property.JOIN_TYPE] && node[Property.JOIN_TYPE] != "Inner") {
// Add the join type to the node name before the "Join" suffix
// Note: in the case of a Nested Loop, "Join" isn't part of the node type
// (hence the regexp)
nodeName = nodeName.replace(
/(\s+Join)*$/,
` ${node[Property.JOIN_TYPE]} Join`,
)
}
if (node[Property.ASYNC_CAPABLE]) {
nodeName = `Async ${nodeName}`
}
return nodeName
})
Expand Down
3 changes: 2 additions & 1 deletion src/services/__tests__/12-line-wrapped-plans/06-expect
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"Shared Dirtied Blocks": 0,
"Plans": [
{
"Node Type": "Hash Semi Join",
"Node Type": "Hash Join",
"Join Type": "Semi",
"Startup Cost": 65.61,
"Total Cost": 24418.81,
"Plan Rows": 1042,
Expand Down
3 changes: 2 additions & 1 deletion src/services/__tests__/12-line-wrapped-plans/07-expect
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"Shared Dirtied Blocks": 0,
"Plans": [
{
"Node Type": "Hash Semi Join",
"Node Type": "Hash Join",
"Join Type": "Semi",
"Startup Cost": 65.61,
"Total Cost": 24418.81,
"Plan Rows": 1042,
Expand Down
34 changes: 30 additions & 4 deletions src/services/__tests__/from-text.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest"
import { PlanService } from "@/services/plan-service"
import type { Node } from "@/interfaces"
import _ from "lodash"
import * as fs from "fs"
import { fileURLToPath } from "url"
Expand All @@ -12,21 +13,46 @@ const __filename = fileURLToPath(import.meta.url)
// The xxx-plan file is parsed and the result is expected to equal the content
// of the corresponding xxx-expect file.

function removeKeys(node: Node | Node[]) {
// Remove keys that cannot be determined from text format
// for tests where the `-expect` file is actually generated with EXPLAIN
// (FORMAT JSON)
if (Array.isArray(node)) {
node.forEach(removeKeys)
return node
}
delete node["Inner Unique"]

if (node["Plans"]) {
removeKeys(node["Plans"])
}

return node
}

const dir = path.join(path.dirname(__filename), "from-text")
const files = fs.readdirSync(dir)
let tests = files.map((file: string) => file.replace(/-([^-]+)$/, ""))
let tests = files.filter((file: string) => file.match(/-plan$/))
tests = _.uniq(tests)
tests.forEach((planTest: string) => {
describe("From text: Plan " + planTest, () => {
test("", () => {
const planFile = path.join(dir, planTest + "-plan")
const planFile = path.join(dir, planTest)
const planText = fs.readFileSync(planFile, { encoding: "utf-8" })
const planExpectFile = path.join(dir, planTest + "-expect")
const planExpectFile = path.join(
dir,
planTest.replace(/-plan$/, "-expect"),
)
const planExpect = fs.readFileSync(planExpectFile, { encoding: "utf-8" })

const planService = new PlanService()
const r = planService.fromSource(planText)
expect(r).toMatchObject(JSON.parse(planExpect))
let planJson = JSON.parse(planExpect)
if (Array.isArray(planJson)) {
planJson = planJson[0]
}
planJson["Plan"] = removeKeys(planJson["Plan"])
expect(r).toMatchObject(planJson)
})
})
})
Loading
Loading