UNPKG

dyngoose

Version:

Elegant DynamoDB object modeling for Typescript

550 lines (469 loc) 18.9 kB
import { type QueryCommandInput, type QueryCommandOutput, type ScanCommandInput, type ScanCommandOutput } from '@aws-sdk/client-dynamodb' import { get, has, includes, isArray } from 'lodash' import { type Attribute } from '../attribute' import { HelpfulError, QueryError } from '../errors' import { type Metadata } from '../index' import { type Key } from '../interfaces/key.interface' import { type ITable, type Table } from '../table' import { Condition } from './condition' import { buildQueryExpression, keyConditionAllowedOperators } from './expression' import { type AttributeNames, type ComplexFilters, type Filter, type Filters } from './filters' import { type GlobalSecondaryIndex } from './global-secondary-index' import { type LocalSecondaryIndex } from './local-secondary-index' import { QueryOutput } from './output' import { type PrimaryKey } from './primary-key' import { buildProjectionExpression } from './projection-expression' import { type IRequestOptions } from '../connections' type Index<T extends Table> = PrimaryKey<T, any, any> | GlobalSecondaryIndex<T> | LocalSecondaryIndex<T> | string export interface MagicSearchInput<T extends Table> { limit?: number exclusiveStartKey?: Key attributes?: string[] projectionExpression?: string rangeOrder?: 'ASC' | 'DESC' consistent?: boolean returnOnlyCount?: boolean /** * Perform your query on the specified index, which can be a GSI object or a string */ index?: Index<T> } export type SearchGroupFunction<T extends Table> = (condition: MagicSearch<T>) => any /** * Use this via Table.search() */ export class MagicSearch<T extends Table> { private filters: ComplexFilters<T> = [] constructor(private readonly tableClass: ITable<T>, filters?: Filters<T>, private readonly input: MagicSearchInput<T> = {}) { if (filters != null) { this.addFilterGroup([filters]) } } addFilterGroup(filters: Array<Filters<T>>): this { this.filters = this.filters.concat(filters) return this } parenthesis(value: SearchGroupFunction<T>): this { return this.group(value) } group(value: SearchGroupFunction<T>): this { const groupedSearch = new MagicSearch<T>(this.tableClass) value(groupedSearch) this.filters.push(groupedSearch.filters) return this } filter< K1 extends AttributeNames<T>, >(a1: K1): Condition<T, Attr, NonNullable<T[K1]>> filter< K1 extends NonNullable<AttributeNames<T>>, K2 extends keyof NonNullable<T[K1]>, >(a1: K1, a2: K2): Condition<T, Attr, NonNullable<NonNullable<T[K1]>[K2]>> filter< K1 extends AttributeNames<T>, K2 extends keyof NonNullable<T[K1]>, K3 extends keyof NonNullable<NonNullable<T[K1]>[K2]>, >(a1: K1, a2: K2, a3: K3): Condition<T, Attr, NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>> filter< K1 extends AttributeNames<T>, K2 extends keyof NonNullable<T[K1]>, K3 extends keyof NonNullable<NonNullable<T[K1]>[K2]>, K4 extends keyof NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>, >(a1: K1, a2: K2, a3: K3, a4: K4): Condition<T, Attr, NonNullable<NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>[K4]>> filter<Attr extends AttributeNames<T>>(...attributePropertyPath: any): Condition<T, Attr, T[Attr]> { return new Condition<T, Attr, T[Attr]>(this, attributePropertyPath.join('.')) } where< K1 extends AttributeNames<T>, >(a1: K1): Condition<T, Attr, NonNullable<T[K1]>> where< K1 extends NonNullable<AttributeNames<T>>, K2 extends keyof NonNullable<T[K1]>, >(a1: K1, a2: K2): Condition<T, Attr, NonNullable<NonNullable<T[K1]>[K2]>> where< K1 extends AttributeNames<T>, K2 extends keyof NonNullable<T[K1]>, K3 extends keyof NonNullable<NonNullable<T[K1]>[K2]>, >(a1: K1, a2: K2, a3: K3): Condition<T, Attr, NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>> where< K1 extends AttributeNames<T>, K2 extends keyof NonNullable<T[K1]>, K3 extends keyof NonNullable<NonNullable<T[K1]>[K2]>, K4 extends keyof NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>, >(a1: K1, a2: K2, a3: K3, a4: K4): Condition<T, Attr, NonNullable<NonNullable<NonNullable<NonNullable<T[K1]>[K2]>[K3]>[K4]>> where<Attr extends AttributeNames<T>>(...attributePropertyPath: any): Condition<T, Attr, T[Attr]> { return new Condition<T, Attr, T[Attr]>(this, attributePropertyPath.join('.')) } or(): this { this.filters.push('OR') return this } and(): this { return this } /** * This function will limit the number of documents that DynamoDB will process in this query request. * * Unlike most SQL databases this does not guarantee the response will contain 5 documents. * Instead DynamoDB will only query a maximum of 5 documents to see if they match and should be returned. * The limit parameter passed in should be a number representing how many documents you wish DynamoDB to process. */ limit(limit: number): this { this.input.limit = limit return this } /** * When there are more documents available to your query than DynamoDB can return, * Dyngoose will let you know by specifying Results.lastEvaluatedKey. * * You can pass that object into this method to get additional results from your table. */ startAt(exclusiveStartKey?: Key): this { this.input.exclusiveStartKey = exclusiveStartKey return this } /** * This function will limit which attributes DynamoDB returns for each item in the table * by building a ProjectionExpression for you. * * This can limit the size of the DynamoDB response and helps you only retrieve the data you need. * Simply provide an array of strings representing the property names you wish DynamoDB to return. */ properties<Attr extends AttributeNames<T>>(...propertyNames: Attr[]): this { const attributeNames: string[] = [] for (const propertyName of propertyNames) { const attr = this.tableClass.schema.getAttributeByPropertyName(propertyName as string) attributeNames.push(attr.name) } if (this.input.attributes == null) { this.input.attributes = [] } this.input.attributes = this.input.attributes.concat(attributeNames) return this } /** * This is similar to `.properties()` except it accepts a list of attribute names * instead of property names. */ attributes(...attributeNames: string[]): this { if (this.input.attributes == null) { this.input.attributes = [] } this.input.attributes = this.input.attributes.concat(attributeNames) return this } /** * Instead of returning the records, this function will cause the query operation to return only the count of possible results. */ count(): this { this.input.returnOnlyCount = true return this } /** * This will cause the query to run in a consistent manner as opposed to the default eventually consistent manner. */ consistent(consistent = true): this { this.input.consistent = consistent return this } /** * This causes the query to be run on a specific index as opposed to the default table wide query. * The index parameter you pass in should represent the name of the index you wish to query on. */ using(index: Index<T> | null): this { if (index === null) { this.input.index = undefined } else { this.input.index = index } return this } /** * This function sorts the documents you receive back by the rangeKey. By default, if not provided, it will sort in ascending order. * * The order parameter must be a string either equal to ascending or descending. */ sort(direction: 'ascending' | 'descending'): this { if (direction === 'ascending') { this.input.rangeOrder = 'ASC' } else if (direction === 'descending') { this.input.rangeOrder = 'DESC' } return this } ascending(): this { return this.sort('ascending') } descending(): this { return this.sort('descending') } /** * This will execute the query you constructed and return one page of results. * * A promise will be returned that will resolve to the results array upon completion. */ async exec(requestOptions?: IRequestOptions): Promise<QueryOutput<T>> { const input = this.getInput() return await this.page(input, requestOptions) } /** * This will execute the query you constructed and page, if necessary, until the * minimum number of requested documents is loaded. * * This can be useful if you are doing advanced queries without good indexing, * which should be avoided but can happen for infrequent operations such as analytics. * * Unlike `.all()` which pages until all results are loaded, `.minimum(min)` will * page only until the specified number of records is loaded and then halts. * * It is recommended you apply a `.limit(minOrMore)` before calling `.minimum` to ensure * you do not load too many results as well. */ async minimum(minimum: number, requestOptions?: IRequestOptions): Promise<QueryOutput<T>> { const outputs: Array<QueryOutput<T>> = [] let count = 0 for await (const page of this.iteratePages(requestOptions)) { count += page.count outputs.push(page) // if we've loaded enough, stop loading more… if (count >= minimum || page.lastEvaluatedKey == null) { break } } return QueryOutput.fromSeveralOutputs(this.tableClass, outputs) } /** * Page internally and return all possible search results. * * Be cautious. This can easily cause timeouts if you're using Lambda functions. * This is also non-ideal for scans, for better performance use a segmented scan * via the Query.PrimaryKey.segmentedScan or Query.GlobalSecondaryIndex.segmentedScan. * * For a more optimized process, consider using: * - iteratePages * - iterateDocuments */ async all(requestOptions?: IRequestOptions): Promise<QueryOutput<T>> { const outputs: Array<QueryOutput<T>> = [] for await (const page of this.iteratePages(requestOptions)) { outputs.push(page) } return QueryOutput.fromSeveralOutputs(this.tableClass, outputs) } /** * Async generator to iterate through every page of results. * * Usage example: * ```typescript * for await (const page of search.iteratePages()) { * for (const document of page) { * // Do something * } * } * ``` */ async * iteratePages(requestOptions?: IRequestOptions): AsyncGenerator<QueryOutput<T>> { const input = this.getInput() let page: QueryOutput<T> | undefined // if this is the first page, or if we have not hit the last page, continue loading records… while (page == null || page.lastEvaluatedKey != null) { if (page?.lastEvaluatedKey != null) { input.ExclusiveStartKey = page.lastEvaluatedKey } page = await this.page(input, requestOptions) yield page } } /** * Async generator to iterate through every document that matches your query. * * Usage example: * ```typescript * for await (const document of search.iterateDocuments()) { * // Do something * } * ``` */ async * iterateDocuments(requestOptions?: IRequestOptions): AsyncGenerator<T> { for await (const page of this.iteratePages(requestOptions)) { for (const item of page) { yield item } } } getInput(): ScanCommandInput | QueryCommandInput { let indexMetadata: Metadata.Index.GlobalSecondaryIndex | Metadata.Index.PrimaryKey | undefined if (this.input.index != null && typeof this.input.index === 'string') { const indexName = this.input.index // if we were given an index, find the metadata object for it for (const index of this.tableClass.schema.globalSecondaryIndexes) { if (index.name === indexName) { indexMetadata = index } } if (indexMetadata == null) { for (const index of this.tableClass.schema.localSecondaryIndexes) { if (index.name === indexName) { indexMetadata = Object.assign({ hash: this.tableClass.schema.primaryKey.hash, }, index) as Metadata.Index.GlobalSecondaryIndex } } } if (indexMetadata == null) { throw new QueryError(`Attempted to perform ${this.tableClass.schema.name}.search using non-existent index ${indexName}`) } } else if (this.input.index != null) { if ((typeof this.input.index.metadata as any).hash === 'undefined') { const metadata: Metadata.Index.GlobalSecondaryIndex = Object.assign({ hash: this.tableClass.schema.primaryKey.hash, }, this.input.index.metadata as Metadata.Index.LocalSecondaryIndex) indexMetadata = metadata } else { indexMetadata = this.input.index.metadata as Metadata.Index.GlobalSecondaryIndex | Metadata.Index.PrimaryKey } } else { // if no index was specified, look to see if there is an available index given the query indexMetadata = this.findAvailableIndex() } const query = buildQueryExpression(this.tableClass.schema, this.filters, indexMetadata) const input: ScanCommandInput | QueryCommandInput = { TableName: this.tableClass.schema.name, ConsistentRead: false, ExpressionAttributeValues: query.ExpressionAttributeValues, FilterExpression: query.FilterExpression, } if (Object.keys(query.ExpressionAttributeNames).length > 0) { input.ExpressionAttributeNames = query.ExpressionAttributeNames } if (this.input.projectionExpression != null) { input.ProjectionExpression = this.input.projectionExpression } else if (this.input.attributes != null) { const expression = buildProjectionExpression(this.tableClass, this.input.attributes, input.ExpressionAttributeNames) input.Select = 'SPECIFIC_ATTRIBUTES' input.ProjectionExpression = expression.ProjectionExpression input.ExpressionAttributeNames = expression.ExpressionAttributeNames } if (this.input.rangeOrder === 'DESC') { (input as QueryCommandInput).ScanIndexForward = false } if (this.input.limit != null) { input.Limit = this.input.limit } if (this.input.exclusiveStartKey != null) { input.ExclusiveStartKey = this.input.exclusiveStartKey } if (this.input.consistent != null) { input.ConsistentRead = this.input.consistent } if (indexMetadata != null && typeof (indexMetadata as any).name === 'string') { input.IndexName = (indexMetadata as Metadata.Index.GlobalSecondaryIndex | Metadata.Index.LocalSecondaryIndex).name } if (this.input.returnOnlyCount === true) { input.Select = 'COUNT' // count does not allow ProjectionExpression to be specified if (input.ProjectionExpression != null) { delete input.ProjectionExpression } } if (query.KeyConditionExpression != null) { (input as QueryCommandInput).KeyConditionExpression = query.KeyConditionExpression } return input } /** * Get a page of documents. Primarily used internally, but to allow advanced * uses, you are able to use `getInput()`, manipulate the scan/query input, * then pass it into this method to run the query and get back your results. */ async page(input: ScanCommandInput | QueryCommandInput, requestOptions?: IRequestOptions): Promise<QueryOutput<T>> { const hasProjection = input.ProjectionExpression == null let output: ScanCommandOutput | QueryCommandOutput // if we are filtering based on key conditions, run a query instead of a scan if ((input as QueryCommandInput).KeyConditionExpression != null) { try { output = await this.tableClass.schema.dynamo.query(input, requestOptions) } catch (ex) { throw new HelpfulError(ex, this.tableClass, input) } } else { if ((input as QueryCommandInput).ScanIndexForward === false) { throw new Error('Cannot specify a sort direction, range order, or use ScanIndexForward on a scan operation. Try specifying the index being used.') } else { delete (input as QueryCommandInput).ScanIndexForward } try { output = await this.tableClass.schema.dynamo.scan(input, requestOptions) } catch (ex) { throw new HelpfulError(ex, this.tableClass, input) } } return QueryOutput.fromDynamoOutput(this.tableClass, output, !hasProjection) } private findAvailableIndex(): Metadata.Index.GlobalSecondaryIndex | Metadata.Index.PrimaryKey | undefined { // look at the primary key first const primaryKey = this.tableClass.schema.primaryKey if (this.checkFilters(primaryKey.hash, primaryKey.range)) { return primaryKey } // look through GlobalSecondaryIndexes for (const index of this.tableClass.schema.globalSecondaryIndexes) { // skip if it doesn't have a full projection if (index.projection === 'INCLUDE' || index.projection === 'KEYS_ONLY') { continue } // determine if we can use this index if (this.checkFilters(index.hash, index.range)) { return index } } // look through LocalSecondaryIndexes for (const index of this.tableClass.schema.localSecondaryIndexes) { // skip if it doesn't have a full projection if (index.projection === 'INCLUDE' || index.projection === 'KEYS_ONLY') { continue } // determine if we can use this index if (this.checkFilters(primaryKey.hash, index.range)) { const metadata: Metadata.Index.GlobalSecondaryIndex = Object.assign({ hash: primaryKey.hash, }, index) return metadata } } } private checkFilters(hash: Attribute<any>, range?: Attribute<any>): boolean { // cannot filter by a key without a value for the hash key for (const filters of this.filters) { if (!has(filters, hash.name)) { continue } const hashFilter: Filter<any> = get(filters, hash.name) // if there is an operator, ensure it is allowed as a key expression if (isArray(hashFilter)) { const operator = hashFilter[0] if (!includes(keyConditionAllowedOperators, operator)) { continue } } // if it has no range, then we're all done if (range == null) { return true } // check for the range now if (!has(filters, range.name)) { continue } const rangeFilter: Filter<any> = get(filters, range.name) // if there is an operator, ensure it is allowed as a key expression if (isArray(rangeFilter)) { const operator = rangeFilter[0] if (!includes(keyConditionAllowedOperators, operator)) { continue } } return true } return false } }