Skip to content

Commit

Permalink
improve select options typing + validation to handle more edge cases
Browse files Browse the repository at this point in the history
  • Loading branch information
Thomas Aribart committed Nov 1, 2023
1 parent 6d47096 commit e368009
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 11 deletions.
30 changes: 25 additions & 5 deletions src/v1/commands/scan/options.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import type { CapacityOption } from 'v1/commands/constants/options/capacity'
import type { Condition } from 'v1/commands/types/condition'
import type { AnyAttributePath, Condition } from 'v1/commands/types'
import type { EntityV2 } from 'v1/entity'

import type { SelectOption } from './constants'
import type {
SelectOption,
AllProjectedAttributesSelectOption,
SpecificAttributesSelectOption
} from './constants'

export type ScanOptions<ENTITIES extends EntityV2 = EntityV2> = {
capacity?: CapacityOption
consistent?: boolean
exclusiveStartKey?: Record<string, unknown>
indexName?: string
limit?: number
select?: SelectOption
filters?: EntityV2 extends ENTITIES
? Record<string, Condition>
: { [ENTITY in ENTITIES as ENTITY['name']]: Condition<ENTITY> }
} & (
| { segment?: never; totalSegments?: never }
// Either both segment & totalSegments are set, either none
} & ({ segment?: never; totalSegments?: never } | { segment: number; totalSegments: number })
| { segment: number; totalSegments: number }
) &
(
| { select?: Exclude<SelectOption, AllProjectedAttributesSelectOption>; indexName?: string }
// "ALL_PROJECTED_ATTRIBUTES" is only available if indexName is present
| { select?: SelectOption; indexName: string }
) &
(
| { attributes?: undefined; select?: SelectOption }
// "SPECIFIC_ATTRIBUTES" is the only valid option if projectionExpression is present
| {
attributes: EntityV2 extends ENTITIES
? Record<string, Condition>
: { [ENTITY in ENTITIES as ENTITY['name']]: AnyAttributePath<ENTITY>[] }
select?: SpecificAttributesSelectOption
}
)
34 changes: 30 additions & 4 deletions src/v1/commands/scan/scanParams/scanParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ScanCommandInput } from '@aws-sdk/lib-dynamodb'
import isEmpty from 'lodash.isempty'

import type { TableV2 } from 'v1/table'
import type { Condition } from 'v1/commands/types'
import type { AnyAttributePath, Condition } from 'v1/commands/types'
import { EntityV2 } from 'v1/entity'
import { DynamoDBToolboxError } from 'v1/errors'
import { parseCapacityOption } from 'v1/commands/utils/parseOptions/parseCapacityOption'
Expand All @@ -17,9 +17,13 @@ import type { ScanOptions } from '../options'

import { selectOptionsSet } from '../constants'

export const scanParams = <TABLE extends TableV2, ENTITIES extends EntityV2>(
export const scanParams = <
TABLE extends TableV2,
ENTITIES extends EntityV2,
OPTIONS extends ScanOptions<ENTITIES>
>(
{ table, entities = [] }: { table: TABLE; entities?: ENTITIES[] },
scanOptions: ScanOptions = {}
scanOptions: OPTIONS = {} as OPTIONS
): ScanCommandInput => {
const {
capacity,
Expand All @@ -30,10 +34,14 @@ export const scanParams = <TABLE extends TableV2, ENTITIES extends EntityV2>(
select,
totalSegments,
segment,
filters = {},
filters: _filters,
attributes: _attributes,
...extraOptions
} = scanOptions

const filters = (_filters ?? {}) as Record<string, Condition>
const attributes = (_attributes ?? {}) as Record<string, AnyAttributePath[]>

const commandOptions: ScanCommandInput = {
TableName: table.getName()
}
Expand Down Expand Up @@ -68,6 +76,24 @@ export const scanParams = <TABLE extends TableV2, ENTITIES extends EntityV2>(
})
}

if (select === 'ALL_PROJECTED_ATTRIBUTES' && indexName === undefined) {
throw new DynamoDBToolboxError('scanCommand.invalidSelectOption', {
message: `Invalid select option: '${String(
select
)}'. Please provide an 'indexName' option.`,
payload: { select }
})
}

if (!isEmpty(attributes) && select !== 'SPECIFIC_ATTRIBUTES') {
throw new DynamoDBToolboxError('scanCommand.invalidSelectOption', {
message: `Invalid select option: '${String(
select
)}'. Select must be 'SPECIFIC_ATTRIBUTES' if a filter expression has been provided.`,
payload: { select }
})
}

commandOptions.Select = select
}

