Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/derived state #689

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
3 changes: 2 additions & 1 deletion docs/docs/entity/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ For more control over an attribute's behavior, you can specify an object as the
| prefix | `string` | `string` | A prefix to be added to an attribute when saved to DynamoDB. This prefix will be removed when parsing the data. |
| suffix | `string` | `string` | A suffix to be added to an attribute when saved to DynamoDB. This suffix will be removed when parsing the data. |
| transform | `function` | all | A function that transforms the input before sending to DynamoDB. This accepts two arguments, the value passed and an object containing the data from other attributes. |
| format | `function` | all | A function that transforms the DynamoDB output before sending it to the parser. This accepts two arguments, the value of the attribute and an object containing the whole item. |
| format | `function` | all | A function that transforms the DynamoDB output before sending it to the parser. This accepts two arguments, the value of the attribute and an object containing the whole item.
| derive | `function` | all | A function that takes the parsed output and returns a derived state value. This accepts one argument, an object containing the whole parsed item. |
| partitionKey | `boolean` or `string` | all | Flags an attribute as the 'partitionKey' for this Entity. If set to `true`, it will be mapped to the Table's `partitionKey`. If set to the name of an **index** defined on the Table, it will be mapped to the secondary index's `partitionKey` |
| sortKey | `boolean` or `string` | all | Flags an attribute as the 'sortKey' for this Entity. If set to `true`, it will be mapped to the Table's `sortKey`. If set to the name of an **index** defined on the Table, it will be mapped to the secondary index's `sortKey` |

Expand Down
1 change: 1 addition & 0 deletions docs/docs/introduction/what-is-dynamodb-toolbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ If you like working with ORMs, that's great, and you should definitely give thes
- **Bidirectional Mapping and Aliasing:** When building a single table design, you can define multiple entities that map to the same table. Each entity can reuse fields (like `pk` and`sk`) and map them to different aliases depending on the item type. Your data is automatically mapped correctly when reading and writing data.
<!-- - **Composite Key Generation and Field Mapping:** Doing some fancy data modeling with composite keys? Like setting your `sortKey` to `[country]#[region]#[state]#[county]#[city]#[neighborhood]` model hierarchies? DynamoDB Toolbox lets you map data to these composite keys which will both autogenerate the value _and_ parse them into fields for you. -->
- **Type Coercion and Validation:** Automatically coerce values to strings, numbers and booleans to ensure consistent data types in your DynamoDB tables. Validate `list`, `map`, and `set` types against your data. Oh yeah, and `set`s are automatically handled for you. 😉
- **Derived State:** Define Entity derived states. These sates will be calculated from a dynamodb response for you.
- **Powerful Query Builder:** Specify a `partitionKey`, and then easily configure your sortKey conditions, filters, and attribute projections to query your primary or secondary indexes. This library can even handle pagination with a simple `.next()` method.
- **Simple Table Scans:** Scan through your table or secondary indexes and add filters, projections, parallel scans and more. And don't forget the pagination support with `.next()`.
- **Filter and Condition Expression Builder:** Build complex Filter and Condition expressions using a standardized `array` and `object` notation. No more appending strings!
Expand Down
20 changes: 19 additions & 1 deletion src/__tests__/formatItem.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ DefaultTable.addEntity(
linked4: ['composite1', 1, { save: false, alias: 'linked4_alias' }],
composite2_alias: { type: 'string', map: 'composite2' },
linked5: ['composite2_alias', 0, { save: false }],
linked6: ['composite2_alias', 1, { save: false, alias: 'linked6_alias' }]
linked6: ['composite2_alias', 1, { save: false, alias: 'linked6_alias' }],
derived: { derive: (item) => item.derivedFrom1 + item.derivedFrom2 },
derivedFrom1: { type:'number'},
derivedFrom2: { type:'number'},
}
} as const)
)
Expand Down Expand Up @@ -195,4 +198,19 @@ describe('formatItem', () => {
})
expect(result).toEqual({ number: null })
})

it('calculates derived states', () => {
const result = formatItem()(DefaultTable.User.schema.attributes, DefaultTable.User.linked, {
other: 'other',
derivedFrom1: 1,
derivedFrom2: 15
}, [], ['derived'])

expect(result).toEqual({
other: 'other',
derivedFrom1: 1,
derivedFrom2: 15,
derived: 16
})
})
})
3 changes: 2 additions & 1 deletion src/__tests__/parseCompositeKey.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const track: TrackingInfo = {
defaults: [],
required: [],
linked: {},
keys: []
keys: [],
derived: [],
}

describe('parseCompositeKey', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/parseEntity.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const entity = {
pk: { partitionKey: true },
sk: { sortKey: true },
attr1: 'number',
attr2: { type: 'list', required: true }
attr2: { type: 'list', required: true },
attr3: { derive: () => 'test' }
},
autoExecute: true,
autoParse: true
Expand All @@ -35,6 +36,7 @@ describe('parseEntity', () => {
expect(ent.autoParse).toBe(true)
expect(ent._etAlias).toBe('typeAlias')
expect(ent.typeHidden).toBe(true)
expect(ent.derived).toEqual(['attr3'])
})

// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand Down
22 changes: 20 additions & 2 deletions src/__tests__/parseMapping.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ let track: TrackingInfo = {
defaults: [],
required: [],
linked: {},
keys: []
keys: [],
derived: [],
}

beforeEach(() => {
Expand All @@ -15,7 +16,8 @@ beforeEach(() => {
defaults: [],
required: [],
linked: {},
keys: []
keys: [],
derived: [],
}
})

Expand Down Expand Up @@ -209,4 +211,20 @@ describe('parseMapping', () => {
parseMapping('attr', { type: 'string', map: 'test', alias: 'testx' }, track)
}).toThrow(`'attr' cannot contain both an alias and a map`)
})

