Skip to content

Graph#

Concourse’s document-graph data model lets you store relationships between records and traverse them efficiently. Relationships are represented as Links — directional pointers from one record to another.

Linking Records#

Use the link method to create a directed relationship from a source record to a destination record via a named key.

1
2
3
// Java
// Create a link: record 1 --employer--> record 100
concourse.link("employer", 100, 1);
1
2
// CaSH
link "employer", 100, 1

Links are directional. The example above creates a relationship from record 1 to record 100, but record 100 has no automatic back-reference to record 1.

1
2
3
// Java
concourse.link("friends",
    Lists.newArrayList(2L, 3L, 4L), 1);

Use unlink to remove a relationship:

1
2
// Java
concourse.unlink("employer", 100, 1);
1
2
// CaSH
unlink "employer", 100, 1

You can query for records that link to a specific destination using the LINKS_TO operator:

1
employer lnk2 @100
1
2
3
// Java
Set<Long> employees = concourse.find(
    "employer", Operator.LINKS_TO, Link.to(100));

This returns all records whose employer key contains a link to record 100.

Navigation lets you traverse links and read data from linked records using dot-separated key paths called navigation keys.

A navigation key is a dot-separated path where each segment before the last is a key containing links, and the final segment is the key to read from the destination record.

1
employer.name

This means: follow the employer link, then read the name key from the linked record.

Using navigate()#

The navigate method traverses navigation keys and returns data from the destination records.

1
2
3
4
// Java
// Get the name of the employer linked from record 1
Map<Long, Set<Object>> results =
    concourse.navigate("employer.name", 1);
1
2
// CaSH
navigate "employer.name" from 1

Multi-Hop Navigation#

Navigation keys can span multiple hops:

1
employer.ceo.name

This traverses two links: first employer, then ceo, and finally reads name from the destination.

1
2
3
// Java
Map<Long, Set<Object>> results =
    concourse.navigate("employer.ceo.name", 1);
1
2
3
4
5
6
// Java
Map<Long, Map<String, Set<Object>>> results =
    concourse.navigate(
        Lists.newArrayList(
            "employer.name", "employer.city"),
        1);
1
2
3
4
// Java
Map<Long, Set<Object>> results =
    concourse.navigate("employer.name",
        Lists.newArrayList(1L, 2L, 3L));

Navigate from all records matching a query:

1
2
3
4
// Java
Map<Long, Set<Object>> results =
    concourse.navigate("employer.name",
        "department = Engineering");

Historical Navigation#

1
2
3
4
// Java
Map<Long, Set<Object>> results =
    concourse.navigate("employer.name", 1,
        Timestamp.fromString("last month"));

Navigation keys can be used directly in CCL conditions to filter records based on data in linked records:

1
employer.name = "Cinchapi"
1
2
3
// Java
Set<Long> records = concourse.find(
    "employer.name = \"Cinchapi\"");

This finds all records whose linked employer record has a name of "Cinchapi". Navigation keys work with all CCL operators:

1
2
3
employer.founded > 2010
manager.department contains "Engineering"
employer.address.city = "Atlanta"

Transitive Navigation#

When a field contains self-referential links — for example, a children key whose links point to more records that also have children — the depth of the graph is data-dependent. You can follow those links recursively by appending a * suffix to any stop in a navigation path.

1
2
3
children*.name
parent*.title
manager*.email

The * tells the server to expand that stop with a breadth-first search: start from the links at the current stop, follow them to their destinations, and if any destination record also has outgoing links on the same field, follow those too, until no new records are discovered. The entire traversal runs server-side in a single RPC.

Cycles and Termination#

Transitive traversal automatically deduplicates records as the BFS frontier expands, so cyclic graphs (e.g., a record that eventually links back to an ancestor) terminate cleanly without infinite loops. The result includes each reachable record at most once.

Examples#

1
2
3
4
5
6
7
8
9
// Java
// Given record 1 → [2, 3]; 2 → [4, 5]; 5 → [6]
//   with "children" links between them, return the names of
//   every descendant in the subtree rooted at record 1
Map<String, Set<Object>> result =
    concourse.select(
        Lists.newArrayList("children*.name"), 1);
Set<Object> allDescendantNames =
    result.get("children*.name");
1
2
3
4
5
// Java
// Find every record that transitively reaches a "Bob" somewhere
// in its descendant tree
Set<Long> ancestors = concourse.find(
    "children*.name", Operator.EQUALS, "Bob");
1
2
3
// CaSH
select "children*.name" from 1
find "children*.name = Bob"

Mixed Stops in the Same Key#

A navigation path can combine transitive and non-transitive stops. Each * expands independently:

1
parent.children*.name

This follows parent once (single hop), then expands the children field transitively on the destination record, then reads name at every descendant.

Multiple transitive stops in the same key are also supported:

1
departments*.manager.reports*.email

Each * expands via its own BFS; the traversals compose in order along the path.

Supported Operations#

Transitive keys work anywhere a navigation key works:

Operation Transitive keys
select
get
navigate
browse
find

If the * modifier is applied to a stop whose field does not contain links (for example, a plain string field), the BFS finds no outgoing edges to follow and the traversal terminates immediately. The result is the same as the non-transitive variant of the same key — transitive navigation degrades gracefully rather than raising an error.


Tracing References#

The trace method returns all incoming links to a record — it answers the question “which records link to this one?”

1
2
3
4
// Java
Map<String, Set<Long>> incoming =
    concourse.trace(100);
// e.g., {"employer" -> [1, 2, 3], "partner" -> [50]}
1
2
// CaSH
trace 100

The result maps each key name to the set of records that contain a link to the traced record via that key.

Trace Multiple Records#

1
2
3
// Java
Map<Long, Map<String, Set<Long>>> results =
    concourse.trace(Lists.newArrayList(100L, 200L));

Historical Trace#

1
2
3
4
// Java
Map<String, Set<Long>> incoming =
    concourse.trace(100,
        Timestamp.fromString("last week"));

Consolidating Records#

The consolidate method atomically merges data from one or more source records into a target record. Every field from each source is added to the target, the sources are cleared, and every incoming link in the database that referenced a source is rewritten to point at the target. The operation is atomic — either every source is fully consolidated and every incoming link is rewritten, or nothing changes.

1
2
3
// Java
// Merge records 200 and 300 into record 100
concourse.consolidate(100, 200, 300);
1
2
// CaSH
consolidate 100, 200, 300

Consolidation is useful when resolving duplicate records or normalizing references across a graph. Because it rewrites inbound links in place, callers that held a link to a source record continue to work — their link now resolves to the consolidated target.

Traversal Optimization#

Concourse automatically selects the most efficient traversal strategy for navigation queries. Depending on the shape of the data, it may use:

  • Forward traversal: Start from the source records and follow links forward to find destination data. This is more efficient when there are fewer source records than destination records.

  • Reverse traversal: Start from potential destination records and trace links backward to find matching sources. This is more efficient when there are fewer destination records than source records.

The optimizer chooses the strategy automatically based on data characteristics. No manual tuning is required.

For transitive keys, the same optimizer considers both strategies but biases toward reverse traversal when the first stop in the path is transitive, since a forward-expansion starting point would need to enumerate an unbounded subgraph before doing any filtering.