Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify query based dropdown population #3337

Merged
merged 87 commits into from
Feb 10, 2019
Merged
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
4a557c7
stop testing `collect_query_parameters`, it's an implementation detail
Dec 27, 2018
856e61c
add tests for `missing_query_params`
Dec 27, 2018
5bd751f
rename SQLQuery -> ParameterizedSqlQuery
Dec 30, 2018
f43c5f7
rename sql_query.py to parameterized_query.py
Dec 30, 2018
b608c5b
split to parameterized queries and parameterized SQL queries, where
Dec 30, 2018
ced3239
Merge branch 'tests-for-find-missing-params' into run-tree-validation…
Dec 30, 2018
8798475
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Dec 31, 2018
e49884d
move missing parameter detection to ParameterizedQuery
Dec 31, 2018
07df144
get rid of some old code
Jan 1, 2019
36b3045
fix tests
Jan 1, 2019
a72f781
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 1, 2019
360f85c
set syntax to `custom`
Jan 1, 2019
5239eba
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 1, 2019
a94f5d9
Merge branch 'run-tree-validations-only-on-sql-dialects' of github.co…
Jan 1, 2019
b0b7164
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 3, 2019
23af64e
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 6, 2019
a136cd0
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 13, 2019
639c76f
revert the max-age-related refactoring
Jan 16, 2019
c18675a
Merge branch 'master' into run-tree-validations-only-on-sql-dialects
Jan 16, 2019
6abcedf
👋 tree validations 😢
Jan 16, 2019
5a12c23
BaseQueryRunner is no longer a factory for ParameterizedQuery, for now
Jan 16, 2019
bf41cc9
Merge branch 'master' into validate-parameters
Jan 17, 2019
71e249c
Merge branch 'master' into validate-parameters
Jan 20, 2019
8b7c9c4
add an endpoint for running a query by its id and (optional) parameters
Jan 20, 2019
866d4e2
Merge branch 'master' into textless-query-result-endpoint
Jan 20, 2019
27cd9af
Merge branch 'master' into textless-query-result-endpoint
Jan 20, 2019
d0d0fc4
Merge branch 'textless-query-result-endpoint' into validate-parameters
Jan 20, 2019
61ae426
adds parameter schema to ParameterizedQuery
Jan 20, 2019
6d74494
adds parameter schema validation (currently for strings)
Jan 20, 2019
07fb716
validate number parameters
Jan 20, 2019
80b06e9
validate date parameters
Jan 20, 2019
a685de9
validate parameters on POST /api/queries/<id>/results
Jan 20, 2019
f801b58
validate enum parameters
Jan 21, 2019
42e28e2
validate date range parameters
Jan 21, 2019
5829497
validate query-based dropdowns by preprocessing them at the handler
Jan 21, 2019
f9d1eb5
change _is_date_range to be a tad more succinct
Jan 21, 2019
4eb3935
a single assignment with a `map` is sufficiently explanatory
Jan 21, 2019
1e31775
Update redash/utils/parameterized_query.py
jezdez Jan 21, 2019
6c0387b
Update redash/utils/parameterized_query.py
jezdez Jan 21, 2019
fb81066
Update redash/utils/parameterized_query.py
jezdez Jan 21, 2019
f3cb6eb
Update redash/utils/parameterized_query.py
jezdez Jan 21, 2019
c23add1
Update redash/handlers/query_results.py
jezdez Jan 21, 2019
271cab6
Update redash/utils/parameterized_query.py
jezdez Jan 21, 2019
f7bfde9
build error message inside the error
Jan 21, 2019
3785f09
support all types of numbers as number parameters
Jan 21, 2019
8d8d1f8
check for permissions when populating query-based dropdowns
Jan 21, 2019
4625d31
Merge branch 'master' into textless-query-result-endpoint
Jan 23, 2019
3a8c18b
check for access to query before running it
Jan 23, 2019
ab60338
Merge branch 'master' into validate-parameters
Jan 23, 2019
d737c56
check for empty rows when populating query-based enums
Jan 23, 2019
944f6c1
don't bother loading query results if user doesn't have access
Jan 23, 2019
5e719d4
💥 on unexpected parameter types
Jan 23, 2019
855be00
parameter schema default is a list, not a dictionary
Jan 23, 2019
074c872
Merge branch 'textless-query-result-endpoint' into validate-parameters
Jan 23, 2019
7ba9f19
Merge branch 'master' into validate-parameters
Jan 23, 2019
2d841ea
Merge branch 'validate-parameters' of github.com:getredash/redash int…
Jan 23, 2019
8070466
fix a totally unrelated typo
Jan 23, 2019
37d0fe4
remove redundant null guards
Jan 24, 2019
6ac444b
introduce /dropdown.json endpoint with dummy data
Jan 24, 2019
f022d2c
wire frontend to /dropdown.json
Jan 24, 2019
99ce0d1
always return name/value combos from /dropdown.json
Jan 24, 2019
afcc816
load actual data into /dropdown.json
Jan 24, 2019
7aa0c6b
pluck correct values for `name` and `value`
Jan 24, 2019
97b0469
reuse dropdwon plucking logic in QueryResultResource
Jan 24, 2019
3f2878f
simplify _get_dropdown_values
Jan 24, 2019
d8b75b3
when doing parameter validation, we only care about the value and not
Jan 24, 2019
1b05156
Merge branch 'validate-parameters' into unify-query-based-dropdown-po…
Jan 24, 2019
b9a9086
rename dropdown to dropdownOptions
Jan 24, 2019
ab2fa2c
Merge branch 'master' into unify-query-based-dropdown-population
Jan 27, 2019
1a504d3
move dropdown_values to utils/parameterized_query.py
Jan 27, 2019
184f28b
stop converting queries to enums and encapsulate the work inside
Jan 27, 2019
affbfec
re-order arguments by importance
Jan 27, 2019
40f4664
test query parameter validation
Jan 27, 2019
e261149
Merge branch 'master' into validate-parameters
Jan 27, 2019
5fa75e1
Merge branch 'validate-parameters' into unify-query-based-dropdown-po…
Jan 27, 2019
987e2e2
tests for dropdown_values logic
Jan 28, 2019
6858d82
remove `.json` suffix to the dropdown endpoint
Jan 28, 2019
63c200a
Merge branch 'master' into unify-query-based-dropdown-population
Jan 29, 2019
95b5610
allow `BaseResource` to handle JSON stuff
Jan 29, 2019
b9da589
move _pluck_name_and_value outside its containing method
Jan 29, 2019
ec81933
case-insensitive lookup when plucking name and value
Jan 29, 2019
be84b27
separate concerns and simplify test isolation for `dropdown_values`
Jan 30, 2019
f71962c
pick the default column according to the order specified in the query
Jan 30, 2019
d18abb4
use `current_org` instead of passing `org`
Jan 30, 2019
07edebf
Merge branch 'master' into unify-query-based-dropdown-population
Feb 6, 2019
b48f624
Merge branch 'master' into unify-query-based-dropdown-population
Feb 10, 2019
7744138
test that user has access to the query when calling the /dropdown
Feb 10, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 1 addition & 39 deletions client/app/components/QueryBasedParameterInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,6 @@ import { Query } from '@/services/query';

