Skip to content

Commit

Permalink
feat(GeoQuery): expose limit function, fixes #68 #26 #8
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelSolati committed Jan 17, 2019
1 parent aa5e320 commit 5fb5de0
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 37 deletions.
8 changes: 7 additions & 1 deletion README.md
Expand Up @@ -15,6 +15,7 @@ GeoFirestore is designed as a lightweight add-on to Firebase. To keep things sim
* [Downloading GeoFirestore](#downloading-geofirestore)
* [Example Usage](#example-usage)
* [Documentation](#documentation)
* [Limitations](#limitations)
* [Contributing](#contributing)

## Downloading GeoFirestore
Expand Down Expand Up @@ -74,7 +75,12 @@ query.get().then((value: GeoQuerySnapshot) => {
Simple. Easy. And very similar with how Firestore handles a `get` from a Firestore `Query`. The difference being the added ability to say query `near` a `center` point, with a set `radius` in kilometers.

## Limitations
Internally GeoFirestore creates multiple geohases around a requested area. It queries them and furter calculations on the seperate results are done within the libary. Because of this the additional filtering methods `orderBy`, `limit`, `startAt` and `endAt` can not be passed though GeoFirestore to [Cloud Firestore](https://firebase.google.com/docs/firestore/) at this time.

Internally GeoFirestore creates multiple geohases around a requested area. It queries them and furter calculations on the seperate results are done within the libary. Because of this the additional filtering methods `orderBy`, `startAt` and `endAt` can not be passed though GeoFirestore to [Cloud Firestore](https://firebase.google.com/docs/firestore/) at this time.

### `limit()`

The `limit` filtering method is exposed through GeoFirestore, however as geoqueries require an aggregation of queries, the library applies a paritial limit on the server, but primarily runs its limit on the client. This may mean you are loading to the client more documents then you intended. Use with this performance limitation in mind.

## Contributing

Expand Down
1 change: 1 addition & 0 deletions src/GeoFirestoreTypes.ts
Expand Up @@ -19,6 +19,7 @@ export namespace GeoFirestoreTypes {
export interface QueryCriteria {
center?: cloud.GeoPoint | web.GeoPoint;
radius?: number;
limit?: number;
}
export interface QueryDocumentSnapshot {
exists: boolean;
Expand Down
29 changes: 21 additions & 8 deletions src/GeoJoinerGet.ts
Expand Up @@ -6,23 +6,33 @@ import { validateQueryCriteria, calculateDistance } from './utils';
* A `GeoJoinerGet` aggregates multiple `get` results.
*/
export class GeoJoinerGet {
private _docs: GeoFirestoreTypes.web.QueryDocumentSnapshot[] = [];
private _docs: Map<string, GeoFirestoreTypes.web.QueryDocumentSnapshot> = new Map();

/**
* @param snapshots An array of snpashots from a Firestore Query `get` call.
* @param _near The center and radius of geo based queries.
* @param _queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit.
*/
constructor(snapshots: GeoFirestoreTypes.web.QuerySnapshot[], private _near: GeoFirestoreTypes.QueryCriteria) {
validateQueryCriteria(_near);
constructor(snapshots: GeoFirestoreTypes.web.QuerySnapshot[], private _queryCriteria: GeoFirestoreTypes.QueryCriteria) {
validateQueryCriteria(_queryCriteria);

snapshots.forEach((snapshot: GeoFirestoreTypes.web.QuerySnapshot) => {
snapshot.docs.forEach((doc) => {
const distance = calculateDistance(this._near.center, doc.data().l);
if (this._near.radius >= distance) {
this._docs.push(doc);
const distance = calculateDistance(this._queryCriteria.center, doc.data().l);
if (this._queryCriteria.radius >= distance) {
this._docs.set(doc.id, doc);
}
});
});

if (this._queryCriteria.limit && this._docs.size > this._queryCriteria.limit) {
const arrayToLimit = Array.from(this._docs.values()).map((doc) => {
return {...doc, distance: calculateDistance(this._queryCriteria.center, doc.data().l)};
}).sort((a, b) => a.distance - b.distance);

for (let i = this._queryCriteria.limit; i < arrayToLimit.length; i++) {
this._docs.delete(arrayToLimit[i].id);
}
}
}

/**
Expand All @@ -31,6 +41,9 @@ export class GeoJoinerGet {
* @return A new `GeoQuerySnapshot` of the filtered documents from the `get`.
*/
public getGeoQuerySnapshot(): GeoQuerySnapshot {
return new GeoQuerySnapshot({ docs: this._docs, docChanges: () => [] } as GeoFirestoreTypes.web.QuerySnapshot, this._near.center);
return new GeoQuerySnapshot(
{ docs: Array.from(this._docs.values()), docChanges: () => [] } as GeoFirestoreTypes.web.QuerySnapshot,
this._queryCriteria.center
);
}
}
48 changes: 33 additions & 15 deletions src/GeoJoinerOnSnapshot.ts
Expand Up @@ -20,15 +20,15 @@ export class GeoJoinerOnSnapshot {

/**
* @param _queries An array of Firestore Queries to aggregate.
* @param _near The center and radius of geo based queries.
* @param _queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit.
* @param _onNext A callback to be called every time a new `QuerySnapshot` is available.
* @param _onError A callback to be called if the listen fails or is cancelled. No further callbacks will occur.
*/
constructor(
private _queries: GeoFirestoreTypes.web.Query[], private _near: GeoFirestoreTypes.QueryCriteria,
private _queries: GeoFirestoreTypes.web.Query[], private _queryCriteria: GeoFirestoreTypes.QueryCriteria,
private _onNext: (snapshot: GeoQuerySnapshot) => void, private _onError?: (error: Error) => void
) {
validateQueryCriteria(_near);
validateQueryCriteria(_queryCriteria);
this._queriesResolved = new Array(_queries.length).fill(0);
_queries.forEach((value: GeoFirestoreTypes.web.Query, index: number) => {
const subscription = value.onSnapshot(snapshot => this._processSnapshot(snapshot, index), error => (this._error = error));
Expand All @@ -54,8 +54,23 @@ export class GeoJoinerOnSnapshot {
* Runs through documents stored in map to set value to send in `next` function.
*/
private _next(): void {
const docChanges = Array.from(this._docs.values())
.map((value: DocMap, index: number) => {
// Sort docs based on distance if there is a limit so we can then limit it
if (this._queryCriteria.limit && this._docs.size > this._queryCriteria.limit) {
const arrayToLimit = Array.from(this._docs.values());
arrayToLimit.sort((a, b) => a.distance - b.distance);
// Iterate over documents outside of limit
for (let i = this._queryCriteria.limit; i < arrayToLimit.length; i++) {
if (arrayToLimit[i].emitted) { // Mark as removed if outside of query and previously emitted
const result = { change: { ...arrayToLimit[i].change }, distance: arrayToLimit[i].distance, emitted: arrayToLimit[i].emitted };
result.change.type = 'removed';
this._docs.set(result.change.doc.id, result);
} else { // Remove if not previously in query
this._docs.delete(arrayToLimit[i].change.doc.id);
}
}
}

const docChanges = Array.from(this._docs.values()).map((value: DocMap, index: number) => {
const result: GeoFirestoreTypes.web.DocumentChange = {
type: value.change.type,
doc: value.change.doc,
Expand Down Expand Up @@ -83,7 +98,7 @@ export class GeoJoinerOnSnapshot {
this._onNext(new GeoQuerySnapshot({
docs,
docChanges: () => docChanges
} as GeoFirestoreTypes.web.QuerySnapshot, this._near.center));
} as GeoFirestoreTypes.web.QuerySnapshot, this._queryCriteria.center));
}

/**
Expand All @@ -109,9 +124,9 @@ export class GeoJoinerOnSnapshot {
*/
private _processSnapshot(snapshot: GeoFirestoreTypes.web.QuerySnapshot, index: number): void {
if (!this._firstRoundResolved) this._queriesResolved[index] = 1;
if (snapshot.docChanges().length) {
if (snapshot.docChanges().length) { // Snapshot has data, key during first snapshot
snapshot.docChanges().forEach(change => {
const distance = change.doc.data().l ? calculateDistance(this._near.center, change.doc.data().l) : null;
const distance = change.doc.data().l ? calculateDistance(this._queryCriteria.center, change.doc.data().l) : null;
const id = change.doc.id;
const fromMap = this._docs.get(id);
const doc: any = {
Expand All @@ -122,21 +137,24 @@ export class GeoJoinerOnSnapshot {
type: (fromMap && this._firstEmitted) ? change.type : 'added'
}, distance, emitted: this._firstEmitted ? !!fromMap : false
};
// Ensure doc in query radius
if (this._near.radius >= distance) {
if (!fromMap && doc.change.type === 'removed') return; // Removed doc and wasn't in map
if (!fromMap && doc.change.type === 'modified') doc.change.type = 'added'; // Modified doc and wasn't in map

if (this._queryCriteria.radius >= distance) { // Ensure doc in query radius
// Ignore doc since it wasn't in map and was already 'removed'
if (!fromMap && doc.change.type === 'removed') return;
// Mark doc as 'added' doc since it wasn't in map and was 'modified' to be
if (!fromMap && doc.change.type === 'modified') doc.change.type = 'added';
this._newValues = true;
this._docs.set(id, doc);
} else if (fromMap) {
} else if (fromMap) { // Document isn't in query, but is in map
doc.change.type = 'removed'; // Not in query anymore, mark for removal
this._newValues = true;
this._docs.set(id, doc);
} else if (!fromMap && !this._firstRoundResolved) {
} else if (!fromMap && !this._firstRoundResolved) { // Document isn't in map and the first round hasn't resolved
// This is an empty query, but it has resolved
this._newValues = true;
}
});
} else if (!this._firstRoundResolved) {
} else if (!this._firstRoundResolved) { // Snapshot doesn't have data, key during first snapshot
this._newValues = true;
}
}
Expand Down
48 changes: 36 additions & 12 deletions src/GeoQuery.ts
Expand Up @@ -11,27 +11,33 @@ import { validateQueryCriteria, geohashQueries } from './utils';
*/
export class GeoQuery {
private _center: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint;
private _limit: number;
private _radius: number;
private _isWeb: boolean;

/**
* @param _query The `Query` instance.
* @param near The center and radius of geo based queries.
* @param queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit.
*/
constructor(
private _query: GeoFirestoreTypes.cloud.Query | GeoFirestoreTypes.web.Query,
near?: GeoFirestoreTypes.QueryCriteria
queryCriteria?: GeoFirestoreTypes.QueryCriteria
) {
if (Object.prototype.toString.call(_query) !== '[object Object]') {
throw new Error('Query must be an instance of a Firestore Query');
}
this._isWeb = Object.prototype.toString
.call((_query as GeoFirestoreTypes.web.CollectionReference).firestore.enablePersistence) === '[object Function]';
if (near && near.center && near.radius) {
// Validate and save the query criteria
validateQueryCriteria(near);
this._center = near.center;
this._radius = near.radius;
if (queryCriteria) {
if (queryCriteria.limit) {
this._limit = queryCriteria.limit;
}
if (queryCriteria.center && queryCriteria.radius) {
// Validate and save the query criteria
validateQueryCriteria(queryCriteria);
this._center = queryCriteria.center;
this._radius = queryCriteria.radius;
}
}
}

Expand All @@ -53,7 +59,7 @@ export class GeoQuery {
get onSnapshot(): ((onNext: (snapshot: GeoQuerySnapshot) => void, onError?: (error: Error) => void) => () => void) {
return (onNext: (snapshot: GeoQuerySnapshot) => void, onError?: (error: Error) => void): (() => void) => {
if (this._center && this._radius) {
return new GeoJoinerOnSnapshot(this._generateQuery(), this._near, onNext, onError).unsubscribe();
return new GeoJoinerOnSnapshot(this._generateQuery(), this._queryCriteria, onNext, onError).unsubscribe();
} else {
return (this._query as GeoFirestoreTypes.web.Query).onSnapshot((snapshot) => onNext(new GeoQuerySnapshot(snapshot)), onError);
}
Expand All @@ -73,14 +79,31 @@ export class GeoQuery {
public get(options: GeoFirestoreTypes.web.GetOptions = { source: 'default' }): Promise<GeoQuerySnapshot> {
if (this._center && this._radius) {
const queries = this._generateQuery().map((query) => this._isWeb ? query.get(options) : query.get());
return Promise.all(queries).then(value => new GeoJoinerGet(value, this._near).getGeoQuerySnapshot());
return Promise.all(queries).then(value => new GeoJoinerGet(value, this._queryCriteria).getGeoQuerySnapshot());
} else {
const promise = this._isWeb ?
(this._query as GeoFirestoreTypes.web.Query).get(options) : (this._query as GeoFirestoreTypes.web.Query).get();
return promise.then((snapshot) => new GeoQuerySnapshot(snapshot));
}
}

/**
* Creates and returns a new GeoQuery that's additionally limited to only return up to the specified number of documents.
*
* This function returns a new (immutable) instance of the GeoQuery (rather than modify the existing instance) to impose the limit.
*
* Note: Geoqueries require an aggregation of queries, the library applies a paritial limit on the server,
* but primarily runs its limit on the client. This may mean you are loading to the client more documents then you intended.
* Use with this performance limitation in mind.
*
* @param limit The maximum number of items to return.
* @return The created GeoQuery.
*/
public limit(limit: number): GeoQuery {
this._limit = limit || this._limit;
return new GeoQuery(this._query.limit(limit), this._queryCriteria);
}

/**
* Creates and returns a new GeoQuery with the geoquery filter where `get` and `onSnapshot` will query around.
*
Expand All @@ -95,7 +118,7 @@ export class GeoQuery {
this._center = newGeoQueryCriteria.center || this._center;
this._radius = newGeoQueryCriteria.radius || this._radius;

return new GeoQuery(this._query, this._near);
return new GeoQuery(this._query, this._queryCriteria);
}

/**
Expand All @@ -114,7 +137,7 @@ export class GeoQuery {
opStr: GeoFirestoreTypes.WhereFilterOp,
value: any
): GeoQuery {
return new GeoQuery(this._query.where((fieldPath ? ('d.' + fieldPath) : fieldPath), opStr, value), this._near);
return new GeoQuery(this._query.where((fieldPath ? ('d.' + fieldPath) : fieldPath), opStr, value), this._queryCriteria);
}

/**
Expand All @@ -139,9 +162,10 @@ export class GeoQuery {
/**
* Returns the center and radius of geo based queries as a QueryCriteria object.
*/
private get _near(): GeoFirestoreTypes.QueryCriteria {
private get _queryCriteria(): GeoFirestoreTypes.QueryCriteria {
return {
center: this._center,
limit: this._limit,
radius: this._radius
};
}
Expand Down
11 changes: 10 additions & 1 deletion src/utils.ts
Expand Up @@ -558,7 +558,7 @@ export function validateQueryCriteria(newQueryCriteria: GeoFirestoreTypes.QueryC
// Throw an error if there are any extraneous attributes
const keys: string[] = Object.keys(newQueryCriteria);
for (const key of keys) {
if (!['center', 'radius'].includes(key)) {
if (!['center', 'radius', 'limit'].includes(key)) {
throw new Error('Unexpected attribute \'' + key + '\' found in query criteria');
}
}
Expand All @@ -576,6 +576,15 @@ export function validateQueryCriteria(newQueryCriteria: GeoFirestoreTypes.QueryC
throw new Error('radius must be greater than or equal to 0');
}
}

// Validate the 'limit' attribute
if (typeof newQueryCriteria.limit !== 'undefined') {
if (typeof newQueryCriteria.limit !== 'number' || isNaN(newQueryCriteria.limit)) {
throw new Error('limit must be a number');
} else if (newQueryCriteria.limit < 0) {
throw new Error('limit must be greater than or equal to 0');
}
}
}

/**
Expand Down

0 comments on commit 5fb5de0

Please sign in to comment.