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 Type | C# Type | Description |
|---|---|---|
| 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 |
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();
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 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
| Operation | Method | DynamoDB 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
| Operation | Method | DynamoDB 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 |
- 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
- Lambda Expressions - Full expression support documentation
- Updates - Advanced update patterns
- Filters - Filter expressions for queries