Skip to main content

Maps and Lists

Work with nested objects (maps), lists, and sets in FluentDynamoDb using type-safe lambda expressions.

Overview

DynamoDB supports complex data types including maps (nested objects), lists, and sets. FluentDynamoDb provides type-safe lambda expression support for:

  • Filtering on nested map properties
  • Filtering on list elements by index
  • Updating nested map properties
  • List operations: append, prepend, update by index, remove by index
  • Set operations: add elements, delete elements

Supported Types

DynamoDB TypeC# TypeDescription
Map (M)Class with [DynamoDbEntity]Nested object with named attributes
List (L)List<T>Ordered collection, supports indexing
String Set (SS)HashSet<string>Unordered unique strings
Number Set (NS)HashSet<int>, HashSet<decimal>Unordered unique numbers
Important Limitation

Nested property access is NOT supported in key condition expressions. DynamoDB key conditions only support partition key and sort key attributes. Nested property access works in:

  • Filter expressions (.WithFilter())
  • Condition expressions (.Where() on Put/Update/Delete)
  • Update expressions (.Set())

Entity Definition with Nested Objects

Basic Nested Object

Use [DynamoDbEntity] for nested types and [DynamoDbMap] on the property:

// Nested type - use [DynamoDbEntity]
[DynamoDbEntity]
public partial class Address
{
[DynamoDbAttribute("street")]
public string Street { get; set; } = string.Empty;

[DynamoDbAttribute("city")]
public string City { get; set; } = string.Empty;

[DynamoDbAttribute("state")]
public string State { get; set; } = string.Empty;

[DynamoDbAttribute("zipCode")]
public string ZipCode { get; set; } = string.Empty;
}

// Parent entity - use [DynamoDbTable]
[DynamoDbTable("Customers")]
public partial class Customer
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string CustomerId { get; set; } = string.Empty;

[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;

// Nested object property
[DynamoDbMap]
[DynamoDbAttribute("address")]
public Address ShippingAddress { get; set; } = new();
}

Multi-Level Nesting

Nested types can contain their own nested types:

[DynamoDbEntity]
public partial class Country
{
[DynamoDbAttribute("code")]
public string Code { get; set; } = string.Empty;

[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;
}

[DynamoDbEntity]
public partial class Address
{
[DynamoDbAttribute("city")]
public string City { get; set; } = string.Empty;

[DynamoDbAttribute("state")]
public string State { get; set; } = string.Empty;

// Nested within nested
[DynamoDbMap]
[DynamoDbAttribute("country")]
public Country Country { get; set; } = new();
}

[DynamoDbTable("Orders")]
public partial class Order
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string OrderId { get; set; } = string.Empty;

[DynamoDbMap]
[DynamoDbAttribute("shippingAddress")]
public Address ShippingAddress { get; set; } = new();
}

Entity with Lists and Sets

[DynamoDbEntity]
public partial class LineItem
{
[DynamoDbAttribute("productId")]
public string ProductId { get; set; } = string.Empty;

[DynamoDbAttribute("quantity")]
public int Quantity { get; set; }

[DynamoDbAttribute("price")]
public decimal Price { get; set; }
}

[DynamoDbTable("Products")]
public partial class Product
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string ProductId { get; set; } = string.Empty;

[DynamoDbAttribute("name")]
public string Name { get; set; } = string.Empty;

// List of strings
[DynamoDbAttribute("tags")]
public List<string> Tags { get; set; } = new();

// Set of strings
[DynamoDbAttribute("categories")]
public HashSet<string> Categories { get; set; } = new();

// Set of numbers
[DynamoDbAttribute("relatedIds")]
public HashSet<int> RelatedProductIds { get; set; } = new();

// List of nested objects
[DynamoDbAttribute("lineItems")]
public List<LineItem> LineItems { get; set; } = new();
}

Filtering on Nested Properties

Single-Level Nested Property

// Filter by nested property in query
var customers = await table.Customers
.Query(x => x.CustomerId == tenantId) // Key condition
.WithFilter(x => x.ShippingAddress.City == "Seattle") // Filter on nested
.ToListAsync();

