firebase/emberfire

View on GitHub
addon/adapters/realtime-database.ts

Summary

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

/**
 * Persist your Ember Data models in the Firebase Realtime Database
 * 
 * ```js
 * // app/adapters/application.js
 * import RealtimeDatabaseAdapter from 'emberfire/adapters/realtime-database';
 *
 * export default RealtimeDatabaseAdapter.extend({
 *   // configuration goes here
 * });
 * ```
 * 
 */
export default class RealtimeDatabaseAdapter extends DS.Adapter.extend({

    namespace: undefined as string|undefined,
    firebaseApp: service('firebase-app'),
    databaseURL: undefined,
    database: undefined as RSVP.Promise<database.Database>|undefined,
    defaultSerializer: '-realtime-database'

}) {

    /**
     * Override the default FirebaseApp Service used by the RealtimeDatabaseAdapter: `service('firebase-app')`
     * 
     * ```js
     * // app/adapters/application.js
     * import RealtimeDatabaseAdapter from 'emberfire/adapters/realtime-database';
     * import { inject as service } from '@ember/service';
     *
     * export default RealtimeDatabaseAdapter.extend({
     *   firebaseApp: service('firebase-different-app')
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    firebaseApp: Ember.ComputedProperty<FirebaseAppService, FirebaseAppService>;


    /**
     * Namespace all of the paths
     * 
     * ```js
     * // app/adapters/application.js
     * import RealtimeDatabaseAdapter from 'emberfire/adapters/realtime-database';
     *
     * export default RealtimeDatabaseAdapter.extend({
     *   namespace: 'environments/production'
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    namespace: string|undefined;
    
    /**
     * Override the default database used by the RealtimeDatabaseAdapter
     * 
     * ```js
     * // app/adapters/application.js
     * import RealtimeDatabaseAdapter from 'emberfire/adapters/realtime-database';
     *
     * export default RealtimeDatabaseAdapter.extend({
     *   databaseURL: 'https://DIFFERENT_DATABASE.firebaseio.com'
     * });
     * ```
     * 
     */
    // @ts-ignore repeat here for the tyepdocs
    databaseURL?: string;

    findRecord<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], id: string) {
        return docReference(this, type, id).then(doc => doc.once('value'));
    }

    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 kill the any
        if (adapter !== this) {
            return adapter.findHasMany(store, snapshot, url, relationship) as RSVP.Promise<any>;
        } else if (relationship.options.subcollection) {
            throw `subcollections (${relationship.parentModelName}.${relationship.key}) are not supported by the Realtime Database, consider using embedded relationships or check out Firestore`;
        } else {
            return rootCollection(this, relationship.type).then(ref => queryDocs(
                ref.orderByChild(relationship.parentModelName).equalTo(snapshot.id),
                relationship.options.query
            ));
        }
    }

    findBelongsTo<K extends keyof ModelRegistry>(store: DS.Store, snapshot: DS.Snapshot<K>, url: any, relationship: any) {
        const adapter = store.adapterFor(relationship.type as never) as any;  // TODO kill the any
        if (adapter !== this) {
            return adapter.findBelongsTo(store, snapshot, url, relationship) as RSVP.Promise<any>;
        } else {
            return docReference(this, relationship.type, snapshot.id).then(ref => ref.once('value'));
        }
    }

    query<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], options?: QueryOptions) {
        return rootCollection(this, type).then(ref => queryDocs(ref, queryOptionsToQueryFn(options)));
    }

    queryRecord<K extends keyof ModelRegistry>(_store: DS.Store, type: ModelRegistry[K], options?: QueryOptions) {
        const query = rootCollection(this, type).then(ref => queryDocs(ref.limitToFirst(1), queryOptionsToQueryFn(options)));
        return query.then(results => {
            let snapshot = undefined as database.DataSnapshot|undefined;
            results.forEach(doc => !!(snapshot = doc));
            if (snapshot) {
                return snapshot;
            } else {
                throw new DS.NotFoundError();
            }
        });
    }

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

    updateRecord<K extends keyof ModelRegistry>(_: 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(ref => ref.set(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(ref => ref.set(data).then(() => ({ref, data})));
        } else {
            return rootCollection(this, type).then(ref => ref.push()).then(ref => {
                (snapshot as any)._internalModel.setId(ref.key!);
                return ref.set(data).then(() => ({ref, data}));
            });
        }
    }

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

}

