Skip to main content

TraversalBuilder

Struct TraversalBuilder 

Source
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: bool

Whether 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

Source

pub fn new(start_node: impl Into<String>) -> Self

Source

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.

Source

pub fn max_depth(self, depth: usize) -> Self

Source

pub fn edge_types(self, types: Vec<String>) -> Self

Source

pub fn min_weight(self, weight: f64) -> Self

Source

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.

Source

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.

Source

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:

settopology comes fromattributes come from
neitherlinks_current, at now_tslive concepts
as_of_valid(v)links_current, bounded at vconcepts valid at v
as_of_recorded(r)transaction_log folded to r, bounded at now_tsthe payload believed at r
bothfolded to r, bounded at vbelieved 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.

Source

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.

Source

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.

Source

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).

Source

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

Source§

fn clone(&self) -> TraversalBuilder

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for TraversalBuilder

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> FromRef<T> for T
where T: Clone,

Source§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoRequest<T> for T

Source§

fn into_request(self) -> Request<T>

Wrap the input message T in a tonic::Request
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more