// Generated filter expression: #address.#city = :v0

Multi-Level Nested Property

// Filter by deeply nested property
var orders = await table.Orders
.Query(x => x.CustomerId == customerId)
.WithFilter(x => x.ShippingAddress.Country.Code == "US")
.ToListAsync();

// Generated: #shippingAddress.#country.#code = :v0

Comparison Operators

// Greater than
var highScoreItems = await table.Items
.Query(x => x.Category == category)
.WithFilter(x => x.Metrics.Score > 90)
.ToListAsync();

// String prefix
var westCoastCustomers = await table.Customers
.Query(x => x.TenantId == tenantId)
.WithFilter(x => x.ShippingAddress.ZipCode.StartsWith("98"))
.ToListAsync();

Logical Operators

// AND
var seattleWaCustomers = await table.Customers
.Query(x => x.TenantId == tenantId)
.WithFilter(x => x.ShippingAddress.City == "Seattle"
&& x.ShippingAddress.State == "WA")
.ToListAsync();

// OR
var pacificNwCustomers = await table.Customers
.Query(x => x.TenantId == tenantId)
.WithFilter(x => x.ShippingAddress.City == "Seattle"
|| x.ShippingAddress.City == "Portland")
.ToListAsync();

Filter on List Element by Index

// Filter by first element in list
var featuredItems = await table.Items
.Query(x => x.Category == "electronics")
.WithFilter(x => x.Tags[0] == "featured")
.ToListAsync();

// Generated: #tags[0] = :v0

Filter on Object Property in List

// Access property of object at list index
var ordersWithProduct = await table.Orders
.Query(x => x.CustomerId == customerId)
.WithFilter(x => x.LineItems[0].ProductId == productId)
.ToListAsync();

// Generated: #lineItems[0].#productId = :v0

Condition Expressions on Writes

Nested property access works in condition expressions for Put, Update, and Delete:

// Condition on Put
await table.Customers.Put(customer)
.Where(x => x.ShippingAddress.City == "Seattle")
.PutAsync();

// Condition on Update
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel { Status = "active" })
.Where(x => x.ShippingAddress.State == "WA")
.UpdateAsync();

// Condition on Delete
await table.Customers.Delete(customerId)
.Where(x => x.ShippingAddress.Country.Code == "US")
.DeleteAsync();

Updating Nested Properties

The source generator creates *UpdateModel types for entities with [DynamoDbMap] properties, enabling type-safe nested updates.

Update Single Nested Property

// Update just the city
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel
{
ShippingAddress = new AddressUpdateModel { City = "Portland" }
})
.UpdateAsync();

// Generated: SET #address.#city = :v0

Update Multiple Nested Properties

// Update multiple properties in nested object
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel
{
ShippingAddress = new AddressUpdateModel
{
City = "Portland",
State = "OR",
ZipCode = "97201"
}
})
.UpdateAsync();

// Generated: SET #address.#city = :v0, #address.#state = :v1, #address.#zipCode = :v2

Multi-Level Nested Updates

// Update deeply nested property
await table.Orders.Update(orderId)
.Set(x => new OrderUpdateModel
{
ShippingAddress = new AddressUpdateModel
{
Country = new CountryUpdateModel { Code = "CA" }
}
})
.UpdateAsync();

// Generated: SET #shippingAddress.#country.#code = :v0

Combined Top-Level and Nested Updates

// Update both top-level and nested properties
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel
{
Name = "John Doe", // Top-level
ShippingAddress = new AddressUpdateModel { City = "Portland" } // Nested
})
.UpdateAsync();

// Generated: SET #name = :v0, #address.#city = :v1

List Operations

FluentDynamoDb provides extension methods for common list operations. Import the namespace:

using Oproto.FluentDynamoDb.Expressions;

Append to List

Add elements to the end of a list:

// Append single element
await table.Products.Update(productId)
.Set(x => x.Tags.Append("new-tag"))
.UpdateAsync();

// Generated: SET #tags = list_append(#tags, :v0)

