From 97a5dc4fa0da91c1dce6099c37e7a0313d122dde Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Sun, 22 Dec 2019 19:53:38 +0000 Subject: [PATCH] implement live query on @orbit/record-cache --- .../record-cache/src/async-record-cache.ts | 19 ++ .../src/live-query/async-live-query.ts | 32 +++ .../record-cache/src/live-query/live-query.ts | 154 +++++++++++ .../src/live-query/sync-live-query.ts | 36 +++ .../record-cache/src/live-query/utils.ts | 58 ++++ .../record-cache/src/sync-record-cache.ts | 19 ++ .../test/async-record-cache-test.ts | 250 ++++++++++++++++++ .../test/sync-record-cache-test.ts | 191 +++++++++++++ 8 files changed, 759 insertions(+) create mode 100644 packages/@orbit/record-cache/src/live-query/async-live-query.ts create mode 100644 packages/@orbit/record-cache/src/live-query/live-query.ts create mode 100644 packages/@orbit/record-cache/src/live-query/sync-live-query.ts create mode 100644 packages/@orbit/record-cache/src/live-query/utils.ts diff --git a/packages/@orbit/record-cache/src/async-record-cache.ts b/packages/@orbit/record-cache/src/async-record-cache.ts index 5a2cc6d59..4aeac008d 100644 --- a/packages/@orbit/record-cache/src/async-record-cache.ts +++ b/packages/@orbit/record-cache/src/async-record-cache.ts @@ -38,6 +38,7 @@ import { } from './record-accessor'; import { PatchResult } from './patch-result'; import { QueryResult, QueryResultData } from './query-result'; +import { AsyncLiveQuery } from './live-query/async-live-query'; const { assert } = Orbit; @@ -246,6 +247,24 @@ export abstract class AsyncRecordCache implements Evented, AsyncRecordAccessor { return result; } + liveQuery( + queryOrExpressions: QueryOrExpressions, + options?: object, + id?: string + ): AsyncLiveQuery { + const query = buildQuery( + queryOrExpressions, + options, + id, + this.queryBuilder + ); + + return new AsyncLiveQuery({ + cache: this, + query + }); + } + ///////////////////////////////////////////////////////////////////////////// // Protected methods ///////////////////////////////////////////////////////////////////////////// diff --git a/packages/@orbit/record-cache/src/live-query/async-live-query.ts b/packages/@orbit/record-cache/src/live-query/async-live-query.ts new file mode 100644 index 000000000..b2fe5310e --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/async-live-query.ts @@ -0,0 +1,32 @@ +import { RecordException } from '@orbit/data'; +import { QueryResult } from '../query-result'; +import { AsyncRecordCache } from '../async-record-cache'; +import { LiveQuery, LiveQuerySettings } from './live-query'; + +export interface AsyncLiveQuerySettings extends LiveQuerySettings { + cache: AsyncRecordCache; +} + +export class AsyncLiveQuery extends LiveQuery { + cache: AsyncRecordCache; + + constructor(settings: AsyncLiveQuerySettings) { + super(settings); + this.cache = settings.cache; + } + + get schema() { + return this.cache.schema; + } + + executeQuery( + onNext: (result: QueryResult) => void, + onError?: (error: RecordException) => void + ): void { + this.cache.query(this.query).then(onNext, onError || defaultOnError); + } +} + +function defaultOnError(error: RecordException): void { + throw error; +} diff --git a/packages/@orbit/record-cache/src/live-query/live-query.ts b/packages/@orbit/record-cache/src/live-query/live-query.ts new file mode 100644 index 000000000..baf6c3834 --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/live-query.ts @@ -0,0 +1,154 @@ +import { Evented } from '@orbit/core'; +import { + QueryExpression, + FindRecord, + FindRecords, + FindRelatedRecord, + FindRelatedRecords, + equalRecordIdentities, + Query, + Schema, + RecordOperation, + RecordException +} from '@orbit/data'; + +import { QueryResult } from '../query-result'; +import { recordOperationChange, RecordChange } from './utils'; + +export interface LiveQuerySettings { + query: Query; +} + +export class LiveQuery { + cache: Evented; + schema: Schema; + query: Query; + + executeQuery( + onNext: (result: QueryResult) => void, + onError?: (error: RecordException) => void + ): void { + throw new TypeError('executeQuery: Not Implemented.'); + } + + on( + onNext: (result: QueryResult) => void, + onError?: (error: RecordException) => void + ): () => void { + const unsubscribePatch = this.cache.on( + 'patch', + (operation: RecordOperation) => { + if (this.match(operation)) { + this.executeQuery(onNext, onError); + } + } + ); + + const unsubscribeReset = this.cache.on('reset', () => { + this.executeQuery(onNext, onError); + }); + + this.executeQuery(onNext, onError); + + return function unsubscribe() { + unsubscribePatch(); + unsubscribeReset(); + }; + } + + constructor(settings: LiveQuerySettings) { + this.query = settings.query; + } + + match(operation: RecordOperation): boolean { + const change = recordOperationChange(operation); + return !!this.query.expressions.find(expression => + this._queryExpressionMatchChange(expression, change) + ); + } + + protected _queryExpressionMatchChange( + expression: QueryExpression, + change: RecordChange + ): boolean { + switch (expression.op) { + case 'findRecord': + return this._findRecordQueryExpressionMatchChange( + expression as FindRecord, + change + ); + case 'findRecords': + return this._findRecordsQueryExpressionMatchChange( + expression as FindRecords, + change + ); + case 'findRelatedRecord': + return this._findRelatedRecordQueryExpressionMatchChange( + expression as FindRelatedRecord, + change + ); + case 'findRelatedRecords': + return this._findRelatedRecordsQueryExpressionMatchChange( + expression as FindRelatedRecords, + change + ); + default: + return true; + } + } + + protected _findRecordQueryExpressionMatchChange( + expression: FindRecord, + change: RecordChange + ): boolean { + return equalRecordIdentities(expression.record, change); + } + + protected _findRecordsQueryExpressionMatchChange( + expression: FindRecords, + change: RecordChange + ): boolean { + if (expression.type) { + return expression.type === change.type; + } else if (expression.records) { + for (let record of expression.records) { + if (record.type === change.type) { + return true; + } + } + return false; + } + return true; + } + + protected _findRelatedRecordQueryExpressionMatchChange( + expression: FindRelatedRecord, + change: RecordChange + ): boolean { + return ( + equalRecordIdentities(expression.record, change) && + (change.relationships.includes(expression.relationship) || change.remove) + ); + } + + protected _findRelatedRecordsQueryExpressionMatchChange( + expression: FindRelatedRecords, + change: RecordChange + ): boolean { + const { type } = this.schema.getRelationship( + expression.record.type, + expression.relationship + ); + + if (Array.isArray(type) && type.find(type => type === change.type)) { + return true; + } else if (type === change.type) { + return true; + } + + return ( + equalRecordIdentities(expression.record, change) && + (change.relationships.includes(expression.relationship) || change.remove) + ); + } +} diff --git a/packages/@orbit/record-cache/src/live-query/sync-live-query.ts b/packages/@orbit/record-cache/src/live-query/sync-live-query.ts new file mode 100644 index 000000000..5ad6bb5fe --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/sync-live-query.ts @@ -0,0 +1,36 @@ +import { RecordException } from '@orbit/data'; +import { QueryResult } from '../query-result'; +import { SyncRecordCache } from '../sync-record-cache'; +import { LiveQuery, LiveQuerySettings } from './live-query'; + +export interface SyncLiveQuerySettings extends LiveQuerySettings { + cache: SyncRecordCache; +} + +export class SyncLiveQuery extends LiveQuery { + cache: SyncRecordCache; + + constructor(settings: SyncLiveQuerySettings) { + super(settings); + this.cache = settings.cache; + } + + get schema() { + return this.cache.schema; + } + + executeQuery( + onNext: (result: QueryResult) => void, + onError?: (error: RecordException) => void + ): void { + try { + onNext(this.cache.query(this.query)); + } catch (error) { + (onError || defaultOnError)(error); + } + } +} + +function defaultOnError(error: RecordException) { + throw error; +} diff --git a/packages/@orbit/record-cache/src/live-query/utils.ts b/packages/@orbit/record-cache/src/live-query/utils.ts new file mode 100644 index 000000000..618756013 --- /dev/null +++ b/packages/@orbit/record-cache/src/live-query/utils.ts @@ -0,0 +1,58 @@ +import { + Record, + cloneRecordIdentity, + RecordIdentity, + RecordOperation +} from '@orbit/data'; + +export interface RecordChange extends RecordIdentity { + keys: string[]; + attributes: string[]; + relationships: string[]; + remove: boolean; +} + +export function recordOperationChange( + operation: RecordOperation +): RecordChange { + const record = operation.record as Record; + const change: RecordChange = { + ...cloneRecordIdentity(record), + remove: false, + keys: [], + attributes: [], + relationships: [] + }; + + switch (operation.op) { + case 'addRecord': + case 'updateRecord': + if (record.keys) { + change.keys = Object.keys(record.keys); + } + if (record.attributes) { + change.attributes = Object.keys(record.attributes); + } + if (record.relationships) { + change.relationships = Object.keys(record.relationships); + } + break; + case 'replaceAttribute': + change.attributes = [operation.attribute]; + break; + case 'replaceKey': + change.keys = [operation.key]; + break; + case 'replaceRelatedRecord': + case 'replaceRelatedRecords': + case 'addToRelatedRecords': + case 'removeFromRelatedRecords': + change.relationships = [operation.relationship]; + break; + case 'removeRecord': + change.remove = true; + break; + } + + return change; +} diff --git a/packages/@orbit/record-cache/src/sync-record-cache.ts b/packages/@orbit/record-cache/src/sync-record-cache.ts index 65e2738a4..9503778d6 100644 --- a/packages/@orbit/record-cache/src/sync-record-cache.ts +++ b/packages/@orbit/record-cache/src/sync-record-cache.ts @@ -38,6 +38,7 @@ import { } from './record-accessor'; import { PatchResult } from './patch-result'; import { QueryResult, QueryResultData } from './query-result'; +import { SyncLiveQuery } from './live-query/sync-live-query'; const { assert } = Orbit; @@ -242,6 +243,24 @@ export abstract class SyncRecordCache implements Evented, SyncRecordAccessor { return result; } + liveQuery( + queryOrExpressions: QueryOrExpressions, + options?: object, + id?: string + ): SyncLiveQuery { + const query = buildQuery( + queryOrExpressions, + options, + id, + this.queryBuilder + ); + + return new SyncLiveQuery({ + cache: this, + query + }); + } + ///////////////////////////////////////////////////////////////////////////// // Protected methods ///////////////////////////////////////////////////////////////////////////// diff --git a/packages/@orbit/record-cache/test/async-record-cache-test.ts b/packages/@orbit/record-cache/test/async-record-cache-test.ts index 04a401993..4fe127050 100644 --- a/packages/@orbit/record-cache/test/async-record-cache-test.ts +++ b/packages/@orbit/record-cache/test/async-record-cache-test.ts @@ -3928,4 +3928,254 @@ module('AsyncRecordCache', function(hooks) { [jupiter, mars] ); }); + + test('#live', async function(assert) { + let cache = new Cache({ schema, keyMap }); + + const jupiter: Record = { + id: 'jupiter', + type: 'planet', + attributes: { name: 'Jupiter' } + }; + + const jupiter2 = { + ...jupiter, + attributes: { name: 'Jupiter 2' } + }; + + const callisto: Record = { + id: 'callisto', + type: 'moon', + attributes: { name: 'Callisto' }, + relationships: { planet: { data: { type: 'planet', id: 'jupiter' } } } + }; + + const jupiterWithCallisto = { + ...jupiter2, + relationships: { moons: { data: [{ type: 'moon', id: 'callisto' }] } } + }; + + const livePlanet = cache.liveQuery(q => + q.findRecord({ type: 'planet', id: 'jupiter' }) + ); + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + const livePlanetMoons = cache.liveQuery(q => + q.findRelatedRecords(jupiter, 'moons') + ); + const liveMoonPlanet = cache.liveQuery(q => + q.findRelatedRecord(callisto, 'planet') + ); + + interface Deferred { + promise?: Promise; + resolve?: () => void; + reject?: () => void; + } + function defer() { + let defer: Deferred = {}; + defer.promise = new Promise((resolve, reject) => { + defer.resolve = resolve; + defer.reject = reject; + }); + return defer; + } + + let empty = defer(); + let jupiterAdded = defer(); + let jupiterUpdated = defer(); + let callistoAdded = defer(); + let jupiterRemoved = defer(); + + function next() { + if (n === 1 && i === 1 && j === 1 && k === 1) { + empty.resolve(); + } + if (n === 2 && i === 2) { + jupiterAdded.resolve(); + } + if (n === 3 && i === 3) { + jupiterUpdated.resolve(); + } + if (n === 4 && i === 4 && j === 3 && k === 2) { + callistoAdded.resolve(); + } + if (n === 5 && i === 5 && j === 5 && k === 3) { + jupiterRemoved.resolve(); + } + } + + let n = 0; + let offLivePlanet = livePlanet.on( + result => { + n++; + if (n === 2) { + assert.deepEqual(result, jupiter, 'findRecord jupiter'); + } else if (n === 3) { + assert.deepEqual(result, jupiter2, 'findRecord jupiter2'); + } else if (n === 4) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRecord jupiterWithCallisto' + ); + } else { + assert.ok(false, 'findRecord should not execute'); + } + next(); + }, + error => { + n++; + if (n === 1 || n === 5) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRecord not found' + ); + } else { + assert.ok(false, 'findRecord should not throw error'); + } + next(); + } + ); + + let i = 0; + let offLivePlanets = livePlanets.on( + result => { + i++; + if (i === 1) { + assert.deepEqual(result, [], 'findRecords []'); + } else if (i === 2) { + assert.deepEqual(result, [jupiter], 'findRecords [jupiter]'); + } else if (i === 3) { + assert.deepEqual(result, [jupiter2], 'findRecords [jupiter2]'); + } else if (i === 4) { + assert.deepEqual( + result, + [jupiterWithCallisto], + 'findRecords [jupiterWithCallisto]' + ); + } else if (i === 5) { + assert.deepEqual(result, [], 'findRecords []'); + } else { + assert.ok(false, 'findRecords should not execute'); + } + next(); + }, + () => { + assert.ok(false, 'findRecords should not throw error'); + } + ); + + let j = 0; + let offLivePlanetMoons = livePlanetMoons.on( + result => { + j++; + if (j === 2) { + assert.deepEqual( + result, + [], + 'findRelatedRecords jupiter.moons => []' + ); + } else if (j === 3) { + assert.deepEqual( + result, + [callisto], + 'findRelatedRecords jupiter.moons => [callisto]' + ); + } else { + assert.ok(false, 'findRelatedRecords should not execute'); + } + next(); + }, + error => { + j++; + if (j === 1 || j === 4 || j === 5) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecords not found' + ); + } else { + assert.ok(false, 'findRelatedRecords should not throw error'); + } + next(); + } + ); + + let k = 0; + let offLiveMoonPlanet = liveMoonPlanet.on( + result => { + k++; + if (k === 2) { + assert.deepEqual( + result, + jupiter2, + 'findRelatedRecord callisto.planet => jupiter' + ); + } else if (k === 3) { + assert.deepEqual( + result, + null, + 'findRelatedRecord callisto.planet => null' + ); + } else { + assert.ok(false, 'findRelatedRecord should not execute'); + } + next(); + }, + error => { + k++; + if (k === 1) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecord not found' + ); + } else { + assert.ok(false, 'findRelatedRecord should not throw error'); + } + next(); + } + ); + + setTimeout(() => { + empty.reject(); + jupiterAdded.reject(); + jupiterUpdated.reject(); + callistoAdded.reject(); + jupiterRemoved.reject(); + }, 500); + + await empty.promise; + + await cache.patch(t => t.addRecord(jupiter)); + await jupiterAdded.promise; + + await cache.patch(t => t.updateRecord(jupiter2)); + await jupiterUpdated.promise; + + await cache.patch(t => t.addRecord(callisto)); + await callistoAdded.promise; + + await cache.patch(t => t.removeRecord(jupiter)); + await jupiterRemoved.promise; + + assert.expect(22); + assert.equal(n, 5, 'findRecord should run 5 times'); + assert.equal(i, 5, 'findRecords should run 5 times'); + assert.equal(j, 5, 'findRelatedRecords should run 5 times'); + assert.equal(k, 3, 'findRelatedRecord should run 3 times'); + + offLivePlanet(); + offLivePlanets(); + offLivePlanetMoons(); + offLiveMoonPlanet(); + + await cache.patch(t => + t.addRecord({ + type: 'planet', + id: 'mercury', + attributes: { + name: 'Mercury' + } + }) + ); + }); }); diff --git a/packages/@orbit/record-cache/test/sync-record-cache-test.ts b/packages/@orbit/record-cache/test/sync-record-cache-test.ts index c77b28e5e..e9532aa7d 100644 --- a/packages/@orbit/record-cache/test/sync-record-cache-test.ts +++ b/packages/@orbit/record-cache/test/sync-record-cache-test.ts @@ -3898,4 +3898,195 @@ module('SyncRecordCache', function(hooks) { [jupiter, mars] ); }); + + test('#live', function(assert) { + let cache = new Cache({ schema, keyMap }); + + const jupiter: Record = { + id: 'jupiter', + type: 'planet', + attributes: { name: 'Jupiter' } + }; + + const jupiter2 = { + ...jupiter, + attributes: { name: 'Jupiter 2' } + }; + + const callisto: Record = { + id: 'callisto', + type: 'moon', + attributes: { name: 'Callisto' }, + relationships: { planet: { data: { type: 'planet', id: 'jupiter' } } } + }; + + const jupiterWithCallisto = { + ...jupiter2, + relationships: { moons: { data: [{ type: 'moon', id: 'callisto' }] } } + }; + + const livePlanet = cache.liveQuery(q => + q.findRecord({ type: 'planet', id: 'jupiter' }) + ); + const livePlanets = cache.liveQuery(q => q.findRecords('planet')); + const livePlanetMoons = cache.liveQuery(q => + q.findRelatedRecords(jupiter, 'moons') + ); + const liveMoonPlanet = cache.liveQuery(q => + q.findRelatedRecord(callisto, 'planet') + ); + + let n = 0; + let offLivePlanet = livePlanet.on( + result => { + n++; + if (n === 2) { + assert.deepEqual(result, jupiter, 'findRecord jupiter'); + } else if (n === 3) { + assert.deepEqual(result, jupiter2, 'findRecord jupiter2'); + } else if (n === 4) { + assert.deepEqual( + result, + jupiterWithCallisto, + 'findRecord jupiterWithCallisto' + ); + } else { + assert.ok(false, 'findRecord should not execute'); + } + }, + error => { + n++; + if (n === 1 || n === 5) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRecord not found' + ); + } else { + assert.ok(false, 'findRecord should not throw error'); + } + } + ); + + let i = 0; + let offLivePlanets = livePlanets.on( + result => { + i++; + if (i === 1) { + assert.deepEqual(result, [], 'findRecords []'); + } else if (i === 2) { + assert.deepEqual(result, [jupiter], 'findRecords [jupiter]'); + } else if (i === 3) { + assert.deepEqual(result, [jupiter2], 'findRecords [jupiter2]'); + } else if (i === 4) { + assert.deepEqual( + result, + [jupiterWithCallisto], + 'findRecords [jupiterWithCallisto]' + ); + } else if (i === 5) { + assert.deepEqual(result, [], 'findRecords []'); + } else { + assert.ok(false, 'findRecords should not execute'); + } + }, + () => { + assert.ok(false, 'findRecords should not throw error'); + } + ); + + let j = 0; + let offLivePlanetMoons = livePlanetMoons.on( + result => { + j++; + if (j === 2) { + assert.deepEqual( + result, + [], + 'findRelatedRecords jupiter.moons => []' + ); + } else if (j === 3) { + assert.deepEqual( + result, + [callisto], + 'findRelatedRecords jupiter.moons => [callisto]' + ); + } else { + assert.ok(false, 'findRelatedRecords should not execute'); + } + }, + error => { + j++; + if (j === 1 || j === 4 || j === 5) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecords not found' + ); + } else { + assert.ok(false, 'findRelatedRecords should not throw error'); + } + } + ); + + let k = 0; + let offLiveMoonPlanet = liveMoonPlanet.on( + result => { + k++; + if (k === 2) { + assert.deepEqual( + result, + jupiter2, + 'findRelatedRecord callisto.planet => jupiter' + ); + } else if (k === 3) { + assert.deepEqual( + result, + null, + 'findRelatedRecord callisto.planet => null' + ); + } else { + assert.ok(false, 'findRelatedRecord should not execute'); + } + }, + error => { + k++; + if (k === 1) { + assert.ok( + error instanceof RecordNotFoundException, + 'findRelatedRecord not found' + ); + } else { + assert.ok(false, 'findRelatedRecord should not throw error'); + } + } + ); + + cache.patch(t => t.addRecord(jupiter)); + + cache.patch(t => t.updateRecord(jupiter2)); + + cache.patch(t => t.addRecord(callisto)); + + cache.patch(t => t.removeRecord(jupiter)); + + assert.expect(22); + assert.equal(n, 5, 'findRecord should run 5 times'); + assert.equal(i, 5, 'findRecords should run 5 times'); + assert.equal(j, 5, 'findRelatedRecords should run 5 times'); + assert.equal(k, 3, 'findRelatedRecord should run 3 times'); + + offLivePlanet(); + offLivePlanets(); + offLivePlanetMoons(); + offLiveMoonPlanet(); + + cache.patch(t => + t.addRecord({ + type: 'planet', + id: 'mercury', + attributes: { + name: 'Mercury' + } + }) + ); + }); });