const { Option } = Select;

function optionsFromQueryResult(queryResult) {
const columns = queryResult.data.columns;
const numColumns = columns.length;
let options = [];
// If there are multiple columns, check if there is a column
// named 'name' and column named 'value'. If name column is present
// in results, use name from name column. Similar for value column.
// Default: Use first string column for name and value.
if (numColumns > 0) {
let nameColumn = null;
let valueColumn = null;
columns.forEach((column) => {
const columnName = column.name.toLowerCase();
if (columnName === 'name') {
nameColumn = column.name;
}
if (columnName === 'value') {
valueColumn = column.name;
}
// Assign first string column as name and value column.
if (nameColumn === null) {
nameColumn = column.name;
}
if (valueColumn === null) {
valueColumn = column.name;
}
});
if (nameColumn !== null && valueColumn !== null) {
options = queryResult.data.rows.map(row => ({
name: row[nameColumn],
value: row[valueColumn],
}));
}
}
return options;
}

export class QueryBasedParameterInput extends React.Component {
static propTypes = {
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
Expand Down Expand Up @@ -81,9 +44,8 @@ export class QueryBasedParameterInput extends React.Component {
_loadOptions(queryId) {
if (queryId && (queryId !== this.state.queryId)) {
this.setState({ loading: true });
Query.resultById({ id: queryId }, (result) => {
Query.dropdownOptions({ id: queryId }, (options) => {
if (this.props.queryId === queryId) {
const options = optionsFromQueryResult(result.query_result);
this.setState({ options, loading: false });

const found = find(options, option => option.value === this.props.value) !== undefined;
Expand Down
5 changes: 5 additions & 0 deletions client/app/services/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ function QueryResource(
isArray: false,
url: 'api/queries/:id/results.json',
},
dropdownOptions: {
method: 'get',
isArray: true,
url: 'api/queries/:id/dropdown.json',
},
favorites: {
method: 'get',
isArray: false,
Expand Down
11 changes: 6 additions & 5 deletions redash/handlers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource
from redash.handlers.events import EventsResource
from redash.handlers.queries import QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource
from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource
from redash.handlers.query_results import QueryResultListResource, QueryResultDropdownResource, QueryResultResource, JobResource
from redash.handlers.users import UserResource, UserListResource, UserInviteResource, UserResetPasswordResource, UserDisableResource, UserRegenerateApiKeyResource
from redash.handlers.visualizations import VisualizationListResource
from redash.handlers.visualizations import VisualizationResource
Expand Down Expand Up @@ -69,10 +69,10 @@ def json_representation(data, code, headers=None):

api.add_org_resource(EventsResource, '/api/events', endpoint='events')

api.add_org_resource(QueryFavoriteListResource, '/api/queries/favorites', endpoint='query_fovorites')
api.add_org_resource(QueryFavoriteResource, '/api/queries/<query_id>/favorite', endpoint='query_fovorite')
api.add_org_resource(DashboardFavoriteListResource, '/api/dashboards/favorites', endpoint='dashboard_fovorites')
api.add_org_resource(DashboardFavoriteResource, '/api/dashboards/<object_id>/favorite', endpoint='dashboard_fovorite')
api.add_org_resource(QueryFavoriteListResource, '/api/queries/favorites', endpoint='query_favorites')
rauchy marked this conversation as resolved.
Show resolved Hide resolved
api.add_org_resource(QueryFavoriteResource, '/api/queries/<query_id>/favorite', endpoint='query_favorite')
api.add_org_resource(DashboardFavoriteListResource, '/api/dashboards/favorites', endpoint='dashboard_favorites')
api.add_org_resource(DashboardFavoriteResource, '/api/dashboards/<object_id>/favorite', endpoint='dashboard_favorite')

api.add_org_resource(QueryTagsResource, '/api/queries/tags', endpoint='query_tags')
api.add_org_resource(DashboardTagsResource, '/api/dashboards/tags', endpoint='dashboard_tags')
Expand All @@ -89,6 +89,7 @@ def json_representation(data, code, headers=None):
api.add_org_resource(CheckPermissionResource, '/api/<object_type>/<object_id>/acl/<access_type>', endpoint='check_permissions')

api.add_org_resource(QueryResultListResource, '/api/query_results', endpoint='query_results')
api.add_org_resource(QueryResultDropdownResource, '/api/queries/<query_id>/dropdown.json', endpoint='query_result_dropdown')
rauchy marked this conversation as resolved.
Show resolved Hide resolved
api.add_org_resource(QueryResultResource,
'/api/query_results/<query_result_id>.<filetype>',
'/api/query_results/<query_result_id>',
Expand Down
16 changes: 12 additions & 4 deletions redash/handlers/query_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from redash.tasks import QueryTask
from redash.tasks.queries import enqueue_query
from redash.utils import (collect_parameters_from_request, gen_query_hash, json_dumps, utcnow)
from redash.utils.parameterized_query import ParameterizedQuery
from redash.utils.parameterized_query import ParameterizedQuery, dropdown_values


def error_response(message):
Expand Down Expand Up @@ -64,7 +64,7 @@ def run_query_sync(data_source, parameter_values, query_text, max_age=0):
return None


def run_query(data_source, parameter_values, query_text, query_id, max_age=0):
def run_query(data_source, parameter_values, query_text, query_id, max_age=0, parameter_schema=None):
if data_source.paused:
if data_source.pause_reason:
message = '{} is paused ({}). Please try later.'.format(data_source.name, data_source.pause_reason)
Expand All @@ -73,7 +73,7 @@ def run_query(data_source, parameter_values, query_text, query_id, max_age=0):

return error_response(message)

query = ParameterizedQuery(query_text).apply(parameter_values)
query = ParameterizedQuery(query_text, parameter_schema, data_source.org).apply(parameter_values)

if query.missing_params:
return error_response(u'Missing parameter value for: {}'.format(u", ".join(query.missing_params)))
Expand Down Expand Up @@ -131,6 +131,13 @@ def post(self):
ONE_YEAR = 60 * 60 * 24 * 365.25


class QueryResultDropdownResource(BaseResource):
def get(self, query_id):
data = dropdown_values(query_id, self.current_org)
headers = {'Content-Type': "application/json"}
return make_response(json_dumps(data), 200, headers)
arikfr marked this conversation as resolved.
Show resolved Hide resolved


class QueryResultResource(BaseResource):
@staticmethod
def add_cors_headers(headers):
Expand Down Expand Up @@ -171,11 +178,12 @@ def post(self, query_id):
max_age = int(params.get('max_age', 0))

query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
parameter_schema = query.options.get("parameters", [])

if not has_access(query.data_source.groups, self.current_user, not_view_only):
return {'job': {'status': 4, 'error': 'You do not have permission to run queries with this data source.'}}, 403
else:
return run_query(query.data_source, parameters, query.query_text, query_id, max_age)
return run_query(query.data_source, parameters, query.query_text, query_id, max_age, parameter_schema=parameter_schema)

@require_permission('view_query')
def get(self, query_id=None, query_result_id=None, filetype='json'):
Expand Down
80 changes: 76 additions & 4 deletions redash/utils/parameterized_query.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import pystache
from redash.utils import mustache_render
from flask_login import current_user
from numbers import Number
from redash import models
from redash.utils import mustache_render, json_loads
from redash.permissions import require_access, view_only
from funcy import distinct
from dateutil.parser import parse


def dropdown_values(query_id, org):
def _pluck_name_and_value(row):
arikfr marked this conversation as resolved.
Show resolved Hide resolved
name_column = "name" if "name" in row.keys() else row.keys()[-1]
rauchy marked this conversation as resolved.
Show resolved Hide resolved
value_column = "value" if "value" in row.keys() else row.keys()[-1]

return {"name": row[name_column], "value": row[value_column]}

query = models.Query.get_by_id_and_org(query_id, org)
require_access(query.data_source.groups, current_user, view_only)
rauchy marked this conversation as resolved.
Show resolved Hide resolved
query_result = models.QueryResult.get_by_id_and_org(query.latest_query_data_id, org)

rows = json_loads(query_result.data)["rows"]

return map(_pluck_name_and_value, rows)


def _collect_key_names(nodes):
Expand Down Expand Up @@ -33,17 +54,62 @@ def _parameter_names(parameter_values):
return names


def _is_date(string):
try:
parse(string)
return True
except ValueError:
return False


def _is_date_range(obj):
try:
return _is_date(obj["start"]) and _is_date(obj["end"])
except (KeyError, TypeError):
return False


class ParameterizedQuery(object):
def __init__(self, template):
def __init__(self, template, schema=None, org=None):
self.schema = schema or []
self.template = template
self.query = template
self.parameters = {}
self.org = org

def apply(self, parameters):
self.parameters.update(parameters)
self.query = mustache_render(self.template, self.parameters)
invalid_parameter_names = [key for (key, value) in parameters.iteritems() if not self._valid(key, value)]
if invalid_parameter_names:
raise InvalidParameterError(invalid_parameter_names)
else:
self.parameters.update(parameters)
self.query = mustache_render(self.template, self.parameters)

return self

def _valid(self, name, value):
definition = next((definition for definition in self.schema if definition["name"] == name), None)

if not definition:
return True

validators = {
"text": lambda value: isinstance(value, basestring),
"number": lambda value: isinstance(value, Number),
"enum": lambda value: value in definition["enumOptions"],
"query": lambda value: value in [v["value"] for v in dropdown_values(definition["queryId"], self.org)],
"date": _is_date,
"datetime-local": _is_date,
"datetime-with-seconds": _is_date,
"date-range": _is_date_range,
"datetime-range": _is_date_range,
"datetime-range-with-seconds": _is_date_range,
}

validate = validators.get(definition["type"], lambda x: False)

return validate(value)

@property
def missing_params(self):
query_parameters = set(_collect_query_parameters(self.template))
Expand All @@ -52,3 +118,9 @@ def missing_params(self):
@property
def text(self):
return self.query


class InvalidParameterError(Exception):
def __init__(self, parameters):
message = u"The following parameter values are incompatible with their type definitions: {}".format(", ".join(parameters))
super(InvalidParameterError, self).__init__(message)
Loading