Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support advanced wallet query #831

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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