Repository: jeremydaly/dynamodb-toolbox
Pull request #294 (2022-07-19 13:29:38)

Allow nested attr/size path (dot notation) for all condition types
Further to #288 - adds support (and tests) for nested `attr` and `size` paths (dot notation) for all other condition/operator types, i.e. `eq`, `ne`, `in`, `between` and so on.

rbdmorgan: @jeremydaly thanks for approving #288. As promised I've extended this to include support for all the other operators.

Will probably make sense to implement this dot notation support for filters as well - I could look at that next?

Patch:
diff --git a/src/lib/expressionBuilder.ts b/src/lib/expressionBuilder.ts
index a85cb3e0..0d6033eb 100644
--- a/src/lib/expressionBuilder.ts
+++ b/src/lib/expressionBuilder.ts
@@ -165,19 +165,23 @@ const parseClause = <EntityTable extends TableDef | undefined = undefined>(
   )
     error(`'entity' value of '${entity}' must be a string and a valid table Entity name`)
 
-  if (typeof attr === 'string' && attr.includes('.')) {
-    checkAttribute(attr, entity ? table[entity].schema.attributes : table.Table.attributes)
-    attr.split('.').forEach((part, i) => (names[`#attr${grp}_${i}`] = part))
+  const path =
+    typeof attr === 'string'
+      ? checkAttribute(attr, entity ? table[entity].schema.attributes : table.Table.attributes)
+      : typeof size === 'string'
+      ? checkAttribute(size, entity ? table[entity].schema.attributes : table.Table.attributes)
+      : error(`A string for 'attr' or 'size' is required for condition expressions`)
+
+  const pathParts = path.split('.')
+
+  if (pathParts.length === 1) {
+    names[`#attr${grp}`] = pathParts[0]
   } else {
-    // Add filter attribute to names
-    names[`#attr${grp}`] =
-      typeof attr === 'string'
-        ? checkAttribute(attr, entity ? table[entity].schema.attributes : table.Table.attributes)
-        : typeof size === 'string'
-        ? checkAttribute(size, entity ? table[entity].schema.attributes : table.Table.attributes)
-        : error(`A string for 'attr' or 'size' is required for condition expressions`)
+    pathParts.forEach((part, i) => (names[`#attr${grp}_${i}`] = part))
   }
 
+  const operand = Object.keys(names).join('.')
+
   // Parse clause operator and value
   let operator, value, f
   if (eq !== undefined) {
@@ -251,7 +255,7 @@ const parseClause = <EntityTable extends TableDef | undefined = undefined>(
         values[`:attr${grp}_0`] = value[0]
         values[`:attr${grp}_1`] = value[1]
         clause = `${
-          size ? `size(#attr${grp})` : `#attr${grp}`
+          size ? `size(${operand})` : `${operand}`
         } between :attr${grp}_0 and :attr${grp}_1`
       } else {
         error(`'between' conditions require an array with two values.`)
@@ -261,7 +265,7 @@ const parseClause = <EntityTable extends TableDef | undefined = undefined>(
       // Verify array input
       if (Array.isArray(value)) {
         // Add values and special key condition
-        clause = `#attr${grp} IN (${value
+        clause = `${operand} IN (${value
           .map((x, i) => {
             values[`:attr${grp}_${i}`] = x
             return `:attr${grp}_${i}`
@@ -272,30 +276,23 @@ const parseClause = <EntityTable extends TableDef | undefined = undefined>(
       }
     } else if (operator === 'EXISTS') {
       if (!attr) error(`'exists' conditions require an 'attr'.`)
-      const path =
-        typeof attr === 'string' && attr.includes('.')
-          ? attr
-              .split('.')
-              .map((_, i) => `#attr${grp}_${i}`)
-              .join('.')
-          : `#attr${grp}`
-      clause = value ? `attribute_exists(${path})` : `attribute_not_exists(${path})`
+      clause = value ? `attribute_exists(${operand})` : `attribute_not_exists(${operand})`
     } else {
       // Add value
       values[`:attr${grp}`] = value
       // If begins_with, add special key condition
       if (operator === 'BEGINS_WITH') {
         if (!attr) error(`'beginsWith' conditions require an 'attr'.`)
-        clause = `begins_with(#attr${grp},:attr${grp})`
+        clause = `begins_with(${operand},:attr${grp})`
       } else if (operator === 'CONTAINS') {
         if (!attr) error(`'contains' conditions require an 'attr'.`)
-        clause = `contains(#attr${grp},:attr${grp})`
+        clause = `contains(${operand},:attr${grp})`
       } else if (operator === 'ATTRIBUTE_TYPE') {
         if (!attr) error(`'type' conditions require an 'attr'.`)
         // TODO: validate/convert types
-        clause = `attribute_type(#attr${grp},:attr${grp})`
+        clause = `attribute_type(${operand},:attr${grp})`
       } else {
-        clause = `${size ? `size(#attr${grp})` : `#attr${grp}`} ${operator} :attr${grp}`
+        clause = `${size ? `size(${operand})` : `${operand}`} ${operator} :attr${grp}`
       }
     } // end if-else
 


Test patch:
diff --git a/src/__tests__/expressionBuilder.unit.test.ts b/src/__tests__/expressionBuilder.unit.test.ts
index 91235e63..825e6ed2 100644
--- a/src/__tests__/expressionBuilder.unit.test.ts
+++ b/src/__tests__/expressionBuilder.unit.test.ts
@@ -174,6 +174,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it('uses size value and checks nested entity attribute', () => {
+    let result = expressionBuilder({ size: 'a.b.c', eq: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('size(#attr1_0.#attr1_1.#attr1_2) = :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it('uses size value and checks table attribute', () => {
     let result = expressionBuilder({ size: 'a', eq: 'b' }, TestTable)
     expect(result.expression).toBe('size(#attr1) = :attr1')
@@ -181,6 +188,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it('uses size value and checks nested table attribute', () => {
+    let result = expressionBuilder({ size: 'a.b.c', eq: 'd' }, TestTable)
+    expect(result.expression).toBe('size(#attr1_0.#attr1_1.#attr1_2) = :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates an 'eq' clause`, () => {
     let result = expressionBuilder({ attr: 'a', eq: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 = :attr1')
@@ -188,6 +202,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates an 'eq' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', eq: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 = :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates an 'eq' clause with null/false values`, () => {
     let result = expressionBuilder({ attr: 'a', eq: false }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 = :attr1')
@@ -212,6 +233,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'ne' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', ne: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 <> :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates an 'ne' clause with null/false values`, () => {
     let result = expressionBuilder({ attr: 'a', ne: false }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 <> :attr1')
@@ -236,6 +264,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1_0': 'b', ':attr1_1': 'c' })
   })
 
+  it(`generates an 'in' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', in: ['d', 'e'] }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 IN (:attr1_0,:attr1_1)')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1_0': 'd', ':attr1_1': 'e' })
+  })
+
   it(`generates a 'lt' clause`, () => {
     let result = expressionBuilder({ attr: 'a', lt: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 < :attr1')
@@ -243,6 +278,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'lt' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', lt: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 < :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates a 'lte' clause`, () => {
     let result = expressionBuilder({ attr: 'a', lte: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 <= :attr1')
@@ -250,6 +292,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'lte' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', lte: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 <= :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates a 'gt' clause`, () => {
     let result = expressionBuilder({ attr: 'a', gt: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 > :attr1')
@@ -257,6 +306,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'gt' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', gt: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 > :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates a 'gte' clause`, () => {
     let result = expressionBuilder({ attr: 'a', gte: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 >= :attr1')
@@ -264,6 +320,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'gte' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', gte: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 >= :attr1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates a 'between' clause`, () => {
     let result = expressionBuilder({ attr: 'a', between: ['b', 'c'] }, TestTable, 'TestEntity')
     expect(result.expression).toBe('#attr1 between :attr1_0 and :attr1_1')
@@ -271,6 +334,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1_0': 'b', ':attr1_1': 'c' })
   })
 
+  it(`generates a 'between' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', between: ['d', 'e'] }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('#attr1_0.#attr1_1.#attr1_2 between :attr1_0 and :attr1_1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1_0': 'd', ':attr1_1': 'e' })
+  })
+
   it(`generates a 'between' clause with 'size'`, () => {
     let result = expressionBuilder({ size: 'a', between: ['b', 'c'] }, TestTable, 'TestEntity')
     expect(result.expression).toBe('size(#attr1) between :attr1_0 and :attr1_1')
@@ -278,6 +348,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1_0': 'b', ':attr1_1': 'c' })
   })
 
+  it(`generates a 'between' clause with 'size' for a nested attribute`, () => {
+    let result = expressionBuilder({ size: 'a.b.c', between: ['d', 'e'] }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('size(#attr1_0.#attr1_1.#attr1_2) between :attr1_0 and :attr1_1')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1_0': 'd', ':attr1_1': 'e' })
+  })
+
   it(`generates an 'exists' clause`, () => {
     let result = expressionBuilder({ attr: 'a', exists: true }, TestTable, 'TestEntity')
     expect(result.expression).toBe('attribute_exists(#attr1)')
@@ -316,6 +393,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'beginsWith' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', beginsWith: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('begins_with(#attr1_0.#attr1_1.#attr1_2,:attr1)')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`generates a 'type' clause`, () => {
     let result = expressionBuilder({ attr: 'a', type: 'b' }, TestTable, 'TestEntity')
     expect(result.expression).toBe('attribute_type(#attr1,:attr1)')
@@ -323,6 +407,13 @@ describe('expressionBuilder', () => {
     expect(result.values).toEqual({ ':attr1': 'b' })
   })
 
+  it(`generates a 'type' clause for a nested attribute`, () => {
+    let result = expressionBuilder({ attr: 'a.b.c', type: 'd' }, TestTable, 'TestEntity')
+    expect(result.expression).toBe('attribute_type(#attr1_0.#attr1_1.#attr1_2,:attr1)')
+    expect(result.names).toEqual({ '#attr1_0': 'a', '#attr1_1': 'b', '#attr1_2': 'c' })
+    expect(result.values).toEqual({ ':attr1': 'd' })
+  })
+
   it(`fails when 'between' value is not an array`, () => {
     // @ts-expect-error
     expect(() => expressionBuilder({ attr: 'a', between: 'b' }, TestTable, 'TestEntity')).toThrow(