Skip to content

Working with nested collection data types

This page demonstrates how to write, read, filter, index, and query data within nested List and Map structures. The examples build on each other, using three complementary techniques:

  • CDT operate API with context selectors for single-element writes and reads at known positions.
  • Expression composition (nesting ListExp / MapExp calls) for reading or filtering nested values within record-level expressions.
  • Path expressions for selecting or filtering across multiple nested elements at once.

The examples on this page use a record with a bin named vehicles and a string bin named username. The vehicles bin contains a List of Maps, where each Map represents a vehicle with color, license, make, and model fields. The map keys are shown in key order (K-order), which is how the application stores them.

[
{ "color": "white", "license": "8PAJ017", "make": "Toyota", "model": "RAV4" },
{ "color": "blue", "license": "6ABC123", "make": "Tesla", "model": "Model 3" },
{ "color": "silver", "license": "7XYZ789", "make": "Honda", "model": "Civic" }
]

We can visualize the data another way:

List
├── [0] Map
│ ├── "color" -> "white"
│ ├── "license" -> "8PAJ017"
│ ├── "make" -> "Toyota"
│ └── "model" -> "RAV4"
├── [1] Map
│ ├── "color" -> "blue"
│ ├── "license" -> "6ABC123"
│ ├── "make" -> "Tesla"
│ └── "model" -> "Model 3"
└── [2] Map
├── "color" -> "silver"
├── "license" -> "7XYZ789"
├── "make" -> "Honda"
└── "model" -> "Civic"

Why the list is unordered and the maps are key-ordered

This data model makes two distinct ordering choices, each for a specific reason.

The list is unordered. Index 0 means “default vehicle” — the application assigns semantic meaning to position. An ordered list would sort the vehicles by their map comparison value, which would rearrange the entries and break the positional contract. You keep the list unordered so that index 0 stays the default, index 1 is the second choice, and so on.

The maps are key-ordered. In this example the maps use the K-ordered subtype, which stores elements in key order. You construct the maps with keys in K-order on the client side for two reasons:

  1. Correct ADD_UNIQUE comparison. When you use the ADD_UNIQUE write flag to prevent duplicate vehicles in the list, the server compares map elements by element count, then keys in stored order, then values. The incoming map must already be K-ordered for the comparison to match the stored form. If a client sends the same vehicle data with keys in a different order, the wire representation differs, comparison fails, and the duplicate slips through. Only ordered maps (K-ordered or KV-ordered) can be reliably compared for equality.
  2. Predictable read results. When you read the data back, map entries come back in key order. If the application constructs maps with keys in an arbitrary order, the displayed result differs from what was sent, which makes debugging harder.

How you construct a K-ordered map depends on the client language:

  • Java — use TreeMap (sorts keys by natural order).
  • Python — use aerospike.KeyOrderedDict. It maps to as_orderedmap in the C layer, which sorts keys and includes the K-ordered flag on the wire. A standard dict maps to as_hashmap, which sorts keys internally but omits the K-ordered wire flag, so the server treats it as unordered.
  • C — use as_orderedmap (as the example does). It keeps keys sorted and includes the K-ordered flag on the wire. as_hashmap also sorts internally but omits the K-ordered wire flag.
  • Go — use a []as.MapPair slice with keys in sorted order.
  • C# — use SortedDictionary<string, object>.
  • Node.js — use a plain object or Map. The Node.js binding creates an as_orderedmap internally, which sorts keys and includes the K-ordered wire flag. Key order in the JavaScript source does not matter.

The vehicle maps in this example are small (four keys each), so the per-operation index rebuild cost is negligible. For maps with many keys, consider using PERSIST_INDEX to store the offset index on disk. See Map performance for operational complexity by subtype and index configuration.

Add a new vehicle as the default

The following example inserts a new vehicle at index 0, making it the default. The list policy uses three flags:

  • UNORDERED — the list is unordered so that index 0 keeps its “default vehicle” meaning.
  • ADD_UNIQUE — prevents inserting a vehicle that already exists in the list, compared as a full map value.
  • NO_FAIL — the operation succeeds silently if the vehicle is already present, so the caller does not need to handle a duplicate error.
TreeMap<String, Object> vehicle = new TreeMap<>();
vehicle.put("color", "red");
vehicle.put("license", "5DEF456");
vehicle.put("make", "Ford");
vehicle.put("model", "Mustang");
ListPolicy policy = new ListPolicy(ListOrder.UNORDERED,
ListWriteFlags.ADD_UNIQUE | ListWriteFlags.NO_FAIL);
Record record = client.operate(null, key,
ListOperation.insert(policy, "vehicles", 0, Value.get(vehicle)),
Operation.get("vehicles"));

After this operation the list contains four vehicles, with the Ford Mustang at index 0 (the new default). Running the same operation again has no effect because ADD_UNIQUE detects the duplicate and NO_FAIL suppresses the error.

Read the license plate of the default vehicle

