Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(query): implement more query methods #173

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions __tests__/query.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,4 +558,160 @@ describe('Queries', () => {
},
);
});

test('it limits response to 1 document', async () => {
const animals = db.collection('animals');
const q = animals.limit(1);
const animalsSnaps = await q.get();
expect(animalsSnaps.size).toBe(1);
});

test('it limits response to 3 document', async () => {
const animals = db.collection('animals');
const q = animals.limit(3);
const animalsSnaps = await q.get();
expect(animalsSnaps.size).toBe(3);
});

test('it should throw when limit is not a number', async () => {
const animals = db.collection('animals');
expect(() => animals.limit('3')).toThrow(TypeError);
});

test('it orders animals by name', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('name');
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject([
'ant',
'chicken',
'cow',
'elephant',
'monkey',
'pogo-stick',
'worm',
]);
});

test('it orders animals by name descending', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('name', 'desc');
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject([
'worm',
'pogo-stick',
'monkey',
'elephant',
'cow',
'chicken',
'ant',
]);
});

test('it orders by nested fields', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('appearance.color');
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['cow']);
});

test('it should throw when using invalid direction', async () => {
const animals = db.collection('animals');
expect(() => animals.orderBy('name', 'invalidDirection')).toThrow();
});

test('it orders animals by legCount', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('legCount');
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['monkey', 'chicken', 'elephant', 'ant']);
});

test('it returns ordered animals, with more than 2 legs', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('legCount').startAfter(2);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['elephant', 'ant']);
});

test('it returns animals ordered by legCount, after elephant', async () => {
const elephant = db.doc('animals/elephant');
const elephantSnap = await elephant.get();

const animals = db.collection('animals');
const q = animals.orderBy('legCount').startAfter(elephantSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['ant']);
});

test('it returns ordered animals, with 4 or more legs', async () => {
const animals = db.collection('animals');
const q = animals.orderBy('legCount').startAt(4);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['elephant', 'ant']);
});

test('it returns animals ordered by legCount, starting at elephant', async () => {
const elephant = db.doc('animals/elephant');
const elephantSnap = await elephant.get();

const animals = db.collection('animals');
const q = animals.orderBy('legCount').startAt(elephantSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['elephant', 'ant']);
});

test('it returns animals ordered by legCount, starting at chicken', async () => {
const chicken = db.doc('animals/chicken');
const chickenSnap = await chicken.get();

const animals = db.collection('animals');
const q = animals.orderBy('legCount').startAt(chickenSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['chicken', 'elephant', 'ant']);
});

test('it returns animals ordered by name, starting after cow', async () => {
const cow = db.doc('animals/cow');
const cowSnap = await cow.get();

const animals = db.collection('animals');
const q = animals.orderBy('name').startAfter(cowSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject(['elephant', 'monkey', 'pogo-stick', 'worm']);
});

test('it returns no documents when snapshot given to startAt does not exist', async () => {
const invalid = db.doc('animals/invalid');
const invalidSnap = await invalid.get();
expect(invalidSnap.exists).toBe(false);

const animals = db.collection('animals');
const q = animals.orderBy('name').startAt(invalidSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject([]);
});

test('it returns no documents when snapshot given is the last document', async () => {
const worm = db.doc('animals/worm');
const wormSnap = await worm.get();
expect(wormSnap.exists).toBe(true);

const animals = db.collection('animals');
const q = animals.orderBy('name').startAfter(wormSnap);
const animalSnaps = await q.get();
const animalIds = animalSnaps.docs.map(doc => doc.id);
expect(animalIds).toMatchObject([]);
});
});
5 changes: 5 additions & 0 deletions mocks/firestore.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,11 @@ FakeFirestore.CollectionReference = class extends FakeFirestore.Query {
records,
isFilteringEnabled ? this.filters : undefined,
this.selectFields,
this.limitCount,
this.orderByField,
this.orderDirection,
this.cursor,
this.inclusive,
);
}

Expand Down
5 changes: 5 additions & 0 deletions mocks/helpers/buildQuerySnapShot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ export default function buildQuerySnapShot(
requestedRecords: Array<DocumentHash>,
filters?: Array<QueryFilter>,
selectFields?: string[],
limit?: number,
orderBy?: string,
orderDirection?: 'asc' | 'desc',
cursor?: unknown,
inclusive?: boolean,
): MockedQuerySnapshot;
127 changes: 125 additions & 2 deletions mocks/helpers/buildQuerySnapShot.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
const buildDocFromHash = require('./buildDocFromHash');