export type ReferenceOrQuery = database.Reference | database.Query;
export type ReferenceOrQueryFn = (ref: ReferenceOrQuery) => ReferenceOrQuery;
export type QueryFn = (ref: database.Reference) => ReferenceOrQuery;

// Keeping this for compatability with version 2
export enum OrderBy { Key = '_key', Value = '_value', Priority = '_priority' }

export type BoundOp = string|number|boolean|null|[string|number|boolean|null,string];

export type QueryOptionsOnlyQuery = {
    query: (ref: database.Reference) => database.Reference|database.Query
}

// TODO adapterOptions?
export type QueryOptions = ({
    filter?: {[key:string]:string|number|boolean|null},
    endAt?: BoundOp,
    equalTo?: BoundOp,
    limitToFirst?: number,
    limitToLast?: number,
    orderBy?: string|OrderBy,
    startAt?: BoundOp
} | QueryOptionsOnlyQuery) & { include?: string }

const isQueryOnly = (arg: any): arg is QueryOptionsOnlyQuery => arg.query !== undefined;

// query: ref => ref.orderByChild('asdf')
// filter: { published: true }
// orderBy: OrderBy.Key, equalTo: 'asdf'
// orderBy: 'publishedAt'
const queryOptionsToQueryFn = (options?:QueryOptions) => (collectionRef:database.Reference) => {
    let ref = collectionRef as ReferenceOrQuery;
    if (options) {
        if (isQueryOnly(options)) { return options.query(collectionRef); }
        if (options.filter) {
            Object.keys(options.filter).forEach(field => {
                ref = ref.orderByChild(field).equalTo(options.filter![field]);
            })
        }
        if (options.orderBy) {
            switch(options.orderBy) {
                case OrderBy.Key:
                    ref = ref.orderByKey();
                    break;
                case OrderBy.Priority:
                    ref = ref.orderByPriority();
                    break;
                case OrderBy.Value:
                    ref = ref.orderByValue();
                    break;
                default:
                    ref = ref.orderByChild(options.orderBy);
            }
        }
        if (options.equalTo !== undefined) { ref = options.equalTo && typeof options.equalTo === "object" ? ref.equalTo(options.equalTo[0], options.equalTo[1]) : ref.equalTo(options.equalTo)  }
        if (options.startAt !== undefined) { ref = options.startAt && typeof options.startAt === "object" ? ref.startAt(options.startAt[0], options.startAt[1]) : ref.startAt(options.startAt) }
        if (options.endAt   !== undefined) { ref = options.endAt   && typeof options.endAt   === "object" ? ref.endAt(options.endAt[0],     options.endAt[1])   : ref.endAt(options.endAt) }
        if (options.limitToFirst) { ref = ref.limitToFirst(options.limitToFirst) }
        if (options.limitToLast)  { ref = ref.limitToLast(options.limitToLast) }
    }
    return ref;
}


const noop = (ref: database.Reference) => ref;
const queryDocs = (referenceOrQuery: ReferenceOrQuery, query?: ReferenceOrQueryFn) => getDocs((query || noop)(referenceOrQuery));
// TODO allow override
const collectionNameForType = (type: any) =>  pluralize(camelize(typeof(type) === 'string' ? type : type.modelName));
export const rootCollection = (adapter: RealtimeDatabaseAdapter, type: any) => databaseInstance(adapter).then(database => database.ref([get(adapter, 'namespace'), collectionNameForType(type)].join('/')));
const getDocs = (query: ReferenceOrQuery) => query.once('value').then(value => ((value as any).query = query) && value);
const docReference = (adapter: RealtimeDatabaseAdapter, type: any, id: string) => rootCollection(adapter, type).then(ref => ref.child(id));

const databaseInstance = (adapter: RealtimeDatabaseAdapter) => {
    let database = get(adapter, 'database');
    if (!database) {
        const app = get(adapter, 'firebaseApp');
        const databaseURL = get(adapter, 'databaseURL');
        database = app.database(databaseURL);
        set(adapter, 'database', database);
    }
    return database!;
}

declare module 'ember-data' {
    interface AdapterRegistry {
        'realtime-database': RealtimeDatabaseAdapter;
    }
}