The first vehicle in the list (index 0) acts as the default vehicle. The following expression read extracts its license plate by nesting ListExp.getByIndex and MapExp.getByKey:

  1. ListExp.getByIndex extracts the map at index 0 from the vehicles bin.
  2. MapExp.getByKey extracts the "license" value from that map.
  3. ExpOperation.read returns the result under the name "defaultLicense".
Record record = client.operate(null, key,
ExpOperation.read("defaultLicense",
Exp.build(
MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING,
Exp.val("license"),
ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.MAP,
Exp.val(0), Exp.listBin("vehicles")))),
ExpReadFlags.DEFAULT));
String plate = record.getString("defaultLicense");

Expected output: "8PAJ017"

Check a license plate against all vehicles

The previous example accessed a single vehicle at a known index. To check a value across all vehicles regardless of list size, you can use a path expression to extract values from every element, then test the resulting list.

The following filter expression checks whether any vehicle in the list has a license plate matching "7XYZ789". A path expression extracts all "license" values regardless of list size, then ListExp.getByValue with EXISTS checks whether the target plate is present. This filter expression can be used for record selection with a query, similar to a relational database’s “WHERE” clause.

String targetPlate = "7XYZ789";
QueryPolicy policy = client.copyQueryPolicyDefault();
policy.filterExp = Exp.build(
ListExp.getByValue(ListReturnType.EXISTS,
Exp.val(targetPlate),
CdtExp.selectByPath(Exp.Type.LIST, SelectFlags.VALUE,
Exp.listBin("vehicles"),
CTX.allChildren(),
CTX.mapKey(Value.get("license"))))
);

Only records where at least one vehicle has a matching license plate pass this filter.

Create an expression index on license plates

The filter expression in the previous section evaluates against every record during a query. Instead of scanning every record, you can create a secondary index on the license plate values. A secondary index on the extracted values lets the server skip non-matching records entirely, which is significantly faster for large datasets.

A path expression extracts all "license" values into a list, and the index is created with collection type LIST so each license string is indexed individually.

Step 1: Create the index

The expression uses selectByPath to walk every element in the vehicles list and extract the "license" value from each map.

Expression licensesExp = Exp.build(
CdtExp.selectByPath(Exp.Type.LIST, SelectFlags.VALUE,
Exp.listBin("vehicles"),
CTX.allChildren(),
CTX.mapKey(Value.get("license"))));
IndexTask task = client.createIndex(null, "test", "demo",
"idx_vehicle_license", IndexType.STRING,
IndexCollectionType.LIST, licensesExp);
task.waitTillComplete();

Step 2: Query the index

Once the index exists, query for records that contain a specific license plate. Because the index is built on an expression (not a bin), the query filter references the same expression.

Instead of returning the full record, this query uses operation projection to return only the matching vehicle and the username bin. A selectByPath operation with allChildrenWithFilter extracts the vehicle whose license matches "7XYZ789", and a plain bin read returns the username.

Exp matchPlate = Exp.eq(
MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING,
Exp.val("license"), Exp.mapLoopVar(LoopVarPart.VALUE)),
Exp.val("7XYZ789"));
Statement stmt = new Statement();
stmt.setNamespace("test");
stmt.setSetName("demo");
stmt.setFilter(Filter.contains(licensesExp,
IndexCollectionType.LIST, "7XYZ789"));
stmt.setOperations(new Operation[] {
CdtOperation.selectByPath("vehicles", SelectFlags.VALUE,
CTX.allChildrenWithFilter(matchPlate)),
Operation.get("username")
});
RecordSet rs = client.query(null, stmt);

Each matching record now returns only the projected data: the vehicle with license "7XYZ789" and the username.

Expected output per record:

{"vehicles": [{"color": "silver", "license": "7XYZ789", "make": "Honda", "model": "Civic"}], "username": "thomasanderson"}

Alternatively: query by index name

Once an expression index exists, you can reference it by name instead of passing the expression to the query filter. This is typically the simpler production pattern because it avoids rebuilding the expression on the client side. The same operation projection applies: a selectByPath extracts the matching vehicle and a bin read returns the username.

Exp matchPlate = Exp.eq(
MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING,
Exp.val("license"), Exp.mapLoopVar(LoopVarPart.VALUE)),
Exp.val("7XYZ789"));
Statement stmt = new Statement();
stmt.setNamespace("test");
stmt.setSetName("demo");
stmt.setFilter(Filter.containsByIndex("idx_vehicle_license",
IndexCollectionType.LIST, "7XYZ789"));
stmt.setOperations(new Operation[] {
CdtOperation.selectByPath("vehicles", SelectFlags.VALUE,
CTX.allChildrenWithFilter(matchPlate)),
Operation.get("username")
});
RecordSet rs = client.query(null, stmt);

Expected output per record:

{"vehicles": [{"color": "silver", "license": "7XYZ789", "make": "Honda", "model": "Civic"}], "username": "thomasanderson"}

For more on operation projection in queries, see Projection. For more on path expressions, see the path expressions quickstart.

Feedback

Was this page helpful?

What type of feedback are you giving?

What would you like us to know?

+Capture screenshot

Can we reach out to you?