From d66bc5ad90cb8a797c7d90449155aed72089ab28 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Thu, 14 Nov 2019 09:44:57 -0800 Subject: [PATCH] SIP-23: Persist SQL Lab state in the backend (#8060) * Squash all commits from VIZ-689 * Fix javascript * Fix black * WIP fixing javascript * Add feature flag SQLLAB_BACKEND_PERSISTENCE * Use feature flag * Small fix * Fix lint * Fix setQueryEditorSql * Improve unit tests * Add unit tests for backend sync * Rename results to description in table_schema * Add integration tests * Fix black * Migrate query history * Handle no results backend * Small improvement * Address comments * Store SQL directly instead of reference to query * Small fixes * Fix clone tab * Fix remove query * Cascade delete * Cascade deletes * Fix tab closing * Small fixes * Small fix * Fix error when deleting tab * Catch 404 when tab is deleted * Remove tables from state on tab close * Add index, autoincrement and cascade * Prevent duplicate table schemas * Fix mapStateToProps * Fix lint * Fix head * Fix javascript * Fix mypy * Fix isort * Fix javascript * Fix merge * Fix heads * Fix heads * Fix displayLimit * Recreate migration script trying to fix heads * Fix heads --- superset/assets/package-lock.json | 958 +++++++++++++++--- .../sqllab/TabbedSqlEditors_spec.jsx | 20 +- .../javascripts/sqllab/actions/sqlLab_spec.js | 614 ++++++++++- .../sqllab/reducers/sqlLab_spec.js | 182 +++- superset/assets/src/SqlLab/App.jsx | 7 +- superset/assets/src/SqlLab/actions/sqlLab.js | 556 ++++++++-- superset/assets/src/SqlLab/components/App.jsx | 6 +- .../src/SqlLab/components/LimitControl.jsx | 2 +- .../src/SqlLab/components/ResultSet.jsx | 6 +- .../src/SqlLab/components/SouthPane.jsx | 33 +- .../src/SqlLab/components/SqlEditor.jsx | 7 + .../SqlLab/components/TabbedSqlEditors.jsx | 62 +- .../components/TemplateParamsEditor.jsx | 7 +- .../src/SqlLab/reducers/getInitialState.js | 122 ++- superset/assets/src/SqlLab/reducers/sqlLab.js | 76 +- .../assets/src/components/TableSelector.jsx | 3 +- superset/assets/src/featureFlags.ts | 1 + superset/assets/src/reduxUtils.js | 17 + superset/config.py | 2 +- ...4b49eb0782_add_tables_for_sql_lab_state.py | 94 ++ superset/models/sql_lab.py | 82 ++ superset/sql_lab.py | 4 +- superset/views/core.py | 50 +- superset/views/sql_lab.py | 173 +++- ...rset_test_config_sqllab_backend_persist.py | 63 ++ tox.ini | 14 + 26 files changed, 2814 insertions(+), 347 deletions(-) create mode 100644 superset/migrations/versions/db4b49eb0782_add_tables_for_sql_lab_state.py create mode 100644 tests/superset_test_config_sqllab_backend_persist.py diff --git a/superset/assets/package-lock.json b/superset/assets/package-lock.json index 22c5b1d74d..d508f6dbb3 100644 --- a/superset/assets/package-lock.json +++ b/superset/assets/package-lock.json @@ -2222,6 +2222,41 @@ "minimist": "^1.2.0" } }, + "@cypress/listr-verbose-renderer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", + "integrity": "sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=", + "requires": { + "chalk": "^1.1.3", + "cli-cursor": "^1.0.2", + "date-fns": "^1.27.2", + "figures": "^1.7.0" + } + }, + "@cypress/xvfb": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.3.tgz", + "integrity": "sha512-yYrK+/bgL3hwoRHMZG4r5fyLniCy1pXex5fimtewAY6vE/jsVs8Q37UsEO03tFlcmiLnQ3rBNMaZBYTi/+C1cw==", + "requires": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "@data-ui/event-flow": { "version": "0.0.80", "resolved": "https://registry.npmjs.org/@data-ui/event-flow/-/event-flow-0.0.80.tgz", @@ -3600,6 +3635,13 @@ "requires": { "@babel/runtime": "^7.1.2", "whatwg-fetch": "^3.0.0" + }, + "dependencies": { + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + } } }, "@superset-ui/core": { @@ -4534,6 +4576,30 @@ } } }, + "@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==" + }, + "@types/bluebird": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.18.tgz", + "integrity": "sha512-OTPWHmsyW18BhrnG5x8F7PzeZ2nFxmHGb42bZn79P9hl+GI5cMzyPgQTwNjbem0lJhoru/8vtjAFCUOu3+gE2w==" + }, + "@types/chai": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.0.8.tgz", + "integrity": "sha512-m812CONwdZn/dMzkIJEY0yAs4apyTkTORgfB2UsMOxgkUbC205AHnm4T8I0I5gPg9MHrFc1dJ35iS75c0CJkjg==" + }, + "@types/chai-jquery": { + "version": "1.1.35", + "resolved": "https://registry.npmjs.org/@types/chai-jquery/-/chai-jquery-1.1.35.tgz", + "integrity": "sha512-7aIt9QMRdxuagLLI48dPz96YJdhu64p6FCa6n4qkGN5DQLHnrIjZpD9bXCvV2G0NwgZ1FAmfP214dxc5zNCfgQ==", + "requires": { + "@types/chai": "*", + "@types/jquery": "*" + } + }, "@types/clone": { "version": "0.1.30", "resolved": "https://registry.npmjs.org/@types/clone/-/clone-0.1.30.tgz", @@ -4635,6 +4701,11 @@ "integrity": "sha512-DC8xTuW/6TYgvEg3HEXS7cu9OijFqprVDXXiOcdOKZCU/5PJNLZU37VVvmZHdtMiGOa8wAA/We+JzbdxFzQTRQ==", "dev": true }, + "@types/jquery": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.6.tgz", + "integrity": "sha512-403D4wN95Mtzt2EoQHARf5oe/jEPhzBOBNrunk+ydQGW8WmkQ/E8rViRAEB1qEt/vssfGfNVD6ujP4FVeegrLg==" + }, "@types/lodash": { "version": "4.14.146", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.146.tgz", @@ -4645,6 +4716,16 @@ "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-1.15.2.tgz", "integrity": "sha512-zHPoyVrLvNaiMRYdhmh88Rn489ZgAgbc6iLxR5Yi0VCNfeNYHcszbhJV2vDHLNrVGy35BPtWBRn4OP2F9BBvFw==" }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" + }, + "@types/mocha": { + "version": "2.2.44", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-2.2.44.tgz", + "integrity": "sha512-k2tWTQU8G4+iSMvqKi0Q9IIsWAp/n8xzdZS4Q4YVIltApoMA00wFBFdlJnmoaK1/z7B0Cy0yPe6GgXteSmdUNw==" + }, "@types/node": { "version": "10.12.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.15.tgz", @@ -4712,6 +4793,20 @@ "@types/react": "*" } }, + "@types/sinon": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.0.tgz", + "integrity": "sha512-kcYoPw0uKioFVC/oOqafk2yizSceIQXCYnkYts9vJIwQklFRsMubTObTDrjQamUyBRd47332s85074cd/hCwxg==" + }, + "@types/sinon-chai": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.2.tgz", + "integrity": "sha512-5zSs2AslzyPZdOsbm2NRtuSNAI2aTWzNKOHa/GRecKo7a5efYD7qGcPxMZXQDayVXT2Vnd5waXxBvV31eCZqiA==", + "requires": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, "@types/source-list-map": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", @@ -5540,14 +5635,12 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" }, "ansicolors": { "version": "0.2.1", @@ -6103,7 +6196,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/async/-/async-2.4.0.tgz", "integrity": "sha1-SZAgDxjqW4N8LMT4wDGmmFw4VhE=", - "dev": true, "requires": { "lodash": "^4.14.0" } @@ -6857,6 +6949,11 @@ } } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, "buffer-equal": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", @@ -6962,6 +7059,14 @@ "schema-utils": "^0.4.2" } }, + "cachedir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-1.3.0.tgz", + "integrity": "sha512-O1ji32oyON9laVPJL1IZ5bmwd2cB46VfpxkDequezH+15FDzzVddEyrGEeX4WusDSqKxdyFdDQDEG1yo1GoWkg==", + "requires": { + "os-homedir": "^1.0.1" + } + }, "caller-callsite": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", @@ -7036,7 +7141,6 @@ "version": "1.1.3", "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, "requires": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -7076,6 +7180,11 @@ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" }, + "check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=" + }, "check-types": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz", @@ -7853,12 +7962,25 @@ } }, "cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", "requires": { - "restore-cursor": "^2.0.0" + "restore-cursor": "^1.0.1" + } + }, + "cli-spinners": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-0.1.2.tgz", + "integrity": "sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw=" + }, + "cli-truncate": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", + "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", + "requires": { + "slice-ansi": "0.0.4", + "string-width": "^1.0.1" } }, "cli-width": { @@ -7982,8 +8104,7 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "dev": true + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "collapse-white-space": { "version": "1.0.4", @@ -8054,6 +8175,14 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==" }, + "common-tags": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.4.0.tgz", + "integrity": "sha1-EYe+Tz1M8MBCfUP3Tu8fc1AWFMA=", + "requires": { + "babel-runtime": "^6.18.0" + } + }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -8352,7 +8481,6 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, "requires": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -8889,6 +9017,232 @@ "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", "dev": true }, + "cypress": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-3.1.5.tgz", + "integrity": "sha512-jzYGKJqU1CHoNocPndinf/vbG28SeU+hg+4qhousT/HDBMJxYgjecXOmSgBX/ga9/TakhqSrIrSP2r6gW/OLtg==", + "requires": { + "@cypress/listr-verbose-renderer": "0.4.1", + "@cypress/xvfb": "1.2.3", + "@types/blob-util": "1.3.3", + "@types/bluebird": "3.5.18", + "@types/chai": "4.0.8", + "@types/chai-jquery": "1.1.35", + "@types/jquery": "3.3.6", + "@types/lodash": "4.14.87", + "@types/minimatch": "3.0.3", + "@types/mocha": "2.2.44", + "@types/sinon": "7.0.0", + "@types/sinon-chai": "3.2.2", + "bluebird": "3.5.0", + "cachedir": "1.3.0", + "chalk": "2.4.1", + "check-more-types": "2.24.0", + "commander": "2.11.0", + "common-tags": "1.4.0", + "debug": "3.1.0", + "execa": "0.10.0", + "executable": "4.1.1", + "extract-zip": "1.6.6", + "fs-extra": "4.0.1", + "getos": "3.1.0", + "glob": "7.1.2", + "is-ci": "1.0.10", + "is-installed-globally": "0.1.0", + "lazy-ass": "1.6.0", + "listr": "0.12.0", + "lodash": "4.17.11", + "log-symbols": "2.2.0", + "minimist": "1.2.0", + "moment": "2.22.2", + "ramda": "0.24.1", + "request": "2.87.0", + "request-progress": "0.3.1", + "supports-color": "5.1.0", + "tmp": "0.0.31", + "url": "0.11.0", + "yauzl": "2.8.0" + }, + "dependencies": { + "@types/lodash": { + "version": "4.14.87", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.87.tgz", + "integrity": "sha512-AqRC+aEF4N0LuNHtcjKtvF9OTfqZI0iaBoe3dA6m/W+/YZJBZjBmW/QIZ8fBeXC6cnytSY9tBoFBqZ9uSCeVsw==" + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "bluebird": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", + "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw=" + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "ci-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", + "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==" + }, + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, + "is-ci": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.0.10.tgz", + "integrity": "sha1-9zkzayYyNlBhqdSCcM1WrjNpMY4=", + "requires": { + "ci-info": "^1.0.0" + } + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=" + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "moment": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", + "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=" + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + }, + "request": { + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, + "supports-color": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.1.0.tgz", + "integrity": "sha512-Ry0AwkoKjDpVKK4sV4h6o3UJmNRbjYm2uXhwfj3J56lMVdvnUNqzQVRztOOMGQ++w1K/TjNDFvpJk0F/LoeBCQ==", + "requires": { + "has-flag": "^2.0.0" + }, + "dependencies": { + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=" + } + } + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "requires": { + "punycode": "^1.4.1" + } + } + } + }, "d3": { "version": "3.5.17", "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", @@ -9181,6 +9535,11 @@ "jquery": ">=1.7" } }, + "date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" + }, "date-now": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", @@ -9196,7 +9555,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -9618,6 +9976,11 @@ "integrity": "sha512-LQJmt0QcUzC/mLjG+ha5QhXgNQ2T2BOxRecuaU/hd92RnZt6G3ZGONsAe7Xvo9SoBvre/POElMoyK77mXjrr3w==", "dev": true }, + "elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=" + }, "elliptic": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", @@ -10437,7 +10800,6 @@ "version": "0.10.0", "resolved": "https://registry.npmjs.org/execa/-/execa-0.10.0.tgz", "integrity": "sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==", - "dev": true, "requires": { "cross-spawn": "^6.0.0", "get-stream": "^3.0.0", @@ -10448,6 +10810,14 @@ "strip-eof": "^1.0.0" } }, + "executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "requires": { + "pify": "^2.2.0" + } + }, "exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -10459,6 +10829,11 @@ "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=", "dev": true }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" + }, "expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -10608,6 +10983,17 @@ "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" + }, + "dependencies": { + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + } } }, "extglob": { @@ -10672,6 +11058,50 @@ } } }, + "extract-zip": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.6.tgz", + "integrity": "sha1-EpDt6NINCHK0Kf0/NRyhKOxe+Fw=", + "requires": { + "concat-stream": "1.6.0", + "debug": "2.6.9", + "mkdirp": "0.5.0", + "yauzl": "2.4.1" + }, + "dependencies": { + "concat-stream": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz", + "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=", + "requires": { + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mkdirp": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz", + "integrity": "sha1-HXMHam35hs2TROFecfzAWkyavxI=", + "requires": { + "minimist": "0.0.8" + } + }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "requires": { + "fd-slicer": "~1.0.1" + } + } + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -10774,6 +11204,14 @@ } } }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "requires": { + "pend": "~1.2.0" + } + }, "fetch-mock": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.2.5.tgz", @@ -10801,12 +11239,12 @@ "dev": true }, "figures": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", - "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", - "dev": true, + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", "requires": { - "escape-string-regexp": "^1.0.5" + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" } }, "file-entry-cache": { @@ -11989,6 +12427,16 @@ "readable-stream": "^2.0.0" } }, + "fs-extra": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.1.tgz", + "integrity": "sha1-f8DGyJV/mD9X8waiTlud3Y0N2IA=", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^3.0.0", + "universalify": "^0.1.0" + } + }, "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -12110,8 +12558,7 @@ "get-stream": { "version": "3.0.0", "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", - "dev": true + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "get-value": { "version": "2.0.6", @@ -12119,6 +12566,14 @@ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", "dev": true }, + "getos": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.1.0.tgz", + "integrity": "sha512-i9vrxtDu5DlLVFcrbqUqGWYlZN/zZ4pGMICCAcZoYsX3JA54nYp8r5EThw5K+m2q3wszkx4Th746JstspB0H4Q==", + "requires": { + "async": "2.4.0" + } + }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -12222,6 +12677,14 @@ "is-symbol": "^1.0.1" } }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "requires": { + "ini": "^1.3.4" + } + }, "global-modules-path": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/global-modules-path/-/global-modules-path-2.3.1.tgz", @@ -12258,8 +12721,7 @@ "graceful-fs": { "version": "4.1.15", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", - "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", - "dev": true + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==" }, "grid-index": { "version": "1.1.0", @@ -12369,7 +12831,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, "requires": { "ansi-regex": "^2.0.0" } @@ -13146,6 +13607,14 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "requires": { + "repeating": "^2.0.0" + } + }, "indexes-of": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", @@ -13172,6 +13641,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, "inline-style-prefixer": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz", @@ -13228,12 +13702,58 @@ "supports-color": "^5.3.0" } }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "rxjs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz", + "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -13601,11 +14121,18 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, "is-fullwidth-code-point": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dev": true, "requires": { "number-is-nan": "^1.0.0" } @@ -13635,6 +14162,15 @@ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz", "integrity": "sha512-but/G3sapV3MNyqiDBLrOi4x8uCIw0RY3o/Vb5GT0sMFHrVV7731wFSVy41T5FO1og7G0gXLJh0MkgPRouko/A==" }, + "is-installed-globally": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz", + "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=", + "requires": { + "global-dirs": "^0.1.0", + "is-path-inside": "^1.0.0" + } + }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", @@ -13686,7 +14222,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", - "dev": true, "requires": { "path-is-inside": "^1.0.1" } @@ -13708,8 +14243,7 @@ "is-promise": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", - "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", - "dev": true + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" }, "is-regex": { "version": "1.0.4", @@ -13798,8 +14332,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -15814,6 +16347,14 @@ "resolved": "http://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, + "jsonfile": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", + "integrity": "sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -15875,6 +16416,11 @@ "webpack-sources": "^1.1.0" } }, + "lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=" + }, "lcid": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", @@ -15950,6 +16496,85 @@ "type-check": "~0.3.2" } }, + "listr": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/listr/-/listr-0.12.0.tgz", + "integrity": "sha1-a84sD1YD+klYDqF81qAMwOX6RRo=", + "requires": { + "chalk": "^1.1.3", + "cli-truncate": "^0.2.1", + "figures": "^1.7.0", + "indent-string": "^2.1.0", + "is-promise": "^2.1.0", + "is-stream": "^1.1.0", + "listr-silent-renderer": "^1.1.1", + "listr-update-renderer": "^0.2.0", + "listr-verbose-renderer": "^0.4.0", + "log-symbols": "^1.0.2", + "log-update": "^1.0.2", + "ora": "^0.2.3", + "p-map": "^1.1.1", + "rxjs": "^5.0.0-beta.11", + "stream-to-observable": "^0.1.0", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "requires": { + "chalk": "^1.0.0" + } + } + } + }, + "listr-silent-renderer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", + "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=" + }, + "listr-update-renderer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz", + "integrity": "sha1-yoDhd5tOcCZoB+ju0a1qvjmFUPk=", + "requires": { + "chalk": "^1.1.3", + "cli-truncate": "^0.2.1", + "elegant-spinner": "^1.0.1", + "figures": "^1.7.0", + "indent-string": "^3.0.0", + "log-symbols": "^1.0.2", + "log-update": "^1.0.2", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=" + }, + "log-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", + "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", + "requires": { + "chalk": "^1.0.0" + } + } + } + }, + "listr-verbose-renderer": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", + "integrity": "sha1-ggb0z21S3cWCfl/RSYng6WWTOjU=", + "requires": { + "chalk": "^1.1.3", + "cli-cursor": "^1.0.2", + "date-fns": "^1.27.2", + "figures": "^1.7.0" + } + }, "load-bmfont": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.0.tgz", @@ -16092,6 +16717,11 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -16109,6 +16739,58 @@ "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", "dev": true }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "requires": { + "chalk": "^2.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "log-update": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-1.0.2.tgz", + "integrity": "sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE=", + "requires": { + "ansi-escapes": "^1.0.0", + "cli-cursor": "^1.0.2" + }, + "dependencies": { + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" + } + } + }, "loglevel": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.1.tgz", @@ -16700,8 +17382,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "multicast-dns": { "version": "6.2.3", @@ -16845,8 +17526,7 @@ "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, "nise": { "version": "1.4.8", @@ -17030,7 +17710,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", - "dev": true, "requires": { "path-key": "^2.0.0" } @@ -17047,8 +17726,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "dev": true + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "nvd3": { "version": "1.8.6", @@ -17233,13 +17911,9 @@ } }, "onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", - "dev": true, - "requires": { - "mimic-fn": "^1.0.0" - } + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" }, "opener": { "version": "1.5.1", @@ -17294,6 +17968,17 @@ "wordwrap": "~1.0.0" } }, + "ora": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/ora/-/ora-0.2.3.tgz", + "integrity": "sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q=", + "requires": { + "chalk": "^1.1.1", + "cli-cursor": "^1.0.2", + "cli-spinners": "^0.1.2", + "object-assign": "^4.0.1" + } + }, "original": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", @@ -17309,6 +17994,11 @@ "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", "dev": true }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, "os-locale": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", @@ -17349,8 +18039,7 @@ "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "output-file-sync": { "version": "2.0.1", @@ -17381,8 +18070,7 @@ "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, "p-is-promise": { "version": "1.1.0", @@ -17411,8 +18099,7 @@ "p-map": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", - "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==", - "dev": true + "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==" }, "p-reduce": { "version": "1.0.0", @@ -17572,14 +18259,12 @@ "path-is-inside": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", - "dev": true + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=" }, "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { "version": "1.0.6", @@ -17631,6 +18316,11 @@ "sha.js": "^2.4.8" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -17644,8 +18334,7 @@ "pify": { "version": "2.3.0", "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" }, "pinkie": { "version": "2.0.4", @@ -20084,8 +20773,7 @@ "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", - "dev": true + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" }, "querystring-es3": { "version": "0.2.1", @@ -20128,6 +20816,11 @@ "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", "dev": true }, + "ramda": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.24.1.tgz", + "integrity": "sha1-w7d1UZfzW43DUCIoJixMkd22uFc=" + }, "randexp": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", @@ -21438,6 +22131,14 @@ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "requires": { + "is-finite": "^1.0.0" + } + }, "replace-ext": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", @@ -21470,6 +22171,14 @@ "uuid": "^3.3.2" } }, + "request-progress": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-0.3.1.tgz", + "integrity": "sha1-ByHBBdipasayzossia4tXs/Pazo=", + "requires": { + "throttleit": "~0.0.2" + } + }, "request-promise-core": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", @@ -21568,13 +22277,12 @@ "dev": true }, "restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", "requires": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" } }, "ret": { @@ -21662,12 +22370,18 @@ "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" }, "rxjs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz", - "integrity": "sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg==", - "dev": true, + "version": "5.5.12", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.12.tgz", + "integrity": "sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw==", "requires": { - "tslib": "^1.9.0" + "symbol-observable": "1.0.1" + }, + "dependencies": { + "symbol-observable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=" + } } }, "safe-buffer": { @@ -21961,7 +22675,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, "requires": { "shebang-regex": "^1.0.0" } @@ -21969,8 +22682,7 @@ "shebang-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" }, "shellwords": { "version": "0.1.1", @@ -21989,8 +22701,7 @@ "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", - "dev": true + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, "simple-swizzle": { "version": "0.2.2", @@ -22048,32 +22759,9 @@ "dev": true }, "slice-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "astral-regex": "^1.0.0", - "is-fullwidth-code-point": "^2.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - } - } + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=" }, "snapdragon": { "version": "0.8.2", @@ -22665,6 +23353,11 @@ "stream-to": "~0.2.0" } }, + "stream-to-observable": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stream-to-observable/-/stream-to-observable-0.1.0.tgz", + "integrity": "sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4=" + }, "strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", @@ -22706,7 +23399,6 @@ "version": "1.0.2", "resolved": "http://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dev": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -22752,7 +23444,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, "requires": { "ansi-regex": "^2.0.0" } @@ -22766,8 +23457,7 @@ "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", - "dev": true + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "strip-json-comments": { "version": "3.0.1", @@ -22889,8 +23579,7 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" }, "svgo": { "version": "1.2.1", @@ -23020,6 +23709,15 @@ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", "dev": true }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -23032,6 +23730,17 @@ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -23250,6 +23959,11 @@ "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", "dev": true }, + "throttleit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-0.0.2.tgz", + "integrity": "sha1-z+34jmDADdlpe2H90qg0OptoDq8=" + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -23301,12 +24015,11 @@ "integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==" }, "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.31.tgz", + "integrity": "sha1-jzirlDjhcxXl29izZX6L+yd65Kc=", "requires": { - "os-tmpdir": "~1.0.2" + "os-tmpdir": "~1.0.1" } }, "tmpl": { @@ -24209,6 +24922,11 @@ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz", "integrity": "sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q==" }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -24290,7 +25008,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", - "dev": true, "requires": { "punycode": "1.3.2", "querystring": "0.2.0" @@ -24299,8 +25016,7 @@ "punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", - "dev": true + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" } } }, @@ -27156,7 +27872,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -27411,6 +28126,15 @@ "dev": true } } + }, + "yauzl": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.8.0.tgz", + "integrity": "sha1-eUUK/yKyqcWkHvVOAtuQfM+/nuI=", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.0.1" + } } } } diff --git a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx index 20fb5deb7c..21e0ba331b 100644 --- a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx @@ -39,7 +39,7 @@ describe('TabbedSqlEditors', () => { 'newEditorId', ]; - const tables = [Object.assign({}, table[0], { + const tables = [Object.assign({}, table, { dataPreviewQueryId: 'B1-VQU1zW', queryEditorId: 'newEditorId', })]; @@ -58,6 +58,7 @@ describe('TabbedSqlEditors', () => { 'B1-VQU1zW': { id: 'B1-VQU1zW', sqlEditorId: 'newEditorId', + tableName: 'ab_user', }, }; const mockedProps = { @@ -133,7 +134,7 @@ describe('TabbedSqlEditors', () => { }); it('should update queriesArray and dataPreviewQueries', () => { expect(wrapper.state().queriesArray.slice(-1)[0]).toBe(queries['B1-VQU1zW']); - expect(wrapper.state().dataPreviewQueries.slice(-1)[0]).toBe(queries['B1-VQU1zW']); + expect(wrapper.state().dataPreviewQueries.slice(-1)[0]).toEqual(queries['B1-VQU1zW']); }); }); it('should rename Tab', () => { @@ -171,16 +172,21 @@ describe('TabbedSqlEditors', () => { .toBe(queryEditors[0]); }); it('should handle select', () => { + const mockEvent = { + target: { + getAttribute: () => null, + }, + }; wrapper = getWrapper(); sinon.spy(wrapper.instance(), 'newQueryEditor'); - sinon.stub(wrapper.instance().props.actions, 'setActiveQueryEditor'); + sinon.stub(wrapper.instance().props.actions, 'switchQueryEditor'); - wrapper.instance().handleSelect('add_tab'); + wrapper.instance().handleSelect('add_tab', mockEvent); expect(wrapper.instance().newQueryEditor.callCount).toBe(1); - wrapper.instance().handleSelect('123'); - expect(wrapper.instance().props.actions.setActiveQueryEditor.getCall(0).args[0].id) - .toContain(123); + // cannot switch to current tab, switchQueryEditor never gets called + wrapper.instance().handleSelect('dfsadfs', mockEvent); + expect(wrapper.instance().props.actions.switchQueryEditor.callCount).toEqual(0); wrapper.instance().newQueryEditor.restore(); }); it('should render', () => { diff --git a/superset/assets/spec/javascripts/sqllab/actions/sqlLab_spec.js b/superset/assets/spec/javascripts/sqllab/actions/sqlLab_spec.js index 32b0900b0a..cb8c3b491c 100644 --- a/superset/assets/spec/javascripts/sqllab/actions/sqlLab_spec.js +++ b/superset/assets/spec/javascripts/sqllab/actions/sqlLab_spec.js @@ -19,12 +19,29 @@ /* eslint no-unused-expressions: 0 */ import sinon from 'sinon'; import fetchMock from 'fetch-mock'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import shortid from 'shortid'; +import * as featureFlags from 'src/featureFlags'; import * as actions from '../../../../src/SqlLab/actions/sqlLab'; -import { query } from '../fixtures'; +import { defaultQueryEditor, query } from '../fixtures'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); describe('async actions', () => { const mockBigNumber = '9223372036854775807'; + const queryEditor = { + id: 'abcd', + autorun: false, + dbId: null, + latestQueryId: null, + selectedText: null, + sql: 'SELECT *\nFROM\nWHERE', + title: 'Untitled Query', + schemaOptions: [{ value: 'main', label: 'main', title: 'main' }], + }; let dispatch; @@ -34,23 +51,31 @@ describe('async actions', () => { afterEach(fetchMock.resetHistory); + const fetchQueryEndpoint = 'glob:*/superset/results/*'; + fetchMock.get( + fetchQueryEndpoint, + JSON.stringify({ data: mockBigNumber, query: { sqlEditorId: 'dfsadfs' } }), + ); + + const runQueryEndpoint = 'glob:*/superset/sql_json/*'; + fetchMock.post(runQueryEndpoint, '{ "data": ' + mockBigNumber + ' }'); + describe('saveQuery', () => { const saveQueryEndpoint = 'glob:*/savedqueryviewapi/api/create'; fetchMock.post(saveQueryEndpoint, 'ok'); it('posts to the correct url', () => { expect.assertions(1); - const thunk = actions.saveQuery(query); - return thunk((/* mockDispatch */) => ({})).then(() => { + const store = mockStore({}); + return store.dispatch(actions.saveQuery(query)).then(() => { expect(fetchMock.calls(saveQueryEndpoint)).toHaveLength(1); }); }); it('posts the correct query object', () => { - const thunk = actions.saveQuery(query); - - return thunk((/* mockDispatch */) => ({})).then(() => { + const store = mockStore({}); + return store.dispatch(actions.saveQuery(query)).then(() => { const call = fetchMock.calls(saveQueryEndpoint)[0]; const formData = call[1].body; Object.keys(query).forEach((key) => { @@ -61,12 +86,9 @@ describe('async actions', () => { }); describe('fetchQueryResults', () => { - const fetchQueryEndpoint = 'glob:*/superset/results/*'; - fetchMock.get(fetchQueryEndpoint, '{ "data": ' + mockBigNumber + ' }'); - const makeRequest = () => { - const actionThunk = actions.fetchQueryResults(query); - return actionThunk(dispatch); + const request = actions.fetchQueryResults(query); + return request(dispatch); }; it('makes the fetch request', () => { @@ -92,31 +114,40 @@ describe('async actions', () => { expect(dispatch.getCall(1).lastArg.results.data.toString()).toBe(mockBigNumber); })); - it('calls querySuccess on fetch success', () => - makeRequest().then(() => { - expect(dispatch.callCount).toBe(2); - expect(dispatch.getCall(1).args[0].type).toBe(actions.QUERY_SUCCESS); - })); + it('calls querySuccess on fetch success', () => { + expect.assertions(1); + + const store = mockStore({}); + const expectedActionTypes = [ + actions.REQUEST_QUERY_RESULTS, + actions.QUERY_SUCCESS, + ]; + return store.dispatch(actions.fetchQueryResults(query)).then(() => { + expect(store.getActions().map(a => a.type)).toEqual(expectedActionTypes); + }); + }); it('calls queryFailed on fetch error', () => { - expect.assertions(2); + expect.assertions(1); + fetchMock.get( fetchQueryEndpoint, { throws: { error: 'error text' } }, { overwriteRoutes: true }, ); - return makeRequest().then(() => { - expect(dispatch.callCount).toBe(2); - expect(dispatch.getCall(1).args[0].type).toBe(actions.QUERY_FAILED); + const store = mockStore({}); + const expectedActionTypes = [ + actions.REQUEST_QUERY_RESULTS, + actions.QUERY_FAILED, + ]; + return store.dispatch(actions.fetchQueryResults(query)).then(() => { + expect(store.getActions().map(a => a.type)).toEqual(expectedActionTypes); }); }); }); describe('runQuery', () => { - const runQueryEndpoint = 'glob:*/superset/sql_json/'; - fetchMock.post(runQueryEndpoint, '{ "data": ' + mockBigNumber + ' }'); - const makeRequest = () => { const request = actions.runQuery(query); return request(dispatch); @@ -146,17 +177,20 @@ describe('async actions', () => { })); it('calls querySuccess on fetch success', () => { - expect.assertions(3); + expect.assertions(1); - return makeRequest().then(() => { - expect(dispatch.callCount).toBe(2); - expect(dispatch.getCall(0).args[0].type).toBe(actions.START_QUERY); - expect(dispatch.getCall(1).args[0].type).toBe(actions.QUERY_SUCCESS); + const store = mockStore({}); + const expectedActionTypes = [ + actions.START_QUERY, + actions.QUERY_SUCCESS, + ]; + return store.dispatch(actions.runQuery(query)).then(() => { + expect(store.getActions().map(a => a.type)).toEqual(expectedActionTypes); }); }); it('calls queryFailed on fetch error', () => { - expect.assertions(2); + expect.assertions(1); fetchMock.post( runQueryEndpoint, @@ -164,9 +198,13 @@ describe('async actions', () => { { overwriteRoutes: true }, ); - return makeRequest().then(() => { - expect(dispatch.callCount).toBe(2); - expect(dispatch.getCall(1).args[0].type).toBe(actions.QUERY_FAILED); + const store = mockStore({}); + const expectedActionTypes = [ + actions.START_QUERY, + actions.QUERY_FAILED, + ]; + return store.dispatch(actions.runQuery(query)).then(() => { + expect(store.getActions().map(a => a.type)).toEqual(expectedActionTypes); }); }); }); @@ -206,4 +244,516 @@ describe('async actions', () => { }); }); }); + + describe('cloneQueryToNewTab', () => { + let stub; + beforeEach(() => { + stub = sinon.stub(shortid, 'generate').returns('abcd'); + }); + afterEach(() => { + stub.restore(); + }); + + it('creates new query editor', () => { + expect.assertions(1); + + const id = 'id'; + const state = { + sqlLab: { + tabHistory: [id], + queryEditors: [{ id, title: 'Dummy query editor' }], + }, + }; + const store = mockStore(state); + const expectedActions = [{ + type: actions.ADD_QUERY_EDITOR, + queryEditor: { + title: 'Copy of Dummy query editor', + dbId: 1, + schema: null, + autorun: true, + sql: 'SELECT * FROM something', + queryLimit: undefined, + maxRow: undefined, + id: 'abcd', + }, + }]; + return store.dispatch(actions.cloneQueryToNewTab(query)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + }); + + describe('addQueryEditor', () => { + let stub; + beforeEach(() => { + stub = sinon.stub(shortid, 'generate').returns('abcd'); + }); + afterEach(() => { + stub.restore(); + }); + + it('creates new query editor', () => { + expect.assertions(1); + + const store = mockStore({}); + const expectedActions = [{ + type: actions.ADD_QUERY_EDITOR, + queryEditor, + }]; + return store.dispatch(actions.addQueryEditor(defaultQueryEditor)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + }); + + describe('backend sync', () => { + const updateTabStateEndpoint = 'glob:*/tabstateview/*'; + fetchMock.put(updateTabStateEndpoint, {}); + fetchMock.delete(updateTabStateEndpoint, {}); + fetchMock.post(updateTabStateEndpoint, JSON.stringify({ id: 1 })); + + const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*'; + fetchMock.put(updateTableSchemaEndpoint, {}); + fetchMock.delete(updateTableSchemaEndpoint, {}); + fetchMock.post(updateTableSchemaEndpoint, JSON.stringify({ id: 1 })); + + const getTableMetadataEndpoint = 'glob:*/superset/table/*'; + fetchMock.get(getTableMetadataEndpoint, {}); + const getExtraTableMetadataEndpoint = 'glob:*/superset/extra_table_metadata/*'; + fetchMock.get(getExtraTableMetadataEndpoint, {}); + + let isFeatureEnabledMock; + + beforeAll(() => { + isFeatureEnabledMock = jest.spyOn(featureFlags, 'isFeatureEnabled') + .mockImplementation(feature => feature === 'SQLLAB_BACKEND_PERSISTENCE'); + }); + + afterAll(() => { + isFeatureEnabledMock.mockRestore(); + }); + + afterEach(fetchMock.resetHistory); + + describe('querySuccess', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const store = mockStore({}); + const results = { query: { sqlEditorId: 'abcd' } }; + const expectedActions = [ + { + type: actions.QUERY_SUCCESS, + query, + results, + }, + ]; + return store.dispatch(actions.querySuccess(query, results)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('fetchQueryResults', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const results = { + data: mockBigNumber, + query: { sqlEditorId: 'abcd' }, + query_id: 'efgh', + }; + fetchMock.get( + fetchQueryEndpoint, + JSON.stringify(results), + { overwriteRoutes: true }, + ); + const store = mockStore({}); + const expectedActions = [ + { + type: actions.REQUEST_QUERY_RESULTS, + query, + }, + // missing below + { + type: actions.QUERY_SUCCESS, + query, + results, + }, + ]; + return store.dispatch(actions.fetchQueryResults(query)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('addQueryEditor', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const store = mockStore({}); + const expectedActions = [ + { + type: actions.ADD_QUERY_EDITOR, + queryEditor: { ...queryEditor, id: '1' }, + }, + ]; + return store.dispatch(actions.addQueryEditor(queryEditor)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('setActiveQueryEditor', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const store = mockStore({}); + const expectedActions = [ + { + type: actions.SET_ACTIVE_QUERY_EDITOR, + queryEditor, + }, + ]; + return store.dispatch(actions.setActiveQueryEditor(queryEditor)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('removeQueryEditor', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const store = mockStore({}); + const expectedActions = [ + { + type: actions.REMOVE_QUERY_EDITOR, + queryEditor, + }, + ]; + return store.dispatch(actions.removeQueryEditor(queryEditor)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetDb', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const dbId = 42; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SETDB, + queryEditor, + dbId, + }, + ]; + return store.dispatch(actions.queryEditorSetDb(queryEditor, dbId)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetSchema', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const schema = 'schema'; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_SCHEMA, + queryEditor, + schema, + }, + ]; + return store.dispatch(actions.queryEditorSetSchema(queryEditor, schema)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetAutorun', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const autorun = true; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_AUTORUN, + queryEditor, + autorun, + }, + ]; + return store.dispatch(actions.queryEditorSetAutorun(queryEditor, autorun)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetTitle', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const title = 'title'; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_TITLE, + queryEditor, + title, + }, + ]; + return store.dispatch(actions.queryEditorSetTitle(queryEditor, title)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetSql', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const sql = 'SELECT * '; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_SQL, + queryEditor, + sql, + }, + ]; + return store.dispatch(actions.queryEditorSetSql(queryEditor, sql)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetQueryLimit', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const queryLimit = 10; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_QUERY_LIMIT, + queryEditor, + queryLimit, + }, + ]; + return store.dispatch( + actions.queryEditorSetQueryLimit(queryEditor, queryLimit)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('queryEditorSetTemplateParams', () => { + it('updates the tab state in the backend', () => { + expect.assertions(2); + + const templateParams = '{"foo": "bar"}'; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.QUERY_EDITOR_SET_TEMPLATE_PARAMS, + queryEditor, + templateParams, + }, + ]; + return store.dispatch( + actions.queryEditorSetTemplateParams(queryEditor, templateParams)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('addTable', () => { + it('updates the table schema state in the backend', () => { + expect.assertions(5); + + const results = { + data: mockBigNumber, + query: { sqlEditorId: 'null' }, + query_id: 'efgh', + }; + fetchMock.post( + runQueryEndpoint, + JSON.stringify(results), + { overwriteRoutes: true }, + ); + + const tableName = 'table'; + const schemaName = 'schema'; + const store = mockStore({}); + const expectedActionTypes = [ + actions.MERGE_TABLE, // addTable + actions.MERGE_TABLE, // getTableMetadata + actions.START_QUERY, // runQuery (data preview) + actions.MERGE_TABLE, // getTableExtendedMetadata + actions.QUERY_SUCCESS, // querySuccess + actions.MERGE_TABLE, // addTable + ]; + return store.dispatch( + actions.addTable(query, tableName, schemaName)) + .then(() => { + expect(store.getActions().map(a => a.type)).toEqual(expectedActionTypes); + expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1); + expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(1); + + // tab state is not updated, since the query is a data preview + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + }); + }); + }); + + describe('expandTable', () => { + it('updates the table schema state in the backend', () => { + expect.assertions(2); + + const table = { id: 1 }; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.EXPAND_TABLE, + table, + }, + ]; + return store.dispatch(actions.expandTable(table)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('collapseTable', () => { + it('updates the table schema state in the backend', () => { + expect.assertions(2); + + const table = { id: 1 }; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.COLLAPSE_TABLE, + table, + }, + ]; + return store.dispatch(actions.collapseTable(table)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('removeTable', () => { + it('updates the table schema state in the backend', () => { + expect.assertions(2); + + const table = { id: 1 }; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.REMOVE_TABLE, + table, + }, + ]; + return store.dispatch(actions.removeTable(table)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + }); + }); + }); + + describe('migrateQueryEditorFromLocalStorage', () => { + it('updates the tab state in the backend', () => { + expect.assertions(3); + + const results = { + data: mockBigNumber, + query: { sqlEditorId: 'null' }, + query_id: 'efgh', + }; + fetchMock.post( + runQueryEndpoint, + JSON.stringify(results), + { overwriteRoutes: true }, + ); + + const tables = [ + { id: 'one', dataPreviewQueryId: 'previewOne' }, + { id: 'two', dataPreviewQueryId: 'previewTwo' }, + ]; + const queries = [ + { ...query, id: 'previewOne' }, + { ...query, id: 'previewTwo' }, + ]; + const store = mockStore({}); + const expectedActions = [ + { + type: actions.MIGRATE_QUERY_EDITOR, + oldQueryEditor: queryEditor, + // new qe has a different id + newQueryEditor: { ...queryEditor, id: '1' }, + }, + { + type: actions.MIGRATE_TAB_HISTORY, + newId: '1', + oldId: 'abcd', + }, + { + type: actions.MIGRATE_TABLE, + oldTable: tables[0], + // new table has a different id and points to new query editor + newTable: { ...tables[0], id: 1, queryEditorId: '1' }, + }, + { + type: actions.MIGRATE_TABLE, + oldTable: tables[1], + // new table has a different id and points to new query editor + newTable: { ...tables[1], id: 1, queryEditorId: '1' }, + }, + { + type: actions.MIGRATE_QUERY, + queryId: 'previewOne', + queryEditorId: '1', + }, + { + type: actions.MIGRATE_QUERY, + queryId: 'previewTwo', + queryEditorId: '1', + }, + ]; + return store.dispatch( + actions.migrateQueryEditorFromLocalStorage(queryEditor, tables, queries)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(3); + + // query editor has 2 tables loaded in the schema viewer + expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(2); + }); + }); + }); + }); }); diff --git a/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js b/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js index abab7376b5..d7aa118957 100644 --- a/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js +++ b/superset/assets/spec/javascripts/sqllab/reducers/sqlLab_spec.js @@ -19,36 +19,11 @@ import sqlLabReducer from '../../../../src/SqlLab/reducers/sqlLab'; import * as actions from '../../../../src/SqlLab/actions/sqlLab'; import { table, initialState as mockState } from '../fixtures'; +import { now } from '../../../../src/modules/dates'; const initialState = mockState.sqlLab; describe('sqlLabReducer', () => { - describe('CLONE_QUERY_TO_NEW_TAB', () => { - const testQuery = { sql: 'SELECT * FROM...', dbId: 1, id: 'flasj233' }; - let newState = { - ...initialState, - queries: { [testQuery.id]: testQuery }, - }; - beforeEach(() => { - newState = sqlLabReducer(newState, actions.cloneQueryToNewTab(testQuery)); - }); - - it('should have at most one more tab', () => { - expect(newState.queryEditors).toHaveLength(2); - }); - - it('should have the same SQL as the cloned query', () => { - expect(newState.queryEditors[1].sql).toBe(testQuery.sql); - }); - - it('should prefix the new tab title with "Copy of"', () => { - expect(newState.queryEditors[1].title).toContain('Copy of'); - }); - - it('should push the cloned tab onto tab history stack', () => { - expect(newState.tabHistory[1]).toBe(newState.queryEditors[1].id); - }); - }); describe('Query editors actions', () => { let newState; let defaultQueryEditor; @@ -56,59 +31,107 @@ describe('sqlLabReducer', () => { beforeEach(() => { newState = { ...initialState }; defaultQueryEditor = newState.queryEditors[0]; - qe = Object.assign({}, defaultQueryEditor); - newState = sqlLabReducer(newState, actions.addQueryEditor(qe)); - qe = newState.queryEditors[newState.queryEditors.length - 1]; + const action = { + type: actions.ADD_QUERY_EDITOR, + queryEditor: { ...initialState.queryEditors[0], id: 'abcd' }, + }; + newState = sqlLabReducer(newState, action); + qe = newState.queryEditors.find(e => e.id === 'abcd'); }); it('should add a query editor', () => { expect(newState.queryEditors).toHaveLength(2); }); it('should remove a query editor', () => { expect(newState.queryEditors).toHaveLength(2); - newState = sqlLabReducer(newState, actions.removeQueryEditor(qe)); + const action = { + type: actions.REMOVE_QUERY_EDITOR, + queryEditor: qe, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors).toHaveLength(1); }); it('should set q query editor active', () => { - newState = sqlLabReducer(newState, actions.addQueryEditor(qe)); - newState = sqlLabReducer(newState, actions.setActiveQueryEditor(defaultQueryEditor)); + const addQueryEditorAction = { + type: actions.ADD_QUERY_EDITOR, + queryEditor: { ...initialState.queryEditors[0], id: 'abcd' }, + }; + newState = sqlLabReducer(newState, addQueryEditorAction); + const setActiveQueryEditorAction = { + type: actions.SET_ACTIVE_QUERY_EDITOR, + queryEditor: defaultQueryEditor, + }; + newState = sqlLabReducer(newState, setActiveQueryEditorAction); expect(newState.tabHistory[newState.tabHistory.length - 1]).toBe(defaultQueryEditor.id); }); it('should not fail while setting DB', () => { const dbId = 9; - newState = sqlLabReducer(newState, actions.queryEditorSetDb(qe, dbId)); + const action = { + type: actions.QUERY_EDITOR_SETDB, + queryEditor: qe, + dbId, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[1].dbId).toBe(dbId); }); it('should not fail while setting schema', () => { const schema = 'foo'; - newState = sqlLabReducer(newState, actions.queryEditorSetSchema(qe, schema)); + const action = { + type: actions.QUERY_EDITOR_SET_SCHEMA, + queryEditor: qe, + schema, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[1].schema).toBe(schema); }); it('should not fail while setting autorun ', () => { - newState = sqlLabReducer(newState, actions.queryEditorSetAutorun(qe, false)); + const action = { + type: actions.QUERY_EDITOR_SET_AUTORUN, + queryEditor: qe, + }; + newState = sqlLabReducer(newState, { ...action, autorun: false }); expect(newState.queryEditors[1].autorun).toBe(false); - newState = sqlLabReducer(newState, actions.queryEditorSetAutorun(qe, true)); + newState = sqlLabReducer(newState, { ...action, autorun: true }); expect(newState.queryEditors[1].autorun).toBe(true); }); it('should not fail while setting title', () => { const title = 'a new title'; - newState = sqlLabReducer(newState, actions.queryEditorSetTitle(qe, title)); + const action = { + type: actions.QUERY_EDITOR_SET_TITLE, + queryEditor: qe, + title, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[1].title).toBe(title); }); it('should not fail while setting Sql', () => { const sql = 'SELECT nothing from dev_null'; - newState = sqlLabReducer(newState, actions.queryEditorSetSql(qe, sql)); + const action = { + type: actions.QUERY_EDITOR_SET_SQL, + queryEditor: qe, + sql, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[1].sql).toBe(sql); }); it('should not fail while setting queryLimit', () => { const queryLimit = 101; - newState = sqlLabReducer(newState, actions.queryEditorSetQueryLimit(qe, queryLimit)); + const action = { + type: actions.QUERY_EDITOR_SET_QUERY_LIMIT, + queryEditor: qe, + queryLimit, + }; + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[1].queryLimit).toEqual(queryLimit); }); it('should set selectedText', () => { const selectedText = 'TEST'; + const action = { + type: actions.QUERY_EDITOR_SET_SELECTED_TEXT, + queryEditor: newState.queryEditors[0], + sql: selectedText, + }; expect(newState.queryEditors[0].selectedText).toBeNull(); - newState = sqlLabReducer( - newState, actions.queryEditorSetSelectedText(newState.queryEditors[0], 'TEST')); + newState = sqlLabReducer(newState, action); expect(newState.queryEditors[0].selectedText).toBe(selectedText); }); }); @@ -117,7 +140,11 @@ describe('sqlLabReducer', () => { let newTable; beforeEach(() => { newTable = Object.assign({}, table); - newState = sqlLabReducer(initialState, actions.mergeTable(newTable)); + const action = { + type: actions.MERGE_TABLE, + table: newTable, + }; + newState = sqlLabReducer(initialState, action); newTable = newState.tables[0]; }); it('should add a table', () => { @@ -127,42 +154,91 @@ describe('sqlLabReducer', () => { it('should merge the table attributes', () => { // Merging the extra attribute newTable.extra = true; - newState = sqlLabReducer(newState, actions.mergeTable(newTable)); + const action = { + type: actions.MERGE_TABLE, + table: newTable, + }; + newState = sqlLabReducer(newState, action); expect(newState.tables).toHaveLength(1); expect(newState.tables[0].extra).toBe(true); }); it('should expand and collapse a table', () => { - newState = sqlLabReducer(newState, actions.collapseTable(newTable)); + const collapseTableAction = { + type: actions.COLLAPSE_TABLE, + table: newTable, + }; + newState = sqlLabReducer(newState, collapseTableAction); expect(newState.tables[0].expanded).toBe(false); - newState = sqlLabReducer(newState, actions.expandTable(newTable)); + const expandTableAction = { + type: actions.EXPAND_TABLE, + table: newTable, + }; + newState = sqlLabReducer(newState, expandTableAction); expect(newState.tables[0].expanded).toBe(true); }); it('should remove a table', () => { - newState = sqlLabReducer(newState, actions.removeTable(newTable)); + const action = { + type: actions.REMOVE_TABLE, + table: newTable, + }; + newState = sqlLabReducer(newState, action); expect(newState.tables).toHaveLength(0); }); }); describe('Run Query', () => { let newState; let query; - let newQuery; beforeEach(() => { newState = { ...initialState }; - newQuery = { ...query }; + query = { + id: 'abcd', + progress: 0, + startDttm: now(), + state: 'running', + cached: false, + sqlEditorId: 'dfsadfs', + }; }); it('should start a query', () => { - newState = sqlLabReducer(newState, actions.startQuery(newQuery)); + const action = { + type: actions.START_QUERY, + query: { + id: 'abcd', + progress: 0, + startDttm: now(), + state: 'running', + cached: false, + sqlEditorId: 'dfsadfs', + }, + }; + newState = sqlLabReducer(newState, action); expect(Object.keys(newState.queries)).toHaveLength(1); }); it('should stop the query', () => { - newState = sqlLabReducer(newState, actions.startQuery(newQuery)); - newState = sqlLabReducer(newState, actions.stopQuery(newQuery)); + const startQueryAction = { + type: actions.START_QUERY, + query, + }; + newState = sqlLabReducer(newState, startQueryAction); + const stopQueryAction = { + type: actions.STOP_QUERY, + query, + }; + newState = sqlLabReducer(newState, stopQueryAction); const q = newState.queries[Object.keys(newState.queries)[0]]; expect(q.state).toBe('stopped'); }); it('should remove a query', () => { - newState = sqlLabReducer(newState, actions.startQuery(newQuery)); - newState = sqlLabReducer(newState, actions.removeQuery(newQuery)); + const startQueryAction = { + type: actions.START_QUERY, + query, + }; + newState = sqlLabReducer(newState, startQueryAction); + const removeQueryAction = { + type: actions.REMOVE_QUERY, + query, + }; + newState = sqlLabReducer(newState, removeQueryAction); expect(Object.keys(newState.queries)).toHaveLength(0); }); it('should refresh queries when polling returns empty', () => { diff --git a/superset/assets/src/SqlLab/App.jsx b/superset/assets/src/SqlLab/App.jsx index 54c4711e4b..59e40b6b26 100644 --- a/superset/assets/src/SqlLab/App.jsx +++ b/superset/assets/src/SqlLab/App.jsx @@ -22,7 +22,7 @@ import { Provider } from 'react-redux'; import thunkMiddleware from 'redux-thunk'; import { hot } from 'react-hot-loader'; -import { initFeatureFlags } from 'src/featureFlags'; +import { initFeatureFlags, isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import getInitialState from './reducers/getInitialState'; import rootReducer from './reducers/index'; import { initEnhancer } from '../reduxUtils'; @@ -79,7 +79,10 @@ const store = createStore( initialState, compose( applyMiddleware(thunkMiddleware), - initEnhancer(true, sqlLabPersistStateConfig), + initEnhancer( + !isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE), + sqlLabPersistStateConfig, + ), ), ); diff --git a/superset/assets/src/SqlLab/actions/sqlLab.js b/superset/assets/src/SqlLab/actions/sqlLab.js index a540cec28a..5381ff70a1 100644 --- a/superset/assets/src/SqlLab/actions/sqlLab.js +++ b/superset/assets/src/SqlLab/actions/sqlLab.js @@ -22,12 +22,14 @@ import { t } from '@superset-ui/translation'; import { SupersetClient } from '@superset-ui/connection'; import invert from 'lodash/invert'; import mapKeys from 'lodash/mapKeys'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { now } from '../../modules/dates'; import { - addSuccessToast as addSuccessToastAction, addDangerToast as addDangerToastAction, addInfoToast as addInfoToastAction, + addSuccessToast as addSuccessToastAction, + addWarningToast as addWarningToastAction, } from '../../messageToasts/actions/index'; import getClientErrorObject from '../../utils/getClientErrorObject'; import COMMON_ERR_MESSAGES from '../../utils/errorMessages'; @@ -55,9 +57,15 @@ export const QUERY_EDITOR_SET_QUERY_LIMIT = 'QUERY_EDITOR_SET_QUERY_LIMIT'; export const QUERY_EDITOR_SET_TEMPLATE_PARAMS = 'QUERY_EDITOR_SET_TEMPLATE_PARAMS'; export const QUERY_EDITOR_SET_SELECTED_TEXT = 'QUERY_EDITOR_SET_SELECTED_TEXT'; export const QUERY_EDITOR_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT'; +export const MIGRATE_QUERY_EDITOR = 'MIGRATE_QUERY_EDITOR'; +export const MIGRATE_TAB_HISTORY = 'MIGRATE_TAB_HISTORY'; +export const MIGRATE_TABLE = 'MIGRATE_TABLE'; +export const MIGRATE_QUERY = 'MIGRATE_QUERY'; export const SET_DATABASES = 'SET_DATABASES'; export const SET_ACTIVE_QUERY_EDITOR = 'SET_ACTIVE_QUERY_EDITOR'; +export const LOAD_QUERY_EDITOR = 'LOAD_QUERY_EDITOR'; +export const SET_TABLES = 'SET_TABLES'; export const SET_ACTIVE_SOUTHPANE_TAB = 'SET_ACTIVE_SOUTHPANE_TAB'; export const REFRESH_QUERIES = 'REFRESH_QUERIES'; export const SET_USER_OFFLINE = 'SET_USER_OFFLINE'; @@ -85,6 +93,7 @@ export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED'; export const addInfoToast = addInfoToastAction; export const addSuccessToast = addSuccessToastAction; export const addDangerToast = addDangerToastAction; +export const addWarningToast = addWarningToastAction; // a map of SavedQuery field names to the different names used client-side, // because for now making the names consistent is too complicated @@ -201,11 +210,39 @@ export function startQuery(query) { } export function querySuccess(query, results) { - return { type: QUERY_SUCCESS, query, results }; + return function (dispatch) { + const sync = (!query.isDataPreview && isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${results.query.sqlEditorId}`), + postPayload: { latest_query_id: query.id }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_SUCCESS, query, results })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while storing the latest query id in the backend. ' + + 'Please contact your administrator if this problem persists.')))); + }; } export function queryFailed(query, msg, link) { - return { type: QUERY_FAILED, query, msg, link }; + return function (dispatch) { + const sync = (!query.isDataPreview && isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${query.sqlEditorId}`), + postPayload: { latest_query_id: query.id }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_FAILED, query, msg, link })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while storing the latest query id in the backend. ' + + 'Please contact your administrator if this problem persists.')))); + }; } export function stopQuery(query) { @@ -234,7 +271,7 @@ export function fetchQueryResults(query, displayLimit) { }) .then(({ text = '{}' }) => { const bigIntJson = JSONbig.parse(text); - dispatch(querySuccess(query, bigIntJson)); + return dispatch(querySuccess(query, bigIntJson)); }) .catch(response => getClientErrorObject(response).then((error) => { @@ -309,9 +346,7 @@ export function validateQuery(query) { postPayload, stringify: false, }) - .then(({ json }) => { - dispatch(queryValidationReturned(query, json)); - }) + .then(({ json }) => dispatch(queryValidationReturned(query, json))) .catch(response => getClientErrorObject(response).then((error) => { let message = error.error || error.statusText || t('Unknown error'); @@ -341,20 +376,189 @@ export function setDatabases(databases) { return { type: SET_DATABASES, databases }; } -export function addQueryEditor(queryEditor) { - const newQueryEditor = { - ...queryEditor, - id: shortid.generate(), +function migrateTable(table, queryEditorId, dispatch) { + return SupersetClient.post({ + endpoint: encodeURI('/tableschemaview/'), + postPayload: { table: { ...table, queryEditorId } }, + }) + .then(({ json }) => { + const newTable = { + ...table, + id: json.id, + queryEditorId, + }; + return dispatch({ type: MIGRATE_TABLE, oldTable: table, newTable }); + }) + .catch(() => dispatch(addWarningToast(t( + 'Unable to migrate table schema state to backend. Superset will retry ' + + 'later. Please contact your administrator if this problem persists.')))); +} + +function migrateQuery(queryId, queryEditorId, dispatch) { + return SupersetClient.post({ + endpoint: encodeURI(`/tabstateview/${queryEditorId}/migrate_query`), + postPayload: { queryId }, + }) + .then(() => dispatch({ type: MIGRATE_QUERY, queryId, queryEditorId })) + .catch(() => dispatch(addWarningToast(t( + 'Unable to migrate query state to backend. Superset will retry later. ' + + 'Please contact your administrator if this problem persists.')))); +} + +export function migrateQueryEditorFromLocalStorage(queryEditor, tables, queries) { + return function (dispatch) { + return SupersetClient.post({ endpoint: '/tabstateview/', postPayload: { queryEditor } }) + .then(({ json }) => { + const newQueryEditor = { + ...queryEditor, + id: json.id.toString(), + }; + dispatch({ type: MIGRATE_QUERY_EDITOR, oldQueryEditor: queryEditor, newQueryEditor }); + dispatch({ type: MIGRATE_TAB_HISTORY, oldId: queryEditor.id, newId: newQueryEditor.id }); + return Promise.all([ + ...tables.map(table => migrateTable(table, newQueryEditor.id, dispatch)), + ...queries.map(query => migrateQuery(query.id, newQueryEditor.id, dispatch)), + ]); + }) + .catch(() => dispatch(addWarningToast(t( + 'Unable to migrate query editor state to backend. Superset will retry ' + + 'later. Please contact your administrator if this problem persists.')))); + }; +} + +export function addQueryEditor(queryEditor) { + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.post({ endpoint: '/tabstateview/', postPayload: { queryEditor } }) + : Promise.resolve({ json: { id: shortid.generate() } }); + + return sync + .then(({ json }) => { + const newQueryEditor = { + ...queryEditor, + id: json.id.toString(), + }; + return dispatch({ type: ADD_QUERY_EDITOR, queryEditor: newQueryEditor }); + }) + .catch(() => dispatch(addDangerToast(t( + 'Unable to add a new tab to the backend. Please contact your administrator.')))); }; - return { type: ADD_QUERY_EDITOR, queryEditor: newQueryEditor }; } export function cloneQueryToNewTab(query) { - return { type: CLONE_QUERY_TO_NEW_TAB, query }; + return function (dispatch, getState) { + const state = getState(); + const { queryEditors, tabHistory } = state.sqlLab; + const sourceQueryEditor = queryEditors.find(qe => qe.id === tabHistory[tabHistory.length - 1]); + const queryEditor = { + title: t('Copy of %s', sourceQueryEditor.title), + dbId: query.dbId ? query.dbId : null, + schema: query.schema ? query.schema : null, + autorun: true, + sql: query.sql, + queryLimit: sourceQueryEditor.queryLimit, + maxRow: sourceQueryEditor.maxRow, + }; + return dispatch(addQueryEditor(queryEditor)); + }; } export function setActiveQueryEditor(queryEditor) { - return { type: SET_ACTIVE_QUERY_EDITOR, queryEditor }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.post({ endpoint: encodeURI(`/tabstateview/${queryEditor.id}/activate`) }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: SET_ACTIVE_QUERY_EDITOR, queryEditor })) + .catch((response) => { + if (response.status !== 404) { + return dispatch(addDangerToast(t( + 'An error occurred while setting the active tab. Please contact ' + + 'your administrator.'))); + } + return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); + }); + }; +} + +export function loadQueryEditor(queryEditor) { + return { type: LOAD_QUERY_EDITOR, queryEditor }; +} + +export function setTables(tableSchemas) { + const tables = tableSchemas.map((tableSchema) => { + const { + columns, + selectStar, + primaryKey, + foreignKeys, + indexes, + dataPreviewQueryId, + } = tableSchema.description; + return { + dbId: tableSchema.database_id, + queryEditorId: tableSchema.tab_state_id.toString(), + schema: tableSchema.schema, + name: tableSchema.table, + expanded: tableSchema.expanded, + id: tableSchema.id, + dataPreviewQueryId, + columns, + selectStar, + primaryKey, + foreignKeys, + indexes, + isMetadataLoading: false, + isExtraMetadataLoading: false, + }; + }); + return { type: SET_TABLES, tables }; +} + +export function switchQueryEditor(queryEditor, displayLimit) { + return function (dispatch) { + if (isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) && !queryEditor.loaded) { + SupersetClient.get({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + }) + .then(({ json }) => { + const loadedQueryEditor = { + id: json.id.toString(), + loaded: true, + title: json.label, + sql: json.sql, + selectedText: null, + latestQueryId: json.latest_query ? json.latest_query.id : null, + autorun: json.autorun, + dbId: json.database_id, + templateParams: json.template_params, + schema: json.schema, + queryLimit: json.query_limit, + validationResult: { + id: null, + errors: [], + completed: false, + }, + }; + dispatch(loadQueryEditor(loadedQueryEditor)); + dispatch(setTables(json.table_schemas || [])); + dispatch(setActiveQueryEditor(loadedQueryEditor)); + if (json.latest_query && json.latest_query.resultsKey) { + dispatch(fetchQueryResults(json.latest_query, displayLimit)); + } + }) + .catch((response) => { + if (response.status !== 404) { + return dispatch(addDangerToast(t( + 'An error occurred while fetching tab state'))); + } + return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); + }); + } else { + dispatch(setActiveQueryEditor(queryEditor)); + } + }; } export function setActiveSouthPaneTab(tabId) { @@ -362,19 +566,75 @@ export function setActiveSouthPaneTab(tabId) { } export function removeQueryEditor(queryEditor) { - return { type: REMOVE_QUERY_EDITOR, queryEditor }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.delete({ endpoint: encodeURI(`/tabstateview/${queryEditor.id}`) }) + : Promise.resolve(); + + return sync + .then(() => + dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }), + ) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while removing tab. Please contact your administrator.'))), + ); + }; } export function removeQuery(query) { - return { type: REMOVE_QUERY, query }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.delete({ + endpoint: encodeURI(`/tabstateview/${query.sqlEditorId}/query/${query.id}`), + }) + : Promise.resolve(); + + return sync + .then(() => + dispatch({ type: REMOVE_QUERY, query }), + ) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while removing query. Please contact your administrator.'))), + ); + }; } export function queryEditorSetDb(queryEditor, dbId) { - return { type: QUERY_EDITOR_SETDB, queryEditor, dbId }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { database_id: dbId }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SETDB, queryEditor, dbId })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab database ID. Please contact your administrator.'))), + ); + }; } export function queryEditorSetSchema(queryEditor, schema) { - return { type: QUERY_EDITOR_SET_SCHEMA, queryEditor, schema }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { schema }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_SCHEMA, queryEditor, schema })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab schema. Please contact your administrator.'))), + ); + }; } export function queryEditorSetSchemaOptions(queryEditor, options) { @@ -386,23 +646,96 @@ export function queryEditorSetTableOptions(queryEditor, options) { } export function queryEditorSetAutorun(queryEditor, autorun) { - return { type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { autorun }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab autorun. Please contact your administrator.'))), + ); + }; } export function queryEditorSetTitle(queryEditor, title) { - return { type: QUERY_EDITOR_SET_TITLE, queryEditor, title }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { label: title }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_TITLE, queryEditor, title })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab title. Please contact your administrator.'))), + ); + }; } export function queryEditorSetSql(queryEditor, sql) { - return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { sql }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_SQL, queryEditor, sql })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while storing your query in the backend. To ' + + 'avoid losing your changes, please save your query using the ' + + '"Save Query" button.'))), + ); + }; } export function queryEditorSetQueryLimit(queryEditor, queryLimit) { - return { type: QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor, queryLimit }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { query_limit: queryLimit }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor, queryLimit })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab title. Please contact your administrator.'))), + ); + }; } export function queryEditorSetTemplateParams(queryEditor, templateParams) { - return { type: QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.put({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + postPayload: { template_params: templateParams }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while setting the tab template parameters. ' + + 'Please contact your administrator.'))), + ); + }; } export function queryEditorSetSelectedText(queryEditor, sql) { @@ -413,6 +746,64 @@ export function mergeTable(table, query) { return { type: MERGE_TABLE, table, query }; } +function getTableMetadata(table, query, dispatch) { + return SupersetClient.get({ endpoint: encodeURI(`/superset/table/${query.dbId}/` + + `${encodeURIComponent(table.name)}/${encodeURIComponent(table.schema)}/`) }) + .then(({ json }) => { + const dataPreviewQuery = { + id: shortid.generate(), + dbId: query.dbId, + sql: json.selectStar, + tableName: table.name, + sqlEditorId: null, + tab: '', + runAsync: false, + ctas: false, + isDataPreview: true, + }; + const newTable = { + ...table, + ...json, + expanded: true, + isMetadataLoading: false, + dataPreviewQueryId: dataPreviewQuery.id, + }; + Promise.all([ + dispatch(mergeTable(newTable, dataPreviewQuery)), // Merge table to tables in state + dispatch(runQuery(dataPreviewQuery)), // Run query to get preview data for table + ]); + return newTable; + }) + .catch(() => + Promise.all([ + dispatch( + mergeTable({ + ...table, + isMetadataLoading: false, + }), + ), + dispatch(addDangerToast(t('An error occurred while fetching table metadata'))), + ]), + ); +} + +function getTableExtendedMetadata(table, query, dispatch) { + return SupersetClient.get({ + endpoint: encodeURI(`/superset/extra_table_metadata/${query.dbId}/` + + `${encodeURIComponent(table.name)}/${encodeURIComponent(table.schema)}/`), + }) + .then(({ json }) => { + dispatch(mergeTable({ ...table, ...json, isExtraMetadataLoading: false })); + return json; + }) + .catch(() => + Promise.all([ + dispatch(mergeTable({ ...table, isExtraMetadataLoading: false })), + dispatch(addDangerToast(t('An error occurred while fetching table metadata'))), + ]), + ); +} + export function addTable(query, tableName, schemaName) { return function (dispatch) { const table = { @@ -430,56 +821,28 @@ export function addTable(query, tableName, schemaName) { }), ); - SupersetClient.get({ endpoint: encodeURI(`/superset/table/${query.dbId}/` + - `${encodeURIComponent(tableName)}/${encodeURIComponent(schemaName)}/`) }) - .then(({ json }) => { - const dataPreviewQuery = { - id: shortid.generate(), - dbId: query.dbId, - sql: json.selectStar, - tableName, - sqlEditorId: null, - tab: '', - runAsync: false, - ctas: false, - }; - const newTable = { - ...table, - ...json, - expanded: true, - isMetadataLoading: false, - }; + return Promise.all([ + getTableMetadata(table, query, dispatch), + getTableExtendedMetadata(table, query, dispatch), + ]) + .then(([newTable, json]) => { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.post({ + endpoint: encodeURI('/tableschemaview/'), + postPayload: { table: { ...newTable, ...json } }, + }) + : Promise.resolve({ json: { id: shortid.generate() } }); - return Promise.all([ - dispatch(mergeTable(newTable, dataPreviewQuery)), // Merge table to tables in state - dispatch(runQuery(dataPreviewQuery)), // Run query to get preview data for table - ]); - }) - .catch(() => - Promise.all([ - dispatch( - mergeTable({ - ...table, - isMetadataLoading: false, - }), - ), - dispatch(addDangerToast(t('An error occurred while fetching table metadata'))), - ]), - ); - - SupersetClient.get({ - endpoint: encodeURI(`/superset/extra_table_metadata/${query.dbId}/` + - `${encodeURIComponent(tableName)}/${encodeURIComponent(schemaName)}/`), - }) - .then(({ json }) => - dispatch(mergeTable({ ...table, ...json, isExtraMetadataLoading: false })), - ) - .catch(() => - Promise.all([ - dispatch(mergeTable({ ...table, isExtraMetadataLoading: false })), - dispatch(addDangerToast(t('An error occurred while fetching table metadata'))), - ]), - ); + return sync + .then(({ json: resultJson }) => + dispatch(mergeTable({ ...table, id: resultJson.id })), + ) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while fetching table metadata. ' + + 'Please contact your administrator.'))), + ); + }); }; } @@ -499,6 +862,7 @@ export function reFetchQueryResults(query) { runAsync: false, ctas: false, queryLimit: query.queryLimit, + isDataPreview: query.isDataPreview, }; dispatch(runQuery(newQuery)); dispatch(changeDataPreviewId(query.id, newQuery)); @@ -506,15 +870,57 @@ export function reFetchQueryResults(query) { } export function expandTable(table) { - return { type: EXPAND_TABLE, table }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.post({ + endpoint: encodeURI(`/tableschemaview/${table.id}/expanded`), + postPayload: { expanded: true }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: EXPAND_TABLE, table })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while expanding the table schema. ' + + 'Please contact your administrator.'))), + ); + }; } export function collapseTable(table) { - return { type: COLLAPSE_TABLE, table }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.post({ + endpoint: encodeURI(`/tableschemaview/${table.id}/expanded`), + postPayload: { expanded: false }, + }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: COLLAPSE_TABLE, table })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while collapsing the table schema. ' + + 'Please contact your administrator.'))), + ); + }; } export function removeTable(table) { - return { type: REMOVE_TABLE, table }; + return function (dispatch) { + const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? SupersetClient.delete({ endpoint: encodeURI(`/tableschemaview/${table.id}`) }) + : Promise.resolve(); + + return sync + .then(() => dispatch({ type: REMOVE_TABLE, table })) + .catch(() => + dispatch(addDangerToast(t( + 'An error occurred while removing the table schema. ' + + 'Please contact your administrator.'))), + ); + }; } export function refreshQueries(alteredQueries) { diff --git a/superset/assets/src/SqlLab/components/App.jsx b/superset/assets/src/SqlLab/components/App.jsx index 0124f8d8c7..418a03f9c1 100644 --- a/superset/assets/src/SqlLab/components/App.jsx +++ b/superset/assets/src/SqlLab/components/App.jsx @@ -126,15 +126,15 @@ class App extends React.PureComponent { App.propTypes = { actions: PropTypes.object, - localStorageUsageInKilobytes: PropTypes.number.isRequired, common: PropTypes.object, + localStorageUsageInKilobytes: PropTypes.number.isRequired, }; function mapStateToProps(state) { - const { localStorageUsageInKilobytes, common } = state; + const { common, localStorageUsageInKilobytes } = state; return { - localStorageUsageInKilobytes, common, + localStorageUsageInKilobytes, }; } diff --git a/superset/assets/src/SqlLab/components/LimitControl.jsx b/superset/assets/src/SqlLab/components/LimitControl.jsx index 7b9734ba33..185d76a6ad 100644 --- a/superset/assets/src/SqlLab/components/LimitControl.jsx +++ b/superset/assets/src/SqlLab/components/LimitControl.jsx @@ -42,7 +42,7 @@ export default class LimitControl extends React.PureComponent { super(props); const { value, defaultQueryLimit } = props; this.state = { - textValue: value.toString() || defaultQueryLimit.toString(), + textValue: (value || defaultQueryLimit).toString(), showOverlay: false, }; this.handleHide = this.handleHide.bind(this); diff --git a/superset/assets/src/SqlLab/components/ResultSet.jsx b/superset/assets/src/SqlLab/components/ResultSet.jsx index b0f5c7e423..7db4a06b91 100644 --- a/superset/assets/src/SqlLab/components/ResultSet.jsx +++ b/superset/assets/src/SqlLab/components/ResultSet.jsx @@ -200,7 +200,7 @@ export default class ResultSet extends React.PureComponent { ); - } else if (query.state === 'success') { + } else if (query.state === 'success' && query.results) { const results = query.results; let data; if (this.props.cache && query.cached) { @@ -229,13 +229,13 @@ export default class ResultSet extends React.PureComponent { return {t('The query returned no data')}; } } - if (query.cached) { + if (query.cached || (query.state === 'success' && !query.results)) { return ( diff --git a/superset/assets/src/SqlLab/components/SouthPane.jsx b/superset/assets/src/SqlLab/components/SouthPane.jsx index e68d05c9a2..92c9934dea 100644 --- a/superset/assets/src/SqlLab/components/SouthPane.jsx +++ b/superset/assets/src/SqlLab/components/SouthPane.jsx @@ -23,6 +23,7 @@ import { Alert, Label, Tab, Tabs } from 'react-bootstrap'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { t } from '@superset-ui/translation'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import * as Actions from '../actions/sqlLab'; import QueryHistory from './QueryHistory'; @@ -88,19 +89,25 @@ export class SouthPane extends React.PureComponent { latestQuery = props.editorQueries.find(q => q.id === this.props.latestQueryId); } let results; - if (latestQuery && - (Date.now() - latestQuery.startDttm) <= LOCALSTORAGE_MAX_QUERY_AGE_MS) { - results = ( - - ); + if (latestQuery) { + if ( + isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) && + (!latestQuery.resultsKey && !latestQuery.results) + ) { + results = {t('No stored results found, you need to re-run your query')}; + } else if ((Date.now() - latestQuery.startDttm) <= LOCALSTORAGE_MAX_QUERY_AGE_MS) { + results = ( + + ); + } } else { results = {t('Run a query to display results here')}; } diff --git a/superset/assets/src/SqlLab/components/SqlEditor.jsx b/superset/assets/src/SqlLab/components/SqlEditor.jsx index d4cc424a45..f6c52c2315 100644 --- a/superset/assets/src/SqlLab/components/SqlEditor.jsx +++ b/superset/assets/src/SqlLab/components/SqlEditor.jsx @@ -57,6 +57,7 @@ import { FeatureFlag, isFeatureEnabled } from '../../featureFlags'; const SQL_EDITOR_PADDING = 10; const INITIAL_NORTH_PERCENT = 30; const INITIAL_SOUTH_PERCENT = 70; +const SET_QUERY_EDITOR_SQL_DEBOUNCE_MS = 2000; const VALIDATION_DEBOUNCE_MS = 600; const WINDOW_RESIZE_THROTTLE_MS = 100; @@ -104,6 +105,10 @@ class SqlEditor extends React.PureComponent { this.stopQuery = this.stopQuery.bind(this); this.onSqlChanged = this.onSqlChanged.bind(this); this.setQueryEditorSql = this.setQueryEditorSql.bind(this); + this.setQueryEditorSqlWithDebounce = debounce( + this.setQueryEditorSql.bind(this), + SET_QUERY_EDITOR_SQL_DEBOUNCE_MS, + ); this.queryPane = this.queryPane.bind(this); this.getAceEditorAndSouthPaneHeights = this.getAceEditorAndSouthPaneHeights.bind(this); this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this); @@ -151,6 +156,7 @@ class SqlEditor extends React.PureComponent { } onSqlChanged(sql) { this.setState({ sql }); + this.setQueryEditorSqlWithDebounce(sql); // Request server-side validation of the query text if (this.canValidateQuery()) { // NB. requestValidation is debounced @@ -274,6 +280,7 @@ class SqlEditor extends React.PureComponent { queryLimit: qe.queryLimit || this.props.defaultQueryLimit, runAsync: this.props.database ? this.props.database.allow_run_async : false, ctas, + updateTabState: !qe.selectedText, }; this.props.actions.runQuery(query); this.props.actions.setActiveSouthPaneTab('Results'); diff --git a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx index 3e37123b36..0ee19ffb61 100644 --- a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx +++ b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx @@ -23,6 +23,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import URI from 'urijs'; import { t } from '@superset-ui/translation'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import * as Actions from '../actions/sqlLab'; import SqlEditor from './SqlEditor'; @@ -70,6 +71,20 @@ class TabbedSqlEditors extends React.PureComponent { this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this); } componentDidMount() { + // migrate query editor and associated tables state to server + if (isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)) { + const localStorageTables = this.props.tables.filter(table => table.inLocalStorage); + const localStorageQueries = Object.values(this.props.queries) + .filter(query => query.inLocalStorage); + this.props.queryEditors.filter(qe => qe.inLocalStorage).forEach((qe) => { + // get all queries associated with the query editor + const queries = localStorageQueries + .filter(query => query.sqlEditorId === qe.id); + const tables = localStorageTables.filter(table => table.queryEditorId === qe.id); + this.props.actions.migrateQueryEditorFromLocalStorage(qe, tables, queries); + }); + } + const query = URI(window.location).search(true); // Popping a new tab based on the querystring if (query.id || query.sql || query.savedQueryId || query.datasourceKey) { @@ -104,6 +119,19 @@ class TabbedSqlEditors extends React.PureComponent { this.props.actions.addQueryEditor(newQueryEditor); } this.popNewTab(); + } else if (this.props.queryEditors.length === 0) { + this.newQueryEditor(); + } else { + const qe = this.activeQueryEditor(); + const latestQuery = this.props.queries[qe.latestQueryId]; + if ( + isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) && + latestQuery && latestQuery.resultsKey + ) { + // when results are not stored in localStorage they need to be + // fetched from the results backend (if configured) + this.props.actions.fetchQueryResults(latestQuery, this.props.displayLimit); + } } } UNSAFE_componentWillReceiveProps(nextProps) { @@ -122,7 +150,7 @@ class TabbedSqlEditors extends React.PureComponent { nextProps.tables.forEach((table) => { const queryId = table.dataPreviewQueryId; if (queryId && nextProps.queries[queryId] && table.queryEditorId === nextActiveQeId) { - dataPreviewQueries.push(nextProps.queries[queryId]); + dataPreviewQueries.push({ ...nextProps.queries[queryId], tableName: table.name }); } }); if (!areArraysShallowEqual(dataPreviewQueries, this.state.dataPreviewQueries)) { @@ -142,29 +170,31 @@ class TabbedSqlEditors extends React.PureComponent { } } activeQueryEditor() { - const qeid = this.props.tabHistory[this.props.tabHistory.length - 1]; - for (let i = 0; i < this.props.queryEditors.length; i++) { - const qe = this.props.queryEditors[i]; - if (qe.id === qeid) { - return qe; - } + if (this.props.tabHistory.length === 0) { + return this.props.queryEditors[0]; } - return null; + const qeid = this.props.tabHistory[this.props.tabHistory.length - 1]; + return this.props.queryEditors.find(qe => qe.id === qeid) || null; } newQueryEditor() { queryCount++; const activeQueryEditor = this.activeQueryEditor(); + const firstDbId = Math.min( + ...Object.values(this.props.databases).map(database => database.id)); + const warning = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) + ? '' + : `${t( + '-- Note: Unless you save your query, these tabs will NOT persist if you clear your cookies or change browsers.', + )}\n\n`; const qe = { title: t('Untitled Query %s', queryCount), dbId: activeQueryEditor && activeQueryEditor.dbId ? activeQueryEditor.dbId - : this.props.defaultDbId, + : (this.props.defaultDbId || firstDbId), schema: activeQueryEditor ? activeQueryEditor.schema : null, autorun: false, - sql: `${t( - '-- Note: Unless you save your query, these tabs will NOT persist if you clear your cookies or change browsers.', - )}\n\nSELECT ...`, + sql: `${warning}SELECT ...`, queryLimit: this.props.defaultQueryLimit, }; this.props.actions.addQueryEditor(qe); @@ -173,7 +203,11 @@ class TabbedSqlEditors extends React.PureComponent { if (key === 'add_tab') { this.newQueryEditor(); } else { - this.props.actions.setActiveQueryEditor({ id: key }); + const qeid = this.props.tabHistory[this.props.tabHistory.length - 1]; + if (key !== qeid) { + const queryEditor = this.props.queryEditors.find(qe => qe.id === key); + this.props.actions.switchQueryEditor(queryEditor, this.props.displayLimit); + } } } removeQueryEditor(qe) { @@ -191,7 +225,7 @@ class TabbedSqlEditors extends React.PureComponent { } render() { const editors = this.props.queryEditors.map((qe, i) => { - const isSelected = qe.id === this.activeQueryEditor().id; + const isSelected = this.activeQueryEditor() && this.activeQueryEditor().id === qe.id; let latestQuery; if (qe.latestQueryId) { diff --git a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx index 1ee3e4f3bc..c945b060b7 100644 --- a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx +++ b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx @@ -70,10 +70,9 @@ export default class TemplateParamsEditor extends React.Component { isValid = false; } this.setState({ parsedJSON, isValid, codeText }); - if (isValid) { - this.props.onChange(codeText); - } else { - this.props.onChange('{}'); + const newValue = isValid ? codeText : '{}'; + if (newValue !== this.props.code) { + this.props.onChange(newValue); } } renderDoc() { diff --git a/superset/assets/src/SqlLab/reducers/getInitialState.js b/superset/assets/src/SqlLab/reducers/getInitialState.js index fb3212f97c..220d598137 100644 --- a/superset/assets/src/SqlLab/reducers/getInitialState.js +++ b/superset/assets/src/SqlLab/reducers/getInitialState.js @@ -16,18 +16,28 @@ * specific language governing permissions and limitations * under the License. */ -import shortid from 'shortid'; import { t } from '@superset-ui/translation'; import getToastsFromPyFlashMessages from '../../messageToasts/utils/getToastsFromPyFlashMessages'; export default function getInitialState({ defaultDbId, ...restBootstrapData }) { + /* + * Before YYYY-MM-DD, the state for SQL Lab was stored exclusively in the + * browser's localStorage. The feature flag `SQLLAB_BACKEND_PERSISTENCE` + * moves the state to the backend instead, migrating it from local storage. + * + * To allow for a transparent migration, the initial state is a combination + * of the backend state (if any) with the browser state (if any). + */ + const queryEditors = []; const defaultQueryEditor = { - id: shortid.generate(), + id: null, + loaded: true, title: t('Untitled Query'), sql: 'SELECT *\nFROM\nWHERE', selectedText: null, latestQueryId: null, autorun: false, + templateParams: null, dbId: defaultDbId, queryLimit: restBootstrapData.common.conf.DEFAULT_SQLLAB_LIMIT, validationResult: { @@ -42,16 +52,114 @@ export default function getInitialState({ defaultDbId, ...restBootstrapData }) { }, }; + /* Load state from the backend. This will be empty if the feature flag + * `SQLLAB_BACKEND_PERSISTENCE` is off. + */ + const activeTab = restBootstrapData.active_tab; + restBootstrapData.tab_state_ids.forEach(({ id, label }) => { + let queryEditor; + if (activeTab && activeTab.id === id) { + queryEditor = { + id: id.toString(), + loaded: true, + title: activeTab.label, + sql: activeTab.sql, + selectedText: null, + latestQueryId: activeTab.latest_query ? activeTab.latest_query.id : null, + autorun: activeTab.autorun, + templateParams: activeTab.template_params, + dbId: activeTab.database_id, + schema: activeTab.schema, + queryLimit: activeTab.query_limit, + validationResult: { + id: null, + errors: [], + completed: false, + }, + }; + } else { + // dummy state, actual state will be loaded on tab switch + queryEditor = { + ...defaultQueryEditor, + id: id.toString(), + loaded: false, + title: label, + }; + } + queryEditors.push(queryEditor); + }); + + const tabHistory = activeTab ? [activeTab.id.toString()] : []; + + const tables = []; + if (activeTab) { + activeTab.table_schemas.forEach((tableSchema) => { + const { + columns, + selectStar, + primaryKey, + foreignKeys, + indexes, + dataPreviewQueryId, + } = tableSchema.description; + const table = { + dbId: tableSchema.database_id, + queryEditorId: tableSchema.tab_state_id.toString(), + schema: tableSchema.schema, + name: tableSchema.table, + expanded: tableSchema.expanded, + id: tableSchema.id, + isMetadataLoading: false, + isExtraMetadataLoading: false, + dataPreviewQueryId, + columns, + selectStar, + primaryKey, + foreignKeys, + indexes, + }; + tables.push(table); + }); + } + + const { databases, queries } = restBootstrapData; + + /* If the `SQLLAB_BACKEND_PERSISTENCE` feature flag is off, or if the user + * hasn't used SQL Lab after it has been turned on, the state will be stored + * in the browser's local storage. + */ + if (localStorage.getItem('redux') && JSON.parse(localStorage.getItem('redux')).sqlLab) { + const sqlLab = JSON.parse(localStorage.getItem('redux')).sqlLab; + + if (sqlLab.queryEditors.length === 0) { + // migration was successful + localStorage.removeItem('redux'); + } else { + // add query editors and tables to state with a special flag so they can + // be migrated if the `SQLLAB_BACKEND_PERSISTENCE` feature flag is on + sqlLab.queryEditors.forEach(qe => queryEditors.push({ + ...qe, + inLocalStorage: true, + loaded: true, + })); + sqlLab.tables.forEach(table => tables.push({ ...table, inLocalStorage: true })); + Object.values(sqlLab.queries).forEach((query) => { + queries[query.id] = { ...query, inLocalStorage: true }; + }); + tabHistory.push(...sqlLab.tabHistory); + } + } + return { sqlLab: { activeSouthPaneTab: 'Results', alerts: [], - databases: {}, + databases, offline: false, - queries: {}, - queryEditors: [defaultQueryEditor], - tabHistory: [defaultQueryEditor.id], - tables: [], + queries, + queryEditors, + tabHistory, + tables, queriesLastUpdate: Date.now(), }, messageToasts: getToastsFromPyFlashMessages( diff --git a/superset/assets/src/SqlLab/reducers/sqlLab.js b/superset/assets/src/SqlLab/reducers/sqlLab.js index 3424bda425..03547b36f6 100644 --- a/superset/assets/src/SqlLab/reducers/sqlLab.js +++ b/superset/assets/src/SqlLab/reducers/sqlLab.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import shortid from 'shortid'; import { t } from '@superset-ui/translation'; import getInitialState from './getInitialState'; @@ -29,6 +28,7 @@ import { removeFromArr, getFromArr, addToArr, + extendArr, } from '../../reduxUtils'; export default function sqlLabReducer(state = {}, action) { @@ -59,7 +59,6 @@ export default function sqlLabReducer(state = {}, action) { ); const qe = { remoteId: progenitor.remoteId, - id: shortid.generate(), title: t('Copy of %s', progenitor.title), dbId: action.query.dbId ? action.query.dbId : null, schema: action.query.schema ? action.query.schema : null, @@ -68,13 +67,13 @@ export default function sqlLabReducer(state = {}, action) { queryLimit: action.query.queryLimit, maxRow: action.query.maxRow, }; - return sqlLabReducer(state, actions.addQueryEditor(qe)); }, [actions.REMOVE_QUERY_EDITOR]() { let newState = removeFromArr(state, 'queryEditors', action.queryEditor); // List of remaining queryEditor ids const qeIds = newState.queryEditors.map(qe => qe.id); + const queries = {}; Object.keys(state.queries).forEach((k) => { const query = state.queries[k]; @@ -82,9 +81,14 @@ export default function sqlLabReducer(state = {}, action) { queries[k] = query; } }); + let tabHistory = state.tabHistory.slice(); tabHistory = tabHistory.filter(id => qeIds.indexOf(id) > -1); - newState = Object.assign({}, newState, { tabHistory, queries }); + + // Remove associated table schemas + const tables = state.tables.filter(table => table.queryEditorId !== action.queryEditor.id); + + newState = Object.assign({}, newState, { tabHistory, tables, queries }); return newState; }, [actions.REMOVE_QUERY]() { @@ -114,7 +118,6 @@ export default function sqlLabReducer(state = {}, action) { } return alterInArr(state, 'tables', existingTable, at); } - at.id = shortid.generate(); // for new table, associate Id of query for data preview at.dataPreviewQueryId = null; let newState = addToArr(state, 'tables', at); @@ -318,16 +321,77 @@ export default function sqlLabReducer(state = {}, action) { }, [actions.SET_ACTIVE_QUERY_EDITOR]() { const qeIds = state.queryEditors.map(qe => qe.id); - if (qeIds.indexOf(action.queryEditor.id) > -1) { + if ( + (qeIds.indexOf(action.queryEditor.id) > -1) && + (state.tabHistory[state.tabHistory.length - 1] !== action.queryEditor.id) + ) { const tabHistory = state.tabHistory.slice(); tabHistory.push(action.queryEditor.id); return Object.assign({}, state, { tabHistory }); } return state; }, + [actions.LOAD_QUERY_EDITOR]() { + return alterInArr(state, 'queryEditors', action.queryEditor, { ...action.queryEditor }); + }, + [actions.SET_TABLES]() { + return extendArr(state, 'tables', action.tables); + }, [actions.SET_ACTIVE_SOUTHPANE_TAB]() { return Object.assign({}, state, { activeSouthPaneTab: action.tabId }); }, + [actions.MIGRATE_QUERY_EDITOR]() { + // remove migrated query editor from localStorage + const sqlLab = JSON.parse(localStorage.getItem('redux')).sqlLab; + sqlLab.queryEditors = sqlLab.queryEditors.filter(qe => qe.id !== action.oldQueryEditor.id); + localStorage.setItem('redux', JSON.stringify({ sqlLab })); + + // replace localStorage query editor with the server backed one + return addToArr( + removeFromArr( + state, + 'queryEditors', + action.oldQueryEditor, + ), + 'queryEditors', + action.newQueryEditor, + ); + }, + [actions.MIGRATE_TABLE]() { + // remove migrated table from localStorage + const sqlLab = JSON.parse(localStorage.getItem('redux')).sqlLab; + sqlLab.tables = sqlLab.tables.filter(table => table.id !== action.oldTable.id); + localStorage.setItem('redux', JSON.stringify({ sqlLab })); + + // replace localStorage table with the server backed one + return addToArr( + removeFromArr( + state, + 'tables', + action.oldTable, + ), + 'tables', + action.newTable, + ); + }, + [actions.MIGRATE_TAB_HISTORY]() { + // remove migrated tab from localStorage tabHistory + const sqlLab = JSON.parse(localStorage.getItem('redux')).sqlLab; + sqlLab.tabHistory = sqlLab.tabHistory.filter(tabId => tabId !== action.oldId); + localStorage.setItem('redux', JSON.stringify({ sqlLab })); + const tabHistory = state.tabHistory.filter(tabId => tabId !== action.oldId); + tabHistory.push(action.newId); + return Object.assign({}, state, { tabHistory }); + }, + [actions.MIGRATE_QUERY]() { + const query = { + ...state.queries[action.queryId], + // point query to migrated query editor + sqlEditorId: action.queryEditorId, + }; + const queries = Object.assign({}, state.queries, { [query.id]: query }); + return Object.assign({}, state, { queries }); + }, [actions.QUERY_EDITOR_SETDB]() { return alterInArr(state, 'queryEditors', action.queryEditor, { dbId: action.dbId }); }, diff --git a/superset/assets/src/components/TableSelector.jsx b/superset/assets/src/components/TableSelector.jsx index 1b83acc32b..f2111b2fde 100644 --- a/superset/assets/src/components/TableSelector.jsx +++ b/superset/assets/src/components/TableSelector.jsx @@ -127,9 +127,8 @@ export default class TableSelector extends React.PureComponent { })); } fetchTables(force, substr) { - // This can be large so it shouldn't be put in the Redux store const forceRefresh = force || false; - const { dbId, schema } = this.props; + const { dbId, schema } = this.state; if (dbId && schema) { this.setState(() => ({ tableLoading: true, tableOptions: [] })); const endpoint = encodeURI(`/superset/tables/${dbId}/` + diff --git a/superset/assets/src/featureFlags.ts b/superset/assets/src/featureFlags.ts index 01b5ac2c44..07e4da52c4 100644 --- a/superset/assets/src/featureFlags.ts +++ b/superset/assets/src/featureFlags.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { SCHEDULED_QUERIES = 'SCHEDULED_QUERIES', SQL_VALIDATORS_BY_ENGINE = 'SQL_VALIDATORS_BY_ENGINE', ESTIMATE_QUERY_COST = 'ESTIMATE_QUERY_COST', + SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE', } export type FeatureFlagMap = { diff --git a/superset/assets/src/reduxUtils.js b/superset/assets/src/reduxUtils.js index 96fe1cec98..49ed4eca90 100644 --- a/superset/assets/src/reduxUtils.js +++ b/superset/assets/src/reduxUtils.js @@ -86,6 +86,23 @@ export function addToArr(state, arrKey, obj, prepend = false) { return Object.assign({}, state, newState); } +export function extendArr(state, arrKey, obj, prepend = false) { + const newObj = [...obj]; + newObj.forEach((el) => { + if (!el.id) { + /* eslint-disable no-param-reassign */ + el.id = shortid.generate(); + } + }); + const newState = {}; + if (prepend) { + newState[arrKey] = [...newObj, ...state[arrKey]]; + } else { + newState[arrKey] = [...state[arrKey], ...newObj]; + } + return Object.assign({}, state, newState); +} + export function initEnhancer(persist = true, persistConfig = {}) { const { paths, config } = persistConfig; const composeEnhancers = process.env.WEBPACK_MODE === 'development' diff --git a/superset/config.py b/superset/config.py index c5728f87aa..132258ef28 100644 --- a/superset/config.py +++ b/superset/config.py @@ -486,7 +486,7 @@ RESULTS_BACKEND = None # rather than JSON. This feature requires additional testing from the # community before it is fully adopted, so this config option is provided # in order to disable should breaking issues be discovered. -RESULTS_BACKEND_USE_MSGPACK = True +RESULTS_BACKEND_USE_MSGPACK = False # The S3 bucket where you want to store your external hive tables created # from CSV files. For example, 'companyname-superset' diff --git a/superset/migrations/versions/db4b49eb0782_add_tables_for_sql_lab_state.py b/superset/migrations/versions/db4b49eb0782_add_tables_for_sql_lab_state.py new file mode 100644 index 0000000000..53afac7d69 --- /dev/null +++ b/superset/migrations/versions/db4b49eb0782_add_tables_for_sql_lab_state.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Add tables for SQL Lab state + +Revision ID: db4b49eb0782 +Revises: 78ee127d0d1d +Create Date: 2019-11-13 11:05:30.122167 + +""" + +# revision identifiers, used by Alembic. +revision = "db4b49eb0782" +down_revision = "78ee127d0d1d" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tab_state", + sa.Column("created_on", sa.DateTime(), nullable=True), + sa.Column("changed_on", sa.DateTime(), nullable=True), + sa.Column("extra_json", sa.Text(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("label", sa.String(length=256), nullable=True), + sa.Column("active", sa.Boolean(), nullable=True), + sa.Column("database_id", sa.Integer(), nullable=True), + sa.Column("schema", sa.String(length=256), nullable=True), + sa.Column("sql", sa.Text(), nullable=True), + sa.Column("query_limit", sa.Integer(), nullable=True), + sa.Column("latest_query_id", sa.String(11), nullable=True), + sa.Column("autorun", sa.Boolean(), nullable=False, default=False), + sa.Column("template_params", sa.Text(), nullable=True), + sa.Column("created_by_fk", sa.Integer(), nullable=True), + sa.Column("changed_by_fk", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]), + sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]), + sa.ForeignKeyConstraint(["database_id"], ["dbs.id"]), + sa.ForeignKeyConstraint(["latest_query_id"], ["query.client_id"]), + sa.ForeignKeyConstraint(["user_id"], ["ab_user.id"]), + sa.PrimaryKeyConstraint("id"), + sqlite_autoincrement=True, + ) + op.create_index(op.f("ix_tab_state_id"), "tab_state", ["id"], unique=True) + op.create_table( + "table_schema", + sa.Column("created_on", sa.DateTime(), nullable=True), + sa.Column("changed_on", sa.DateTime(), nullable=True), + sa.Column("extra_json", sa.Text(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("tab_state_id", sa.Integer(), nullable=True), + sa.Column("database_id", sa.Integer(), nullable=False), + sa.Column("schema", sa.String(length=256), nullable=True), + sa.Column("table", sa.String(length=256), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("expanded", sa.Boolean(), nullable=True), + sa.Column("created_by_fk", sa.Integer(), nullable=True), + sa.Column("changed_by_fk", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]), + sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]), + sa.ForeignKeyConstraint(["database_id"], ["dbs.id"]), + sa.ForeignKeyConstraint(["tab_state_id"], ["tab_state.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sqlite_autoincrement=True, + ) + op.create_index(op.f("ix_table_schema_id"), "table_schema", ["id"], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_table_schema_id"), table_name="table_schema") + op.drop_table("table_schema") + op.drop_index(op.f("ix_tab_state_id"), table_name="tab_state") + op.drop_table("tab_state") + # ### end Alembic commands ### diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index a1afaca4f3..3b97890675 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -19,6 +19,7 @@ import re from datetime import datetime +import simplejson as json import sqlalchemy as sqla from flask import Markup from flask_appbuilder import Model @@ -188,6 +189,87 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin): return "/superset/sqllab?savedQueryId={0}".format(self.id) +class TabState(Model, AuditMixinNullable, ExtraJSONMixin): + + __tablename__ = "tab_state" + + # basic info + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("ab_user.id")) + label = Column(String(256)) + active = Column(Boolean, default=False) + + # selected DB and schema + database_id = Column(Integer, ForeignKey("dbs.id")) + database = relationship("Database", foreign_keys=[database_id]) + schema = Column(String(256)) + + # tables that are open in the schema browser and their data previews + table_schemas = relationship( + "TableSchema", + cascade="all, delete-orphan", + backref="tab_state", + passive_deletes=True, + ) + + # the query in the textarea, and results (if any) + sql = Column(Text) + query_limit = Column(Integer) + + # latest query that was run + latest_query_id = Column(Integer, ForeignKey("query.client_id")) + latest_query = relationship("Query") + + # other properties + autorun = Column(Boolean, default=False) + template_params = Column(Text) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "label": self.label, + "active": self.active, + "database_id": self.database_id, + "schema": self.schema, + "table_schemas": [ts.to_dict() for ts in self.table_schemas], + "sql": self.sql, + "query_limit": self.query_limit, + "latest_query": self.latest_query.to_dict() if self.latest_query else None, + "autorun": self.autorun, + "template_params": self.template_params, + } + + +class TableSchema(Model, AuditMixinNullable, ExtraJSONMixin): + + __tablename__ = "table_schema" + + id = Column(Integer, primary_key=True, autoincrement=True) + tab_state_id = Column(Integer, ForeignKey("tab_state.id", ondelete="CASCADE")) + + database_id = Column(Integer, ForeignKey("dbs.id"), nullable=False) + database = relationship("Database", foreign_keys=[database_id]) + schema = Column(String(256)) + table = Column(String(256)) + + # JSON describing the schema, partitions, latest partition, etc. + description = Column(Text) + + expanded = Column(Boolean, default=False) + + def to_dict(self): + return { + "id": self.id, + "tab_state_id": self.tab_state_id, + "database_id": self.database_id, + "schema": self.schema, + "table": self.table, + "description": json.loads(self.description), + "expanded": self.expanded, + } + + # events for updating tags sqla.event.listen(SavedQuery, "after_insert", QueryUpdater.after_insert) sqla.event.listen(SavedQuery, "after_update", QueryUpdater.after_update) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index f64a4307d6..df4ecc53fe 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -318,7 +318,7 @@ def execute_sql_statements( db_engine_spec = database.db_engine_spec db_engine_spec.patch() - if store_results and not results_backend: + if database.allow_run_async and not results_backend: raise SqlLabException("Results backend isn't configured.") # Breaking down into multiple statements @@ -394,7 +394,7 @@ def execute_sql_statements( ) payload["query"]["state"] = QueryStatus.SUCCESS - if store_results: + if store_results and results_backend: key = str(uuid.uuid4()) logging.info( f"Query {query_id}: Storing results in results backend, key: {key}" diff --git a/superset/views/core.py b/superset/views/core.py index 3db014bbd6..52550695e5 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -77,7 +77,7 @@ from superset.exceptions import ( SupersetTimeoutException, ) from superset.jinja_context import get_template_processor -from superset.models.sql_lab import Query +from superset.models.sql_lab import Query, TabState from superset.models.user_attributes import UserAttribute from superset.sql_parse import ParsedQuery from superset.sql_validators import get_validator_by_name @@ -117,6 +117,20 @@ stats_logger = config["STATS_LOGGER"] DAR = models.DatasourceAccessRequest QueryStatus = utils.QueryStatus +DATABASE_KEYS = [ + "allow_csv_upload", + "allow_ctas", + "allow_dml", + "allow_multi_schema_metadata_fetch", + "allow_run_async", + "allows_subquery", + "backend", + "database_name", + "expose_in_sqllab", + "force_ctas_schema", + "id", +] + ALL_DATASOURCE_ACCESS_ERR = __( "This endpoint requires the `all_datasource_access` permission" @@ -2644,12 +2658,17 @@ class Superset(BaseSupersetView): try: timeout = config["SQLLAB_TIMEOUT"] timeout_msg = f"The query exceeded the {timeout} seconds timeout." + store_results = ( + is_feature_enabled("SQLLAB_BACKEND_PERSISTENCE") + and not query.select_as_cta + ) with utils.timeout(seconds=timeout, error_message=timeout_msg): # pylint: disable=no-value-for-parameter data = sql_lab.get_sql_results( query.id, rendered_query, return_results=True, + store_results=store_results, user_name=g.user.username if g.user else None, expand_data=expand_data, ) @@ -2997,9 +3016,38 @@ class Superset(BaseSupersetView): @expose("/sqllab") def sqllab(self): """SQL Editor""" + + # send list of tab state ids + tab_state_ids = ( + db.session.query(TabState.id, TabState.label) + .filter_by(user_id=g.user.get_id()) + .all() + ) + # return first active tab, or fallback to another one if no tab is active + active_tab = ( + db.session.query(TabState) + .filter_by(user_id=g.user.get_id()) + .order_by(TabState.active.desc()) + .first() + ) + databases = { + database.id: { + k: v for k, v in database.to_json().items() if k in DATABASE_KEYS + } + for database in db.session.query(models.Database).all() + } + user_queries = db.session.query(Query).filter_by(user_id=g.user.get_id()).all() + queries = { + query.client_id: {k: v for k, v in query.to_dict().items()} + for query in user_queries + } d = { "defaultDbId": config["SQLLAB_DEFAULT_DBID"], "common": self.common_bootstrap_payload(), + "tab_state_ids": tab_state_ids, + "active_tab": active_tab.to_dict() if active_tab else None, + "databases": databases, + "queries": queries, } return self.render_template( "superset/basic.html", diff --git a/superset/views/sql_lab.py b/superset/views/sql_lab.py index cdb51cf64d..fe072956fb 100644 --- a/superset/views/sql_lab.py +++ b/superset/views/sql_lab.py @@ -18,18 +18,24 @@ from typing import Callable import simplejson as json -from flask import g, redirect +from flask import g, redirect, request, Response from flask_appbuilder import expose from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.security.decorators import has_access, has_access_api from flask_babel import gettext as __, lazy_gettext as _ from flask_sqlalchemy import BaseQuery -from superset import appbuilder, get_feature_flags, security_manager -from superset.models.sql_lab import Query, SavedQuery +from superset import appbuilder, db, get_feature_flags, security_manager +from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState from superset.utils import core as utils -from .base import BaseSupersetView, DeleteMixin, SupersetFilter, SupersetModelView +from .base import ( + BaseSupersetView, + DeleteMixin, + json_success, + SupersetFilter, + SupersetModelView, +) class QueryFilter(SupersetFilter): @@ -169,6 +175,165 @@ class SavedQueryViewApi(SavedQueryView): appbuilder.add_view_no_menu(SavedQueryViewApi) appbuilder.add_view_no_menu(SavedQueryView) + +class TabStateView(BaseSupersetView): + def _get_owner_id(self, tab_state_id): + return db.session.query(TabState.user_id).filter_by(id=tab_state_id).scalar() + + @has_access_api + @expose("/", methods=["POST"]) + def post(self): + query_editor = json.loads(request.form["queryEditor"]) + tab_state = TabState( + user_id=g.user.get_id(), + label=query_editor.get("title", "Untitled Query"), + active=True, + database_id=query_editor["dbId"], + schema=query_editor.get("schema"), + sql=query_editor.get("sql", "SELECT ..."), + query_limit=query_editor.get("queryLimit"), + ) + ( + db.session.query(TabState) + .filter_by(user_id=g.user.get_id()) + .update({"active": False}) + ) + db.session.add(tab_state) + db.session.commit() + return json_success(json.dumps({"id": tab_state.id})) + + @has_access_api + @expose("/", methods=["DELETE"]) + def delete(self, tab_state_id): + if self._get_owner_id(tab_state_id) != int(g.user.get_id()): + return Response(status=403) + + db.session.query(TabState).filter(TabState.id == tab_state_id).delete( + synchronize_session=False + ) + db.session.query(TableSchema).filter( + TableSchema.tab_state_id == tab_state_id + ).delete(synchronize_session=False) + db.session.commit() + return json_success(json.dumps("OK")) + + @has_access_api + @expose("/", methods=["GET"]) + def get(self, tab_state_id): + if self._get_owner_id(tab_state_id) != int(g.user.get_id()): + return Response(status=403) + + tab_state = db.session.query(TabState).filter_by(id=tab_state_id).first() + if tab_state is None: + return Response(status=404) + return json_success( + json.dumps(tab_state.to_dict(), default=utils.json_iso_dttm_ser) + ) + + @has_access_api + @expose("/activate", methods=["POST"]) + def activate(self, tab_state_id): + owner_id = self._get_owner_id(tab_state_id) + if owner_id is None: + return Response(status=404) + if owner_id != int(g.user.get_id()): + return Response(status=403) + + ( + db.session.query(TabState) + .filter_by(user_id=g.user.get_id()) + .update({"active": TabState.id == tab_state_id}) + ) + db.session.commit() + return json_success(json.dumps(tab_state_id)) + + @has_access_api + @expose("", methods=["PUT"]) + def put(self, tab_state_id): + if self._get_owner_id(tab_state_id) != int(g.user.get_id()): + return Response(status=403) + + fields = {k: json.loads(v) for k, v in request.form.to_dict().items()} + db.session.query(TabState).filter_by(id=tab_state_id).update(fields) + db.session.commit() + return json_success(json.dumps(tab_state_id)) + + @has_access_api + @expose("/migrate_query", methods=["POST"]) + def migrate_query(self, tab_state_id): + if self._get_owner_id(tab_state_id) != int(g.user.get_id()): + return Response(status=403) + + client_id = json.loads(request.form["queryId"]) + db.session.query(Query).filter_by(client_id=client_id).update( + {"sql_editor_id": tab_state_id} + ) + db.session.commit() + return json_success(json.dumps(tab_state_id)) + + @has_access_api + @expose("/query/", methods=["DELETE"]) + def delete_query(self, tab_state_id, client_id): + db.session.query(Query).filter_by( + client_id=client_id, user_id=g.user.get_id(), sql_editor_id=tab_state_id + ).delete(synchronize_session=False) + db.session.commit() + return json_success(json.dumps("OK")) + + +class TableSchemaView(BaseSupersetView): + @has_access_api + @expose("/", methods=["POST"]) + def post(self): + table = json.loads(request.form["table"]) + + # delete any existing table schema + db.session.query(TableSchema).filter( + TableSchema.tab_state_id == table["queryEditorId"], + TableSchema.database_id == table["dbId"], + TableSchema.schema == table["schema"], + TableSchema.table == table["name"], + ).delete(synchronize_session=False) + + table_schema = TableSchema( + tab_state_id=table["queryEditorId"], + database_id=table["dbId"], + schema=table["schema"], + table=table["name"], + description=json.dumps(table), + expanded=True, + ) + db.session.add(table_schema) + db.session.commit() + return json_success(json.dumps({"id": table_schema.id})) + + @has_access_api + @expose("/", methods=["DELETE"]) + def delete(self, table_schema_id): + db.session.query(TableSchema).filter(TableSchema.id == table_schema_id).delete( + synchronize_session=False + ) + db.session.commit() + return json_success(json.dumps("OK")) + + @has_access_api + @expose("//expanded", methods=["POST"]) + def expanded(self, table_schema_id): + payload = json.loads(request.form["expanded"]) + ( + db.session.query(TableSchema) + .filter_by(id=table_schema_id) + .update({"expanded": payload}) + ) + db.session.commit() + response = json.dumps({"id": table_schema_id, "expanded": payload}) + return json_success(response) + + +appbuilder.add_view_no_menu(TabStateView) +appbuilder.add_view_no_menu(TableSchemaView) + + appbuilder.add_link( __("Saved Queries"), href="/sqllab/my_queries/", icon="fa-save", category="SQL Lab" ) diff --git a/tests/superset_test_config_sqllab_backend_persist.py b/tests/superset_test_config_sqllab_backend_persist.py new file mode 100644 index 0000000000..ace73b85b8 --- /dev/null +++ b/tests/superset_test_config_sqllab_backend_persist.py @@ -0,0 +1,63 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# flake8: noqa +import os +from copy import copy + +from superset.config import * # type: ignore + +AUTH_USER_REGISTRATION_ROLE = "alpha" +SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(DATA_DIR, "unittests.db") +DEBUG = True +SUPERSET_WEBSERVER_PORT = 8081 + +# Allowing SQLALCHEMY_DATABASE_URI to be defined as an env var for +# continuous integration +if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: + SQLALCHEMY_DATABASE_URI = os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"] + +SQL_SELECT_AS_CTA = True +SQL_MAX_ROW = 666 +FEATURE_FLAGS = {"foo": "bar"} + + +def GET_FEATURE_FLAGS_FUNC(ff): + ff_copy = copy(ff) + ff_copy["super"] = "set" + return ff_copy + + +TESTING = True +SECRET_KEY = "thisismyscretkey" +WTF_CSRF_ENABLED = False +PUBLIC_ROLE_LIKE_GAMMA = True +AUTH_ROLE_PUBLIC = "Public" +EMAIL_NOTIFICATIONS = False + +CACHE_CONFIG = {"CACHE_TYPE": "simple"} + + +class CeleryConfig(object): + BROKER_URL = "redis://localhost" + CELERY_IMPORTS = ("superset.sql_lab",) + CELERY_ANNOTATIONS = {"sql_lab.add": {"rate_limit": "10/s"}} + CONCURRENCY = 1 + + +CELERY_CONFIG = CeleryConfig + +DEFAULT_FEATURE_FLAGS = {"SQLLAB_BACKEND_PERSISTENCE": True} diff --git a/tox.ini b/tox.ini index 8a8096df6c..6873d29130 100644 --- a/tox.ini +++ b/tox.ini @@ -78,6 +78,19 @@ setenv = SUPERSET_CONFIG = tests.superset_test_config SUPERSET_HOME = {envtmpdir} +[testenv:cypress-sqllab-backend-persist] +commands = + npm install -g npm@'>=6.5.0' + pip install -e {toxinidir}/ + {toxinidir}/superset/assets/cypress_build.sh sqllab +deps = + -rrequirements.txt + -rrequirements-dev.txt +setenv = + PYTHONPATH = {toxinidir} + SUPERSET_CONFIG = tests.superset_test_config_sqllab_backend_persist + SUPERSET_HOME = {envtmpdir} + [testenv:eslint] changedir = {toxinidir}/superset/assets commands = @@ -142,6 +155,7 @@ envlist = cypress-dashboard cypress-explore cypress-sqllab + cypress-sqllab-backend-persist eslint isort javascript