Skip to content

Commit

Permalink
Merge pull request #621 from jeremydaly/finish-implementing-scan-comm…
Browse files Browse the repository at this point in the history
…ands-for-good

Finish implementing scan commands for good
  • Loading branch information
ThomasAribart committed Nov 1, 2023
2 parents 7e3592d + 9f88c1b commit 11ac57f
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 40 deletions.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import isEmpty from 'lodash.isempty'
import { parseCapacityOption } from 'v1/commands/utils/parseOptions/parseCapacityOption'
import { rejectExtraOptions } from 'v1/commands/utils/parseOptions/rejectExtraOptions'
import { parseConsistentOption } from 'v1/commands/utils/parseOptions/parseConsistentOption'
import { parseProjection } from 'v1/commands/expression/projection/parse'
import { EntityV2 } from 'v1/entity'

import type { GetItemOptions } from '../options'
import { parseProjection } from '../projection'

type CommandOptions = Omit<GetCommandInput, 'TableName' | 'Key'>

Expand Down
2 changes: 1 addition & 1 deletion src/v1/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export { ScanCommand } from './scan'
export type { ScanOptions, ScanResponse } from './scan'
export { formatSavedItem } from './utils/formatSavedItem'
export { parseCondition } from './expression/condition/parse'
export { parseProjection } from './getItem/projection'
export { parseProjection } from './expression/projection/parse'
export * from './types'
58 changes: 41 additions & 17 deletions src/v1/commands/scan/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,73 @@ import { isString } from 'v1/utils/validation'

import type { TableCommandClass } from '../class'
import type { ScanOptions } from './options'
import type { CountSelectOption } from './constants'
import { scanParams } from './scanParams'

