Skip to content

Instantly share code, notes, and snippets.

@katowulf
Last active September 13, 2019 16:11
Show Gist options
  • Select an option

  • Save katowulf/36f7e59fea24e453c477405e63a6bfab to your computer and use it in GitHub Desktop.

Select an option

Save katowulf/36f7e59fea24e453c477405e63a6bfab to your computer and use it in GitHub Desktop.

Revisions

  1. Kato Richardson revised this gist Sep 13, 2019. 1 changed file with 3 additions and 0 deletions.
    3 changes: 3 additions & 0 deletions seed.ts
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,6 @@
    //
    // When calling DbData.loadSeedData(...), pass in an object similar to this
    //
    export default {
    'users/U1TR3ljGptbEyihYUqiVhxOsoQn1': { name: 'Kato' },
    'users/Md4mdB6cWOTNF2o7lXZf0s0BHdq2': { name: 'Puf' },
  2. Kato Richardson created this gist Sep 13, 2019.
    160 changes: 160 additions & 0 deletions DbBatch.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,160 @@
    import db from './db';

    // Set this to the max ops per batch listed in the Firestore docs
    // https://firebase.google.com/docs/firestore/manage-data/transactions
    export const MAX_BATCH_OPS = 500;

    // A batch processor that will accept any number of requests.
    // It processes batches in the maximum number of events allowed.
    // Does not guarantee atomicity across batches.
    //
    export default class DbBatch {
    private opsCount: number;
    private totalOpsCount: number;
    private batchCount: number;
    private currentBatch;
    private promises: Array<Promise<any>>;

    constructor() {
    this.promises = [];
    this.opsCount = 0;
    this.totalOpsCount = 0;
    this.batchCount = 0;
    }

    // Add a new document at path
    create(path: string | Array<string>, data: any) {
    const batch = this.getBatch();
    const id = db.newId();
    batch.set(db.path([path, id]), data);
    return id;
    }

    // Add or replace a document at path
    set(path: string | Array<string>, data: any) {
    const batch = this.getBatch();
    batch.set(db.path(path), data);
    }

    // Update properties on a document at path
    update(path: string | Array<string>, data: any) {
    const batch = this.getBatch();
    batch.update(db.path(path), data);
    }

    // Delete a single document at path
    delete(path: string | Array<string>) {
    const batch = this.getBatch();
    batch.delete(db.path(path));
    }

    // Delete all docs in a collection (but not any child collections)
    deleteCollection(collectionName: string | Array<string>) {
    const query = db.path(collectionName).orderBy('__name__');
    this.deleteDocsMatchingQuery(query);
    }

    // Delete all docs matching the query provided, do not include
    // limit attributes on the query. Those are added internally.
    deleteDocsMatchingQuery(query: FirebaseFirestore.Query) {
    // Unfortunately, there's no synchronous way to update the totalOpsCount
    // based on number of docs affected, so we count this as a single DB op
    // We do return the number of docs deleted in the promise.
    this.totalOpsCount += 1;
    this.batchCount += 1;

    // Process batches of MAX_BATCH_OPS until we run out of docs
    const nextBatch = (resolve, reject, limitQuery, totalDocsDeleted = 0) => {
    const currentBatch = db.batch();
    limitQuery.get().then(snapshot => {
    totalDocsDeleted += snapshot.size;
    if (snapshot.size > 0) {
    snapshot.docs.forEach(doc => {
    currentBatch.delete(doc.ref);
    });
    currentBatch.commit().then(() => {
    process.nextTick(() => nextBatch(resolve, reject, limitQuery, totalDocsDeleted));
    });
    }
    else {
    resolve(totalDocsDeleted);
    }
    })
    };

    // For query-based deletes, there are going to be an unknown
    // number of batches (each making asynchronous calls). This means that
    // we can't depend on all the promises being collected in this.promises
    // before commitAll() is invoked. We solve this by using a single
    // promise and passing the resolve method indefinitely until we run out
    // of batches.
    this.promises.push(new Promise((resolve, reject) => {
    const limitQuery = query.limit(MAX_BATCH_OPS);
    nextBatch(resolve, reject, limitQuery);
    }));
    }

    // Get the current number of batch operations that have been sent.
    count() {
    return this.totalOpsCount;
    }

    // Resolves when all batch operations have been completed.
    commitAll() {
    if( this.currentBatch ) {
    this.promises.push(this.currentBatch.commit());
    }
    this.currentBatch = null;
    return Promise.all(this.promises).then(() => this);
    }

    private getBatch() {
    if( this.opsCount === MAX_BATCH_OPS ) {
    this.promises.push(this.currentBatch.commit());
    this.opsCount = 0;
    }
    if( this.opsCount === 0 ) {
    this.batchCount++;
    this.currentBatch = db.batch();
    }
    this.opsCount++;
    this.totalOpsCount++;
    return this.currentBatch;
    }

    static deleteCollections(list: Array<string>) {
    const batch = new DbBatch();
    list.forEach(url => batch.deleteCollection(url));
    return batch.commitAll();
    }

    static loadSeedData(data) {
    const batch = new DbBatch();
    Object.keys(data).forEach(docPath => {
    batch.set(docPath, data[docPath]);
    });
    return batch.commitAll();
    }

    static deleteRecursively(docPath: string|Array<string>) {
    const batch = new DbBatch();
    return this.recurseAndDelete(batch, db.path(docPath)).then(() => batch.commitAll());
    }

    private static recurseAndDelete(batch: DbBatch, docRef: FirebaseFirestore.DocumentReference) {
    // Delete the current document
    batch.getBatch().delete(docRef);

    // Delete subcollections of the doc
    return docRef.listCollections().then(subcollections => {
    return Promise.all(subcollections.map(coll => {
    return coll.listDocuments().then(docs => {
    // For each document, rinse and repeat
    return Promise.all(docs.map(doc => {
    return this.recurseAndDelete(batch, doc);
    }));
    });
    }));
    })
    }
    }
    5 changes: 5 additions & 0 deletions conf.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,5 @@
    export default {
    databaseURL: 'https://YOUR_PROJECT_ID.firebaseio.com',
    basePath: [], // root
    //basePath: 'organizations/companyName', // use a doc as an ad hoc namespace
    }
    102 changes: 102 additions & 0 deletions db.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,102 @@
    /**********************************
    * This is a convenience utility to
    * abstract a bit of the Firestore
    * boilerplate and complexity dealing
    * with docs vs collections.
    *********************************/

    import conf from './conf';

    const TRANSACTION_LIMIT = 500;

    import * as admin from "firebase-admin";
    import {DocumentReference, FirebaseFirestore} from "@firebase/firestore-types";
    if( admin.apps.length === 0 ) {
    admin.initializeApp();
    }

    // this is not a privileged ref
    const db = admin.firestore();

    function buildPath(url: string|Array<string>) {
    let ref: any = db;
    splitUrl(conf.basePath, url).forEach(p => {
    if( typeof ref.collection === 'function' ) {
    ref = ref.collection(p);
    }
    else {
    ref = ref.doc(p);
    }
    });
    return ref;
    }

    function getData(url: string|Array<string>) {
    return buildPath(url).get().then(snap => {
    if( snap instanceof admin.firestore.DocumentSnapshot ) {
    if( snap.exists ) {
    return snap.data();
    }
    else {
    return null;
    }
    }
    else {
    const data = {};
    snap.forEach(doc => data[doc.id] = doc.data());
    return data;
    }
    });
    }

    function splitUrl(...url: Array<string|Array<string>>): Array<string> {
    let parts = [];
    url.forEach(u => {
    if( typeof u === 'string' ) {
    u.replace(/^\//, '').replace(/\/$/, '').split('/').forEach(uu =>{
    if( uu !== '' ) { parts.push(uu); }
    });
    }
    else {
    u.forEach(ubit => parts = [...parts, ...splitUrl(ubit)]);
    }
    });
    return parts;
    }

    export default {
    path: buildPath,

    get: getData,

    relativePath(ref: DocumentReference) {
    const re = new RegExp(`^${conf.basePath}/?`);
    return ref.path.replace(re, '');
    },

    newId: function() {
    return buildPath('foo/bar').id;
    },

    addToArray: function(url: string|Array<string>, field: string, val) {
    const data = {};
    data[field] = admin.firestore.FieldValue.arrayUnion(val);
    return buildPath(url).update(data);
    },

    removeFromArray: function(url: string|Array<string>, field: string, val) {
    const data = {};
    data[field] = admin.firestore.FieldValue.arrayRemove(val);
    return buildPath(url).update(data);
    },

    batch: function() { return db.batch(); },

    root: function(): FirebaseFirestore.DocumentReference { return db.doc(conf.basePath); },

    transaction: db.runTransaction.bind(db),

    FieldValue: admin.firestore.FieldValue,

    TRANSACTION_LIMIT: TRANSACTION_LIMIT
    };
    72 changes: 72 additions & 0 deletions seed.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,72 @@
    export default {
    'users/U1TR3ljGptbEyihYUqiVhxOsoQn1': { name: 'Kato' },
    'users/Md4mdB6cWOTNF2o7lXZf0s0BHdq2': { name: 'Puf' },
    'users/e1sRcmQ7PudVx4gDsr8wuyosGOk1': { name: 'Randi' },
    'users/3873ll6llkZqpxkx6v1ECq6TSOg2': { name: 'Jeff' },
    'users/EFVDAJizX3fuD1o8FhFmJ28k5bt2': { name: 'Kiana' },
    'users/jyb8sSRn1yawd3aTzNVo3u7CbOm2': { name: 'Mike' },

    "groups/group1": { name: 'Group 1' },
    'groups/group1/members/U1TR3ljGptbEyihYUqiVhxOsoQn1': {},
    'groups/group1/members/Md4mdB6cWOTNF2o7lXZf0s0BHdq2': {},

    // circular relationship
    "groups/group3": { name: 'Group 3' },
    'groups/group3/members/3873ll6llkZqpxkx6v1ECq6TSOg2': {},
    'groups/group3/members/U1TR3ljGptbEyihYUqiVhxOsoQn1': {},

    "groups/group4": { name: 'Group 4' },
    'groups/group4/members/EFVDAJizX3fuD1o8FhFmJ28k5bt2': {},
    'groups/group4/members/3873ll6llkZqpxkx6v1ECq6TSOg2': {},

    "groups/group5": { name: 'Group 5' },
    'groups/group5/members/jyb8sSRn1yawd3aTzNVo3u7CbOm2': {},

    // linear relationship
    "groups/group2": { name: 'Group 2' },
    'groups/group2/members/U1TR3ljGptbEyihYUqiVhxOsoQn1': {},
    'groups/group2/members/e1sRcmQ7PudVx4gDsr8wuyosGOk1': {},

    "groups/group6": { name: 'Group 6' },
    'groups/group6/members/EFVDAJizX3fuD1o8FhFmJ28k5bt2': {},

    "groups/group7": { name: 'Group 7' },

    "groups/group2/inherits/group1": {},
    "groups/group3/inherits/group1": {},
    "groups/group3/inherits/group4": {},
    "groups/group4/inherits/group5": {},
    "groups/group5/inherits/group3": {},
    "groups/group6/inherits/group2": {},
    "groups/group7/inherits/group6": {},

    "docs/doc1": {
    title: 'Doc 1',
    owner: 'U1TR3ljGptbEyihYUqiVhxOsoQn1',
    groups: ['group1'],
    },
    "docs/doc2": {
    title: 'Doc 2',
    owner: 'Md4mdB6cWOTNF2o7lXZf0s0BHdq2',
    groups: ['group2']
    },
    "docs/doc3": {
    title: 'Doc 3',
    owner: '3873ll6llkZqpxkx6v1ECq6TSOg2',
    groups: ['group3', 'group5']
    },
    "docs/doc4": {
    title: 'Doc 4',
    owner: 'U1TR3ljGptbEyihYUqiVhxOsoQn1',
    groups: ['group4']
    },
    "docs/doc5": {
    title: 'Doc 5',
    owner: 'e1sRcmQ7PudVx4gDsr8wuyosGOk1',
    },
    "docs/doc6": {
    title: 'Doc 6',
    owner: 'EFVDAJizX3fuD1o8FhFmJ28k5bt2',
    groups: ['group1']
    }
    };