UNPKG

fireodm

Version:

A basic and extensible ODM for the Firestore Admin SDK in Node.js with decorators, relationships, and validation.

762 lines 34.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.BaseModel = void 0; const firestore_1 = require("firebase-admin/firestore"); require("reflect-metadata"); const zod_1 = require("zod"); const firestore_instance_1 = require("../config/firestore-instance"); const context_1 = require("./context"); const decorators_1 = require("./decorators"); const errors_1 = require("./errors"); const validation_1 = require("./validation"); function isFirestoreTransaction(obj) { return (obj && typeof obj.get === "function" && typeof obj.set === "function" && typeof obj.update === "function" && typeof obj.delete === "function"); } function isWriteBatch(obj) { return (obj && typeof obj.commit === "function" && typeof obj.get === "undefined"); } class BaseModel { static get schema() { if (!this._builtSchema) { this._builtSchema = (0, validation_1.getValidationSchema)(this); } return this._builtSchema; } constructor(data, idOrParent) { // cache for popular relationships (avoids recharge) this._populatedRelations = {}; if (idOrParent instanceof BaseModel) { this.__parent = idOrParent; if (idOrParent.__parent) { this.__parent.__parent = idOrParent.__parent; } } else if (typeof idOrParent === "string") { this.id = idOrParent; } Object.assign(this, data); } get docRef() { return this._getDocRef(); } get collectionRef() { return this.docRef.parent; } // --- Static methods --- static _getCollectionName() { const name = (0, decorators_1.getCollectionName)(this); if (!name) { throw new Error(`@Collection decorator is not defined on class ${this.name}`); } return name; } static _getRelationMetadata() { return (0, decorators_1.getRelationMetadata)(this); } static getCollectionRef() { const db = (0, firestore_instance_1.getFirestoreInstance)(); return db .collection(this._getCollectionName()) .withConverter(this._getFirestoreConverter()); } static _getFirestoreConverter() { const Self = this; return { toFirestore(modelInstance) { return modelInstance._toFirestore(true); }, fromFirestore(snapshot, options) { const instance = Self._fromFirestore(snapshot); if (!instance) { throw new Error(`Failed to convert snapshot ${snapshot.id} to ${Self.name}`); } return instance; }, }; } /** * Get a typed CollectionReference for a named subcollection on this document. */ async subcollection(prop, queryFn) { const ctor = this.constructor; const metas = (Reflect.getOwnMetadata(decorators_1.SUBCOL_KEY, ctor) || []); const meta = metas.find((m) => m.propertyName === prop); if (!meta) { throw new Error(`@SubCollection not defined for property "${String(prop)}" on ${ctor.name}`); } const parentDocRef = this._getDocRef(); const subCollectionRef = parentDocRef .collection(meta.name) .withConverter(meta.model()._getFirestoreConverter()); const query = queryFn ? queryFn(subCollectionRef) : subCollectionRef; const snap = await query.get(); return snap.docs.map((d) => { const inst = d.data(); inst.__parent = this; return inst; }); } static _fromFirestore(snapshot) { if (!snapshot.exists) { return null; } const data = snapshot.data() || {}; const relationMeta = this._getRelationMetadata(); const instanceData = {}; // Separate data into relational and non-relational for the constructor for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { // Check if it's a relation field that holds a DocumentReference const isRelationRef = relationMeta.some((meta) => meta.propertyName === key && data[key] instanceof firestore_1.DocumentReference); if (!isRelationRef) { instanceData[key] = data[key]; } } } // Create the instance with non-relational data const instance = new this(instanceData, snapshot.id); instance.__docRef = snapshot.ref; // Now, assign DocumentReferences for relations directly to the instance relationMeta.forEach((meta) => { const refData = snapshot.get(meta.propertyName); if (refData instanceof firestore_1.DocumentReference) { instance[meta.propertyName] = refData; } // If data[meta.propertyName] was not a DocumentReference but existed (e.g., nested map), // Object.assign in the constructor likely handled it. }); // Call afterLoad hook (async, non-blocking) Promise.resolve(instance.afterLoad(snapshot)).catch((err) => { console.error(`Error in afterLoad hook for ${this.name} ID ${instance.id}:`, err); }); return instance; } static async findById(id, options) { var _a; try { const docRef = this.getCollectionRef().doc(id); const docSnap = await docRef.get(); const instance = this._fromFirestore(docSnap); if (!instance) return null; const relMeta = this._getRelationMetadata(); const eagerRels = relMeta .filter((m) => !m.lazy) .map((m) => m.propertyName); if (options === null || options === void 0 ? void 0 : options.populate) { await instance.populate(options.populate); } else if (eagerRels.length) { await instance.populate(eagerRels); } const subMetas = Reflect.getOwnMetadata(decorators_1.SUBCOL_KEY, this) || []; const eagerSubs = subMetas.map((m) => m.propertyName); const subsToPopulate = ((_a = options === null || options === void 0 ? void 0 : options.populateSub) === null || _a === void 0 ? void 0 : _a.length) ? options.populateSub : eagerSubs; for (const propName of subsToPopulate || []) { const meta = subMetas.find((m) => m.propertyName === propName); if (!meta) continue; const items = await instance.subcollection(propName); instance[propName] = items; } return instance; } catch (error) { if ((error === null || error === void 0 ? void 0 : error.code) === 5) { return null; } throw error; } } static async findAll(options) { var _a; try { let query = this.getCollectionRef(); if (options === null || options === void 0 ? void 0 : options.queryFn) { query = options.queryFn(this.getCollectionRef()); } if (options === null || options === void 0 ? void 0 : options.orderBy) { query = query.orderBy(options.orderBy.field, options.orderBy.direction); } else if ((options === null || options === void 0 ? void 0 : options.startAfter) || (options === null || options === void 0 ? void 0 : options.startAt) || (options === null || options === void 0 ? void 0 : options.endBefore) || (options === null || options === void 0 ? void 0 : options.endAt)) { query = query.orderBy(firestore_1.FieldPath.documentId()); } if (options === null || options === void 0 ? void 0 : options.startAfter) query = query.startAfter(options.startAfter); if (options === null || options === void 0 ? void 0 : options.startAt) query = query.startAt(options.startAt); if (options === null || options === void 0 ? void 0 : options.endBefore) query = query.endBefore(options.endBefore); if (options === null || options === void 0 ? void 0 : options.endAt) query = query.endAt(options.endAt); if (options === null || options === void 0 ? void 0 : options.limit) query = query.limit(options.limit); const snapshot = await query.get(); const results = []; for (const doc of snapshot.docs) { const instance = doc.data(); if (!instance) continue; const relMeta = this._getRelationMetadata(); const eagerRels = relMeta .filter((m) => !m.lazy) .map((m) => m.propertyName); if (options === null || options === void 0 ? void 0 : options.populate) { await instance.populate(options.populate); } else if (eagerRels.length) { await instance.populate(eagerRels); } const subMetas = Reflect.getOwnMetadata(decorators_1.SUBCOL_KEY, this) || []; const eagerSubs = subMetas.map((m) => m.propertyName); const subsToPopulate = ((_a = options === null || options === void 0 ? void 0 : options.populateSub) === null || _a === void 0 ? void 0 : _a.length) ? options.populateSub : eagerSubs; if (subsToPopulate && subsToPopulate.length) { for (const propName of subsToPopulate) { const items = await instance.subcollection(propName); instance[propName] = items; } } results.push(instance); } return { results, lastVisible: snapshot.docs[snapshot.docs.length - 1], }; } catch (error) { throw error; } } static async findWhere(field, // Allow string or FieldPath operator, value, options) { const result = await this.findAll(Object.assign(Object.assign({}, options), { queryFn: (ref) => ref.where(field, operator, value) })); return result.results; // Return only array for simplicity/consistency } static async findOne(queryFn, options) { const findOptions = { queryFn: (ref) => queryFn(ref).limit(1), populate: options === null || options === void 0 ? void 0 : options.populate, }; // Need to handle potential errors from findAll try { const result = await this.findAll(findOptions); return result.results.length > 0 ? result.results[0] : null; } catch (error) { // Let caller handle errors throw error; } } // --- Instance Methods --- _getConstructor() { return this.constructor; } _getCollectionRef() { return this._getConstructor().getCollectionRef(); } _getDocRef() { if (this.__docRef) { if (!this.id && this.__docRef.id) { this.id = this.__docRef.id; } return this.__docRef; } const ctor = this._getConstructor(); const meta = Reflect.getOwnMetadata(decorators_1.SUBMODEL_KEY, ctor); let ref; if (meta) { const parent = this.__parent; if (!(parent === null || parent === void 0 ? void 0 : parent.id)) { throw new Error(`Cannot get DocumentReference for submodel ${ctor.name} without a parent instance having an ID.`); } const parentDocRef = parent._getDocRef(); const subColl = parentDocRef.collection(meta.subPath); ref = this.id ? subColl.doc(this.id) : subColl.doc(); } else { const rawCollectionRef = (0, firestore_instance_1.getFirestoreInstance)().collection(ctor._getCollectionName()); ref = this.id ? rawCollectionRef.doc(this.id) : rawCollectionRef.doc(); } if (!this.id) { this.id = ref.id; } this.__docRef = ref; return this.__docRef; } _toFirestore(serializing = false) { const data = {}; const constructor = this._getConstructor(); const relationMeta = constructor._getRelationMetadata(); const relationProperties = new Set(relationMeta.map((r) => r.propertyName)); for (const key in this) { // Basic filtering of non-data properties if (key === "id" || key.startsWith("_") || // Exclude internal properties like _populatedRelations typeof this[key] === "function" || !Object.prototype.hasOwnProperty.call(this, key)) { continue; } const value = this[key]; // Handle Relations if (relationProperties.has(key)) { if (value instanceof BaseModel) { // If populated and serializing for save/set, convert back to Ref if (serializing && value.id) { const relatedConstructor = relationMeta .find((m) => m.propertyName === key) .relatedModel(); data[key] = (0, firestore_instance_1.getFirestoreInstance)() .collection(relatedConstructor._getCollectionName()) .doc(value.id); } else if (!serializing) { // If preparing for update, DO NOT include the populated instance // Only include if the user explicitly passes a new DocumentReference or null in updateData continue; } else { // Serializing but related instance has no ID - log warning, store null? console.warn(`[${constructor.name}] Serializing relation '${key}' but related instance has no ID. Storing null.`); data[key] = null; } } else if (value instanceof firestore_1.DocumentReference) { // Already a DocumentReference, store as is data[key] = value; } else if (serializing && value === null) { // Explicitly setting relation to null data[key] = null; } else if (serializing && value === undefined) { // If serializing, don't store undefined for relations continue; } else if (!serializing && value !== undefined) { // If preparing for update, and value isn't a BaseModel or DocumentReference, // this shouldn't happen unless the type is wrong. Log warning. console.warn(`[${constructor.name}] Relation property '${key}' has unexpected type during update preparation: ${typeof value}`); } continue; // Skip further processing for relation fields } // Handle Timestamps and Dates if (value instanceof Date) { data[key] = firestore_1.Timestamp.fromDate(value); } else if (value instanceof firestore_1.Timestamp) { data[key] = value; // Already a Timestamp } // Handle GeoPoints else if (value instanceof firestore_1.GeoPoint) { data[key] = value; } // Handle FieldValues (like serverTimestamp, increment) - pass them through else if (value instanceof firestore_1.FieldValue) { data[key] = value; } // Handle undefined (Firestore ignores undefined unless ignoreUndefinedProperties is false) else if (value !== undefined) { // Store other primitive types, arrays, plain objects data[key] = value; } } return data; } async populate(fieldNames) { const constructor = this._getConstructor(); const relationMeta = constructor._getRelationMetadata(); const subDocMeta = Reflect.getOwnMetadata(decorators_1.SUBCOL_DOC_KEY, constructor) || []; let fieldsToPopulate; if (typeof fieldNames === "boolean" && fieldNames) { fieldsToPopulate = relationMeta .filter((meta) => !meta.lazy) .map((meta) => meta.propertyName); } else { fieldsToPopulate = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; } const populationPromises = []; for (const fieldName of fieldsToPopulate) { const meta = relationMeta.find((m) => m.propertyName === fieldName); if (!meta) { console.warn(`[${constructor.name}] Attempted to populate non-relation field: '${fieldName}'`); continue; } // Check cache first (synchronous check) if (this._populatedRelations.hasOwnProperty(fieldName)) { // If it's cached (even as null), use the cached value and skip fetching this[fieldName] = this._populatedRelations[fieldName]; continue; } const value = this[fieldName]; if (value instanceof firestore_1.DocumentReference) { // Add the fetch operation to the list of promises populationPromises.push((async () => { try { const RelatedModel = meta.relatedModel(); // Check if the related model has a subcollection metadata const subCollectionMeta = Reflect.getOwnMetadata(decorators_1.SUBMODEL_KEY, RelatedModel); let relatedInstance = null; // If it's a subcollection, fetch the document from the subcollection if (subCollectionMeta) { const docSnap = await value.get(); if (docSnap.exists) { relatedInstance = RelatedModel._fromFirestore(docSnap); } } else { // Use findById without populate options here to prevent deep loops by default relatedInstance = await RelatedModel.findById(value.id); } this[fieldName] = relatedInstance; // Update instance property this._populatedRelations[fieldName] = relatedInstance; // Update cache } catch (error) { console.error(`[${constructor.name}] Error populating relation '${fieldName}' (Ref ID: ${value.id}) on instance ${this.id}:`, error); // Decide behavior on error: keep Ref? Set null? Throw? this[fieldName] = null; // Set to null on error this._populatedRelations[fieldName] = null; // Cache null on error } })()); } else if (value instanceof BaseModel) { // Already populated (or was assigned as instance), ensure it's cached this._populatedRelations[fieldName] = value; } else if (value === null || value === undefined) { // Relation is explicitly null or undefined, cache it as null this._populatedRelations[fieldName] = null; } // else: The field holds something other than Ref/Instance/null/undefined - ignore. } for (const fieldName of fieldsToPopulate) { const meta = subDocMeta.find((m) => m.propertyName === fieldName); if (!meta) { continue; } if (this._populatedRelations.hasOwnProperty(fieldName)) { this[fieldName] = this._populatedRelations[fieldName]; continue; } populationPromises.push((async () => { try { const Model = meta.model(); const parentDocRef = this._getDocRef(); const docRef = parentDocRef .collection(meta.subcollectionName) .doc(meta.docId); const docSnap = await docRef.get(); let instance = null; if (docSnap.exists) { instance = Model._fromFirestore(docSnap); if (instance) { instance.__parent = this; } } this[fieldName] = instance; this._populatedRelations[fieldName] = instance; } catch (error) { console.error(`[${constructor.name}] Error populating subcollection document '${fieldName}' (ID: ${meta.docId}) on instance ${this.id}:`, error); this[fieldName] = null; this._populatedRelations[fieldName] = null; } })()); } // Wait for all fetches to complete await Promise.all(populationPromises); } validate(dataToValidate) { const constructor = this._getConstructor(); const schema = constructor.schema; if (!schema) { return; // No schema, no validation } try { // If specific data is passed (like in update), validate that. // Otherwise, validate the whole instance's data representation. const data = dataToValidate !== null && dataToValidate !== void 0 ? dataToValidate : this._toFirestore(false); // Use false for plain data representation schema.parse(data); } catch (error) { if (error instanceof zod_1.ZodError) { const validationError = new errors_1.ValidationError(`[${constructor.name}] Validation failed: ${error.errors.map((e) => `(${e.path.join(".")}) ${e.message}`).join("; ")}`, error.issues); throw validationError; // Throw the custom error } else { // Re-throw unexpected errors console.error(`[${constructor.name}] Unexpected error during validation:`, error); throw error; } } } /** * Saves (creates or overwrites) the document in Firestore. * If executed within an active transaction or batch context (started via `runInTransaction` or `runInBatch`), * the operation will be added to that context. * * @param options Options for the Firestore `set` operation (e.g., `{ merge: true }`). * @returns A Promise resolving with the `WriteResult` for direct operations, or `undefined` if executed within a transaction/batch context. * @throws {ValidationError} If validation against the static schema fails. */ async save(options) { // <--- Changed return type const constructor = this._getConstructor(); const currentContext = context_1.transactionContext.getStore(); // <--- Get context const isTransactional = currentContext !== undefined; // --- Defaults and Hooks --- const defaultsTimestamps = Reflect.getOwnMetadata(decorators_1.TIMESTAMP_KEY, this.constructor) || []; for (const prop of defaultsTimestamps) { if (this[prop] == null) { this[prop] = firestore_1.Timestamp.now(); } } const defaultsBoolean = Reflect.getOwnMetadata(decorators_1.BOOLEAN_KEY, this.constructor) || []; for (const defaultBoolean of defaultsBoolean) { if (this[defaultBoolean.prop] == null) { this[defaultBoolean.prop] = defaultBoolean.defaultValue; } } await this.beforeSave(options); // Always run beforeSave // --- Prepare and Validate Data --- const dataForFirestore = this._toFirestore(true); this.validate(dataForFirestore); // --- Get Ref --- const docRef = this._getDocRef(); // Ensures ID is generated if needed // --- Perform Operation --- if (currentContext) { // Transaction or Batch context is active if (isFirestoreTransaction(currentContext)) { currentContext.set(docRef, dataForFirestore, options || {}); } else if (isWriteBatch(currentContext)) { currentContext.set(docRef, dataForFirestore, options || {}); } // afterSave hook is SKIPPED, return undefined return undefined; // <--- Return undefined in context } else { // Direct operation try { const result = await docRef.set(dataForFirestore, options || {}); await this.afterSave(result, options); // Run afterSave ONLY for direct ops return result; // <--- Return WriteResult } catch (error) { console.error(`[${constructor.name}] Error saving document (ID: ${this.id}):`, error); throw error; } } } /** * Updates specific fields of the document in Firestore. Requires the instance to have an ID. * If executed within an active transaction or batch context (started via `runInTransaction` or `runInBatch`), * the operation will be added to that context. * * @param updateData Object containing the fields to update. Can include `FieldValue`s. * @returns A Promise resolving with the `WriteResult` for direct operations, or `undefined` if executed within a transaction/batch context. * @throws {ValidationError} If validation against the static schema fails for the updated fields (if implemented). * @throws {Error} If the instance does not have an `id`. */ async update(updateData) { if (!this.id) { throw new Error("Cannot update document without an ID. Use save() or ensure the instance has an ID."); } const constructor = this._getConstructor(); const currentContext = context_1.transactionContext.getStore(); // <--- Get context const isTransactional = currentContext !== undefined; // --- Prepare clean update data --- let cleanUpdateData = {}; const relationMeta = constructor._getRelationMetadata(); const relationProperties = new Set(relationMeta.map((r) => r.propertyName)); // ... (same cleaning logic as before) ... for (const key in updateData) { if (key === "id" || key.startsWith("_") || typeof this[key] === "function" || !Object.prototype.hasOwnProperty.call(updateData, key)) { continue; } const value = updateData[key]; if (value instanceof firestore_1.FieldValue) { cleanUpdateData[key] = value; } else if (relationProperties.has(key) && (value === null || value instanceof firestore_1.DocumentReference)) { cleanUpdateData[key] = value; } else if (!relationProperties.has(key) && value === null) { cleanUpdateData[key] = null; } else if (!relationProperties.has(key) && value !== undefined) { cleanUpdateData[key] = value instanceof Date ? firestore_1.Timestamp.fromDate(value) : value; } } if (Object.keys(cleanUpdateData).length === 0) { console.warn(`[${constructor.name}] Update called with no valid fields to update for ID ${this.id}.`); return isTransactional ? undefined : {}; // Return undefined or empty WR } // --- Partial Validation (Optional) --- // try { this.validate(cleanUpdateData); } catch (e) { throw e; } // --- Hook and Ref --- await this.beforeUpdate(cleanUpdateData); // Always run beforeUpdate const docRef = this._getDocRef(); // Get ref without converter // --- Perform Operation --- if (currentContext) { // Transaction or Batch context is active if (isFirestoreTransaction(currentContext)) { currentContext.update(docRef, cleanUpdateData); } else if (isWriteBatch(currentContext)) { currentContext.update(docRef, cleanUpdateData); } // Update local state, skip afterUpdate hook, return undefined this._updateLocalState(cleanUpdateData, relationProperties); return undefined; // <--- Return undefined in context } else { // Direct operation try { const result = await docRef.update(cleanUpdateData); this._updateLocalState(cleanUpdateData, relationProperties); // Update local state await this.afterUpdate(result, cleanUpdateData); // Run afterUpdate ONLY for direct ops return result; // <--- Return WriteResult } catch (error) { console.error(`[${constructor.name}] Error updating document (ID: ${this.id}):`, error); throw error; } } } /** * Deletes the document from Firestore. Requires the instance to have an ID. * If executed within an active transaction or batch context (started via `runInTransaction` or `runInBatch`), * the operation will be added to that context. * * @returns A Promise resolving with the `WriteResult` for direct operations, or `undefined` if executed within a transaction/batch context. * @throws {Error} If the instance does not have an `id`. */ async delete() { if (!this.id) { throw new Error("Cannot delete document without an ID."); } const constructor = this._getConstructor(); const originalId = this.id; const currentContext = context_1.transactionContext.getStore(); // <--- Get context const isTransactional = currentContext !== undefined; // --- Hook and Ref --- await this.beforeDelete(); // Always run beforeDelete const docRef = this._getDocRef(); // --- Perform Operation --- if (currentContext) { // Transaction or Batch context is active if (isFirestoreTransaction(currentContext)) { currentContext.delete(docRef); } else if (isWriteBatch(currentContext)) { currentContext.delete(docRef); } // Invalidate local state, skip afterDelete hook, return undefined this.id = undefined; this._populatedRelations = {}; return undefined; // <--- Return undefined in context } else { // Direct operation try { const result = await docRef.delete(); this.id = undefined; // Invalidate local state this._populatedRelations = {}; await this.afterDelete(result, originalId); // Run afterDelete ONLY for direct ops return result; // <--- Return WriteResult } catch (error) { console.error(`[${constructor.name}] Error deleting document (ID: ${originalId}):`, error); throw error; } } } async reload(options) { if (!this.id) { throw new Error("Cannot reload document without an ID."); } const constructor = this._getConstructor(); const freshInstance = await constructor.findById(this.id, { populate: options === null || options === void 0 ? void 0 : options.populate, }); if (!freshInstance) { throw new errors_1.NotFoundError(constructor.name, this.id); } // Clear cache this._populatedRelations = {}; // copy Freshinstance data in this for (const key in freshInstance) { if (!Object.prototype.hasOwnProperty.call(freshInstance, key)) continue; if (key === "id") continue; this[key] = freshInstance[key]; } // Reconstructs Relationship Cache const relationMeta = constructor._getRelationMetadata(); relationMeta.forEach((meta) => { const val = this[meta.propertyName]; if (val instanceof BaseModel || val === null) { this._populatedRelations[meta.propertyName] = val; } }); return this; } /** * Express/etc will call toJSON() under the hood, * and the payload will only contain the real fields. */ toJSON() { const out = {}; if (this.id != null) out.id = this.id; Object.assign(out, this._toFirestore(false)); return out; } _updateLocalState(cleanUpdateData, relationProperties) { for (const key in cleanUpdateData) { if (Object.prototype.hasOwnProperty.call(cleanUpdateData, key)) { const value = cleanUpdateData[key]; if (!(value instanceof firestore_1.FieldValue) || value === firestore_1.FieldValue.delete()) { this[key] = value === firestore_1.FieldValue.delete() ? undefined : value; if (relationProperties.has(key)) { delete this._populatedRelations[key]; } } else { // Não loga mais warning aqui, pois é esperado em tx/batch } } } } async beforeSave(options) { } async afterSave(result, options) { } async beforeUpdate(data) { } async afterUpdate(result, data) { } async beforeDelete() { } async afterDelete(result, originalId) { } async afterLoad(snapshot) { } } exports.BaseModel = BaseModel; //# sourceMappingURL=base-model.js.map