Skip to content

Commit

Permalink
feat: support advanced wallet query (#831)
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>
  • Loading branch information
TimoGlastra authored Jun 2, 2022
1 parent 10cf74d commit 28e0ffa
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 16 deletions.
1 change: 0 additions & 1 deletion packages/core/src/modules/oob/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { OutOfBandInvitationOptions } from './messages'

import { AriesFrameworkError } from '../../error'
import { ConnectionInvitationMessage, HandshakeProtocol } from '../connections'
import { didKeyToVerkey, verkeyToDidKey } from '../dids/helpers'

Expand Down
34 changes: 28 additions & 6 deletions packages/core/src/storage/IndyStorageService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BaseRecord, TagsBase } from './BaseRecord'
import type { StorageService, BaseRecordConstructor } from './StorageService'
import type { StorageService, BaseRecordConstructor, Query } from './StorageService'
import type { default as Indy, WalletQuery, WalletRecord, WalletSearchOptions } from 'indy-sdk'

import { scoped, Lifecycle } from 'tsyringe'
Expand Down Expand Up @@ -92,6 +92,31 @@ export class IndyStorageService<T extends BaseRecord> implements StorageService<
return transformedTags
}

/**
* Transforms the search query into a wallet query compatible with indy WQL.
*
* The format used by AFJ is almost the same as the indy query, with the exception of
* the encoding of values, however this is handled by the {@link IndyStorageService.transformToRecordTagValues}
* method.
*/
private indyQueryFromSearchQuery(query: Query<T>): Record<string, unknown> {
// eslint-disable-next-line prefer-const
let { $and, $or, $not, ...tags } = query

$and = ($and as Query<T>[] | undefined)?.map((q) => this.indyQueryFromSearchQuery(q))
$or = ($or as Query<T>[] | undefined)?.map((q) => this.indyQueryFromSearchQuery(q))
$not = $not ? this.indyQueryFromSearchQuery($not as Query<T>) : undefined

const indyQuery = {
...this.transformFromRecordTagValues(tags as unknown as TagsBase),
$and,
$or,
$not,
}

return indyQuery
}

private recordToInstance(record: WalletRecord, recordClass: BaseRecordConstructor<T>): T {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const instance = JsonTransformer.deserialize<T>(record.value!, recordClass)
Expand Down Expand Up @@ -191,11 +216,8 @@ export class IndyStorageService<T extends BaseRecord> implements StorageService<
}

