Cloud Firestore collection count

后端 未结 17 1296
庸人自扰
庸人自扰 2020-11-22 09:25

Is it possible to count how many items a collection has using the new Firebase database, Cloud Firestore?

If so, how do I do that?

17条回答
  •  隐瞒了意图╮
    2020-11-22 09:42

    I created a universal function using all these ideas to handle all counter situations (except queries).

    The only exception would be when doing so many writes a second, it slows you down. An example would be likes on a trending post. It is overkill on a blog post, for example, and will cost you more. I suggest creating a separate function in that case using shards: https://firebase.google.com/docs/firestore/solutions/counters

    // trigger collections
    exports.myFunction = functions.firestore
        .document('{colId}/{docId}')
        .onWrite(async (change: any, context: any) => {
            return runCounter(change, context);
        });
    
    // trigger sub-collections
    exports.mySubFunction = functions.firestore
        .document('{colId}/{docId}/{subColId}/{subDocId}')
        .onWrite(async (change: any, context: any) => {
            return runCounter(change, context);
        });
    
    // add change the count
    const runCounter = async function (change: any, context: any) {
    
        const col = context.params.colId;
    
        const eventsDoc = '_events';
        const countersDoc = '_counters';
    
        // ignore helper collections
        if (col.startsWith('_')) {
            return null;
        }
        // simplify event types
        const createDoc = change.after.exists && !change.before.exists;
        const updateDoc = change.before.exists && change.after.exists;
    
        if (updateDoc) {
            return null;
        }
        // check for sub collection
        const isSubCol = context.params.subDocId;
    
        const parentDoc = `${countersDoc}/${context.params.colId}`;
        const countDoc = isSubCol
            ? `${parentDoc}/${context.params.docId}/${context.params.subColId}`
            : `${parentDoc}`;
    
        // collection references
        const countRef = db.doc(countDoc);
        const countSnap = await countRef.get();
    
        // increment size if doc exists
        if (countSnap.exists) {
            // createDoc or deleteDoc
            const n = createDoc ? 1 : -1;
            const i = admin.firestore.FieldValue.increment(n);
    
            // create event for accurate increment
            const eventRef = db.doc(`${eventsDoc}/${context.eventId}`);
    
            return db.runTransaction(async (t: any): Promise => {
                const eventSnap = await t.get(eventRef);
                // do nothing if event exists
                if (eventSnap.exists) {
                    return null;
                }
                // add event and update size
                await t.update(countRef, { count: i });
                return t.set(eventRef, {
                    completed: admin.firestore.FieldValue.serverTimestamp()
                });
            }).catch((e: any) => {
                console.log(e);
            });
            // otherwise count all docs in the collection and add size
        } else {
            const colRef = db.collection(change.after.ref.parent.path);
            return db.runTransaction(async (t: any): Promise => {
                // update size
                const colSnap = await t.get(colRef);
                return t.set(countRef, { count: colSnap.size });
            }).catch((e: any) => {
                console.log(e);
            });;
        }
    }
    

    This handles events, increments, and transactions. The beauty in this, is that if you are not sure about the accuracy of a document (probably while still in beta), you can delete the counter to have it automatically add them up on the next trigger. Yes, this costs, so don't delete it otherwise.

    Same kind of thing to get the count:

    const collectionPath = 'buildings/138faicnjasjoa89/buildingContacts';
    const colSnap = await db.doc('_counters/' + collectionPath).get();
    const count = colSnap.get('count');
    

    Also, you may want to create a cron job (scheduled function) to remove old events to save money on database storage. You need at least a blaze plan, and there may be some more configuration. You could run it every sunday at 11pm, for example. https://firebase.google.com/docs/functions/schedule-functions

    This is untested, but should work with a few tweaks:

    exports.scheduledFunctionCrontab = functions.pubsub.schedule('5 11 * * *')
        .timeZone('America/New_York')
        .onRun(async (context) => {
    
            // get yesterday
            const yesterday = new Date();
            yesterday.setDate(yesterday.getDate() - 1);
    
            const eventFilter = db.collection('_events').where('completed', '<=', yesterday);
            const eventFilterSnap = await eventFilter.get();
            eventFilterSnap.forEach(async (doc: any) => {
                await doc.ref.delete();
            });
            return null;
        });
    

    And last, don't forget to protect the collections in firestore.rules:

    match /_counters/{document} {
      allow read;
      allow write: if false;
    }
    match /_events/{document} {
      allow read, write: if false;
    }
    

    Update: Queries

    Adding to my other answer if you want to automate query counts as well, you can use this modified code in your cloud function:

        if (col === 'posts') {
    
            // counter reference - user doc ref
            const userRef = after ? after.userDoc : before.userDoc;
            // query reference
            const postsQuery = db.collection('posts').where('userDoc', "==", userRef);
            // add the count - postsCount on userDoc
            await addCount(change, context, postsQuery, userRef, 'postsCount');
    
        }
        return delEvents();
    
    

    Which will automatically update the postsCount in the userDocument. You could easily add other one to many counts this way. This just gives you ideas of how you can automate things. I also gave you another way to delete the events. You have to read each date to delete it, so it won't really save you to delete them later, just makes the function slower.

    /**
     * Adds a counter to a doc
     * @param change - change ref
     * @param context - context ref
     * @param queryRef - the query ref to count
     * @param countRef - the counter document ref
     * @param countName - the name of the counter on the counter document
     */
    const addCount = async function (change: any, context: any, 
      queryRef: any, countRef: any, countName: string) {
    
        // events collection
        const eventsDoc = '_events';
    
        // simplify event type
        const createDoc = change.after.exists && !change.before.exists;
    
        // doc references
        const countSnap = await countRef.get();
    
        // increment size if field exists
        if (countSnap.get(countName)) {
            // createDoc or deleteDoc
            const n = createDoc ? 1 : -1;
            const i = admin.firestore.FieldValue.increment(n);
    
            // create event for accurate increment
            const eventRef = db.doc(`${eventsDoc}/${context.eventId}`);
    
            return db.runTransaction(async (t: any): Promise => {
                const eventSnap = await t.get(eventRef);
                // do nothing if event exists
                if (eventSnap.exists) {
                    return null;
                }
                // add event and update size
                await t.set(countRef, { [countName]: i }, { merge: true });
                return t.set(eventRef, {
                    completed: admin.firestore.FieldValue.serverTimestamp()
                });
            }).catch((e: any) => {
                console.log(e);
            });
            // otherwise count all docs in the collection and add size
        } else {
            return db.runTransaction(async (t: any): Promise => {
                // update size
                const colSnap = await t.get(queryRef);
                return t.set(countRef, { [countName]: colSnap.size }, { merge: true });
            }).catch((e: any) => {
                console.log(e);
            });;
        }
    }
    /**
     * Deletes events over a day old
     */
    const delEvents = async function () {
    
        // get yesterday
        const yesterday = new Date();
        yesterday.setDate(yesterday.getDate() - 1);
    
        const eventFilter = db.collection('_events').where('completed', '<=', yesterday);
        const eventFilterSnap = await eventFilter.get();
        eventFilterSnap.forEach(async (doc: any) => {
            await doc.ref.delete();
        });
        return null;
    }
    

    I should also warn you that universal functions will run on every onWrite call period. It may be cheaper to only run the function on onCreate and on onDelete instances of your specific collections. Like the noSQL database we are using, repeated code and data can save you money.

    UPDATE 11/20

    I created an npm package for easy access: https://fireblog.io/blog/post/firestore-counters

提交回复
热议问题