Skip to content

Commit

Permalink
Realtime database support in realtime route mixin
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesdaniels committed Feb 10, 2019
1 parent fcffb12 commit f3ae31d
Show file tree
Hide file tree
Showing 13 changed files with 190 additions and 46 deletions.
5 changes: 4 additions & 1 deletion addon/adapters/realtime-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ const databaseInstance = (adapter: RealtimeDatabaseAdapter) => {
const rootCollection = (adapter: RealtimeDatabaseAdapter, type: any) =>
databaseInstance(adapter).then(database => database.ref(collectionNameForType(type)));

const getDocs = (query: ReferenceOrQuery) => query.once('value')
const getDocs = (query: ReferenceOrQuery) => query.once('value').then(value => {
(value as any).query = query; // tack query on for now
return value;
});

const docReference = (adapter: RealtimeDatabaseAdapter, type: any, id: string) =>
rootCollection(adapter, type).then(ref => ref.child(id))
11 changes: 5 additions & 6 deletions addon/serializers/realtime-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ export default class RealtimeDatabaseSerializer extends DS.JSONSerializer {

normalizeSingleResponse(store: DS.Store, primaryModelClass: DS.Model, payload: database.DataSnapshot, _id: string | number, _requestType: string) {
if (!payload.exists) { throw new DS.NotFoundError(); }
const { data, included } = normalize(store, primaryModelClass, payload) as any;
return { data, included, meta: {} };
return normalize(store, primaryModelClass, payload);
}

normalizeArrayResponse(store: DS.Store, primaryModelClass: DS.Model, payload: database.DataSnapshot, _id: string | number, _requestType: string) {
Expand All @@ -16,9 +15,9 @@ export default class RealtimeDatabaseSerializer extends DS.JSONSerializer {
payload.forEach(snapshot => {
const { data, included } = normalize(store, primaryModelClass, snapshot);
noramlizedRecords.push(data);
Object.assign(embeddedRecords, included);
Object.assign(embeddedRecords, [...embeddedRecords, ...included]);
});
return { data: noramlizedRecords, included: embeddedRecords, meta: {} };
return { data: noramlizedRecords, included: embeddedRecords, meta: { query: (payload as any).query || payload.ref } };
}

}
Expand All @@ -29,10 +28,10 @@ declare module 'ember-data' {
}
}

const normalize = (store: DS.Store, modelClass: DS.Model, snapshot: database.DataSnapshot) => {
export const normalize = (store: DS.Store, modelClass: DS.Model, snapshot: database.DataSnapshot) => {
const id = snapshot.key;
const type = (<any>modelClass).modelName;
const attributes = snapshot.val();
const attributes = { ...snapshot.val(), _ref: snapshot.ref };
const { relationships, included } = normalizeRelationships(store, modelClass, attributes);
const data = { id, type, attributes, relationships };
return { data, included };
Expand Down
163 changes: 128 additions & 35 deletions addon/services/realtime-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { getOwner } from '@ember/application';
import DS from 'ember-data';
import { get } from '@ember/object';
import { run } from '@ember/runloop';
import { firestore } from 'firebase/app';
import { firestore, database } from 'firebase/app';

// TODO don't hardcode these, but having trouble otherwise
import { normalize as firestoreNormalize } from '../serializers/firestore';
import { normalize as databaseNormalize } from '../serializers/realtime-database';

const getService = (object:Object) => getOwner(object).lookup('service:realtime-listener') as RealtimeListenerService;
const isFastboot = (object:Object) => {
Expand All @@ -25,6 +29,14 @@ const setRouteSubscription = (service: RealtimeListenerService, route: Object, u
}
}

function isFirestoreQuery(arg: any): arg is firestore.Query {
return arg.onSnapshot !== undefined;
}

function isFirestoreDocumentRefernce(arg: any): arg is firestore.DocumentReference {
return arg.onSnapshot !== undefined;
}

export default class RealtimeListenerService extends Service.extend({

routeSubscriptions: {} as {[key:string]: () => void}
Expand All @@ -34,51 +46,132 @@ export default class RealtimeListenerService extends Service.extend({
subscribe(route: Object, model: any) {
const store = model.store as DS.Store;
const modelName = (model.modelName || model.get('_internalModel.modelName')) as never
const query = model.get('meta.query') as firestore.Query|undefined;
const ref = model.get('_internalModel._recordData._data._ref') as firestore.DocumentReference|undefined;
const modelClass = store.modelFor(modelName);
const query = model.get('meta.query') as firestore.Query|database.Reference|undefined;
const ref = model.get('_internalModel._recordData._data._ref') as firestore.DocumentReference|database.Reference|undefined;
if (query) {
const unsubscribe = query.onSnapshot(snapshot => {
snapshot.docChanges().forEach(change => run(() => {
const normalizedData = store.normalize(modelName, change.doc);
switch(change.type) {
case 'added': {
const current = model.content.objectAt(change.newIndex);
if (current == null || current.id !== change.doc.id ) {
const doc = store.push(normalizedData) as any;
model.content.insertAt(change.newIndex, doc._internalModel);
if (isFirestoreQuery(query)) {
const unsubscribe = query.onSnapshot(snapshot => {
snapshot.docChanges().forEach(change => run(() => {
const normalizedData = firestoreNormalize(store, modelClass, change.doc);
switch(change.type) {
case 'added': {
const current = model.content.objectAt(change.newIndex);
if (current == null || current.id !== change.doc.id ) {
const doc = store.push(normalizedData) as any;
model.content.insertAt(change.newIndex, doc._internalModel);
}
break;
}
break;
}
case 'modified': {
const current = model.content.objectAt(change.oldIndex);
if (current == null || current.id == change.doc.id) {
if (change.newIndex !== change.oldIndex) {
case 'modified': {
const current = model.content.objectAt(change.oldIndex);
if (current == null || current.id == change.doc.id) {
if (change.newIndex !== change.oldIndex) {
model.content.removeAt(change.oldIndex);
model.content.insertAt(change.newIndex, current)
}
}
store.push(normalizedData);
break;
}
case 'removed': {
const current = model.content.objectAt(change.oldIndex);
if (current && current.id == change.doc.id) {
model.content.removeAt(change.oldIndex);
model.content.insertAt(change.newIndex, current)
}
break;
}
}
}))
});
setRouteSubscription(this, route, unsubscribe);
} else {
const onChildAdded = query.on('child_added', (snapshot, priorKey) => {
run(() => {
if (snapshot) {
const normalizedData = databaseNormalize(store, modelClass, snapshot);
const doc = store.push(normalizedData) as any;
const existing = model.content.find((record:any) => record.id === doc.id);
if (existing) { model.content.removeObject(existing); }
let insertIndex = 0;
if (priorKey) {
const record = model.content.find((record:any) => record.id === priorKey);
insertIndex = model.content.indexOf(record) + 1;
}
const current = model.content.objectAt(insertIndex);
if (current == null || current.id !== doc.id ) {
model.content.insertAt(insertIndex, doc._internalModel);
}
}
});
});
const onChildRemoved = query.on('child_removed', snapshot => {
run(() => {
if (snapshot) {
const record = model.content.find((record:any) => record.id === snapshot.key)
if (record) { model.content.removeObject(record); }
}
});
});
const onChildChanged = query.on('child_changed', snapshot => {
run(() => {
if (snapshot) {
const normalizedData = databaseNormalize(store, modelClass, snapshot);
store.push(normalizedData);
break;
}
case 'removed': {
const current = model.content.objectAt(change.oldIndex);
if (current && current.id == change.doc.id) {
model.content.removeAt(change.oldIndex);
});
});
const onChildMoved = query.on('child_moved', (snapshot, priorKey) => {
run(() => {
if (snapshot) {
const normalizedData = databaseNormalize(store, modelClass, snapshot);
const doc = store.push(normalizedData) as any;
const existing = model.content.find((record:any) => record.id === doc.id);
if (existing) { model.content.removeObject(existing); }
if (priorKey) {
const record = model.content.find((record:any) => record.id === priorKey);
const index = model.content.indexOf(record);
model.content.insertAt(index+1, doc._internalModel);
} else {
model.content.insertAt(0, doc._internalModel);
}
break;
}
}
}))
});
setRouteSubscription(this, route, unsubscribe);
});
});
const unsubscribe = () => {
query.off('child_added', onChildAdded);
query.off('child_removed', onChildRemoved);
query.off('child_changed', onChildChanged);
query.off('child_moved', onChildMoved);
}
setRouteSubscription(this, route, unsubscribe);
}
} else if (ref) {
const unsubscribe = ref.onSnapshot(doc => {
run(() => {
const normalizedData = store.normalize(modelName, doc);
store.push(normalizedData);
if (isFirestoreDocumentRefernce(ref)) {
const unsubscribe = ref.onSnapshot(doc => {
run(() => {
const normalizedData = firestoreNormalize(store, modelClass, doc);
store.push(normalizedData);
});
});
setRouteSubscription(this, route, unsubscribe);
} else {
const listener = ref.on('value', snapshot => {
run(() => {
if (snapshot) {
if (snapshot.exists()) {
const normalizedData = databaseNormalize(store, modelClass, snapshot);
store.push(normalizedData);
} else {
const record = store.findRecord(modelName, snapshot.key!)
if (record) { store.deleteRecord(record) }
}
}
});
});
});
setRouteSubscription(this, route, unsubscribe);
const unsubscribe = () => ref.off('value', listener);
setRouteSubscription(this, route, unsubscribe);
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion express.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ let fastboot = new FastBoot({
distPath: `${__dirname}/dist`,
sandboxGlobals: {
XMLHttpRequest: require('xmlhttprequest').XMLHttpRequest,
WebSocket: require('ws')
WebSocket: require('ws'),
clearInterval: clearInterval,
setInterval: setInterval
}
});

Expand Down
3 changes: 3 additions & 0 deletions tests/dummy/app/adapters/comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import RealtimeDatabaseAdapter from 'emberfire/adapters/realtime-database';

export default RealtimeDatabaseAdapter.extend({});
8 changes: 8 additions & 0 deletions tests/dummy/app/models/comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import DS from 'ember-data';

const { attr, belongsTo } = DS;

export default DS.Model.extend({
body: attr('string'),
something: belongsTo('something')
});
5 changes: 3 additions & 2 deletions tests/dummy/app/models/something.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import DS from 'ember-data';

const { attr, belongsTo } = DS;
const { attr, belongsTo, hasMany } = DS;

export default DS.Model.extend({
title: attr('string'),
description: attr('string'),
user: belongsTo('user')
user: belongsTo('user'),
comments: hasMany('comment')
});
1 change: 1 addition & 0 deletions tests/dummy/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Router.map(function() {
this.route('users', () => {
this.route('user', {path: 'users/:id'});
});
this.route('comments');
});

export default Router;
8 changes: 8 additions & 0 deletions tests/dummy/app/routes/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Route from '@ember/routing/route';
import RealtimeRouteMixin from 'emberfire/mixins/realtime-route';

export default Route.extend(RealtimeRouteMixin, {
model() {
return this.store.query('comment', ref => ref.orderByChild('body'));
}
})
1 change: 1 addition & 0 deletions tests/dummy/app/serializers/comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'emberfire/serializers/realtime-database';
8 changes: 8 additions & 0 deletions tests/dummy/app/templates/application.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@
<button {{action 'login'}}>Login</button>
{{/if}}

<nav>
<ul>
<li>{{#link-to "somethings"}}Somethings{{/link-to}}</li>
<li>{{#link-to "users"}}Users{{/link-to}}</li>
<li>{{#link-to "comments"}}Comments{{/link-to}}</li>
</ul>
</nav>

{{outlet}}
8 changes: 8 additions & 0 deletions tests/dummy/app/templates/comments.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<ul>
{{#each model as |comment|}}
<li>
<p>{{ comment.body }}</p>
<small>{{ comment.id }}</small>
</li>
{{/each}}
</ul>
11 changes: 10 additions & 1 deletion tests/dummy/app/templates/something.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@
{{#if model.user }}
<h2>{{#link-to 'user' model.user.id }}{{ model.user.name }}{{/link-to}}</h2>
{{/if}}
<p>{{ model.description }}</p>

<p>{{ model.description }}</p>

<ul>
{{#each model.comments as |comment|}}
<li>
<p>{{ comment.body }}</p>
</li>
{{/each}}
</ul>

0 comments on commit f3ae31d

Please sign in to comment.