├── .browserslistrc ├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ ├── config.grenrc.cjs │ ├── main.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── Dockerfile.test ├── LICENSE ├── README.md ├── babel.config.js ├── index.html ├── jsconfig.json ├── karma.conf.cjs ├── lib └── sql-js │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── benchmark │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── karma.conf.js │ ├── lib │ │ └── package.json │ ├── make.sh │ ├── package.json │ ├── procpath │ │ ├── karma_docker.procpath │ │ ├── top2_cpu.sql │ │ └── top2_rss.sql │ ├── result-analysis.ipynb │ └── suite.js │ ├── build.py │ ├── configure.py │ ├── dist │ ├── sql-wasm.js │ └── sql-wasm.wasm │ ├── make.sh │ └── package.json ├── package-lock.json ├── package.json ├── public ├── Logo192x192.png ├── Logo48x48.png ├── Logo512x512.png ├── favicon.png ├── inquiries.json └── manifest.webmanifest ├── src ├── App.vue ├── assets │ ├── fonts │ │ ├── OpenSans-Bold.woff2 │ │ ├── OpenSans-BoldItalic.woff2 │ │ ├── OpenSans-Italic.woff2 │ │ ├── OpenSans-Regular.woff2 │ │ ├── OpenSans-SemiBold.woff2 │ │ └── OpenSans-SemiBoldItalic.woff2 │ ├── images │ │ ├── Logo.svg │ │ ├── Screenshot_editor.png │ │ ├── arrow-hover.svg │ │ ├── arrow.svg │ │ ├── body.svg │ │ ├── bottom.svg │ │ ├── checkbox_checked.svg │ │ ├── checkbox_checked_disabled.svg │ │ ├── checkbox_checked_light.svg │ │ ├── chevron.svg │ │ ├── close.svg │ │ ├── copy.svg │ │ ├── database-edit.svg │ │ ├── delete-tag-hover.svg │ │ ├── delete-tag.svg │ │ ├── delete.svg │ │ ├── error.svg │ │ ├── file-export.svg │ │ ├── file.png │ │ ├── info.svg │ │ ├── leftArm.svg │ │ ├── logo_simple.svg │ │ ├── rename.svg │ │ ├── rightArm.svg │ │ ├── sort.svg │ │ ├── success.svg │ │ └── top.svg │ └── styles │ │ ├── buttons.css │ │ ├── dialogs.css │ │ ├── messages.css │ │ ├── multiselect.css │ │ ├── scrollbars.css │ │ ├── tables.css │ │ ├── tooltips.css │ │ └── variables.css ├── components │ ├── CheckBox.vue │ ├── CsvJsonImport │ │ ├── DelimiterSelector │ │ │ ├── ascii.js │ │ │ └── index.vue │ │ └── index.vue │ ├── DbUploader.vue │ ├── IconButton.vue │ ├── LoadingDialog.vue │ ├── LoadingIndicator.vue │ ├── Logs.vue │ ├── Splitpanes │ │ ├── index.vue │ │ └── splitter.js │ ├── SqlTable │ │ ├── Pager.vue │ │ └── index.vue │ ├── TextField.vue │ └── svg │ │ ├── addTable.vue │ │ ├── arrow.vue │ │ ├── changeDb.vue │ │ ├── chart.vue │ │ ├── clear.vue │ │ ├── clipboard.vue │ │ ├── close.vue │ │ ├── dataView.vue │ │ ├── dropDownChevron.vue │ │ ├── edgeArrow.vue │ │ ├── export.vue │ │ ├── exportToCsv.vue │ │ ├── exportToSvg.vue │ │ ├── hint.vue │ │ ├── html.vue │ │ ├── pivot.vue │ │ ├── png.vue │ │ ├── row.vue │ │ ├── run.vue │ │ ├── sort.vue │ │ ├── sqlEditor.vue │ │ ├── table.vue │ │ ├── treeChevron.vue │ │ └── viewCellValue.vue ├── lib │ ├── ReactPlotlyEditorWithPlotRef.jsx │ ├── chartHelper.js │ ├── csv.js │ ├── database │ │ ├── _sql.js │ │ ├── _statements.js │ │ ├── _worker.js │ │ └── index.js │ ├── eventBus.js │ ├── storedInquiries │ │ ├── _migrations.js │ │ └── index.js │ ├── tab.js │ └── utils │ │ ├── clipboardIo.js │ │ ├── events.js │ │ ├── fileIo.js │ │ └── time.js ├── main.js ├── registerServiceWorker.js ├── router.js ├── store │ ├── actions.js │ ├── index.js │ ├── mutations.js │ └── state.js ├── tooltipMixin.js └── views │ ├── LoadView.vue │ ├── MainView │ ├── AppDiagnosticInfo.vue │ ├── Inquiries │ │ ├── index.vue │ │ └── svg │ │ │ ├── copy.vue │ │ │ ├── delete.vue │ │ │ └── rename.vue │ ├── MainMenu.vue │ ├── Workspace │ │ ├── Schema │ │ │ ├── TableDescription.vue │ │ │ └── index.vue │ │ ├── Tabs │ │ │ ├── Tab │ │ │ │ ├── DataView │ │ │ │ │ ├── Chart │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── Pivot │ │ │ │ │ │ ├── PivotUi │ │ │ │ │ │ │ ├── PivotSortBtn.vue │ │ │ │ │ │ │ └── index.vue │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ └── pivotHelper.js │ │ │ │ │ └── index.vue │ │ │ │ ├── RunResult │ │ │ │ │ ├── Record │ │ │ │ │ │ ├── RowNavigator.vue │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── ValueViewer.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── SideToolBar.vue │ │ │ │ ├── SqlEditor │ │ │ │ │ ├── hint.js │ │ │ │ │ └── index.vue │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ └── index.vue │ └── index.vue │ └── Welcome.vue ├── test.setup.js ├── tests ├── App.spec.js ├── components │ ├── CheckBox.spec.js │ ├── CsvImport │ │ ├── CsvImport.spec.js │ │ └── DelimiterSelector.spec.js │ ├── DbUploader.spec.js │ ├── LoadingIndicator.spec.js │ ├── Logs.spec.js │ ├── Splitpanes │ │ ├── Splitpanes.spec.js │ │ └── splitter.spec.js │ └── SqlTable │ │ └── Pager.spec.js ├── lib │ ├── chartHelper.spec.js │ ├── csv.spec.js │ ├── database │ │ ├── _sql.spec.js │ │ ├── _statements.spec.js │ │ ├── database.spec.js │ │ └── sqliteExtensions.spec.js │ ├── storedInquiries │ │ ├── _migrations.spec.js │ │ └── storedInquiries.spec.js │ ├── tab.spec.js │ └── utils │ │ ├── clipboardIo.spec.js │ │ ├── fileIo.spec.js │ │ └── time.spec.js ├── store │ ├── actions.spec.js │ └── mutations.spec.js ├── tooltipMixin.spec.js └── views │ ├── LoadView.spec.js │ └── MainView │ ├── Inquiries │ └── Inquiries.spec.js │ ├── MainMenu.spec.js │ └── Workspace │ ├── Schema │ ├── Schema.spec.js │ └── TableDescription.spec.js │ ├── Tabs │ ├── Tab │ │ ├── DataView │ │ │ ├── Chart │ │ │ │ └── Chart.spec.js │ │ │ ├── DataView.spec.js │ │ │ └── Pivot │ │ │ │ ├── Pivot.spec.js │ │ │ │ ├── PivotUi │ │ │ │ ├── PivotSortBtn.spec.js │ │ │ │ └── PivotUi.spec.js │ │ │ │ └── pivotHelper.spec.js │ │ ├── RunResult │ │ │ ├── Record.spec.js │ │ │ ├── RunResult.spec.js │ │ │ └── ValueViewer.spec.js │ │ ├── SqlEditor │ │ │ ├── SqlEditor.spec.js │ │ │ └── hint.spec.js │ │ └── Tab.spec.js │ └── Tabs.spec.js │ └── Workspace.spec.js ├── vite.config.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | es2022: true 6 | }, 7 | extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'prettier'], 8 | rules: { 9 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 10 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 11 | 'no-case-declarations': 'off', 12 | 'max-len': [2, 100, 4, { ignoreUrls: true }], 13 | 'vue/multi-word-component-names': 'off', 14 | 'vue/no-mutating-props': 'warn', 15 | 'vue/no-reserved-component-names': 'warn', 16 | 'vue/no-v-model-argument': 'off', 17 | 'vue/require-default-prop': 'off', 18 | 'vue/custom-event-name-casing': ['error', 'camelCase'], 19 | 'vue/attribute-hyphenation': ['error', 'never'] 20 | }, 21 | overrides: [ 22 | { 23 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/**/*.spec.{j,t}s?(x)'], 24 | env: { 25 | mocha: true 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/config.grenrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dataSource: 'milestones', 3 | ignoreIssuesWith: ['wontfix', 'duplicate'], 4 | milestoneMatch: 'v{{tag_name}}', 5 | template: { 6 | issue: '- {{name}} [{{text}}]({{url}})', 7 | changelogTitle: '', 8 | release: '{{body}}' 9 | }, 10 | groupBy: { 11 | Enhancements: ['enhancement', 'internal'], 12 | 'Bug fixes': ['bug'] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | deploy: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 18.x 18 | 19 | - name: Update npm 20 | run: npm install -g npm@10 21 | 22 | - name: npm install and build 23 | run: | 24 | npm install 25 | npm run build 26 | 27 | - name: Create archives 28 | run: | 29 | cd dist 30 | zip -9 -r ../dist.zip . -x "*.map" 31 | zip -9 -r ../dist_map.zip . 32 | 33 | - name: Create Release Notes 34 | run: | 35 | npm install github-release-notes@0.16.0 -g 36 | gren changelog --generate --config="/.github/workflows/config.grenrc.cjs" 37 | env: 38 | GREN_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Create release 41 | uses: ncipollo/release-action@v1 42 | with: 43 | artifacts: 'dist.zip,dist_map.zip' 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | bodyFile: 'CHANGELOG.md' 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - 'master' 7 | pull_request: 8 | branches: 9 | - 'master' 10 | 11 | jobs: 12 | test: 13 | name: Run tests 14 | runs-on: ubuntu-20.04 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 18.x 21 | - name: Install browsers 22 | run: | 23 | export DEBIAN_FRONTEND=noninteractive 24 | sudo apt-get update 25 | sudo apt-get install -y chromium-browser firefox 26 | 27 | - name: Update npm 28 | run: npm install -g npm@10 29 | 30 | - name: Install the project 31 | run: npm install 32 | 33 | - name: Run lint 34 | run: npm run lint -- --no-fix 35 | 36 | - name: Run karma tests 37 | run: npm run test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | # An easy way to run tests locally without Nodejs installed: 2 | # 3 | # docker build -t sqliteviz/test -f Dockerfile.test . 4 | # 5 | 6 | FROM node:12.22-buster 7 | 8 | RUN set -ex; \ 9 | apt update; \ 10 | apt install -y chromium firefox-esr; \ 11 | npm install -g npm@7 12 | 13 | WORKDIR /tmp/build 14 | 15 | COPY package.json package-lock.json ./ 16 | COPY lib lib 17 | RUN npm install 18 | 19 | COPY . . 20 | 21 | RUN set -ex; \ 22 | sed -i 's/browsers: \[.*\],/browsers: ['"'FirefoxHeadlessTouch'"'],/' karma.conf.js 23 | 24 | RUN npm run lint -- --no-fix && npm run test 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # sqliteviz 6 | 7 | Sqliteviz is a single-page offline-first PWA for fully client-side visualisation 8 | of SQLite databases, CSV, JSON or NDJSON files. 9 | 10 | With sqliteviz you can: 11 | 12 | - run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets 13 | - import a CSV/JSON/NDJSON file into a SQLite database and visualize imported data 14 | - export result set to CSV file 15 | - manage inquiries and run them against different databases 16 | - import/export inquiries from/to a JSON file 17 | - export a modified SQLite database 18 | - use it offline from your OS application menu like any other desktop app 19 | 20 | https://user-images.githubusercontent.com/24638357/128249848-f8fab0f5-9add-46e0-a9c1-dd5085a8623e.mp4 21 | 22 | ## Quickstart 23 | 24 | The latest release of sqliteviz is deployed on [sqliteviz.com/app][6]. 25 | 26 | ## Wiki 27 | 28 | For user documentation, check out sqliteviz [documentation][7]. 29 | 30 | ## Motivation 31 | 32 | It's a kind of middleground between [Plotly Falcon][1] and [Redash][2]. 33 | 34 | ## Components 35 | 36 | It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4] and [Vue-Codemirror][8] in [Vue.js][5]. CSV parsing is performed with [Papa Parse][9]. 37 | 38 | [1]: https://github.com/plotly/falcon 39 | [2]: https://github.com/getredash/redash 40 | [3]: https://github.com/plotly/react-chart-editor 41 | [4]: https://github.com/sql-js/sql.js 42 | [5]: https://github.com/vuejs/vue 43 | [6]: https://sqliteviz.com/app/ 44 | [7]: https://sqliteviz.com/docs 45 | [8]: https://github.com/surmon-china/vue-codemirror#readme 46 | [9]: https://www.papaparse.com/ 47 | [10]: https://github.com/lana-k/sqliteviz/wiki/Predefined-queries 48 | [11]: https://github.com/plotly/plotly.js 49 | [12]: https://github.com/nicolaskruchten/pivottable 50 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | sqliteviz 10 | 75 | 76 | 77 | 78 | 79 | 85 |
86 |
87 |
LOADING
88 | 89 | 90 | 91 | 92 |
93 |
94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "tests/**/*"], 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "paths": { 7 | "@*": ["./src/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /karma.conf.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | vite: { 4 | config: { 5 | resolve: { 6 | alias: { 7 | vue: 'vue/dist/vue.esm-bundler.js' 8 | } 9 | }, 10 | server: { 11 | preTransformRequests: false 12 | } 13 | }, 14 | coverage: { 15 | enable: true, 16 | include: 'src/*', 17 | exclude: ['node_modules', 'src/components/svg/*'], 18 | extension: ['.js', '.vue'], 19 | requireEnv: false 20 | } 21 | }, 22 | // base path that will be used to resolve all patterns (eg. files, exclude) 23 | basePath: '', 24 | 25 | // frameworks to use 26 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 27 | frameworks: ['mocha', 'sinon-chai', 'vite'], 28 | 29 | // list of files / patterns to load in the browser 30 | files: [ 31 | { 32 | pattern: 'test.setup.js', 33 | type: 'module', 34 | watched: false, 35 | served: false 36 | }, 37 | { 38 | pattern: 'tests/**/*.spec.js', 39 | type: 'module', 40 | watched: false, 41 | served: false 42 | }, 43 | { 44 | pattern: 'src/assets/styles/*.css', 45 | type: 'css', 46 | watched: false, 47 | served: false 48 | } 49 | ], 50 | 51 | plugins: [ 52 | 'karma-vite', 53 | 'karma-mocha', 54 | 'karma-sinon-chai', 55 | 'karma-firefox-launcher', 56 | 'karma-chrome-launcher', 57 | 'karma-spec-reporter', 58 | 'karma-coverage' 59 | ], 60 | 61 | // list of files / patterns to exclude 62 | exclude: [], 63 | 64 | // test results reporter to use 65 | // possible values: 'dots', 'progress' 66 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 67 | reporters: ['spec', 'coverage'], 68 | 69 | coverageReporter: { 70 | dir: 'coverage', 71 | reporters: [{ type: 'lcov', subdir: '.' }, { type: 'text-summary' }] 72 | }, 73 | 74 | // web server port 75 | port: 9876, 76 | 77 | // enable / disable colors in the output (reporters and logs) 78 | colors: true, 79 | 80 | // level of logging 81 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || 82 | // config.LOG_INFO || config.LOG_DEBUG 83 | logLevel: config.LOG_INFO, 84 | 85 | // enable / disable watching file and executing tests whenever any file changes 86 | autoWatch: false, 87 | 88 | customLaunchers: { 89 | FirefoxHeadlessTouch: { 90 | base: 'FirefoxHeadless', 91 | prefs: { 92 | 'dom.w3c_touch_events.enabled': 1, 93 | 'dom.events.asyncClipboard.clipboardItem': true 94 | } 95 | } 96 | }, 97 | // start these browsers 98 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 99 | browsers: ['ChromiumHeadless', 'FirefoxHeadlessTouch'], 100 | 101 | // Continuous Integration mode 102 | // if true, Karma captures browsers, runs the tests and exits 103 | singleRun: true, 104 | 105 | // Concurrency level 106 | // how many browser should be started simultaneous 107 | concurrency: 2, 108 | 109 | client: { 110 | captureConsole: true, 111 | mocha: { 112 | timeout: 7000 113 | } 114 | }, 115 | browserConsoleLogOptions: { 116 | terminal: true, 117 | level: '' 118 | } 119 | }) 120 | // Fix the timezone 121 | process.env.TZ = 'Europe/Amsterdam' 122 | } 123 | -------------------------------------------------------------------------------- /lib/sql-js/.dockerignore: -------------------------------------------------------------------------------- 1 | benchmark 2 | dist 3 | -------------------------------------------------------------------------------- /lib/sql-js/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM emscripten/emsdk:3.0.1 2 | 3 | WORKDIR /tmp/build 4 | 5 | COPY configure.py . 6 | RUN python3.8 configure.py 7 | 8 | COPY build.py . 9 | RUN python3.8 build.py 10 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/.gitignore: -------------------------------------------------------------------------------- 1 | /lib/build-* 2 | /lib/dist 3 | /build-*-result.json 4 | /sample.csv 5 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.14-bookworm 2 | 3 | RUN set -ex; \ 4 | apt-get update; \ 5 | apt-get install -y firefox-esr; \ 6 | apt-get install -y chromium 7 | 8 | WORKDIR /tmp/build 9 | 10 | COPY package.json ./ 11 | COPY lib/dist lib/dist 12 | COPY lib/package.json lib/package.json 13 | RUN npm install 14 | 15 | COPY . . 16 | 17 | CMD npm run benchmark 18 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/README.md: -------------------------------------------------------------------------------- 1 | # SQLite WebAssembly build micro-benchmark 2 | 3 | This directory contains a micro-benchmark for evaluating SQLite WebAssembly 4 | builds performance on read and write SQL queries, run from `make.sh` script. If 5 | the script has permission to `nice` processes and [Procpath][1] is installed, 6 | e.g. it is run with `sudo -E env PATH=$PATH ./make.sh`, it'll `renice` all 7 | processes running inside the benchmark containers. It can also serve as a smoke 8 | test (e.g. for memory leaks). 9 | 10 | The benchmark operates on a set of SQLite WebAssembly builds expected in 11 | `lib/build-$NAME` directories each containing `sql-wasm.js` and 12 | `sql-wasm.wasm`. Then it creates a Docker image for each, and runs the 13 | benchmark in Firefox and Chromium using Karma in the container. 14 | 15 | After successful run, the benchmark produces the following per each build: 16 | 17 | - `build-$NAME-result.json` 18 | - `build-$NAME.sqlite` (if Procpath is installed) 19 | - `build-$NAME.svg` (if Procpath is installed) 20 | 21 | These files can be analysed using `result-analysis.ipynb` Jupyter notebook. 22 | The SVG is a chart with CPU and RSS usage of each test container (i.e. Chromium 23 | run, then Firefox run per container). 24 | 25 | [1]: https://pypi.org/project/Procpath/ 26 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | const timeout = 15 * 60 * 1000 3 | config.set({ 4 | frameworks: ['mocha'], 5 | 6 | files: [ 7 | 'suite.js', 8 | { 9 | pattern: 'node_modules/sql.js/dist/sql-wasm.wasm', 10 | served: true, 11 | included: false 12 | }, 13 | { pattern: 'sample.csv', served: true, included: false } 14 | ], 15 | 16 | reporters: ['progress', 'json-to-file'], 17 | 18 | singleRun: true, 19 | 20 | customLaunchers: { 21 | ChromiumHeadlessNoSandbox: { 22 | base: 'ChromiumHeadless', 23 | flags: ['--no-sandbox'] 24 | } 25 | }, 26 | browsers: ['ChromiumHeadlessNoSandbox', 'FirefoxHeadless'], 27 | concurrency: 1, 28 | 29 | browserDisconnectTimeout: timeout, 30 | browserNoActivityTimeout: timeout, 31 | captureTimeout: timeout, 32 | browserSocketTimeout: timeout, 33 | pingTimeout: timeout, 34 | client: { 35 | captureConsole: true, 36 | mocha: { timeout: timeout } 37 | }, 38 | 39 | logLevel: config.LOG_INFO, 40 | browserConsoleLogOptions: { terminal: true, level: config.LOG_INFO }, 41 | 42 | preprocessors: { 'suite.js': ['webpack'] }, 43 | webpack: { 44 | mode: 'development', 45 | module: { 46 | noParse: [__dirname + '/node_modules/benchmark/benchmark.js'] 47 | }, 48 | node: { fs: 'empty' } 49 | }, 50 | 51 | proxies: { 52 | '/sql-wasm.wasm': '/base/node_modules/sql.js/dist/sql-wasm.wasm' 53 | }, 54 | 55 | jsonToFileReporter: { outputPath: '.', fileName: 'suite-result.json' } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sql.js", 3 | "main": "./dist/sql-wasm.js", 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cleanup () { 4 | rm -rf lib/dist "$renice_flag_file" 5 | docker rm -f sqljs-benchmark-run 2> /dev/null || true 6 | } 7 | trap cleanup EXIT 8 | 9 | if [ ! -f sample.csv ]; then 10 | wget --header="accept-encoding: gzip" -q -O- \ 11 | https://github.com/plotly/datasets/raw/547090bd/wellspublic.csv \ 12 | | gunzip -c > sample.csv 13 | fi 14 | 15 | PLAYBOOK=procpath/karma_docker.procpath 16 | 17 | # for renice to work run like "sudo -E env PATH=$PATH ./make.sh" 18 | test_ni=$(nice -n -5 nice) 19 | if [ $test_ni == -5 ]; then 20 | renice_flag_file=$(mktemp) 21 | fi 22 | { 23 | while [ -f $renice_flag_file ]; do 24 | procpath --logging-level ERROR play -f $PLAYBOOK renice:watch 25 | done 26 | } & 27 | 28 | shopt -s nullglob 29 | for d in lib/build-* ; do 30 | rm -rf lib/dist 31 | cp -r $d lib/dist 32 | sample_name=$(basename $d) 33 | 34 | docker build -t sqliteviz/sqljs-benchmark . 35 | docker rm sqljs-benchmark-run 2> /dev/null || true 36 | docker run -d -it --cpus 2 --name sqljs-benchmark-run sqliteviz/sqljs-benchmark 37 | { 38 | rm -f ${sample_name}.sqlite 39 | procpath play -f $PLAYBOOK -o database_file=${sample_name}.sqlite track:record 40 | procpath play -f $PLAYBOOK -o database_file=${sample_name}.sqlite \ 41 | -o plot_file=${sample_name}.svg track:plot 42 | } & 43 | 44 | docker attach sqljs-benchmark-run 45 | docker cp sqljs-benchmark-run:/tmp/build/suite-result.json ${sample_name}-result.json 46 | docker rm sqljs-benchmark-run 47 | done 48 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqlite-webassembly-microbenchmark", 3 | "private": true, 4 | "dependencies": { 5 | "@babel/core": "^7.14.8", 6 | "babel-loader": "^8.2.2", 7 | "benchmark": "^2.1.4", 8 | "lodash": "^4.17.4", 9 | "papaparse": "^5.3.1", 10 | "mocha": "^9.0.3", 11 | "karma": "^6.3.4", 12 | "karma-chrome-launcher": "^3.1.0", 13 | "karma-firefox-launcher": "^2.1.1", 14 | "karma-json-to-file-reporter": "^1.0.1", 15 | "karma-mocha": "^2.0.1", 16 | "karma-webpack": "^4.0.2", 17 | "webpack": "^4.46.0", 18 | "sql.js": "file:./lib" 19 | }, 20 | "scripts": { 21 | "benchmark": "karma start karma.conf.js" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/procpath/karma_docker.procpath: -------------------------------------------------------------------------------- 1 | # This command may run when "sqljs-benchmark-run" does not yet exist or run 2 | [renice:watch] 3 | interval: 2 4 | repeat: 30 5 | environment: 6 | ROOT_PID=docker inspect -f "{{.State.Pid}}" sqljs-benchmark-run 2> /dev/null || true 7 | query: 8 | PIDS=$..children[?(@.stat.pid in [$ROOT_PID])]..pid 9 | command: 10 | echo $PIDS | tr , '\n' | xargs --no-run-if-empty -I{} -- renice -n -5 -p {} 11 | 12 | # Expected input arguments: database_file 13 | [track:record] 14 | interval: 1 15 | stop_without_result: 1 16 | environment: 17 | ROOT_PID=docker inspect -f "{{.State.Pid}}" sqljs-benchmark-run 18 | query: 19 | $..children[?(@.stat.pid == $ROOT_PID)] 20 | pid_list: $ROOT_PID 21 | 22 | # Expected input arguments: database_file, plot_file 23 | [track:plot] 24 | moving_average_window: 5 25 | title: Chromium vs Firefox (№1 RSS, №2 CPU) 26 | custom_query_file: 27 | procpath/top2_rss.sql 28 | procpath/top2_cpu.sql 29 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/procpath/top2_cpu.sql: -------------------------------------------------------------------------------- 1 | WITH diff_all AS ( 2 | SELECT 3 | record_id, 4 | ts, 5 | stat_pid, 6 | stat_utime + stat_stime - LAG(stat_utime + stat_stime) OVER ( 7 | PARTITION BY stat_pid 8 | ORDER BY record_id 9 | ) tick_diff, 10 | ts - LAG(ts) OVER ( 11 | PARTITION BY stat_pid 12 | ORDER BY record_id 13 | ) ts_diff 14 | FROM record 15 | ), diff AS ( 16 | SELECT * FROM diff_all WHERE tick_diff IS NOT NULL 17 | ), one_time_pid_condition AS ( 18 | SELECT stat_pid 19 | FROM record 20 | GROUP BY 1 21 | ORDER BY SUM(stat_utime + stat_stime) DESC 22 | LIMIT 2 23 | ) 24 | SELECT 25 | ts, 26 | stat_pid pid, 27 | 100.0 * tick_diff / (SELECT value FROM meta WHERE key = 'clock_ticks') / ts_diff value 28 | FROM diff 29 | JOIN one_time_pid_condition USING(stat_pid) 30 | -------------------------------------------------------------------------------- /lib/sql-js/benchmark/procpath/top2_rss.sql: -------------------------------------------------------------------------------- 1 | WITH one_time_pid_condition AS ( 2 | SELECT stat_pid 3 | FROM record 4 | GROUP BY 1 5 | ORDER BY SUM(stat_rss) DESC 6 | LIMIT 2 7 | ) 8 | SELECT 9 | ts, 10 | stat_pid pid, 11 | stat_rss / 1024.0 / 1024 * (SELECT value FROM meta WHERE key = 'page_size') value 12 | FROM record 13 | JOIN one_time_pid_condition USING(stat_pid) 14 | -------------------------------------------------------------------------------- /lib/sql-js/build.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | from pathlib import Path 4 | 5 | # See the setting descriptions on these pages: 6 | # - https://emscripten.org/docs/optimizing/Optimizing-Code.html 7 | # - https://github.com/emscripten-core/emscripten/blob/main/src/settings.js 8 | cflags = ( 9 | # SQLite configuration 10 | '-DSQLITE_DEFAULT_CACHE_SIZE=-65536', # 64 MiB 11 | '-DSQLITE_DEFAULT_MEMSTATUS=0', 12 | '-DSQLITE_DEFAULT_SYNCHRONOUS=0', 13 | '-DSQLITE_DISABLE_LFS', 14 | '-DSQLITE_DQS=0', 15 | '-DSQLITE_ENABLE_FTS3', 16 | '-DSQLITE_ENABLE_FTS3_PARENTHESIS', 17 | '-DSQLITE_ENABLE_FTS5', 18 | '-DSQLITE_ENABLE_NORMALIZE', 19 | '-DSQLITE_EXTRA_INIT=extra_init', 20 | '-DSQLITE_OMIT_DEPRECATED', 21 | '-DSQLITE_OMIT_LOAD_EXTENSION', 22 | '-DSQLITE_OMIT_SHARED_CACHE', 23 | '-DSQLITE_THREADSAFE=0', 24 | # Compile-time optimisation 25 | '-Os', # reduces the code size about in half comparing to -O2 26 | '-flto', 27 | '-Isrc', '-Isrc/lua', 28 | ) 29 | emflags = ( 30 | # Base 31 | '--memory-init-file', '0', 32 | '-s', 'ALLOW_TABLE_GROWTH=1', 33 | # WASM 34 | '-s', 'WASM=1', 35 | '-s', 'ALLOW_MEMORY_GROWTH=1', 36 | '-s', 'ENVIRONMENT=web,worker', 37 | # Link-time optimisation 38 | '-Os', 39 | '-flto', 40 | # sql.js 41 | '-s', 'EXPORTED_FUNCTIONS=@src/sqljs/exported_functions.json', 42 | '-s', 'EXPORTED_RUNTIME_METHODS=@src/sqljs/exported_runtime_methods.json', 43 | '--pre-js', 'src/sqljs/api.js', 44 | ) 45 | 46 | 47 | def build(src: Path, dst: Path): 48 | out = Path('out') 49 | out.mkdir() 50 | 51 | logging.info('Building LLVM bitcode for sqlite3.c') 52 | subprocess.check_call([ 53 | 'emcc', 54 | *cflags, 55 | '-c', src / 'sqlite3.c', 56 | '-o', out / 'sqlite3.o', 57 | ]) 58 | logging.info('Building LLVM bitcode for extension-functions.c') 59 | subprocess.check_call([ 60 | 'emcc', 61 | *cflags, 62 | '-c', src / 'extension-functions.c', 63 | '-o', out / 'extension-functions.o', 64 | ]) 65 | logging.info('Building LLVM bitcode for SQLite Lua extension') 66 | subprocess.check_call([ 67 | 'emcc', 68 | *cflags, 69 | '-shared', 70 | *(src / 'lua').glob('*.c'), 71 | *(src / 'sqlitelua').glob('*.c'), 72 | '-o', out / 'sqlitelua.o', 73 | ]) 74 | 75 | logging.info('Building WASM from bitcode') 76 | subprocess.check_call([ 77 | 'emcc', 78 | *emflags, 79 | out / 'sqlite3.o', 80 | out / 'extension-functions.o', 81 | out / 'sqlitelua.o', 82 | '-o', out / 'sql-wasm.js', 83 | ]) 84 | 85 | logging.info('Post-processing build and copying to dist') 86 | (out / 'sql-wasm.wasm').rename(dst / 'sql-wasm.wasm') 87 | with (dst / 'sql-wasm.js').open('w') as f: 88 | f.write((src / 'sqljs' / 'shell-pre.js').read_text()) 89 | f.write((out / 'sql-wasm.js').read_text()) 90 | f.write((src / 'sqljs' / 'shell-post.js').read_text()) 91 | 92 | 93 | if __name__ == '__main__': 94 | logging.basicConfig(level='INFO', format='%(asctime)s %(levelname)s %(name)s %(message)s') 95 | 96 | src = Path('src') 97 | dst = Path('dist') 98 | dst.mkdir() 99 | build(src, dst) 100 | -------------------------------------------------------------------------------- /lib/sql-js/dist/sql-wasm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/lib/sql-js/dist/sql-wasm.wasm -------------------------------------------------------------------------------- /lib/sql-js/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | docker build -t sqliteviz/sqljs . 4 | 5 | rm -r dist || true 6 | 7 | CONTAINER=$(docker create sqliteviz/sqljs) 8 | docker cp $CONTAINER:/tmp/build/dist . 9 | docker rm $CONTAINER 10 | -------------------------------------------------------------------------------- /lib/sql-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sql.js", 3 | "main": "./dist/sql-wasm.js", 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqliteviz", 3 | "version": "0.26.0", 4 | "license": "Apache-2.0", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "serve": "vite preview", 11 | "test": "karma start karma.conf.cjs", 12 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", 13 | "format": "prettier . --write" 14 | }, 15 | "dependencies": { 16 | "buffer": "^6.0.3", 17 | "codemirror": "^5.65.18", 18 | "codemirror-editor-vue3": "^2.8.0", 19 | "core-js": "^3.6.5", 20 | "dataurl-to-blob": "^0.0.1", 21 | "html2canvas": "^1.1.4", 22 | "jquery": "^3.6.0", 23 | "nanoid": "^3.1.12", 24 | "papaparse": "^5.4.1", 25 | "pivottable": "^2.23.0", 26 | "plotly.js": "^2.35.2", 27 | "promise-worker": "^2.0.1", 28 | "react": "^16.14.0", 29 | "react-chart-editor": "^0.46.1", 30 | "react-dom": "^16.14.0", 31 | "sql.js": "file:./lib/sql-js", 32 | "tiny-emitter": "^2.1.0", 33 | "veaury": "^2.5.1", 34 | "vue": "^3.5.11", 35 | "vue-final-modal": "^4.5.5", 36 | "vue-multiselect": "^3.0.0-beta.3", 37 | "vue-router": "^4.4.5", 38 | "vuejs-paginate-next": "^1.0.2", 39 | "vuex": "^4.1.0" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.25.7", 43 | "@vitejs/plugin-vue": "^5.2.1", 44 | "@vue/eslint-config-standard": "^8.0.1", 45 | "@vue/test-utils": "^2.4.6", 46 | "chai": "^4.1.2", 47 | "chai-as-promised": "^8.0.1", 48 | "eslint": "^8.57.1", 49 | "eslint-config-prettier": "^10.1.1", 50 | "eslint-plugin-import": "^2.20.2", 51 | "eslint-plugin-node": "^11.1.0", 52 | "eslint-plugin-promise": "^4.2.1", 53 | "eslint-plugin-standard": "^4.0.0", 54 | "eslint-plugin-vue": "^9.28.0", 55 | "flush-promises": "^1.0.2", 56 | "karma": "^6.4.4", 57 | "karma-coverage": "^2.2.1", 58 | "karma-coverage-istanbul-reporter": "^3.0.3", 59 | "karma-firefox-launcher": "^2.1.3", 60 | "karma-mocha": "^1.3.0", 61 | "karma-spec-reporter": "^0.0.36", 62 | "karma-vite": "^1.0.5", 63 | "mocha": "^5.2.0", 64 | "prettier": "3.5.3", 65 | "process": "^0.11.10", 66 | "url-loader": "^4.1.1", 67 | "vite": "^5.4.14", 68 | "vite-plugin-istanbul": "^5.0.0", 69 | "vite-plugin-node-polyfills": "^0.23.0", 70 | "vite-plugin-pwa": "^0.21.1", 71 | "vite-plugin-static-copy": "^2.2.0", 72 | "vue-cli-plugin-ui-karma": "^0.2.5" 73 | }, 74 | "overrides": { 75 | "karma-vite": { 76 | "vite-plugin-istanbul": "$vite-plugin-istanbul" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public/Logo192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/public/Logo192x192.png -------------------------------------------------------------------------------- /public/Logo48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/public/Logo48x48.png -------------------------------------------------------------------------------- /public/Logo512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/public/Logo512x512.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/public/favicon.png -------------------------------------------------------------------------------- /public/inquiries.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "white", 3 | "description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases, CSV, JSON or NDJSON.", 4 | "display": "fullscreen", 5 | "icons": [ 6 | { 7 | "src": "favicon.png", 8 | "sizes": "32x32", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "Logo48x48.png", 13 | "sizes": "48x48", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "Logo192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "Logo512x512.png", 23 | "sizes": "512x512", 24 | "type": "image/png" 25 | } 26 | ], 27 | "name": "sqliteviz", 28 | "short_name": "sqliteviz", 29 | "start_url": "index.html" 30 | } 31 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 32 | 33 | 94 | -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-BoldItalic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-Italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-SemiBold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/fonts/OpenSans-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /src/assets/images/Screenshot_editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/images/Screenshot_editor.png -------------------------------------------------------------------------------- /src/assets/images/arrow-hover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/body.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/images/checkbox_checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/images/checkbox_checked_disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/images/checkbox_checked_light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/images/chevron.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/database-edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/delete-tag-hover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/delete-tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/images/file-export.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lana-k/sqliteviz/3ee825defe91e240a996f308df2eb4ed4d0621e1/src/assets/images/file.png -------------------------------------------------------------------------------- /src/assets/images/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/leftArm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/logo_simple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/rename.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/images/rightArm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/images/sort.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/images/success.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/images/top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/styles/buttons.css: -------------------------------------------------------------------------------- 1 | button { 2 | box-sizing: border-box; 3 | height: 36px; 4 | padding: 0 12px; 5 | font-size: 14px; 6 | font-weight: 600; 7 | cursor: pointer; 8 | } 9 | 10 | button:focus { 11 | outline: none; 12 | } 13 | 14 | button.primary { 15 | background: var(--color-accent); 16 | border: 1px solid var(--color-accent-shade); 17 | border-radius: var(--border-radius-big); 18 | min-width: 83px; 19 | color: var(--color-text-light); 20 | text-shadow: var(--shadow); 21 | } 22 | 23 | button.primary:hover { 24 | background: var(--color-accent-shade); 25 | border: 1px solid var(--color-accent-shade); 26 | color: var(--color-text-light); 27 | text-shadow: var(--shadow); 28 | } 29 | 30 | button.secondary { 31 | background: white; 32 | border: 1px solid var(--color-border); 33 | border-radius: var(--border-radius-big); 34 | min-width: 83px; 35 | color: var(--color-text-base); 36 | } 37 | 38 | button.secondary:hover { 39 | border: 1px solid var(--color-text-light-2); 40 | color: var(--color-text-active); 41 | } 42 | 43 | button.toolbar { 44 | background: transparent; 45 | border: none; 46 | color: var(--color-text-base); 47 | padding: 0; 48 | } 49 | 50 | button.toolbar:hover { 51 | color: var(--color-accent); 52 | } 53 | 54 | button.primary:disabled, 55 | button.secondary:disabled { 56 | background: var(--color-bg-light-2); 57 | border: 1px solid var(--color-border); 58 | color: var(--color-text-light-2); 59 | text-shadow: none; 60 | cursor: default; 61 | } 62 | -------------------------------------------------------------------------------- /src/assets/styles/dialogs.css: -------------------------------------------------------------------------------- 1 | .dialog { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | .dialog .vfm__content { 7 | border-radius: var(--border-radius-big); 8 | box-shadow: 0px 2px 9px rgba(80, 103, 132, 0.8); 9 | background-color: white; 10 | overflow: hidden; 11 | } 12 | 13 | .dialog-header { 14 | height: 46px; 15 | line-height: 46px; 16 | padding: 0 22px 0 12px; 17 | color: var(--color-text-base); 18 | font-size: 16px; 19 | font-weight: 600; 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | } 24 | 25 | .dialog-body { 26 | min-height: 56px; 27 | background-color: var(--color-bg-light); 28 | padding: 24px; 29 | border-top: 1px solid var(--color-border-light); 30 | color: var(--color-text-base); 31 | font-size: 13px; 32 | } 33 | 34 | .dialog-buttons-container { 35 | display: flex; 36 | justify-content: flex-end; 37 | background-color: var(--color-bg-light); 38 | padding: 24px; 39 | } 40 | 41 | .dialog-buttons-container button { 42 | margin-left: 16px; 43 | } 44 | 45 | .vfm__overlay.vfm--overlay { 46 | background-color: rgba(162, 177, 198, 0.5); 47 | } 48 | -------------------------------------------------------------------------------- /src/assets/styles/messages.css: -------------------------------------------------------------------------------- 1 | .warning { 2 | background-color: var(--color-bg-warning); 3 | color: var(--color-text-base); 4 | font-size: 13px; 5 | padding: 0 24px; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/styles/scrollbars.css: -------------------------------------------------------------------------------- 1 | /* width */ 2 | ::-webkit-scrollbar { 3 | width: 5px; 4 | height: 5px; 5 | } 6 | 7 | /* Track */ 8 | ::-webkit-scrollbar-track { 9 | background: transparent; 10 | border-radius: 5px; 11 | } 12 | 13 | /* Handle */ 14 | ::-webkit-scrollbar-thumb { 15 | background: var(--color-accent); 16 | border-radius: 10px; 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/styles/tables.css: -------------------------------------------------------------------------------- 1 | .rounded-bg { 2 | padding: 35px 5px 5px; 3 | background-color: white; 4 | border-radius: 5px; 5 | position: relative; 6 | border: 1px solid var(--color-border-light); 7 | box-sizing: border-box; 8 | } 9 | 10 | .straight .rounded-bg { 11 | border-radius: 0; 12 | border-width: 0 0 1px 0; 13 | } 14 | 15 | .header-container { 16 | overflow: hidden; 17 | position: absolute; 18 | top: -1px; 19 | left: -1px; 20 | width: calc(100% + 2px); 21 | padding-left: 7px; 22 | box-sizing: border-box; 23 | background-color: var(--color-bg-dark); 24 | border-radius: 5px 5px 0 0; 25 | } 26 | 27 | .straight .header-container { 28 | border-radius: 0; 29 | } 30 | 31 | .straight { 32 | height: 100%; 33 | } 34 | 35 | .straight .rounded-bg { 36 | /* 27 - height of table footer */ 37 | height: calc(100% - 27px); 38 | } 39 | 40 | @supports (-moz-appearance: none) { 41 | .header-container { 42 | top: 0; 43 | padding-left: 6px; 44 | } 45 | } 46 | 47 | .header-container > div { 48 | display: flex; 49 | width: fit-content; 50 | padding-right: 30px; 51 | } 52 | .table-container { 53 | width: 100%; 54 | max-height: 100%; 55 | overflow: auto; 56 | } 57 | table.sqliteviz-table { 58 | min-width: 100%; 59 | margin-top: -35px; 60 | border-collapse: collapse; 61 | } 62 | .sqliteviz-table thead th, 63 | .fixed-header { 64 | font-size: 14px; 65 | font-weight: 600; 66 | box-sizing: border-box; 67 | background-color: var(--color-bg-dark); 68 | color: var(--color-text-light); 69 | border-right: 1px solid var(--color-border-light); 70 | overflow: hidden; 71 | text-overflow: ellipsis; 72 | } 73 | .sqliteviz-table tbody td { 74 | font-size: 13px; 75 | background-color: white; 76 | color: var(--color-text-base); 77 | box-sizing: border-box; 78 | border-bottom: 1px solid var(--color-border-light); 79 | border-right: 1px solid var(--color-border-light); 80 | } 81 | .sqliteviz-table td, 82 | .sqliteviz-table th, 83 | .fixed-header { 84 | padding: 8px 24px; 85 | white-space: nowrap; 86 | } 87 | 88 | .sqliteviz-table tbody tr td:last-child, 89 | .sqliteviz-table thead tr th:last-child, 90 | .header-container div .fixed-header:last-child { 91 | border-right: none; 92 | } 93 | 94 | .sqliteviz-table td > div.cell-data { 95 | width: -webkit-max-content; 96 | width: -moz-max-content; 97 | width: max-content; 98 | white-space: nowrap; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | } 102 | .table-footer { 103 | display: flex; 104 | justify-content: space-between; 105 | padding: 6px 12px; 106 | } 107 | .table-footer-count { 108 | font-size: 11px; 109 | color: var(--color-text-base); 110 | } 111 | 112 | .sqliteviz-table tbody td[data-isNull='true'], 113 | .sqliteviz-table tbody td[data-isBlob='true'] { 114 | color: var(--color-text-light-2); 115 | font-style: italic; 116 | } 117 | -------------------------------------------------------------------------------- /src/assets/styles/tooltips.css: -------------------------------------------------------------------------------- 1 | .icon-tooltip { 2 | background-color: rgba(80, 103, 132, 0.85); 3 | color: #fff; 4 | text-align: center; 5 | font-size: 12px; 6 | padding: 0 6px; 7 | line-height: 19px; 8 | position: fixed; 9 | height: 19px; 10 | border-radius: var(--border-radius-medium); 11 | white-space: nowrap; 12 | z-index: 999; 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-white: #ffffff; 3 | --color-gray-light: #f3f6fa; 4 | --color-gray-light-2: #dfe8f3; 5 | --color-gray-light-3: #c8d4e3; 6 | --color-gray-light-4: #ebf0f8; 7 | --color-gray-light-5: #f8f8f9; 8 | --color-gray-medium: #a2b1c6; 9 | --color-gray-dark: #506784; 10 | --color-blue-medium: #119dff; 11 | --color-blue-dark: #0d76bf; 12 | --color-blue-dark-2: #2a3f5f; 13 | --color-red: #ef553b; 14 | --color-red-2: #de350b; 15 | --color-red-light: #ffbdad; 16 | --color-yellow: #fbefcb; 17 | 18 | --color-bg-light: var(--color-gray-light); 19 | --color-bg-light-2: var(--color-gray-light-2); 20 | --color-bg-light-3: var(--color-gray-light-5); 21 | --color-bg-light-4: var(--color-gray-light-4); 22 | --color-bg-dark: var(--color-gray-dark); 23 | --color-bg-warning: var(--color-yellow); 24 | --color-bg-danger: var(--color-red-light); 25 | --color-danger: var(--color-red-2); 26 | --color-accent: var(--color-blue-medium); 27 | --color-accent-shade: var(--color-blue-dark); 28 | --color-border-light: var(--color-gray-light-2); 29 | --color-border: var(--color-gray-light-3); 30 | --color-border-dark: var(--color-gray-medium); 31 | --color-text-light: var(--color-white); 32 | --color-text-light-2: var(--color-gray-medium); 33 | --color-text-base: var(--color-gray-dark); 34 | --color-text-active: var(--color-blue-dark-2); 35 | --color-text-error: var(--color-red); 36 | 37 | --shadow: 0 1px 2px rgba(42, 63, 95, 0.7); 38 | --shadow-1: 0 2px 9px rgba(80, 103, 132, 0.2); 39 | 40 | --border-radius-big: 5px; 41 | --border-radius-medium: 3px; 42 | --border-radius-medium-2: 4px; 43 | --border-radius-small: 2px; 44 | } 45 | 46 | .plotly-editor--theme-provider { 47 | --sidebar-width: 112px; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/CheckBox.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 74 | 75 | 116 | -------------------------------------------------------------------------------- /src/components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 48 | 49 | 125 | -------------------------------------------------------------------------------- /src/components/LoadingDialog.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 77 | 78 | 102 | 103 | 116 | -------------------------------------------------------------------------------- /src/components/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 68 | 69 | 130 | -------------------------------------------------------------------------------- /src/components/Logs.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 66 | 67 | 95 | -------------------------------------------------------------------------------- /src/components/Splitpanes/splitter.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Get the cursor position relative to the splitpane container. 3 | getCurrentMouseDrag(event, container) { 4 | const rect = container.getBoundingClientRect() 5 | const { clientX, clientY } = 6 | 'ontouchstart' in window && event.touches ? event.touches[0] : event 7 | return { 8 | x: clientX - rect.left, 9 | y: clientY - rect.top 10 | } 11 | }, 12 | 13 | // Returns the drag percentage of the splitter relative to the 2 panes it's inbetween. 14 | getCurrentDragPercentage(event, container, isHorisontal) { 15 | let drag = this.getCurrentMouseDrag(event, container) 16 | drag = drag[isHorisontal ? 'y' : 'x'] 17 | const containerSize = 18 | container[isHorisontal ? 'clientHeight' : 'clientWidth'] 19 | return (drag * 100) / containerSize 20 | }, 21 | 22 | // Returns the new position in percents. 23 | calculateOffset( 24 | event, 25 | { container, isHorisontal, paneBeforeMax, paneAfterMax } 26 | ) { 27 | const dragPercentage = this.getCurrentDragPercentage( 28 | event, 29 | container, 30 | isHorisontal 31 | ) 32 | 33 | const paneBeforeMaxReached = 34 | paneBeforeMax < 100 && dragPercentage >= paneBeforeMax 35 | const paneAfterMaxReached = 36 | paneAfterMax < 100 && dragPercentage <= 100 - paneAfterMax 37 | 38 | // Prevent dragging beyond pane max. 39 | if (paneBeforeMaxReached || paneAfterMaxReached) { 40 | return paneBeforeMaxReached 41 | ? paneBeforeMax 42 | : Math.max(100 - paneAfterMax, 0) 43 | } else { 44 | return Math.min(Math.max(dragPercentage, 0), paneBeforeMax) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/SqlTable/Pager.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | 55 | 110 | -------------------------------------------------------------------------------- /src/components/TextField.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 46 | 47 | 112 | -------------------------------------------------------------------------------- /src/components/svg/addTable.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 58 | 59 | 70 | -------------------------------------------------------------------------------- /src/components/svg/arrow.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /src/components/svg/changeDb.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 45 | 46 | 55 | -------------------------------------------------------------------------------- /src/components/svg/chart.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 48 | -------------------------------------------------------------------------------- /src/components/svg/clear.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 29 | 30 | 47 | -------------------------------------------------------------------------------- /src/components/svg/clipboard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /src/components/svg/close.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 37 | 38 | 49 | -------------------------------------------------------------------------------- /src/components/svg/dataView.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /src/components/svg/dropDownChevron.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 24 | 41 | -------------------------------------------------------------------------------- /src/components/svg/edgeArrow.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /src/components/svg/export.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | 46 | 57 | -------------------------------------------------------------------------------- /src/components/svg/exportToCsv.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /src/components/svg/exportToSvg.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 56 | -------------------------------------------------------------------------------- /src/components/svg/hint.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 65 | 66 | 83 | -------------------------------------------------------------------------------- /src/components/svg/html.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /src/components/svg/pivot.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /src/components/svg/png.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 40 | -------------------------------------------------------------------------------- /src/components/svg/row.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 48 | -------------------------------------------------------------------------------- /src/components/svg/run.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/svg/sort.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 43 | 44 | 57 | -------------------------------------------------------------------------------- /src/components/svg/sqlEditor.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 61 | -------------------------------------------------------------------------------- /src/components/svg/table.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 43 | -------------------------------------------------------------------------------- /src/components/svg/treeChevron.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 31 | 37 | -------------------------------------------------------------------------------- /src/components/svg/viewCellValue.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 45 | -------------------------------------------------------------------------------- /src/lib/ReactPlotlyEditorWithPlotRef.jsx: -------------------------------------------------------------------------------- 1 | import ReactPlotlyEditor from 'react-chart-editor' 2 | import React, { createRef } from 'react' 3 | import EditorControls from 'react-chart-editor/lib/EditorControls' 4 | 5 | /** 6 | * This extended ReactPlotlyEditor has a reference to PlotComponent. 7 | * The reference makes it possible to call updatePlotly method of PlotComponent. 8 | * updatePlotly method allows smoothly resize the plot 9 | * when resize chart editor container. 10 | */ 11 | export default class ReactPlotlyEditorWithPlotRef extends ReactPlotlyEditor { 12 | constructor(props) { 13 | super(props) 14 | this.plotComponentRef = createRef() 15 | } 16 | render() { 17 | return ( 18 |
19 | {!this.props.hideControls && ( 20 | 41 | {this.props.children} 42 | 43 | )} 44 |
48 | 61 |
62 |
63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/chartHelper.js: -------------------------------------------------------------------------------- 1 | import * as dereference from 'react-chart-editor/lib/lib/dereference' 2 | import plotly from 'plotly.js' 3 | import { nanoid } from 'nanoid' 4 | 5 | export function getOptionsFromDataSources(dataSources) { 6 | if (!dataSources) { 7 | return [] 8 | } 9 | 10 | return Object.keys(dataSources).map(name => ({ 11 | value: name, 12 | label: name 13 | })) 14 | } 15 | 16 | export function getOptionsForSave(state, dataSources) { 17 | // we don't need to save the data, only settings 18 | // so we modify state.data using dereference 19 | const stateCopy = JSON.parse(JSON.stringify(state)) 20 | const emptySources = {} 21 | for (const key in dataSources) { 22 | emptySources[key] = [] 23 | } 24 | dereference.default(stateCopy.data, emptySources) 25 | return stateCopy 26 | } 27 | 28 | export async function getImageDataUrl(element, type) { 29 | const chartElement = element.querySelector('.js-plotly-plot') 30 | return await plotly.toImage(chartElement, { 31 | format: type, 32 | width: null, 33 | height: null 34 | }) 35 | } 36 | 37 | export function getChartData(element) { 38 | const chartElement = element.querySelector('.js-plotly-plot') 39 | return { 40 | data: chartElement.data, 41 | layout: chartElement.layout 42 | } 43 | } 44 | 45 | export function getHtml(options) { 46 | const chartId = nanoid() 47 | return ` 48 | 49 |
50 | 67 | ` 68 | } 69 | 70 | export default { 71 | getOptionsFromDataSources, 72 | getOptionsForSave, 73 | getImageDataUrl, 74 | getHtml, 75 | getChartData 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/database/_sql.js: -------------------------------------------------------------------------------- 1 | import initSqlJs from 'sql.js' 2 | import dbUtils from './_statements' 3 | import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url' 4 | 5 | let SQL = null 6 | const sqlModuleReady = initSqlJs({ 7 | locateFile: () => wasmUrl 8 | }).then(sqlModule => { 9 | SQL = sqlModule 10 | }) 11 | 12 | function _getDataSourcesFromSqlResult(sqlResult) { 13 | if (!sqlResult) { 14 | return {} 15 | } 16 | const dataSources = {} 17 | sqlResult.columns.forEach((column, index) => { 18 | dataSources[column] = sqlResult.values.map(row => row[index]) 19 | }) 20 | return dataSources 21 | } 22 | 23 | export default class Sql { 24 | constructor() { 25 | this.db = null 26 | } 27 | 28 | static build() { 29 | return sqlModuleReady.then(() => { 30 | return new Sql() 31 | }) 32 | } 33 | 34 | createDb(buffer) { 35 | if (this.db != null) this.db.close() 36 | this.db = new SQL.Database(buffer) 37 | return this.db 38 | } 39 | 40 | open(buffer) { 41 | this.createDb(buffer && new Uint8Array(buffer)) 42 | return { 43 | ready: true 44 | } 45 | } 46 | 47 | exec(sql, params) { 48 | if (this.db === null) { 49 | this.createDb() 50 | } 51 | if (!sql) { 52 | throw new Error('exec: Missing query string') 53 | } 54 | const sqlResults = this.db.exec(sql, params) 55 | return sqlResults.map(result => { 56 | return { 57 | columns: result.columns, 58 | values: _getDataSourcesFromSqlResult(result) 59 | } 60 | }) 61 | } 62 | 63 | import(tabName, data, progressCounterId, progressCallback, chunkSize = 1500) { 64 | if (this.db === null) { 65 | this.createDb() 66 | } 67 | const columns = data.columns 68 | const rowCount = data.values[columns[0]].length 69 | this.db.exec(dbUtils.getCreateStatement(tabName, data.values)) 70 | const chunks = dbUtils.generateChunks(data.values, chunkSize) 71 | const chunksAmount = Math.ceil(rowCount / chunkSize) 72 | let count = 0 73 | const insertStr = dbUtils.getInsertStmt(tabName, columns) 74 | const insertStmt = this.db.prepare(insertStr) 75 | 76 | progressCallback({ progress: 0, id: progressCounterId }) 77 | for (const chunk of chunks) { 78 | this.db.exec('BEGIN') 79 | for (const row of chunk) { 80 | insertStmt.run(row) 81 | } 82 | this.db.exec('COMMIT') 83 | count++ 84 | progressCallback({ 85 | progress: 100 * (count / chunksAmount), 86 | id: progressCounterId 87 | }) 88 | } 89 | 90 | return { 91 | finish: true 92 | } 93 | } 94 | 95 | export() { 96 | return this.db.export() 97 | } 98 | 99 | close() { 100 | if (this.db) { 101 | this.db.close() 102 | } 103 | return { 104 | finished: true 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/database/_statements.js: -------------------------------------------------------------------------------- 1 | export default { 2 | *generateChunks(data, size) { 3 | const matrix = Object.keys(data).map(col => data[col]) 4 | const [row] = matrix 5 | const transposedMatrix = row.map((value, column) => 6 | matrix.map(row => row[column]) 7 | ) 8 | 9 | const count = Math.ceil(transposedMatrix.length / size) 10 | 11 | for (let i = 0; i <= count - 1; i++) { 12 | const start = size * i 13 | const end = start + size 14 | yield transposedMatrix.slice(start, end) 15 | } 16 | }, 17 | 18 | getInsertStmt(tabName, columns) { 19 | const colList = `"${columns.join('", "')}"` 20 | const params = columns.map(() => '?').join(', ') 21 | return `INSERT INTO "${tabName}" (${colList}) VALUES (${params});` 22 | }, 23 | 24 | getCreateStatement(tabName, data) { 25 | let result = `CREATE table "${tabName}"(` 26 | for (const col in data) { 27 | // Get the first row of values to determine types 28 | const value = data[col][0] 29 | let type = '' 30 | switch (typeof value) { 31 | case 'number': { 32 | type = 'REAL' 33 | break 34 | } 35 | case 'boolean': { 36 | type = 'INTEGER' 37 | break 38 | } 39 | case 'string': { 40 | type = 'TEXT' 41 | break 42 | } 43 | default: 44 | type = 'TEXT' 45 | } 46 | result += `"${col}" ${type}, ` 47 | } 48 | 49 | result = result.replace(/,\s$/, ');') 50 | return result 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/database/_worker.js: -------------------------------------------------------------------------------- 1 | import registerPromiseWorker from 'promise-worker/register' 2 | import Sql from './_sql' 3 | 4 | const sqlReady = Sql.build() 5 | 6 | function processMsg(sql) { 7 | const data = this 8 | switch (data && data.action) { 9 | case 'open': 10 | return sql.open(data.buffer) 11 | case 'reopen': 12 | return sql.open(sql.export()) 13 | case 'exec': 14 | return sql.exec(data.sql, data.params) 15 | case 'import': 16 | return sql.import( 17 | data.tabName, 18 | data.data, 19 | data.progressCounterId, 20 | postMessage 21 | ) 22 | case 'export': 23 | return sql.export() 24 | case 'close': 25 | return sql.close() 26 | default: 27 | throw new Error('Invalid action : ' + (data && data.action)) 28 | } 29 | } 30 | 31 | function onError(error) { 32 | return { 33 | error: error.message 34 | } 35 | } 36 | 37 | registerPromiseWorker(data => { 38 | return sqlReady.then(processMsg.bind(data)).catch(onError) 39 | }) 40 | -------------------------------------------------------------------------------- /src/lib/eventBus.js: -------------------------------------------------------------------------------- 1 | import emitter from 'tiny-emitter/instance' 2 | 3 | export default { 4 | $on: (...args) => emitter.on(...args), 5 | $once: (...args) => emitter.once(...args), 6 | $off: (...args) => emitter.off(...args), 7 | $emit: (...args) => emitter.emit(...args) 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/storedInquiries/_migrations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | _migrate(installedVersion, inquiries) { 3 | if (installedVersion === 1) { 4 | inquiries.forEach(inquire => { 5 | inquire.viewType = 'chart' 6 | inquire.viewOptions = inquire.chart 7 | delete inquire.chart 8 | }) 9 | return inquiries 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/storedInquiries/index.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import fu from '@/lib/utils/fileIo' 3 | import events from '@/lib/utils/events' 4 | import migration from './_migrations' 5 | 6 | const migrate = migration._migrate 7 | 8 | export default { 9 | version: 2, 10 | getStoredInquiries() { 11 | let myInquiries = JSON.parse(localStorage.getItem('myInquiries')) 12 | if (!myInquiries) { 13 | const oldInquiries = localStorage.getItem('myQueries') 14 | if (oldInquiries) { 15 | myInquiries = migrate(1, JSON.parse(oldInquiries)) 16 | this.updateStorage(myInquiries) 17 | return myInquiries 18 | } 19 | return [] 20 | } 21 | 22 | return (myInquiries && myInquiries.inquiries) || [] 23 | }, 24 | 25 | duplicateInquiry(baseInquiry) { 26 | const newInquiry = JSON.parse(JSON.stringify(baseInquiry)) 27 | newInquiry.name = newInquiry.name + ' Copy' 28 | newInquiry.id = nanoid() 29 | newInquiry.createdAt = new Date() 30 | delete newInquiry.isPredefined 31 | 32 | return newInquiry 33 | }, 34 | 35 | isTabNeedName(inquiryTab) { 36 | return inquiryTab.isPredefined || !inquiryTab.name 37 | }, 38 | 39 | updateStorage(inquiries) { 40 | localStorage.setItem( 41 | 'myInquiries', 42 | JSON.stringify({ version: this.version, inquiries }) 43 | ) 44 | }, 45 | 46 | serialiseInquiries(inquiryList) { 47 | const preparedData = JSON.parse(JSON.stringify(inquiryList)) 48 | preparedData.forEach(inquiry => delete inquiry.isPredefined) 49 | return JSON.stringify( 50 | { version: this.version, inquiries: preparedData }, 51 | null, 52 | 4 53 | ) 54 | }, 55 | 56 | deserialiseInquiries(str) { 57 | const inquiries = JSON.parse(str) 58 | let inquiryList = [] 59 | if (!inquiries.version) { 60 | // Turn data into array if they are not 61 | inquiryList = !Array.isArray(inquiries) ? [inquiries] : inquiries 62 | inquiryList = migrate(1, inquiryList) 63 | } else { 64 | inquiryList = inquiries.inquiries || [] 65 | } 66 | 67 | // Generate new ids if they are the same as existing inquiries 68 | inquiryList.forEach(inquiry => { 69 | const allInquiriesIds = this.getStoredInquiries().map( 70 | inquiry => inquiry.id 71 | ) 72 | if (allInquiriesIds.includes(inquiry.id)) { 73 | inquiry.id = nanoid() 74 | } 75 | }) 76 | 77 | return inquiryList 78 | }, 79 | 80 | importInquiries() { 81 | return fu.importFile().then(str => { 82 | const inquires = this.deserialiseInquiries(str) 83 | 84 | events.send('inquiry.import', inquires.length) 85 | 86 | return inquires 87 | }) 88 | }, 89 | export(inquiryList, fileName) { 90 | const jsonStr = this.serialiseInquiries(inquiryList) 91 | fu.exportToFile(jsonStr, fileName) 92 | 93 | events.send('inquiry.export', inquiryList.length) 94 | }, 95 | 96 | async readPredefinedInquiries() { 97 | const res = await fu.readFile('./inquiries.json') 98 | const data = await res.json() 99 | 100 | if (!data.version) { 101 | return data.length > 0 ? migrate(1, data) : [] 102 | } else { 103 | return data.inquiries 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/tab.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import time from '@/lib/utils/time' 3 | import events from '@/lib/utils/events' 4 | 5 | export default class Tab { 6 | constructor(state, inquiry = {}) { 7 | this.id = inquiry.id || nanoid() 8 | this.name = inquiry.id ? inquiry.name : null 9 | this.tempName = 10 | inquiry.name || 11 | (state.untitledLastIndex 12 | ? `Untitled ${state.untitledLastIndex}` 13 | : 'Untitled') 14 | this.query = inquiry.query 15 | this.viewOptions = inquiry.viewOptions || undefined 16 | this.isPredefined = inquiry.isPredefined 17 | this.viewType = inquiry.viewType || 'chart' 18 | this.result = null 19 | this.isGettingResults = false 20 | this.error = null 21 | this.time = 0 22 | this.layout = inquiry.layout || { 23 | sqlEditor: 'above', 24 | table: 'bottom', 25 | dataView: 'hidden' 26 | } 27 | this.maximize = inquiry.maximize 28 | 29 | this.isSaved = !!inquiry.id 30 | this.state = state 31 | } 32 | 33 | async execute() { 34 | this.isGettingResults = true 35 | this.result = null 36 | this.error = null 37 | const db = this.state.db 38 | try { 39 | const start = new Date() 40 | this.result = await db.execute(this.query + ';') 41 | this.time = time.getPeriod(start, new Date()) 42 | 43 | if (this.result && this.result.values) { 44 | events.send( 45 | 'resultset.create', 46 | this.result.values[this.result.columns[0]].length 47 | ) 48 | } 49 | 50 | events.send('query.run', parseFloat(this.time), { status: 'success' }) 51 | } catch (err) { 52 | this.error = { 53 | type: 'error', 54 | message: err 55 | } 56 | 57 | events.send('query.run', 0, { status: 'error' }) 58 | } 59 | db.refreshSchema() 60 | this.isGettingResults = false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/utils/clipboardIo.js: -------------------------------------------------------------------------------- 1 | import Lib from 'plotly.js/src/lib' 2 | import dataUrlToBlob from 'dataurl-to-blob' 3 | 4 | export default { 5 | async copyText(str, notifyMessage) { 6 | await navigator.clipboard.writeText(str) 7 | if (notifyMessage) { 8 | Lib.notifier(notifyMessage, 'long') 9 | } 10 | }, 11 | 12 | async copyImage(source) { 13 | if (source instanceof HTMLCanvasElement) { 14 | return this._copyCanvas(source) 15 | } else { 16 | return this._copyFromDataUrl(source) 17 | } 18 | }, 19 | 20 | async _copyBlob(blob) { 21 | await navigator.clipboard.write([ 22 | new ClipboardItem({ 23 | // eslint-disable-line no-undef 24 | [blob.type]: blob 25 | }) 26 | ]) 27 | }, 28 | 29 | async _copyFromDataUrl(url) { 30 | const blob = dataUrlToBlob(url) 31 | await this._copyBlob(blob) 32 | Lib.notifier('Image copied to clipboard successfully', 'long') 33 | }, 34 | 35 | async _copyCanvas(canvas) { 36 | canvas.toBlob( 37 | async blob => { 38 | await this._copyBlob(blob) 39 | Lib.notifier('Image copied to clipboard successfully', 'long') 40 | }, 41 | 'image/png', 42 | 1 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/utils/events.js: -------------------------------------------------------------------------------- 1 | export default { 2 | send(name, value, labels) { 3 | const event = new CustomEvent('sqliteviz-app-event', { 4 | detail: { 5 | name, 6 | value, 7 | labels: labels || {} 8 | } 9 | }) 10 | window.dispatchEvent(event) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/utils/fileIo.js: -------------------------------------------------------------------------------- 1 | export default { 2 | isJSON(file) { 3 | return file && file.type === 'application/json' 4 | }, 5 | isNDJSON(file) { 6 | return file && file.name.endsWith('.ndjson') 7 | }, 8 | isDatabase(file) { 9 | const dbTypes = ['application/vnd.sqlite3', 'application/x-sqlite3'] 10 | return file.type 11 | ? dbTypes.includes(file.type) 12 | : /\.(db|sqlite(3)?)+$/.test(file.name) 13 | }, 14 | 15 | getFileName(file) { 16 | return file.name.replace(/\.[^.]+$/, '') 17 | }, 18 | 19 | downloadFromUrl(url, fileName) { 20 | // Create downloader 21 | const downloader = document.createElement('a') 22 | downloader.href = url 23 | downloader.download = fileName 24 | 25 | // Trigger click 26 | downloader.click() 27 | 28 | // Clean up 29 | URL.revokeObjectURL(url) 30 | }, 31 | 32 | async exportToFile(str, fileName, type = 'octet/stream') { 33 | const blob = new Blob([str], { type }) 34 | const url = URL.createObjectURL(blob) 35 | this.downloadFromUrl(url, fileName) 36 | }, 37 | 38 | /** 39 | * Note: if user press Cancel in file choosing dialog 40 | * it will be an unsettled promise. But it's grabbed by 41 | * the garbage collector (tested with FinalizationRegistry). 42 | */ 43 | getFileFromUser(type) { 44 | return new Promise(resolve => { 45 | const uploader = document.createElement('input') 46 | 47 | uploader.type = 'file' 48 | uploader.accept = type 49 | 50 | uploader.addEventListener('change', () => { 51 | const file = uploader.files[0] 52 | resolve(file) 53 | }) 54 | 55 | uploader.click() 56 | }) 57 | }, 58 | 59 | importFile() { 60 | return this.getFileFromUser('.json').then(file => { 61 | return this.getFileContent(file) 62 | }) 63 | }, 64 | 65 | getFileContent(file) { 66 | const reader = new FileReader() 67 | return new Promise(resolve => { 68 | reader.onload = e => resolve(e.target.result) 69 | reader.readAsText(file) 70 | }) 71 | }, 72 | 73 | readFile(path) { 74 | return fetch(path) 75 | }, 76 | 77 | readAsArrayBuffer(file) { 78 | const fileReader = new FileReader() 79 | 80 | return new Promise((resolve, reject) => { 81 | fileReader.onerror = () => { 82 | fileReader.abort() 83 | reject(new Error('Problem parsing input file.')) 84 | } 85 | 86 | fileReader.onload = () => { 87 | resolve(fileReader.result) 88 | } 89 | fileReader.readAsArrayBuffer(file) 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/utils/time.js: -------------------------------------------------------------------------------- 1 | export default { 2 | getPeriod(start, end) { 3 | const diff = end.getTime() - start.getTime() 4 | const seconds = diff / 1000 5 | return seconds.toFixed(3) + 's' 6 | }, 7 | 8 | debounce(func, ms) { 9 | let timeout 10 | return function () { 11 | clearTimeout(timeout) 12 | timeout = setTimeout(() => func.apply(this, arguments), ms) 13 | } 14 | }, 15 | 16 | sleep(ms) { 17 | return new Promise(resolve => { 18 | setTimeout(() => { 19 | resolve() 20 | }, ms) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from '@/App.vue' 3 | import router from '@/router' 4 | import store from '@/store' 5 | import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal' 6 | 7 | import '@/assets/styles/variables.css' 8 | import '@/assets/styles/buttons.css' 9 | import '@/assets/styles/tables.css' 10 | import '@/assets/styles/dialogs.css' 11 | import '@/assets/styles/tooltips.css' 12 | import '@/assets/styles/messages.css' 13 | import 'vue-multiselect/dist/vue-multiselect.css' 14 | import '@/assets/styles/multiselect.css' 15 | import 'vue-final-modal/style.css' 16 | 17 | if (!['localhost', '127.0.0.1'].includes(location.hostname)) { 18 | import('./registerServiceWorker') // eslint-disable-line no-unused-expressions 19 | } 20 | 21 | const app = createApp(App) 22 | .use(router) 23 | .use(store) 24 | .use(createVfm()) 25 | .component('modal', VueFinalModal) 26 | 27 | const vfm = useVfm() 28 | app.config.globalProperties.$modal = { 29 | show: modalId => vfm.open(modalId), 30 | hide: modalId => vfm.close(modalId) 31 | } 32 | app.mount('#app') 33 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | import events from '@/lib/utils/events' 2 | let refresh = false 3 | 4 | function invokeServiceWorkerUpdateFlow(registration) { 5 | const agree = confirm('New version of the app is available. Refresh now?') 6 | if (agree) { 7 | if (registration.waiting) { 8 | // let waiting Service Worker know it should became active 9 | refresh = true 10 | registration.waiting.postMessage({ type: 'SKIP_WAITING' }) 11 | } 12 | } 13 | } 14 | 15 | if ('serviceWorker' in navigator) { 16 | window.addEventListener('load', async () => { 17 | const registration = 18 | await navigator.serviceWorker.register('service-worker.js') 19 | // ensure the case when the updatefound event was missed is also handled 20 | // by re-invoking the prompt when there's a waiting Service Worker 21 | if (registration.waiting) { 22 | invokeServiceWorkerUpdateFlow(registration) 23 | } 24 | 25 | // detect Service Worker update available and wait for it to become installed 26 | registration.addEventListener('updatefound', () => { 27 | const newRegestration = registration.installing 28 | if (newRegestration) { 29 | // wait until the new Service worker is actually installed (ready to take over) 30 | newRegestration.addEventListener('statechange', () => { 31 | if (registration.waiting && navigator.serviceWorker.controller) { 32 | invokeServiceWorkerUpdateFlow(registration) 33 | } 34 | }) 35 | } 36 | }) 37 | 38 | // detect controller change and refresh the page 39 | navigator.serviceWorker.addEventListener('controllerchange', () => { 40 | if (refresh) { 41 | window.location.reload() 42 | refresh = false 43 | } 44 | }) 45 | }) 46 | 47 | window.addEventListener('appinstalled', () => { 48 | events.send('pwa.install') 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import Workspace from '@/views/MainView/Workspace' 3 | import Inquiries from '@/views/MainView/Inquiries' 4 | import Welcome from '@/views/Welcome' 5 | import MainView from '@/views/MainView' 6 | import LoadView from '@/views/LoadView' 7 | import store from '@/store' 8 | import database from '@/lib/database' 9 | 10 | const routes = [ 11 | { 12 | path: '/', 13 | name: 'Welcome', 14 | component: Welcome 15 | }, 16 | { 17 | path: '/', 18 | name: 'MainView', 19 | component: MainView, 20 | children: [ 21 | { 22 | path: '/workspace', 23 | name: 'Workspace', 24 | component: Workspace 25 | }, 26 | { 27 | path: '/inquiries', 28 | name: 'Inquiries', 29 | component: Inquiries 30 | } 31 | ] 32 | }, 33 | { 34 | path: '/load', 35 | name: 'Load', 36 | component: LoadView 37 | } 38 | ] 39 | 40 | const router = createRouter({ 41 | history: createWebHashHistory(), 42 | routes 43 | }) 44 | 45 | router.beforeEach(async (to, from, next) => { 46 | if (!store.state.db && to.name !== 'Load') { 47 | const newDb = database.getNewDatabase() 48 | await newDb.loadDb() 49 | store.commit('setDb', newDb) 50 | } 51 | next() 52 | }) 53 | 54 | export default router 55 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import Tab from '@/lib/tab' 2 | import { nanoid } from 'nanoid' 3 | 4 | export default { 5 | async addTab({ state }, inquiry = {}) { 6 | // add new tab only if it was not already opened 7 | if (!state.tabs.some(openedTab => openedTab.id === inquiry.id)) { 8 | const tab = new Tab(state, JSON.parse(JSON.stringify(inquiry))) 9 | state.tabs.push(tab) 10 | if (!tab.name) { 11 | state.untitledLastIndex += 1 12 | } 13 | return tab.id 14 | } 15 | 16 | return inquiry.id 17 | }, 18 | async saveInquiry({ state }, { inquiryTab, newName }) { 19 | const value = { 20 | id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id, 21 | query: inquiryTab.query, 22 | viewType: inquiryTab.dataView.mode, 23 | viewOptions: inquiryTab.dataView.getOptionsForSave(), 24 | name: newName || inquiryTab.name 25 | } 26 | 27 | // Get inquiries from local storage 28 | const myInquiries = state.inquiries 29 | 30 | // Set createdAt 31 | if (newName) { 32 | value.createdAt = new Date() 33 | } else { 34 | var inquiryIndex = myInquiries.findIndex( 35 | oldInquiry => oldInquiry.id === inquiryTab.id 36 | ) 37 | value.createdAt = myInquiries[inquiryIndex].createdAt 38 | } 39 | 40 | // Insert in inquiries list 41 | if (newName) { 42 | myInquiries.push(value) 43 | } else { 44 | myInquiries.splice(inquiryIndex, 1, value) 45 | } 46 | 47 | return value 48 | }, 49 | addInquiry({ state }, newInquiry) { 50 | state.inquiries.push(newInquiry) 51 | }, 52 | deleteInquiries({ state, commit }, inquiryIdSet) { 53 | state.inquiries = state.inquiries.filter( 54 | inquiry => !inquiryIdSet.has(inquiry.id) 55 | ) 56 | 57 | // Close deleted inquiries if it was opened 58 | const tabs = state.tabs 59 | let i = tabs.length - 1 60 | while (i > -1) { 61 | if (inquiryIdSet.has(tabs[i].id)) { 62 | commit('deleteTab', tabs[i]) 63 | } 64 | i-- 65 | } 66 | }, 67 | renameInquiry({ state, commit }, { inquiryId, newName }) { 68 | const renamingInquiry = state.inquiries.find( 69 | inquiry => inquiry.id === inquiryId 70 | ) 71 | 72 | renamingInquiry.name = newName 73 | 74 | // update tab, if renamed inquiry is opened 75 | const tab = state.tabs.find(tab => tab.id === renamingInquiry.id) 76 | if (tab) { 77 | commit('updateTab', { 78 | tab, 79 | newValues: { 80 | name: newName 81 | } 82 | }) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | import state from '@/store/state' 3 | import mutations from '@/store/mutations' 4 | import actions from '@/store/actions' 5 | 6 | export default createStore({ 7 | state, 8 | mutations, 9 | actions 10 | }) 11 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | setDb(state, db) { 3 | if (state.db) { 4 | state.db.shutDown() 5 | } 6 | state.db = db 7 | }, 8 | 9 | updateTab(state, { tab, newValues }) { 10 | const { name, id, query, viewType, viewOptions, isSaved } = newValues 11 | const oldId = tab.id 12 | 13 | if (id && state.currentTabId === oldId) { 14 | state.currentTabId = id 15 | } 16 | 17 | if (id) { 18 | tab.id = id 19 | } 20 | if (name) { 21 | tab.name = name 22 | } 23 | if (query) { 24 | tab.query = query 25 | } 26 | if (viewType) { 27 | tab.viewType = viewType 28 | } 29 | if (viewOptions) { 30 | tab.viewOptions = viewOptions 31 | } 32 | if (isSaved !== undefined) { 33 | tab.isSaved = isSaved 34 | } 35 | if (isSaved) { 36 | // Saved inquiry is not predefined 37 | delete tab.isPredefined 38 | } 39 | }, 40 | 41 | deleteTab(state, tab) { 42 | const index = state.tabs.indexOf(tab) 43 | // If closing tab is the current opened 44 | if (tab.id === state.currentTabId) { 45 | if (index < state.tabs.length - 1) { 46 | state.currentTabId = state.tabs[index + 1].id 47 | } else if (index > 0) { 48 | state.currentTabId = state.tabs[index - 1].id 49 | } else { 50 | state.currentTabId = null 51 | state.untitledLastIndex = 0 52 | } 53 | state.currentTab = state.currentTabId 54 | ? state.tabs.find(tab => tab.id === state.currentTabId) 55 | : null 56 | } 57 | state.tabs.splice(index, 1) 58 | }, 59 | setCurrentTabId(state, id) { 60 | try { 61 | state.currentTabId = id 62 | state.currentTab = state.tabs.find(tab => tab.id === id) 63 | } catch (e) { 64 | console.error("Can't open a tab id:" + id) 65 | } 66 | }, 67 | updatePredefinedInquiries(state, inquiries) { 68 | state.predefinedInquiries = Array.isArray(inquiries) 69 | ? inquiries 70 | : [inquiries] 71 | }, 72 | setLoadingPredefinedInquiries(state, value) { 73 | state.loadingPredefinedInquiries = value 74 | }, 75 | setPredefinedInquiriesLoaded(state, value) { 76 | state.predefinedInquiriesLoaded = value 77 | }, 78 | setInquiries(state, value) { 79 | state.inquiries = value 80 | }, 81 | setIsWorkspaceVisible(state, value) { 82 | state.isWorkspaceVisible = value 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/store/state.js: -------------------------------------------------------------------------------- 1 | export default { 2 | tabs: [], 3 | currentTab: null, 4 | currentTabId: null, 5 | untitledLastIndex: 0, 6 | inquiries: [], 7 | predefinedInquiries: [], 8 | loadingPredefinedInquiries: false, 9 | predefinedInquiriesLoaded: false, 10 | db: null, 11 | isWorkspaceVisible: false 12 | } 13 | -------------------------------------------------------------------------------- /src/tooltipMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | tooltipStyle: { 5 | visibility: 'hidden' 6 | } 7 | } 8 | }, 9 | computed: { 10 | tooltipElement() { 11 | return this.$refs.tooltip 12 | } 13 | }, 14 | methods: { 15 | showTooltip(e, tooltipPosition) { 16 | const position = tooltipPosition 17 | ? tooltipPosition.split('-') 18 | : ['top', 'right'] 19 | const offset = 12 20 | 21 | if (position[0] === 'top') { 22 | this.tooltipStyle.top = e.clientY - offset + 'px' 23 | } else { 24 | this.tooltipStyle.top = e.clientY + offset + 'px' 25 | } 26 | 27 | if (position[1] === 'right') { 28 | this.tooltipStyle.left = e.clientX + offset + 'px' 29 | } else { 30 | this.tooltipStyle.left = 31 | e.clientX - offset - this.tooltipElement.offsetWidth + 'px' 32 | } 33 | 34 | this.tooltipStyle.visibility = 'visible' 35 | }, 36 | hideTooltip() { 37 | this.tooltipStyle.visibility = 'hidden' 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/views/MainView/AppDiagnosticInfo.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 62 | 63 | 68 | 69 | 99 | -------------------------------------------------------------------------------- /src/views/MainView/Inquiries/svg/copy.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 44 | 45 | 54 | -------------------------------------------------------------------------------- /src/views/MainView/Inquiries/svg/delete.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 42 | 43 | 53 | -------------------------------------------------------------------------------- /src/views/MainView/Inquiries/svg/rename.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 42 | 43 | 53 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Schema/TableDescription.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | 34 | 56 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/PivotUi/PivotSortBtn.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | 39 | 76 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Tabs/Tab/RunResult/Record/RowNavigator.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 60 | 61 | 71 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Tabs/Tab/SideToolBar.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 59 | 60 | 74 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Tabs/Tab/SqlEditor/hint.js: -------------------------------------------------------------------------------- 1 | import CM from 'codemirror' 2 | import 'codemirror/addon/hint/show-hint.js' 3 | import 'codemirror/addon/hint/sql-hint.js' 4 | import store from '@/store' 5 | 6 | function _getHintText(hint) { 7 | return typeof hint === 'string' ? hint : hint.text 8 | } 9 | export function getHints(cm, options) { 10 | const result = CM.hint.sql(cm, options) 11 | 12 | // Don't show the hint if there is only one option 13 | // and the replacingText is already equals to this option 14 | const replacedText = cm.getRange(result.from, result.to).toUpperCase() 15 | if ( 16 | result.list.length === 1 && 17 | _getHintText(result.list[0]).toUpperCase() === replacedText 18 | ) { 19 | result.list = [] 20 | } 21 | 22 | return result 23 | } 24 | 25 | const hintOptions = { 26 | get tables() { 27 | const tables = {} 28 | if (store.state.db.schema) { 29 | store.state.db.schema.forEach(table => { 30 | tables[table.name] = table.columns.map(column => column.name) 31 | }) 32 | } 33 | return tables 34 | }, 35 | get defaultTable() { 36 | const schema = store.state.db.schema 37 | return schema && schema.length === 1 ? schema[0].name : null 38 | }, 39 | completeSingle: false, 40 | completeOnSingleClick: true, 41 | alignWithWord: false 42 | } 43 | 44 | export function showHintOnDemand(editor) { 45 | CM.showHint(editor, getHints, hintOptions) 46 | } 47 | 48 | export default function showHint(editor) { 49 | // Don't show autocomplete after a space or semicolon or in string literals 50 | const token = editor.getTokenAt(editor.getCursor()) 51 | const ch = token.string.slice(-1) 52 | const tokenType = token.type 53 | if (tokenType === 'string' || !ch || ch === ' ' || ch === ';') { 54 | return 55 | } 56 | 57 | CM.showHint(editor, getHints, hintOptions) 58 | } 59 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/Tabs/Tab/SqlEditor/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 83 | 84 | 113 | -------------------------------------------------------------------------------- /src/views/MainView/Workspace/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 74 | 75 | 81 | -------------------------------------------------------------------------------- /src/views/MainView/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 28 | -------------------------------------------------------------------------------- /src/views/Welcome.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | 22 | 55 | -------------------------------------------------------------------------------- /test.setup.js: -------------------------------------------------------------------------------- 1 | import { config } from '@vue/test-utils' 2 | import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal' 3 | 4 | config.global.plugins = [createVfm()] 5 | config.global.components = { 6 | modal: VueFinalModal 7 | } 8 | const vfm = useVfm() 9 | config.global.mocks = { 10 | $modal: { 11 | show: modalId => vfm.open(modalId), 12 | hide: modalId => vfm.close(modalId) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/App.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import { shallowMount } from '@vue/test-utils' 4 | import { createStore } from 'vuex' 5 | import App from '@/App' 6 | import storedInquiries from '@/lib/storedInquiries' 7 | import mutations from '@/store/mutations' 8 | import { nextTick } from 'vue' 9 | 10 | describe('App.vue', () => { 11 | afterEach(() => { 12 | sinon.restore() 13 | }) 14 | 15 | it('Gets inquiries', () => { 16 | sinon 17 | .stub(storedInquiries, 'getStoredInquiries') 18 | .returns([{ id: 1 }, { id: 2 }, { id: 3 }]) 19 | const state = { 20 | predefinedInquiries: [], 21 | inquiries: [] 22 | } 23 | const store = createStore({ state, mutations }) 24 | shallowMount(App, { 25 | global: { 26 | stubs: ['router-view'], 27 | plugins: [store] 28 | } 29 | }) 30 | 31 | expect(state.inquiries).to.eql([{ id: 1 }, { id: 2 }, { id: 3 }]) 32 | }) 33 | 34 | it('Updates inquiries when they change in store', async () => { 35 | sinon.stub(storedInquiries, 'getStoredInquiries').returns([ 36 | { id: 1, name: 'foo' }, 37 | { id: 2, name: 'baz' }, 38 | { id: 3, name: 'bar' } 39 | ]) 40 | sinon.spy(storedInquiries, 'updateStorage') 41 | 42 | const state = { 43 | predefinedInquiries: [], 44 | inquiries: [] 45 | } 46 | const store = createStore({ state, mutations }) 47 | shallowMount(App, { 48 | global: { stubs: ['router-view'], plugins: [store] } 49 | }) 50 | 51 | store.state.inquiries.splice(0, 1, { id: 1, name: 'new foo name' }) 52 | await nextTick() 53 | 54 | expect(storedInquiries.updateStorage.calledTwice).to.equal(true) 55 | 56 | expect(storedInquiries.updateStorage.args[1][0]).to.eql([ 57 | { id: 1, name: 'new foo name' }, 58 | { id: 2, name: 'baz' }, 59 | { id: 3, name: 'bar' } 60 | ]) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/components/CheckBox.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import CheckBox from '@/components/CheckBox' 4 | 5 | describe('CheckBox', () => { 6 | it('unchecked by default', () => { 7 | const wrapper = shallowMount(CheckBox, { 8 | props: { init: false }, 9 | attachTo: document.body 10 | }) 11 | expect(wrapper.find('img').isVisible()).to.equal(false) 12 | wrapper.unmount() 13 | }) 14 | 15 | it('gets init state according to passed props', () => { 16 | const wrapperChecked = shallowMount(CheckBox, { 17 | props: { init: true }, 18 | attachTo: document.body 19 | }) 20 | expect(wrapperChecked.find('img.checked').isVisible()).to.equal(true) 21 | wrapperChecked.unmount() 22 | 23 | const wrapperUnchecked = shallowMount(CheckBox, { 24 | props: { init: false }, 25 | attachTo: document.body 26 | }) 27 | expect(wrapperUnchecked.find('img').isVisible()).to.equal(false) 28 | wrapperUnchecked.unmount() 29 | }) 30 | 31 | it('checked on click', async () => { 32 | const wrapper = shallowMount(CheckBox, { attachTo: document.body }) 33 | await wrapper.trigger('click') 34 | expect(wrapper.find('img.checked').isVisible()).to.equal(true) 35 | wrapper.unmount() 36 | }) 37 | 38 | it('emits event on click', async () => { 39 | const wrapper = shallowMount(CheckBox) 40 | await wrapper.trigger('click') 41 | expect(wrapper.emitted().click).to.have.lengthOf(1) 42 | expect(wrapper.emitted().click[0]).to.eql([true]) 43 | await wrapper.trigger('click') 44 | expect(wrapper.emitted().click).to.have.lengthOf(2) 45 | expect(wrapper.emitted().click[1]).to.eql([false]) 46 | }) 47 | 48 | it('disabled', async () => { 49 | const wrapper = shallowMount(CheckBox, { 50 | props: { disabled: true } 51 | }) 52 | expect(wrapper.find('.checkbox-container').classes()).to.include('disabled') 53 | expect(wrapper.find('.checkbox-container').classes()).to.not.include( 54 | 'checked' 55 | ) 56 | await wrapper.trigger('click') 57 | expect(wrapper.emitted().click).to.equal(undefined) 58 | expect(wrapper.find('.checkbox-container').classes()).to.not.include( 59 | 'checked' 60 | ) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/components/LoadingIndicator.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import LoadingIndicator from '@/components/LoadingIndicator' 4 | 5 | describe('LoadingIndicator.vue', () => { 6 | it('Calculates animation class', async () => { 7 | const wrapper = shallowMount(LoadingIndicator, { 8 | props: { progress: 0 } 9 | }) 10 | expect(wrapper.find('svg').classes()).to.contain('progress') 11 | await wrapper.setProps({ progress: undefined }) 12 | expect(wrapper.find('svg').classes()).to.not.contain('progress') 13 | expect(wrapper.find('svg').classes()).to.contain('loading') 14 | }) 15 | 16 | it('Calculates arc', async () => { 17 | const wrapper = shallowMount(LoadingIndicator, { 18 | props: { progress: 50 } 19 | }) 20 | // The lendth of circle in the component is 50.24. If progress is 50% then resulting arc 21 | // should be 25.12 22 | expect( 23 | wrapper.find('.loader-svg.front').element.style.strokeDasharray 24 | ).to.equal('25.12px, 25.12px') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /tests/components/Logs.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import Logs from '@/components/Logs' 4 | import { nextTick } from 'vue' 5 | 6 | let place 7 | describe('Logs.vue', () => { 8 | beforeEach(() => { 9 | place = document.createElement('div') 10 | document.body.appendChild(place) 11 | }) 12 | 13 | afterEach(() => { 14 | place.remove() 15 | }) 16 | 17 | it('Scrolled to bottom on mounted', async () => { 18 | const messages = [ 19 | { type: 'error', message: 'msg 1' }, 20 | { type: 'error', message: 'msg 2' }, 21 | { type: 'error', message: 'msg 3' }, 22 | { type: 'error', message: 'msg 4' } 23 | ] 24 | 25 | const containerHeight = 160 26 | const borderWidth = 1 27 | const viewHeight = containerHeight - 2 * borderWidth 28 | const wrapper = shallowMount(Logs, { 29 | attachTo: place, 30 | props: { messages, style: `height: ${containerHeight}px` } 31 | }) 32 | await nextTick() 33 | const height = wrapper.find('.logs-container').element.scrollHeight 34 | expect(wrapper.find('.logs-container').element.scrollTop).to.equal( 35 | height - viewHeight 36 | ) 37 | wrapper.unmount() 38 | }) 39 | 40 | it('Scrolled to bottom when a message added', async () => { 41 | const messages = [ 42 | { type: 'error', message: 'msg 1' }, 43 | { type: 'error', message: 'msg 2' }, 44 | { type: 'error', message: 'msg 3' }, 45 | { type: 'error', message: 'msg 4' } 46 | ] 47 | 48 | const containerHeight = 160 49 | const borderWidth = 1 50 | const viewHeight = containerHeight - 2 * borderWidth 51 | const wrapper = shallowMount(Logs, { 52 | attachTo: place, 53 | props: { messages, style: `height: ${containerHeight}px` } 54 | }) 55 | 56 | await nextTick() 57 | messages.push({ type: 'error', message: 'msg 5' }) 58 | 59 | await nextTick() 60 | await nextTick() 61 | const height = wrapper.find('.logs-container').element.scrollHeight 62 | expect(wrapper.find('.logs-container').element.scrollTop).to.equal( 63 | height - viewHeight 64 | ) 65 | wrapper.unmount() 66 | }) 67 | 68 | it('Serializes messages', async () => { 69 | const messages = [ 70 | { type: 'error', message: 'msg 1.', row: 0, hint: 'Try again later.' }, 71 | { type: 'error', message: 'msg 2!', row: 2, hint: undefined }, 72 | { type: 'error', message: 'msg 3?', hint: 'Be happy!' }, 73 | { type: 'error', message: 'msg 4' } 74 | ] 75 | 76 | const wrapper = shallowMount(Logs, { 77 | props: { messages } 78 | }) 79 | 80 | const logs = wrapper.findAll('.msg') 81 | expect(logs[0].text()).to.equal('Error in row 0. msg 1. Try again later.') 82 | expect(logs[1].text()).to.equal('Error in row 2. msg 2!') 83 | expect(logs[2].text()).to.equal('msg 3? Be happy!') 84 | expect(logs[3].text()).to.equal('msg 4.') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/components/Splitpanes/splitter.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import splitter from '@/components/Splitpanes/splitter' 4 | 5 | describe('splitter.js', () => { 6 | afterEach(() => { 7 | sinon.restore() 8 | }) 9 | 10 | it('getCurrentMouseDrag', () => { 11 | const container = document.createElement('div') 12 | container.style.width = '100px' 13 | container.style.height = '100px' 14 | container.style.position = 'fixed' 15 | container.style.top = '10px' 16 | container.style.left = '20px' 17 | 18 | document.body.appendChild(container) 19 | 20 | const event = new MouseEvent('mousemove', { 21 | clientX: 70, 22 | clientY: 80 23 | }) 24 | 25 | const mouseDrag = splitter.getCurrentMouseDrag(event, container) 26 | expect(mouseDrag.x).to.equal(50) 27 | expect(mouseDrag.y).to.equal(70) 28 | }) 29 | 30 | it('getCurrentDragPercentage - horisontal', () => { 31 | sinon.stub(splitter, 'getCurrentMouseDrag').returns({ x: 50, y: 70 }) 32 | 33 | const event = {} 34 | const isHorisontal = true 35 | const container = document.createElement('div') 36 | container.style.width = '200px' 37 | container.style.height = '140px' 38 | 39 | document.body.appendChild(container) 40 | 41 | const dragPercentage = splitter.getCurrentDragPercentage( 42 | event, 43 | container, 44 | isHorisontal 45 | ) 46 | expect(dragPercentage).to.equal(50) 47 | }) 48 | 49 | it('getCurrentDragPercentage - vertical', () => { 50 | sinon.stub(splitter, 'getCurrentMouseDrag').returns({ x: 50, y: 70 }) 51 | 52 | const event = {} 53 | const isHorisontal = false 54 | const container = document.createElement('div') 55 | container.style.width = '200px' 56 | container.style.height = '140px' 57 | 58 | document.body.appendChild(container) 59 | 60 | const dragPercentage = splitter.getCurrentDragPercentage( 61 | event, 62 | container, 63 | isHorisontal 64 | ) 65 | expect(dragPercentage).to.equal(25) 66 | }) 67 | 68 | it('calculateOffset', () => { 69 | sinon.stub(splitter, 'getCurrentDragPercentage').returns(25) 70 | 71 | const event = {} 72 | const container = {} 73 | 74 | const splitterInfo = { 75 | container, 76 | paneBeforeMax: 70, 77 | paneAfterMax: 80, 78 | isHorisontal: true 79 | } 80 | const offset = splitter.calculateOffset(event, splitterInfo) 81 | 82 | expect(offset).to.equal(25) 83 | }) 84 | 85 | it('calculateOffset prevents dragging beyond paneBefore max', () => { 86 | sinon.stub(splitter, 'getCurrentDragPercentage').returns(75) 87 | 88 | const event = {} 89 | const container = {} 90 | const splitterInfo = { 91 | container, 92 | paneBeforeMax: 70, 93 | paneAfterMax: 80, 94 | isHorisontal: true 95 | } 96 | const offset = splitter.calculateOffset(event, splitterInfo) 97 | 98 | expect(offset).to.equal(70) 99 | }) 100 | 101 | it('calculateOffset prevents dragging beyond paneAfter max', () => { 102 | sinon.stub(splitter, 'getCurrentDragPercentage').returns(10) 103 | 104 | const event = {} 105 | const container = {} 106 | const splitterInfo = { 107 | container, 108 | paneBeforeMax: 70, 109 | paneAfterMax: 80, 110 | isHorisontal: true 111 | } 112 | const offset = splitter.calculateOffset(event, splitterInfo) 113 | 114 | expect(offset).to.equal(20) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /tests/components/SqlTable/Pager.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import { mount } from '@vue/test-utils' 4 | import Pager from '@/components/SqlTable/Pager' 5 | 6 | describe('Pager.vue', () => { 7 | afterEach(() => { 8 | sinon.restore() 9 | }) 10 | 11 | it('emits update:modelValue event with a page', async () => { 12 | const wrapper = mount(Pager, { 13 | props: { 14 | pageCount: 5, 15 | 'onUpdate:modelValue': e => wrapper.setProps({ modelValue: e }) 16 | } 17 | }) 18 | 19 | // click on 'next page' link 20 | await wrapper.find('.paginator-next').trigger('click') 21 | expect(wrapper.props('modelValue')).to.eql(2) 22 | 23 | // click on the link to page 3 (it has index 2) 24 | await wrapper.findAll('.paginator-page-link')[2].trigger('click') 25 | expect(wrapper.props('modelValue')).to.eql(3) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/lib/chartHelper.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import sinon from 'sinon' 3 | import chartHelper from '@/lib/chartHelper' 4 | import * as dereference from 'react-chart-editor/lib/lib/dereference' 5 | 6 | describe('chartHelper.js', () => { 7 | afterEach(() => { 8 | sinon.restore() 9 | }) 10 | 11 | it('getOptionsFromDataSources', () => { 12 | const dataSources = { 13 | id: [1, 2], 14 | name: ['foo', 'bar'] 15 | } 16 | 17 | const ds = chartHelper.getOptionsFromDataSources(dataSources) 18 | expect(ds).to.eql([ 19 | { value: 'id', label: 'id' }, 20 | { value: 'name', label: 'name' } 21 | ]) 22 | }) 23 | 24 | it('getOptionsForSave', () => { 25 | const state = { 26 | data: { 27 | foo: {}, 28 | bar: {} 29 | }, 30 | layout: {}, 31 | frames: {} 32 | } 33 | const dataSources = { 34 | id: [1, 2], 35 | name: ['foo', 'bar'] 36 | } 37 | sinon.stub(dereference, 'default') 38 | sinon.spy(JSON, 'parse') 39 | 40 | const ds = chartHelper.getOptionsForSave(state, dataSources) 41 | 42 | expect(dereference.default.calledOnce).to.equal(true) 43 | 44 | const args = dereference.default.firstCall.args 45 | expect(args[0]).to.eql({ 46 | foo: {}, 47 | bar: {} 48 | }) 49 | expect(args[1]).to.eql({ 50 | id: [], 51 | name: [] 52 | }) 53 | 54 | expect(ds).to.equal(JSON.parse.returnValues[0]) 55 | }) 56 | 57 | it('getImageDataUrl returns dataUrl', async () => { 58 | const element = document.createElement('div') 59 | const child = document.createElement('div') 60 | element.append(child) 61 | child.classList.add('js-plotly-plot') 62 | 63 | let url = await chartHelper.getImageDataUrl(element, 'png') 64 | expect(/^data:image\/png/.test(url)).to.equal(true) 65 | 66 | url = await chartHelper.getImageDataUrl(element, 'svg') 67 | expect(/^data:image\/svg\+xml/.test(url)).to.equal(true) 68 | }) 69 | 70 | it('getChartData returns plotly data and layout from element', async () => { 71 | const element = document.createElement('div') 72 | const child = document.createElement('div') 73 | element.append(child) 74 | child.classList.add('js-plotly-plot') 75 | child.data = 'plotly data' 76 | child.layout = 'plotly layout' 77 | 78 | const chartData = chartHelper.getChartData(element) 79 | expect(chartData).to.eql({ 80 | data: 'plotly data', 81 | layout: 'plotly layout' 82 | }) 83 | }) 84 | 85 | it('getHtml returns valid html', async () => { 86 | const options = { 87 | data: 'plotly data', 88 | layout: 'plotly layout' 89 | } 90 | 91 | const html = chartHelper.getHtml(options) 92 | const doc = document.createElement('div') 93 | doc.innerHTML = html 94 | 95 | expect(doc.innerHTML).to.equal(html) 96 | expect(doc.children).to.have.lengthOf(3) 97 | expect(doc.children[0].src).to.includes('plotly-latest.js') 98 | expect(doc.children[1].id).to.have.lengthOf(21) 99 | expect(doc.children[2].innerHTML).to.includes(doc.children[1].id) 100 | expect(doc.children[2].innerHTML).to.includes( 101 | 'Plotly.newPlot(el, "plotly data", "plotly layout"' 102 | ) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /tests/lib/database/_statements.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import stmts from '@/lib/database/_statements' 3 | 4 | describe('_statements.js', () => { 5 | it('generateChunks', () => { 6 | const source = { 7 | id: ['1', '2', '3', '4', '5'] 8 | } 9 | const size = 2 10 | const chunks = stmts.generateChunks(source, size) 11 | const output = [] 12 | for (const chunk of chunks) { 13 | output.push(chunk) 14 | } 15 | expect(output[0]).to.eql([['1'], ['2']]) 16 | expect(output[1]).to.eql([['3'], ['4']]) 17 | expect(output[2]).to.eql([['5']]) 18 | }) 19 | 20 | it('getInsertStmt', () => { 21 | const columns = ['id', 'name'] 22 | expect(stmts.getInsertStmt('foo', columns)).to.equal( 23 | 'INSERT INTO "foo" ("id", "name") VALUES (?, ?);' 24 | ) 25 | }) 26 | 27 | it('getCreateStatement', () => { 28 | const data = { 29 | id: [1, 2], 30 | name: ['foo', 'bar'], 31 | isAdmin: [true, false], 32 | startDate: [new Date(), new Date()] 33 | } 34 | 35 | expect(stmts.getCreateStatement('foo', data)).to.equal( 36 | 'CREATE table "foo"("id" REAL, "name" TEXT, "isAdmin" INTEGER, "startDate" TEXT);' 37 | ) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /tests/lib/storedInquiries/_migrations.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import migrations from '@/lib/storedInquiries/_migrations' 3 | 4 | describe('_migrations.js', () => { 5 | it('migrates from version 1 to the current', () => { 6 | const oldInquiries = [ 7 | { 8 | id: '123', 9 | name: 'foo', 10 | query: 'SELECT * FROM foo', 11 | chart: { here_are: 'foo chart settings' }, 12 | createdAt: '2021-05-06T11:05:50.877Z' 13 | }, 14 | { 15 | id: '456', 16 | name: 'bar', 17 | query: 'SELECT * FROM bar', 18 | chart: { here_are: 'bar chart settings' }, 19 | createdAt: '2021-05-07T11:05:50.877Z' 20 | } 21 | ] 22 | 23 | expect(migrations._migrate(1, oldInquiries)).to.eql([ 24 | { 25 | id: '123', 26 | name: 'foo', 27 | query: 'SELECT * FROM foo', 28 | viewType: 'chart', 29 | viewOptions: { here_are: 'foo chart settings' }, 30 | createdAt: '2021-05-06T11:05:50.877Z' 31 | }, 32 | { 33 | id: '456', 34 | name: 'bar', 35 | query: 'SELECT * FROM bar', 36 | viewType: 'chart', 37 | viewOptions: { here_are: 'bar chart settings' }, 38 | createdAt: '2021-05-07T11:05:50.877Z' 39 | } 40 | ]) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /tests/lib/utils/clipboardIo.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import cIo from '@/lib/utils/clipboardIo' 3 | import sinon from 'sinon' 4 | 5 | describe('clipboardIo.js', async () => { 6 | afterEach(() => { 7 | sinon.restore() 8 | }) 9 | 10 | it('copyText', async () => { 11 | sinon.stub(navigator.clipboard, 'writeText').resolves(true) 12 | await cIo.copyText('id\tname\r\n1\t2') 13 | expect(navigator.clipboard.writeText.calledOnceWith('id\tname\r\n1\t2')) 14 | }) 15 | 16 | it('copyImage for canvas calls _copyCanvas', async () => { 17 | sinon.stub(cIo, '_copyCanvas').resolves(true) 18 | const canvas = document.createElement('canvas') 19 | 20 | await cIo.copyImage(canvas) 21 | expect(cIo._copyCanvas.calledOnceWith(canvas)) 22 | }) 23 | 24 | it('copyImage for dataUrl calls _copyFromDataUrl', async () => { 25 | sinon.stub(cIo, '_copyFromDataUrl').resolves(true) 26 | const url = document.createElement('canvas').toDataURL() 27 | await cIo.copyImage(url) 28 | expect(cIo._copyFromDataUrl.calledOnceWith(url)) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/lib/utils/time.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import time from '@/lib/utils/time' 3 | 4 | describe('time.js', () => { 5 | it('getPeriod', () => { 6 | // 1.01.2021 13:00:00 000 7 | let start = new Date(2021, 0, 1, 13, 0, 0, 0) 8 | 9 | // 1.01.2021 13:01:00 500 10 | let end = new Date(2021, 0, 1, 13, 1, 0, 500) 11 | 12 | expect(time.getPeriod(start, end)).to.equal('60.500s') 13 | 14 | // 1.01.2021 13:00:00 000 15 | start = new Date(2021, 0, 1, 13, 0, 0, 0) 16 | 17 | // 1.01.2021 13:00:20 500 18 | end = new Date(2021, 0, 1, 13, 0, 20, 500) 19 | 20 | expect(time.getPeriod(start, end)).to.equal('20.500s') 21 | 22 | // 1.01.2021 13:00:00 000 23 | start = new Date(2021, 0, 1, 13, 0, 0, 0) 24 | 25 | // 1.01.2021 13:00:00 45 26 | end = new Date(2021, 0, 1, 13, 0, 0, 45) 27 | 28 | expect(time.getPeriod(start, end)).to.equal('0.045s') 29 | }) 30 | 31 | it('sleep resolves after n ms', async () => { 32 | let before = performance.now() 33 | await time.sleep(10) 34 | expect(performance.now() - before).to.be.least(10) 35 | 36 | before = performance.now() 37 | await time.sleep(30) 38 | expect(performance.now() - before).to.be.least(30) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Schema/TableDescription.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import TableDescription from '@/views/MainView/Workspace/Schema/TableDescription' 4 | 5 | describe('TableDescription.vue', () => { 6 | it('Initially the columns are hidden and table name is rendered', () => { 7 | const wrapper = shallowMount(TableDescription, { 8 | attachTo: document.body, 9 | props: { 10 | name: 'Test table', 11 | columns: [ 12 | { name: 'id', type: 'number' }, 13 | { name: 'title', type: 'nvarchar(24)' } 14 | ] 15 | } 16 | }) 17 | expect(wrapper.find('.table-name').text()).to.equal('Test table') 18 | expect(wrapper.find('.columns').isVisible()).to.equal(false) 19 | wrapper.unmount() 20 | }) 21 | 22 | it('Columns are visible and correct when click on table name', async () => { 23 | const wrapper = shallowMount(TableDescription, { 24 | global: { 25 | stubs: ['router-link'] 26 | }, 27 | props: { 28 | name: 'Test table', 29 | columns: [ 30 | { name: 'id', type: 'number' }, 31 | { name: 'title', type: 'nvarchar(24)' } 32 | ] 33 | } 34 | }) 35 | await wrapper.find('.table-name').trigger('click') 36 | 37 | expect(wrapper.find('.columns').isVisible()).to.equal(true) 38 | expect(wrapper.findAll('.column').length).to.equal(2) 39 | expect(wrapper.findAll('.column')[0].text()) 40 | .to.include('id') 41 | .and.include('number') 42 | expect(wrapper.findAll('.column')[1].text()) 43 | .to.include('title') 44 | .and.include('nvarchar(24)') 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/PivotUi/PivotSortBtn.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import PivotSortBtn from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/PivotUi/PivotSortBtn' 4 | 5 | describe('PivotSortBtn.vue', () => { 6 | it('switches order', async () => { 7 | const wrapper = shallowMount(PivotSortBtn, { 8 | props: { 9 | modelValue: 'key_a_to_z', 10 | 'onUpdate:modelValue': e => wrapper.setProps({ modelValue: e }) 11 | } 12 | }) 13 | 14 | expect(wrapper.props('modelValue')).to.equal('key_a_to_z') 15 | await wrapper.find('.pivot-sort-btn').trigger('click') 16 | expect(wrapper.props('modelValue')).to.equal('value_a_to_z') 17 | 18 | await wrapper.setValue('value_a_to_z') 19 | await wrapper.find('.pivot-sort-btn').trigger('click') 20 | expect(wrapper.props('modelValue')).to.equal('value_z_to_a') 21 | 22 | await wrapper.setValue('value_z_to_a') 23 | await wrapper.find('.pivot-sort-btn').trigger('click') 24 | expect(wrapper.props('modelValue')).to.equal('key_a_to_z') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | _getDataSources, 4 | getPivotCanvas, 5 | getPivotHtml 6 | } from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper' 7 | 8 | describe('pivotHelper.js', () => { 9 | it('_getDataSources returns data sources', () => { 10 | /* 11 | +---+---+---------+---------+ 12 | | | x | 5 | 10 | 13 | | +---+----+----+----+----+ 14 | | | z | 2 | 3 | 1 | 6 | 15 | +---+---+ | | | | 16 | | y | | | | | | 17 | +---+---+----+----+----+----+ 18 | | 3 | 5 | 6 | 4 | 9 | 19 | +-------+----+----+----+----+ 20 | | 6 | 8 | 9 | 7 | 12 | 21 | +-------+----+----+----+----+ 22 | | 9 | 11 | 12 | 10 | 15 | 23 | +-------+----+----+----+----+ 24 | */ 25 | const pivotData = { 26 | rowAttrs: ['y'], 27 | colAttrs: ['x', 'z'], 28 | getRowKeys() { 29 | return [[3], [6], [9]] 30 | }, 31 | getColKeys() { 32 | return [ 33 | [5, 2], 34 | [5, 3], 35 | [10, 1], 36 | [10, 6] 37 | ] 38 | }, 39 | getAggregator(row, col) { 40 | return { 41 | value() { 42 | return +row + +col[1] 43 | } 44 | } 45 | } 46 | } 47 | 48 | expect(_getDataSources(pivotData)).to.eql({ 49 | 'Column keys': ['5-2', '5-3', '10-1', '10-6'], 50 | 'Row keys': ['3', '6', '9'], 51 | 'x-z:5-2': [5, 8, 11], 52 | 'x-z:5-3': [6, 9, 12], 53 | 'x-z:10-1': [4, 7, 10], 54 | 'x-z:10-6': [9, 12, 15], 55 | 'y:3': [5, 6, 4, 9], 56 | 'y:6': [8, 9, 7, 12], 57 | 'y:9': [11, 12, 10, 15] 58 | }) 59 | }) 60 | 61 | it('getPivotCanvas returns canvas', async () => { 62 | const pivotOutput = document.body 63 | const child = document.createElement('div') 64 | child.classList.add('pvtTable') 65 | pivotOutput.append(child) 66 | 67 | expect(await getPivotCanvas(pivotOutput)).to.be.instanceof( 68 | HTMLCanvasElement 69 | ) 70 | }) 71 | 72 | it('getPivotHtml returns html with styles', async () => { 73 | const pivotOutput = document.createElement('div') 74 | pivotOutput.append('test') 75 | 76 | const html = getPivotHtml(pivotOutput) 77 | const doc = document.createElement('div') 78 | doc.innerHTML = html 79 | 80 | expect(doc.innerHTML).to.equal(html) 81 | expect(doc.children).to.have.lengthOf(2) 82 | expect(doc.children[0].tagName).to.equal('STYLE') 83 | expect(doc.children[1].tagName).to.equal('DIV') 84 | expect(doc.children[1].innerHTML).to.equal('test') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Tabs/Tab/RunResult/ValueViewer.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { mount } from '@vue/test-utils' 3 | import ValueViewer from '@/views/MainView/Workspace/Tabs/Tab/RunResult/ValueViewer.vue' 4 | import sinon from 'sinon' 5 | 6 | describe('ValueViewer.vue', () => { 7 | afterEach(() => { 8 | sinon.restore() 9 | }) 10 | 11 | it('shows value in text mode', () => { 12 | const wrapper = mount(ValueViewer, { 13 | props: { 14 | cellValue: 'foo' 15 | } 16 | }) 17 | 18 | expect(wrapper.find('.value-body').text()).to.equals('foo') 19 | }) 20 | 21 | it('shows error in json mode if the value is not json', async () => { 22 | const wrapper = mount(ValueViewer, { 23 | props: { 24 | cellValue: 'foo' 25 | } 26 | }) 27 | await wrapper.find('button.json').trigger('click') 28 | expect(wrapper.find('.value-body').text()).to.equals("Can't parse JSON.") 29 | }) 30 | 31 | it('copy to clipboard', async () => { 32 | sinon.stub(window.navigator.clipboard, 'writeText').resolves() 33 | const wrapper = mount(ValueViewer, { 34 | props: { 35 | cellValue: 'foo' 36 | } 37 | }) 38 | 39 | await wrapper.find('button.copy').trigger('click') 40 | 41 | expect(window.navigator.clipboard.writeText.calledOnceWith('foo')).to.equal( 42 | true 43 | ) 44 | }) 45 | 46 | it('wraps lines', async () => { 47 | const wrapper = mount(ValueViewer, { 48 | attachTo: document.body, 49 | props: { 50 | cellValue: 'foo' 51 | } 52 | }) 53 | 54 | wrapper.wrapperElement.parentElement.style.width = '50px' 55 | const valueBody = wrapper.find('.value-body').wrapperElement 56 | expect(valueBody.scrollWidth).to.equal(valueBody.clientWidth) 57 | 58 | await wrapper.setProps({ cellValue: 'foo'.repeat(100) }) 59 | expect(valueBody.scrollWidth).not.to.equal(valueBody.clientWidth) 60 | 61 | await wrapper.find('button.line-wrap').trigger('click') 62 | expect(valueBody.scrollWidth).to.equal(valueBody.clientWidth) 63 | wrapper.unmount() 64 | }) 65 | 66 | it('wraps lines in code mirror', async () => { 67 | const wrapper = mount(ValueViewer, { 68 | attachTo: document.body, 69 | props: { 70 | cellValue: '{"foo": "foofoofoofoofoofoofoofoofoofoo"}' 71 | } 72 | }) 73 | 74 | await wrapper.find('button.json').trigger('click') 75 | 76 | wrapper.wrapperElement.parentElement.style.width = '50px' 77 | const codeMirrorScroll = wrapper.find('.CodeMirror-scroll').wrapperElement 78 | expect(codeMirrorScroll.scrollWidth).not.to.equal( 79 | codeMirrorScroll.clientWidth 80 | ) 81 | 82 | await wrapper.find('button.line-wrap').trigger('click') 83 | expect(codeMirrorScroll.scrollWidth).to.equal(codeMirrorScroll.clientWidth) 84 | wrapper.unmount() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Tabs/Tab/SqlEditor/SqlEditor.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { mount } from '@vue/test-utils' 3 | import { createStore } from 'vuex' 4 | import SqlEditor from '@/views/MainView/Workspace/Tabs/Tab/SqlEditor' 5 | import { nextTick } from 'vue' 6 | 7 | describe('SqlEditor.vue', () => { 8 | it('Emits update:modelValue event when a query is changed', async () => { 9 | // mock store state 10 | const state = { 11 | db: {} 12 | } 13 | 14 | const store = createStore({ state }) 15 | 16 | const wrapper = mount(SqlEditor, { 17 | global: { 18 | plugins: [store] 19 | } 20 | }) 21 | await wrapper 22 | .findComponent({ ref: 'cm' }) 23 | .setValue('SELECT * FROM foo', 'value') 24 | expect(wrapper.emitted()['update:modelValue'][0]).to.eql([ 25 | 'SELECT * FROM foo' 26 | ]) 27 | }) 28 | 29 | it('Run is disabled if there is no db or no query or is getting result set', async () => { 30 | const state = { 31 | db: null 32 | } 33 | const store = createStore({ state }) 34 | 35 | const wrapper = mount(SqlEditor, { 36 | global: { plugins: [store] }, 37 | props: { isGettingResults: false } 38 | }) 39 | await wrapper 40 | .findComponent({ ref: 'cm' }) 41 | .setValue('SELECT * FROM foo', 'value') 42 | const runButton = wrapper.findComponent({ ref: 'runBtn' }) 43 | 44 | expect(runButton.props('disabled')).to.equal(true) 45 | 46 | store.state.db = {} 47 | await nextTick() 48 | expect(runButton.props('disabled')).to.equal(false) 49 | 50 | await wrapper.findComponent({ ref: 'cm' }).setValue('', 'value') 51 | expect(runButton.props('disabled')).to.equal(true) 52 | 53 | await wrapper 54 | .findComponent({ ref: 'cm' }) 55 | .setValue('SELECT * FROM foo', 'value') 56 | expect(runButton.props('disabled')).to.equal(false) 57 | 58 | await wrapper.setProps({ isGettingResults: true }) 59 | expect(runButton.props('disabled')).to.equal(true) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /tests/views/MainView/Workspace/Workspace.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { mount } from '@vue/test-utils' 3 | import actions from '@/store/actions' 4 | import mutations from '@/store/mutations' 5 | import { createStore } from 'vuex' 6 | import Workspace from '@/views/MainView/Workspace' 7 | 8 | describe('Workspace.vue', () => { 9 | it('Creates a tab with example if schema is empty', () => { 10 | const state = { 11 | db: {}, 12 | tabs: [] 13 | } 14 | const store = createStore({ state, actions, mutations }) 15 | const $route = { path: '/workspace', query: {} } 16 | mount(Workspace, { 17 | global: { 18 | stubs: ['router-link', 'modal'], 19 | mocks: { $route }, 20 | plugins: [store] 21 | } 22 | }) 23 | 24 | expect(state.tabs[0].query).to.include('Your database is empty.') 25 | expect(state.tabs[0].tempName).to.equal('Untitled') 26 | expect(state.tabs[0].name).to.equal(null) 27 | expect(state.tabs[0].viewType).to.equal('chart') 28 | expect(state.tabs[0].viewOptions).to.equal(undefined) 29 | expect(state.tabs[0].isSaved).to.equal(false) 30 | }) 31 | 32 | it('Collapse schema if hide_schema is 1', () => { 33 | const state = { 34 | db: {}, 35 | tabs: [] 36 | } 37 | const store = createStore({ state, actions, mutations }) 38 | const $route = { path: '/workspace', query: { hide_schema: '1' } } 39 | const vm = mount(Workspace, { 40 | global: { 41 | stubs: ['router-link', 'modal'], 42 | mocks: { $route }, 43 | plugins: [store] 44 | } 45 | }) 46 | 47 | expect(vm.find('#schema-container').element.offsetWidth).to.equal(0) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import { nodePolyfills } from 'vite-plugin-node-polyfills' 5 | import { viteStaticCopy } from 'vite-plugin-static-copy' 6 | import { VitePWA } from 'vite-plugin-pwa' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | base: './', 11 | plugins: [ 12 | vue(), 13 | nodePolyfills({ 14 | include: ['process', 'util', 'stream', 'buffer'], 15 | globals: { global: true, process: true } 16 | }), 17 | viteStaticCopy({ 18 | targets: [ 19 | { 20 | src: 'LICENSE', 21 | dest: './' 22 | } 23 | ] 24 | }), 25 | VitePWA({ 26 | filename: 'service-worker.js', 27 | manifest: false, 28 | injectRegister: false, 29 | workbox: { 30 | globPatterns: ['**\/*.{js,wasm,css,html,woff2,png}'], 31 | globIgnores: ['*.map', 'LICENSE', 'inquiries.json'], 32 | clientsClaim: true, 33 | skipWaiting: false, 34 | maximumFileSizeToCacheInBytes: 40000000 35 | } 36 | }) 37 | ], 38 | resolve: { 39 | alias: { 40 | '@': fileURLToPath(new URL('./src', import.meta.url)) 41 | }, 42 | extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'] 43 | }, 44 | optimizeDeps: { 45 | include: ['sql.js'], 46 | esbuildOptions: { 47 | define: { 48 | global: 'globalThis' 49 | } 50 | } 51 | }, 52 | build: { 53 | sourcemap: true, 54 | assetsInlineLimit: 10000, 55 | commonjsOptions: { 56 | include: ['sql.js', /sql-wasm.js/, /node_modules/] 57 | }, 58 | rollupOptions: { 59 | output: { 60 | manualChunks: id => { 61 | if (id.includes('maplibre-gl') || id.includes('mapbox-gl')) { 62 | return 'maps' 63 | } else if (id.includes('node_modules')) { 64 | return 'vendor' 65 | } else { 66 | return null 67 | } 68 | } 69 | } 70 | } 71 | } 72 | }) 73 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | 3 | module.exports = defineConfig({ 4 | parallel: false, 5 | transpileDependencies: true, 6 | publicPath: '', 7 | // Workaround for https://github.com/vuejs/vue-cli/issues/5399 as described 8 | // in https://stackoverflow.com/a/63185174 9 | lintOnSave: process.env.NODE_ENV === 'development' 10 | }) 11 | --------------------------------------------------------------------------------