Repository: jeremydaly/dynamodb-toolbox (ts)

Issue:
[feature] $append behaviour on undefined list
This is a question/suggestion about the libs default behaviour. 

When a list attribute is defined in the schema, but the property is not set in the item, and we call `$append` on said list, should the default behaviour create the list?

This behaviour can be set using:

`list_append(if_not_exists())`

[Here](https://stackoverflow.com/a/35001736/200653) is an SO question/answer which uses this behaviour. 

Current behaviour, dynamo will throw an error as the property is undefined and the `list_append` can't append to undefined.

It would make sense that the list is created and set when it is undefined on the item.

E.g (excuse the pseudocode)

Given this definition:

```
const MyEntity = new Entity({
  ...
  attributes: {
    pk: { partitionKey: true, type: "number" },
    sk: { sortKey: true, type: "number" },
    notes: { type: "list" }
  }
});
```

The following should complete with no errors:

```
MyEntity.put({
  pk: 1,
  sk: 2
});

...

MyEntity.update({
  pk: 1,
  sk: 2,
  notes: {
    $append: "note 1"
  }
});
```

Resulting in:

```
{
  pk: 1,
  sk: 2,
  notes: [
    "note 1"
  ]
}
```

Any thoughts?

Pull request description:
feat(entity/update): allow append and prepend on undefined list 
closes #188

Patch:
diff --git a/src/classes/Entity/Entity.ts b/src/classes/Entity/Entity.ts
index 2125208a..6d897c52 100644
--- a/src/classes/Entity/Entity.ts
+++ b/src/classes/Entity/Entity.ts
@@ -15,6 +15,10 @@ import getKey from '../../lib/getKey'
 import parseConditions from '../../lib/expressionBuilder'
 import parseProjections from '../../lib/projectionBuilder'
 import { error, transformAttr, isEmpty, If, PreventKeys, FirstDefined } from '../../lib/utils'
+import {
+  ATTRIBUTE_VALUES_LIST_DEFAULT_KEY,
+  ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
+} from '../../constants'
 import type { ScanOptions, TableDef } from '../Table'
 import type {
   $GetOptions,
@@ -247,9 +251,9 @@ class Entity<
   } // end attribute
 
   // Parses the item
-  parse(input: {Item: unknown}, include?: string[]): Item
-  parse(input:  {Items: unknown[]} , include?: string[]): Item[]
-  parse(input: unknown[], include?: string[] ): Item[]
+  parse(input: { Item: unknown }, include?: string[]): Item
+  parse(input: { Items: unknown[] }, include?: string[]): Item[]
+  parse(input: unknown[], include?: string[]): Item[]
   parse(input: unknown, include?: string[]): Item
   parse(input: any, include: string[] = []): Item | Item[] {
     // TODO: 'include' needs to handle nested maps?
@@ -1058,8 +1062,8 @@ class Entity<
           }
 
           const attributeHasDefaultValue = schema.attributes[attrs[i]].default !== undefined
-          if(attributeHasDefaultValue) {
-error(`'${attrs[i]}' has a default value and cannot be removed`)
+          if (attributeHasDefaultValue) {
+            error(`'${attrs[i]}' has a default value and cannot be removed`)
           }
 
           // Grab the attribute name and add to REMOVE and names
@@ -1113,14 +1117,22 @@ error(`'${attrs[i]}' has a default value and cannot be removed`)
           // if list and appending or prepending
         } else if (mapping.type === 'list' && (data[field]?.$append || data[field]?.$prepend)) {
           if (data[field].$append) {
-            SET.push(`#${field} = list_append(#${field},:${field})`)
+            SET.push(
+              `#${field} = list_append(if_not_exists(#${field}, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}) ,:${field})`
+            )
             values[`:${field}`] = validateType(mapping, field, data[field].$append)
           } else {
-            SET.push(`#${field} = list_append(:${field},#${field})`)
+            SET.push(
+              `#${field} = list_append(:${field}, if_not_exists(#${field}, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}))`
+            )
             values[`:${field}`] = validateType(mapping, field, data[field].$prepend)
           }
+
           // Add field to names
           names[`#${field}`] = field
+
+          values[`:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`] = ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
+
           // if a list and updating by index
         } else if (
           mapping.type === 'list' &&
@@ -1157,13 +1169,28 @@ error(`'${attrs[i]}' has a default value and cannot be removed`)
                   REMOVE.push(`${path}`)
                 } else if (input.$add) {
                   ADD.push(`${path} :${value}`)
+
                   values[`:${value}`] = input.$add
                 } else if (input.$append) {
-                  SET.push(`${path} = list_append(${path},:${value})`)
+                  SET.push(
+                    `${path} = list_append(if_not_exists(${path}, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}), :${value})`
+                  )
+
                   values[`:${value}`] = input.$append
+                  // add default list value
+                  values[
+                    `:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`
+                  ] = ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
                 } else if (input.$prepend) {
-                  SET.push(`${path} = list_append(:${value},${path})`)
+                  SET.push(
+                    `${path} = list_append(:${value}, if_not_exists(${path}, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}))`
+                  )
+
                   values[`:${value}`] = input.$prepend
+                  // add default list value
+                  values[
+                    `:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`
+                  ] = ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
                 } else if (input.$remove) {
                   // console.log('REMOVE:',input.$remove);
                   input.$remove.forEach((i: number) => {
@@ -1174,6 +1201,7 @@ error(`'${attrs[i]}' has a default value and cannot be removed`)
                   })
                 } else {
                   SET.push(`${path} = :${value}`)
+
                   values[`:${value}`] = input
                 }
 
@@ -1185,6 +1213,7 @@ error(`'${attrs[i]}' has a default value and cannot be removed`)
                       )
                     }
                     SET.push(`${path}[${i}] = :${value}_${i}`)
