CDT Operations

Complex Data Type (CDT) operations let you read and modify nested lists and maps within a single bin, server-side, without fetching the whole record.

Reading CDT Data

Navigate into a bin’s structure using on_list() and on_map_key():

users = DataSet.of("test", "users")

# Read a map value by key
stream = await (
    session.query(users.id(1))
    .bin("settings").on_map_key("theme").get()
    .execute()
)

# Read a list element by index
stream = await (
    session.query(users.id(1))
    .bin("scores").on_list_index(0).get()
    .execute()
)

# Read a nested value: $.profile.address.city
stream = await (
    session.query(users.id(1))
    .bin("profile").on_map_key("address").on_map_key("city").get()
    .execute()
)

List Ranges

# Get items by index range
stream = await (
    session.query(users.id(1))
    .bin("scores").on_list_index_range(0, 3).get()
    .execute()
)

# Get items by value range
stream = await (
    session.query(users.id(1))
    .bin("scores").on_list_value_range(10, 100).get()
    .execute()
)

Map Ranges

# Get map entries by key range
stream = await (
    session.query(users.id(1))
    .bin("metrics").on_map_key_range("a", "m").get()
    .execute()
)

# Get by rank (top N values)
stream = await (
    session.query(users.id(1))
    .bin("scores").on_map_value_rank_range(-3).get()
    .execute()
)

Collection Metadata

# Get list size
stream = await (
    session.query(users.id(1))
    .bin("scores").on_list().size()
    .execute()
)

# Check if key exists in map
stream = await (
    session.query(users.id(1))
    .bin("settings").on_map_key("theme").exists()
    .execute()
)

Joining String Lists

list_join concatenates the string items of a list into a single string. The separator is optional; the list must hold only strings, and an empty list joins to an empty string.

# ["one", "two", "three"] → "one,two,three"
stream = await (
    session.query(users.id(1))
    .bin("tags").list_join(",")
    .execute()
)

# Join a list nested under a map key
stream = await (
    session.query(users.id(1))
    .bin("profile").on_map_key("nicknames").list_join(", ")
    .execute()
)

String Operations on a Nested Leaf

When a navigated path lands on a string, the str_* family is available on the navigation builder itself: reads such as str_strlen and str_contains on either verb, modifies such as str_append and str_upper on write verbs. See the nested-strings section of String Operations.

# Uppercase one nickname in place
await (
    session.upsert(users.id(1))
    .bin("profile").on_map_key("nicknames").on_list_index(0).str_upper()
    .execute()
)

Writing CDT Data

Set a Value

# Set a map key
await (
    session.update(users.id(1))
    .bin("settings").on_map_key("theme").set_to("dark")
    .execute()
)

# Set a list element by index
await (
    session.update(users.id(1))
    .bin("scores").on_list_index(0).set_to(99)
    .execute()
)

Add / Increment

# Increment a map value
await (
    session.update(users.id(1))
    .bin("counters").on_map_key("views").add(1)
    .execute()
)

List Operations

# Append to a list
await (
    session.update(users.id(1))
    .bin("scores").list_append(95)
    .execute()
)

# Add item (insert-sorted for ordered lists)
await (
    session.update(users.id(1))
    .bin("scores").list_add(95)
    .execute()
)

# Append multiple items
await (
    session.update(users.id(1))
    .bin("tags").list_append_items(["python", "aerospike"])
    .execute()
)

# The same list writes work on a list reached by navigation
await (
    session.update(users.id(1))
    .bin("teams").on_map_key("team1").list_append("Diana")
    .execute()
)

# Clear a list
await (
    session.update(users.id(1))
    .bin("scores").list_clear()
    .execute()
)

# Sort a list
await (
    session.update(users.id(1))
    .bin("scores").list_sort()
    .execute()
)

Map Operations

# Upsert map entries
await (
    session.update(users.id(1))
    .bin("settings").map_upsert_items({"theme": "dark", "lang": "en"})
    .execute()
)

# Clear a map
await (
    session.update(users.id(1))
    .bin("settings").map_clear()
    .execute()
)

Key-ordered maps

A map written as a plain dict is stored unordered. The server sorts the entries either way, so a read looks identical — but it will not binary-search a map that was not declared ordered, and keyed or range access on one falls back to a scan. On a large map that is the difference between a lookup and a walk.

Declare the order by wrapping the dict in SortedMap:

from aerospike_sdk import SortedMap

await (
    session.upsert(users.id(1))
    .put({"scores": SortedMap({"zoe": 3, "amy": 1})})
    .execute()
)

The flag is stored with the record and survives later modification, so it governs the cost of every subsequent access — by any client — until the map is rewritten unordered.

SortedMap subclasses dict, so it behaves as one everywhere, and a key-ordered map reads back as a SortedMap rather than a plain dict:

scores = record.bins["scores"]

scores["amy"]                     # 1
scores == {"amy": 1, "zoe": 3}    # True
isinstance(scores, dict)          # True

A selection read with get_keys_and_values() is a plain dict in the order the server selected the entries, such as rank order for a rank range. get_as_map() is the same dict with no order promise. End the selection with get_as_ordered_map() instead to get a SortedMap in key order, whatever the bin’s own order:

stream = await (
    session.query(users.id(1))
    .bin("scores").on_map_rank_range(-3, 3).get_as_ordered_map()
    .execute()
)
top_three = (await stream.first_or_raise()).record.bins["scores"]   # SortedMap

Maps created through the CDT surface take their order from the operation instead, so SortedMap is not needed there:

await (
    session.update(users.id(1))
    .bin("settings").map_upsert_items({"theme": "dark"}, order=MapOrder.KEY_ORDERED)
    .execute()
)

Remove