it('parses mapping with derived', async () => {
// Parse to string so we can compare without the derive function giving false negative
expect(JSON.stringify(parseMapping('attr', { derive: () => 'test' }, track))).toEqual(
JSON.stringify({
attr: { save: false, derive: () => 'test', type: 'string', coerce: true, }
})
)
})

it('fails on non-function derive', async () => {
expect(() => {
// @ts-expect-error
parseMapping('attr', { derive: 'test' }, track)
}).toThrow(`'derive' must be a function`)
})
})
11 changes: 7 additions & 4 deletions src/classes/Entity/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class Entity<Name extends string = string,
public _etAlias!: string
public defaults: any
public linked: any
public derived: any
public required: any
public attributes: ReadonlyAttributeDefinitions
public timestamps: Timestamps
Expand Down Expand Up @@ -255,17 +256,19 @@ class Entity<Name extends string = string,
})

// Load the schema
const { schema, linked } = this
const { schema, linked, derived } = this

// Assume standard response from DynamoDB
const data = input.Item || input.Items || input



if (Array.isArray(data)) {
return data.map(item =>
formatItem()(schema.attributes, linked, item, include),
formatItem()(schema.attributes, linked, item, include, derived)
) as any
} else {
return formatItem()(schema.attributes, linked, data, include) as any
return formatItem()(schema.attributes, linked, data, include, derived) as any
}
}

Expand Down Expand Up @@ -1676,4 +1679,4 @@ export const shouldExecute = (execute: boolean | undefined, autoExecute: boolean
execute === true || (execute === undefined && autoExecute)

export const shouldParse = (parse: boolean | undefined, autoParse: boolean): boolean =>
parse === true || (parse === undefined && autoParse)
parse === true || (parse === undefined && autoParse)
4 changes: 3 additions & 1 deletion src/classes/Entity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type KeyAttributeDefinition = {
alias: never
map: never
setType: never
derive: never
}

export type PartitionKeyDefinition = Partial<KeyAttributeDefinition> & {
Expand Down Expand Up @@ -94,7 +95,8 @@ export type PureAttributeDefinition = Partial<{
setType: DynamoDBKeyTypes
delimiter: string
prefix: string
suffix: string
suffix: string,
derive: (data: Record<string, any>) => any
}>

export type CompositeAttributeDefinition =
Expand Down
15 changes: 13 additions & 2 deletions src/lib/formatItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ const unwrapAttributeValue = (value: NativeAttributeValue): boolean | number |
return value
}

// Format item based on attribute defnition
// Format item based on attribute definition
export default () => (
attributes: { [key: string]: PureAttributeDefinition },
linked: Linked,
item: any,
include: string[] = [],
derived: string[] = []
) => {
// TODO: Support nested maps?
// TODO: include alias support?
Expand All @@ -60,7 +61,7 @@ export default () => (
// Intialize validate type
const validateType = validateTypes()

return Object.keys(item).reduce((acc, field) => {
let formattedItem = Object.keys(item).reduce((acc, field) => {
const link =
linked[field] ||
(attributes[field] && attributes[field].alias && linked[attributes[field].alias!])
Expand Down Expand Up @@ -114,6 +115,16 @@ export default () => (
[(attributes[field] && attributes[field].alias) || field]: transformedValue,
})
}, {})

formattedItem = derived.reduce(
(acc, derivedAttribute) => ({
...acc,
[derivedAttribute]: attributes[derivedAttribute].derive?.(formattedItem)
}),
formattedItem
)

return formattedItem
}

function escapeRegExp(text: string) {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/parseEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface TrackingInfo {
required: any
linked: Linked
keys: any
derived: string[]
}

export interface Linked {
Expand Down Expand Up @@ -44,6 +45,7 @@ export type ParsedEntity<
autoExecute: AutoExecute | undefined
linked: Linked
defaults: any
derived: string[]
required: any
table?: EntityTable | undefined,
setTable?: <NextTable extends EntityTable | undefined>(table: NextTable) => ParsedEntity<NextTable, Name, AutoExecute, AutoParse, TypeAlias, TypeHidden>
Expand Down Expand Up @@ -164,7 +166,8 @@ export function parseEntity<
defaults: {}, // tracks default attributes
required: {},
linked: {},
keys: {} // tracks partition/sort/index keys
keys: {}, // tracks partition/sort/index keys
derived: [],
}

const schema = parseEntityAttributes<ReadonlyAttributeDefinitions>(attributes, track) // removed nested attribute?
Expand All @@ -188,6 +191,7 @@ export function parseEntity<
defaults: track.defaults,
required: track.required,
linked: track.linked,
derived: track.derived,
autoExecute,
autoParse,
typeHidden,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/parseMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,20 @@ export default (
error(`'${prop}' must be a boolean, string, or array`)
}
break
case 'derive':
if (typeof config[prop] !== 'function') error(`'${prop}' must be a function`)
break
default:
error(`'${prop}' is not a valid property type`)
}
})

// Set derived and force no save
if (config.derive !== undefined) {
config.save = false
track.derived.push(field)
}

// Error on alias and map
if (config.alias && config.map) error(`'${field}' cannot contain both an alias and a map`)

Expand Down