Path Selector & Structural Pull DSL (path/1, pull/2, pull_pattern/1, ~x)

Copy Markdown View Source

While Xitdb's Access implementation (db["users"]["alice"]["name"]) is ideal for interactive exploration and passing lazy %Xitdb.Cursor{} handles around, it resolves one 9-byte Slot per path segment across the NIF boundary.

When you already know the target path—or when you want to project multiple attributes and bounded list slices in a single NIF call—Xitdb.DSL.path/1, ~x, and Xitdb.Pull give you single-call execution with compile-time validation and safe variable interpolation.


1. Path Selector Macro (path/1) & Sigil (~x)

Import the DSL with use Xitdb.DSL (or call Xitdb.path/1 after require Xitdb):

use Xitdb.DSL

# 1. Static paths fold into a single binary literal at compile time:
path(users.alice.profile.city)
# => ".users.alice.profile.city"

# 2. Static ~x sigil validates the selector prefix at compile time:
~x|.users["user.101"].todos[2]|
# => ".users[\"user.101\"].todos[2]"

# 3. Dynamic bracket expressions safely interpolate and escape runtime variables:
user_id = "user.101"
todo_idx = 2

path(users[user_id].todos[todo_idx])
# => ".users.[\"user.101\"].todos[2]"

Root Syntax Forms

Because Elixir's parser does not allow a bare unary leading dot (.users), path/1 supports five equivalent root forms:

FormExampleCompiled Selector
Bare identifierpath(users[id].role)".users..."
Explicit root / _path(root.users[id].role) / path(_.users[id].role)".users..."
Atom rootpath(:users[id].role)".users..."
Step-list syntaxpath([:users, id, :role])".users..."
Pinned selector prefix (^)path(^base_sel.profile.city)base_sel <> ".profile.city"

Automatic Special-Character Key Escaping