+
                     values[`:${value}_${i}`] = input.$set[i]
                   })
                 }
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 00000000..ec6f2e0d
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,2 @@
+export const ATTRIBUTE_VALUES_LIST_DEFAULT_KEY = '_ld'
+export const ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE = []


Test patch:
diff --git a/src/__tests__/entity.update.unit.test.ts b/src/__tests__/entity.update.unit.test.ts
index 00aabf25..542fc79e 100644
--- a/src/__tests__/entity.update.unit.test.ts
+++ b/src/__tests__/entity.update.unit.test.ts
@@ -1,3 +1,7 @@
+import {
+  ATTRIBUTE_VALUES_LIST_DEFAULT_KEY,
+  ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
+} from '../constants'
 import { Table, Entity } from '../index'
 import { DocumentClient } from './bootstrap-tests'
 
@@ -99,8 +103,13 @@ const TestEntityGSI = new Entity({
 
 describe('update', () => {
   it('creates default update', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({ email: 'test-pk', sort: 'test-sk' })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({ email: 'test-pk', sort: 'test-sk' })
 
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et)'
@@ -134,12 +143,17 @@ describe('update', () => {
   })
 
   it('creates update with multiple fields (default types)', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        test_string: 'test string'
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_string: 'test string'
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = :test_string, #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et)'
     )
@@ -165,7 +179,9 @@ describe('update', () => {
   })
 
   it('fails when removing fields with default values', () => {
-    expect(()=>TestEntity.updateParams({ email: 'test-pk', sort: 'test-sk', $remove: 'test_string' })).toThrow(`'test_string' has a default value and cannot be removed`);
+    expect(() =>
+      TestEntity.updateParams({ email: 'test-pk', sort: 'test-sk', $remove: 'test_string' })
+    ).toThrow(`'test_string' has a default value and cannot be removed`)
   })
 
   it('creates update that just removes a field', () => {
@@ -212,8 +228,13 @@ describe('update', () => {
   })
 
   it('creates update that just saves a composite field', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity2.updateParams({ email: 'test-pk', test_composite: 'test' })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity2.updateParams({ email: 'test-pk', test_composite: 'test' })
 
     expect(UpdateExpression).toBe('SET #test_composite = :test_composite')
     expect(ExpressionAttributeNames).toEqual({ '#test_composite': 'test_composite' })
@@ -307,13 +328,18 @@ describe('update', () => {
   })
 
   it('performs an add operation', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        test_number: { $add: 10 },
-        test_number_set_type: { $add: [1, 2, 3] }
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_number: { $add: 10 },
+      test_number_set_type: { $add: [1, 2, 3] }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et) ADD #test_number :test_number, #test_number_set_type :test_number_set_type'
     )
@@ -360,14 +386,19 @@ describe('update', () => {
   })
 
   it('performs a delete operation', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        pk: 'test-pk',
-        sk: 'test-sk',
-        test_string_set_type: { $delete: ['1', '2', '3'] },
-        // @ts-expect-error 💥 TODO: Improve list support
-        test_number_set_type: { $delete: [1, 2, 3] }
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      pk: 'test-pk',
+      sk: 'test-sk',
+      test_string_set_type: { $delete: ['1', '2', '3'] },
+      // @ts-expect-error 💥 TODO: Improve list support
+      test_number_set_type: { $delete: [1, 2, 3] }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et) DELETE #test_string_set_type :test_string_set_type, #test_number_set_type :test_number_set_type'
     )
@@ -394,13 +425,18 @@ describe('update', () => {
   })
 
   it('removes items from a list', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        // @ts-expect-error 💥 TODO: Improve list support
-        test_list: { $remove: [2, 3, 8] }
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      // @ts-expect-error 💥 TODO: Improve list support
+      test_list: { $remove: [2, 3, 8] }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et) REMOVE #test_list[2], #test_list[3], #test_list[8]'
     )
@@ -424,13 +460,18 @@ describe('update', () => {
   })
 
   it('updates specific items in a list', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        // @ts-expect-error 💥 TODO: Improve list support
-        test_list: { 2: 'Test2', 5: 'Test5' }
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      // @ts-expect-error 💥 TODO: Improve list support
+      test_list: { 2: 'Test2', 5: 'Test5' }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_list[2] = :test_list_2, #test_list[5] = :test_list_5'
     )
@@ -456,15 +497,20 @@ describe('update', () => {
   })
 
   it('appends and prepends data to a list', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        test_list: { $append: [1,2,3] },
-        test_list_coerce: { $prepend: [1, 2, 3] }
-      })
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_list: { $append: [1, 2, 3] },
+      test_list_coerce: { $prepend: [1, 2, 3] }
+    })
     expect(UpdateExpression).toBe(
-      'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_list = list_append(#test_list,:test_list), #test_list_coerce = list_append(:test_list_coerce,#test_list_coerce)'
+      `SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_list = list_append(if_not_exists(#test_list, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}) ,:test_list), #test_list_coerce = list_append(:test_list_coerce, if_not_exists(#test_list_coerce, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}))`
     )
     expect(ExpressionAttributeNames).toEqual({
       '#test_string': 'test_string',
@@ -488,21 +534,92 @@ describe('update', () => {
     expect(TableName).toBe('test-table')
   })
 
-  it('updates nested data in a map', () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test-pk',
-        sort: 'test-sk',
-        test_map: {
-          $set: {
-            prop1: 'some value',
-            'prop2[1]': 'list value',
-            'prop2[4]': 'list value4',
-            'prop3.prop4': 'nested',
-            prop5: [1, 2, 3]
-          }
+  it('provides a default list value when appending/prepending a value to a list', () => {
+    let { TableName, Key, ExpressionAttributeValues } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_list: { $append: [1, 2, 3] },
+      test_list_coerce: { $prepend: [1, 2, 3] }
+    })
+
+    expect(TableName).toBe('test-table')
+    expect(Key).toEqual({ pk: 'test-pk', sk: 'test-sk' })
+
+    expect(ExpressionAttributeValues![`:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`]).toBe(
+      ATTRIBUTE_VALUES_LIST_DEFAULT_VALUE
+    )
+  })
+
+  it("doesn't provide a default list value when not appending/prepending a value to a list", () => {
+    let { TableName, Key, ExpressionAttributeValues } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk'
+    })
+
+    expect(TableName).toBe('test-table')
+    expect(Key).toEqual({ pk: 'test-pk', sk: 'test-sk' })
+
+    expect(ExpressionAttributeValues).not.toHaveProperty(ATTRIBUTE_VALUES_LIST_DEFAULT_KEY)
+  })
+
+  it("doesn't provide a default list value when not appending/prepending a value to a nested list within a map.", () => {
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_map: {
+        $set: {
+          prop1: 'some-value'
         }
+      }
+    })
+
+    expect(Key).toEqual({ pk: 'test-pk', sk: 'test-sk' })
+    expect(TableName).toBe('test-table')
+
+    expect(UpdateExpression).toBe(
+      'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_map.#test_map_prop1 = :test_map_prop1'
+    )
+
+    expect(ExpressionAttributeNames).toEqual(
+      expect.objectContaining({
+        '#test_map': 'test_map',
+        '#test_map_prop1': 'prop1'
+      })
+    )
+    expect(ExpressionAttributeValues).toEqual(
+      expect.objectContaining({
+        ':test_map_prop1': 'some-value'
       })
+    )
+    expect(ExpressionAttributeValues).not.toHaveProperty(`:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`)
+  })
+
+  it('updates nested data in a map', () => {
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_map: {
+        $set: {
+          prop1: 'some value',
+          'prop2[1]': 'list value',
+          'prop2[4]': 'list value4',
+          'prop3.prop4': 'nested',
+          prop5: [1, 2, 3]
+        }
+      }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_map.#test_map_prop1 = :test_map_prop1, #test_map.#test_map_prop2[1] = :test_map_prop2_1, #test_map.#test_map_prop2[4] = :test_map_prop2_4, #test_map.#test_map_prop3.#test_map_prop3_prop4 = :test_map_prop3_prop4, #test_map.#test_map_prop5 = :test_map_prop5'
     )
@@ -532,15 +649,71 @@ describe('update', () => {
     expect(TableName).toBe('test-table')
   })
 
-  it('uses an alias', async () => {
-    let { TableName, Key, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues } =
-      TestEntity.updateParams({
-        email: 'test@test.com',
-        sort: 'test-sk',
-        // @ts-expect-error 💥 TODO: Handle aliases
-        count: { $add: 10 },
-        contents: { a: 1, b: 2 }
+  it('supports appending/prepending nested lists in a map.', () => {
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test-pk',
+      sort: 'test-sk',
+      test_map: {
+        $set: {
+          prop1: {
+            $append: [1, 2, 3]
+          },
+          prop2: {
+            $prepend: [1, 2, 3]
+          },
+          'prop3.prop4': {
+            $append: [1, 2, 3]
+          }
+        }
+      }
+    })
+
+    expect(Key).toEqual({ pk: 'test-pk', sk: 'test-sk' })
+    expect(TableName).toBe('test-table')
+
+    expect(UpdateExpression).toBe(
+      `SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_map.#test_map_prop1 = list_append(if_not_exists(#test_map.#test_map_prop1, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}), :test_map_prop1), #test_map.#test_map_prop2 = list_append(:test_map_prop2, if_not_exists(#test_map.#test_map_prop2, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY})), #test_map.#test_map_prop3.#test_map_prop3_prop4 = list_append(if_not_exists(#test_map.#test_map_prop3.#test_map_prop3_prop4, :${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}), :test_map_prop3_prop4)`
+    )
+
+    expect(ExpressionAttributeNames).toEqual(
+      expect.objectContaining({
+        '#test_map': 'test_map',
+        '#test_map_prop1': 'prop1',
+        '#test_map_prop2': 'prop2',
+        '#test_map_prop3': 'prop3',
+        '#test_map_prop3_prop4': 'prop4'
       })
+    )
+    expect(ExpressionAttributeValues).toEqual(
+      expect.objectContaining({
+        ':test_map_prop1': [1, 2, 3],
+        ':test_map_prop2': [1, 2, 3],
+        ':test_map_prop3_prop4': [1, 2, 3],
+        [`:${ATTRIBUTE_VALUES_LIST_DEFAULT_KEY}`]: []
+      })
+    )
+  })
+
+  it('uses an alias', async () => {
+    let {
+      TableName,
+      Key,
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues
+    } = TestEntity.updateParams({
+      email: 'test@test.com',
+      sort: 'test-sk',
+      // @ts-expect-error 💥 TODO: Handle aliases
+      count: { $add: 10 },
+      contents: { a: 1, b: 2 }
+    })
     expect(UpdateExpression).toBe(
       'SET #test_string = if_not_exists(#test_string,:test_string), #test_number_coerce = if_not_exists(#test_number_coerce,:test_number_coerce), #test_boolean_default = if_not_exists(#test_boolean_default,:test_boolean_default), #_ct = if_not_exists(#_ct,:_ct), #_md = :_md, #_et = if_not_exists(#_et,:_et), #test_map = :test_map ADD #test_number :test_number'
     )
@@ -793,11 +966,15 @@ describe('update', () => {
   })
 
   it('sets conditions', () => {
-    let { TableName, ExpressionAttributeNames, ExpressionAttributeValues, ConditionExpression } =
-      TestEntity.updateParams(
-        { email: 'x', sort: 'y' },
-        { conditions: { attr: 'email', gt: 'test' } }
-      )
+    let {
+      TableName,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues,
+      ConditionExpression
+    } = TestEntity.updateParams(
+      { email: 'x', sort: 'y' },
+      { conditions: { attr: 'email', gt: 'test' } }
+    )
     expect(TableName).toBe('test-table')
     expect(ExpressionAttributeNames).toEqual({
       '#test_string': 'test_string',