Expand Down
65 changes: 65 additions & 0 deletions src/v1/commands/scan/scanParams/scanParams.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,71 @@ describe('scan', () => {
)
})

it('sets select option', () => {
const { Select } = TestTable.build(ScanCommand).options({ select: 'COUNT' }).params()

expect(Select).toBe('COUNT')
})

it('fails on invalid select option', () => {
const invalidCall = () =>
TestTable.build(ScanCommand)
.options({
// @ts-expect-error
select: 'foobar'
})
.params()

expect(invalidCall).toThrow(DynamoDBToolboxError)
expect(invalidCall).toThrow(
expect.objectContaining({ code: 'scanCommand.invalidSelectOption' })
)
})

it('sets "ALL_PROJECTED_ATTRIBUTES" select option if an index is provided', () => {
const { Select } = TestTable.build(ScanCommand)
.options({ select: 'ALL_PROJECTED_ATTRIBUTES', indexName: 'my-index' })
.params()

expect(Select).toBe('ALL_PROJECTED_ATTRIBUTES')
})

it('fails if select option is "ALL_PROJECTED_ATTRIBUTES" but no index is provided', () => {
const invalidCall = () =>
TestTable.build(ScanCommand)
// @ts-expect-error
.options({ select: 'ALL_PROJECTED_ATTRIBUTES' })
.params()

expect(invalidCall).toThrow(DynamoDBToolboxError)
expect(invalidCall).toThrow(
expect.objectContaining({ code: 'scanCommand.invalidSelectOption' })
)
})

it('accepts "SPECIFIC_ATTRIBUTES" select option if a projection expression has been provided', () => {
const { Select } = TestTable.build(ScanCommand)
.entities(Entity1)
.options({ attributes: { entity1: ['age'] }, select: 'SPECIFIC_ATTRIBUTES' })
.params()

expect(Select).toBe('SPECIFIC_ATTRIBUTES')
})

it('fails if a projection expression has been provided but select option is NOT "SPECIFIC_ATTRIBUTES"', () => {
const invalidCall = () =>
TestTable.build(ScanCommand)
.entities(Entity1)
// @ts-expect-error
.options({ attributes: { entity1: ['age'] }, select: 'ALL_ATTRIBUTES' })
.params()

expect(invalidCall).toThrow(DynamoDBToolboxError)
expect(invalidCall).toThrow(
expect.objectContaining({ code: 'scanCommand.invalidSelectOption' })
)
})

it('sets limit option', () => {
const { Limit } = TestTable.build(ScanCommand).options({ limit: 3 }).params()

Expand Down
6 changes: 4 additions & 2 deletions src/v1/commands/types/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ type AttributePath<ATTRIBUTE_PATH extends string, ATTRIBUTE extends Attribute> =
: never
: never)

export type SchemaAttributePath<SCHEMA extends Schema> = Schema extends SCHEMA
export type SchemaAttributePath<SCHEMA extends Schema = Schema> = Schema extends SCHEMA
? string
: keyof SCHEMA['attributes'] extends infer ATTRIBUTE_PATH
? ATTRIBUTE_PATH extends string
? AttributePath<ATTRIBUTE_PATH, SCHEMA['attributes'][ATTRIBUTE_PATH]>
: never
: never

export type AnyAttributePath<ENTITY extends EntityV2> = SchemaAttributePath<ENTITY['schema']>
export type AnyAttributePath<ENTITY extends EntityV2 = EntityV2> = SchemaAttributePath<
ENTITY['schema']
>

0 comments on commit e368009

Please sign in to comment.