├── .envrc ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .gitmodules ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── default.nix ├── flake.lock ├── flake.nix ├── media ├── checkbox.svg ├── dark │ ├── database.svg │ ├── dbconnection.svg │ └── plus.svg ├── light │ ├── database.svg │ ├── dbconnection.svg │ └── plus.svg ├── logo.png ├── logo.svg └── logo128.png ├── package-lock.json ├── package.json ├── src ├── commands.ts ├── connections.ts ├── controller.ts ├── driver.ts ├── form.ts ├── lsp.ts ├── main.ts ├── markdown.ts ├── serializer.ts └── tsconfig.json └── webview ├── Form.tsx ├── main.tsx └── tsconfig.json /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": "off", 11 | "@typescript-eslint/semi": "warn", 12 | "curly": "warn", 13 | "eqeqeq": "warn", 14 | "no-throw-literal": "warn" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cmoog] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | package: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: DeterminateSystems/nix-installer-action@v4 14 | - uses: DeterminateSystems/magic-nix-cache-action@v2 15 | - run: nix build 16 | - name: Prepare to upload 17 | run: cp result sqlnotebook-${{ github.sha }}.vsix 18 | - name: Upload vsix as artifact 19 | uses: actions/upload-artifact@v1 20 | with: 21 | name: sqlnotebook-${{ github.sha }}.vsix 22 | path: sqlnotebook-${{ github.sha }}.vsix 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | push: 4 | tags: 'v*' 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: DeterminateSystems/nix-installer-action@v4 12 | - uses: DeterminateSystems/magic-nix-cache-action@v2 13 | - name: Parse version 14 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 15 | - run: nix build 16 | - name: Prepare to upload 17 | run: cp result sqlnotebook-${{ env.RELEASE_VERSION }}.vsix 18 | - name: Upload vsix as artifact 19 | uses: actions/upload-artifact@v1 20 | with: 21 | name: sqlnotebook-${{ env.RELEASE_VERSION }}.vsix 22 | path: sqlnotebook-${{ env.RELEASE_VERSION }}.vsix 23 | - name: Publish Extension to Microsoft Marketplace 24 | run: npx vsce publish --packagePath ./sqlnotebook-${{ env.RELEASE_VERSION }}.vsix 25 | env: 26 | VSCE_PAT: ${{ secrets.VSCE_CREDENTIALS }} 27 | - name: Publish to OpenVSX 28 | run: npx ovsx publish ./sqlnotebook-${{ env.RELEASE_VERSION }}.vsix -p ${{ secrets.OPEN_VSX_CREDENTIALS }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.vsix 4 | sqls_bin 5 | sqls 6 | .direnv 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "sqls"] 2 | path = sqls 3 | url = https://github.com/cmoog/sqls 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "pwa-extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 14 | "debugWebviews": true, 15 | "trace": true, 16 | "preLaunchTask": "npm: watch" 17 | }, 18 | { 19 | "name": "Run Extension (no server)", 20 | "type": "extensionHost", 21 | "request": "launch", 22 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 23 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 24 | "debugWebviews": true, 25 | "preLaunchTask": "npm: watch" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "dist": false // set this to true to hide the "out" folder with the compiled JS files 4 | }, 5 | "search.exclude": { 6 | "dist": true 7 | }, 8 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 9 | "typescript.tsc.autoDetect": "off", 10 | "cSpell.enabled": true 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "isBackground": true, 10 | "presentation": { 11 | "reveal": "never" 12 | }, 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | }, 18 | { 19 | "type": "npm", 20 | "script": "compile", 21 | "group": "build" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | * 2 | **/** 3 | !LICENSE 4 | !CHANGELOG.md 5 | !dist/**/* 6 | !media/**/* 7 | !sqls_bin/**/* 8 | !README.md 9 | !package.json 10 | !package-lock.json 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v0.6.0 4 | 5 | - Support `sqlite` driver. Connect to on-disk SQLite files (or use :memory:). 6 | - The implementation uses the JS-only sql.js library. This may cause incompatibility with large 7 | database files on memory constrained devices. But, this avoids having to package native bindings 8 | for every platform and keeps the door open for in-browser support. 9 | 10 | ## v0.5.3 11 | 12 | - Fix rendering of binary and JSON data. 13 | - Serialize binary data as hexadecimal with a `0x` prefix. 14 | - Marshal JSON data to a string. 15 | - Inline all dependencies to reduce bundle size by ~20%. 16 | 17 | ## v0.5.2 18 | 19 | - When clicking `Run All`, cells now execute in series. Previously, cells executed in parallel. 20 | 21 | - New configuration option for maximum number of result rows before truncating the result table. 22 | Defaults to `25`. 23 | 24 | ```json 25 | { 26 | "SQLNotebook.maxResultRows": 25 27 | } 28 | ``` 29 | 30 | ## v0.5.1 31 | 32 | - Fix for `mysql` driver result tables that caused each row to render with its own header. 33 | 34 | ## v0.5.0 35 | 36 | - Bundle `sqls` language server into `vscode-sql-notebook`. 37 | - When running on a compatible arch/os, notebooks can now 38 | benefit from intelligent autocomplete and hover information 39 | when connected to a valid database connection. To enable this unstable 40 | feature, add the following to your `settings.json`. 41 | 42 | ```json 43 | { 44 | "SQLNotebook.useLanguageServer": true 45 | } 46 | ``` 47 | 48 | - New configuration option for query timeout in milliseconds. Defaults to 30000. 49 | 50 | ```json 51 | { 52 | "SQLNotebook.queryTimeout": 30000 53 | } 54 | ``` 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Charles Moog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VS Code SQL Notebook 2 | 3 | 4 | 5 | [![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/cmoog.sqlnotebook)](https://marketplace.visualstudio.com/items?itemName=cmoog.sqlnotebook) 6 | [![GitHub Release](https://img.shields.io/github/v/release/cmoog/vscode-sql-notebook?color=6b9ded&include_prerelease=false)](https://github.com/cmoog/vscode-sql-notebook/releases) 7 | 8 | Open SQL files in the VS Code Notebook interface. Execute query blocks 9 | and view output interactively. 10 | 11 | ![Screen Shot 2021-12-30 at 1 34 19 PM](https://user-images.githubusercontent.com/7585078/147782832-1d281462-9567-4a58-a022-815e36941547.png) 12 | 13 | ## Features 14 | 15 | - Open any `.sql` file as a Notebook. 16 | - Execute query blocks in the Notebook UI and view output. 17 | - Configure database connections in the SQL Notebook side-panel. 18 | - Supports MySQL, PostgreSQL, SQLite, and MSSQL (OracleDB support coming soon). 19 | - (unstable) Built-in typed auto-complete with an embedded language server. 20 | 21 | ## Usage 22 | 23 | Open any `.sql` file with the `Open With` menu option. Then, select the `SQL Notebook` format. Configure database connections in the SQL Notebook side-panel. 24 | 25 | ![Screen Shot 2021-12-30 at 1 30 29 PM](https://user-images.githubusercontent.com/7585078/147782921-78dca657-6737-4055-af46-c019e9df4ea3.png) 26 | 27 | ![Screen Shot 2021-12-30 at 1 30 39 PM](https://user-images.githubusercontent.com/7585078/147782929-f9b7846b-6911-45ed-8354-ff0130a912b1.png) 28 | 29 | ![Screen Shot 2021-12-30 at 1 34 32 PM](https://user-images.githubusercontent.com/7585078/147782853-c0ea8ecb-e5f7-410f-83c2-af3d0562302e.png) 30 | 31 | ## FAQ 32 | 33 | **If the file is stored as a regular `.sql` file, how are cell boundaries detected?** 34 | 35 | Cell boundaries are inferred from the presence of two consecutive empty lines. 36 | 37 | Note: this can pose issues with certain code formatting tools. You will need to 38 | configure them to respect consecutive newlines. 39 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { npm2nix, runCommand, nodejs, zip, unzip, sqls }: 2 | npm2nix.v2.build { 3 | src = runCommand "src-with-sqls" { } '' 4 | mkdir $out 5 | cp -r ${./.}/* $out 6 | cp -r ${sqls}/bin $out/sqls_bin 7 | ''; 8 | inherit nodejs; 9 | buildCommands = [ "npm run build" ]; 10 | buildInputs = [ zip unzip ]; 11 | installPhase = '' 12 | # vsce errors when modtime of zipped files are > present 13 | new_modtime="0101120000" # MMDDhhmmYY (just needs to be fixed and < present) 14 | mkdir ./tmp 15 | unzip -q ./*.vsix -d ./tmp 16 | 17 | for file in $(find ./tmp/ -type f); do 18 | touch -m "$new_modtime" "$file" 19 | touch -t "$new_modtime" "$file" 20 | done 21 | 22 | cd ./tmp 23 | zip -q -r $out . 24 | ''; 25 | } 26 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1644229661, 6 | "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1690720142, 21 | "narHash": "sha256-GywuiZjBKfFkntQwpNQfL+Ksa2iGjPprBGL0/psgRZM=", 22 | "owner": "nixos", 23 | "repo": "nixpkgs", 24 | "rev": "3acb5c4264c490e7714d503c7166a3fde0c51324", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "nixos", 29 | "ref": "nixpkgs-unstable", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "npmlock2nix": { 35 | "flake": false, 36 | "locked": { 37 | "lastModified": 1673447413, 38 | "narHash": "sha256-sJM82Sj8yfQYs9axEmGZ9Evzdv/kDcI9sddqJ45frrU=", 39 | "owner": "nix-community", 40 | "repo": "npmlock2nix", 41 | "rev": "9197bbf397d76059a76310523d45df10d2e4ca81", 42 | "type": "github" 43 | }, 44 | "original": { 45 | "owner": "nix-community", 46 | "repo": "npmlock2nix", 47 | "type": "github" 48 | } 49 | }, 50 | "root": { 51 | "inputs": { 52 | "flake-utils": "flake-utils", 53 | "nixpkgs": "nixpkgs", 54 | "npmlock2nix": "npmlock2nix" 55 | } 56 | } 57 | }, 58 | "root": "root", 59 | "version": 7 60 | } 61 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "VS Code extension for opening SQL files as interactive notebooks."; 3 | inputs = { 4 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | npmlock2nix = { 7 | url = "github:nix-community/npmlock2nix"; 8 | flake = false; 9 | }; 10 | }; 11 | 12 | outputs = { self, nixpkgs, npmlock2nix, flake-utils }: 13 | flake-utils.lib.eachDefaultSystem (system: 14 | let 15 | pkgs = nixpkgs.legacyPackages.${system}; 16 | npm2nix = import npmlock2nix { inherit pkgs; }; 17 | buildSqls = { arch, os }: with pkgs; (buildGoModule { 18 | name = "sqls_${arch}_${os}"; 19 | src = fetchFromGitHub { 20 | owner = "cmoog"; 21 | repo = "sqls"; 22 | rev = "8f600074d1b0778c7a0b6b9b820dd4d2d05fbdee"; 23 | sha256 = "sha256-3nYWMDKqmQ0NnflX/4vx1BA+rubWV7pRdZcDaKUatO0="; 24 | }; 25 | doCheck = false; 26 | vendorHash = "sha256-Xv/LtjwgxydMwychQtW1+quqUbkC5PVzhga5qT5lI3s="; 27 | CGO_ENABLED = 0; 28 | }).overrideAttrs (old: old // { GOOS = os; GOARCH = arch; }); 29 | # build each os/arch combination 30 | sqlsInstallCommands = builtins.concatStringsSep "\n" (pkgs.lib.flatten (map 31 | (os: (map 32 | (arch: 33 | # skip this invalid os/arch combination 34 | if arch == "386" && os == "darwin" then "" else 35 | "cp $(find ${buildSqls { inherit os arch; }} -type f) $out/bin/sqls_${arch}_${os}" 36 | ) [ "amd64" "arm64" "386" ])) [ "linux" "darwin" "windows" ])); 37 | sqls = pkgs.runCommand "multiarch-sqls" { } '' 38 | mkdir -p $out/bin 39 | ${sqlsInstallCommands} 40 | ''; 41 | in 42 | { 43 | formatter = pkgs.nixpkgs-fmt; 44 | packages = { 45 | inherit sqls; 46 | default = pkgs.callPackage ./. { inherit sqls npm2nix; }; 47 | }; 48 | devShells.default = pkgs.mkShell { 49 | packages = with pkgs; [ 50 | fish 51 | go 52 | nodejs 53 | typos 54 | upx 55 | ]; 56 | }; 57 | } 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /media/checkbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /media/dark/database.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /media/dark/dbconnection.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | bp3-data-connection 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /media/dark/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /media/light/database.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /media/light/dbconnection.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | bp3-data-connection 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /media/light/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmoog/vscode-sql-notebook/87b95ae1e20c118606b4c3e5e45d682677f90865/media/logo.png -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 10 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /media/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmoog/vscode-sql-notebook/87b95ae1e20c118606b4c3e5e45d682677f90865/media/logo128.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqlnotebook", 3 | "displayName": "SQL Notebook", 4 | "publisher": "cmoog", 5 | "description": "View SQL files as notebooks. Execute cells and view query output.", 6 | "icon": "media/logo128.png", 7 | "repository": { 8 | "url": "https://github.com/cmoog/vscode-sql-notebook" 9 | }, 10 | "version": "0.7.0", 11 | "preview": false, 12 | "engines": { 13 | "vscode": "^1.59.0" 14 | }, 15 | "categories": [ 16 | "Data Science", 17 | "Notebooks", 18 | "Visualization" 19 | ], 20 | "activationEvents": [ 21 | "onNotebook:sql-notebook", 22 | "onView:sqlnotebook-connections" 23 | ], 24 | "main": "./dist/index.js", 25 | "capabilities": { 26 | "virtualWorkspaces": true, 27 | "untrustedWorkspaces": { 28 | "supported": false 29 | } 30 | }, 31 | "extensionKind": [ 32 | "workspace" 33 | ], 34 | "contributes": { 35 | "notebooks": [ 36 | { 37 | "type": "sql-notebook", 38 | "displayName": "SQL Notebook", 39 | "priority": "option", 40 | "selector": [ 41 | { 42 | "filenamePattern": "*.sql" 43 | } 44 | ] 45 | } 46 | ], 47 | "menus": { 48 | "view/item/context": [ 49 | { 50 | "command": "sqlnotebook.connect", 51 | "when": "view == sqlnotebook-connections && viewItem == database", 52 | "group": "inline" 53 | }, 54 | { 55 | "command": "sqlnotebook.connect", 56 | "when": "view == sqlnotebook-connections && viewItem == database" 57 | }, 58 | { 59 | "command": "sqlnotebook.deleteConnectionConfiguration", 60 | "when": "view == sqlnotebook-connections && viewItem == database" 61 | } 62 | ], 63 | "view/title": [] 64 | }, 65 | "commands": [ 66 | { 67 | "command": "sqlnotebook.connect", 68 | "title": "Connect to Database", 69 | "icon": { 70 | "dark": "media/dark/dbconnection.svg", 71 | "light": "media/light/dbconnection.svg" 72 | } 73 | }, 74 | { 75 | "command": "sqlnotebook.deleteConnectionConfiguration", 76 | "title": "Delete SQL Connection Configuration" 77 | }, 78 | { 79 | "command": "sqlnotebook.refreshConnectionPanel", 80 | "title": "Refresh SQL Connection Panel", 81 | "shortTitle": "Refresh" 82 | } 83 | ], 84 | "viewsContainers": { 85 | "activitybar": [ 86 | { 87 | "id": "sqlnotebook", 88 | "title": "SQL Notebook", 89 | "icon": "media/logo.svg" 90 | } 91 | ] 92 | }, 93 | "configuration": { 94 | "title": "SQL Notebook", 95 | "properties": { 96 | "SQLNotebook.useLanguageServer": { 97 | "type": "boolean", 98 | "default": false, 99 | "description": "(Unstable) Use embedded language server for intelligent completion and hover information." 100 | }, 101 | "SQLNotebook.outputJSON": { 102 | "type": "boolean", 103 | "default": false, 104 | "description": "Output JSON in addition to markdown. Other extensions may use this output type to render an interactive table." 105 | }, 106 | "SQLNotebook.queryTimeout": { 107 | "type": "number", 108 | "default": 30000, 109 | "description": "Query timeout in milliseconds for cell query execution." 110 | }, 111 | "SQLNotebook.maxResultRows": { 112 | "type": "number", 113 | "default": 25, 114 | "description": "Maximum number of result rows to display before truncating result table." 115 | } 116 | } 117 | }, 118 | "views": { 119 | "sqlnotebook": [ 120 | { 121 | "id": "sqlnotebook-connections", 122 | "name": "SQL Connections", 123 | "visibility": "visible", 124 | "icon": "media/logo.svg", 125 | "contextualTitle": "Connections" 126 | }, 127 | { 128 | "type": "webview", 129 | "id": "sqlnotebook.connectionForm", 130 | "name": "New SQL Connection", 131 | "contextualTitle": "New Connection", 132 | "visibility": "visible" 133 | }, 134 | { 135 | "id": "sqlnotebook-helpfeedback", 136 | "name": "Help and Feedback", 137 | "visibility": "collapsed", 138 | "icon": "media/logo.svg", 139 | "contextualTitle": "Help and Feedback" 140 | } 141 | ] 142 | } 143 | }, 144 | "scripts": { 145 | "build": "vsce package", 146 | "vscode:prepublish": "npm run compile", 147 | "compile": "npm run build:server && npm run build:webview && mkdir -p ./dist/node_modules/sql.js/dist && cp -r ./node_modules/sql.js ./dist/node_modules", 148 | "build:server": "esbuild ./src/main.ts --sourcemap --minify --bundle --platform=node --external:vscode --external:pg-native --outfile=./dist/index.js", 149 | "build:webview": "esbuild ./webview/main.tsx --sourcemap --minify --bundle --outfile=./dist/webview/main-bundle.js", 150 | "clean": "rm -rf ./dist", 151 | "lint": "eslint src --ext ts", 152 | "watch": "npm run build:server -- --watch & npm run build:webview -- --watch", 153 | "fmt": "prettier --write --ignore-path .gitignore ." 154 | }, 155 | "devDependencies": { 156 | "@types/escape-html": "^1.0.1", 157 | "@types/glob": "^7.1.3", 158 | "@types/mocha": "^8.2.2", 159 | "@types/mssql": "^7.1.3", 160 | "@types/node": "14.x", 161 | "@types/pg": "^8.6.1", 162 | "@types/react": "^17.0.37", 163 | "@types/react-dom": "^17.0.11", 164 | "@types/sql.js": "^1.4.3", 165 | "@types/vscode": "^1.59.0", 166 | "@types/vscode-notebook-renderer": "^1.57.8", 167 | "@types/vscode-webview": "^1.57.0", 168 | "@typescript-eslint/eslint-plugin": "^5.30.7", 169 | "@typescript-eslint/parser": "^5.30.7", 170 | "@vscode/vsce": "^2.21.0", 171 | "esbuild": "^0.14.49", 172 | "eslint": "^8.20.0", 173 | "glob": "^7.1.7", 174 | "ovsx": "^0.3.0", 175 | "prettier": "^2.6.0", 176 | "typescript": "^4.7.4", 177 | "vscode-notebook-error-overlay": "^1.0.1", 178 | "vscode-test": "^1.5.2" 179 | }, 180 | "dependencies": { 181 | "@vscode/webview-ui-toolkit": "^0.9.3", 182 | "mssql": "^7.2.1", 183 | "mysql2": "^2.3.0", 184 | "pg": "^8.7.1", 185 | "react": "^17.0.2", 186 | "react-dom": "^17.0.2", 187 | "sql.js": "^1.7.0", 188 | "vscode-languageclient": "^7.0.0" 189 | }, 190 | "prettier": { 191 | "semi": true, 192 | "singleQuote": true 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { 3 | ConnData, 4 | ConnectionListItem, 5 | SQLNotebookConnections, 6 | } from './connections'; 7 | import { DriverKey, getPool, PoolConfig } from './driver'; 8 | import { storageKey, globalConnPool, globalLspClient } from './main'; 9 | import { getCompiledLSPBinaryPath, sqlsDriverFromDriver } from './lsp'; 10 | 11 | export function deleteConnectionConfiguration( 12 | context: vscode.ExtensionContext, 13 | connectionsSidepanel: SQLNotebookConnections 14 | ) { 15 | return async (item: ConnectionListItem) => { 16 | const without = context.globalState 17 | .get(storageKey, []) 18 | .filter(({ name }) => name !== item.config.name); 19 | context.globalState.update(storageKey, without); 20 | await context.secrets.delete(item.config.name); 21 | 22 | connectionsSidepanel.refresh(); 23 | vscode.window.showInformationMessage( 24 | `Successfully deleted connection configuration "${item.config.name}"` 25 | ); 26 | connectionsSidepanel.refresh(); 27 | }; 28 | } 29 | 30 | export function connectToDatabase( 31 | context: vscode.ExtensionContext, 32 | connectionsSidepanel: SQLNotebookConnections 33 | ) { 34 | return async (item?: ConnectionListItem) => { 35 | let selectedName: string; 36 | if (!item) { 37 | const names = context.globalState 38 | .get(storageKey, []) 39 | .map(({ name }) => name); 40 | const namePicked = await vscode.window.showQuickPick(names, { 41 | ignoreFocusOut: true, 42 | }); 43 | if (!namePicked) { 44 | vscode.window.showErrorMessage(`Invalid database connection name.`); 45 | return; 46 | } 47 | selectedName = namePicked; 48 | } else { 49 | selectedName = item.config.name; 50 | } 51 | 52 | const match = context.globalState 53 | .get(storageKey, []) 54 | .find(({ name }) => name === selectedName); 55 | if (!match) { 56 | vscode.window.showErrorMessage( 57 | `"${selectedName}" not found. Please add the connection config in the sidebar before connecting.` 58 | ); 59 | return; 60 | } 61 | 62 | let password: string | undefined; 63 | try { 64 | if (match.driver === 'sqlite') { 65 | globalConnPool.pool = await getPool({ 66 | driver: 'sqlite', 67 | path: match.path, 68 | }); 69 | } else { 70 | password = await context.secrets.get(match.passwordKey); 71 | if (password === undefined) { 72 | // can also mean that the platform doesn't work with `keytar`, see #18 73 | vscode.window.showWarningMessage( 74 | `Connection password not found in secret store. There may be a problem with the system keychain.` 75 | ); 76 | // continue so that Linux users without a keychain can use empty password configurations 77 | } 78 | 79 | globalConnPool.pool = await getPool({ 80 | ...match, 81 | password, 82 | queryTimeout: getQueryTimeoutConfiguration(), 83 | } as PoolConfig); 84 | } 85 | const conn = await globalConnPool.pool.getConnection(); 86 | await conn.query('SELECT 1'); // essentially a ping to see if the connection works 87 | connectionsSidepanel.setActive(match.name); 88 | if (shouldUseLanguageServer()) { 89 | startLanguageServer(match, password); 90 | } 91 | 92 | vscode.window.showInformationMessage( 93 | `Successfully connected to "${match.name}"` 94 | ); 95 | } catch (err) { 96 | vscode.window.showErrorMessage( 97 | `Failed to connect to "${match.name}": ${ 98 | (err as { message: string }).message 99 | }` 100 | ); 101 | globalLspClient.stop(); 102 | globalConnPool.pool = null; 103 | connectionsSidepanel.setActive(null); 104 | } 105 | }; 106 | } 107 | 108 | function startLanguageServer(conn: ConnData, password?: string) { 109 | if (conn.driver === 'sqlite') { 110 | vscode.window.showWarningMessage( 111 | `Driver ${conn.driver} not supported by language server. Completion support disabled.` 112 | ); 113 | return; 114 | } 115 | try { 116 | const driver = sqlsDriverFromDriver(conn.driver); 117 | const binPath = getCompiledLSPBinaryPath(); 118 | if (!binPath) { 119 | throw Error('Platform not supported, language server disabled.'); 120 | } 121 | if (driver) { 122 | globalLspClient.start({ 123 | binPath, 124 | host: conn.host, 125 | port: conn.port, 126 | password: password, 127 | driver, 128 | database: conn.database, 129 | user: conn.user, 130 | }); 131 | } else { 132 | vscode.window.showWarningMessage( 133 | `Driver ${conn.driver} not supported by language server. Completion support disabled.` 134 | ); 135 | } 136 | } catch (e) { 137 | vscode.window.showWarningMessage( 138 | `Language server failed to initialize: ${e}` 139 | ); 140 | } 141 | } 142 | 143 | function shouldUseLanguageServer(): boolean { 144 | return ( 145 | vscode.workspace.getConfiguration('SQLNotebook').get('useLanguageServer') || 146 | false 147 | ); 148 | } 149 | 150 | function getQueryTimeoutConfiguration(): number { 151 | const defaultTimeout = 30000; // make this the same as the package.json-level configuration default 152 | return ( 153 | vscode.workspace.getConfiguration('SQLNotebook').get('queryTimeout') ?? 154 | defaultTimeout 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/connections.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { storageKey } from './main'; 4 | import { DriverKey } from './driver'; 5 | 6 | export class SQLNotebookConnections 7 | implements vscode.TreeDataProvider 8 | { 9 | private _onDidChangeTreeData: vscode.EventEmitter< 10 | ConnectionListItem | undefined | void 11 | > = new vscode.EventEmitter(); 12 | readonly onDidChangeTreeData: vscode.Event< 13 | ConnectionListItem | undefined | void 14 | > = this._onDidChangeTreeData.event; 15 | 16 | constructor(public readonly context: vscode.ExtensionContext) { 17 | this.refresh(); 18 | this.activeConn = null; 19 | } 20 | 21 | refresh(): void { 22 | this._onDidChangeTreeData.fire(); 23 | } 24 | 25 | getTreeItem(element: ConnectionListItem): vscode.TreeItem { 26 | return element; 27 | } 28 | 29 | public setActive(connName: string | null) { 30 | this.activeConn = connName; 31 | this.refresh(); 32 | } 33 | private activeConn: string | null; 34 | 35 | getChildren(element?: ConnectionListItem): Thenable { 36 | if (element) { 37 | if (element.config.driver === 'sqlite') { 38 | return Promise.resolve([ 39 | new vscode.TreeItem( 40 | `filename: ${element.config.path}`, 41 | vscode.TreeItemCollapsibleState.None 42 | ), 43 | new vscode.TreeItem( 44 | `driver: ${element.config.driver}`, 45 | vscode.TreeItemCollapsibleState.None 46 | ), 47 | ]); 48 | } 49 | return Promise.resolve([ 50 | new vscode.TreeItem( 51 | `host: ${element.config.host}`, 52 | vscode.TreeItemCollapsibleState.None 53 | ), 54 | new vscode.TreeItem( 55 | `port: ${element.config.port}`, 56 | vscode.TreeItemCollapsibleState.None 57 | ), 58 | new vscode.TreeItem( 59 | `user: ${element.config.user}`, 60 | vscode.TreeItemCollapsibleState.None 61 | ), 62 | new vscode.TreeItem( 63 | `database: ${element.config.database}`, 64 | vscode.TreeItemCollapsibleState.None 65 | ), 66 | new vscode.TreeItem( 67 | `driver: ${element.config.driver}`, 68 | vscode.TreeItemCollapsibleState.None 69 | ), 70 | ]); 71 | } 72 | const connections = 73 | this.context.globalState.get(storageKey) ?? []; 74 | 75 | return Promise.resolve( 76 | connections.map( 77 | (config) => 78 | new ConnectionListItem( 79 | config, 80 | config.name === this.activeConn, 81 | vscode.TreeItemCollapsibleState.Expanded 82 | ) 83 | ) 84 | ); 85 | } 86 | } 87 | 88 | export type ConnData = 89 | | ({ 90 | driver: Exclude; 91 | name: string; 92 | host: string; 93 | port: number; 94 | user: string; 95 | passwordKey: string; 96 | database: string; 97 | } & { 98 | [key: string]: any; 99 | }) 100 | | { 101 | driver: 'sqlite'; 102 | name: string; 103 | path: string; 104 | }; 105 | 106 | export class ConnectionListItem extends vscode.TreeItem { 107 | constructor( 108 | public readonly config: ConnData, 109 | public readonly isActive: boolean, 110 | public readonly collapsibleState: vscode.TreeItemCollapsibleState, 111 | public readonly command?: vscode.Command 112 | ) { 113 | super(config.name, collapsibleState); 114 | if (isActive) { 115 | this.iconPath = { 116 | dark: path.join(mediaDir, 'dark', 'dbconnection.svg'), 117 | light: path.join(mediaDir, 'light', 'dbconnection.svg'), 118 | }; 119 | this.description = 'Connected!'; 120 | } else { 121 | this.iconPath = { 122 | dark: path.join(mediaDir, 'dark', 'database.svg'), 123 | light: path.join(mediaDir, 'light', 'database.svg'), 124 | }; 125 | this.description = 'Inactive'; 126 | } 127 | this.contextValue = 'database'; 128 | } 129 | } 130 | 131 | export const mediaDir = path.join(__filename, '..', '..', 'media'); 132 | -------------------------------------------------------------------------------- /src/controller.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ExecutionResult } from './driver'; 3 | import { globalConnPool, notebookType } from './main'; 4 | import { resultToMarkdownTable } from './markdown'; 5 | 6 | export class SQLNotebookController { 7 | readonly controllerId = 'sql-notebook-executor'; 8 | readonly notebookType = notebookType; 9 | readonly label = 'SQL Notebook'; 10 | readonly supportedLanguages = ['sql']; 11 | 12 | private readonly _controller: vscode.NotebookController; 13 | private _executionOrder = 0; 14 | 15 | constructor() { 16 | this._controller = vscode.notebooks.createNotebookController( 17 | this.controllerId, 18 | this.notebookType, 19 | this.label 20 | ); 21 | 22 | this._controller.supportedLanguages = this.supportedLanguages; 23 | this._controller.supportsExecutionOrder = true; 24 | this._controller.executeHandler = this._execute.bind(this); 25 | } 26 | 27 | private async _execute( 28 | cells: vscode.NotebookCell[], 29 | _notebook: vscode.NotebookDocument, 30 | _controller: vscode.NotebookController 31 | ): Promise { 32 | for (let cell of cells) { 33 | // run each cell sequentially, awaiting its completion 34 | await this.doExecution(cell); 35 | } 36 | } 37 | 38 | dispose() { 39 | globalConnPool.pool?.end(); 40 | } 41 | 42 | private async doExecution(cell: vscode.NotebookCell): Promise { 43 | const execution = this._controller.createNotebookCellExecution(cell); 44 | execution.executionOrder = ++this._executionOrder; 45 | execution.start(Date.now()); 46 | 47 | // this is a sql block 48 | const rawQuery = cell.document.getText(); 49 | if (!globalConnPool.pool) { 50 | writeErr( 51 | execution, 52 | 'No active connection found. Configure database connections in the SQL Notebook sidepanel.' 53 | ); 54 | return; 55 | } 56 | const conn = await globalConnPool.pool.getConnection(); 57 | execution.token.onCancellationRequested(() => { 58 | console.debug('got cancellation request'); 59 | (async () => { 60 | conn.release(); 61 | conn.destroy(); 62 | writeErr(execution, 'Query cancelled'); 63 | })(); 64 | }); 65 | 66 | console.debug('executing query', { query: rawQuery }); 67 | let result: ExecutionResult; 68 | try { 69 | result = await conn.query(rawQuery); 70 | console.debug('sql query completed', result); 71 | conn.release(); 72 | } catch (err) { 73 | console.debug('sql query failed', err); 74 | // @ts-ignore 75 | writeErr(execution, err.message); 76 | conn.release(); 77 | return; 78 | } 79 | 80 | if (typeof result === 'string') { 81 | writeSuccess(execution, [[text(result)]]); 82 | return; 83 | } 84 | 85 | if ( 86 | result.length === 0 || 87 | (result.length === 1 && result[0].length === 0) 88 | ) { 89 | writeSuccess(execution, [[text('Successfully executed query')]]); 90 | return; 91 | } 92 | 93 | writeSuccess( 94 | execution, 95 | result.map((item) => { 96 | const outputs = [text(resultToMarkdownTable(item), 'text/markdown')]; 97 | if (outputJsonMimeType()) { 98 | outputs.push(json(item)); 99 | } 100 | return outputs; 101 | }) 102 | ); 103 | } 104 | } 105 | 106 | function writeErr(execution: vscode.NotebookCellExecution, err: string) { 107 | execution.replaceOutput([ 108 | new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.text(err)]), 109 | ]); 110 | execution.end(false, Date.now()); 111 | } 112 | 113 | const { text, json } = vscode.NotebookCellOutputItem; 114 | 115 | function writeSuccess( 116 | execution: vscode.NotebookCellExecution, 117 | outputs: vscode.NotebookCellOutputItem[][] 118 | ) { 119 | execution.replaceOutput( 120 | outputs.map((items) => new vscode.NotebookCellOutput(items)) 121 | ); 122 | execution.end(true, Date.now()); 123 | } 124 | 125 | function outputJsonMimeType(): boolean { 126 | return ( 127 | vscode.workspace.getConfiguration('SQLNotebook').get('outputJSON') ?? false 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/driver.ts: -------------------------------------------------------------------------------- 1 | import * as mysql from 'mysql2/promise'; 2 | import * as pg from 'pg'; 3 | import * as mssql from 'mssql'; 4 | import initSqlJs from 'sql.js'; 5 | import * as fs from 'fs/promises'; 6 | import type { Database as SqliteDatabase } from 'sql.js'; 7 | import * as path from 'path'; 8 | import * as vscode from 'vscode'; 9 | 10 | const supportedDrivers = ['mysql', 'postgres', 'mssql', 'sqlite'] as const; 11 | 12 | export type DriverKey = typeof supportedDrivers[number]; 13 | 14 | export interface Pool { 15 | getConnection: () => Promise; 16 | end: () => void; 17 | } 18 | 19 | // ExecutionResult can represent the output of multiple queries, 20 | // any of which can be `exec`, schema changes, `select`, etc. 21 | export type ExecutionResult = TabularResult[]; 22 | 23 | // TabularResult represents a table of data capable of marshalling into human readable output. 24 | export type TabularResult = Row[]; 25 | 26 | // Row represents an arbitrary map of data with marshallable values. 27 | export type Row = { [key: string]: any }; 28 | 29 | // Conn is an abstraction over driver-specific connection interfaces. 30 | interface Conn { 31 | release: () => void; 32 | query: (q: string) => Promise; 33 | destroy: () => void; 34 | } 35 | 36 | // PoolConfig exposes general and driver-specific configuration options for opening database pools. 37 | export type PoolConfig = 38 | | SqliteConfig 39 | | MySQLConfig 40 | | MSSQLConfig 41 | | PostgresConfig; 42 | 43 | export async function getPool(c: PoolConfig): Promise { 44 | switch (c.driver) { 45 | case 'mysql': 46 | return createMySQLPool(c); 47 | case 'mssql': 48 | return createMSSQLPool(c); 49 | case 'postgres': 50 | return createPostgresPool(c); 51 | case 'sqlite': 52 | return createSqLitePool(c); 53 | default: 54 | throw Error('invalid driver key'); 55 | } 56 | } 57 | 58 | // BaseConfig describes driver configuration options common between all implementations. 59 | // Driver-specific options are included in extensions that inherit this. 60 | interface BaseConfig { 61 | driver: DriverKey; 62 | host: string; 63 | port: number; 64 | user: string; 65 | password?: string; 66 | database?: string; 67 | 68 | queryTimeout: number; 69 | } 70 | 71 | interface SqliteConfig { 72 | driver: 'sqlite'; 73 | // :memory: for in-mem database 74 | // empty string for tmp on-disk file db 75 | path: string; 76 | } 77 | 78 | async function createSqLitePool({ 79 | path: filepath, 80 | }: SqliteConfig): Promise { 81 | const sqlite = await initSqlJs({ 82 | locateFile: (file) => 83 | path.join(__dirname, 'node_modules', 'sql.js', 'dist', file), 84 | }); 85 | if (filepath === ':memory:') { 86 | return sqlitePool(new sqlite.Database()); 87 | } 88 | 89 | const fullPath = path.resolve(workspaceRoot(), filepath); 90 | const buff = await fs.readFile(fullPath); 91 | const db = new sqlite.Database(buff); 92 | 93 | return sqlitePool(db, fullPath); 94 | } 95 | 96 | // TODO: think through how this should work 97 | // what should the sqlite filepath be relative too if we have multiple workspace roots?? 98 | const workspaceRoot = () => 99 | (vscode.workspace.workspaceFolders && 100 | vscode.workspace.workspaceFolders[0]?.uri.fsPath) || 101 | ''; 102 | 103 | function sqlitePool(pool: SqliteDatabase, dbFile?: string): Pool { 104 | return { 105 | async getConnection(): Promise { 106 | return sqliteConn(pool, dbFile); 107 | }, 108 | end: () => { 109 | pool.close(); 110 | }, 111 | }; 112 | } 113 | 114 | function sqliteConn(conn: SqliteDatabase, dbFile?: string): Conn { 115 | return { 116 | async query(q: string): Promise { 117 | const stm = conn.prepare(q); 118 | const result = [stm.getAsObject()]; 119 | while (stm.step()) { 120 | result.push(stm.getAsObject()); 121 | } 122 | stm.free(); 123 | if (dbFile) { 124 | const data = conn.export(); 125 | const buffer = Buffer.from(data); 126 | await fs.writeFile(dbFile, buffer); 127 | } 128 | 129 | return [result]; 130 | }, 131 | destroy: () => {}, 132 | release: () => {}, 133 | }; 134 | } 135 | 136 | interface MySQLConfig extends BaseConfig { 137 | driver: 'mysql'; 138 | multipleStatements: boolean; 139 | } 140 | 141 | async function createMySQLPool({ 142 | host, 143 | port, 144 | user, 145 | password, 146 | database, 147 | multipleStatements, 148 | queryTimeout, 149 | }: MySQLConfig): Promise { 150 | return mysqlPool( 151 | mysql.createPool({ 152 | host, 153 | port, 154 | user, 155 | password, 156 | database, 157 | multipleStatements, 158 | typeCast(field, next) { 159 | switch (field.type) { 160 | case 'TIMESTAMP': 161 | case 'DATE': 162 | case 'DATETIME': 163 | return field.string(); 164 | default: 165 | return next(); 166 | } 167 | }, 168 | }), 169 | queryTimeout 170 | ); 171 | } 172 | 173 | function mysqlPool(pool: mysql.Pool, queryTimeout: number): Pool { 174 | return { 175 | async getConnection(): Promise { 176 | return mysqlConn(await pool.getConnection(), queryTimeout); 177 | }, 178 | end() { 179 | pool.end(); 180 | }, 181 | }; 182 | } 183 | 184 | function mysqlConn(conn: mysql.PoolConnection, queryTimeout: number): Conn { 185 | return { 186 | destroy() { 187 | conn.destroy(); 188 | }, 189 | async query(q: string): Promise { 190 | const [result, ok] = (await conn.query({ 191 | sql: q, 192 | timeout: queryTimeout, 193 | })) as any; 194 | console.debug('mysql query result', { result, ok }); 195 | 196 | if (!result.length) { 197 | // this is a singleton exec query result 198 | return [[result]]; 199 | } 200 | 201 | // this indicates whether there are results for multiple distinct queries 202 | const hasMultipleResults = 203 | ok.length > 1 && ok.some((a: any) => a?.length); 204 | if (hasMultipleResults) { 205 | // when we have `ResultSetHeader`, which is the result of an exec request, 206 | // we want to nest that into an array so that is display as a single row table 207 | return result.map((res: any) => 208 | res.length !== undefined ? res : [res] 209 | ); 210 | } 211 | return [result]; 212 | }, 213 | release() { 214 | conn.release(); 215 | }, 216 | }; 217 | } 218 | 219 | interface PostgresConfig extends BaseConfig { 220 | driver: 'postgres'; 221 | } 222 | 223 | const identity = (input: T) => input; 224 | 225 | async function createPostgresPool({ 226 | host, 227 | port, 228 | user, 229 | password, 230 | database, 231 | queryTimeout, 232 | }: PostgresConfig): Promise { 233 | const pool = new pg.Pool({ 234 | host, 235 | port, 236 | password, 237 | database, 238 | user, 239 | query_timeout: queryTimeout, 240 | types: { 241 | getTypeParser(id, format) { 242 | switch (id) { 243 | case pg.types.builtins.TIMESTAMP: 244 | case pg.types.builtins.TIMESTAMPTZ: 245 | case pg.types.builtins.TIME: 246 | case pg.types.builtins.TIMETZ: 247 | case pg.types.builtins.DATE: 248 | case pg.types.builtins.INTERVAL: 249 | return identity; 250 | default: 251 | return pg.types.getTypeParser(id, format); 252 | } 253 | }, 254 | }, 255 | }); 256 | return postgresPool(pool); 257 | } 258 | 259 | function postgresPool(pool: pg.Pool): Pool { 260 | return { 261 | async getConnection(): Promise { 262 | const conn = await pool.connect(); 263 | return postgresConn(conn); 264 | }, 265 | end() { 266 | pool.end(); 267 | }, 268 | }; 269 | } 270 | 271 | function postgresConn(conn: pg.PoolClient): Conn { 272 | return { 273 | async query(q: string): Promise { 274 | const response = (await conn.query(q)) as any as pg.QueryResult[]; 275 | console.debug('pg query response', { response }); 276 | 277 | // Typings for pg unfortunately miss that `query` may return an array of 278 | // results when the query strings contains multiple sql statements. 279 | const maybeResponses = !!response.length 280 | ? response 281 | : ([response] as any as pg.QueryResult[]); 282 | 283 | return maybeResponses.map(({ rows, rowCount }) => { 284 | if (!rows.length) { 285 | return rowCount !== null ? [{ rowCount: rowCount }] : []; 286 | } 287 | return rows; 288 | }); 289 | }, 290 | destroy() { 291 | // TODO: verify 292 | conn.release(); 293 | }, 294 | release() { 295 | conn.release(); 296 | }, 297 | }; 298 | } 299 | 300 | interface MSSQLConfig extends BaseConfig { 301 | driver: 'mssql'; 302 | encrypt: boolean; 303 | trustServerCertificate: boolean; 304 | } 305 | 306 | async function createMSSQLPool(config: MSSQLConfig): Promise { 307 | const conn = await mssql.connect({ 308 | server: config.host, 309 | port: config.port, 310 | user: config.user, 311 | password: config.password, 312 | database: config.database, 313 | requestTimeout: config.queryTimeout, 314 | options: { 315 | encrypt: config.encrypt, 316 | trustServerCertificate: config.trustServerCertificate, 317 | }, 318 | }); 319 | return mssqlPool(conn); 320 | } 321 | 322 | function mssqlPool(pool: mssql.ConnectionPool): Pool { 323 | return { 324 | async getConnection(): Promise { 325 | const req = new mssql.Request(); 326 | return mssqlConn(req); 327 | }, 328 | end() { 329 | pool.close(); 330 | }, 331 | }; 332 | } 333 | 334 | function mssqlConn(req: mssql.Request): Conn { 335 | return { 336 | destroy() { 337 | req.cancel(); 338 | }, 339 | async query(q: string): Promise { 340 | // TODO: support multiple queries 341 | const res = await req.query(q); 342 | if (res.recordsets.length < 1) { 343 | return [[{ rows_affected: `${res.rowsAffected}` }]]; 344 | } 345 | return [res.recordsets[0]]; 346 | }, 347 | release() { 348 | // TODO: verify correctness 349 | }, 350 | }; 351 | } 352 | -------------------------------------------------------------------------------- /src/form.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ConnData } from './connections'; 3 | import { storageKey } from './main'; 4 | 5 | export function activateFormProvider(context: vscode.ExtensionContext) { 6 | const provider = new SQLConfigurationViewProvider( 7 | 'sqlnotebook.connectionForm', 8 | context 9 | ); 10 | context.subscriptions.push( 11 | vscode.window.registerWebviewViewProvider(provider.viewId, provider) 12 | ); 13 | } 14 | 15 | class SQLConfigurationViewProvider implements vscode.WebviewViewProvider { 16 | public readonly viewId: string; 17 | private readonly context: vscode.ExtensionContext; 18 | constructor(viewId: string, context: vscode.ExtensionContext) { 19 | this.viewId = viewId; 20 | this.context = context; 21 | } 22 | 23 | async resolveWebviewView( 24 | webviewView: vscode.WebviewView, 25 | _context: vscode.WebviewViewResolveContext, 26 | _token: vscode.CancellationToken 27 | ): Promise { 28 | webviewView.webview.options = { 29 | enableScripts: true, 30 | enableForms: true, 31 | localResourceRoots: [this.context.extensionUri], 32 | }; 33 | 34 | webviewView.webview.html = await getWebviewContent( 35 | webviewView.webview, 36 | this.context.extensionUri 37 | ); 38 | webviewView.webview.onDidReceiveMessage(async (message) => { 39 | switch (message.type) { 40 | case 'create_connection': 41 | const { displayName, password, port, ...rest } = message.data; 42 | 43 | const passwordKey = `sqlnotebook.${displayName}`; 44 | 45 | const newConfig = { 46 | ...rest, 47 | name: displayName, 48 | passwordKey, 49 | port: parseInt(port), 50 | }; 51 | 52 | if (!isValid(newConfig)) { 53 | return; 54 | } 55 | await this.context.secrets.store(passwordKey, password || ''); 56 | 57 | // this ensures we don't store the password in plain text 58 | delete newConfig.password; 59 | 60 | const existing = this.context.globalState 61 | .get(storageKey, []) 62 | .filter(({ name }) => name !== displayName); 63 | existing.push(newConfig); 64 | this.context.globalState.update(storageKey, existing); 65 | await vscode.commands.executeCommand( 66 | 'sqlnotebook.refreshConnectionPanel' 67 | ); 68 | webviewView.webview.postMessage({ type: 'clear_form' }); 69 | } 70 | }); 71 | } 72 | } 73 | 74 | function isValid(config: ConnData): boolean { 75 | if (config.driver === 'sqlite') { 76 | if (config.path) { 77 | return true; 78 | } 79 | vscode.window.showErrorMessage( 80 | `invalid "Path", must be nonempty. Use ":memory:" for an in-memory database.` 81 | ); 82 | return false; 83 | } 84 | if (!config.name) { 85 | vscode.window.showErrorMessage(`invalid "Database Name", must be nonempty`); 86 | return false; 87 | } 88 | if (!config.host) { 89 | vscode.window.showErrorMessage(`invalid "host", must be nonempty`); 90 | return false; 91 | } 92 | if (!config.port && config.port !== 0) { 93 | vscode.window.showErrorMessage( 94 | `invalid "port", must be parsable as an integer` 95 | ); 96 | return false; 97 | } 98 | return true; 99 | } 100 | 101 | async function getWebviewContent( 102 | webview: vscode.Webview, 103 | extensionUri: vscode.Uri 104 | ) { 105 | const bundlePath = getUri(webview, extensionUri, [ 106 | 'dist', 107 | 'webview', 108 | 'main-bundle.js', 109 | ]); 110 | 111 | return ` 112 | 113 | 114 | 115 | 116 | 117 | SQL Notebook New Connection 118 | 119 | 120 |
121 | 122 | 123 | 124 | `; 125 | } 126 | 127 | function getUri( 128 | webview: vscode.Webview, 129 | extensionUri: vscode.Uri, 130 | pathList: string[] 131 | ) { 132 | return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, ...pathList)); 133 | } 134 | -------------------------------------------------------------------------------- /src/lsp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LanguageClient, 3 | LanguageClientOptions, 4 | ServerOptions, 5 | } from 'vscode-languageclient/node'; 6 | import * as vscode from 'vscode'; 7 | import { DriverKey } from './driver'; 8 | import * as path from 'path'; 9 | 10 | export interface LspConfig { 11 | binPath: string; 12 | host: string; 13 | port: number; 14 | user: string; 15 | password?: string; 16 | database?: string; 17 | driver: SqlsDriver; 18 | } 19 | 20 | export type SqlsDriver = 'mysql' | 'postgresql' | 'mysql8' | 'sqlight3'; // TODO: complete 21 | 22 | export const sqlsDriverFromDriver = ( 23 | driverKey: DriverKey 24 | ): SqlsDriver | null => { 25 | switch (driverKey) { 26 | case 'mysql': 27 | return 'mysql'; 28 | case 'postgres': 29 | return 'postgresql'; 30 | } 31 | return null; 32 | }; 33 | 34 | export function getCompiledLSPBinaryPath(): string | null { 35 | const { arch, platform } = process; 36 | const goarch = { arm64: 'arm64', x64: 'amd64' }[arch]; 37 | const goos = { linux: 'linux', darwin: 'darwin', win32: 'windows' }[ 38 | platform.toString() 39 | ]; 40 | if (!goarch && !goos) { 41 | return null; 42 | } 43 | return path.join( 44 | __filename, 45 | '..', 46 | '..', 47 | 'sqls_bin', 48 | `sqls_${goarch}_${goos}` 49 | ); 50 | } 51 | 52 | export class SqlLspClient { 53 | private client: LanguageClient | null; 54 | constructor() { 55 | this.client = null; 56 | } 57 | start(config: LspConfig) { 58 | let serverOptions: ServerOptions = { 59 | command: config.binPath, 60 | args: [], 61 | }; 62 | 63 | let clientOptions: LanguageClientOptions = { 64 | documentSelector: [{ language: 'sql' }], 65 | initializationOptions: { 66 | disableCodeAction: true, 67 | connectionConfig: { 68 | driver: config.driver, 69 | user: config.user, 70 | passwd: config.password, 71 | 72 | host: config.host, 73 | port: config.port, 74 | 75 | dbName: config.database, 76 | 77 | proto: 'tcp', 78 | }, 79 | }, 80 | outputChannel: vscode.window.createOutputChannel('sqls'), 81 | }; 82 | 83 | this.client = new LanguageClient('sqls', serverOptions, clientOptions); 84 | this.client.start(); 85 | } 86 | async stop() { 87 | await this.client?.stop(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { SQLNotebookConnections } from './connections'; 3 | import { connectToDatabase, deleteConnectionConfiguration } from './commands'; 4 | import { Pool } from './driver'; 5 | import { activateFormProvider } from './form'; 6 | import { SqlLspClient } from './lsp'; 7 | import { SQLSerializer } from './serializer'; 8 | import { SQLNotebookController } from './controller'; 9 | 10 | export const notebookType = 'sql-notebook'; 11 | export const storageKey = 'sqlnotebook-connections'; 12 | 13 | export const globalConnPool: { pool: Pool | null } = { 14 | pool: null, 15 | }; 16 | 17 | export const globalLspClient = new SqlLspClient(); 18 | 19 | export function activate(context: vscode.ExtensionContext) { 20 | context.subscriptions.push( 21 | vscode.workspace.registerNotebookSerializer( 22 | notebookType, 23 | new SQLSerializer() 24 | ) 25 | ); 26 | const connectionsSidepanel = new SQLNotebookConnections(context); 27 | vscode.window.registerTreeDataProvider( 28 | 'sqlnotebook-connections', 29 | connectionsSidepanel 30 | ); 31 | 32 | activateFormProvider(context); 33 | 34 | context.subscriptions.push(new SQLNotebookController()); 35 | 36 | vscode.commands.registerCommand( 37 | 'sqlnotebook.deleteConnectionConfiguration', 38 | deleteConnectionConfiguration(context, connectionsSidepanel) 39 | ); 40 | 41 | vscode.commands.registerCommand('sqlnotebook.refreshConnectionPanel', () => { 42 | connectionsSidepanel.refresh(); 43 | }); 44 | vscode.commands.registerCommand( 45 | 'sqlnotebook.connect', 46 | connectToDatabase(context, connectionsSidepanel) 47 | ); 48 | } 49 | 50 | export function deactivate() {} 51 | -------------------------------------------------------------------------------- /src/markdown.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Row, TabularResult } from './driver'; 3 | 4 | export function resultToMarkdownTable(result: TabularResult): string { 5 | if (result.length < 1) { 6 | return '*Empty Results Table*'; 7 | } 8 | 9 | const maxRows = getMaxRows(); 10 | if (result.length > maxRows) { 11 | result = result.slice(0, maxRows); 12 | result.push( 13 | Object.fromEntries(Object.entries(result).map((pair) => [pair[0], '...'])) 14 | ); 15 | } 16 | return `${markdownHeader(result[0])}\n${result.map(markdownRow).join('\n')}`; 17 | } 18 | 19 | function getMaxRows(): number { 20 | const fallbackMaxRows = 25; 21 | const maxRows: number | undefined = vscode.workspace 22 | .getConfiguration('SQLNotebook') 23 | .get('maxResultRows'); 24 | return maxRows ?? fallbackMaxRows; 25 | } 26 | 27 | function serializeCell(a: any): any { 28 | try { 29 | // serialize buffers as hex strings 30 | if (Buffer.isBuffer(a)) { 31 | return `0x${a.toString('hex')}`; 32 | } 33 | // attempt to serialize all remaining "object" values as JSON 34 | if (typeof a === 'object') { 35 | return JSON.stringify(a); 36 | } 37 | if (typeof a === 'string') { 38 | return a.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); 39 | } 40 | return a; 41 | } catch { 42 | return a; 43 | } 44 | } 45 | 46 | function markdownRow(row: Row): string { 47 | const middle = Object.entries(row) 48 | .map((pair) => pair[1]) 49 | .map(serializeCell) 50 | .join(' | '); 51 | return `| ${middle} |`; 52 | } 53 | 54 | function markdownHeader(obj: Row): string { 55 | const keys = Object.keys(obj).join(' | '); 56 | const divider = Object.keys(obj) 57 | .map(() => '--') 58 | .join(' | '); 59 | return `| ${keys} |\n| ${divider} |`; 60 | } 61 | -------------------------------------------------------------------------------- /src/serializer.ts: -------------------------------------------------------------------------------- 1 | import { TextDecoder, TextEncoder } from 'util'; 2 | import * as vscode from 'vscode'; 3 | 4 | // Cell block delimiter 5 | const DELIMITER = '\n\n'; 6 | 7 | export class SQLSerializer implements vscode.NotebookSerializer { 8 | async deserializeNotebook( 9 | context: Uint8Array, 10 | _token: vscode.CancellationToken 11 | ): Promise { 12 | const str = new TextDecoder().decode(context); 13 | const blocks = splitSqlBlocks(str); 14 | 15 | const cells = blocks.map((query) => { 16 | const isMarkdown = query.startsWith('/*markdown') && query.endsWith('*/'); 17 | if (isMarkdown) { 18 | const lines = query.split('\n'); 19 | const innerMarkdown = 20 | lines.length > 2 ? lines.slice(1, lines.length - 1).join('\n') : ''; 21 | return new vscode.NotebookCellData( 22 | vscode.NotebookCellKind.Markup, 23 | innerMarkdown, 24 | 'markdown' 25 | ); 26 | } 27 | 28 | return new vscode.NotebookCellData( 29 | vscode.NotebookCellKind.Code, 30 | query, 31 | 'sql' 32 | ); 33 | }); 34 | return new vscode.NotebookData(cells); 35 | } 36 | 37 | async serializeNotebook( 38 | data: vscode.NotebookData, 39 | _token: vscode.CancellationToken 40 | ): Promise { 41 | return new TextEncoder().encode( 42 | data.cells 43 | .map(({ value, kind }) => 44 | kind === vscode.NotebookCellKind.Code 45 | ? value 46 | : `/*markdown\n${value}\n*/` 47 | ) 48 | .join(DELIMITER) 49 | ); 50 | } 51 | } 52 | 53 | function splitSqlBlocks(raw: string): string[] { 54 | const blocks = []; 55 | for (const block of raw.split(DELIMITER)) { 56 | if (block.trim().length > 0) { 57 | blocks.push(block); 58 | continue; 59 | } 60 | if (blocks.length < 1) { 61 | continue; 62 | } 63 | blocks[blocks.length - 1] += '\n\n'; 64 | } 65 | return blocks; 66 | } 67 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2019", 5 | "lib": ["ES2019"], 6 | "rootDir": ".", 7 | "types": ["node"], 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "references": [] 15 | } 16 | -------------------------------------------------------------------------------- /webview/Form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | VSCodeButton, 4 | VSCodeTextField, 5 | VSCodeDropdown, 6 | VSCodeOption, 7 | VSCodeCheckbox, 8 | } from '@vscode/webview-ui-toolkit/react'; 9 | 10 | const Form: React.FC<{ handleSubmit: (form: HTMLFormElement) => void }> = ({ 11 | handleSubmit, 12 | }) => { 13 | const { 14 | ref: dropdownRef, 15 | value: driver, 16 | setValue: setDriver, 17 | } = useDropdownValue(); 18 | const formRef = React.useRef(null); 19 | 20 | React.useEffect(() => { 21 | window.addEventListener('message', (event) => { 22 | const { data } = event; 23 | switch (data.type) { 24 | case 'clear_form': 25 | formRef.current?.reset(); 26 | setDriver('mysql'); 27 | } 28 | }); 29 | }, []); 30 | 31 | return ( 32 |
33 | 34 |
35 | 38 | 39 | mysql 40 | postgres 41 | mssql 42 | sqlite 43 | 44 |
45 | {/* special case for sqlite, don't need default options */} 46 | {driver !== 'sqlite' && ( 47 | <> 48 | 49 | 50 | 51 | 56 | 57 | 58 | )} 59 | 60 | {showDriverConfig(driver)} 61 | 62 |
63 | { 66 | formRef.current?.reset(); 67 | setDriver('mysql'); 68 | }} 69 | > 70 | Clear 71 | 72 | handleSubmit(formRef.current!)}> 73 | Create 74 | 75 |
76 | 77 | ); 78 | }; 79 | 80 | export default Form; 81 | 82 | function useDropdownValue() { 83 | // warning, this is hacky 84 | // since the dropdown web component does not seem to respect 85 | // setting `value` as a controlled prop, this can easily get out of sync. 86 | // 87 | // so we have to manually ensure that during form resets 88 | // the value is in sync. 89 | const [value, setValue] = React.useState('mysql'); 90 | const ref = React.useRef(null); 91 | React.useEffect(() => { 92 | const { current } = ref; 93 | current?.addEventListener('change', (e) => { 94 | setValue((e.target as HTMLInputElement)?.value); 95 | }); 96 | }, [ref.current]); 97 | return { ref, value, setValue }; 98 | } 99 | 100 | function showDriverConfig(driver: string) { 101 | switch (driver) { 102 | case 'mysql': 103 | return ( 104 | <> 105 | 106 | Multiple statements 107 | 108 | 109 | ); 110 | case 'postgres': 111 | return <>; 112 | case 'mssql': 113 | return ( 114 | <> 115 | 116 | Encrypt 117 | 118 | 119 | Trust Server Certificate 120 | 121 | 122 | ); 123 | case 'sqlite': 124 | return ; 125 | } 126 | return <>; 127 | } 128 | 129 | const TextOption: React.FC<{ 130 | label: string; 131 | objectKey: string; 132 | type?: string; 133 | }> = ({ objectKey, label, type }) => { 134 | return ( 135 | 136 | {label} 137 | 138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /webview/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import Form from './Form'; 4 | 5 | const vscode = acquireVsCodeApi(); 6 | 7 | function createConnection(config: any) { 8 | vscode.postMessage({ type: 'create_connection', data: config }); 9 | } 10 | 11 | function handleSubmit(form: HTMLFormElement) { 12 | // @ts-ignore 13 | const data = Object.fromEntries(new FormData(form)); 14 | 15 | // now for some data cleanup 16 | if (data.encrypt) { 17 | // if "on", we want `true`, if nullish, we want false 18 | data.encrypt = !!data.encrypt; 19 | } 20 | if (data.trustServerCertificate) { 21 | // if "on", we want `true`, if nullish, we want false 22 | data.trustServerCertificate = !!data.trustServerCertificate; 23 | } 24 | 25 | createConnection(data); 26 | } 27 | 28 | document.addEventListener('DOMContentLoaded', () => { 29 | const root = document.getElementById('root'); 30 | ReactDOM.render(
, root); 31 | }); 32 | -------------------------------------------------------------------------------- /webview/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | // "target": "es5", 6 | "lib": ["ES2019", "dom"], 7 | "outDir": "../dist/webview", 8 | "moduleResolution": "node", 9 | "strict": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------