From 1cdf6f8d1a486e7289e333517841dbe97763eaea Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Mon, 20 Jan 2020 14:43:28 +0530 Subject: [PATCH 01/17] feat: add support to observe selected columns on raw records --- src/Collection/index.d.ts | 2 +- src/Collection/index.js | 14 ++++++---- src/Query/index.js | 4 +-- src/QueryDescription/index.js | 28 +++++++++++++------ src/adapters/sqlite/encodeQuery/index.js | 23 +++++++++++++-- .../subscribeToSimpleQuery/index.js | 12 ++++++-- 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/Collection/index.d.ts b/src/Collection/index.d.ts index bd1512555..108fbac6b 100644 --- a/src/Collection/index.d.ts +++ b/src/Collection/index.d.ts @@ -1,5 +1,5 @@ declare module '@nozbe/watermelondb/Collection' { - import { Database, Model, Query, RecordId, TableName, TableSchema } from '@nozbe/watermelondb' + import { Database, Model, Query, RecordId, TableName, TableSchema, RawRecord } from '@nozbe/watermelondb' import { Condition } from '@nozbe/watermelondb/QueryDescription' import { Class } from '@nozbe/watermelondb/utils/common' import { Observable, Subject } from 'rxjs' diff --git a/src/Collection/index.js b/src/Collection/index.js index b2651218f..3d197b7ca 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -6,7 +6,7 @@ import { defer } from 'rxjs/observable/defer' import { switchMap } from 'rxjs/operators' import invariant from '../utils/common/invariant' import noop from '../utils/fp/noop' -import { type ResultCallback, toPromise, mapValue } from '../utils/fp/Result' +import { type ResultCallback, type Result, type CachedQueryResult, toPromise, mapValue } from '../utils/fp/Result' import { type Unsubscribe } from '../utils/subscriptions' import Query from '../Query' @@ -114,10 +114,14 @@ export default class Collection { } // See: Query.fetch - _fetchQuery(query: Query, callback: ResultCallback): void { - this.database.adapter.underlyingAdapter.query(query.serialize(), result => - callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)), - ) + _fetchQuery(query: Query, callback: ResultCallback[]>): void { + this.database.adapter.underlyingAdapter.query(query.serialize(), result => { + if(query.description.select.length) { + callback(result) + return + } + callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)) + }) } // See: Query.fetchCount diff --git a/src/Query/index.js b/src/Query/index.js index 2a48a4ed3..e29e43245 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -60,9 +60,9 @@ export default class Query { // Creates a new Query that extends the conditions of this query extend(...conditions: Condition[]): Query { const { collection } = this - const { join, where } = this._rawDescription + const { select, join, where } = this._rawDescription - return new Query(collection, [...join, ...where, ...conditions]) + return new Query(collection, [...select, ...join, ...where, ...conditions]) } pipe(transform: this => T): T { diff --git a/src/QueryDescription/index.js b/src/QueryDescription/index.js index 438fba1cb..0c888998e 100644 --- a/src/QueryDescription/index.js +++ b/src/QueryDescription/index.js @@ -1,9 +1,8 @@ // @flow -import { propEq, pipe, prop, uniq, map } from 'rambdax' +import { pipe, prop, uniq, map, groupBy } from 'rambdax' // don't import whole `utils` to keep worker size small -import partition from '../utils/fp/partition' import invariant from '../utils/common/invariant' import type { $RE } from '../types' @@ -41,6 +40,11 @@ export type WhereDescription = $RE<{ comparison: Comparison, }> +export type Select = $RE<{ + type: 'select', + columns: ColumnName[], +}> + /* eslint-disable-next-line */ export type Where = WhereDescription | And | Or export type And = $RE<{ type: 'and', conditions: Where[] }> @@ -51,8 +55,8 @@ export type On = $RE<{ left: ColumnName, comparison: Comparison, }> -export type Condition = Where | On -export type QueryDescription = $RE<{ where: Where[], join: On[] }> +export type Condition = Select | Where | On +export type QueryDescription = $RE<{ select: Select[], where: Where[], join: On[] }> // Note: These operators are designed to match SQLite semantics // to ensure that iOS, Android, web, and Query observation yield exactly the same results @@ -186,6 +190,11 @@ function _valueOrComparison(arg: Value | Comparison): Comparison { return eq(arg) } +// Select +export function select(columns: ColumnName[]): Select { + return { type: 'select', columns } +} + export function where(left: ColumnName, valueOrComparison: Value | Comparison): WhereDescription { return { type: 'where', left, comparison: _valueOrComparison(valueOrComparison) } } @@ -231,8 +240,10 @@ export const on: OnFunction = (table, leftOrWhereDescription, valueOrComparison) } const syncStatusColumn = columnName('_status') -const getJoins: (Condition[]) => [On[], Where[]] = (partition(propEq('type', 'on')): any) const whereNotDeleted = where(syncStatusColumn, notEq('deleted')) +const getGroupedConditions = groupBy( + condition => ['select', 'on'].includes(condition.type) ? condition.type : 'where' +) const joinsWithoutDeleted = pipe( map(prop('table')), uniq, @@ -240,9 +251,9 @@ const joinsWithoutDeleted = pipe( ) export function buildQueryDescription(conditions: Condition[]): QueryDescription { - const [join, whereConditions] = getJoins(conditions) + const {select: selections = [], on: join = [], where: whereConditions = [] }: QueryDescription = getGroupedConditions(conditions) - const query = { join, where: whereConditions } + const query = { select: selections, join, where: whereConditions } if (process.env.NODE_ENV !== 'production') { Object.freeze(query) } @@ -250,9 +261,10 @@ export function buildQueryDescription(conditions: Condition[]): QueryDescription } export function queryWithoutDeleted(query: QueryDescription): QueryDescription { - const { join, where: whereConditions } = query + const { select: selections, join, where: whereConditions } = query const newQuery = { + select: selections, join: [...join, ...joinsWithoutDeleted(join)], where: [...whereConditions, whereNotDeleted], } diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index d01cdd2c3..69302d192 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -1,6 +1,7 @@ // @flow /* eslint-disable no-use-before-define */ +import {pipe, pluck, flatten, uniq} from "rambdax" import type { SerializedQuery, AssociationArgs } from '../../../Query' import type { NonNullValues, @@ -122,6 +123,7 @@ const encodeConditions: (TableName, QueryDescription) => string = (table, d // relation, then we need to add `distinct` on the query to ensure there are no duplicates const encodeMethod = ( table: TableName, + selections: ColumnName[], countMode: boolean, needsDistinct: boolean, ): string => { @@ -131,9 +133,16 @@ const encodeMethod = ( : `select count(*) as "count" from ${encodeName(table)}` } + const getSelectionQueryString = () => { + if(!selections.length) { + return `${encodeName(table)}.*` + } + return selections.map(column => `${encodeName(table)}.${encodeName(column)}`).join(', ') + } + return needsDistinct - ? `select distinct ${encodeName(table)}.* from ${encodeName(table)}` - : `select ${encodeName(table)}.* from ${encodeName(table)}` + ? `select distinct ${getSelectionQueryString()} from ${encodeName(table)}` + : `select ${getSelectionQueryString()} from ${encodeName(table)}` } const encodeAssociation: (TableName) => AssociationArgs => string = mainTable => ([ @@ -156,11 +165,19 @@ const encodeQuery = (query: SerializedQuery, countMode: boolean = false): string const hasJoins = !!query.description.join.length const associations = hasJoins ? query.associations : [] + const hasSelections = !!query.description.select.length + const selections = hasSelections + ? pipe( + pluck('columns'), + flatten, + uniq, + )(query.description.select) + : [] const hasToManyJoins = associations.some(([, association]) => association.type === 'has_many') const sql = - encodeMethod(table, countMode, hasToManyJoins) + + encodeMethod(table, selections, countMode, hasToManyJoins) + encodeJoin(table, associations) + encodeConditions(table, description) diff --git a/src/observation/subscribeToSimpleQuery/index.js b/src/observation/subscribeToSimpleQuery/index.js index d723f5ca0..9869b77fd 100644 --- a/src/observation/subscribeToSimpleQuery/index.js +++ b/src/observation/subscribeToSimpleQuery/index.js @@ -1,5 +1,6 @@ // @flow +import {pipe, pick, uniq, flatten, pluck} from 'rambdax' import { invariant, logError } from '../../utils/common' import { type Unsubscribe } from '../../utils/subscriptions' @@ -14,6 +15,7 @@ import encodeMatcher, { type Matcher } from '../encodeMatcher' // WARN: Mutates arguments export function processChangeSet( changeSet: CollectionChangeSet, + selections: ColumnName[], matcher: Matcher, mutableMatchingRecords: Record[], ): boolean { @@ -40,7 +42,8 @@ export function processChangeSet( shouldEmit = true } else if (matches && !currentlyMatching) { // Add if should be included but isn't - mutableMatchingRecords.push(record) + const _record = selections.length ? pick(selections, record._raw) : record + mutableMatchingRecords.push(_record) shouldEmit = true } }) @@ -86,7 +89,12 @@ export default function subscribeToSimpleQuery( unsubscribe = query.collection.experimentalSubscribe(function observeQueryCollectionChanged( changeSet, ): void { - const shouldEmit = processChangeSet(changeSet, matcher, matchingRecords) + const selections = pipe( + pluck('columns'), + flatten, + uniq, + )(query.description.select) + const shouldEmit = processChangeSet(changeSet, selections, matcher, matchingRecords) if (shouldEmit || alwaysEmit) { emitCopy() } From 2f4c634da8f04e11e7655bf17c472a046e67169d Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Mon, 20 Jan 2020 15:31:32 +0530 Subject: [PATCH 02/17] test: Update QueryDescription and encodeQuery tests --- src/QueryDescription/test.js | 10 ++++++++++ src/adapters/sqlite/encodeQuery/test.js | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/src/QueryDescription/test.js b/src/QueryDescription/test.js index a5f60f565..84b835bad 100644 --- a/src/QueryDescription/test.js +++ b/src/QueryDescription/test.js @@ -4,6 +4,7 @@ describe('QueryDescription', () => { it('builds empty query', () => { const query = Q.buildQueryDescription([]) expect(query).toEqual({ + select: [], where: [], join: [], }) @@ -11,6 +12,7 @@ describe('QueryDescription', () => { it('builds simple query', () => { const query = Q.buildQueryDescription([Q.where('left_column', 'right_value')]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -33,6 +35,7 @@ describe('QueryDescription', () => { Q.where('col5', null), ]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -94,6 +97,7 @@ describe('QueryDescription', () => { Q.where('col11', Q.notLike('def%')), ]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -198,6 +202,7 @@ describe('QueryDescription', () => { it('supports column comparisons', () => { const query = Q.buildQueryDescription([Q.where('left_column', Q.gte(Q.column('right_column')))]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -221,6 +226,7 @@ describe('QueryDescription', () => { ), ]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -283,6 +289,7 @@ describe('QueryDescription', () => { Q.on('foreign_table2', 'foreign_column2', Q.gt(Q.column('foreign_column3'))), ]) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -365,6 +372,7 @@ describe('QueryDescription', () => { it('builds empty query without deleted', () => { const query = Q.queryWithoutDeleted(Q.buildQueryDescription([])) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -383,6 +391,7 @@ describe('QueryDescription', () => { Q.buildQueryDescription([Q.where('left_column', 'right_value')]), ) expect(query).toEqual({ + select: [], where: [ { type: 'where', @@ -414,6 +423,7 @@ describe('QueryDescription', () => { ]), ) expect(query).toEqual({ + select: [], where: [ { type: 'where', diff --git a/src/adapters/sqlite/encodeQuery/test.js b/src/adapters/sqlite/encodeQuery/test.js index a1343b892..99a36e4fc 100644 --- a/src/adapters/sqlite/encodeQuery/test.js +++ b/src/adapters/sqlite/encodeQuery/test.js @@ -111,4 +111,12 @@ describe('SQLite encodeQuery', () => { `select "tasks".* from "tasks" where "tasks"."col1" like '%abc' and "tasks"."col2" not like 'def%' and "tasks"."_status" is not 'deleted'`, ) }) + it('encodes simple select queries', () => { + const query = new Query(mockCollection, [ + Q.select(['col1', 'col2']), + ]) + expect(encodeQuery(query)).toBe( + `select "tasks"."col1", "tasks"."col2" from "tasks" where "tasks"."_status" is not 'deleted'`, + ) + }) }) From 08bf04c793fe83acd23b4fa25e2c96530ed5cf83 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Wed, 29 Jan 2020 20:08:37 +0530 Subject: [PATCH 03/17] refactor: Change API to observe on selected columns and add tests --- src/Collection/index.js | 23 ++-- src/Query/index.js | 20 +++- src/Query/test.js | 27 +++++ src/QueryDescription/index.js | 10 +- src/QueryDescription/test.js | 60 ++++++++++ src/adapters/sqlite/encodeQuery/index.js | 2 +- src/adapters/sqlite/encodeQuery/test.js | 4 +- .../subscribeToQueryWithSelect/index.js | 107 ++++++++++++++++++ .../subscribeToQueryWithSelect/test.js | 75 ++++++++++++ .../subscribeToSimpleQuery/index.js | 12 +- 10 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 src/observation/subscribeToQueryWithSelect/index.js create mode 100644 src/observation/subscribeToQueryWithSelect/test.js diff --git a/src/Collection/index.js b/src/Collection/index.js index 3d197b7ca..fb4bc8216 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -6,7 +6,7 @@ import { defer } from 'rxjs/observable/defer' import { switchMap } from 'rxjs/operators' import invariant from '../utils/common/invariant' import noop from '../utils/fp/noop' -import { type ResultCallback, type Result, type CachedQueryResult, toPromise, mapValue } from '../utils/fp/Result' +import { type ResultCallback, type Result, toPromise, mapValue } from '../utils/fp/Result' import { type Unsubscribe } from '../utils/subscriptions' import Query from '../Query' @@ -14,7 +14,7 @@ import type Database from '../Database' import type Model, { RecordId } from '../Model' import type { Condition } from '../QueryDescription' import { type TableName, type TableSchema } from '../Schema' -import { type DirtyRaw } from '../RawRecord' +import { type DirtyRaw, type RawRecord } from '../RawRecord' import RecordCache from './RecordCache' import { CollectionChangeTypes } from './common' @@ -114,16 +114,25 @@ export default class Collection { } // See: Query.fetch - _fetchQuery(query: Query, callback: ResultCallback[]>): void { + _fetchQuery(query: Query, callback: ResultCallback): void { this.database.adapter.underlyingAdapter.query(query.serialize(), result => { - if(query.description.select.length) { - callback(result) - return - } callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)) }) } + _fetchQuerySelect(query: Query, callback: ResultCallback[]>): void { + this.database.adapter.underlyingAdapter.query(query.serialize(), result => { + callback(mapValue(rawRecords => { + return rawRecords.map(rawRecordOrId => { + if (typeof rawRecordOrId === 'string') { + return this._cache._cachedModelForId(rawRecordOrId)._raw + } + return rawRecordOrId + }) + }, result)) + }) + } + // See: Query.fetchCount _fetchCount(query: Query, callback: ResultCallback): void { this.database.adapter.underlyingAdapter.count(query.serialize(), callback) diff --git a/src/Query/index.js b/src/Query/index.js index e29e43245..a6c35cdee 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -13,7 +13,8 @@ import lazy from '../decorators/lazy' // import from decorarators break the app import subscribeToCount from '../observation/subscribeToCount' import subscribeToQuery from '../observation/subscribeToQuery' import subscribeToQueryWithColumns from '../observation/subscribeToQueryWithColumns' -import { buildQueryDescription, queryWithoutDeleted } from '../QueryDescription' +import subscribeToQueryWithSelect from '../observation/subscribeToQueryWithSelect' +import { experimentalSelect, buildQueryDescription, queryWithoutDeleted } from '../QueryDescription' import type { Condition, QueryDescription } from '../QueryDescription' import type Model, { AssociationInfo } from '../Model' import type Collection from '../Collection' @@ -74,6 +75,11 @@ export default class Query { return toPromise(callback => this.collection._fetchQuery(this, callback)) } + fetchSelected(columnNames: ColumnName[]): Promise { + const queryWithSelect = this.extend(experimentalSelect(columnNames)) + return toPromise(callback => this.collection._fetchQuerySelect(queryWithSelect, callback)) + } + // Emits an array of matching records, then emits a new array every time it changes observe(): Observable { return Observable.create(observer => @@ -93,6 +99,18 @@ export default class Query { ) } + // Same as `observeWithColumns(columnNames)` but emits raw records with only the + // selected `columnNames` (and `id` property added implicitly). + // Note: This is an experimental feature and this API might change in future versions. + experimentalObserveColumns(columnNames: columnName[]): Observable { + const queryWithSelect = this.extend(experimentalSelect(columnNames)) + return Observable.create(observer => + subscribeToQueryWithSelect(queryWithSelect, records => { + observer.next(records) + }) + ) + } + // Returns the number of matching records fetchCount(): Promise { return toPromise(callback => this.collection._fetchCount(this, callback)) diff --git a/src/Query/test.js b/src/Query/test.js index 418140121..a41f9e1a4 100644 --- a/src/Query/test.js +++ b/src/Query/test.js @@ -185,6 +185,33 @@ describe('Query observation', () => { ) }) + const testQueryWithSelectObservation = async makeSubscribe => { + const { database, tasks } = mockDatabase({ actionsEnabled: true }) + const adapterSpy = jest.spyOn(database.adapter.underlyingAdapter, 'query') + const query = new Query(tasks, []) + const observer = jest.fn() + + const unsubscribe = makeSubscribe(query, observer) + await waitFor(database) + expect(adapterSpy).toHaveBeenCalledTimes(1) + expect(observer).toHaveBeenCalledTimes(1) + expect(observer).toHaveBeenLastCalledWith([]) + + const t1 = await database.action(() => tasks.create(t => {t.name = 'foo'})) + await waitFor(database) + expect(observer).toHaveBeenCalledTimes(2) + expect(observer).toHaveBeenLastCalledWith([{id: t1.id, name: t1.name}]) + + unsubscribe() + } + + it('can observe to columns raw values', async () => { + await testQueryWithSelectObservation((query, subscriber) => { + const subscription = query.experimentalObserveColumns(['name']).subscribe(subscriber) + return () => subscription.unsubscribe() + }) + }) + const testCountObservation = async (makeSubscribe, isThrottled) => { const { database, tasks } = mockDatabase({ actionsEnabled: true }) const adapterSpy = jest.spyOn(database.adapter.underlyingAdapter, 'count') diff --git a/src/QueryDescription/index.js b/src/QueryDescription/index.js index 0c888998e..3ba59583f 100644 --- a/src/QueryDescription/index.js +++ b/src/QueryDescription/index.js @@ -190,9 +190,13 @@ function _valueOrComparison(arg: Value | Comparison): Comparison { return eq(arg) } -// Select -export function select(columns: ColumnName[]): Select { - return { type: 'select', columns } +// Do not use this directly. Select columns using `experimentalObserveColumns` only. +export function experimentalSelect(columns: ColumnName[]): Select { + const _columns = columns.slice(0) + if(!columns.includes('id')) { + _columns.unshift('id') + } + return { type: 'select', columns: _columns } } export function where(left: ColumnName, valueOrComparison: Value | Comparison): WhereDescription { diff --git a/src/QueryDescription/test.js b/src/QueryDescription/test.js index 84b835bad..b3263eabe 100644 --- a/src/QueryDescription/test.js +++ b/src/QueryDescription/test.js @@ -491,4 +491,64 @@ describe('QueryDescription', () => { ], }) }) + it('supports selected columns in query', () => { + const query = Q.buildQueryDescription([ + Q.experimentalSelect(['col1', 'col2']), + ]) + expect(query).toEqual({ + select: [ + { + type: 'select', + columns: ['id', 'col1', 'col2'], + }, + ], + where: [], + join: [], + }) + }) + it('supports multiple select queries', () => { + const query = Q.buildQueryDescription([ + Q.experimentalSelect(['col1', 'col2']), + Q.experimentalSelect(['col2', 'col3']), + ]) + expect(query).toEqual({ + select: [ + { + type: 'select', + columns: ['id', 'col1', 'col2'], + }, + { + type: 'select', + columns: ['id', 'col2', 'col3'], + }, + ], + where: [], + join: [], + }) + }) + it('supports select with where conditions', () => { + const query = Q.buildQueryDescription([ + Q.experimentalSelect(['col1', 'col2']), + Q.where('col', 'val'), + ]) + expect(query).toEqual({ + select: [ + { + type: 'select', + columns: ['id', 'col1', 'col2'], + }, + ], + where: [ + { + type: 'where', + left: 'col', + comparison: { + operator: 'eq', + right: { value: 'val' }, + }, + }, + ], + join: [], + }) + }) }) diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index 69302d192..50879fb30 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -1,7 +1,7 @@ // @flow /* eslint-disable no-use-before-define */ -import {pipe, pluck, flatten, uniq} from "rambdax" +import {pipe, pluck, flatten, uniq} from 'rambdax' import type { SerializedQuery, AssociationArgs } from '../../../Query' import type { NonNullValues, diff --git a/src/adapters/sqlite/encodeQuery/test.js b/src/adapters/sqlite/encodeQuery/test.js index 99a36e4fc..da06b6157 100644 --- a/src/adapters/sqlite/encodeQuery/test.js +++ b/src/adapters/sqlite/encodeQuery/test.js @@ -113,10 +113,10 @@ describe('SQLite encodeQuery', () => { }) it('encodes simple select queries', () => { const query = new Query(mockCollection, [ - Q.select(['col1', 'col2']), + Q.experimentalSelect(['col1', 'col2']), ]) expect(encodeQuery(query)).toBe( - `select "tasks"."col1", "tasks"."col2" from "tasks" where "tasks"."_status" is not 'deleted'`, + `select "tasks"."id", "tasks"."col1", "tasks"."col2" from "tasks" where "tasks"."_status" is not 'deleted'`, ) }) }) diff --git a/src/observation/subscribeToQueryWithSelect/index.js b/src/observation/subscribeToQueryWithSelect/index.js new file mode 100644 index 000000000..cf995c1c9 --- /dev/null +++ b/src/observation/subscribeToQueryWithSelect/index.js @@ -0,0 +1,107 @@ +// @flow + +import {pipe, pickAll, propEq, uniq, flatten, pluck} from 'rambdax' +import { invariant, logError } from '../../utils/common' +import { type Unsubscribe } from '../../utils/subscriptions' + +import type { CollectionChangeSet } from '../../Collection' +import { CollectionChangeTypes } from '../../Collection/common' + +import type Query from '../../Query' +import type Model from '../../Model' +import { type RawRecord } from '../../RawRecord' + +import encodeMatcher, { type Matcher } from '../encodeMatcher' + +// WARN: Mutates arguments +export function processChangeSet( + changeSet: CollectionChangeSet, + sanitizeRaw: (RawRecord[]) => RawRecord[], + matcher: Matcher, + mutableMatchingRecords: RawRecord[], +): boolean { + let shouldEmit = false + changeSet.forEach(change => { + const { record, type } = change + const index = mutableMatchingRecords.findIndex(propEq('id', record.id)) + const currentlyMatching = index > -1 + + if (type === CollectionChangeTypes.destroyed) { + if (currentlyMatching) { + // Remove if record was deleted + mutableMatchingRecords.splice(index, 1) + shouldEmit = true + } + return + } + + const matches = matcher(record._raw) + + if (currentlyMatching && !matches) { + // Remove if doesn't match anymore + mutableMatchingRecords.splice(index, 1) + shouldEmit = true + } else if (matches && !currentlyMatching) { + // Add if should be included but isn't + const _record = sanitizeRaw(record._raw) + mutableMatchingRecords.push(_record) + shouldEmit = true + } + }) + return shouldEmit +} + +export default function subscribeToQueryWithSelect( + query: Query, + subscriber: (RawRecord[]) => void +): Unsubscribe { + invariant(!query.hasJoins, 'subscribeToQueryWithSelect only supports simple queries!') + + const matcher: Matcher = encodeMatcher(query.description) + const columnNames: ColumnName[] = pipe( + pluck('columns'), + flatten, + uniq, + )(query.description.select) + const sanitizeRaw = pickAll(columnNames) + let unsubscribed = false + let unsubscribe = null + + query.collection._fetchQuerySelect(query, function observeQueryInitialEmission(result): void { + if (unsubscribed) { + return + } + + if (!result.value) { + logError(result.error.toString()) + return + } + + const initialRecords = result.value + + // Send initial matching records + const matchingRecords: RawRecord[] = initialRecords.map(sanitizeRaw) + const emitCopy = () => subscriber(matchingRecords.slice(0)) + emitCopy() + + // Check if emitCopy haven't completed source observable to avoid memory leaks + if (unsubscribed) { + return + } + + // Observe changes to the collection + unsubscribe = query.collection.experimentalSubscribe(function observeQueryCollectionChanged( + changeSet, + ): void { + const shouldEmit = processChangeSet(changeSet, sanitizeRaw, matcher, matchingRecords) + if (shouldEmit) { + emitCopy() + } + }) + }) + + return () => { + unsubscribed = true + unsubscribe && unsubscribe() + } +} diff --git a/src/observation/subscribeToQueryWithSelect/test.js b/src/observation/subscribeToQueryWithSelect/test.js new file mode 100644 index 000000000..41db67fec --- /dev/null +++ b/src/observation/subscribeToQueryWithSelect/test.js @@ -0,0 +1,75 @@ +import {pipe, pickAll, prop} from 'rambdax' +import { mockDatabase } from '../../__tests__/testModels' + +import Query from '../../Query' +import * as Q from '../../QueryDescription' + +import subscribeToQueryWithSelect from './index' + +const makeMock = (database, {name, position, isCompleted}) => + database.collections.get('mock_tasks').create(mock => { + mock.name = name + mock.position = position + mock.isCompleted = isCompleted + }) + +describe('subscribeToQueryWithSelect', () => { + it('observes changes correctly', async () => { + const { database } = mockDatabase() + + // insert a few models + const m1 = await makeMock(database, {name: 'name_1', position: 1, isCompleted: false}) + const m2 = await makeMock(database, {name: 'name_2', position: 2, isCompleted: true}) + + // start observing + const selectColumns = ['name', 'is_completed'] + const query = new Query( + database.collections.get('mock_tasks'), + [Q.experimentalSelect(selectColumns), Q.where('position', 1)] + ) + const observer = jest.fn() + const unsubscribe = subscribeToQueryWithSelect(query, observer) + + // Reecord to raw result with selected columns + const allCols = ['id'].concat(selectColumns) + const toSelectedRaw = pipe(prop('_raw'), pickAll(allCols)) + + // check initial matches + await new Promise(process.nextTick) // give time to propagate + expect(observer).toHaveBeenCalledWith([toSelectedRaw(m1)]) + + // make some irrelevant changes (no emission) + const m3 = await makeMock(database, {name: 'irrelevant', position: 2, isCompleted: false}) + await m1.update(mock => { + mock.description = 'irrelevant description' + }) + await m2.destroyPermanently() + + // add a matching record + const m4 = await makeMock(database, {name: 'foo', position: 1, isCompleted: true}) + expect(observer).toHaveBeenCalledWith([m1, m4].map(toSelectedRaw)) + + // change existing record to match + await m3.update(mock => { + mock.position = 1 + }) + expect(observer).toHaveBeenCalledWith([m1, m4, m3].map(toSelectedRaw)) + + // change existing record to no longer match + await m4.update(mock => { + mock.position = 2 + }) + expect(observer).toHaveBeenCalledWith([m1, m3].map(toSelectedRaw)) + + // change matching record in irrelevant ways (no emission) + await m3.update() + + // remove matching record + await m1.destroyPermanently() + expect(observer).toHaveBeenCalledWith([m3].map(toSelectedRaw)) + + // ensure no extra emissions + unsubscribe() + expect(observer).toHaveBeenCalledTimes(5) + }) +}) diff --git a/src/observation/subscribeToSimpleQuery/index.js b/src/observation/subscribeToSimpleQuery/index.js index 9869b77fd..d723f5ca0 100644 --- a/src/observation/subscribeToSimpleQuery/index.js +++ b/src/observation/subscribeToSimpleQuery/index.js @@ -1,6 +1,5 @@ // @flow -import {pipe, pick, uniq, flatten, pluck} from 'rambdax' import { invariant, logError } from '../../utils/common' import { type Unsubscribe } from '../../utils/subscriptions' @@ -15,7 +14,6 @@ import encodeMatcher, { type Matcher } from '../encodeMatcher' // WARN: Mutates arguments export function processChangeSet( changeSet: CollectionChangeSet, - selections: ColumnName[], matcher: Matcher, mutableMatchingRecords: Record[], ): boolean { @@ -42,8 +40,7 @@ export function processChangeSet( shouldEmit = true } else if (matches && !currentlyMatching) { // Add if should be included but isn't - const _record = selections.length ? pick(selections, record._raw) : record - mutableMatchingRecords.push(_record) + mutableMatchingRecords.push(record) shouldEmit = true } }) @@ -89,12 +86,7 @@ export default function subscribeToSimpleQuery( unsubscribe = query.collection.experimentalSubscribe(function observeQueryCollectionChanged( changeSet, ): void { - const selections = pipe( - pluck('columns'), - flatten, - uniq, - )(query.description.select) - const shouldEmit = processChangeSet(changeSet, selections, matcher, matchingRecords) + const shouldEmit = processChangeSet(changeSet, matcher, matchingRecords) if (shouldEmit || alwaysEmit) { emitCopy() } From 47102f0355100e42260a6ef4792e451a2159a4b4 Mon Sep 17 00:00:00 2001 From: Aziz Khambati Date: Wed, 29 Jan 2020 22:51:57 +0530 Subject: [PATCH 04/17] Fix a few types --- src/Collection/index.js | 26 ++++++++++--------- src/Query/index.js | 6 +++-- src/QueryDescription/index.js | 15 +++++++---- src/adapters/lokijs/worker/executeQuery.js | 2 +- src/adapters/sqlite/encodeQuery/index.js | 12 +++------ .../subscribeToQueryWithSelect/index.js | 16 +++++------- 6 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/Collection/index.js b/src/Collection/index.js index fb4bc8216..008f4088e 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -115,21 +115,23 @@ export default class Collection { // See: Query.fetch _fetchQuery(query: Query, callback: ResultCallback): void { - this.database.adapter.underlyingAdapter.query(query.serialize(), result => { - callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)) - }) + this.database.adapter.underlyingAdapter.query(query.serialize(), result => + callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)), + ) } - _fetchQuerySelect(query: Query, callback: ResultCallback[]>): void { + _fetchQuerySelect(query: Query, callback: ResultCallback): void { this.database.adapter.underlyingAdapter.query(query.serialize(), result => { - callback(mapValue(rawRecords => { - return rawRecords.map(rawRecordOrId => { - if (typeof rawRecordOrId === 'string') { - return this._cache._cachedModelForId(rawRecordOrId)._raw - } - return rawRecordOrId - }) - }, result)) + callback( + mapValue(rawRecords => { + return rawRecords.map(rawRecordOrId => { + if (typeof rawRecordOrId === 'string') { + return this._cache._cachedModelForId(rawRecordOrId)._raw + } + return rawRecordOrId + }) + }, result), + ) }) } diff --git a/src/Query/index.js b/src/Query/index.js index a6c35cdee..5d03b23a2 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -19,6 +19,8 @@ import type { Condition, QueryDescription } from '../QueryDescription' import type Model, { AssociationInfo } from '../Model' import type Collection from '../Collection' import type { TableName, ColumnName } from '../Schema' +import { columnName } from '../Schema' +import { type RawRecord } from '../RawRecord' import { getSecondaryTables, getAssociations } from './helpers' @@ -102,12 +104,12 @@ export default class Query { // Same as `observeWithColumns(columnNames)` but emits raw records with only the // selected `columnNames` (and `id` property added implicitly). // Note: This is an experimental feature and this API might change in future versions. - experimentalObserveColumns(columnNames: columnName[]): Observable { + experimentalObserveColumns(columnNames: ColumnName[]): Observable { const queryWithSelect = this.extend(experimentalSelect(columnNames)) return Observable.create(observer => subscribeToQueryWithSelect(queryWithSelect, records => { observer.next(records) - }) + }), ) } diff --git a/src/QueryDescription/index.js b/src/QueryDescription/index.js index 3ba59583f..f1fed3370 100644 --- a/src/QueryDescription/index.js +++ b/src/QueryDescription/index.js @@ -193,8 +193,8 @@ function _valueOrComparison(arg: Value | Comparison): Comparison { // Do not use this directly. Select columns using `experimentalObserveColumns` only. export function experimentalSelect(columns: ColumnName[]): Select { const _columns = columns.slice(0) - if(!columns.includes('id')) { - _columns.unshift('id') + if (!columns.includes('id')) { + _columns.unshift(columnName('id')) } return { type: 'select', columns: _columns } } @@ -245,8 +245,9 @@ export const on: OnFunction = (table, leftOrWhereDescription, valueOrComparison) const syncStatusColumn = columnName('_status') const whereNotDeleted = where(syncStatusColumn, notEq('deleted')) -const getGroupedConditions = groupBy( - condition => ['select', 'on'].includes(condition.type) ? condition.type : 'where' +// $FlowFixMe +const getGroupedConditions: (Condition[]) => QueryDescription = groupBy(condition => + ['select', 'on'].includes(condition.type) ? condition.type : 'where', ) const joinsWithoutDeleted = pipe( map(prop('table')), @@ -255,7 +256,11 @@ const joinsWithoutDeleted = pipe( ) export function buildQueryDescription(conditions: Condition[]): QueryDescription { - const {select: selections = [], on: join = [], where: whereConditions = [] }: QueryDescription = getGroupedConditions(conditions) + const { + select: selections = [], + on: join = [], + where: whereConditions = [], + }: QueryDescription = getGroupedConditions(conditions) const query = { select: selections, join, where: whereConditions } if (process.env.NODE_ENV !== 'production') { diff --git a/src/adapters/lokijs/worker/executeQuery.js b/src/adapters/lokijs/worker/executeQuery.js index e96b28963..cf6fccce4 100644 --- a/src/adapters/lokijs/worker/executeQuery.js +++ b/src/adapters/lokijs/worker/executeQuery.js @@ -17,7 +17,7 @@ function refineResultsForColumnComparisons( ): LokiResultset { if (hasColumnComparisons(conditions)) { // ignore JOINs (already checked and encodeMatcher can't check it) - const queryWithoutJoins = { where: conditions, join: [] } + const queryWithoutJoins = { where: conditions, join: [], select: [] } const matcher = encodeMatcher(queryWithoutJoins) return roughResults.where(matcher) diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index 50879fb30..2aee3ca2b 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -1,7 +1,7 @@ // @flow /* eslint-disable no-use-before-define */ -import {pipe, pluck, flatten, uniq} from 'rambdax' +import { uniq } from 'rambdax' import type { SerializedQuery, AssociationArgs } from '../../../Query' import type { NonNullValues, @@ -134,7 +134,7 @@ const encodeMethod = ( } const getSelectionQueryString = () => { - if(!selections.length) { + if (!selections.length) { return `${encodeName(table)}.*` } return selections.map(column => `${encodeName(table)}.${encodeName(column)}`).join(', ') @@ -166,12 +166,8 @@ const encodeQuery = (query: SerializedQuery, countMode: boolean = false): string const hasJoins = !!query.description.join.length const associations = hasJoins ? query.associations : [] const hasSelections = !!query.description.select.length - const selections = hasSelections - ? pipe( - pluck('columns'), - flatten, - uniq, - )(query.description.select) + const selections: ColumnName[] = hasSelections + ? uniq([].concat(...query.description.select.map(s => s.columns))) : [] const hasToManyJoins = associations.some(([, association]) => association.type === 'has_many') diff --git a/src/observation/subscribeToQueryWithSelect/index.js b/src/observation/subscribeToQueryWithSelect/index.js index cf995c1c9..aec37d50a 100644 --- a/src/observation/subscribeToQueryWithSelect/index.js +++ b/src/observation/subscribeToQueryWithSelect/index.js @@ -1,6 +1,6 @@ // @flow -import {pipe, pickAll, propEq, uniq, flatten, pluck} from 'rambdax' +import { pipe, pick, propEq, uniq, flatten, pluck } from 'rambdax' import { invariant, logError } from '../../utils/common' import { type Unsubscribe } from '../../utils/subscriptions' @@ -12,11 +12,12 @@ import type Model from '../../Model' import { type RawRecord } from '../../RawRecord' import encodeMatcher, { type Matcher } from '../encodeMatcher' +import { type ColumnName } from '../../Schema' // WARN: Mutates arguments export function processChangeSet( changeSet: CollectionChangeSet, - sanitizeRaw: (RawRecord[]) => RawRecord[], + sanitizeRaw: RawRecord => RawRecord, matcher: Matcher, mutableMatchingRecords: RawRecord[], ): boolean { @@ -53,17 +54,14 @@ export function processChangeSet( export default function subscribeToQueryWithSelect( query: Query, - subscriber: (RawRecord[]) => void + subscriber: (RawRecord[]) => void, ): Unsubscribe { invariant(!query.hasJoins, 'subscribeToQueryWithSelect only supports simple queries!') const matcher: Matcher = encodeMatcher(query.description) - const columnNames: ColumnName[] = pipe( - pluck('columns'), - flatten, - uniq, - )(query.description.select) - const sanitizeRaw = pickAll(columnNames) + const columnNames: ColumnName[] = uniq([].concat(...query.description.select.map(s => s.columns))) + // $FlowFixMe + const sanitizeRaw: RawRecord => RawRecord = pick(columnNames.map(c => c.toString())) let unsubscribed = false let unsubscribe = null From be79a10b9e6bd6b4bf7658fb646e46fd7a8fc6f5 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 09:28:27 +0530 Subject: [PATCH 05/17] refactor: rename fetchSelected to experimentalFetchColumns --- src/Query/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Query/index.js b/src/Query/index.js index a6c35cdee..e5f94e5a4 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -19,6 +19,7 @@ import type { Condition, QueryDescription } from '../QueryDescription' import type Model, { AssociationInfo } from '../Model' import type Collection from '../Collection' import type { TableName, ColumnName } from '../Schema' +import type { RawRecord } from '../RawRecord' import { getSecondaryTables, getAssociations } from './helpers' @@ -75,7 +76,7 @@ export default class Query { return toPromise(callback => this.collection._fetchQuery(this, callback)) } - fetchSelected(columnNames: ColumnName[]): Promise { + experimentalFetchColumns(columnNames: ColumnName[]): Promise { const queryWithSelect = this.extend(experimentalSelect(columnNames)) return toPromise(callback => this.collection._fetchQuerySelect(queryWithSelect, callback)) } @@ -102,7 +103,7 @@ export default class Query { // Same as `observeWithColumns(columnNames)` but emits raw records with only the // selected `columnNames` (and `id` property added implicitly). // Note: This is an experimental feature and this API might change in future versions. - experimentalObserveColumns(columnNames: columnName[]): Observable { + experimentalObserveColumns(columnNames: ColumnName[]): Observable { const queryWithSelect = this.extend(experimentalSelect(columnNames)) return Observable.create(observer => subscribeToQueryWithSelect(queryWithSelect, records => { From 57a8a9b62f8cf671f7c6d9ced7a66a730da2f3c2 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 09:30:05 +0530 Subject: [PATCH 06/17] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c67b92d..55a58d2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. - [Database] Added experimental `database.experimentalSubscribe(['table1', 'table2'], () => { ... })` method as a vanilla JS alternative to Rx-based `database.withChangesForTables()`. Unlike the latter, `experimentalSubscribe` notifies the subscriber only once after a batch that makes a change in multiple collections subscribed to. It also doesn't notify the subscriber immediately upon subscription, and doesn't send details about the changes, only a signal. - Added `experimentalDisableObserveCountThrottling()` to `@nozbe/watermelondb/observation/observeCount` that globally disables count observation throttling. We think that throttling on WatermelonDB level is not a good feature and will be removed in a future release - and will be better implemented on app level if necessary - [Query] Added experimental `query.experimentalSubscribe(records => { ... })`, `query.experimentalSubscribeWithColumns(['col1', 'col2'], records => { ... })`, and `query.experimentalSubscribeToCount(count => { ... })` methods +- [Query] Added experimental `query.experimentalFetchColumns(['col1', 'col2'])` and `query.experimentalObserveColumns(['col1', 'col2'])` and methods to fetch and observe on raw records with only the selected columns. ### Changes From 07cd83af2caa3a59c8397dcb63e0c59dccda2d28 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 12:31:17 +0530 Subject: [PATCH 07/17] refactor: flow fixes --- src/Collection/index.js | 2 +- src/QueryDescription/index.js | 5 ++--- src/adapters/sqlite/encodeQuery/index.js | 8 ++++++-- src/observation/subscribeToQueryWithSelect/index.js | 12 +++++++----- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Collection/index.js b/src/Collection/index.js index 008f4088e..ced49461d 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -6,7 +6,7 @@ import { defer } from 'rxjs/observable/defer' import { switchMap } from 'rxjs/operators' import invariant from '../utils/common/invariant' import noop from '../utils/fp/noop' -import { type ResultCallback, type Result, toPromise, mapValue } from '../utils/fp/Result' +import { type ResultCallback, toPromise, mapValue } from '../utils/fp/Result' import { type Unsubscribe } from '../utils/subscriptions' import Query from '../Query' diff --git a/src/QueryDescription/index.js b/src/QueryDescription/index.js index f1fed3370..ab4ff1ac7 100644 --- a/src/QueryDescription/index.js +++ b/src/QueryDescription/index.js @@ -245,8 +245,7 @@ export const on: OnFunction = (table, leftOrWhereDescription, valueOrComparison) const syncStatusColumn = columnName('_status') const whereNotDeleted = where(syncStatusColumn, notEq('deleted')) -// $FlowFixMe -const getGroupedConditions: (Condition[]) => QueryDescription = groupBy(condition => +const getGroupedConditions = groupBy(condition => ['select', 'on'].includes(condition.type) ? condition.type : 'where', ) const joinsWithoutDeleted = pipe( @@ -260,7 +259,7 @@ export function buildQueryDescription(conditions: Condition[]): QueryDescription select: selections = [], on: join = [], where: whereConditions = [], - }: QueryDescription = getGroupedConditions(conditions) + } = getGroupedConditions(conditions) const query = { select: selections, join, where: whereConditions } if (process.env.NODE_ENV !== 'production') { diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index 2aee3ca2b..27d119dea 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -1,7 +1,7 @@ // @flow /* eslint-disable no-use-before-define */ -import { uniq } from 'rambdax' +import { pipe, pluck, flatten, uniq } from 'rambdax' import type { SerializedQuery, AssociationArgs } from '../../../Query' import type { NonNullValues, @@ -167,7 +167,11 @@ const encodeQuery = (query: SerializedQuery, countMode: boolean = false): string const associations = hasJoins ? query.associations : [] const hasSelections = !!query.description.select.length const selections: ColumnName[] = hasSelections - ? uniq([].concat(...query.description.select.map(s => s.columns))) + ? (pipe( + pluck('columns'), + flatten, + uniq + ): any)(query.description.select) : [] const hasToManyJoins = associations.some(([, association]) => association.type === 'has_many') diff --git a/src/observation/subscribeToQueryWithSelect/index.js b/src/observation/subscribeToQueryWithSelect/index.js index aec37d50a..159054941 100644 --- a/src/observation/subscribeToQueryWithSelect/index.js +++ b/src/observation/subscribeToQueryWithSelect/index.js @@ -1,6 +1,6 @@ // @flow -import { pipe, pick, propEq, uniq, flatten, pluck } from 'rambdax' +import { pipe, pickAll, propEq, uniq, flatten, pluck } from 'rambdax' import { invariant, logError } from '../../utils/common' import { type Unsubscribe } from '../../utils/subscriptions' @@ -12,7 +12,6 @@ import type Model from '../../Model' import { type RawRecord } from '../../RawRecord' import encodeMatcher, { type Matcher } from '../encodeMatcher' -import { type ColumnName } from '../../Schema' // WARN: Mutates arguments export function processChangeSet( @@ -59,9 +58,12 @@ export default function subscribeToQueryWithSelect( invariant(!query.hasJoins, 'subscribeToQueryWithSelect only supports simple queries!') const matcher: Matcher = encodeMatcher(query.description) - const columnNames: ColumnName[] = uniq([].concat(...query.description.select.map(s => s.columns))) - // $FlowFixMe - const sanitizeRaw: RawRecord => RawRecord = pick(columnNames.map(c => c.toString())) + const columnNames: string[] = (pipe( + pluck('columns'), + flatten, + uniq + ): any)(query.description.select) + const sanitizeRaw: RawRecord => RawRecord = (pickAll(columnNames): any) let unsubscribed = false let unsubscribe = null From 776c93d124ff9817df68323a37be67e612f4f912 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 12:32:51 +0530 Subject: [PATCH 08/17] add api to typescript declations --- src/Query/index.d.ts | 6 +++++- src/QueryDescription/index.d.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Query/index.d.ts b/src/Query/index.d.ts index d72731c67..6a13ec3b2 100644 --- a/src/Query/index.d.ts +++ b/src/Query/index.d.ts @@ -1,5 +1,5 @@ declare module '@nozbe/watermelondb/Query' { - import { Collection, ColumnName, Model, TableName } from '@nozbe/watermelondb' + import { Collection, ColumnName, Model, TableName, RawRecord } from '@nozbe/watermelondb' import { AssociationInfo } from '@nozbe/watermelondb/Model' import { Condition, QueryDescription } from '@nozbe/watermelondb/QueryDescription' import { Observable } from 'rxjs' @@ -22,10 +22,14 @@ declare module '@nozbe/watermelondb/Query' { public fetch(): Promise + public experimentalFetchColumns(rawFields: ColumnName[]): Promise + public observe(): Observable public observeWithColumns(rawFields: ColumnName[]): Observable + public experimentalObserveColumns(rawFields: ColumnName[]): Observable + public fetchCount(): Promise public observeCount(isThrottled?: boolean): Observable diff --git a/src/QueryDescription/index.d.ts b/src/QueryDescription/index.d.ts index 8cf1dd286..6a18b689b 100644 --- a/src/QueryDescription/index.d.ts +++ b/src/QueryDescription/index.d.ts @@ -49,8 +49,13 @@ declare module '@nozbe/watermelondb/QueryDescription' { left: ColumnName comparison: Comparison } - export type Condition = Where | On + export interface Select { + type: 'select' + columns: ColumnName[] + } + export type Condition = Select | Where | On export interface QueryDescription { + select: Select[] where: Where[] join: On[] } From cbcfc96bfe07b548da5d9e244a9f4f6f3754bbeb Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 17:36:14 +0530 Subject: [PATCH 09/17] refacotor API and code cleanup --- src/Collection/index.js | 9 +++-- src/Query/index.js | 16 +++++--- src/QueryDescription/index.js | 10 ++++- src/RawRecord/index.js | 19 ++++++++++ src/adapters/sqlite/encodeQuery/index.js | 6 +-- .../subscribeToQueryWithColumns/index.js | 19 ++-------- .../subscribeToQueryWithSelect/index.js | 38 ++++++++++++------- 7 files changed, 72 insertions(+), 45 deletions(-) diff --git a/src/Collection/index.js b/src/Collection/index.js index ced49461d..5fb7e3318 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -14,7 +14,7 @@ import type Database from '../Database' import type Model, { RecordId } from '../Model' import type { Condition } from '../QueryDescription' import { type TableName, type TableSchema } from '../Schema' -import { type DirtyRaw, type RawRecord } from '../RawRecord' +import { type DirtyRaw, type RecordState } from '../RawRecord' import RecordCache from './RecordCache' import { CollectionChangeTypes } from './common' @@ -120,15 +120,16 @@ export default class Collection { ) } - _fetchQuerySelect(query: Query, callback: ResultCallback): void { + _fetchQuerySelect(query: Query, callback: ResultCallback): void { this.database.adapter.underlyingAdapter.query(query.serialize(), result => { callback( mapValue(rawRecords => { return rawRecords.map(rawRecordOrId => { if (typeof rawRecordOrId === 'string') { - return this._cache._cachedModelForId(rawRecordOrId)._raw + const rawRecord = this._cache._cachedModelForId(rawRecordOrId)._raw + return query._getRecordState(rawRecord) } - return rawRecordOrId + return query._getRecordState(rawRecordOrId) }) }, result), ) diff --git a/src/Query/index.js b/src/Query/index.js index 3634ecee0..b4d9edb9f 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -14,14 +14,15 @@ import subscribeToCount from '../observation/subscribeToCount' import subscribeToQuery from '../observation/subscribeToQuery' import subscribeToQueryWithColumns from '../observation/subscribeToQueryWithColumns' import subscribeToQueryWithSelect from '../observation/subscribeToQueryWithSelect' -import { experimentalSelect, buildQueryDescription, queryWithoutDeleted } from '../QueryDescription' +import { experimentalSelect, buildQueryDescription, queryWithoutDeleted, getSelectedColumns } from '../QueryDescription' import type { Condition, QueryDescription } from '../QueryDescription' import type Model, { AssociationInfo } from '../Model' import type Collection from '../Collection' import type { TableName, ColumnName } from '../Schema' -import type { RawRecord } from '../RawRecord' +import type { RecordState } from '../RawRecord' import { getSecondaryTables, getAssociations } from './helpers' +import { getRecordState, type RawRecord } from '../RawRecord' export type AssociationArgs = [TableName, AssociationInfo] export type SerializedQuery = $Exact<{ @@ -76,12 +77,12 @@ export default class Query { return toPromise(callback => this.collection._fetchQuery(this, callback)) } - experimentalFetchColumns(columnNames: ColumnName[]): Promise { + experimentalFetchColumns(columnNames: ColumnName[]): Promise { const queryWithSelect = this.extend(experimentalSelect(columnNames)) return toPromise(callback => this.collection._fetchQuerySelect(queryWithSelect, callback)) } - // Emits an array of matching records, then emits a new array every time it changes + // Emits an array of matching records, then emits a new array every time it changess observe(): Observable { return Observable.create(observer => this._cachedSubscribable.subscribe(records => { @@ -103,7 +104,7 @@ export default class Query { // Same as `observeWithColumns(columnNames)` but emits raw records with only the // selected `columnNames` (and `id` property added implicitly). // Note: This is an experimental feature and this API might change in future versions. - experimentalObserveColumns(columnNames: ColumnName[]): Observable { + experimentalObserveColumns(columnNames: ColumnName[]): Observable { const queryWithSelect = this.extend(experimentalSelect(columnNames)) return Observable.create(observer => subscribeToQueryWithSelect(queryWithSelect, records => { @@ -145,6 +146,11 @@ export default class Query { return this._cachedCountSubscribable.subscribe(subscriber) } + _getRecordState(rawRecord: RawRecord): RecordState { + const columns = getSelectedColumns(this.description) + return getRecordState(rawRecord, columns) + } + // Marks as deleted all records matching the query async markAllAsDeleted(): Promise { const records = await this.fetch() diff --git a/src/QueryDescription/index.js b/src/QueryDescription/index.js index ab4ff1ac7..ea74a6757 100644 --- a/src/QueryDescription/index.js +++ b/src/QueryDescription/index.js @@ -1,6 +1,6 @@ // @flow -import { pipe, prop, uniq, map, groupBy } from 'rambdax' +import { pipe, prop, pluck, uniq, map, groupBy, flatten } from 'rambdax' // don't import whole `utils` to keep worker size small import invariant from '../utils/common/invariant' @@ -317,3 +317,11 @@ const searchForColumnComparisons: any => boolean = value => { export function hasColumnComparisons(conditions: Where[]): boolean { return searchForColumnComparisons(conditions) } + +export function getSelectedColumns(query: QueryDescription): ColumnName[] { + return (pipe( + pluck('columns'), + flatten, + uniq + ): any)(query.select) +} diff --git a/src/RawRecord/index.js b/src/RawRecord/index.js index 6674a3918..72456b832 100644 --- a/src/RawRecord/index.js +++ b/src/RawRecord/index.js @@ -2,10 +2,13 @@ /* eslint-disable no-lonely-if */ /* eslint-disable no-self-compare */ +import {pickAll, values} from 'rambdax' import { type ColumnName, type ColumnSchema, type TableSchema } from '../Schema' import { type RecordId, type SyncStatus } from '../Model' +import { type Value } from '../QueryDescription' import randomId from '../utils/common/randomId' +import identicalArrays from '../utils/fp/identicalArrays' // Raw object representing a model record, coming from an untrusted source // (disk, sync, user data). Before it can be used to create a Model instance @@ -26,6 +29,11 @@ type _RawRecord = { // - … and the same optionality (will not be null unless isOptional: true) export opaque type RawRecord: _RawRecord = _RawRecord +export type RecordState = { + id: RecordId, + [field: ColumnName]: Value, +} + // a number, but not NaN (NaN !== NaN) or Infinity function isValidNumber(value: any): boolean { return typeof value === 'number' && value === value && value !== Infinity && value !== -Infinity @@ -124,3 +132,14 @@ export function nullValue(columnSchema: ColumnSchema): NullValue { throw new Error(`Unknown type for column schema ${JSON.stringify(columnSchema)}`) } + +export function getRecordState(rawRecord: RawRecord, columnNames: ColumnName[]): RecordState { + // `pickAll` guarantees same length and order of keys! + // $FlowFixMe + return pickAll(columnNames, rawRecord) +} + +// Invariant: same length and order of keys! +export function recordStatesEqual(left: RecordState, right: RecordState): boolean { + return identicalArrays(values(left), values(right)) +} diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index 27d119dea..58dd7f53b 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -167,11 +167,7 @@ const encodeQuery = (query: SerializedQuery, countMode: boolean = false): string const associations = hasJoins ? query.associations : [] const hasSelections = !!query.description.select.length const selections: ColumnName[] = hasSelections - ? (pipe( - pluck('columns'), - flatten, - uniq - ): any)(query.description.select) + ? Q.getSelectedColumns(query.description) : [] const hasToManyJoins = associations.some(([, association]) => association.type === 'has_many') diff --git a/src/observation/subscribeToQueryWithColumns/index.js b/src/observation/subscribeToQueryWithColumns/index.js index 8c7b53d75..b686f6b3a 100644 --- a/src/observation/subscribeToQueryWithColumns/index.js +++ b/src/observation/subscribeToQueryWithColumns/index.js @@ -1,31 +1,18 @@ // @flow -import { pickAll, values } from 'rambdax' - import identicalArrays from '../../utils/fp/identicalArrays' import arrayDifference from '../../utils/fp/arrayDifference' import { type Unsubscribe } from '../../utils/subscriptions' -import { type Value } from '../../QueryDescription' import { type ColumnName } from '../../Schema' import type Query from '../../Query' import type { CollectionChangeSet } from '../../Collection' import type Model, { RecordId } from '../../Model' +import { type RecordState, getRecordState, recordStatesEqual } from '../../RawRecord' import subscribeToSimpleQuery from '../subscribeToSimpleQuery' import subscribeToQueryReloading from '../subscribeToQueryReloading' -type RecordState = { [field: ColumnName]: Value } - -const getRecordState: (Model, ColumnName[]) => RecordState = (record, columnNames) => - // `pickAll` guarantees same length and order of keys! - // $FlowFixMe - pickAll(columnNames, record._raw) - -// Invariant: same length and order of keys! -const recordStatesEqual = (left: RecordState, right: RecordState): boolean => - identicalArrays(values(left), values(right)) - // Observes the given observable list of records, and in those records, // changes to given `rawFields` // @@ -94,7 +81,7 @@ export default function subscribeToQueryWithColumns( } // Check if record changed one of its observed fields - const newState = getRecordState(record, columnNames) + const newState = getRecordState(record._raw, columnNames) if (!recordStatesEqual(previousState, newState)) { recordStates.set(record.id, newState) hasColumnChanges = true @@ -141,7 +128,7 @@ export default function subscribeToQueryWithColumns( // Save current record state for later comparison added.forEach(newRecord => { - recordStates.set(newRecord.id, getRecordState(newRecord, columnNames)) + recordStates.set(newRecord.id, getRecordState(newRecord._raw, columnNames)) }) // Emit diff --git a/src/observation/subscribeToQueryWithSelect/index.js b/src/observation/subscribeToQueryWithSelect/index.js index 159054941..f3ef548cc 100644 --- a/src/observation/subscribeToQueryWithSelect/index.js +++ b/src/observation/subscribeToQueryWithSelect/index.js @@ -1,6 +1,6 @@ // @flow -import { pipe, pickAll, propEq, uniq, flatten, pluck } from 'rambdax' +import { propEq } from 'rambdax' import { invariant, logError } from '../../utils/common' import { type Unsubscribe } from '../../utils/subscriptions' @@ -9,16 +9,18 @@ import { CollectionChangeTypes } from '../../Collection/common' import type Query from '../../Query' import type Model from '../../Model' -import { type RawRecord } from '../../RawRecord' +import { type RecordState, getRecordState, recordStatesEqual } from '../../RawRecord' +import { type ColumnName } from '../../Schema' import encodeMatcher, { type Matcher } from '../encodeMatcher' +import { getSelectedColumns } from '../../QueryDescription' // WARN: Mutates arguments export function processChangeSet( changeSet: CollectionChangeSet, - sanitizeRaw: RawRecord => RawRecord, + columnNames: ColumnName[], matcher: Matcher, - mutableMatchingRecords: RawRecord[], + mutableMatchingRecords: RecordState[], ): boolean { let shouldEmit = false changeSet.forEach(change => { @@ -35,6 +37,18 @@ export function processChangeSet( return } + if(type === CollectionChangeTypes.updated) { + if(currentlyMatching) { + const prevState = mutableMatchingRecords[index] + const newState = getRecordState(record._raw, columnNames) + if(!recordStatesEqual(prevState, newState)) { + mutableMatchingRecords[index] = newState + shouldEmit = true + return + } + } + } + const matches = matcher(record._raw) if (currentlyMatching && !matches) { @@ -43,7 +57,7 @@ export function processChangeSet( shouldEmit = true } else if (matches && !currentlyMatching) { // Add if should be included but isn't - const _record = sanitizeRaw(record._raw) + const _record = getRecordState(record._raw, columnNames) mutableMatchingRecords.push(_record) shouldEmit = true } @@ -53,20 +67,16 @@ export function processChangeSet( export default function subscribeToQueryWithSelect( query: Query, - subscriber: (RawRecord[]) => void, + subscriber: (RecordState[]) => void, ): Unsubscribe { invariant(!query.hasJoins, 'subscribeToQueryWithSelect only supports simple queries!') const matcher: Matcher = encodeMatcher(query.description) - const columnNames: string[] = (pipe( - pluck('columns'), - flatten, - uniq - ): any)(query.description.select) - const sanitizeRaw: RawRecord => RawRecord = (pickAll(columnNames): any) let unsubscribed = false let unsubscribe = null + const columnNames = getSelectedColumns(query.description) + query.collection._fetchQuerySelect(query, function observeQueryInitialEmission(result): void { if (unsubscribed) { return @@ -80,7 +90,7 @@ export default function subscribeToQueryWithSelect( const initialRecords = result.value // Send initial matching records - const matchingRecords: RawRecord[] = initialRecords.map(sanitizeRaw) + const matchingRecords: RecordState[] = initialRecords const emitCopy = () => subscriber(matchingRecords.slice(0)) emitCopy() @@ -93,7 +103,7 @@ export default function subscribeToQueryWithSelect( unsubscribe = query.collection.experimentalSubscribe(function observeQueryCollectionChanged( changeSet, ): void { - const shouldEmit = processChangeSet(changeSet, sanitizeRaw, matcher, matchingRecords) + const shouldEmit = processChangeSet(changeSet, columnNames, matcher, matchingRecords) if (shouldEmit) { emitCopy() } From 266c7faae0d4990e0a1a86bab7f406c9498118a1 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 17:37:50 +0530 Subject: [PATCH 10/17] Add changelog entry and update docs to include new API --- CHANGELOG.md | 2 +- docs-master/Query.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a58d2c4..f167b1bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. - [Database] Added experimental `database.experimentalSubscribe(['table1', 'table2'], () => { ... })` method as a vanilla JS alternative to Rx-based `database.withChangesForTables()`. Unlike the latter, `experimentalSubscribe` notifies the subscriber only once after a batch that makes a change in multiple collections subscribed to. It also doesn't notify the subscriber immediately upon subscription, and doesn't send details about the changes, only a signal. - Added `experimentalDisableObserveCountThrottling()` to `@nozbe/watermelondb/observation/observeCount` that globally disables count observation throttling. We think that throttling on WatermelonDB level is not a good feature and will be removed in a future release - and will be better implemented on app level if necessary - [Query] Added experimental `query.experimentalSubscribe(records => { ... })`, `query.experimentalSubscribeWithColumns(['col1', 'col2'], records => { ... })`, and `query.experimentalSubscribeToCount(count => { ... })` methods -- [Query] Added experimental `query.experimentalFetchColumns(['col1', 'col2'])` and `query.experimentalObserveColumns(['col1', 'col2'])` and methods to fetch and observe on raw records with only the selected columns. +- [Query] Added experimental `query.experimentalFetchColumns(['col1', 'col2'])` and `query.experimentalObserveColumns(['col1', 'col2'])` and methods to fetch and observe on record states with only the selected columns. ### Changes diff --git a/docs-master/Query.md b/docs-master/Query.md index 1fbc275aa..a730c4312 100644 --- a/docs-master/Query.md +++ b/docs-master/Query.md @@ -74,6 +74,28 @@ const comments = await post.comments.fetch() const verifiedCommentCount = await post.verifiedComments.fetchCount() ``` +To fetch record states with selected columns only, use `experimentalFetchColumns(['col1', 'col2'])`. + +```js +const commentStates = await post.comments.experimentalFetchColumns(['created_at', 'is_verified']) +/** + * commentStates = [{ + * id: "abcd", + * created_at: 1580384390117, + * is_verified: true + * }, + * { + * id: "efgh", + * created_at: 1580388020008, + * is_verified: true + * }] + **/ +const sortedComments = commentStates.sort((comment1, comment2) => comment1.created_at - comment2.created_at) +const isLastCommentVerified = !!sortedComments.length && sortedComments[0].is_verified +``` + +This will query **all** comments of the post and fetch comment states with only `id`, `created_at` and `is_verified` columns. + ## Query conditions ```js @@ -168,6 +190,26 @@ The first argument for `Q.on` is the table name you're making a condition on. Th Call `query.observeWithColumns(['foo', 'bar'])` to create an Observable that emits a value not only when the list of matching records changes (new records/deleted records), but also when any of the matched records changes its `foo` or `bar` column. [Use this for observing sorted lists](./Components.md) +### Observing on columns + +Call `query.experimentalObserveColumns(['col1', 'col2'])` to create an Observable that emits matching records' states with only the selected columns populated (`id` is included implicitly). The observable will emit not only when a matching record is added or deleted but also when any of the matched records changes its observed columns (any of 'col1' or 'col2' in this case) + +This is same as + +```js +query.observe().pipe( + mergeMap(records => + records.map( + record => ({ + id: record._raw.id, + col1: record._raw.col1, + col2: record._raw.col2 + }) + ) + ) +) +``` + #### Count throttling By default, calling `query.observeCount()` returns an Observable that is throttled to emit at most once every 250ms. You can disable throttling using `query.observeCount(false)`. From ab7231376a601cef00995fbb457b788b1a910e4b Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 17:39:25 +0530 Subject: [PATCH 11/17] fix: minor change in doc --- docs-master/Query.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-master/Query.md b/docs-master/Query.md index a730c4312..aabbb2b3f 100644 --- a/docs-master/Query.md +++ b/docs-master/Query.md @@ -198,7 +198,7 @@ This is same as ```js query.observe().pipe( - mergeMap(records => + map(records => records.map( record => ({ id: record._raw.id, From 97460e41610c5acdb002a851d8a9f1351861de06 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 17:56:56 +0530 Subject: [PATCH 12/17] fix: typescript declarations --- src/Query/index.d.ts | 6 +++--- src/RawRecord/index.d.ts | 5 +++++ src/index.d.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Query/index.d.ts b/src/Query/index.d.ts index 6a13ec3b2..568d0993d 100644 --- a/src/Query/index.d.ts +++ b/src/Query/index.d.ts @@ -1,5 +1,5 @@ declare module '@nozbe/watermelondb/Query' { - import { Collection, ColumnName, Model, TableName, RawRecord } from '@nozbe/watermelondb' + import { Collection, ColumnName, Model, TableName, RecordState } from '@nozbe/watermelondb' import { AssociationInfo } from '@nozbe/watermelondb/Model' import { Condition, QueryDescription } from '@nozbe/watermelondb/QueryDescription' import { Observable } from 'rxjs' @@ -22,13 +22,13 @@ declare module '@nozbe/watermelondb/Query' { public fetch(): Promise - public experimentalFetchColumns(rawFields: ColumnName[]): Promise + public experimentalFetchColumns(rawFields: ColumnName[]): Promise public observe(): Observable public observeWithColumns(rawFields: ColumnName[]): Observable - public experimentalObserveColumns(rawFields: ColumnName[]): Observable + public experimentalObserveColumns(rawFields: ColumnName[]): Observable public fetchCount(): Promise diff --git a/src/RawRecord/index.d.ts b/src/RawRecord/index.d.ts index a7f21d557..7aa9f4b38 100644 --- a/src/RawRecord/index.d.ts +++ b/src/RawRecord/index.d.ts @@ -11,6 +11,11 @@ declare module '@nozbe/watermelondb/RawRecord' { last_modified: number | null } + export interface RecordState { + id: string + [k: string]: any + } + export function sanitizedRaw(dirtyRaw: DirtyRaw, tableSchema: TableSchema): RawRecord export function setRawSanitized( diff --git a/src/index.d.ts b/src/index.d.ts index 6b29751ea..74a67c450 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -11,7 +11,7 @@ declare module '@nozbe/watermelondb' { export { tableName, columnName, appSchema, tableSchema } from '@nozbe/watermelondb/Schema' export { DatabaseAdapter } from '@nozbe/watermelondb/adapters/type' - export { RawRecord, DirtyRaw } from '@nozbe/watermelondb/RawRecord' + export { RawRecord, DirtyRaw, RecordState } from '@nozbe/watermelondb/RawRecord' export { RecordId } from '@nozbe/watermelondb/Model' export { TableName, From 7eaabf017a3d2c10294018a15878f47c0608d2f1 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Thu, 30 Jan 2020 20:44:16 +0530 Subject: [PATCH 13/17] refactor: remove unused imports --- src/adapters/sqlite/encodeQuery/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/adapters/sqlite/encodeQuery/index.js b/src/adapters/sqlite/encodeQuery/index.js index 58dd7f53b..adafba15b 100644 --- a/src/adapters/sqlite/encodeQuery/index.js +++ b/src/adapters/sqlite/encodeQuery/index.js @@ -1,7 +1,6 @@ // @flow /* eslint-disable no-use-before-define */ -import { pipe, pluck, flatten, uniq } from 'rambdax' import type { SerializedQuery, AssociationArgs } from '../../../Query' import type { NonNullValues, From 8786d99fc8604aaf1dc0996b2a35dbe354fea319 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Fri, 31 Jan 2020 19:11:12 +0530 Subject: [PATCH 14/17] fix: typing of RecordState --- src/Query/index.d.ts | 4 ++-- src/RawRecord/index.d.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Query/index.d.ts b/src/Query/index.d.ts index 568d0993d..85597ac40 100644 --- a/src/Query/index.d.ts +++ b/src/Query/index.d.ts @@ -22,13 +22,13 @@ declare module '@nozbe/watermelondb/Query' { public fetch(): Promise - public experimentalFetchColumns(rawFields: ColumnName[]): Promise + public experimentalFetchColumns(rawFields: Array): Promise[]> public observe(): Observable public observeWithColumns(rawFields: ColumnName[]): Observable - public experimentalObserveColumns(rawFields: ColumnName[]): Observable + public experimentalObserveColumns(rawFields: Array): Observable[]> public fetchCount(): Promise diff --git a/src/RawRecord/index.d.ts b/src/RawRecord/index.d.ts index 7aa9f4b38..957796de8 100644 --- a/src/RawRecord/index.d.ts +++ b/src/RawRecord/index.d.ts @@ -11,10 +11,7 @@ declare module '@nozbe/watermelondb/RawRecord' { last_modified: number | null } - export interface RecordState { - id: string - [k: string]: any - } + export type RecordState = T & Record<"id", string> export function sanitizedRaw(dirtyRaw: DirtyRaw, tableSchema: TableSchema): RawRecord From 97e41d8930d22a96946f1754fe6d9e338856f6a4 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Sun, 2 Feb 2020 21:18:32 +0530 Subject: [PATCH 15/17] fix: bypass caching for select queries --- .../com/nozbe/watermelondb/DatabaseBridge.kt | 4 ++++ .../com/nozbe/watermelondb/DatabaseDriver.kt | 12 +++++++++++- native/ios/WatermelonDB/DatabaseBridge.m | 5 +++++ native/ios/WatermelonDB/DatabaseBridge.swift | 17 +++++++++++++++++ native/ios/WatermelonDB/DatabaseDriver.swift | 12 +++++++++++- src/Collection/RecordCache.js | 17 +++++++++++++++-- src/Collection/index.js | 19 +++++-------------- src/Collection/test.js | 10 +++++----- src/Query/index.js | 6 ++---- src/Query/test.js | 2 +- src/RawRecord/index.js | 4 ++-- src/adapters/__tests__/commonTests.js | 12 ++++++------ src/adapters/compat.js | 6 +++++- src/adapters/lokijs/common.js | 1 + src/adapters/lokijs/index.js | 6 ++++++ src/adapters/lokijs/worker/executor.js | 13 ++++++++++--- src/adapters/lokijs/worker/lokiWorker.js | 1 + src/adapters/sqlite/index.js | 14 ++++++++++++-- src/adapters/type.js | 3 +++ 19 files changed, 122 insertions(+), 42 deletions(-) diff --git a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseBridge.kt b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseBridge.kt index 35f9a3113..1b76452b8 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseBridge.kt +++ b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseBridge.kt @@ -117,6 +117,10 @@ class DatabaseBridge(private val reactContext: ReactApplicationContext) : @ReactMethod fun query(tag: ConnectionTag, table: TableName, query: SQL, promise: Promise) = + withDriver(tag, promise) { it.query(table, query) } + + @ReactMethod + fun cachedQuery(tag: ConnectionTag, table: TableName, query: SQL, promise: Promise) = withDriver(tag, promise) { it.cachedQuery(table, query) } @ReactMethod diff --git a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt index 42d95eb3b..48945f85c 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt +++ b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt @@ -64,8 +64,16 @@ class DatabaseDriver(context: Context, dbName: String) { } } + fun query(table: TableName, query: SQL): WritableArray { + return query(table, query, false) + } + fun cachedQuery(table: TableName, query: SQL): WritableArray { // log?.info("Cached Query: $query") + return query(table, query, true) + } + + private fun query(table: TableName, query: SQL, willCache: Boolean): WritableArray { val resultArray = Arguments.createArray() database.rawQuery(query).use { if (it.count > 0 && it.columnNames.contains("id")) { @@ -74,7 +82,9 @@ class DatabaseDriver(context: Context, dbName: String) { if (isCached(table, id)) { resultArray.pushString(id) } else { - markAsCached(table, id) + if(willCache) { + markAsCached(table, item.id) + } resultArray.pushMapFromCursor(it) } } diff --git a/native/ios/WatermelonDB/DatabaseBridge.m b/native/ios/WatermelonDB/DatabaseBridge.m index 2273aab57..20986b18f 100644 --- a/native/ios/WatermelonDB/DatabaseBridge.m +++ b/native/ios/WatermelonDB/DatabaseBridge.m @@ -41,6 +41,11 @@ @interface RCT_EXTERN_REMAP_MODULE(DatabaseBridge, DatabaseBridge, NSObject) query:(nonnull NSString *)query ) +WMELON_BRIDGE_METHOD(cachedQuery, + table:(nonnull NSString *)table + query:(nonnull NSString *)query +) + WMELON_BRIDGE_METHOD(count, query:(nonnull NSString *)query ) diff --git a/native/ios/WatermelonDB/DatabaseBridge.swift b/native/ios/WatermelonDB/DatabaseBridge.swift index 44718a5c3..0d40f23cb 100644 --- a/native/ios/WatermelonDB/DatabaseBridge.swift +++ b/native/ios/WatermelonDB/DatabaseBridge.swift @@ -155,6 +155,16 @@ extension DatabaseBridge { table: Database.TableName, query: Database.SQL, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + withDriver(tag, resolve, reject) { + try $0.query(table: table, query: query) + } + } + + @objc(cachedQuery:table:query:resolve:reject:) + func cachedQuery(tag: ConnectionTag, + table: Database.TableName, + query: Database.SQL, + resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { withDriver(tag, resolve, reject) { try $0.cachedQuery(table: table, query: query) } @@ -261,6 +271,13 @@ extension DatabaseBridge { @objc(querySynchronous:table:query:) func querySynchronous(tag: ConnectionTag, table: Database.TableName, query: Database.SQL) -> NSDictionary { + return withDriverSynchronous(tag) { + try $0.query(table: table, query: query) + } + } + + @objc(cachedQuerySynchronous:table:query:) + func cahedQuerySynchronous(tag: ConnectionTag, table: Database.TableName, query: Database.SQL) -> NSDictionary { return withDriverSynchronous(tag) { try $0.cachedQuery(table: table, query: query) } diff --git a/native/ios/WatermelonDB/DatabaseDriver.swift b/native/ios/WatermelonDB/DatabaseDriver.swift index fe00a03a0..75022eb9b 100644 --- a/native/ios/WatermelonDB/DatabaseDriver.swift +++ b/native/ios/WatermelonDB/DatabaseDriver.swift @@ -58,14 +58,24 @@ class DatabaseDriver { return record.resultDictionary! } + func query(table: Database.TableName, query: Database.SQL) throws -> [Any] { + return query(table, query, false) + } + func cachedQuery(table: Database.TableName, query: Database.SQL) throws -> [Any] { + return query(table, query, true) + } + + private func query(table: Database.TableName, query: Database.SQL, willCache: Bool) throws -> [Any] { return try database.queryRaw(query).map { row in let id = row.string(forColumn: "id")! if isCached(table, id) { return id } else { - markAsCached(table, id) + if willCache { + markAsCached(table, id) + } return row.resultDictionary! } } diff --git a/src/Collection/RecordCache.js b/src/Collection/RecordCache.js index 8da541d5e..fdd0b82b6 100644 --- a/src/Collection/RecordCache.js +++ b/src/Collection/RecordCache.js @@ -5,8 +5,9 @@ import invariant from '../utils/common/invariant' import type Model, { RecordId } from '../Model' import type { CachedQueryResult } from '../adapters/type' -import type { TableName } from '../Schema' -import type { RawRecord } from '../RawRecord' +import type { TableName, ColumnName } from '../Schema' +import type { RawRecord, RecordState } from '../RawRecord' +import { getRecordState } from '../RawRecord' type Instantiator = RawRecord => T @@ -50,6 +51,18 @@ export default class RecordCache { return this._modelForRaw(result) } + recordStatesFromQueryResult(result: CachedQueryResult, columns: ColumnName[]): RecordState[] { + return result.map(res => this.recordStateFromQueryResult(res, columns)) + } + + recordStateFromQueryResult(result: RecordId | RawRecord, columns: ColumnName[]): RecordState { + let rawRecord = result + if (typeof rawRecord === 'string') { + rawRecord = this._cachedModelForId(rawRecord)._raw + } + return getRecordState(rawRecord, columns) + } + _cachedModelForId(id: RecordId): Record { const record = this.map.get(id) diff --git a/src/Collection/index.js b/src/Collection/index.js index 5cea31775..5efd472dc 100644 --- a/src/Collection/index.js +++ b/src/Collection/index.js @@ -115,25 +115,16 @@ export default class Collection { // See: Query.fetch _fetchQuery(query: Query, callback: ResultCallback): void { - this.database.adapter.underlyingAdapter.query(query.serialize(), result => + this.database.adapter.underlyingAdapter.cachedQuery(query.serialize(), result => callback(mapValue(rawRecords => this._cache.recordsFromQueryResult(rawRecords), result)), ) } _fetchQuerySelect(query: Query, callback: ResultCallback): void { - this.database.adapter.underlyingAdapter.query(query.serialize(), result => { - callback( - mapValue(rawRecords => { - return rawRecords.map(rawRecordOrId => { - if (typeof rawRecordOrId === 'string') { - const rawRecord = this._cache._cachedModelForId(rawRecordOrId)._raw - return query._getRecordState(rawRecord) - } - return query._getRecordState(rawRecordOrId) - }) - }, result), - ) - }) + const columns = query.getSelectedColumns() + this.database.adapter.underlyingAdapter.query(query.serialize(), result => + callback(mapValue(rawRecords => this._cache.recordStatesFromQueryResult(rawRecords, columns), result)), + ) } // See: Query.fetchCount diff --git a/src/Collection/test.js b/src/Collection/test.js index 907a19c01..fcda17025 100644 --- a/src/Collection/test.js +++ b/src/Collection/test.js @@ -82,7 +82,7 @@ describe('fetching queries', () => { it('fetches queries and caches records', async () => { const { tasks: collection, adapter } = mockDatabase() - adapter.query = jest + adapter.cachedQuery = jest .fn() .mockImplementation((query, cb) => cb({ value: [{ id: 'm1' }, { id: 'm2' }] })) @@ -103,13 +103,13 @@ describe('fetching queries', () => { expect(collection._cache.map.get('m2')).toBe(models[1]) // check if query was passed correctly - expect(adapter.query.mock.calls.length).toBe(1) - expect(adapter.query.mock.calls[0][0]).toEqual(query.serialize()) + expect(adapter.cachedQuery.mock.calls.length).toBe(1) + expect(adapter.cachedQuery.mock.calls[0][0]).toEqual(query.serialize()) }) it('fetches query records from cache if possible', async () => { const { tasks: collection, adapter } = mockDatabase() - adapter.query = jest.fn().mockImplementation((query, cb) => cb({ value: ['m1', { id: 'm2' }] })) + adapter.cachedQuery = jest.fn().mockImplementation((query, cb) => cb({ value: ['m1', { id: 'm2' }] })) const m1 = new MockTask(collection, { id: 'm1' }) collection._cache.add(m1) @@ -126,7 +126,7 @@ describe('fetching queries', () => { it('fetches query records from cache even if full raw object was sent', async () => { const { tasks: collection, adapter } = mockDatabase() - adapter.query = jest + adapter.cachedQuery = jest .fn() .mockImplementation((query, cb) => cb({ value: [{ id: 'm1' }, { id: 'm2' }] })) diff --git a/src/Query/index.js b/src/Query/index.js index b4d9edb9f..bd6ee3422 100644 --- a/src/Query/index.js +++ b/src/Query/index.js @@ -22,7 +22,6 @@ import type { TableName, ColumnName } from '../Schema' import type { RecordState } from '../RawRecord' import { getSecondaryTables, getAssociations } from './helpers' -import { getRecordState, type RawRecord } from '../RawRecord' export type AssociationArgs = [TableName, AssociationInfo] export type SerializedQuery = $Exact<{ @@ -146,9 +145,8 @@ export default class Query { return this._cachedCountSubscribable.subscribe(subscriber) } - _getRecordState(rawRecord: RawRecord): RecordState { - const columns = getSelectedColumns(this.description) - return getRecordState(rawRecord, columns) + getSelectedColumns(): ColumnName[] { + return getSelectedColumns(this.description) } // Marks as deleted all records matching the query diff --git a/src/Query/test.js b/src/Query/test.js index a41f9e1a4..d3ad6fb0b 100644 --- a/src/Query/test.js +++ b/src/Query/test.js @@ -134,7 +134,7 @@ describe('Query observation', () => { } const testQueryObservation = async (makeSubscribe, withColumns) => { const { database, tasks } = mockDatabase({ actionsEnabled: true }) - const adapterSpy = jest.spyOn(database.adapter.underlyingAdapter, 'query') + const adapterSpy = jest.spyOn(database.adapter.underlyingAdapter, 'cachedQuery') const query = new Query(tasks, []) const observer = jest.fn() diff --git a/src/RawRecord/index.js b/src/RawRecord/index.js index 72456b832..66e2ade89 100644 --- a/src/RawRecord/index.js +++ b/src/RawRecord/index.js @@ -133,10 +133,10 @@ export function nullValue(columnSchema: ColumnSchema): NullValue { throw new Error(`Unknown type for column schema ${JSON.stringify(columnSchema)}`) } -export function getRecordState(rawRecord: RawRecord, columnNames: ColumnName[]): RecordState { +export function getRecordState(rawRecord: RawRecord, columns: ColumnName[]): RecordState { // `pickAll` guarantees same length and order of keys! // $FlowFixMe - return pickAll(columnNames, rawRecord) + return pickAll(columns, rawRecord) } // Invariant: same length and order of keys! diff --git a/src/adapters/__tests__/commonTests.js b/src/adapters/__tests__/commonTests.js index c54ce0cd1..31e8434fe 100644 --- a/src/adapters/__tests__/commonTests.js +++ b/src/adapters/__tests__/commonTests.js @@ -167,13 +167,13 @@ export default () => [ await adapter.batch([['create', 'tasks', s1]]) // returns empty array - expectSortedEqual(await adapter.query(projectQuery()), []) + expectSortedEqual(await adapter.cachedQuery(projectQuery()), []) const p1 = mockProjectRaw({ id: 'id1', num1: 1, text1: 'foo' }) await adapter.batch([['create', 'projects', p1]]) // returns cached ID after create - expectSortedEqual(await adapter.query(projectQuery()), ['id1']) + expectSortedEqual(await adapter.cachedQuery(projectQuery()), ['id1']) // add more project, restart app const p2 = mockProjectRaw({ id: 'id2', num1: 1, text1: 'foo' }) @@ -184,12 +184,12 @@ export default () => [ await adapter.batch([['create', 'tasks', s2]]) // returns cached IDs after create - expectSortedEqual(await adapter.query(taskQuery()), [s1, 'id2']) + expectSortedEqual(await adapter.cachedQuery(taskQuery()), [s1, 'id2']) // returns raw if not cached for a different table - expectSortedEqual(await adapter.query(projectQuery()), [p1, p2]) + expectSortedEqual(await adapter.cachedQuery(projectQuery()), [p1, p2]) // returns cached IDs after previous query - expectSortedEqual(await adapter.query(taskQuery()), ['id1', 'id2']) + expectSortedEqual(await adapter.cachedQuery(taskQuery()), ['id1', 'id2']) }, ], [ @@ -285,7 +285,7 @@ export default () => [ 'compacts query results', async _adapter => { let adapter = _adapter - const queryAll = () => adapter.query(taskQuery()) + const queryAll = () => adapter.cachedQuery(taskQuery()) // add records, restart app const s1 = mockTaskRaw({ id: 's1', order: 1 }) diff --git a/src/adapters/compat.js b/src/adapters/compat.js index e12b565ed..6d44fc6a5 100644 --- a/src/adapters/compat.js +++ b/src/adapters/compat.js @@ -23,7 +23,7 @@ export default class DatabaseAdapterCompat { const sqlAdapter: SQLDatabaseAdapter = (adapter: any) if (sqlAdapter.unsafeSqlQuery) { this.unsafeSqlQuery = (tableName, sql) => - toPromise(callback => sqlAdapter.unsafeSqlQuery(tableName, sql, callback)) + toPromise(callback => sqlAdapter.unsafeSqlQuery(tableName, sql, true, callback)) } } @@ -43,6 +43,10 @@ export default class DatabaseAdapterCompat { return toPromise(callback => this.underlyingAdapter.query(query, callback)) } + cachedQuery(query: SerializedQuery): Promise { + return toPromise(callback => this.underlyingAdapter.cachedQuery(query, callback)) + } + count(query: SerializedQuery): Promise { return toPromise(callback => this.underlyingAdapter.count(query, callback)) } diff --git a/src/adapters/lokijs/common.js b/src/adapters/lokijs/common.js index 81ff2f7a9..58be7de98 100644 --- a/src/adapters/lokijs/common.js +++ b/src/adapters/lokijs/common.js @@ -8,6 +8,7 @@ export const actions = { SETUP: 'SETUP', FIND: 'FIND', QUERY: 'QUERY', + CACHED_QUERY: 'CACHED_QUERY', COUNT: 'COUNT', BATCH: 'BATCH', GET_DELETED_RECORDS: 'GET_DELETED_RECORDS', diff --git a/src/adapters/lokijs/index.js b/src/adapters/lokijs/index.js index 94c9bf3d3..502fdea09 100644 --- a/src/adapters/lokijs/index.js +++ b/src/adapters/lokijs/index.js @@ -18,6 +18,7 @@ const { SETUP, FIND, QUERY, + CACHED_QUERY, COUNT, BATCH, UNSAFE_RESET_DATABASE, @@ -119,6 +120,11 @@ export default class LokiJSAdapter implements DatabaseAdapter { this.workerBridge.send(QUERY, [query], callback, 'immutable') } + cachedQuery(query: SerializedQuery, callback: ResultCallback): void { + // SerializedQueries are immutable, so we need no copy + this.workerBridge.send(CACHED_QUERY, [query], callback, 'immutable') + } + count(query: SerializedQuery, callback: ResultCallback): void { // SerializedQueries are immutable, so we need no copy this.workerBridge.send(COUNT, [query], callback, 'immutable') diff --git a/src/adapters/lokijs/worker/executor.js b/src/adapters/lokijs/worker/executor.js index a2ef5bf67..9e04a15bf 100644 --- a/src/adapters/lokijs/worker/executor.js +++ b/src/adapters/lokijs/worker/executor.js @@ -97,7 +97,12 @@ export default class LokiExecutor { query(query: SerializedQuery): CachedQueryResult { const records = executeQuery(query, this.loki).data() - return this._compactQueryResults(records, query.table) + return this._compactQueryResults(records, query.table, false) + } + + cachedQuery(query: SerializedQuery): CachedQueryResult { + const records = executeQuery(query, this.loki).data() + return this._compactQueryResults(records, query.table, true) } count(query: SerializedQuery): number { @@ -383,7 +388,7 @@ export default class LokiExecutor { } // Maps records to their IDs if the record is already cached on JS side - _compactQueryResults(records: DirtyRaw[], table: TableName): CachedQueryResult { + _compactQueryResults(records: DirtyRaw[], table: TableName, willCache: boolean): CachedQueryResult { return records.map(raw => { const { id } = raw @@ -391,7 +396,9 @@ export default class LokiExecutor { return id } - this.markAsCached(table, id) + if(willCache) { + this.markAsCached(table, id) + } return sanitizedRaw(raw, this.schema.tables[table]) }) } diff --git a/src/adapters/lokijs/worker/lokiWorker.js b/src/adapters/lokijs/worker/lokiWorker.js index 1f4d0fac2..f96905c4b 100644 --- a/src/adapters/lokijs/worker/lokiWorker.js +++ b/src/adapters/lokijs/worker/lokiWorker.js @@ -19,6 +19,7 @@ const executorMethods = { [actions.SETUP]: ExecutorProto.setUp, [actions.FIND]: ExecutorProto.find, [actions.QUERY]: ExecutorProto.query, + [actions.CACHED_QUERY]: ExecutorProto.cachedQuery, [actions.COUNT]: ExecutorProto.count, [actions.BATCH]: ExecutorProto.batch, [actions.UNSAFE_RESET_DATABASE]: ExecutorProto.unsafeResetDatabase, diff --git a/src/adapters/sqlite/index.js b/src/adapters/sqlite/index.js index 89fc06880..e03a3ebba 100644 --- a/src/adapters/sqlite/index.js +++ b/src/adapters/sqlite/index.js @@ -82,6 +82,7 @@ type NativeDispatcher = $Exact<{ ) => void, find: (ConnectionTag, TableName, RecordId, ResultCallback) => void, query: (ConnectionTag, TableName, SQL, ResultCallback) => void, + cachedQuery: (ConnectionTag, TableName, SQL, ResultCallback) => void, count: (ConnectionTag, SQL, ResultCallback) => void, batch: (ConnectionTag, NativeBridgeBatchOperation[], ResultCallback) => void, batchJSON?: (ConnectionTag, string, ResultCallback) => void, @@ -99,6 +100,7 @@ const dispatcherMethods = [ 'setUpWithMigrations', 'find', 'query', + 'cachedQuery', 'count', 'batch', 'batchJSON', @@ -117,6 +119,7 @@ type NativeBridgeType = { setUpWithMigrations: (ConnectionTag, string, SQL, SchemaVersion, SchemaVersion) => Promise, find: (ConnectionTag, TableName, RecordId) => Promise, query: (ConnectionTag, TableName, SQL) => Promise, + cachedQuery: (ConnectionTag, TableName, SQL) => Promise, count: (ConnectionTag, SQL) => Promise, batch: (ConnectionTag, NativeBridgeBatchOperation[]) => Promise, batchJSON?: (ConnectionTag, string) => Promise, @@ -139,6 +142,7 @@ type NativeBridgeType = { ) => SyncReturn, findSynchronous?: (ConnectionTag, TableName, RecordId) => SyncReturn, querySynchronous?: (ConnectionTag, TableName, SQL) => SyncReturn, + cachedQuerySynchronous?: (ConnectionTag, TableName, SQL) => SyncReturn, countSynchronous?: (ConnectionTag, SQL) => SyncReturn, batchSynchronous?: (ConnectionTag, NativeBridgeBatchOperation[]) => SyncReturn, batchJSONSynchronous?: (ConnectionTag, string) => SyncReturn, @@ -340,15 +344,21 @@ export default class SQLiteAdapter implements DatabaseAdapter, SQLDatabaseAdapte } query(query: SerializedQuery, callback: ResultCallback): void { - this.unsafeSqlQuery(query.table, encodeQuery(query), callback) + this.unsafeSqlQuery(query.table, encodeQuery(query), false, callback) + } + + cachedQuery(query: SerializedQuery, callback: ResultCallback): void { + this.unsafeSqlQuery(query.table, encodeQuery(query), true, callback) } unsafeSqlQuery( tableName: TableName, sql: string, + willCache: boolean, callback: ResultCallback, ): void { - this._dispatcher.query(this._tag, tableName, sql, result => + const dispatch = willCache ? this._dispatcher.cachedQuery : this._dispatcher.query + dispatch(this._tag, tableName, sql, result => callback( mapValue( rawRecords => sanitizeQueryResult(rawRecords, this.schema.tables[tableName]), diff --git a/src/adapters/type.js b/src/adapters/type.js index 135868b90..baa3f7952 100644 --- a/src/adapters/type.js +++ b/src/adapters/type.js @@ -27,6 +27,8 @@ export interface DatabaseAdapter { // Fetches matching records. Should not send raw object if already cached in JS query(query: SerializedQuery, callback: ResultCallback): void; + cachedQuery(query: SerializedQuery, callback: ResultCallback): void; + // Counts matching records count(query: SerializedQuery, callback: ResultCallback): void; @@ -60,6 +62,7 @@ export interface SQLDatabaseAdapter { unsafeSqlQuery( tableName: TableName, sql: string, + willCache: boolean, callback: ResultCallback, ): void; } From 1a7d94630a7668f3f3a723ef00aa31c523018dbd Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Wed, 5 Feb 2020 16:42:51 +0530 Subject: [PATCH 16/17] fix: minor fix in android database driver --- .../src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt index 48945f85c..b76cce862 100644 --- a/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt +++ b/native/android/src/main/java/com/nozbe/watermelondb/DatabaseDriver.kt @@ -83,7 +83,7 @@ class DatabaseDriver(context: Context, dbName: String) { resultArray.pushString(id) } else { if(willCache) { - markAsCached(table, item.id) + markAsCached(table, id) } resultArray.pushMapFromCursor(it) } From 7c75d081f1832f6b05b5f6b53c32dff1a842a311 Mon Sep 17 00:00:00 2001 From: Pranjal Jain Date: Tue, 25 Feb 2020 19:03:33 +0530 Subject: [PATCH 17/17] fix: run matcher on change set before returning --- src/observation/subscribeToQueryWithSelect/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/observation/subscribeToQueryWithSelect/index.js b/src/observation/subscribeToQueryWithSelect/index.js index f3ef548cc..e8707f118 100644 --- a/src/observation/subscribeToQueryWithSelect/index.js +++ b/src/observation/subscribeToQueryWithSelect/index.js @@ -44,7 +44,6 @@ export function processChangeSet( if(!recordStatesEqual(prevState, newState)) { mutableMatchingRecords[index] = newState shouldEmit = true - return } } }