/** @inheritDoc */
public async findByQuery(
recordClass: BaseRecordConstructor<T>,
query: Partial<ReturnType<T['getTags']>>
): Promise<T[]> {
const indyQuery = this.transformFromRecordTagValues(query as unknown as TagsBase)
public async findByQuery(recordClass: BaseRecordConstructor<T>, query: Query<T>): Promise<T[]> {
const indyQuery = this.indyQueryFromSearchQuery(query)

const recordIterator = this.search(recordClass.type, indyQuery, IndyStorageService.DEFAULT_QUERY_OPTIONS)
const records = []
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/storage/StorageService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { Constructor } from '../utils/mixins'
import type { BaseRecord } from './BaseRecord'
import type { BaseRecord, TagsBase } from './BaseRecord'

export type Query<T extends BaseRecord> = Partial<ReturnType<T['getTags']>>
// https://stackoverflow.com/questions/51954558/how-can-i-remove-a-wider-type-from-a-union-type-without-removing-its-subtypes-in/51955852#51955852
export type SimpleQuery<T extends BaseRecord> = Partial<ReturnType<T['getTags']>> & TagsBase

interface AdvancedQuery<T extends BaseRecord> {
$and?: Query<T>[]
$or?: Query<T>[]
$not?: Query<T>
}

export type Query<T extends BaseRecord> = AdvancedQuery<T> | SimpleQuery<T>

export interface BaseRecordConstructor<T> extends Constructor<T> {
type: string
Expand Down
109 changes: 108 additions & 1 deletion packages/core/src/storage/__tests__/IndyStorageService.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { TagsBase } from '../BaseRecord'
import type * as Indy from 'indy-sdk'

import { getAgentConfig } from '../../../tests/helpers'
import { agentDependencies, getAgentConfig } from '../../../tests/helpers'
import { AgentConfig } from '../../agent/AgentConfig'
import { RecordDuplicateError, RecordNotFoundError } from '../../error'
import { IndyWallet } from '../../wallet/IndyWallet'
import { IndyStorageService } from '../IndyStorageService'
Expand Down Expand Up @@ -189,5 +190,111 @@ describe('IndyStorageService', () => {
expect(records.length).toBe(1)
expect(records[0]).toEqual(expectedRecord)
})

it('finds records using $and statements', async () => {
const expectedRecord = await insertRecord({ tags: { myTag: 'foo', anotherTag: 'bar' } })
await insertRecord({ tags: { myTag: 'notfoobar' } })

const records = await storageService.findByQuery(TestRecord, {
$and: [{ myTag: 'foo' }, { anotherTag: 'bar' }],
})

expect(records.length).toBe(1)
expect(records[0]).toEqual(expectedRecord)
})

it('finds records using $or statements', async () => {
const expectedRecord = await insertRecord({ tags: { myTag: 'foo' } })
const expectedRecord2 = await insertRecord({ tags: { anotherTag: 'bar' } })
await insertRecord({ tags: { myTag: 'notfoobar' } })

const records = await storageService.findByQuery(TestRecord, {
$or: [{ myTag: 'foo' }, { anotherTag: 'bar' }],
})

expect(records.length).toBe(2)
expect(records).toEqual(expect.arrayContaining([expectedRecord, expectedRecord2]))
})

it('finds records using $not statements', async () => {
const expectedRecord = await insertRecord({ tags: { myTag: 'foo' } })
const expectedRecord2 = await insertRecord({ tags: { anotherTag: 'bar' } })
await insertRecord({ tags: { myTag: 'notfoobar' } })

const records = await storageService.findByQuery(TestRecord, {
$not: { myTag: 'notfoobar' },
})

expect(records.length).toBe(2)
expect(records).toEqual(expect.arrayContaining([expectedRecord, expectedRecord2]))
})

it('correctly transforms an advanced query into a valid WQL query', async () => {
const indySpy = jest.fn()
const storageServiceWithoutIndy = new IndyStorageService<TestRecord>(
wallet,
new AgentConfig(
{ label: 'hello' },
{
...agentDependencies,
indy: {
openWalletSearch: indySpy,
fetchWalletSearchNextRecords: jest.fn(() => ({ records: undefined })),
closeWalletSearch: jest.fn(),
} as unknown as typeof Indy,
}
)
)

await storageServiceWithoutIndy.findByQuery(TestRecord, {
$and: [
{
$or: [{ myTag: true }, { myTag: false }],
},
{
$and: [{ theNumber: '0' }, { theNumber: '1' }],
},
],
$or: [
{
aValue: ['foo', 'bar'],
},
],
$not: { myTag: 'notfoobar' },
})

const expectedQuery = {
$and: [
{
$and: undefined,
$not: undefined,
$or: [
{ myTag: '1', $and: undefined, $or: undefined, $not: undefined },
{ myTag: '0', $and: undefined, $or: undefined, $not: undefined },
],
},
{
$or: undefined,
$not: undefined,
$and: [
{ theNumber: 'n__0', $and: undefined, $or: undefined, $not: undefined },
{ theNumber: 'n__1', $and: undefined, $or: undefined, $not: undefined },
],
},
],
$or: [
{
'aValue:foo': '1',
'aValue:bar': '1',
$and: undefined,
$or: undefined,
$not: undefined,
},
],
$not: { myTag: 'notfoobar', $and: undefined, $or: undefined, $not: undefined },
}

expect(indySpy).toBeCalledWith(expect.anything(), expect.anything(), expectedQuery, expect.anything())
})
})
})
15 changes: 9 additions & 6 deletions tests/InMemoryStorageService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { BaseRecord, TagsBase } from '../packages/core/src/storage/BaseRecord'
import type { StorageService, BaseRecordConstructor } from '../packages/core/src/storage/StorageService'
import type { StorageService, BaseRecordConstructor, Query } from '../packages/core/src/storage/StorageService'

import { scoped, Lifecycle } from 'tsyringe'

import { RecordNotFoundError, RecordDuplicateError, JsonTransformer } from '@aries-framework/core'
import { RecordNotFoundError, RecordDuplicateError, JsonTransformer, AriesFrameworkError } from '@aries-framework/core'

interface StorageRecord {
value: Record<string, unknown>
Expand Down Expand Up @@ -97,10 +97,13 @@ export class InMemoryStorageService<T extends BaseRecord = BaseRecord> implement
}

/** @inheritDoc */
public async findByQuery(
recordClass: BaseRecordConstructor<T>,
query: Partial<ReturnType<T['getTags']>>
): Promise<T[]> {
public async findByQuery(recordClass: BaseRecordConstructor<T>, query: Query<T>): Promise<T[]> {
if (query.$and || query.$or || query.$not) {
throw new AriesFrameworkError(
'Advanced wallet query features $and, $or or $not not supported in in memory storage'
)
}

const records = Object.values(this.records)
.filter((record) => {
const tags = record.tags as TagsBase
Expand Down

0 comments on commit 28e0ffa

Please sign in to comment.