diff --git a/docs/MagicSearch.md b/docs/MagicSearch.md
index 44f9737..7356095 100644
--- a/docs/MagicSearch.md
+++ b/docs/MagicSearch.md
@@ -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')`
`.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`. |
diff --git a/src/query/condition.ts b/src/query/condition.ts
index 9ae6766..009bbcd 100644
--- a/src/query/condition.ts
+++ b/src/query/condition.ts
@@ -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 {
@@ -142,45 +143,61 @@ export class Condition {
}
/**
- * 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 {
+ includes(...values: Array> | Array>>): MagicSearch {
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 {
+ excludes(...values: Array>): MagicSearch {
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> | Array>>): MagicSearch {
+ 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> | Array>>): MagicSearch {
+ return this.intersects('all', values)
+ }
+
/**
* Greater than or equal to the first (lower) value, and less than or equal to the second (upper) value.
*
@@ -209,12 +226,37 @@ export class Condition {
return this.finalize()
}
+ private intersects(allOrSome: 'all' | 'some', values: Array> | Array>>): MagicSearch {
+ 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 = [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 {
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
diff --git a/src/query/filters.ts b/src/query/filters.ts
index 10c0d80..930ecda 100644
--- a/src/query/filters.ts
+++ b/src/query/filters.ts
@@ -2,9 +2,11 @@ import { type Table } from '../table'
export type AttributeNames = Exclude, () => any>
-export type ContainsType = Type extends Array
- ? E
- : Type extends Set ? E : Type
+export type ContainsType = Type extends Array | Set ? E : Type
+
+export type IntersectsType = Type extends Array | Set ? E : never
+
+export type SimpleTypesOnly = Type extends string | number | bigint | number | bigint ? Type : never
export type Filter =
['=', Type] |
diff --git a/src/query/search.spec.ts b/src/query/search.spec.ts
index f4fce59..bf0f237 100644
--- a/src/query/search.spec.ts
+++ b/src/query/search.spec.ts
@@ -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' }),
@@ -94,6 +98,16 @@ describe('Query/Search', () => {
expect(result.count).to.eq(3)
})
+ it('should support includes operator', async () => {
+ const search = new MagicSearch(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)
.filter('title').contains('Table.search')
@@ -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)
.filter('testStringSet').contains('search')
.and()
@@ -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)
+ .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)
+ .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)
.filter('testMap', 'property1').eq('test')