diff --git a/packages/firebase/data/firestore-paginated.data-source.ts b/packages/firebase/data/firestore-paginated.data-source.ts new file mode 100644 index 00000000..e6461f0a --- /dev/null +++ b/packages/firebase/data/firestore-paginated.data-source.ts @@ -0,0 +1,108 @@ +import firebase from 'firebase/app'; +import { GetDataSource, PaginationPage, Query, QueryNotSupportedError } from '@mobilejazz/harmony-core'; + +import { FirestoreEntity } from './firestore.entity'; +import { FirestorePaginationNextQuery, FirestorePaginationPreviousQuery } from './firestore.query'; + +export class FirestorePage extends PaginationPage { + constructor( + values: T[], + page: number, + size: number, + readonly firstDoc: firebase.firestore.DocumentSnapshot, + readonly lastDoc: firebase.firestore.DocumentSnapshot, + ) { + super(values, page, size); + } +} + +export class FirestorePaginatedDataSource implements GetDataSource>> { + protected collection: firebase.firestore.CollectionReference; + + constructor(protected collectionName: string, protected firestore: firebase.firestore.Firestore) { + this.collection = this.firestore.collection(collectionName); + } + + public async get(query: Query): Promise>> { + if (query instanceof FirestorePaginationNextQuery || query instanceof FirestorePaginationPreviousQuery) { + let fsQuery: firebase.firestore.Query; + + // Check if `doc` filter is present + const idFilter = (query.filterBy || []).find(f => f.fieldPath === 'doc'); + const hasIdFilter = idFilter !== undefined; + + if (hasIdFilter) { + const doc = await this.collection.doc(idFilter.value).get(); + const entity = { id: doc.id, data: doc.data() as T }; + + if (doc.exists) { + return new FirestorePage([entity], query.page, query.size, doc, doc); + } else { + return new FirestorePage([], query.page, query.size, undefined, undefined); + } + } + + // Order + if (query.orderBy) { + const filters = query.filterBy ?? []; + const isOrderInFilter = filters.some(f => f.fieldPath === query.orderBy.field); + + // If inequality filters are applied (e.g. for searching on a field that starts by a word) then + // the first `orderBy`, if present, must be on the same field that it's being filtered. Which makes `orderBy` useless. + // That's why if there is an inequality filter we ignore ordering. Firebase rulez. -_- + const hasInequalityFilter = filters.some(f => ['<', '<=', '>', '>='].includes(f.opStr)); + + // Apply order only if we're not filtering on the same field + if (!isOrderInFilter && !hasInequalityFilter) { + fsQuery = this.collection.orderBy(query.orderBy.field, query.orderBy.direction); + } + } + + if (query instanceof FirestorePaginationNextQuery) { + fsQuery = (fsQuery ? fsQuery : this.collection).limit(query.size); + + (query.filterBy || []).forEach(f => { + fsQuery = fsQuery.where(f.fieldPath, f.opStr, f.value); + }); + + if (query.lastDoc) { + fsQuery = fsQuery.startAfter(query.lastDoc); + } + } else if (query instanceof FirestorePaginationPreviousQuery) { + fsQuery = (fsQuery ? fsQuery : this.collection).limitToLast(query.size); + + (query.filterBy || []).forEach(f => { + fsQuery = fsQuery.where(f.fieldPath, f.opStr, f.value); + }); + + if (query.firstDoc) { + fsQuery = fsQuery.endBefore(query.firstDoc); + } + } + + // Get + const entries = await fsQuery.get(); + const values = entries.docs.map((doc: firebase.firestore.QueryDocumentSnapshot) => { + return { id: doc.id, data: doc.data() as T }; + }); + + if (values.length) { + return new FirestorePage( + values, + query.page, + query.size, + entries.docs[0], + entries.docs[entries.docs.length - 1], + ); + } else { + return new FirestorePage(values, query.page, query.size, undefined, undefined); + } + } + + throw new QueryNotSupportedError(); + } + + public getAll(query: Query): Promise>[]> { + throw new QueryNotSupportedError(); + } +} diff --git a/packages/firebase/data/firestore.data-source.ts b/packages/firebase/data/firestore.data-source.ts new file mode 100644 index 00000000..ac97254b --- /dev/null +++ b/packages/firebase/data/firestore.data-source.ts @@ -0,0 +1,117 @@ +import firebase from 'firebase/app'; +import { + DeleteDataSource, + GetDataSource, + IdQuery, + NotFoundError, + PutDataSource, + Query, + QueryNotSupportedError, +} from '@mobilejazz/harmony-core'; + +import { FirestoreIncrementQuery, FirestoreCreateDocumentQuery } from './firestore.query'; +import { FirestoreEntity } from './firestore.entity'; + +export class FirestoreDataSource + implements GetDataSource>, PutDataSource>, DeleteDataSource +{ + protected collection: firebase.firestore.CollectionReference; + + constructor(protected collectionName: string, protected firestore: firebase.firestore.Firestore) { + this.collection = this.firestore.collection(collectionName); + } + + protected async findOne(query: firebase.firestore.Query): Promise> { + const result = await query.limit(1).get(); + + if (!result.empty) { + const doc = result.docs[0]; + + return { + id: doc.id, + data: doc.data() as T, + }; + } else { + throw new NotFoundError(); + } + } + + async get(query: Query): Promise> { + if (query instanceof IdQuery) { + const doc = await this.collection.doc(query.id).get(); + + if (doc.exists) { + return { + id: doc.id, + data: doc.data() as T, + }; + } else { + throw new NotFoundError(); + } + } + + throw new QueryNotSupportedError(); + } + + getAll(query: Query): Promise[]> { + throw new QueryNotSupportedError(); + } + + delete(query: Query): Promise { + throw new QueryNotSupportedError(); + } + + deleteAll(query: Query): Promise { + throw new QueryNotSupportedError(); + } + + async put(value: FirestoreEntity, query: Query): Promise> { + if (query instanceof IdQuery) { + const ref = this.collection.doc(query.id); + const doc = await ref.get(); + + if (doc.exists) { + await ref.update(value.data); + } else { + await ref.set(value.data); + } + + return { + id: doc.id, + data: doc.data() as T, + }; + } else if (query instanceof FirestoreCreateDocumentQuery) { + if (value.id) { + const ref = this.collection.doc(value.id); + await ref.set(value.data); + + return value; + } else { + const ref = await this.collection.add(value.data); + const doc = await ref.get(); + + return { + id: doc.id, + data: doc.data() as T, + }; + } + } else if (query instanceof FirestoreIncrementQuery) { + const increment = firebase.firestore.FieldValue.increment(query.amount); + const userRef = this.collection.doc(value.id); + const data = {}; + + // Build udpate "instructions" + data[query.field] = increment; + + await userRef.update(data); + + return value; + } + + throw new QueryNotSupportedError(); + } + + putAll(values: FirestoreEntity[], query: Query): Promise[]> { + throw new QueryNotSupportedError(); + } +} diff --git a/packages/firebase/data/firestore.entity.ts b/packages/firebase/data/firestore.entity.ts new file mode 100644 index 00000000..55f99897 --- /dev/null +++ b/packages/firebase/data/firestore.entity.ts @@ -0,0 +1,4 @@ +export interface FirestoreEntity { + id: string; + data: T; +} diff --git a/packages/firebase/data/firestore.query.ts b/packages/firebase/data/firestore.query.ts new file mode 100644 index 00000000..bd5dbba6 --- /dev/null +++ b/packages/firebase/data/firestore.query.ts @@ -0,0 +1,49 @@ +import firebase from 'firebase/app'; +import { PaginationQuery, Query } from '@mobilejazz/harmony-core'; + +export class FirestoreCreateDocumentQuery extends Query {} + +export class FirestoreIncrementQuery extends Query { + constructor(readonly field: string, readonly amount: number) { + super(); + } +} + +export class FirestoreWhereClause { + constructor( + readonly fieldPath: string | firebase.firestore.FieldPath, + readonly opStr: firebase.firestore.WhereFilterOp, + readonly value: any, + ) {} +} + +export type FirestoreFilter = FirestoreWhereClause[]; + +export interface FirestoreOrder { + field: string; + direction?: 'asc' | 'desc'; +} + +export class FirestorePaginationNextQuery extends PaginationQuery { + constructor( + readonly page: number, + readonly size: number, + readonly filterBy: FirestoreFilter, + readonly orderBy: FirestoreOrder, + readonly lastDoc: firebase.firestore.DocumentSnapshot, + ) { + super(); + } +} + +export class FirestorePaginationPreviousQuery extends PaginationQuery { + constructor( + readonly page: number, + readonly size: number, + readonly filterBy: FirestoreFilter, + readonly orderBy: FirestoreOrder, + readonly firstDoc: firebase.firestore.DocumentSnapshot, + ) { + super(); + } +} diff --git a/packages/firebase/index.ts b/packages/firebase/index.ts new file mode 100644 index 00000000..85732c83 --- /dev/null +++ b/packages/firebase/index.ts @@ -0,0 +1,4 @@ +export * from './data/firestore-paginated.data-source'; +export * from './data/firestore.data-source'; +export * from './data/firestore.entity'; +export * from './data/firestore.query';