Skip to content

Querying object graphs

You can reach one value with a path such as rig.osc.filter.cutoff. What if you want every cutoff in a rig, every string in a scene, or all the gains above a threshold? You might not even know how many there are while you're still building the rig.

graphPaths lets you search a live object for values by pattern, rather like XPath searches a document. It uses reflection, so you can query objects you already have without adding a base class, annotations or registration.

A graph, addressed

Give graphPaths a pattern and it hands back a PathSet: an ordered collection of live lenses. Each one is an address plus a getter, and a setter too if the slot is mutable. Nothing is copied, so if the value changes between two reads, the second read sees the change.

:::static
class Osc(var freq: Float, var gain: Float, var label: String)
class Rig(val carrier: Osc, val modulator: Osc, var name: String)

val rig = Rig(Osc(220f, 0.8f, "sine"), Osc(440f, 0.2f, "saw"), "rig")

display("main", rig.graphPaths("**"))

an output panel labelled main holding a PathSet of 9, as a two-column table of path and value: carrier is an Osc, carrier/freq 220, carrier/gain 0.800, carrier/label sine, modulator is an Osc, modulator/freq 440, modulator/gain 0.200, modulator/label saw, and name rig. Paths are in blue, values in white

** matched everything. Interior edges (carrier) are listed alongside leaves (carrier/freq), and properties come out in primary-constructor order rather than alphabetically: you read them in the order you declared them.

The slash shorthand

rig / "**/freq" is shorthand for rig.graphPaths("**/freq"): the same query, less to type.

// the same query, twice
(rig / "**/freq").whereValueIs<Float> { it > 300f }
rig.graphPaths("**/freq").whereValueIs<Float> { it > 300f }

// and the pattern can be split across several `/` if that reads better —
// this still reports `carrier/freq`, not a bare `freq`
rig / "carrier" / "freq"

Those parentheses on the first line are not optional: . binds tighter than /, so rig / "**/freq".whereValueIs<Float> { ... } would be looking for that method on the string.

The shorthand takes no options. When you need a depth cap, private properties or a custom descendInto, call graphPaths and pass a GraphWalkOptions.

What / does to ordinary division

/ is defined on Any?, but ordinary division is untouched: a member div always beats an extension, so numbers, vectors, quaternions and signals keep their own arithmetic, and kotlin.io.path's path / "sub" still joins a path.

Patterns and predicates

Two wildcards: * matches exactly one segment, ** matches any number of segments including zero. A * inside a segment globs within that one name, so "*Alpha" finds contourAlpha and "osc*" finds osc1 and osc2. It never crosses a /; that is **'s job. Names are matched ignoring case, so "cutoff" reaches cutOff. If one object really does hold two names differing only in case, one pattern matches both of them, and they remain two distinct slots.

A | separates whole patterns and unions what they find:

// everything that reads like an alpha, plus every w component, at any depth
look / "**/*alpha | **/w"

One walk answers both branches, so each slot appears once even when two branches would have claimed it, and the order is the graph's rather than the pattern's. Because | separates whole patterns, "**/freq|gain" means "**/freq" or "gain", not two leaves under **. Repeat the prefix on both sides.

Patterns say where a slot lives. To filter on what it holds, add a predicate.

:::static
class Osc(var freq: Float, var gain: Float, var label: String)
class Rig(val carrier: Osc, val modulator: Osc, var name: String)

val rig = Rig(Osc(220f, 0.8f, "sine"), Osc(440f, 0.2f, "saw"), "rig")

// every freq at any depth, but only the ones above 300
display("main", rig.graphPaths("**/freq").whereValueIs<Float> { it > 300f })

the same panel and table headings, now holding a PathSet of 1: the single row modulator/freq 440. Everything the unfiltered query returned has been narrowed to one slot

whereValueIs<T> only ever shows the predicate values of type T, so a graph holding strings and booleans next to your floats can't trip it up. The rest of the family: where (the whole ref — path, owner and value), whereValue, ownedBy, nonNull, atDepth, excluding, and writable(). They compose, and each returns a new set rather than mutating the receiver.

Typed lenses for any leaf type

Numbers aren't special. lenses<T>() narrows a PathSet to the slots holding a T and gives you typed reads and writes: strings, booleans, enums, vectors, whatever your graph is made of.

:::static
enum class Mode {
    OFF,
    ON,
}
class Light(var name: String, var on: Boolean, var mode: Mode, var level: Float)
class Room(val lamp: Light, val strip: Light)

val room = Room(
    Light("lamp", true, Mode.ON, 0.4f),
    Light("strip", false, Mode.OFF, 0.9f),
)

// flip every boolean in the graph, then read back the enums
room.graphPaths("**").lenses<Boolean>().update { !it }

display("main", room.graphPaths("**").lenses<Mode>())

an output panel holding a LensSet of Mode with 2 rows, and a third column beyond path and value: lamp/mode is a Mode and writable, strip/mode is a Mode and writable

update is read-modify-write per slot; set assigns one value everywhere; set(list) assigns positionally. Each returns how many slots it actually wrote, so a read-only slot is reported rather than silently skipped.

Naming a path is cheap