module.exports = function buildQuerySnapShot(requestedRecords, filters, selectFields) {
module.exports = function buildQuerySnapShot(
requestedRecords,
filters,
selectFields,
limit,
orderBy,
orderDirection,
cursor,
inclusive,
) {
const definiteRecords = requestedRecords.filter(rec => !!rec);
const results = _filteredDocuments(definiteRecords, filters);
const orderedRecords = orderBy
? _orderedDocuments(definiteRecords, orderBy, orderDirection)
: definiteRecords;
const cursoredRecords = cursor
? _cursoredDocuments(orderedRecords, cursor, orderBy, inclusive)
: orderedRecords;
const filteredRecords = _filteredDocuments(cursoredRecords, filters);
const results = _limitDocuments(filteredRecords, limit);
const docs = results.map(doc => buildDocFromHash(doc, 'abc123', selectFields));

return {
Expand Down Expand Up @@ -109,6 +125,10 @@ function _shouldCompareNumerically(a, b) {
return typeof a === 'number' && typeof b === 'number';
}

function _shouldCompareStrings(a, b) {
return typeof a === 'string' && typeof b === 'string';
}

function _shouldCompareTimestamp(a, b) {
//We check whether toMillis method exists to support both Timestamp mock and Firestore Timestamp object
//B is expected to be Date, not Timestamp, just like Firestore does
Expand Down Expand Up @@ -299,6 +319,109 @@ function _recordsWithOneOfValues(records, key, value) {
);
}

/**
* Orders an array of mock document data by a specified field.
*
* @param {Array<DocumentHash>} records The array of records to order.
* @param {string} orderBy The field to order the records after.
* @param {'asc' | 'desc'} direction The direction to order the records. Deafault `'asc'`.
*
* @returns {Array<import('./buildDocFromHash').DocumentHash>} The ordered documents.
*/
function _orderedDocuments(records, orderBy, direction = 'asc') {
const ordered = [
...records.filter(record => {
// When using the orderBy query, we must filter away the documents that do not have the order-by field defined.
const value = getValueByPath(record, orderBy);
return value !== null && typeof value !== 'undefined';
}),
].sort((a, b) => {
const aVal = getValueByPath(a, orderBy);
const bVal = getValueByPath(b, orderBy);
if (!aVal || !bVal) {
return 0;
}
if (_shouldCompareNumerically(aVal, bVal)) {
return aVal - bVal;
}
if (_shouldCompareStrings(aVal, bVal)) {
return aVal.localeCompare(bVal);
}

const cmpTimestamps =
typeof aVal === 'object' &&
typeof bVal === 'object' &&
aVal !== null &&
bVal !== null &&
typeof aVal.toMillis === 'function' &&
typeof bVal.toMillis === 'function';
if (cmpTimestamps) {
return aVal.toMillis() - bVal.toMillis();
}
return 0;
});

return direction === 'asc' ? ordered : ordered.reverse();
}

/**
* Returns a subsection of records, starting from a cursor, set to a field value.
*
* @param {Array<DocumentHash>} records The array of records to get a subsection of. The array should be ordered by the same field specified in the `orderBy` parameter.
* @param {unknown} cursor The cursor. Either a field value or a document snapshot.
* @param {string} orderBy The field the records are ordered by.
* @param {boolean} inclusive Should the record at the cursor be included.
*
* @returns {Array<import('./buildDocFromHash').DocumentHash>} The subsection of documents, starting at the `cursor`.
*/
function _cursoredDocuments(records, cursor, orderBy, inclusive) {
if (_isSnapshot(cursor)) {
// Place the cursor at a document, based on a snapshot.
const cursorIndex = records.findIndex(record => record.id === cursor.id);
if (cursorIndex < 0) {
return [];
}
return records.slice(cursorIndex + (inclusive ? 0 : 1));
} else {
// Place the cursor at a field, based on a value.
const cmpAt = (a, b) => a >= b;
const cmpAfter = (a, b) => a > b;

const cmp = inclusive ? cmpAt : cmpAfter;
return records.filter(record => {
const v = getValueByPath(record, orderBy);
if (_shouldCompareNumerically(v, cursor)) {
return cmp(v, cursor);
}
if (_shouldCompareTimestamp(v, cursor)) {
return cmp(v.toMillis(), cursor.getTime());
}
if (typeof v.toMillis === 'function' && typeof cursor.toMillis === 'function') {
return cmp(v.toMillis(), cursor.toMillis());
}
if (_shouldCompareStrings(v, cursor)) {
return v.localeCompare(cursor);
}
// TODO: Compare other values as well.
return true;
});
}
}

function _limitDocuments(records, limit) {
return records.slice(0, limit);
}

function _isSnapshot(data) {
return (
typeof data === 'object' &&
data !== null &&
typeof data.ref === 'object' &&
typeof data.ref.firestore === 'object' &&
typeof data.id === 'string'
);
}

function getValueByPath(record, path) {
const keys = path.split('.');
return keys.reduce((nestedObject = {}, key) => nestedObject[key], record);
Expand Down
34 changes: 30 additions & 4 deletions mocks/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class Query {
this.filters = [];
this.selectFields = undefined;
this.isGroupQuery = isGroupQuery;
this.limitCount = undefined;
// TODO: By default, Firestore orders by ID.
this.orderByField = undefined;
this.orderDirection = undefined;
this.cursor = undefined;
this.inclusive = undefined;
}

get() {
Expand Down Expand Up @@ -79,6 +85,11 @@ class Query {
requestedRecords,
isFilteringEnabled ? this.filters : undefined,
this.selectFields,
this.limitCount,
this.orderByField,
this.orderDirection,
this.cursor,
this.inclusive,
);
}

Expand Down Expand Up @@ -107,19 +118,34 @@ class Query {
return mockOffset(...arguments) || this;
}

limit() {
limit(count) {
if (typeof count !== 'number') {
throw new TypeError('Query\'s limit was not set to a number.');
}
this.limitCount = count;
return mockLimit(...arguments) || this;
}

orderBy() {
orderBy(field, direction = 'asc') {
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(
`Query's orderBy received invalid direction: ${direction}. Must be 'asc' or 'desc'.`,
);
}
this.orderByField = field;
this.orderDirection = direction;
return mockOrderBy(...arguments) || this;
}

startAfter() {
startAfter(snapshotOrField) {
this.cursor = snapshotOrField;
this.inclusive = false;
return mockStartAfter(...arguments) || this;
}

startAt() {
startAt(snapshotOrField) {
this.cursor = snapshotOrField;
this.inclusive = true;
return mockStartAt(...arguments) || this;
}

Expand Down