If a dynamic runtime key is empty or contains special selector characters (., [, ], #, $, %, ^, quotes, or whitespace), path/1 automatically wraps the key in bracket-quoted ["..."] notation so keys with dots or sigils never corrupt path parsing:

email = "alice.liddell@acme.io"

path(accounts[email].balance)
# => ".accounts.[\"alice.liddell@acme.io\"].balance"

path(sorted("email->id")[email])
# => "$[\"email->id\"].[\"alice.liddell@acme.io\"]"

2. Typed Collection Modifiers

xitdb distinguishes collection types in selector strings using step sigils (. for Hash_Map, # for Counted_Hash_Map, $ for Sorted_Map, [i] for Array_List, [[i]] for Linked_Array_List, % / #% / ^ for sets).

path/1 lets you express these collection tags either as chained macro modifiers or as typed step tuples in a list:

Collection KindSelector SigilChained path/1 SyntaxStep-List path/1 SyntaxCompiled Output
Hash_Map.keyusers[id] or users.alice[:users, id]".users.alice"
Counted_Hash_Map#keyorg.counted(:departments)[dept_id]<br>or org.departments[counted(dept_id)][:org, :departments, {:counted, dept_id}]".org.departments#eng"
Sorted_Map$keyeng.sorted(:directory)[emp_id]<br>or eng.directory[sorted(emp_id)][:eng, :directory, {:sorted, emp_id}]".eng.directory$101"
Array_List[idx]items[0] or items.array(0)[:items, 0] or [:items, {:array, 0}]".items[0]"
Linked_Array_List[[idx]]todos[[2]] or todos.linked(2)[:todos, [2]] or [:todos, {:linked, 2}]".todos[[2]]"
Hash_Set%memberflags.set_member("active")[:flags, {:set_member, "active"}]".flags%active"
Counted_Hash_Set#%memberflags.counted_set_member("active")[:flags, {:counted_set_member, "active"}]".flags#%active"
Sorted_Set^membertags.sorted_set_member("elixir")[:tags, {:sorted_set_member, "elixir"}]".tags^elixir"
Historical Moment@momentat_moment(0, users[id].role)—"@0.users.alice.role"

3. Composing & Splicing Selectors (^prefix)

You can build reusable base selectors and splice them into deeper paths using the pin operator (^):

use Xitdb.DSL

dept_path = path(org.counted(:departments)[dept_id])
# => ".org.departments#eng"

emp_todo_path = path(^dept_path.sorted(:directory)[emp_id].todos[[0]])
# => ".org.departments#eng.directory$101.todos[[0]]"

Where path/1 Selectors Can Be Used

Compiled selectors work across all Xitdb read, history, and mutation APIs:

# 1. Single-call selection against the latest moment or a historical moment:
todo = Xitdb.select!(db, path(users[id].todos[[0]]))
v0_role = Xitdb.select!(db, path(at_moment(0, users[id].role)))

# 2. Inspecting the temporal lineage of a path across all commits:
{:ok, lineage} = Xitdb.history_of(db, path(users[id].role))

# 3. Staging list insertions, deletions, or subtree forks inside Xitdb.transact/2:
{:ok, db} =
  Xitdb.transact(db, fn root ->
    root
    |> Xitdb.insert_at(path(users[id].todos), 0, "ship docs")
    |> Xitdb.delete_at(path(users[id].todos), 3)
    |> Xitdb.fork(path(users[id]), path(backups[id]))
  end)

4. Structural Pull Patterns (Xitdb.pull/2 & pull_pattern/1)

Xitdb.pull/2 and Xitdb.pull!/2 accept either a raw xitdb Pull string or a structured Elixir keyword/list pattern compiled by Xitdb.Pull:

# Structured Pull pattern list (no string concatenation needed):
Xitdb.pull!(user_cursor, [
  :username,
  :age,
  todos: 0..2,
  profile: [:city, :tier]
])
# => %{
#   "username" => "dan",
#   "age" => 29,
#   "todos" => ["design schema", "write specs"],
#   "profile" => %{"city" => "Montreal", "tier" => "pro"}
# }

Supported Pull Pattern Entries

Pattern EntryCompiled Pull SyntaxDescription
:field or "field"fieldProjects a single key
:* or "*"*Wildcard: projects all keys on the map
field: first..end_exclusivefield[first..end_exclusive]Projects a half-open range [first, end_exclusive) of a list (Array_List or Linked_Array_List)
field: {:slice, offset, limit}field[offset..(offset + limit)]Projects limit items starting at offset (matches Enum.slice(list, offset, limit))
field: [:a, :b]field{a, b}Recursively projects nested map attributes
field: {0..5, [:a, :b]}field[0..5]{a, b}Slices a list of maps by range and projects nested attributes on each element
field: {{:slice, offset, limit}, [:a, :b]}field[offset..(offset + limit)]{a, b}Slices limit maps starting at offset and projects nested attributes on each element

Practical Pull Recipes

Recipe 1: Wildcard :* + Bounded List Slice

When an entity has many scalar fields plus a large append-only list (such as audit_log), combine :* with a slice to project all attributes while bounding the list:

Xitdb.pull!(user_cursor, [
  :*,
  audit_log: {:slice, 0, 5}
])

Recipe 2: Paginating a List of Nested Entities

When an organization or user record contains a list of nested maps (such as team members), slice the list and select only the needed nested fields in one NIF call:

page_offset = 10
page_size = 5

Xitdb.pull!(account_cursor, [
  :email,
  team: {{:slice, page_offset, page_size}, [:username, profile: [:city, :tier]]}
])

Recipe 3: Zero-Overhead Compile-Time Patterns (pull_pattern/1)

If your Pull pattern is a static literal used in a hot loop, wrap it in pull_pattern/1 so Xitdb.Pull.compile!/1 runs once at compile time:

use Xitdb.DSL

@user_card pull_pattern([:username, :age, todos: 0..2, profile: [:city]])
# Compiled at compile time to "{username, age, todos[0..2], profile{city}}"

Xitdb.pull!(user_cursor, @user_card)