diff --git a/client/app/components/app-header/index.js b/client/app/components/app-header/index.js index ca631d975a..fc0c571f1e 100644 --- a/client/app/components/app-header/index.js +++ b/client/app/components/app-header/index.js @@ -6,7 +6,7 @@ import './app-header.css'; const logger = debug('redash:appHeader'); -function controller($rootScope, $location, $uibModal, Auth, currentUser, clientConfig, Dashboard) { +function controller($rootScope, $location, $route, $uibModal, Auth, currentUser, clientConfig, Dashboard) { this.logoUrl = logoUrl; this.basePath = clientConfig.basePath; this.currentUser = currentUser; @@ -35,7 +35,8 @@ function controller($rootScope, $location, $uibModal, Auth, currentUser, clientC }; this.searchQueries = () => { - $location.path('/queries/search').search({ q: this.term }); + $location.path('/queries').search({ search: this.term }); + $route.reload(); }; this.logout = () => { diff --git a/client/app/lib/pagination/live-paginator.js b/client/app/lib/pagination/live-paginator.js index 8204117ccb..24c5fd9fe0 100644 --- a/client/app/lib/pagination/live-paginator.js +++ b/client/app/lib/pagination/live-paginator.js @@ -1,14 +1,34 @@ export default class LivePaginator { - constructor(rowsFetcher, { page = 1, itemsPerPage = 20 } = {}) { + constructor(rowsFetcher, { + page = 1, + itemsPerPage = 20, + orderByField, + orderByReverse = false, + params = {}, + } = {}) { this.page = page; this.itemsPerPage = itemsPerPage; + this.orderByField = orderByField; + this.orderByReverse = orderByReverse; + this.params = params; this.rowsFetcher = rowsFetcher; - this.rowsFetcher(this.page, this.itemsPerPage, this); + this.fetch(this.page); + } + + fetch(page) { + this.rowsFetcher( + page, + this.itemsPerPage, + this.orderByField, + this.orderByReverse, + this.params, + this, + ); } setPage(page) { this.page = page; - this.rowsFetcher(page, this.itemsPerPage, this); + this.fetch(page); } getPageRows() { @@ -23,5 +43,17 @@ export default class LivePaginator { this.totalCount = 0; } } -} + orderBy(column) { + if (column === this.orderByField) { + this.orderByReverse = !this.orderByReverse; + } else { + this.orderByField = column; + this.orderByReverse = false; + } + + if (this.orderByField) { + this.fetch(this.page); + } + } +} diff --git a/client/app/pages/queries-list/index.js b/client/app/pages/queries-list/index.js index 9a44e37a1b..a5ea2621e6 100644 --- a/client/app/pages/queries-list/index.js +++ b/client/app/pages/queries-list/index.js @@ -1,12 +1,23 @@ import moment from 'moment'; +import { isString } from 'underscore'; +import startsWith from 'underscore.string/startsWith'; import { LivePaginator } from '@/lib/pagination'; import template from './queries-list.html'; + class QueriesListCtrl { - constructor($location, Title, Query) { + constructor($location, $log, $route, Title, Query) { const page = parseInt($location.search().page || 1, 10); - + const orderSeparator = '-'; + const pageOrder = $location.search().order || 'created_at'; + const pageOrderReverse = startsWith(pageOrder, orderSeparator); + this.showEmptyState = false; + this.showDrafts = false; + this.pageSize = parseInt($location.search().page_size || 20, 10); + this.pageSizeOptions = [5, 10, 20, 50, 100]; + this.searchTerm = $location.search().search || ''; + this.oldSearchTerm = $location.search().q; this.defaultOptions = {}; const self = this; @@ -20,16 +31,42 @@ class QueriesListCtrl { Title.set('My Queries'); this.resource = Query.myQueries; break; + // Redirect to the real search view. + // TODO: check if that really always works. + case '/queries/search': + window.location.replace('/queries?q=' + this.oldSearchTerm); + break; default: break; } - function queriesFetcher(requestedPage, itemsPerPage, paginator) { + const setSearchOrClear = (name, value) => { + if (value) { + $location.search(name, value); + } else { + $location.search(name, undefined); + } + }; + + function queriesFetcher(requestedPage, itemsPerPage, orderByField, orderByReverse, params, paginator) { $location.search('page', requestedPage); + $location.search('page_size', itemsPerPage); + if (orderByReverse && !startsWith(orderByField, orderSeparator)) { + orderByField = orderSeparator + orderByField; + } + setSearchOrClear('order', orderByField); + setSearchOrClear('search', params.searchTerm); + setSearchOrClear('drafts', params.showDrafts); const request = Object.assign( {}, self.defaultOptions, - { page: requestedPage, page_size: itemsPerPage }, + { + page: requestedPage, + page_size: itemsPerPage, + order: orderByField, + search: params.searchTerm, + drafts: params.showDrafts, + }, ); return self.resource(request).$promise.then((data) => { @@ -43,16 +80,49 @@ class QueriesListCtrl { }); } - this.paginator = new LivePaginator(queriesFetcher, { page }); + this.paginator = new LivePaginator( + queriesFetcher, + { + page, + itemsPerPage: this.pageSize, + orderByField: pageOrder, + orderByReverse: pageOrderReverse, + params: this.parameters, + }, + ); + + this.parameters = () => ({ + searchTerm: this.searchTerm, + showDrafts: this.showDrafts, + }); this.tabs = [ { path: 'queries', name: 'All Queries', isActive: path => path === '/queries' }, { name: 'My Queries', path: 'queries/my' }, - { name: 'Search', path: 'queries/search' }, ]; - this.showList = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length > 0; - this.showEmptyState = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length === 0; + this.searchUsed = () => this.searchTerm !== undefined || this.searchTerm !== ''; + + this.hasResults = () => this.paginator.getPageRows() !== undefined && + this.paginator.getPageRows().length > 0; + + this.showEmptyState = () => !this.hasResults() && !this.searchUsed(); + + this.showDraftsCheckbox = () => $location.path() !== '/queries/my'; + + this.clearSearch = () => { + this.searchTerm = ''; + this.update(); + }; + + this.update = () => { + if (!isString(this.searchTerm) || this.searchTerm.trim() === '') { + this.searchTerm = ''; + } + this.paginator.itemsPerPage = this.pageSize; + this.paginator.params = this.parameters(); + this.paginator.fetch(page); + }; } } @@ -71,5 +141,10 @@ export default function init(ngModule) { template: '', reloadOnSearch: false, }, + // just for backward-compatible routes + '/queries/search': { + template: '', + reloadOnSearch: false, + }, }; } diff --git a/client/app/pages/queries-list/queries-list.html b/client/app/pages/queries-list/queries-list.html index e4a62ef24d..c63d36bdd3 100644 --- a/client/app/pages/queries-list/queries-list.html +++ b/client/app/pages/queries-list/queries-list.html @@ -4,39 +4,75 @@ -
- + -
- - - - - - - - - - - - - - - - - - - - - -
NameCreated ByCreated AtRuntimeLast Executed AtUpdate Schedule
- {{query.name}} - Unpublished - - - {{query.user.name}} - {{query.created_at | dateTime}}{{query.runtime | durationHumanize}}{{query.retrieved_at | dateTime}}{{query.schedule | scheduleHumanize}}
- +
+
+
+
+
+ + + + + +
+
+
+
+
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Name Created By Created At Runtime Last Executed At Update Schedule
+ {{query.name}} + Unpublished + + + {{query.user.name}} + {{query.created_at | dateTime}}{{query.runtime | durationHumanize}}{{query.retrieved_at | dateTime}}{{query.schedule | scheduleHumanize}}
No queries found.
+ +
diff --git a/client/app/pages/queries/queries-search-results-page.html b/client/app/pages/queries/queries-search-results-page.html deleted file mode 100644 index dd39ec67dc..0000000000 --- a/client/app/pages/queries/queries-search-results-page.html +++ /dev/null @@ -1,43 +0,0 @@ -
- - -
-
-
- - - - -
-
-
-
- - - - - - - - - - - - - - - - - - - - -
Name Created By Created At Update Schedule
{{query.name}} Unpublished - - {{query.user.name}} - {{query.created_at | dateTime}}{{query.schedule | scheduleHumanize}}
No results found.
- -
-
diff --git a/client/app/pages/queries/queries-search-results-page.js b/client/app/pages/queries/queries-search-results-page.js deleted file mode 100644 index 0742fc6da1..0000000000 --- a/client/app/pages/queries/queries-search-results-page.js +++ /dev/null @@ -1,66 +0,0 @@ -import moment from 'moment'; -import { isString } from 'underscore'; - -import { Paginator } from '@/lib/pagination'; -import template from './queries-search-results-page.html'; - -function QuerySearchCtrl($location, $filter, currentUser, Events, Query) { - this.term = $location.search().q; - this.paginator = new Paginator([], { itemsPerPage: 20 }); - - this.tabs = [ - { path: 'queries', name: 'All Queries', isActive: path => path === '/queries' }, - { name: 'My Queries', path: 'queries/my' }, - { name: 'Search', path: 'queries/search' }, - ]; - - Query.search({ q: this.term, include_drafts: true }, (results) => { - const queries = results.map((query) => { - query.created_at = moment(query.created_at); - return query; - }); - - this.paginator.updateRows(queries); - }); - - this.createdAtSort = row => row.created_at.valueOf(); - this.createdBySort = row => row.user.name; - this.scheduleSort = (row) => { - if (!row.schedule) { - return null; - } - if (row.schedule.match(/\d\d:\d\d/) !== null) { - const parts = row.schedule.split(':'); - const localTime = moment.utc() - .hour(parts[0]) - .minute(parts[1]); - return localTime.valueOf(); - } - return parseInt(row.schedule, 10); - }; - - this.search = () => { - if (!isString(this.term) || this.term.trim() === '') { - this.paginator.updateRows([]); - } else { - $location.search({ q: this.term }); - } - }; - - Events.record('search', 'query', '', { term: this.term }); -} - -export default function init(ngModule) { - ngModule.component('queriesSearchResultsPage', { - template, - controller: QuerySearchCtrl, - }); - - return { - '/queries/search': { - template: '', - reloadOnSearch: true, - title: 'Queries Search', - }, - }; -} diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index ec7cdb8458..853de9de43 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -1,13 +1,15 @@ from itertools import chain import sqlparse -from flask import jsonify, request +from flask import jsonify, request, url_for from flask_login import login_required from flask_restful import abort from funcy import distinct, take from sqlalchemy.orm.exc import StaleDataError +from sqlalchemy_utils import sort_query from redash import models, settings +from redash.authentication.org_resolving import current_org from redash.handlers.base import (BaseResource, get_object_or_404, org_scoped_rule, paginate, routes) from redash.handlers.query_results import run_query @@ -18,6 +20,34 @@ from redash.utils import collect_parameters_from_request +# Ordering map for relationships +order_map = { + 'name': 'lowercase_name', + '-name': '-lowercase_name', + 'runtime': 'query_results-runtime', + '-runtime': '-query_results-runtime', + 'executed_at': 'query_results-retrieved_at', + '-executed_at': '-query_results-retrieved_at', + 'created_by': 'users-name', + '-created_by': '-users-name', +} + + +def order_results(results, default_order='-created_at'): + """ + Orders the given results with the sort order as requested in the + "order" request query parameter or the given default order. + """ + # See if a particular order has been requested + order = request.args.get('order', '').strip() or default_order + # and if it matches a long-form for related fields, falling + # back to the default order + selected_order = order_map.get(order, order) + # The query may already have an ORDER BY statement attached + # so we clear it here and apply the selected order + return sort_query(results.order_by(None), selected_order) + + @routes.route(org_scoped_rule('/api/queries/format'), methods=['POST']) @login_required def format_sql_query(org_slug=None): @@ -40,6 +70,7 @@ def get(self): Search query text, names, and descriptions. :qparam string q: Search term + :qparam number include_drafts: Whether to include draft in results Responds with a list of :ref:`query ` objects. """ @@ -49,11 +80,14 @@ def get(self): include_drafts = request.args.get('include_drafts') is not None - return [q.to_dict(with_last_modified_by=False) - for q in models.Query.search(term, - self.current_user.group_ids, - include_drafts=include_drafts, - limit=None)] + # this redirects to the new query list API that is aware of search + new_location = url_for( + 'queries', + org_slug=current_org.slug, + search=term, + drafts='true' if include_drafts else 'false' + ) + return {}, 301, {'Location': new_location} class QueryRecentResource(BaseResource): @@ -145,16 +179,48 @@ def get(self): """ Retrieve a list of queries. - :qparam number page_size: Number of queries to return + :qparam number page_size: Number of queries to return per page :qparam number page: Page number to retrieve + :qparam number order: Name of column to order by + :qparam number search: Full text search term + :qparam number drafts: Whether to include draft in results Responds with an array of :ref:`query ` objects. """ - - results = models.Query.all_queries(self.current_user.group_ids, self.current_user.id) - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 25, type=int) - return paginate(results, page, page_size, lambda q: q.to_dict(with_stats=True, with_last_modified_by=False)) + # See if we want to do full-text search or just regular queries + term = request.args.get('search', '') + drafts = request.args.get('drafts', '').lower() == 'true' + if term: + self.record_event({ + 'action': 'search', + 'object_id': term, + 'object_type': 'query', + }) + results = models.Query.search( + term, + self.current_user.group_ids, + self.current_user.id, + include_drafts=drafts, + ) + else: + results = models.Query.all_queries( + self.current_user.group_ids, + self.current_user.id, + drafts=drafts, + ) + + # order results according to passed order parameter + ordered_results = order_results(results) + + return paginate( + ordered_results, + page=request.args.get('page', 1, type=int), + page_size=request.args.get('page_size', 25, type=int), + serializer=lambda q: q.to_dict( + with_stats=True, + with_last_modified_by=False + ), + ) class MyQueriesResource(BaseResource): @@ -165,13 +231,29 @@ def get(self): :qparam number page_size: Number of queries to return :qparam number page: Page number to retrieve + :qparam number order: Name of column to order by + :qparam number search: Full text search term + :qparam number include_drafts: Whether to include draft in results Responds with an array of :ref:`query ` objects. """ - drafts = request.args.get('drafts') is not None - results = models.Query.by_user(self.current_user) + term = request.args.get('search', '') + if term: + self.record_event({ + 'action': 'search', + 'object_id': term, + 'object_type': 'query', + }) + results = models.Query.search_by_user(term, self.current_user) + else: + results = models.Query.by_user(self.current_user) + + # order results according to passed order parameter + results = order_results(results) + page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 25, type=int) + return paginate(results, page, page_size, lambda q: q.to_dict(with_stats=True, with_last_modified_by=False)) diff --git a/redash/models.py b/redash/models.py index c5322954cd..541b902de8 100644 --- a/redash/models.py +++ b/redash/models.py @@ -29,9 +29,10 @@ from sqlalchemy import distinct, or_ from sqlalchemy.dialects import postgresql from sqlalchemy.event import listens_for +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.mutable import Mutable from sqlalchemy.inspection import inspect -from sqlalchemy.orm import backref, joinedload, object_session +from sqlalchemy.orm import backref, joinedload, object_session, contains_eager from sqlalchemy.orm.exc import NoResultFound # noqa: F401 from sqlalchemy.types import TypeDecorator from sqlalchemy.orm.attributes import flag_modified @@ -522,7 +523,7 @@ def get_by_api_key_and_org(cls, api_key, org): @classmethod def all(cls, org): return cls.query.filter(cls.org == org) - + @classmethod def all_not_disabled(cls, org): return cls.all(org).filter(cls.disabled_at == None) @@ -973,20 +974,50 @@ def create(cls, **kwargs): @classmethod def all_queries(cls, group_ids, user_id=None, drafts=False): - query_ids = (db.session.query(distinct(cls.id)) - .join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id) - .filter(Query.is_archived == False) - .filter(DataSourceGroup.group_id.in_(group_ids))) - - q = (cls.query - .options(joinedload(Query.user), - joinedload(Query.latest_query_data).load_only('runtime', 'retrieved_at')) - .filter(cls.id.in_(query_ids)) - .order_by(Query.created_at.desc())) + query_ids = ( + db.session + .query(distinct(cls.id)) + .join( + DataSourceGroup, + Query.data_source_id == DataSourceGroup.data_source_id + ) + .filter(Query.is_archived == False) + .filter(DataSourceGroup.group_id.in_(group_ids)) + ) + + q = ( + cls + .query + .options( + joinedload(Query.user), + joinedload( + Query.latest_query_data + ).load_only( + 'runtime', + 'retrieved_at', + ) + ) + .filter(cls.id.in_(query_ids)) + # Adding outer joins to be able to order by relationship + .outerjoin(User, User.id == Query.user_id) + .outerjoin( + QueryResult, + QueryResult.id == Query.latest_query_data_id + ) + .options( + contains_eager(Query.user), + contains_eager(Query.latest_query_data), + ) + .order_by(Query.created_at.desc()) + ) if not drafts: - q = q.filter(or_(Query.is_draft == False, Query.user_id == user_id)) - + q = q.filter( + or_( + Query.is_draft == False, + Query.user_id == user_id + ) + ) return q @classmethod @@ -1019,24 +1050,14 @@ def outdated_queries(cls): return outdated_queries.values() @classmethod - def search(cls, term, group_ids, include_drafts=False, limit=20): - where = cls.is_archived == False - - if not include_drafts: - where &= cls.is_draft == False + def search(cls, term, group_ids, user_id=None, include_drafts=False, limit=None): + all_queries = cls.all_queries(group_ids, user_id=user_id, drafts=include_drafts) + # sort the result using the weight as defined in the search vector column + return all_queries.search(term, sort=True).limit(limit) - where &= DataSourceGroup.group_id.in_(group_ids) - - return cls.query.join( - DataSourceGroup, - cls.data_source_id == DataSourceGroup.data_source_id - ).options( - joinedload(cls.user) - ).filter(where).search( - term, - # sort the result using the weight as defined in the search vector column - sort=True - ).distinct().limit(limit) + @classmethod + def search_by_user(cls, term, user, limit=None): + return cls.by_user(user).search(term, sort=True).limit(limit) @classmethod def recent(cls, group_ids, user_id=None, limit=20): @@ -1099,6 +1120,16 @@ def groups(self): return self.data_source.groups + @hybrid_property + def lowercase_name(self): + "Optional property useful for sorting purposes." + return self.name.lower() + + @lowercase_name.expression + def lowercase_name(cls): + "The SQLAlchemy expression for the property above." + return func.lower(cls.name) + def __unicode__(self): return unicode(self.id) diff --git a/requirements.txt b/requirements.txt index 7d6dcd7354..d1c94e6f39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ pytz==2016.7 redis==2.10.5 requests==2.11.1 six==1.10.0 -SQLAlchemy==1.1.4 +SQLAlchemy==1.2.7 SQLAlchemy-Searchable==0.10.6 SQLAlchemy-Utils>=0.29.0 sqlparse==0.2.4 diff --git a/tests/__init__.py b/tests/__init__.py index 5e5b486f16..1673485b33 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -45,6 +45,7 @@ def setUp(self): self.app = create_app() self.db = db self.app.config['TESTING'] = True + self.app.config['SERVER_NAME'] = 'localhost' self.app_ctx = self.app.app_context() self.app_ctx.push() db.session.close() @@ -60,7 +61,7 @@ def tearDown(self): redis_connection.flushdb() def make_request(self, method, path, org=None, user=None, data=None, - is_json=True): + is_json=True, follow_redirects=False): if user is None: user = self.factory.user @@ -84,7 +85,13 @@ def make_request(self, method, path, org=None, user=None, data=None, else: content_type = None - response = method_fn(path, data=data, headers=headers, content_type=content_type) + response = method_fn( + path, + data=data, + headers=headers, + content_type=content_type, + follow_redirects=follow_redirects, + ) if response.data and is_json: response.json = json.loads(response.data) diff --git a/tests/handlers/test_queries.py b/tests/handlers/test_queries.py index 52b4eac626..822b7358ba 100644 --- a/tests/handlers/test_queries.py +++ b/tests/handlers/test_queries.py @@ -49,6 +49,35 @@ def test_query_only_accessible_to_users_from_its_organization(self): rv = self.make_request('get', '/api/queries/{}'.format(query.id), user=self.factory.create_admin()) self.assertEquals(rv.status_code, 200) + def test_query_search(self): + names = [ + 'Harder', + 'Better', + 'Faster', + 'Stronger', + ] + for name in names: + self.factory.create_query(name=name) + + rv = self.make_request('get', '/api/queries?search=better') + + self.assertEquals(rv.status_code, 200) + self.assertEquals(len(rv.json['results']), 1) + + rv = self.make_request('get', '/api/queries?search=better OR faster') + + self.assertEquals(rv.status_code, 200) + self.assertEquals(len(rv.json['results']), 2) + + # test the old search API and that it redirects to the new one + rv = self.make_request('get', '/api/queries/search?q=stronger') + self.assertEquals(rv.status_code, 301) + self.assertIn('/api/queries?search=stronger', rv.headers['Location']) + + rv = self.make_request('get', '/api/queries/search?q=stronger', follow_redirects=True) + self.assertEquals(rv.status_code, 200) + self.assertEquals(len(rv.json['results']), 1) + class TestQueryResourcePost(BaseTestCase): def test_update_query(self): @@ -158,7 +187,7 @@ def test_refresh_query_you_dont_have_access_to(self): user = self.factory.create_user(group_ids=[group.id]) response = self.make_request('post', self.path, user=user) self.assertEqual(403, response.status_code) - + def test_refresh_forbiden_with_query_api_key(self): response = self.make_request('post', '{}?api_key={}'.format(self.path, self.query.api_key), user=False) self.assertEqual(403, response.status_code) diff --git a/tests/test_models.py b/tests/test_models.py index 35b5e781bd..4b931d4768 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -356,6 +356,20 @@ def test_includes_drafts_of_given_user(self): q = self.factory.create_query(is_draft=True) self.assertIn(q, models.Query.all_queries([self.factory.default_group.id], user_id=q.user_id)) + def test_order_by_relationship(self): + u1 = self.factory.create_user(name='alice') + u2 = self.factory.create_user(name='bob') + self.factory.create_query(user=u1) + self.factory.create_query(user=u2) + db.session.commit() + # have to reset the order here with None since all_queries orders by + # created_at by default + base = models.Query.all_queries([self.factory.default_group.id]).order_by(None) + qs1 = base.order_by(models.User.name) + self.assertEqual(['alice', 'bob'], [q.user.name for q in qs1]) + qs2 = base.order_by(models.User.name.desc()) + self.assertEqual(['bob', 'alice'], [q.user.name for q in qs2]) + class TestGroup(BaseTestCase): def test_returns_groups_with_specified_names(self):