firebase/emberfire

View on GitHub
addon/adapters/firestore.ts

Summary

Maintainability
F
4 days
Test Coverage
import DS from 'ember-data';
import { getOwner } from '@ember/application';
import { pluralize } from 'ember-inflector';
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
import { camelize } from '@ember/string';
import RSVP, { resolve } from 'rsvp';
import Ember from 'ember';
import FirebaseAppService from '../services/firebase-app';
import ModelRegistry from 'ember-data/types/registries/model';
import { firestore } from 'firebase/app';

/**
 * Persist your Ember Data models in Cloud Firestore
 * 
 * ```js
 * // app/adapters/application.js
 * import FirestoreAdapter from 'emberfire/adapters/firestore';
 *
 * export default FirestoreAdapter.extend({
 *   // configuration goes here
 * });
 * ```
 * 
 */
export default class FirestoreAdapter extends DS.Adapter.extend({

    namespace: undefined as string|undefined,
    firebaseApp: service('firebase-app'),
    settings: { } as firestore.Settings,
    enablePersistence: false as boolean,
    persistenceSettings: { } as firestore.PersistenceSettings,
    firestore: undefined as RSVP.Promise<firestore.Firestore>|undefined,
    defaultSerializer: '-firestore'

}) {

    /**
     * Enable offline persistence with Cloud Firestore, it is not enabled by default
     * 
     * ```js
     * // app/adapters/application.js
     * import FirestoreAdapter from 'emberfire/adapters/firestore';
     *
     * export default FirestoreAdapter.extend({
     *   enablePersistence: true
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    enablePersistence: boolean;

    /**
     * Namespace all of the default collections
     * 
     * ```js
     * // app/adapters/application.js
     * import FirestoreAdapter from 'emberfire/adapters/firestore';
     *
     * export default FirestoreAdapter.extend({
     *   namespace: 'environments/production'
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    namespace: string|undefined;

    /**
     * Override the default configuration of the Cloud Firestore adapter: `{ timestampsInSnapshots: true }`
     * 
     * ```js
     * // app/adapters/application.js
     * import FirestoreAdapter from 'emberfire/adapters/firestore';
     *
     * export default FirestoreAdapter.extend({
     *   settings: { timestampsInSnapshots: true }
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    settings: firestore.Settings;

    /**
     * Pass persistence settings to Cloud Firestore, enablePersistence has to be true for these to be used
     * 
     * ```js
     * // app/adapters/application.js
     * import FirestoreAdapter from 'emberfire/adapters/firestore';
     *
     * export default FirestoreAdapter.extend({
     *   enablePersistence: true,
     *   persistenceSettings: { synchronizeTabs: true }
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    persistenceSettings: firestore.PersistenceSettings;
    
    /**
     * Override the default FirebaseApp Service used by the FirestoreAdapter: `service('firebase-app')`
     * 
     * ```js
     * // app/adapters/application.js
     * import FirestoreAdapter from 'emberfire/adapters/firestore';
     * import { inject as service } from '@ember/service';
     *
     * export default FirestoreAdapter.extend({
     *   firebaseApp: service('firebase-different-app')
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    firebaseApp: Ember.ComputedProperty<FirebaseAppService, FirebaseAppService>;

    findRecord<K extends keyof ModelRegistry>(store: DS.Store, type: ModelRegistry[K], id: string, snapshot: any) {
        return rootCollection(this, type).then(ref =>
            includeRelationships(ref.doc(id).get(), store, this, snapshot, type)
        );
    }

    findAll<K extends keyof ModelRegistry>(store: DS.Store, type: ModelRegistry[K]) {
        return this.query(store, type);
    }
    
    findHasMany<K extends keyof ModelRegistry>(store: DS.Store, snapshot: DS.Snapshot<K>, url: string, relationship: {[key:string]: any}) {
        const adapter = store.adapterFor(relationship.type as never) as any; // TODO fix types
        if (adapter !== this) {
            return adapter.findHasMany(store, snapshot, url, relationship) as RSVP.Promise<any>;
        } else if (relationship.options.subcollection) {
            return docReference(this, relationship.parentModelName, snapshot.id).then(doc => queryDocs(doc.collection(collectionNameForType(relationship.type)), relationship.options.query));
        } else {
            return rootCollection(this, relationship.type).then(collection => queryDocs(collection.where(relationship.parentModelName, '==', snapshot.id), relationship.options.query));
        }
    }

    findBelongsTo<K extends keyof ModelRegistry>(store: DS.Store, snapshot: DS.Snapshot<K>, url: string, relationship: {[key:string]: any}) {
        const adapter = store.adapterFor(relationship.type as never) as any; // TODO fix types
        if (adapter !== this) {
            return adapter.findBelongsTo(store, snapshot, url, relationship) as RSVP.Promise<any>;
        } else {
            return getDoc(this, relationship.type, snapshot.id);
        }
    }

    query<K extends keyof ModelRegistry>(store: DS.Store, type: ModelRegistry[K], options?: QueryOptions, _recordArray?: DS.AdapterPopulatedRecordArray<any>) {
        return rootCollection(this, type).then(collection =>
            queryDocs(collection, queryOptionsToQueryFn(options))
        ).then(q => includeCollectionRelationships(q, store, this, options, type));
    }

    queryRecord<K extends keyof ModelRegistry>(store: DS.Store, type: ModelRegistry[K], options?: QueryOptions|QueryRecordOptions) {
        return rootCollection(this, type).then((ref:firestore.CollectionReference) => {
            const queryOrRef = queryRecordOptionsToQueryFn(options)(ref);
            if (isQuery(queryOrRef)) {
                return queryOrRef.limit(1).get();
            } else {
                (options as any).id = queryOrRef.id;
                return includeRelationships(queryOrRef.get() as any, store, this, options, type); // TODO fix the types here, they're a little broken
            }
        }).then((snapshot:firestore.QuerySnapshot|firestore.DocumentSnapshot) => {
            if (isQuerySnapshot(snapshot)) {
                return includeRelationships(resolve(snapshot.docs[0]), store, this, options, type);
            } else {
                return snapshot;
            }
        });
    }

    shouldBackgroundReloadRecord() {
        return false; // TODO can we make this dependent on a listener attached
    }

    updateRecord<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], snapshot: DS.Snapshot<K>) {
        const id = snapshot.id;
        const data = this.serialize(snapshot, { includeId: false });
        // TODO is this correct? e.g, clear dirty state and trigger didChange; what about failure?
        return docReference(this, type, id).then(doc => doc.update(data));
    }

    createRecord<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], snapshot: DS.Snapshot<K>) {
        const id = snapshot.id;
        const data = this.serialize(snapshot, { includeId: false });
        if (id) {
            return docReference(this, type, id).then(doc => doc.set(data).then(() => ({doc, data})));
        } else {
            return rootCollection(this, type).then(collection => {
                const doc = collection.doc();
                (snapshot as any)._internalModel.setId(doc.id);
                return doc.set(data).then(() => ({doc, data}));
            });
        }
    }

    deleteRecord<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], snapshot: DS.Snapshot<K>) {
        return docReference(this, type, snapshot.id).then(doc => doc.delete());
    }

}

export type CollectionReferenceOrQuery = firestore.CollectionReference | firestore.Query;
export type QueryFn = (ref: CollectionReferenceOrQuery) => CollectionReferenceOrQuery;
export type QueryRecordFn = (ref: firestore.CollectionReference) => firestore.DocumentReference;

export type WhereOp = [string|firestore.FieldPath, firestore.WhereFilterOp, any];
export type OrderOp = string|{[key:string]: "asc"|"desc"};
export type BoundOp = firestore.DocumentSnapshot|any[];

export type QueryOptionsOnlyQuery = {
    query: (ref: firestore.CollectionReference) => firestore.CollectionReference|firestore.Query
}

// TODO adapterOptions?
export type QueryOptions = ({
    filter?: {[key:string]:any},
    where?: WhereOp|WhereOp[],
    endAt?: BoundOp,
    endBefore?: BoundOp,
    startAt?: BoundOp,
    startAfter?: BoundOp,
    orderBy?: OrderOp,
    limit?: number
} | QueryOptionsOnlyQuery) & { include?: string };

export type QueryRecordOptions = { doc: QueryRecordFn, include?: string };

// Type guards
const isDocOnly = (arg: any): arg is QueryRecordOptions => arg.doc !== undefined;
const isQueryOnly = (arg: any): arg is QueryOptionsOnlyQuery => arg.query !== undefined;
const isQuery = (arg: any): arg is firestore.Query => arg.limit !== undefined;
const isWhereOp = (arg: any): arg is WhereOp => typeof arg[0] === "string" || arg[0].length === undefined;
const isQuerySnapshot = (arg: any): arg is firestore.QuerySnapshot => arg.docs !== undefined;

// Helpers
const noop = (ref: CollectionReferenceOrQuery) => ref;
const getDoc = (adapter: FirestoreAdapter, type: DS.Model, id: string) => docReference(adapter, type, id).then(doc => doc.get());
// TODO allow override
const collectionNameForType = (type: any) => pluralize(camelize(typeof(type) === 'string' ? type : type.modelName));
const docReference = (adapter: FirestoreAdapter, type: any, id: string) => rootCollection(adapter, type).then(collection => collection.doc(id));
const getDocs = (query: CollectionReferenceOrQuery) => query.get();
export const rootCollection = (adapter: FirestoreAdapter, type: any) =>  getFirestore(adapter).then(firestore => {
    const namespace = get(adapter, 'namespace');
    const root = namespace ? firestore.doc(namespace) : firestore;
    return root.collection(collectionNameForType(type));
})
const queryDocs = (referenceOrQuery: CollectionReferenceOrQuery, query?: QueryFn) => getDocs((query || noop)(referenceOrQuery));

const queryRecordOptionsToQueryFn = (options?:QueryOptions|QueryRecordOptions) => (ref:firestore.CollectionReference) => isDocOnly(options) ? options.doc(ref) : queryOptionsToQueryFn(options)(ref);

// query: ref => ref.where(...)
// filter: { published: true }
// where: ['something', '<', 11]
// where: [['something', '<', 11], ['else', '==', true]]
// orderBy: 'publishedAt'
// orderBy: { publishedAt: 'desc' }
const queryOptionsToQueryFn = (options?:QueryOptions) => (collectionRef:firestore.CollectionReference) => {
    let ref = collectionRef as CollectionReferenceOrQuery;
    if (options) {
        if (isQueryOnly(options)) { return options.query(collectionRef); }
        if (options.filter) {
            Object.keys(options.filter).forEach(field => {
                ref = ref.where(field, '==', options.filter![field]);
            })
        }
        if (options.where) {
            const runWhereOp = ([field, op, value]:WhereOp) => ref = ref.where(field, op, value);
            if (isWhereOp(options.where)) { runWhereOp(options.where) } else { options.where.forEach(runWhereOp) }
        }
        if (options.orderBy) {
            if (typeof options.orderBy === "string") {
                ref = ref.orderBy(options.orderBy)
            } else {
                Object.keys(options.orderBy).forEach(field => {
                    ref = ref.orderBy(field, (options.orderBy as any)[field] as "asc"|"desc") // TODO fix type
                });
            }
        }
        if (options.endAt)      { ref = ref.endAt(options.endAt) }
        if (options.endBefore)  { ref = ref.endBefore(options.endBefore) }
        if (options.startAt)    { ref = ref.startAt(options.startAt) }
        if (options.startAfter) { ref = ref.startAfter(options.startAfter) }
        if (options.limit) { ref = ref.limit(options.limit) }
    }
    return ref;
}

const getFirestore = (adapter: FirestoreAdapter) => {
    let cachedFirestoreInstance = get(adapter, 'firestore');
    if (!cachedFirestoreInstance) {
        const app = get(adapter, 'firebaseApp');
        cachedFirestoreInstance = app.firestore().then(firestore => {
            const settings = get(adapter, 'settings');
            firestore.settings(settings);
            const enablePersistence = get(adapter, 'enablePersistence');
            const fastboot = getOwner(adapter).lookup('service:fastboot');
            if (enablePersistence && (fastboot == null || !fastboot.isFastBoot)) {
                const persistenceSettings = get(adapter, 'persistenceSettings');
                firestore.enablePersistence(persistenceSettings).catch(console.warn);
            }
            return firestore;
        });
        set(adapter, 'firestore', cachedFirestoreInstance);
    }
    return cachedFirestoreInstance!;
};

const includeCollectionRelationships = (collection: firestore.QuerySnapshot, store: DS.Store, adapter: FirestoreAdapter, snapshot: any, type: any): Promise<firestore.QuerySnapshot> => {
    if (snapshot && snapshot.include) {
        const includes = snapshot.include.split(',') as Array<string>;
        const relationshipsToInclude = includes.map(e => type.relationshipsByName.get(e) as {[key:string]: any}).filter(r => !!r && !r.options.embedded);
        return Promise.all(
            relationshipsToInclude.map(r => {
                if (r.meta.kind == 'hasMany') {
                    return Promise.all(collection.docs.map(d => adapter.findHasMany(store, { id: d.id } as any, '', r)))
                } else {
                    const belongsToIds = [...new Set(collection.docs.map(d => d.data()[r.meta.key]).filter(id => !!id))]
                    return Promise.all(belongsToIds.map(id => adapter.findBelongsTo(store, { id } as any, '', r)))
                }
            })
        ).then(allIncludes => {
            relationshipsToInclude.forEach((r: any, i:number) => {
                const relationship = r.meta;
                const pluralKey = pluralize(relationship.key);
                const key = relationship.kind == 'belongsTo' ? relationship.key : pluralKey;
                const includes = allIncludes[i];
                collection.docs.forEach(doc => {
                    if (relationship.kind == 'belongsTo') {
                        const result = includes.find((r:any) => r.id == doc.data()[key]);
                        if (result) {
                            if (!(doc as any)._document) { (doc as any)._document = {} }
                            if (!(doc as any)._document._included) { (doc as any)._document._included = {} }
                            (doc as any)._document._included[key] = result;
                        }
                    } else {
                        if (!(doc as any)._document) { (doc as any)._document = {} }
                        if (!(doc as any)._document._included) { (doc as any)._document._included = {} }
                        (doc as any)._document._included[pluralKey] = includes;
                    }
                });
            });
            return collection;
        });
    } else {
        return resolve(collection);
    }
}

const includeRelationships = <T=any>(promise: Promise<T>, store: DS.Store, adapter: FirestoreAdapter, snapshot: any, type: any): Promise<T> => {
    if (snapshot && snapshot.include) {
        const includes = snapshot.include.split(',') as Array<string>;
        const relationshipsToInclude = includes.map(e => type.relationshipsByName.get(e) as {[key:string]: any}).filter(r => !!r && !r.options.embedded);
        const hasManyRelationships = relationshipsToInclude.filter(r => r.meta.kind == 'hasMany');
        const belongsToRelationships = relationshipsToInclude.filter(r => r.meta.kind == 'belongsTo');
        return Promise.all([
            promise,
            ...hasManyRelationships.map(r => adapter.findHasMany(store, snapshot, '', r))
        ]).then(([doc, ...includes]) => {
            if (!(doc as any)._document) { (doc as any)._document = {} }
            doc._document._included = hasManyRelationships.reduce((c, e, i) => {
                c[e.key] = includes[i];
                return c;
            }, {});
            return Promise.all([
                resolve(doc),
                ...belongsToRelationships.filter(r => !!doc.data()[r.meta.key]).map(r => {
                    return adapter.findBelongsTo(store, { id: doc.data()[r.meta.key] } as any, '', r)
                })
            ]);
        }).then(([doc, ...includes]) => {
            doc._document._included = { ...doc._document._included, ...belongsToRelationships.reduce((c, e, i) => {
                c[e.key] = includes[i];
                return c;
            }, {})};
            return doc;
        });
    } else {
        return promise;
    }
}

declare module 'ember-data' {
    interface AdapterRegistry {
        'firestore': FirestoreAdapter;
    }
}