├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .tool-versions ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages ├── codegen │ ├── assets-src │ │ └── icon.svg │ ├── package.json │ └── src │ │ ├── OrigConfig.d.ts.ejs │ │ └── README.md.ejs ├── core │ ├── package.json │ ├── src │ │ ├── bigquery.test.ts │ │ ├── bigquery.ts │ │ ├── flat.test.ts │ │ ├── flat.ts │ │ ├── formatter.test.ts │ │ ├── formatter.ts │ │ ├── gcloud.ts │ │ ├── index.ts │ │ ├── metadata.json │ │ ├── parser.test.ts │ │ ├── parser.ts │ │ ├── table.json │ │ ├── transform.test.ts │ │ └── transform.ts │ └── tsconfig.json ├── extension │ ├── package.json │ ├── src │ │ ├── OrigConfig.d.ts │ │ ├── checksum.ts │ │ ├── configManager.ts │ │ ├── downloader.ts │ │ ├── dryRunner.ts │ │ ├── errorManager.ts │ │ ├── errorMarker.ts │ │ ├── getQueryText.ts │ │ ├── index.ts │ │ ├── isBigQuery.ts │ │ ├── logger.ts │ │ ├── paramManager.ts │ │ ├── previewer.ts │ │ ├── quickfix.ts │ │ ├── renderer.ts │ │ ├── runner.ts │ │ ├── statusManager.ts │ │ ├── tree.ts │ │ └── window.ts │ └── tsconfig.json ├── misc │ ├── assets-src │ │ ├── icon-copy.svg │ │ ├── icon-end.svg │ │ ├── icon-next.svg │ │ ├── icon-panel.svg │ │ ├── icon-prev.svg │ │ ├── icon-start.svg │ │ ├── icon.svg │ │ └── loading.svg │ ├── assets │ │ ├── icon-activity-bar.svg │ │ ├── icon-panel.png │ │ └── icon.png │ ├── docs │ │ └── flow.md │ ├── mock │ │ ├── rows.json │ │ └── vscode.css │ ├── package.json │ ├── queries │ │ ├── array-join.bqsql │ │ ├── create-or-replace-a.bqsql │ │ ├── create-or-replace-b.bqsql │ │ ├── create-or-replace.bqsql │ │ ├── create-struct-array.bqsql │ │ ├── empty.bqsql │ │ ├── error-quickfix.bqsql │ │ ├── error-syntax.bqsql │ │ ├── error-type.bqsql │ │ ├── error.bqsql │ │ ├── geom.bqsql │ │ ├── in-code.ts │ │ ├── join-differect-pj-tables.bqsql │ │ ├── json.bqsql │ │ ├── merge.bqsql │ │ ├── nullable-struct.bqsql │ │ ├── params-arrays.bqsql │ │ ├── params-named-duplicated.bqsql │ │ ├── params-named-with-comments.bqsql │ │ ├── params-named-with-strings.bqsql │ │ ├── params-named.bqsql │ │ ├── params-positional-with-comments.bqsql │ │ ├── params-positional-with-strings.bqsql │ │ ├── params-positional.bqsql │ │ ├── params-structs.bqsql │ │ ├── params-timestamps.bqsql │ │ ├── procedure.bqsql │ │ ├── select-a.bqsql │ │ ├── select-b.bqsql │ │ ├── select.bqsql │ │ ├── spreadsheet.bqsql │ │ ├── spreadsheet.tsv │ │ ├── system-variables.bqsql │ │ ├── types.bqsql │ │ ├── variables.bqsql │ │ ├── wikipedia.bqsql │ │ └── without-dataset.bqsql │ └── screenshots │ │ ├── dry-run.gif │ │ ├── query-validation.gif │ │ └── run.gif ├── shared │ ├── package.json │ ├── src │ │ ├── error.ts │ │ ├── funcs.test.ts │ │ ├── funcs.ts │ │ ├── index.ts │ │ ├── result.ts │ │ └── types.ts │ └── tsconfig.json └── viewer │ ├── config │ ├── env.js │ ├── getHttpsConfig.js │ ├── jest │ │ ├── babelTransform.js │ │ ├── cssTransform.js │ │ └── fileTransform.js │ ├── modules.js │ └── paths.js │ ├── package.json │ ├── public │ └── index.html │ ├── scripts │ └── test.js │ ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── context │ │ └── Clipboard.tsx │ ├── domain │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Job.test.tsx │ │ ├── Job.tsx │ │ ├── Routine.tsx │ │ ├── Rows.tsx │ │ └── Table.tsx │ ├── index.tsx │ ├── jobEvent.json │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── routineEvent.json │ ├── setupTests.ts │ ├── tableEvent.json │ ├── theme.ts │ ├── types.ts │ └── ui │ │ ├── Breakable.tsx │ │ └── CopyButton.tsx │ └── tsconfig.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 12, 6 | "sourceType": "module", 7 | "project": "tsconfig.json" 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:import/recommended", 13 | "plugin:react/recommended", 14 | "prettier" 15 | ], 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "import", 19 | "unused-imports", 20 | "react", 21 | "react-hooks", 22 | "strict-dependencies", 23 | "sonarjs" 24 | ], 25 | "rules": { 26 | // Do with TypeScript 27 | "no-undef": "off", 28 | 29 | "sonarjs/no-ignored-return": "error", 30 | 31 | // https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492 32 | "@typescript-eslint/ban-types": [ 33 | "error", 34 | { 35 | "extendDefaults": true, 36 | "types": { 37 | "{}": false 38 | } 39 | } 40 | ], 41 | "@typescript-eslint/naming-convention": [ 42 | "warn", 43 | { 44 | "selector": "function", 45 | "format": ["PascalCase", "camelCase"] 46 | } 47 | ], 48 | "@typescript-eslint/semi": "warn", 49 | "@typescript-eslint/no-floating-promises": "error", 50 | "@typescript-eslint/no-invalid-void-type": "error", 51 | "@typescript-eslint/consistent-type-imports": [ 52 | "error", 53 | { 54 | "prefer": "type-imports" 55 | } 56 | ], 57 | 58 | // Use unused-imports/no-unused-vars 59 | "@typescript-eslint/no-unused-vars": "off", 60 | 61 | "no-useless-rename": "error", 62 | "object-shorthand": "error", 63 | 64 | "import/order": [ 65 | "error", 66 | { 67 | "groups": ["builtin", "external", "parent", "sibling", "index"], 68 | "newlines-between": "never", 69 | "alphabetize": { 70 | "order": "asc", 71 | "caseInsensitive": false 72 | } 73 | } 74 | ], 75 | "import/no-unresolved": "off", 76 | "unused-imports/no-unused-imports": "error", 77 | "unused-imports/no-unused-vars": [ 78 | "warn", 79 | { 80 | "vars": "all", 81 | "varsIgnorePattern": "^_", 82 | "args": "after-used", 83 | "argsIgnorePattern": "^_" 84 | } 85 | ], 86 | "curly": "error", 87 | "eqeqeq": "error", 88 | "no-throw-literal": "warn", 89 | "semi": "off", 90 | "react-hooks/rules-of-hooks": "error", 91 | "react-hooks/exhaustive-deps": "error", 92 | "react/prop-types": "off", 93 | "react/forbid-elements": [ 94 | "error", 95 | { 96 | "forbid": [ 97 | "a", 98 | "abbr", 99 | "address", 100 | "area", 101 | "article", 102 | "aside", 103 | "audio", 104 | "b", 105 | "base", 106 | "bdi", 107 | "bdo", 108 | "big", 109 | "blockquote", 110 | "body", 111 | "br", 112 | "button", 113 | "canvas", 114 | "caption", 115 | "cite", 116 | "code", 117 | "col", 118 | "colgroup", 119 | "data", 120 | "datalist", 121 | "dd", 122 | "del", 123 | "details", 124 | "dfn", 125 | "dialog", 126 | "div", 127 | "dl", 128 | "dt", 129 | "em", 130 | "embed", 131 | "fieldset", 132 | "figcaption", 133 | "figure", 134 | "footer", 135 | // "form", 136 | "h1", 137 | "h2", 138 | "h3", 139 | "h4", 140 | "h5", 141 | "h6", 142 | "head", 143 | "header", 144 | "hr", 145 | "html", 146 | "i", 147 | "iframe", 148 | "img", 149 | "input", 150 | "ins", 151 | "kbd", 152 | "keygen", 153 | "label", 154 | "legend", 155 | "li", 156 | "link", 157 | "main", 158 | "map", 159 | "mark", 160 | "menu", 161 | "menuitem", 162 | "meta", 163 | "meter", 164 | "nav", 165 | "noscript", 166 | "object", 167 | "ol", 168 | "optgroup", 169 | "option", 170 | "output", 171 | "p", 172 | "param", 173 | "picture", 174 | "pre", 175 | "progress", 176 | "q", 177 | "rp", 178 | "rt", 179 | "ruby", 180 | "s", 181 | "samp", 182 | "script", 183 | "section", 184 | "select", 185 | "small", 186 | "source", 187 | "span", 188 | "strong", 189 | "style", 190 | "sub", 191 | "summary", 192 | "sup", 193 | "table", 194 | "tbody", 195 | "td", 196 | "textarea", 197 | "tfoot", 198 | "th", 199 | "thead", 200 | "time", 201 | "title", 202 | "tr", 203 | "track", 204 | "u", 205 | "ul", 206 | "var", 207 | "video", 208 | "wbr" 209 | ] 210 | } 211 | ], 212 | 213 | "strict-dependencies/strict-dependencies": [ 214 | "error", 215 | [ 216 | { 217 | "module": "src/pages", 218 | "allowReferenceFrom": ["src/App.tsx"], 219 | "allowSameModule": false 220 | }, 221 | { 222 | "module": "next/domain", 223 | "allowReferenceFrom": ["src/pages"], 224 | "allowSameModule": false 225 | }, 226 | { 227 | "module": "src/ui", 228 | "allowReferenceFrom": ["src/domain"], 229 | "allowSameModule": true 230 | } 231 | ], 232 | { 233 | "resolveRelativeImport": true 234 | } 235 | ] 236 | }, 237 | "ignorePatterns": ["out", "dist", "**/*.d.ts"], 238 | "settings": { 239 | "react": { 240 | "version": "detect" 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: minodisk 2 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 16 16 | - name: Install modules 17 | run: npm ci 18 | - name: "Authenticate to Google Cloud" 19 | uses: "google-github-actions/auth@v0.4.0" 20 | with: 21 | credentials_json: "${{ secrets.GOOGLE_CREDENTIALS }}" 22 | - name: Check 23 | run: npm run check 24 | - name: Lint 25 | run: npm run lint 26 | - name: Run headless test 27 | uses: GabrielBB/xvfb-action@v1.0 28 | with: 29 | run: npm test 30 | - name: Build 31 | run: npm run build 32 | - name: Publish to Open VSX Registry 33 | uses: HaaLeo/publish-vscode-extension@v1 34 | with: 35 | pat: ${{ secrets.OPEN_VSX_TOKEN }} 36 | - name: Publish to Visual Studio Marketplace 37 | uses: HaaLeo/publish-vscode-extension@v1 38 | with: 39 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 40 | registryUrl: https://marketplace.visualstudio.com 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 16 16 | - name: Install modules 17 | run: npm ci 18 | - name: "Authenticate to Google Cloud" 19 | uses: "google-github-actions/auth@v0.4.0" 20 | with: 21 | credentials_json: "${{ secrets.GOOGLE_CREDENTIALS }}" 22 | - name: Check 23 | run: npm run check 24 | - name: Lint 25 | run: npm run lint 26 | - name: Run headless test 27 | uses: GabrielBB/xvfb-action@v1.0 28 | with: 29 | run: npm test 30 | - name: Coverage 31 | uses: codecov/codecov-action@v2 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode-test 3 | coverage 4 | dist 5 | out 6 | output 7 | node_modules 8 | service-account.json 9 | *.jsonl 10 | *.csv 11 | *.txt 12 | 名称未設定.* 13 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.9.13 2 | nodejs 20.10.0 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /.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": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": [ 21 | "--extensionDevelopmentPath=${workspaceFolder}", 22 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 23 | ], 24 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 25 | "preLaunchTask": "${defaultBuildTask}" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | 12 | "[html]": { 13 | "editor.formatOnSave": false 14 | }, 15 | 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": "explicit" 18 | }, 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | "eslint.validate": ["javascript", "typescript"], 21 | 22 | "Prettier-SQL.SQLFlavourOverride": "bigquery", 23 | "Prettier-SQL.tabSizeOverride": 2, 24 | "Prettier-SQL.linesBetweenQueries": 1, 25 | "[sql]": { 26 | "editor.defaultFormatter": "inferrinizzard.prettier-sql-vscode", 27 | "editor.tabSize": 2 28 | }, 29 | 30 | // "bigqueryRunner.keyFilename": "./service-account.json", 31 | "bigqueryRunner.projectId": "minodisk-api", 32 | "bigqueryRunner.tree.projectIds": ["vertect"] 33 | } 34 | -------------------------------------------------------------------------------- /.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 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .github 3 | .vscode 4 | coverage 5 | dist 6 | node_modules 7 | output 8 | packages 9 | .eslintrc.json 10 | .gitignore 11 | package-lock.json 12 | service-account.json 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | First, this VSCode extension is responsible for the following as it relates to BigQuery: 4 | 5 | - Dry-run the query 6 | - Run the query 7 | - Feedback errors to the editor 8 | - Display the results of the query 9 | - Limited static analysis of queries 10 | - For finding query parameters 11 | 12 | And it does not do the following: 13 | 14 | - Syntax highlighting 15 | - Static analysis of queries 16 | - For other than finding query parameters 17 | 18 | ## Issues 19 | 20 | ### Bugs 21 | 22 | When reporting a bug, please provide the smallest query that can reproduce the bug. 23 | 24 | ### Features 25 | 26 | New features and feature improvements should be proposed in an issue first. This may solve the problem faster than making a Pull Request without doing so. 27 | 28 | ## Pull Request 29 | 30 | If you send a Pull Request, please test and debug it. 31 | 32 | ### Install packages 33 | 34 | First, please install the dependent packages: 35 | 36 | ``` 37 | npm install 38 | ``` 39 | 40 | ### Test 41 | 42 | Unit test: 43 | 44 | ``` 45 | npm test 46 | ``` 47 | 48 | Compiler/Formatter check: 49 | 50 | ``` 51 | npm run check 52 | ``` 53 | 54 | Lint: 55 | 56 | ``` 57 | npm run check 58 | ``` 59 | 60 | ### Debug 61 | 62 | Debug it to check its behavior around the VSCode extension: 63 | 64 | 1. [Create a service account and its key](https://cloud.google.com/docs/authentication/getting-started) and save it in the project root with the name `service-account.json`. 65 | 1. Run `Shell Command: Install 'code' command in PATH` in VSCode command palette. 66 | 1. Run `npm run debug` to build BigQuery Runner and install it into VSCode. 67 | 1. Run the command `Developer: Reload Window` in VSCode to activate the newly installed BigQuery Runner. 68 | 1. Open a query file and run the command `BigQuery Runner: Run` in VSCode. 69 | -------------------------------------------------------------------------------- /packages/codegen/assets-src/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/codegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "codegen", 4 | "version": "0.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "nodemon --exec 'npm run build'", 9 | "build": "run-p build:*", 10 | "build:readme": "ejs ./src/README.md.ejs -f ../../package.json -o ../../README.md", 11 | "build:dts": "ejs ./src/OrigConfig.d.ts.ejs -f ../../package.json -o ../extension/src/OrigConfig.d.ts" 12 | }, 13 | "nodemonConfig": { 14 | "watch": [ 15 | "./src/", 16 | "../../package.json" 17 | ], 18 | "ext": "ejs,json" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/codegen/src/OrigConfig.d.ts.ejs: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT 2 | // This file is generated from gen-src/config.d.ts.ejs. 3 | 4 | <% 5 | const config = Object.keys(contributes.configuration.properties).reduce((obj, property) => { 6 | const value = contributes.configuration.properties[property]; 7 | const properties = property.split('.'); 8 | const last = properties.length - 1; 9 | properties.reduce((o, property, i) => { 10 | if (i === 0) { 11 | return o 12 | } 13 | if (i === last) { 14 | o[property] = value 15 | return o 16 | } 17 | if (!o[property]) { 18 | o[property] = {} 19 | } 20 | return o[property] 21 | }, obj) 22 | return obj 23 | }, {}) 24 | function build(config, indent = 0) { 25 | return `Readonly<{ 26 | ${Object.keys(config).map((key) => 27 | `${' '.repeat(indent + 1)}${key}: ${(config[key].description != null) ? buildType(config[key]) : build(config[key], indent + 1)};` 28 | ).join('\n')} 29 | ${' '.repeat(indent)}}>` 30 | } 31 | function buildType(value) { 32 | if (value.enum) { 33 | return value.enum.map(JSON.stringify, JSON).map((t) => t === 'null' ? 'undefined' : t).join(' | ') 34 | } 35 | if (Array.isArray(value.type)) { 36 | return value.type.map((t) => t === 'null' ? 'undefined' : t).join(' | ') 37 | } 38 | if (value.type === 'array') { 39 | if (!value.default) { 40 | return `Array`; 41 | } 42 | if (value.items) { 43 | return `Array<${value.items.type}>`; 44 | } 45 | const typeSet = value.default.reduce((s, v) => { 46 | s.add(typeof v) 47 | return s 48 | }, new Set()); 49 | return `Array<${ Array.from(typeSet).join(" | ") }>` 50 | } 51 | return value.type 52 | } 53 | -%> 54 | export type OrigConfig = <%- build(config) %>; 55 | -------------------------------------------------------------------------------- /packages/codegen/src/README.md.ejs: -------------------------------------------------------------------------------- 1 | 3 | <% const base = 'https://storage.googleapis.com/bigquery-runner/' -%> 4 | # BigQuery Runner [![GitHub Actions](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fminodisk%2Fbigquery-runner%2Fbadge%3Fref%3Dmain&style=flat-square)](https://actions-badge.atrox.dev/minodisk/bigquery-runner/goto?ref=main) [![Visual Studio Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/minodisk.bigquery-runner?style=flat-square)](https://marketplace.visualstudio.com/items?itemName=minodisk.bigquery-runner) [![Codecov](https://img.shields.io/codecov/c/github/minodisk/bigquery-runner?style=flat-square)](https://app.codecov.io/gh/minodisk/bigquery-runner/) 5 | 6 | An extension to query BigQuery directly and view the results in VSCode. 7 | 8 | ![Preview](https://user-images.githubusercontent.com/514164/180352233-ed635538-f064-4389-814a-c3ec306aa832.gif) 9 | 10 | ## Features 11 | 12 | - Mark errors in queries. 13 | - If the query error can be corrected automatically, suggest a candidate for a quick fix. 14 | - Run queries: 15 | - from files. 16 | - from selected text. 17 | - with query parameters. 18 | - Display the results in viewers: 19 | - Rows 20 | - Fast rendering of large result tables. 21 | - Pagination. 22 | - Can be downloaded as a file. 23 | - Table 24 | - Temporary tables can be opened in yet another viewer. 25 | - Schema 26 | - Routine 27 | - Job 28 | - Download the rows in a variety of formats, both from the viewer and from the query file: 29 | - JSON Lines 30 | - JSON 31 | - CSV 32 | - Markdown 33 | - Plain text 34 | - Pretty formatted text like a table. 35 | - All operations can be executed from [commands](#commands). 36 | - Therefore, it can be set to be performed with [keyboard shortcuts](#keyboard-shortcuts). 37 | - Of course, it can also be operated from the GUI. 38 | 39 | ## Installation 40 | 41 | 1. Go to [the page of this extension in Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=minodisk.bigquery-runner). 42 | 2. Click the `Install` button. 43 | 3. This will open the VSCode page for this extension, and click the `Install` button. 44 | 45 | ## Authentication 46 | 47 | This extension requires authentication to the Google Cloud API. You can get started by authenticating in one of the following two ways. 48 | 49 | ### Gcloud Credential ([Recommended](https://cloud.google.com/iam/docs/best-practices-service-accounts#development)) 50 | 51 | 56 | 57 | 1. [Install the gcloud CLI](https://cloud.google.com/sdk/docs/install). 58 | 1. Run [`gcloud auth application-default login`](https://cloud.google.com/sdk/gcloud/reference/auth/application-default) in your terminal. 59 | 1. Set `bigqueryRunner.projectId` in `setting.json`. 60 | 61 | - Don't set `bigqueryRunner.keyFilename` in `setting.json`. 62 | - Don't set `GOOGLE_APPLICATION_CREDENTIALS` as an environment variable. 63 | 64 | ### Service Account Key 65 | 66 | 1. [Create a service account and its key](https://cloud.google.com/docs/authentication/getting-started). 67 | - Give the service account the necessary roles. Such as [`roles/bigquery.user`](https://cloud.google.com/bigquery/docs/access-control#bigquery.user) for example. 68 | 1. Tell the key path to this extension in one of the following two ways: 69 | - Set the path to the key `bigqueryRunner.keyFilename` in `settings.json`. 70 | - [Set the path to the key as the environment variable `GOOGLE_APPLICATION_CREDENTIALS`](https://cloud.google.com/docs/authentication/getting-started#setting_the_environment_variable). 71 | 72 | ## Usage 73 | 74 | 1. Open a query file with `.bqsql` extension. 75 | 1. Open the command palette. 76 | 1. Run `BigQuery Runner: Run`. 77 | 78 | ### Query parameters 79 | 80 | If query has one or more named parameters, the extension will ask you for the values of that parameter. The values must be given in JSON format, e.g. quotation marks should be used for simple values such as `"20231224"`. See below for more complex examples. 81 | 82 | Once set, the parameters are saved for future use and should be reset if necessary using the [bigqueryRunner.clearParams](#bigquery-runner-clear-parameters) command. 83 | 84 | ![Parameters usage](https://user-images.githubusercontent.com/514164/178248203-a24126dc-4ade-4e6f-93ae-200702edfa51.gif) 85 | 86 | ## Commands 87 | 88 | <% contributes.commands.forEach((command) => { -%> 89 | ### <%- command.title %> 90 | 91 | |ID| 92 | |---| 93 | |<%- command.command %>| 94 | 95 | <%- command.description %> 96 | 97 | <% }) -%> 98 | ## Configuration 99 | 100 | The extension can be customized by modifying your `settings.json` file. The available configuration options, and their defaults, are below. 101 | 102 | <% Object.keys(contributes.configuration.properties).forEach((property) => { 103 | const value = contributes.configuration.properties[property] 104 | -%> 105 | ### `<%- property -%>` 106 | 107 | |Type|Default|<% if (value.enum) { %>Enum|<% } %> 108 | |---|---|<% if (value.enum) { %>---|<% } %> 109 | |<%- Array.isArray(value.type) ? value.type.join(' | ') : value.type %>|<%- JSON.stringify(value.default) %>|<% if (value.enum) { %><%- value.enum.map(JSON.stringify, JSON).join(' | ') %>|<% } %> 110 | 111 | <%- value.description -%> 112 | 113 | <% if (value.screenshot) { -%> 114 | ![<%- property %>](<%- base %><%- value.screenshot %>) 115 | <% } -%> 116 | 117 | <% }) -%> 118 | 119 | ## Additional Settings 120 | 121 | ### Keyboard shortcuts 122 | 123 | `keybindings.json`: 124 | 125 | ```json:keybindings.json 126 | [ 127 | { 128 | "key": "cmd+enter", 129 | "command": "bigqueryRunner.run", 130 | "when": "resourceLangId in bigqueryRunner.languageIds || resourceExtname in bigqueryRunner.extensions" 131 | }, 132 | { 133 | "key": "space h", 134 | "command": "bigqueryRunner.prevPage", 135 | "when": "resourceLangId in bigqueryRunner.languageIds || resourceExtname in bigqueryRunner.extensions && vim.mode == 'Normal' || vim.mode == 'Visual' || vim.mode == 'VisualBlock' || vim.mode == 'VisualLine'" 136 | }, 137 | { 138 | "key": "space l", 139 | "command": "bigqueryRunner.nextPage", 140 | "when": "resourceLangId in bigqueryRunner.languageIds || resourceExtname in bigqueryRunner.extensions && vim.mode == 'Normal' || vim.mode == 'Visual' || vim.mode == 'VisualBlock' || vim.mode == 'VisualLine'" 141 | } 142 | ] 143 | ``` 144 | 145 | ### Syntax highlighting `.bqsql` files as SQL 146 | 147 | `settings.json`: 148 | 149 | ```json:settings.json 150 | { 151 | "files.associations": { 152 | "*.bqsql": "sql" 153 | } 154 | } 155 | ``` 156 | 157 | ## More documents 158 | 159 | ### Changelog 160 | 161 | If you want to know the difference between each release, see [CHANGELOG.md](CHANGELOG.md) 162 | 163 | ### Contributing 164 | 165 | When you create an issue, pull request, or fork see [CONTRIBUTING.md](CONTRIBUTING.md) 166 | 167 | ### License 168 | 169 | Apache 2.0 licensed. See the [LICENSE](LICENSE) file for details. 170 | This extension is forked from [google/vscode-bigquery](https://github.com/google/vscode-bigquery). 171 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "core", 4 | "main": "src/index.ts", 5 | "scripts": { 6 | "lint": "eslint \"./src/**/*.ts\"", 7 | "check": "run-p check:*", 8 | "check:tsc": "tsc", 9 | "check:format": "npm run format -- --check", 10 | "fix": "run-p fix:*", 11 | "fix:lint": "npm run lint -- --fix", 12 | "fix:format": "npm run format -- --write", 13 | "test": "jest --silent=false", 14 | "test-coverage": "npm run test -- --coverage --coverageDirectory ../../coverage/core", 15 | "test-watch": "npm run test -- --watchAll", 16 | "format": "prettier src" 17 | }, 18 | "jest": { 19 | "roots": [ 20 | "/src" 21 | ], 22 | "testMatch": [ 23 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 24 | ], 25 | "transform": { 26 | "^.+\\.(ts|tsx)$": "ts-jest" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/core/src/bigquery.test.ts: -------------------------------------------------------------------------------- 1 | import type { JobResponse } from "@google-cloud/bigquery"; 2 | import { 3 | checkAuthentication, 4 | runQuery, 5 | getPage, 6 | toSerializablePage, 7 | } from "./bigquery"; 8 | 9 | describe("bigquery", () => { 10 | describe("checkAuthentication", () => { 11 | it("should pass with valid setting", async () => { 12 | const getCredentials = jest.fn(() => 13 | Promise.resolve({ 14 | client_email: "", 15 | private_key: "", 16 | }) 17 | ); 18 | const result = await checkAuthentication({ 19 | keyFilename: "XXXXX", 20 | getCredentials, 21 | }); 22 | expect(result.success).toBeTruthy(); 23 | expect(result.value).toEqual(undefined); 24 | }); 25 | 26 | it("should fail invalid keyFilename", async () => { 27 | const getCredentials = jest.fn(() => 28 | Promise.reject( 29 | new Error("Unable to detect a Project ID in the current environment.") 30 | ) 31 | ); 32 | const result = await checkAuthentication({ 33 | keyFilename: "XXXXX", 34 | getCredentials, 35 | }); 36 | expect(result.success).toBeFalsy(); 37 | expect(result.value).toEqual({ 38 | type: "Authentication", 39 | reason: `Unable to detect a Project ID in the current environment.`, 40 | hasKeyFilename: true, 41 | }); 42 | }); 43 | 44 | it("should fail without keyFilename", async () => { 45 | const getProjectId = jest.fn(() => 46 | Promise.reject( 47 | new Error("Unable to detect a Project ID in the current environment.") 48 | ) 49 | ); 50 | const result = await checkAuthentication({ 51 | getCredentials: getProjectId, 52 | }); 53 | expect(result.success).toBeFalsy(); 54 | expect(result.value).toEqual({ 55 | type: "Authentication", 56 | reason: `Unable to detect a Project ID in the current environment.`, 57 | hasKeyFilename: false, 58 | }); 59 | }); 60 | 61 | it("should fail with unknown error", async () => { 62 | const getProjectId = jest.fn(() => Promise.reject(new Error("foo"))); 63 | const result = await checkAuthentication({ 64 | getCredentials: getProjectId, 65 | }); 66 | expect(result.success).toBeFalsy(); 67 | expect(result.value).toEqual({ 68 | type: "Authentication", 69 | reason: `foo`, 70 | hasKeyFilename: false, 71 | }); 72 | }); 73 | }); 74 | 75 | describe("createQueryJob", () => { 76 | it("should return job", async () => { 77 | const options = { dryRun: false }; 78 | const job = {}; 79 | const createQueryJobMock = jest.fn(() => 80 | Promise.resolve([job] as unknown as JobResponse) 81 | ); 82 | const result = await runQuery({ 83 | createQueryJob: createQueryJobMock, 84 | options, 85 | }); 86 | expect(createQueryJobMock).toBeCalledWith(options); 87 | expect(result.success).toBeTruthy(); 88 | expect(result.value).toStrictEqual(job); 89 | }); 90 | 91 | it("should fail with no position error", async () => { 92 | const options = { dryRun: false }; 93 | const createQueryJobMock = jest.fn(() => 94 | Promise.reject(new Error("foo")) 95 | ); 96 | const result = await runQuery({ 97 | createQueryJob: createQueryJobMock, 98 | options, 99 | }); 100 | expect(createQueryJobMock).toBeCalledWith(options); 101 | expect(result.success).toBeFalsy(); 102 | expect(result.value).toStrictEqual({ 103 | type: "Query", 104 | reason: "foo", 105 | }); 106 | }); 107 | 108 | it("should fail with a position but no suggestion error", async () => { 109 | const options = { dryRun: false }; 110 | const createQueryJobMock = jest.fn(() => 111 | Promise.reject(new Error("foo bar baz at [3:40]")) 112 | ); 113 | const result = await runQuery({ 114 | createQueryJob: createQueryJobMock, 115 | options, 116 | }); 117 | expect(createQueryJobMock).toBeCalledWith(options); 118 | expect(result.success).toBeFalsy(); 119 | expect(result.value).toStrictEqual({ 120 | type: "QueryWithPosition", 121 | reason: "foo bar baz", 122 | position: { line: 2, character: 39 }, 123 | }); 124 | }); 125 | 126 | it("should fail with a position and a suggestion error", async () => { 127 | const options = { dryRun: false }; 128 | const createQueryJobMock = jest.fn(() => 129 | Promise.reject( 130 | new Error("Unrecognized name: foo; Did you mean bar? at [3:40]") 131 | ) 132 | ); 133 | const result = await runQuery({ 134 | createQueryJob: createQueryJobMock, 135 | options, 136 | }); 137 | expect(createQueryJobMock).toBeCalledWith(options); 138 | expect(result.success).toBeFalsy(); 139 | expect(result.value).toStrictEqual({ 140 | type: "QueryWithPosition", 141 | reason: "Unrecognized name: foo; Did you mean bar?", 142 | position: { line: 2, character: 39 }, 143 | suggestion: { 144 | before: "foo", 145 | after: "bar", 146 | }, 147 | }); 148 | }); 149 | }); 150 | 151 | // Since just-worker uses JSON in its messaging interface, 152 | // it must be asserted in a serializable state. 153 | // Therefore, not only getPage but also toSerializablePage is tested. 154 | describe("getPage and toSerializablePage", () => { 155 | it("should return no page", () => { 156 | expect( 157 | toSerializablePage( 158 | getPage({ 159 | totalRows: "0", 160 | rows: 0, 161 | }) 162 | ) 163 | ).toStrictEqual({ 164 | hasPrev: false, 165 | hasNext: false, 166 | startRowNumber: "0", 167 | endRowNumber: "0", 168 | totalRows: "0", 169 | }); 170 | }); 171 | 172 | it("should return 1st page without prevPage", () => { 173 | expect( 174 | toSerializablePage( 175 | getPage({ 176 | totalRows: "99999", 177 | rows: 100, 178 | }) 179 | ) 180 | ).toStrictEqual({ 181 | hasPrev: false, 182 | hasNext: false, 183 | startRowNumber: "1", 184 | endRowNumber: "100", 185 | totalRows: "99999", 186 | }); 187 | }); 188 | 189 | it("should return n-th page with prevPage", () => { 190 | expect( 191 | toSerializablePage( 192 | getPage({ 193 | totalRows: "99999", 194 | rows: 100, 195 | prevPage: { endRowNumber: 831n }, 196 | }) 197 | ) 198 | ).toStrictEqual({ 199 | hasPrev: true, 200 | hasNext: false, 201 | startRowNumber: "832", 202 | endRowNumber: "931", 203 | totalRows: "99999", 204 | }); 205 | }); 206 | 207 | it("should return hasNext true with nextPageToken", () => { 208 | expect( 209 | toSerializablePage( 210 | getPage({ 211 | totalRows: "99999", 212 | rows: 100, 213 | nextPageToken: "XXXXXXXXXX", 214 | }) 215 | ) 216 | ).toStrictEqual({ 217 | hasPrev: false, 218 | hasNext: true, 219 | startRowNumber: "1", 220 | endRowNumber: "100", 221 | totalRows: "99999", 222 | }); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /packages/core/src/flat.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | 3 | import type { 4 | Accessor, 5 | Column, 6 | Field, 7 | NumberedRows, 8 | Value, 9 | Row, 10 | StructuralRow, 11 | } from "shared"; 12 | import { valueToPrimitive } from "./transform"; 13 | 14 | export type Flat = Readonly<{ 15 | heads: ReadonlyArray; 16 | getNumberedRows( 17 | props: Readonly<{ 18 | structs: ReadonlyArray; 19 | rowNumberStart: bigint; 20 | }> 21 | ): ReadonlyArray; 22 | }>; 23 | 24 | export function createFlat(fields: ReadonlyArray): Flat { 25 | const heads = fieldsToHeads(fields, []); 26 | const columns = fieldsToColumns(fields); 27 | return { 28 | heads, 29 | getNumberedRows({ structs, rowNumberStart }) { 30 | return structs.map((struct, i) => { 31 | const rows = structToRows({ heads, columns, struct }); 32 | return { 33 | rowNumber: `${rowNumberStart + BigInt(i)}`, 34 | rows, 35 | }; 36 | }); 37 | }, 38 | }; 39 | } 40 | 41 | function fieldsToHeads( 42 | fields: ReadonlyArray, 43 | names: Array 44 | ): ReadonlyArray { 45 | return fields.flatMap((field) => { 46 | if (field.type === "STRUCT" || field.type === "RECORD") { 47 | return fieldsToHeads(field.fields, [...names, field.name]); 48 | } 49 | return { 50 | ...field, 51 | id: [...names, field.name].join("."), 52 | }; 53 | }); 54 | } 55 | 56 | function fieldsToColumns(fields: ReadonlyArray): ReadonlyArray { 57 | return fields.flatMap((field) => { 58 | if (field.type === "STRUCT" || field.type === "RECORD") { 59 | return fieldsToColumns(field.fields).map((columns) => [ 60 | { 61 | ...field, 62 | id: field.name, 63 | }, 64 | ...columns.map((column) => ({ 65 | ...column, 66 | id: `${field.name}.${column.id}`, 67 | })), 68 | ]); 69 | } 70 | return [[{ ...field, id: field.name }]]; 71 | }); 72 | } 73 | 74 | function structToRows({ 75 | heads, 76 | columns, 77 | struct, 78 | }: Readonly<{ 79 | heads: ReadonlyArray; 80 | columns: ReadonlyArray; 81 | struct: StructuralRow; 82 | }>): ReadonlyArray { 83 | const rows: Array = []; 84 | const depths = new Array(columns.length).fill(0); 85 | const createFillWithRow = ({ columnIndex }: { columnIndex: number }) => { 86 | return ({ accessor, value }: { value: Value; accessor: Accessor }) => { 87 | if (!rows[depths[columnIndex]!]) { 88 | rows[depths[columnIndex]!] = heads.map(({ id }) => ({ 89 | id, 90 | value: undefined, 91 | })); 92 | } 93 | rows[depths[columnIndex]!]![columnIndex] = { 94 | id: accessor.id, 95 | value: valueToPrimitive(value), 96 | }; 97 | depths[columnIndex]! += 1; 98 | }; 99 | }; 100 | 101 | columns.forEach((column, columnIndex) => 102 | walk({ 103 | struct, 104 | column, 105 | accessorIndex: 0, 106 | fill: createFillWithRow({ columnIndex }), 107 | }) 108 | ); 109 | 110 | return rows; 111 | } 112 | 113 | function walk({ 114 | struct, 115 | column, 116 | accessorIndex, 117 | fill, 118 | }: Readonly<{ 119 | struct: StructuralRow; 120 | column: Column; 121 | accessorIndex: number; 122 | fill(props: { accessor: Accessor; value: Value }): void; 123 | }>): void { 124 | let s: StructuralRow = struct; 125 | let isNull = false; 126 | for (let ai = accessorIndex; ai < column.length; ai += 1) { 127 | const accessor = column[ai]!; 128 | if (accessor.mode === "REPEATED") { 129 | if (accessor.type === "STRUCT" || accessor.type === "RECORD") { 130 | (s[accessor.name] as ReadonlyArray).forEach((struct) => { 131 | walk({ 132 | struct, 133 | column, 134 | accessorIndex: ai + 1, 135 | fill, 136 | }); 137 | }); 138 | break; 139 | } 140 | (s[accessor.name] as ReadonlyArray).forEach((value) => 141 | fill({ 142 | accessor, 143 | value, 144 | }) 145 | ); 146 | } else { 147 | if (accessor.type === "STRUCT" || accessor.type === "RECORD") { 148 | if (!isNull) { 149 | s = s[accessor.name] as StructuralRow; 150 | isNull = s === null; 151 | } 152 | continue; 153 | } 154 | fill({ 155 | accessor, 156 | value: isNull ? null : (s[accessor.name] as Value), 157 | }); 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /packages/core/src/formatter.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from "csv-stringify"; 2 | import { stringify } from "csv-stringify"; 3 | import EasyTable from "easy-table"; 4 | import type { StructuralRow } from "shared"; 5 | import type { Flat } from "./flat"; 6 | 7 | export type Formatter = Readonly<{ 8 | head(): void; 9 | body( 10 | props: Readonly<{ 11 | structs: ReadonlyArray; 12 | rowNumberStart: bigint; 13 | }> 14 | ): void; 15 | foot(): Promise; 16 | }>; 17 | 18 | export function createJSONLinesFormatter({ 19 | writer, 20 | }: { 21 | writer: NodeJS.WritableStream; 22 | }): Formatter { 23 | return { 24 | head() { 25 | // do nothing 26 | }, 27 | body({ 28 | structs, 29 | }: Readonly<{ 30 | structs: ReadonlyArray; 31 | }>) { 32 | structs.forEach((struct) => { 33 | writer.write(JSON.stringify(struct)); 34 | writer.write("\n"); 35 | }); 36 | }, 37 | async foot(): Promise { 38 | return new Promise((resolve) => writer.end(resolve)); 39 | }, 40 | }; 41 | } 42 | 43 | export function createJSONFormatter({ 44 | writer, 45 | }: { 46 | writer: NodeJS.WritableStream; 47 | }): Formatter { 48 | let first = true; 49 | return { 50 | head() { 51 | writer.write("["); 52 | }, 53 | body({ 54 | structs, 55 | }: Readonly<{ 56 | structs: ReadonlyArray; 57 | }>) { 58 | structs.forEach((struct) => { 59 | if (!first) { 60 | writer.write(","); 61 | } 62 | writer.write(JSON.stringify(struct)); 63 | first = false; 64 | }); 65 | }, 66 | async foot(): Promise { 67 | writer.write("]\n"); 68 | return new Promise((resolve) => writer.end(resolve)); 69 | }, 70 | }; 71 | } 72 | 73 | export function createCSVFormatter({ 74 | flat, 75 | writer, 76 | options, 77 | }: { 78 | flat: Flat; 79 | writer: NodeJS.WritableStream; 80 | options: Options; 81 | }): Formatter { 82 | const stringifier = stringify( 83 | options.header 84 | ? { ...options, columns: flat.heads.map(({ id }) => id) } 85 | : options 86 | ); 87 | stringifier.pipe(writer); 88 | const promise = new Promise((resolve, reject) => { 89 | stringifier.on("error", reject); 90 | stringifier.on("finish", () => resolve()); 91 | }); 92 | return { 93 | head() { 94 | // do nothing 95 | }, 96 | body({ structs, rowNumberStart }) { 97 | if (structs.length === 0) { 98 | return; 99 | } 100 | flat.getNumberedRows({ structs, rowNumberStart }).forEach(({ rows }) => 101 | rows.forEach((row) => { 102 | stringifier.write( 103 | row.map(({ value }) => 104 | value === null || value === undefined ? "" : `${value}` 105 | ) 106 | ); 107 | }) 108 | ); 109 | }, 110 | async foot() { 111 | stringifier.end(); 112 | return promise; 113 | }, 114 | }; 115 | } 116 | 117 | export function createMarkdownFormatter({ 118 | flat, 119 | writer, 120 | }: { 121 | flat: Flat; 122 | writer: NodeJS.WritableStream; 123 | }): Formatter { 124 | return { 125 | head() { 126 | if (flat.heads.length === 0) { 127 | return; 128 | } 129 | writer.write(`|`); 130 | flat.heads.forEach(({ id }) => writer.write(`${id}|`)); 131 | writer.write(`\n`); 132 | writer.write(`|`); 133 | flat.heads.forEach(() => writer.write("---|")); 134 | writer.write(`\n`); 135 | }, 136 | body({ 137 | structs, 138 | rowNumberStart, 139 | }: Readonly<{ 140 | structs: ReadonlyArray; 141 | rowNumberStart: bigint; 142 | }>) { 143 | flat.getNumberedRows({ structs, rowNumberStart }).forEach(({ rows }) => 144 | rows.forEach((row) => { 145 | writer.write(`|`); 146 | row.forEach(({ value }) => { 147 | if (value === undefined) { 148 | writer.write("|"); 149 | return; 150 | } 151 | if (typeof value !== "string") { 152 | writer.write(`${value}|`); 153 | return; 154 | } 155 | writer.write(value.replace(/\n/g, "
")); 156 | writer.write("|"); 157 | }); 158 | writer.write(`\n`); 159 | }) 160 | ); 161 | }, 162 | async foot(): Promise { 163 | return new Promise((resolve) => writer.end(resolve)); 164 | }, 165 | }; 166 | } 167 | 168 | export function createTableFormatter({ 169 | flat, 170 | writer, 171 | }: { 172 | flat: Flat; 173 | writer: NodeJS.WritableStream; 174 | }): Formatter { 175 | return { 176 | head() { 177 | // do nothing 178 | }, 179 | body({ 180 | structs, 181 | rowNumberStart, 182 | }: Readonly<{ 183 | structs: ReadonlyArray; 184 | rowNumberStart: bigint; 185 | }>) { 186 | const t = new EasyTable(); 187 | flat.getNumberedRows({ structs, rowNumberStart }).forEach(({ rows }) => { 188 | rows.forEach((row) => { 189 | row.forEach((cell) => t.cell(cell.id, cell.value)); 190 | t.newRow(); 191 | }); 192 | }); 193 | writer.write(t.toString().trimEnd()); 194 | writer.write("\n"); 195 | }, 196 | async foot(): Promise { 197 | return new Promise((resolve) => writer.end(resolve)); 198 | }, 199 | }; 200 | } 201 | -------------------------------------------------------------------------------- /packages/core/src/gcloud.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import type { Logger } from "extension/src/logger"; 3 | import { errorToString, tryCatch } from "shared"; 4 | 5 | export type Gcloud = ReturnType; 6 | 7 | export const createGcloud = ({ logger }: { logger: Logger }) => { 8 | const l = logger.createChild("Gcloud"); 9 | const gcloud = (args: ReadonlyArray) => { 10 | return new Promise>((resolve, reject) => { 11 | const stream = spawn("gcloud", args); 12 | const outs: Array = []; 13 | const errs: Array = []; 14 | stream.stdout.on("data", (data) => { 15 | l.log(`stdout: ${data}`); 16 | outs.push(data); 17 | }); 18 | stream.stderr.on("data", (data) => { 19 | l.log(`stderr: ${data}`); 20 | errs.push(data); 21 | }); 22 | stream.on("close", (code) => { 23 | l.log(`close: ${code}`); 24 | if (code !== 0) { 25 | reject(errs); 26 | return; 27 | } 28 | resolve(outs); 29 | }); 30 | }); 31 | }; 32 | return { 33 | async login() { 34 | return tryCatch( 35 | () => gcloud(["auth", "application-default", "login"]), 36 | (err) => ({ 37 | type: "LoginFailed", 38 | message: errorToString(err), 39 | }) 40 | ); 41 | }, 42 | 43 | async logout() { 44 | return tryCatch( 45 | () => gcloud(["auth", "application-default", "revoke", "--quiet"]), 46 | (err) => ({ 47 | type: "LogoutFailed", 48 | message: errorToString(err), 49 | }) 50 | ); 51 | }, 52 | 53 | dispose() { 54 | // do nothing 55 | }, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bigquery"; 2 | export * from "./flat"; 3 | export * from "./formatter"; 4 | export * from "./gcloud"; 5 | export * from "./parser"; 6 | -------------------------------------------------------------------------------- /packages/core/src/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "bigquery#job", 3 | "etag": "1PYwXDe/uYzFCOCwqlpyWw==", 4 | "id": "minodisk-api:US.9552c26b-2151-45b2-9536-ceb50fc7b5a1", 5 | "selfLink": "https://bigquery.googleapis.com/bigquery/v2/projects/minodisk-api/jobs/9552c26b-2151-45b2-9536-ceb50fc7b5a1?location=US", 6 | "user_email": "bigquery-runner@minodisk-api.iam.gserviceaccount.com", 7 | "configuration": { 8 | "query": { 9 | "query": "select\n en_label,\n labels,\nfrom\n `bigquery-public-data.wikipedia.wikidata`\nwhere\n array_length(labels) < 4\nlimit\n 580\n", 10 | "destinationTable": { 11 | "projectId": "minodisk-api", 12 | "datasetId": "_974002322e1183b3df64c0f31d9b6832d25246ef", 13 | "tableId": "anond8e3e2a367e0234e97050be371e91fe8dd62ed85" 14 | }, 15 | "writeDisposition": "WRITE_TRUNCATE", 16 | "priority": "INTERACTIVE", 17 | "useLegacySql": false 18 | }, 19 | "jobType": "QUERY" 20 | }, 21 | "jobReference": { 22 | "projectId": "minodisk-api", 23 | "jobId": "9552c26b-2151-45b2-9536-ceb50fc7b5a1", 24 | "location": "US" 25 | }, 26 | "statistics": { 27 | "creationTime": "1649770220681", 28 | "startTime": "1649770220849", 29 | "endTime": "1649770220887", 30 | "totalBytesProcessed": "0", 31 | "query": { 32 | "totalBytesProcessed": "0", 33 | "totalBytesBilled": "0", 34 | "cacheHit": true, 35 | "statementType": "SELECT" 36 | } 37 | }, 38 | "status": { "state": "DONE" } 39 | } 40 | -------------------------------------------------------------------------------- /packages/core/src/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { createParser } from "./parser"; 2 | 3 | describe("createParser", () => { 4 | it("should parse params", () => { 5 | const query = ` 6 | SELECT 7 | corpus, 8 | word, 9 | word_count 10 | FROM 11 | \`bigquery-public-data.samples.shakespeare\` 12 | WHERE 13 | corpus = @corpus -- "romeoandjuliet" 14 | AND word_count >= @min_word_count -- 250 15 | ORDER BY 16 | word_count DESC 17 | `; 18 | const { parse } = createParser(); 19 | const tokens = parse(query); 20 | expect(tokens).toStrictEqual([ 21 | { 22 | type: "RESERVED_SELECT", 23 | raw: "SELECT", 24 | text: "SELECT", 25 | start: 1, 26 | precedingWhitespace: "\n", 27 | line: 1, 28 | character: 0, 29 | }, 30 | { 31 | type: "IDENTIFIER", 32 | raw: "corpus", 33 | text: "corpus", 34 | start: 12, 35 | precedingWhitespace: "\n ", 36 | line: 2, 37 | character: 4, 38 | }, 39 | { 40 | type: "COMMA", 41 | raw: ",", 42 | text: ",", 43 | start: 18, 44 | precedingWhitespace: undefined, 45 | line: 2, 46 | character: 10, 47 | }, 48 | { 49 | type: "IDENTIFIER", 50 | raw: "word", 51 | text: "word", 52 | start: 24, 53 | precedingWhitespace: "\n ", 54 | line: 3, 55 | character: 4, 56 | }, 57 | { 58 | type: "COMMA", 59 | raw: ",", 60 | text: ",", 61 | start: 28, 62 | precedingWhitespace: undefined, 63 | line: 3, 64 | character: 8, 65 | }, 66 | { 67 | type: "IDENTIFIER", 68 | raw: "word_count", 69 | text: "word_count", 70 | start: 34, 71 | precedingWhitespace: "\n ", 72 | line: 4, 73 | character: 4, 74 | }, 75 | { 76 | type: "RESERVED_CLAUSE", 77 | raw: "FROM", 78 | text: "FROM", 79 | start: 45, 80 | precedingWhitespace: "\n", 81 | line: 5, 82 | character: 0, 83 | }, 84 | { 85 | type: "QUOTED_IDENTIFIER", 86 | raw: "`bigquery-public-data.samples.shakespeare`", 87 | text: "`bigquery-public-data.samples.shakespeare`", 88 | start: 54, 89 | precedingWhitespace: "\n ", 90 | line: 6, 91 | character: 4, 92 | }, 93 | { 94 | type: "RESERVED_CLAUSE", 95 | raw: "WHERE", 96 | text: "WHERE", 97 | start: 97, 98 | precedingWhitespace: "\n", 99 | line: 7, 100 | character: 0, 101 | }, 102 | { 103 | type: "IDENTIFIER", 104 | raw: "corpus", 105 | text: "corpus", 106 | start: 107, 107 | precedingWhitespace: "\n ", 108 | line: 8, 109 | character: 4, 110 | }, 111 | { 112 | type: "OPERATOR", 113 | raw: "=", 114 | text: "=", 115 | start: 114, 116 | precedingWhitespace: " ", 117 | line: 8, 118 | character: 11, 119 | }, 120 | { 121 | type: "NAMED_PARAMETER", 122 | raw: "@corpus", 123 | text: "@corpus", 124 | start: 116, 125 | key: "corpus", 126 | precedingWhitespace: " ", 127 | line: 8, 128 | character: 13, 129 | }, 130 | { 131 | type: "LINE_COMMENT", 132 | raw: '-- "romeoandjuliet"', 133 | text: '-- "romeoandjuliet"', 134 | start: 124, 135 | precedingWhitespace: " ", 136 | line: 8, 137 | character: 21, 138 | }, 139 | { 140 | type: "AND", 141 | raw: "AND", 142 | text: "AND", 143 | start: 148, 144 | precedingWhitespace: "\n ", 145 | line: 9, 146 | character: 4, 147 | }, 148 | { 149 | type: "IDENTIFIER", 150 | raw: "word_count", 151 | text: "word_count", 152 | start: 152, 153 | precedingWhitespace: " ", 154 | line: 9, 155 | character: 8, 156 | }, 157 | { 158 | type: "OPERATOR", 159 | raw: ">=", 160 | text: ">=", 161 | start: 163, 162 | precedingWhitespace: " ", 163 | line: 9, 164 | character: 19, 165 | }, 166 | { 167 | type: "NAMED_PARAMETER", 168 | raw: "@min_word_count", 169 | text: "@min_word_count", 170 | start: 166, 171 | key: "min_word_count", 172 | precedingWhitespace: " ", 173 | line: 9, 174 | character: 22, 175 | }, 176 | { 177 | type: "LINE_COMMENT", 178 | raw: "-- 250", 179 | text: "-- 250", 180 | start: 182, 181 | precedingWhitespace: " ", 182 | line: 9, 183 | character: 38, 184 | }, 185 | { 186 | type: "RESERVED_CLAUSE", 187 | raw: "ORDER BY", 188 | text: "ORDER BY", 189 | start: 189, 190 | precedingWhitespace: "\n", 191 | line: 10, 192 | character: 0, 193 | }, 194 | { 195 | type: "IDENTIFIER", 196 | raw: "word_count", 197 | text: "word_count", 198 | start: 202, 199 | precedingWhitespace: "\n ", 200 | line: 11, 201 | character: 4, 202 | }, 203 | { 204 | type: "RESERVED_KEYWORD", 205 | raw: "DESC", 206 | text: "DESC", 207 | start: 213, 208 | precedingWhitespace: " ", 209 | line: 11, 210 | character: 15, 211 | }, 212 | ]); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /packages/core/src/parser.ts: -------------------------------------------------------------------------------- 1 | import { formatters } from "sql-formatter"; 2 | import type { Token as OrigToken } from "sql-formatter/lib/src/lexer/token"; 3 | 4 | export type Token = OrigToken & { 5 | line: number; 6 | character: number; 7 | }; 8 | 9 | export const createParser = () => { 10 | const tokenizer = formatters.bigquery.prototype.tokenizer(); 11 | 12 | const breaks = (precedingWhitespace: string) => { 13 | let newLines = 0; 14 | let charactersBeforeLastLine = 0; 15 | for (let i = 0; i < precedingWhitespace.length; i++) { 16 | const char = precedingWhitespace[i]; 17 | if (char === "\n") { 18 | newLines += 1; 19 | charactersBeforeLastLine = i + 1; 20 | } 21 | } 22 | return { newLines, charactersBeforeLastLine }; 23 | }; 24 | 25 | return { 26 | parse(query: string): Array { 27 | const tokens = tokenizer.tokenize(query, {}); 28 | let line = 0; 29 | let caret = 0; 30 | let lineStart = 0; 31 | return tokens.map((token) => { 32 | if (!token.precedingWhitespace) { 33 | const t = { 34 | ...token, 35 | line, 36 | character: token.start - lineStart, 37 | }; 38 | caret += token.raw.length; 39 | return t; 40 | } 41 | const { newLines, charactersBeforeLastLine } = breaks( 42 | token.precedingWhitespace 43 | ); 44 | line += newLines; 45 | if (newLines) { 46 | lineStart = caret + charactersBeforeLastLine; 47 | } 48 | const t = { 49 | ...token, 50 | line, 51 | character: token.start - lineStart, 52 | }; 53 | caret += token.precedingWhitespace.length + token.raw.length; 54 | return t; 55 | }); 56 | }, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /packages/core/src/table.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "bigquery#table", 3 | "etag": "Ps9LfjYICT3GQ95Gociing==", 4 | "id": "minodisk-api:_974002322e1183b3df64c0f31d9b6832d25246ef.anond8e3e2a367e0234e97050be371e91fe8dd62ed85", 5 | "selfLink": "https://bigquery.googleapis.com/bigquery/v2/projects/minodisk-api/datasets/_974002322e1183b3df64c0f31d9b6832d25246ef/tables/anond8e3e2a367e0234e97050be371e91fe8dd62ed85", 6 | "tableReference": { 7 | "projectId": "minodisk-api", 8 | "datasetId": "_974002322e1183b3df64c0f31d9b6832d25246ef", 9 | "tableId": "anond8e3e2a367e0234e97050be371e91fe8dd62ed85" 10 | }, 11 | "schema": { 12 | "fields": [ 13 | { "name": "en_label", "type": "STRING", "mode": "NULLABLE" }, 14 | { 15 | "name": "labels", 16 | "type": "RECORD", 17 | "mode": "REPEATED", 18 | "fields": [ 19 | { "name": "language", "type": "STRING", "mode": "NULLABLE" }, 20 | { "name": "value", "type": "STRING", "mode": "NULLABLE" } 21 | ] 22 | } 23 | ] 24 | }, 25 | "numBytes": "0", 26 | "numLongTermBytes": "0", 27 | "numRows": "580", 28 | "creationTime": "1649767720933", 29 | "expirationTime": "1649856620804", 30 | "lastModifiedTime": "1649770220804", 31 | "type": "TABLE", 32 | "location": "US", 33 | "numTotalLogicalBytes": "0", 34 | "numActiveLogicalBytes": "0", 35 | "numLongTermLogicalBytes": "0" 36 | } 37 | -------------------------------------------------------------------------------- /packages/core/src/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BigQueryDate, 3 | BigQueryDatetime, 4 | BigQueryInt, 5 | BigQueryTime, 6 | BigQueryTimestamp, 7 | Geography, 8 | } from "@google-cloud/bigquery"; 9 | import type { Value } from "shared"; 10 | import { valueToPrimitive } from "./transform"; 11 | 12 | describe("transform", () => { 13 | describe("valueToPrimitive", () => { 14 | const cases: Array< 15 | Readonly<{ 16 | message: string; 17 | value: Value; 18 | expected: null | boolean | number | string; 19 | }> 20 | > = [ 21 | { 22 | message: "should return boolean when boolean is input", 23 | value: true, 24 | expected: true, 25 | }, 26 | { 27 | message: "should return number when number is input", 28 | value: 123.45, 29 | expected: 123.45, 30 | }, 31 | { 32 | message: "should return string when string is input", 33 | value: "foo", 34 | expected: "foo", 35 | }, 36 | { 37 | message: "should return null when null is input", 38 | value: null, 39 | expected: null, 40 | }, 41 | { 42 | message: "should return number when BigInt is input", 43 | value: BigInt("99999999999999999999"), 44 | expected: "99999999999999999999", 45 | }, 46 | { 47 | message: "should return string when Buffer is input", 48 | value: Buffer.from("foo"), 49 | expected: "foo", 50 | }, 51 | { 52 | message: "should return string when BigQueryDate is input", 53 | value: new Geography("POINT(-70.8754261 42.0625498)"), 54 | expected: "POINT(-70.8754261 42.0625498)", 55 | }, 56 | { 57 | message: "should return string when BigQueryDate is input", 58 | value: new BigQueryDate("2006-01-02"), 59 | expected: "2006-01-02", 60 | }, 61 | { 62 | message: "should return string when BigQueryDatetime is input", 63 | value: new BigQueryDatetime("2006-01-02T15:04:05Z"), 64 | expected: "2006-01-02 15:04:05", 65 | }, 66 | { 67 | message: "should return string when BigQueryInt is input", 68 | value: new BigQueryInt(10), 69 | expected: "10", 70 | }, 71 | { 72 | message: "should return string when BigQueryTime is input", 73 | value: new BigQueryTime("15:04:05"), 74 | expected: "15:04:05", 75 | }, 76 | { 77 | message: "should return string when BigQueryTimestamp is input", 78 | value: new BigQueryTimestamp("2006-01-02T15:04:05Z"), 79 | expected: "2006-01-02T15:04:05.000Z", 80 | }, 81 | ]; 82 | cases.forEach(({ message, value, expected }) => 83 | it(message, () => { 84 | expect(valueToPrimitive(value)).toStrictEqual(expected); 85 | }) 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /packages/core/src/transform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BigQueryDate, 3 | BigQueryDatetime, 4 | BigQueryInt, 5 | BigQueryTime, 6 | BigQueryTimestamp, 7 | Geography, 8 | } from "@google-cloud/bigquery"; 9 | import type { Primitive, Value } from "shared"; 10 | 11 | export function valueToPrimitive(value: Value): Primitive { 12 | if ( 13 | value === null || 14 | typeof value === "number" || 15 | typeof value === "string" || 16 | typeof value === "boolean" 17 | ) { 18 | return value; 19 | } 20 | if ( 21 | value instanceof BigQueryDate || 22 | value instanceof BigQueryDatetime || 23 | value instanceof BigQueryInt || 24 | value instanceof BigQueryTime || 25 | value instanceof BigQueryTimestamp || 26 | value instanceof Geography 27 | ) { 28 | return value.value; 29 | } 30 | if (Buffer.isBuffer(value)) { 31 | return value.toString(); 32 | } 33 | return `${value}`; 34 | } 35 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "extension", 4 | "main": "src/extension.ts", 5 | "scripts": { 6 | "dev": "run-p build:watch", 7 | "build": "esbuild ./src/index.ts --bundle --outfile=../../out/extension/index.js --external:vscode --format=cjs --platform=node", 8 | "build:watch": "npm run build -- --sourcemap --watch", 9 | "build-production": "npm run build -- --minify", 10 | "build-debug": "npm run build -- --sourcemap", 11 | "lint": "eslint \"./src/**/*.ts\"", 12 | "fix": "run-p fix:*", 13 | "fix:lint": "npm run lint -- --fix", 14 | "fix:format": "npm run format -- --write", 15 | "check": "run-p check:*", 16 | "check:tsc": "tsc", 17 | "check:format": "npm run format -- --check", 18 | "format": "prettier --write src" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/extension/src/OrigConfig.d.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT 2 | // This file is generated from gen-src/config.d.ts.ejs. 3 | 4 | export type OrigConfig = Readonly<{ 5 | keyFilename: string | undefined; 6 | projectId: string | undefined; 7 | location: string | undefined; 8 | useLegacySql: boolean; 9 | maximumBytesBilled: string | undefined; 10 | extensions: Array; 11 | languageIds: Array; 12 | icon: boolean; 13 | defaultDataset: Readonly<{ 14 | datasetId: string | undefined; 15 | projectId: string | undefined; 16 | }>; 17 | downloader: Readonly<{ 18 | csv: Readonly<{ 19 | delimiter: string; 20 | header: boolean; 21 | }>; 22 | rowsPerPage: number | undefined; 23 | }>; 24 | tree: Readonly<{ 25 | projectIds: Array; 26 | }>; 27 | viewer: Readonly<{ 28 | column: string | number; 29 | rowsPerPage: number | undefined; 30 | }>; 31 | previewer: Readonly<{ 32 | rowsPerPage: number | undefined; 33 | }>; 34 | statusBarItem: Readonly<{ 35 | align: "left" | "right" | undefined; 36 | priority: number | undefined; 37 | }>; 38 | validation: Readonly<{ 39 | enabled: boolean; 40 | debounceInterval: number; 41 | }>; 42 | }>; 43 | -------------------------------------------------------------------------------- /packages/extension/src/checksum.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | export const checksum = (text: string): string => 4 | createHash("md5") 5 | .update(text.trimStart().trimEnd().replace(/\s+/g, " ")) 6 | .digest("hex"); 7 | -------------------------------------------------------------------------------- /packages/extension/src/configManager.ts: -------------------------------------------------------------------------------- 1 | import { isAbsolute, join } from "path"; 2 | import type { Disposable } from "vscode"; 3 | import { commands, workspace } from "vscode"; 4 | import type { OrigConfig } from "./OrigConfig"; 5 | 6 | export type ConfigManager = ReturnType; 7 | 8 | export type Config = Omit & { 9 | defaultDataset?: OrigConfig["defaultDataset"]; 10 | }; 11 | 12 | type Callback = (config: Config) => unknown; 13 | 14 | export function createConfigManager(section: string) { 15 | let config = getConfig(section); 16 | setContext(config); 17 | 18 | const callbacks = new Set(); 19 | 20 | const subscriptions = [ 21 | workspace.onDidChangeConfiguration((e) => { 22 | if (!e.affectsConfiguration(section)) { 23 | return; 24 | } 25 | 26 | config = getConfig(section); 27 | setContext(config); 28 | 29 | callbacks.forEach((cb) => cb(config)); 30 | }), 31 | ]; 32 | 33 | return { 34 | get(): Config { 35 | return config; 36 | }, 37 | 38 | onChange(callback: Callback): Disposable { 39 | callbacks.add(callback); 40 | return { 41 | dispose() { 42 | callbacks.delete(callback); 43 | }, 44 | }; 45 | }, 46 | 47 | dispose(): void { 48 | subscriptions.forEach((s) => s.dispose()); 49 | callbacks.clear(); 50 | }, 51 | }; 52 | } 53 | 54 | function getConfig(section: string): Config { 55 | const config = workspace.getConfiguration(section) as unknown as OrigConfig; 56 | return { 57 | ...config, 58 | defaultDataset: 59 | config.defaultDataset.datasetId || config.defaultDataset.projectId 60 | ? config.defaultDataset 61 | : undefined, 62 | downloader: { 63 | ...config.downloader, 64 | rowsPerPage: 65 | config.downloader.rowsPerPage === undefined || 66 | config.downloader.rowsPerPage === null 67 | ? undefined 68 | : config.downloader.rowsPerPage, 69 | }, 70 | keyFilename: 71 | config.keyFilename === null || config.keyFilename === undefined 72 | ? undefined 73 | : isAbsolute(config.keyFilename) || 74 | !workspace.workspaceFolders || 75 | !workspace.workspaceFolders[0] || 76 | workspace.workspaceFolders.length === 0 77 | ? config.keyFilename 78 | : join(workspace.workspaceFolders[0].uri.fsPath, config.keyFilename), 79 | statusBarItem: { 80 | align: 81 | config.statusBarItem.align === null || 82 | config.statusBarItem.align === undefined 83 | ? undefined 84 | : config.statusBarItem.align, 85 | priority: 86 | config.statusBarItem.priority === null || 87 | config.statusBarItem.priority === undefined 88 | ? undefined 89 | : config.statusBarItem.priority, 90 | }, 91 | viewer: { 92 | ...config.viewer, 93 | rowsPerPage: 94 | config.viewer.rowsPerPage === undefined || 95 | config.viewer.rowsPerPage === null 96 | ? undefined 97 | : config.viewer.rowsPerPage, 98 | }, 99 | }; 100 | } 101 | 102 | function setContext(config: Config): void { 103 | const map = flatten(config, "bigqueryRunner"); 104 | Object.keys(map).forEach((k) => 105 | commands.executeCommand("setContext", k, map[k]) 106 | ); 107 | } 108 | 109 | function flatten( 110 | source: { [key: string]: unknown }, 111 | parentKey: string, 112 | target: { [key: string]: unknown } = {} 113 | ): { [key: string]: unknown } { 114 | return Object.keys(source).reduce((t, k) => { 115 | const v = source[k]; 116 | const type = Object.prototype.toString.call(v); 117 | if (type === "[object Object]" && !Array.isArray(v)) { 118 | t = flatten(v as { [key: string]: unknown }, parentKey + "." + k, t); 119 | } else if (type !== "[object Function]") { 120 | t[parentKey + "." + k] = v; 121 | } 122 | return t; 123 | }, target); 124 | } 125 | -------------------------------------------------------------------------------- /packages/extension/src/dryRunner.ts: -------------------------------------------------------------------------------- 1 | import { format as formatBytes, parse } from "bytes"; 2 | import { createClient } from "core"; 3 | import type { RunnerID } from "shared"; 4 | import { unwrap } from "shared"; 5 | import type { TextDocument, TextEditor } from "vscode"; 6 | import { window } from "vscode"; 7 | import type { ConfigManager } from "./configManager"; 8 | import type { ErrorManager } from "./errorManager"; 9 | import type { ErrorMarkerManager } from "./errorMarker"; 10 | import { getQueryText } from "./getQueryText"; 11 | import { isBigQuery } from "./isBigQuery"; 12 | import type { Logger } from "./logger"; 13 | import type { QuickFixManager } from "./quickfix"; 14 | import type { StatusManager } from "./statusManager"; 15 | 16 | export type DryRunner = ReturnType; 17 | 18 | export function createDryRunner({ 19 | logger, 20 | configManager, 21 | statusManager, 22 | errorManager, 23 | errorMarkerManager, 24 | quickFixManager, 25 | }: Readonly<{ 26 | logger: Logger; 27 | configManager: ConfigManager; 28 | statusManager: StatusManager; 29 | errorManager: ErrorManager; 30 | errorMarkerManager: ErrorMarkerManager; 31 | quickFixManager: QuickFixManager; 32 | }>) { 33 | const pathTimeoutId = new Map(); 34 | 35 | return { 36 | async validateWithDocument(document: TextDocument): Promise { 37 | await Promise.all( 38 | window.visibleTextEditors 39 | .filter((editor) => editor.document === document) 40 | .map((editor) => this.validate(editor)) 41 | ); 42 | }, 43 | 44 | async validate(editor: TextEditor): Promise { 45 | const { document } = editor; 46 | const { fileName } = document; 47 | 48 | const config = configManager.get(); 49 | if (!isBigQuery({ config, document }) || !config.validation.enabled) { 50 | return; 51 | } 52 | 53 | const runnerId: RunnerID = `file://${fileName}`; 54 | const timeoutId = pathTimeoutId.get(runnerId); 55 | if (timeoutId) { 56 | clearTimeout(timeoutId); 57 | pathTimeoutId.delete(runnerId); 58 | } 59 | pathTimeoutId.set( 60 | runnerId, 61 | setTimeout(async () => { 62 | logger.log(`validate`); 63 | await this.run(editor); 64 | }, config.validation.debounceInterval) 65 | ); 66 | }, 67 | 68 | async run(editor: TextEditor): Promise { 69 | const { 70 | document: { fileName }, 71 | } = editor; 72 | 73 | const runnerId: RunnerID = `file://${fileName}`; 74 | const status = statusManager.get(runnerId); 75 | const errorMarker = errorMarkerManager.get({ 76 | runnerId, 77 | editor, 78 | }); 79 | const quickFix = quickFixManager.get(editor.document); 80 | 81 | const queryTextResult = await getQueryText(editor); 82 | if (!queryTextResult.success) { 83 | errorMarker.clear(); 84 | quickFix.clear(); 85 | return; 86 | } 87 | const query = queryTextResult.value; 88 | 89 | logger.log(`run`); 90 | status.loadProcessed(); 91 | 92 | const config = configManager.get(); 93 | logger.log("dry-run with:", JSON.stringify(config, null, " ")); 94 | 95 | const clientResult = await createClient({ 96 | keyFilename: config.keyFilename, 97 | projectId: config.projectId, 98 | location: config.location, 99 | }); 100 | if (!clientResult.success) { 101 | logger.log("failed to create client"); 102 | logger.error(clientResult); 103 | status.errorProcessed(); 104 | const err = unwrap(clientResult); 105 | errorManager.show(err); 106 | return; 107 | } 108 | const client = unwrap(clientResult); 109 | 110 | const dryRunJobResult = await client.createDryRunJob({ 111 | query, 112 | useLegacySql: config.useLegacySql, 113 | maximumBytesBilled: config.maximumBytesBilled 114 | ? parse(config.maximumBytesBilled).toString() 115 | : undefined, 116 | defaultDataset: config.defaultDataset, 117 | maxResults: config.viewer.rowsPerPage, 118 | }); 119 | 120 | errorMarker.clear(); 121 | quickFix.clear(); 122 | if (!dryRunJobResult.success) { 123 | logger.log("failed to dry-run"); 124 | logger.error(dryRunJobResult); 125 | const err = unwrap(dryRunJobResult); 126 | status.errorProcessed(); 127 | 128 | if (err.type === "QueryWithPosition") { 129 | if (err.suggestion) { 130 | quickFix.register({ 131 | start: err.position, 132 | ...err.suggestion, 133 | }); 134 | } 135 | errorMarker.markAt({ 136 | reason: err.reason, 137 | position: err.position, 138 | }); 139 | return; 140 | } 141 | if (err.type === "Query") { 142 | errorMarker.markAll({ 143 | reason: err.reason, 144 | }); 145 | return; 146 | } 147 | 148 | errorManager.show(err); 149 | return; 150 | } 151 | errorMarker.clear(); 152 | quickFix.clear(); 153 | 154 | const job = unwrap(dryRunJobResult); 155 | 156 | logger.log(`job ID: ${job.id}`); 157 | const bytes = formatBytes(job.totalBytesProcessed); 158 | logger.log(`result: ${bytes} estimated to be read`); 159 | 160 | status.succeedProcessed({ 161 | bytes, 162 | }); 163 | }, 164 | 165 | dispose() { 166 | pathTimeoutId.forEach((timeoutId) => { 167 | clearTimeout(timeoutId); 168 | }); 169 | pathTimeoutId.clear(); 170 | }, 171 | }; 172 | } 173 | -------------------------------------------------------------------------------- /packages/extension/src/errorManager.ts: -------------------------------------------------------------------------------- 1 | import type { AuthenticationError, Gcloud, NoProjectIDError } from "core"; 2 | import type { Err } from "shared"; 3 | import { errorToString } from "shared"; 4 | import { commands, env, Uri } from "vscode"; 5 | import type { Logger } from "./logger"; 6 | import { showError, showInformation } from "./window"; 7 | 8 | export type ErrorManager = ReturnType; 9 | 10 | export const createErrorManager = ({ 11 | logger, 12 | gcloud, 13 | }: { 14 | logger: Logger; 15 | gcloud: Gcloud; 16 | }) => { 17 | const l = logger.createChild("ErrorManager"); 18 | const isAuthenticationError = ( 19 | err: Err 20 | ): err is AuthenticationError => err.type === "Authentication"; 21 | const isNoProjectIDError = (err: Err): err is NoProjectIDError => 22 | err.type === "NoProjectID"; 23 | 24 | const userSettings = async () => { 25 | l.log("open user settings"); 26 | await commands.executeCommand("workbench.action.openSettingsJson"); 27 | }; 28 | const workspaceSettings = async () => { 29 | l.log("open workspace settings"); 30 | await commands.executeCommand("workbench.action.openWorkspaceSettingsFile"); 31 | }; 32 | const login = async () => { 33 | l.log("login"); 34 | const res = await gcloud.login(); 35 | if (!res.success) { 36 | showError(`Login failure: ${errorToString(res.value)}`); 37 | return; 38 | } 39 | showInformation(`Login success`); 40 | }; 41 | const moreInformation = async () => { 42 | l.log("open more information"); 43 | await env.openExternal( 44 | Uri.parse( 45 | "https://github.com/minodisk/bigquery-runner/blob/main/README.md#authentication" 46 | ) 47 | ); 48 | }; 49 | 50 | const showAuthenticationError = (err: AuthenticationError) => { 51 | showError(err.reason, { 52 | ...(err.hasKeyFilename 53 | ? { 54 | "User settings": userSettings, 55 | "Workspace settings": workspaceSettings, 56 | } 57 | : { 58 | Login: login, 59 | }), 60 | "More information": moreInformation, 61 | }); 62 | }; 63 | 64 | const showNoProjectIDError = (err: NoProjectIDError) => { 65 | showError(err.reason, { 66 | "User settings": userSettings, 67 | "Workspace settings": workspaceSettings, 68 | "More information": moreInformation, 69 | }); 70 | }; 71 | 72 | return { 73 | show(err: Err) { 74 | if (isAuthenticationError(err)) { 75 | showAuthenticationError(err); 76 | return; 77 | } 78 | if (isNoProjectIDError(err)) { 79 | showNoProjectIDError(err); 80 | return; 81 | } 82 | showError(err); 83 | }, 84 | 85 | dispose() { 86 | // do nothing 87 | }, 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /packages/extension/src/errorMarker.ts: -------------------------------------------------------------------------------- 1 | import type { RunnerID } from "shared"; 2 | import type { TextEditor } from "vscode"; 3 | import { Diagnostic, languages, Position, Range, workspace } from "vscode"; 4 | 5 | export type ErrorMarkerManager = ReturnType; 6 | export type ErrorMarker = Readonly<{ 7 | markAt( 8 | props: Readonly<{ 9 | reason: string; 10 | position: { line: number; character: number }; 11 | }> 12 | ): void; 13 | markAll( 14 | props: Readonly<{ 15 | reason: string; 16 | }> 17 | ): void; 18 | clear(): void; 19 | }>; 20 | 21 | export function createErrorMarkerManager(section: string) { 22 | const diagnosticCollection = languages.createDiagnosticCollection(section); 23 | const errorMarkers = new Map(); 24 | 25 | return { 26 | get({ 27 | runnerId, 28 | editor, 29 | }: { 30 | runnerId: RunnerID; 31 | editor: TextEditor; 32 | }): ErrorMarker { 33 | const m = errorMarkers.get(runnerId); 34 | if (m) { 35 | return m; 36 | } 37 | 38 | const marker: ErrorMarker = { 39 | markAt({ reason, position: { line, character } }) { 40 | const { 41 | document: { fileName }, 42 | selections, 43 | } = editor; 44 | 45 | const document = workspace.textDocuments.find( 46 | (document) => document.fileName === fileName 47 | ); 48 | if (!document) { 49 | return; 50 | } 51 | 52 | if (selections.some((s) => !s.isEmpty)) { 53 | diagnosticCollection.set( 54 | document.uri, 55 | selections.map( 56 | (selection) => 57 | new Diagnostic( 58 | new Range(selection.start, selection.end), 59 | reason 60 | ) 61 | ) 62 | ); 63 | return; 64 | } 65 | 66 | const wordRange = document.getWordRangeAtPosition( 67 | new Position(line, character) 68 | ); 69 | diagnosticCollection.set(document.uri, [ 70 | new Diagnostic( 71 | wordRange ?? 72 | new Range( 73 | new Position(line, character), 74 | new Position(line, character + 1) 75 | ), 76 | reason 77 | ), 78 | ]); 79 | }, 80 | 81 | markAll({ reason }) { 82 | const { 83 | document: { fileName }, 84 | selections, 85 | } = editor; 86 | 87 | const document = workspace.textDocuments.find( 88 | (document) => document.fileName === fileName 89 | ); 90 | if (!document) { 91 | return; 92 | } 93 | 94 | if (selections.some((s) => !s.isEmpty)) { 95 | diagnosticCollection.set( 96 | document.uri, 97 | selections.map( 98 | (selection) => 99 | new Diagnostic( 100 | new Range(selection.start, selection.end), 101 | reason 102 | ) 103 | ) 104 | ); 105 | return; 106 | } 107 | 108 | diagnosticCollection.set(document.uri, [ 109 | new Diagnostic( 110 | new Range( 111 | document.lineAt(0).range.start, 112 | document.lineAt(document.lineCount - 1).range.end 113 | ), 114 | reason 115 | ), 116 | ]); 117 | return; 118 | }, 119 | 120 | clear() { 121 | diagnosticCollection.delete(editor.document.uri); 122 | }, 123 | }; 124 | errorMarkers.set(runnerId, marker); 125 | 126 | return marker; 127 | }, 128 | 129 | dispose() { 130 | errorMarkers.clear(); 131 | diagnosticCollection.dispose(); 132 | }, 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /packages/extension/src/getQueryText.ts: -------------------------------------------------------------------------------- 1 | import type { Result, Err } from "shared"; 2 | import { succeed, fail } from "shared"; 3 | import type { TextEditor } from "vscode"; 4 | 5 | export async function getQueryText( 6 | editor: TextEditor 7 | ): Promise, string>> { 8 | const text = (() => { 9 | const selections = editor.selections.filter( 10 | (selection) => !selection.isEmpty 11 | ); 12 | if (selections.length === 0) { 13 | return editor.document.getText(); 14 | } 15 | return selections 16 | .map((selection) => editor.document.getText(selection)) 17 | .join("\n"); 18 | })(); 19 | 20 | if (text.trim() === "") { 21 | return fail({ 22 | type: "NoText" as const, 23 | reason: `no text in the editor`, 24 | }); 25 | } 26 | 27 | return succeed(text); 28 | } 29 | -------------------------------------------------------------------------------- /packages/extension/src/isBigQuery.ts: -------------------------------------------------------------------------------- 1 | import { extname } from "path"; 2 | import type { TextDocument } from "vscode"; 3 | import type { Config } from "./configManager"; 4 | 5 | export function isBigQuery({ 6 | config, 7 | document, 8 | }: { 9 | readonly config: Config; 10 | readonly document: TextDocument; 11 | }): boolean { 12 | return ( 13 | config.languageIds.includes(document.languageId) || 14 | config.extensions.includes(extname(document.fileName)) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/extension/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { errorToString } from "shared"; 2 | import type { OutputChannel } from "vscode"; 3 | 4 | export type Logger = ReturnType; 5 | 6 | export const createLogger = (chan: OutputChannel) => { 7 | const createChild = (prefixes: string[]) => { 8 | return { 9 | log(...messages: string[]) { 10 | chan.appendLine([prefix(prefixes), ...messages].join(" ")); 11 | }, 12 | error(err: unknown) { 13 | chan.appendLine( 14 | [prefix(prefixes), "Error:", errorToString(err)].join(" ") 15 | ); 16 | }, 17 | createChild(prefix: string) { 18 | return createChild([...prefixes, prefix]); 19 | }, 20 | dispose() { 21 | // do nothing 22 | }, 23 | }; 24 | }; 25 | 26 | return createChild([]); 27 | }; 28 | 29 | const prefix = (prefixes: Array): string => 30 | prefixes.map((p) => `[${p}]`).join(""); 31 | -------------------------------------------------------------------------------- /packages/extension/src/paramManager.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from "core"; 2 | import ordinal from "ordinal"; 3 | import type { Err, Result, RunnerID } from "shared"; 4 | import { errorToString, succeed, tryCatchSync } from "shared"; 5 | import type { ExtensionContext } from "vscode"; 6 | import { window } from "vscode"; 7 | import type { Logger } from "./logger"; 8 | 9 | export type ParamManager = ReturnType; 10 | export type Manager = { 11 | get( 12 | tokens: Array 13 | ): Promise< 14 | Result< 15 | Err<"NoParameter" | "InvalidJSON">, 16 | { [key: string]: unknown } | Array | undefined 17 | > 18 | >; 19 | clearParams(): Promise; 20 | }; 21 | 22 | export const createParamManager = ({ 23 | state, 24 | logger, 25 | }: { 26 | state: ExtensionContext["globalState"]; 27 | logger: Logger; 28 | }) => { 29 | const managers = new Map(); 30 | const l = logger.createChild("ParamManager"); 31 | return { 32 | get({ runnerId }: { runnerId: RunnerID }) { 33 | return managers.get(runnerId); 34 | }, 35 | 36 | create({ runnerId }: { runnerId: RunnerID }) { 37 | const manager = managers.get(runnerId); 38 | if (manager) { 39 | return manager; 40 | } 41 | const m: Manager = { 42 | async get(tokens) { 43 | const cache = state.get<{ [key: string]: unknown } | Array>( 44 | runnerId 45 | ); 46 | l.log("cache:", JSON.stringify(cache)); 47 | 48 | { 49 | const paramTokens = tokens.filter( 50 | (token) => token.type === "NAMED_PARAMETER" 51 | ); 52 | if (paramTokens.length > 0) { 53 | const map = paramTokens.reduce((m, token) => { 54 | const key = token.key ?? token.raw.replace(/^@/, ""); 55 | const tokens = m.get(key); 56 | m.set(key, tokens ? [...tokens, token] : [token]); 57 | return m; 58 | }, new Map>()); 59 | { 60 | if ( 61 | cache && 62 | !Array.isArray(cache) && 63 | equals(Object.keys(cache), Array.from(map.keys())) 64 | ) { 65 | l.log("use cache:", JSON.stringify(cache)); 66 | return succeed(cache); 67 | } 68 | } 69 | const values: { [key: string]: unknown } = {}; 70 | for (const [key] of map) { 71 | const value = await window.showInputBox({ 72 | title: `Set a parameter to "${key}"`, 73 | prompt: `Specify in JSON format`, 74 | }); 75 | if (value === undefined) { 76 | return fail({ 77 | type: "NoParameter" as const, 78 | reason: `Parameter "${key}" is not specified`, 79 | }); 80 | } 81 | const parseJSONResult = parseJSON(value); 82 | if (!parseJSONResult.success) { 83 | return parseJSONResult; 84 | } 85 | values[key] = parseJSONResult.value; 86 | } 87 | await state.update(runnerId, values); 88 | return succeed(values); 89 | } 90 | } 91 | 92 | { 93 | const paramTokens = tokens.filter( 94 | (token) => token.type === "POSITIONAL_PARAMETER" 95 | ); 96 | if (paramTokens.length > 0) { 97 | { 98 | if ( 99 | cache && 100 | Array.isArray(cache) && 101 | cache.values.length === paramTokens.length 102 | ) { 103 | l.log("use cache:", JSON.stringify(cache)); 104 | return succeed(cache); 105 | } 106 | } 107 | 108 | const values: Array = []; 109 | for (let i = 0; i < paramTokens.length; i++) { 110 | const key = ordinal(i + 1); 111 | const value = await window.showInputBox({ 112 | title: `Set a parameter for the ${key} param`, 113 | prompt: `Specify in JSON format`, 114 | }); 115 | if (value === undefined) { 116 | return fail({ 117 | type: "NoParameter" as const, 118 | reason: `${key} parameter is not specified`, 119 | }); 120 | } 121 | const parseJSONResult = parseJSON(value); 122 | if (!parseJSONResult.success) { 123 | return parseJSONResult; 124 | } 125 | values[i] = parseJSONResult.value; 126 | } 127 | await state.update(runnerId, values); 128 | return succeed(values); 129 | } 130 | } 131 | 132 | return succeed(undefined); 133 | }, 134 | 135 | async clearParams() { 136 | await Promise.all([ 137 | state.update(`${runnerId}`, undefined), 138 | state.update(`${runnerId}-named`, undefined), 139 | state.update(`${runnerId}-positional`, undefined), 140 | ]); 141 | }, 142 | }; 143 | managers.set(runnerId, m); 144 | return m; 145 | }, 146 | 147 | async clearAllParams() { 148 | await Promise.all( 149 | state.keys().map((key) => state.update(key, undefined)) 150 | ); 151 | }, 152 | 153 | dispose() { 154 | managers.clear(); 155 | }, 156 | }; 157 | }; 158 | 159 | const parseJSON = (value: string): Result, unknown> => { 160 | return tryCatchSync( 161 | () => JSON.parse(value), 162 | (err) => ({ 163 | type: "InvalidJSON", 164 | reason: errorToString(err), 165 | }) 166 | ); 167 | }; 168 | const equals = (a: Array, b: Array): boolean => 169 | a.length === b.length && a.every((v, i) => v === b[i]); 170 | -------------------------------------------------------------------------------- /packages/extension/src/previewer.ts: -------------------------------------------------------------------------------- 1 | import type { TableReference } from "shared"; 2 | import { getTableName } from "shared"; 3 | import type { ConfigManager } from "./configManager"; 4 | import type { Logger } from "./logger"; 5 | import type { RunnerManager } from "./runner"; 6 | 7 | export type Previewer = ReturnType; 8 | 9 | export const createPreviewer = ({ 10 | logger, 11 | configManager, 12 | runnerManager, 13 | }: { 14 | logger: Logger; 15 | configManager: ConfigManager; 16 | runnerManager: RunnerManager; 17 | }) => { 18 | return { 19 | async preview(tableReference: TableReference) { 20 | const id = getTableName(tableReference); 21 | const { 22 | previewer: { rowsPerPage }, 23 | } = configManager.get(); 24 | const query = 25 | `SELECT * FROM \`${id}\`` + 26 | (rowsPerPage ? ` LIMIT ${rowsPerPage}` : ""); 27 | logger.log("query:", query); 28 | 29 | const runnerResult = await runnerManager.preview({ 30 | title: tableReference.tableId, 31 | query, 32 | tableReference, 33 | }); 34 | if (!runnerResult.success) { 35 | logger.error(runnerResult); 36 | return; 37 | } 38 | const runner = runnerResult.value; 39 | 40 | await runner.run(); 41 | }, 42 | 43 | dispose() { 44 | // do nothing 45 | }, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/extension/src/quickfix.ts: -------------------------------------------------------------------------------- 1 | import type { Disposable, TextDocument } from "vscode"; 2 | import { 3 | CodeAction, 4 | CodeActionKind, 5 | languages, 6 | Position, 7 | Range, 8 | WorkspaceEdit, 9 | } from "vscode"; 10 | import type { Config, ConfigManager } from "./configManager"; 11 | 12 | export type QuickFixManager = ReturnType; 13 | 14 | export type QuickFix = { 15 | register(params: { 16 | start: { line: number; character: number }; 17 | before: string; 18 | after: string; 19 | }): void; 20 | clear(): void; 21 | }; 22 | 23 | export const createQuickFixManager = ({ 24 | configManager, 25 | }: { 26 | configManager: ConfigManager; 27 | }): Disposable & { 28 | get(document: TextDocument): QuickFix; 29 | } => { 30 | const codeActionsWithRange = new Map< 31 | TextDocument, 32 | Array<{ action: CodeAction; range: Range }> 33 | >(); 34 | 35 | let unregister: Disposable = { 36 | dispose() { 37 | // do nothing 38 | }, 39 | }; 40 | let languageIds: Array = []; 41 | let extensions: Array = []; 42 | const register = (config: Config) => { 43 | // ignore when languageIds is not changed 44 | if ( 45 | JSON.stringify(languageIds) === JSON.stringify(config.languageIds) && 46 | JSON.stringify(extensions) === JSON.stringify(config.extensions) 47 | ) { 48 | return; 49 | } 50 | languageIds = config.languageIds; 51 | extensions = config.extensions; 52 | 53 | unregister.dispose(); 54 | unregister = languages.registerCodeActionsProvider( 55 | [ 56 | ...config.languageIds.map((language) => ({ language })), 57 | ...config.extensions.map((ext) => ({ pattern: `**/*${ext}` })), 58 | ], 59 | { 60 | provideCodeActions(document, range) { 61 | const actionsWithRange = codeActionsWithRange.get(document); 62 | if (!actionsWithRange) { 63 | return; 64 | } 65 | const actions = actionsWithRange 66 | .filter((actionWithRange) => 67 | actionWithRange.range.intersection(range) 68 | ) 69 | .map(({ action }) => action); 70 | if (!actions.length) { 71 | return; 72 | } 73 | return actions; 74 | }, 75 | }, 76 | { 77 | providedCodeActionKinds: [CodeActionKind.QuickFix], 78 | } 79 | ); 80 | }; 81 | register(configManager.get()); 82 | const subscriptions = [configManager.onChange(register)]; 83 | 84 | return { 85 | get(document) { 86 | return { 87 | register({ start, before, after }) { 88 | const action = new CodeAction( 89 | `Change name to '${after}'`, 90 | CodeActionKind.QuickFix 91 | ); 92 | const edit = new WorkspaceEdit(); 93 | const range = new Range( 94 | new Position(start.line, start.character), 95 | new Position(start.line, start.character + before.length) 96 | ); 97 | edit.replace(document.uri, range, after); 98 | action.edit = edit; 99 | codeActionsWithRange.set(document, [ 100 | { 101 | range, 102 | action, 103 | }, 104 | ]); 105 | }, 106 | 107 | clear() { 108 | codeActionsWithRange.delete(document); 109 | }, 110 | }; 111 | }, 112 | 113 | dispose() { 114 | subscriptions.forEach((s) => s.dispose()); 115 | unregister.dispose(); 116 | codeActionsWithRange.clear(); 117 | }, 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /packages/extension/src/statusManager.ts: -------------------------------------------------------------------------------- 1 | import type { RunnerID } from "shared"; 2 | import type { window } from "vscode"; 3 | import { StatusBarAlignment } from "vscode"; 4 | import type { Config, ConfigManager } from "./configManager"; 5 | 6 | export type StatusManager = ReturnType; 7 | export type Status = Readonly<{ 8 | loadProcessed(): void; 9 | errorProcessed(): void; 10 | succeedProcessed(usage: ProcessedUsage): void; 11 | loadBilled(): void; 12 | errorBilled(): void; 13 | succeedBilled(usage: BilledUsage): void; 14 | show(): void; 15 | }>; 16 | 17 | type State = "ready" | "loading" | "error" | "success"; 18 | 19 | type Processed = { state: State; usage?: ProcessedUsage }; 20 | 21 | type ProcessedUsage = { 22 | bytes: string; 23 | }; 24 | 25 | type Billed = { state: State; usage?: BilledUsage }; 26 | 27 | type BilledUsage = { 28 | bytes: string; 29 | cacheHit: boolean; 30 | }; 31 | 32 | export function createStatusManager({ 33 | configManager, 34 | createStatusBarItem, 35 | }: { 36 | configManager: ConfigManager; 37 | createStatusBarItem: ReturnType; 38 | }) { 39 | const statuses = new Map(); 40 | 41 | let statusBarItem = createStatusBarItem(configManager.get().statusBarItem); 42 | const subscriptions = [ 43 | configManager.onChange((config) => { 44 | statusBarItem.dispose(); 45 | statusBarItem = createStatusBarItem(config.statusBarItem); 46 | }), 47 | ]; 48 | 49 | const statusManager = { 50 | get(runnerId: RunnerID): Status { 51 | const s = statuses.get(runnerId); 52 | if (s) { 53 | return s; 54 | } 55 | 56 | const status = create(); 57 | statuses.set(runnerId, status); 58 | return status; 59 | }, 60 | 61 | hide() { 62 | statusBarItem.hide(); 63 | statusBarItem.text = ""; 64 | statusBarItem.tooltip = undefined; 65 | }, 66 | 67 | dispose() { 68 | subscriptions.forEach((s) => s.dispose()); 69 | statusBarItem.dispose(); 70 | statuses.clear(); 71 | }, 72 | }; 73 | 74 | const create = () => { 75 | const processed: Processed = { 76 | state: "ready", 77 | usage: { 78 | bytes: "", 79 | }, 80 | }; 81 | const billed: Billed = { 82 | state: "ready", 83 | usage: { 84 | bytes: "", 85 | cacheHit: false, 86 | }, 87 | }; 88 | 89 | const apply = () => { 90 | statusBarItem.text = [ 91 | getProcessedIcon(processed?.state), 92 | processed?.usage?.bytes, 93 | getBilledIcon(billed?.state), 94 | billed?.usage?.bytes, 95 | ].join(" "); 96 | statusBarItem.tooltip = [ 97 | processed?.usage 98 | ? `This query will process ${processed.usage.bytes} when run.` 99 | : undefined, 100 | billed?.usage 101 | ? `In the last query that ran, 102 | the cache ${billed.usage.cacheHit ? "was" : "wasn't"} applied and ${ 103 | billed.usage.bytes 104 | } was the target of the bill.` 105 | : undefined, 106 | ].join("\n"); 107 | statusBarItem.show(); 108 | }; 109 | 110 | return { 111 | loadProcessed() { 112 | processed.state = "loading"; 113 | apply(); 114 | }, 115 | errorProcessed() { 116 | processed.state = "error"; 117 | apply(); 118 | }, 119 | succeedProcessed(usage: ProcessedUsage) { 120 | processed.state = "success"; 121 | processed.usage = usage; 122 | apply(); 123 | }, 124 | loadBilled() { 125 | billed.state = "loading"; 126 | apply(); 127 | }, 128 | errorBilled() { 129 | billed.state = "error"; 130 | apply(); 131 | }, 132 | succeedBilled(usage: BilledUsage) { 133 | billed.state = "success"; 134 | billed.usage = usage; 135 | apply(); 136 | }, 137 | show() { 138 | apply(); 139 | }, 140 | }; 141 | }; 142 | 143 | return statusManager; 144 | } 145 | 146 | export function createStatusBarItemCreator(w: typeof window) { 147 | return (options: Config["statusBarItem"]) => { 148 | return w.createStatusBarItem( 149 | options.align === "left" 150 | ? StatusBarAlignment.Left 151 | : options.align === "right" 152 | ? StatusBarAlignment.Right 153 | : undefined, 154 | options.priority 155 | ); 156 | }; 157 | } 158 | 159 | function getProcessedIcon(state?: State) { 160 | switch (state) { 161 | case "loading": 162 | return "$(loading~spin)"; 163 | case "error": 164 | return "$(error)"; 165 | case "success": 166 | return "$(database)"; 167 | default: 168 | return ""; 169 | } 170 | } 171 | 172 | function getBilledIcon(state?: State) { 173 | switch (state) { 174 | case "loading": 175 | return "$(loading~spin)"; 176 | case "error": 177 | return "$(error)"; 178 | case "success": 179 | return "$(credit-card)"; 180 | default: 181 | return ""; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /packages/extension/src/window.ts: -------------------------------------------------------------------------------- 1 | import type { Err, Result } from "shared"; 2 | import { errorToString, tryCatch } from "shared"; 3 | import type { Progress, ProgressOptions } from "vscode"; 4 | import { window } from "vscode"; 5 | 6 | export const showError = ( 7 | err: unknown, 8 | callbacks: { [label: string]: () => unknown } = {} 9 | ): void => { 10 | const cbs = new Map(Object.entries(callbacks)); 11 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 12 | (async () => { 13 | const key = await window.showErrorMessage( 14 | errorToString(err), 15 | ...cbs.keys() 16 | ); 17 | if (!key) { 18 | return; 19 | } 20 | const callback = cbs.get(key); 21 | if (!callback) { 22 | return; 23 | } 24 | callback(); 25 | })(); 26 | }; 27 | 28 | export const showInformation = ( 29 | message: string, 30 | callbacks: { [label: string]: () => unknown } = {} 31 | ): void => { 32 | const cbs = new Map(Object.entries(callbacks)); 33 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 34 | (async () => { 35 | const key = await window.showInformationMessage(message, ...cbs.keys()); 36 | if (!key) { 37 | return; 38 | } 39 | const callback = cbs.get(key); 40 | if (!callback) { 41 | return; 42 | } 43 | callback(); 44 | })(); 45 | }; 46 | 47 | export const openProgress = (options: ProgressOptions) => { 48 | let close!: () => Promise, void>>; 49 | let progress!: Progress<{ message?: string; increment?: number }>; 50 | const taskPromise = new Promise((resolve) => { 51 | close = async () => { 52 | resolve(null); 53 | return uiPromise; 54 | }; 55 | }); 56 | const uiPromise = tryCatch( 57 | async () => 58 | window.withProgress(options, async (prog) => { 59 | progress = prog; 60 | await taskPromise; 61 | }), 62 | (err) => ({ 63 | type: "Unknown" as const, 64 | reason: errorToString(err), 65 | }) 66 | ); 67 | return { 68 | report: progress.report.bind(progress), 69 | close, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /packages/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-end.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-panel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-prev.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon-start.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets-src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/misc/assets-src/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/misc/assets/icon-activity-bar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/misc/assets/icon-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/assets/icon-panel.png -------------------------------------------------------------------------------- /packages/misc/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/assets/icon.png -------------------------------------------------------------------------------- /packages/misc/docs/flow.md: -------------------------------------------------------------------------------- 1 | ## Resource Lifecycle 2 | 3 | ```mermaid 4 | flowchart LR 5 | subgraph usage 6 | query[/Query/] 7 | resource[Resource] 8 | ui(UI) 9 | end 10 | ``` 11 | 12 | ```mermaid 13 | flowchart LR 14 | procedure[/PROCEDURE/] 15 | routine[Routine] 16 | run(Run) 17 | preview(Preview) 18 | select[/SELECT/] 19 | merge[/MERGE/] 20 | table[Table] 21 | results[Results] 22 | download(Download) 23 | format(Select\nformat) 24 | path(Select\nfile path) 25 | file[File] 26 | 27 | procedure --> routine --> run 28 | run --> select 29 | run --> merge 30 | 31 | merge --> table --> preview --> select 32 | select --> results 33 | results --> download --> format --> path --> file 34 | ``` 35 | 36 | ## Processing 37 | 38 | ```mermaid 39 | flowchart LR 40 | 41 | subgraph Runner 42 | direction LR 43 | Query(Query) -- dry run --> Query -- run --> data(Rows) 44 | end 45 | 46 | subgraph Formatter 47 | direction LR 48 | Table(Table) 49 | CSV(CSV) 50 | JSON(JSON) 51 | JSONL(JSON Lines) 52 | end 53 | 54 | subgraph Renderer 55 | direction LR 56 | Viewer 57 | File 58 | Clipboard(Clipboard\nunimplemented) 59 | Log(Log\ndeprecated) 60 | end 61 | 62 | Runner --> Formatter --> Renderer 63 | ``` 64 | 65 | DEPRECATED 66 | 67 | ```json 68 | "bigqueryRunner.format.type": { 69 | "type": "string", 70 | "default": "table", 71 | "enum": [ 72 | "table", 73 | "markdown", 74 | "json", 75 | "json-lines", 76 | "csv" 77 | ], 78 | "description": "Formatting method." 79 | }, 80 | "bigqueryRunner.output.type": { 81 | "type": "string", 82 | "default": "viewer", 83 | "enum": [ 84 | "viewer", 85 | "log", 86 | "file" 87 | ], 88 | "description": "The output destination for the query results. When set to `viewer`, this extension opens the webview pane and renders the results with tags. When set to `log`, this extension opens the output panel and outputs the results in the format set in `bigqueryRunner.format.type`. When set to `file`, this extension outputs the results as a file in the directory set in `bigqueryRunner.output.file.path`, in the format set in `bigqueryRunner.format.type`." 89 | }, 90 | "bigqueryRunner.output.file.path": { 91 | "type": "string", 92 | "default": ".", 93 | "description": "The output directory of the file when `bigqueryRunner.output.type` is specified as `file`." 94 | }, 95 | ``` 96 | 97 | ```typescript 98 | switch (config.output.type) { 99 | case "log": 100 | return createLogOutput({ 101 | formatter: createFormatter({ config }), 102 | outputChannel, 103 | }); 104 | case "file": { 105 | if (!workspace.workspaceFolders || !workspace.workspaceFolders[0]) { 106 | throw new Error(`no workspace folders`); 107 | } 108 | 109 | const formatter = createFormatter({ config }); 110 | const dirname = join( 111 | workspace.workspaceFolders[0].uri.path || 112 | workspace.workspaceFolders[0].uri.fsPath, 113 | config.output.file.path 114 | ); 115 | const path = join( 116 | dirname, 117 | `${basename(fileName, extname(fileName))}${formatToExtension( 118 | formatter.type 119 | )}` 120 | ); 121 | await mkdirp(dirname); 122 | const stream = createWriteStream(path, "utf-8"); 123 | 124 | return createFileOutput({ 125 | formatter, 126 | stream, 127 | }); 128 | } 129 | } 130 | ``` 131 | -------------------------------------------------------------------------------- /packages/misc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "misc", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "build": "mkdirp ../../out && cpx \"./assets/*\" ../../out/assets" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/misc/queries/array-join.bqsql: -------------------------------------------------------------------------------- 1 | with 2 | orders as ( 3 | select 4 | 1 as order_id, 5 | [ 6 | struct (1001 as product_id, 4 as quantity), 7 | struct (1003 as product_id, 1 as quantity) 8 | ] as items 9 | union all 10 | select 11 | 2 as order_id, 12 | [ 13 | struct (1003 as product_id, 2 as quantity), 14 | struct (1003 as product_id, 4 as quantity) 15 | ] as items 16 | ), 17 | products as ( 18 | select 19 | 1001 as product_id, 20 | 'wallet' as name, 21 | 30000 as price 22 | union all 23 | select 24 | 1002 as product_id, 25 | 'watch' as name, 26 | 10000 as price 27 | union all 28 | select 29 | 1003 as product_id, 30 | 'bag' as name, 31 | 50000 as price 32 | ) 33 | select 34 | order_id, 35 | array ( 36 | select as struct 37 | * 38 | from 39 | unnest (items) 40 | left join products using (product_id) 41 | ) as items 42 | from 43 | orders 44 | -------------------------------------------------------------------------------- /packages/misc/queries/create-or-replace-a.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | `minodisk-api.testing.a` as 3 | select 4 | 1 as id, 5 | 'foo' as column_a 6 | union all 7 | select 8 | 2 as id, 9 | 'bar' as column_a 10 | union all 11 | select 12 | 3 as id, 13 | 'baz' as column_a 14 | -------------------------------------------------------------------------------- /packages/misc/queries/create-or-replace-b.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | `vertect.test.b` as 3 | select 4 | 1 as id, 5 | 'hoge' as column_b 6 | union all 7 | select 8 | 2 as id, 9 | 'fuga' as column_b 10 | union all 11 | select 12 | 3 as id, 13 | 'piyo' as column_b 14 | -------------------------------------------------------------------------------- /packages/misc/queries/create-or-replace.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | `minodisk-api.testing.create_or_replace` as 3 | select 4 | 1 as a, 5 | 'foo' as b 6 | union all 7 | select 8 | 2 as a, 9 | 'bar' as b 10 | union all 11 | select 12 | 3 as a, 13 | 'baz' as b 14 | -------------------------------------------------------------------------------- /packages/misc/queries/create-struct-array.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | testing.struct_array as 3 | select 4 | struct ( 5 | 1 as a, 6 | 'foo' as b, 7 | timestamp('2022-10-08T00:00:00Z') as c 8 | ) as simple_struct, 9 | ['foo', 'bar'] as simple_array, 10 | [ 11 | struct ( 12 | 2 as a, 13 | 'bar' as b, 14 | timestamp('2022-10-09T00:00:00Z') as c 15 | ), 16 | struct ( 17 | 3 as a, 18 | 'baz' as b, 19 | timestamp('2022-10-10T00:00:00Z') as c 20 | ) 21 | ] as sharrow_array, 22 | [ 23 | struct ( 24 | 2 as a, 25 | 'bar' as b, 26 | [ 27 | struct ( 28 | true as d, 29 | 100 as e, 30 | struct ('foo' as g, 'baz' as h) as f 31 | ), 32 | struct ( 33 | false as d, 34 | 200 as e, 35 | struct ('bar' as g, 'bar' as h) as f 36 | ), 37 | struct ( 38 | true as d, 39 | 300 as e, 40 | struct ('baz' as g, 'foo' as h) as f 41 | ) 42 | ] as c 43 | ) 44 | ] as deep_array, 45 | -------------------------------------------------------------------------------- /packages/misc/queries/empty.bqsql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/queries/empty.bqsql -------------------------------------------------------------------------------- /packages/misc/queries/error-quickfix.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | en_labl, 3 | laels 4 | from 5 | `bigquery-public-data.wikipedia.wikidata` 6 | where 7 | array_length(labels) < 5 8 | limit 9 | 100012 10 | -------------------------------------------------------------------------------- /packages/misc/queries/error-syntax.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | * 3 | from 4 | ( 5 | select 1 as id, 'foo' as name 6 | ) 7 | left join ( 8 | select 1 as id, 'bar' as type 9 | ) 10 | -------------------------------------------------------------------------------- /packages/misc/queries/error-type.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | ip 3 | from 4 | ( 5 | select 1 as id 6 | union all select 2 as id 7 | ) 8 | -------------------------------------------------------------------------------- /packages/misc/queries/error.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | ip 3 | from 4 | ( 5 | select 6 | 'a' as id 7 | ) 8 | -------------------------------------------------------------------------------- /packages/misc/queries/geom.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | geo_id, 3 | name, 4 | internal_point_latitude, 5 | internal_point_longitude, 6 | from 7 | `bigquery-public-data.geo_us_boundaries.metropolitan_divisions` 8 | -- limit 9 | -- 10 10 | -------------------------------------------------------------------------------- /packages/misc/queries/in-code.ts: -------------------------------------------------------------------------------- 1 | export function main() { 2 | const invalid = ` 3 | select 4 | * 5 | from 6 | ( 7 | select 1 as id, 'foo' as name 8 | ) left join ( 9 | select 1 as id, 'bar' as type 10 | ) 11 | `; 12 | const valid = `select 13 | id 14 | from 15 | ( 16 | select 1 as id 17 | union all select 2 as id 18 | )`; 19 | console.log(invalid, valid); 20 | } 21 | -------------------------------------------------------------------------------- /packages/misc/queries/join-differect-pj-tables.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | * 3 | FROM 4 | `minodisk-api.testing.a` 5 | join `vertect.test.b` using (id) 6 | -------------------------------------------------------------------------------- /packages/misc/queries/json.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | `minodisk-api.testing.create_or_replace` as 3 | select 4 | 1 as a, 5 | JSON '{"name": "Alice", "age": 30}' as b; 6 | -------------------------------------------------------------------------------- /packages/misc/queries/merge.bqsql: -------------------------------------------------------------------------------- 1 | create or replace table 2 | `testing.merge_target` as 3 | select 4 | 1 as id, 5 | 'foo' as val 6 | union all 7 | select 8 | 2 as id, 9 | 'bar' as val; 10 | 11 | create or replace table 12 | `testing.merge_source` as 13 | select 14 | 2 as id, 15 | 'barbar' as val 16 | union all 17 | select 18 | 3 as id, 19 | 'baz' as val; 20 | 21 | merge 22 | testing.merge_target as target using testing.merge_source as source on target.id = source.id 23 | when matched then 24 | update set 25 | id = source.id, 26 | val = source.val 27 | when not matched then 28 | insert 29 | row; 30 | -------------------------------------------------------------------------------- /packages/misc/queries/nullable-struct.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | struct( 3 | 1 as b, 4 | struct( 5 | 2 as d 6 | ) as c 7 | ) as a 8 | union all 9 | select 10 | null as a 11 | union all 12 | select 13 | struct( 14 | 1 as b, 15 | struct( 16 | null as d 17 | ) as c 18 | ) as a 19 | -------------------------------------------------------------------------------- /packages/misc/queries/params-arrays.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | name, 3 | sum(number) as count 4 | FROM 5 | `bigquery-public-data.usa_names.usa_1910_2013` 6 | WHERE 7 | gender = @gender -- "M" 8 | AND state IN UNNEST(@states) -- ["WA", "WI", "WV", "WY"] 9 | GROUP BY 10 | name 11 | ORDER BY 12 | count DESC 13 | LIMIT 14 | 10; 15 | -------------------------------------------------------------------------------- /packages/misc/queries/params-named-duplicated.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | * 3 | FROM 4 | `bigquery-public-data.samples.shakespeare` 5 | WHERE 6 | corpus = @corpus -- "romeoandjuliet" 7 | AND word_count >= @min_word_count -- 250 8 | AND word_count < @min_word_count + 100 9 | ORDER BY 10 | word_count DESC 11 | -------------------------------------------------------------------------------- /packages/misc/queries/params-named-with-comments.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | corpus, 3 | word, 4 | # @foo, 5 | # @bar # -- @baz */ @qux, 6 | word_count 7 | -- @bar # @baz -- @foo #-- @qux 8 | FROM 9 | `bigquery-public-data.samples.shakespeare` 10 | WHERE 11 | corpus = @corpus/* @foo -- @bar -- "romeoandjuliet" 12 | @baz # /* @qux 13 | @foo 14 | @foo@bar */AND word_count >= @min_word_count -- 250 15 | ORDER BY 16 | word_count DESC 17 | -------------------------------------------------------------------------------- /packages/misc/queries/params-named-with-strings.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | word, 3 | word_count 4 | FROM 5 | `bigquery-public-data.samples.shakespeare` 6 | WHERE 7 | word != "@a" 8 | AND word != '@b' 9 | AND word != '@c it\'s @c' 10 | AND word != "@d it\"s @d" 11 | AND word != """@e""" 12 | AND word != """ 13 | @f 14 | """ 15 | AND word != """ 16 | \""" @f 17 | """ 18 | AND corpus = @corpus -- "romeoandjuliet" 19 | AND word_count >= @min_word_count -- 250 20 | ORDER BY 21 | word_count DESC 22 | -------------------------------------------------------------------------------- /packages/misc/queries/params-named.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | corpus, 3 | word, 4 | word_count 5 | FROM 6 | `bigquery-public-data.samples.shakespeare` 7 | WHERE 8 | corpus = @corpus -- "romeoandjuliet" 9 | AND word_count >= @min_word_count -- 250 10 | ORDER BY 11 | word_count DESC 12 | -------------------------------------------------------------------------------- /packages/misc/queries/params-positional-with-comments.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | corpus, 3 | word, 4 | # @foo, 5 | # @bar # -- @baz */ @qux, 6 | word_count 7 | -- @bar # @baz -- @foo #-- @qux 8 | FROM 9 | `bigquery-public-data.samples.shakespeare` 10 | WHERE 11 | corpus = ?/* @foo -- @bar -- "romeoandjuliet" 12 | @baz # /* @qux 13 | @foo 14 | @foo@bar */AND word_count >= ? -- 250 15 | ORDER BY 16 | word_count DESC 17 | -------------------------------------------------------------------------------- /packages/misc/queries/params-positional-with-strings.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | word, 3 | word_count 4 | FROM 5 | `bigquery-public-data.samples.shakespeare` 6 | WHERE 7 | word != "?" 8 | AND word != '?' 9 | AND word != '? it\'s ?' 10 | AND word != "? it\"s ?" 11 | AND word != """?""" 12 | AND word != """ 13 | ? 14 | """ 15 | AND word != """ 16 | \""" ? 17 | """ 18 | AND corpus = ? -- "romeoandjuliet" 19 | AND word_count >= ? -- 250 20 | ORDER BY 21 | word_count DESC 22 | -------------------------------------------------------------------------------- /packages/misc/queries/params-positional.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | corpus, 3 | word, 4 | word_count 5 | FROM 6 | `bigquery-public-data.samples.shakespeare` 7 | WHERE 8 | corpus = ? -- "romeoandjuliet" 9 | AND word_count >= ? -- 250 10 | ORDER BY 11 | word_count DESC 12 | -------------------------------------------------------------------------------- /packages/misc/queries/params-structs.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | @struct_value AS struct_obj; -- {"x": 1, "y": "foo"} 3 | -------------------------------------------------------------------------------- /packages/misc/queries/params-timestamps.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | TIMESTAMP_ADD(@ts_value, INTERVAL 1 HOUR); -- "2006-01-02T15:04:05+0700" 3 | -------------------------------------------------------------------------------- /packages/misc/queries/procedure.bqsql: -------------------------------------------------------------------------------- 1 | create or replace procedure testing.procedure() 2 | begin 3 | select * from `testing.create_or_replace`; 4 | end; 5 | -------------------------------------------------------------------------------- /packages/misc/queries/select-a.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | * 3 | FROM 4 | `minodisk-api.testing.a` 5 | -------------------------------------------------------------------------------- /packages/misc/queries/select-b.bqsql: -------------------------------------------------------------------------------- 1 | SELECT 2 | * 3 | FROM 4 | `vertect.test.b` 5 | -------------------------------------------------------------------------------- /packages/misc/queries/select.bqsql: -------------------------------------------------------------------------------- 1 | select 1 as a 2 | -------------------------------------------------------------------------------- /packages/misc/queries/spreadsheet.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | * 3 | from 4 | testing.spreadsheet 5 | -------------------------------------------------------------------------------- /packages/misc/queries/spreadsheet.tsv: -------------------------------------------------------------------------------- 1 | alphanumeric name phone numberrange country region postalZip address email text currency list 2 | SNL75UDJ2XX Imogene Chapman (516) 622-2461 6 Chile Zhytomyr oblast 13814 Ap #552-6241 Nascetur Avenue accumsan.sed@icloud.ca diam eu dolor egestas rhoncus. Proin nisl sem, consequat nec, $42.23 11 3 | OYI18QFY3TU Dalton Estrada 1-740-283-3671 0 France Auvergne 36958 3123 Massa Avenue ac@icloud.net Donec sollicitudin adipiscing ligula. Aenean gravida nunc sed pede. Cum $22.45 9 4 | TEJ36PSN2JN Tamekah Lindsay 1-184-543-5214 9 Sweden Katsina 74226 202-2369 Ullamcorper Rd. erat.eget@hotmail.edu lorem semper auctor. Mauris vel turpis. Aliquam adipiscing lobortis risus. $67.12 13 5 | DDB97BFI5DE Cain Floyd 1-808-389-2735 2 Nigeria Ontario 4187 Ap #577-6848 Amet Street ante.lectus.convallis@yahoo.couk nonummy ut, molestie in, tempus eu, ligula. Aenean euismod mauris $4.86 1 6 | HZG24KMK7DU Rhona Dale (112) 772-4333 5 India North Jeolla 7665 XL Ap #849-1554 Ultrices Rd. orci.luctus@aol.com eleifend, nunc risus varius orci, in consequat enim diam vel $60.71 5 7 | -------------------------------------------------------------------------------- /packages/misc/queries/system-variables.bqsql: -------------------------------------------------------------------------------- 1 | SELECT @@project_id as project_id; 2 | -------------------------------------------------------------------------------- /packages/misc/queries/types.bqsql: -------------------------------------------------------------------------------- 1 | declare a bignumeric(10) default cast('999999' as bignumeric(10)); 2 | declare d bytes(10) default cast('foo' as bytes(10)); 3 | declare e float64 default cast('3.14' as float64); 4 | declare f int64 default cast('9162371277713859723' as int64); 5 | DECLARE y NUMERIC(5, 2) DEFAULT 123.45; 6 | 7 | select 8 | a as BIGNUMERIC, 9 | true as BOOL, 10 | false as BOOLEAN, 11 | d as BYTES, 12 | current_date() as DATE, 13 | current_datetime() as DATETIME, 14 | 3.14 as FLOAT, 15 | e as FLOAT64, 16 | f as INT64, 17 | 4 as INTEGER, 18 | make_interval(6, 5, 4, 3, 2, 1) as itvl, 19 | y as NUMERIC, 20 | 'foo' as STRING, 21 | current_time() as TIME, 22 | current_timestamp() as TIMESTAMP, 23 | -------------------------------------------------------------------------------- /packages/misc/queries/variables.bqsql: -------------------------------------------------------------------------------- 1 | DECLARE x NUMERIC(10) DEFAULT 12345; 2 | 3 | SELECT x AS x; 4 | -------------------------------------------------------------------------------- /packages/misc/queries/wikipedia.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | en_label, 3 | labels 4 | from 5 | `bigquery-public-data.wikipedia.wikidata` 6 | where 7 | array_length(labels) < 5 8 | limit 9 | 10000 10 | -------------------------------------------------------------------------------- /packages/misc/queries/without-dataset.bqsql: -------------------------------------------------------------------------------- 1 | select 2 | en_label, 3 | labels 4 | from 5 | wikidata 6 | where 7 | array_length(labels) < 5 8 | limit 9 | 5000 10 | -------------------------------------------------------------------------------- /packages/misc/screenshots/dry-run.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/screenshots/dry-run.gif -------------------------------------------------------------------------------- /packages/misc/screenshots/query-validation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/screenshots/query-validation.gif -------------------------------------------------------------------------------- /packages/misc/screenshots/run.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minodisk/bigquery-runner/cf3781d7bb6538342dc98e5c12af6532d7af2617/packages/misc/screenshots/run.gif -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "shared", 4 | "main": "src/index.ts", 5 | "scripts": { 6 | "lint": "eslint \"./src/**/*.ts\"", 7 | "check": "run-p check:*", 8 | "check:tsc": "tsc", 9 | "check:format": "npm run format -- --check", 10 | "fix": "run-p fix:*", 11 | "fix:lint": "npm run lint -- --fix", 12 | "fix:format": "npm run format -- --write", 13 | "test": "jest --silent=false", 14 | "test-coverage": "npm run test -- --coverage --coverageDirectory ../../coverage/core", 15 | "test-watch": "npm run test -- --watchAll", 16 | "format": "prettier --write src" 17 | }, 18 | "jest": { 19 | "roots": [ 20 | "/src" 21 | ], 22 | "testMatch": [ 23 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 24 | ], 25 | "transform": { 26 | "^.+\\.(ts|tsx)$": "ts-jest" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/shared/src/error.ts: -------------------------------------------------------------------------------- 1 | import type { Failure } from "./result"; 2 | import { unwrap } from "./result"; 3 | 4 | export type Err = { 5 | type: T; 6 | reason: string; 7 | }; 8 | 9 | export type UnknownError = Err<"Unknown">; 10 | 11 | export const errorToString = (err: unknown): string => { 12 | const failure = err as Failure>; 13 | if (failure.success === false) { 14 | const error = unwrap(failure); 15 | return `${error.type}: ${error.reason}`; 16 | } 17 | 18 | const error = err as Err; 19 | if (error.type && error.reason) { 20 | return `${error.type}: ${error.reason}`; 21 | } 22 | 23 | const e = err as { message: string; toString(): string }; 24 | if (e.message) { 25 | return e.message; 26 | } 27 | 28 | if (e.toString) { 29 | return e.toString(); 30 | } 31 | 32 | return String(e); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/shared/src/funcs.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commas, 3 | getJobName, 4 | getRoutineName, 5 | getTableName, 6 | isData, 7 | } from "./funcs"; 8 | 9 | describe("funcs", () => { 10 | describe(getJobName.name, () => { 11 | it("should return job name", () => { 12 | expect( 13 | getJobName({ 14 | projectId: "foo", 15 | location: "bar", 16 | jobId: "baz", 17 | }) 18 | ).toStrictEqual(`foo:bar.baz`); 19 | }); 20 | }); 21 | 22 | describe(getTableName.name, () => { 23 | it("should return table name", () => { 24 | expect( 25 | getTableName({ 26 | projectId: "foo", 27 | datasetId: "bar", 28 | tableId: "baz", 29 | }) 30 | ).toStrictEqual(`foo.bar.baz`); 31 | }); 32 | }); 33 | 34 | describe(getRoutineName.name, () => { 35 | it("should return routine name", () => { 36 | expect( 37 | getRoutineName({ 38 | projectId: "foo", 39 | datasetId: "bar", 40 | routineId: "baz", 41 | }) 42 | ).toStrictEqual(`foo.bar.baz`); 43 | }); 44 | }); 45 | 46 | describe(isData.name, () => { 47 | it("should return detailed type", () => { 48 | const data = { source: "" }; 49 | if (isData(data)) { 50 | // No type error 51 | data.payload.event; 52 | } 53 | 54 | expect(isData({})).toBeFalsy(); 55 | expect(isData({ source: "foo" })).toBeFalsy(); 56 | expect(isData({ source: "bigquery-runner" })).toBeTruthy(); 57 | }); 58 | }); 59 | 60 | describe(commas.name, () => { 61 | it("should add comma every three digits", () => { 62 | expect(commas("1")).toBe("1"); 63 | expect(commas("12")).toBe("12"); 64 | expect(commas("123")).toBe("123"); 65 | expect(commas("1234")).toBe("1,234"); 66 | expect(commas("12345")).toBe("12,345"); 67 | expect(commas("123456")).toBe("123,456"); 68 | expect(commas("1234567")).toBe("1,234,567"); 69 | expect(commas("123000")).toBe("123,000"); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/shared/src/funcs.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChildDdlRoutineQuery, 3 | ChildDdlTableQuery, 4 | ChildDmlQuery, 5 | ChildQuery, 6 | Data, 7 | DownloadEvent, 8 | EndEvent, 9 | FailProcessingEvent, 10 | FocusedEvent, 11 | FocusOnTabEvent, 12 | JobHasChildrenStatistics, 13 | JobReference, 14 | JobStandaloneStatistics, 15 | JobStatistics, 16 | MetadataEvent, 17 | MoveTabFocusEvent, 18 | NextEvent, 19 | PrevEvent, 20 | PreviewEvent, 21 | RendererEvent, 22 | RoutinesEvent, 23 | RoutineReference, 24 | RowsEvent, 25 | StartEvent, 26 | StartProcessingEvent, 27 | SuccessProcessingEvent, 28 | TablesEvent, 29 | TableReference, 30 | ViewerEvent, 31 | } from "./types"; 32 | 33 | export const getJobName = ({ projectId, location, jobId }: JobReference) => 34 | `${projectId}:${location}.${jobId}`; 35 | 36 | export const getTableName = ({ 37 | projectId, 38 | datasetId, 39 | tableId, 40 | }: TableReference): string => `${projectId}.${datasetId}.${tableId}`; 41 | 42 | export const getRoutineName = ({ 43 | projectId, 44 | datasetId, 45 | routineId, 46 | }: RoutineReference): string => `${projectId}.${datasetId}.${routineId}`; 47 | 48 | export const isStandaloneStatistics = ( 49 | statistics: JobStatistics 50 | ): statistics is JobStandaloneStatistics => { 51 | return !("numChildJobs" in statistics); 52 | }; 53 | export const isJobHasChildrenStatistics = ( 54 | statistics: JobStatistics 55 | ): statistics is JobHasChildrenStatistics => { 56 | return "numChildJobs" in statistics; 57 | }; 58 | 59 | export const isChildDmlQuery = (query: ChildQuery): query is ChildDmlQuery => { 60 | return "dmlStats" in query; 61 | }; 62 | export const isChildDdlTableQuery = ( 63 | query: ChildQuery 64 | ): query is ChildDdlTableQuery => { 65 | return "ddlTargetTable" in query; 66 | }; 67 | export const isChildDdlRoutineQuery = ( 68 | query: ChildQuery 69 | ): query is ChildDdlRoutineQuery => { 70 | return "ddlTargetRoutine" in query; 71 | }; 72 | 73 | export function isData(data: { source?: string }): data is Data { 74 | return data.source === "bigquery-runner"; 75 | } 76 | 77 | export function isFocusedEvent(e: RendererEvent): e is FocusedEvent { 78 | return e.event === "focused"; 79 | } 80 | 81 | export function isStartProcessingEvent( 82 | e: RendererEvent 83 | ): e is StartProcessingEvent { 84 | return e.event === "startProcessing"; 85 | } 86 | 87 | export function isMetadataEvent(e: RendererEvent): e is MetadataEvent { 88 | return e.event === "metadata"; 89 | } 90 | 91 | export function isTablesEvent(e: RendererEvent): e is TablesEvent { 92 | return e.event === "tables"; 93 | } 94 | 95 | export function isRoutinesEvent(e: RendererEvent): e is RoutinesEvent { 96 | return e.event === "routines"; 97 | } 98 | 99 | export function isRowsEvent(e: RendererEvent): e is RowsEvent { 100 | return e.event === "rows"; 101 | } 102 | 103 | export function isSuccessLoadingEvent( 104 | e: RendererEvent 105 | ): e is SuccessProcessingEvent { 106 | return e.event === "successProcessing"; 107 | } 108 | 109 | export function isFailProcessingEvent( 110 | e: RendererEvent 111 | ): e is FailProcessingEvent { 112 | return e.event === "failProcessing"; 113 | } 114 | 115 | export function isMoveTabFocusEvent(e: RendererEvent): e is MoveTabFocusEvent { 116 | return e.event === "moveTabFocus"; 117 | } 118 | 119 | export function isFocusOnTabEvent(e: RendererEvent): e is FocusOnTabEvent { 120 | return e.event === "focusOnTab"; 121 | } 122 | 123 | export function isLoadedEvent(e: ViewerEvent): e is StartEvent { 124 | return e.event === "loaded"; 125 | } 126 | 127 | export function isStartEvent(e: ViewerEvent): e is StartEvent { 128 | return e.event === "start"; 129 | } 130 | 131 | export function isEndEvent(e: ViewerEvent): e is EndEvent { 132 | return e.event === "end"; 133 | } 134 | 135 | export function isPrevEvent(e: ViewerEvent): e is PrevEvent { 136 | return e.event === "prev"; 137 | } 138 | 139 | export function isNextEvent(e: ViewerEvent): e is NextEvent { 140 | return e.event === "next"; 141 | } 142 | 143 | export function isDownloadEvent(e: ViewerEvent): e is DownloadEvent { 144 | return e.event === "download"; 145 | } 146 | 147 | export function isPreviewEvent(e: ViewerEvent): e is PreviewEvent { 148 | return e.event === "preview"; 149 | } 150 | 151 | export const commas = (num: bigint | string): string => { 152 | const text = num.toString(); 153 | const len = text.length; 154 | let result = ""; 155 | for (let i = 0; i < len; i++) { 156 | const char = text[len - 1 - i]; 157 | if (!char) { 158 | break; 159 | } 160 | if (i === 0 || i % 3 !== 0) { 161 | result = `${char}${result}`; 162 | } else { 163 | result = `${char},${result}`; 164 | } 165 | } 166 | return result; 167 | }; 168 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./error"; 2 | export * from "./funcs"; 3 | export * from "./result"; 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /packages/shared/src/result.ts: -------------------------------------------------------------------------------- 1 | export type Result = Failure | Success; 2 | 3 | export type Failure = Readonly<{ 4 | success: false; 5 | value: F; 6 | }>; 7 | 8 | export const fail = (value: F): Failure => ({ 9 | success: false, 10 | value, 11 | }); 12 | 13 | export type Success = Readonly<{ 14 | success: true; 15 | value: S; 16 | }>; 17 | 18 | export const succeed = (value: S): Success => ({ 19 | success: true, 20 | value, 21 | }); 22 | 23 | export const unwrap = ({ value }: { value: T }): T => value; 24 | 25 | export const tryCatchSync = ( 26 | tryFn: () => S, 27 | catchFn: (err: unknown) => F 28 | ): Result => { 29 | try { 30 | return succeed(tryFn()); 31 | } catch (err) { 32 | return fail(catchFn(err)); 33 | } 34 | }; 35 | 36 | export const tryCatch = async ( 37 | tryFn: () => Promise, 38 | catchFn: (err: unknown) => F 39 | ): Promise> => { 40 | try { 41 | return succeed(await tryFn()); 42 | } catch (err) { 43 | return fail(catchFn(err)); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/viewer/config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | const dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | // Don't include `.env.local` for `test` environment 21 | // since normally you expect tests to produce the same 22 | // results for everyone 23 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 24 | `${paths.dotenv}.${NODE_ENV}`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. Variable expansion is supported in .env files. 31 | // https://github.com/motdotla/dotenv 32 | // https://github.com/motdotla/dotenv-expand 33 | dotenvFiles.forEach(dotenvFile => { 34 | if (fs.existsSync(dotenvFile)) { 35 | require('dotenv-expand')( 36 | require('dotenv').config({ 37 | path: dotenvFile, 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | // We support resolving modules according to `NODE_PATH`. 44 | // This lets you use absolute paths in imports inside large monorepos: 45 | // https://github.com/facebook/create-react-app/issues/253. 46 | // It works similar to `NODE_PATH` in Node itself: 47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 49 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. 50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 51 | // We also resolve them to make sure all tools using them work consistently. 52 | const appDirectory = fs.realpathSync(process.cwd()); 53 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 54 | .split(path.delimiter) 55 | .filter(folder => folder && !path.isAbsolute(folder)) 56 | .map(folder => path.resolve(appDirectory, folder)) 57 | .join(path.delimiter); 58 | 59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 60 | // injected into the application via DefinePlugin in webpack configuration. 61 | const REACT_APP = /^REACT_APP_/i; 62 | 63 | function getClientEnvironment(publicUrl) { 64 | const raw = Object.keys(process.env) 65 | .filter(key => REACT_APP.test(key)) 66 | .reduce( 67 | (env, key) => { 68 | env[key] = process.env[key]; 69 | return env; 70 | }, 71 | { 72 | // Useful for determining whether we’re running in production mode. 73 | // Most importantly, it switches React into the correct mode. 74 | NODE_ENV: process.env.NODE_ENV || 'development', 75 | // Useful for resolving the correct path to static assets in `public`. 76 | // For example, . 77 | // This should only be used as an escape hatch. Normally you would put 78 | // images into the `src` and `import` them in code to get their paths. 79 | PUBLIC_URL: publicUrl, 80 | // We support configuring the sockjs pathname during development. 81 | // These settings let a developer run multiple simultaneous projects. 82 | // They are used as the connection `hostname`, `pathname` and `port` 83 | // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` 84 | // and `sockPort` options in webpack-dev-server. 85 | WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, 86 | WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, 87 | WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, 88 | // Whether or not react-refresh is enabled. 89 | // It is defined here so it is available in the webpackHotDevClient. 90 | FAST_REFRESH: process.env.FAST_REFRESH !== 'false', 91 | } 92 | ); 93 | // Stringify all values so we can feed into webpack DefinePlugin 94 | const stringified = { 95 | 'process.env': Object.keys(raw).reduce((env, key) => { 96 | env[key] = JSON.stringify(raw[key]); 97 | return env; 98 | }, {}), 99 | }; 100 | 101 | return { raw, stringified }; 102 | } 103 | 104 | module.exports = getClientEnvironment; 105 | -------------------------------------------------------------------------------- /packages/viewer/config/getHttpsConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const crypto = require('crypto'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const paths = require('./paths'); 8 | 9 | // Ensure the certificate and key provided are valid and if not 10 | // throw an easy to debug error 11 | function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { 12 | let encrypted; 13 | try { 14 | // publicEncrypt will throw an error with an invalid cert 15 | encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); 16 | } catch (err) { 17 | throw new Error( 18 | `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` 19 | ); 20 | } 21 | 22 | try { 23 | // privateDecrypt will throw an error with an invalid key 24 | crypto.privateDecrypt(key, encrypted); 25 | } catch (err) { 26 | throw new Error( 27 | `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ 28 | err.message 29 | }` 30 | ); 31 | } 32 | } 33 | 34 | // Read file and throw an error if it doesn't exist 35 | function readEnvFile(file, type) { 36 | if (!fs.existsSync(file)) { 37 | throw new Error( 38 | `You specified ${chalk.cyan( 39 | type 40 | )} in your env, but the file "${chalk.yellow(file)}" can't be found.` 41 | ); 42 | } 43 | return fs.readFileSync(file); 44 | } 45 | 46 | // Get the https config 47 | // Return cert files if provided in env, otherwise just true or false 48 | function getHttpsConfig() { 49 | const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; 50 | const isHttps = HTTPS === 'true'; 51 | 52 | if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { 53 | const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); 54 | const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); 55 | const config = { 56 | cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), 57 | key: readEnvFile(keyFile, 'SSL_KEY_FILE'), 58 | }; 59 | 60 | validateKeyAndCerts({ ...config, keyFile, crtFile }); 61 | return config; 62 | } 63 | return isHttps; 64 | } 65 | 66 | module.exports = getHttpsConfig; 67 | -------------------------------------------------------------------------------- /packages/viewer/config/jest/babelTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const babelJest = require('babel-jest').default; 4 | 5 | const hasJsxRuntime = (() => { 6 | if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') { 7 | return false; 8 | } 9 | 10 | try { 11 | require.resolve('react/jsx-runtime'); 12 | return true; 13 | } catch (e) { 14 | return false; 15 | } 16 | })(); 17 | 18 | module.exports = babelJest.createTransformer({ 19 | presets: [ 20 | [ 21 | require.resolve('babel-preset-react-app'), 22 | { 23 | runtime: hasJsxRuntime ? 'automatic' : 'classic', 24 | }, 25 | ], 26 | ], 27 | babelrc: false, 28 | configFile: false, 29 | }); 30 | -------------------------------------------------------------------------------- /packages/viewer/config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/viewer/config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const camelcase = require('camelcase'); 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)); 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }); 19 | const componentName = `Svg${pascalCaseFilename}`; 20 | return `const React = require('react'); 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | }; 34 | }), 35 | };`; 36 | } 37 | 38 | return `module.exports = ${assetFilename};`; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /packages/viewer/config/modules.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const resolve = require('resolve'); 8 | 9 | /** 10 | * Get additional module paths based on the baseUrl of a compilerOptions object. 11 | * 12 | * @param {Object} options 13 | */ 14 | function getAdditionalModulePaths(options = {}) { 15 | const baseUrl = options.baseUrl; 16 | 17 | if (!baseUrl) { 18 | return ''; 19 | } 20 | 21 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 22 | 23 | // We don't need to do anything if `baseUrl` is set to `node_modules`. This is 24 | // the default behavior. 25 | if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { 26 | return null; 27 | } 28 | 29 | // Allow the user set the `baseUrl` to `appSrc`. 30 | if (path.relative(paths.appSrc, baseUrlResolved) === '') { 31 | return [paths.appSrc]; 32 | } 33 | 34 | // If the path is equal to the root directory we ignore it here. 35 | // We don't want to allow importing from the root directly as source files are 36 | // not transpiled outside of `src`. We do allow importing them with the 37 | // absolute path (e.g. `src/Components/Button.js`) but we set that up with 38 | // an alias. 39 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 40 | return null; 41 | } 42 | 43 | // Otherwise, throw an error. 44 | throw new Error( 45 | chalk.red.bold( 46 | "Your project's `baseUrl` can only be set to `src` or `node_modules`." + 47 | ' Create React App does not support other values at this time.' 48 | ) 49 | ); 50 | } 51 | 52 | /** 53 | * Get webpack aliases based on the baseUrl of a compilerOptions object. 54 | * 55 | * @param {*} options 56 | */ 57 | function getWebpackAliases(options = {}) { 58 | const baseUrl = options.baseUrl; 59 | 60 | if (!baseUrl) { 61 | return {}; 62 | } 63 | 64 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 65 | 66 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 67 | return { 68 | src: paths.appSrc, 69 | }; 70 | } 71 | } 72 | 73 | /** 74 | * Get jest aliases based on the baseUrl of a compilerOptions object. 75 | * 76 | * @param {*} options 77 | */ 78 | function getJestAliases(options = {}) { 79 | const baseUrl = options.baseUrl; 80 | 81 | if (!baseUrl) { 82 | return {}; 83 | } 84 | 85 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 86 | 87 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 88 | return { 89 | '^src/(.*)$': '/src/$1', 90 | }; 91 | } 92 | } 93 | 94 | function getModules() { 95 | // Check if TypeScript is setup 96 | const hasTsConfig = fs.existsSync(paths.appTsConfig); 97 | const hasJsConfig = fs.existsSync(paths.appJsConfig); 98 | 99 | if (hasTsConfig && hasJsConfig) { 100 | throw new Error( 101 | 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' 102 | ); 103 | } 104 | 105 | let config; 106 | 107 | // If there's a tsconfig.json we assume it's a 108 | // TypeScript project and set up the config 109 | // based on tsconfig.json 110 | if (hasTsConfig) { 111 | const ts = require(resolve.sync('typescript', { 112 | basedir: paths.appNodeModules, 113 | })); 114 | config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; 115 | // Otherwise we'll check if there is jsconfig.json 116 | // for non TS projects. 117 | } else if (hasJsConfig) { 118 | config = require(paths.appJsConfig); 119 | } 120 | 121 | config = config || {}; 122 | const options = config.compilerOptions || {}; 123 | 124 | const additionalModulePaths = getAdditionalModulePaths(options); 125 | 126 | return { 127 | additionalModulePaths: additionalModulePaths, 128 | webpackAliases: getWebpackAliases(options), 129 | jestAliases: getJestAliases(options), 130 | hasTsConfig, 131 | }; 132 | } 133 | 134 | module.exports = getModules(); 135 | -------------------------------------------------------------------------------- /packages/viewer/config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 13 | // "public path" at which the app is served. 14 | // webpack needs to know it to put the right 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/viewer/scripts/test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = "test"; 5 | process.env.NODE_ENV = "test"; 6 | process.env.PUBLIC_URL = ""; 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on("unhandledRejection", (err) => { 12 | throw err; 13 | }); 14 | 15 | // Ensure environment variables are read. 16 | require("../config/env"); 17 | 18 | const jest = require("jest"); 19 | const execSync = require("child_process").execSync; 20 | let argv = process.argv.slice(2); 21 | 22 | function isInGitRepository() { 23 | try { 24 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); 25 | return true; 26 | } catch (e) { 27 | return false; 28 | } 29 | } 30 | 31 | function isInMercurialRepository() { 32 | try { 33 | execSync("hg --cwd . root", { stdio: "ignore" }); 34 | return true; 35 | } catch (e) { 36 | return false; 37 | } 38 | } 39 | 40 | // Watch unless on CI or explicitly running all tests 41 | // if ( 42 | // !process.env.CI && 43 | // argv.indexOf('--watchAll') === -1 && 44 | // argv.indexOf('--watchAll=false') === -1 45 | // ) { 46 | // // https://github.com/facebook/create-react-app/issues/5210 47 | // const hasSourceControl = isInGitRepository() || isInMercurialRepository(); 48 | // argv.push(hasSourceControl ? '--watch' : '--watchAll'); 49 | // } 50 | 51 | jest.run(argv); 52 | -------------------------------------------------------------------------------- /packages/viewer/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen, waitFor } from "@testing-library/react"; 2 | import React from "react"; 3 | import type { 4 | Data, 5 | MetadataEvent, 6 | RoutinesEvent, 7 | RowsEvent, 8 | StartProcessingEvent, 9 | SuccessProcessingEvent, 10 | } from "shared"; 11 | import type { WebviewApi } from "vscode-webview"; 12 | import type { State } from "./App"; 13 | import App from "./App"; 14 | import { ClipboardProvider } from "./context/Clipboard"; 15 | import jobEvent from "./jobEvent.json"; 16 | import routinesEvent from "./routineEvent.json"; 17 | 18 | const mockWebview = ({ 19 | postMessage, 20 | }: { 21 | postMessage: jest.Mock; 22 | }): WebviewApi => { 23 | let state: State | undefined; 24 | return { 25 | postMessage(message: unknown) { 26 | postMessage(message); 27 | }, 28 | getState(): State | undefined { 29 | return state; 30 | }, 31 | setState(newState: T): T { 32 | state = newState; 33 | return newState; 34 | }, 35 | }; 36 | }; 37 | 38 | describe("App", () => { 39 | describe("with Rows", () => { 40 | it("should render null, boolean, number and string", async () => { 41 | const postMessage = jest.fn(); 42 | const webview = mockWebview({ postMessage }); 43 | 44 | render(); 45 | window.postMessage( 46 | { 47 | source: "bigquery-runner", 48 | payload: { 49 | event: "startProcessing", 50 | }, 51 | } as Data, 52 | "*" 53 | ); 54 | window.postMessage( 55 | JSON.stringify({ 56 | source: "bigquery-runner", 57 | payload: { 58 | event: "rows", 59 | payload: { 60 | heads: [ 61 | { 62 | id: "column1", 63 | name: "column1", 64 | type: "STRING", 65 | mode: "NULLABLE", 66 | }, 67 | { 68 | id: "column2", 69 | name: "column2", 70 | type: "BOOLEAN", 71 | mode: "NULLABLE", 72 | }, 73 | { 74 | id: "column3", 75 | name: "column3", 76 | type: "FLOAT", 77 | mode: "NULLABLE", 78 | }, 79 | { 80 | id: "column4", 81 | name: "column4", 82 | type: "STRING", 83 | mode: "NULLABLE", 84 | }, 85 | ], 86 | rows: [ 87 | { 88 | rowNumber: "234", 89 | rows: [ 90 | [ 91 | { id: "column1", value: null }, 92 | { id: "column2", value: true }, 93 | { id: "column3", value: 3.14 }, 94 | { id: "column4", value: "foo" }, 95 | ], 96 | ], 97 | }, 98 | ], 99 | page: { 100 | hasPrev: false, 101 | hasNext: false, 102 | startRowNumber: "123", 103 | endRowNumber: "456", 104 | totalRows: "123000", 105 | }, 106 | }, 107 | }, 108 | } as Data), 109 | "*" 110 | ); 111 | window.postMessage( 112 | { 113 | source: "bigquery-runner", 114 | payload: { 115 | event: "successProcessing", 116 | }, 117 | } as Data, 118 | "*" 119 | ); 120 | await waitFor(() => { 121 | expect(postMessage).toHaveBeenCalledTimes(1); 122 | expect(postMessage).toHaveBeenCalledWith({ event: "loaded" }); 123 | 124 | expect(screen.getByText("column1")).toBeInTheDocument(); 125 | expect(screen.getByText("column2")).toBeInTheDocument(); 126 | expect(screen.getByText("column3")).toBeInTheDocument(); 127 | expect(screen.getByText("column4")).toBeInTheDocument(); 128 | expect(screen.getByText("234")).toBeInTheDocument(); 129 | expect(screen.getByText("null")).toBeInTheDocument(); 130 | expect(screen.getByText("true")).toBeInTheDocument(); 131 | expect(screen.getByText("3.14")).toBeInTheDocument(); 132 | expect(screen.getByText("foo")).toBeInTheDocument(); 133 | expect(screen.getByText("123")).toBeInTheDocument(); 134 | expect(screen.getByText("456")).toBeInTheDocument(); 135 | expect(screen.getByText("123,000")).toBeInTheDocument(); 136 | 137 | const jsonl = screen.getByText("JSON Lines"); 138 | expect(jsonl).toBeInTheDocument(); 139 | fireEvent.click(jsonl); 140 | expect(postMessage).toHaveBeenCalledTimes(2); 141 | expect(postMessage).toHaveBeenCalledWith({ 142 | event: "download", 143 | format: "jsonl", 144 | }); 145 | 146 | const json = screen.getByText("JSON"); 147 | expect(json).toBeInTheDocument(); 148 | fireEvent.click(json); 149 | expect(postMessage).toHaveBeenCalledTimes(3); 150 | expect(postMessage).toHaveBeenCalledWith({ 151 | event: "download", 152 | format: "json", 153 | }); 154 | 155 | const csv = screen.getByText("CSV"); 156 | expect(csv).toBeInTheDocument(); 157 | fireEvent.click(csv); 158 | expect(postMessage).toHaveBeenCalledTimes(4); 159 | expect(postMessage).toHaveBeenCalledWith({ 160 | event: "download", 161 | format: "csv", 162 | }); 163 | 164 | const md = screen.getByText("Markdown"); 165 | expect(md).toBeInTheDocument(); 166 | fireEvent.click(md); 167 | expect(postMessage).toHaveBeenCalledTimes(5); 168 | expect(postMessage).toHaveBeenCalledWith({ 169 | event: "download", 170 | format: "md", 171 | }); 172 | 173 | const txt = screen.getByText("Plain Text"); 174 | expect(txt).toBeInTheDocument(); 175 | fireEvent.click(txt); 176 | expect(postMessage).toHaveBeenCalledTimes(6); 177 | expect(postMessage).toHaveBeenCalledWith({ 178 | event: "download", 179 | format: "txt", 180 | }); 181 | }); 182 | }); 183 | }); 184 | 185 | describe("JobEvent", () => { 186 | it("should render Job tab", async () => { 187 | const writeText = jest.fn(); 188 | 189 | const postMessage = jest.fn(); 190 | const webview = mockWebview({ postMessage }); 191 | 192 | render( 193 | 194 | 195 | 196 | ); 197 | window.postMessage( 198 | { 199 | source: "bigquery-runner", 200 | payload: { 201 | event: "startProcessing", 202 | }, 203 | } as Data, 204 | "*" 205 | ); 206 | window.postMessage( 207 | { 208 | source: "bigquery-runner", 209 | payload: jobEvent, 210 | } as Data, 211 | "*" 212 | ); 213 | window.postMessage( 214 | { 215 | source: "bigquery-runner", 216 | payload: { 217 | event: "successProcessing", 218 | }, 219 | } as Data, 220 | "*" 221 | ); 222 | await waitFor(() => { 223 | expect(postMessage).toHaveBeenCalledTimes(1); 224 | expect(postMessage).toHaveBeenCalledWith({ event: "loaded" }); 225 | 226 | expect(screen.getByText("Job ID")).toBeInTheDocument(); 227 | expect(screen.getByText("User")).toBeInTheDocument(); 228 | expect(screen.getByText("Location")).toBeInTheDocument(); 229 | expect(screen.getByText("Creation time")).toBeInTheDocument(); 230 | expect(screen.getByText("Start time")).toBeInTheDocument(); 231 | expect(screen.getByText("End time")).toBeInTheDocument(); 232 | expect(screen.getByText("Duration")).toBeInTheDocument(); 233 | expect(screen.getByText("Bytes processed")).toBeInTheDocument(); 234 | expect(screen.getByText("Bytes billed")).toBeInTheDocument(); 235 | expect(screen.getByText("Use legacy SQL")).toBeInTheDocument(); 236 | 237 | expect(writeText).toBeCalledTimes(0); 238 | const copy = screen.getByLabelText("Copy"); 239 | expect(copy).toBeInTheDocument(); 240 | fireEvent.click(copy); 241 | expect(writeText).toBeCalledTimes(1); 242 | expect(writeText).toBeCalledWith( 243 | "minodisk-api:US.6654cc27-8a00-464c-b9d6-4df155a9b66d" 244 | ); 245 | }); 246 | }); 247 | }); 248 | 249 | describe("RoutineEvent", () => { 250 | it("should render Routine tab", async () => { 251 | const writeText = jest.fn(); 252 | 253 | const postMessage = jest.fn(); 254 | const webview = mockWebview({ postMessage }); 255 | 256 | render( 257 | 258 | 259 | 260 | ); 261 | window.postMessage( 262 | { 263 | source: "bigquery-runner", 264 | payload: { 265 | event: "startProcessing", 266 | }, 267 | } as Data, 268 | "*" 269 | ); 270 | window.postMessage( 271 | { 272 | source: "bigquery-runner", 273 | payload: routinesEvent, 274 | } as Data, 275 | "*" 276 | ); 277 | window.postMessage( 278 | { 279 | source: "bigquery-runner", 280 | payload: { 281 | event: "successProcessing", 282 | }, 283 | } as Data, 284 | "*" 285 | ); 286 | await waitFor(() => { 287 | expect(postMessage).toHaveBeenCalledTimes(1); 288 | expect(postMessage).toHaveBeenCalledWith({ event: "loaded" }); 289 | 290 | expect(screen.getByText("Routine ID")).toBeInTheDocument(); 291 | expect(screen.getByText("Created")).toBeInTheDocument(); 292 | expect(screen.getByText("Last modified")).toBeInTheDocument(); 293 | expect(screen.getByText("Language")).toBeInTheDocument(); 294 | expect(screen.getByText("Definition")).toBeInTheDocument(); 295 | 296 | expect(writeText).toBeCalledTimes(0); 297 | const copy = screen.getByLabelText("Copy"); 298 | expect(copy).toBeInTheDocument(); 299 | fireEvent.click(copy); 300 | expect(writeText).toBeCalledTimes(1); 301 | expect(writeText).toBeCalledWith("minodisk-api.testing.procedure"); 302 | }); 303 | }); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /packages/viewer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tab, 3 | TabList, 4 | TabPanel, 5 | TabPanels, 6 | Tabs, 7 | useToast, 8 | } from "@chakra-ui/react"; 9 | import type { FC } from "react"; 10 | import React, { useCallback, useEffect, useState } from "react"; 11 | import type { 12 | RowsPayload, 13 | RoutinePayload, 14 | MetadataPayload, 15 | TablePayload, 16 | Err, 17 | Format, 18 | Tab as TabName, 19 | TableReference, 20 | } from "shared"; 21 | import { 22 | isMoveTabFocusEvent, 23 | isFocusOnTabEvent, 24 | isData, 25 | isRowsEvent, 26 | isRoutinesEvent, 27 | isMetadataEvent, 28 | isTablesEvent, 29 | isStartProcessingEvent, 30 | isSuccessLoadingEvent, 31 | isFailProcessingEvent, 32 | } from "shared"; 33 | import type { WebviewApi } from "vscode-webview"; 34 | import { Header } from "./domain/Header"; 35 | import { Job } from "./domain/Job"; 36 | import { Routine } from "./domain/Routine"; 37 | import { Rows } from "./domain/Rows"; 38 | import { Table } from "./domain/Table"; 39 | 40 | export type State = Partial< 41 | Readonly<{ 42 | tabIndex: number; 43 | tabs: ReadonlyArray; 44 | metadataPayload: MetadataPayload; 45 | tablePayloads: ReadonlyArray; 46 | routinePayloads: ReadonlyArray; 47 | rowsPayload: RowsPayload; 48 | }> 49 | >; 50 | 51 | const App: FC<{ webview: WebviewApi }> = ({ webview: vscode }) => { 52 | // const [focused, setFocused] = useState(false); 53 | const [processing, setProcessing] = useState(false); 54 | const [error, setError] = useState | undefined>(undefined); 55 | const toast = useToast(); 56 | const [metadataPayload, setMetadataPayload] = useState< 57 | MetadataPayload | undefined 58 | >(vscode.getState()?.metadataPayload); 59 | const [tablePayloads, setTablesPayloads] = useState< 60 | ReadonlyArray 61 | >(vscode.getState()?.tablePayloads ?? []); 62 | const [routinePayloads, setRoutinePayloads] = useState< 63 | ReadonlyArray 64 | >(vscode.getState()?.routinePayloads ?? []); 65 | const [rowsPayload, setRowsPayload] = useState( 66 | vscode.getState()?.rowsPayload 67 | ); 68 | const [tabIndex, setTabIndex] = useState(vscode.getState()?.tabIndex ?? 0); 69 | const [tabs, setTabs] = useState>( 70 | vscode.getState()?.tabs ?? [] 71 | ); 72 | 73 | const setState = useCallback( 74 | (state: State) => { 75 | const old = vscode.getState() ?? {}; 76 | vscode.setState({ ...old, ...state }); 77 | }, 78 | [vscode] 79 | ); 80 | 81 | const onPrevRequest = useCallback(() => { 82 | vscode.postMessage({ event: "prev" }); 83 | }, [vscode]); 84 | const onNextRequest = useCallback(() => { 85 | vscode.postMessage({ event: "next" }); 86 | }, [vscode]); 87 | const onDownloadRequest = useCallback( 88 | (format: Format) => { 89 | vscode.postMessage({ event: "download", format }); 90 | }, 91 | [vscode] 92 | ); 93 | const onPreviewRequest = useCallback( 94 | (tableReference: TableReference) => { 95 | vscode.postMessage({ 96 | event: "preview", 97 | payload: { 98 | tableReference, 99 | }, 100 | }); 101 | }, 102 | [vscode] 103 | ); 104 | 105 | const onTabChange = useCallback((tabIndex: number) => { 106 | setTabIndex(tabIndex); 107 | }, []); 108 | 109 | const onMessage = useCallback( 110 | (e: MessageEvent) => { 111 | // When postMessage from a test, this value becomes a JSON string, so parse it. 112 | const data = 113 | typeof e.data === "string" && e.data ? JSON.parse(e.data) : e.data; 114 | if (!isData(data)) { 115 | return; 116 | } 117 | const { payload } = data; 118 | // if (isFocusedEvent(payload)) { 119 | // setFocused(payload.payload.focused); 120 | // return; 121 | // } 122 | if (isStartProcessingEvent(payload)) { 123 | setProcessing(true); 124 | setError(undefined); 125 | return; 126 | } 127 | if (isMetadataEvent(payload)) { 128 | setMetadataPayload(payload.payload); 129 | setState({ metadataPayload: payload.payload }); 130 | return; 131 | } 132 | if (isTablesEvent(payload)) { 133 | const tablePayloads = payload.payload; 134 | setTablesPayloads(tablePayloads); 135 | setState({ tablePayloads }); 136 | return; 137 | } 138 | if (isRoutinesEvent(payload)) { 139 | const routinePayloads = payload.payload; 140 | setRoutinePayloads(routinePayloads); 141 | setState({ routinePayloads }); 142 | return; 143 | } 144 | if (isRowsEvent(payload)) { 145 | setRowsPayload(payload.payload); 146 | setState({ rowsPayload: payload.payload }); 147 | return; 148 | } 149 | if (isSuccessLoadingEvent(payload)) { 150 | setProcessing(false); 151 | setError(undefined); 152 | return; 153 | } 154 | if (isFailProcessingEvent(payload)) { 155 | setProcessing(false); 156 | setError(payload.payload); 157 | return; 158 | } 159 | if (isMoveTabFocusEvent(payload)) { 160 | setTabIndex((index) => { 161 | const i = index + payload.payload.diff; 162 | const min = 0; 163 | if (i < min) { 164 | return min; 165 | } 166 | const max = tabs.length - 1; 167 | if (i > max) { 168 | return max; 169 | } 170 | return i; 171 | }); 172 | return; 173 | } 174 | if (isFocusOnTabEvent(payload)) { 175 | const index = tabs.indexOf(payload.payload.tab); 176 | if (index < 0 || tabs.length - 1 < index) { 177 | return; 178 | } 179 | setTabIndex(index); 180 | return; 181 | } 182 | throw new Error(`undefined data payload:\n'${JSON.stringify(payload)}'`); 183 | }, 184 | [setState, tabs] 185 | ); 186 | 187 | useEffect(() => { 188 | setState({ tabIndex }); 189 | }, [setState, tabIndex]); 190 | 191 | useEffect(() => { 192 | const tabs = [ 193 | ...(rowsPayload ? ["Rows" as const] : []), 194 | ...tablePayloads.map(() => "Table" as const), 195 | ...routinePayloads.map(() => "Routine" as const), 196 | ...(metadataPayload ? ["Job" as const] : []), 197 | ]; 198 | setTabs(tabs); 199 | setState({ tabs }); 200 | }, [metadataPayload, routinePayloads, rowsPayload, setState, tablePayloads]); 201 | 202 | useEffect(() => { 203 | vscode.postMessage({ event: "loaded" }); 204 | }, [vscode]); 205 | 206 | useEffect(() => { 207 | window.addEventListener("message", onMessage); 208 | return () => { 209 | window.removeEventListener("message", onMessage); 210 | }; 211 | }, [onMessage]); 212 | 213 | useEffect(() => { 214 | if (error) { 215 | toast({ 216 | title: error.type, 217 | description: error.reason, 218 | status: "error", 219 | position: "bottom-right", 220 | duration: 8000, 221 | }); 222 | } else { 223 | toast.closeAll(); 224 | } 225 | }, [error, toast]); 226 | 227 | return ( 228 | 229 |
230 | 231 | {rowsPayload ? Rows : null} 232 | {tablePayloads.map(({ id }) => ( 233 | 234 | Table 235 | 236 | ))} 237 | {routinePayloads.map(({ id }) => ( 238 | 239 | Routine 240 | 241 | ))} 242 | {metadataPayload ? Job : null} 243 | 244 |
245 | 246 | {rowsPayload ? ( 247 | 248 | 254 | 255 | ) : null} 256 | {tablePayloads.map((tablePayload) => ( 257 | 258 |
263 | 264 | ))} 265 | {routinePayloads.map((routinePayload) => ( 266 | 267 | 268 | 269 | ))} 270 | {metadataPayload ? ( 271 | 272 | 273 | 274 | ) : null} 275 | 276 | 277 | ); 278 | }; 279 | 280 | export default App; 281 | -------------------------------------------------------------------------------- /packages/viewer/src/context/Clipboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext } from "react"; 2 | import type { CFC } from "../types"; 3 | 4 | export type Clipboard = Readonly<{ 5 | writeText(data: string): Promise; 6 | }>; 7 | 8 | const ClipboardContext = createContext({ 9 | async writeText() { 10 | // do nothing 11 | }, 12 | }); 13 | 14 | export const ClipboardProvider: CFC = ({ children, ...value }) => { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | 22 | export const BrowserClipboardProvider: CFC = (props) => { 23 | const writeText = useCallback( 24 | (data: string) => window.navigator.clipboard.writeText(data), 25 | [] 26 | ); 27 | return ; 28 | }; 29 | 30 | export const useClipboard = () => { 31 | return useContext(ClipboardContext); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import type { CFC } from "../types"; 4 | 5 | export const Footer: CFC = ({ children, ...props }) => ( 6 | 7 | 19 | {children} 20 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, HStack, Spinner } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import type { CFC } from "../types"; 4 | 5 | export const Header: CFC<{ processing: boolean }> = ({ 6 | processing, 7 | children, 8 | ...props 9 | }) => { 10 | return ( 11 | 12 | 24 | {children} 25 | {processing ? ( 26 | 27 | 28 | 29 | ) : null} 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Job.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import React from "react"; 3 | import { Job } from "./Job"; 4 | 5 | describe("Job", () => { 6 | describe("metadata", () => { 7 | it("should be rendered with cache", async () => { 8 | render( 9 | 59 | ); 60 | await waitFor(() => { 61 | expect( 62 | screen.getByText( 63 | "project-id-for-test:location-for-test.job-id-for-test" 64 | ) 65 | ).toBeInTheDocument(); 66 | expect( 67 | screen.getByText("user@example.iam.gserviceaccount.com") 68 | ).toBeInTheDocument(); 69 | expect(screen.getByText("1KB")).toBeInTheDocument(); 70 | expect(screen.getByText("2KB")).toBeInTheDocument(); 71 | expect(screen.getByText("(results cached)")).toBeInTheDocument(); 72 | expect(screen.getByText("2.206 seconds")).toBeInTheDocument(); 73 | expect(screen.getByText("false")).toBeInTheDocument(); 74 | }); 75 | }); 76 | 77 | it("should be rendered without cache", async () => { 78 | render( 79 | 127 | ); 128 | await waitFor(() => { 129 | expect( 130 | screen.getByText( 131 | "project-id-for-test:location-for-test.job-id-for-test" 132 | ) 133 | ).toBeInTheDocument(); 134 | expect( 135 | screen.getByText("user@example.iam.gserviceaccount.com") 136 | ).toBeInTheDocument(); 137 | expect(screen.getByText("1KB")).toBeInTheDocument(); 138 | expect(screen.getByText("2KB")).toBeInTheDocument(); 139 | expect(screen.queryByText("(results cached)")).toBeNull(); 140 | expect(screen.getByText("2.206 seconds")).toBeInTheDocument(); 141 | expect(screen.getByText("false")).toBeInTheDocument(); 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Job.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Table, Tbody, Td, Text, Th, Tr } from "@chakra-ui/react"; 2 | import bytes from "bytes"; 3 | import formatDuration from "date-fns/formatDuration"; 4 | import formatISO from "date-fns/formatISO"; 5 | import React from "react"; 6 | import type { Metadata } from "shared"; 7 | import { isStandaloneStatistics, getJobName } from "shared"; 8 | import { Breakable } from "../ui/Breakable"; 9 | import { CopyButton } from "../ui/CopyButton"; 10 | 11 | export const Job = ({ metadata }: { metadata: Metadata }) => { 12 | const id = getJobName(metadata.jobReference); 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 58 | 59 | 60 | 61 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
Job ID 20 | 21 | {id} 22 | 23 | 24 |
User{metadata.user_email}
Location{metadata.jobReference.location}
Creation time 37 | {formatISO(Number(metadata.statistics.creationTime)).toString()} 38 |
Start time{formatISO(Number(metadata.statistics.startTime)).toString()}
End time{formatISO(Number(metadata.statistics.endTime)).toString()}
Duration 51 | {formatDuration({ 52 | seconds: 53 | (Number(metadata.statistics.endTime) - 54 | Number(metadata.statistics.creationTime)) / 55 | 1000, 56 | })} 57 |
Bytes processed 62 | 63 | 64 | {bytes(Number(metadata.statistics.query.totalBytesProcessed))} 65 | 66 | {isStandaloneStatistics(metadata.statistics) && 67 | metadata.statistics.query.cacheHit ? ( 68 | (results cached) 69 | ) : null} 70 | 71 |
Bytes billed{bytes(Number(metadata.statistics.query.totalBytesBilled))}
Use legacy SQL{`${metadata.configuration.query.useLegacySql}`}
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Routine.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Table, Tbody, Td, Text, Th, Tr } from "@chakra-ui/react"; 2 | import formatISO from "date-fns/formatISO"; 3 | import React from "react"; 4 | import type { Routine as RoutineData } from "shared"; 5 | import { getRoutineName } from "shared"; 6 | import { Breakable } from "../ui/Breakable"; 7 | import { CopyButton } from "../ui/CopyButton"; 8 | 9 | export const Routine = ({ routine }: { routine: RoutineData }) => { 10 | const id = getRoutineName(routine.metadata.routineReference); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 |
Routine ID 18 | 19 | {id} 20 | 21 | 22 |
Created{formatISO(Number(routine.metadata.creationTime))}
Last modified{formatISO(Number(routine.metadata.lastModifiedTime))}
Language{routine.metadata.language}
Definition 39 | {routine.metadata.definitionBody} 40 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Rows.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChevronLeftIcon, 3 | ChevronRightIcon, 4 | DownloadIcon, 5 | } from "@chakra-ui/icons"; 6 | import { 7 | HStack, 8 | IconButton, 9 | Menu, 10 | MenuButton, 11 | MenuItem, 12 | MenuList, 13 | Table, 14 | Tbody, 15 | Td, 16 | Text, 17 | Th, 18 | Thead, 19 | Tooltip, 20 | Tr, 21 | } from "@chakra-ui/react"; 22 | import type { FC } from "react"; 23 | import React from "react"; 24 | import type { Format, RowsPayload } from "shared"; 25 | import { commas } from "shared"; 26 | import { Footer } from "./Footer"; 27 | 28 | export const Rows: FC< 29 | Readonly<{ 30 | rowsPayload: RowsPayload; 31 | onPrevRequest: () => unknown; 32 | onNextRequest: () => unknown; 33 | onDownloadRequest: (format: Format) => unknown; 34 | }> 35 | > = ({ 36 | rowsPayload: { heads, rows, page }, 37 | onPrevRequest, 38 | onNextRequest, 39 | onDownloadRequest, 40 | }) => { 41 | return ( 42 | <> 43 | 44 | 45 | 46 | 53 | ))} 54 | 55 | 56 | 57 | {rows.map(({ rowNumber, rows }) => { 58 | return rows.map((row, j) => ( 59 | 60 | {j === 0 ? ( 61 | 62 | ) : null} 63 | {row.map((cell) => { 64 | return ( 65 | 68 | ); 69 | })} 70 | 71 | )); 72 | })} 73 | 74 |
47 | {heads.map((head) => ( 48 | 49 | 50 | {head.id} 51 | 52 |
{`${rowNumber}`} 66 | {cell.value === undefined ? null : `${cell.value}`} 67 |
75 |
76 | 77 | 78 | } 81 | size="xs" 82 | disabled={!page.hasPrev} 83 | onClick={onPrevRequest} 84 | /> 85 | } 88 | size="xs" 89 | disabled={!page.hasNext} 90 | onClick={onNextRequest} 91 | /> 92 | 93 | 94 | {`${commas(page.startRowNumber)}`} 95 | - 96 | {`${commas(page.endRowNumber)}`} 97 | of 98 | {commas(page.totalRows)} 99 | 100 | 101 | 102 | 103 | } 107 | size="xs" 108 | /> 109 | 110 | onDownloadRequest("jsonl")}> 111 | JSON Lines 112 | 113 | onDownloadRequest("json")}> 114 | JSON 115 | 116 | onDownloadRequest("csv")}>CSV 117 | onDownloadRequest("md")}> 118 | Markdown 119 | 120 | onDownloadRequest("txt")}> 121 | Plain Text 122 | 123 | 124 | 125 | 126 |
127 | 128 | ); 129 | }; 130 | -------------------------------------------------------------------------------- /packages/viewer/src/domain/Table.tsx: -------------------------------------------------------------------------------- 1 | import { ViewIcon } from "@chakra-ui/icons"; 2 | import { 3 | Heading, 4 | HStack, 5 | IconButton, 6 | Table as TableComponent, 7 | Tbody, 8 | Td, 9 | Th, 10 | Thead, 11 | Tr, 12 | VStack, 13 | } from "@chakra-ui/react"; 14 | import bytes from "bytes"; 15 | import formatISO from "date-fns/formatISO"; 16 | import type { FC } from "react"; 17 | import React from "react"; 18 | import type { Accessor, Table as TableData, TableReference } from "shared"; 19 | import { commas, getTableName } from "shared"; 20 | import { Breakable } from "../ui/Breakable"; 21 | import { CopyButton } from "../ui/CopyButton"; 22 | 23 | export const Table: FC<{ 24 | heads: ReadonlyArray; 25 | table: TableData; 26 | onPreviewRequest: (tableReference: TableReference) => unknown; 27 | }> = ({ heads, table, onPreviewRequest }) => { 28 | const id = getTableName(table.tableReference); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | Table ID 36 | 37 | 38 | {id} 39 | 40 | } 43 | size="xs" 44 | onClick={() => onPreviewRequest(table.tableReference)} 45 | /> 46 | 47 | 48 | 49 | 50 | Table size 51 | {bytes(Number(table.numBytes))} 52 | 53 | 54 | Long-term storage size 55 | {bytes(Number(table.numLongTermBytes))} 56 | 57 | 58 | Number of rows 59 | {commas(table.numRows)} 60 | 61 | 62 | Created 63 | {formatISO(Number(table.creationTime))} 64 | 65 | 66 | Last modified 67 | {formatISO(Number(table.lastModifiedTime))} 68 | 69 | {table.expirationTime ? ( 70 | 71 | Table expiration 72 | {formatISO(Number(table.expirationTime))} 73 | 74 | ) : null} 75 | 76 | Data location 77 | {table.location} 78 | 79 | 80 | 81 | 82 | 83 | 84 | Schema 85 | 86 | 87 | 88 | 89 | Field name 90 | Type 91 | Mode 92 | 93 | 94 | 95 | {heads.map(({ id, type, mode }) => ( 96 | 97 | {id} 98 | {type} 99 | {mode} 100 | 101 | ))} 102 | 103 | 104 | 105 | 106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /packages/viewer/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import type { State } from "./App"; 5 | import App from "./App"; 6 | import { BrowserClipboardProvider } from "./context/Clipboard"; 7 | import { theme } from "./theme"; 8 | // import reportWebVitals from "./reportWebVitals"; 9 | 10 | (() => { 11 | const root = document.getElementById("root"); 12 | if (!root) { 13 | throw new Error("root element is not found"); 14 | } 15 | 16 | const webview = acquireVsCodeApi(); 17 | 18 | createRoot(root).render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | })(); 28 | 29 | // If you want to start measuring performance in your app, pass a function 30 | // to log results (for example: reportWebVitals(console.log)) 31 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 32 | // reportWebVitals(); 33 | -------------------------------------------------------------------------------- /packages/viewer/src/jobEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "metadata", 3 | "payload": { 4 | "metadata": { 5 | "kind": "bigquery#job", 6 | "etag": "xchfQInTgPOv9oEn1LExVQ==", 7 | "id": "minodisk-api:US.6654cc27-8a00-464c-b9d6-4df155a9b66d", 8 | "selfLink": "https://bigquery.googleapis.com/bigquery/v2/projects/minodisk-api/jobs/6654cc27-8a00-464c-b9d6-4df155a9b66d?location=US", 9 | "user_email": "bigquery-runner@minodisk-api.iam.gserviceaccount.com", 10 | "configuration": { 11 | "query": { 12 | "query": "select\n en_label,\n labels\nfrom\n `bigquery-public-data.wikipedia.wikidata`\nwhere\n array_length(labels) < 5\nlimit\n 5000\n", 13 | "destinationTable": { 14 | "projectId": "minodisk-api", 15 | "datasetId": "_974002322e1183b3df64c0f31d9b6832d25246ef", 16 | "tableId": "anon283b16a2558286aa168497689737d8c844796c95" 17 | }, 18 | "writeDisposition": "WRITE_TRUNCATE", 19 | "priority": "INTERACTIVE", 20 | "useLegacySql": false 21 | }, 22 | "jobType": "QUERY" 23 | }, 24 | "jobReference": { 25 | "projectId": "minodisk-api", 26 | "jobId": "6654cc27-8a00-464c-b9d6-4df155a9b66d", 27 | "location": "US" 28 | }, 29 | "statistics": { 30 | "creationTime": "1657984072599", 31 | "startTime": "1657984072639", 32 | "endTime": "1657984072788", 33 | "totalBytesProcessed": "0", 34 | "query": { 35 | "totalBytesProcessed": "0", 36 | "totalBytesBilled": "0", 37 | "cacheHit": true, 38 | "statementType": "SELECT" 39 | } 40 | }, 41 | "status": { "state": "DONE" } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/viewer/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare namespace NodeJS { 6 | interface ProcessEnv { 7 | readonly NODE_ENV: "development" | "production" | "test"; 8 | readonly PUBLIC_URL: string; 9 | } 10 | } 11 | 12 | declare module "*.avif" { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module "*.bmp" { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module "*.gif" { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module "*.jpg" { 28 | const src: string; 29 | export default src; 30 | } 31 | 32 | declare module "*.jpeg" { 33 | const src: string; 34 | export default src; 35 | } 36 | 37 | declare module "*.png" { 38 | const src: string; 39 | export default src; 40 | } 41 | 42 | declare module "*.webp" { 43 | const src: string; 44 | export default src; 45 | } 46 | 47 | declare module "*.svg" { 48 | import * as React from "react"; 49 | 50 | export const ReactComponent: React.FunctionComponent< 51 | React.SVGProps & { title?: string } 52 | >; 53 | 54 | const src: string; 55 | export default src; 56 | } 57 | 58 | declare module "*.module.css" { 59 | const classes: { readonly [key: string]: string }; 60 | export default classes; 61 | } 62 | 63 | declare module "*.module.scss" { 64 | const classes: { readonly [key: string]: string }; 65 | export default classes; 66 | } 67 | 68 | declare module "*.module.sass" { 69 | const classes: { readonly [key: string]: string }; 70 | export default classes; 71 | } 72 | -------------------------------------------------------------------------------- /packages/viewer/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import type { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals") 6 | .then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 7 | getCLS(onPerfEntry); 8 | getFID(onPerfEntry); 9 | getFCP(onPerfEntry); 10 | getLCP(onPerfEntry); 11 | getTTFB(onPerfEntry); 12 | }) 13 | .catch((err) => console.error(err)); 14 | } 15 | }; 16 | 17 | export default reportWebVitals; 18 | -------------------------------------------------------------------------------- /packages/viewer/src/routineEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "routines", 3 | "payload": [ 4 | { 5 | "id": "minodisk-api.testing.procedure", 6 | "routine": { 7 | "id": "procedure", 8 | "baseUrl": "/routines", 9 | "metadata": { 10 | "etag": "eJ5GgfJUCQyq1x9zqeFsKQ==", 11 | "routineReference": { 12 | "projectId": "minodisk-api", 13 | "datasetId": "testing", 14 | "routineId": "procedure" 15 | }, 16 | "routineType": "PROCEDURE", 17 | "creationTime": "1657985972727", 18 | "lastModifiedTime": "1657985972727", 19 | "language": "SQL", 20 | "definitionBody": "begin\n select * from testing.create;\nend" 21 | } 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/viewer/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /packages/viewer/src/tableEvent.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "tables", 3 | "payload": [ 4 | { 5 | "id": "minodisk-api._974002322e1183b3df64c0f31d9b6832d25246ef.anon283b16a2558286aa168497689737d8c844796c95", 6 | "heads": [ 7 | { 8 | "name": "en_label", 9 | "type": "STRING", 10 | "mode": "NULLABLE", 11 | "id": "en_label" 12 | }, 13 | { 14 | "name": "language", 15 | "type": "STRING", 16 | "mode": "NULLABLE", 17 | "id": "labels.language" 18 | }, 19 | { 20 | "name": "value", 21 | "type": "STRING", 22 | "mode": "NULLABLE", 23 | "id": "labels.value" 24 | } 25 | ], 26 | "table": { 27 | "kind": "bigquery#table", 28 | "etag": "bcqlyOCg2fu9HDQn9W3xxA==", 29 | "id": "minodisk-api:_974002322e1183b3df64c0f31d9b6832d25246ef.anon283b16a2558286aa168497689737d8c844796c95", 30 | "selfLink": "https://bigquery.googleapis.com/bigquery/v2/projects/minodisk-api/datasets/_974002322e1183b3df64c0f31d9b6832d25246ef/tables/anon283b16a2558286aa168497689737d8c844796c95", 31 | "tableReference": { 32 | "projectId": "minodisk-api", 33 | "datasetId": "_974002322e1183b3df64c0f31d9b6832d25246ef", 34 | "tableId": "anon283b16a2558286aa168497689737d8c844796c95" 35 | }, 36 | "schema": { 37 | "fields": [ 38 | { "name": "en_label", "type": "STRING", "mode": "NULLABLE" }, 39 | { 40 | "name": "labels", 41 | "type": "RECORD", 42 | "mode": "REPEATED", 43 | "fields": [ 44 | { "name": "language", "type": "STRING", "mode": "NULLABLE" }, 45 | { "name": "value", "type": "STRING", "mode": "NULLABLE" } 46 | ] 47 | } 48 | ] 49 | }, 50 | "numBytes": "594473", 51 | "numLongTermBytes": "0", 52 | "numRows": "5000", 53 | "creationTime": "1657956923808", 54 | "expirationTime": "1658043323808", 55 | "lastModifiedTime": "1657956923808", 56 | "type": "TABLE", 57 | "location": "US", 58 | "numTotalLogicalBytes": "594473", 59 | "numActiveLogicalBytes": "594473", 60 | "numLongTermLogicalBytes": "0" 61 | } 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /packages/viewer/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | 3 | export const theme = extendTheme({ 4 | colors: { 5 | foreground: { 6 | 500: "var(--vscode-descriptionForeground)", 7 | 600: "var(--vscode-editor-foreground)", 8 | }, 9 | background: "var(--vscode-editor-background)", 10 | border: "var(--vscode-terminal-border)", 11 | error: "var(--vscode-editorMarkerNavigationError-background)", 12 | warning: "var(--vscode-editorMarkerNavigationWarning-background)", 13 | info: "var(--vscode-editorMarkerNavigationInfo-background)", 14 | }, 15 | fontSizes: { 16 | md: "var(--vscode-font-size)", 17 | }, 18 | styles: { 19 | global: { 20 | html: { 21 | WebkitFontSmoothing: "unset", 22 | }, 23 | body: { 24 | fontFamily: "var(--vscode-font-family)", 25 | fontSize: "md", 26 | color: "foreground.500", 27 | backgroundColor: "background", 28 | padding: 0, 29 | }, 30 | }, 31 | }, 32 | components: { 33 | Button: { 34 | defaultProps: { 35 | variant: "ghost", 36 | colorScheme: "foreground", 37 | }, 38 | variants: { 39 | ghost: { 40 | _focus: { 41 | background: "rgba(90, 93, 94, 0.15)", 42 | boxShadow: "unset", 43 | }, 44 | _hover: { 45 | background: "rgba(90, 93, 94, 0.15)", 46 | }, 47 | _active: { 48 | background: "rgba(90, 93, 94, 0.31)", 49 | }, 50 | }, 51 | }, 52 | }, 53 | Tabs: { 54 | baseStyle: { 55 | tabpanel: { 56 | p: 0, 57 | }, 58 | }, 59 | variants: { 60 | line: { 61 | tab: { 62 | height: "36px", 63 | fontSize: "md", 64 | cursor: "default", 65 | color: "var(--vscode-tab-inactiveForeground)", 66 | backgroundColor: "var(--vscode-tab-inactiveBackground)", 67 | borderColor: "var(--vscode-editorGroupHeader-tabsBorder)", 68 | 69 | _selected: { 70 | color: "var(--vscode-tab-activeForeground)", 71 | backgroundColor: "var(--vscode-tab-activeBackground)", 72 | borderColor: "var(--vscode-tab-activeBorder)", 73 | }, 74 | _active: { 75 | background: "unset", 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | Table: { 82 | variants: { 83 | simple: { 84 | table: { 85 | borderCollapse: "separate", 86 | borderSpacing: 0, 87 | }, 88 | th: { 89 | color: "foreground.500", 90 | borderColor: "border", 91 | backgroundColor: "background", 92 | textTransform: "unset", 93 | }, 94 | td: { 95 | fontFamily: "var(--vscode-editor-font-family)", 96 | fontSize: "var(--vscode-editor-font-size)", 97 | color: "foreground.600", 98 | borderColor: "border", 99 | }, 100 | }, 101 | }, 102 | }, 103 | Menu: { 104 | baseStyle: { 105 | list: { 106 | borderColor: "border", 107 | backgroundColor: "background", 108 | }, 109 | item: { 110 | _focus: { 111 | background: "rgba(90, 93, 94, 0.15)", 112 | }, 113 | _hover: { 114 | background: "rgba(90, 93, 94, 0.15)", 115 | }, 116 | _active: { 117 | background: "rgba(90, 93, 94, 0.31)", 118 | }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /packages/viewer/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from "react"; 2 | 3 | export type CFC

= FC>; 4 | -------------------------------------------------------------------------------- /packages/viewer/src/ui/Breakable.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import type { CFC } from "../types"; 4 | 5 | export const Breakable: CFC = (props) => ( 6 | 12 | ); 13 | -------------------------------------------------------------------------------- /packages/viewer/src/ui/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { CopyIcon } from "@chakra-ui/icons"; 2 | import { IconButton, useToast, VStack } from "@chakra-ui/react"; 3 | import React, { useCallback } from "react"; 4 | import { useClipboard } from "../context/Clipboard"; 5 | import type { CFC } from "../types"; 6 | 7 | export const CopyButton: CFC<{ 8 | text: string; 9 | }> = ({ text, ...props }) => { 10 | const toast = useToast(); 11 | const { writeText } = useClipboard(); 12 | 13 | const copy = useCallback(async () => { 14 | if (!text) { 15 | return; 16 | } 17 | await writeText(text); 18 | toast({ 19 | title: "Copied", 20 | status: "success", 21 | position: "bottom-right", 22 | duration: 1000, 23 | }); 24 | }, [text, toast, writeText]); 25 | 26 | return ( 27 | 28 | } 31 | size="xs" 32 | variant="ghost" 33 | disabled={!text} 34 | onClick={copy} 35 | {...props} 36 | /> 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/viewer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["dom", "dom.iterable", "ES2022"], 5 | "jsx": "react-jsx", 6 | 7 | "rootDir": "src" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "lib": ["ES2022"], 6 | 7 | "noEmit": true, 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true, 12 | "forceConsistentCasingInFileNames": true, 13 | 14 | "strict": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUnusedParameters": true, 18 | "noUncheckedIndexedAccess": true 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------