Skip to content

Commit

Permalink
feat: add someOf an allOf filter condition utilities
Browse files Browse the repository at this point in the history
These help when querying Set attributes.

* Improved typing for `includes` operator to only operate on simple
  attribute value types (string, number, binary). This helps avoid
  mistaken use with Set attributes.
* Improved documentation around usage of `includes` an `excludes`.
  • Loading branch information
benhutchins committed Apr 2, 2024
1 parent 62d7a52 commit d6c3f65
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 34 deletions.
9 changes: 5 additions & 4 deletions docs/MagicSearch.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ The available methods on `Dyngoose.Condition` are:
| `.gte(value)` | `.filter('count').gte(100)` | Attribute must be greater than or equal to specified value. Works on `Number`, `String`, and `Date` attribute types. |
| `.beginsWith(value)` | `.filter('name').beginsWith('John')` | Attribute must start with specified value. |
| `.between(start, end)` | `.filter('dateOfBirth').between(Date.parse('1990-01-01'), Date.parse('1999-12-31'))` | Looks for any record where the attribute value is between the given range. Works on `Number`, `String`, and `Date` attribute types. |
| `.includes(...value)` | `.filter('numbers').includes(1, 2, 3)` | Looks within a [Set Attribute](Attributes.md#set-attributes) for any of the specified values. Values must match exactly. |
| `.excludes(...value)` | `.filter('skills').excludes('magic', 'sorcery')` | Looks within a [Set Attribute](Attributes.md#set-attributes) to verify no value matches the specified values. Values must match exactly. Using `.not().includes()` has the same effect. |
| `.contains(value)` | `.filter('email').contains('@example.com')` | Looks at a value to see if it contains the given substring. This can be performed on a [Set Attribute](Attributes.md#set-attributes) to look for partial matches instead of exact matches. |
| `.not().contains(value)` | `.filter('email').not().contains('@example.com')` | Similar to `contains`, except the exact opposite. Values must not contain the given substring. |
| `.includes([...value])` | `.filter('numbers').includes([1, 2, 3])` | Attribute value must match one of the specified values. Works only on String, Number, and Binary attributes. |
| `.excludes([...value])` | `.filter('numbers').excludes([1, 2, 3])` | Attribute value must not match any of the specified values. Works only on String, Number, and Binary attributes. |
| `.contains(value)` | `.filter('email').contains('@example.com')`<br>`.filter('phoneNumbers').contains('+12345678901')` | Looks at a value to see if it contains the given substring. This can be performed on a [Set Attribute](Attributes.md#set-attributes) to determine if the set contains the value provided (this usage is similar to `someOf`, but only accepts a single value). |
| `.someOf([...values])` | `.filter('referenceIds').someOf(['id1', 'id2'])` | Set attribute must contain at least one of the specified values. |
| `.allOf([...values])` | `.filter('referenceIds').allOf(['id1', 'id2'])` | Set attribute must contain all of the specified values. |
| `.null()` | `.filter('someProperty').null()` | Attribute value must exist and the value must be null. |
| `.not().null()` | `.filter('someProperty').not().null()` | Attribute value value must exist and have any value other than null. |
| `.exists()` | `.filter('someProperty').exists()` | Attribute must exist, value can be anything, including `NULL`. |
Expand Down
90 changes: 66 additions & 24 deletions src/query/condition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { flatten } from 'lodash'
import { type Table } from '../dyngoose'
import { type ContainsType, type Filter } from './filters'
import { type SimpleTypesOnly, type ContainsType, type Filter, type IntersectsType } from './filters'
import { type MagicSearch } from './search'

export class Condition<T extends Table, Attr, AttributeValueType> {
Expand Down Expand Up @@ -142,45 +143,61 @@ export class Condition<T extends Table, Attr, AttributeValueType> {
}

/**
* Checks for matching elements in a list.
* Check if the attribute value matches of one of the given values.
*
* If any of the values specified are equal to the item attribute, the expression evaluates to true.
* If the attribute value matches any of the values specified, the expression evaluates to true.
*
* This is a rather complicated method, so here is a simple example.
* Can provide up to 100 values to compare against.
*
* **Example documents:**
* [ { "name": "Bob "}, { "name": "Robert" }, { "name": "Robby" } ]
*
* **Example condition:**
* `filter('name').includes(['Bob', 'Robert'])`
*
* **Example result:**
* [ { "name": "Bob "}, { "name": "Robert" } ]
*
* Works for String, Number, or Binary (not a set type) attribute.
* Does not work on sets.
* Works for String, Number, or Binary (not a set type) attributes. **Note: Does not work on sets.**
*/
includes(...values: AttributeValueType extends any[] ? never : AttributeValueType[]): MagicSearch<T> {
includes(...values: Array<SimpleTypesOnly<AttributeValueType>> | Array<Array<SimpleTypesOnly<AttributeValueType>>>): MagicSearch<T> {
if (this._not) {
this.filter = ['excludes', values]
this.filter = ['excludes', flatten(values)]
} else {
this.filter = ['includes', values]
this.filter = ['includes', flatten(values)]
}
return this.finalize()
}

/**
* A utility method, identical as if you did `.not().includes(…)`
* Check if the attribute value does NOT match any of the given values.
*
* This is a utility method, identical as if you did `.not().includes(…)`
*
* Works for String, Number, or Binary (not a set type) attributes. **Note: Does not work on sets.**
*/
excludes(...values: AttributeValueType extends any[] ? never : AttributeValueType[]): MagicSearch<T> {
excludes(...values: Array<SimpleTypesOnly<AttributeValueType>>): MagicSearch<T> {
if (this._not) {
this.filter = ['includes', values]
this.filter = ['includes', flatten(values)]
} else {
this.filter = ['excludes', values]
this.filter = ['excludes', flatten(values)]
}
return this.finalize()
}

/**
* Checks if a Set contains any of the provided values in a list.
*
* If any of the values specified are contained in the attribute's Set, the expression evaluates to true.
*
* Works for StringSet, NumberSet, or BinarySet attributes.
*/
someOf(...values: Array<IntersectsType<AttributeValueType>> | Array<Array<IntersectsType<AttributeValueType>>>): MagicSearch<T> {
return this.intersects('some', values)
}

/**
* Checks if a Set contains all of the provided values in a list.
*
* If every one of the values are contained with the attribute's Set, the expression evaluates to true.
*
* Works for StringSet, NumberSet, or BinarySet attributes.
*/
allOf(...values: Array<IntersectsType<AttributeValueType>> | Array<Array<IntersectsType<AttributeValueType>>>): MagicSearch<T> {
return this.intersects('all', values)
}

/**
* Greater than or equal to the first (lower) value, and less than or equal to the second (upper) value.
*
Expand Down Expand Up @@ -209,12 +226,37 @@ export class Condition<T extends Table, Attr, AttributeValueType> {
return this.finalize()
}

private intersects(allOrSome: 'all' | 'some', values: Array<IntersectsType<AttributeValueType>> | Array<Array<IntersectsType<AttributeValueType>>>): MagicSearch<T> {
const key = this.key as any
const filters: any[] = [] // any is because of the Exclude on beginsWith
const operator: 'contains' | 'not contains' = this._not ? 'not contains' : 'contains'
const options = flatten(values)

for (let i = 0; i < options.length; i++) {
const value = options[i]
const filter: Filter<AttributeValueType> = [operator, value]
filters.push({ [key]: filter })

if (allOrSome === 'some' && i !== options.length - 1) {
filters.push('OR')
}
}

if (allOrSome === 'some') {
this.search.parenthesis(group => group.addFilterGroup(filters))
} else {
this.search.addFilterGroup(filters)
}

return this.search
}

private finalize(): MagicSearch<T> {
const key = this.key as any
this.search.addFilterGroup([
{
[key]: this.filter as any, // any is because of the Exclude on beginsWith
},
[key]: this.filter,
} as any, // any is because of the Exclude on beginsWith
])

return this.search
Expand Down
8 changes: 5 additions & 3 deletions src/query/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { type Table } from '../table'

export type AttributeNames<T extends Table> = Exclude<Exclude<keyof T, keyof Table>, () => any>

export type ContainsType<Type> = Type extends Array<infer E>
? E
: Type extends Set<infer E> ? E : Type
export type ContainsType<Type> = Type extends Array<infer E> | Set<infer E> ? E : Type

export type IntersectsType<Type> = Type extends Array<infer E> | Set<infer E> ? E : never

export type SimpleTypesOnly<Type> = Type extends string | number | bigint | number | bigint ? Type : never

export type Filter<Type> =
['=', Type] |
Expand Down
40 changes: 37 additions & 3 deletions src/query/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import { MagicSearch } from './search'

describe('Query/Search', () => {
before(async () => {
const sets = { testStringSet: new Set(['search']), testStringSetArray: ['search'] }
const sets = {
testStringSet: new Set(['search', 'search1', 'search2']),
testStringSetArray: ['search', 'search3', 'search4'],
}

await TestableTable.documentClient.batchPut([
TestableTable.new({ id: 500, title: 'Table.search 0', lowercaseString: 'table search 0', ...sets }),
TestableTable.new({ id: 501, title: 'Table.search 1', lowercaseString: 'table search 1', ...sets }),
TestableTable.new({ id: 502, title: 'Table.search 2', lowercaseString: 'table search 2', ...sets }),
TestableTable.new({ id: 503, title: 'Table.search 3', lowercaseString: 'table search 3' }),
TestableTable.new({ id: 503, title: 'Table.search 3', lowercaseString: 'table search 3', testStringSet: ['search10'] }),
TestableTable.new({ id: 504, title: 'Table.search 4', lowercaseString: 'reject the search 4' }),
TestableTable.new({ id: 504, title: 'Table.search 5', lowercaseString: 'magic' }),
TestableTable.new({ id: 504, title: 'Table.search 6', lowercaseString: 'search' }),
Expand Down Expand Up @@ -94,6 +98,16 @@ describe('Query/Search', () => {
expect(result.count).to.eq(3)
})

it('should support includes operator', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('lowercaseString').includes('search', 'magic')
const input = search.getInput()
expect(input.IndexName).to.be.a('undefined')
expect(input.FilterExpression).to.eq('#a0 IN (:v00, :v01)')
const result = await search.exec()
expect(result.count).to.eq(3)
})

it('should support AND and OR operators together', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('title').contains('Table.search')
Expand All @@ -110,7 +124,7 @@ describe('Query/Search', () => {
expect(result.count).to.eq(3)
})

it('should support filtering on sets', async () => {
it('should support contains filtering on sets', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('testStringSet').contains('search')
.and()
Expand All @@ -122,6 +136,26 @@ describe('Query/Search', () => {
expect(result.count).to.eq(3)
})

it('should support someOf filtering on sets', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('testStringSet').someOf(['search1', 'search10'])
const input = search.getInput()
expect(input.IndexName).to.be.a('undefined')
expect(input.FilterExpression).to.eq('(contains(#a0, :v0) OR contains(#a0, :v1))')
const result = await search.exec()
expect(result.count).to.eq(4) // 3 records contain search1, 1 record will contain search10
})

it('should support allOf filtering on sets', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('testStringSetArray').allOf(['search3', 'search4'])
const input = search.getInput()
expect(input.IndexName).to.be.a('undefined')
expect(input.FilterExpression).to.eq('contains(#a0, :v0) AND contains(#a0, :v1)')
const result = await search.exec()
expect(result.count).to.eq(3)
})

it('should support filtering on children of maps', async () => {
const search = new MagicSearch<TestableTable>(TestableTable)
.filter('testMap', 'property1').eq('test')
Expand Down

0 comments on commit d6c3f65

Please sign in to comment.