#[non_exhaustive]pub struct TraversalBuilder {
pub start_node: String,
pub max_depth: usize,
pub limit: Option<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).
#[non_exhaustive] since 0.15.13 (W15.3, C-11,
D-255), which for
this struct is the attribute alone: every field below already had a setter,
and Self::new is the only entry a caller ever used. The fields stay
pub and stay readable — what is gone is the literal, and with it the
property that the eleventh field breaks whoever wrote the first ten.
Fields (Non-exhaustive)§
This struct is marked as non-exhaustive
Struct { .. } syntax; cannot be matched against without a wildcard ..; and struct update syntax will not work.start_node: String§max_depth: usize§limit: Option<usize>A ceiling on the rows the walk may produce, if the caller set one (0.15.10, W13.5, C-8).
None — the default — walks until Self::max_depth and the frontier
stop it. Some(n) emits LIMIT ?n inside the recursive CTE, which
is the placement that bounds work; Self::limit() carries the
measurement that decides it and the contract that follows.
Set through Self::limit().
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.
The Option outlived the reason it was one. It was public for
construction-by-struct-literal — a caller building the struct directly
should have to write down the same thing the setter records — and
0.15.13 took the literal away. It stays an Option because the
distinction above is the mechanism and a private bool beside the mode
would say the same thing in two places; what is gone is the argument
that a caller needed to see it.
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::resolve_for. 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.
Sourcepub fn plan(self, plan: ReadPlan) -> Self
pub fn plan(self, plan: ReadPlan) -> Self
Take every read qualifier from one ReadPlan (0.15.9, W13.4,
D-251).
Exactly Self::on_branch, Self::as_of_valid,
Self::as_of_recorded and Self::limit() applied in turn, and a
None field unsets what was there rather than leaving it.
It was three qualifiers when this shipped and is four since 0.15.10
(D-252), which is the change the wording is deliberately no longer
counting: a plan is whatever a read is qualified by, and this method’s
contract is that it applies all of it. That is the whole reason to
prefer a plan to three calls: a plan is the read, so applying one
answers what the read is instead of amending what it was. A caller who
wants to amend has the three setters and they are not going anywhere —
this release was additive on purpose so a caller pinned to 0.15 got
the plan without being broken by it. W15.3 decided their fate: they
stay (0.15.13, C-11,
D-255). C-11
asked for #[non_exhaustive] and a setter per field; this struct had
the setters already, and a plan that replaces does not make an amend
that is spelled out wrong.
The round trip is exact in both directions: b.plan(p).read_plan() == p
for every plan, and b.plan(b.read_plan()) leaves b alone.
Sourcepub fn read_plan(&self) -> Result<ReadPlan>
pub fn read_plan(&self) -> Result<ReadPlan>
What this traversal’s read qualifiers say, as a ReadPlan.
The inverse of Self::plan, and the reason the pair is worth having
over a one-way setter: a caller can take the qualifiers off a traversal
they were handed and give the same read to
Database::edges, or to a second traversal
from a different start node, without restating the fields and without
the restatement being the place they drift.
§Errors
DbError::InvalidBranchId when the
name in Self::branch is not one. This builder takes its lineage as
a String and validates it nowhere — Lineages::shape refuses an
unregistered name at read time, which is a different question — so the
conversion to BranchId is where an unconstructible
name is finally noticed. Every branch that exists in a database passed
through BranchId::new to get there, so this fails only for a name
that could not have been read anyway.
pub fn max_depth(self, depth: usize) -> Self
Sourcepub fn limit(self, n: usize) -> Self
pub fn limit(self, n: usize) -> Self
Stop the walk once n rows have entered it (0.15.10, W13.5, C-8).
§Why this is not a LIMIT on the projection
C-8 is that FilteredVectorSearch::probe_cap “bounds memory, not work”:
it ran the whole traversal and then truncated the tail, so a name that
reads as a ceiling on cost was a ceiling on the size of the answer. The
obvious repair — LIMIT on the statement’s outer SELECT — repeats the
defect one line further down, because that projection sorts, and a sort
materialises the whole walk before the limit can apply. Measured on
the same hub graph, counting edges visited by a walk of 20,050:
no limit 20,050 edges
LIMIT 20 on the outer SELECT 20,050 edges
LIMIT 20 inside the recursive CTE 7,250 edges
LIMIT 5 inside the recursive CTE 1,250 edgesSo the limit goes inside the CTE, where SQLite’s recursion halts as soon
as the recursive table reaches it. Work is then bounded by the fan-out of
the first n rows taken out of the queue, which is why the saving is
proportional rather than absolute: at LIMIT 200 the same graph still
visits every edge, because expanding 51 rows already costs all of them.
A limit buys nothing until it is smaller than the expensive frontier.
§What n counts, and what comes back
n counts walk rows, not answers. The walk holds (node_id, depth)
and dedupes on the pair, so a node reachable at two depths spends two of
them; the projection then drops retired concepts. The result is
therefore at most n ids, and fewer than n does not mean the graph
was smaller. Self::execute_ids_explained is where that is answered —
exactly, from the walk’s own row count — and it is the reason this method
did not ship alone.
The subset is the near end. SQLite’s recursive queue is FIFO, so the walk
is breadth-first and a limit drops the farthest nodes first. Among nodes
at the same depth the cut is arbitrary, which is the one thing
Self::max_depth — the crate’s other stated bound — does not do.
§Every surface that runs the walk honours it
Self::execute and
Database::load_subgraph_with
splice the same CTE, so a limit set here bounds those too, and neither
return type can report having been cut short. That is deliberate rather
than overlooked: a subgraph’s own bound is byte_budget, which
refuses with
DbError::SubgraphTooLarge rather
than truncating, and a caller who wants a bounded walk and needs to know
whether the bound bit asks Self::execute_ids_explained first.
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.
The refusal is scoped to the instants the archive actually took
(0.15.4, W14.2, D-246). The newest row per entity is never archivable, so
an instant at or after the newest stamp still in the log — now
included — folds completely and is answered. Through 0.15.3 the guard
discarded the instant and refused everything on any archived database.
§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::resolve_for 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_ids_explained(
&self,
conn: &Connection,
now_ts: &str,
) -> Result<(Vec<String>, WalkOutcome)>
pub async fn execute_ids_explained( &self, conn: &Connection, now_ts: &str, ) -> Result<(Vec<String>, WalkOutcome)>
Self::execute_ids, plus whether Self::limit cut the walk short
(0.15.10, W13.5, C-8).
Named for FilteredVectorSearch::execute_explained,
which is the same bargain: the plain method answers the question, and
the explained one also hands back the fact a caller would otherwise have
to guess at from the shape of the answer.
Without a limit this is always
WalkOutcome::Complete and costs exactly what Self::execute_ids
costs — the reporting column is emitted only when there is a ceiling to
report on, so an unlimited traversal runs the statement it has always
run.
§Errors
The same two as Self::execute_ids, and for the same reasons.
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.
Self::limit bounds this walk as it bounds every other, and this
return type cannot say whether it bit — hydrated nodes leave no room for
the answer. Self::execute_ids_explained is where that is asked.
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