Skip to content

Commit 3209ff1

Browse files
authored
Fix SearchResult.next with sort/size (#508)
## What does this PR do? :warning: Depends on kuzzleio/kuzzle#1600 Fix few bugs on the `SearchResult` pagination with `sort` and `size`. The SDK now support every kind of ES sort: - string value - array of string - array of object Errors are now thrown when: - the sort is invalid - the sort combination does not identify one item only ### Other changes - the `next` method now return rejected promises instead of throwing (inconsistent behavior that can lead to unhandled rejection) - change `_uid` by `_id` because this field does not longer exists in ES 7.x
1 parent 0f8d1ed commit 3209ff1

File tree

10 files changed

+154
-56
lines changed

10 files changed

+154
-56
lines changed

doc/7/core-classes/search-result/next/index.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ next();
2020

2121
Resolves to a `SearchResult` object, or to `null` if no more pages are available.
2222

23-
## Throw
23+
## Rejects
2424

25-
This method throws an exception if:
25+
This method returns a rejected promise with an error if:
2626

2727
- No pagination strategy can be applied (see below)
2828
- If invoking it would lead to more than 10 000 items being retrieved with the `from/size` strategy
@@ -56,19 +56,26 @@ You can restrict the scroll session maximum duration under the `services.storage
5656

5757
If the initial search contains `sort` and `size` parameters, the `next` method retrieves the next page of results following the sort order, the last item of the current page acting as a live cursor.
5858

59-
To avoid too many duplicates, it is advised to provide a sort combination that will always identify one item only. The recommended way is to use the field `_uid` which is certain to contain one unique value for each document.
59+
This strategy uses Elasticsearch [search_after](https://www.elastic.co/guide/en/elasticsearch/reference/7.4/search-request-body.html#request-body-search-search-after) parameter.
60+
61+
::: warning
62+
You have to provide a sort combination that will always identify one item only. The recommended way is to use the field `_id` which is certain to contain one unique value for each document.
63+
To prevent partial retrieval of results, the SDK will reject with an error if the sort combination can identify multiple items.
64+
:::
6065

6166
Because this method does not freeze the search results between two calls, there can be missing or duplicated documents between two result pages.
6267

6368
This method efficiently mitigates the costs of scroll searches, but returns less consistent results: it's a middle ground, ideal for real-time search requests.
6469

70+
<<< ./snippets/sortsize.js
71+
6572
### Strategy: from / size
6673

6774
If the initial search contains `from` and `size` parameters, the `next` method retrieves the next page of result by incrementing the `from` offset.
6875

6976
Because this method does not freeze the search results between two calls, there can be missing or duplicated documents between two result pages.
7077

7178
It's the fastest pagination method available, but also the less consistent, and it is not possible to retrieve more than 10000 items using it.
72-
Above that limit, any call to `next` throws an Exception.
79+
Above that limit, any call to `next` will return a rejected promise with an error.
7380

7481
<<< ./snippets/fromsize.js
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
try {
2+
const documents = [];
3+
4+
for (let i = 0; i < 100; i++) {
5+
documents.push({ _id: `suv_no${i}`, body: { category: 'suv' } });
6+
}
7+
8+
await kuzzle.document.mCreate('nyc-open-data', 'yellow-taxi', documents, {
9+
refresh: 'wait_for'
10+
});
11+
12+
let results = await kuzzle.document.search(
13+
'nyc-open-data',
14+
'yellow-taxi',
15+
{
16+
query: { match: { category: 'suv' } },
17+
sort: [
18+
{ '_kuzzle_info.createdAt': 'desc' },
19+
'_id'
20+
]
21+
},
22+
{ size: 5 });
23+
24+
// Fetch the matched items by advancing through the result pages
25+
const matched = [];
26+
27+
while (results) {
28+
matched.push(...results.hits);
29+
results = await results.next();
30+
}
31+
32+
console.log(matched[0]);
33+
/*
34+
{ _id: 'suv_no1',
35+
_score: 0.03390155,
36+
_source:
37+
{ _kuzzle_info:
38+
{ author: '-1',
39+
updater: null,
40+
updatedAt: null,
41+
createdAt: 1570093133057 },
42+
category: 'suv' } }
43+
*/
44+
console.log(`Successfully retrieved ${matched.length} documents`);
45+
} catch (error) {
46+
console.error(error.message);
47+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
name: searchresult#sortsize
2+
description: Next method with sort/size
3+
hooks:
4+
before: |
5+
curl -XDELETE kuzzle:7512/nyc-open-data
6+
curl -XPOST kuzzle:7512/nyc-open-data/_create
7+
curl -XPUT kuzzle:7512/nyc-open-data/yellow-taxi
8+
after: |
9+
curl -XDELETE kuzzle:7512/nyc-open-data
10+
template: default
11+
expected: Successfully retrieved 100 documents

src/core/searchResult/Role.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class RoleSearchResult extends SearchResultBase {
1616
// in Kuzzle API, scrollRoles action is not available, and searchRoles allows only from and size parameters
1717
// => we deny "scroll" and "sort" parameters.
1818
if (this._request.scroll || this._request.sort) {
19-
throw new Error('only from/size params are allowed for role search');
19+
return Promise.reject(new Error('only from/size params are allowed for role search'));
2020
}
2121

2222
return super.next()

src/core/searchResult/SearchResultBase.js

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,42 @@ class SearchResultBase {
3838
.then(response => this._buildNextSearchResult(response));
3939
}
4040
else if (this._request.size && this._request.body.sort) {
41-
const
42-
request = Object.assign({}, this._request, {
43-
action: this._searchAction
44-
}),
45-
hit = this._response.hits[this._response.hits.length - 1];
41+
const request = { ...this._request, action: this._searchAction };
42+
const hit = this._response.hits[this._response.hits.length - 1];
43+
44+
// When sorting only on a non unique field, the search_after will not iterate
45+
// over all documents having the same values but ES will returns the results
46+
// directly after.
47+
// It resulting in having less fetched documents than the total and thus the SDK
48+
// try to fetch the next results page but it's empty
49+
if (! hit) {
50+
return Promise.reject(new Error('Unable to retrieve all results from search: the sort combination must identify one item only. Add document "_id" to the sort.'));
51+
}
4652

4753
request.body.search_after = [];
4854

49-
for (const sort of this._request.body.sort) {
55+
let sorts;
56+
if (typeof this._request.body.sort === 'string') {
57+
sorts = [this._request.body.sort];
58+
}
59+
else if (Array.isArray(this._request.body.sort)) {
60+
sorts = this._request.body.sort;
61+
}
62+
else {
63+
sorts = Object.keys(this._request.body.sort);
64+
}
65+
66+
if (sorts.length === 0) {
67+
return Promise.reject(new Error('Unable to retrieve next results from search: sort param is empty'));
68+
}
69+
70+
for (const sort of sorts) {
5071
const key = typeof sort === 'string'
5172
? sort
5273
: Object.keys(sort)[0];
53-
const value = key === '_uid'
54-
? this._request.collection + '#' + hit._id
74+
75+
const value = key === '_id'
76+
? hit._id
5577
: this._get(hit._source, key.split('.'));
5678

5779
request.body.search_after.push(value);
@@ -65,14 +87,15 @@ class SearchResultBase {
6587
return Promise.resolve(null);
6688
}
6789

68-
return this._kuzzle.query(Object.assign({}, this._request, {
90+
return this._kuzzle.query({
91+
...this._request,
6992
action: this._searchAction,
7093
from: this.fetched
71-
}), this._options)
94+
}, this._options)
7295
.then(response => this._buildNextSearchResult(response));
7396
}
7497

75-
throw new Error('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
98+
return Promise.reject(new Error('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params'));
7699
}
77100

78101
_get (object, path) {

test/core/searchResult/document.test.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,8 @@ describe('DocumentSearchResult', () => {
8989

9090
searchResult = new DocumentSearchResult(kuzzle, request, options, response);
9191

92-
should(function () {
93-
searchResult.next();
94-
}).throw('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
92+
return should(searchResult.next())
93+
.be.rejectedWith('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
9594
});
9695

9796
describe('#with scroll option', () => {
@@ -153,18 +152,20 @@ describe('DocumentSearchResult', () => {
153152
});
154153

155154
describe('#with size and sort option', () => {
156-
const nextResponse = {
157-
hits: [
158-
{_id: 'document3', _score: 0.6543, _source: {foo: 'barbaz', bar: 4567}},
159-
{_id: 'document4', _score: 0.6123, _source: {foo: 'bazbar', bar: 6789}}
160-
],
161-
aggregations: 'nextAggregations',
162-
total: 30
163-
};
155+
let nextResponse;
164156

165157
beforeEach(() => {
158+
nextResponse = {
159+
hits: [
160+
{_id: 'document3', _score: 0.6543, _source: {foo: 'barbaz', bar: 4567}},
161+
{_id: 'document4', _score: 0.6123, _source: {foo: 'bazbar', bar: 6789}}
162+
],
163+
aggregations: 'nextAggregations',
164+
total: 30
165+
};
166+
166167
request.size = 2;
167-
request.body.sort = ['foo', {bar: 'asc'}, {_uid: 'desc'}];
168+
request.body.sort = ['foo', {bar: 'asc'}, {_id: 'desc'}];
168169

169170
response = {
170171
hits: [
@@ -191,8 +192,8 @@ describe('DocumentSearchResult', () => {
191192
query: {
192193
foo: 'bar'
193194
},
194-
sort: ['foo', {bar: 'asc'}, {_uid: 'desc'}],
195-
search_after: ['barbar', 2345, 'collection#document2']
195+
sort: ['foo', {bar: 'asc'}, {_id: 'desc'}],
196+
search_after: ['barbar', 2345, 'document2']
196197
},
197198
controller: 'document',
198199
action: 'search',
@@ -215,6 +216,22 @@ describe('DocumentSearchResult', () => {
215216
should(nextSearchResult.aggregations).equal(nextResponse.aggregations);
216217
});
217218
});
219+
220+
it('should reject with an error if the sort is invalid', () => {
221+
request.body.sort = [];
222+
searchResult = new DocumentSearchResult(kuzzle, request, options, response);
223+
224+
return should(searchResult.next())
225+
.be.rejected();
226+
});
227+
228+
it('should reject if the sort combination does not allow to retrieve all the documents', () => {
229+
response.hits = [];
230+
searchResult = new DocumentSearchResult(kuzzle, request, options, response);
231+
232+
return should(searchResult.next())
233+
.be.rejected();
234+
});
218235
});
219236

220237
describe('#with from and size option', () => {

test/core/searchResult/profile.test.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ describe('ProfileSearchResult', () => {
8282

8383
});
8484

85-
it('should throw an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
85+
it('should reject with an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
8686
response = {
8787
scrollId: 'scroll-id',
8888
hits: [
@@ -94,9 +94,8 @@ describe('ProfileSearchResult', () => {
9494

9595
searchResult = new ProfileSearchResult(kuzzle, request, options, response);
9696

97-
should(function () {
98-
searchResult.next();
99-
}).throw('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
97+
return should(searchResult.next())
98+
.be.rejectedWith('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
10099
});
101100

102101
describe('#with scroll option', () => {

test/core/searchResult/role.test.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,34 +79,30 @@ describe('RoleSearchResult', () => {
7979
should(kuzzle.query).not.be.called();
8080
should(result).be.Null();
8181
});
82-
8382
});
8483

8584

86-
it('should throw an error if scrollId parameters is set', () => {
85+
it('should reject with an error if scrollId parameters is set', () => {
8786
request.scroll = '10s';
8887
searchResult = new RoleSearchResult(kuzzle, request, options, response);
8988

90-
should(function () {
91-
searchResult.next();
92-
}).throw('only from/size params are allowed for role search');
89+
return should(searchResult.next())
90+
.be.rejectedWith('only from/size params are allowed for role search');
9391
});
9492

95-
it('should throw an error if sort parameters is set', () => {
96-
request.sort = ['foo', {bar: 'asc'}];
93+
it('should reject with an error if sort parameters is set', () => {
94+
request.sort = ['foo', { bar: 'asc' }];
9795
searchResult = new RoleSearchResult(kuzzle, request, options, response);
9896

99-
should(function () {
100-
searchResult.next();
101-
}).throw('only from/size params are allowed for role search');
97+
return should(searchResult.next())
98+
.be.rejectedWith('only from/size params are allowed for role search');
10299
});
103100

104-
it('should throw an error if size and from parameters are not set', () => {
101+
it('should reject with an error if size and from parameters are not set', () => {
105102
searchResult = new RoleSearchResult(kuzzle, request, options, response);
106103

107-
should(function () {
108-
searchResult.next();
109-
}).throw('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
104+
return should(searchResult.next())
105+
.be.rejectedWith('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
110106
});
111107

112108
describe('#with from and size option', () => {

test/core/searchResult/specifications.test.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe('SpecificationsSearchResult', () => {
7171

7272
});
7373

74-
it('should throw an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
74+
it('should reject with an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
7575
response = {
7676
scrollId: 'scroll-id',
7777
hits: [
@@ -83,9 +83,8 @@ describe('SpecificationsSearchResult', () => {
8383

8484
searchResult = new SpecificationsSearchResult(kuzzle, request, options, response);
8585

86-
should(function () {
87-
searchResult.next();
88-
}).throw('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
86+
return should(searchResult.next())
87+
.be.rejectedWith('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
8988
});
9089

9190
describe('#with scroll option', () => {

test/core/searchResult/user.test.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('UserSearchResult', () => {
8484

8585
});
8686

87-
it('should throw an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
87+
it('should reject with an error if neither scroll, nor size/sort, nor size/from parameters are set', () => {
8888
response = {
8989
scrollId: 'scroll-id',
9090
hits: [
@@ -96,9 +96,8 @@ describe('UserSearchResult', () => {
9696

9797
searchResult = new UserSearchResult(kuzzle, request, options, response);
9898

99-
should(function () {
100-
searchResult.next();
101-
}).throw('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
99+
return should(searchResult.next())
100+
.be.rejectedWith('Unable to retrieve next results from search: missing scrollId, from/sort, or from/size params');
102101
});
103102

104103
describe('#with scroll option', () => {

0 commit comments

Comments
 (0)