Skip to content

Working with nested collection data types

For the complete documentation index see: llms.txt

All documentation pages available in markdown.

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:

Each operation’s Example shows the code in nine tabs: Aerospike Expression Language (AEL) text on the Java SDK and Python SDK tabs, and the Exp builder on the other seven. See the AEL reference for AEL grammar.

  • CDT operate API with context selectors for single-element writes and reads at known positions.
  • Expression composition (nesting list and map expressions) for reading or filtering nested values within record-level expressions.
  • Path expressions for selecting or filtering across multiple nested elements at once.
  • String expressions (Database 8.2.0 and later) for transforming String values nested in CDTs, or a context path on the Operate API to write one back in place.

For the Developer SDK, the same nested-data patterns can also be written as AEL text, for example "$.vehicles.[0].color == 'white'", instead of composed ListExp/MapExp calls.

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"

List and map ordering

This data model uses two separate ordering choices.

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 so index 0 would no longer be the default. Keep the list unordered so that index 0 stays the default and index 1 is the second choice.

The maps are key-ordered. In this example the maps use the K-ordered subtype, which stores elements in key order. Construct the maps with keys in K-order on the client side for predictable read results. When you read the data back, map entries come back in key order. If the application constructs maps with keys in arbitrary order, the displayed result differs from what was sent, which makes debugging harder.

K-ordered map construction 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: the client has no K-ordered map value. Write the map, then set its order with MapSetPolicyOp and a KEY_ORDERED map policy, as the example does. []as.MapPair is only how the client returns a K-ordered map; it can’t be written.
  • C#: pass a SortedDictionary<string, object> as Value.Get(map, MapOrder.KEY_ORDERED). Value.Get(map) alone writes any dictionary unordered.
  • 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");
Record record = session.upsert(key)
.bin("vehicles").listInsert(0, vehicle, opts -> opts.addUnique().allowFailures())
.bin("vehicles").get()
.execute()
.getFirstRecord();

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 list_get_by_index and map_get_by_key:

  1. list_get_by_index extracts the map at index 0 from the vehicles bin.
  2. map_get_by_key extracts the "license" value from that map.
  3. A read operation expression returns the result under the name "defaultLicense".
Record record = session.query(key)
.bin("defaultLicense")
.selectFrom("$.vehicles.[0].license:STRING")
.execute()
.getFirstRecord();
String plate = record.getString("defaultLicense");
System.out.println(plate);

Expected output: "5DEF456", the license of the Ford Mustang the previous example inserted at index 0.

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 list_get_by_value 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.

DataSet demo = DataSet.of("test", "demo");
RecordStream rs = session.query(demo)
.where("'7XYZ789' in $.vehicles:LIST.*.license")
.withHint(hint -> hint.allowScansWithWhere())
.execute();

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.

DataSet demo = DataSet.of("test", "demo");
IndexTask task = session.createIndex(demo, "idx_vehicle_license",
IndexType.STRING, IndexCollectionType.LIST,
"$.vehicles:LIST.*.license");
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.

from aerospike_sdk import CollectionIndexType, DataSet, Filter
from aerospike_sdk.exp import Exp
demo = DataSet.of("test", "demo")
stream = (
session.query(demo)
.filter(
Filter.contains("", "7XYZ789", CollectionIndexType.LIST)
.expression(Exp.from_server_compiled_ael("$.vehicles:LIST.*.license"))
)
.bin("vehicles").select_from("$.vehicles:LIST.*[?(@.license == '7XYZ789')]")
.bin("username").get()
.execute()
)

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.

from aerospike_sdk import CollectionIndexType, DataSet, Filter
demo = DataSet.of("test", "demo")
stream = (
session.query(demo)
.filter(Filter.contains_by_index("idx_vehicle_license", "7XYZ789",
CollectionIndexType.LIST))
.bin("vehicles").select_from("$.vehicles:LIST.*[?(@.license == '7XYZ789')]")
.bin("username").get()
.execute()
)

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.