Skip to content

Commit

Permalink
feat: Support executing queries from store
Browse files Browse the repository at this point in the history
We add a new option in the query API called `executeFromStore`.
This is useful to allow the execution of a query directly on the store,
i.e. without running on the link (StackLink or PouchLink), hence the
database.

The use-case for this is to be able to have a very fast query when we
have the relevant data on the store, typically after a sync. In such
situation, we do not need to make a network call on the database as we
can simply benefit from the local store.
  • Loading branch information
paultranvan committed Jul 30, 2024
1 parent f32d2c2 commit 6a4cb52
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 113 deletions.
184 changes: 92 additions & 92 deletions docs/api/cozy-client/classes/CozyClient.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,11 @@ We rely on [sift.js](https://github.com/crcn/sift.js) for the in-memory query ev

💡 If you want to know more about our query system and syntax, please check [this documentation](https://docs.cozy.io/en/tutorials/data/queries/).

⚠️ Please not that there is not a perfect matching between mango operators and sift, see [this issue](https://github.com/cozy/cozy-client/issues/1132) for instance.
⚠️ Please note that there is not a perfect matching between mango operators and sift, see [this issue](https://github.com/cozy/cozy-client/issues/1132) for instance.

ℹ️ You can also directly run a query on the store's `documents`, and never actually use the database. This is useful when you know you have already the documents, typically after a sync. Simply use the `executeFromStore` option:

```js
await client.query(queryDef, { executeFromStore: true})
```

16 changes: 11 additions & 5 deletions packages/cozy-client/src/CozyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import {
getCollectionFromState,
getDocumentFromState,
resetState,
isQueryExisting
isQueryExisting,
executeQueryFromState
} from './store'
import fetchPolicies from './policies'
import Schema from './Schema'
Expand Down Expand Up @@ -915,7 +916,7 @@ client.query(Q('io.cozy.bills'))`)
* @param {import("./types").QueryOptions} [options] - Options
* @returns {Promise<import("./types").QueryResult>}
*/
async query(queryDefinition, { update, ...options } = {}) {
async query(queryDefinition, { update, executeFromStore, ...options } = {}) {
this.ensureStore()
const queryId =
options.as || this.queryIdGenerator.generateId(queryDefinition)
Expand Down Expand Up @@ -957,9 +958,14 @@ client.query(Q('io.cozy.bills'))`)
: this.options.backgroundFetching
this.dispatch(loadQuery(queryId, { backgroundFetching }))

const response = await this._promiseCache.exec(
() => this.requestQuery(queryDefinition),
() => stringify(queryDefinition)
const requestFn = executeFromStore
? () =>
Promise.resolve(
executeQueryFromState(this.store.getState(), queryDefinition)
)
: () => this.requestQuery(queryDefinition)
const response = await this._promiseCache.exec(requestFn, () =>
stringify(queryDefinition)
)

this.dispatch(
Expand Down
16 changes: 13 additions & 3 deletions packages/cozy-client/src/CozyClient.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import MockDate from 'mockdate'

import {
SCHEMA,
TODO_1,
Expand Down Expand Up @@ -32,12 +31,14 @@ import {
getRawQueryFromState,
isQueryExisting,
loadQuery,
resetQuery
resetQuery,
executeQueryFromState
} from './store'
import { HasManyFiles, Association, HasMany } from './associations'
import mapValues from 'lodash/mapValues'
import FileCollection from 'cozy-stack-client/dist/FileCollection'
import logger from './logger'

const normalizeData = data =>
mapValues(data, (docs, doctype) => {
return docs.map(doc => ({
Expand All @@ -54,7 +55,8 @@ jest.mock('./store', () => ({
...jest.requireActual('./store'),
isQueryExisting: jest.fn().mockReturnValue(false),
getQueryFromState: jest.fn().mockReturnValue({}),
getRawQueryFromState: jest.fn().mockReturnValue({})
getRawQueryFromState: jest.fn().mockReturnValue({}),
executeQueryFromState: jest.fn().mockReturnValue([])
}))

describe('CozyClient initialization', () => {
Expand Down Expand Up @@ -1228,6 +1230,7 @@ describe('CozyClient', () => {
beforeEach(() => {
query = Q('io.cozy.todos')
fakeResponse = { data: 'FAKE!!!' }
jest.clearAllMocks()
})
it('should throw an error if the option.enabled is not a boolean', async () => {
await expect(
Expand Down Expand Up @@ -1390,6 +1393,13 @@ describe('CozyClient', () => {
expect(client.requestQuery).toHaveBeenCalledTimes(1)
})

it('should handle the executeFromStore option', async () => {
executeQueryFromState.mockReturnValueOnce([])
await client.query(query, { executeFromStore: true })
expect(requestHandler).toHaveBeenCalledTimes(0)
expect(executeQueryFromState).toHaveBeenCalledTimes(1)
})

describe('relationship with query failure', () => {
beforeEach(() => {
jest.spyOn(HasManyFiles, 'query').mockImplementation(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cozy-client/src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ export {
loadQuery,
resetQuery,
receiveQueryResult,
receiveQueryError
receiveQueryError,
executeQueryFromState
} from './queries'

export { resetState }
Expand Down
40 changes: 32 additions & 8 deletions packages/cozy-client/src/store/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import sift from 'sift'

import flag from 'cozy-flags'

import { getDocumentFromSlice } from './documents'
import { getCollectionFromSlice, getDocumentFromSlice } from './documents'
import { isReceivingMutationResult } from './mutations'
import { properId } from './helpers'
import { isAGetByIdQuery, QueryDefinition } from '../queries/dsl'
Expand Down Expand Up @@ -307,23 +307,47 @@ const getSelectorFilterFn = queryDefinition => {
}
}

/**
* Execute the given query against the document state.
*
* @param {import('../types').DocumentsStateSlice} state - The documents state
* @param {QueryDefinition} queryDefinition - The query definition to execute
* @returns {import("../types").QueryStateData} - The returned documents from the query
*/
export const executeQueryFromState = (state, queryDefinition) => {
const documents = getCollectionFromSlice(state, queryDefinition.doctype)
const isSingleObjectResponse = !!queryDefinition.id
if (!documents) {
return { data: isSingleObjectResponse ? null : [] }
}
const res = documents.filter(makeFilterDocumentFn(queryDefinition))
if (isSingleObjectResponse) {
return {
data: res.length > 0 ? res[0] : null
}
}
return {
data: res
}
}

/**
*
* Returns a predicate function that checks if a document should be
* included in the result of the query.
*
* @param {import("../types").QueryState} query - Definition of the query
* @param {QueryDefinition} queryDefinition - Definition of the query
* @returns {function(import("../types").CozyClientDocument): boolean} Predicate function
*/
const getQueryDocumentsChecker = query => {
const qdoctype = query.definition.doctype
const selectorFilterFn = getSelectorFilterFn(query.definition)
const makeFilterDocumentFn = queryDefinition => {
const qdoctype = queryDefinition.doctype
const selectorFilterFn = getSelectorFilterFn(queryDefinition)
return datum => {
const ddoctype = datum._type
if (ddoctype !== qdoctype) return false
if (datum._deleted) return false
if (!selectorFilterFn) return true
return !!selectorFilterFn(datum)
if (!selectorFilterFn) return true // no selector: query all the docs
return !!selectorFilterFn(datum) // evaluate the sift function
}
}

Expand Down Expand Up @@ -371,7 +395,7 @@ export const makeSorterFromDefinition = definition => {
* @returns {import("../types").QueryState} - Updated query state
*/
export const updateData = (query, newData, documents) => {
const belongsToQuery = getQueryDocumentsChecker(query)
const belongsToQuery = makeFilterDocumentFn(query.definition)
const res = mapValues(groupBy(newData, belongsToQuery), docs =>
docs.map(properId)
)
Expand Down
121 changes: 120 additions & 1 deletion packages/cozy-client/src/store/queries.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import queries, {
sortAndLimitDocsIds,
loadQuery,
receiveQueryError,
updateData
updateData,
executeQueryFromState
} from './queries'
import { Q } from '../queries/dsl'
import { TODO_1, TODO_2, TODO_3 } from '../__tests__/fixtures'
Expand Down Expand Up @@ -671,3 +672,121 @@ describe('updateData', () => {
expect(updatedDataToCheck.count).toEqual(1)
})
})

describe('execute query from state', () => {
const docState = {
'io.cozy.files': {
'123': {
_id: '123',
_type: 'io.cozy.files',
name: 'well',
created_at: '2024-01-01'
},
'456': {
_id: '456',
_type: 'io.cozy.files',
name: 'hello',
created_at: '2024-02-01'
},
'789': {
_id: '789',
_type: 'io.cozy.files',
name: 'there',
created_at: '2024-03-01'
}
}
}
it('should get the correct filtered results from state thanks to selector', () => {
const query1 = {
doctype: 'io.cozy.files',
selector: {
created_at: {
$gt: '2024-01-31'
}
}
}
const res1 = executeQueryFromState(docState, query1)
expect(res1.data.length).toEqual(2)
expect(res1.data[0]).toEqual(docState['io.cozy.files']['456'])
expect(res1.data[1]).toEqual(docState['io.cozy.files']['789'])

const query2 = {
doctype: 'io.cozy.files',
selector: {
name: 'well'
}
}
const res2 = executeQueryFromState(docState, query2)
expect(res2.data.length).toEqual(1)
expect(res2.data[0]).toEqual(docState['io.cozy.files']['123'])

const query3 = {
doctype: 'io.cozy.files',
selector: {
created_at: {
$gt: '2024-01-31'
},
name: 'hello'
}
}
const res3 = executeQueryFromState(docState, query3)
expect(res3.data.length).toEqual(1)
expect(res3.data[0]).toEqual(docState['io.cozy.files']['456'])

const query4 = {
doctype: 'io.cozy.files',
selector: {
created_at: {
$gt: '2024-01-30',
$lt: '2024-01-31'
}
}
}
const res4 = executeQueryFromState(docState, query4)
expect(res4.data.length).toEqual(0)
})

it('should get the correct filtered results from state thanks to id', () => {
const query1 = {
doctype: 'io.cozy.files',
id: '123'
}
const res1 = executeQueryFromState(docState, query1)
expect(res1.data).toEqual(docState['io.cozy.files']['123'])

const query2 = {
doctype: 'io.cozy.files',
ids: ['123', '789']
}
const res2 = executeQueryFromState(docState, query2)
expect(res2.data.length).toEqual(2)
expect(res2.data[0]).toEqual(docState['io.cozy.files']['123'])
expect(res2.data[1]).toEqual(docState['io.cozy.files']['789'])

const query3 = {
doctype: 'io.cozy.files',
id: '-1'
}
const res3 = executeQueryFromState(docState, query3)
expect(res3.data).toEqual(null)
})

it('should get all the docs from state for the doctype when no filter', () => {
const query1 = {
doctype: 'io.cozy.files'
}
const res1 = executeQueryFromState(docState, query1)
expect(res1.data.length).toEqual(3)
})

it('should correctly return when no doc is available', () => {
const res1 = executeQueryFromState({}, { doctype: 'io.cozy.files' })
expect(res1.data).toEqual([])

const res2 = executeQueryFromState(
{},
{ doctype: 'io.cozy.files', id: '123' }
)
expect(res2.data).toEqual(null)
})
})
1 change: 1 addition & 0 deletions packages/cozy-client/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ import { QueryDefinition } from './queries/dsl'
* @property {boolean} [singleDocData] - If true, the "data" returned will be
* a single doc instead of an array for single doc queries. Defaults to false for backward
* compatibility but will be set to true in the future.
* @property {boolean} [executeFromStore=false] - If set to true, the query will be run directly on the current store's state
*/

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cozy-client/types/CozyClient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ declare class CozyClient {
* @param {import("./types").QueryOptions} [options] - Options
* @returns {Promise<import("./types").QueryResult>}
*/
query(queryDefinition: QueryDefinition, { update, ...options }?: import("./types").QueryOptions): Promise<import("./types").QueryResult>;
query(queryDefinition: QueryDefinition, { update, executeFromStore, ...options }?: import("./types").QueryOptions): Promise<import("./types").QueryResult>;
/**
* Will fetch all documents for a `queryDefinition`, automatically fetching more
* documents if the total of documents is superior to the pagination limit. Can
Expand Down
2 changes: 1 addition & 1 deletion packages/cozy-client/types/store/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ declare function combinedReducer(state: {
export function resetState(): {
type: string;
};
export { initQuery, loadQuery, resetQuery, receiveQueryResult, receiveQueryError } from "./queries";
export { initQuery, loadQuery, resetQuery, receiveQueryResult, receiveQueryError, executeQueryFromState } from "./queries";
export { initMutation, receiveMutationResult, receiveMutationError } from "./mutations";
1 change: 1 addition & 0 deletions packages/cozy-client/types/store/queries.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function sortAndLimitDocsIds(queryState: import("../types").QueryState, d
}): Array<string>;
export function convert$gtNullSelectors(selector: any): object;
export function mergeSelectorAndPartialIndex(queryDefinition: object): object;
export function executeQueryFromState(state: import('../types').DocumentsStateSlice, queryDefinition: QueryDefinition): import("../types").QueryStateData;
export function makeSorterFromDefinition(definition: QueryDefinition): (arg0: Array<import("../types").CozyClientDocument>) => Array<import("../types").CozyClientDocument>;
export function updateData(query: import("../types").QueryState, newData: Array<import("../types").CozyClientDocument>, documents: import("../types").DocumentsStateSlice): import("../types").QueryState;
export default queries;
Expand Down
4 changes: 4 additions & 0 deletions packages/cozy-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,10 @@ export type QueryOptions = {
* compatibility but will be set to true in the future.
*/
singleDocData?: boolean;
/**
* - If set to true, the query will be run directly on the current store's state
*/
executeFromStore?: boolean;
};
export type Query = {
definition: QueryDefinition;
Expand Down

0 comments on commit 6a4cb52

Please sign in to comment.