UNPKG

@webda/gcp

Version:

Webda GCP Services implementation

423 lines 15.1 kB
import { FieldValue, Firestore, Timestamp } from "@google-cloud/firestore"; import { JSONUtils, Store, StoreNotFoundError, StoreParameters, UpdateConditionFailError, WebdaQL } from "@webda/core"; /** * Firebase parameters */ export class FireStoreParameters extends StoreParameters { constructor(params, service) { super(params, service); // Default to empty compoundIndexes this.compoundIndexes ?? (this.compoundIndexes = []); } } /** * Implement Firebase abstraction within Webda * * @WebdaModda GoogleCloudFireStore */ export default class FireStore extends Store { /** * @override */ loadParameters(params) { return new FireStoreParameters(params, this); } /** * @override */ async init() { await super.init(); this.firestore = new Firestore(); this.firestore.settings({ ignoreUndefinedProperties: true }); this.compoundIndexes = {}; this.parameters.compoundIndexes.forEach(a => { const key = Object.keys(a).join("/"); // Should contain the array of accessible order this.compoundIndexes[key] = {}; Object.keys(a).forEach(field => { var _a; (_a = this.compoundIndexes[key])[field] ?? (_a[field] = new Set()); this.compoundIndexes[key][field].add(a[field]); }); }); return this; } /** * Get Document Reference * @param uuid * @param collection * @returns */ getDocumentRef(uuid, collection = this.parameters.collection) { return this.firestore.doc(`${collection}/${uuid}`); } /** * @override * * Return all for now */ async find(parsedQuery) { let offset = parseInt(parsedQuery.continuationToken || "0"); let query = this.firestore.collection(this.parameters.collection); if (offset) { query = query.offset(offset); } let filter = new WebdaQL.AndExpression([]); let rangeAttribute; let toProcess; if (!(parsedQuery.filter instanceof WebdaQL.AndExpression)) { toProcess = new WebdaQL.AndExpression([parsedQuery.filter]); } else { toProcess = parsedQuery.filter; } const queryAttributes = new Set(); let count = 0; let hasIn = false; let hasContains = false; toProcess.children.forEach((child) => { if (!(child instanceof WebdaQL.ComparisonExpression) || count) { // Do not manage OR yet filter.children.push(child); return; } if (child.operator === "LIKE") { this.log("WARN", "Firebase do not natively have 'LIKE'"); // LIKE is not managed by Firestore filter.children.push(child); return; } let operator; let attribute = child.attribute.join("."); queryAttributes.add(attribute); // CONTAINS -> array_contains // Translate operators if (["=", "IN", "CONTAINS"].includes(child.operator)) { // = is permitted on every fields // if rangeQuery then need to check indexes if (child.operator === "=") { operator = "=="; } else if (child.operator === "IN") { operator = "in"; if (child.value.length > 10) { this.log("WARN", "Firebase cannot have more than 10 values for 'in'"); filter.children.push(child); return; } if (hasIn) { this.log("WARN", "Firebase cannot have two 'in' (IN) clause"); filter.children.push(child); return; } hasIn = true; } else if (child.operator === "CONTAINS") { operator = "array-contains"; if (hasContains) { this.log("WARN", "Firebase cannot have two 'array-contains' (CONTAINS) clause"); filter.children.push(child); return; } hasContains = true; } } else { // Requires compoundIndex if (queryAttributes.size > 1 && !this.compoundIndexes[[...queryAttributes.values()].sort().join("/")]) { this.log("WARN", "Compound index not defined"); filter.children.push(child); return; } if (child.operator !== "!=") { // Check compoundIndex exist rangeAttribute ?? (rangeAttribute = attribute); // Range need to apply on only one attribute if (rangeAttribute !== attribute) { filter.children.push(child); return; } } operator = child.operator; } query = query.where(attribute, operator, child.value); }); // OrderBy have quite some complexity with FireStore query = this.handleOrderBy(query, parsedQuery.orderBy, rangeAttribute); const res = await query.limit(parsedQuery.limit || 1000).get(); return { results: res.docs.map(d => this.initModel(this.giveDatesBack(d.data()))), filter: filter.children.length ? filter : true, continuationToken: res.docs.length >= parsedQuery.limit ? (offset + parsedQuery.limit).toString() : undefined }; } /** * Manage OrderBy complex condition on Firebase * @param query * @param orderBy * @param rangeAttribute * @param index */ handleOrderBy(query, orderBy, rangeAttribute) { // Manage order if index if (!orderBy) { return query; } let requiredIndex = orderBy.map(order => order.field).sort(); let orders; // Require index with multiple ORDER BY if (requiredIndex.length > 1) { orders = this.compoundIndexes[requiredIndex.join("/")]; if (!orders) { this.log("WARN", "Skip orderBy as we are missing the index"); return query; } } // Range must be the first orderBy if (rangeAttribute) { if (!orderBy.some(order => { // Need to check the permitted orderBy and direction if (order.field === rangeAttribute) { query = query.orderBy(order.field, order.direction.toLowerCase()); return true; } })) { this.log("WARN", "Skip orderBy as the range attribute is not within ORDER BY expression"); // If rangeAttribute is not in orderBy then skip the orderBy completely return query; } } // Add remaining orderBy from index orderBy .filter(order => order.field !== rangeAttribute) .forEach(order => { if (!orders || orders[order.field].has(order.direction.toLowerCase())) { query = query.orderBy(order.field, order.direction.toLowerCase()); } else { this.log("WARN", "Skip orderBy as the direction does not match index"); } }); return query; } /** * @override */ async _exists(uid) { return (await this.getDocumentRef(uid).get()).exists; } /** * @override */ async _delete(uid, writeCondition, itemWriteConditionField) { await this.firestore.runTransaction(async (t) => { const docRef = this.getDocumentRef(uid); const doc = (await t.get(docRef)).data(); if (writeCondition && doc[itemWriteConditionField] !== writeCondition) { throw new UpdateConditionFailError(uid, itemWriteConditionField, writeCondition); } t.delete(docRef); }); } /** * Recursively replace Timestamp by Date * @param doc * @returns */ giveDatesBack(doc) { if (doc instanceof Timestamp) { return doc.toDate(); } else if (Array.isArray(doc)) { return doc.map(d => this.giveDatesBack(d)); } else if (doc instanceof Object) { let res = {}; Object.keys(doc).forEach(k => { res[k] = this.giveDatesBack(doc[k]); }); return res; } else { return doc; } } /** * @override */ async _get(uid, raiseIfNotFound) { let doc = (await this.getDocumentRef(uid).get()).data(); if (!doc) { if (raiseIfNotFound) { throw new StoreNotFoundError(uid, this.getName()); } return undefined; } return this.initModel(this.giveDatesBack(doc)); } /** * @override */ async getAll(list) { let res = []; if (list) { res = (await this.firestore.collection(this.parameters.collection).where("uuid", "in", list).get()).docs; } else { // Limit to 1M docs res = (await this.firestore.collection(this.parameters.collection).limit(1000000).get()).docs; } return res.map(doc => this.initModel(this.giveDatesBack(doc.data()))); } /** * @override */ async _update(object, uid, itemWriteCondition, itemWriteConditionField) { await this._setDocument(false, object, uid, itemWriteCondition, itemWriteConditionField); } checkCondition(uid, data, field, condition) { if (condition) { let current = data[field] instanceof Timestamp ? data[field].toDate().toISOString() : data[field]; if (condition instanceof Date) { condition = condition.toISOString(); } if (current !== condition) { throw new UpdateConditionFailError(uid, field, condition); } } } /*** * Internal function that implement both update and patch */ async _setDocument(merge, object, uid, itemWriteCondition, itemWriteConditionField) { let update = object; // Ensure we send pure prototype if (object.toStoredJSON && typeof object.toStoredJSON === "function") { update = object.toStoredJSON(); } else { update = JSONUtils.duplicate(object); } await this.firestore.runTransaction(async (t) => { const docRef = this.getDocumentRef(uid); const doc = await t.get(docRef); if (!doc.exists) { throw new StoreNotFoundError(uid, this.getName()); } this.checkCondition(uid, doc.data(), itemWriteConditionField, itemWriteCondition); t.set(docRef, update, { merge }); }); } /** * @override */ async _patch(object, uid, itemWriteCondition, itemWriteConditionField) { await this._setDocument(true, object, uid, itemWriteCondition, itemWriteConditionField); } /** * @override */ async _removeAttribute(uuid, attribute, itemWriteCondition, itemWriteConditionField) { await this.firestore.runTransaction(async (t) => { const docRef = this.getDocumentRef(uuid); const doc = await t.get(docRef); if (!doc.exists) { throw new StoreNotFoundError(uuid, this.getName()); } this.checkCondition(uuid, doc.data(), itemWriteConditionField, itemWriteCondition); t.update(docRef, { [attribute]: FieldValue.delete() }); }); } /** * @override */ async _save(object) { await this.getDocumentRef(object.getUuid()).set(object.toStoredJSON()); return object; } /** * @override */ async _incrementAttributes(uid, params, updateDate) { try { const args = { ["_lastUpdate"]: updateDate }; params.forEach(p => { args[p.property] = FieldValue.increment(p.value); }); await this.getDocumentRef(uid).update(args); } catch (err) { if (err.code === 5) { throw new StoreNotFoundError(uid, this.getName()); } throw err; } } /** * @override */ async _upsertItemToCollection(uid, prop, item, index, itemWriteCondition, itemWriteConditionField, updateDate) { if (index !== undefined) { await this.firestore.runTransaction(async (t) => { const docRef = this.getDocumentRef(uid); const doc = await t.get(docRef); if (!doc.exists) { throw new StoreNotFoundError(uid, this.getName()); } const data = doc.data(); this.checkCondition(uid, data[prop][index], itemWriteConditionField, itemWriteCondition); data[prop][index] = item; t.update(docRef, { [prop]: data[prop], ["_lastUpdate"]: updateDate }); }); } else { try { await this.getDocumentRef(uid).update({ [prop]: FieldValue.arrayUnion(item) }); } catch (err) { if (err.code === 5) { throw new StoreNotFoundError(uid, this.getName()); } throw err; } } } /** * @override */ async _deleteItemFromCollection(uid, prop, index, itemWriteCondition, itemWriteConditionField, updateDate) { await this.firestore.runTransaction(async (t) => { const docRef = this.getDocumentRef(uid); const doc = await t.get(docRef); if (!doc.exists) { throw new StoreNotFoundError(uid, this.getName()); } const data = doc.data(); this.checkCondition(uid, data[prop][index], itemWriteConditionField, itemWriteCondition); // Get item from doc? t.update(docRef, { [prop]: FieldValue.arrayRemove(data[prop][index]), ["_lastUpdate"]: updateDate }); }); } /** * @override */ async __clean() { // Empty on purpose, unit test use custom collection each time } } export { FireStore }; //# sourceMappingURL=firestore.js.map