pub struct TraversalBuilder {
pub start_node: String,
pub max_depth: usize,
pub edge_types: Vec<String>,
pub min_weight: f64,
pub attribute_mode: Option<AttributeMode>,
pub as_of_valid: Option<String>,
pub as_of_recorded: Option<String>,
pub branch: Option<String>,
pub content: bool,
}Expand description
Recursive CTE traversal query builder (§5.2).
Fields§
§start_node: String§max_depth: usize§edge_types: Vec<String>§min_weight: f64§attribute_mode: Option<AttributeMode>None means defaulted, not Current (T3.2, D-085).
The distinction is the whole mechanism. Current chosen by a caller who
knows what it means is a legitimate, fast answer; Current arrived at by
never touching the setting, on a query about the past, is a wrong answer
nobody asked for. Those two produce identical behaviour and must not be
stored identically, so the field records which happened.
Public for construction-by-struct-literal, which is why it is an Option
here rather than a private bool beside the mode: a caller building the
struct directly should have to write down the same thing the builder
method records.
as_of_valid: Option<String>The valid-time instant to traverse at, if it is not the present.
Added in 0.6.0 as as_of so “as of Tuesday” is expressible. Before
that, the instant arrived as execute’s now_ts parameter and a
historical traversal was indistinguishable from a live one — which is why
the mismatch with AttributeMode::Current could only ever be a warn!:
nothing in the call had the information needed to raise an error.
Renamed from as_of in 0.13.2 (W7.1, D-174). The old name carried
one instant onto two clocks; the method’s own docs are where that is
argued out.
as_of_recorded: Option<String>The transaction-time instant to traverse at: what did we believe then (0.13.2, W7.1, D-174).
None — the default — means current belief, and the walk reads
links_current exactly as it always has. Some(t) folds
transaction_log to t instead, so the topology is the one the ledger
held at t rather than the one it holds now. See
Self::as_of_recorded.
branch: Option<String>The lineage this traversal reads (§15.3, D-220).
None is what every traversal written before v12 meant and what every
database without a fork still holds: the trunk. Some(id) reads that
branch’s belief — one row per edge key, taken from the nearest
branch on the path from it to the root, so a branch that corrects or
retires an inherited edge is seen to have done so.
An unregistered lineage is refused rather than defaulted, by
graph::lineage::lineage_shape. Answering it
with the trunk’s view is the answer a caller is least able to detect,
because on a database that has never forked it is the answer they
expected anyway.
Set through Self::on_branch.
content: boolWhether crate::Database::load_subgraph_with should fetch
concepts.content (0.8.0, B3, D-116).
Default false, which is a change in what a load returns. No
algorithm reads document text, and at realistic document sizes it is
most of the byte budget, so the default was spending the budget on bytes
nothing would look at. A caller who needs it asks; one who does not gets
NodeData::content() == None, which is distinguishable from an empty
document.
Ignored by crate::Database::load_subgraph, which has no builder and
never loads content.
Implementations§
Source§impl TraversalBuilder
impl TraversalBuilder
pub fn new(start_node: impl Into<String>) -> Self
Sourcepub fn on_branch(self, branch: impl Into<String>) -> Self
pub fn on_branch(self, branch: impl Into<String>) -> Self
Read one lineage’s belief rather than the trunk’s (§15.3, D-220).
When this shipped, the only way to put a second lineage into a database
was raw SQL. The read went first because it is the half that had to be
measured (D-219), and because a write that creates something unreadable
is the worse order to ship the two halves in. fork() arrived at
0.14.7 — this comment said 0.14.5 until 0.14.9, which is a stale
prediction rather than a record, and the kind D-223 and D-224 were both
found by reading. BranchView::traversal
seeds this from a lineage the caller already holds.
pub fn max_depth(self, depth: usize) -> Self
pub fn edge_types(self, types: Vec<String>) -> Self
pub fn min_weight(self, weight: f64) -> Self
Sourcepub fn attribute_mode(self, mode: AttributeMode) -> Self
pub fn attribute_mode(self, mode: AttributeMode) -> Self
State the attribute mode explicitly.
Calling this is what turns Current from a default into a decision, and
Self::execute treats the two differently on a historical traversal —
see Self::as_of_valid.
Sourcepub fn content(self, content: bool) -> Self
pub fn content(self, content: bool) -> Self
Fetch concepts.content into every hydrated node (0.8.0, B3, D-116).
Off by default. Turning it on is what the byte budget is then spent on: at 20 KB per concept, document text is the large majority of a loaded graph, and none of the six algorithms reads it.
Sourcepub fn as_of_valid(self, ts: impl Into<String>) -> Self
pub fn as_of_valid(self, ts: impl Into<String>) -> Self
Traverse the graph as it was in the world at ts — the valid-time
axis (§5.2, W7.1).
§This was as_of, and the rename is the fix (0.13.2, W7.1, D-174)
Doctrine VIII
says a query that mixes the two clocks says so in its signature. as_of
did not: one timestamp reached links.valid_from/valid_to on the
valid-time axis and transaction_log.recorded_at on the
transaction-time axis, so as_of(t).attribute_mode(AtTime) answered
“the edges valid at t, labelled with what we believed at t” — two
questions under one word. §3.1
named it; 0.12.17 (W5.6, D-160) wrote the semantics down without changing
them, precisely so this change could be reviewed against a stated
position; this is that change.
The two axes are now two parameters, and they compose:
| set | topology comes from | attributes come from |
|---|---|---|
| neither | links_current, at now_ts | live concepts |
as_of_valid(v) | links_current, bounded at v | concepts valid at v |
as_of_recorded(r) | transaction_log folded to r, bounded at now_ts | the payload believed at r |
| both | folded to r, bounded at v | believed at r, valid at v |
The last row is the cell Jensen and Snodgrass’s BCDM defines a bitemporal
database as answering — what did we believe at r about what was true at
v — and before this it was not expressible on any surface in the crate.
§Setting either instant makes the attribute mode a required decision (T3.2, D-085)
A historical traversal has two independent questions and until 0.6.0 only
one of them was asked. The topology comes from the instants. The node
attributes — titles, content — come from wherever AttributeMode
says, and the default said Current, which is live text. So a historical
traversal returned the past’s graph wearing today’s titles, and reported
that through a tracing::warn! — invisible in any application that has
not configured a subscriber, which is most of them at first run.
So: with either instant set and no Self::attribute_mode call,
Self::execute returns
DbError::AttributeModeUnstated
rather than guessing. Both answers stay available and neither is silent:
// What was true on Tuesday, as best we now know.
let then = TraversalBuilder::new("a")
.as_of_valid("2026-01-06T00:00:00.000000Z")
.attribute_mode(AttributeMode::AtTime)
.execute(conn, now)
.await?;
// Tuesday's topology with today's titles — legitimate, and now stated.
let mixed = TraversalBuilder::new("a")
.as_of_valid("2026-01-06T00:00:00.000000Z")
.attribute_mode(AttributeMode::Current)
.execute(conn, now)
.await?;A traversal with neither instant is a query about now, where Current
and AtTime agree about which text to return, so the default stands and
no caller has to change.
§What the rename buys, concretely
Suppose a concept’s title is corrected today, fixing a typo made in 2020.
Under the old as_of("2020-06-01") with AtTime the answer was the
uncorrected title, because the correction was recorded after ts —
the right answer to what did we believe in 2020 and the wrong one to
what was true in 2020, which is what the name promised. Now
as_of_valid("2020-06-01") alone gives the corrected title,
as_of_recorded("2020-06-01") gives the uncorrected one, and a caller
asking for either says which.
The second, smaller mismatch W5.6 recorded is closed by the same change:
AtTime hydration consulted the payload’s retired flag and never the
concept’s own valid interval, so a concept whose validity had ended
still hydrated. It is now bounded by whichever instants are set, so the
two halves of the answer agree about what “existed then” means.
Sourcepub fn as_of_recorded(self, ts: impl Into<String>) -> Self
pub fn as_of_recorded(self, ts: impl Into<String>) -> Self
Traverse the graph as the ledger believed it at ts — the
transaction-time axis (0.13.2, W7.1, D-174).
Where Self::as_of_valid asks what was true, this asks what did we
think was true. Setting it moves the walk off links_current and onto a
fold of transaction_log bounded at ts — the same fold
crate::temporal::reconstruct performs. That operation still exists
and still returns the whole state; this makes the same instant reachable
from a traversal, which is what lets the two axes be set on one query.
§This reads the hot log, and refuses rather than guessing
A fold can only answer for instants the hot log still covers.
crate::Database::archive removes superseded rows, so an instant below
what remains is not before history, it is history that is in the other
file — and this surface takes a connection, not an archive path, so it
cannot go and get it. It returns
DbError::RecordedInstantUnreachable
naming the instant and pointing at reconstruct, which does take the
path. Answering from a partial fold would return nearly the right
topology, which is the worst failure available to a ledger.
§Cost, stated rather than discovered
links_current is a projection maintained for exactly this read and
indexed for it (idx_lc_traversal_cover). The fold is a window function
over transaction_log with a json_extract per column, materialised
once per query and joined per hop. It is not the fast path and is not
meant to be. W10.6 measures it and decides whether anything should be
built for it.
Sourcepub fn build_sql(&self) -> String
pub fn build_sql(&self) -> String
Compile the recursive CTE query string as specified in §5.2.
Edge types become bind placeholders, not quoted literals. An earlier
version spliced them in with format!("'{t}'"), which made any caller
string a SQL fragment on the read path — and the only validation in the
crate, super::edge::validate_edge_type, runs in
super::EdgeAssertion::normalized on the write path, so a traversal
never passed through it. Binding removes the question rather than
answering it: unlike a table name, an edge type is a value, and values
can be parameters.
§This function cannot ask the database, and the shape depends on it
It emits the resolved form when a branch is named and the trunk form when one is not, which is the shape this builder’s own configuration implies. That is exact on every database holding a single lineage — which is every database this crate has written — and it is not exact on a forked one, where an unbranched traversal reads the ancestor’s and the descendant’s rows alike.
The execution paths do not have that gap: Self::execute_ids,
Self::execute and Database::load_subgraph_with each ask
graph::lineage::lineage_shape and pass the answer to the
shape-taking form of this method. This method stays for inspecting and
explaining the query — which is what its callers in tests/ do — and
says so rather than quietly returning the shape that is usually right.
Sourcepub async fn execute_ids(
&self,
conn: &Connection,
now_ts: &str,
) -> Result<Vec<String>>
pub async fn execute_ids( &self, conn: &Connection, now_ts: &str, ) -> Result<Vec<String>>
Node ids reachable under this traversal, in id order (§5.2).
Reads at Self::as_of_valid when set, else at now_ts, and under the
belief Self::as_of_recorded names when set, else current belief. No
attribute mode is involved, so this never returns
DbError::AttributeModeUnstated:
topology at an instant is unambiguous, and it is only the pairing with
live attributes that needed a decision.
§Errors
DbError::RecordedInstantUnreachable
when Self::as_of_recorded is below what the hot log still covers.
DbError::UnknownBranch, naming the
branch, when Self::on_branch names a lineage that is not registered
(0.14.4; NotFound until 0.14.7).
Sourcepub async fn execute(
&self,
conn: &Connection,
now_ts: &str,
) -> Result<Vec<NodeAttributes>>
pub async fn execute( &self, conn: &Connection, now_ts: &str, ) -> Result<Vec<NodeAttributes>>
Execute the traversal and hydrate attributes per Self::attribute_mode
(§5.2).
The hydration is a second step rather than a join in the CTE because the
three modes read from two different places: Current and Omit from
concepts, AtTime from transaction_log. The previous version always
emitted the concepts join, so attribute_mode was stored, exposed by a
builder method, and never read — a caller asking for AtTime got live
attributes with no indication that the mode had been ignored. That is the
exact failure Doctrine II exists to prevent, arriving as a silent wrong
answer rather than as an error.
AttributeMode::Omit returns Ok(vec![]) here, which is
indistinguishable from a traversal that reached nothing. That is a
limitation of this method’s return type rather than of the mode; callers
wanting topology only should use Self::execute_ids, which says what it
found.
§Errors
DbError::AttributeModeUnstated
when either instant is set and Self::attribute_mode is not — see
Self::as_of_valid for why that combination is a question rather than
a default (T3.2, D-085).
DbError::RecordedInstantUnreachable
when Self::as_of_recorded is below what the hot log still covers.
now_ts is the caller’s present, and it is the fallback on both axes:
a traversal with neither instant set reads live topology and live text.
Trait Implementations§
Source§impl Clone for TraversalBuilder
impl Clone for TraversalBuilder
Source§fn clone(&self) -> TraversalBuilder
fn clone(&self) -> TraversalBuilder
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreAuto Trait Implementations§
impl Freeze for TraversalBuilder
impl RefUnwindSafe for TraversalBuilder
impl Send for TraversalBuilder
impl Sync for TraversalBuilder
impl Unpin for TraversalBuilder
impl UnsafeUnpin for TraversalBuilder
impl UnwindSafe for TraversalBuilder
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoRequest<T> for T
impl<T> IntoRequest<T> for T
Source§fn into_request(self) -> Request<T>
fn into_request(self) -> Request<T>
T in a tonic::Request