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 @@
-
-
+
-
-
-
-
- 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}} |
-
-
-
-
+
+
+
+
+
+
+ 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):