Skip to content

Commit bc9d6af

Browse files
authored
Make iteration query methods respect returned promises (#1112)
* Make ParseQuery.map respect returned promises This function is documented as allowing the callback to return a promise, and when the callback returns a promise we should wait for that promise to resolve before continuing, and stop iteration if the promise rejects. If the callback returned a promise, the previous implementation would return an array of promises, and run the callback on all of them. The new implementation wraps the returned value in `Promise.resolve`, coercing it into a promise, so that we can safely extract a value from it before pushing the value into the results array. Returning this promise in the `.each()` callback also provides the behavior of waiting / stopping based on the resolution of a promise from the callback. * Make ParseQuery.reduce respect returned promises This function is documented as allowing the callback to return a promise, and when the callback returns a promise we should wait for that promise to resolve before continuing, and stop iteration if the promise rejects. The previous implementation was a bit naive in that it would load the entire set of objects matching the query into memory, and then call `.reduce()` on it. The new implementation avoids this by applying the callback on each object in turn. This lets us then return the result of the callback to the `.each()` call, which handles waiting for promise resolution / rejection. Things here get a little tricky in that the behavior of `reduce` is special when no initial value is provided: we take the first item, don't pass it into the callback, and treat that item as the initial value. We need to keep that treatment, which entails a check for a special case: we're looking at the first item AND the initial value was undefined. * Make ParseQuery.filter respect returned promises This function is documented as allowing the callback to return a promise, and when the callback returns a promise we should wait for that promise to resolve before continuing, and stop iteration if the promise rejects. When the callback returns a promise, the old behavior would have been to push the object into the results array, because a promise is a truthy value. Now we wait for the promise to resolve before checking the value. * Reject when reducing empty result set with no initial value This more closely mirrors the behavior of Array.reduce(), which throws a TypeError in this situation. Implementation notes: - We return a rejected promise instead of throwing synchronously, because we need to wait for the result set to materialize before we know if you're reducing on an empty set - The `.each()` callback is never invoked for an empty result set, so we need to have this empty check happen _after_ we've finished iteration. Fortunately we're already keeping track of the iteration index in this scope, so we can reuse that for this check. Thanks for the feedback @acinader!
1 parent fb474a2 commit bc9d6af

File tree

2 files changed

+166
-55
lines changed

2 files changed

+166
-55
lines changed

src/ParseQuery.js

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -999,8 +999,10 @@ class ParseQuery {
999999
const array = [];
10001000
let index = 0;
10011001
await this.each((object) => {
1002-
array.push(callback(object, index, this));
1003-
index += 1;
1002+
return Promise.resolve(callback(object, index, this)).then((result) => {
1003+
array.push(result);
1004+
index += 1;
1005+
});
10041006
}, options);
10051007
return array;
10061008
}
@@ -1028,11 +1030,27 @@ class ParseQuery {
10281030
* iteration has completed.
10291031
*/
10301032
async reduce(callback: (accumulator: any, currentObject: ParseObject, index: number) => any, initialValue: any, options?: BatchOptions): Promise<Array<any>> {
1031-
const objects = [];
1033+
let accumulator = initialValue;
1034+
let index = 0;
10321035
await this.each((object) => {
1033-
objects.push(object);
1036+
// If no initial value was given, we take the first object from the query
1037+
// as the initial value and don't call the callback with it.
1038+
if (index === 0 && initialValue === undefined) {
1039+
accumulator = object;
1040+
index += 1;
1041+
return;
1042+
}
1043+
return Promise.resolve(callback(accumulator, object, index)).then((result) => {
1044+
accumulator = result;
1045+
index += 1;
1046+
});
10341047
}, options);
1035-
return objects.reduce(callback, initialValue);
1048+
if (index === 0 && initialValue === undefined) {
1049+
// Match Array.reduce behavior: "Calling reduce() on an empty array
1050+
// without an initialValue will throw a TypeError".
1051+
throw new TypeError("Reducing empty query result set with no initial value");
1052+
}
1053+
return accumulator;
10361054
}
10371055

10381056
/**
@@ -1061,11 +1079,12 @@ class ParseQuery {
10611079
const array = [];
10621080
let index = 0;
10631081
await this.each((object) => {
1064-
const flag = callback(object, index, this);
1065-
if (flag) {
1066-
array.push(object);
1067-
}
1068-
index += 1;
1082+
return Promise.resolve(callback(object, index, this)).then((flag) => {
1083+
if (flag) {
1084+
array.push(object);
1085+
}
1086+
index += 1;
1087+
});
10691088
}, options);
10701089
return array;
10711090
}

src/__tests__/ParseQuery-test.js

Lines changed: 137 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,64 +1676,156 @@ describe('ParseQuery', () => {
16761676
expect(q._hint).toBeUndefined();
16771677
});
16781678

1679-
it('can iterate over results with map()', async () => {
1680-
CoreManager.setQueryController({
1681-
aggregate() {},
1682-
find() {
1683-
return Promise.resolve({
1684-
results: [
1685-
{ objectId: 'I55', size: 'medium', name: 'Product 55' },
1686-
{ objectId: 'I89', size: 'small', name: 'Product 89' },
1687-
{ objectId: 'I91', size: 'small', name: 'Product 91' },
1688-
]
1689-
});
1690-
}
1679+
describe('iterating over results via .map()', () => {
1680+
beforeEach(() => {
1681+
CoreManager.setQueryController({
1682+
aggregate() {},
1683+
find() {
1684+
return Promise.resolve({
1685+
results: [
1686+
{ objectId: 'I55', size: 'medium', name: 'Product 55' },
1687+
{ objectId: 'I89', size: 'small', name: 'Product 89' },
1688+
{ objectId: 'I91', size: 'small', name: 'Product 91' },
1689+
]
1690+
});
1691+
}
1692+
});
16911693
});
16921694

1693-
const q = new ParseQuery('Item');
1695+
it('can iterate with a synchronous callback', async () => {
1696+
const callback = (object) => object.attributes.size;
1697+
const q = new ParseQuery('Item');
1698+
const results = await q.map(callback);
1699+
expect(results).toEqual(['medium', 'small', 'small']);
1700+
});
16941701

1695-
const results = await q.map((object) => object.attributes.size);
1696-
expect(results.length).toBe(3);
1697-
});
1702+
it('can iterate with an asynchronous callback', async () => {
1703+
const callback = async (object) => object.attributes.size;
1704+
const q = new ParseQuery('Item');
1705+
const results = await q.map(callback);
1706+
expect(results).toEqual(['medium', 'small', 'small']);
1707+
});
1708+
1709+
it('stops iteration when a rejected promise is returned', async () => {
1710+
let callCount = 0;
1711+
await new ParseQuery('Item').map(() => {
1712+
callCount++;
1713+
return Promise.reject(new Error('Callback rejecting'));
1714+
}).catch(() => {});
1715+
expect(callCount).toEqual(1);
1716+
});
1717+
});
1718+
1719+
describe('iterating over results with .reduce()', () => {
1720+
beforeEach(() => {
1721+
CoreManager.setQueryController({
1722+
aggregate() {},
1723+
find() {
1724+
return Promise.resolve({
1725+
results: [
1726+
{ objectId: 'I55', number: 1 },
1727+
{ objectId: 'I89', number: 2 },
1728+
{ objectId: 'I91', number: 3 },
1729+
]
1730+
});
1731+
}
1732+
});
1733+
});
16981734

1699-
it('can iterate over results with reduce()', async () => {
1700-
CoreManager.setQueryController({
1701-
aggregate() {},
1702-
find() {
1703-
return Promise.resolve({
1704-
results: [
1705-
{ objectId: 'I55', number: 1 },
1706-
{ objectId: 'I89', number: 2 },
1707-
{ objectId: 'I91', number: 3 },
1708-
]
1709-
});
1735+
it('can iterate with a synchronous callback', async () => {
1736+
const callback = (accumulator, object) => accumulator + object.attributes.number;
1737+
const q = new ParseQuery('Item');
1738+
const result = await q.reduce(callback, 0);
1739+
expect(result).toBe(6);
1740+
});
1741+
1742+
it('can iterate with an asynchronous callback', async () => {
1743+
const callback = async (accumulator, object) => accumulator + object.attributes.number;
1744+
const q = new ParseQuery('Item');
1745+
const result = await q.reduce(callback, 0);
1746+
expect(result).toBe(6);
1747+
});
1748+
1749+
it('stops iteration when a rejected promise is returned', async () => {
1750+
let callCount = 0;
1751+
const callback = () => {
1752+
callCount += 1;
1753+
return Promise.reject(new Error("Callback rejecting"));
17101754
}
1755+
const q = new ParseQuery('Item');
1756+
await q.reduce(callback, 0).catch(() => {});
1757+
expect(callCount).toBe(1);
17111758
});
17121759

1713-
const q = new ParseQuery('Item');
1760+
it('uses the first object as an initial value when no initial value is passed', async () => {
1761+
let callCount = 0;
1762+
const callback = (accumulator, object) => {
1763+
callCount += 1;
1764+
accumulator.attributes.number += object.attributes.number;
1765+
return accumulator;
1766+
}
1767+
const q = new ParseQuery('Item');
1768+
const result = await q.reduce(callback);
1769+
expect(result.id).toBe('I55');
1770+
expect(result.attributes.number).toBe(6);
1771+
expect(callCount).toBe(2); // Not called for the first object when used as initial value
1772+
});
17141773

1715-
const result = await q.reduce((accumulator, object) => accumulator + object.attributes.number, 0);
1716-
expect(result).toBe(6);
1717-
});
1774+
it('rejects with a TypeError when there are no results and no initial value was provided', async () => {
1775+
CoreManager.setQueryController({
1776+
aggregate() {},
1777+
find() { return Promise.resolve({ results: [] }) },
1778+
});
17181779

1719-
it('can iterate over results with filter()', async () => {
1720-
CoreManager.setQueryController({
1721-
aggregate() {},
1722-
find() {
1723-
return Promise.resolve({
1724-
results: [
1725-
{ objectId: 'I55', size: 'medium', name: 'Product 55' },
1726-
{ objectId: 'I89', size: 'small', name: 'Product 89' },
1727-
{ objectId: 'I91', size: 'small', name: 'Product 91' },
1728-
]
1729-
});
1780+
const q = new ParseQuery('Item');
1781+
const callback = (accumulator, object) => {
1782+
accumulator.attributes.number += object.attributes.number;
1783+
return accumulator;
17301784
}
1785+
return expect(q.reduce(callback)).rejects.toThrow(TypeError);
17311786
});
1787+
});
17321788

1733-
const q = new ParseQuery('Item');
1789+
describe('iterating over results with .filter()', () => {
1790+
beforeEach(() => {
1791+
CoreManager.setQueryController({
1792+
aggregate() {},
1793+
find() {
1794+
return Promise.resolve({
1795+
results: [
1796+
{ objectId: 'I55', size: 'medium', name: 'Product 55' },
1797+
{ objectId: 'I89', size: 'small', name: 'Product 89' },
1798+
{ objectId: 'I91', size: 'small', name: 'Product 91' },
1799+
]
1800+
});
1801+
}
1802+
});
1803+
});
17341804

1735-
const results = await q.filter((object) => object.attributes.size === 'small');
1736-
expect(results.length).toBe(2);
1805+
it('can iterate results with a synchronous callback', async () => {
1806+
const callback = (object) => object.attributes.size === 'small';
1807+
const q = new ParseQuery('Item');
1808+
const results = await q.filter(callback);
1809+
expect(results.length).toBe(2);
1810+
});
1811+
1812+
it('can iterate results with an async callback', async () => {
1813+
const callback = async (object) => object.attributes.size === 'small';
1814+
const q = new ParseQuery('Item');
1815+
const results = await q.filter(callback);
1816+
expect(results.length).toBe(2);
1817+
});
1818+
1819+
it('stops iteration when a rejected promise is returned', async () => {
1820+
let callCount = 0;
1821+
const callback = async () => {
1822+
callCount += 1;
1823+
return Promise.reject(new Error('Callback rejecting'));
1824+
};
1825+
const q = new ParseQuery('Item');
1826+
await q.filter(callback).catch(() => {});
1827+
expect(callCount).toBe(1);
1828+
});
17371829
});
17381830

17391831
it('returns an error when iterating over an invalid query', (done) => {

0 commit comments

Comments
 (0)