# Remove a map key
await (
    session.update(users.id(1))
    .bin("settings").on_map_key("deprecated_key").remove()
    .execute()
)

# Remove a list element
await (
    session.update(users.id(1))
    .bin("scores").on_list_index(-1).remove()
    .execute()
)

Nested Navigation

CDT navigation composes — navigate into arbitrarily nested structures:

# $.users_map["alice"].scores[0] = 100
await (
    session.update(dataset.id(1))
    .bin("users_map").on_map_key("alice").on_map_key("scores").on_list_index(0).set_to(100)
    .execute()
)

Paths: acting on every child at once

The navigation above picks out one element. A path selects a whole level — every child, or only those matching a predicate — and applies a single server operation across the selection.

on_each_child() walks every element; on_each_child_where(pred) keeps the matches. The predicate is an Exp over the current element’s loop variable:

from aerospike_sdk import Exp, LoopVarPart

# add 10 to every element of the list
add_10 = Exp.num_add([Exp.int_loop_var(LoopVarPart.VALUE), Exp.val(10)])
await session.update(key).bin("nums").on_each_child().modify_by(add_10).execute()

# drop just the elements over 5
over_5 = Exp.gt(Exp.int_loop_var(LoopVarPart.VALUE), Exp.val(5))
await (
    session.update(key).bin("nums").on_each_child_where(over_5).remove_matches().execute()
)

Reads use the collect_* terminals — named apart from get_* because they return a selection rather than one element:

Terminal

Returns

collect_values()

the value of every selected element

collect_map_keys()

the key of every selected map entry

collect_map_entries()

selected map entries as key/value pairs

collect_matching_tree()

the selection, still nested as it was found

Paths nest, so a predicate can apply a level down:

# every value over 5, in every list under the map
result = await (
    await session.query(key)
    .bin("m").on_each_child().on_each_child_where(over_5).collect_values()
    .execute()
).first_or_raise()

Selecting map entries by key

on_map_keys_in(keys) selects the entries of a map whose key is in keys; keys the map does not hold are skipped. It opens a path from a bin, or continues one after any navigation step. and_filter(pred) then narrows that selection with a predicate over each entry:

# the values under "alpha" and "gamma"
result = await (
    await session.query(key)
    .bin("m").on_map_keys_in(["alpha", "gamma"]).collect_values()
    .execute()
).first_or_raise()

# entries a, b, c whose value is over 10, as key/value pairs
over_10 = Exp.gt(Exp.int_loop_var(LoopVarPart.VALUE), Exp.val(10))
result = await (
    await session.query(key)
    .bin("m").on_map_keys_in(["a", "b", "c"]).and_filter(over_10).collect_map_entries()
    .execute()
).first_or_raise()

# add 10 to just the selected entries
await session.update(key).bin("m").on_map_keys_in(["a", "c"]).modify_by(add_10).execute()

keys is any collection, and keys of different types may be mixed. A bytes element is one blob key. A bare str or bytes in place of the collection raises TypeError rather than selecting one key per character or byte; wrap a single key in a list: on_map_keys_in([b"\x01\x02"]). The same holds for on_map_key_list, on_map_value_list and on_list_value_list.

and_filter refines the step before it, so it follows on_map_keys_in or a single-element step such as on_map_key, once per level. After a single-element step it keeps that element only if it matches, and opens a path there:

# the "book" price, only if it is over 10
.bin("prices").on_map_key("book").and_filter(over_10).collect_values()

The builder raises TypeError where the server would reject it: as the first step, after on_each_child / on_each_child_where (put the predicate there instead), or after another and_filter (combine conditions with Exp.and_ in one call). It also refuses a range or multi-element selection, which has no single element to filter, and on_map_index / on_map_rank, where the server does not return the filtered value correctly.

Writes have modify_by(expr), modify_no_fail(expr) — which tolerates elements the expression cannot be applied to — and remove_matches(). For explicit SelectFlags, collect_by_path(flags) is the unsugared form.

An empty or absent collection is never an error; the read yields an empty list. A path filter that meets an element it cannot evaluate, such as an integer comparison against a string, fails the operation unless you pass no_fail=True, which skips that element instead:

# "mixed" holds ["a", 7, 9]: without no_fail the string fails the read
.bin("d").on_map_key("mixed").on_each_child_where(over_5).collect_values(no_fail=True)
# -> [7, 9]

collect_values_as_expression_read(bin_type) reads the same selection through an expression read instead of a CDT operation. Pass the bin’s type, ExpType.MAP or ExpType.LIST; the result is a list under the bin’s name. It takes no_fail as well, and ignore_eval_failure=True leaves the bin out of the result, rather than failing, when the evaluation itself fails, for example because the bin is not of the declared type:

from aerospike_sdk import ExpType

result = await (
    await session.query(key)
    .bin("catalog").on_map_key("book").on_each_child().on_map_key("title")
    .collect_values_as_expression_read(ExpType.MAP)
    .execute()
).first_or_raise()
titles = result.record.bins["catalog"]

For select flags other than values, build the expression with Exp.exp_select_by_path and pass it to select_from.

AEL expressions on CDT

AEL supports CDT paths for filtering. A collection predicate like the ones below is generally not satisfiable from a secondary index, so it falls back to a primary-index (full-set) scan. That is allowed by default; set allow_scans_with_where=False on the query or the Behavior to reject the fallback instead:

# Filter records where the list has more than 5 items
stream = await (
    session.query(users)
    .where("$.scores:LIST.count() > 5")
    .execute()
)

# Filter on a nested map value
stream = await (
    session.query(users)
    .where('$.settings.theme == "dark"')
    .execute()
)

A collection index can serve some of these predicates directly; see Secondary Indexes.