Skip to content

Commit 8d072e6

Browse files
Merge pull request dynamodb-toolbox#1004 from dynamodb-toolbox/enable-passing-any-filter-to-query-and-scan
enable passing any filter to queries and scans (without entities)
2 parents 7d63cf7 + bc6e8df commit 8d072e6

File tree

9 files changed

+221
-69
lines changed

9 files changed

+221
-69
lines changed

docs/docs/2-tables/2-actions/1-scan/index.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Available options (see the [DynamoDB documentation](https://docs.aws.amazon.com/
149149
</td>
150150
</tr>
151151
<tr>
152-
<td rowSpan="4" align="center" class="vertical"><b>Filters</b></td>
152+
<td rowSpan="5" align="center" class="vertical"><b>Filters</b></td>
153153
<td><code>select</code></td>
154154
<td align="center"><code>SelectOption</code></td>
155155
<td align="center">-</td>
@@ -168,6 +168,16 @@ Available options (see the [DynamoDB documentation](https://docs.aws.amazon.com/
168168
<br/><br/>See the <a href="../../entities/actions/parse-condition#building-conditions"><code>ConditionParser</code></a> action for more details on how to write conditions.
169169
</td>
170170
</tr>
171+
<tr>
172+
<td><code>filter</code></td>
173+
<td align="center"><code>Condition</code></td>
174+
<td align="center">-</td>
175+
<td>
176+
An untyped condition that must be satisfied in order for evaluated items to be returned (improves performances but does not reduce costs).
177+
<br/><br/>No effect if <a href="#entities"><code>entities</code></a> are provided (use <code>filters</code> instead).
178+
<br/><br/>See the <a href="../../entities/actions/parse-condition#building-conditions"><code>ConditionParser</code></a> action for more details on how to write conditions.
179+
</td>
180+
</tr>
171181
<tr>
172182
<td><code>attributes</code></td>
173183
<td align="center"><code>string[]</code></td>
@@ -319,7 +329,7 @@ const { Items } = await PokeTable.build(ScanCommand)
319329
:::note[Filtered]
320330

321331
<Tabs>
322-
<TabItem value="filtered" label="Filtered">
332+
<TabItem value="filters" label="Filters">
323333

324334
```ts
325335
const { Items } = await PokeTable.build(ScanCommand)
@@ -333,6 +343,17 @@ const { Items } = await PokeTable.build(ScanCommand)
333343
.send()
334344
```
335345

346+
</TabItem>
347+
<TabItem value="filter" label="Filter">
348+
349+
```ts
350+
const { Items } = await PokeTable.build(ScanCommand)
351+
.options({
352+
filter: { attr: 'pokeType', eq: 'fire' }
353+
})
354+
.send()
355+
```
356+
336357
</TabItem>
337358
<TabItem value="attributes" label="Attributes">
338359

docs/docs/2-tables/2-actions/2-query/index.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Available options (see the [DynamoDB documentation](https://docs.aws.amazon.com/
206206
</td>
207207
</tr>
208208
<tr>
209-
<td rowSpan="4" align="center" class="vertical"><b>Filters</b></td>
209+
<td rowSpan="5" align="center" class="vertical"><b>Filters</b></td>
210210
<td><code>select</code></td>
211211
<td align="center"><code>SelectOption</code></td>
212212
<td align="center">-</td>
@@ -225,6 +225,16 @@ Available options (see the [DynamoDB documentation](https://docs.aws.amazon.com/
225225
<br/><br/>See the <a href="../../entities/actions/parse-condition#building-conditions"><code>ConditionParser</code></a> action for more details on how to write conditions.
226226
</td>
227227
</tr>
228+
<tr>
229+
<td><code>filter</code></td>
230+
<td align="center"><code>Condition</code></td>
231+
<td align="center">-</td>
232+
<td>
233+
An untyped condition that must be satisfied in order for evaluated items to be returned (improves performances but does not reduce costs).
234+
<br/><br/>No effect if <a href="#entities"><code>entities</code></a> are provided (use <code>filters</code> instead).
235+
<br/><br/>See the <a href="../../entities/actions/parse-condition#building-conditions"><code>ConditionParser</code></a> action for more details on how to write conditions.
236+
</td>
237+
</tr>
228238
<tr>
229239
<td><code>attributes</code></td>
230240
<td align="center"><code>string[]</code></td>
@@ -344,7 +354,7 @@ const { Items } = await PokeTable.build(QueryCommand)
344354
:::note[Filtered]
345355

346356
<Tabs>
347-
<TabItem value="filtered" label="Filtered">
357+
<TabItem value="filters" label="Filters">
348358

349359
```ts
350360
const { Items } = await PokeTable.build(QueryCommand)
@@ -359,6 +369,18 @@ const { Items } = await PokeTable.build(QueryCommand)
359369
.send()
360370
```
361371

372+
</TabItem>
373+
<TabItem value="filter" label="Filter">
374+
375+
```ts
376+
const { Items } = await PokeTable.build(QueryCommand)
377+
.query({ partition: 'ashKetchum' })
378+
.options({
379+
filters: { attr: 'pokeType', eq: 'fire' }
380+
})
381+
.send()
382+
```
383+
362384
</TabItem>
363385
<TabItem value="attributes" label="Attributes">
364386

src/schema/actions/utils/appendAttributePath.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const appendAttributePath = (
6969
let attributeMatches = [...attributePath.matchAll(pathRegex)]
7070
let attributePathTail: string | undefined
7171

72+
let root = true
7273
while (attributeMatches.length > 0) {
7374
const attributeMatch = attributeMatches.shift() as RegExpMatchArray
7475

@@ -93,7 +94,7 @@ export const appendAttributePath = (
9394
}
9495
default: {
9596
const expressionAttributeNameIndex = parser.expressionAttributeNames.push(matchedKey)
96-
expressionPath += `.#${expressionAttributePrefix}${expressionAttributeNameIndex}`
97+
expressionPath += `${root ? '' : '.'}#${expressionAttributePrefix}${expressionAttributeNameIndex}`
9798
}
9899
}
99100

@@ -118,7 +119,7 @@ export const appendAttributePath = (
118119
const parsedKey = new Parser(keyAttribute).parse(matchedKey, { fill: false }) as string
119120

120121
const expressionAttributeNameIndex = parser.expressionAttributeNames.push(parsedKey)
121-
expressionPath += `.#${expressionAttributePrefix}${expressionAttributeNameIndex}`
122+
expressionPath += `${root ? '' : '.'}#${expressionAttributePrefix}${expressionAttributeNameIndex}`
122123

123124
parentAttribute = parentAttribute.elements
124125
break
@@ -134,10 +135,7 @@ export const appendAttributePath = (
134135
childAttribute.savedAs ?? matchedKey
135136
)
136137

137-
expressionPath +=
138-
parentAttribute.type === 'schema'
139-
? `#${expressionAttributePrefix}${expressionAttributeNameIndex}`
140-
: `.#${expressionAttributePrefix}${expressionAttributeNameIndex}`
138+
expressionPath += `${root ? '' : '.'}#${expressionAttributePrefix}${expressionAttributeNameIndex}`
141139
parentAttribute = childAttribute
142140
break
143141
}
@@ -171,13 +169,15 @@ export const appendAttributePath = (
171169
}
172170

173171
parser.expressionAttributeNames = validElementExpressionParser.expressionAttributeNames
174-
expressionPath += validElementExpressionParser.expression
172+
expressionPath += `${root ? '' : '.'}${validElementExpressionParser.expression}`
175173
// No need to go over the rest of the path
176174
attributeMatches = []
177175

178176
break
179177
}
180178
}
179+
180+
root = false
181181
}
182182

183183
if (

src/table/actions/query/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type QueryOptions<
2222
maxPages?: number
2323
reverse?: boolean
2424
entityAttrFilter?: boolean
25+
filter?: Entity[] extends ENTITIES ? Condition : never
2526
filters?: Entity[] extends ENTITIES
2627
? Record<string, Condition>
2728
: { [ENTITY in ENTITIES[number] as ENTITY['name']]?: Condition<ENTITY> }

src/table/actions/query/queryParams/queryParams.ts

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { QueryCommandInput } from '@aws-sdk/lib-dynamodb'
22

3+
import { AnyAttribute } from '~/attributes/any/index.js'
34
import { EntityConditionParser } from '~/entity/actions/parseCondition/index.js'
45
import type { Condition } from '~/entity/actions/parseCondition/index.js'
56
import { EntityPathParser } from '~/entity/actions/parsePaths/index.js'
@@ -14,6 +15,7 @@ import { parseMaxPagesOption } from '~/options/maxPages.js'
1415
import { rejectExtraOptions } from '~/options/rejectExtraOptions.js'
1516
import { parseSelectOption } from '~/options/select.js'
1617
import { parseTableNameOption } from '~/options/tableName.js'
18+
import { ConditionParser } from '~/schema/actions/parseCondition/index.js'
1719
import type { Table } from '~/table/index.js'
1820
import { isEmpty } from '~/utils/isEmpty.js'
1921
import { isBoolean } from '~/utils/validation/isBoolean.js'
@@ -34,6 +36,17 @@ type QueryParamsGetter = <
3436
options?: OPTIONS
3537
) => QueryCommandInput
3638

39+
const defaultAnyAttribute = new AnyAttribute({
40+
required: 'never',
41+
hidden: false,
42+
key: false,
43+
savedAs: undefined,
44+
defaults: { key: undefined, put: undefined, update: undefined },
45+
links: { key: undefined, put: undefined, update: undefined },
46+
validators: { key: undefined, put: undefined, update: undefined },
47+
castAs: undefined
48+
})
49+
3750
export const queryParams: QueryParamsGetter = <
3851
TABLE extends Table,
3952
ENTITIES extends Entity[],
@@ -55,6 +68,7 @@ export const queryParams: QueryParamsGetter = <
5568
reverse,
5669
select,
5770
entityAttrFilter = true,
71+
filter,
5872
filters: _filters,
5973
attributes: _attributes,
6074
tableName,
@@ -134,14 +148,50 @@ export const queryParams: QueryParamsGetter = <
134148
Object.assign(expressionAttributeNames, keyConditionExpressionAttributeNames)
135149
Object.assign(expressionAttributeValues, keyConditionExpressionAttributeValues)
136150

151+
if (entities.length === 0 && filter !== undefined) {
152+
const {
153+
ExpressionAttributeNames: filterExpressionAttributeNames,
154+
ExpressionAttributeValues: filterExpressionAttributeValues,
155+
ConditionExpression: filterExpression
156+
} = new ConditionParser(defaultAnyAttribute).parse(filter).toCommandOptions()
157+
158+
Object.assign(expressionAttributeNames, filterExpressionAttributeNames)
159+
Object.assign(expressionAttributeValues, filterExpressionAttributeValues)
160+
commandOptions.FilterExpression = filterExpression
161+
}
162+
137163
if (entities.length > 0) {
138164
const filterExpressions: string[] = []
139165
let projectionExpression: string | undefined = undefined
140166

141-
entities.forEach((entity, index) => {
167+
let index = 0
168+
for (const entity of entities) {
169+
/**
170+
* @debt feature "For now, we compute the projectionExpression using the first entity. Will probably use Table schemas once they exist."
171+
*/
172+
if (projectionExpression === undefined && attributes !== undefined) {
173+
const { entityAttributeName } = entity
174+
175+
const {
176+
ExpressionAttributeNames: projectionExpressionAttributeNames,
177+
ProjectionExpression
178+
} = entity
179+
.build(EntityPathParser)
180+
.parse(
181+
// entityAttributeName is required at all times for formatting
182+
attributes.includes(entityAttributeName)
183+
? attributes
184+
: [entityAttributeName, ...attributes]
185+
)
186+
.toCommandOptions()
187+
188+
Object.assign(expressionAttributeNames, projectionExpressionAttributeNames)
189+
projectionExpression = ProjectionExpression
190+
}
191+
142192
const entityFilter = filters[entity.name]
143193
if (entityFilter === undefined && !entityAttrFilter) {
144-
return
194+
continue
145195
}
146196

147197
const entityNameFilter = { attr: entity.entityAttributeName, eq: entity.name }
@@ -163,29 +213,8 @@ export const queryParams: QueryParamsGetter = <
163213
Object.assign(expressionAttributeValues, filterExpressionAttributeValues)
164214
filterExpressions.push(filterExpression)
165215

166-
/**
167-
* @debt feature "For now, we compute the projectionExpression using the first entity. Will probably use Table schemas once they exist."
168-
*/
169-
if (attributes !== undefined) {
170-
const { entityAttributeName } = entity
171-
172-
const {
173-
ExpressionAttributeNames: projectionExpressionAttributeNames,
174-
ProjectionExpression
175-
} = entity
176-
.build(EntityPathParser)
177-
.parse(
178-
// entityAttributeName is required at all times for formatting
179-
attributes.includes(entityAttributeName)
180-
? attributes
181-
: [entityAttributeName, ...attributes]
182-
)
183-
.toCommandOptions()
184-
185-
Object.assign(expressionAttributeNames, projectionExpressionAttributeNames)
186-
projectionExpression = ProjectionExpression
187-
}
188-
})
216+
index++
217+
}
189218

190219
if (filterExpressions.length > 0) {
191220
commandOptions.FilterExpression =

src/table/actions/query/queryParams/queryParams.unit.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,32 @@ describe('query', () => {
738738
expect(invalidCall).toThrow(expect.objectContaining({ code: 'options.invalidTableNameOption' }))
739739
})
740740

741+
test('applies any filter if no entity has been provided', () => {
742+
const command = TestTable.build(QueryCommand)
743+
.query({ partition: 'foo' })
744+
.options({ filter: { attr: 'foo', eq: 'bar' } })
745+
const { FilterExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
746+
command.params()
747+
748+
expect(FilterExpression).toBe('#c_1 = :c_1')
749+
expect(ExpressionAttributeNames).toMatchObject({ '#c_1': 'foo' })
750+
expect(ExpressionAttributeValues).toMatchObject({ ':c_1': 'bar' })
751+
})
752+
753+
test('ignores filter if entities have been provided', () => {
754+
const command = TestTable.build(QueryCommand)
755+
.query({ partition: 'foo' })
756+
.entities(Entity1)
757+
.options({
758+
// @ts-expect-error
759+
filter: { attr: 'foo', eq: 'bar' }
760+
})
761+
const { ExpressionAttributeNames = {}, ExpressionAttributeValues = {} } = command.params()
762+
763+
expect(Object.values(ExpressionAttributeNames)).not.toContain('foo')
764+
expect(Object.values(ExpressionAttributeValues)).not.toContain('bar')
765+
})
766+
741767
test('applies entity _et filter', () => {
742768
const command = TestTable.build(QueryCommand).query({ partition: 'foo' }).entities(Entity1)
743769
const { FilterExpression, ExpressionAttributeNames, ExpressionAttributeValues } =

src/table/actions/scan/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type ScanOptions<TABLE extends Table = Table, ENTITIES extends Entity[] =
1616
limit?: number
1717
maxPages?: number
1818
entityAttrFilter?: boolean
19+
filter?: Entity[] extends ENTITIES ? Condition : never
1920
filters?: Entity[] extends ENTITIES
2021
? Record<string, Condition>
2122
: { [ENTITY in ENTITIES[number] as ENTITY['name']]?: Condition<ENTITY> }

0 commit comments

Comments
 (0)