[sqllab] show partition metadata for Presto (#1342)

This commit is contained in:
Maxime Beauchemin
2016-10-19 09:17:08 -07:00
committed by GitHub
parent 2edce5bf8a
commit c255e89219
15 changed files with 390 additions and 87 deletions

View File

@@ -2,7 +2,7 @@ export const RESET_STATE = 'RESET_STATE';
export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR';
export const CLONE_QUERY_TO_NEW_TAB = 'CLONE_QUERY_TO_NEW_TAB';
export const REMOVE_QUERY_EDITOR = 'REMOVE_QUERY_EDITOR';
export const ADD_TABLE = 'ADD_TABLE';
export const MERGE_TABLE = 'MERGE_TABLE';
export const REMOVE_TABLE = 'REMOVE_TABLE';
export const START_QUERY = 'START_QUERY';
export const STOP_QUERY = 'STOP_QUERY';
@@ -86,8 +86,8 @@ export function queryEditorSetSql(queryEditor, sql) {
return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql };
}
export function addTable(table) {
return { type: ADD_TABLE, table };
export function mergeTable(table) {
return { type: MERGE_TABLE, table };
}
export function expandTable(table) {

View File

@@ -55,7 +55,7 @@ class SouthPane extends React.Component {
return (
<div className="SouthPane">
<Tabs bsStyle="tabs" id={shortid.generate()}>
<Tab title="Results" eventKey={1} id={shortid.generate()}>
<Tab title="Results" eventKey={1}>
<div style={{ overflow: 'auto' }}>
{results}
</div>

View File

@@ -28,7 +28,7 @@ import shortid from 'shortid';
import SouthPane from './SouthPane';
import Timer from './Timer';
import SqlEditorTopToolbar from './SqlEditorTopToolbar';
import SqlEditorLeftBar from './SqlEditorLeftBar';
class SqlEditor extends React.Component {
constructor(props) {
@@ -252,7 +252,7 @@ class SqlEditor extends React.Component {
<div className="SqlEditor" style={{ minHeight: this.sqlEditorHeight() }}>
<Row>
<Col md={3}>
<SqlEditorTopToolbar queryEditor={this.props.queryEditor} />
<SqlEditorLeftBar queryEditor={this.props.queryEditor} />
</Col>
<Col md={9}>
<AceEditor

View File

@@ -4,14 +4,13 @@ import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';
import shortid from 'shortid';
import Select from 'react-select';
import { Label, Button } from 'react-bootstrap';
import TableElement from './TableElement';
import DatabaseSelect from './DatabaseSelect';
class SqlEditorTopToolbar extends React.Component {
class SqlEditorLeftBar extends React.Component {
constructor(props) {
super(props);
this.state = {
@@ -79,12 +78,11 @@ class SqlEditorTopToolbar extends React.Component {
changeTable(tableOpt) {
const tableName = tableOpt.value;
const qe = this.props.queryEditor;
const url = `/caravel/table/${qe.dbId}/${tableName}/${qe.schema}/`;
let url = `/caravel/table/${qe.dbId}/${tableName}/${qe.schema}/`;
this.setState({ tableLoading: true });
$.get(url, (data) => {
this.props.actions.addTable({
id: shortid.generate(),
this.props.actions.mergeTable({
dbId: this.props.queryEditor.dbId,
queryEditorId: this.props.queryEditor.id,
name: data.name,
@@ -102,6 +100,18 @@ class SqlEditorTopToolbar extends React.Component {
});
this.setState({ tableLoading: false });
});
url = `/caravel/extra_table_metadata/${qe.dbId}/${tableName}/${qe.schema}/`;
$.get(url, (data) => {
const table = {
dbId: this.props.queryEditor.dbId,
queryEditorId: this.props.queryEditor.id,
schema: qe.schema,
name: tableName,
};
Object.assign(table, data);
this.props.actions.mergeTable(table);
});
}
render() {
let networkAlert = null;
@@ -158,14 +168,14 @@ class SqlEditorTopToolbar extends React.Component {
}
}
SqlEditorTopToolbar.propTypes = {
SqlEditorLeftBar.propTypes = {
queryEditor: React.PropTypes.object,
tables: React.PropTypes.array,
actions: React.PropTypes.object,
networkOn: React.PropTypes.bool,
};
SqlEditorTopToolbar.defaultProps = {
SqlEditorLeftBar.defaultProps = {
tables: [],
};
@@ -182,4 +192,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SqlEditorTopToolbar);
export default connect(mapStateToProps, mapDispatchToProps)(SqlEditorLeftBar);

View File

@@ -116,7 +116,6 @@ class TabbedSqlEditors extends React.Component {
key={qe.id}
title={tabTitle}
eventKey={qe.id}
id={`a11y-query-editor-${qe.id}`}
>
<div className="panel panel-default">
<div className="panel-body">

View File

@@ -1,11 +1,23 @@
import React from 'react';
import { ButtonGroup } from 'react-bootstrap';
import { ButtonGroup, Well } from 'react-bootstrap';
import Link from './Link';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as Actions from '../actions';
import shortid from 'shortid';
import ModalTrigger from '../../components/ModalTrigger.jsx';
import ModalTrigger from '../../components/ModalTrigger';
import CopyToClipboard from '../../components/CopyToClipboard';
const propTypes = {
table: React.PropTypes.object,
queryEditor: React.PropTypes.object,
actions: React.PropTypes.object,
};
const defaultProps = {
table: null,
actions: {},
};
class TableElement extends React.Component {
setSelectStar() {
@@ -40,40 +52,85 @@ class TableElement extends React.Component {
collapseTable(e) {
e.preventDefault();
this.props.actions.collapseTable.bind(this, this.props.table)();
this.props.actions.collapseTable(this.props.table);
}
expandTable(e) {
e.preventDefault();
this.props.actions.expandTable.bind(this, this.props.table)();
this.props.actions.expandTable(this.props.table);
}
removeTable() {
this.props.actions.removeTable(this.props.table);
}
render() {
const table = this.props.table;
let metadata = null;
let buttonToggle;
if (this.props.table.expanded) {
let header;
if (table.partitions) {
let partitionQuery;
let partitionClipBoard;
if (table.partitions.partitionQuery) {
partitionQuery = table.partitions.partitionQuery;
const tt = 'Copy partition query to clipboard';
partitionClipBoard = (
<CopyToClipboard
text={partitionQuery}
shouldShowText={false}
tooltipText={tt}
copyNode={<i className="fa fa-clipboard" />}
/>
);
}
let latest = [];
for (const k in table.partitions.latest) {
latest.push(`${k}=${table.partitions.latest[k]}`);
}
latest = latest.join('/');
header = (
<Well bsSize="small">
<div>
<small>
latest partition: {latest}
</small> {partitionClipBoard}
</div>
</Well>
);
}
if (table.expanded) {
buttonToggle = (
<a
href="#"
onClick={(e) => { this.collapseTable(e); }}
>
<strong>{this.props.table.name}</strong>
<strong>{table.name}</strong>
<small className="m-l-5"><i className="fa fa-minus" /></small>
</a>
);
metadata = (
<div>
{this.props.table.columns.map((col) => (
<div className="clearfix" key={shortid.generate()}>
<div className="pull-left m-l-10">
{col.name}
</div>
<div className="pull-right text-muted">
<small> {col.type}</small>
</div>
</div>
))}
<hr />
{header}
<div className="table-columns">
{table.columns.map((col) => {
let name = col.name;
if (col.indexed) {
name = <strong>{col.name}</strong>;
}
return (
<div className="clearfix table-column" key={shortid.generate()}>
<div className="pull-left m-l-10">
{name}
</div>
<div className="pull-right text-muted">
<small> {col.type}</small>
</div>
</div>);
})}
<hr />
</div>
</div>
);
} else {
@@ -82,34 +139,34 @@ class TableElement extends React.Component {
href="#"
onClick={(e) => { this.expandTable(e); }}
>
{this.props.table.name}
{table.name}
<small className="m-l-5"><i className="fa fa-plus" /></small>
</a>
);
}
let keyLink;
if (this.props.table.indexes && this.props.table.indexes.length > 0) {
if (table.indexes && table.indexes.length > 0) {
keyLink = (
<ModalTrigger
modalTitle={
<div>
Keys for table <strong>{this.props.table.name}</strong>
Keys for table <strong>{table.name}</strong>
</div>
}
modalBody={
<pre>{JSON.stringify(this.props.table.indexes, null, 4)}</pre>
<pre>{JSON.stringify(table.indexes, null, 4)}</pre>
}
triggerNode={
<Link
className="fa fa-key pull-left m-l-2"
tooltip={`View indexes (${this.props.table.indexes.length})`}
tooltip={`View indexes (${table.indexes.length})`}
/>
}
/>
);
}
return (
<div>
<div className="TableElement">
<div className="clearfix">
<div className="pull-left">
{buttonToggle}
@@ -131,26 +188,22 @@ class TableElement extends React.Component {
/>
<Link
className="fa fa-trash pull-left m-l-2"
onClick={this.props.actions.removeTable.bind(this, this.props.table)}
onClick={this.removeTable.bind(this)}
tooltip="Remove from workspace"
href="#"
/>
</ButtonGroup>
</div>
</div>
{metadata}
<div>
{metadata}
</div>
</div>
);
}
}
TableElement.propTypes = {
table: React.PropTypes.object,
queryEditor: React.PropTypes.object,
actions: React.PropTypes.object,
};
TableElement.defaultProps = {
table: null,
};
TableElement.propTypes = propTypes;
TableElement.defaultProps = defaultProps;
function mapDispatchToProps(dispatch) {
return {
@@ -158,3 +211,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(null, mapDispatchToProps)(TableElement);
export { TableElement };

View File

@@ -232,3 +232,9 @@ div.tablePopover:hover {
.SouthPane .tab-content {
padding-top: 10px;
}
.TableElement .well {
margin-top: 5px;
margin-bottom: 5px;
padding: 5px 10px;
}

View File

@@ -72,8 +72,23 @@ export const sqlLabReducer = function (state, action) {
[actions.RESET_STATE]() {
return Object.assign({}, initialState);
},
[actions.ADD_TABLE]() {
return addToArr(state, 'tables', action.table);
[actions.MERGE_TABLE]() {
const at = Object.assign({}, action.table);
let existingTable;
state.tables.forEach((t) => {
if (
t.dbId === at.dbId &&
t.queryEditorId === at.queryEditorId &&
t.schema === at.schema &&
t.name === at.name) {
existingTable = t;
}
});
if (existingTable) {
return alterInArr(state, 'tables', existingTable, at);
}
at.id = shortid.generate();
return addToArr(state, 'tables', at);
},
[actions.EXPAND_TABLE]() {
return alterInArr(state, 'tables', action.table, { expanded: true });

View File

@@ -76,9 +76,11 @@ export default class CopyToClipboard extends React.Component {
return (
<span>
{this.props.shouldShowText &&
<span>{this.props.text}</span>
<span>
{this.props.text}
&nbsp;&nbsp;&nbsp;&nbsp;
</span>
}
&nbsp;&nbsp;&nbsp;&nbsp;
<OverlayTrigger placement="top" overlay={this.renderTooltip()} trigger={['hover']}>
<Button
bsStyle="link"

View File

@@ -4,7 +4,7 @@ import cx from 'classnames';
const propTypes = {
triggerNode: PropTypes.node.isRequired,
modalTitle: PropTypes.string.isRequired,
modalTitle: PropTypes.node.isRequired,
modalBody: PropTypes.node.isRequired,
beforeOpen: PropTypes.func,
isButton: PropTypes.bool,

View File

@@ -0,0 +1,158 @@
import React from 'react';
import Link from '../../../javascripts/SqlLab/components/Link';
import { Button } from 'react-bootstrap';
import { TableElement } from '../../../javascripts/SqlLab/components/TableElement';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
describe('TableElement', () => {
const mockedProps = {
'table': {
"dbId": 1,
"queryEditorId": "rJ-KP47a",
"schema": "caravel",
"name": "ab_user",
"id": "r11Vgt60",
"indexes": [
{
"unique": true,
"column_names": [
"username"
],
"type": "UNIQUE",
"name": "username"
},
{
"unique": true,
"column_names": [
"email"
],
"type": "UNIQUE",
"name": "email"
},
{
"unique": false,
"column_names": [
"created_by_fk"
],
"name": "created_by_fk"
},
{
"unique": false,
"column_names": [
"changed_by_fk"
],
"name": "changed_by_fk"
}
],
"columns": [
{
"indexed": false,
"longType": "INTEGER(11)",
"type": "INTEGER",
"name": "id"
},
{
"indexed": false,
"longType": "VARCHAR(64)",
"type": "VARCHAR",
"name": "first_name"
},
{
"indexed": false,
"longType": "VARCHAR(64)",
"type": "VARCHAR",
"name": "last_name"
},
{
"indexed": true,
"longType": "VARCHAR(64)",
"type": "VARCHAR",
"name": "username"
},
{
"indexed": false,
"longType": "VARCHAR(256)",
"type": "VARCHAR",
"name": "password"
},
{
"indexed": false,
"longType": "TINYINT(1)",
"type": "TINYINT",
"name": "active"
},
{
"indexed": true,
"longType": "VARCHAR(64)",
"type": "VARCHAR",
"name": "email"
},
{
"indexed": false,
"longType": "DATETIME",
"type": "DATETIME",
"name": "last_login"
},
{
"indexed": false,
"longType": "INTEGER(11)",
"type": "INTEGER",
"name": "login_count"
},
{
"indexed": false,
"longType": "INTEGER(11)",
"type": "INTEGER",
"name": "fail_login_count"
},
{
"indexed": false,
"longType": "DATETIME",
"type": "DATETIME",
"name": "created_on"
},
{
"indexed": false,
"longType": "DATETIME",
"type": "DATETIME",
"name": "changed_on"
},
{
"indexed": true,
"longType": "INTEGER(11)",
"type": "INTEGER",
"name": "created_by_fk"
},
{
"indexed": true,
"longType": "INTEGER(11)",
"type": "INTEGER",
"name": "changed_by_fk"
}
],
"expanded": true
}
}
it('should just render', () => {
expect(
React.isValidElement(<TableElement />)
).to.equal(true);
});
it('should render with props', () => {
expect(
React.isValidElement(<TableElement {...mockedProps} />)
).to.equal(true);
});
it('has 3 Link elements', () => {
const wrapper = shallow(<TableElement {...mockedProps} />);
expect(wrapper.find(Link)).to.have.length(3);
});
it('has 14 columns', () => {
const wrapper = shallow(<TableElement {...mockedProps} />);
expect(wrapper.find('div.table-column')).to.have.length(14);
});
});

View File

@@ -16,8 +16,10 @@ from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import inspect
from collections import namedtuple
import inspect
import textwrap
from flask_babel import lazy_gettext as _
Grain = namedtuple('Grain', 'name label function')
@@ -36,7 +38,7 @@ class BaseEngineSpec(object):
return cls.epoch_to_dttm().replace('{col}', '({col}/1000.0)')
@classmethod
def extra_table_metadata(cls, table):
def extra_table_metadata(cls, database, table_name, schema_name):
"""Returns engine-specific table metadata"""
return {}
@@ -109,6 +111,7 @@ class MySQLEngineSpec(BaseEngineSpec):
Grain("month", _('month'), "DATE(DATE_SUB({col}, "
"INTERVAL DAYOFMONTH({col}) - 1 DAY))"),
)
@classmethod
def convert_dttm(cls, target_type, dttm):
if target_type.upper() in ('DATETIME', 'DATE'):
@@ -158,6 +161,46 @@ class PrestoEngineSpec(BaseEngineSpec):
def epoch_to_dttm(cls):
return "from_unixtime({col})"
@staticmethod
def show_partition_pql(
table_name, schema_name=None, order_by=None, limit=100):
if schema_name:
table_name = schema_name + '.' + table_name
order_by = order_by or []
order_by_clause = ''
if order_by:
order_by_clause = "ORDER BY " + ', '.join(order_by) + " DESC"
limit_clause = ''
if limit:
limit_clause = "LIMIT {}".format(limit)
return textwrap.dedent("""\
SHOW PARTITIONS
FROM {table_name}
{order_by_clause}
{limit_clause}
""").format(**locals())
@classmethod
def extra_table_metadata(cls, database, table_name, schema_name):
indexes = database.get_indexes(table_name, schema_name)
if not indexes:
return {}
cols = indexes[0].get('column_names', [])
pql = cls.show_partition_pql(table_name, schema_name, cols)
df = database.get_df(pql, schema_name)
latest_part = df.to_dict(orient='records')[0] if not df.empty else None
partition_query = cls.show_partition_pql(table_name, schema_name, cols)
return {
'partitions': {
'cols': cols,
'latest': latest_part,
'partitionQuery': partition_query,
}
}
class MssqlEngineSpec(BaseEngineSpec):
engine = 'mssql'
@@ -189,7 +232,7 @@ class MssqlEngineSpec(BaseEngineSpec):
@classmethod
def convert_dttm(cls, target_type, dttm):
return "CONVERT(DATETIME, '{}', 126)".format(iso)
return "CONVERT(DATETIME, '{}', 126)".format(dttm.isoformat())
class RedshiftEngineSpec(PostgresEngineSpec):

View File

@@ -1895,6 +1895,10 @@ class Caravel(BaseCaravelView):
return Response(
json.dumps({'error': utils.error_msg_from_exception(e)}),
mimetype="application/json")
indexed_columns = set()
for index in indexes:
indexed_columns |= set(index.get('column_names', []))
for col in t:
dtype = ""
try:
@@ -1905,6 +1909,7 @@ class Caravel(BaseCaravelView):
'name': col['name'],
'type': dtype.split('(')[0] if '(' in dtype else dtype,
'longType': dtype,
'indexed': col['name'] in indexed_columns,
})
tbl = {
'name': table_name,
@@ -1913,6 +1918,16 @@ class Caravel(BaseCaravelView):
}
return Response(json.dumps(tbl), mimetype="application/json")
@has_access
@expose("/extra_table_metadata/<database_id>/<table_name>/<schema>/")
@log_this
def extra_table_metadata(self, database_id, table_name, schema):
schema = None if schema in ('null', 'undefined') else schema
mydb = db.session.query(models.Database).filter_by(id=database_id).one()
payload = mydb.db_engine_spec.extra_table_metadata(
mydb, table_name, schema)
return Response(json.dumps(payload), mimetype="application/json")
@has_access
@expose("/select_star/<database_id>/<table_name>/")
@log_this

View File

@@ -5,6 +5,7 @@ from __future__ import print_function
from __future__ import unicode_literals
import imp
import json
import os
import unittest
@@ -124,6 +125,18 @@ class CaravelTestCase(unittest.TestCase):
resp = self.client.get(url, follow_redirects=True)
return resp.data.decode('utf-8')
def get_json_resp(self, url):
"""Shortcut to get the parsed results while following redirects"""
resp = self.get_resp(url)
return json.loads(resp)
def get_main_database(self, session):
return (
db.session.query(models.Database)
.filter_by(database_name='main')
.first()
)
def get_access_requests(self, username, ds_type, ds_id):
DAR = models.DatasourceAccessRequest
return (

View File

@@ -15,10 +15,7 @@ import unittest
from flask import escape
from flask_appbuilder.security.sqla import models as ab_models
import caravel
from caravel import app, db, models, utils, appbuilder, sm
from caravel.source_registry import SourceRegistry
from caravel.models import DruidDatasource
from caravel import db, models, utils, appbuilder, sm
from caravel.views import DatabaseView
from .base_tests import CaravelTestCase
@@ -156,12 +153,7 @@ class CoreTests(CaravelTestCase):
assert self.get_resp('/ping') == "OK"
def test_testconn(self):
database = (
db.session
.query(models.Database)
.filter_by(database_name='main')
.first()
)
database = self.get_main_database(db.session)
# validate that the endpoint works with the password-masked sqlalchemy uri
data = json.dumps({
@@ -182,25 +174,23 @@ class CoreTests(CaravelTestCase):
def test_databaseview_edit(self, username='admin'):
# validate that sending a password-masked uri does not over-write the decrypted uri
self.login(username=username)
database = db.session.query(models.Database).filter_by(database_name='main').first()
database = self.get_main_database(db.session)
sqlalchemy_uri_decrypted = database.sqlalchemy_uri_decrypted
url = 'databaseview/edit/{}'.format(database.id)
data = {k: database.__getattribute__(k) for k in DatabaseView.add_columns}
data['sqlalchemy_uri'] = database.safe_sqlalchemy_uri()
response = self.client.post(url, data=data)
database = db.session.query(models.Database).filter_by(database_name='main').first()
self.client.post(url, data=data)
database = self.get_main_database(db.session)
self.assertEqual(sqlalchemy_uri_decrypted, database.sqlalchemy_uri_decrypted)
def test_warm_up_cache(self):
slice = db.session.query(models.Slice).first()
resp = self.get_resp(
data = self.get_json_resp(
'/caravel/warm_up_cache?slice_id={}'.format(slice.id))
data = json.loads(resp)
assert data == [{'slice_id': slice.id, 'slice_name': slice.slice_name}]
resp = self.get_resp(
data = self.get_json_resp(
'/caravel/warm_up_cache?table_name=energy_usage&db_name=main')
data = json.loads(resp)
assert len(data) == 3
def test_shortner(self):
@@ -275,11 +265,7 @@ class CoreTests(CaravelTestCase):
def run_sql(self, sql, user_name, client_id):
self.login(username=user_name)
dbid = (
db.session.query(models.Database)
.filter_by(database_name='main')
.first().id
)
dbid = self.get_main_database(db.session).id
resp = self.client.post(
'/caravel/sql_json/',
data=dict(database_id=dbid, sql=sql, select_as_create_as=False,
@@ -296,9 +282,7 @@ class CoreTests(CaravelTestCase):
assert len(data['error']) > 0
def test_sql_json_has_access(self):
main_db = (
db.session.query(models.Database).filter_by(database_name="main").first()
)
main_db = self.get_main_database(db.session)
utils.merge_perm(sm, 'database_access', main_db.perm)
db.session.commit()
main_db_permission_view = (
@@ -347,16 +331,14 @@ class CoreTests(CaravelTestCase):
self.assertEquals(403, resp.status_code)
self.login('admin')
resp = self.get_resp('/caravel/queries/{}'.format(0))
data = json.loads(resp)
data = self.get_json_resp('/caravel/queries/{}'.format(0))
self.assertEquals(0, len(data))
self.logout()
self.run_sql("SELECT * FROM ab_user", 'admin', client_id='client_id_1')
self.run_sql("SELECT * FROM ab_user1", 'admin', client_id='client_id_2')
self.login('admin')
resp = self.get_resp('/caravel/queries/{}'.format(0))
data = json.loads(resp)
data = self.get_json_resp('/caravel/queries/{}'.format(0))
self.assertEquals(2, len(data))
query = db.session.query(models.Query).filter_by(
@@ -364,8 +346,7 @@ class CoreTests(CaravelTestCase):
query.changed_on = utils.EPOCH
db.session.commit()
resp = self.get_resp('/caravel/queries/{}'.format(123456000))
data = json.loads(resp)
data = self.get_json_resp('/caravel/queries/{}'.format(123456000))
self.assertEquals(1, len(data))
self.logout()
@@ -450,5 +431,12 @@ class CoreTests(CaravelTestCase):
db.session.commit()
self.test_save_dash('alpha')
def test_extra_table_metadata(self):
self.login('admin')
dbid = self.get_main_database(db.session).id
self.get_json_resp(
'/caravel/extra_table_metadata/{dbid}/'
'ab_permission_view/panoramix/'.format(**locals()))
if __name__ == '__main__':
unittest.main()