export type ScanResponse<ENTITIES extends EntityV2> = O.Merge<
Omit<ScanCommandOutput, 'Items'>,
{
// TODO: Update Response according to Select option
Items?: (EntityV2 extends ENTITIES
type ReturnedItems<
ENTITIES extends EntityV2,
OPTIONS extends ScanOptions<ENTITIES>
> = OPTIONS['select'] extends CountSelectOption
? undefined
: (EntityV2 extends ENTITIES
? Item
: ENTITIES extends infer ENTITY
? ENTITY extends EntityV2
? FormattedItem<ENTITY>
: never
: never)[]
}
>

export class ScanCommand<TABLE extends TableV2 = TableV2, ENTITIES extends EntityV2 = EntityV2>
implements TableCommandClass<TABLE, ENTITIES> {
export type ScanResponse<
ENTITIES extends EntityV2,
OPTIONS extends ScanOptions<ENTITIES>
> = O.Merge<Omit<ScanCommandOutput, 'Items'>, { Items?: ReturnedItems<ENTITIES, OPTIONS> }>

export class ScanCommand<
TABLE extends TableV2 = TableV2,
ENTITIES extends EntityV2 = EntityV2,
OPTIONS extends ScanOptions<ENTITIES> = ScanOptions<ENTITIES>
> implements TableCommandClass<TABLE, ENTITIES> {
static commandType = 'scan' as const

public _table: TABLE
public _entities: ENTITIES[]
public entities: <NEXT_ENTITIES extends EntityV2[]>(
...nextEntities: NEXT_ENTITIES
) => ScanCommand<TABLE, NEXT_ENTITIES[number]>
public _options: ScanOptions
public options: (nextOptions: ScanOptions<ENTITIES>) => ScanCommand<TABLE, ENTITIES>
) => ScanCommand<
TABLE,
NEXT_ENTITIES[number],
OPTIONS extends ScanOptions<NEXT_ENTITIES[number]>
? OPTIONS
: ScanOptions<NEXT_ENTITIES[number]>
>
public _options: OPTIONS
public options: <NEXT_OPTIONS extends ScanOptions<ENTITIES>>(
nextOptions: NEXT_OPTIONS
) => ScanCommand<TABLE, ENTITIES, NEXT_OPTIONS>

constructor(
{ table, entities = [] }: { table: TABLE; entities?: ENTITIES[] },
options: ScanOptions = {}
options: OPTIONS = {} as OPTIONS
) {
this._table = table
this._entities = entities
this._options = options

this.entities = <NEXT_ENTITIES extends EntityV2[]>(...nextEntities: NEXT_ENTITIES) =>
new ScanCommand(
new ScanCommand<
TABLE,
NEXT_ENTITIES[number],
OPTIONS extends ScanOptions<NEXT_ENTITIES[number]>
? OPTIONS
: ScanOptions<NEXT_ENTITIES[number]>
>(
{
table: this._table,
entities: nextEntities
},
this._options
this._options as OPTIONS extends ScanOptions<NEXT_ENTITIES[number]>
? OPTIONS
: ScanOptions<NEXT_ENTITIES[number]>
)
this.options = nextOptions =>
new ScanCommand({ table: this._table, entities: this._entities }, nextOptions)
Expand All @@ -67,7 +91,7 @@ export class ScanCommand<TABLE extends TableV2 = TableV2, ENTITIES extends Entit
return params
}

send = async (): Promise<ScanResponse<ENTITIES>> => {
send = async (): Promise<ScanResponse<ENTITIES, OPTIONS>> => {
const scanParams = this.params()

const commandOutput = await this._table.documentClient.send(new _ScanCommand(scanParams))
Expand Down Expand Up @@ -103,7 +127,7 @@ export class ScanCommand<TABLE extends TableV2 = TableV2, ENTITIES extends Entit
}

return {
Items: formattedItems as ScanResponse<ENTITIES>['Items'],
Items: formattedItems as ScanResponse<ENTITIES, OPTIONS>['Items'],
...restCommandOutput
}
}
Expand Down
32 changes: 26 additions & 6 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> }
: { [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
}
)
70 changes: 60 additions & 10 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 @@ -12,14 +12,19 @@ import { parseLimitOption } from 'v1/commands/utils/parseOptions/parseLimitOptio
import { rejectExtraOptions } from 'v1/commands/utils/parseOptions/rejectExtraOptions'
import { isInteger } from 'v1/utils/validation/isInteger'
import { parseCondition } from 'v1/commands/expression/condition/parse'
import { parseProjection } from 'v1/commands/expression/projection/parse'

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 +35,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 +77,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 Expand Up @@ -105,24 +132,43 @@ export const scanParams = <TABLE extends TableV2, ENTITIES extends EntityV2>(
const expressionAttributeNames: Record<string, string> = {}
const expressionAttributeValues: Record<string, any> = {}
const filterExpressions: string[] = []
const projectionExpressions: string[] = []

entities.forEach((entity, index) => {
const entityNameFilter = { attr: entity.entityAttributeName, eq: entity.name }
const entityFilter = filters[entity.name]

const {
ExpressionAttributeNames,
ExpressionAttributeValues,
ConditionExpression
ExpressionAttributeNames: filterExpressionAttributeNames,
ExpressionAttributeValues: filterExpressionAttributeValues,
ConditionExpression: filterExpression
} = parseCondition<EntityV2, Condition<EntityV2>>(
entity,
entityFilter !== undefined ? { and: [entityNameFilter, entityFilter] } : entityNameFilter,
index.toString()
)

Object.assign(expressionAttributeNames, ExpressionAttributeNames)
Object.assign(expressionAttributeValues, ExpressionAttributeValues)
filterExpressions.push(ConditionExpression)
Object.assign(expressionAttributeNames, filterExpressionAttributeNames)
Object.assign(expressionAttributeValues, filterExpressionAttributeValues)
filterExpressions.push(filterExpression)

const entityAttributes = attributes[entity.name]
if (entityAttributes !== undefined) {
/**
* @debt refactor "Would be better to have a single attribute list for all entities (typed as the intersection of all entities AnyAttributePath) but parseProjection is designed to work with entities so it's a big rework. Will do that later."
*/
const {
ExpressionAttributeNames: projectionExpressionAttributeNames,
ProjectionExpression: projectionExpression
} = parseProjection<EntityV2, AnyAttributePath[]>(
entity,
entityAttributes,
index.toString()
)

Object.assign(expressionAttributeNames, projectionExpressionAttributeNames)
projectionExpressions.push(projectionExpression)
}
})

if (!isEmpty(expressionAttributeNames)) {
Expand All @@ -139,6 +185,10 @@ export const scanParams = <TABLE extends TableV2, ENTITIES extends EntityV2>(
? filterExpressions[0]
: `(${filterExpressions.filter(Boolean).join(') OR (')})`
}

if (projectionExpressions.length > 0) {
commandOptions.ProjectionExpression = projectionExpressions.filter(Boolean).join(', ')
}
}

rejectExtraOptions(extraOptions)
Expand Down

0 comments on commit 11ac57f

Please sign in to comment.