Repository: jeremydaly/dynamodb-toolbox
Pull request #332 (2022-09-20 08:30:56)

feat(entity): add format function property
We needed a way to be able to format a value coming from DynamoDB before parsing it. That's why we added a format field option.

This is a new updated PR with the changes asked in #90 .

naorpeled: Hey @GeertWille,
will review this PR asap.

Thanks for creating it, you're awesome 😎 🙏 
GeertWille: > Overall looks great 🔥
> 
> Let's add a test that mocks the DDB returned value and make sure that `format` is acting as we're expecting.
> 
> After that test is added and my comments are addressed, the PR will be ready to merge imo.

Added a test for the format function

Patch:
diff --git a/docs/docs/entity/index.md b/docs/docs/entity/index.md
index 4d23adf1..8280ca90 100644
--- a/docs/docs/entity/index.md
+++ b/docs/docs/entity/index.md
@@ -84,6 +84,7 @@ 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 DDB output before sending it to the parser. This accepts two arguments, the value of the attribute and an object containing the whole 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`                |
 
diff --git a/src/classes/Entity/types.ts b/src/classes/Entity/types.ts
index e0be64af..b7665e9f 100644
--- a/src/classes/Entity/types.ts
+++ b/src/classes/Entity/types.ts
@@ -39,7 +39,8 @@ export type KeyAttributeDefinition = {
   suffix: string
   onUpdate: boolean
   dependsOn: string | string[]
-  transform: (value: any, data: any) => any
+  transform: (value: any, data: any) => any,
+  format: (value: any, data: any) => any,
   coerce: boolean
   // 💥 TODO: Are following options forbidden in KeyAttributeDefinitions ?
   save: never
@@ -78,6 +79,7 @@ export type PureAttributeDefinition = O.Partial<{
   dependsOn: string | string[]
   // 🔨 TOIMPROVE: Probably typable
   transform: (value: any, data: {}) => any
+  format: (value: any, data: {}) => any
   coerce: boolean
   save: boolean
   onUpdate: boolean
diff --git a/src/lib/formatItem.ts b/src/lib/formatItem.ts
index 571ccc7a..bb67092b 100644
--- a/src/lib/formatItem.ts
+++ b/src/lib/formatItem.ts
@@ -5,7 +5,7 @@
  */
 import { DocumentClient } from 'aws-sdk/clients/dynamodb'
 
-import { PureAttributeDefinition } from '../classes/Entity'
+import { KeyAttributeDefinition, PureAttributeDefinition } from '../classes/Entity'
 import validateTypes from './validateTypes'
 import { Linked } from './parseEntity'
 
@@ -64,13 +64,31 @@ export default (DocumentClient: DocumentClient) => (
     ) {
       item[field] = item[field].values
     }
+
+    const fieldValue =
+        attributes[field] &&
+        (attributes[field].prefix || attributes[field].suffix)
+          ? item[field]
+              .replace(
+                new RegExp(`^${escapeRegExp(attributes[field].prefix!)}`),
+                ""
+              )
+              .replace(
+                new RegExp(`${escapeRegExp(attributes[field].suffix!)}$`),
+                ""
+              )
+          : item[field];
+
+    const transformedValue =
+        attributes[field] && attributes[field].format
+          ? (
+              attributes[field] as Required<PureAttributeDefinition>
+            ).format(fieldValue, item)
+          : fieldValue
+
     return Object.assign(acc, {
       [(attributes[field] && attributes[field].alias) || field]:
-        attributes[field] && (attributes[field].prefix || attributes[field].suffix)
-          ? item[field]
-              .replace(new RegExp(`^${escapeRegExp(attributes[field].prefix!)}`), '')
-              .replace(new RegExp(`${escapeRegExp(attributes[field].suffix!)}$`), '')
-          : item[field]
+        transformedValue,
     })
   }, {})
 }
diff --git a/src/lib/parseMapping.ts b/src/lib/parseMapping.ts
index 29e89af1..23751c41 100644
--- a/src/lib/parseMapping.ts
+++ b/src/lib/parseMapping.ts
@@ -38,6 +38,9 @@ export default (
       case 'transform':
         if (typeof config[prop] !== 'function') error(`'${prop}' must be a function`)
         break
+      case "format":
+        if (typeof config[prop] !== "function") error(`'${prop}' must be a function`)
+        break
       case 'coerce':
       case 'onUpdate':
       case 'hidden':


Test patch:
diff --git a/src/__tests__/entities/entity.test.ts b/src/__tests__/entities/entity.test.ts
index cd3ffbb2..6bd96f4f 100644
--- a/src/__tests__/entities/entity.test.ts
+++ b/src/__tests__/entities/entity.test.ts
@@ -27,6 +27,10 @@ export = {
     test_string_set_type_coerce: { type: 'set', setType: 'string', coerce: true },
     test_number_set_type_coerce: { type: 'set', setType: 'number', coerce: true },
     test_binary: { type: 'binary' },
-    simple_string: 'string'
+    simple_string: 'string',
+    format_simple_string: {
+      type: "string",
+      format: (input: string) => input.toUpperCase(),
+    }
   }
 }
diff --git a/src/__tests__/entity-creation.unit.test.ts b/src/__tests__/entity-creation.unit.test.ts
index 9d0dddfc..db42624d 100644
--- a/src/__tests__/entity-creation.unit.test.ts
+++ b/src/__tests__/entity-creation.unit.test.ts
@@ -1,7 +1,7 @@
 // Require Table and Entity classes
-import '../classes/Table'
+import Table from '../classes/Table'
 import Entity from '../classes/Entity'
-import './bootstrap.test'
+import {DocumentClient} from './bootstrap.test'
 
 describe('Entity creation', () => {
   it('creates basic entity w/ defaults', async () => {
@@ -328,6 +328,45 @@ describe('Entity creation', () => {
     expect(result).toThrow(`Please provide a valid entity definition`)
   })
 
+  it("creates an attribute with an inverseTransformation function", async () => {
+    const TestTable = new Table({
+      name: "test-table",
+      partitionKey: "pk",
+      sortKey: "sk",
+      DocumentClient,
+    });
+
+    const TestEntity = new Entity({
+      name: "TestEnt",
+      attributes: {
+        pk: {
+          partitionKey: true,
+          format: (val) => val.toUpperCase(),
+          default: "pkDef",
+        },
+        test: {
+          format: (val: string, data: any) => {
+            return val.toUpperCase();
+          },
+          default: () => "defaultVal",
+        },
+        sk: { type: "string", sortKey: true },
+        testx: ["sk", 0],
+        testy: [
+          "sk",
+          1,
+          {
+            default: () => "testDefaultY",
+            format: (val) => {
+              return "__" + val.toUpperCase();
+            },
+          },
+        ],
+      },
+      table: TestTable,
+    })
+  })
+
   // it('creates entity w/ table', async () => {
 
   //   // Create basic table
diff --git a/src/__tests__/format.unit.test.ts b/src/__tests__/format.unit.test.ts
new file mode 100644
index 00000000..09640526
--- /dev/null
+++ b/src/__tests__/format.unit.test.ts
@@ -0,0 +1,46 @@
+import { DocumentClient } from './bootstrap.test'
+
+// Import Table and Entity classes
+import Table from '../classes/Table'
+import Entity from '../classes/Entity'
+
+// Create basic table
+const TestTable = new Table({
+  name: 'test-table',
+  partitionKey: 'pk',
+  DocumentClient
+})
+
+const FormatEntity = new Entity({
+  // Specify entity name
+  name: 'FormatEntity',
+
+  // Define attributes
+  attributes: {
+    pk: { type: 'string', partitionKey: true },
+    formatted_field: {
+      type: 'string', format: (value: string) => value.toUpperCase()
+    },
+    formatted_field_2: {
+      type: 'number', format: (value: number) => value.toFixed(1)
+    }
+  },
+  table: TestTable
+} as const)
+
+describe('format', () => {
+  it('format single item', () => {
+    let item = FormatEntity.parse({
+      pk: 'test@test.com',
+      formatted_field: 'test',
+      formatted_field_2: 100.123,
+      _et: 'TestEntity'
+    })
+    expect(item).toEqual({
+      pk: 'test@test.com',
+      formatted_field: 'TEST',
+      formatted_field_2: '100.1',
+      entity: 'TestEntity',
+    })
+  })
+}) // end format