├── .prettierrc ├── src ├── clarinet.d.ts ├── vite-env.d.ts ├── components │ ├── index.ts │ ├── SeverityBullet.vue │ ├── GridProgressBar.vue │ ├── SortLink.vue │ ├── LevelDivider.vue │ ├── SortedTable.vue │ ├── LogoImage.vue │ ├── SortGroup.vue │ ├── MiscDetail.vue │ ├── Copy.vue │ ├── NodeBadges.vue │ ├── StatsTableItem.vue │ ├── PlanStats.vue │ ├── Grid.vue │ ├── Diagram.vue │ ├── PlanNodeDetail.vue │ ├── DiagramRow.vue │ ├── PlanNode.vue │ ├── Stats.vue │ ├── GridRow.vue │ └── Plan.vue ├── assets │ └── scss │ │ ├── _copy.scss │ │ ├── _pev2.scss │ │ ├── _plan-diagram.scss │ │ ├── _plan-grid.scss │ │ ├── _variables.scss │ │ ├── _plan.scss │ │ ├── _base.scss │ │ └── _plan-node.scss ├── symbols.ts ├── d3-flextree.d.ts ├── services │ ├── color-service.ts │ ├── help-service.ts │ └── plan-service.ts ├── dragscroll.ts ├── enums.ts ├── interfaces.ts ├── node.ts └── filters.ts ├── .husky └── pre-commit ├── example └── src │ ├── views │ ├── NotFoundView.vue │ ├── PlanView.vue │ ├── AboutView.vue │ └── HomeView.vue │ ├── main.ts │ ├── assets │ ├── logo.svg │ └── base.css │ ├── components │ └── VLink.vue │ ├── layouts │ └── MainLayout.vue │ ├── App.vue │ ├── utils.ts │ └── idb.ts ├── .vscode └── extensions.json ├── duckdb-explain-visualizer-screenshot.png ├── .editorconfig ├── jest.config.js ├── env.d.ts ├── .github ├── ISSUE_TEMPLATE │ ├── parsing_error.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── release.yml │ └── build.yml ├── tsconfig.vite-config.json ├── .gitignore ├── CONTRIBUTING.md ├── tsconfig.json ├── eslint.config.mjs ├── LICENSE ├── index.html ├── vite.config.ts ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /src/clarinet.d.ts: -------------------------------------------------------------------------------- 1 | declare module "clarinet" 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __APP_VERSION__: string 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Plan from "./Plan.vue" 2 | 3 | export { Plan } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx lint-staged 4 | npm run test 5 | npm run typecheck 6 | -------------------------------------------------------------------------------- /example/src/views/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/scss/_copy.scss: -------------------------------------------------------------------------------- 1 | * > .copy { 2 | display: none; 3 | } 4 | *:hover > .copy { 5 | display: block; 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /duckdb-explain-visualizer-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBatUTuebingen/duckdb-explain-visualizer/HEAD/duckdb-explain-visualizer-screenshot.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue,scss}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /src/assets/scss/_pev2.scss: -------------------------------------------------------------------------------- 1 | @forward "base"; 2 | @forward "plan"; 3 | @forward "plan-diagram"; 4 | @forward "plan-grid"; 5 | @forward "plan-node"; 6 | @forward "copy"; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | roots: ["/src"], 5 | testMatch: ["**/*.test.ts"], 6 | moduleFileExtensions: ["ts", "js", "json"], 7 | setupFilesAfterEnv: ["/src/tests/setup.ts"], 8 | } 9 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // Use 'object' instead of '{}' for better type safety 6 | const component: DefineComponent 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /example/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue" 2 | import "bootstrap" 3 | import "bootstrap/dist/css/bootstrap.css" 4 | 5 | import App from "./App.vue" 6 | 7 | createApp(App).mount("#app") 8 | 9 | declare global { 10 | interface Window { 11 | setPlanData: () => void 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/parsing_error.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Parsing error 3 | about: Create a 'parsing error' report to help us improve 4 | title: "Failed to parse plan" 5 | labels: "parsing" 6 | assignees: "" 7 | --- 8 | 9 | **Version of PEV2: xxxx** 10 | 11 | **Copy the failing plan** 12 | 13 | ``` 14 | *Your plan here* 15 | ``` 16 | -------------------------------------------------------------------------------- /tsconfig.vite-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "types": [ 8 | "node", 9 | "vitest" 10 | ] 11 | }, 12 | "include": [ 13 | "vite.config.*" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/components/SeverityBullet.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | dist-app 15 | coverage 16 | *.local 17 | 18 | /cypress/videos/ 19 | /cypress/screenshots/ 20 | 21 | # Editor directories and files 22 | .vscode/* 23 | !.vscode/extensions.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | .eslintcache 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # pev2 2 | 3 | For the develoment of this project you need to have NPM version > 8 installed. 4 | 5 | ## Project Setup 6 | 7 | ```sh 8 | npm install 9 | ``` 10 | 11 | ### Compile and Hot-Reload for Development 12 | 13 | ```sh 14 | npm run dev 15 | ``` 16 | 17 | ### Type-Check, Compile and Minify for Production 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | ### Lint with [ESLint](https://eslint.org/) 24 | 25 | ```sh 26 | npm run lint 27 | ``` 28 | -------------------------------------------------------------------------------- /example/src/views/PlanView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /example/src/components/VLink.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey, Ref } from "vue" 2 | import type { IPlan, ViewOptions } from "@/interfaces" 3 | 4 | export const SelectedNodeIdKey: InjectionKey> = 5 | Symbol("selectedNodeId") 6 | export const HighlightedNodeIdKey: InjectionKey> = 7 | Symbol("highlightedNodeId") 8 | 9 | export const SelectNodeKey: InjectionKey< 10 | (nodeId: number, center: boolean) => void 11 | > = Symbol("selectNode") 12 | 13 | export const ViewOptionsKey: InjectionKey = Symbol("viewOptions") 14 | 15 | export const PlanKey: InjectionKey> = Symbol("plan") 16 | -------------------------------------------------------------------------------- /example/src/layouts/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow uploads index.html file to release assets 2 | 3 | name: Release 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*.*.*" 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Build 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 20.x 22 | cache: 'npm' 23 | - run: npm ci 24 | - run: npm run build 25 | - name: Release 26 | uses: softprops/action-gh-release@v1 27 | with: 28 | files: dist-app/index.html 29 | # To only send out release mails when the release 30 | # notes have been manually added 31 | draft: true 32 | -------------------------------------------------------------------------------- /src/components/GridProgressBar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /example/src/App.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "env.d.ts", 4 | "src/**/*", 5 | "src/**/*.vue" 6 | ], 7 | "compilerOptions": { 8 | "target": "esnext", 9 | "module": "esnext", 10 | "strict": true, 11 | "jsx": "preserve", 12 | "moduleResolution": "node", 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "useDefineForClassFields": true, 18 | "sourceMap": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": [ 22 | "./src/*" 23 | ] 24 | }, 25 | "types": [ 26 | "node", 27 | "jest", 28 | "d3", 29 | "vite/client", 30 | "vue" 31 | ], 32 | "verbatimModuleSyntax": true, 33 | "lib": [ 34 | "esnext", 35 | "dom", 36 | "dom.iterable", 37 | "scripthost" 38 | ] 39 | }, 40 | "references": [ 41 | { 42 | "path": "./tsconfig.vite-config.json" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SortLink.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | -------------------------------------------------------------------------------- /src/components/LevelDivider.vue: -------------------------------------------------------------------------------- 1 | 18 | 33 | -------------------------------------------------------------------------------- /src/components/SortedTable.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import { 3 | defineConfigWithVueTs, 4 | vueTsConfigs, 5 | } from '@vue/eslint-config-typescript' 6 | 7 | import globals from 'globals' 8 | 9 | 10 | export default defineConfigWithVueTs([ 11 | pluginVue.configs['flat/recommended'], 12 | vueTsConfigs.recommended, 13 | { 14 | ignores: ['dist', 'node_modules'], 15 | languageOptions: { 16 | sourceType: 'module', 17 | globals: { 18 | ...globals.browser 19 | } 20 | }, 21 | rules: { 22 | '@typescript-eslint/no-empty-object-type': 'off', 23 | '@typescript-eslint/ban-types': [ 24 | 'error', 25 | { 26 | types: { 27 | '{}': false, 28 | }, 29 | extendDefaults: true, 30 | allowObjectTypes: true, 31 | }, 32 | ], 33 | }, 34 | rules: { 35 | '@typescript-eslint/no-explicit-any': 'off', 36 | '@typescript-eslint/no-unused-vars': 'off', 37 | 'vue/multi-word-component-names': 'off', 38 | 'vue/no-dupe-keys': 'off', 39 | } 40 | }, 41 | ]) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Database Research Group @ University of Tübingen 2025, 2 | 3 | Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. 4 | 5 | IN NO EVENT SHALL THE Database Research Group @ University of Tübingen BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE Database Research Group @ University of Tübingen HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 6 | 7 | THE Database Research Group @ University of Tübingen SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE Database Research Group @ University of Tübingen HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. 8 | -------------------------------------------------------------------------------- /src/components/LogoImage.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - name: Clear npm cache 30 | run: npm cache clean --force 31 | - name: Install dependencies 32 | run: npm ci 33 | - name: Run tests 34 | run: npm test 35 | - name: Run linting 36 | run: npm run lint -- --config eslint.config.mjs 37 | - name: Build 38 | run: npm run build --if-present 39 | -------------------------------------------------------------------------------- /src/components/SortGroup.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 45 | -------------------------------------------------------------------------------- /src/components/MiscDetail.vue: -------------------------------------------------------------------------------- 1 | 37 | 47 | -------------------------------------------------------------------------------- /src/components/Copy.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 44 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | DuckDB EXPLAIN Visualizer 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/d3-flextree.d.ts: -------------------------------------------------------------------------------- 1 | declare module "d3-flextree" { 2 | import * as d3 from "d3" 3 | export function flextree(opts: { 4 | nodeSize?: (node: FlexHierarchyPointNode) => [number, number] 5 | spacing?: 6 | | number 7 | | (( 8 | nodeA: FlexHierarchyPointNode, 9 | nodeB: FlexHierarchyPointNode 10 | ) => number) 11 | }): FlexTreeLayout 12 | 13 | export interface FlexHierarchyPointLink { 14 | /** 15 | * The source of the link. 16 | */ 17 | source: FlexHierarchyPointNode 18 | 19 | /** 20 | * The target of the link. 21 | */ 22 | target: FlexHierarchyPointNode 23 | } 24 | 25 | export interface FlexHierarchyPointNode 26 | extends d3.HierarchyPointNode { 27 | /** 28 | * The horizontal size 29 | */ 30 | xSize: number 31 | 32 | /** 33 | * The vertical size 34 | */ 35 | ySize: number 36 | 37 | links(): Array> 38 | } 39 | 40 | export interface FlexTreeLayout extends d3.TreeLayout { 41 | /** 42 | * Lays out the specified root hierarchy. 43 | * You may want to call `root.sort` before passing the hierarchy to the tree layout. 44 | * 45 | * @param root The specified root hierarchy. 46 | */ 47 | (root: FlexHierarchiePointNode): FlexHierarchyPointNode 48 | 49 | hierarchy( 50 | data: Datum, 51 | children?: (d: Datum) => Iterable | null | undefined 52 | ): FlexHierarchiePointNode 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { fileURLToPath, URL } from "url" 3 | 4 | import { defineConfig } from "vite" 5 | import vue from "@vitejs/plugin-vue" 6 | import { viteSingleFile } from "vite-plugin-singlefile" 7 | 8 | const build = process.env.LIB 9 | ? { 10 | lib: { 11 | entry: path.resolve(__dirname, "src/components/index.ts"), 12 | name: "duckdb-explain-visualizer", 13 | fileName: (format) => `duckdb-explain-visualizer.${format === 'es' ? 'mjs' : 'umd.js'}`, 14 | }, 15 | rollupOptions: { 16 | external: ["vue"], 17 | output: { 18 | globals: { 19 | vue: "Vue", 20 | }, 21 | }, 22 | }, 23 | } 24 | : { 25 | outDir: "dist-app", 26 | target: "esnext", 27 | assetsInlineLimit: 100000000, 28 | chunkSizeWarningLimit: 100000000, 29 | cssCodeSplit: false, 30 | brotliSize: false, 31 | rollupOptions: { 32 | output: { 33 | inlineDynamicImports: true, 34 | }, 35 | }, 36 | } 37 | 38 | // https://vitejs.dev/config/ 39 | export default defineConfig({ 40 | build: build, 41 | plugins: [ 42 | vue({ 43 | template: { 44 | compilerOptions: { 45 | whitespace: "preserve", 46 | }, 47 | }, 48 | }), 49 | viteSingleFile(), 50 | ], 51 | resolve: { 52 | alias: { 53 | "@": fileURLToPath(new URL("./src", import.meta.url)), 54 | }, 55 | }, 56 | define: { 57 | __APP_VERSION__: JSON.stringify(process.env.npm_package_version), 58 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /src/components/NodeBadges.vue: -------------------------------------------------------------------------------- 1 | 28 | 50 | -------------------------------------------------------------------------------- /src/assets/scss/_plan-diagram.scss: -------------------------------------------------------------------------------- 1 | @use "variables" as *; 2 | 3 | .plan-diagram { 4 | line-height: 0.85em; 5 | 6 | &.plan-diagram-top { 7 | max-height: 30%; 8 | } 9 | 10 | // make sure diagram right column takes as much width as possible 11 | table tr td:nth-child(3) { 12 | width: 50%; 13 | } 14 | 15 | table tr td:nth-child(2) { 16 | max-width: 0; 17 | width: 60%; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | white-space: nowrap; 21 | } 22 | 23 | font-family: $font-family-sans-serif; 24 | 25 | .legend ul li { 26 | font-size: $font-size-base; 27 | 28 | span { 29 | display: inline-block; 30 | width: 8px; 31 | height: 8px; 32 | border-radius: 50%; 33 | } 34 | } 35 | 36 | tbody::after { 37 | content: ''; 38 | display: block; 39 | height: $padding-lg; 40 | } 41 | 42 | th, 43 | td { 44 | margin: 0; 45 | color: $text-color; 46 | white-space: nowrap; 47 | 48 | &.node-index, 49 | &.node-type, 50 | &.subplan { 51 | font-size: $font-size-base; 52 | } 53 | 54 | &.node-type { 55 | font-weight: bold; 56 | } 57 | } 58 | 59 | th { 60 | font-size: $font-size-base; 61 | } 62 | 63 | tr.node { 64 | cursor: pointer; 65 | } 66 | 67 | tr.selected { 68 | outline: 1px solid $highlight-color; 69 | box-shadow: 0px 0px 5px 2px rgba($highlight-color, 0.4); 70 | } 71 | 72 | tr.highlight, 73 | tr.selected { 74 | background-color: white; 75 | } 76 | 77 | table.highlight { 78 | tr { 79 | opacity: 50%; 80 | 81 | &.highlight { 82 | opacity: 100%; 83 | } 84 | } 85 | } 86 | 87 | .tree-lines { 88 | font-family: 'monospace'; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/assets/scss/_plan-grid.scss: -------------------------------------------------------------------------------- 1 | @use "variables" as *; 2 | 3 | $grid-progress-padding-x: 0.5rem; 4 | $grid-progress-padding-y: 0.25rem; 5 | $grid-progress-margin: 1px; 6 | 7 | .plan-grid { 8 | // To compensate the transparent background-color for grid-progress-cell 9 | background-color: white; 10 | line-height: 0.85em; 11 | 12 | >table { 13 | // Truly attach borders to th even when collapsed when using sticky 14 | border-collapse: separate; 15 | border-spacing: 0; 16 | 17 | >tbody, 18 | >thead, 19 | & { 20 | >tr { 21 | 22 | >th, 23 | >td { 24 | font-size: $font-size-base; 25 | min-width: 20px; 26 | padding-left: $grid-progress-padding-x; 27 | padding-right: $grid-progress-padding-x; 28 | } 29 | } 30 | } 31 | } 32 | 33 | .detailed { 34 | line-height: initial; 35 | margin: 5px; 36 | width: calc(100% - 10px) !important; 37 | max-width: 700px; 38 | } 39 | 40 | .tree-lines { 41 | font-family: 'monospace'; 42 | } 43 | 44 | tr.node { 45 | cursor: pointer; 46 | } 47 | 48 | .grid-progress-cell { 49 | position: relative; 50 | // Without this bottom border disappareas on Firefox 51 | background-color: transparent; 52 | 53 | .grid-progress { 54 | margin-top: 1px; 55 | } 56 | } 57 | 58 | .grid-progress { 59 | position: absolute; 60 | width: 100%; 61 | height: initial; 62 | $progress-gutter: 2px; 63 | left: calc($grid-progress-padding-x - $progress-gutter); 64 | top: calc($grid-progress-margin * -1 + $grid-progress-padding-y - $progress-gutter); 65 | height: calc(1em + $progress-gutter *2); 66 | width: calc(100% - $grid-progress-padding-x * 2 + $progress-gutter * 2); 67 | z-index: 0; 68 | font-size: inherit; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /example/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function time_ago(time) { 2 | switch (typeof time) { 3 | case "number": 4 | break 5 | case "string": 6 | time = +new Date(time) 7 | break 8 | case "object": 9 | if (time.constructor === Date) time = time.getTime() 10 | break 11 | default: 12 | time = +new Date() 13 | } 14 | const time_formats = [ 15 | [2, "1 second ago", "1 second from now"], 16 | [60, "seconds", 1], // 60 17 | [120, "1 minute ago", "1 minute from now"], // 60*2 18 | [3600, "minutes", 60], // 60*60, 60 19 | [7200, "1 hour ago", "1 hour from now"], // 60*60*2 20 | [86400, "hours", 3600], // 60*60*24, 60*60 21 | [172800, "Yesterday", "Tomorrow"], // 60*60*24*2 22 | [604800, "days", 86400], // 60*60*24*7, 60*60*24 23 | [1209600, "Last week", "Next week"], // 60*60*24*7*4*2 24 | [2419200, "weeks", 604800], // 60*60*24*7*4, 60*60*24*7 25 | [4838400, "Last month", "Next month"], // 60*60*24*7*4*2 26 | [29030400, "months", 2419200], // 60*60*24*7*4*12, 60*60*24*7*4 27 | [58060800, "Last year", "Next year"], // 60*60*24*7*4*12*2 28 | [2903040000, "years", 29030400], // 60*60*24*7*4*12*100, 60*60*24*7*4*12 29 | ] 30 | let seconds = (+new Date() - time) / 1000, 31 | token = "ago", 32 | list_choice = 1 33 | 34 | if (seconds == 0) { 35 | return "Just now" 36 | } 37 | if (seconds < 0) { 38 | seconds = Math.abs(seconds) 39 | token = "from now" 40 | list_choice = 2 41 | } 42 | let i = 0, 43 | format: (string | number)[] 44 | while ((format = time_formats[i++])) 45 | if (seconds < format[0]) { 46 | if (typeof format[2] == "string") return format[list_choice] 47 | else { 48 | return Math.floor(seconds / format[2]) + " " + format[1] + " " + token 49 | } 50 | } 51 | return time 52 | } 53 | -------------------------------------------------------------------------------- /src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "sass:color"; 3 | 4 | $page-width: 1000px; 5 | 6 | $padding-base: 6px; 7 | $padding-sm: 3px; 8 | $padding-lg: 10px; 9 | $padding-xl: 18px; 10 | 11 | $font-size-base: 13px; 12 | $font-size-xs: math.round($font-size-base * 0.7); 13 | $font-size-sm: math.round($font-size-base * 0.9); 14 | $font-size-lg: math.round($font-size-base * 1.3); 15 | $font-size-xl: math.round($font-size-base * 1.7); 16 | 17 | $font-family-sans-serif: 'Noto Sans', sans-serif; 18 | 19 | $gray-lightest: #f7f7f7; 20 | $gray-light: color.adjust($gray-lightest, $lightness: -10%); 21 | $gray: color.adjust(#f7f7f7, $lightness: -30%); 22 | $gray-dark: color.adjust(#f7f7f7, $lightness: -50%); 23 | $gray-darkest: color.adjust($gray-lightest, $lightness: -70%); 24 | $white: white; 25 | 26 | $blue: #00B5E2; 27 | $dark-blue: #008CAF; 28 | $light-blue: #65DDFB; 29 | 30 | $red: #AF2F11; 31 | $dark-red: #7C210C; 32 | $light-red: #FB8165; 33 | 34 | $green: #279404; 35 | $yellow: #F8E400; 36 | 37 | $bg-color: $gray-lightest; 38 | 39 | $text-color: #4d525a; 40 | $text-color-light: color.adjust($text-color, $lightness: 30%); 41 | 42 | $line-color: $gray-light; 43 | $line-color-light: color.adjust($gray-light, $lightness: 10%); 44 | 45 | $link-color: $blue; 46 | 47 | $border-radius-base: 3px; 48 | $border-radius-lg: 6px; 49 | 50 | $main-color: $blue; 51 | $main-color-dark: $blue; 52 | 53 | $plan-node-bg: $white; 54 | $highlight-color: $blue; 55 | $highlight-color-dark: $dark-blue; 56 | 57 | $alert-color: #FB4418; 58 | 59 | 60 | $severity-colors: ( 61 | 1: #ccc, 62 | 2: #FDDB61, 63 | 3: #e80, 64 | 4: #800) !default; 65 | 66 | // The yiq lightness value that determines when the lightness of color changes from "dark" to "light". Acceptable values are between 0 and 255. 67 | $yiq-contrasted-threshold: 150 !default; 68 | 69 | // Customize the light and dark text colors for use in our YIQ color contrast function. 70 | $yiq-text-dark: $gray-darkest !default; 71 | $yiq-text-light: $white !default; 72 | -------------------------------------------------------------------------------- /src/services/color-service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * http://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion 3 | * 4 | * Converts an HSL color value to RGB. Conversion formula 5 | * adapted from http://en.wikipedia.org/wiki/HSL_color_space. 6 | * Assumes h, s, and l are contained in the set [0, 1] and 7 | * returns r, g, and b in the set [0, 255]. 8 | * 9 | * @param Number h The hue 10 | * @param Number s The saturation 11 | * @param Number l The lightness 12 | * @return Array The RGB representation 13 | */ 14 | function hslToRgb(h: number, s: number, l: number) { 15 | let r 16 | let g 17 | let b 18 | 19 | if (s === 0) { 20 | r = g = b = l // achromatic 21 | } else { 22 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s 23 | const p = 2 * l - q 24 | r = hue2rgb(p, q, h + 1 / 3) 25 | g = hue2rgb(p, q, h) 26 | b = hue2rgb(p, q, h - 1 / 3) 27 | } 28 | 29 | return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)] 30 | } 31 | 32 | // convert a number to a color using hsl 33 | export function numberToColorHsl(i: number) { 34 | // as the function expects a value between 0 and 100, and red = 100° and green = 0° 35 | // we convert the input to the appropriate hue value 36 | const hue = ((100 - i) * 1.2) / 360 37 | // we convert hsl to rgb (saturation 100%, lightness 50%) 38 | const rgb = hslToRgb(hue, 0.9, 0.4) 39 | // we format to css value and return 40 | return "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")" 41 | } 42 | 43 | export function durationPercentToClass(i: number): number { 44 | if (i > 90) { 45 | return 4 46 | } else if (i > 40) { 47 | return 3 48 | } else if (i > 10) { 49 | return 2 50 | } 51 | return 1 52 | } 53 | 54 | function hue2rgb(p: number, q: number, t: number) { 55 | if (t < 0) { 56 | t += 1 57 | } 58 | if (t > 1) { 59 | t -= 1 60 | } 61 | if (t < 1 / 6) { 62 | return p + (q - p) * 6 * t 63 | } 64 | if (t < 1 / 2) { 65 | return q 66 | } 67 | if (t < 2 / 3) { 68 | return p + (q - p) * (2 / 3 - t) * 6 69 | } 70 | return p 71 | } 72 | -------------------------------------------------------------------------------- /example/src/idb.ts: -------------------------------------------------------------------------------- 1 | const DB_NAME = "pev2" 2 | const DB_VERSION = 1 3 | let DB 4 | 5 | export default { 6 | async getDb() { 7 | return new Promise((resolve, reject) => { 8 | if (DB) { 9 | return resolve(DB) 10 | } 11 | console.log("OPENING DB", DB) 12 | const request = window.indexedDB.open(DB_NAME, DB_VERSION) 13 | 14 | request.onerror = (e) => { 15 | console.log("Error opening db", e) 16 | reject("Error") 17 | } 18 | 19 | request.onsuccess = () => { 20 | DB = request.result 21 | resolve(DB) 22 | } 23 | 24 | request.onupgradeneeded = () => { 25 | console.log("onupgradeneeded") 26 | const db = request.result 27 | db.createObjectStore("plans", { autoIncrement: true, keyPath: "id" }) 28 | } 29 | }) 30 | }, 31 | async deletePlan(plan) { 32 | const db = await this.getDb() 33 | 34 | return new Promise((resolve) => { 35 | const trans = db.transaction(["plans"], "readwrite") 36 | trans.oncomplete = () => { 37 | resolve() 38 | } 39 | 40 | const store = trans.objectStore("plans") 41 | store.delete(plan.id) 42 | }) 43 | }, 44 | 45 | async getPlans() { 46 | const db = await this.getDb() 47 | 48 | return new Promise((resolve) => { 49 | const trans = db.transaction(["plans"], "readonly") 50 | trans.oncomplete = () => { 51 | resolve(plans) 52 | } 53 | 54 | const store = trans.objectStore("plans") 55 | const plans = [] 56 | 57 | store.openCursor().onsuccess = (e) => { 58 | const cursor = e.target.result 59 | if (cursor) { 60 | plans.push(cursor.value) 61 | cursor.continue() 62 | } 63 | } 64 | }) 65 | }, 66 | 67 | async savePlan(plan) { 68 | const db = await this.getDb() 69 | 70 | return new Promise((resolve) => { 71 | const trans = db.transaction(["plans"], "readwrite") 72 | trans.oncomplete = () => { 73 | resolve() 74 | } 75 | 76 | const store = trans.objectStore("plans") 77 | store.put(plan) 78 | }) 79 | }, 80 | } 81 | -------------------------------------------------------------------------------- /src/dragscroll.ts: -------------------------------------------------------------------------------- 1 | export default class Dragscroll { 2 | private element: Element 3 | private listener: boolean 4 | private start: boolean 5 | private startMousePositionX: number 6 | private startMousePositionY: number 7 | private startScrollPositionX: number 8 | private startScrollPositionY: number 9 | 10 | constructor(element: Element) { 11 | this.element = element 12 | this.listener = true 13 | 14 | this.start = false 15 | this.startMousePositionX = 0 16 | this.startMousePositionY = 0 17 | this.startScrollPositionX = 0 18 | this.startScrollPositionY = 0 19 | 20 | this.element.addEventListener("mousedown", (e: Event) => { 21 | const event = e as MouseEvent 22 | const target = e.target as Element 23 | if (target.closest(".plan-node-body")) { 24 | return 25 | } 26 | event.preventDefault() 27 | this.clearSelection() 28 | 29 | this.startMousePositionX = event.screenX 30 | this.startMousePositionY = event.screenY 31 | this.startScrollPositionX = this.element.scrollLeft 32 | this.startScrollPositionY = this.element.scrollTop 33 | 34 | this.start = true 35 | }) 36 | 37 | document.documentElement.addEventListener("mouseup", (e: Event) => { 38 | e.preventDefault() 39 | 40 | this.startMousePositionX = 0 41 | this.startMousePositionY = 0 42 | this.startScrollPositionX = 0 43 | this.startScrollPositionY = 0 44 | 45 | this.start = false 46 | }) 47 | 48 | this.element.addEventListener("mousemove", (e: Event) => { 49 | const event = e as MouseEvent 50 | if (this.listener && this.start) { 51 | event.preventDefault() 52 | 53 | this.element.scrollTo( 54 | this.startScrollPositionX + 55 | (this.startMousePositionX - event.screenX), 56 | this.startScrollPositionY + (this.startMousePositionY - event.screenY) 57 | ) 58 | } 59 | }) 60 | } 61 | 62 | private clearSelection() { 63 | if (window.getSelection) { 64 | const sel = window.getSelection() 65 | if (sel) { 66 | sel.removeAllRanges() 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /example/src/views/AboutView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 64 | -------------------------------------------------------------------------------- /example/src/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | :root { 3 | --vt-c-white: #ffffff; 4 | --vt-c-white-soft: #f8f8f8; 5 | --vt-c-white-mute: #f2f2f2; 6 | 7 | --vt-c-black: #181818; 8 | --vt-c-black-soft: #222222; 9 | --vt-c-black-mute: #282828; 10 | 11 | --vt-c-indigo: #2c3e50; 12 | 13 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 14 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 15 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 16 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 17 | 18 | --vt-c-text-light-1: var(--vt-c-indigo); 19 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 20 | --vt-c-text-dark-1: var(--vt-c-white); 21 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 22 | } 23 | 24 | /* semantic color variables for this project */ 25 | :root { 26 | --color-background: var(--vt-c-white); 27 | --color-background-soft: var(--vt-c-white-soft); 28 | --color-background-mute: var(--vt-c-white-mute); 29 | 30 | --color-border: var(--vt-c-divider-light-2); 31 | --color-border-hover: var(--vt-c-divider-light-1); 32 | 33 | --color-heading: var(--vt-c-text-light-1); 34 | --color-text: var(--vt-c-text-light-1); 35 | 36 | --section-gap: 160px; 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --color-background: var(--vt-c-black); 42 | --color-background-soft: var(--vt-c-black-soft); 43 | --color-background-mute: var(--vt-c-black-mute); 44 | 45 | --color-border: var(--vt-c-divider-dark-2); 46 | --color-border-hover: var(--vt-c-divider-dark-1); 47 | 48 | --color-heading: var(--vt-c-text-dark-1); 49 | --color-text: var(--vt-c-text-dark-2); 50 | } 51 | } 52 | 53 | *, 54 | *::before, 55 | *::after { 56 | box-sizing: border-box; 57 | margin: 0; 58 | position: relative; 59 | font-weight: normal; 60 | } 61 | 62 | body { 63 | min-height: 100vh; 64 | color: var(--color-text); 65 | background: var(--color-background); 66 | transition: color 0.5s, background-color 0.5s; 67 | line-height: 1.6; 68 | font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 69 | Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 70 | font-size: 15px; 71 | text-rendering: optimizeLegibility; 72 | -webkit-font-smoothing: antialiased; 73 | -moz-osx-font-smoothing: grayscale; 74 | } 75 | -------------------------------------------------------------------------------- /src/components/StatsTableItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 84 | -------------------------------------------------------------------------------- /src/assets/scss/_plan.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | @use "variables" as *; 3 | 4 | $connector-height: 12px; 5 | $connector-line: 2px solid color.adjust($line-color, $lightness: -10%); 6 | 7 | .bg-secondary-light { 8 | background-color: $gray-light !important; 9 | } 10 | 11 | .plan-container { 12 | font-family: $font-family-sans-serif; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | 16 | .menu { 17 | position: absolute; 18 | z-index: 1; 19 | right: 0; 20 | 21 | &-hidden { 22 | 23 | ul, 24 | h3 { 25 | display: none; 26 | } 27 | } 28 | } 29 | 30 | .grab-bing { 31 | cursor: -webkit-grab; 32 | cursor: -moz-grab; 33 | cursor: -o-grab; 34 | cursor: grab; 35 | } 36 | 37 | .grab-bing:active { 38 | cursor: -webkit-grabbing; 39 | cursor: -moz-grabbing; 40 | cursor: -o-grabbing; 41 | cursor: grabbing; 42 | } 43 | 44 | .text-secondary { 45 | color: $gray !important; 46 | } 47 | 48 | .cursor-help { 49 | cursor: help; 50 | } 51 | 52 | pre { 53 | overflow: initial; 54 | } 55 | } 56 | 57 | .plan-stats { 58 | font-size: $font-size-base; 59 | 60 | .stat-dropdown-container { 61 | border: 1px solid $line-color; 62 | padding: $padding-lg; 63 | background-color: #fff; 64 | position: absolute; 65 | box-shadow: 0px 0px 10px 2px rgba(0, 0, 0, 0.3); 66 | border-radius: $border-radius-base; 67 | margin-bottom: $padding-xl; 68 | z-index: 6; 69 | right: 0; 70 | width: 300px; 71 | max-height: 70vh; 72 | overflow: auto; 73 | 74 | h3 { 75 | font-size: $font-size-lg; 76 | width: 93%; 77 | text-align: left; 78 | border-bottom: 1px solid $line-color; 79 | padding-bottom: $padding-base; 80 | margin-bottom: $padding-lg; 81 | } 82 | } 83 | } 84 | 85 | .tippy-popper .tippy-tooltip { 86 | text-align: left; 87 | } 88 | 89 | .tippy-popper .text-secondary { 90 | color: inherit !important; 91 | } 92 | 93 | .splitpanes { 94 | &__pane { 95 | transition: none !important; 96 | } 97 | } 98 | 99 | .no-focus-outline:focus { 100 | outline: none; 101 | } 102 | 103 | .table-nonfluid { 104 | width: auto !important; 105 | } 106 | 107 | .more-info[title] { 108 | text-decoration-line: underline; 109 | text-decoration-style: dotted; 110 | text-decoration-color: $gray; 111 | cursor: help; 112 | } 113 | 114 | foreignObject { 115 | overflow: visible; 116 | } 117 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duckdb-explain-visualizer", 3 | "version": "1.1.4", 4 | "homepage": "https://github.com/DBatUTuebingen/duckdb-explain-visualizer", 5 | "license": "PostgreSQL license", 6 | "files": [ 7 | "dist" 8 | ], 9 | "main": "./dist/duckdb-explain-visualizer.umd.js", 10 | "module": "./dist/duckdb-explain-visualizer.mjs", 11 | "exports": { 12 | ".": { 13 | "import": "./dist/duckdb-explain-visualizer.mjs", 14 | "require": "./dist/duckdb-explain-visualizer.umd.js" 15 | }, 16 | "./dist/duckdb-explain-visualizer.css": "./dist/duckdb-explain-visualizer.css" 17 | }, 18 | "scripts": { 19 | "dev": "vite", 20 | "build": "vue-tsc --noEmit && vite build", 21 | "build-lib": "vue-tsc --noEmit && LIB=true vite build", 22 | "build-lib-win": "set LIB=true && npm run build", 23 | "preview": "vite preview --port 5050", 24 | "typecheck": "vue-tsc --noEmit", 25 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix .gitignore", 26 | "prepare": "husky install", 27 | "test": "jest || exit 0" 28 | }, 29 | "dependencies": { 30 | "@fortawesome/fontawesome-svg-core": "^7.0.0", 31 | "@fortawesome/free-solid-svg-icons": "^7.0.0", 32 | "@fortawesome/vue-fontawesome": "^3.0.6", 33 | "bootstrap": "^5.3.2", 34 | "clarinet": "^0.12.5", 35 | "d3": "^7.8.2", 36 | "d3-flextree": "^2.1.2", 37 | "emitter": "^0.0.5", 38 | "highlight.js": "^11.7.0", 39 | "humanize-duration": "^3.28.0", 40 | "lodash": "^4.17.21", 41 | "sass": "^1.58.0", 42 | "splitpanes": "^4.0.0", 43 | "vue": "^3.2.45", 44 | "vue-clipboard3": "^2.0.0", 45 | "vue-tippy": "^6.0.0" 46 | }, 47 | "devDependencies": { 48 | "@rushstack/eslint-patch": "^1.1.0", 49 | "@types/d3": "^7.4.3", 50 | "@types/humanize-duration": "^3.27.1", 51 | "@types/jest": "^30.0.0", 52 | "@types/lodash": "^4.14.202", 53 | "@types/node": "^24.2.1", 54 | "@types/splitpanes": "^2.2.6", 55 | "@vitejs/plugin-vue": "^6.0.1", 56 | "@vue/eslint-config-prettier": "^10.2.0", 57 | "@vue/eslint-config-typescript": "^14.6.0", 58 | "@vue/tsconfig": "^0.7.0", 59 | "eslint": "^9.33.0", 60 | "eslint-plugin-vue": "^10.4.0", 61 | "husky": "^9.1.0", 62 | "jest": "^30.0.5", 63 | "lint-staged": "^16.1.5", 64 | "prettier": "^3.6.0", 65 | "stream": "^0.0.3", 66 | "ts-jest": "^29.4.1", 67 | "ts-node": "^10.7.0", 68 | "typescript": "^5.9.2", 69 | "vite": "^7.1.1", 70 | "vite-plugin-singlefile": "^2.3.0", 71 | "vue-tsc": "^3.0.0" 72 | }, 73 | "lint-staged": { 74 | "*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}": "eslint --cache --fix", 75 | "*.{js,css,md}": "prettier --write" 76 | }, 77 | "type": "module" 78 | } 79 | -------------------------------------------------------------------------------- /src/assets/scss/_base.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "sass:color"; 3 | @use "sass:map"; 4 | @use "variables" as *; 5 | 6 | // Color contrast 7 | @function color-yiq($color, $dark: $yiq-text-dark, $light: $yiq-text-light) { 8 | $r: color.channel($color, "red", $space: rgb); 9 | $g: color.channel($color, "green", $space: rgb); 10 | $b: color.channel($color, "blue", $space: rgb); 11 | 12 | $yiq: math.div(($r * 299) + ($g * 587) + ($b * 114), 1000); 13 | 14 | @if ($yiq >=$yiq-contrasted-threshold) { 15 | @return $dark; 16 | } 17 | 18 | @else { 19 | @return $light; 20 | } 21 | } 22 | 23 | /** 24 | * Severity classes 25 | */ 26 | @each $color, $value in $severity-colors { 27 | .alert.c-#{$color} { 28 | background-color: $value; 29 | color: color-yiq($value); 30 | font-weight: bold; 31 | 32 | .text-secondary { 33 | color: color-yiq($value) !important; 34 | font-weight: normal; 35 | } 36 | } 37 | 38 | .progress-bar.c-#{$color} { 39 | background-color: $value; 40 | border-color: $value !important; 41 | } 42 | 43 | .text-c-#{$color} { 44 | color: $value; 45 | } 46 | 47 | svg circle.c-#{$color} { 48 | fill: $value; 49 | } 50 | } 51 | 52 | $buffers-colors: ( 53 | hit: map.get($severity-colors, 1), 54 | read: map.get($severity-colors, 2), 55 | dirtied: map.get($severity-colors, 3), 56 | written: map.get($severity-colors, 4), 57 | ) !default; 58 | 59 | 60 | /** 61 | * Shared buffers classes 62 | */ 63 | @each $color, $value in $buffers-colors { 64 | .bg-#{$color} { 65 | background-color: $value; 66 | } 67 | 68 | .border-#{$color} { 69 | border-color: $value !important; 70 | } 71 | } 72 | 73 | 74 | .btn-group-xs>.btn, 75 | .btn-xs { 76 | padding: .25rem .4rem; 77 | font-size: $font-size-sm; 78 | border-radius: .2rem; 79 | 80 | &, 81 | .fa { 82 | line-height: .5; 83 | } 84 | } 85 | 86 | /* 87 | * Used with @extend in .plan-node.never-executed 88 | */ 89 | %bg-hatched { 90 | $color: $gray-lightest; 91 | $angle: 45deg; 92 | $progress-height: 1rem !default; 93 | background-image: linear-gradient($angle, $color 25%, transparent 25%, transparent 50%, $color 50%, $color 75%, transparent 75%, transparent 94 | ); 95 | background-size: $progress-height $progress-height; 96 | } 97 | 98 | .bg-hatched { 99 | @extend %bg-hatched; 100 | } 101 | 102 | .border-dashed { 103 | border-style: dashed !important; 104 | background-color: transparent !important; 105 | } 106 | 107 | .line-clamp-2 { 108 | display: -webkit-box; 109 | -webkit-line-clamp: 2; 110 | line-clamp: 2; 111 | -webkit-box-orient: vertical; 112 | text-overflow: ellipsis; 113 | overflow: hidden; 114 | } 115 | 116 | .opacity-20 { 117 | opacity: 0.2 !important; 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | duckdb-explain-visualizer: A VueJS component to show a graphical vizualization of a DuckDB execution 2 | plan. 3 | 4 | ![DuckDB Explain Visualizer screenshot](duckdb-explain-visualizer-screenshot.png) 5 | 6 | # Usage 7 | 8 | To use the explain vizualizer you can choose one of the following options: 9 | 10 | ## Service (recommended) 11 | 12 | [db.cs.uni-tuebingen.de/explain](https://db.cs.uni-tuebingen.de/explain) 13 | 14 | This service is provided by the Database Systems Research Group @ University of Tübingen and can help you to share your plans with colleagues or customers. 15 | 16 | ## All-in-one local (no installation, no network) 17 | 18 | The DuckDB Explain Visualizer can be run locally without any external internet resource. 19 | 20 | Simply download 21 | [index.html](https://www.github.com/DBatUTuebingen/duckdb-explain-visualizer/releases/latest/download/index.html), 22 | open it in your favorite internet browser. 23 | 24 | ## Integrated in a web application 25 | 26 | ### Without building tools 27 | 28 | ```html 29 | 30 | 31 | 35 | 39 | 40 |
41 | 42 |
43 | 44 | 60 | ``` 61 | 62 | [See it live](https://stackblitz.com/edit/pev2-vanilla). 63 | 64 | ### With build tools 65 | 66 | The DuckDB Explain Visualizer can be integrated as a component in a web application. 67 | 68 | Install it: 69 | 70 | ``` 71 | npm install duckdb-explain-visualizer 72 | ``` 73 | 74 | Declare the `duckdb-explain-visualizer` component and use it: 75 | 76 | ```javascript 77 | import { Plan } from "duckdb-explain-visualizer" 78 | import "duckdb-explain-visualizer/dist/style.css" 79 | 80 | export default { 81 | name: "DuckDB Explain Visualizer example", 82 | components: { 83 | "duckdb-explain-visualizer": Plan, 84 | }, 85 | data() { 86 | return { 87 | plan: plan, 88 | query: query, 89 | } 90 | }, 91 | } 92 | ``` 93 | 94 | Then add the `duckdb-explain-visualizer` component to your template: 95 | 96 | ```html 97 |
98 | 102 |
103 | ``` 104 | 105 | The DuckDB Explain Visualizer requires `Bootstrap (CSS)` to work so don't forget to 106 | add the following in you header (or load them with your favorite bundler). 107 | 108 | ```html 109 | 113 | ``` 114 | 115 | [See it live](https://stackblitz.com/edit/pev2-vite). 116 | 117 | # Disclaimer 118 | 119 | This project is a hard fork of the excellent [Postgres Explain Visualizer 2 (PEV2)][pev2]. Kudos go to [Dalibo][dalibo]. We have adapted the project to work with DuckDB. The initial heavy lifting was done by Matthis Noël (https://github.com/Matthis02). 120 | 121 | [pev2]: https://github.com/dalibo/pev2/ 122 | [dalibo]: https://www.dalibo.com/ 123 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum Metric { 2 | time, 3 | rows, 4 | result, 5 | } 6 | 7 | export enum BufferLocation { 8 | shared = "Shared", 9 | temp = "Temp", 10 | local = "Local", 11 | } 12 | 13 | export enum BufferType { 14 | hit = "Hit", 15 | read = "Read", 16 | written = "Written", 17 | dirtied = "Dirtied", 18 | } 19 | 20 | export class HighlightType { 21 | public static NONE = "none" 22 | public static DURATION = "duration" 23 | public static ROWS = "rows" 24 | public static RESULT = "result" 25 | } 26 | 27 | export enum SortDirection { 28 | asc = "asc", 29 | desc = "desc", 30 | } 31 | 32 | export enum EstimateDirection { 33 | over = 1, 34 | under = 2, 35 | none = 3, 36 | } 37 | 38 | export enum CenterMode { 39 | center, 40 | visible, 41 | none, 42 | } 43 | 44 | export enum NodeProp { 45 | // plan property keys 46 | QUERY = "query_name", 47 | NODE_TYPE = "operator_type", // the name of each operator 48 | NODE_TYPE_EXPLAIN = "name", // same as above (without ANALYZE) 49 | ACTUAL_ROWS = "operator_cardinality", // the number of rows it returns to its parent 50 | ACTUAL_TIME = "operator_timing", // the time taken by each operator 51 | BLOCKED_THREAD_TIME = "blocked_thread_time", // the total time threads are blocked 52 | PLANS = "children", 53 | CPU_TIME = "cpu_time", 54 | CUMULATIVE_CARDINALITY = "cumulative_cardinality", 55 | CUMULATIVE_ROWS_SCANNED = "cumulative_rows_scanned", 56 | OPERATOR_ROWS_SCANNED = "operator_rows_scanned", // The total rows scanned by each operator. 57 | RESULT_SET_SIZE = "result_set_size", // The size of the result. 58 | LATENCY = "latency", 59 | ROWS_RETURNED = "rows_returned", 60 | 61 | // EXTRA INFO KEYS 62 | EXTRA_INFO = "extra_info", // Unique operator metrics 63 | RELATION_NAME = "Table", 64 | PROJECTIONS = "Projections", 65 | ESTIMATED_ROWS = "Estimated Cardinality", 66 | AGGREGATES = "Aggregates", 67 | CTE_NAME = "CTE Name", 68 | TABLE_INDEX = "Table Index", 69 | GROUPS = "Groups", 70 | JOIN_TYPE = "Join Type", 71 | CONDITIONS = "Conditions", 72 | CTE_INDEX = "CTE Index", 73 | FILTER = "Expression", 74 | DELIM_INDEX = "Delim Index", 75 | FUNCTION = "Function", 76 | FUNCTION_NAME = "Name", 77 | 78 | // computed by dev 79 | NODE_ID = "nodeId", 80 | DEV_PLAN_TAG = "plan_", 81 | } 82 | 83 | export enum PropType { 84 | blocks, 85 | boolean, 86 | bytes, 87 | cost, 88 | duration, 89 | estimateDirection, 90 | factor, 91 | increment, 92 | json, 93 | kilobytes, 94 | list, 95 | loops, 96 | rows, 97 | sortGroups, 98 | transferRate, 99 | } 100 | 101 | export const nodePropTypes: { [key: string]: PropType } = {} 102 | 103 | nodePropTypes[NodeProp.ACTUAL_ROWS] = PropType.rows 104 | nodePropTypes[NodeProp.CUMULATIVE_CARDINALITY] = PropType.rows 105 | nodePropTypes[NodeProp.CUMULATIVE_ROWS_SCANNED] = PropType.rows 106 | nodePropTypes[NodeProp.OPERATOR_ROWS_SCANNED] = PropType.rows 107 | nodePropTypes[NodeProp.CPU_TIME] = PropType.duration 108 | nodePropTypes[NodeProp.BLOCKED_THREAD_TIME] = PropType.duration 109 | nodePropTypes[NodeProp.RESULT_SET_SIZE] = PropType.bytes 110 | nodePropTypes[NodeProp.ACTUAL_TIME] = PropType.duration 111 | nodePropTypes[NodeProp.LATENCY] = PropType.duration 112 | nodePropTypes[NodeProp.ROWS_RETURNED] = PropType.rows 113 | 114 | export class WorkerProp { 115 | // plan property keys 116 | public static WORKER_NUMBER = "Worker Number" 117 | } 118 | 119 | nodePropTypes[WorkerProp.WORKER_NUMBER] = PropType.increment 120 | 121 | export enum SortGroupsProp { 122 | GROUP_COUNT = "Group Count", 123 | SORT_METHODS_USED = "Sort Methods Used", 124 | SORT_SPACE_MEMORY = "Sort Space Memory", 125 | } 126 | 127 | export enum SortSpaceMemoryProp { 128 | AVERAGE_SORT_SPACE_USED = "Average Sort Space Used", 129 | PEAK_SORT_SPACE_USED = "Peak Sort Space Used", 130 | } 131 | -------------------------------------------------------------------------------- /src/components/PlanStats.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 124 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | HighlightType, 3 | SortGroupsProp, 4 | SortSpaceMemoryProp, 5 | } from "@/enums" 6 | 7 | export interface IPlan { 8 | id: string 9 | name: string 10 | content: IPlanContent 11 | query: string 12 | createdOn: Date 13 | planStats: IPlanStats 14 | formattedQuery?: string 15 | } 16 | 17 | export interface IPlanContent { 18 | "Query Text"?: string 19 | _: Node 20 | [NodeProp.PLANS]: Node[] 21 | maxRows: number 22 | maxRowsScanned: number 23 | maxEstimatedRows: number 24 | maxResult: number 25 | maxDuration: number 26 | [k: string]: Node | Node[] | number | string | undefined 27 | } 28 | 29 | export interface IPlanStats { 30 | blockedThreadTime?: number 31 | executionTime?: number 32 | latency?: number 33 | rowsReturned?: number 34 | resultSize?: number 35 | maxRows: number 36 | maxRowsScanned: number 37 | maxEstimatedRows: number 38 | maxResult: number 39 | maxDuration: number 40 | } 41 | 42 | import { NodeProp } from "@/enums" 43 | 44 | // Optional properties for advanced DuckDB Explain plans 45 | export interface ExtraInfo { 46 | [NodeProp.RELATION_NAME]?: string 47 | [NodeProp.PROJECTIONS]?: string | string[] 48 | [NodeProp.ESTIMATED_ROWS]?: string 49 | [NodeProp.AGGREGATES]?: string | string[] 50 | [NodeProp.CTE_NAME]?: string 51 | [NodeProp.TABLE_INDEX]?: string 52 | [NodeProp.GROUPS]?: string | string[] 53 | [NodeProp.JOIN_TYPE]?: string 54 | [NodeProp.CONDITIONS]?: string | string[] 55 | [NodeProp.CTE_INDEX]?: string 56 | [NodeProp.FILTER]?: string 57 | [NodeProp.DELIM_INDEX]?: string 58 | [NodeProp.FUNCTION]?: string 59 | [NodeProp.FUNCTION_NAME]?: string 60 | [k: string]: any 61 | } 62 | 63 | // Class to create nodes when parsing text for DuckDB Explain Plans 64 | export class Node { 65 | nodeId!: number; 66 | size!: [number, number]; 67 | // DuckDB specific properties 68 | [NodeProp.NODE_TYPE]?: string; // Type of operation in DuckDB (e.g., "Filter", "Scan") 69 | [NodeProp.NODE_TYPE_EXPLAIN]?: string; // same (without ANALYZE) 70 | [NodeProp.ACTUAL_TIME]?: number; // Actual timing for the node if available 71 | [NodeProp.ACTUAL_ROWS]?: number; // Estimated number of rows 72 | [NodeProp.PLANS]!: Node[]; 73 | [NodeProp.CPU_TIME]?: number; 74 | [NodeProp.CUMULATIVE_CARDINALITY]?: number; 75 | [NodeProp.CUMULATIVE_ROWS_SCANNED]?: number; 76 | [NodeProp.OPERATOR_ROWS_SCANNED]?: number; 77 | [NodeProp.RESULT_SET_SIZE]?: number; 78 | [NodeProp.EXTRA_INFO]!: ExtraInfo; 79 | [k: string]: 80 | | Node 81 | | Node[] 82 | | Timing 83 | | boolean 84 | | number 85 | | string 86 | | string[] 87 | | JSON 88 | | object 89 | | object[] 90 | | undefined 91 | | [number, number] 92 | 93 | constructor(type?: string) { 94 | this.nodeId = 0; 95 | this.size = [0, 0]; 96 | this[NodeProp.PLANS] = []; 97 | this[NodeProp.CPU_TIME] = 0; 98 | this[NodeProp.CUMULATIVE_CARDINALITY] = 0; 99 | this[NodeProp.CUMULATIVE_ROWS_SCANNED] = 0; 100 | this[NodeProp.OPERATOR_ROWS_SCANNED] = 0; 101 | this[NodeProp.RESULT_SET_SIZE] = 0; 102 | this[NodeProp.EXTRA_INFO] = {}; 103 | 104 | if (type) { 105 | this[NodeProp.NODE_TYPE] = type; 106 | } 107 | } 108 | } 109 | 110 | import { WorkerProp } from "@/enums" 111 | // Class to create workers when parsing text 112 | export class Worker { 113 | [k: string]: string | number | object 114 | constructor(workerNumber: number) { 115 | this[WorkerProp.WORKER_NUMBER] = workerNumber 116 | } 117 | } 118 | 119 | export type Options = { 120 | [k: string]: string 121 | } 122 | 123 | export type Timing = { 124 | [k: string]: number 125 | } 126 | 127 | export type Settings = { 128 | [k: string]: string 129 | } 130 | 131 | export type SortGroups = { 132 | [SortGroupsProp.SORT_METHODS_USED]: string[] 133 | [SortGroupsProp.SORT_SPACE_MEMORY]: SortSpaceMemory 134 | [key: string]: number | string | string[] | SortSpaceMemory 135 | } 136 | 137 | export type SortSpaceMemory = { 138 | [key in SortSpaceMemoryProp]: number 139 | } 140 | 141 | export type StatsTableItemType = { 142 | name: string 143 | count: number 144 | time: number 145 | timePercent: number 146 | nodes: Node[] 147 | } 148 | 149 | export type ViewOptions = { 150 | showHighlightBar: boolean 151 | showPlanStats: boolean 152 | highlightType: HighlightType 153 | diagramWidth: number 154 | } 155 | 156 | export interface JIT { 157 | ["Timing"]: Timing 158 | [key: string]: number | Timing 159 | } 160 | 161 | // A plan node with id, node, isLastSibling, branches 162 | export type Row = [number, Node, boolean, number[]] 163 | -------------------------------------------------------------------------------- /src/components/Grid.vue: -------------------------------------------------------------------------------- 1 | 120 | 121 | 170 | -------------------------------------------------------------------------------- /src/assets/scss/_plan-node.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @use "sass:color"; 3 | @use "variables" as *; 4 | @use "base" as *; 5 | 6 | $compact-width: 50px; 7 | $bg-color: #fff; 8 | 9 | .plan-node { 10 | cursor: default; 11 | text-decoration: none; 12 | color: $text-color; 13 | display: inline-block; 14 | position: relative; 15 | font-size: $font-size-sm; 16 | margin-bottom: 4px; 17 | overflow-wrap: break-word; 18 | word-wrap: break-word; 19 | width: 240px; 20 | 21 | .plan-node-body { 22 | position: relative; 23 | border: 1px solid $line-color; 24 | border-radius: $border-radius-base; 25 | background-color: $plan-node-bg; 26 | box-shadow: 1px 1px 3px 0px rgba(0, 0, 0, 0.1); 27 | 28 | &.card { 29 | 30 | .card-body, 31 | .card-header { 32 | padding: $padding-base $padding-lg; 33 | } 34 | 35 | .card-header .card-header-tabs { 36 | margin-right: 0; 37 | margin-left: 0; 38 | margin-bottom: -$padding-base; 39 | margin-top: $padding-base; 40 | 41 | .nav-link { 42 | padding: math.div($padding-base, 2) $padding-lg; 43 | } 44 | } 45 | } 46 | } 47 | 48 | &.parallel .plan-node-body { 49 | box-shadow: none; 50 | } 51 | 52 | header { 53 | margin-bottom: $padding-base; 54 | overflow: hidden; 55 | 56 | h4 { 57 | font-size: $font-size-base; 58 | font-weight: 600; 59 | margin: 0; 60 | line-height: inherit; 61 | color: black !important; 62 | } 63 | 64 | .node-duration { 65 | float: right; 66 | margin-left: $padding-lg; 67 | font-size: $font-size-base; 68 | } 69 | } 70 | 71 | .prop-list { 72 | color: inherit; 73 | // required for overflow-wrap to be taken into account 74 | table-layout: fixed; 75 | } 76 | 77 | //hovers 78 | &:hover, 79 | &.highlight { 80 | $hover-color: rgba(0, 0, 0, 0.4); 81 | 82 | .plan-node-body { 83 | box-shadow: 1px 1px 5px 0px $hover-color; 84 | } 85 | 86 | .workers>div { 87 | border-color: rgba($hover-color, 0.2); 88 | } 89 | } 90 | 91 | 92 | &.selected { 93 | .plan-node-body { 94 | border-color: $highlight-color; 95 | box-shadow: 0px 0px 5px 2px rgba($highlight-color, 0.4); 96 | } 97 | 98 | .workers>div { 99 | border-color: rgba($highlight-color, 0.2); 100 | } 101 | } 102 | 103 | .node-description { 104 | text-align: left; 105 | font-style: italic; 106 | word-break: normal; 107 | 108 | .node-type { 109 | font-weight: 600; 110 | background-color: $blue; 111 | color: #fff; 112 | padding: 0 $padding-base; 113 | } 114 | } 115 | 116 | .btn-default { 117 | border: 0; 118 | } 119 | 120 | .text-secondary { 121 | color: $text-color-light; 122 | } 123 | 124 | .plan-query-container { 125 | border: 1px solid $line-color; 126 | padding: $padding-xl; 127 | background-color: #fff; 128 | position: absolute; 129 | box-shadow: 0px 0px 10px 2px rgba(0, 0, 0, 0.3); 130 | border-radius: $border-radius-base; 131 | margin-bottom: $padding-xl; 132 | z-index: 1; 133 | left: 0; 134 | 135 | h3 { 136 | font-size: $font-size-lg; 137 | width: 93%; 138 | text-align: left; 139 | border-bottom: 1px solid $line-color; 140 | padding-bottom: $padding-base; 141 | margin-bottom: $padding-lg; 142 | } 143 | } 144 | 145 | &.never-executed .plan-node-body { 146 | @extend %bg-hatched !optional; 147 | } 148 | 149 | .workers { 150 | position: absolute; 151 | left: -1px; 152 | top: 1px; 153 | width: 100%; 154 | height: 100%; 155 | cursor: pointer; 156 | 157 | >div { 158 | border: 1px solid $gray-light; 159 | border-radius: $border-radius-base; 160 | background-color: $white; 161 | width: 100%; 162 | height: 100%; 163 | position: absolute; 164 | } 165 | } 166 | 167 | .workers-handle { 168 | cursor: pointer; 169 | position: absolute; 170 | top: 0; 171 | right: -5px; 172 | 173 | >div { 174 | position: absolute; 175 | top: 0; 176 | background-color: white; 177 | border: 1px solid color.adjust($gray-lightest, $lightness: -3%); 178 | border-radius: $border-radius-base; 179 | 180 | &:hover { 181 | background-color: $gray-lightest; 182 | border-color: $gray-light; 183 | } 184 | } 185 | } 186 | 187 | .subplan-name { 188 | background-color: #B3D7D7; 189 | border-radius: $border-radius-base; 190 | } 191 | 192 | button { 193 | .fa-chevron-right { 194 | display: inline-block; 195 | } 196 | 197 | .fa-chevron-down { 198 | display: none; 199 | } 200 | } 201 | 202 | button[aria-expanded='true'] { 203 | .fa-chevron-right { 204 | display: none !important; 205 | } 206 | 207 | .fa-chevron-down { 208 | display: inline-block !important; 209 | } 210 | } 211 | 212 | &.plan-node-detail .text-truncate { 213 | overflow: initial; 214 | white-space: initial; 215 | text-overflow: initial; 216 | } 217 | } 218 | 219 | .node-bar-container { 220 | height: 5px; 221 | margin-top: $padding-lg; 222 | margin-bottom: $padding-sm; 223 | border-radius: $border-radius-lg; 224 | background-color: $gray-light; 225 | position: relative; 226 | 227 | .node-bar { 228 | border-radius: $border-radius-lg; 229 | height: 100%; 230 | text-align: left; 231 | position: absolute; 232 | left: 0; 233 | top: 0; 234 | } 235 | } 236 | 237 | .node-bar-label { 238 | text-align: left; 239 | display: block; 240 | } 241 | 242 | .detailed { 243 | width: 400px !important; 244 | } 245 | -------------------------------------------------------------------------------- /src/components/Diagram.vue: -------------------------------------------------------------------------------- 1 | 127 | 128 | 186 | -------------------------------------------------------------------------------- /src/components/PlanNodeDetail.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 175 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | // Composable for PlanNode and PlanNodeDetail components 2 | import _ from "lodash" 3 | import { computed, onBeforeMount, ref, watch } from "vue" 4 | import type { Ref } from "vue" 5 | import type { IPlan, Node, ViewOptions } from "@/interfaces" 6 | import { NodeProp, HighlightType } from "@/enums" 7 | import { result, duration, rows } from "@/filters" 8 | import { numberToColorHsl } from "@/services/color-service" 9 | 10 | export default function useNode( 11 | plan: Ref, 12 | node: Node, 13 | viewOptions: ViewOptions 14 | ) { 15 | const executionTimePercent = ref(NaN) 16 | const resultPercent = ref(NaN) 17 | const rowsPercent = ref(NaN) 18 | const estimationPercent = ref(NaN) 19 | const barWidth = ref(0) 20 | const highlightValue = ref(null) 21 | 22 | onBeforeMount(() => { 23 | calculateBar() 24 | calculateDuration() 25 | calculateResult() 26 | calculateRows() 27 | }) 28 | 29 | watch(() => viewOptions.highlightType, calculateBar) 30 | 31 | function calculateBar(): void { 32 | let value: number | undefined 33 | switch (viewOptions.highlightType) { 34 | case HighlightType.DURATION: 35 | value = node[NodeProp.ACTUAL_TIME] 36 | if (value === undefined) { 37 | highlightValue.value = null 38 | break 39 | } 40 | barWidth.value = Math.round( 41 | (value / (plan.value.planStats.maxDuration as number)) * 100 42 | ) 43 | highlightValue.value = duration(value) 44 | break 45 | case HighlightType.ROWS: 46 | value = node[NodeProp.ACTUAL_ROWS] 47 | if (value === undefined) { 48 | highlightValue.value = null 49 | break 50 | } 51 | barWidth.value = 52 | Math.round( 53 | (value / (plan.value.planStats.maxRows as number)) * 100 54 | ) || 0 55 | highlightValue.value = rows(value) 56 | break 57 | case HighlightType.RESULT: 58 | value = node[NodeProp.RESULT_SET_SIZE] 59 | if (value === undefined) { 60 | highlightValue.value = null 61 | break 62 | } 63 | barWidth.value = Math.round( 64 | (value / (plan.value.planStats.maxResult as number)) * 100 65 | ) 66 | highlightValue.value = result(value) 67 | break 68 | } 69 | } 70 | 71 | const barColor = computed((): string => { 72 | return numberToColorHsl(barWidth.value) 73 | }) 74 | 75 | const nodeName = computed((): string => { 76 | let nodeName = "" 77 | nodeName += node[NodeProp.NODE_TYPE] ?? node[NodeProp.NODE_TYPE_EXPLAIN] 78 | return nodeName 79 | }) 80 | 81 | function calculateDuration() { 82 | // use the first node total time if plan execution time is not available 83 | const executionTime = 84 | (plan.value.planStats.executionTime as number) || 85 | (plan.value.content?.[NodeProp.CPU_TIME] as number) 86 | const duration = node[NodeProp.ACTUAL_TIME] as number 87 | executionTimePercent.value = _.round((duration / executionTime) * 100) 88 | } 89 | 90 | function calculateResult() { 91 | const maxResult = plan.value.content.maxResult as number 92 | const result = node[NodeProp.RESULT_SET_SIZE] as number 93 | resultPercent.value = _.round((result / maxResult) * 100) 94 | } 95 | 96 | function calculateRows() { 97 | const maxRows = plan.value.content.maxRows as number 98 | const rows = node[NodeProp.ACTUAL_ROWS] as number 99 | rowsPercent.value = _.round((rows / maxRows) * 100) 100 | } 101 | 102 | const durationClass = computed(() => { 103 | let c 104 | const i = executionTimePercent.value 105 | if (i > 90) { 106 | c = 4 107 | } else if (i > 50) { 108 | c = 3 109 | } 110 | if (c) { 111 | return "c-" + c 112 | } 113 | return false 114 | }) 115 | 116 | const rowsClass = computed(() => { 117 | let c 118 | const i = rowsPercent.value 119 | if (i > 90) { 120 | c = 4 121 | } else if (i > 50) { 122 | c = 3 123 | } 124 | if (c) { 125 | return "c-" + c 126 | } 127 | return false 128 | }) 129 | 130 | const resultClass = computed(() => { 131 | let c 132 | const i = resultPercent.value 133 | if (i > 90) { 134 | c = 4 135 | } else if (i > 50) { 136 | c = 3 137 | } 138 | if (c) { 139 | return "c-" + c 140 | } 141 | return false 142 | }) 143 | 144 | const estimationClass = computed(() => { 145 | let c 146 | const i = estimationPercent.value 147 | if (i > 90) { 148 | c = 4 149 | } else if (i > 50) { 150 | c = 3 151 | } 152 | if (c) { 153 | return "c-" + c 154 | } 155 | return false 156 | }) 157 | 158 | const isNeverExecuted = computed((): boolean => { 159 | return ( 160 | !!plan.value.planStats.executionTime && 161 | !node[NodeProp.ACTUAL_TIME] && 162 | !node[NodeProp.ACTUAL_ROWS] 163 | ) 164 | }) 165 | 166 | const timeTooltip = computed((): string => { 167 | return [ 168 | "Duration:
Actual Time: ", 169 | duration(node[NodeProp.ACTUAL_TIME]), 170 | ", CPU Time: ", 171 | duration(node[NodeProp.CPU_TIME]), 172 | ].join("") 173 | }) 174 | 175 | const rowsTooltip = computed((): string => { 176 | return ["Rows: ", rows(node[NodeProp.ACTUAL_ROWS] as number)].join("") 177 | }) 178 | 179 | const resultTooltip = computed((): string => { 180 | return ["Result: ", rows(node[NodeProp.RESULT_SET_SIZE] as number)].join("") 181 | }) 182 | 183 | const estimationTooltip = computed((): string => { 184 | return [ 185 | "Estimated: ", 186 | rows( 187 | node[NodeProp.EXTRA_INFO][NodeProp.ESTIMATED_ROWS] as unknown as number 188 | ), 189 | ].join("") 190 | }) 191 | 192 | return { 193 | barColor, 194 | barWidth, 195 | resultClass, 196 | resultTooltip, 197 | durationClass, 198 | rowsClass, 199 | estimationClass, 200 | executionTimePercent, 201 | highlightValue, 202 | isNeverExecuted, 203 | nodeName, 204 | rowsTooltip, 205 | timeTooltip, 206 | estimationTooltip, 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/components/DiagramRow.vue: -------------------------------------------------------------------------------- 1 | 113 | 114 | 223 | -------------------------------------------------------------------------------- /src/filters.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import { createApp } from "vue" 3 | import { EstimateDirection, nodePropTypes, PropType } from "@/enums" 4 | import SortGroup from "@/components/SortGroup.vue" 5 | import hljs from "highlight.js/lib/core" 6 | import pgsql from "highlight.js/lib/languages/pgsql" 7 | hljs.registerLanguage("pgsql", pgsql) 8 | 9 | import json from "highlight.js/lib/languages/json" 10 | hljs.registerLanguage("json", json) 11 | 12 | export function duration(value: number | undefined): string { 13 | if (value === undefined) { 14 | return "N/A" 15 | } 16 | const result: string[] = [] 17 | let denominator: number = 60 * 60 * 24 18 | const days = Math.floor(value / denominator) 19 | if (days) { 20 | result.push(days + "d") 21 | } 22 | let remainder = value % denominator 23 | denominator /= 24 24 | const hours = Math.floor(remainder / denominator) 25 | if (hours) { 26 | result.push(hours + "h") 27 | } 28 | remainder = remainder % denominator 29 | denominator /= 60 30 | const minutes = Math.floor(remainder / denominator) 31 | if (minutes) { 32 | result.push(minutes + "m") 33 | } 34 | remainder = remainder % denominator 35 | if (remainder >= 1) { 36 | const seconds = parseFloat(remainder.toFixed(2)) 37 | result.push(seconds.toLocaleString() + "s") 38 | } else { 39 | const milliseconds = parseFloat((remainder * 1000).toFixed(2)) 40 | result.push(milliseconds.toLocaleString() + "ms") 41 | } 42 | 43 | return result.slice(0, 2).join(" ") 44 | } 45 | 46 | export function result(value: number): string { 47 | if (value === undefined) { 48 | return "N/A" 49 | } 50 | value = parseFloat(value.toPrecision(3)) 51 | return value.toLocaleString() 52 | } 53 | 54 | export function rows(value: number): string { 55 | if (value === undefined && value != 0) { 56 | return "N/A" 57 | } 58 | return value.toLocaleString() 59 | } 60 | 61 | export function loops(value: number): string { 62 | if (value === undefined) { 63 | return "N/A" 64 | } 65 | return value.toLocaleString() 66 | } 67 | 68 | export function factor(value: number): string { 69 | const f: string = parseFloat(value.toPrecision(2)).toLocaleString() 70 | const compiled = _.template("${f} ×") 71 | return compiled({ f }) 72 | } 73 | 74 | export function keysToString(value: string[] | string): string { 75 | const values = Array.isArray(value) ? value : [value] 76 | 77 | return values 78 | // .map((v) => v.replace(/^\(|\)$/g, "")) // Remove outer parentheses 79 | .map((v) => v.replace(/^'|'$/g, "")) // Remove outer ' 80 | .join(", ") 81 | } 82 | 83 | export function sortKeys( 84 | sort: string[], 85 | presort: string[] | undefined 86 | ): string { 87 | return _.map(sort, (v) => { 88 | let result = _.escape(v) 89 | if (presort) { 90 | result += 91 | presort.indexOf(v) !== -1 92 | ? ' (presort)' 93 | : "" 94 | } 95 | return result 96 | }).join(", ") 97 | } 98 | 99 | export function truncate(text: string, length: number, clamp: string): string { 100 | clamp = clamp || "..." 101 | return text.length > length ? text.slice(0, length) + clamp : text 102 | } 103 | 104 | export function kilobytes(value: number): string { 105 | return formatBytes(value * 1024) 106 | } 107 | 108 | export function bytes(value: number): string { 109 | return formatBytes(value) 110 | } 111 | 112 | export function formatBytes(value: number, precision = 2) { 113 | const k = 1024 114 | const dm = precision < 0 ? 0 : precision 115 | const units = ["Bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] 116 | const i = Math.floor(Math.log(value) / Math.log(k)) 117 | const compiled = _.template("${value} ${unit}") 118 | const valueString = parseFloat( 119 | (value / Math.pow(k, i)).toPrecision(dm) 120 | ).toLocaleString() 121 | return compiled({ value: valueString, unit: units[i] }) 122 | } 123 | 124 | export function blocksAsBytes(value: number): string { 125 | return value ? formatBytes(value * 8 * 1024) : "" 126 | } 127 | 128 | export function blocks(value: number, asHtml = false): string { 129 | asHtml = !!asHtml 130 | if (!value) { 131 | return "" 132 | } 133 | let r = value.toLocaleString() 134 | if (asHtml) { 135 | r += `
${blocksAsBytes(value)}` 136 | } 137 | return r 138 | } 139 | 140 | export function percent(value: number): string { 141 | if (isNaN(value)) { 142 | return "-" 143 | } 144 | return _.round(value * 100) + "%" 145 | } 146 | 147 | export function list(value: string[] | string): string { 148 | if (typeof value === "string") { 149 | value = value.split(/\s*,\s*/) 150 | } 151 | const compiled = _.template( 152 | "<% _.forEach(lines, function(line) { %>
  • <%= line %>
  • <% }); %>" 153 | ) 154 | return ( 155 | '
      ' + compiled({ lines: value }) + "
    " 156 | ) 157 | } 158 | 159 | function sortGroups(value: string): string { 160 | const app = createApp(SortGroup, { sortGroup: value }).mount( 161 | document.createElement("div") 162 | ) 163 | return app.$el.outerHTML 164 | } 165 | 166 | export function transferRate(value: number): string { 167 | if (!value) { 168 | return "" 169 | } 170 | return formatBytes(value * 8 * 1024) + "/s" 171 | } 172 | 173 | export function formatNodeProp(key: string, value: unknown): string { 174 | if (_.has(nodePropTypes, key)) { 175 | if (nodePropTypes[key] === PropType.duration) { 176 | return duration(value as number) 177 | } else if (nodePropTypes[key] === PropType.boolean) { 178 | return value ? "yes" : "no" 179 | } else if (nodePropTypes[key] === PropType.cost) { 180 | return result(value as number) 181 | } else if (nodePropTypes[key] === PropType.rows) { 182 | return rows(value as number) 183 | } else if (nodePropTypes[key] === PropType.loops) { 184 | return loops(value as number) 185 | } else if (nodePropTypes[key] === PropType.factor) { 186 | return factor(value as number) 187 | } else if (nodePropTypes[key] === PropType.estimateDirection) { 188 | switch (value) { 189 | case EstimateDirection.over: 190 | return ' over' 191 | case EstimateDirection.under: 192 | return ' under' 193 | default: 194 | return "-" 195 | } 196 | } else if (nodePropTypes[key] === PropType.json) { 197 | return JSON.stringify(value, null, 2) 198 | } else if (nodePropTypes[key] === PropType.bytes) { 199 | return bytes(value as number) 200 | } else if (nodePropTypes[key] === PropType.kilobytes) { 201 | return kilobytes(value as number) 202 | } else if (nodePropTypes[key] === PropType.blocks) { 203 | return blocks(value as number, true) 204 | } else if (nodePropTypes[key] === PropType.list) { 205 | return list(value as string[]) 206 | } else if (nodePropTypes[key] === PropType.sortGroups) { 207 | return sortGroups(value as string) 208 | } else if (nodePropTypes[key] === PropType.transferRate) { 209 | return transferRate(value as number) 210 | } 211 | } 212 | return _.escape(value as unknown as string) 213 | } 214 | 215 | export function durationClass(i: number): string { 216 | let c 217 | if (i > 90) { 218 | c = 4 219 | } else if (i > 40) { 220 | c = 3 221 | } else if (i > 10) { 222 | c = 2 223 | } 224 | if (c) { 225 | return "c-" + c 226 | } 227 | return "" 228 | } 229 | 230 | export function pgsql_(text: string) { 231 | return hljs.highlight(text, { language: "pgsql" }).value 232 | } 233 | export function json_(text: string) { 234 | return hljs.highlight(text, { language: "json" }).value 235 | } 236 | -------------------------------------------------------------------------------- /src/components/PlanNode.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 261 | -------------------------------------------------------------------------------- /src/components/Stats.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | 289 | -------------------------------------------------------------------------------- /example/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 152 | 153 | 310 | -------------------------------------------------------------------------------- /src/services/help-service.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import type { IPlan, Node } from "@/interfaces" 3 | import { NodeProp } from "@/enums" 4 | import { nodePropTypes, PropType } from "@/enums" 5 | 6 | export class HelpService { 7 | public nodeId = 0 8 | 9 | public getNodeTypeDescription(nodeType: string) { 10 | return NODE_DESCRIPTIONS[nodeType.toUpperCase()] 11 | } 12 | 13 | public getHelpMessage(helpMessage: string) { 14 | return HELP_MESSAGES[helpMessage.toUpperCase()] 15 | } 16 | } 17 | 18 | interface INodeDescription { 19 | [key: string]: string 20 | } 21 | 22 | export const NODE_DESCRIPTIONS: INodeDescription = { 23 | NESTED_LOOP_JOIN: `Joins two tables using a nested loop.`, 24 | MERGE_JOIN: `Performs a join by first sorting both tables on the join key and then merging them efficiently.`, 25 | HASH_JOIN: `Performs a join by building a hash table on one of the input tables for fast lookups.`, 26 | HASH_GROUP_BY: `Is a group-by and aggregate implementation that uses a hash table to perform the grouping.`, 27 | FILTER: `It removes non-matching tuples from the result. Note that it does not physically change the data, it only adds a selection vector to the chunk.`, 28 | PROJECTION: `Computes expressions and selects specific columns from the input dataset.`, 29 | TABLE_SCAN: `Reads rows from a base table.`, 30 | // "INDEX_SCAN": `Uses an index to quickly locate matching rows instead of scanning the entire table.`, 31 | // "INDEX_JOIN": `Uses an index lookup to efficiently join two tables.`, 32 | // "COLUMN_SCAN": `Reads data from the columnar storage format, optimizing access for analytical queries.`, 33 | // "TABLE_FUNCTION": `Executes a table-producing function, often used for reading external data formats.`, 34 | UNNEST: `Unnests an array or stuct into a table.`, 35 | WINDOW: `Performs window function computations over a specified partition of data.`, 36 | STREAMING_WINDOW: `Computes window functions in a streaming fashion without materializing the entire result set.`, 37 | CTE: `Materialized CTEs hold a temporary table defined within the scope of a query that can be referenced multiple times.`, 38 | CTE_SCAN: `Scans the result table of a materialized CTE.`, 39 | RECURSIVE_CTE: `Defines a recursive Common Table Expression (CTE) that enables iterative query processing.`, 40 | RECURSIVE_CTE_SCAN: `Scans the working table of a RECURSIVE_CTE.`, 41 | CROSS_PRODUCT: `Performs a Cartesian product between two tables.`, 42 | UNION: `Combines the results of two tables.`, 43 | UNGROUPED_AGGREGATE: `Computes aggregate functions over the entire input table without grouping.`, 44 | READ_CSV_AUTO: `Reads and parses CSV files, inferring column types and delimiters without explicit user specification.`, 45 | DUMMY_SCAN: `Generates a single-row, zero-column result, typically used for queries without an explicit table source (e.g., SELECT 1).`, 46 | DELIM_SCAN: `A DELIM_SCAN works in conjunction with a DELIM_JOIN and reads the set of correlated values.`, 47 | INOUT_FUNCTION: `Represents a table in-out function that can accepts a table as input and returns a table.`, 48 | RIGHT_DELIM_JOIN: `A DELIM_JOIN is used when DuckDB detects (and eliminates) a correlated subquery.`, 49 | LEFT_DELIM_JOIN: `A DELIM_JOIN is used when DuckDB detects (and eliminates) a correlated subquery.`, 50 | INSERT: `Inserts new rows into a table by consuming input data from its child node.`, 51 | UPDATE: `Updates rows in a table.`, 52 | DELETE: `Deletes rows of a table.`, 53 | } 54 | 55 | interface IHelpMessage { 56 | [key: string]: string 57 | } 58 | 59 | export const HELP_MESSAGES: IHelpMessage = { 60 | "MISSING EXECUTION TIME": `Execution time (or Total runtime) not available for this plan. Make sure you 61 | use ANALYZE.`, 62 | "MISSING BLOCKED THREAD TIME": `Blocked thread time not available for this plan. Make sure you 63 | use ANALYZE.`, 64 | "MISSING LATENCY": `Latency not available for this plan. Make sure you 65 | use ANALYZE.`, 66 | "MISSING ROWS RETURNED": `Rows returned not available for this plan. Make sure you 67 | use ANALYZE.`, 68 | "MISSING RESULT SIZE": `Result size not available for this plan. Make sure you 69 | use ANALYZE.`, 70 | } 71 | 72 | interface EaseInOutQuadOptions { 73 | currentTime: number 74 | start: number 75 | change: number 76 | duration: number 77 | } 78 | 79 | export function scrollChildIntoParentView( 80 | parent: Element, 81 | child: Element, 82 | shouldCenter: boolean, 83 | done?: () => void 84 | ) { 85 | if (!child) { 86 | return 87 | } 88 | // Where is the parent on page 89 | const parentRect = parent.getBoundingClientRect() 90 | // Where is the child 91 | const childRect = child.getBoundingClientRect() 92 | 93 | let scrollLeft = parent.scrollLeft // don't move 94 | const isChildViewableX = 95 | childRect.left >= parentRect.left && 96 | childRect.left <= parentRect.right && 97 | childRect.right <= parentRect.right 98 | 99 | let scrollTop = parent.scrollTop 100 | const isChildViewableY = 101 | childRect.top >= parentRect.top && 102 | childRect.top <= parentRect.bottom && 103 | childRect.bottom <= parentRect.bottom 104 | 105 | if (shouldCenter || !isChildViewableX || !isChildViewableY) { 106 | // scroll by offset relative to parent 107 | // try to put the child in the middle of parent horizontaly 108 | scrollLeft = 109 | childRect.left + 110 | parent.scrollLeft - 111 | parentRect.left - 112 | parentRect.width / 2 + 113 | childRect.width / 2 114 | scrollTop = 115 | childRect.top + 116 | parent.scrollTop - 117 | parentRect.top - 118 | parentRect.height / 2 + 119 | childRect.height / 2 120 | smoothScroll({ 121 | element: parent, 122 | to: { scrollTop, scrollLeft }, 123 | duration: 400, 124 | done, 125 | }) 126 | } else if (done) { 127 | done() 128 | } 129 | } 130 | 131 | const easeInOutQuad = ({ 132 | currentTime, 133 | start, 134 | change, 135 | duration, 136 | }: EaseInOutQuadOptions) => { 137 | let newCurrentTime = currentTime 138 | newCurrentTime /= duration / 2 139 | 140 | if (newCurrentTime < 1) { 141 | return (change / 2) * newCurrentTime * newCurrentTime + start 142 | } 143 | 144 | newCurrentTime -= 1 145 | return (-change / 2) * (newCurrentTime * (newCurrentTime - 2) - 1) + start 146 | } 147 | 148 | interface SmoothScrollOptions { 149 | duration: number 150 | element: Element 151 | to: { 152 | scrollTop: number 153 | scrollLeft: number 154 | } 155 | done?: () => void 156 | } 157 | 158 | export function smoothScroll({ 159 | duration, 160 | element, 161 | to, 162 | done, 163 | }: SmoothScrollOptions) { 164 | const startX = element.scrollTop 165 | const startY = element.scrollLeft 166 | const changeX = to.scrollTop - startX 167 | const changeY = to.scrollLeft - startY 168 | const startDate = new Date().getTime() 169 | 170 | const animateScroll = () => { 171 | const currentDate = new Date().getTime() 172 | const currentTime = currentDate - startDate 173 | element.scrollTop = easeInOutQuad({ 174 | currentTime, 175 | start: startX, 176 | change: changeX, 177 | duration, 178 | }) 179 | element.scrollLeft = easeInOutQuad({ 180 | currentTime, 181 | start: startY, 182 | change: changeY, 183 | duration, 184 | }) 185 | 186 | if (currentTime < duration) { 187 | requestAnimationFrame(animateScroll) 188 | } else { 189 | element.scrollTop = to.scrollTop 190 | element.scrollLeft = to.scrollLeft 191 | if (done) { 192 | done() 193 | } 194 | } 195 | } 196 | animateScroll() 197 | } 198 | 199 | /* 200 | * Split a string, ensuring balanced parenthesis and balanced quotes. 201 | */ 202 | export function splitBalanced(input: string, split: string) { 203 | // Build the pattern from params with defaults: 204 | const pattern = "([\\s\\S]*?)(e)?(?:(o)|(c)|(t)|(sp)|$)" 205 | .replace("sp", split) 206 | .replace("o", "[\\(\\{\\[]") 207 | .replace("c", "[\\)\\}\\]]") 208 | .replace("t", "['\"]") 209 | .replace("e", "[\\\\]") 210 | const r = new RegExp(pattern, "gi") 211 | const stack: string[] = [] 212 | let buffer: string[] = [] 213 | const results: string[] = [] 214 | input.replace(r, ($0, $1, $e, $o, $c, $t, $s) => { 215 | if ($e) { 216 | // Escape 217 | buffer.push($1, $s || $o || $c || $t) 218 | return "" 219 | } else if ($o) { 220 | // Open 221 | stack.push($o) 222 | } else if ($c) { 223 | // Close 224 | stack.pop() 225 | } else if ($t) { 226 | // Toggle 227 | if (stack[stack.length - 1] !== $t) { 228 | stack.push($t) 229 | } else { 230 | stack.pop() 231 | } 232 | } else { 233 | // Split (if no stack) or EOF 234 | if ($s ? !stack.length : !$1) { 235 | buffer.push($1) 236 | results.push(buffer.join("")) 237 | buffer = [] 238 | return "" 239 | } 240 | } 241 | buffer.push($0) 242 | return "" 243 | }) 244 | return results 245 | } 246 | 247 | export function findNodeById(plan: IPlan, id: number): Node | undefined { 248 | let o: Node | undefined = undefined 249 | const root = plan.content[NodeProp.PLANS][0] as Node 250 | if (root.nodeId == id) { 251 | return root 252 | } 253 | if (root && root[NodeProp.PLANS]) { 254 | root[NodeProp.PLANS]?.some(function iter(child: Node): boolean | undefined { 255 | if (child.nodeId === id) { 256 | o = child 257 | return true 258 | } 259 | return child[NodeProp.PLANS] && child[NodeProp.PLANS].some(iter) 260 | }) 261 | } 262 | return o 263 | } 264 | 265 | export function findNodeBySubplanName( 266 | plan: IPlan, 267 | subplanName: string 268 | ): Node | undefined { 269 | const o: Node | undefined = undefined 270 | return o 271 | } 272 | 273 | // Returns the list of properties that have already been displayed either in 274 | // the main panel or in other detailed tabs. 275 | const notMiscProperties: string[] = [ 276 | NodeProp.NODE_TYPE, 277 | NodeProp.NODE_TYPE_EXPLAIN, 278 | NodeProp.EXTRA_INFO, 279 | NodeProp.ACTUAL_TIME, 280 | NodeProp.ACTUAL_ROWS, 281 | NodeProp.OPERATOR_ROWS_SCANNED, 282 | NodeProp.ESTIMATED_ROWS, 283 | NodeProp.CTE_NAME, 284 | NodeProp.JOIN_TYPE, 285 | NodeProp.NODE_ID, 286 | "size", // Manually added to use FlexTree 287 | NodeProp.RELATION_NAME, 288 | NodeProp.FUNCTION_NAME, 289 | NodeProp.PROJECTIONS, 290 | NodeProp.CONDITIONS, 291 | NodeProp.FILTER, 292 | ] 293 | 294 | export function shouldShowProp(key: string, value: unknown): boolean { 295 | return ( 296 | (!!value || 297 | nodePropTypes[key] === PropType.increment || 298 | key === NodeProp.ACTUAL_ROWS) && 299 | notMiscProperties.indexOf(key) === -1 300 | ) 301 | } 302 | -------------------------------------------------------------------------------- /src/services/plan-service.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import { NodeProp } from "@/enums" 3 | import type { IPlan, IPlanContent, IPlanStats } from "@/interfaces" 4 | import type { Node } from "@/interfaces" 5 | import clarinet from "clarinet" 6 | 7 | export class PlanService { 8 | private static instance: PlanService 9 | private nodeId = 0 10 | 11 | public createPlan( 12 | planName: string, 13 | planContent: IPlanContent, 14 | planQuery: string 15 | ): IPlan { 16 | // remove any extra white spaces in the middle of query 17 | // (\S) start match after any non-whitespace character => group 1 18 | // (?!$) don't start match after end of line 19 | // (\s{2,}) group of 2 or more white spaces 20 | // '$1 ' reuse group 1 and and a single space 21 | planQuery = planQuery.replace(/(\S)(?!$)(\s{2,})/gm, "$1 ") 22 | 23 | if (planContent) { 24 | const plan: IPlan = { 25 | id: NodeProp.DEV_PLAN_TAG + new Date().getTime().toString(), 26 | name: planName || "plan created on " + new Date().toDateString(), 27 | createdOn: new Date(), 28 | content: planContent, 29 | query: planQuery, 30 | planStats: {} as IPlanStats, 31 | } 32 | this.nodeId = 1 33 | // console.log(planContent) 34 | if (planContent[NodeProp.CPU_TIME] !== undefined) { 35 | // plan is analyzed 36 | this.processNode(planContent[NodeProp.PLANS]![0]!, plan) 37 | } else { 38 | // plan is not analyzed 39 | this.processNode(planContent as unknown as Node, plan) 40 | } 41 | this.calculateMaximums(plan) 42 | return plan 43 | } else { 44 | throw new Error("Invalid plan") 45 | } 46 | } 47 | 48 | // recursively walk down the plan to compute various metrics 49 | public processNode(node: Node, plan: IPlan) { 50 | node.nodeId = this.nodeId++ 51 | 52 | _.each(node[NodeProp.PLANS], (child) => { 53 | this.processNode(child, plan) 54 | }) 55 | } 56 | 57 | public calculateMaximums(plan: IPlan) { 58 | type recurseItemType = Array<[Node, recurseItemType]> 59 | 60 | function recurse(nodes: Node[]): recurseItemType { 61 | return _.map(nodes, (node) => [node, recurse(node[NodeProp.PLANS])]) 62 | } 63 | 64 | let flat: Node[] = [] 65 | flat = flat.concat(_.flattenDeep(recurse(plan.content[NodeProp.PLANS]))) 66 | 67 | const largest = _.maxBy(flat, NodeProp.ACTUAL_ROWS) 68 | plan.content.maxRows = largest && largest[NodeProp.ACTUAL_ROWS] !== undefined 69 | ? largest[NodeProp.ACTUAL_ROWS] as number 70 | : 0 71 | 72 | const largestScanned = _.maxBy(flat, NodeProp.OPERATOR_ROWS_SCANNED) 73 | plan.content.maxRowsScanned = largestScanned && largestScanned[NodeProp.OPERATOR_ROWS_SCANNED] !== undefined 74 | ? largestScanned[NodeProp.OPERATOR_ROWS_SCANNED] as number 75 | : 0 76 | 77 | const largestResult = _.maxBy(flat, NodeProp.RESULT_SET_SIZE) 78 | plan.content.maxResult = largestResult && largestResult[NodeProp.RESULT_SET_SIZE] !== undefined 79 | ? largestResult[NodeProp.RESULT_SET_SIZE] as number 80 | : 0 81 | 82 | const largestEstimate = _.maxBy(flat, function (node: Node) { 83 | const estimatedRows = node[NodeProp.EXTRA_INFO][NodeProp.ESTIMATED_ROWS] 84 | return estimatedRows ? parseInt(estimatedRows as string, 10) : 0 85 | }) 86 | plan.content.maxEstimatedRows = largestEstimate && largestEstimate[NodeProp.EXTRA_INFO][NodeProp.ESTIMATED_ROWS] 87 | ? parseInt(largestEstimate[NodeProp.EXTRA_INFO][NodeProp.ESTIMATED_ROWS] as string, 10) 88 | : 0 89 | 90 | const slowest = _.maxBy(flat, NodeProp.ACTUAL_TIME) 91 | plan.content.maxDuration = slowest && slowest[NodeProp.ACTUAL_TIME] !== undefined 92 | ? slowest[NodeProp.ACTUAL_TIME] as number 93 | : 0 94 | } 95 | 96 | public cleanupSource(source: string) { 97 | // Remove frames around, handles |, ║, 98 | source = source.replace(/^(\||║|│)(.*)\1\r?\n/gm, "$2\n") 99 | // Remove frames at the end of line, handles |, ║, 100 | // source = source.replace(/(.*)(\||║|│)$\r?\n/gm, "$1\n") 101 | 102 | // Remove separator lines from various types of borders 103 | source = source.replace(/^\+-+\+\r?\n/gm, "") 104 | source = source.replace(/^(-|─|═)\1+\r?\n/gm, "") 105 | source = source.replace(/^(├|╟|╠|╞)(─|═)\2*(┤|╢|╣|╡)\r?\n/gm, "") 106 | 107 | // Remove more horizontal lines 108 | source = source.replace(/^\+-+\+\r?\n/gm, "") 109 | source = source.replace(/^└(─)+┘\r?\n/gm, "") 110 | source = source.replace(/^╚(═)+╝\r?\n/gm, "") 111 | source = source.replace(/^┌(─)+┐\r?\n/gm, "") 112 | source = source.replace(/^╔(═)+╗\r?\n/gm, "") 113 | 114 | // Remove quotes around lines, both ' and " 115 | source = source.replace(/^(["'])(.*)\1\r?/gm, "$2") 116 | 117 | // Remove "+" line continuations 118 | // source = source.replace(/\s*\+\r?\n/g, "\n") 119 | 120 | // Remove "↵" line continuations 121 | source = source.replace(/↵\r?/gm, "\n") 122 | 123 | // Remove "query plan" header 124 | source = source.replace(/^\s*QUERY PLAN\s*\r?\n/m, "") 125 | 126 | // Remove rowcount 127 | // example: (8 rows) 128 | // Note: can be translated 129 | // example: (8 lignes) 130 | source = source.replace(/^\(\d+\s+[a-z]*s?\)(\r?\n|$)/gm, "\n") 131 | 132 | return source 133 | } 134 | 135 | public fromSource(source: string) { 136 | source = this.cleanupSource(source) 137 | return this.parseJson(source) 138 | } 139 | 140 | public fromJson(source: string) { 141 | // We need to remove things before and/or after explain 142 | // To do this, first - split explain into lines... 143 | const sourceLines = source.split(/[\r\n]+/) 144 | 145 | // Now, find first line of explain, and cache it's prefix (some spaces ...) 146 | let prefix = "" 147 | let firstLineIndex = 0 148 | _.each(sourceLines, (l: string, index: number) => { 149 | const matches = /^(\s*)(\[|\{)\s*$/.exec(l) 150 | if (matches) { 151 | prefix = matches[1] 152 | firstLineIndex = index 153 | return false 154 | } 155 | }) 156 | // now find last line 157 | let lastLineIndex = 0 158 | _.each(sourceLines, (l: string, index: number) => { 159 | const matches = new RegExp("^" + prefix + "(]|})s*$").exec(l) 160 | if (matches) { 161 | lastLineIndex = index 162 | return false 163 | } 164 | }) 165 | 166 | const useSource: string = sourceLines 167 | .slice(firstLineIndex, lastLineIndex + 1) 168 | .join("\n") 169 | // Replace two double quotes (added by pgAdmin) 170 | .replace(/""/gm, '"') 171 | 172 | return this.parseJson(useSource) 173 | } 174 | 175 | // Stream parse JSON as it can contain duplicate keys (workers) 176 | public parseJson(source: string) { 177 | const parser = clarinet.parser() 178 | type JsonElement = { [key: string]: JsonElement | null } 179 | const elements: (JsonElement | never[])[] = [] 180 | let root: JsonElement | JsonElement[] | null = null 181 | // Store the level and duplicated object|array 182 | let duplicated: [number, JsonElement | null] | null = null 183 | parser.onvalue = (v: JsonElement) => { 184 | const current = elements[elements.length - 1] as JsonElement 185 | if (_.isArray(current)) { 186 | current.push(v) 187 | } else { 188 | const keys = Object.keys(current) 189 | const lastKey = keys[keys.length - 1] 190 | current[lastKey] = v 191 | } 192 | } 193 | parser.onopenobject = (key: string) => { 194 | const o: JsonElement = {} 195 | o[key] = null 196 | elements.push(o) 197 | } 198 | parser.onkey = (key: string) => { 199 | const current = elements[elements.length - 1] as JsonElement 200 | const keys = Object.keys(current) 201 | if (keys.indexOf(key) !== -1) { 202 | duplicated = [elements.length - 1, current[key]] 203 | } else { 204 | current[key] = null 205 | } 206 | } 207 | parser.onopenarray = () => { 208 | elements.push([]) 209 | } 210 | parser.oncloseobject = parser.onclosearray = () => { 211 | const popped = elements.pop() as JsonElement 212 | 213 | if (!elements.length) { 214 | root = popped 215 | } else { 216 | const current = elements[elements.length - 1] as JsonElement 217 | 218 | if (duplicated && duplicated[0] === elements.length - 1) { 219 | _.merge(duplicated[1], popped) 220 | duplicated = null 221 | } else { 222 | if (_.isArray(current)) { 223 | current.push(popped) 224 | } else { 225 | const keys = Object.keys(current) 226 | const lastKey = keys[keys.length - 1] 227 | current[lastKey] = popped 228 | } 229 | } 230 | } 231 | } 232 | parser.write(source).close() 233 | if (Array.isArray(root)) { 234 | root = root[0] 235 | } 236 | return root 237 | } 238 | 239 | public splitIntoLines(text: string): string[] { 240 | // Splits source into lines, while fixing (well, trying to fix) 241 | // cases where input has been force-wrapped to some length. 242 | const out: string[] = [] 243 | const lines = text.split(/\r?\n/) 244 | const countChar = (str: string, ch: RegExp) => (str.match(ch) || []).length 245 | const closingFirst = (str: string) => { 246 | const closingParIndex = str.indexOf(")") 247 | const openingParIndex = str.indexOf("(") 248 | return closingParIndex != -1 && closingParIndex < openingParIndex 249 | } 250 | 251 | const sameIndent = (line1: string, line2: string) => { 252 | return line1.search(/\S/) == line2.search(/\S/) 253 | } 254 | 255 | _.each(lines, (line: string) => { 256 | if (countChar(line, /\)/g) > countChar(line, /\(/g)) { 257 | // if there more closing parenthesis this means that it's the 258 | // continuation of a previous line 259 | out[out.length - 1] += line 260 | } else if ( 261 | line.match( 262 | /^(?:Total\s+runtime|Planning\s+time|Execution\s+time|Time|Filter|Output|JIT)/i 263 | ) 264 | ) { 265 | out.push(line) 266 | } else if ( 267 | line.match(/^\S/) || // doesn't start with a blank space (allowed only for the first node) 268 | line.match(/^\s*\(/) || // first non-blank character is an opening parenthesis 269 | closingFirst(line) // closing parenthesis before opening one 270 | ) { 271 | if (0 < out.length) { 272 | out[out.length - 1] += line 273 | } else { 274 | out.push(line) 275 | } 276 | } else if ( 277 | 0 < out.length && 278 | out[out.length - 1].match(/^\s*Output/i) && 279 | !sameIndent(out[out.length - 1], line) 280 | ) { 281 | // If previous line was Output and current line is not same indent 282 | // (which would mean a new information line) 283 | out[out.length - 1] += line 284 | } else { 285 | out.push(line) 286 | } 287 | }) 288 | return out 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/components/GridRow.vue: -------------------------------------------------------------------------------- 1 | 89 | 344 | -------------------------------------------------------------------------------- /src/components/Plan.vue: -------------------------------------------------------------------------------- 1 | 524 | 525 | 791 | 792 | 841 | --------------------------------------------------------------------------------