From 3ec39ecb85d7897df4957d4d1ccb856bbd1bb03e Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 19:41:02 +0200 Subject: [PATCH 01/13] Enhance entity conversion by adding support for selected attributes in EntityManager and related classes. --- lib/entity/entityManager.ts | 4 ++-- lib/entity/helpers/converters.ts | 19 ++++++++++++++++++- lib/query/index.ts | 2 +- lib/retriever/index.ts | 3 +++ lib/scan/index.ts | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/entity/entityManager.ts b/lib/entity/entityManager.ts index fdd71de..833c466 100644 --- a/lib/entity/entityManager.ts +++ b/lib/entity/entityManager.ts @@ -237,7 +237,7 @@ export default function EntityManager, E extends typeof En throw new NotFoundError(); } - return convertAttributeValuesToEntity(entity, result.Item); + return convertAttributeValuesToEntity(entity, result.Item, options?.attributes); })(); } @@ -701,7 +701,7 @@ export default function EntityManager, E extends typeof En result.UnprocessedKeys?.[tableName]?.Keys?.map((key) => fromDynamo(key) as TablePrimaryKey) || []; return { - items: items.map((item) => convertAttributeValuesToEntity(entity, item)), + items: items.map((item) => convertAttributeValuesToEntity(entity, item, options?.attributes)), unprocessedKeys, }; })(); diff --git a/lib/entity/helpers/converters.ts b/lib/entity/helpers/converters.ts index f0160c6..a204620 100644 --- a/lib/entity/helpers/converters.ts +++ b/lib/entity/helpers/converters.ts @@ -7,6 +7,7 @@ import { AttributeValues, fromDynamo, GenericObject, objectToDynamo } from '@lib export function convertAttributeValuesToEntity( entity: E, dynamoItem: AttributeValues, + selectedAttributes?: Array, ): InstanceType { const object = fromDynamo(dynamoItem); const attributes = Dynamode.storage.getEntityAttributes(entity.name); @@ -25,7 +26,21 @@ export function convertAttributeValuesToEntity( object[attribute.propertyName] = truncateValue(entity, attribute.propertyName, value); }); - return new entity(object) as InstanceType; + const instance = new entity(object) as InstanceType; + + if (selectedAttributes && selectedAttributes.length > 0) { + Object.values(attributes) + .filter( + (attribute) => + !selectedAttributes.includes(attribute.propertyName) && attribute.propertyName !== 'dynamodeEntity', + ) + .forEach((attribute) => { + // @ts-expect-error undefined is not assignable to every Entity's property + instance[attribute.propertyName] = undefined; + }); + } + + return instance; } export function convertEntityToAttributeValues( @@ -35,6 +50,8 @@ export function convertEntityToAttributeValues( const dynamoObject: GenericObject = {}; const attributes = Dynamode.storage.getEntityAttributes(entity.name); + console.log(`%%% attributes`, attributes); + Object.values(attributes).forEach((attribute) => { dynamoObject[attribute.propertyName] = transformValue( entity, diff --git a/lib/query/index.ts b/lib/query/index.ts index 4498645..dedb79c 100644 --- a/lib/query/index.ts +++ b/lib/query/index.ts @@ -160,7 +160,7 @@ export default class Query, E extends typeof Entity> exten } while (all && !!lastKey && count < max); return { - items: items.map((item) => convertAttributeValuesToEntity(this.entity, item)), + items: items.map((item) => convertAttributeValuesToEntity(this.entity, item, this.selectedAttributes)), lastKey: lastKey && convertAttributeValuesToLastKey(this.entity, lastKey), count, scannedCount, diff --git a/lib/retriever/index.ts b/lib/retriever/index.ts index 86c3a9b..02318bc 100644 --- a/lib/retriever/index.ts +++ b/lib/retriever/index.ts @@ -18,6 +18,8 @@ import { AttributeNames, AttributeValues } from '@lib/utils'; export default class RetrieverBase, E extends typeof Entity> extends Condition { /** The DynamoDB input object (QueryInput or ScanInput) */ protected input: QueryInput | ScanInput; + /** The list of attributes actually fetched from DynamoDB */ + protected selectedAttributes: Array> = []; /** Attribute names mapping for expression attribute names */ protected attributeNames: AttributeNames = {}; /** Attribute values mapping for expression attribute values */ @@ -165,6 +167,7 @@ export default class RetrieverBase, E extends typeof Entit attributes, this.attributeNames, ).projectionExpression; + this.selectedAttributes = attributes; return this; } } diff --git a/lib/scan/index.ts b/lib/scan/index.ts index 8a4c736..f2a7f39 100644 --- a/lib/scan/index.ts +++ b/lib/scan/index.ts @@ -113,7 +113,7 @@ export default class Scan, E extends typeof Entity> extend const items = result.Items || []; return { - items: items.map((item) => convertAttributeValuesToEntity(this.entity, item)), + items: items.map((item) => convertAttributeValuesToEntity(this.entity, item, this.selectedAttributes)), count: result.Count || 0, scannedCount: result.ScannedCount || 0, lastKey: result.LastEvaluatedKey From cc60d544d89308a9fe50bf90547e072af0566b57 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 19:41:09 +0200 Subject: [PATCH 02/13] Add partial mock instance to TestTable for enhanced testing flexibility --- tests/fixtures/TestTable.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/fixtures/TestTable.ts b/tests/fixtures/TestTable.ts index f7ab6e4..3a5a923 100644 --- a/tests/fixtures/TestTable.ts +++ b/tests/fixtures/TestTable.ts @@ -209,4 +209,17 @@ export const mockInstance = new MockEntity({ binary: new Uint8Array([1, 2, 3]), }); +export const partialMockInstance = new MockEntity({ + string: 'string', + map: new Map([['1', '2']]), +} as MockEntityProps); +// @ts-expect-error Values are set to undefined on purpose +partialMockInstance.strDate = undefined; +// @ts-expect-error Values are set to undefined on purpose +partialMockInstance.numDate = undefined; +// @ts-expect-error Values are set to undefined on purpose +partialMockInstance.createdAt = undefined; +// @ts-expect-error Values are set to undefined on purpose +partialMockInstance.updatedAt = undefined; + vi.useRealTimers(); From 48f1b12a4ed369a52a8e616bd244419c3dada0c5 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 19:41:30 +0200 Subject: [PATCH 03/13] Update entity conversion tests --- tests/unit/entity/helpers/converters.test.ts | 33 +++++++++++++++++--- tests/unit/entity/index.test.ts | 8 ++--- tests/unit/query/index.test.ts | 12 +++---- tests/unit/scan/index.test.ts | 2 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/tests/unit/entity/helpers/converters.test.ts b/tests/unit/entity/helpers/converters.test.ts index 5e9f31c..ecb47e1 100644 --- a/tests/unit/entity/helpers/converters.test.ts +++ b/tests/unit/entity/helpers/converters.test.ts @@ -11,7 +11,13 @@ import { } from '@lib/entity/helpers/converters'; import * as transformValuesHelpers from '@lib/entity/helpers/transformValues'; -import { mockDate, MockEntity, mockInstance, TestTableMetadata } from '../../../fixtures/TestTable'; +import { + mockDate, + MockEntity, + mockInstance, + partialMockInstance, + TestTableMetadata, +} from '../../../fixtures/TestTable'; const metadata = { tableName: 'test-table', @@ -92,6 +98,16 @@ const mockEntityAttributes = { propertyName: 'binary', type: Uint8Array, }, + numDate: { + propertyName: 'numDate', + type: Number, + role: 'date', + }, + strDate: { + propertyName: 'strDate', + type: String, + role: 'date', + }, } as any as AttributesMetadata; const dynamoObject = { @@ -107,6 +123,13 @@ const dynamoObject = { array: { L: [{ S: '1' }, { S: '2' }] }, boolean: { BOOL: true }, binary: { B: new Uint8Array([1, 2, 3]) }, + strDate: { S: mockDate.toISOString() }, + numDate: { N: mockDate.getTime().toString() }, +}; + +const partialDynamoObject = { + string: { S: 'string' }, + map: { M: { '1': { S: '2' } } }, }; describe('Converters entity helpers', () => { @@ -141,15 +164,17 @@ describe('Converters entity helpers', () => { getEntityMetadataSpy.mockReturnValue(metadata as any); expect(convertAttributeValuesToEntity(MockEntity, dynamoObject)).toEqual(mockInstance); - expect(truncateValueSpy).toBeCalledTimes(16); + expect(truncateValueSpy).toBeCalledTimes(18); }); test('Should return object in dynamode format', async () => { getEntityAttributesSpy.mockReturnValue(mockEntityAttributes); getEntityMetadataSpy.mockReturnValue(metadata as any); - expect(convertAttributeValuesToEntity(MockEntity, dynamoObject)).toEqual(mockInstance); - expect(truncateValueSpy).toBeCalledTimes(16); + expect(convertAttributeValuesToEntity(MockEntity, partialDynamoObject, ['map', 'string'])).toEqual( + partialMockInstance, + ); + expect(truncateValueSpy).toBeCalledTimes(18); }); }); diff --git a/tests/unit/entity/index.test.ts b/tests/unit/entity/index.test.ts index f825520..c1e1ddd 100644 --- a/tests/unit/entity/index.test.ts +++ b/tests/unit/entity/index.test.ts @@ -167,7 +167,7 @@ describe('entityManager', () => { Key: primaryKey, ConsistentRead: false, }); - expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, mockInstance); + expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, mockInstance, undefined); }); test("Should throw an error if item wasn't found", async () => { @@ -928,8 +928,8 @@ describe('entityManager', () => { }, }); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(2); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, mockInstance); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, testTableInstance); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, mockInstance, undefined); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, testTableInstance, undefined); }); test('Should return dynamode result (one item found)', async () => { @@ -958,7 +958,7 @@ describe('entityManager', () => { }, }); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(1); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, mockInstance); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, mockInstance, undefined); }); test('Should return dynamode result with unprocessed keys', async () => { diff --git a/tests/unit/query/index.test.ts b/tests/unit/query/index.test.ts index 927f1bd..f06a5ec 100644 --- a/tests/unit/query/index.test.ts +++ b/tests/unit/query/index.test.ts @@ -250,7 +250,7 @@ describe('Query', () => { expect(timeoutSpy).not.toBeCalled(); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(1); - expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, { key: 'value' }); + expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, { key: 'value' }, []); expect(convertAttributeValuesToLastKeySpy).toBeCalledTimes(1); expect(convertAttributeValuesToLastKeySpy).toBeCalledWith(MockEntity, { partitionKey: 'lastValue', @@ -281,9 +281,9 @@ describe('Query', () => { expect(timeoutSpy).toBeCalledTimes(3); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(3); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, { key: 'value1' }); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, { key: 'value2' }); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(3, MockEntity, { key: 'value3' }); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, { key: 'value1' }, []); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, { key: 'value2' }, []); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(3, MockEntity, { key: 'value3' }, []); expect(convertAttributeValuesToLastKeySpy).not.toBeCalled(); }); @@ -309,8 +309,8 @@ describe('Query', () => { expect(timeoutSpy).toBeCalledTimes(2); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(2); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, { key: 'value1' }); - expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, { key: 'value2' }); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, { key: 'value1' }, []); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, { key: 'value2' }, []); expect(convertAttributeValuesToLastKeySpy).toBeCalledTimes(1); expect(convertAttributeValuesToLastKeySpy).toBeCalledWith(MockEntity, { key: 'value2' }); }); diff --git a/tests/unit/scan/index.test.ts b/tests/unit/scan/index.test.ts index d0e20db..f30c7f9 100644 --- a/tests/unit/scan/index.test.ts +++ b/tests/unit/scan/index.test.ts @@ -139,7 +139,7 @@ describe('Scan', () => { }); expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(1); - expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, { key: 'value' }); + expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, { key: 'value' }, []); expect(convertAttributeValuesToLastKeySpy).toBeCalledTimes(1); expect(convertAttributeValuesToLastKeySpy).toBeCalledWith(MockEntity, { partitionKey: 'lastValue', From 46032fb40790a6f2af29bdc57fb1d5af5ff1b345 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 19:42:25 +0200 Subject: [PATCH 04/13] Remove debug logging from entity attribute conversion function --- lib/entity/helpers/converters.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/entity/helpers/converters.ts b/lib/entity/helpers/converters.ts index a204620..4867432 100644 --- a/lib/entity/helpers/converters.ts +++ b/lib/entity/helpers/converters.ts @@ -50,8 +50,6 @@ export function convertEntityToAttributeValues( const dynamoObject: GenericObject = {}; const attributes = Dynamode.storage.getEntityAttributes(entity.name); - console.log(`%%% attributes`, attributes); - Object.values(attributes).forEach((attribute) => { dynamoObject[attribute.propertyName] = transformValue( entity, From 3e2dd2241639859b421b98c20ddd52416c796af0 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 19:43:07 +0200 Subject: [PATCH 05/13] Refactor transformValueSpy to handle date attributes based on their defined types in entity attributes --- tests/unit/entity/helpers/converters.test.ts | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/unit/entity/helpers/converters.test.ts b/tests/unit/entity/helpers/converters.test.ts index ecb47e1..a366d5f 100644 --- a/tests/unit/entity/helpers/converters.test.ts +++ b/tests/unit/entity/helpers/converters.test.ts @@ -148,9 +148,24 @@ describe('Converters entity helpers', () => { truncateValueSpy.mockImplementation((_1, _2, v) => v); transformValueSpy = vi.spyOn(transformValuesHelpers, 'transformValue'); - transformValueSpy.mockImplementation((_1, k, v) => - v instanceof Date ? (k === 'updatedAt' ? v.getTime() : v.toISOString()) : v, - ); + transformValueSpy.mockImplementation((entity, k, v) => { + if (v instanceof Date) { + const attributes = Dynamode.storage.getEntityAttributes(entity.name); + const attribute = attributes[k as string]; + + if (attribute?.role === 'date') { + switch (attribute.type) { + case String: + return v.toISOString(); + case Number: + return v.getTime(); + default: + return v; + } + } + } + return v; + }); }); afterEach(() => { @@ -192,7 +207,7 @@ describe('Converters entity helpers', () => { getEntityAttributesSpy.mockReturnValue(mockEntityAttributes); expect(convertEntityToAttributeValues(MockEntity, mockInstance)).toEqual(dynamoObject); - expect(transformValueSpy).toBeCalledTimes(16); + expect(transformValueSpy).toBeCalledTimes(18); }); }); From 10f07c57515c52b619c1dd03360557928828dc33 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 20:05:39 +0200 Subject: [PATCH 06/13] Add tests for entityManager to handle attribute selection in get and batchGet methods --- tests/unit/entity/index.test.ts | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/unit/entity/index.test.ts b/tests/unit/entity/index.test.ts index c1e1ddd..f697e1b 100644 --- a/tests/unit/entity/index.test.ts +++ b/tests/unit/entity/index.test.ts @@ -170,6 +170,25 @@ describe('entityManager', () => { expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, mockInstance, undefined); }); + test('Should return dynamode result with attributes (item found)', async () => { + buildGetProjectionExpressionSpy.mockReturnValue({}); + getItemMock.mockResolvedValue({ Item: mockInstance }); + convertAttributeValuesToEntitySpy.mockImplementation((_, item) => item as any); + + await expect(MockEntityManager.get(primaryKey, { attributes: ['string', 'number'] })).resolves.toEqual( + mockInstance, + ); + + expect(buildGetProjectionExpressionSpy).toBeCalledWith(['string', 'number']); + expect(convertPrimaryKeyToAttributeValuesSpy).toBeCalledWith(MockEntity, primaryKey); + expect(getItemMock).toBeCalledWith({ + TableName: TEST_TABLE_NAME, + Key: primaryKey, + ConsistentRead: false, + }); + expect(convertAttributeValuesToEntitySpy).toBeCalledWith(MockEntity, mockInstance, ['string', 'number']); + }); + test("Should throw an error if item wasn't found", async () => { buildGetProjectionExpressionSpy.mockReturnValue({}); getItemMock.mockResolvedValue({ Item: undefined }); @@ -932,6 +951,44 @@ describe('entityManager', () => { expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, testTableInstance, undefined); }); + test('Should return dynamode result with attributes (all items found)', async () => { + buildGetProjectionExpressionSpy.mockReturnValue({}); + batchGetItem.mockResolvedValue({ + Responses: { + [TEST_TABLE_NAME]: [mockInstance, testTableInstance], + UnprocessedKeys: undefined, + }, + }); + convertAttributeValuesToEntitySpy.mockImplementation((_, item) => item as any); + + await expect( + MockEntityManager.batchGet([primaryKey, primaryKey], { attributes: ['string', 'number'] }), + ).resolves.toEqual({ + items: [mockInstance, testTableInstance], + unprocessedKeys: [], + }); + + expect(buildGetProjectionExpressionSpy).toBeCalledWith(['string', 'number']); + expect(convertPrimaryKeyToAttributeValuesSpy).toBeCalledWith(MockEntity, primaryKey); + expect(batchGetItem).toBeCalledWith({ + RequestItems: { + [TEST_TABLE_NAME]: { + Keys: [primaryKey, primaryKey], + ConsistentRead: false, + }, + }, + }); + expect(convertAttributeValuesToEntitySpy).toBeCalledTimes(2); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(1, MockEntity, mockInstance, [ + 'string', + 'number', + ]); + expect(convertAttributeValuesToEntitySpy).toHaveBeenNthCalledWith(2, MockEntity, testTableInstance, [ + 'string', + 'number', + ]); + }); + test('Should return dynamode result (one item found)', async () => { buildGetProjectionExpressionSpy.mockReturnValue({}); batchGetItem.mockResolvedValue({ From c70fae7f7fde844bc899b51c9951786110b2dd23 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 20:15:07 +0200 Subject: [PATCH 07/13] Add CHANGELOG.md to document project updates and notable changes --- CHANGELOG.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..67db18e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- JSDoc documentation was added to the project ([#40](https://github.com/blazejkustra/dynamode/pull/40)) +- Enhanced entity conversion to handle attribute selection in EntityManager `get()` and `batchGet()` methods + +### Changed +- Bump AWS SDK packages to version 3.883.0 & improve type safety ([#42](https://github.com/blazejkustra/dynamode/pull/40)) + +### Fixed +- Fix import example in documentation ([#37](https://github.com/blazejkustra/dynamode/pull/37)) - Thanks @gmreburn! + +## [1.5.0] - 2024-08-15 + +### Added +- `@attribute.customName()` decorator to set custom names for entity attributes ([#33](https://github.com/blazejkustra/dynamode/pull/33)) +- Tests for `customName` decorator + +### Fixed +- Fixed entity renaming logic +- Improved code safety and error handling + +## [1.4.0] - 2024-07-21 + +### Added +- Support for multiple GSI decorators on the same attribute ([#28](https://github.com/blazejkustra/dynamode/pull/28), [#29](https://github.com/blazejkustra/dynamode/pull/29)) +- Allow GSI decorators to decorate both partition and sort keys interchangeably ([#31](https://github.com/blazejkustra/dynamode/pull/31)) + +### Changed +- Moved test fixtures to a dedicated catalog +- Improved test organization and coverage + +### Fixed +- Fixed issue where multiple attribute decorators could not be added to primary keys +- Fixed typecheck issues + +## [1.3.0] - 2024-06-23 + +### Added +- Support for decorating an attribute with multiple indexes ([#28](https://github.com/blazejkustra/dynamode/pull/28)) + +## [1.2.0] - 2024-04-17 + +### Added +- Binary data type support ([#26](https://github.com/blazejkustra/dynamode/pull/26)) + +## [1.1.0] - 2024-04-13 + +### Fixed +- Fixed `startAt` fails when querying secondary indices + +## [1.0.0] - 2024-03-24 + +### Added +- 🎉 Dynamode is now out of beta! +- DynamoDB stream support ([#21](https://github.com/blazejkustra/dynamode/pull/21)) + +## Earlier Versions + +For changes in earlier versions (< 1.0.0), please refer to the [Git commit history](https://github.com/blazejkustra/dynamode/commits/main). + From 9e8d6149bcebb9ee448dda2c8812088db0a36efc Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:04:33 +0200 Subject: [PATCH 08/13] Enhance EntityManager to support attribute selection in get and batchGet methods, improving type safety and flexibility in entity retrieval. --- lib/entity/entityManager.ts | 42 +++++++++++++++++++++---------------- lib/entity/types.ts | 37 +++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/lib/entity/entityManager.ts b/lib/entity/entityManager.ts index 833c466..97b16fc 100644 --- a/lib/entity/entityManager.ts +++ b/lib/entity/entityManager.ts @@ -38,6 +38,7 @@ import { EntityGetOptions, EntityKey, EntityPutOptions, + EntitySelectedAttributes, EntityTransactionDeleteOptions, EntityTransactionGetOptions, EntityTransactionPutOptions, @@ -170,10 +171,10 @@ export default function EntityManager, E extends typeof En * }); * ``` */ - function get( + function get> | undefined = undefined>( primaryKey: TablePrimaryKey, - options?: EntityGetOptions & { return?: 'default' }, - ): Promise>; + options?: EntityGetOptions & { return?: 'default' }, + ): Promise>; /** * Retrieves a single item from the table by its primary key, returning the raw AWS response. @@ -184,7 +185,7 @@ export default function EntityManager, E extends typeof En */ function get( primaryKey: TablePrimaryKey, - options: EntityGetOptions & { return: 'output' }, + options: EntityGetOptions & { return: 'output' }, ): Promise; /** @@ -196,7 +197,7 @@ export default function EntityManager, E extends typeof En */ function get( primaryKey: TablePrimaryKey, - options: EntityGetOptions & { return: 'input' }, + options: EntityGetOptions & { return: 'input' }, ): GetItemCommandInput; /** @@ -207,10 +208,10 @@ export default function EntityManager, E extends typeof En * @returns A promise that resolves to the entity instance, raw AWS response, or command input * @throws {NotFoundError} When the item is not found and return type is 'default' */ - function get( + function get> | undefined = undefined>( primaryKey: TablePrimaryKey, - options?: EntityGetOptions, - ): Promise | GetItemCommandOutput> | GetItemCommandInput { + options?: EntityGetOptions, + ): Promise | GetItemCommandOutput> | GetItemCommandInput { const { projectionExpression, attributeNames } = buildGetProjectionExpression(options?.attributes); const commandInput: GetItemCommandInput = { @@ -237,7 +238,10 @@ export default function EntityManager, E extends typeof En throw new NotFoundError(); } - return convertAttributeValuesToEntity(entity, result.Item, options?.attributes); + return convertAttributeValuesToEntity(entity, result.Item, options?.attributes) as EntitySelectedAttributes< + E, + Attributes + >; })(); } @@ -624,10 +628,10 @@ export default function EntityManager, E extends typeof En * }); * ``` */ - function batchGet( + function batchGet> | undefined = undefined>( primaryKeys: Array>, - options?: EntityBatchGetOptions & { return?: 'default' }, - ): Promise>; + options?: EntityBatchGetOptions & { return?: 'default' }, + ): Promise>; /** * Retrieves multiple items, returning the raw AWS response. @@ -638,7 +642,7 @@ export default function EntityManager, E extends typeof En */ function batchGet( primaryKeys: Array>, - options: EntityBatchGetOptions & { return: 'output' }, + options: EntityBatchGetOptions & { return: 'output' }, ): Promise; /** @@ -650,7 +654,7 @@ export default function EntityManager, E extends typeof En */ function batchGet( primaryKeys: Array>, - options: EntityBatchGetOptions & { return: 'input' }, + options: EntityBatchGetOptions & { return: 'input' }, ): BatchGetItemCommandInput; /** @@ -660,10 +664,10 @@ export default function EntityManager, E extends typeof En * @param options - Optional configuration for the batch get operation * @returns A promise that resolves to the batch get result, raw AWS response, or command input */ - function batchGet( + function batchGet> | undefined = undefined>( primaryKeys: Array>, - options?: EntityBatchGetOptions, - ): Promise | BatchGetItemCommandOutput> | BatchGetItemCommandInput { + options?: EntityBatchGetOptions, + ): Promise | BatchGetItemCommandOutput> | BatchGetItemCommandInput { const { projectionExpression, attributeNames } = buildGetProjectionExpression(options?.attributes); const commandInput: BatchGetItemCommandInput = { @@ -701,7 +705,9 @@ export default function EntityManager, E extends typeof En result.UnprocessedKeys?.[tableName]?.Keys?.map((key) => fromDynamo(key) as TablePrimaryKey) || []; return { - items: items.map((item) => convertAttributeValuesToEntity(entity, item, options?.attributes)), + items: items.map((item) => convertAttributeValuesToEntity(entity, item, options?.attributes)) as Array< + EntitySelectedAttributes + >, unprocessedKeys, }; })(); diff --git a/lib/entity/types.ts b/lib/entity/types.ts index 1ce7a0d..7d31648 100644 --- a/lib/entity/types.ts +++ b/lib/entity/types.ts @@ -1,4 +1,4 @@ -import { RequireAtLeastOne } from 'type-fest'; +import { NonEmptyTuple, RequireAtLeastOne } from 'type-fest'; import { BatchGetItemCommandInput, @@ -61,18 +61,34 @@ export type EntityKey = keyof EntityProperties exten */ export type EntityValue> = FlattenObject>[K]; +/** + * Helper type to select only specified attributes from an entity instance. + * If attributes are not specified, returns the full entity type. + * + * @template E - The entity class type + * @template Attributes - Array of attribute keys to select (optional) + */ +export type EntitySelectedAttributes< + E extends typeof Entity, + Attributes extends Array> | undefined = undefined, +> = + Attributes extends NonEmptyTuple> + ? Omit, Exclude, Attributes[number] | 'dynamodeEntity'>> + : InstanceType; + /** * Options for entity get operations. * * @template E - The entity class type + * @template Attributes - Array of attribute keys to select (optional) */ -export type EntityGetOptions = { +export type EntityGetOptions> | undefined> = { /** Additional DynamoDB input parameters */ extraInput?: Partial; /** Return type option */ return?: ReturnOption; /** Specific attributes to retrieve */ - attributes?: Array>; + attributes?: Attributes; /** Whether to use consistent read */ consistent?: boolean; }; @@ -178,10 +194,13 @@ export type BuildDeleteConditionExpression = { // entityManager.batchGet -export type EntityBatchGetOptions = { +export type EntityBatchGetOptions< + E extends typeof Entity, + Attributes extends ReadonlyArray> | undefined = undefined, +> = { extraInput?: Partial; return?: ReturnOption; - attributes?: Array>; + attributes?: Attributes; consistent?: boolean; }; @@ -191,8 +210,12 @@ export type EntityBatchDeleteOutput = { // entityManager.batchGet -export type EntityBatchGetOutput, E extends typeof Entity> = { - items: Array>; +export type EntityBatchGetOutput< + M extends Metadata, + E extends typeof Entity, + Attributes extends Array> | undefined = undefined, +> = { + items: Array>; unprocessedKeys: Array>; }; From 902d5375d98589695b0613255266dd87691bb0c0 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:04:43 +0200 Subject: [PATCH 09/13] Fix typecheck --- tests/e2e/entity/batchGet.test.ts | 8 ++++---- tests/e2e/entity/get.test.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/e2e/entity/batchGet.test.ts b/tests/e2e/entity/batchGet.test.ts index ff2d472..b5fa1f3 100644 --- a/tests/e2e/entity/batchGet.test.ts +++ b/tests/e2e/entity/batchGet.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; -import { MockEntityManager, TEST_TABLE_NAME, TestTableManager } from '../../fixtures/TestTable'; +import { MockEntity, MockEntityManager, TEST_TABLE_NAME, TestTableManager } from '../../fixtures/TestTable'; import { mockEntityFactory } from '../mockEntityFactory'; describe('EntityManager.batchGet', () => { @@ -84,15 +84,15 @@ describe('EntityManager.batchGet', () => { expect(mocks).not.toEqual([mock1, mock2, mock3]); expect(mocks[0].number).toEqual(1); expect(mocks[0].string).toEqual('string'); - expect(mocks[0].object).toEqual(undefined); + expect((mocks[0] as MockEntity).object).toEqual(undefined); expect(mocks[1].number).toEqual(1); expect(mocks[1].string).toEqual('string'); - expect(mocks[1].object).toEqual(undefined); + expect((mocks[1] as MockEntity).object).toEqual(undefined); expect(mocks[2].number).toEqual(1); expect(mocks[2].string).toEqual('string'); - expect(mocks[2].object).toEqual(undefined); + expect((mocks[2] as MockEntity).object).toEqual(undefined); }); }); }); diff --git a/tests/e2e/entity/get.test.ts b/tests/e2e/entity/get.test.ts index 5d1537c..ca72182 100644 --- a/tests/e2e/entity/get.test.ts +++ b/tests/e2e/entity/get.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import { NotFoundError } from '@lib/utils'; -import { MockEntityManager, TEST_TABLE_NAME, TestTableManager } from '../../fixtures/TestTable'; +import { MockEntity, MockEntityManager, TEST_TABLE_NAME, TestTableManager } from '../../fixtures/TestTable'; import { mockEntityFactory } from '../mockEntityFactory'; describe('EntityManager.get', () => { @@ -57,10 +57,10 @@ describe('EntityManager.get', () => { // Assert expect(mockEntityRetrieved.string).toEqual('string'); expect(mockEntityRetrieved.number).toEqual(1); - expect(mockEntityRetrieved.boolean).toEqual(undefined); - expect(mockEntityRetrieved.object).toEqual(undefined); - expect(mockEntityRetrieved.partitionKey).toEqual(undefined); - expect(mockEntityRetrieved.sortKey).toEqual(undefined); + expect((mockEntityRetrieved as MockEntity).boolean).toEqual(undefined); + expect((mockEntityRetrieved as MockEntity).object).toEqual(undefined); + expect((mockEntityRetrieved as MockEntity).partitionKey).toEqual(undefined); + expect((mockEntityRetrieved as MockEntity).sortKey).toEqual(undefined); }); }); }); From ac28c737d87442521a93f4c7b8b51b7f0600d52f Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:09:55 +0200 Subject: [PATCH 10/13] Add tests for EntitySelectedAttributes type --- tests/types/EntityKey.test.ts | 2 + tests/types/EntitySelectedAttributes.test.ts | 177 +++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/types/EntitySelectedAttributes.test.ts diff --git a/tests/types/EntityKey.test.ts b/tests/types/EntityKey.test.ts index c3fc524..8042dcb 100644 --- a/tests/types/EntityKey.test.ts +++ b/tests/types/EntityKey.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable unused-imports/no-unused-vars */ import { describe, expectTypeOf, test } from 'vitest'; import Entity from '@lib/entity'; diff --git a/tests/types/EntitySelectedAttributes.test.ts b/tests/types/EntitySelectedAttributes.test.ts new file mode 100644 index 0000000..cfbdecd --- /dev/null +++ b/tests/types/EntitySelectedAttributes.test.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable unused-imports/no-unused-vars */ +import { describe, expectTypeOf, test } from 'vitest'; + +import Entity from '@lib/entity'; +import { EntitySelectedAttributes } from '@lib/entity/types'; + +import { MockEntity } from '../fixtures/TestTable'; + +class User extends Entity { + id!: string; + name!: string; + email!: string; + age!: number; + address!: string; +} + +class NestedEntity extends Entity { + id!: string; + profile!: { + name: string; + bio?: string; + }; + tags!: Array; + metadata!: Record; +} + +describe('EntitySelectedAttributes type tests', () => { + test('Should return full InstanceType when Attributes is undefined', () => { + type Result = EntitySelectedAttributes; + expectTypeOf().toEqualTypeOf>(); + }); + + test('Should return full InstanceType when Attributes is not provided', () => { + type Result = EntitySelectedAttributes; + expectTypeOf().toEqualTypeOf>(); + }); + + test('Should return full InstanceType when Attributes is empty array type', () => { + type Result = EntitySelectedAttributes; + expectTypeOf().toEqualTypeOf>(); + }); + + test('Should narrow to single attribute plus dynamodeEntity', () => { + type Result = EntitySelectedAttributes; + + // Should have these properties + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + // Should NOT have these properties + expectTypeOf().not.toHaveProperty('name'); + expectTypeOf().not.toHaveProperty('email'); + expectTypeOf().not.toHaveProperty('age'); + expectTypeOf().not.toHaveProperty('address'); + }); + + test('Should narrow to multiple selected attributes plus dynamodeEntity', () => { + type Result = EntitySelectedAttributes; + + // Should have these properties + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + // Should NOT have these properties + expectTypeOf().not.toHaveProperty('age'); + expectTypeOf().not.toHaveProperty('address'); + }); + + test('Should preserve correct property types in narrowed result', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toMatchTypeOf<{ id: string; age: number; dynamodeEntity: string }>(); + }); + + test('Should work with nested object properties', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('profile'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + // Should NOT have these + expectTypeOf().not.toHaveProperty('tags'); + expectTypeOf().not.toHaveProperty('metadata'); + }); + + test('Should work with array properties', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('tags'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + expectTypeOf().not.toHaveProperty('profile'); + expectTypeOf().not.toHaveProperty('metadata'); + }); + + test('Should always include dynamodeEntity even if not specified', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toHaveProperty('dynamodeEntity'); + expectTypeOf().toEqualTypeOf(); + }); + + test('Should work with MockEntity complex structure', () => { + type Result = EntitySelectedAttributes< + typeof MockEntity, + ['partitionKey', 'sortKey', 'string', 'number', 'boolean'] + >; + + // Should have selected properties + expectTypeOf().toHaveProperty('partitionKey'); + expectTypeOf().toHaveProperty('sortKey'); + expectTypeOf().toHaveProperty('string'); + expectTypeOf().toHaveProperty('number'); + expectTypeOf().toHaveProperty('boolean'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + // Should NOT have unselected properties + expectTypeOf().not.toHaveProperty('object'); + expectTypeOf().not.toHaveProperty('array'); + expectTypeOf().not.toHaveProperty('map'); + expectTypeOf().not.toHaveProperty('set'); + expectTypeOf().not.toHaveProperty('binary'); + expectTypeOf().not.toHaveProperty('GSI_1_PK'); + }); + + test('Should work with all properties selected', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('age'); + expectTypeOf().toHaveProperty('address'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + }); + + test('Should narrow nested object paths correctly', () => { + type Result = EntitySelectedAttributes; + + expectTypeOf().toHaveProperty('object'); + expectTypeOf().toHaveProperty('dynamodeEntity'); + + expectTypeOf().not.toHaveProperty('string'); + expectTypeOf().not.toHaveProperty('number'); + expectTypeOf().not.toHaveProperty('array'); + }); + + test('Should work with different attribute types', () => { + type StringOnly = EntitySelectedAttributes; + expectTypeOf().toMatchTypeOf<{ name: string; dynamodeEntity: string }>(); + + type NumberOnly = EntitySelectedAttributes; + expectTypeOf().toMatchTypeOf<{ age: number; dynamodeEntity: string }>(); + + type Mixed = EntitySelectedAttributes; + expectTypeOf().toMatchTypeOf<{ name: string; age: number; dynamodeEntity: string }>(); + }); + + test('Should maintain instance type structure', () => { + type Result = EntitySelectedAttributes; + + // Result should still be an object type + expectTypeOf().toBeObject(); + + // Should not be the full User instance + expectTypeOf().not.toEqualTypeOf>(); + + // But should be assignable from a partial User with selected fields + type Partial = Pick, 'id' | 'name' | 'dynamodeEntity'>; + expectTypeOf().toMatchTypeOf(); + }); +}); From f896db25727fad8b52b9278c6515bc880bd40260 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:11:55 +0200 Subject: [PATCH 11/13] 1.6.0-rc.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cc6d52..5b10586 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dynamode", - "version": "1.5.1-rc.0", + "version": "1.6.0-rc.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dynamode", - "version": "1.5.1-rc.0", + "version": "1.6.0-rc.0", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "^3.883.0", diff --git a/package.json b/package.json index bfc2e97..c3c9440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dynamode", - "version": "1.5.1-rc.0", + "version": "1.6.0-rc.0", "description": "Dynamode is a modeling tool for Amazon's DynamoDB", "main": "index.js", "types": "index.d.ts", From 7723e25d4ed61906047127f12c752537808b73e9 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:24:09 +0200 Subject: [PATCH 12/13] Refactor attribute filtering in convertAttributeValuesToEntity to exclude nested attributes --- lib/entity/helpers/converters.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/entity/helpers/converters.ts b/lib/entity/helpers/converters.ts index 4867432..745ae95 100644 --- a/lib/entity/helpers/converters.ts +++ b/lib/entity/helpers/converters.ts @@ -29,11 +29,14 @@ export function convertAttributeValuesToEntity( const instance = new entity(object) as InstanceType; if (selectedAttributes && selectedAttributes.length > 0) { + // Remove nested attributes from the selected attributes + const selectedAttributesSet = new Set([ + 'dynamodeEntity', + ...selectedAttributes.map((attribute) => attribute.split('.')[0]), + ]); + Object.values(attributes) - .filter( - (attribute) => - !selectedAttributes.includes(attribute.propertyName) && attribute.propertyName !== 'dynamodeEntity', - ) + .filter((attribute) => !selectedAttributesSet.has(attribute.propertyName)) .forEach((attribute) => { // @ts-expect-error undefined is not assignable to every Entity's property instance[attribute.propertyName] = undefined; From ef80f2423838cfd1515659e1fe7a8f4969b97f87 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 20 Oct 2025 21:24:41 +0200 Subject: [PATCH 13/13] 1.6.0-rc.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b10586..527885d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dynamode", - "version": "1.6.0-rc.0", + "version": "1.6.0-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dynamode", - "version": "1.6.0-rc.0", + "version": "1.6.0-rc.1", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "^3.883.0", diff --git a/package.json b/package.json index c3c9440..46697db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dynamode", - "version": "1.6.0-rc.0", + "version": "1.6.0-rc.1", "description": "Dynamode is a modeling tool for Amazon's DynamoDB", "main": "index.js", "types": "index.d.ts",