UNPKG

simplyfire

Version:

A lightweight firestore api for firebase cloud functions & Angular.

408 lines (400 loc) 15.5 kB
import * as i0 from '@angular/core'; import { inject, EnvironmentInjector, runInInjectionContext, Injectable } from '@angular/core'; import { endBefore, endAt, startAfter, startAt, limitToLast, limit, orderBy, where, query, getFirestore, doc, setDoc, addDoc, collection, updateDoc, deleteDoc, writeBatch, serverTimestamp, increment, runTransaction, collectionData, collectionGroup, getDocs, collectionChanges, docData, documentId } from '@angular/fire/firestore'; import { lastValueFrom, of, defer, combineLatest } from 'rxjs'; import { map, take, switchMap } from 'rxjs/operators'; class AbstractFirestoreApi { // Maximum number of writes that can be passed to a Commit operation // or performed in a transaction // https://cloud.google.com/firestore/quotas#writes_and_transactions BATCH_MAX_WRITES = 500; getValueFromSnapshot(snapshot) { return (snapshot.exists ? snapshot.data() : null); } } class QueryBuilder { _where = []; _orderBy = []; _leftJoins = []; _limit; _limitToLast; _startAt; _startAfter; _endAt; _endBefore; get joins() { return this._leftJoins; } where(...where) { this._where.push(where); return this; } orderBy(...orderBy) { this._orderBy.push(orderBy); return this; } leftJoin(...leftJoin) { this._leftJoins.push(leftJoin); } limit(limit) { this._limit = limit; return this; } limitToLast(limitToLast) { this._limitToLast = limitToLast; return this; } startAt(...startAt) { this._startAt = startAt; return this; } startAfter(...startAfter) { this._startAfter = startAfter; return this; } endAt(...endAt) { this._endAt = endAt; return this; } endBefore(...endBefore) { this._endBefore = endBefore; return this; } // Still have to use <any> type due to most interfaces of @google-cloud/firestore // are not compatible with @firebase/firestore's interfaces. exec(ref, queryOps) { if (typeof window === 'undefined') { return this.execQueryForCloud(ref); } if (!queryOps) { throw Error('invalid arguments'); } const { query, where, orderBy, limit, limitToLast, startAt, startAfter, endAt, endBefore } = queryOps; const queryConstraints = [ ...this._where.map((w) => where(...w)), ...this._orderBy.map((o) => orderBy(...o)), ...(this._limit ? [limit(this._limit)] : []), ...(this._limitToLast ? [limitToLast(this._limitToLast)] : []), ...(this._startAt?.every((i) => !!i) ? [startAt(...this._startAt)] : []), ...(this._startAfter?.every((i) => !!i) ? [startAfter(...this._startAfter)] : []), ...(this._endAt?.every((i) => !!i) ? [endAt(...this._endAt)] : []), ...(this._endBefore?.every((i) => !!i) ? [endBefore(...this._endBefore)] : []) ]; return query(ref, ...queryConstraints); } execQueryForCloud(ref) { let query = this._where.reduce((q, wh) => q.where(...wh), ref); query = this._orderBy.reduce((q, ob) => q.orderBy(...ob), query); if (this._limit) { query = query.limit(this._limit); } if (this._limitToLast) { query = query.limitToLast(this._limitToLast); } if (this._startAt) { query = query.startAt(this._startAt); } if (this._startAfter) { query = query.startAfter(this._startAfter); } if (this._endAt) { query = query.endAt(this._endAt); } if (this._endBefore) { query = query.endBefore(this._endBefore); } return query; } } // chunk array to a certain size const arrayToChunks = (list, size) => { list = [...list]; return [...Array(Math.ceil(list.length / size))].map((_) => list.splice(0, size)); }; // flatten the array of observables to one level deep const flatten = (source) => { return source.pipe(map((arr) => arr.reduce((acc, cur) => acc.concat(cur)))); }; const queryOps = { query, where, orderBy, limit, limitToLast, startAt, startAfter, endAt, endBefore }; const CACHE_MAX_AGE = 5 * 60 * 1000; class FirestoreService extends AbstractFirestoreApi { injector = inject(EnvironmentInjector); run(fn) { return runInInjectionContext(this.injector, fn); } get firestore() { return this.run(() => getFirestore()); } cache = new Map(); // ----------------------------------------------------------------------------------------------------- // @ Abstract members // ----------------------------------------------------------------------------------------------------- collection(path, qb, maxAge = CACHE_MAX_AGE) { return lastValueFrom(this.collectionWithCache(path, qb, maxAge).pipe(take(1))); } collectionGroup(collectionId, qb, maxAge = CACHE_MAX_AGE) { return lastValueFrom(this.collectionGroupWithCache(collectionId, qb, maxAge).pipe(take(1))); } doc(path, maxAge = CACHE_MAX_AGE) { return lastValueFrom(this.docWithCache(path, maxAge).pipe(take(1))); } docRef(path) { return this.run(() => doc(this.firestore, path)); } async upsert(collectionPath, data, opts = { merge: true }) { const timestamp = this.serverTimestamp; let { id, ...updata } = data; updata.createdTs ??= timestamp; updata.updatedTs = timestamp; if (id) { await this.run(() => setDoc(this.docRef(`${collectionPath}/${id}`), Object.assign({}, updata), opts)); } else { id = (await this.run(() => addDoc(collection(this.firestore, collectionPath), updata))).id; } return id; } update(docPath, data) { const updatedTs = this.serverTimestamp; // ignore id delete data['id']; return this.run(() => updateDoc(this.docRef(docPath), Object.assign({}, data, { updatedTs }))); } delete(docPath) { return this.run(() => deleteDoc(this.docRef(docPath))); } async bulkUpsert(path, data, opts = { merge: true }) { const bulkIds = []; const promises = []; if (Array.isArray(data)) { // Due to a batch limitation, need to split docs array into chunks for (const chunks of arrayToChunks(data, this.BATCH_MAX_WRITES)) { const batch = this.batch; const timestamp = this.serverTimestamp; chunks.forEach(({ id, ...updata }) => { updata.createdTs ??= timestamp; updata.updatedTs = timestamp; let docRef; if (id) { docRef = this.docRef(`${path}/${id}`); } else { docRef = doc(collection(this.firestore, path)); } bulkIds.push(docRef.id); batch.set(docRef, updata, opts); }); const p = batch.commit(); promises.push(p); } } else { const snapshot = await this.collectionSnapshot(path, data.qb); // Due to a batch limitation, need to split docs array into chunks for (const chunks of arrayToChunks(snapshot.docs, this.BATCH_MAX_WRITES)) { const batch = this.batch; const timestamp = this.serverTimestamp; chunks.forEach((d) => batch.set(d.ref, { updatedTs: timestamp, ...data.data }, opts) && bulkIds.push(d.id)); const p = batch.commit(); promises.push(p); } } await Promise.all(promises); return bulkIds; } async bulkDelete(path, qb, maxSize = 1000) { qb ??= new QueryBuilder(); qb.limit(maxSize); const bulkIds = []; const promises = []; const snapshot = await this.collectionSnapshot(path, qb); // Due to a batch limitation, need to split docs array into chunks for (const chunks of arrayToChunks(snapshot.docs, this.BATCH_MAX_WRITES)) { const batch = this.batch; chunks.forEach((d) => batch.delete(d.ref) && bulkIds.push(d.id)); const p = batch.commit(); promises.push(p); } await Promise.all(promises); return bulkIds; } /** * write batch */ get batch() { return this.run(() => writeBatch(this.firestore)); } /** * firestore timestamp */ get serverTimestamp() { return this.run(() => serverTimestamp()); } /** * FieldValue increment */ increment(n = 1) { return this.run(() => increment(n)); } /** * Returns a generated Firestore Document Id. */ createId(colPath) { return this.run(() => doc(collection(this.firestore, colPath ?? '_')).id); } runTransaction(updateFunction) { return this.run(() => runTransaction(this.firestore, updateFunction)); } // ----------------------------------------------------------------------------------------------------- // @ Custom methods // ----------------------------------------------------------------------------------------------------- collectionValueChanges(path, qb) { qb ??= new QueryBuilder(); return this.run(() => { const collectionRef = collection(this.firestore, path); return collectionData(qb.exec(collectionRef, queryOps), { idField: 'id' }).pipe((s) => (qb?.joins ?? []).map((j) => leftJoin(this, ...j)).reduce((ss, o) => o(ss), s)); }); } collectionGroupValueChanges(collectionId, qb) { qb ??= new QueryBuilder(); return this.run(() => { const collectionRef = collectionGroup(this.firestore, collectionId); return collectionData(qb.exec(collectionRef, queryOps), { idField: 'id' }).pipe((s) => (qb?.joins ?? []).map((j) => leftJoin(this, ...j)).reduce((ss, o) => o(ss), s)); }); } collectionSnapshot(path, qb) { qb ??= new QueryBuilder(); return this.run(() => { const collectionRef = collection(this.firestore, path); return getDocs(qb.exec(collectionRef, queryOps)); }); } collectionSnapshotChanges(path, qb, events) { qb ??= new QueryBuilder(); return this.run(() => { const collectionRef = collection(this.firestore, path); return collectionChanges(qb.exec(collectionRef, queryOps), { events }).pipe(map((changes) => changes.map((c) => Object.assign({}, c.doc.data(), { id: c.doc.id }))), (s) => (qb?.joins ?? []).map((j) => leftJoin(this, ...j)).reduce((ss, o) => o(ss), s)); }); } collectionGroupSnapshotChanges(collectionId, qb, events) { qb ??= new QueryBuilder(); return this.run(() => { const collectionRef = collectionGroup(this.firestore, collectionId); return collectionChanges(qb.exec(collectionRef, queryOps), { events }).pipe(map((changes) => changes.map((c) => Object.assign({}, c.doc.data(), { id: c.doc.id }))), (s) => (qb?.joins ?? []).map((j) => leftJoin(this, ...j)).reduce((ss, o) => o(ss), s)); }); } docValueChanges(path) { return this.run(() => { const docRef = doc(this.firestore, path); return docData(docRef, { idField: 'id' }); }); } /** * @experimental * * Cache collection data in memory */ collectionWithCache(path, qb, maxAge) { return this.fetchFromCache(path + (qb ? JSON.stringify(qb) : ''), this.collectionValueChanges(path, qb), maxAge); } /** * @experimental * * Cache collectionGroup data in memory */ collectionGroupWithCache(collectionId, qb, maxAge) { return this.fetchFromCache(collectionId + (qb ? JSON.stringify(qb) : ''), this.collectionGroupValueChanges(collectionId, qb), maxAge); } /** * @experimental * * Cache document data in memory */ docWithCache(path, maxAge) { return this.fetchFromCache(path, this.docValueChanges(path), maxAge); } /** * @experimental * * Delete cached data from the memory */ deleteCache(path, qb) { const key = path + (qb ? JSON.stringify(qb) : ''); return this.cache.delete(key); } fetchFromCache(key, source, maxAge) { const cached = this.cache.get(key); if (maxAge === 0 || !cached || (maxAge && Date.now() - cached.lastRead > maxAge)) { return source.pipe(map((data) => this.cache.set(key, { lastRead: Date.now(), data }) && data)); } return of(cached.data); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: FirestoreService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: FirestoreService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: FirestoreService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); const leftJoin = (fs, key, collection, alias, maxAge) => { if (key === alias) { throw Error('Due to use of Cache, you must use different alias for a key.'); } return (source) => defer(() => { let ret; return source.pipe(switchMap((data) => { ret = data; const fetchJoinData = (id) => { if (Array.isArray(id)) { const qb = new QueryBuilder(); qb.where(documentId(), 'in', id); return fs.collectionWithCache(collection, qb, maxAge); } return fs.docWithCache(`${collection}/${id}`, maxAge); }; if (Array.isArray(data)) { const docs$ = ret.filter((i) => i[key]).map((i) => fetchJoinData(i[key])); return docs$.length ? combineLatest(docs$) : of([]); } return data && data[key] ? fetchJoinData(data[key]) : of(null); }), map((joins) => { if (Array.isArray(ret)) { return ret.map((r) => { const id = r[key]; if (id) { if (Array.isArray(id)) { r[alias] = joins .filter((j) => Array.isArray(j) && JSON.stringify(j.map((jj) => jj.id).sort()) === JSON.stringify(id.sort())) .pop(); } else { r[alias] = joins.filter((j) => j?.id === id).pop(); } } return r; }); } if (ret) { ret[alias] = joins; } return ret; })); }); }; /** * Generated bundle index. Do not edit. */ export { AbstractFirestoreApi, FirestoreService, QueryBuilder, arrayToChunks, flatten }; //# sourceMappingURL=simplyfire-ngx.mjs.map