From 0c25d54836f241aebe1470df758781d8242de3c5 Mon Sep 17 00:00:00 2001 From: gnaponie Date: Mar 21 2019 08:31:22 +0000 Subject: Latest endpoint: group by additional fields The results/latest endpoint groups the results by testcase. Sometimes it might be useful to group by additional fields in the ResultData table. This new version of the API endpoint gets an additional parameter "group_by" that takes a list of fields (separated by ",") on which will be done the group by. This new feature is not available in case no other filtering is provided. In that case the API returns an 400 error, since it wouldn't be possible to retrieve the requested results with an efficient request. --- diff --git a/APIDOCS.apiary b/APIDOCS.apiary index a70894b..6282f7e 100644 --- a/APIDOCS.apiary +++ b/APIDOCS.apiary @@ -179,7 +179,7 @@ Examples are provided in the Parameters section of the documentation. } -## Get a list of current Results for a specified filter _FIXME: reword to make more sense_ [GET /results/latest{?keyval,testcase,groups,since}] +## Get a list of current Results for a specified filter _FIXME: reword to make more sense_ [GET /results/latest{?keyval,testcases,groups,since}] Especially with automation in mind, a simpe query to get the latest `Results` of all the `Testcases` based on a filter makes a lot of sense. For example Koji could be interested in data like "All current results for the `koji_build` `koschei-1.7.2-1.fc24`", without @@ -189,21 +189,26 @@ This endpoint does just that - takes filter parameters, and returns the most rec Only `Testcases` with at least one `Result` that meet the filter are present - e.g. if ResultsDB contained `dist.rpmlint` and `dist.rpmgrill` `Testcases`, but there was only a `dist.rpmlint` `Result` for the `koschei-1.7.2-1.fc24` `koji_build`, just `dist.rpmlint`'s `Result` would be returned. +An additional available parameter is `_distinct_on`, if specified allows the user to group by additional fields (example: `scenario`). + + Parameters + keyval (string) - Any key-value pair in `Result.data`. Replace `keyval` with the key's name: `...&item=koschei-1.7.2-1.fc24` - Multiple values can be provided, separate by commas to get `or` filter based on all the values provided: `...&arch=x86_64,noarch` - `like` filter with `*` as wildcards: `...&item:like=koschei*fc24*` - Multiple key-value pairs provide `and` filter, e.g. to search for all `Results` with `item` like `koschei*fc24*` and `arch` being either `noarch` or `x86_64`: `...&item:like=koschei*fc24*&arch=noarch` - + testcase (string, optional) + + testcases (string, optional) - Use to narrow down `Testcases` of interest. By default, all `Testcases` are searched for `Results` - - Multiple values can be provided, separate by coma to get `or` filter based on all the values provided: `...&testcase=dist.rpmlint,dist.depcheck` - - `like` filter with `*` as wildcards: `...&testcase:like=dist.*` + - Multiple values can be provided, separate by comma to get `or` filter based on all the values provided: `...&testcases=dist.rpmlint,dist.depcheck` + - `like` filter with `*` as wildcards: `...&testcases:like=dist.*` + groups: `27f94e36-62ec-11e6-83fd-525400d7d6a4` (string, optional) - - Multiple values can be provided, separate by commas to get `or` filter based on all the values provided: `...&group=uuid1,uuid2` + - Multiple values can be provided, separate by commas to get `or` filter based on all the values provided: `...&groups=uuid1,uuid2` + since: `2016-08-15T13:00:00` (string) Date (or datetime) in ISO8601 format. To specify range, separate start and end date(time) by comma: `...&since=2016-08-14,2016-08-15T13:42:57` + + _distinct_on: `scenario` (string, optional) + - The value can be any `key` in `Result.data`. Example: `...&_distinct_on=scenario` + - Multiple values can be provided, separate by comma. Example: `...&_distinct_on=scenario,item` + Request `.../results/latest?item=koschei-1.7.2-1.fc24&type=koji_build` + Parameters diff --git a/resultsdb/controllers/api_v2.py b/resultsdb/controllers/api_v2.py index ffce47c..3b8a086 100644 --- a/resultsdb/controllers/api_v2.py +++ b/resultsdb/controllers/api_v2.py @@ -26,6 +26,8 @@ from flask import Blueprint, jsonify, request, url_for from flask_restful import reqparse from sqlalchemy.orm import exc as orm_exc +from sqlalchemy import distinct +from sqlalchemy.sql import text from werkzeug.exceptions import HTTPException from werkzeug.exceptions import BadRequest as JSONBadRequest @@ -319,22 +321,7 @@ def create_group(): # ============================================================================= # RESULTS # ============================================================================= - -def select_results(since_start=None, since_end=None, outcomes=None, groups=None, testcases=None, testcases_like=None, result_data=None, _sort=None): - # Checks if the sort parameter specified in the request is valid before querying. - # Sorts by submit_time in a descending order if the sort parameter is absent or invalid. - query_sorted = False - if _sort: - sort_match = re.match(r'^(?Pasc|desc):(?P.+)$', _sort) - if sort_match: - if sort_match.group('column') == 'submit_time': - sort_order = {'asc': db.asc, 'desc': db.desc}[sort_match.group('order')] - sort_column = getattr(Result, sort_match.group('column')) - q = db.session.query(Result).order_by(sort_order(sort_column)) - query_sorted = True - if not query_sorted: - q = db.session.query(Result).order_by(db.desc(Result.submit_time)) - +def filter_results(q, since_start=None, since_end=None, outcomes=None, groups=None, testcases=None, testcases_like=None, result_data=None): # Time constraints if since_start: q = q.filter(Result.submit_time >= since_start) @@ -388,6 +375,26 @@ def select_results(since_start=None, since_end=None, outcomes=None, groups=None, return q +def select_results(since_start=None, since_end=None, outcomes=None, groups=None, testcases=None, testcases_like=None, result_data=None, _sort=None): + # Checks if the sort parameter specified in the request is valid before querying. + # Sorts by submit_time in a descending order if the sort parameter is absent or invalid. + query_sorted = False + if _sort: + sort_match = re.match(r'^(?Pasc|desc):(?P.+)$', _sort) + if sort_match: + if sort_match.group('column') == 'submit_time': + sort_order = {'asc': db.asc, 'desc': db.desc}[sort_match.group('order')] + sort_column = getattr(Result, sort_match.group('column')) + q = db.session.query(Result).order_by(sort_order(sort_column)) + query_sorted = True + if not query_sorted: + q = db.session.query(Result).order_by(db.desc(Result.submit_time)) + + q = filter_results(q, since_start, since_end, outcomes, groups, testcases, testcases_like, result_data) + + return q + + def __get_results_parse_args(): retval = {"args": None, "error": None, "result_data": None} try: @@ -417,6 +424,7 @@ def __get_results_parse_args(): args['testcases'] = [tc.strip() for tc in args['testcases'].split(',') if tc.strip()] args['testcases:like'] = [tc.strip() for tc in args['testcases:like'].split(',') if tc.strip()] args['groups'] = [group.strip() for group in args['groups'].split(',') if group.strip()] + args['_distinct_on'] = [_distinct_on.strip() for _distinct_on in args['_distinct_on'].split(',') if _distinct_on.strip()] retval['args'] = args # find results_data with the query parameters @@ -456,39 +464,72 @@ def get_results_latest(): return p['error'] args = p['args'] + if not args['_distinct_on']: + q = select_results( + since_start=args['since']['start'], + since_end=args['since']['end'], + groups=args['groups'], + testcases=args['testcases'], + testcases_like=args['testcases:like'], + result_data=p['result_data'], + _sort=args['_sort'], + ) - q = select_results( - since_start=args['since']['start'], - since_end=args['since']['end'], - groups=args['groups'], - testcases=args['testcases'], - testcases_like=args['testcases:like'], - result_data=p['result_data'], - _sort=args['_sort'], - ) - - # Produce a subquery with the same filter criteria as above *except* - # test case name, which we group by and join on. - sq = select_results( - since_start=args['since']['start'], - since_end=args['since']['end'], - groups=args['groups'], - result_data=p['result_data'], - )\ - .order_by(None)\ - .with_entities( - Result.testcase_name.label('testcase_name'), - db.func.max(Result.submit_time).label('max_submit_time'))\ - .group_by(Result.testcase_name)\ - .subquery() - q = q.join(sq, db.and_(Result.testcase_name == sq.c.testcase_name, - Result.submit_time == sq.c.max_submit_time)) + # Produce a subquery with the same filter criteria as above *except* + # test case name, which we group by and join on. + sq = select_results( + since_start=args['since']['start'], + since_end=args['since']['end'], + groups=args['groups'], + result_data=p['result_data'], + )\ + .order_by(None)\ + .with_entities( + Result.testcase_name.label('testcase_name'), + db.func.max(Result.submit_time).label('max_submit_time'))\ + .group_by(Result.testcase_name)\ + .subquery() + q = q.join(sq, db.and_(Result.testcase_name == sq.c.testcase_name, + Result.submit_time == sq.c.max_submit_time)) + + results = q.all() + + return jsonify(dict( + data=[SERIALIZE(o) for o in results], + )) + + testcases = args.get('testcases', None) + testcases_like = args.get('testcases:like', None) + since_start = args['since'].get('start', None) + since_end = args['since'].get('end', None) + groups = args.get('groups', None) + distinct_on = args['_distinct_on'] + + if not any([testcases, testcases_like, since_start, since_end, groups, p['result_data']]): + return jsonify({'message': ("Please, provide at least one " + "filter beside '_distinct_on'")}), 400 + + q = db.session.query(Result) + q = filter_results(q, since_start=since_start, since_end=since_end, + groups=groups, testcases=testcases, + testcases_like=testcases_like, result_data=p['result_data']) + + values_distinct_on = ['result.testcase_name'] + for i in distinct_on: + name = 'result_data_{}'.format(i) + alias = db.aliased( + db.session.query(ResultData).filter(ResultData.key == i).subquery(), name=name) + q = q.outerjoin(alias) + values_distinct_on.append('{}.value'.format(name)) + q = q.distinct(*values_distinct_on).order_by( + text(', '.join(values_distinct_on)), db.desc(text('result.submit_time'))) results = q.all() - - return jsonify(dict( + results = dict( data=[SERIALIZE(o) for o in results], - )) + ) + results['data'] = sorted(results['data'], key=lambda x: x['submit_time'], reverse=True) + return jsonify(results) RP['get_results'] = reqparse.RequestParser() @@ -498,6 +539,7 @@ RP['get_results'].add_argument('since', location='args') RP['get_results'].add_argument('outcome', location='args') RP['get_results'].add_argument('groups', default="", location='args') RP['get_results'].add_argument('_sort', default="", location='args') +RP['get_results'].add_argument('_distinct_on', default="", location='args') # TODO - can this be done any better? RP['get_results'].add_argument('testcases', default="", location='args') RP['get_results'].add_argument('testcases:like', default="", location='args') diff --git a/testing/functest_api_v20.py b/testing/functest_api_v20.py index 6977cdf..4c3c8bd 100644 --- a/testing/functest_api_v20.py +++ b/testing/functest_api_v20.py @@ -857,6 +857,117 @@ class TestFuncApiV20(): assert data['data'][1]['testcase']['name'] == self.ref_testcase_name assert data['data'][1]['outcome'] == "FAILED" + def test_get_results_latest_distinct_on(self): + self.helper_create_testcase() + + self.helper_create_result(outcome="PASSED", data={'scenario': 'scenario1'}, testcase=self.ref_testcase_name) + self.helper_create_result(outcome="PASSED", data={'scenario': 'scenario2'}, testcase=self.ref_testcase_name) + r = self.app.get('/api/v2.0/results/latest?testcases=' + self.ref_testcase_name + '&_distinct_on=scenario') + data = json.loads(r.data) + assert len(data['data']) == 2 + assert data['data'][0]['data']['scenario'][0] == 'scenario2' + assert data['data'][1]['data']['scenario'][0] == 'scenario1' + r = self.app.get('/api/v2.0/results/latest?testcases=' + self.ref_testcase_name) + data = json.loads(r.data) + assert len(data['data']) == 1 + assert data['data'][0]['data']['scenario'][0] == 'scenario2' + + def test_get_results_latest_distinct_on_more_specific_cases(self): + ''' + | id | testcase | scenario | + |----|----------|----------| + | 1 | tc_1 | s_1 | + | 2 | tc_2 | s_1 | + | 3 | tc_2 | s_2 | + | 4 | tc_3 | | + ''' + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub', + 'scenario': 's_1'}, testcase='tc_1') + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub', + 'scenario': 's_1'}, testcase='tc_2') + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub', + 'scenario': 's_2'}, testcase='tc_2') + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub'}, testcase='tc_3') + r = self.app.get('/api/v2.0/results/latest?item=grub&_distinct_on=scenario') + data = json.loads(r.data) + assert len(data['data']) == 4 + for i, result in enumerate(reversed(data['data'])): + assert result['id'] == (i+1) + + r = self.app.get('/api/v2.0/results/latest?item=grub') + data = json.loads(r.data) + assert len(data['data']) == 3 + assert data['data'][0]['id'] == 4 + assert data['data'][1]['id'] == 3 + assert data['data'][2]['id'] == 1 + + ''' + | id | testcase | scenario | + |----|----------|----------| + | 1 | tc_1 | s_1 | + | 2 | tc_2 | s_1 | + | 3 | tc_2 | s_2 | + | 4 | tc_3 | | + | 5 | tc_1 | | + ''' + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub'}, testcase='tc_1') + r = self.app.get('/api/v2.0/results/latest?item=grub&_distinct_on=scenario') + data = json.loads(r.data) + assert len(data['data']) == 5 + for i, result in enumerate(reversed(data['data'])): + assert result['id'] == (i+1) + + r = self.app.get('/api/v2.0/results/latest?item=grub') + data = json.loads(r.data) + assert len(data['data']) == 3 + assert data['data'][0]['id'] == 5 + assert data['data'][1]['id'] == 4 + assert data['data'][2]['id'] == 3 + + ''' + | id | testcase | scenario | + |----|----------|----------| + | 1 | tc_1 | s_1 | + | 2 | tc_2 | s_1 | + | 3 | tc_2 | s_2 | + | 4 | tc_3 | | + | 5 | tc_1 | | + | 6 | tc_1 | s_1 | + ''' + self.helper_create_result(outcome="PASSED", data={ + 'item': 'grub', 'scenario': 's_1'}, testcase='tc_1') + r = self.app.get('/api/v2.0/results/latest?item=grub&_distinct_on=scenario') + data = json.loads(r.data) + assert len(data['data']) == 5 + for i, result in enumerate(reversed(data['data'])): + assert result['id'] == (i+2) # 2, 3, 4, 5, 6 + r = self.app.get('/api/v2.0/results/latest?item=grub') + data = json.loads(r.data) + assert len(data['data']) == 3 + assert data['data'][0]['id'] == 6 + assert data['data'][1]['id'] == 4 + assert data['data'][2]['id'] == 3 + + def test_get_results_latest_distinct_on_with_scenario_not_defined(self): + self.helper_create_testcase() + + self.helper_create_result(outcome="PASSED", testcase=self.ref_testcase_name) + self.helper_create_result(outcome="PASSED", testcase=self.ref_testcase_name) + r = self.app.get('/api/v2.0/results/latest?testcases=' + self.ref_testcase_name + '&_distinct_on=scenario') + data = json.loads(r.data) + assert len(data['data']) == 2 + + def test_get_results_latest_distinct_on_wrong_params(self): + r = self.app.get('/api/v2.0/results/latest?_distinct_on=scenario') + data = json.loads(r.data) + assert r.status_code == 400 + assert data['message'] == "Please, provide at least one filter beside '_distinct_on'" + def test_message_publication(self): self.helper_create_result() plugin = resultsdb.messaging.DummyPlugin