diff --git a/src/database/database.ts b/src/database/database.ts index ca2dcd0a2..5d199cec6 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { database } from 'firebase/app'; import 'firebase/database'; import { FirebaseApp } from 'angularfire2'; -import { PathReference, DatabaseQuery, DatabaseReference, DatabaseSnapshot, ChildEvent, ListenEvent, SnapshotChange, QueryFn, AngularFireList, AngularFireObject } from './interfaces'; +import { PathReference, DatabaseQuery, DatabaseReference, DatabaseSnapshot, ChildEvent, ListenEvent, QueryFn, AngularFireList, AngularFireObject } from './interfaces'; import { getRef } from './utils'; import { createListReference } from './list/create-reference'; import { createObjectReference } from './object/create-reference'; @@ -41,8 +41,7 @@ export { DatabaseReference, DatabaseSnapshot, ChildEvent, - ListenEvent, - SnapshotChange, + ListenEvent, QueryFn, AngularFireList, AngularFireObject, diff --git a/src/database/interfaces.ts b/src/database/interfaces.ts index c40af4a70..464633bb7 100644 --- a/src/database/interfaces.ts +++ b/src/database/interfaces.ts @@ -12,51 +12,40 @@ export interface AngularFireList { update(item: FirebaseOperation, data: T): Promise; set(item: FirebaseOperation, data: T): Promise; push(data: T): firebase.database.ThenableReference; - remove(item?: FirebaseOperation): Promise; + remove(item?: FirebaseOperation): Promise; } export interface AngularFireObject { query: DatabaseQuery; valueChanges(): Observable; - snapshotChanges(): Observable; - update(data: T): Promise; + snapshotChanges(): Observable; + update(data: Partial): Promise; set(data: T): Promise; - remove(): Promise; + remove(): Promise; } export interface FirebaseOperationCases { - stringCase: () => Promise; - firebaseCase?: () => Promise; - snapshotCase?: () => Promise; - unwrappedSnapshotCase?: () => Promise; + stringCase: () => Promise; + firebaseCase?: () => Promise; + snapshotCase?: () => Promise; + unwrappedSnapshotCase?: () => Promise; } export type QueryFn = (ref: DatabaseReference) => DatabaseQuery; export type ChildEvent = 'child_added' | 'child_removed' | 'child_changed' | 'child_moved'; export type ListenEvent = 'value' | ChildEvent; -export type SnapshotChange = { - event: string; - snapshot: DatabaseSnapshot | null; - prevKey: string | undefined; -} - export interface Action { - type: string; + type: ListenEvent; payload: T; }; export interface AngularFireAction extends Action { - prevKey: string | undefined; + prevKey: string | null | undefined; key: string | null; } -export interface SnapshotPrevKey { - snapshot: DatabaseSnapshot | null; - prevKey: string | undefined; -} - -export type SnapshotAction = AngularFireAction; +export type SnapshotAction = AngularFireAction; export type Primitive = number | string | boolean; diff --git a/src/database/list/audit-trail.spec.ts b/src/database/list/audit-trail.spec.ts index 25dfdd3ec..309a52be6 100644 --- a/src/database/list/audit-trail.spec.ts +++ b/src/database/list/audit-trail.spec.ts @@ -9,7 +9,7 @@ import 'rxjs/add/operator/skip'; const rando = () => (Math.random() + 1).toString(36).substring(7); const FIREBASE_APP_NAME = rando(); -describe('stateChanges', () => { +describe('auditTrail', () => { let app: FirebaseApp; let db: AngularFireDatabase; let createRef: (path: string) => firebase.database.Reference; @@ -56,7 +56,7 @@ describe('stateChanges', () => { const { changes } = prepareAuditTrail(); changes.subscribe(actions => { - const data = actions.map(a => a.payload!.val()); + const data = actions.map(a => a.payload.val()); expect(data).toEqual(items); done(); }); diff --git a/src/database/list/audit-trail.ts b/src/database/list/audit-trail.ts index d759784b4..d06317643 100644 --- a/src/database/list/audit-trail.ts +++ b/src/database/list/audit-trail.ts @@ -1,8 +1,10 @@ -import { DatabaseQuery, ChildEvent, AngularFireAction, SnapshotAction } from '../interfaces'; +import { DatabaseQuery, ChildEvent, DatabaseSnapshot, AngularFireAction, SnapshotAction } from '../interfaces'; import { stateChanges } from './state-changes'; -import { waitForLoaded } from './loaded'; import { Observable } from 'rxjs/Observable'; import { database } from 'firebase/app'; +import { fromRef } from '../observable/fromRef'; + + import 'rxjs/add/operator/skipWhile'; import 'rxjs/add/operator/withLatestFrom'; import 'rxjs/add/operator/map'; @@ -16,3 +18,47 @@ export function auditTrail(query: DatabaseQuery, events?: ChildEvent[]): Observa .scan((current, action) => [...current, action], []); return waitForLoaded(query, auditTrail$); } + +interface LoadedMetadata { + data: AngularFireAction; + lastKeyToLoad: any; +} + +function loadedData(query: DatabaseQuery): Observable { + // Create an observable of loaded values to retrieve the + // known dataset. This will allow us to know what key to + // emit the "whole" array at when listening for child events. + return fromRef(query, 'value') + .map(data => { + // Store the last key in the data set + let lastKeyToLoad; + // Loop through loaded dataset to find the last key + data.payload.forEach(child => { + lastKeyToLoad = child.key; return false; + }); + // return data set and the current last key loaded + return { data, lastKeyToLoad }; + }); +} + +function waitForLoaded(query: DatabaseQuery, action$: Observable) { + const loaded$ = loadedData(query); + return loaded$ + .withLatestFrom(action$) + // Get the latest values from the "loaded" and "child" datasets + // We can use both datasets to form an array of the latest values. + .map(([loaded, actions]) => { + // Store the last key in the data set + let lastKeyToLoad = loaded.lastKeyToLoad; + // Store all child keys loaded at this point + const loadedKeys = actions.map(snap => snap.key); + return { actions, lastKeyToLoad, loadedKeys } + }) + // This is the magical part, only emit when the last load key + // in the dataset has been loaded by a child event. At this point + // we can assume the dataset is "whole". + .skipWhile(meta => meta.loadedKeys.indexOf(meta.lastKeyToLoad) === -1) + // Pluck off the meta data because the user only cares + // to iterate through the snapshots + .map(meta => meta.actions); +} \ No newline at end of file diff --git a/src/database/list/changes.spec.ts b/src/database/list/changes.spec.ts index 9ea52d82d..aee106ead 100644 --- a/src/database/list/changes.spec.ts +++ b/src/database/list/changes.spec.ts @@ -43,69 +43,104 @@ describe('listChanges', () => { describe('events', () => { - it('should stream child_added events', (done) => { + it('should stream value at first', (done) => { const someRef = ref(rando()); - someRef.set(batch); const obs = listChanges(someRef, ['child_added']); - const sub = obs.skip(2).subscribe(changes => { - const data = changes.map(change => change.payload!.val()); + const sub = obs.take(1).subscribe(changes => { + const data = changes.map(change => change.payload.val()); expect(data).toEqual(items); - done(); - }); + }).add(done); + someRef.set(batch); }); - it('should process a new child_added event', (done) => { + it('should process a new child_added event', done => { const aref = ref(rando()); - aref.set(batch); const obs = listChanges(aref, ['child_added']); - const sub = obs.skip(3).subscribe(changes => { - const data = changes.map(change => change.payload!.val()); + const sub = obs.skip(1).take(1).subscribe(changes => { + const data = changes.map(change => change.payload.val()); expect(data[3]).toEqual({ name: 'anotha one' }); - done(); - }); + }).add(done); + aref.set(batch); aref.push({ name: 'anotha one' }); }); - it('should process a new child_removed event', (done) => { + it('should stream in order events', (done) => { const aref = ref(rando()); + const obs = listChanges(aref.orderByChild('name'), ['child_added']); + const sub = obs.take(1).subscribe(changes => { + const names = changes.map(change => change.payload.val().name); + expect(names[0]).toEqual('one'); + expect(names[1]).toEqual('two'); + expect(names[2]).toEqual('zero'); + }).add(done); aref.set(batch); - const obs = listChanges(aref, ['child_added','child_removed']) + }); - const sub = obs.skip(3).subscribe(changes => { - const data = changes.map(change => change.payload!.val()); + it('should stream in order events w/child_added', (done) => { + const aref = ref(rando()); + const obs = listChanges(aref.orderByChild('name'), ['child_added']); + const sub = obs.skip(1).take(1).subscribe(changes => { + const names = changes.map(change => change.payload.val().name); + expect(names[0]).toEqual('anotha one'); + expect(names[1]).toEqual('one'); + expect(names[2]).toEqual('two'); + expect(names[3]).toEqual('zero'); + }).add(done); + aref.set(batch); + aref.push({ name: 'anotha one' }); + }); + + it('should stream events filtering', (done) => { + const aref = ref(rando()); + const obs = listChanges(aref.orderByChild('name').equalTo('zero'), ['child_added']); + obs.skip(1).take(1).subscribe(changes => { + const names = changes.map(change => change.payload.val().name); + expect(names[0]).toEqual('zero'); + expect(names[1]).toEqual('zero'); + }).add(done); + aref.set(batch); + aref.push({ name: 'zero' }); + }); + + it('should process a new child_removed event', done => { + const aref = ref(rando()); + const obs = listChanges(aref, ['child_added','child_removed']); + const sub = obs.skip(1).take(1).subscribe(changes => { + const data = changes.map(change => change.payload.val()); expect(data.length).toEqual(items.length - 1); - done(); + }).add(done); + app.database().goOnline(); + aref.set(batch).then(() => { + aref.child(items[0].key).remove(); }); - const childR = aref.child(items[0].key); - childR.remove().then(console.log); }); it('should process a new child_changed event', (done) => { const aref = ref(rando()); - aref.set(batch); const obs = listChanges(aref, ['child_added','child_changed']) - const sub = obs.skip(3).subscribe(changes => { - const data = changes.map(change => change.payload!.val()); - expect(data[0].name).toEqual('lol'); - done(); + const sub = obs.skip(1).take(1).subscribe(changes => { + const data = changes.map(change => change.payload.val()); + expect(data[1].name).toEqual('lol'); + }).add(done); + app.database().goOnline(); + aref.set(batch).then(() => { + aref.child(items[1].key).update({ name: 'lol'}); }); - const childR = aref.child(items[0].key); - childR.update({ name: 'lol'}); }); it('should process a new child_moved event', (done) => { const aref = ref(rando()); - aref.set(batch); const obs = listChanges(aref, ['child_added','child_moved']) - const sub = obs.skip(3).subscribe(changes => { - const data = changes.map(change => change.payload!.val()); + const sub = obs.skip(1).take(1).subscribe(changes => { + const data = changes.map(change => change.payload.val()); // We moved the first item to the last item, so we check that // the new result is now the last result expect(data[data.length - 1]).toEqual(items[0]); - done(); + }).add(done); + app.database().goOnline(); + aref.set(batch).then(() => { + aref.child(items[0].key).setPriority('a', () => {}); }); - const childR = aref.child(items[0].key); - childR.setPriority('a', () => {}); }); }); diff --git a/src/database/list/changes.ts b/src/database/list/changes.ts index 05c673a00..a75c0676c 100644 --- a/src/database/list/changes.ts +++ b/src/database/list/changes.ts @@ -1,36 +1,93 @@ import { fromRef } from '../observable/fromRef'; import { Observable } from 'rxjs/Observable'; -import { DatabaseQuery, ChildEvent, SnapshotChange, AngularFireAction, SnapshotAction } from '../interfaces'; -import { positionFor, positionAfter } from './utils'; +import { DatabaseQuery, ChildEvent, AngularFireAction, SnapshotAction } from '../interfaces'; +import { isNil } from '../utils'; + import 'rxjs/add/operator/scan'; import 'rxjs/add/observable/merge'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/switchMap'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/delay'; +import 'rxjs/add/operator/distinctUntilChanged'; -// TODO(davideast): check safety of ! operator in scan export function listChanges(ref: DatabaseQuery, events: ChildEvent[]): Observable { - const childEvent$ = events.map(event => fromRef(ref, event)); - return Observable.merge(...childEvent$) - .scan((current, action) => { - const { payload, type, prevKey, key } = action; - switch (action.type) { - case 'child_added': - return [...current, action]; - case 'child_removed': - // ! is okay here because only value events produce null results - return current.filter(x => x.payload!.key !== payload!.key); - case 'child_changed': - return current.map(x => x.payload!.key === key ? action : x); - case 'child_moved': - const curPos = positionFor(current, payload!.key) - if(curPos > -1) { - const data = current.splice(curPos, 1)[0]; - const newPost = positionAfter(current, prevKey); - current.splice(newPost, 0, data); - return current; - } - return current; - // default will also remove null results - default: - return current; + return fromRef(ref, 'value', 'once').switchMap(snapshotAction => { + const childEvent$ = [Observable.of(snapshotAction)]; + events.forEach(event => childEvent$.push(fromRef(ref, event))); + return Observable.merge(...childEvent$).scan(buildView, []) + }) + .distinctUntilChanged(); +} + +function positionFor(changes: SnapshotAction[], key) { + const len = changes.length; + for(let i=0; i { + const action = {payload, type: 'value', prevKey, key: payload.key}; + prevKey = payload.key; + current = [...current, action]; + return false; + }); + } + return current; + case 'child_added': + if (currentKeyPosition > -1) { + // check that the previouskey is what we expect, else reorder + const previous = current[currentKeyPosition - 1]; + if ((previous && previous.key || null) != prevKey) { + current = current.filter(x => x.payload.key !== payload.key); + current.splice(afterPreviousKeyPosition, 0, action); + } + } else if (prevKey == null) { + return [action, ...current]; + } else { + current = current.slice() + current.splice(afterPreviousKeyPosition, 0, action); + } + return current; + case 'child_removed': + return current.filter(x => x.payload.key !== payload.key); + case 'child_changed': + return current.map(x => x.payload.key === key ? action : x); + case 'child_moved': + if(currentKeyPosition > -1) { + const data = current.splice(currentKeyPosition, 1)[0]; + current = current.slice() + current.splice(afterPreviousKeyPosition, 0, data); + return current; + } + return current; + // default will also remove null results + default: + return current; + } +} \ No newline at end of file diff --git a/src/database/list/create-reference.ts b/src/database/list/create-reference.ts index 6278777d2..8fcc24ac7 100644 --- a/src/database/list/create-reference.ts +++ b/src/database/list/create-reference.ts @@ -1,5 +1,5 @@ import { DatabaseQuery, AngularFireList, ChildEvent } from '../interfaces'; -import { createLoadedChanges, loadedSnapshotChanges } from './loaded'; +import { snapshotChanges } from './snapshot-changes'; import { createStateChanges } from './state-changes'; import { createAuditTrail } from './audit-trail'; import { createDataOperationMethod } from './data-operation'; @@ -12,12 +12,14 @@ export function createListReference(query: DatabaseQuery): AngularFireList set: createDataOperationMethod(query.ref, 'set'), push: (data: T) => query.ref.push(data), remove: createRemoveMethod(query.ref), - snapshotChanges: createLoadedChanges(query), + snapshotChanges(events?: ChildEvent[]) { + return snapshotChanges(query, events); + }, stateChanges: createStateChanges(query), auditTrail: createAuditTrail(query), valueChanges(events?: ChildEvent[]) { - return loadedSnapshotChanges(query, events) - .map(actions => actions.map(a => a.payload!.val())); + return snapshotChanges(query, events) + .map(actions => actions.map(a => a.payload.val())); } } } diff --git a/src/database/list/loaded.spec.ts b/src/database/list/loaded.spec.ts deleted file mode 100644 index 9756c0182..000000000 --- a/src/database/list/loaded.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as firebase from 'firebase/app'; -import { FirebaseApp, FirebaseAppConfig, AngularFireModule } from 'angularfire2'; -import { AngularFireDatabase, AngularFireDatabaseModule, createLoadedChanges } from 'angularfire2/database'; -import { TestBed, inject } from '@angular/core/testing'; -import { COMMON_CONFIG } from '../test-config'; -import 'rxjs/add/operator/skip'; - -// generate random string to test fidelity of naming -const rando = () => (Math.random() + 1).toString(36).substring(7); -const FIREBASE_APP_NAME = rando(); - -describe('createLoadedChanges', () => { - let app: FirebaseApp; - let db: AngularFireDatabase; - let createRef: (path: string) => firebase.database.Reference; - let batch = {}; - const items = [{ name: 'zero' }, { name: 'one' }, { name: 'two' }].map((item, i) => ( { key: i.toString(), ...item } )); - Object.keys(items).forEach(function (key, i) { - const itemValue = items[key]; - batch[i] = itemValue; - }); - // make batch immutable to preserve integrity - batch = Object.freeze(batch); - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - AngularFireModule.initializeApp(COMMON_CONFIG, FIREBASE_APP_NAME), - AngularFireDatabaseModule - ] - }); - inject([FirebaseApp, AngularFireDatabase], (app_: FirebaseApp, _db: AngularFireDatabase) => { - app = app_; - db = _db; - app.database().goOnline(); - createRef = (path: string) => { app.database().goOnline(); return app.database().ref(path); }; - })(); - }); - - afterEach(done => { - app.delete().then(done, done.fail); - }); - - it('should not emit until the array is whole', (done) => { - const ref = createRef(rando()); - ref.set(batch); - createLoadedChanges(ref)().subscribe(actions => { - const data = actions.map(a => a.payload!.val()); - expect(data).toEqual(items); - done(); - }); - }); - -}); diff --git a/src/database/list/loaded.ts b/src/database/list/loaded.ts deleted file mode 100644 index 28e243853..000000000 --- a/src/database/list/loaded.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { DatabaseQuery, ChildEvent, DatabaseSnapshot, AngularFireAction, SnapshotAction } from '../interfaces'; -import { fromRef } from '../observable/fromRef'; -import { snapshotChanges } from './snapshot-changes'; -import { database } from 'firebase/app'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/operator/skipWhile'; -import 'rxjs/add/operator/withLatestFrom'; -import 'rxjs/add/operator/map'; - -/** - * Creates a function that creates a "loaded observable". - * A "loaded observable" waits until the final child emissions are - * complete and match the last key in the dataset before emitting - * the "whole" array. Realtime updates can continue to apply to the - * array, but by leveraging skipWhile, we wait until the first value - * set is "whole" so the user is inundated with child_added updates. - * @param query - */ -export function createLoadedChanges(query: DatabaseQuery): (events?: ChildEvent[]) => Observable { - return (events?: ChildEvent[]) => loadedSnapshotChanges(query, events); -} - -export interface LoadedMetadata { - data: AngularFireAction; - lastKeyToLoad: any; -} - -export function loadedData(query: DatabaseQuery): Observable { - // Create an observable of loaded values to retrieve the - // known dataset. This will allow us to know what key to - // emit the "whole" array at when listening for child events. - return fromRef(query, 'value') - .map(data => { - // Store the last key in the data set - let lastKeyToLoad; - // Loop through loaded dataset to find the last key - data.payload!.forEach(child => { - lastKeyToLoad = child.key; return false; - }); - // return data set and the current last key loaded - return { data, lastKeyToLoad }; - }); -} - -export function waitForLoaded(query: DatabaseQuery, action$: Observable) { - const loaded$ = loadedData(query); - return loaded$ - .withLatestFrom(action$) - // Get the latest values from the "loaded" and "child" datasets - // We can use both datasets to form an array of the latest values. - .map(([loaded, actions]) => { - // Store the last key in the data set - let lastKeyToLoad = loaded.lastKeyToLoad; - // Store all child keys loaded at this point - const loadedKeys = actions.map(snap => snap.key); - return { actions, lastKeyToLoad, loadedKeys } - }) - // This is the magical part, only emit when the last load key - // in the dataset has been loaded by a child event. At this point - // we can assume the dataset is "whole". - .skipWhile(meta => meta.loadedKeys.indexOf(meta.lastKeyToLoad) === -1) - // Pluck off the meta data because the user only cares - // to iterate through the snapshots - .map(meta => meta.actions); -} - -export function loadedSnapshotChanges(query: DatabaseQuery, events?: ChildEvent[]): Observable { - const snapChanges$ = snapshotChanges(query, events); - return waitForLoaded(query, snapChanges$); -} diff --git a/src/database/list/snapshot-changes.spec.ts b/src/database/list/snapshot-changes.spec.ts index aff2624b1..e0a13f455 100644 --- a/src/database/list/snapshot-changes.spec.ts +++ b/src/database/list/snapshot-changes.spec.ts @@ -4,6 +4,7 @@ import { AngularFireDatabase, AngularFireDatabaseModule, snapshotChanges, ChildE import { TestBed, inject } from '@angular/core/testing'; import { COMMON_CONFIG } from '../test-config'; import 'rxjs/add/operator/skip'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; // generate random string to test fidelity of naming const rando = () => (Math.random() + 1).toString(36).substring(7); @@ -44,7 +45,6 @@ describe('snapshotChanges', () => { function prepareSnapshotChanges(opts: { events?: ChildEvent[], skip: number } = { skip: 0 }) { const { events, skip } = opts; const aref = createRef(rando()); - aref.set(batch); const snapChanges = snapshotChanges(aref, events); return { snapChanges: snapChanges.skip(skip), @@ -53,40 +53,92 @@ describe('snapshotChanges', () => { } it('should listen to all events by default', (done) => { - const { snapChanges } = prepareSnapshotChanges({ skip: 2 }); - const sub = snapChanges.subscribe(actions => { + const { snapChanges, ref } = prepareSnapshotChanges(); + snapChanges.take(1).subscribe(actions => { const data = actions.map(a => a.payload!.val()); expect(data).toEqual(items); - done(); - sub.unsubscribe(); - }); + }).add(done); + ref.set(batch); }); - it('should listen to only child_added events', (done) => { - const { snapChanges } = prepareSnapshotChanges({ events: ['child_added'], skip: 2 }); - const sub = snapChanges.subscribe(actions => { + it('should handle multiple subscriptions (hot)', (done) => { + const { snapChanges, ref } = prepareSnapshotChanges(); + const sub = snapChanges.subscribe(() => {}).add(done); + snapChanges.take(1).subscribe(actions => { const data = actions.map(a => a.payload!.val()); expect(data).toEqual(items); - done(); - sub.unsubscribe(); + }).add(sub); + ref.set(batch); + }); + + it('should handle multiple subscriptions (warm)', done => { + const { snapChanges, ref } = prepareSnapshotChanges(); + snapChanges.take(1).subscribe(() => {}).add(() => { + snapChanges.take(1).subscribe(actions => { + const data = actions.map(a => a.payload!.val()); + expect(data).toEqual(items); + }).add(done); }); + ref.set(batch); + }); + + it('should listen to only child_added events', (done) => { + const { snapChanges, ref } = prepareSnapshotChanges({ events: ['child_added'], skip: 0 }); + snapChanges.take(1).subscribe(actions => { + const data = actions.map(a => a.payload!.val()); + expect(data).toEqual(items); + }).add(done); + ref.set(batch); }); it('should listen to only child_added, child_changed events', (done) => { const { snapChanges, ref } = prepareSnapshotChanges({ events: ['child_added', 'child_changed'], - skip: 3 + skip: 1 }); const name = 'ligatures'; - const sub = snapChanges.subscribe(actions => { + snapChanges.take(1).subscribe(actions => { const data = actions.map(a => a.payload!.val());; const copy = [...items]; copy[0].name = name; expect(data).toEqual(copy); - done(); - sub.unsubscribe(); + }).add(done); + app.database().goOnline(); + ref.set(batch).then(() => { + ref.child(items[0].key).update({ name }) }); - ref.child(items[0].key).update({ name }); - }); + }); + + it('should handle empty sets', done => { + const aref = createRef(rando()); + aref.set({}); + snapshotChanges(aref).take(1).subscribe(data => { + expect(data.length).toEqual(0); + }).add(done); + }); + + it('should handle dynamic queries that return empty sets', done => { + const ITEMS = 10; + let count = 0; + let firstIndex = 0; + let namefilter$ = new BehaviorSubject(null); + const aref = createRef(rando()); + aref.set(batch); + namefilter$.switchMap(name => { + const filteredRef = name ? aref.child('name').equalTo(name) : aref + return snapshotChanges(filteredRef); + }).take(2).subscribe(data => { + count = count + 1; + // the first time should all be 'added' + if(count === 1) { + expect(Object.keys(data).length).toEqual(3); + namefilter$.next(-1); + } + // on the second round, we should have filtered out everything + if(count === 2) { + expect(Object.keys(data).length).toEqual(0); + } + }).add(done); + }); }); diff --git a/src/database/list/snapshot-changes.ts b/src/database/list/snapshot-changes.ts index 5d11632bb..10b52dc6f 100644 --- a/src/database/list/snapshot-changes.ts +++ b/src/database/list/snapshot-changes.ts @@ -5,7 +5,6 @@ import { database } from 'firebase/app'; import { validateEventsArray } from './utils'; import 'rxjs/add/operator/map'; -// TODO(davideast): Test safety of ! unwrap export function snapshotChanges(query: DatabaseQuery, events?: ChildEvent[]): Observable { events = validateEventsArray(events); return listChanges(query, events!); diff --git a/src/database/list/state-changes.spec.ts b/src/database/list/state-changes.spec.ts index 6c12f7613..30ae8471b 100644 --- a/src/database/list/state-changes.spec.ts +++ b/src/database/list/state-changes.spec.ts @@ -57,7 +57,7 @@ describe('stateChanges', () => { const { changes } = prepareStateChanges({ skip: 2 }); changes.subscribe(action => { expect(action.key).toEqual('2'); - expect(action.payload!.val()).toEqual(items[items.length - 1]); + expect(action.payload.val()).toEqual(items[items.length - 1]); done(); }); diff --git a/src/database/list/utils.ts b/src/database/list/utils.ts index a6838fd2b..2f1c30647 100644 --- a/src/database/list/utils.ts +++ b/src/database/list/utils.ts @@ -1,32 +1,8 @@ import { isNil } from '../utils'; -import { SnapshotAction } from '../interfaces'; export function validateEventsArray(events?: any[]) { if(isNil(events) || events!.length === 0) { events = ['child_added', 'child_removed', 'child_changed', 'child_moved']; } return events; -} - -export function positionFor(changes: SnapshotAction[], key) { - const len = changes.length; - for(let i=0; i(query: DatabaseQuery) { - return function valueChanges(events?: ChildEvent[]): Observable { - events = validateEventsArray(events); - return listChanges(query, events!) - .map(changes => changes.map(change => change.payload.snapshot!.val())) - } -} diff --git a/src/database/object/create-reference.ts b/src/database/object/create-reference.ts index 2ad2fdc14..c8a6e15ed 100644 --- a/src/database/object/create-reference.ts +++ b/src/database/object/create-reference.ts @@ -5,12 +5,12 @@ export function createObjectReference(query: DatabaseQuery): AngularFireObjec return { query, snapshotChanges: createObjectSnapshotChanges(query), - update(data: T) { return query.ref.update(data) as Promise; }, - set(data: T) { return query.ref.set(data) as Promise; }, - remove() { return query.ref.remove() as Promise; }, + update(data: Partial) { return query.ref.update(data) as Promise; }, + set(data: T) { return query.ref.set(data) as Promise; }, + remove() { return query.ref.remove() as Promise; }, valueChanges() { return createObjectSnapshotChanges(query)() - .map(action => action.payload ? action.payload.val() as T : null) + .map(action => action.payload.exists() ? action.payload.val() as T : null) }, } } diff --git a/src/database/object/snapshot-changes.ts b/src/database/object/snapshot-changes.ts index bd062d95c..8c6a3c81f 100644 --- a/src/database/object/snapshot-changes.ts +++ b/src/database/object/snapshot-changes.ts @@ -4,7 +4,7 @@ import { DatabaseQuery, AngularFireAction, SnapshotAction } from '../interfaces' import { database } from 'firebase/app'; export function createObjectSnapshotChanges(query: DatabaseQuery) { - return function snapshotChanges(): Observable { + return function snapshotChanges(): Observable { return fromRef(query, 'value'); } } diff --git a/src/database/object/value-changes.ts b/src/database/object/value-changes.ts deleted file mode 100644 index 7f4904d2b..000000000 --- a/src/database/object/value-changes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Observable } from 'rxjs/Observable'; -import { fromRef } from '../observable/fromRef'; -import { DatabaseQuery, ChildEvent } from '../interfaces'; - -export function createObjectValueChanges(query: DatabaseQuery) { - return function valueChanges(): Observable { - return fromRef(query, 'value') - .map(change => change.payload ? change.payload.val() : null); - } -} diff --git a/src/database/observable/fromRef.spec.ts b/src/database/observable/fromRef.spec.ts index 1500da483..10709c0ec 100644 --- a/src/database/observable/fromRef.spec.ts +++ b/src/database/observable/fromRef.spec.ts @@ -53,6 +53,23 @@ describe('fromRef', () => { expect(count).toEqual(0); }); + it('it should should handle non-existence', (done) => { + const itemRef = ref(rando()); + itemRef.set({}); + const obs = fromRef(itemRef, 'value'); + const sub = obs.take(1).subscribe(change => { + expect(change.payload.exists()).toEqual(false); + expect(change.payload.val()).toEqual(null); + }).add(done); + }); + + it('once should complete', (done) => { + const itemRef = ref(rando()); + itemRef.set(batch); + const obs = fromRef(itemRef, 'value', 'once'); + obs.subscribe(change => {}, () => {}, done); + }); + it('it should listen and then unsubscribe', (done) => { const itemRef = ref(rando()); itemRef.set(batch); @@ -80,7 +97,7 @@ describe('fromRef', () => { count = count + 1; const { type, payload } = change; expect(type).toEqual('child_added'); - expect(payload!.val()).toEqual(batch[payload!.key!]); + expect(payload.val()).toEqual(batch[payload.key!]); if (count === items.length) { done(); sub.unsubscribe(); @@ -98,8 +115,8 @@ describe('fromRef', () => { const sub = obs.subscribe(change => { const { type, payload } = change; expect(type).toEqual('child_changed'); - expect(payload!.key).toEqual(key); - expect(payload!.val()).toEqual({ key, name }); + expect(payload.key).toEqual(key); + expect(payload.val()).toEqual({ key, name }); sub.unsubscribe(); done(); }); @@ -115,8 +132,8 @@ describe('fromRef', () => { const sub = obs.subscribe(change => { const { type, payload } = change; expect(type).toEqual('child_removed'); - expect(payload!.key).toEqual(key); - expect(payload!.val()).toEqual({ key, name }); + expect(payload.key).toEqual(key); + expect(payload.val()).toEqual({ key, name }); sub.unsubscribe(); done(); }); @@ -132,8 +149,8 @@ describe('fromRef', () => { const sub = obs.subscribe(change => { const { type, payload } = change; expect(type).toEqual('child_moved'); - expect(payload!.key).toEqual(key); - expect(payload!.val()).toEqual({ key, name }); + expect(payload.key).toEqual(key); + expect(payload.val()).toEqual({ key, name }); sub.unsubscribe(); done(); }); @@ -147,7 +164,7 @@ describe('fromRef', () => { const sub = obs.subscribe(change => { const { type, payload } = change; expect(type).toEqual('value'); - expect(payload!.val()).toEqual(batch); + expect(payload.val()).toEqual(batch); done(); sub.unsubscribe(); expect(sub.closed).toEqual(true); @@ -161,7 +178,7 @@ describe('fromRef', () => { const obs = fromRef(query, 'value'); const sub = obs.subscribe(change => { let child; - change.payload!.forEach(snap => { child = snap.val(); return true; }); + change.payload.forEach(snap => { child = snap.val(); return true; }); expect(child).toEqual(items[0]); done(); }); diff --git a/src/database/observable/fromRef.ts b/src/database/observable/fromRef.ts index d0d51c317..26b030bc0 100644 --- a/src/database/observable/fromRef.ts +++ b/src/database/observable/fromRef.ts @@ -1,31 +1,42 @@ -import { DatabaseQuery, DatabaseSnapshot, ListenEvent, SnapshotPrevKey, AngularFireAction } from '../interfaces'; +import { DatabaseQuery, DatabaseSnapshot, ListenEvent, AngularFireAction } from '../interfaces'; import { Observable } from 'rxjs/Observable'; import { observeOn } from 'rxjs/operator/observeOn'; import { ZoneScheduler } from 'angularfire2'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/delay'; +import 'rxjs/add/operator/share'; + +interface SnapshotPrevKey { + snapshot: DatabaseSnapshot; + prevKey: string | null | undefined; +} /** * Create an observable from a Database Reference or Database Query. * @param ref Database Reference * @param event Listen event type ('value', 'added', 'changed', 'removed', 'moved') */ -export function fromRef(ref: DatabaseQuery, event: ListenEvent, listenType = 'on'): Observable> { - const ref$ = new Observable(subscriber => { +export function fromRef(ref: DatabaseQuery, event: ListenEvent, listenType = 'on'): Observable> { + const ref$ = new Observable(subscriber => { const fn = ref[listenType](event, (snapshot, prevKey) => { - subscriber.next({ snapshot, prevKey }) + subscriber.next({ snapshot, prevKey }); + if (listenType == 'once') { subscriber.complete(); } }, subscriber.error.bind(subscriber)); - return { unsubscribe() { ref.off(event, fn)} } + if (listenType == 'on') { + return { unsubscribe() { ref.off(event, fn)} }; + } else { + return { unsubscribe() { } }; + } }) .map((payload: SnapshotPrevKey) => { const { snapshot, prevKey } = payload; let key: string | null = null; - if(snapshot) { key = snapshot.key; } + if (snapshot.exists()) { key = snapshot.key; } return { type: event, payload: snapshot, prevKey, key }; }) // Ensures subscribe on observable is async. This handles // a quirk in the SDK where on/once callbacks can happen // synchronously. .delay(0); - return observeOn.call(ref$, new ZoneScheduler(Zone.current)); + return observeOn.call(ref$, new ZoneScheduler(Zone.current)).share(); } diff --git a/src/database/public_api.ts b/src/database/public_api.ts index 22a72f3ca..bac23e359 100644 --- a/src/database/public_api.ts +++ b/src/database/public_api.ts @@ -4,6 +4,5 @@ export * from './list/create-reference'; export * from './list/snapshot-changes'; export * from './list/state-changes'; export * from './list/audit-trail'; -export * from './list/loaded'; export * from './observable/fromRef'; export * from './database.module' diff --git a/src/database/utils.ts b/src/database/utils.ts index 7f88a7157..f3e445a9f 100644 --- a/src/database/utils.ts +++ b/src/database/utils.ts @@ -33,7 +33,7 @@ export function getRef(app: FirebaseApp, pathRef: PathReference): DatabaseRefere : app.database().ref(pathRef as string); } -export function checkOperationCases(item: FirebaseOperation, cases: FirebaseOperationCases) : Promise { +export function checkOperationCases(item: FirebaseOperation, cases: FirebaseOperationCases) : Promise { if (isString(item)) { return cases.stringCase(); } else if (isFirebaseRef(item)) { diff --git a/src/firestore/document/document.spec.ts b/src/firestore/document/document.spec.ts index c787dbd9e..f69cbbaa9 100644 --- a/src/firestore/document/document.spec.ts +++ b/src/firestore/document/document.spec.ts @@ -57,13 +57,10 @@ describe('AngularFirestoreDocument', () => { const stock = new AngularFirestoreDocument(ref); await stock.set(FAKE_STOCK_DATA); const obs$ = stock.valueChanges(); - const sub = obs$.catch(e => { console.log(e); return e; }) - .take(1) // this will unsubscribe after the first - .subscribe(async (data: Stock) => { - sub.unsubscribe(); - expect(JSON.stringify(data)).toBe(JSON.stringify(FAKE_STOCK_DATA)); - stock.delete().then(done).catch(done.fail); - }); + obs$.take(1).subscribe(async (data: Stock) => { + expect(JSON.stringify(data)).toBe(JSON.stringify(FAKE_STOCK_DATA)); + stock.delete().then(done).catch(done.fail); + }); }); }); diff --git a/src/root.spec.js b/src/root.spec.js index ae8d8d844..ab802aa3c 100644 --- a/src/root.spec.js +++ b/src/root.spec.js @@ -8,7 +8,6 @@ export * from './packages-dist/database/database.spec'; export * from './packages-dist/database/utils.spec'; export * from './packages-dist/database/observable/fromRef.spec'; export * from './packages-dist/database/list/changes.spec'; -export * from './packages-dist/database/list/loaded.spec'; export * from './packages-dist/database/list/snapshot-changes.spec'; export * from './packages-dist/database/list/state-changes.spec'; export * from './packages-dist/database/list/audit-trail.spec'; diff --git a/tools/build.js b/tools/build.js index 43660515d..d7b7eae8a 100644 --- a/tools/build.js +++ b/tools/build.js @@ -34,6 +34,7 @@ const GLOBALS = { 'rxjs/add/operator/scan': 'Rx.Observable.prototype', 'rxjs/add/operator/skip': 'Rx.Observable.prototype', 'rxjs/add/operator/do': 'Rx.Observable.prototype', + 'rxjs/add/operator/distinctUntilChanged': 'Rx.Observable.prototype', 'rxjs/add/operator/filter': 'Rx.Observable.prototype', 'rxjs/add/operator/skipUntil': 'Rx.Observable.prototype', 'rxjs/add/operator/skipWhile': 'Rx.Observable.prototype',