Append Multiple Elements

// Append multiple elements
await table.Products.Update(productId)
.Set(x => x.Tags.AppendRange(new[] { "tag1", "tag2", "tag3" }))
.UpdateAsync();

Prepend to List

Add elements to the beginning of a list:

// Prepend single element
await table.Products.Update(productId)
.Set(x => x.Tags.Prepend("priority-tag"))
.UpdateAsync();

// Generated: SET #tags = list_append(:v0, #tags)

Update Element by Index

Use the SetAt extension method to update an element at a specific index:

// Update element at specific index
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(0, "updated-first-tag"))
.UpdateAsync();

// Generated: SET #tags[0] = :v0

Remove Element by Index

Use the RemoveAt extension method to remove an element at a specific index:

// Remove element at specific index
await table.Products.Update(productId)
.Set(x => x.Tags.RemoveAt(2))
.UpdateAsync();

// Generated: REMOVE #tags[2]

Nested List Operations

List operations work with nested lists:

// Append to nested list
await table.Products.Update(productId)
.Set(x => x.Metadata.Keywords.Append("sale"))
.UpdateAsync();

// Generated: SET #metadata.#keywords = list_append(#metadata.#keywords, :v0)

// SetAt on nested list
await table.Products.Update(productId)
.Set(x => x.Metadata.Keywords.SetAt(0, "updated"))
.UpdateAsync();

// Generated: SET #metadata.#keywords[0] = :v0

// RemoveAt on nested list
await table.Products.Update(productId)
.Set(x => x.Metadata.Keywords.RemoveAt(1))
.UpdateAsync();

// Generated: REMOVE #metadata.#keywords[1]

Dynamic Index Support

List indices can be variables, method calls, or property accesses, not just constants. The index is evaluated at expression translation time.

Variable Index

int index = GetIndexToUpdate();
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(index, "updated"))
.UpdateAsync();

// Generated: SET #tags[N] = :v0 (where N is the evaluated index)

Method Call Index

await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(GetTargetIndex(), "updated"))
.UpdateAsync();

// Generated: SET #tags[N] = :v0 (where N is the result of GetTargetIndex())

Property Access Index

var config = GetConfig();
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(config.TargetIndex, "updated"))
.UpdateAsync();

// Generated: SET #tags[N] = :v0 (where N is config.TargetIndex)

Dynamic Index in Filter Expressions

Dynamic indices also work in filter and condition expressions:

int index = 0;
var items = await table.Items.Query(x => x.Category == category)
.WithFilter(x => x.Tags[index] == "featured")
.ToListAsync();

// Generated: #tags[0] = :v0

// Method call index in filter
var items = await table.Items.Query(x => x.Category == category)
.WithFilter(x => x.Tags[GetPrimaryTagIndex()] == "featured")
.ToListAsync();

// Property access index in filter
var config = GetConfig();
var items = await table.Items.Query(x => x.Category == category)
.WithFilter(x => x.Tags[config.PrimaryIndex] == "featured")
.ToListAsync();
Entity Parameter Restriction

The index expression cannot reference the entity parameter. The index must be evaluable without access to the entity being queried/updated.

// ❌ NOT ALLOWED - index references entity parameter
.WithFilter(x => x.Tags[x.PrimaryIndex] == "featured")
// Throws UnsupportedExpressionException: "List index cannot reference the entity parameter"

// ❌ NOT ALLOWED - index references entity parameter
.Set(x => x.Tags.SetAt(x.LastIndex, "updated"))
// Throws UnsupportedExpressionException

// ✅ ALLOWED - index is a local variable
int index = entity.PrimaryIndex; // Capture value first
.WithFilter(x => x.Tags[index] == "featured")

Index Validation

Indices are validated at translation time to ensure they are non-negative:

int index = -1;
// Throws ArgumentOutOfRangeException: "List index must be non-negative. Got: -1"
.Set(x => x.Tags.SetAt(index, "updated"))

Chaining List Operations

Multiple SetAt calls can be chained to update different indices in a single operation:

// Update multiple indices in one operation
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(0, "first").SetAt(1, "second").SetAt(2, "third"))
.UpdateAsync();

// Generated: SET #tags[0] = :v0, #tags[1] = :v1, #tags[2] = :v2
DynamoDB Overlapping Path Limitation

DynamoDB does NOT allow multiple operations on overlapping document paths in a single update expression.

Allowed Chaining Combinations

Multiple SetAt calls with different indices are allowed:

// ✅ ALLOWED - Multiple SetAt with different indices
.Set(x => x.Tags.SetAt(0, "a").SetAt(1, "b"))

Disallowed Chaining Combinations

The following combinations throw UnsupportedExpressionException due to overlapping paths:

// ❌ NOT ALLOWED - SetAt + Append on same list
.Set(x => x.Tags.SetAt(0, "a").Append("new"))
// Throws UnsupportedExpressionException: overlapping document paths

// ❌ NOT ALLOWED - SetAt + RemoveAt on same list
.Set(x => x.Tags.SetAt(0, "a").RemoveAt(1))
// Throws UnsupportedExpressionException: overlapping document paths

// ❌ NOT ALLOWED - Append + RemoveAt on same list
.Set(x => x.Tags.Append("new").RemoveAt(0))
// Throws UnsupportedExpressionException: overlapping document paths

To perform these operations, use separate update calls:

// ✅ Correct - separate update calls
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(0, "a"))
.UpdateAsync();

await table.Products.Update(productId)
.Set(x => x.Tags.Append("new"))
.UpdateAsync();

List Operations Reference

OperationMethodDynamoDB Expression
Append single.Append(item)SET #attr = list_append(#attr, :val)
Append multiple.AppendRange(items)SET #attr = list_append(#attr, :val)
Prepend single.Prepend(item)SET #attr = list_append(:val, #attr)
Prepend multiple.PrependRange(items)SET #attr = list_append(:val, #attr)
Update by index.SetAt(index, value)SET #attr[index] = :val
Remove by index.RemoveAt(index)REMOVE #attr[index]
Chained SetAt.SetAt(0, a).SetAt(1, b)SET #attr[0] = :v0, #attr[1] = :v1

Set Operations

Sets in DynamoDB are unordered collections of unique values. FluentDynamoDb supports string sets (HashSet<string>) and number sets (HashSet<int>, HashSet<decimal>, etc.).

Add to Set

// Add single element
await table.Products.Update(productId)
.Add(x => x.Categories, "electronics")
.UpdateAsync();

// Generated: ADD #categories :v0
// Where :v0 = { SS: ["electronics"] }

Add Multiple Elements

// Add multiple elements
await table.Products.Update(productId)
.Add(x => x.Categories, new[] { "electronics", "sale", "featured" })
.UpdateAsync();

Delete from Set

// Delete single element
await table.Products.Update(productId)
.Delete(x => x.Categories, "clearance")
.UpdateAsync();

// Generated: DELETE #categories :v0

Delete Multiple Elements

// Delete multiple elements
await table.Products.Update(productId)
.Delete(x => x.Categories, new[] { "clearance", "discontinued" })
.UpdateAsync();

Numeric Set Operations

// Add to number set
await table.Products.Update(productId)
.Add(x => x.RelatedProductIds, 42)
.UpdateAsync();

// Add multiple numbers
await table.Products.Update(productId)
.Add(x => x.RelatedProductIds, new[] { 100, 200, 300 })
.UpdateAsync();

Set Operations Reference

OperationMethodDynamoDB Expression
Add single.Add(x => x.Set, value)ADD #attr :val
Add multiple.Add(x => x.Set, values[])ADD #attr :val
Delete single.Delete(x => x.Set, value)DELETE #attr :val
Delete multiple.Delete(x => x.Set, values[])DELETE #attr :val
Important Notes on Sets
  • ADD creates if not exists: If the set attribute doesn't exist, ADD creates it
  • DELETE requires existing set: DELETE on a non-existent attribute returns an error
  • Sets are unordered: Elements have no guaranteed order
  • Sets contain unique values: Duplicate values are automatically deduplicated