A pattern with no wildcards doesn't traverse anything: it walks straight down the segments you named, one property read each. A pattern with a literal prefix descends that prefix directly and then searches only the subtree below it. On a graph of any size that is a large difference, and you can measure it:

:::static
var reads = 0

class Node(val label: String) : LiveGraphInspectable {
    val kids = linkedMapOf<String, Node>()
    var leaf: Float = 1f

    override fun liveGraphChildren(): Iterable<LiveGraphEdge> =
        kids.map { (name, child) ->
            LiveGraphEdge(name) {
                reads++
                child
            }
        } +
            LiveGraphEdge(
                "leaf",
                {
                    reads++
                    leaf
                },
                { v -> leaf = (v as Number).toFloat() },
            )
}

fun build(depth: Int, breadth: Int): Node {
    val node = Node("n")
    if (depth > 0) repeat(breadth) { i -> node.kids["k$i"] = build(depth - 1, breadth) }
    return node
}

val root = build(depth = 3, breadth = 5)

reads = 0
root.graphPaths("k1/k2/k3/leaf")
val named = reads

reads = 0
val all = root.graphPaths("**")
val everything = reads

display(
    "main",
    """
    <table style="font-size:20px;border-spacing:24px 8px">
      <tr><td>naming a path</td><td style="text-align:right">$named edge reads</td></tr>
      <tr><td>walking all ${all.size} slots</td><td style="text-align:right">$everything edge reads</td></tr>
    </table>
    """,
)

a two-row table, the two measurements the example prints: naming a path, 4 edge reads; walking all 4443 slots, 1666 edge reads

A within-segment glob ("osc*"), a literal spelled in a different case from the property, and a pattern with more than one | branch all end the literal prefix, and the query walks from there.

That example also shows LiveGraphInspectable: implement it and you decide what your object's children are, instead of letting reflection guess. Custom edges are merged ahead of reflected ones, so you can rename or hide as much as you like and still get everything else for free.

If you're going to run the same query repeatedly, compile it once with GraphQuery.compile(pattern) and call resolve(root) on the result — the pattern is parsed a single time and the plan is reusable.

Driving numbers: params()

params() narrows a query to the slots that can be driven: Field's Params, and any writable numeric field. A plain Float or Int property on an ordinary object can then be set, modulated and supplied like a synth parameter, and you can change the whole group at once.

:::static
class Osc(var freq: Float, var gain: Float)
class Rig(val a: Osc, val b: Osc, val c: Osc)

val rig = Rig(Osc(220f, 0.5f), Osc(440f, 0.5f), Osc(880f, 0.5f))

// spread the gains evenly, then halve every freq
rig.params("**/gain").rewrite { ramp(0.1f..0.9f) }
rig.params("**/freq").scale(0.5f)

display("main", rig.params("**"))

an output panel holding a ParamSet of 6, with columns path, base and state: a/freq 110, a/gain 0.100, b/freq 220, b/gain 0.500, c/freq 440, c/gain 0.900, and every one of the six marked scalar

ParamSet carries the group operations you would expect: set, offset, scale, clamp, random, choose. It also has a rewrite { } scope for shaping a set relative to itself, with ramp, normalize, jitter, and the mean/min/max of the group.

Modulation and suppliers work the same way across the whole set:

// one shared LFO added on top of every cutoff — `matches` is to `params()`
// what `/` is to `graphPaths()`
(rig matches "**/cutoff").modulate(sine * 40f, mode = ParamModulationMode.ADD)

// or a per-entry expression, with the group's statistics in scope
rig.params("**/freq").supply { entry, index, group ->
    group.mean + sin(time * (0.2f + index * 0.05f)) * 12f
}

Those attach to the runloop and keep evaluating, so the underlying fields move on their own. clearModulation() and clearSuppliers() detach them again.

Notes on traversal

What the walk does on a large or unusual graph:

  • Descent stops at the obvious leaves — numbers, strings, booleans, enums, primitive arrays, functions, class objects — and at anything in the JDK's own packages, so you get your graph rather than the JVM's. GraphWalkOptions descendInto refines that policy: it receives both the value and the default decision, so { v, byDefault -> byDefault && v !is Osc } prunes without losing the built-in guards.
  • Cycles are safe. Detection is path-scoped: a node is skipped only when it already appears on the current ancestor chain. A node shared by several edges is therefore reachable under each of its addresses, while a → b → a still terminates. maxDepth and maxRefs bound the rest.
  • Collections are transparent. Maps are keyed by their stringified key, lists and arrays by index, and mutable ones are writable. A Set is addressed by position in iteration order and is read-only.
  • You can browse the same edges. tree("t", value) and column("c", value) list exactly what this walk sees; p copies a row's path, and a filter containing /, * or | runs graphPaths and lists the matches (see Trees and columns).
  • Non-public classes work. Your own private and internal model classes are read and written like any other.
  • A throwing getter won't derail a walk, but reading that one slot propagates the real exception rather than reporting null.

A pattern addresses slots; it cannot follow an edge and then follow it again. When the question is recursive (what runs downstream of this box, what does nothing feed into), Datalog in a Box is the next page, and it can take graphPaths output as its facts.