diff --git a/.codeclimate.yml b/.codeclimate.yml index 64acda56b6e7..4fe69490ce19 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -27,7 +27,6 @@ exclude_paths: - "**.gz" - "env/" - "tests/" -- "superset/ascii_art.py" - "superset/assets/images/" - "superset/assets/vendor/" - "superset/assets/node_modules/" diff --git a/.landscape.yml b/.landscape.yml index 5b0e8ad51ac4..12c19b482e2a 100644 --- a/.landscape.yml +++ b/.landscape.yml @@ -17,7 +17,6 @@ pep8: ignore-paths: - docs - superset/migrations/env.py - - superset/ascii_art.py ignore-patterns: - ^example/doc_.*\.py$ - (^|/)docs(/|$) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d57efc52cb..e6903c0d6336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ ## Change Log +### 0.14.0 (2016/11/29 23:03 +00:00) +- [03b21dc](https://github.com/airbnb/superset/commit/03b21dcf0a3fc18e1290f7770004d3b74df8cef3) [explorev2] Bug fixes in Save Modal (#1707) (@vera-liu) +- [dc98c67](https://github.com/airbnb/superset/commit/dc98c6739fcccc8edc60ef7e761cb1491005f644) Implement table name extraction. (#1598) (@bkyryliuk) +- [fcb8707](https://github.com/airbnb/superset/commit/fcb870728db69bbee092d20c3f78cb7785fe2e61) Add per schema permissions. (#1698) (@bkyryliuk) +- [7919428](https://github.com/airbnb/superset/commit/7919428a1e02457a50ae00439e827f996403f71c) Vliu explorev2 bugs (#1701) (@vera-liu) +- [3496a80](https://github.com/airbnb/superset/commit/3496a80f5a85a0b66e59ec259ed13ca9ba3d5ba0) make stack trace more readable (#1672) (@ascott) +- [56b917a](https://github.com/airbnb/superset/commit/56b917a5c206d3083d9d9d3d0606b976c64b6044) [explore-v2] fix errors on table view (#1675) (@ascott) +- [18c43aa](https://github.com/airbnb/superset/commit/18c43aaea2f889e50211b22f0a68269f314bcafa) make chart title larger, fix explore actions btn spacing (#1680) (@ascott) +- [c43fc38](https://github.com/airbnb/superset/commit/c43fc38f69d6284729cd47368e796117adcc1d1b) [druid] fix having clause (#1694) (@mistercrunch) +- [c07f0ab](https://github.com/airbnb/superset/commit/c07f0ab9c72430f5892f701d6cba35718ef322ad) Config programmatic roles in the config.py (#1664) (@bkyryliuk) +- [1c429b2](https://github.com/airbnb/superset/commit/1c429b27bc425aa8ba0f8cc6b43887cfb91dcd15) Fixing issue #1689 (#1696) (@mistercrunch) +- [b7019ad](https://github.com/airbnb/superset/commit/b7019ad4f343ecbd5d33ce4a5800a72a9f4301b6) [sqllab] bugfix SouthPane doesn't update as expected (#1699) (@mistercrunch) +- [84e8f74](https://github.com/airbnb/superset/commit/84e8f741ae969888c4f2501ada132f58bdcfb249) Add 'Save As' feature for dashboards (#1669) (@the-dcruz) +- [e3a9b39](https://github.com/airbnb/superset/commit/e3a9b393c26ab173fe3ffe3dd14191705cab7119) Missing merge_perm function. Fixes 1691. (#1692) (@niconoe) +- [16aba51](https://github.com/airbnb/superset/commit/16aba517e4640300c9a71f6186776671540bc488) Use smaller size for node max_old_space_size (#1679) (@xrmx) +- [205928e](https://github.com/airbnb/superset/commit/205928e6df892060cdd3ffe0af6a1217a848f301) docs: fix python-redis link markup (#1683) (@xrmx) +- [39ce4aa](https://github.com/airbnb/superset/commit/39ce4aa049fffef3b9f6e368d64130ae85cb86d8) Added filter in ControlPanelsContainer for explore V2 (#1647) (@vera-liu) +- [cef4a82](https://github.com/airbnb/superset/commit/cef4a8296a6a9d46503dd63e268be3a35e9e8e91) [sqllab] adding a sql preprocessor for Presto (#1670) (@mistercrunch) +- [b370ef0](https://github.com/airbnb/superset/commit/b370ef0229377c6b85f78d9ba080d00ff6dba58e) Rerender chart without clicking query button for fields (#1658) (@vera-liu) +- [6b80f5b](https://github.com/airbnb/superset/commit/6b80f5bb35e497c79fe458b25ba87266e3c0f3bf) Get sections to render when switching datasource (#1660) (@vera-liu) +- [bdae570](https://github.com/airbnb/superset/commit/bdae570a69cd948987b05fed2e7653a221ef0d80) Temperary fix of a slice bug (#1648) (@vera-liu) +- [face524](https://github.com/airbnb/superset/commit/face5245a99d13089b9fa4cfa7521ee2ca6b209c) Make explore container resize with browser window (#1608) (@vera-liu) +- [db1ed2a](https://github.com/airbnb/superset/commit/db1ed2a765d317e55377f2550f169b78f981b4a0) Calculate height dynamically using jquery for scrollable sqllab (#1611) (@vera-liu) +- [10982de](https://github.com/airbnb/superset/commit/10982dec3c69f1bed709b38616417eada995d2f4) Make QueryTable scrollable in Query Search page (#1656) (@vera-liu) +- [6825e75](https://github.com/airbnb/superset/commit/6825e75681b1249d066d9fa0bf0dca9f1824bb24) Fixed bug with querylink passing sql object instead of string (#1659) (@vera-liu) +- [bd6a439](https://github.com/airbnb/superset/commit/bd6a439e0b2a3a76f8aece91f11a7eee2ebf6d29) [QuerySearch] Add loading status to QuerySearch page (#1657) (@vera-liu) +- [c90dd49](https://github.com/airbnb/superset/commit/c90dd4902f18bb11c46bc38b8f70bfc14cfc2171) Programatically sync the role with user list. (#1619) (@bkyryliuk) +- [868e5c4](https://github.com/airbnb/superset/commit/868e5c45fed8e090750dffe88660f3943f373c19) Redirect URL requests with "caravel" to "superset" (#1651) (@kingo55) +- [7e1852e](https://github.com/airbnb/superset/commit/7e1852ee883628d38b2e3bb71e2b2b03fad41ba3) User profile pages (favorites, created content, recent activity, security & access) (#1615) (@mistercrunch) +- [5ae98bc](https://github.com/airbnb/superset/commit/5ae98bc7c9b432683d03d30a30631a6efd7a78a3) Improving jinja2 security by using SandboxedEnvironment (#1632) (@mistercrunch) +- [1624e7d](https://github.com/airbnb/superset/commit/1624e7de7dd50f1c4f5fdd9153adac4ba5b983d2) Add all_tables endpoint to allow airpal / superset perm sync. (#1614) (@bkyryliuk) +- [7a98f84](https://github.com/airbnb/superset/commit/7a98f848909ca2099e29d3f485fd299037142e65) Admin / Alpha permission cleanup and fixes. (#1645) (@bkyryliuk) +- [9b18128](https://github.com/airbnb/superset/commit/9b181280d44171cb0c724a07f50488eb08f98e72) include jQuery and bootstrap (#1642) (@ascott) +- [38e94b9](https://github.com/airbnb/superset/commit/38e94b9e43f82c682f311fe1563c8f502ae4157a) Save modal component for explore v2 (#1612) (@vera-liu) +- [dc25bc6](https://github.com/airbnb/superset/commit/dc25bc6f4d5eeb74665dd353bafda5d97ef5faa1) Fix alpha permission checks. (#1641) (@bkyryliuk) +- [f64a205](https://github.com/airbnb/superset/commit/f64a2056038e96883e31419df5fcd4fa396dffb6) Use Alert for visualization error (#1639) (@vera-liu) +- [a8480f5](https://github.com/airbnb/superset/commit/a8480f54922775992a28edd7878b1cfa7690264e) Added Alert for ControlPanel and ChartContainer (#1626) (@vera-liu) +- [0acf26b](https://github.com/airbnb/superset/commit/0acf26b37c7a59cb976cf7a929caf7cc5a1a968e) Fixed a bug with switching viz_type in exploreV2 (#1631) (@vera-liu) +- [2c068a1](https://github.com/airbnb/superset/commit/2c068a1a1583fa61db2f1797b0fcb2618cd6dbe3) increase space between fieldsset rows (#1629) (@ascott) +- [b961c95](https://github.com/airbnb/superset/commit/b961c95121e5e4d4342a2926746dbf8a62bd77ea) dim visualization during refresh (#1636) (@mistercrunch) +- [8269321](https://github.com/airbnb/superset/commit/82693211f0545affbdc306561a1abb4478c2de9a) Update faq.rst (#1637) (@dodysw) +- [e546746](https://github.com/airbnb/superset/commit/e5467462cb73630a9b487891845ab1f01245f2a8) Make nvd3 refresh smoother. (#1618) (@the-dcruz) +- [ab5a410](https://github.com/airbnb/superset/commit/ab5a4102cd8921ca2df234bfa6133973ba83a425) [dashboard] give user feedback when there are unsaved changes (#1633) (@ascott) +- [d5ef937](https://github.com/airbnb/superset/commit/d5ef937b315f4afc679349369b4e7ac7455748f0) Fixed bugs with viz in exploreV2 (#1609) (@vera-liu) +- [bce02e3](https://github.com/airbnb/superset/commit/bce02e3f518237c03273e3ed4d9d1a13d9f8f6a9) [security] improving the security scheme (#1587) (@mistercrunch) +- [aad9744](https://github.com/airbnb/superset/commit/aad9744d85b50721d55d5770aad70ba1ee397ede) add new screenshots (#1589) (@ascott) +- [506b781](https://github.com/airbnb/superset/commit/506b781f3a6048b433c12d25c1dbce614b5bd31b) [explore-v2] add fave star and edit button to chart header (#1623) (@ascott) +- [267fd5b](https://github.com/airbnb/superset/commit/267fd5b9bc4f21a55c4664ae8c3ee717cc1be82c) [table viz] adding support for pagination (#1616) (@mistercrunch) +- [c362f28](https://github.com/airbnb/superset/commit/c362f2869e012a4eeb9b76ff654ee3e82a190979) More Dashboard UX unit tests (#1603) (@mistercrunch) +- [4f7f437](https://github.com/airbnb/superset/commit/4f7f43752798f57daa8cd8b8ed8a9cbc9c948000) Vliu put datasource in store (#1610) (@vera-liu) +- [ab5da5b](https://github.com/airbnb/superset/commit/ab5da5ba2811ac6c2350c7d0534dd209906318af) [table viz] allow sorting on any column (#1601) (@mistercrunch) +- [7531bb8](https://github.com/airbnb/superset/commit/7531bb89429547fb541c36fe365791cd742d82a1) Fixed dashboard controls for standalone bug (#1617) (@vera-liu) +- [811ee8c](https://github.com/airbnb/superset/commit/811ee8ccdc76a2630a4c8014df26558391b981fe) Deleted unused components in exploreV2 (#1613) (@vera-liu) +- [51cb485](https://github.com/airbnb/superset/commit/51cb485ce3e8cb80c72ec8c732281a78441396fd) Add standalone to reactified dashboard page (#1596) (@vera-liu) +- [83d08b8](https://github.com/airbnb/superset/commit/83d08b8b8f7c73cbf4de25cadeab93dd3fdfc2fc) Get query button working in explorev2 (#1581) (@vera-liu) +- [ed3d44d](https://github.com/airbnb/superset/commit/ed3d44d5919fc2ba739cf8d82e75e2680630646d) Changelog entries for 0.13.2 (@mistercrunch) + + ### 0.13.2 (2016/11/16 00:23 +00:00) - [895fe23](https://github.com/airbnb/superset/commit/895fe23203a85a4590f84625507849ce63d69f30) v0.13.2 (@mistercrunch) - [af04a56](https://github.com/airbnb/superset/commit/af04a560c887ecbcee40b53c358ee9c2ad2f44ad) Moved check to the correct place. (#1606) (@edevil) diff --git a/superset/ascii_art.py b/superset/ascii_art.py deleted file mode 100644 index fdd6c21fd481..000000000000 --- a/superset/ascii_art.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - -error = ( -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMM8OI++=~~~~~~=+?IODMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMD$~~~~~~~~~~~~~~~~~~~~~~~=$MMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMN8?:~~~~~~~~~~~~~~~~~~~~~~~~~~=+8NMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMO=~~~~~~~~~~~~~~~~~+I??~~~~~~~~~~~~~+DMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMNI~~~~~~~~~~~~~~~~~~IIIII=~~~~~~~~~~~~~~=NMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMM+=~~~~~~~~~~~~~~~~~~~=III+~~~~~~~~~~~~~~~~~?8MMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMM?~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+++=~~~~8MMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMI=~~~~~~~~~~~~~~~~~~~~~~~~~III?I~~~~~~~~,:++++++~~8MMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMN7~~~~~~~~~~~~~~~~==+=~~~~~~=IIIII~~~~~~:. ..:=++=~=MMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMO=~~~~~~~~~~~~~~~~+++=~~~~~~~~??I?I~~~~~~. ...,~~~~IMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMM~~~~~~~~~~~~~~~~~+++:,~~~~~~~~~~~?=~~~~~:. ..~~~~~OMMMMMMMMMMMM\n"+ -"MMMMMMMMM$=~~~~~~~~~~~~~~~=++:.. ..~~~~~~~~~~~~~~~~,. . . :~~~~~OMMMMMMMMMMM\n"+ -"MMMMMMMMM~~~~~~~~~~~~~~~~+++,. .~~~~~~~~~~~~~~~.. .. . .~~~~~=OMMMMMMMMMM\n"+ -"MMMMMMMM?~~~~~~~~~~~~~~~=+~. .~~~~~~~~~~~~~~. ,MMMMM,=~~~~~~NMMMMMMMMM\n"+ -"MMMMMMMN~~~~~~~~~~~~~~~~~,. .,~~~~~~~~~~~~~.. ZMMM,+Z:~~~~~~$MMMMMMMMM\n"+ -"MMMMMM8?~~~~~~~~~~~~~~~~~.. ..~~~~~~~~~~~~~:. DMMM,+D~~~~~~~~IMMMMMMMM\n"+ -"MMMMMMI~~~~~~~~~~~~~~~~~~.. :MMMO~~~~~~~~~~~~~~~,.. ?MMMMMI~~~~~~~~~MMMMMMMM\n"+ -"MMMMMM=~~~~~~~~~~~~~~~~~~.. MMM+=M:~~~~~~~~~~~~~:. .:IM$~~~~~~~~~~~8MMMMMMM\n"+ -"MMMMMD~~~~~~~~~~~~~~~~~~~:. MMM:,M:~~~~~~~~~~~~~~~.......:~~~~~~~~~~$MMMMMMM\n"+ -"MMMMMI~~~~~~~~~~~~~~~~~~~~, MMMMMM~~~~~~~~~~~~~~~~~~,..:~~~~~~~~~~~~+MMMMMMM\n"+ -"MMMMD+~~~~~~~~~~~~~~~~~~~~~. $MMMM$~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=MMMMMMM\n"+ -"MMMM8~~~~~~~~~~~~~~~~~~~~~~:. . .:~~~~~~,..:. .=~~~~~~~~~~~~~~~~~~~~MMMMMMM\n"+ -"MMMMO~~~~~~~~~~~~~~~~~~~~~~~:, .:~~~~~=8.. .+ . =8ZI~~~~~~~~~~~~~~~~=MMMMMMM\n"+ -"MMMMZ=~~~~~~~~~~~~~~~~~~~~~~~~:,,,:~~~~~~IZ8:. .O....888?~~~~~~~~~~~~~~~+MMMMMMM\n"+ -"MMMMO=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~?888=...I~I88888O?~~~~~~~~~~~~~~7MMMMMMM\n"+ -"MMMMO~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~Z888OO88888888888O?~~~~~~~~~~~~~OMMMMMMM\n"+ -"MMMMD+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=8888888888888888888~~~~~~~~~~~~+MMMMMMMM\n"+ -"MMMMM7~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~?8888888888888888888?~~~~~~~~~~=$MMMMMMMM\n"+ -"MMMMMD~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=$8888888888888888888O~~~~~~~~~~8MMMMMMMMM\n"+ -"MMMMMN=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+Z88888888888888888ZZ7=~~~~~~~~?MMMMMMMMMM\n"+ -"MMMMMMZ=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+Z88888888Z7I===~~~~~~~~~~~~~=OMMMMMMMMMMM\n"+ -"MMMMMMN$~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=$88888O7?=~~~~~~~~~~~~~~~~~~OMMMMMMMMMMMM\n"+ -"MMMMMMMM?~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~I8OZ+~~~~~~~~~~~~~~~~~~~~=DMMMMMMMMMMMMMM\n"+ -"MMMMMMMM8=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+$+=~~~~~~~~~~~~~~~~~~~~+MMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMD7~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=$DMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMM?~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~=$OMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMD7=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+ZMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMZ7=~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~78MMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMM8OI=~~~~~~~~~~~~~~~~~~~=+?ZDNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMNDZ7?++~=~==~+?IONMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM\n"+ -"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM") - -stacktrace=""" -------------------------------------------------------------------------------------------------------- -======================================================================================================= -------------------------------------------------------------------------------------------------------- - ___ ___ ___ - ( ) ( ) ( ) - .--. | |_ .---. .--. | | ___ | |_ ___ .-. .---. .--. .--. - / _ \ ( __) / .-, \ / \ | | ( ) ( __) ( ) \ / .-, \ / \ / \\ - . .' `. ; | | (__) ; | | .-. ; | | ' / | | | ' .-. ; (__) ; | | .-. ; | .-. ; - | ' | | | | ___ .'` | | |(___) | |,' / | | ___ | / (___) .'` | | |(___) | | | | - _\_`.(___) | |( ) / .'| | | | | . '. | |( ) | | / .'| | | | | |/ | -( ). '. | | | | | / | | | | ___ | | `. \ | | | | | | | / | | | | ___ | ' _.' - | | `\ | | ' | | ; | ; | | '( ) | | \ \ | ' | | | | ; | ; | | '( ) | .'.-. - ; '._,' ' ' `-' ; ' `-' | ' `-' | | | \ . ' `-' ; | | ' `-' | ' `-' | ' `-' / - '.___.' `.__. `.__.'_. `.__,' (___ ) (___) `.__. (___) `.__.'_. `.__,' `.__.' - -------------------------------------------------------------------------------------------------------- -======================================================================================================= -------------------------------------------------------------------------------------------------------- -""" - -boat = """\ - + + - )`.). - )``)``) .~~ - ).-'.-')|) - |-).-).-'_'-/ - ~~~\ `o-o-o' /~~~~ - ~~~'---.____/~~~""" diff --git a/superset/assets/javascripts/SqlLab/components/SouthPane.jsx b/superset/assets/javascripts/SqlLab/components/SouthPane.jsx index ed312eebb060..5291c38456b3 100644 --- a/superset/assets/javascripts/SqlLab/components/SouthPane.jsx +++ b/superset/assets/javascripts/SqlLab/components/SouthPane.jsx @@ -5,7 +5,6 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import * as Actions from '../actions'; import React from 'react'; -import { areArraysShallowEqual } from '../../reduxUtils'; import shortid from 'shortid'; @@ -28,11 +27,6 @@ class SouthPane extends React.PureComponent { switchTab(id) { this.props.actions.setActiveSouthPaneTab(id); } - shouldComponentUpdate(nextProps) { - return !areArraysShallowEqual(this.props.editorQueries, nextProps.editorQueries) - || !areArraysShallowEqual(this.props.dataPreviewQueries, nextProps.dataPreviewQueries) - || this.props.activeSouthPaneTab !== nextProps.activeSouthPaneTab; - } render() { let latestQuery; const props = this.props; diff --git a/superset/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx b/superset/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx index b843f4871e28..b74169059493 100644 --- a/superset/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx +++ b/superset/assets/javascripts/SqlLab/components/TabbedSqlEditors.jsx @@ -6,7 +6,7 @@ import * as Actions from '../actions'; import SqlEditor from './SqlEditor'; import { getParamFromQuery } from '../../../utils/common'; import CopyQueryTabUrl from './CopyQueryTabUrl'; -import { areObjectsEqual } from '../../reduxUtils'; +import { areArraysShallowEqual } from '../../reduxUtils'; const propTypes = { actions: React.PropTypes.object.isRequired, @@ -37,6 +37,7 @@ class TabbedSqlEditors extends React.PureComponent { cleanUri, query, queriesArray: [], + dataPreviewQueries: [], hideLeftBar: false, }; } @@ -56,17 +57,27 @@ class TabbedSqlEditors extends React.PureComponent { } } componentWillReceiveProps(nextProps) { - const activeQeId = this.props.tabHistory[this.props.tabHistory.length - 1]; - const newActiveQeId = nextProps.tabHistory[nextProps.tabHistory.length - 1]; - if (activeQeId !== newActiveQeId || !areObjectsEqual(this.props.queries, nextProps.queries)) { - const queriesArray = []; - for (const id in this.props.queries) { - if (this.props.queries[id].sqlEditorId === newActiveQeId) { - queriesArray.push(this.props.queries[id]); - } + const nextActiveQeId = nextProps.tabHistory[nextProps.tabHistory.length - 1]; + const queriesArray = []; + for (const id in nextProps.queries) { + if (nextProps.queries[id].sqlEditorId === nextActiveQeId) { + queriesArray.push(nextProps.queries[id]); } + } + if (!areArraysShallowEqual(queriesArray, this.state.queriesArray)) { this.setState({ queriesArray }); } + + const dataPreviewQueries = []; + nextProps.tables.forEach((table) => { + const queryId = table.dataPreviewQueryId; + if (queryId && nextProps.queries[queryId] && table.queryEditorId === nextActiveQeId) { + dataPreviewQueries.push(nextProps.queries[queryId]); + } + }); + if (!areArraysShallowEqual(dataPreviewQueries, this.state.dataPreviewQueries)) { + this.setState({ dataPreviewQueries }); + } } renameTab(qe) { /* eslint no-alert: 0 */ @@ -124,14 +135,6 @@ class TabbedSqlEditors extends React.PureComponent { } const state = (latestQuery) ? latestQuery.state : ''; - const dataPreviewQueries = []; - this.props.tables.forEach((table) => { - const queryId = table.dataPreviewQueryId; - if (queryId && this.props.queries[queryId] && table.queryEditorId === qe.id) { - dataPreviewQueries.push(this.props.queries[queryId]); - } - }); - const tabTitle = (
{qe.title} {' '} @@ -173,7 +176,7 @@ class TabbedSqlEditors extends React.PureComponent { tables={this.props.tables.filter((t) => (t.queryEditorId === qe.id))} queryEditor={qe} editorQueries={this.state.queriesArray} - dataPreviewQueries={dataPreviewQueries} + dataPreviewQueries={this.state.dataPreviewQueries} latestQuery={latestQuery} database={database} actions={this.props.actions} diff --git a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx index 388f6f150da9..0917d57126d3 100644 --- a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx @@ -25,6 +25,7 @@ const propTypes = { isChartLoading: PropTypes.bool, isStarred: PropTypes.bool.isRequired, alert: PropTypes.string, + table_name: PropTypes.string, }; class ChartContainer extends React.Component { @@ -133,6 +134,16 @@ class ChartContainer extends React.Component { visMap[this.props.viz_type](this.state.mockSlice).render(); } + renderChartTitle() { + let title; + if (this.props.slice_name) { + title = this.props.slice_name; + } else { + title = `[${this.props.table_name}] - untitled`; + } + return title; + } + render() { return (
@@ -141,27 +152,31 @@ class ChartContainer extends React.Component { header={
- {this.props.slice_name} - - - - - - - - + {this.renderChartTitle()} + + {this.props.slice_id && + + + + + + + + + + }
{ this.chartContainerRef = ref; }} className={this.props.viz_type} + style={{ 'overflow-x': 'scroll' }} />) } @@ -215,6 +231,7 @@ function mapStateToProps(state) { isChartLoading: state.isChartLoading, isStarred: state.isStarred, alert: state.chartAlert, + table_name: state.viz.form_data.datasource_name, }; } diff --git a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx index b3aafa736d01..6d79f8a8f645 100644 --- a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx @@ -8,7 +8,7 @@ import ControlPanelsContainer from './ControlPanelsContainer'; import SaveModal from './SaveModal'; import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns'; import { autoQueryFields } from '../stores/store'; -import { getParamObject } from '../../modules/utils.js'; +import { getParamObject } from '../exploreUtils'; const $ = require('jquery'); diff --git a/superset/assets/javascripts/explorev2/components/SaveModal.js b/superset/assets/javascripts/explorev2/components/SaveModal.js index 6c38b2f4237e..30203fa80b9e 100644 --- a/superset/assets/javascripts/explorev2/components/SaveModal.js +++ b/superset/assets/javascripts/explorev2/components/SaveModal.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { Modal, Alert, Button, Radio } from 'react-bootstrap'; import Select from 'react-select'; import { connect } from 'react-redux'; -import { getParamObject } from '../../modules/utils.js'; +import { getParamObject } from '../exploreUtils'; const propTypes = { can_edit: PropTypes.bool, @@ -58,7 +58,8 @@ class SaveModal extends React.Component { saveOrOverwrite(gotodash) { this.setState({ alert: null }); this.props.actions.removeSaveModalAlert(); - const params = getParamObject(this.props.form_data, this.props.datasource_type); + const params = getParamObject( + this.props.form_data, this.props.datasource_type, this.state.action === 'saveas'); const sliceParams = {}; params.datasource_name = this.props.form_data.datasource_name; @@ -199,7 +200,7 @@ class SaveModal extends React.Component { type="button" id="btn_modal_save" className="btn pull-left" - onClick={this.saveOrOverwrite.bind(this)} + onClick={this.saveOrOverwrite.bind(this, false)} > Save diff --git a/superset/assets/javascripts/explorev2/exploreUtils.js b/superset/assets/javascripts/explorev2/exploreUtils.js new file mode 100644 index 000000000000..52a9ed70444b --- /dev/null +++ b/superset/assets/javascripts/explorev2/exploreUtils.js @@ -0,0 +1,33 @@ +/* eslint camelcase: 0 */ +function formatFilters(filters) { + // outputs an object of url params of filters + // prefix can be 'flt' or 'having' + const params = {}; + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + params[`${filter.prefix}_col_${i + 1}`] = filter.col; + params[`${filter.prefix}_op_${i + 1}`] = filter.op; + params[`${filter.prefix}_eq_${i + 1}`] = filter.value; + } + return params; +} + +export function getParamObject(form_data, datasource_type, saveNewSlice) { + const data = { + // V2 tag temporarily for updating url + // Todo: remove after launch + V2: true, + datasource_id: form_data.datasource, + datasource_type, + }; + Object.keys(form_data).forEach((field) => { + // filter out null fields + if (form_data[field] !== null && field !== 'datasource' + && !(saveNewSlice && field === 'slice_name')) { + data[field] = form_data[field]; + } + }); + const filterParams = formatFilters(form_data.filters); + Object.assign(data, filterParams); + return data; +} diff --git a/superset/assets/javascripts/explorev2/index.jsx b/superset/assets/javascripts/explorev2/index.jsx index 17a37831e578..71a0bba878ff 100644 --- a/superset/assets/javascripts/explorev2/index.jsx +++ b/superset/assets/javascripts/explorev2/index.jsx @@ -18,14 +18,16 @@ const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstr import { exploreReducer } from './reducers/exploreReducer'; -const bootstrappedState = Object.assign(initialState(bootstrapData.viz.form_data.viz_type), { - can_edit: bootstrapData.can_edit, - can_download: bootstrapData.can_download, - datasources: bootstrapData.datasources, - datasource_type: bootstrapData.datasource_type, - viz: bootstrapData.viz, - user_id: bootstrapData.user_id, -}); +const bootstrappedState = Object.assign( + initialState(bootstrapData.viz.form_data.viz_type, bootstrapData.datasource_type), { + can_edit: bootstrapData.can_edit, + can_download: bootstrapData.can_download, + datasources: bootstrapData.datasources, + datasource_type: bootstrapData.datasource_type, + viz: bootstrapData.viz, + user_id: bootstrapData.user_id, + } +); bootstrappedState.viz.form_data.datasource = parseInt(bootstrapData.datasource_id, 10); bootstrappedState.viz.form_data.datasource_name = bootstrapData.datasource_name; diff --git a/superset/assets/javascripts/explorev2/stores/store.js b/superset/assets/javascripts/explorev2/stores/store.js index cd70eff5291f..0c02435bc06f 100644 --- a/superset/assets/javascripts/explorev2/stores/store.js +++ b/superset/assets/javascripts/explorev2/stores/store.js @@ -1715,7 +1715,7 @@ export function defaultFormData(vizType = 'table', datasourceType = 'table') { return data; } -export function defaultViz(vizType) { +export function defaultViz(vizType, datasourceType = 'table') { return { cached_key: null, cached_timeout: null, @@ -1724,14 +1724,14 @@ export function defaultViz(vizType) { csv_endpoint: null, is_cached: false, data: [], - form_data: defaultFormData(vizType), + form_data: defaultFormData(vizType, datasourceType), json_endpoint: null, query: null, standalone_endpoint: null, }; } -export function initialState(vizType = 'table') { +export function initialState(vizType = 'table', datasourceType = 'table') { return { dashboards: [], isDatasourceMetaLoading: false, @@ -1739,7 +1739,7 @@ export function initialState(vizType = 'table') { datasource_type: null, filterColumnOpts: [], fields, - viz: defaultViz(vizType), + viz: defaultViz(vizType, datasourceType), isStarred: false, }; } diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 065341d9ce15..edf7c1a65235 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -154,38 +154,6 @@ export function slugify(string) { .replace(/-$/, ''); // remove last floating dash } -function formatFilters(filters) { - // outputs an object of url params of filters - // prefix can be 'flt' or 'having' - const params = {}; - for (let i = 0; i < filters.length; i++) { - const filter = filters[i]; - params[`${filter.prefix}_col_${i + 1}`] = filter.col; - params[`${filter.prefix}_op_${i + 1}`] = filter.op; - params[`${filter.prefix}_eq_${i + 1}`] = filter.value; - } - return params; -} - -export function getParamObject(form_data, datasource_type) { - const data = { - // V2 tag temporarily for updating url - // Todo: remove after launch - V2: true, - datasource_id: form_data.datasource, - datasource_type, - }; - Object.keys(form_data).forEach((field) => { - // filter out null fields - if (form_data[field] !== null && field !== 'datasource') { - data[field] = form_data[field]; - } - }); - const filterParams = formatFilters(form_data.filters); - Object.assign(data, filterParams); - return data; -} - export function getAjaxErrorMsg(error) { const respJSON = error.responseJSON; return (respJSON && respJSON.message) ? respJSON.message : diff --git a/superset/assets/package.json b/superset/assets/package.json index cf3694681d60..bff1c1c31b91 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -1,6 +1,6 @@ { "name": "superset", - "version": "0.13.2", + "version": "0.14.0", "description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.", "license": "Apache-2.0", "directories": { diff --git a/superset/assets/stylesheets/less/cosmo/bootswatch.less b/superset/assets/stylesheets/less/cosmo/bootswatch.less index 1274eae64ec8..20ee0f4ed913 100644 --- a/superset/assets/stylesheets/less/cosmo/bootswatch.less +++ b/superset/assets/stylesheets/less/cosmo/bootswatch.less @@ -337,6 +337,10 @@ label { } } +.panel-title-large { + font-size: 24px; +} + a.list-group-item { &-success { diff --git a/superset/assets/visualizations/heatmap.js b/superset/assets/visualizations/heatmap.js index 271befd4d920..285b2397674e 100644 --- a/superset/assets/visualizations/heatmap.js +++ b/superset/assets/visualizations/heatmap.js @@ -12,9 +12,9 @@ function heatmapVis(slice) { function refresh() { // Header for panel in explore v2 const header = document.getElementById('slice-header'); - const headerHeight = header ? header.getBoundingClientRect().height : 0; + const headerHeight = header ? 30 + header.getBoundingClientRect().height : 0; const margin = { - top: 20 + headerHeight, + top: headerHeight, right: 10, bottom: 35, left: 35, @@ -101,14 +101,14 @@ function heatmapVis(slice) { .style('height', hmHeight + 'px') .style('image-rendering', fd.canvas_image_rendering) .style('left', margin.left + 'px') - .style('top', margin.top + 'px') + .style('top', margin.top + headerHeight + 'px') .style('position', 'absolute'); const svg = container.append('svg') .attr('width', width) .attr('height', height) .style('left', '0px') - .style('top', '0px') + .style('top', headerHeight + 'px') .style('position', 'absolute'); const rect = svg.append('g') diff --git a/superset/cli.py b/superset/cli.py index 3c32e14a75c8..1b108b68530e 100755 --- a/superset/cli.py +++ b/superset/cli.py @@ -13,7 +13,7 @@ from flask_migrate import MigrateCommand from flask_script import Manager -from superset import app, ascii_art, db, data, security +from superset import app, db, data, security config = app.config @@ -70,11 +70,10 @@ def init(): def version(verbose): """Prints the current version number""" s = ( - "\n{boat}\n\n" - "-----------------------\n" + "\n-----------------------\n" "Superset {version}\n" "-----------------------").format( - boat=ascii_art.boat, version=config.get('VERSION_STRING')) + version=config.get('VERSION_STRING')) print(s) if verbose: print("[DB] : " + "{}".format(db.engine)) diff --git a/superset/config.py b/superset/config.py index b16560c08a9e..a6dac3ba9fea 100644 --- a/superset/config.py +++ b/superset/config.py @@ -244,6 +244,10 @@ class CeleryConfig(object): # dictionary. JINJA_CONTEXT_ADDONS = {} +# Roles that are controlled by the API / Superset and should not be changes +# by humans. +ROBOT_PERMISSION_ROLES = ['Public', 'Gamma', 'Alpha', 'Admin', 'sql_lab'] + try: from superset_config import * # noqa print('Loaded your LOCAL configuration') diff --git a/superset/models.py b/superset/models.py index d60bca49e58f..9364db5a1bce 100644 --- a/superset/models.py +++ b/superset/models.py @@ -21,7 +21,6 @@ import sqlalchemy as sqla from sqlalchemy.engine.url import make_url from sqlalchemy.orm import subqueryload -from sqlalchemy.ext.hybrid import hybrid_property import sqlparse from dateutil.parser import parse @@ -666,6 +665,7 @@ class Database(Model, AuditMixinNullable): """An ORM object that stores Database related information""" __tablename__ = 'dbs' + type = "table" id = Column(Integer, primary_key=True) database_name = Column(String(250), unique=True) @@ -908,6 +908,11 @@ def link(self): return Markup( '{table_name}'.format(**locals())) + @property + def schema_perm(self): + """Returns schema permission if present, database one otherwise.""" + return utils.get_schema_perm(self.database, self.schema) + def get_perm(self): return ( "[{obj.database}].[{obj.table_name}]" @@ -1063,9 +1068,9 @@ def visit_column(element, compiler, **kw): dttm_col = cols[granularity] time_grain = extras.get('time_grain_sqla') - timestamp = dttm_col.get_timestamp_expression(time_grain) if is_timeseries: + timestamp = dttm_col.get_timestamp_expression(time_grain) select_exprs += [timestamp] groupby_exprs += [timestamp] @@ -1119,7 +1124,7 @@ def visit_column(element, compiler, **kw): qry = qry.limit(row_limit) - if timeseries_limit and groupby: + if is_timeseries and timeseries_limit and groupby: # some sql dialects require for order by expressions # to also be in the select clause inner_select_exprs += [main_metric_expr] @@ -1520,6 +1525,7 @@ class DruidCluster(Model, AuditMixinNullable): """ORM object referencing the Druid clusters""" __tablename__ = 'clusters' + type = "druid" id = Column(Integer, primary_key=True) cluster_name = Column(String(250), unique=True) @@ -1626,6 +1632,19 @@ def num_cols(self): def name(self): return self.datasource_name + @property + def schema(self): + name_pieces = self.datasource_name.split('.') + if len(name_pieces) > 1: + return name_pieces[0] + else: + return None + + @property + def schema_perm(self): + """Returns schema permission if present, cluster one otherwise.""" + return utils.get_schema_perm(self.cluster, self.schema) + def get_perm(self): return ( "[{obj.cluster_name}].[{obj.datasource_name}]" @@ -2040,7 +2059,9 @@ def recursive_get_fields(_conf): del qry['dimensions'] qry['metric'] = list(qry['aggregations'].keys())[0] client.topn(**qry) - elif len(groupby) > 1: + elif len(groupby) > 1 or having_filters: + # If grouping on multiple fields or using a having filter + # we have to force a groupby query if timeseries_limit and is_timeseries: order_by = metrics[0] if metrics else self.metrics[0] if timeseries_limit_metric: @@ -2130,7 +2151,7 @@ def increment_timestamp(ts): tzinfo=config.get("DRUID_TZ")) return dt + timedelta(milliseconds=time_offset) if DTTM_ALIAS in df.columns and time_offset: - df.timestamp = df.timestamp.apply(increment_timestamp) + df[DTTM_ALIAS] = df[DTTM_ALIAS].apply(increment_timestamp) return QueryResult( df=df, @@ -2561,7 +2582,7 @@ class DatasourceAccessRequest(Model, AuditMixinNullable): datasource_id = Column(Integer) datasource_type = Column(String(200)) - ROLES_BLACKLIST = set(['Admin', 'Alpha', 'Gamma', 'Public']) + ROLES_BLACKLIST = set(config.get('ROBOT_PERMISSION_ROLES', [])) @property def cls_model(self): diff --git a/superset/security.py b/superset/security.py index fbafbe6de2a7..54a618b1ca44 100644 --- a/superset/security.py +++ b/superset/security.py @@ -30,6 +30,7 @@ ADMIN_ONLY_PERMISSIONS = { 'all_database_access', 'datasource_access', + 'schema_access', 'database_access', 'can_sql_json', 'can_override_role_permissions', @@ -50,6 +51,7 @@ 'can_edit', 'can_save', 'datasource_access', + 'schema_access', 'database_access', 'muldelete', 'all_datasource_access', @@ -59,6 +61,7 @@ OBJECT_SPEC_PERMISSIONS = set([ 'database_access', + 'schema_access', 'datasource_access', 'metric_access', ]) @@ -186,6 +189,9 @@ def sync_role_definitions(): for datasource in datasources: perm = datasource.get_perm() sm.add_permission_view_menu('datasource_access', perm) + if datasource.schema: + sm.add_permission_view_menu( + 'schema_access', datasource.schema_perm) if perm != datasource.perm: datasource.perm = perm diff --git a/superset/source_registry.py b/superset/source_registry.py index 0705460c646c..2c72157ebf0b 100644 --- a/superset/source_registry.py +++ b/superset/source_registry.py @@ -40,6 +40,27 @@ def get_datasource_by_name(cls, session, datasource_type, datasource_name, d.name == datasource_name and schema == schema] return db_ds[0] + @classmethod + def query_datasources_by_name( + cls, session, database, datasource_name, schema=None): + datasource_class = SourceRegistry.sources[database.type] + if database.type == 'table': + query = ( + session.query(datasource_class) + .filter_by(database_id=database.id) + .filter_by(table_name=datasource_name)) + if schema: + query = query.filter_by(schema=schema) + return query.all() + if database.type == 'druid': + return ( + session.query(datasource_class) + .filter_by(cluster_name=database.id) + .filter_by(datasource_name=datasource_name) + .all() + ) + return None + @classmethod def get_eager_datasource(cls, session, datasource_type, datasource_id): """Returns datasource with columns and metrics.""" diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 5ee65468190f..0e5b84c3fedc 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from superset import ( - app, db, models, utils, dataframe, results_backend) + app, db, models, utils, dataframe, results_backend, sql_parse, sm) from superset.db_engine_specs import LimitMethod from superset.jinja_context import get_template_processor QueryStatus = models.QueryStatus @@ -19,16 +19,12 @@ celery_app = celery.Celery(config_source=app.config.get('CELERY_CONFIG')) -def is_query_select(sql): - return sql.upper().startswith('SELECT') - - def create_table_as(sql, table_name, schema=None, override=False): """Reformats the query into the create table as query. Works only for the single select SQL statements, in all other cases the sql query is not modified. - :param sql: string, sql query that will be executed + :param superset_query: string, sql query that will be executed :param table_name: string, will contain the results of the query execution :param override, boolean, table table_name will be dropped if true :return: string, create table as query @@ -41,12 +37,9 @@ def create_table_as(sql, table_name, schema=None, override=False): if schema: table_name = schema + '.' + table_name exec_sql = '' - if is_query_select(sql): - if override: - exec_sql = 'DROP TABLE IF EXISTS {table_name};\n' - exec_sql += "CREATE TABLE {table_name} AS \n{sql}" - else: - raise Exception("Could not generate CREATE TABLE statement") + if override: + exec_sql = 'DROP TABLE IF EXISTS {table_name};\n' + exec_sql += "CREATE TABLE {table_name} AS \n{sql}" return exec_sql.format(**locals()) @@ -76,12 +69,12 @@ def handle_error(msg): raise Exception(query.error_message) # Limit enforced only for retrieving the data, not for the CTA queries. - is_select = is_query_select(executed_sql); - if not is_select and not database.allow_dml: + superset_query = sql_parse.SupersetQuery(executed_sql) + if not superset_query.is_select() and not database.allow_dml: handle_error( "Only `SELECT` statements are allowed against this database") if query.select_as_cta: - if not is_select: + if not superset_query.is_select(): handle_error( "Only `SELECT` statements can be used with the CREATE TABLE " "feature.") @@ -94,7 +87,7 @@ def handle_error(msg): executed_sql, query.tmp_table_name, database.force_ctas_schema) query.select_as_cta_used = True elif ( - query.limit and is_select and + query.limit and superset_query.is_select() and db_engine_spec.limit_method == LimitMethod.WRAP_SQL): executed_sql = database.wrap_sql_limit(executed_sql, query.limit) query.limit_used = True diff --git a/superset/sql_parse.py b/superset/sql_parse.py new file mode 100644 index 000000000000..8f2c6e018b8e --- /dev/null +++ b/superset/sql_parse.py @@ -0,0 +1,101 @@ +import sqlparse +from sqlparse.sql import IdentifierList, Identifier +from sqlparse.tokens import Keyword, Name + +RESULT_OPERATIONS = {'UNION', 'INTERSECT', 'EXCEPT'} +PRECEDES_TABLE_NAME = {'FROM', 'JOIN', 'DESC', 'DESCRIBE', 'WITH'} + + +# TODO: some sql_lab logic here. +class SupersetQuery(object): + def __init__(self, sql_statement): + self._tokens = [] + self.sql = sql_statement + self._table_names = set() + self._alias_names = set() + # TODO: multistatement support + for statement in sqlparse.parse(self.sql): + self.__extract_from_token(statement) + self._table_names = self._table_names - self._alias_names + + @property + def tables(self): + return self._table_names + + # TODO: use sqlparse for this check. + def is_select(self): + return self.sql.upper().startswith('SELECT') + + @staticmethod + def __precedes_table_name(token_value): + for keyword in PRECEDES_TABLE_NAME: + if keyword in token_value: + return True + return False + + @staticmethod + def __get_full_name(identifier): + if len(identifier.tokens) > 1 and identifier.tokens[1].value == '.': + return "{}.{}".format(identifier.tokens[0].value, + identifier.tokens[2].value) + return identifier.get_real_name() + + @staticmethod + def __is_result_operation(keyword): + for operation in RESULT_OPERATIONS: + if operation in keyword.upper(): + return True + return False + + @staticmethod + def __is_identifier(token): + return ( + isinstance(token, IdentifierList) or isinstance(token, Identifier)) + + def __process_identifier(self, identifier): + # exclude subselects + if '(' not in '{}'.format(identifier): + self._table_names.add(SupersetQuery.__get_full_name(identifier)) + return + + # store aliases + if hasattr(identifier, 'get_alias'): + self._alias_names.add(identifier.get_alias()) + if hasattr(identifier, 'tokens'): + # some aliases are not parsed properly + if identifier.tokens[0].ttype == Name: + self._alias_names.add(identifier.tokens[0].value) + self.__extract_from_token(identifier) + + def __extract_from_token(self, token): + if not hasattr(token, 'tokens'): + return + + table_name_preceding_token = False + + for item in token.tokens: + if item.is_group and not self.__is_identifier(item): + self.__extract_from_token(item) + + if item.ttype in Keyword: + if SupersetQuery.__precedes_table_name(item.value.upper()): + table_name_preceding_token = True + continue + + if not table_name_preceding_token: + continue + + if item.ttype in Keyword: + if SupersetQuery.__is_result_operation(item.value): + table_name_preceding_token = False + continue + # FROM clause is over + break + + if isinstance(item, Identifier): + self.__process_identifier(item) + + if isinstance(item, IdentifierList): + for token in item.tokens: + if SupersetQuery.__is_identifier(token): + self.__process_identifier(token) diff --git a/superset/templates/superset/explorev2.html b/superset/templates/superset/explorev2.html index 9dd5564e664c..4fe0c7546133 100644 --- a/superset/templates/superset/explorev2.html +++ b/superset/templates/superset/explorev2.html @@ -1,15 +1,15 @@ {% extends "superset/basic.html" %} {% block title %} - {% if slice_name %} - [slice] {{ slice_name }} + {% if slc %} + [slice] {{ slc.slice_name }} {% else %} [explore] {{ table_name }} {% endif %} {% endblock %} {% block body %} - +
+

Sorry, something went wrong

+

500 - Internal Server Error

+
+

Stacktrace

+
-
{{ art }}
-{{ title }}
-{{ error_msg }}
-
+
+        {{ error_msg }}
+      
diff --git a/superset/utils.py b/superset/utils.py index 9e14f0916e12..d4dc2dbe5f3e 100644 --- a/superset/utils.py +++ b/superset/utils.py @@ -330,6 +330,12 @@ def get_datasource_full_name(database_name, datasource_name, schema=None): return "[{}].[{}].[{}]".format(database_name, schema, datasource_name) +def get_schema_perm(database, schema): + if schema: + return "[{}].[{}]".format(database, schema) + return database.perm + + def validate_json(obj): if obj: try: diff --git a/superset/views.py b/superset/views.py index 70d4c0a72cdd..8768234374b3 100755 --- a/superset/views.py +++ b/superset/views.py @@ -36,7 +36,7 @@ import superset from superset import ( appbuilder, cache, db, models, viz, utils, app, - sm, ascii_art, sql_lab, results_backend, security, + sm, sql_lab, sql_parse, results_backend, security, ) from superset.source_registry import SourceRegistry from superset.models import DatasourceAccessRequest as DAR @@ -61,13 +61,31 @@ def database_access(self, database): self.can_access("database_access", database.perm) ) - def datasource_access(self, datasource): + def schema_access(self, datasource): return ( self.database_access(datasource.database) or self.all_datasource_access() or + self.can_access("schema_access", datasource.schema_perm) + ) + + def datasource_access(self, datasource): + return ( + self.schema_access(datasource) or self.can_access("datasource_access", datasource.perm) ) + def datasource_access_by_name( + self, database, datasource_name, schema=None): + if (self.database_access(database) or + self.all_datasource_access()): + return True + datasources = SourceRegistry.query_datasources_by_name( + db.session, database, datasource_name, schema=schema) + for datasource in datasources: + if self.can_access("datasource_access", datasource.perm): + return True + return False + class ListWidgetWithCheckboxes(ListWidget): """An alternative to list view that renders Boolean fields as checkboxes @@ -575,6 +593,9 @@ class DatabaseView(SupersetModelView, DeleteMixin): # noqa def pre_add(self, db): db.set_sqlalchemy_uri(db.sqlalchemy_uri) security.merge_perm(sm, 'database_access', db.perm) + for schema in db.all_schema_names(): + security.merge_perm( + sm, 'schema_access', utils.get_schema_perm(db, schema)) def pre_update(self, db): self.pre_add(db) @@ -685,6 +706,9 @@ def pre_add(self, table): def post_add(self, table): table.fetch_metadata() security.merge_perm(sm, 'datasource_access', table.perm) + if table.schema: + security.merge_perm(sm, 'schema_access', table.schema_perm) + flash(_( "The table was created. As part of this two phase configuration " "process, you should now click the edit button by " @@ -1049,6 +1073,8 @@ def pre_add(self, datasource): def post_add(self, datasource): datasource.generate_metrics() security.merge_perm(sm, 'datasource_access', datasource.perm) + if datasource.schema: + security.merge_perm(sm, 'schema_access', datasource.schema_perm) def post_update(self, datasource): self.post_add(datasource) @@ -1451,11 +1477,14 @@ def explore(self, datasource_type, datasource_id): "user_id": g.user.get_id() if g.user else None, "viz": json.loads(viz_obj.get_json()) } + table_name = viz_obj.datasource.table_name \ + if datasource_type == 'table' \ + else viz_obj.datasource.datasource_name return self.render_template( "superset/explorev2.html", bootstrap_data=json.dumps(bootstrap_data), - slice_name=slc.slice_name, - table_name=viz_obj.datasource.table_name) + slice=slc, + table_name=table_name) else: return self.render_template( "superset/explore.html", @@ -2286,27 +2315,45 @@ def results(self, key): @log_this def sql_json(self): """Runs arbitrary sql and returns and json""" + def table_accessible(database, full_table_name, schema_name=None): + table_name_pieces = full_table_name.split(".") + if len(table_name_pieces) == 2: + table_schema = table_name_pieces[0] + table_name = table_name_pieces[1] + else: + table_schema = schema_name + table_name = table_name_pieces[0] + return self.datasource_access_by_name( + database, table_name, schema=table_schema) + async = request.form.get('runAsync') == 'true' sql = request.form.get('sql') database_id = request.form.get('database_id') session = db.session() - mydb = session.query(models.Database).filter_by(id=database_id).first() + mydb = session.query(models.Database).filter_by(id=database_id).one() if not mydb: json_error_response( 'Database with id {} is missing.'.format(database_id)) - if not self.database_access(mydb): + superset_query = sql_parse.SupersetQuery(sql) + schema = request.form.get('schema') + schema = schema if schema else None + + rejected_tables = [ + t for t in superset_query.tables if not + table_accessible(mydb, t, schema_name=schema)] + if rejected_tables: json_error_response( - get_database_access_error_msg(mydb.database_name)) + get_datasource_access_error_msg('{}'.format(rejected_tables))) session.commit() query = models.Query( database_id=int(database_id), limit=int(app.config.get('SQL_MAX_ROW', None)), sql=sql, - schema=request.form.get('schema'), + schema=schema, select_as_cta=request.form.get('select_as_cta') == 'true', start_time=utils.now_as_float(), tab_name=request.form.get('tab'), @@ -2324,7 +2371,8 @@ def sql_json(self): if async: # Ignore the celery future object and the request may time out. sql_lab.get_sql_results.delay( - query_id, return_results=False, store_results=not query.select_as_cta) + query_id, return_results=False, + store_results=not query.select_as_cta) return Response( json.dumps({'query': query.to_dict()}, default=utils.json_int_dttm_ser, @@ -2542,12 +2590,10 @@ def refresh_datasources(self): @app.errorhandler(500) def show_traceback(self): - error_msg = get_error_msg() return render_template( 'superset/traceback.html', - error_msg=error_msg, - title=ascii_art.stacktrace, - art=ascii_art.error), 500 + error_msg=get_error_msg(), + ), 500 @expose("/welcome") def welcome(self): diff --git a/tests/sql_parse_tests.py b/tests/sql_parse_tests.py new file mode 100644 index 000000000000..284e16845f61 --- /dev/null +++ b/tests/sql_parse_tests.py @@ -0,0 +1,295 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import unittest + +from superset import sql_parse + + +class SupersetTestCase(unittest.TestCase): + + def extract_tables(self, query): + sq = sql_parse.SupersetQuery(query) + return sq.tables + + def test_simple_select(self): + query = "SELECT * FROM tbname" + self.assertEquals({"tbname"}, self.extract_tables(query)) + + # underscores + query = "SELECT * FROM tb_name" + self.assertEquals({"tb_name"}, + self.extract_tables(query)) + + # quotes + query = 'SELECT * FROM "tbname"' + self.assertEquals({"tbname"}, self.extract_tables(query)) + + # schema + self.assertEquals( + {"schemaname.tbname"}, + self.extract_tables("SELECT * FROM schemaname.tbname")) + + # quotes + query = "SELECT field1, field2 FROM tb_name" + self.assertEquals({"tb_name"}, self.extract_tables(query)) + + query = "SELECT t1.f1, t2.f2 FROM t1, t2" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + def test_select_named_table(self): + query = "SELECT a.date, a.field FROM left_table a LIMIT 10" + self.assertEquals( + {"left_table"}, self.extract_tables(query)) + + def test_reverse_select(self): + query = "FROM t1 SELECT field" + self.assertEquals({"t1"}, self.extract_tables(query)) + + def test_subselect(self): + query = """ + SELECT sub.* + FROM ( + SELECT * + FROM s1.t1 + WHERE day_of_week = 'Friday' + ) sub, s2.t2 + WHERE sub.resolution = 'NONE' + """ + self.assertEquals({"s1.t1", "s2.t2"}, + self.extract_tables(query)) + + query = """ + SELECT sub.* + FROM ( + SELECT * + FROM s1.t1 + WHERE day_of_week = 'Friday' + ) sub + WHERE sub.resolution = 'NONE' + """ + self.assertEquals({"s1.t1"}, self.extract_tables(query)) + + query = """ + SELECT * FROM t1 + WHERE s11 > ANY + (SELECT COUNT(*) /* no hint */ FROM t2 + WHERE NOT EXISTS + (SELECT * FROM t3 + WHERE ROW(5*t2.s1,77)= + (SELECT 50,11*s1 FROM t4))); + """ + self.assertEquals({"t1", "t2", "t3", "t4"}, + self.extract_tables(query)) + + def test_select_in_expression(self): + query = "SELECT f1, (SELECT count(1) FROM t2) FROM t1" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + def test_union(self): + query = "SELECT * FROM t1 UNION SELECT * FROM t2" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + query = "SELECT * FROM t1 UNION ALL SELECT * FROM t2" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + query = "SELECT * FROM t1 INTERSECT ALL SELECT * FROM t2" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + def test_select_from_values(self): + query = "SELECT * FROM VALUES (13, 42)" + self.assertFalse(self.extract_tables(query)) + + def test_select_array(self): + query = """ + SELECT ARRAY[1, 2, 3] AS my_array + FROM t1 LIMIT 10 + """ + self.assertEquals({"t1"}, self.extract_tables(query)) + + def test_select_if(self): + query = """ + SELECT IF(CARDINALITY(my_array) >= 3, my_array[3], NULL) + FROM t1 LIMIT 10 + """ + self.assertEquals({"t1"}, self.extract_tables(query)) + + # SHOW TABLES ((FROM | IN) qualifiedName)? (LIKE pattern=STRING)? + def test_show_tables(self): + query = 'SHOW TABLES FROM s1 like "%order%"' + # TODO: figure out what should code do here + self.assertEquals({"s1"}, self.extract_tables(query)) + + # SHOW COLUMNS (FROM | IN) qualifiedName + def test_show_columns(self): + query = "SHOW COLUMNS FROM t1" + self.assertEquals({"t1"}, self.extract_tables(query)) + + def test_where_subquery(self): + query = """ + SELECT name + FROM t1 + WHERE regionkey = (SELECT max(regionkey) FROM t2) + """ + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + query = """ + SELECT name + FROM t1 + WHERE regionkey IN (SELECT regionkey FROM t2) + """ + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + query = """ + SELECT name + FROM t1 + WHERE regionkey EXISTS (SELECT regionkey FROM t2) + """ + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + # DESCRIBE | DESC qualifiedName + def test_describe(self): + self.assertEquals({"t1"}, self.extract_tables("DESCRIBE t1")) + self.assertEquals({"t1"}, self.extract_tables("DESC t1")) + + # SHOW PARTITIONS FROM qualifiedName (WHERE booleanExpression)? + # (ORDER BY sortItem (',' sortItem)*)? (LIMIT limit=(INTEGER_VALUE | ALL))? + def test_show_partitions(self): + query = """ + SHOW PARTITIONS FROM orders + WHERE ds >= '2013-01-01' ORDER BY ds DESC; + """ + self.assertEquals({"orders"}, self.extract_tables(query)) + + def test_join(self): + query = "SELECT t1.*, t2.* FROM t1 JOIN t2 ON t1.a = t2.a;" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + # subquery + join + query = """ + SELECT a.date, b.name FROM + left_table a + JOIN ( + SELECT + CAST((b.year) as VARCHAR) date, + name + FROM right_table + ) b + ON a.date = b.date + """ + self.assertEquals({"left_table", "right_table"}, + self.extract_tables(query)) + + query = """ + SELECT a.date, b.name FROM + left_table a + LEFT INNER JOIN ( + SELECT + CAST((b.year) as VARCHAR) date, + name + FROM right_table + ) b + ON a.date = b.date + """ + self.assertEquals({"left_table", "right_table"}, + self.extract_tables(query)) + + query = """ + SELECT a.date, b.name FROM + left_table a + RIGHT OUTER JOIN ( + SELECT + CAST((b.year) as VARCHAR) date, + name + FROM right_table + ) b + ON a.date = b.date + """ + self.assertEquals({"left_table", "right_table"}, + self.extract_tables(query)) + + query = """ + SELECT a.date, b.name FROM + left_table a + FULL OUTER JOIN ( + SELECT + CAST((b.year) as VARCHAR) date, + name + FROM right_table + ) b + ON a.date = b.date + """ + self.assertEquals({"left_table", "right_table"}, + self.extract_tables(query)) + + # TODO: add SEMI join support, SQL Parse does not handle it. + # query = """ + # SELECT a.date, b.name FROM + # left_table a + # LEFT SEMI JOIN ( + # SELECT + # CAST((b.year) as VARCHAR) date, + # name + # FROM right_table + # ) b + # ON a.date = b.date + # """ + # self.assertEquals({"left_table", "right_table"}, + # sql_parse.extract_tables(query)) + + def test_combinations(self): + query = """ + SELECT * FROM t1 + WHERE s11 > ANY + (SELECT * FROM t1 UNION ALL SELECT * FROM ( + SELECT t6.*, t3.* FROM t6 JOIN t3 ON t6.a = t3.a) tmp_join + WHERE NOT EXISTS + (SELECT * FROM t3 + WHERE ROW(5*t3.s1,77)= + (SELECT 50,11*s1 FROM t4))); + """ + self.assertEquals({"t1", "t3", "t4", "t6"}, + self.extract_tables(query)) + + query = """ + SELECT * FROM (SELECT * FROM (SELECT * FROM (SELECT * FROM EmployeeS) + AS S1) AS S2) AS S3; + """ + self.assertEquals({"EmployeeS"}, self.extract_tables(query)) + + def test_with(self): + query = """ + WITH + x AS (SELECT a FROM t1), + y AS (SELECT a AS b FROM t2), + z AS (SELECT b AS c FROM t3) + SELECT c FROM z; + """ + self.assertEquals({"t1", "t2", "t3"}, + self.extract_tables(query)) + + query = """ + WITH + x AS (SELECT a FROM t1), + y AS (SELECT a AS b FROM x), + z AS (SELECT b AS c FROM y) + SELECT c FROM z; + """ + self.assertEquals({"t1"}, self.extract_tables(query)) + + def test_reusing_aliases(self): + query = """ + with q1 as ( select key from q2 where key = '5'), + q2 as ( select key from src where key = '5') + select * from (select key from q1) a; + """ + self.assertEquals({"src"}, self.extract_tables(query)) + + def multistatement(self): + query = "SELECT * FROM t1; SELECT * FROM t2" + self.assertEquals({"t1", "t2"}, self.extract_tables(query)) + + query = "SELECT * FROM t1; SELECT * FROM t2;" + self.assertEquals({"t1", "t2"}, self.extract_tables(query))