Best Practices

Use [DynamoDbEntity] for Nested Types

// ✅ Correct: Use [DynamoDbEntity] for nested types
[DynamoDbEntity]
public partial class Address { ... }

// ❌ Wrong: Don't use [DynamoDbTable] for nested types
[DynamoDbTable("Addresses")]
public partial class Address { ... }

Filter on Nested Properties, Not Key Conditions

// ✅ Correct: Nested in filter
.Query(x => x.CustomerId == id)
.WithFilter(x => x.ShippingAddress.City == "Seattle")

// ❌ Wrong: Nested in key condition
.Query(x => x.CustomerId == id && x.ShippingAddress.City == "Seattle")

Update Specific Nested Properties

// ✅ Efficient: Update only changed properties
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel
{
ShippingAddress = new AddressUpdateModel { City = "Portland" }
})
.UpdateAsync();
// Only updates #address.#city

// ⚠️ Less efficient: Replace entire nested object
await table.Customers.Update(customerId)
.Set(x => new CustomerUpdateModel
{
ShippingAddress = entireNewAddress // Replaces whole object
})
.UpdateAsync();

Use Append for Adding to Lists

// ✅ Efficient: Append is O(1)
.Set(x => x.Tags.Append("new-tag"))

// ⚠️ Less efficient: Prepend is O(n)
.Set(x => x.Tags.Prepend("new-tag"))

Troubleshooting

Error: UnmappedPropertyException on Nested Property

Problem: Property 'City' on type 'Address' does not map to a DynamoDB attribute.

Solution: Add [DynamoDbAttribute] to the nested type's property:

[DynamoDbEntity]
public partial class Address
{
[DynamoDbAttribute("city")] // Add this
public string City { get; set; } = string.Empty;
}

Error: Nested Property in Key Condition

Problem: Property 'ShippingAddress.City' cannot be used in key condition expression.

Solution: Move nested property access to filter expression:

// ❌ Wrong
.Query(x => x.CustomerId == id && x.ShippingAddress.City == "Seattle")

// ✅ Correct
.Query(x => x.CustomerId == id)
.WithFilter(x => x.ShippingAddress.City == "Seattle")

Error: List Index Cannot Reference Entity Parameter

Problem: List index cannot reference the entity parameter

Solution: The index expression cannot reference the entity being queried/updated. Capture the value first:

// ❌ Wrong - index references entity parameter
.WithFilter(x => x.Tags[x.PrimaryIndex] == "value")
.Set(x => x.Tags.SetAt(x.LastIndex, "updated"))

// ✅ Correct - capture value first
int index = entity.PrimaryIndex;
.WithFilter(x => x.Tags[index] == "value")

Error: List Index Must Be Non-Negative

Problem: List index must be non-negative. Got: -1

Solution: Ensure the index value is 0 or greater:

// ❌ Wrong - negative index
int index = -1;
.Set(x => x.Tags.SetAt(index, "value"))

// ✅ Correct - non-negative index
int index = 0;
.Set(x => x.Tags.SetAt(index, "value"))

Error: Overlapping Document Paths

Problem: Overlapping document paths in update expression

Solution: DynamoDB does not allow multiple operations on overlapping paths in a single update. Use separate update calls:

// ❌ Wrong - overlapping paths in single update
.Set(x => x.Tags.SetAt(0, "a").Append("new"))
.Set(x => x.Tags.SetAt(0, "a").RemoveAt(1))

// ✅ Correct - separate update calls
await table.Products.Update(productId)
.Set(x => x.Tags.SetAt(0, "a"))
.UpdateAsync();

await table.Products.Update(productId)
.Set(x => x.Tags.Append("new"))
.UpdateAsync();

Error: Missing UpdateModel for Nested Type

Problem: AddressUpdateModel type not found.

Solution: Ensure the nested type has [DynamoDbEntity] attribute:

[DynamoDbEntity]  // Required for UpdateModel generation
public partial class Address
{
// ...
}

Next Steps