From d6c3f65d2bd775052acf89b4172231fd49d8b300 Mon Sep 17 00:00:00 2001 From: Benjamin Hutchins Date: Tue, 2 Apr 2024 14:16:43 -0400 Subject: [PATCH] feat: add someOf an allOf filter condition utilities 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`. --- docs/MagicSearch.md | 9 ++-- src/query/condition.ts | 90 +++++++++++++++++++++++++++++----------- src/query/filters.ts | 8 ++-- src/query/search.spec.ts | 40 ++++++++++++++++-- 4 files changed, 113 insertions(+), 34 deletions(-) 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')