use crate::graph::{GraphNodeType, GraphStorage};
use crate::value::TraverserValue;
pub trait DiagnosticError: std::error::Error {
fn help(&self) -> Option<String>;
fn details(&self) -> Vec<(String, String)> {
Vec::new()
}
fn diagnostic_body(&self) -> String {
let mut buf = format!("Error: {self}");
push_details(&mut buf, &self.details());
let mut current: Option<&dyn std::error::Error> = self.source();
while let Some(src) = current {
buf.push_str(&format!("\nCaused by: {src}"));
current = src.source();
}
buf
}
}
pub(crate) fn push_details(buf: &mut String, details: &[(String, String)]) {
if details.is_empty() {
return;
}
let width = details
.iter()
.map(|(key, _)| key.chars().count())
.max()
.unwrap_or(0);
for (key, value) in details {
let prefix = format!(" {key}:{} ", " ".repeat(width - key.chars().count()));
let padding = " ".repeat(prefix.chars().count());
for (idx, line) in value.lines().enumerate() {
if idx == 0 {
buf.push_str(&format!("\n{prefix}{line}"));
} else {
buf.push_str(&format!("\n{padding}{line}"));
}
}
}
}
pub fn format_diagnostic<E>(err: &E) -> String
where
E: DiagnosticError + ?Sized,
{
let mut buf = err.diagnostic_body();
if let Some(help) = err.help() {
for (idx, line) in help.lines().enumerate() {
if idx == 0 {
buf.push_str(&format!("\nHelp: {line}"));
} else {
buf.push_str(&format!("\n {line}"));
}
}
}
buf
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ValueOrigin {
pub element_type: &'static str,
pub element_id: Option<String>,
pub element_label: Option<String>,
pub property: Option<String>,
}
impl ValueOrigin {
pub fn vertex(id: Option<String>, label: Option<String>, property: Option<String>) -> Self {
Self {
element_type: "vertex",
element_id: id,
element_label: label,
property,
}
}
pub fn edge(id: Option<String>, label: Option<String>, property: Option<String>) -> Self {
Self {
element_type: "edge",
element_id: id,
element_label: label,
property,
}
}
}
impl ValueOrigin {
pub fn element(&self) -> String {
let mut rendered = self.element_type.to_string();
if let Some(id) = &self.element_id {
rendered.push_str(&format!(" id=\"{id}\""));
}
if let Some(label) = &self.element_label {
rendered.push_str(&format!(" label=\"{label}\""));
}
rendered
}
}
impl std::fmt::Display for ValueOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.element_type)?;
if let Some(id) = &self.element_id {
write!(f, " id=\"{id}\"")?;
}
if let Some(label) = &self.element_label {
write!(f, " label=\"{label}\"")?;
}
if let Some(property) = &self.property {
write!(f, " property=\"{property}\"")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct StepFrame {
pub steps: Vec<String>,
pub position: usize,
pub(crate) logical: Option<LogicalFrame>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub(crate) struct LogicalFrame {
pub steps: Vec<String>,
pub positions: Vec<usize>,
}
fn render_marked(steps: &[String], marked: &[usize]) -> String {
let plan = steps.join(".");
if marked.is_empty() {
return plan;
}
let mut caret = String::new();
let mut column = 0;
for (index, step) in steps.iter().enumerate() {
let width = step.chars().count();
if marked.contains(&index) {
let start = if index > 0 && marked.contains(&(index - 1)) {
column - 1
} else {
column
};
while caret.chars().count() < start {
caret.push(' ');
}
while caret.chars().count() < column + width {
caret.push('^');
}
}
column += width + 1;
}
if caret.trim().is_empty() {
return plan;
}
format!("{plan}\n{caret}")
}
impl StepFrame {
pub fn new(steps: Vec<String>, position: usize) -> Self {
Self {
steps,
position,
logical: None,
}
}
pub(crate) fn with_logical(mut self, logical: LogicalFrame) -> Self {
self.logical = Some(logical);
self
}
pub fn step_name(&self) -> Option<&str> {
self.steps.get(self.position).map(|s| s.as_str())
}
pub fn plan(&self) -> String {
self.steps.join(".")
}
pub fn render(&self) -> String {
render_marked(&self.steps, &[self.position])
}
pub fn render_logical(&self) -> Option<String> {
let logical = self.logical.as_ref()?;
if logical.positions.is_empty() || logical.steps == self.steps {
return None;
}
Some(render_marked(&logical.steps, &logical.positions))
}
}
fn level_segments(child_index: Option<usize>, step_index: Option<usize>) -> Vec<String> {
let mut segments = Vec::with_capacity(2);
if let Some(child) = child_index {
segments.push(child.to_string());
}
segments.push(
step_index
.map(|index| index.to_string())
.unwrap_or_else(|| "?".to_string()),
);
segments
}
fn step_path(child: &Option<usize>, index: &Option<usize>, source: &TraverserError) -> String {
let mut segments: Vec<String> = level_segments(*child, *index);
let mut current = source;
while let TraverserError::StepFailed {
child_index,
step_index,
source,
..
} = current
{
segments.extend(level_segments(*child_index, *step_index));
current = source;
}
if segments.iter().all(|s| s == "?") {
return String::new();
}
format!("#{} ", segments.join("."))
}
fn innermost_step_name(step_name: &str, source: &TraverserError) -> String {
match source {
TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: inner,
source,
..
} => innermost_step_name(inner, source),
_ => step_name.to_string(),
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum ValueError {
#[error("Cast exception: expected type [{expected}], got type '{actual}'")]
#[non_exhaustive]
CastException {
expected: String,
actual: String,
value: Option<String>,
origin: Option<Box<ValueOrigin>>,
},
#[error("Step '{step}' cannot order a {actual} value among {expected} values")]
#[non_exhaustive]
IncomparableValues {
step: &'static str,
expected: String,
actual: String,
value: Option<String>,
origin: Option<Box<ValueOrigin>>,
},
#[error("Property '{key}' not found on element type '{element_type}'")]
#[non_exhaustive]
PropertyNotFound {
key: String,
element_type: &'static str,
},
#[error("Step label '{label}' is never declared in this query")]
#[non_exhaustive]
UndeclaredLabel {
label: String,
},
#[error("Invalid operation '{op}' between types '{type_a}' and '{type_b}'")]
#[non_exhaustive]
InvalidTypeOperation {
op: &'static str,
type_a: &'static str,
type_b: &'static str,
},
#[error("Path is not defined or initialized for traverser")]
PathNotDefined,
#[error("Invalid numeric value or conversion: {reason}")]
#[non_exhaustive]
InvalidNumber {
reason: String,
},
#[error("Unsupported cast target '{target}'")]
#[non_exhaustive]
UnsupportedCastTarget {
target: String,
reason: &'static str,
},
#[error(
"Cast exception: The {step}(local) step can only take {accepts}, but element {index} of \
the list has type '{actual}'"
)]
#[non_exhaustive]
LocalElementType {
step: &'static str,
accepts: &'static str,
index: usize,
actual: String,
},
#[error("The {step}() step takes {expected}, but got a value of type '{actual}'")]
#[non_exhaustive]
LabelStepElementKind {
step: &'static str,
expected: &'static str,
actual: String,
},
#[error("The {step}() step takes a {expected} as its {operand}, but got {actual}")]
#[non_exhaustive]
ListFunctionInput {
step: &'static str,
operand: &'static str,
expected: &'static str,
actual: String,
},
#[error("The {step}() step takes a {expected} as its {operand}, but got {actual}")]
#[non_exhaustive]
StringFunctionInput {
step: &'static str,
operand: &'static str,
expected: &'static str,
actual: String,
},
#[error(
"The sample() step weighs each traverser by its by() value, which must be a number of at \
least 0, but got {actual}"
)]
#[non_exhaustive]
SampleWeight {
actual: String,
},
#[error("Invalid JSONPath '{path}': {reason}")]
#[non_exhaustive]
InvalidJsonPath {
path: String,
reason: String,
},
#[error("Invalid glob pattern '{pattern}' at position {position}: {reason}")]
#[non_exhaustive]
InvalidGlobPattern {
pattern: String,
position: usize,
reason: String,
},
#[error("Invalid regular expression '{pattern}': {reason}")]
#[non_exhaustive]
InvalidRegex {
pattern: String,
reason: String,
},
#[error("{step} compares element ids with a number, but element ids are strings")]
#[non_exhaustive]
NumericIdComparison {
step: String,
fix: String,
},
#[error("{}", operator_failed_message(operator, kind, left, right))]
#[non_exhaustive]
OperatorFailed {
operator: &'static str,
kind: OperatorFailure,
left: String,
right: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum OperatorFailure {
Overflow,
DivisionByZero,
#[non_exhaustive]
IncompatibleOperands {
expected: &'static str,
},
}
fn operator_failed_message(
operator: &str,
kind: &OperatorFailure,
left: &str,
right: &str,
) -> String {
match kind {
OperatorFailure::Overflow => {
format!(
"Operator '{operator}' overflowed the int64 range combining {left} with {right}"
)
}
OperatorFailure::DivisionByZero => {
format!("Operator '{operator}' cannot divide {left} by {right}: division by zero")
}
OperatorFailure::IncompatibleOperands { expected } => {
format!("Operator '{operator}' cannot combine {left} with {right}: expected {expected}")
}
}
}
fn operator_help(operator: &str, kind: &OperatorFailure) -> String {
let mut help = match kind {
OperatorFailure::Overflow => format!(
"'{operator}' works on int64 and does not widen or wrap: the result is outside the int64 range. \
Convert an operand to a float first (for example g.v().values(\"age\").as_number()) \
or reduce the values before combining them."
),
OperatorFailure::DivisionByZero => format!(
"'{operator}' of two integers is an integer division, so a zero divisor is an error. \
Filter the zero out first, or divide by a float (1.0 / 0.0 is infinity)."
),
OperatorFailure::IncompatibleOperands { expected } => {
format!("'{operator}' expects {expected}.")
}
};
help.push_str(
"\nOperators: sum, minus, mult and div take numbers (null as the right operand leaves the left one); \
min and max take two numbers or two values of one kind (string, boolean, uuid); and and or take \
booleans; sumLong takes int64 only; addAll takes two lists, two maps, or a list and a value; assign \
accepts anything. Convert values with as_number(), as_bool() or as_string() first, for example \
g.v().values(\"age\").as_number().",
);
help.push_str(
"\nIn a sack the operand comes from by(), or is the traverser's own value without one: \
g.with_sack(0).v().out_e().sack(Operator.sum).by(\"weight\") adds every edge weight to the \
sack. To combine a sack with a value that is not a number yet, convert the operand, for \
example g.with_sack(0).v().sack(Operator.sum).by(__.values(\"age\").as_number()).",
);
help.push_str(
"\nfold(seed, Operator) and with_side_effect(name, initial, Operator) apply the operator to every \
value of the stream: g.v().values(\"age\").fold(0, Operator.sum) adds the ages, and \
g.with_side_effect(\"a\", 0, Operator.sum).v().aggregate(\"a\").by(\"age\").cap(\"a\") does the \
same into a side effect. The seed decides the kind: 0 for sum, 1 for mult, true for and, [] for addAll.",
);
if operator == "normSack" {
help.push_str(
"\nbarrier(Barrier.normSack) scales numeric sacks (null sacks are skipped), so every \
sack in the stream must be a number or null, for example \
g.with_sack(1.0).v().out().barrier(Barrier.normSack).sack().",
);
}
help
}
fn coercion_step(expected: &str) -> Option<&'static str> {
Some(match expected {
"string" => "as_string()",
"boolean" => "as_bool()",
"number" => "as_number(GType.LONG)",
"int64" => "as_number(GType.LONG)",
"float64" => "as_number(GType.DOUBLE)",
"uuid" => "cast(GType.UUID)",
_ => return None,
})
}
fn coercion_note(expected: &str) -> Option<&'static str> {
match expected {
"boolean" => Some(
"as_bool() turns 0 (and NaN) into false, any other number into true, and the texts \
true/false in any letter case; any other text is an error.",
),
"number" | "int64" => Some("as_number(GType.LONG) truncates a float toward zero."),
_ => None,
}
}
fn is_list_kind(actual: &str) -> bool {
actual == "array" || actual.starts_with("array<")
}
const LIST_SCOPE_LOCAL_HINT: &str = "\nThe value is a list (for example from fold()): reduce each \
traverser's list with Scope.local, for example g.v().values(\"age\").fold().sum(Scope.local) \
or g.v().fold().count(Scope.local), or unfold() it first. \
A string step transforms each element of the list the same way, for example \
g.v().values(\"name\").fold().to_upper(Scope.local). \
To join the elements into one string use conjoin(), for example \
g.v().values(\"name\").fold().conjoin(\", \"); to combine it with a second list use \
combine(), merge(), intersect(), difference(), disjunct() or product(), for example \
g.v().values(\"name\").fold().combine([\"dave\"]). reverse() reverses the list itself, for \
example g.v().values(\"name\").fold().reverse().";
fn snake_step_name(step: &str) -> String {
let mut name = String::with_capacity(step.len() + 2);
for c in step.chars() {
if c.is_ascii_uppercase() {
name.push('_');
name.push(c.to_ascii_lowercase());
} else {
name.push(c);
}
}
name
}
fn local_element_help(step: &str, index: usize, actual: &str) -> String {
let snake = snake_step_name(step);
if step == "asString" {
return format!(
"{step}(local) converts each element of the list a traverser holds to a string, and \
element {index} has type '{actual}', which has no string form.\n\
Drop the null elements before converting, for example \
g.v().values(\"age\").fold().unfold().not(__.is(typeOf(GType.NULL))).fold().as_string(Scope.local), \
or convert the stream before folding it: g.v().values(\"age\").as_string().fold()."
);
}
format!(
"{step}(local) transforms each element of the list a traverser holds, so every element \
must be a string (a null element stays null), but element {index} has type '{actual}'.\n\
Convert the elements first, for example \
g.v().values(\"name\").fold().unfold().as_string().fold().{snake}(Scope.local), \
or keep only the strings with \
g.v().values(\"name\").fold().unfold().is(typeOf(GType.STRING)).fold().{snake}(Scope.local)."
)
}
fn label_step_help(step: &str, actual: &str) -> String {
let vertex_steps = "a vertex carries a label set, changed with add_label() and drop_label(), \
for example g.v().has_label(\"person\").add_label(\"employee\") or \
g.v(1).drop_label(\"person\")";
let edge_step = "an edge has exactly one label, changed with set_label(), for example \
g.e().has_label(\"knows\").set_label(\"met\")";
match (step, actual) {
("set_label", "vertex") => {
format!("set_label() changes the label of an edge; {vertex_steps}.")
}
("add_label" | "drop_label", "edge") => {
format!("{step}() changes the label set of a vertex; {edge_step}.")
}
_ => format!(
"{step}() changes the label of a graph element, not of a value of type '{actual}': \
{vertex_steps}; {edge_step}. Keep the elements in the stream (no values(), \
id() or label() before the step)."
),
}
}
fn list_function_help(step: &str, operand: &str, expected: &str, actual: &str) -> String {
let argument = if step == "conjoin" {
"\"-\""
} else {
"[\"x\"]"
};
match (operand, expected) {
("incoming value", "list or map") => format!(
"{step}() works on the list or the map a traverser holds. Collect the stream into a \
list with fold() first, for example g.v().values(\"name\").fold().{step}([\"x\"]), or \
merge two maps, for example \
g.v().project(\"name\").by(\"name\").{step}([\"other\": \"blueprint\"]) (Rhai: \
#{{other: \"blueprint\"}})."
),
("incoming value", _) if actual == "null" => format!(
"{step}() works on the list a traverser holds, and a null is not a list. Keep the \
null values out before the step, for example \
g.v().values(\"tags\").not(__.is(typeOf(GType.NULL))).{step}({argument})."
),
("incoming value", _) => format!(
"{step}() works on the list a traverser holds (a fold(), a path(), a list-valued \
property). Collect the stream into a list with fold() first, for example \
g.v().values(\"name\").fold().{step}({argument})."
),
("traversal argument", _) => format!(
"The child traversal of {step}() must yield a {expected} as its first result. End it \
with fold() to turn its stream into a list, for example \
g.v().values(\"name\").fold().{step}(__.v().values(\"name\").fold()); a traversal \
that yields nothing or null cannot be used."
),
(_, "map") => format!(
"{step}() of a map merges another map into it: pass a map constant or a traversal \
whose first result is a map, for example \
g.v().has(\"name\", \"marko\").element_map().{step}(__.v().has(\"name\", \"lop\")\
.element_map()); to merge lists, fold() the incoming stream into a list first."
),
_ => format!(
"The argument of {step}() is a {expected} constant such as [\"a\", \"b\"] or a child \
traversal whose first result is a {expected}, for example \
g.v().values(\"name\").fold().{step}(__.v().values(\"name\").fold()); null and \
single values are not lists (wrap one value: [\"a\"])."
),
}
}
fn string_function_help(step: &str, operand: &str, actual: &str) -> String {
match operand {
"traversal argument" if actual.starts_with("no result") => format!(
"A child traversal of {step}() must yield a value for every traverser: its first \
result is appended. Make it always produce one, for example with coalesce(): \
g.v().values(\"name\").{step}(__.coalesce(__.values(\"lang\"), __.constant(\"\"))), \
or keep only the traversers it has a value for: \
g.v().where(__.values(\"lang\")).values(\"name\").{step}(__.values(\"lang\"))."
),
"traversal argument" => format!(
"A child traversal of {step}() must yield a string (or null, which is skipped). \
Convert its result with as_string(), for example \
g.v().values(\"name\").{step}(\" \").{step}(__.values(\"age\").as_string()); a list \
is joined into one string with conjoin(), for example \
g.v().values(\"name\").{step}(__.out().values(\"name\").fold().conjoin(\",\"))."
),
_ => format!(
"The arguments of {step}() are strings (a null is skipped) or child traversals, for \
example g.v().values(\"name\").{step}(\" is \", \"here\") or \
g.v().values(\"name\").{step}(__.values(\"age\").as_string()); convert a number with \
as_string() in a child traversal instead of passing it directly."
),
}
}
const UUID_CANONICAL_HINT: &str = "\nA UUID is written in canonical form: 8-4-4-4-12 lowercase hex \
digits, no braces, for example UUID.from_string(\"550e8400-e29b-41d4-a716-446655440000\").";
pub(crate) const SELECT_COLUMN_EXPECTED: &str = "map or entry";
pub(crate) const T_TOKEN_EXPECTED: &[&str] = &[
"vertexProperty",
"edgeProperty",
"pathProperty",
"map entry",
];
const PROPERTY_OBJECT_EXPECTED: &str = "vertexProperty or edgeProperty or pathProperty";
const DROP_EXPECTED: &str = "vertex or vertexProperty or edge or edgeProperty";
const KEY_FILTER_EXPECTED: &str = "computed or vertexProperty or edgeProperty or vertex or edge";
const ADJACENCY_EXPECTED: &str = "vertex or vertexProperty or computed (linked to vertex)";
fn expects_an_element(expected: &str) -> bool {
expected
.split(" or ")
.any(|kind| kind == "vertex" || kind == "edge")
}
const DROP_HINT: &str = "\ndrop() removes stored graph data: a vertex, an edge, or a property \
from properties(key) (or a values(key) reference). A computed value, a list, a map or a path \
is not stored, so there is nothing to remove. To drop the elements a list or a path holds, \
unfold() it first, for example g.v(\"1\").out().fold().unfold().drop() or \
g.v(\"1\").out().path().unfold().drop(); to remove a property keep the element and use \
properties(\"key\").drop() or remove_property(\"key\").";
const KEY_FILTER_PATH_PROPERTY_HINT: &str = "\nhas(key)/has_not(key) read a key of a vertex, an \
edge, a map or an object-valued property. A path property element from \
properties(jpath(..)) is the value at the end of the path: test the path on the element \
instead, for example g.v().has(jpath(\"meta.a\"), 1) or g.v().has(jpath(\"meta.a\"), P.gt(0)), \
or test the value itself with has_value(..) or value().is(..).";
const KEY_FILTER_ENTRY_HINT: &str = "\nhas(key)/has_not(key) read a key of a whole map, but \
unfold() split the map into single key/value entries. Filter the map before unfold(), for \
example g.v().value_map().has(\"name\").unfold(), or test an entry's key or value with \
select(Column.keys).is(..) / select(Column.values).is(..), for example \
g.v().value_map().unfold().where(__.select(Column.keys).is(\"name\")).";
const ADJACENCY_ON_EDGE_HINT: &str = "\nout()/in()/both() and out_e()/in_e()/both_e() start from a \
vertex. From an edge, step to its vertices first with out_v()/in_v()/both_v()/other_v(), for \
example g.v(\"1\").out_e().in_v().in_e() (the edges into the vertices the outgoing edges reach).";
fn is_map_kind(actual: &str) -> bool {
matches!(actual, "object" | "record" | "entry")
}
const MAP_COLUMN_HINT: &str = "\nA map (for example the result of group() or group_count()) is not a \
graph element, so properties()/key()/value() do not apply to it. Read its keys or values with \
unfold().select(Column.keys) / unfold().select(Column.values), one traverser per entry, for \
example g.v().group().by(\"name\").by(__.out().count()).unfold().select(Column.keys); \
select(Column.keys) without unfold() returns them all as one list.";
const SELECT_COLUMN_HINT: &str = "\nselect(Column.keys) / select(Column.values) reads a map \
(group(), group_count(), project(), element_map(), value_map()) or one entry of it produced by \
unfold(), for example g.v().group_count().by(\"name\").unfold().select(Column.keys). To read \
a path label use select(\"label\") with a string; to read a property use values(\"key\"). \
select(Column.keys) of a path() lists the step labels of each position, for example \
g.v().as(\"a\").out().as(\"b\").path().select(Column.keys), and select(Column.values) \
of a path() lists its objects, for example g.v(\"1\").out().path().select(Column.values).";
fn gtype_token(observed: &str) -> Option<&'static str> {
Some(match observed {
"null" => "NULL",
"boolean" => "BOOLEAN",
"int64" => "LONG",
"float64" => "DOUBLE",
"string" => "STRING",
"uuid" => "UUID",
_ => return None,
})
}
fn is_nested_container(observed: &str) -> bool {
observed == "object" || observed.starts_with("array<")
}
fn glob_pattern_help(pattern: &str, reason: &str) -> String {
use crate::value::glob_path::{double_star_suggestion, reason as r};
const SYNTAX: &str = "\nA pattern is a '/'-separated list of segments matched level by level below \
the start vertex: '*' (any value), '?' (one character), '**' (any number of levels, a whole \
segment only), '[a-z]' / '[!a-z]' (character classes), '{a,b}' (alternatives within one \
segment) and '\\' to escape any of them, for example glob_path(\"**/src/*.{rs,toml}\").";
let advice = if reason == r::DOUBLE_STAR_IN_SEGMENT {
match double_star_suggestion(pattern) {
Some(fixed) => format!(
"'**' spans whole levels, so it must stand alone between slashes. Write \
glob_path(\"{fixed}\") instead, or plain glob_path(\"**\") to match every level \
below the start vertex. A literal '*' is written '\\*'."
),
None => "'**' spans whole levels, so it must stand alone between slashes, for example \
glob_path(\"a*/**/*b\") instead of glob_path(\"a**b\"), or plain \
glob_path(\"**\")."
.to_string(),
}
} else if reason == r::SLASH_IN_ALTERNATIVES {
"Alternatives in '{..}' choose within one segment and cannot contain '/'. Split the \
alternative into two queries, for example glob_path(\"src/a\") and glob_path(\"lib/b\") \
instead of glob_path(\"{src/a,lib/b}\"), or use '**' to span levels, for example \
glob_path(\"**/{a,b}\"). A value that itself contains '/' is matched with '\\/'."
.to_string()
} else if reason == r::NESTED_ALTERNATIVES {
"Alternatives cannot be nested; flatten them into one group, for example \
glob_path(\"{a,b1,b2}\") instead of glob_path(\"{a,b{1,2}}\"), or place two groups side \
by side: glob_path(\"{a,b}{1,2}\")."
.to_string()
} else if reason == r::RESERVED_SEGMENT {
"'.' and '..' as whole segments are reserved for relative navigation. The start vertex is \
already the current level: glob_path(\"docs/*\") matches its children directly. To walk \
back up use .in() or repeat(__.in()) before the step, for example \
g.v(\"x\").in().glob_path(\"docs/*\"). A value that is literally '.' is matched with '\\.'."
.to_string()
} else if reason == r::TOO_MANY_SEGMENTS {
"A pattern can have at most 63 segments (a run of '**/**' counts once). Split the walk \
into two consecutive steps, each matching below the vertices the previous one emits, for \
example glob_path(\"a/b/c\").glob_path(\"d/e/f\") instead of glob_path(\"a/b/c/d/e/f\")."
.to_string()
} else if reason == r::TOO_MANY_ALTERNATIVES {
"The '{..}' groups of one segment expand to their product, capped at 256 alternatives. \
Replace a group with a class or a wildcard, for example glob_path(\"file[0-9][0-9]\") \
instead of glob_path(\"file{0,1,...,9}{0,1,...,9}\"), or split the query."
.to_string()
} else if reason == r::UNCLOSED_CLASS {
"Close the character class with ']' in the same segment, for example \
glob_path(\"file[0-9].txt\"); match a literal '[' with '\\['."
.to_string()
} else if reason == r::EMPTY_CLASS {
"A character class needs at least one character; a ']' right after '[' or '[!' is taken \
literally, so glob_path(\"[]]\") matches ']' and glob_path(\"[!]]\") anything else."
.to_string()
} else if reason == r::INVERTED_RANGE {
"A range goes from the lower to the higher character, for example glob_path(\"[a-z]*\") \
instead of glob_path(\"[z-a]*\")."
.to_string()
} else if reason == r::UNCLOSED_ALTERNATIVES {
"Close the alternatives with '}', for example glob_path(\"*.{rs,toml}\"); match a literal \
'{' with '\\{'."
.to_string()
} else if reason == r::TRAILING_ESCAPE {
"'\\' escapes the character after it; match a literal backslash with '\\\\', for example \
glob_path(\"dir\\\\\")."
.to_string()
} else if reason == r::EMPTY_PATTERN {
"Pass a non-empty pattern, for example glob_path(\"*\") for the children of the start \
vertex or glob_path(\"**\") for all of its descendants."
.to_string()
} else if reason == r::LEADING_SLASH
|| reason == r::TRAILING_SLASH
|| reason == r::EMPTY_SEGMENT
{
"The pattern is relative to the start vertex: drop the leading and trailing '/' and the \
empty segments, for example glob_path(\"src/main.rs\") instead of \
glob_path(\"/src//main.rs/\"). Use '**' to match any number of levels."
.to_string()
} else {
"Fix the pattern at the reported position.".to_string()
};
format!("{advice}{SYNTAX}")
}
impl DiagnosticError for ValueError {
fn help(&self) -> Option<String> {
match self {
ValueError::CastException {
expected,
actual,
origin,
..
} => {
let convert = coercion_step(expected);
let t_token = *expected == T_TOKEN_EXPECTED.join(" or ");
let locate = gtype_token(actual).filter(|_| !t_token);
match origin.as_ref().and_then(|origin| {
origin.property.as_ref().map(|property| (origin, property))
}) {
Some((origin, property)) => {
let mut help = format!(
"Property '{property}' holds a {actual} on {}, but [{expected}] is required here.\n\
Filter the stream before this step (for example with .has(\"{property}\", ...)), \
or declare '{property}' in the graph schema so the type is enforced on write.",
origin.element()
);
if let Some(convert) = convert {
help.push_str(&format!(
"\nConvert the values in the query instead: values(\"{property}\").{convert}."
));
help.push_str(&format!(
"\nOr fix the data in place: property(\"{property}\", __.values(\"{property}\").{convert})."
));
if let Some(note) = coercion_note(expected) {
help.push_str(&format!("\n{note}"));
}
}
if let Some(locate) = locate {
help.push_str(&format!(
"\nFind every offending value with values(\"{property}\").is(typeOf(GType.{locate}))."
));
}
if expected == "uuid" {
help.push_str(UUID_CANONICAL_HINT);
}
if is_nested_container(actual) {
help.push_str(&format!(
"\nReach one nested field directly instead of chaining values(): \
json_path(\"{property}.subkey\") instead of \
values(\"{property}\").values(\"subkey\").\n\
A jpath key does the same inside any step that takes a property name: \
values(jpath(\"{property}.subkey\")), has(jpath(\"{property}.subkey\"), v), \
order().by(jpath(\"{property}.subkey\")); a missing path is simply absent."
));
}
if is_list_kind(actual) {
help.push_str(&format!(
"\n'{property}' holds a list: reduce each traverser's list with \
Scope.local, for example values(\"{property}\").sum(Scope.local) \
or values(\"{property}\").count(Scope.local), or unfold() it first. \
values(\"{property}\").conjoin(\", \") joins it into one string."
));
}
Some(help)
}
None => {
let mut help =
"Check the value type before performing conversion or cast operation."
.to_string();
if let Some(convert) = convert {
help.push_str(&format!("\nConvert the stream first with .{convert}."));
if let Some(note) = coercion_note(expected) {
help.push_str(&format!("\n{note}"));
}
}
if let Some(locate) = locate {
help.push_str(&format!(
"\nFind every offending value with .is(typeOf(GType.{locate}))."
));
}
if (expected == "string"
|| (expected.contains("computed") && !expects_an_element(expected)))
&& matches!(actual.as_str(), "vertex" | "edge")
{
help.push_str(
"\nThis step needs a string, and a graph element is not one. as_string() \
renders an element as text (g.v().as_string() gives \"v[1]\", an edge \
\"e[7][1-knows->2]\"); to work on a property text, read it first: \
g.v().values(\"name\").to_upper().",
);
}
if expected == "uuid" {
help.push_str(UUID_CANONICAL_HINT);
}
if expected == "scalar, array or object" {
help.push_str(
"\nA sack holds data, not a graph element. Store the element's id or one \
of its properties instead: sack(Operator.assign).by(T.id) or \
sack(Operator.assign).by(\"name\")."
);
}
if t_token {
help.push_str(
"\nT.key and T.value read a property element or a map entry, as in \
TinkerPop: produce one with .properties(...) (or unfold() a map) \
first, for example g.v().properties(\"name\").order().by(T.value). \
A plain value (a values(...) result, a number) is already the \
value: use by() with no argument or by(__.identity()) instead, \
for example g.v().values(\"age\").order().by().",
);
}
if expected == PROPERTY_OBJECT_EXPECTED {
help.push_str(
"\n.value()/.key() only read a property object, produced by \
.properties(...) or a raw .values(key) reference — call one of \
those first, for example .properties(\"name\").value() instead \
of .value() directly on a vertex/edge.",
);
}
if expected == DROP_EXPECTED && actual != "pathProperty" {
help.push_str(DROP_HINT);
}
if expected == KEY_FILTER_EXPECTED && actual == "pathProperty" {
help.push_str(KEY_FILTER_PATH_PROPERTY_HINT);
}
if expected == KEY_FILTER_EXPECTED && actual == "entry" {
help.push_str(KEY_FILTER_ENTRY_HINT);
}
if expected == ADJACENCY_EXPECTED && actual == "edge" {
help.push_str(ADJACENCY_ON_EDGE_HINT);
}
if actual == "pathProperty" && expected == DROP_EXPECTED {
help.push_str(
"\nA property found by a path, properties(jpath(..)), holds no reference to \
the element it came from, so drop() cannot remove it. Remove the value at \
the path with remove_property(jpath(..)) on the element, for example \
g.v().remove_property(jpath(\"meta.tags[0]\")) (an object key is removed, an \
array element is removed with shift).",
);
}
if is_map_kind(actual)
&& (expected.contains("vertex") || expected.contains("edge"))
&& !(expected == KEY_FILTER_EXPECTED && actual == "entry")
&& expected != DROP_EXPECTED
{
help.push_str(MAP_COLUMN_HINT);
}
if expected == SELECT_COLUMN_EXPECTED {
help.push_str(SELECT_COLUMN_HINT);
}
if is_list_kind(actual) && expected != DROP_EXPECTED {
help.push_str(LIST_SCOPE_LOCAL_HINT);
}
Some(help)
}
}
}
ValueError::IncomparableValues { step, expected, actual, origin, .. } => {
let property = origin.as_ref().and_then(|origin| origin.property.as_ref());
let convert = coercion_step(expected);
let locate = gtype_token(actual);
let mut help = match property {
Some(property) => format!(
"Property '{property}' holds a {actual} where '{step}' can only order [{expected}] values."
),
None => format!(
"'{step}' can only order [{expected}] values here, but the stream holds a {actual}."
),
};
help.push_str(&format!(
"\n{step}() compares numbers with numbers and text with text (booleans and uuids \
with their own kind); graph elements, paths and collections are not ordered."
));
match property {
Some(property) => {
help.push_str(&format!(
"\nFilter the stream before this step (for example with .has(\"{property}\", ...)), \
or declare '{property}' in the graph schema so the type is enforced on write."
));
if let Some(convert) = convert {
help.push_str(&format!(
"\nConvert the values in the query instead: values(\"{property}\").{convert}."
));
help.push_str(&format!(
"\nOr fix the data in place: property(\"{property}\", __.values(\"{property}\").{convert})."
));
if let Some(note) = coercion_note(expected) {
help.push_str(&format!("\n{note}"));
}
}
if let Some(locate) = locate {
help.push_str(&format!(
"\nFind every offending value with values(\"{property}\").is(typeOf(GType.{locate}))."
));
}
}
None => {
if let Some(convert) = convert {
help.push_str(&format!("\nConvert the stream first with .{convert}."));
if let Some(note) = coercion_note(expected) {
help.push_str(&format!("\n{note}"));
}
}
if let Some(locate) = locate {
help.push_str(&format!(
"\nFind every offending value with .is(typeOf(GType.{locate}))."
));
}
}
}
help.push_str(&format!(
"\nTo compare a mixed property read it as one kind first, with \
values(\"p\").as_string() or values(\"p\").as_number(GType.LONG), for example \
g.v().values(\"name\").{step}()."
));
Some(help)
}
ValueError::PropertyNotFound {
key,
element_type: "path",
} => Some(format!(
"No step label '{key}' is set on the path of this traverser.\n\
Label the position with as() before reading it, for example \
g.v(\"1\").as(\"{key}\").out().select(\"{key}\"), or read the whole path \
with path(). A label set several times (as(\"{key}\") inside repeat()) is read \
with select(Pop.all, \"{key}\") (every occurrence), Pop.first, Pop.last (the \
default) or Pop.mixed."
)),
ValueError::UndeclaredLabel { label: key } => Some(format!(
"Step label '{key}' is never declared anywhere in this query: no as(\"{key}\") \
and no side-effect key '{key}' from aggregate(\"{key}\")/store(\"{key}\")/\
group(\"{key}\")/group_count(\"{key}\")/\
tree(\"{key}\") exists, so it can never resolve — this is caught before \
execution instead of silently returning nothing.\n\
Label the position with as() before reading it, for example \
g.v(\"1\").as(\"{key}\").out().select(\"{key}\"), or read the whole path \
with path(). A label that is declared but only sometimes set (e.g. inside \
union()/optional()/choose()) is not an error: the traverser is filtered instead.\n\
A label set several times (as(\"{key}\") inside repeat()) is read with \
select(Pop.all, \"{key}\") (every occurrence), Pop.first, Pop.last (the default) \
or Pop.mixed, for example g.v(\"1\").as(\"{key}\").repeat(__.out().as(\"{key}\"))\
.times(2).select(Pop.all, \"{key}\").\n\
A label can also be read with where(): g.v(\"1\").as(\"{key}\").out()\
.where(__.as(\"{key}\").values(\"name\")) starts the child at the object \
labelled '{key}', and a trailing as(\"{key}\") keeps the traverser only when the \
child ends at that object."
)),
ValueError::PropertyNotFound {
key,
element_type: "side_effect",
} => Some(format!(
"No side-effect key '{key}' has been populated yet.\n\
A key whose aggregate()/store()/subgraph() step ran but collected nothing is not \
an error: cap(\"{key}\") returns the empty collection. This error therefore means \
that no aggregate()/store()/subgraph()/tree()/with_side_effect() declares the key \
(usually a typo).\n\
Populate it upstream with aggregate(\"{key}\") or store(\"{key}\") (or \
with_side_effect(\"{key}\", ...) for a fixed value, tree(\"{key}\") for a \
hierarchical tree, group(\"{key}\")/group_count(\"{key}\") for a grouped map, \
for example g.v().group_count(\"{key}\").by(T.label).cap(\"{key}\")), then read it with cap(\"{key}\") or \
where(within(\"{key}\"))/where(without(\"{key}\")) — a bare string argument to \
within()/without() inside where() resolves against a populated side-effect key \
first and only falls back to a literal value comparison when no such key exists.\n\
aggregate()/store() keep duplicates; to collect each value once declare the key \
as a set first: g.with_side_effect(\"{key}\", Set.of()).v().both().values(\"name\")\
.aggregate(\"{key}\").cap(\"{key}\"); to combine them with an operator instead of \
collecting them declare a reducer: g.with_side_effect(\"{key}\", 0, Operator.sum).v()\
.aggregate(\"{key}\").by(\"age\").cap(\"{key}\")."
)),
ValueError::PropertyNotFound {
key,
element_type: "list",
} => Some(format!(
"A list has no property '{key}': by(\"{key}\") reads a property of an element or a \
map, and the value here is a list (for example the result of select(Pop.all, \"a\") \
or of a select(Pop.mixed, \"a\") over a label set several times, or of fold()).\n\
Ensure the property key exists on the specified element type or use .has() filter.\n\
List the keys actually present with .properties() (no arguments).\n\
Read the property of every element of the list with a child traversal, for example \
g.v().as(\"a\").out().as(\"a\").select(Pop.all, \"a\")\
.by(__.unfold().values(\"{key}\").fold()); by() applies to the whole selected list, \
never to each element."
)),
ValueError::PropertyNotFound { key, .. } => {
Some(format!(
"Ensure the property key exists on the specified element type or use .has() filter.\n\
List the keys actually present with .properties() (no arguments).\n\
A key is a literal property name, so '{key}' is not read as a path even when it \
contains dots. To reach a field inside an object or array property use a jpath key, \
for example values(jpath(\"meta.k\")) or by(jpath(\"meta.k\")); a path that does not \
exist yields nothing instead of an error."
))
}
ValueError::InvalidTypeOperation { .. } => Some(
"Ensure operand types are compatible for operation, or convert one side first \
with .as_string(), .as_bool() or .as_number(GType.LONG)."
.to_string(),
),
ValueError::PathNotDefined => Some(
"Paths are recorded automatically for the steps that read them: label a position \
with as() and read it with select(), for example \
g.v(\"1\").as(\"a\").out().select(\"a\"), or read the whole path with \
path(), for example g.v(\"1\").out().path()."
.to_string(),
),
ValueError::InvalidNumber { .. } => {
Some("Verify that numeric values are within valid bounds and correct type format.".to_string())
}
ValueError::UnsupportedCastTarget { target, reason } => Some(format!(
"'{target}' cannot be produced by a conversion yet: {reason}.\n\
The supported targets are as_string(), as_bool(), as_number(), \
as_number(GType.LONG) and as_number(GType.DOUBLE)."
)),
ValueError::InvalidJsonPath { .. } => Some(
"A property path is a singular JSONPath (RFC 9535): member names and integer array \
indices only, e.g. jpath(\"meta.tags[0]\"), jpath(\"$.meta.tags[-1]\") (negative \
counts from the end) or jpath(\"meta['a.b'].k\") (bracket notation for a name that \
holds a dot, a quote or a bracket, escaped with \\' and \\\\). Use it as a key \
wherever a property name is taken, for example g.v().values(jpath(\"meta.k\")) \
or g.v().has(jpath(\"meta.k\"), 1), and as a step with json_path(\"$.meta.tags[0]\") \
or .by(By::JsonPath(\"meta.k\")). Recursive descent ('..'), wildcards ('*'), slices \
and filter expressions ('[?(...)]') are not supported. A plain string \
key such as values(\"a.b\") is never parsed: it names a property that literally \
contains a dot. A path has at most 256 segments, and an index is at most \
9007199254740991 (2^53 - 1) from either end."
.to_string(),
),
ValueError::InvalidGlobPattern { pattern, reason, .. } => {
Some(glob_pattern_help(pattern, reason))
}
ValueError::SampleWeight { .. } => Some(
"sample(n).by(..) draws each traverser with a probability proportional to its \
by() value, so the by() must yield a number of at least 0: a numeric property \
(g.e().sample(2).by(\"weight\")) or a traversal ending in a number \
(g.v().sample(1).by(__.out_e().count())). Convert a numeric string with \
as_number() inside the by(), for example \
g.v().sample(1).by(__.values(\"score\").as_number()), or drop by() for a uniform \
sample."
.to_string(),
),
ValueError::NumericIdComparison { fix, .. } => Some(format!(
"Element ids are strings, so an order comparison (P.gt, P.gte, P.lt, P.lte, \
P.between, P.inside, P.outside) with a number can never hold. Compare with a \
string instead: {fix}. Strings are ordered character by character, so \"10\" \
sorts before \"9\": P.gt(\"3\") keeps \"30\" but not \"10\". Equality needs no \
change: has_id(3), has_id(P.eq(3)), has_id(P.neq(3)), has_id(P.within(1, 2)) and \
has_id(P.without(1, 2)) compare the \
number's text."
)),
ValueError::InvalidRegex { .. } => Some(
"TextP.regex()/TextP.not_regex() take a regular expression in the syntax of the \
Rust `regex` crate (https://docs.rs/regex/latest/regex/#syntax), not Java's: \
classes [a-z], \\d, \\w, \\s, groups (..), alternation |, quantifiers * + ? \
{n,m}, inline flags such as (?i). Look-around ((?=..), (?!..)) and \
back-references (\\1) are not supported. Escape a literal metacharacter with a \
backslash (in a script string the backslash itself is doubled): \
g.v().has(\"name\", TextP.regex(\"^ma\\\\.\")). The match is unanchored: \
write ^...$ to match the whole string."
.to_string(),
),
ValueError::LocalElementType {
step,
index,
actual,
..
} => Some(local_element_help(step, *index, actual)),
ValueError::LabelStepElementKind { step, actual, .. } => {
Some(label_step_help(step, actual))
}
ValueError::ListFunctionInput {
step,
operand,
expected,
actual,
} => Some(list_function_help(step, operand, expected, actual)),
ValueError::StringFunctionInput {
step,
operand,
actual,
..
} => Some(string_function_help(step, operand, actual)),
ValueError::OperatorFailed { operator, kind, .. } => Some(operator_help(operator, kind)),
}
}
fn details(&self) -> Vec<(String, String)> {
let (value, origin) = match self {
ValueError::CastException { value, origin, .. }
| ValueError::IncomparableValues { value, origin, .. } => (value, origin),
_ => return Vec::new(),
};
let mut details = Vec::new();
if let Some(value) = value {
details.push(("value".to_string(), value.clone()));
}
if let Some(origin) = origin {
details.push(("origin".to_string(), origin.to_string()));
}
details
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum GraphError {
#[error("Vertex with ID '{id}' was not found in the graph")]
#[non_exhaustive]
VertexNotFound {
id: String,
},
#[error("Edge with ID '{id}' was not found in the graph")]
#[non_exhaustive]
EdgeNotFound {
id: String,
},
#[error("Vertex with ID '{id}' already exists in the graph")]
#[non_exhaustive]
VertexIdUniqueConstraint {
id: String,
},
#[error("Edge with ID '{id}' already exists in the graph")]
#[non_exhaustive]
EdgeIdUniqueConstraint {
id: String,
},
#[error(
"The {} was removed earlier in this query; its id, label and value are no longer available",
match .kind { GraphNodeType::Vertex => "vertex", GraphNodeType::Edge => "edge" }
)]
#[non_exhaustive]
ElementRemoved {
kind: GraphNodeType,
},
#[error("Invalid element state: {reason}")]
#[non_exhaustive]
InvalidElementState {
reason: String,
},
#[error(
"Vertex label '{label}' is invalid: it contains \"::\", which GraphSON and GraphML vertex label export reserve as the multi-label delimiter"
)]
#[non_exhaustive]
InvalidLabel {
label: String,
},
#[error(
"Property '{property_name}' cannot hold {}: {}",
match *.kind { "edge" => "an edge", "map with a non-string key" => "a map with a non-string key", _ => "a vertex" },
if *.kind == "map with a non-string key" { "a property map is keyed by strings" } else { "vertices and edges are not property values" }
)]
#[non_exhaustive]
InvalidPropertyValue {
property_name: String,
kind: &'static str,
},
#[error("Cannot write property path '{path}': {fault}")]
#[non_exhaustive]
InvalidPropertyPath {
path: String,
fault: PathWriteFault,
},
#[error(transparent)]
Schema(#[from] SchemaError),
#[error(transparent)]
Definition(#[from] DefinitionError),
#[error("The commit was rejected by the commit hook '{hook}': {source}")]
#[non_exhaustive]
CommitRejected {
hook: String,
#[source]
source: crate::graph::HookError,
},
#[error("Cannot set a mark: {reason}")]
#[non_exhaustive]
MarkUnavailable {
reason: &'static str,
},
#[error("The mark was rejected by '{hook}': {source}")]
#[non_exhaustive]
MarkRejected {
hook: String,
#[source]
source: crate::graph::HookError,
},
#[error("This graph storage does not support {operation}")]
#[non_exhaustive]
Unsupported {
operation: &'static str,
},
#[error("The {sequence} of this graph is exhausted: it reached {max} and cannot advance", max = u64::MAX)]
#[non_exhaustive]
SequenceOverflow {
sequence: &'static str,
},
#[error("Invalid change set: {reason}")]
#[non_exhaustive]
InvalidChangeSet {
reason: String,
},
#[error(
"Resource limit '{limit}' exceeded by the {operation}{}",
limit_detail(*.max, *.observed)
)]
#[non_exhaustive]
ResourceLimitExceeded {
operation: &'static str,
limit: String,
max: Option<u64>,
observed: Option<u64>,
},
}
pub(crate) const COMMIT_SEQUENCE: &str = "commit sequence number";
pub(crate) const SUBGRAPH_TARGET: &str =
"being the subgraph() target of with_side_effect(name, graph) (only a TraversalGraph is)";
pub(crate) const VERTEX_ID_SEQUENCE: &str = "vertex auto-id sequence";
pub(crate) const EDGE_ID_SEQUENCE: &str = "edge auto-id sequence";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PathWriteFault {
FirstSegmentNotName,
KeyOnNonObject {
at: String,
found: &'static str,
},
IndexOnNonArray {
at: String,
found: &'static str,
},
IndexOutOfRange {
at: String,
index: i64,
len: usize,
},
MissingArray {
at: String,
},
CardinalityWithPath,
}
impl std::fmt::Display for PathWriteFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathWriteFault::FirstSegmentNotName => {
write!(f, "the first segment must name the property, not an index")
}
PathWriteFault::KeyOnNonObject { at, found } => {
write!(
f,
"'{at}' is {} {found}, not an object, so a key cannot be set on it",
article(found)
)
}
PathWriteFault::IndexOnNonArray { at, found } => {
write!(
f,
"'{at}' is {} {found}, not an array, so an index cannot be used on it",
article(found)
)
}
PathWriteFault::IndexOutOfRange { at, index, len } => {
write!(
f,
"index {index} is out of range for the array '{at}' of length {len}"
)
}
PathWriteFault::MissingArray { at } => {
write!(
f,
"the array '{at}' does not exist, and an index segment never creates one"
)
}
PathWriteFault::CardinalityWithPath => {
write!(
f,
"property(Cardinality, jpath(..), value) is not supported"
)
}
}
}
}
impl GraphError {
pub fn unsupported(operation: &'static str) -> Self {
GraphError::Unsupported { operation }
}
pub fn element_removed(kind: GraphNodeType) -> Self {
GraphError::ElementRemoved { kind }
}
pub fn vertex_not_found(id: impl Into<String>) -> Self {
GraphError::VertexNotFound { id: id.into() }
}
pub fn edge_not_found(id: impl Into<String>) -> Self {
GraphError::EdgeNotFound { id: id.into() }
}
pub fn duplicate_vertex_id(id: impl Into<String>) -> Self {
GraphError::VertexIdUniqueConstraint { id: id.into() }
}
pub fn duplicate_edge_id(id: impl Into<String>) -> Self {
GraphError::EdgeIdUniqueConstraint { id: id.into() }
}
pub fn invalid_label(label: impl Into<String>) -> Self {
GraphError::InvalidLabel {
label: label.into(),
}
}
pub fn invalid_element_state(reason: impl Into<String>) -> Self {
GraphError::InvalidElementState {
reason: reason.into(),
}
}
pub fn invalid_property_value(property_name: impl Into<String>, kind: &'static str) -> Self {
GraphError::InvalidPropertyValue {
property_name: property_name.into(),
kind,
}
}
pub fn invalid_property_path(path: &crate::value::JsonPath, fault: PathWriteFault) -> Self {
GraphError::InvalidPropertyPath {
path: path.to_string(),
fault,
}
}
pub fn mark_unavailable(reason: &'static str) -> Self {
GraphError::MarkUnavailable { reason }
}
pub(crate) fn vertex_removed() -> Self {
GraphError::ElementRemoved {
kind: GraphNodeType::Vertex,
}
}
pub(crate) fn edge_removed() -> Self {
GraphError::ElementRemoved {
kind: GraphNodeType::Edge,
}
}
}
fn refusal_help(source: &(dyn std::error::Error + Send + Sync + 'static)) -> Option<String> {
if let Some(refusal) = source.downcast_ref::<crate::graph::HookRefusal>() {
return Some(refusal.help().to_string());
}
#[cfg(feature = "persist")]
if let Some(err) = source.downcast_ref::<crate::persist::PersistError>() {
return err.help();
}
None
}
fn article(kind: &str) -> &'static str {
if kind.starts_with(['a', 'e', 'i', 'o', 'u']) {
"an"
} else {
"a"
}
}
impl DiagnosticError for GraphError {
fn help(&self) -> Option<String> {
match self {
GraphError::VertexNotFound { .. } => Some(
"Verify that the vertex ID exists in the graph before referencing it. A vertex that was \
dropped earlier (also earlier in the same traversal, for example by \
side_effect(__.drop())) no longer exists, and neither do its edges; do not add an \
edge to it, or add the edge before the drop."
.to_string(),
),
GraphError::EdgeNotFound { .. } => Some(
"Verify that the edge ID exists in the graph before referencing it.".to_string(),
),
GraphError::VertexIdUniqueConstraint { .. } => {
Some(
"Ensure vertex IDs are unique when adding new vertices. To get the vertex \
with this id if it exists and create it otherwise, use mergeV, for example \
g.merge_v(#{\"T.id\": \"1\"}).option(Merge.onCreate, #{\"T.label\": \"person\"})."
.to_string(),
)
}
GraphError::EdgeIdUniqueConstraint { .. } => {
Some(
"Ensure edge IDs are unique when adding new edges. To get the edge with this \
id if it exists and create it otherwise, use mergeE, for example \
g.merge_e(#{\"T.id\": \"7\"}).option(Merge.onCreate, #{\"T.label\": \
\"knows\", \"Direction.OUT\": \"1\", \"Direction.IN\": \"2\"})."
.to_string(),
)
}
GraphError::ElementRemoved { kind } => {
let (kind, example) = match kind {
GraphNodeType::Vertex => (
"vertex",
"g.v(\"1\").as(\"a\").element_map().as(\"m\")\
.select(\"a\").side_effect(__.drop()).select(\"m\")",
),
GraphNodeType::Edge => (
"edge",
"g.v(\"1\").out_e().as(\"e\").element_map().as(\"m\")\
.select(\"e\").side_effect(__.drop()).select(\"m\")",
),
};
Some(format!(
"The {kind} was dropped earlier in the same query (by drop(), \
side_effect(__.drop()), or together with a dropped vertex), but a later step \
still holds it, through a step label (as()/select()), a side effect \
(aggregate()/store()/cap()), a path or a sack. A removed {kind} has no \
properties and no edges, its id(), label() and value cannot be returned, \
and it cannot be changed. Materialize what you need before the drop (element_map(), value_map(), \
project()) and keep that instead, for example {example}, or run the drop as \
a separate query after reading. A lazy value such as values(\"name\") or \
id() taken before the drop is read only when it is returned, so it fails \
the same way."
))
}
GraphError::InvalidElementState { .. } => {
Some("Ensure element properties and states are valid.".to_string())
}
GraphError::InvalidLabel { .. } => Some(
"Rename the label to remove \"::\" — e.g. add_v(\"Admin_Role\") instead of \
add_v(\"Admin::Role\") — since GraphSON and GraphML export join a vertex's multiple labels \
with \"::\" (see labels()/has_label())."
.to_string(),
),
GraphError::InvalidPropertyValue {
property_name,
kind,
} if *kind == "map with a non-string key" => Some(format!(
"A property value that is a map must have string keys, because a stored map is a \
plain object. Store the map under string keys, for example \
property(\"{property_name}\", #{{\"29\": 1}}), or keep the data in the stream and \
show a map with a non-string key with to_json(), for example \
g.v().group_count().by(\"age\").to_json()."
)),
GraphError::InvalidPropertyValue {
property_name,
kind,
} => {
let (step, lookup) = if *kind == "edge" {
("out_e(\"knows\")", "g.e(id)")
} else {
("out(\"knows\")", "g.v(id)")
};
Some(format!(
"Store the {kind}'s id instead of the {kind} itself, e.g. \
property(\"{property_name}\", __.{step}.id()), and look the {kind} up \
again with {lookup}."
))
}
GraphError::InvalidPropertyPath { path, fault } => Some(match fault {
PathWriteFault::FirstSegmentNotName => format!(
"Start the path with the property name, for example \
property(jpath(\"meta.tags[0]\"), \"x\"); '{path}' begins with an index."
),
PathWriteFault::KeyOnNonObject { .. } => format!(
"A key segment needs an object. Read the current value first, for example \
g.v().values(jpath(\"{path}\")), or write a whole object at a shorter \
path, for example property(jpath(\"meta\"), #{{\"k\": 1}}). Missing keys are \
created, existing scalars and arrays are never overwritten by a deeper path."
),
PathWriteFault::IndexOnNonArray { .. } => format!(
"An index segment needs an array. Use a key segment for an object, for example \
jpath(\"meta.tags\") instead of jpath(\"meta[0]\"), or write the array first \
with property(jpath(\"meta.tags\"), [\"x\"]). Written path: {path}."
),
PathWriteFault::IndexOutOfRange { .. } => format!(
"Use an index inside the array, [len] to append as the last segment, or a \
negative index [-1] for the last element; arrays are never padded with null. \
Written path: {path}."
),
PathWriteFault::MissingArray { .. } => format!(
"Create the array first, for example property(jpath(\"meta.tags\"), [\"x\"]), \
then address its elements. Only key segments create missing objects. Written \
path: {path}."
),
PathWriteFault::CardinalityWithPath => {
"Graphersal stores one value per property; write a nested value with \
property(jpath(\"meta.tags[0]\"), \"x\") without a Cardinality, or build the \
whole value and write it with property(Cardinality.single, \"meta\", value)."
.to_string()
}
}),
GraphError::Schema(err) => err.help(),
GraphError::Definition(err) => err.help(),
GraphError::CommitRejected { hook, source }
if refusal_help(source.as_ref()).is_some() =>
{
refusal_help(source.as_ref()).map(|own| {
format!(
"The commit hook '{hook}' refused the commit, so the whole unit was \
rolled back and left nothing behind. {own}"
)
})
}
GraphError::CommitRejected { .. } => Some(
"A commit hook registered by the host application (for example the write-ahead \
log of a server) refused the changes, so the whole unit was rolled back: the \
query, transaction, whole script, import or applied change set left nothing \
behind. The hook's reason is in the message; once its cause is fixed (disk \
space, a replica, a business rule), run the query again unchanged. To see what would \
have been committed without committing, run it in a dry run (Rust: \
Transactional::dry_run), which calls no hooks."
.to_string(),
),
GraphError::MarkUnavailable { .. } => Some(
"A mark is a record in the graph's journal: register one first (Rust: \
persist::Journal::new(file, Durability::EveryCommit).attach(&mut graph), \
Python: graph.start_journal(path)), give it a name of 1 to 255 bytes and set \
it between queries, for example g.mark(\"before-import\"), not inside a \
transaction. A storage that keeps no journal (a custom GraphStorage without \
one) cannot set marks at all."
.to_string(),
),
GraphError::MarkRejected { .. } => Some(
"Mark names are unique within a journal: choose a new one, for example \
g.mark(\"before-import-2\"). When the message reports a write failure, the \
journal is unusable until the graph is recovered from its snapshot and journal."
.to_string(),
),
GraphError::Unsupported { operation } if *operation == SUBGRAPH_TARGET => Some(
"subgraph() writes only into a TraversalGraph, and every graph a script builds \
is one: let the subgraph land in a graph of the script, for example \
g.e().has_label(\"knows\").subgraph(\"sg\").cap(\"sg\") or \
g.e().has_label(\"knows\").to_graph(), and copy what the host graph needs \
into it with add_v()/add_e() or merge_v()/merge_e()."
.to_string(),
),
GraphError::Unsupported { operation } => Some(format!(
"The graph's storage does not implement {operation}: a GraphStorage \
implementation supports only what it implements of the trait (the trait's \
defaults refuse), and some operations are refused only in a state (on a \
read-only view, while a unit is open). Check the storage's documentation and \
what its GraphStorage::capabilities() report before using the operation (for \
example commit hooks and dry runs need change_capture, and therefore atomic), \
or run it on a TraversalGraph, the reference storage, which implements every \
operation of the trait."
)),
GraphError::SequenceOverflow { sequence } if *sequence == COMMIT_SEQUENCE => Some(
"The graph was numbered close to the end of the commit sequence (for example by \
TraversalGraph::with_last_commit_seq(u64::MAX), or by replaying a change set with \
such a commit_seq), so no further unit can commit; the failed one was rolled \
back and left nothing behind. Rebuild the graph from its data with a small \
commit sequence number: export it and load it into a new TraversalGraph."
.to_string(),
),
GraphError::SequenceOverflow { .. } => Some(
"An explicit numeric id raises the auto-id sequence to it, so an element added \
with an id at the end of the range (for example \
g.add_v(\"person\").property(T.id, \"18446744073709551615\")) leaves no room \
for further auto ids; the failed query was rolled back. Give new elements \
explicit ids, for example g.add_v(\"person\").property(T.id, \"p-1\"), and \
prefer non-numeric ids for imported elements, which do not move the sequence."
.to_string(),
),
GraphError::InvalidChangeSet { .. } => Some(
"A change set built with ChangeSet::builder carries the position of a real commit \
(commit_seq from 1 on, with its commit time in microseconds since the Unix \
epoch) or is a never-committed one (both 0, like a dry run). A \
Mutation::set_definition needs a before or an after definition, both of the \
kind and name it names; build it with Mutation::set_definition(kind, name, \
before, after)."
.to_string(),
),
GraphError::ResourceLimitExceeded {
operation,
limit,
max,
observed,
} => Some(unit_resource_limit_help(operation, limit, *max, *observed)),
}
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum IOError {
#[error("Failed to read file '{path}': {source}")]
#[non_exhaustive]
FileRead {
path: String,
#[source]
source: std::io::Error,
},
#[error("XML parsing error at position {position}: {message}")]
#[non_exhaustive]
XmlParse {
position: u64,
message: String,
},
#[error("Failed to write file '{path}': {source}")]
#[non_exhaustive]
FileWrite {
path: String,
#[source]
source: std::io::Error,
},
#[error("GraphSON parse error at line {line}: {message}")]
#[non_exhaustive]
GraphSONParse {
line: usize,
message: String,
},
#[error("Unsupported GraphSON variant at line {line}: {reason}")]
#[non_exhaustive]
UnsupportedGraphSON {
line: usize,
reason: String,
},
#[error("GraphSON input at line {line} is larger than the limit of {limit} bytes")]
#[non_exhaustive]
InputTooLarge {
line: usize,
limit: usize,
},
#[error("Unsupported graph file format: '{path}'")]
#[non_exhaustive]
UnsupportedFormat {
path: String,
},
#[cfg(feature = "serde")]
#[error("JSON parsing error: {0}")]
JsonParse(#[from] serde_json::Error),
#[cfg(feature = "persist")]
#[error("Snapshot '{path}': {source}")]
#[non_exhaustive]
Snapshot {
path: String,
#[source]
source: Box<crate::persist::PersistError>,
},
#[error("Missing required XML attribute '{attribute}' on element '{element}'")]
#[non_exhaustive]
MissingAttribute {
element: String,
attribute: String,
},
#[error(
"Invalid attribute '{attribute}' value '{value}' for key '{key}' (expected '{expected_type}')"
)]
#[non_exhaustive]
InvalidAttributeValue {
key: String,
attribute: String,
value: String,
expected_type: String,
},
#[error("Property '{key}' holds the non-finite float {value}, which GraphML cannot represent")]
#[non_exhaustive]
NonFiniteFloat {
key: String,
value: String,
},
#[error(
"Property '{key}' is declared as {declared}, a union of an array/object and a text type, \
which GraphML import cannot tell apart"
)]
#[non_exhaustive]
AmbiguousContainerImport {
key: String,
declared: String,
},
#[error(
"Property '{key}' is declared as {declared}, but its GraphML value is not valid JSON: \
{reason}"
)]
#[non_exhaustive]
InvalidJsonValue {
key: String,
declared: String,
reason: String,
},
#[error(transparent)]
Graph(#[from] GraphError),
}
impl DiagnosticError for IOError {
fn help(&self) -> Option<String> {
match self {
IOError::FileRead { .. } => Some(
"Check if the file path is correct and accessible with read permissions."
.to_string(),
),
IOError::XmlParse { .. } => {
Some("Verify that the XML file is well-formed.".to_string())
}
IOError::FileWrite { .. } => Some(
"Check that the directory exists and is writable, and that the disk is not full."
.to_string(),
),
IOError::GraphSONParse { .. } => Some(
"GraphSON is read as one vertex JSON object per line (TinkerPop's default, what \
g.io(file).write() produces) or as one document {\"vertices\": [...]}. Fix the \
JSON on the named line; a number must fit int64/float64."
.to_string(),
),
IOError::UnsupportedGraphSON { .. } => Some(
"Re-export the graph from TinkerPop 3 as GraphSON 3.0 (the default of \
g.io(\"graph.json\").write()) or as GraphML, and import that file."
.to_string(),
),
IOError::InputTooLarge { .. } => Some(
"Write the graph in the line form (one vertex per line, TinkerPop's default) \
instead of one wrapped document, or raise the importer's limit with \
GraphSONImport::new().with_max_text_bytes(n)."
.to_string(),
),
IOError::UnsupportedFormat { .. } => Some(
"Graph files are read by their extension: GraphSON ('.json', '.jsonl', \
'.graphson') or GraphML ('.xml', '.graphml'), for example \
GraphSource::file(\"graph.json\") or GraphSource::file(\"graph.graphml\")."
.to_string(),
),
#[cfg(feature = "serde")]
IOError::JsonParse(_) => {
Some("Verify that the JSON document is well-formed.".to_string())
}
#[cfg(feature = "persist")]
IOError::Snapshot { source, .. } => source.help(),
IOError::MissingAttribute { .. } => {
Some("Ensure all required XML attributes are present.".to_string())
}
IOError::InvalidAttributeValue { .. } => {
Some("Ensure attribute values match expected type formats.".to_string())
}
IOError::NonFiniteFloat { key, .. } => Some(format!(
"GraphML writes numbers as finite decimal text, so NaN and infinities cannot be \
exported: overwrite or drop the property on the affected elements before \
exporting, e.g. .property(\"{key}\", 0.0), .remove_property(\"{key}\") (keeps the element) or \
.properties(\"{key}\").drop(). GraphSON keeps them: \
g.export_graphson(\"graph.json\")."
)),
IOError::AmbiguousContainerImport { key, .. } => Some(format!(
"Declare '{key}' either as an array/object type (its GraphML text is then read \
as JSON) or as `string` (the text is kept as it is), not as a union of both, \
and set that schema on the target graph before importing. GraphSON keeps array/object \
values typed and needs no declaration: g.export_graphson(\"graph.json\")."
)),
IOError::InvalidJsonValue { key, .. } => Some(format!(
"A property declared as an array/object is imported from its JSON text, as \
GraphML export writes it (e.g. [\"a\",\"b\"] or {{\"cpu\":2}}). Fix the value \
of '{key}' in the file, or declare '{key}' as `string` in the target graph's \
schema to import the text unchanged. GraphSON keeps array/object values typed: \
g.export_graphson(\"graph.json\")."
)),
IOError::Graph(err) => err.help(),
}
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum ScriptError {
#[error("Script evaluation error near '{script_snippet}': {message}")]
#[non_exhaustive]
Eval {
script_snippet: String,
line: Option<usize>,
column: Option<usize>,
message: String,
},
#[error("Unsupported step '{step_name}' in script context")]
#[non_exhaustive]
UnsupportedStep {
step_name: String,
},
#[error("Argument mismatch for step '{step_name}': expected {expected}, got {actual}")]
#[non_exhaustive]
ArgumentMismatch {
step_name: String,
expected: String,
actual: String,
},
#[error("Invalid script parameter '{parameter}': {message}")]
#[non_exhaustive]
InvalidParameter {
parameter: String,
message: String,
},
#[error("A script run as one unit cannot return a closure or function pointer ({location})")]
#[non_exhaustive]
ReturnedClosure {
location: String,
},
#[error("query(\"{name}\") can only start a traversal, it cannot follow {position}")]
#[non_exhaustive]
QueryNotAtStart {
name: String,
position: String,
},
#[error("Saved query calls nested deeper than {limit} levels (calling '{name}')")]
#[non_exhaustive]
QueryDepthExceeded {
name: String,
limit: usize,
},
#[error("Saved query '{query}' has no parameter '{argument}'")]
#[non_exhaustive]
UnknownQueryArgument {
query: String,
argument: String,
parameters: Vec<String>,
},
#[error(
"Saved query '{query}' needs the argument '{parameter}' ({expected}), it has no default"
)]
#[non_exhaustive]
MissingQueryArgument {
query: String,
parameter: String,
expected: String,
},
#[error("Invalid argument '{parameter}' for saved query '{query}': {reason}")]
#[non_exhaustive]
InvalidQueryArgument {
query: String,
parameter: String,
reason: String,
},
#[error("Invalid body of saved query '{query}': {reason}")]
#[non_exhaustive]
InvalidQueryBody {
query: String,
reason: String,
},
#[error("Saved query '{query}' returned a traversal with a writing step ({location})")]
#[non_exhaustive]
QueryReturnedWrite {
query: String,
location: String,
},
}
pub(crate) const SAVED_QUERY_READ_ONLY: &str = "a saved query is read-only";
impl DiagnosticError for ScriptError {
fn help(&self) -> Option<String> {
match self {
ScriptError::Eval { .. } => {
Some("Check script syntax and expressions at indicated line/column.".to_string())
}
ScriptError::ReturnedClosure { location } => Some(format!(
"Return data or a traversal instead of a closure: run the closure inside the \
script and return its result (for example `let f = |x| x + 1; f.call(1)` \
instead of `|x| x + 1`), or return the traversal itself \
(`g.v().has_label(\"person\")`), which runs against the real graph when it is \
rendered. The closure was found at {location}."
)),
ScriptError::UnsupportedStep { .. } => {
Some("Verify step name and DSL script compatibility.".to_string())
}
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(step_name.as_str(), "value_map" | "valueMap") =>
{
Some(
"value_map() takes property keys as strings; a leading `true` adds id and \
label: g.v().value_map(true, \"name\"). A boolean is accepted only as the \
first argument, and a null key (`()`) is ignored. element_map() returns the \
same shape with plain values instead of lists: g.v().element_map(\"name\"). \
A key can also be a jpath(..) into a nested value, keyed by the canonical \
path text: g.v().value_map(jpath(\"meta.k\"))."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(step_name.as_str(), "and" | "or" | "not" | "negate") =>
{
Some(
"P.and(), P.or(), P.not() and negate() combine predicates only. Combine two \
predicates with p.and(q) / p.or(q), for example \
g.v().has(\"age\", P.gt(18).and(P.lt(30))) or \
g.v().has(\"age\", P.lt(18).or(P.gt(65))); complement one with P.not(p) or \
p.negate(), for example g.v().where(P.not(P.within(\"a\"))). To combine \
traversals instead, use the traversal combinators: \
g.v().where(__.and(__.out(), __.has(\"age\"))) or \
g.v().and(__.out(\"knows\"), __.has(\"age\", P.gt(30)))."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(step_name.as_str(), "choose" | "branch") =>
{
Some(
"choose(selector) and branch(selector) take a traversal or the tokens T.label / \
T.id as the selector, for example \
g.v().choose(T.label).option(\"person\", __.out(\"knows\")) or \
g.v().choose(__.values(\"age\")).option(P.gt(30), __.constant(\"old\")). \
The two- and three-argument forms take a traversal or a predicate as the \
condition, for example \
g.v().values(\"age\").choose(P.gt(30), __.constant(\"old\"), __.constant(\"young\")); \
without the third argument a traverser that fails the condition passes through \
unchanged. A property key (T.key), a string or a number is not a selector."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "add_e" => Some(
"add_e() takes the edge label as a string, or as a traversal whose first result is \
the label, for example g.v(\"1\").add_e(\"knows\").to(\"2\") or \
g.v().as(\"a\").out_e().as(\"b\").in_v().add_e(__.select(\"b\").label()).to(\"a\"). \
A list of labels is not accepted."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(step_name.as_str(), "property" | "has" | "values" | "has_not" | "has_property") =>
{
Some(
"A property key is a string (a literal name) or a jpath(..) value (a path into a \
nested value); a number, a map or another type is not a key. For example \
g.v().property(\"name\", \"x\"), g.v().property(jpath(\"meta.tags[0]\"), \"x\") or \
g.v().has(jpath(\"meta.k\"), 1). A dotted string such as \"meta.k\" is never \
parsed as a path: wrap it in jpath(\"meta.k\")."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "remove_property" => Some(
"remove_property() takes property keys as strings or jpath(..) values, one key or an \
array of keys, for example g.v().has_label(\"person\").remove_property(\"age\") or \
g.v().remove_property([\"age\", \"name\"]); a jpath removes the value below a \
property, for example g.v().remove_property(jpath(\"meta.tags[0]\")) (an array \
element is removed with shift, a missing path is a no-op); without an argument it \
removes every property of the element. The step keeps the element, so \
values(\"name\") can follow. property(k, ()) stores a null and does not remove."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "to_map" => Some(
"to_map() turns a map into a Rhai object map, whose keys are strings, so it fails \
for a map with a non-string key such as the result of group_count().by(\"age\"). \
Read an entry with an indexer, for example \
let m = g.v().group_count().by(\"age\").next(); m[29]; list the keys with m.keys(); \
or show the whole map with g.v().group_count().by(\"age\").to_json()."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "property_json" => Some(
"property_json() takes JSON-compatible values, and a JSON object has string keys, so \
a map with a non-string key (for example 29 or a list) is rejected. Use string keys, \
for example property_json(#{\"29\": 1}); to show a map with a non-string key use \
to_json(), for example g.v().group_count().by(\"age\").to_json()."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "Set.of" => Some(
"Set.of(...) and Set.of_list([...]) take plain values: strings, numbers, booleans, \
UUIDs or null (an element or a traversal is not a value of a set). For example \
g.with_side_effect(\"a\", Set.of(\"alice\", \"bob\")).v().both().values(\"name\")\
.aggregate(\"a\").cap(\"a\") or Set.of_list([\"alice\", \"bob\"]); Set.of() starts an \
empty set."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "glob_path" => Some(
"glob_path(pattern, #{...}) accepts the options max_depth (an integer from 1: the \
deepest level below the start vertex the walk visits) and prune (an anonymous \
traversal: a visited vertex for which it yields a result is not descended into, \
but is still emitted when it matches); the keys edges, path and case_insensitive \
are reserved. For example \
g.v(\"root\").glob_path(\"**/*.rs\", #{max_depth: 3, prune: __.has(\"name\", \"target\")}).by(\"name\")."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, .. } if step_name == "select" => Some(
"select() reads step labels, map keys or side-effect keys: select(\"a\"), \
select(\"a\", \"b\") (a map), select(Column.keys) (the keys of a map). When a \
label was set several times, name the occurrence with a Pop as the FIRST argument: \
select(Pop.first, \"a\"), select(Pop.last, \"a\") (the default), \
select(Pop.all, \"a\") (a list of every occurrence) or select(Pop.mixed, \"a\"), \
for example g.v().as(\"a\").out().as(\"a\").select(Pop.all, \"a\") or \
g.v().as(\"a\").out().as(\"b\").select(Pop.first, \"a\", \"b\"). The labels after the \
Pop are strings (or one array of strings), and at least one is required. The key \
can also be computed by a traversal, with exactly one key: \
select(Pop.all, __.constant(\"a\")) or select(__.constant(\"a\")); its first \
result must be a string."
.to_string(),
),
ScriptError::ArgumentMismatch { step_name, actual, .. }
if matches!(step_name.as_str(), "has_id" | "has_label" | "has_key")
&& actual.contains("predicate mixed") =>
{
Some(
"has_id(), has_label() and has_key() take either plain values or ONE predicate, \
never both in one call. Use P.within to combine values, for example \
g.v().has_id(P.within([\"1\", \"2\"])) or g.v().has_label(P.neq(\"person\")); \
to combine conditions chain the steps: \
g.v().has_label(\"person\").has_id(P.neq(\"1\"))."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(step_name.as_str(), "V" | "E" | "has_id") =>
{
Some(
"v(), e() and has_id() take ids (strings, numbers, UUIDs), elements returned by \
next()/to_list(), or lists of them. For example: \
let e = g.e(\"11\").next(); g.e(e).to_list() or g.v([\"1\", \"2\"]).to_list(). \
A map is accepted only as an element, that is, with an `id` key. \
has_id() also takes one predicate, for example g.v().has_id(P.neq(\"1\"))."
.to_string(),
)
}
ScriptError::ArgumentMismatch { step_name, .. }
if matches!(
step_name.as_str(),
"define_query"
| "define_queries"
| "query"
| "move_query"
| "describe_query"
) =>
{
Some(
"Saved queries take maps and strings: \
g.define_query(#{name: \"top\", folder: \"reports\", description: \"..\", \
params: #{limit: #{schema: #{type: \"integer\", minimum: 1}, default: 20}}, \
body: \"fn top(limit) { g.v().limit(limit) }\"}) (name and body required), \
g.define_queries(\"reports\", source, #{top: #{limit: \"integer\"}}), \
g.query(\"top\", #{limit: 5}) (named arguments in a map), \
g.move_query(\"top\", \"archive\") and g.describe_query(\"top\", \"..\") \
(\"\" or () for the root or no description). See the book page \"Saved \
queries\"."
.to_string(),
)
}
ScriptError::ArgumentMismatch { .. } => {
Some("Check function call signature and argument types in script.".to_string())
}
ScriptError::InvalidParameter { .. } => Some(
"Bind named parameters with `eval_with_params` using identifier names that do \
not shadow the DSL globals (`g`, `__`, `_g`, `params`, `keys`, `values` and every token \
class such as `P`, `TextP`, `V`, `T`, `By`, `Order`, `Pick`, `GType`, `Pop`), for \
example params = {\"label\": \"person\"} used as \
g.v().has_label(label).to_list(). Positional parameters are 1-based: supply one \
value per placeholder to `eval_with_positional_params`, so that \
g.v().has_label($1).to_list() is evaluated with a single-element vector."
.to_string(),
),
ScriptError::QueryNotAtStart { name, .. } => Some(format!(
"Call a saved query at the start of a traversal, on g: g.query(\"{name}\") (with \
arguments g.query(\"{name}\", #{{limit: 5}})), and continue its result there, for \
example g.query(\"{name}\").values(\"name\"). Calling a saved query on incoming \
traversers (g.v().query(..), __.query(..)) is not supported yet; filter in the \
query itself, or pass the values it needs as arguments: \
let ids = g.v().has_label(\"person\").id().to_list(); g.query(\"{name}\", #{{ids: ids}})."
)),
ScriptError::QueryDepthExceeded { limit, .. } => Some(format!(
"Saved queries may call each other at most {limit} levels deep; this bound stops a \
query that calls itself (directly or through other queries) without an end. \
Check the chain of g.query(..) calls in the bodies (g.get_query(name)[\"body\"] \
shows a body) and end the recursion, or compute the repetition inside one query \
with repeat(), for example g.v(id).repeat(__.out()).times(3)."
)),
ScriptError::UnknownQueryArgument {
query, parameters, ..
} => Some(if parameters.is_empty() {
format!(
"'{query}' takes no arguments: call it as g.query(\"{query}\"). \
g.get_query(\"{query}\") shows its declaration."
)
} else {
format!(
"Arguments are named after the parameters of '{query}': {}. For example \
g.query(\"{query}\", #{{{}: ..}}). g.get_query(\"{query}\") shows each \
parameter's schema and default; names are case-sensitive.",
parameters.join(", "),
parameters[0]
)
}),
ScriptError::MissingQueryArgument {
query, parameter, ..
} => Some(format!(
"Pass every parameter without a default by name, for example \
g.query(\"{query}\", #{{{parameter}: ..}}). A parameter with a default may be \
left out; give one a default by redefining the query with \
params: #{{{parameter}: #{{schema: .., default: ..}}}}."
)),
ScriptError::InvalidQueryArgument {
query, parameter, ..
} => Some(format!(
"An argument is a data value (a string, a number, a boolean, a UUID, a list or a \
map, never a traversal or a closure) that satisfies its parameter's schema; types \
are exact (only an int64 is accepted where a float64 is declared). \
g.get_query(\"{query}\")[\"params\"] shows the schema of '{parameter}'; convert \
the value before the call, for example with parse_int(\"5\") or \
UUID.from_string(\"..\")."
)),
ScriptError::InvalidQueryBody { query, .. } => Some(format!(
"The body of a saved query is the text of ONE Rhai function named like the query \
whose parameters are the declared ones, and nothing else, for example \
g.define_query(#{{name: \"{query}\", params: #{{limit: \"integer\"}}, body: \
\"fn {query}(limit) {{ g.v().limit(limit) }}\"}}). Several queries at once go \
through g.define_queries(folder, source) (one query per function, doc comments \
as descriptions). A stored body that no longer compiles is listed by \
g.queries() with status \"error\"; redefine it to fix it."
)),
ScriptError::QueryReturnedWrite { .. } => Some(
"Saved queries are read-only, also through a traversal they return. Return a \
reading traversal (or its values) and write in a traversal of your own, for \
example let ids = g.query(\"q\").id().to_list(); \
g.v(ids).property(\"seen\", true).to_list()."
.to_string(),
),
}
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum SchemaError {
#[error("Unknown vertex label '{label}' is not declared in the closed schema")]
#[non_exhaustive]
UnknownVertexLabel {
label: String,
allowed: Vec<String>,
},
#[error("Unknown edge label '{label}' is not declared in the closed schema")]
#[non_exhaustive]
UnknownEdgeLabel {
label: String,
allowed: Vec<String>,
},
#[error(
"Disallowed edge connection: '(:{source_label})-[:{edge_label}]->(:{target_label})' is not defined in schema"
)]
#[non_exhaustive]
InvalidEdgeTopology {
source_label: String,
edge_label: String,
target_label: String,
allowed: Vec<String>,
},
#[error(
"Undeclared property '{property_name}' on {element_type} '{label}' is not declared and not admitted by the schema"
)]
#[non_exhaustive]
UndeclaredProperty {
element_type: &'static str,
label: String,
property_name: String,
},
#[error("Missing required property '{property_name}' on {element_type} '{label}'")]
#[non_exhaustive]
MissingRequiredProperty {
element_type: &'static str,
label: String,
property_name: String,
},
#[error("Cannot remove required property '{property_name}' from {element_type} '{label}'")]
#[non_exhaustive]
RequiredPropertyRemoval {
element_type: &'static str,
label: String,
property_name: String,
},
#[error(
"Type mismatch on {element_type} '{label}.{property_name}': expected {expected_type}, got {actual_type}"
)]
#[non_exhaustive]
TypeMismatch {
element_type: &'static str,
label: String,
property_name: String,
expected_type: String,
actual_type: String,
},
#[error(
"Cannot store {actual_value} in {element_type} '{label}.{property_name}' declared as {declared_type}: {reason}"
)]
#[non_exhaustive]
CoercionFailed {
element_type: &'static str,
label: String,
property_name: String,
declared_type: String,
actual_value: Box<str>,
reason: &'static str,
},
#[error(
"Constraint violation on '{label}.{property_name}': {constraint_rule} (actual: {actual_value})"
)]
#[non_exhaustive]
ConstraintViolation {
label: String,
property_name: String,
constraint_rule: String,
actual_value: String,
},
#[error("Invalid schema pattern '{pattern}': {reason}")]
#[non_exhaustive]
InvalidPattern {
pattern: String,
reason: String,
},
#[error("{report}")]
#[non_exhaustive]
ExistingDataViolatesSchema {
report: Box<crate::graph::schema::SchemaViolationReport>,
},
#[error("Invalid schema at {path}: {reason}")]
#[non_exhaustive]
InvalidSchema {
path: String,
reason: String,
hint: String,
},
}
#[derive(thiserror::Error, Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum DefinitionError {
#[error("Invalid {kind} name '{name}': {reason}")]
#[non_exhaustive]
InvalidName {
kind: String,
name: String,
reason: String,
},
#[error("Invalid folder '{folder}' of '{name}': {reason}")]
#[non_exhaustive]
InvalidFolder {
name: String,
folder: String,
reason: String,
},
#[error("Invalid {field} of '{name}': {reason}")]
#[non_exhaustive]
InvalidField {
name: String,
field: String,
reason: String,
},
#[error("Invalid parameter '{parameter}': {reason}")]
#[non_exhaustive]
InvalidParameter {
parameter: String,
reason: String,
},
#[error("The catalog already holds {limit} definitions, the maximum")]
#[non_exhaustive]
TooManyDefinitions {
limit: usize,
},
#[error("There is no {kind} named '{name}'")]
#[non_exhaustive]
NotFound {
kind: String,
name: String,
},
#[error("Conflicting compression rule '{name}': {reason}")]
#[non_exhaustive]
Conflict {
name: String,
existing: String,
reason: String,
},
#[error("Malformed definition: {reason}")]
#[non_exhaustive]
Malformed {
reason: String,
},
}
impl DiagnosticError for DefinitionError {
fn help(&self) -> Option<String> {
use crate::graph::definition as d;
Some(match self {
DefinitionError::InvalidName { kind, .. } if kind == "compression" => format!(
"A compression rule's name is an identifier of 1 to {} bytes (ASCII letters, \
digits and '_', starting with a letter or '_'), for example \"customer_cv\". \
It names the rule for g.drop_compression and g.recompress.",
d::MAX_QUERY_NAME_BYTES
),
DefinitionError::InvalidName { .. } => format!(
"A saved query's name is also its Rhai function name and its MCP tool name: 1 to \
{} bytes of ASCII letters, digits and '_', starting with a letter or '_' (a \
letter before the first digit), not a Rhai keyword, for example \
\"top_customers\". Names are case-sensitive and unique per kind; group queries \
with a folder instead of a prefix in the name.",
d::MAX_QUERY_NAME_BYTES
),
DefinitionError::InvalidFolder { .. } => format!(
"A folder is a '/'-separated path such as \"reports/sales\": non-empty segments \
without surrounding spaces, no leading or trailing '/', at most {} segments and \
{} bytes. Leave the folder out (or pass an empty one) for the root.",
d::MAX_FOLDER_SEGMENTS,
d::MAX_FOLDER_BYTES
),
DefinitionError::InvalidField { field, .. } => match field.as_str() {
"description" => format!(
"Keep the description to at most {} bytes; longer documentation belongs in \
the query's source as comments.",
d::MAX_DESCRIPTION_BYTES
),
"source" => format!(
"The source is the text of one Rhai function, at most {} bytes; split a \
larger body into several saved queries that call each other.",
d::MAX_SOURCE_BYTES
),
"path" => "A rule path names object keys only, from the top-level property \
down: \"cv\" or \"details.career.cv\" (or \
jpath(\"$.details.career.cv\")); an index such as [0] or [-1] is not \
allowed, arrays are not compressed."
.to_string(),
"min_bytes" => format!(
"min_bytes is the shortest string (in UTF-8 bytes) that is compressed: 1 to \
{} (default {}); shorter strings stay plain.",
crate::graph::compression::MAX_MIN_BYTES,
crate::graph::compression::DEFAULT_MIN_BYTES
),
"label" => "Name the vertex or edge label the rule applies to, for example \
\"customer\"; it need not exist yet."
.to_string(),
"dialect" => format!(
"The dialect names the language of the source; this version writes \"{}\".",
d::QUERY_DIALECT
),
_ => "Check the field against the limits of the catalog (book page \"Saved \
queries\")."
.to_string(),
},
DefinitionError::InvalidParameter { .. } => format!(
"A parameter is {{\"schema\": <schema node>, \"default\": <value>}} (or just a \
type name such as \"integer\"): its name is a Rhai identifier that is not a \
DSL global (g, __, P, T, ...), its schema uses the node format of the graph \
schema (type, enum, minimum, maximum, pattern, ...) and its default, when \
given, satisfies that schema, for example \
{{\"limit\": {{\"schema\": {{\"type\": \"integer\", \"minimum\": 1}}, \
\"default\": 20}}}}. At most {} parameters.",
d::MAX_PARAMETERS
),
DefinitionError::TooManyDefinitions { .. } => {
"Remove definitions that are no longer used before adding new ones.".to_string()
}
DefinitionError::NotFound { .. } => {
"List the existing definitions first: g.queries() in the DSL (or \
g.queries(\"reports\") for one folder), GraphTraversalSource::list_definitions \
in Rust; names are case-sensitive, and a query is called by its name alone, \
never with its folder. Define a missing one with g.define_query(#{name: .., \
body: ..})."
.to_string()
}
DefinitionError::Conflict { existing, .. } => format!(
"One rule per element kind, label and path: change the existing rule instead \
(define it again under its name '{existing}' with the new parameters), or drop \
it first with g.drop_compression(\"{existing}\")."
),
DefinitionError::Malformed { .. } => {
"A definition in JSON is an object with \"kind\" (\"query\") and \"name\", plus \
for a query \"source\", \"params\" and optionally \"folder\", \
\"description\", \"dialect\" and \"meta\"; a compression rule \
(\"kind\": \"compression\") has \"label\", \"path\" and optionally \"element\", \
\"codec\", \"min_bytes\" and \"dictionary\"; a catalog is \
{\"definitions\": [..]}."
.to_string()
}
})
}
}
fn is_nested_schema_path(name: &str) -> bool {
name.starts_with("$.") || name.starts_with("$[")
}
impl SchemaError {
pub fn violation_report(&self) -> Option<&crate::graph::schema::SchemaViolationReport> {
match self {
SchemaError::ExistingDataViolatesSchema { report } => Some(report),
_ => None,
}
}
}
impl DiagnosticError for SchemaError {
fn help(&self) -> Option<String> {
match self {
SchemaError::ExistingDataViolatesSchema { report } => {
let mut help = String::from(
"The schema was NOT applied; the graph and its previous schema are unchanged. Fix or \
migrate the offending data first (add the missing properties, correct the values), or \
relax the schema (make the key optional by removing it from \"required\", allow null \
with \"type\": [\"T\", \"null\"], declare the label or property), or apply it with mode \
\"none\" (no validation), or start from the schema of the data (g.infer_schema()). \
Check a schema without applying it with g.validate_schema(schema) (or \
g.validate_schema_patch(patch)): it lists the same violations and the change's \
compatibility.\n\
To make a key required that existing elements lack, backfill first, in one \
transaction: (in a closed schema, declare the key optional first with \
g.patch_schema(..)), write the values, then patch the key into \"required\". A schema \
\"default\" is NOT applied to existing elements (it is an annotation only).",
);
let unlabeled = report
.groups
.iter()
.any(|group| group.label == crate::graph::schema::UNLABELED);
if unlabeled {
help.push_str(
"\nElements without a label: a closed schema admits only declared labels, and \
an unlabeled vertex or edge has no label to declare (g.infer_schema() skips \
it). Freeze the structure in mode open instead, which admits them unchecked \
(g.set_schema(g.infer_schema(), SchemaMode.open)), or label them and freeze \
again: g.v(\"id\").add_label(\"Label\") for a vertex and \
g.e(\"id\").set_label(\"label\") for an edge listed above (every unlabeled \
one: g.v().not(__.has_label()).add_label(\"Label\"), \
g.e().not(__.has_label()).set_label(\"label\")), then \
g.set_schema(g.infer_schema(), SchemaMode.closed); or recreate them with a \
label (add_v(\"Label\"), add_e(\"label\")) and drop the unlabeled ones.",
);
}
Some(help)
}
SchemaError::UnknownVertexLabel { allowed, .. } => Some(format!(
"A closed schema admits only declared vertex labels: use one of [{}], declare the label \
(g.patch_schema(#{{vertices: #{{label: #{{}}}}}})), or set mode \"open\".",
allowed.join(", ")
)),
SchemaError::UnknownEdgeLabel { allowed, .. } => Some(format!(
"A closed schema admits only declared edge labels: use one of [{}], declare the label \
(g.patch_schema(#{{edges: #{{label: #{{}}}}}})), or set mode \"open\".",
allowed.join(", ")
)),
SchemaError::InvalidEdgeTopology { allowed, .. } => Some(format!(
"A closed schema admits an edge only between the labels of a declared connection (any \
label of each endpoint may match; \"*\" matches every label). Declared connections: \
[{}]. Add {{\"from\": .., \"to\": ..}} to the edge label's \"connections\", or connect \
vertices with matching labels.",
allowed.join(", ")
)),
SchemaError::UndeclaredProperty { property_name, .. } if is_nested_schema_path(property_name) => {
Some(format!(
"Declare '{property_name}' among the \"properties\" of its enclosing object in the \
schema, or let that object admit undeclared keys with \"additionalProperties\": true \
(a closed schema's default is false; an open schema's is true)."
))
}
SchemaError::UndeclaredProperty { property_name, .. } => Some(format!(
"Declare '{property_name}' in the label's \"properties\", or let the label admit \
undeclared keys: \"additionalProperties\": true on its schema, or mode \"open\". With \
several labels the key is allowed when ANY label declares or admits it."
)),
SchemaError::MissingRequiredProperty { property_name, .. } if is_nested_schema_path(property_name) => {
Some(format!(
"Provide a value for the nested key '{property_name}' (e.g. \
property(jpath(\"{property_name}\"), value)) or remove it from \"required\" of its \
enclosing object in the schema; a required nested key cannot be removed with \
remove_property(jpath(..)). A schema \"default\" is an annotation only: it does not \
fill a missing key."
))
}
SchemaError::MissingRequiredProperty { property_name, .. } => {
Some(format!(
"Provide a value for '{property_name}' or remove it from the label's \"required\" in \
the schema. A schema \"default\" is an annotation only: it is never filled in, so a \
required property needs a value even when its schema has a default.\n\
A required property must be supplied when the element is created: \
add_v(\"L\").property(\"{property_name}\", value) works because the optimizer rule \
add_property_fold folds the directly following constant property() steps into \
add_v()/add_e(). If a property() after it is not folded (the rule is disabled with \
g.with(\"optimizer.disabled\", [\"add_property_fold\"]), the value is a traversal, a \
Cardinality or a jpath key, or another step sits in between), the element is created \
empty and this error is raised; move a constant property(\"{property_name}\", value) \
directly behind add_v()/add_e() and keep add_property_fold enabled."
))
}
SchemaError::RequiredPropertyRemoval { property_name, label, element_type } => {
Some(format!(
"'{property_name}' is in \"required\" of {element_type} label '{label}', so it cannot \
be removed in open or closed mode. Overwrite it with a new value instead (null only \
when its type admits null), remove it from \"required\" in the schema \
(g.patch_schema(..)), or set the schema mode to \"none\"."
))
}
SchemaError::InvalidSchema { hint, .. } => Some(if hint.is_empty() {
crate::graph::schema::parse::FORMAT_HELP.to_string()
} else {
format!("{hint}\n{}", crate::graph::schema::parse::FORMAT_HELP)
}),
SchemaError::TypeMismatch {
property_name,
expected_type,
actual_type,
..
} => {
let mut help = if actual_type == "null" {
format!(
"'{property_name}' does not admit null: write a value of type \
'{expected_type}', remove the property instead (absence is governed by \
\"required\", not by null), or allow null in the schema with \"type\": \
[\"T\", \"null\"]."
)
} else {
format!("Convert value to expected type '{expected_type}' before inserting.")
};
let key = if is_nested_schema_path(property_name) {
format!("jpath(\"{property_name}\")")
} else {
format!("\"{property_name}\"")
};
if let Some(convert) = coercion_step(&expected_type.to_lowercase()) {
help.push_str(&format!(
"\nIn a query, convert the values with values({key}).{convert}."
));
}
if expected_type == "uuid" && actual_type != "string" {
help.push_str(
"\nOnly a canonical UUID string is converted to a uuid on write; convert \
a string value explicitly with .cast(GType.UUID).",
);
}
if let Some(locate) = gtype_token(&actual_type.to_lowercase()) {
help.push_str(&format!(
"\nFind every offending value with values({key}).is(typeOf(GType.{locate}))."
));
}
Some(help)
}
SchemaError::CoercionFailed {
property_name,
declared_type,
..
} => Some(match declared_type.as_str() {
"uuid" => "UUIDs are written as 8-4-4-4-12 lowercase hex, e.g. \
`550e8400-e29b-41d4-a716-446655440000`."
.to_string(),
"float64" => "The integer is too large to be represented exactly as float64; \
store it in an int64 field."
.to_string(),
_ => format!(
"Store a value of type '{declared_type}' in '{property_name}', or change the \
declared type in the schema."
),
}),
SchemaError::ConstraintViolation { constraint_rule, .. } => {
Some(format!("Ensure value complies with validation constraint: {constraint_rule}."))
}
SchemaError::InvalidPattern { .. } => Some(
"A schema `pattern` is a regular expression with JSON Schema semantics (matched \
anywhere in the value; anchor it with ^...$ for a whole-value match), in the syntax \
of the Rust `regex` crate: classes [a-z], \\d, \\w, \\s, groups (..), alternation |, \
quantifiers * + ? {n,m}. Look-around ((?=..), (?!..)) and back-references (\\1) are \
not supported. Escape a literal metacharacter with a backslash, e.g. \\. for a dot \
(in a JSON schema file the backslash itself is doubled: \"^\\\\d+$\")."
.to_string(),
),
}
}
}
pub type BoxedTraverserError = Box<TraverserError>;
pub type TraverserResult<T> = Result<T, BoxedTraverserError>;
macro_rules! impl_from_suberror_for_boxed {
($($sub:ty => $variant:ident),+ $(,)?) => {
$(
impl From<$sub> for BoxedTraverserError {
fn from(err: $sub) -> Self {
Box::new(TraverserError::$variant(err))
}
}
)+
};
}
impl_from_suberror_for_boxed! {
ValueError => Value,
GraphError => Graph,
IOError => Io,
ScriptError => Script,
SchemaError => Schema,
}
impl DiagnosticError for BoxedTraverserError {
fn help(&self) -> Option<String> {
(**self).help()
}
fn details(&self) -> Vec<(String, String)> {
(**self).details()
}
fn diagnostic_body(&self) -> String {
(**self).diagnostic_body()
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum TraverserError {
#[error(transparent)]
Value(#[from] ValueError),
#[error(transparent)]
Graph(#[from] GraphError),
#[error(transparent)]
Io(#[from] IOError),
#[error(transparent)]
Script(#[from] ScriptError),
#[error(transparent)]
Schema(#[from] SchemaError),
#[error(
"Step {}'{}' execution failed{}",
step_path(.child_index, .step_index, .source),
innermost_step_name(.step_name, .source),
.element_id.as_ref().map(|id| format!(" for element ID '{id}'")).unwrap_or_default()
)]
#[non_exhaustive]
StepFailed {
step_name: String,
step_index: Option<usize>,
child_index: Option<usize>,
frame: Option<Box<StepFrame>>,
element_id: Option<String>,
#[source]
source: Box<TraverserError>,
spelling: crate::traversal::Spelling,
},
#[error("Query execution error: {reason}")]
#[non_exhaustive]
QueryExecution {
query: String,
reason: String,
},
#[error("Feature not implemented yet: {feature}")]
#[non_exhaustive]
NotImplemented {
feature: &'static str,
},
#[error("Path tracking exhausted: the traversal recorded more than {capacity} path positions")]
#[non_exhaustive]
PathArenaExhausted {
capacity: u64,
},
#[error("Sack storage exhausted: the traversal stored more than {capacity} sack values")]
#[non_exhaustive]
SackArenaExhausted {
capacity: u64,
},
#[error("Traversal exceeded evaluationTimeout of {limit_ms} ms (ran {elapsed_ms} ms)")]
#[non_exhaustive]
Timeout {
limit_ms: u64,
elapsed_ms: u64,
},
#[error("Traversal cancelled after {elapsed_ms} ms")]
#[non_exhaustive]
Cancelled {
elapsed_ms: u64,
},
#[error("Invalid value for option '{key}': {reason}")]
#[non_exhaustive]
InvalidOption {
key: String,
reason: String,
},
#[error(
"Option '{key}' is locked by this instance's execution policy and cannot be set by a query"
)]
#[non_exhaustive]
LockedOption {
key: String,
},
#[error("Permission denied for '{step}' ({request}): {reason}")]
#[non_exhaustive]
PermissionDenied {
step: String,
request: Box<crate::graph::AccessRequestBuf>,
reason: String,
},
#[error(
"repeat(){} exceeded the loop limit of {max_loops} iterations",
.loop_name.as_ref().map(|name| format!(" loop '{name}'")).unwrap_or_default()
)]
#[non_exhaustive]
RepeatLimitExceeded {
max_loops: u32,
loop_name: Option<String>,
body: String,
},
#[error("The number of traversers at step '{step}' is too large to count (more than 2^63 - 1)")]
#[non_exhaustive]
BulkOverflow {
step: String,
},
#[error(
"Step '{step}' would have to materialize {requested} values, more than the limit of {limit} \
(bulk.max_expansion)"
)]
#[non_exhaustive]
BulkExpansionLimit {
step: String,
requested: u64,
limit: u64,
},
#[error("Resource limit '{limit}' exceeded{}", limit_detail(*.max, *.observed))]
#[non_exhaustive]
ResourceLimitExceeded {
limit: String,
max: Option<u64>,
observed: Option<u64>,
},
#[error("loops(\"{name}\") names no enclosing repeat(){}", active_loops(.active))]
#[non_exhaustive]
UnknownLoopName {
name: String,
active: Vec<String>,
},
#[error("{modulator} modulates a repeat() step, but the traversal has no repeat() for it")]
#[non_exhaustive]
RepeatWithoutBody {
modulator: String,
},
#[error("inject() is not allowed as a step of a repeat() body")]
#[non_exhaustive]
RepeatInject {
loop_name: Option<String>,
},
#[error(
"{modulator} modulates a choose(traversal)/branch(traversal) or merge_v()/merge_e() step, but the traversal has none for it (or it already has choose(predicate, onTrue, onFalse)'s two fixed branches)"
)]
#[non_exhaustive]
OptionWithoutBranch {
modulator: String,
},
#[error("other_v() found no vertex earlier in the traversal to compare against edge {edge}")]
#[non_exhaustive]
OtherVWithoutOrigin {
edge: String,
},
#[error("Invalid range [{low}, {high}): {reason}")]
#[non_exhaustive]
InvalidRange {
low: i64,
high: i64,
reason: String,
},
#[error("`{step}()` cannot use its {modulator} #{position}: {reason}")]
#[non_exhaustive]
InvalidModulator {
step: String,
position: usize,
modulator: &'static str,
reason: String,
},
#[error(
"`{step}()` configures the traversal source g and cannot be used inside a child traversal"
)]
#[non_exhaustive]
SourceStepInChild {
step: String,
},
#[error("{}", unsupported_cardinality_message(.step, *.cardinality, *.on_edge))]
#[non_exhaustive]
UnsupportedCardinality {
step: String,
cardinality: crate::value::Cardinality,
on_edge: bool,
},
#[error("{}", token_property_outside_add_message(.token))]
#[non_exhaustive]
TokenPropertyOutsideAdd {
token: String,
},
#[error("{reason} (in {argument} of {step}())")]
#[non_exhaustive]
InvalidMergeMap {
step: String,
argument: String,
reason: String,
},
#[error(
"option(onCreate) cannot override values from merge() argument: {key} is {merge_value} in {step}() but {on_create_value} in option(onCreate)"
)]
#[non_exhaustive]
MergeOverride {
step: String,
key: String,
merge_value: String,
on_create_value: String,
},
#[error("{reason}")]
#[non_exhaustive]
MergeVertexUnresolved {
direction: crate::value::Direction,
reason: String,
},
#[error("{message} (fail() reached by {value})")]
#[non_exhaustive]
Fail {
message: String,
value: String,
},
#[error("math({}) failed at column {column}: {message}", crate::value::literal::rhai_string(.expr))]
#[non_exhaustive]
MathExpression {
expr: String,
offset: usize,
column: usize,
width: usize,
kind: MathErrorKind,
message: String,
},
#[error("{source}")]
#[non_exhaustive]
Spelled {
spelling: crate::traversal::Spelling,
source: Box<TraverserError>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MathErrorKind {
Syntax,
#[non_exhaustive]
UnknownFunction {
name: String,
},
#[non_exhaustive]
UnknownVariable {
name: String,
},
#[non_exhaustive]
BadPath {
path: String,
},
#[non_exhaustive]
NotANumber {
path: String,
actual: String,
},
#[non_exhaustive]
Null {
path: String,
},
DivisionByZero,
Internal,
}
fn unsupported_cardinality_message(
step: &str,
cardinality: crate::value::Cardinality,
on_edge: bool,
) -> String {
if on_edge {
format!(
"Property cardinality can only be set for a Vertex: {step}(Cardinality.{cardinality}, ..) was applied to an edge"
)
} else {
format!(
"Cardinality.{cardinality} is not supported: Graphersal stores one value per property key ({step}() would write a second value)"
)
}
}
fn token_property_outside_add_message(token: &str) -> String {
if token == "T.label" {
format!(
"property({token}, ..) can only set the label of a vertex add_v() is creating (an edge's label comes from add_e(label))"
)
} else {
format!(
"property({token}, ..) can only set the id of an element add_v()/add_e() is creating"
)
}
}
fn active_loops(active: &[String]) -> String {
if active.is_empty() {
String::new()
} else {
format!(" (active loops: {})", active.join(", "))
}
}
pub(crate) trait StepNameSource {
fn into_step_name(self) -> String;
}
impl StepNameSource for &str {
fn into_step_name(self) -> String {
self.to_string()
}
}
impl StepNameSource for String {
fn into_step_name(self) -> String {
self
}
}
impl<F: FnOnce() -> String> StepNameSource for F {
fn into_step_name(self) -> String {
self()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StepAt {
pub(crate) child: Option<usize>,
pub(crate) index: usize,
}
pub(crate) trait ResultExt<T> {
#[allow(dead_code)]
fn with_step<S: StepNameSource>(self, step_name: S) -> TraverserResult<T>;
fn with_step_frame<S: StepNameSource, F: FnOnce() -> StepFrame>(
self,
step_name: S,
at: StepAt,
frame: F,
) -> TraverserResult<T>;
#[allow(dead_code)]
fn with_step_element<S: StepNameSource, EId: StepNameSource>(
self,
step_name: S,
element_id: EId,
) -> TraverserResult<T>;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
E: Into<TraverserError>,
{
fn with_step<S: StepNameSource>(self, step_name: S) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: None,
child_index: None,
frame: None,
element_id: None,
source: Box::new(err.into()),
})
})
}
fn with_step_frame<S: StepNameSource, F: FnOnce() -> StepFrame>(
self,
step_name: S,
at: StepAt,
frame: F,
) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: Some(at.index),
child_index: at.child,
frame: Some(Box::new(frame())),
element_id: None,
source: Box::new(err.into()),
})
})
}
fn with_step_element<S: StepNameSource, EId: StepNameSource>(
self,
step_name: S,
element_id: EId,
) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: None,
child_index: None,
frame: None,
element_id: Some(element_id.into_step_name()),
source: Box::new(err.into()),
})
})
}
}
impl<T> ResultExt<T> for Result<T, BoxedTraverserError> {
fn with_step<S: StepNameSource>(self, step_name: S) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: None,
child_index: None,
frame: None,
element_id: None,
source: err,
})
})
}
fn with_step_frame<S: StepNameSource, F: FnOnce() -> StepFrame>(
self,
step_name: S,
at: StepAt,
frame: F,
) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: Some(at.index),
child_index: at.child,
frame: Some(Box::new(frame())),
element_id: None,
source: err,
})
})
}
fn with_step_element<S: StepNameSource, EId: StepNameSource>(
self,
step_name: S,
element_id: EId,
) -> TraverserResult<T> {
self.map_err(|err| {
Box::new(TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: step_name.into_step_name(),
step_index: None,
child_index: None,
frame: None,
element_id: Some(element_id.into_step_name()),
source: err,
})
})
}
}
impl TraverserError {
fn canonical_help(&self) -> Option<String> {
match self {
TraverserError::Value(err) => err.help(),
TraverserError::Graph(err) => err.help(),
TraverserError::Io(err) => err.help(),
TraverserError::Script(err) => err.help(),
TraverserError::Schema(err) => err.help(),
TraverserError::StepFailed { .. } => match self.root_cause() {
TraverserError::Timeout { limit_ms, .. } => {
Some(timeout_help(*limit_ms, self.outermost_steps()))
}
TraverserError::RepeatLimitExceeded {
max_loops,
loop_name,
body,
} => Some(repeat_limit_help(
*max_loops,
loop_name.as_deref(),
body,
self.outermost_steps(),
)),
TraverserError::RepeatWithoutBody { modulator } => {
Some(repeat_without_body_help(modulator, self.failing_level_prefix()))
}
TraverserError::RepeatInject { .. } => Some(repeat_inject_help()),
TraverserError::OptionWithoutBranch { modulator } => {
Some(option_without_branch_help(modulator))
}
TraverserError::InvalidOption { key, .. } if key == GLOB_PATH_MAX_DEPTH => {
Some(glob_path_max_depth_help(self.outermost_steps()))
}
TraverserError::MathExpression { expr, kind, .. } => {
Some(math_expression_help(expr, kind, self.outermost_steps()))
}
root => root.help(),
},
TraverserError::Spelled { source, .. } => source.canonical_help(),
TraverserError::Timeout { limit_ms, .. } => Some(timeout_help(*limit_ms, None)),
TraverserError::Cancelled { .. } => Some(cancelled_help()),
TraverserError::InvalidOption { key, .. } => Some(invalid_option_help(key)),
TraverserError::LockedOption { key } => Some(locked_option_help(key)),
TraverserError::PermissionDenied { reason, .. }
if reason.starts_with(SAVED_QUERY_READ_ONLY) =>
{
Some(
"A saved query never writes: not the data, the schema, the saved queries or \
files, also not through a query it calls or a traversal it returns (a \
g.query(..) result cannot be continued with a writing step). Write in a \
traversal of your own after the call, for example \
let ids = g.query(\"q\").id().to_list(); \
g.v(ids).property(\"seen\", true).to_list(). A graph the query builds \
itself (GraphSource::empty(), __) stays writable."
.to_string(),
)
}
TraverserError::PermissionDenied { step, request, .. } => {
Some(permission_denied_help(step, &request.as_request()))
}
TraverserError::RepeatLimitExceeded {
max_loops,
loop_name,
body,
} => Some(repeat_limit_help(*max_loops, loop_name.as_deref(), body, None)),
TraverserError::BulkOverflow { step } => Some(bulk_overflow_help(step)),
TraverserError::BulkExpansionLimit {
step,
requested,
limit,
} => Some(bulk_expansion_limit_help(step, *requested, *limit)),
TraverserError::ResourceLimitExceeded {
limit,
max,
observed,
} => Some(resource_limit_help(limit, *max, *observed)),
TraverserError::UnknownLoopName { name, active } => {
Some(unknown_loop_name_help(name, active))
}
TraverserError::RepeatWithoutBody { modulator } => {
Some(repeat_without_body_help(modulator, None))
}
TraverserError::RepeatInject { .. } => Some(repeat_inject_help()),
TraverserError::OptionWithoutBranch { modulator } => {
Some(option_without_branch_help(modulator))
}
TraverserError::QueryExecution { query, reason }
if query == "add_e" && reason.contains("from()") =>
{
Some(add_e_endpoint_help())
}
TraverserError::QueryExecution { query, reason }
if query == "tree" && reason.contains("already holds") =>
{
Some(tree_key_clash_help(reason))
}
TraverserError::QueryExecution { query, reason }
if (query == "group" || query == "group_count")
&& (reason.contains("already holds") || reason.contains("reduce differently")) =>
{
Some(group_key_clash_help(reason))
}
TraverserError::QueryExecution { query, reason }
if query == "id"
&& reason == crate::traversal::step::map_steps::step_id::PROPERTY_ELEMENT_ID_REASON =>
{
Some(
"Graphersal has no property ids, so id() cannot read one from properties(). A \
vertex property is identified by its vertex id and key: read the key with \
g.v().properties().key(), or the owning vertex id with \
g.v().properties().element().id(). label() and has_label() work on \
properties(): the label of a vertex property is its key. The same holds \
for by(T.id) over properties(): there is no id to sort, group or dedup by; \
use by(T.label) (the key) or by(__.element().id())."
.to_string(),
)
}
TraverserError::QueryExecution { query, .. }
if query == "by(T.label)" || query == "by(T.id)" =>
{
let token = &query["by(".len()..query.len() - 1];
Some(format!(
"{token} reads the label or id of a graph element, so the stream reaching this \
by() must hold vertices, edges or vertex properties (the label of a vertex \
property is its key). The values here are plain data, which have no \
{token}: order or group the elements before extracting values, or use a \
property key or a child traversal instead, for example \
g.v().order().by(\"name\") or g.v().order().by(__.values(\"name\")). \
To sort plain values, use order() with no by()."
))
}
TraverserError::QueryExecution { query, reason } if query == "to_graph" => {
Some(to_graph_help(reason))
}
TraverserError::QueryExecution { query, .. } if query == "subgraph()" => {
Some(
"subgraph() needs the name of the side effect that collects the edges. Use \
subgraph(\"sg\") and read the graph with cap(\"sg\"): \
g.e(0).subgraph(\"sg\").cap(\"sg\"), or the short terminal to_graph() that \
needs no label: g.e(0).to_graph()."
.to_string(),
)
}
TraverserError::QueryExecution { query, reason } if query == "subgraph" => {
Some(subgraph_help(reason))
}
TraverserError::QueryExecution { query, .. } if query == "property(map)" => {
Some(property_map_help())
}
TraverserError::QueryExecution { .. } => {
Some("Review traversal query steps and conditions.".to_string())
}
TraverserError::UnsupportedCardinality { step, on_edge, .. } => {
Some(unsupported_cardinality_help(step, *on_edge))
}
TraverserError::TokenPropertyOutsideAdd { token } => {
Some(token_property_outside_add_help(token))
}
TraverserError::InvalidMergeMap { step, reason, .. } => {
Some(invalid_merge_map_help(step, reason))
}
TraverserError::MergeOverride { step, key, .. } => Some(merge_override_help(step, key)),
TraverserError::MergeVertexUnresolved { direction, reason } => {
Some(merge_vertex_unresolved_help(*direction, reason))
}
TraverserError::NotImplemented { .. } => {
Some("This feature is not yet supported in current release.".to_string())
}
TraverserError::SackArenaExhausted { .. } => Some(
"Every update of a sack to a new distinct value stores one more value, so the \
sack grew past what one execution can address.\n\
Keep sacks to a few distinct values (a counter that is incremented a bounded \
number of times, a flag), narrow the stream before the sack is updated, for \
example g.with_sack(0).v().limit(1000).out().sack(Operator.sum).by(__.constant(1)).sack(), \
or aggregate with a reducing step such as sum() or group_count() instead of \
carrying the running value in the sack."
.to_string(),
),
TraverserError::PathArenaExhausted { .. } => Some(
"Every traverser that reaches path(), simple_path(), cyclic_path() or other_v() \
records its whole path, so the traversal fans out into more positions than can \
be tracked.\n\
Cap the stream before it grows, for example g.v().out().limit(1000).path(), \
narrow the start set, for example g.v(\"1\").out().path() instead of \
g.v().out().path(), or remove the path consumer when only the end \
elements are needed.\n\
When the fan-out comes from a few starting elements each having many \
neighbours, cap it per starting element instead of across the whole stream with \
local(), for example g.v().local(__.out().limit(2)).path().\n\
When the paths walk in cycles, drop them early with simple_path(), for example \
g.v().both().both().simple_path().path(), or at every hop of a loop: \
g.v().repeat(__.both().simple_path()).times(2).path()."
.to_string(),
),
TraverserError::OtherVWithoutOrigin { .. } => Some(
"other_v() reads the vertex the traversal visited right before reaching this \
edge, so it needs a vertex earlier in the same traversal, for example \
g.v(\"1\").both_e(\"knows\").other_v() or out().out_e().other_v(). Starting \
directly from edges has no such vertex, for example g.e().other_v(); use \
both_v() there instead to get both endpoints, or out_v()/in_v() to pick one side \
unconditionally."
.to_string(),
),
TraverserError::InvalidRange { .. } => Some(
"range(low, high) slices the stream to [low, high): low must be zero or a \
positive integer, and high must be -1 (meaning \"no upper bound\", for example \
g.v().range(2, -1) to skip the first two and take the rest) or an integer at \
least as large as low, for example g.v().range(0, 10) for the first page or \
g.v().range(10, 20) for the next. To take a fixed number from the start, use \
limit(n) instead, for example g.v().limit(10). The same bounds apply to \
the Scope.local form, which slices the list or map each traverser holds, for \
example g.v().values(\"name\").fold().range(Scope.local, 1, 3); its \
limit(Scope.local, n), skip(Scope.local, n) and tail(Scope.local, n) take a \
count of zero or more, for example \
g.v().values(\"name\").fold().limit(Scope.local, 2)."
.to_string(),
),
TraverserError::InvalidModulator {
step,
modulator,
reason,
..
} if *modulator == crate::traversal::with_reason::MODULATOR => {
Some(with_modulator_help(step, reason))
}
TraverserError::InvalidModulator { step, reason, .. } => {
Some(invalid_modulator_help(step, reason))
}
TraverserError::SourceStepInChild { step } => Some(source_step_in_child_help(step)),
TraverserError::MathExpression { expr, kind, .. } => {
Some(math_expression_help(expr, kind, None))
}
TraverserError::Fail { .. } => Some(
"The query stopped on purpose: a traverser reached its fail() step. If that \
branch should not fail, remove fail() or guard it so that only unexpected \
traversers reach it, for example \
g.v().coalesce(__.out(\"knows\"), __.fail(\"no friends\")) fails only for a \
vertex without an out(\"knows\") neighbour, and \
g.v().choose(__.has(\"age\"), __.values(\"age\"), __.fail(\"age missing\")) \
only for a vertex without an age. A mutating traversal that fails is rolled \
back as a whole."
.to_string(),
),
}
}
pub fn spelling(&self) -> crate::traversal::Spelling {
match self {
TraverserError::StepFailed { spelling, .. }
| TraverserError::Spelled { spelling, .. } => *spelling,
_ => crate::traversal::Spelling::Snake,
}
}
pub(crate) fn respell(&mut self, spelling: crate::traversal::Spelling) {
use crate::traversal::spelling::respell;
if spelling == crate::traversal::Spelling::Snake
|| matches!(self, TraverserError::Spelled { .. })
{
return;
}
if let TraverserError::PermissionDenied { step, .. } = self.root_cause_mut() {
*step = if step.contains('(') {
respell(step, spelling).into_owned()
} else {
crate::traversal::spelling::camel_name(step).into_owned()
};
}
if !matches!(self, TraverserError::StepFailed { .. }) {
let source =
std::mem::replace(self, TraverserError::NotImplemented { feature: "respell" });
*self = TraverserError::Spelled {
spelling,
source: Box::new(source),
};
return;
}
let mut outermost = true;
let mut current = self;
while let TraverserError::StepFailed {
step_name,
frame,
source,
spelling: marked,
..
} = current
{
*step_name = respell(step_name, spelling).into_owned();
if let Some(frame) = frame {
for step in &mut frame.steps {
*step = respell(step, spelling).into_owned();
}
if let Some(logical) = &mut frame.logical {
for step in &mut logical.steps {
*step = respell(step, spelling).into_owned();
}
}
}
if outermost {
*marked = spelling;
outermost = false;
}
current = source;
}
}
}
impl DiagnosticError for TraverserError {
fn help(&self) -> Option<String> {
let help = self.canonical_help()?;
Some(crate::traversal::spelling::respell(&help, self.spelling()).into_owned())
}
fn details(&self) -> Vec<(String, String)> {
match self {
TraverserError::Spelled { source, .. } => source.details(),
TraverserError::Value(err) => err.details(),
TraverserError::Graph(err) => err.details(),
TraverserError::Io(err) => err.details(),
TraverserError::Script(err) => err.details(),
TraverserError::Schema(err) => err.details(),
TraverserError::StepFailed { .. } => {
let mut details = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut current = self;
while let TraverserError::StepFailed {
step_index,
child_index,
frame,
source,
..
} = current
{
path.extend(level_segments(*child_index, *step_index));
if let Some(frame) = frame {
match frame.render_logical() {
Some(logical) => {
details.push((format!("at #{}", path.join(".")), logical));
details.push(("plan".to_string(), frame.render()));
}
None => {
details.push((format!("at #{}", path.join(".")), frame.render()))
}
}
}
current = source;
}
details
}
TraverserError::MathExpression {
expr,
column,
width,
..
} => vec![(
"equation".to_string(),
math_underline(expr, *column, *width),
)],
_ => Vec::new(),
}
}
fn diagnostic_body(&self) -> String {
if let TraverserError::Spelled { spelling, source } = self {
let body = source.diagnostic_body();
return crate::traversal::spelling::respell(&body, *spelling).into_owned();
}
let mut buf = format!("Error: {self}");
push_details(&mut buf, &self.details());
let root = self.root_cause();
if !std::ptr::eq(root, self) {
buf.push_str(&format!("\nCaused by: {root}"));
push_details(&mut buf, &root.details());
}
let mut current: Option<&dyn std::error::Error> = std::error::Error::source(root);
while let Some(src) = current {
buf.push_str(&format!("\nCaused by: {src}"));
current = src.source();
}
match crate::traversal::spelling::respell(&buf, self.spelling()) {
std::borrow::Cow::Borrowed(_) => buf,
std::borrow::Cow::Owned(respelled) => respelled,
}
}
}
const TIMEOUT_EXAMPLE_STEPS: [&str; 4] = ["v()", "out()", "out()", "count()"];
fn timeout_help(limit_ms: u64, steps: Option<Vec<String>>) -> String {
let steps = steps.filter(|steps| !steps.is_empty()).unwrap_or_else(|| {
TIMEOUT_EXAMPLE_STEPS
.iter()
.map(|s| s.to_string())
.collect()
});
let with_after_start = |inserted: &str| {
let mut rewritten = steps.clone();
rewritten.insert(1, inserted.to_string());
runnable_glob_path_options(&rewritten.join("."))
};
let query = runnable_glob_path_options(&steps.join("."));
let raised = limit_ms.saturating_mul(2).max(1);
let glob_path_advice = glob_path_query_with_max_depth(&steps)
.map(|bounded| {
format!(
"Bound the '**' walk of glob_path() with max_depth, the deepest level below the \
start vertex it visits, for example g.{bounded}.\n"
)
})
.unwrap_or_default();
let repeat_advice = if query.contains("repeat(") {
"Bound every repeat() loop with times() or until(), and skip cycles with simple_path() \
in its body, for example g.v().repeat(__.out().simple_path()).times(3) or \
g.v().repeat(__.out().simple_path()).until(__.has(\"name\", \"<value>\")).\n\
Equal traversers are merged automatically inside repeat(), so a loop over converging \
paths stays cheap; path() and simple_path() record full paths, which prevents the \
merge and keeps exponential path counts expensive, so leave them out when you only \
need the reached elements or a count. A sack merge operator or with_bulk(false) turns \
automatic merging off: add barrier() inside the loop body, for example \
g.v().repeat(__.out().barrier()).times(3).count().\n"
} else {
""
};
let barriered = barrier_between_expansions(&steps)
.map(|rewritten| runnable_glob_path_options(&rewritten.join(".")))
.unwrap_or_else(|| "v().out().barrier().out().barrier().out().count()".to_string());
format!(
"The traversal ran longer than its evaluationTimeout budget of {limit_ms} ms. The budget \
is checked between steps, every 1024 traversers and every 1024 values a step \
materializes (fold(), group(), order(), a terminal list); the step named above is the \
one that was running.\n\
{repeat_advice}\
{glob_path_advice}\
Merge duplicate traversers: barrier() merges equal traversers so later steps process \
each distinct one once, for example g.{barriered}.\n\
Narrow the start set with has_label() or has_id(), for example \
g.{narrowed}.\n\
Cap the stream before it fans out with limit(), for example g.{limited}.\n\
Or raise the budget: g.with(\"evaluationTimeout\", {raised}).{query}. It is an administrative option: a script whose host restricts it needs Update on that Option to set it.\n\
To see which step spends the time, run it with execute() and a profile: the profile \
up to the timed-out step comes back with the error, for example \
g.{query}.execute(#{{profile: true}}).\n\
If the traversal mutates the graph, nothing of it was applied: a failing traversal \
is rolled back.",
narrowed = with_after_start("has_label(\"<label>\")"),
limited = with_after_start("limit(1000)"),
)
}
fn barrier_between_expansions(steps: &[String]) -> Option<Vec<String>> {
const EXPANSIONS: [&str; 10] = [
"out(", "in(", "both(", "out_e(", "in_e(", "both_e(", "out_v(", "in_v(", "both_v(",
"other_v(",
];
let is_expansion = |step: &String| EXPANSIONS.iter().any(|name| step.starts_with(name));
let mut rewritten = Vec::with_capacity(steps.len() + 1);
let mut inserted = false;
for (index, step) in steps.iter().enumerate() {
rewritten.push(step.clone());
if is_expansion(step) && steps.get(index + 1).is_some_and(is_expansion) {
rewritten.push("barrier()".to_string());
inserted = true;
}
}
inserted.then_some(rewritten)
}
fn repeat_inject_help() -> String {
"TinkerPop forbids inject() as a step of a repeat() body: the injected values would enter \
again in every iteration. Inject before the loop, for example \
g.inject(\"y\").repeat(__.out()).times(2), or wrap it so it is not a direct step of the \
body, for example g.v().repeat(__.union(__.identity(), __.inject(\"y\"))).times(2)."
.to_string()
}
fn source_step_in_child_help(step: &str) -> String {
let example = if step == "with_sack" {
"g.with_sack(1).v().local(__.out().sack()) instead of g.v().local(__.with_sack(1).out().sack())"
} else if step == "with_path" {
"g.with_path().v().local(__.out().path()) instead of g.v().local(__.with_path().out().path()); \
with_path() changes nothing in Graphersal (paths are recorded as the query reads them), \
so it can also simply be left out"
} else {
"g.with_side_effect(\"x\", 1).v().local(__.select(\"x\")) instead of \
g.v().local(__.with_side_effect(\"x\", 1).select(\"x\"))"
};
format!(
"{step}() sets up the traversal source g: it applies to the whole query, child \
traversals included, so it is written once on g, before the first step, for example \
{example}. A child traversal sees the sacks and side effects of the outer traversal."
)
}
fn with_modulator_help(step: &str, reason: &str) -> String {
let start = if reason == crate::traversal::with_reason::NO_STEP {
"with(WithOptions..) configures the step right before it, so it cannot start a traversal."
.to_string()
} else {
format!("{step}() cannot take this with() option.")
};
format!(
"{start} Two steps take a WithOptions option: value_map() takes WithOptions.tokens \
(with no value, or ids, labels, all, none), for example \
g.v().value_map(\"name\").with(WithOptions.tokens, WithOptions.labels); index() takes \
WithOptions.indexer with WithOptions.list or WithOptions.map, for example \
g.v().values(\"name\").fold().index().with(WithOptions.indexer, WithOptions.map). \
To set an execution option of the whole traversal use g.with(\"key\", value) with a \
string key instead, for example g.with(\"random.seed\", 7)."
)
}
fn invalid_modulator_help(step: &str, reason: &str) -> String {
if reason == crate::traversal::by_reason::NO_STEP {
return format!(
"by() modulates the step right before it, so it cannot start a traversal. Write the \
step first and the by() after it, for example g.v().order().by(\"name\"). {BY_STEPS}"
);
}
if reason == crate::traversal::args_reason::NO_STEP {
return format!(
"from()/to() modulates the step right before it, so it cannot start a traversal. \
Write the step first and from()/to() after it, for example \
g.v(\"1\").add_e(\"knows\").to(\"2\"). {FROM_TO_STEPS}"
);
}
if reason == crate::traversal::args_reason::ONE_LABEL {
return format!(
"from()/to() of {step}() takes exactly one step label, a window between two labelled \
positions of the path, not a list: write g.v().as(\"a\").out().as(\"b\").path()\
.from(\"a\").to(\"b\"). The label is set with as() before the step. {FROM_TO_STEPS}"
);
}
if reason.ends_with(crate::traversal::args_reason::NOT_ACCEPTED) {
return from_to_not_accepted_help(step);
}
if reason.ends_with(crate::traversal::by_reason::NOT_ACCEPTED) {
return by_not_accepted_help(step);
}
if reason == crate::traversal::by_reason::COLUMN {
return column_modulator_help(step);
}
if step == "glob_path" {
return glob_path_modulator_help(reason);
}
if step == "dedup" {
return dedup_modulator_help(reason);
}
if step == "value_map" {
return value_map_modulator_help(reason);
}
if step == "aggregate" || step == "store" {
return aggregate_modulator_help(step);
}
if step == "group" || step == "group_count" {
return group_modulator_help(step, reason);
}
if step == "sample" {
return "sample(n) takes at most one by(), the weight each traverser is drawn with, and it \
must project a number: a numeric property or a traversal ending in a number, for \
example g.e().sample(2).by(\"weight\") or g.v().sample(1).by(__.out_e().count()). \
An order or Count is not a weight; to sort and then sample, order first: \
g.v().order().by(\"age\").sample(2). Drop by() for a uniform sample."
.to_string();
}
if step == "sack" {
return "sack(Operator) updates the sack with one operand per traverser, so it takes a single \
by(), for example g.with_sack(0).v().out_e().sack(Operator.sum).by(\"weight\"). To \
combine several values into one operand, compute it in one traversal, for example \
g.with_sack(0).v().sack(Operator.sum).by(__.values(\"age\").sum())."
.to_string();
}
if reason == crate::traversal::ring_reason::KIND {
return ring_modulator_help(step);
}
format!(
"A step's by() modulators are positional: the first by() of {step}() means one thing, \
the second another, and each position accepts only certain kinds of argument (a \
property key, T.id/T.label, a traversal such as __.values(\"name\"), ...). \
A list of vertex labels, such as by([\"person\", \"software\"]), is not a projection \
{step}() can use. To keep only elements with certain labels, filter them with \
has_label(...) before the step instead, for example \
g.v().has_label(\"person\", \"software\").order().by(\"name\")."
)
}
fn column_modulator_help(step: &str) -> String {
format!(
"by(Column.keys) / by(Column.values) reads the key or value of a map entry (or the keys or \
values of a whole map), and only order() takes it, as a sort key, not {step}(). Sort map \
entries with it, for example \
g.v().has_label(\"person\").group().by(\"name\").by(__.out_e().values(\"weight\").sum()).unfold().order().by(Column.values, Order.desc), \
or sort the entries inside each map with order(Scope.local).by(Column.values). To read a \
map's keys or values as data, use select(Column.keys) / select(Column.values) after the \
step instead, for example g.v().group().by(\"name\").select(Column.keys). {BY_STEPS}"
)
}
const BY_STEPS: &str = "The steps that take by() are order(), dedup(), group(), group_count(), \
select() with step labels, project(), path(), simple_path(), cyclic_path(), where(), tree(), \
math(), value_map(), aggregate(), store(), sample() (the weight) and glob_path().";
fn by_not_accepted_help(step: &str) -> String {
let fix = match step {
"count" => {
"To count per value, use group_count() with the by() instead, for example \
g.v().group_count().by(\"name\") rather than g.v().count().by(\"name\")."
}
"sum" | "max" | "min" | "mean" | "median" => {
"To aggregate a property, read it with values() first, for example \
g.v().values(\"age\").sum() rather than g.v().sum().by(\"age\")."
}
"select" => {
"They read a map as it is; put the by() on the step that builds the map, for \
example g.v().group().by(\"name\").select(Column.keys)."
}
_ => {
"Move the by() to the step it was meant for, for example g.v().out().order().by(\"name\") \
sorts the neighbours by name; to read a property of each element, use values(), for \
example g.v().out().values(\"name\") rather than g.v().out().by(\"name\")."
}
};
let what = match step {
"select" => "select(Column.keys) / select(Column.values)".to_string(),
_ => format!("{step}()"),
};
format!(
"by() modulates the step right before it, and {what} does not take one. {fix} {BY_STEPS}"
)
}
const FROM_TO_STEPS: &str = "The steps that take from()/to() are add_e(), path(), \
simple_path() and cyclic_path().";
fn from_to_not_accepted_help(step: &str) -> String {
format!(
"from()/to() modulates the step right before it, and {step}() does not take one. \
add_e() accepts from()/to(), to point a new edge at existing elements or \
labelled positions, for example g.v(\"1\").add_e(\"knows\").to(\"2\") or \
g.v(\"1\").as(\"a\").out(\"knows\").add_e(\"co-developer\").from(\"a\"). An endpoint \
can also be a traversal, g.v(\"1\").add_e(\"knows\").to(__.v(\"2\")), or a side-effect key, \
g.with_side_effect(\"b\", \"2\").v(\"1\").add_e(\"knows\").to(\"b\"). \
path(), simple_path() and cyclic_path() take them as a window onto the path between two \
labelled positions, for example \
g.v().as(\"a\").out().as(\"b\").path().from(\"a\").to(\"b\"). {FROM_TO_STEPS}"
)
}
fn ring_modulator_help(step: &str) -> String {
let (what, example) = match step {
"select" => (
"one by() per selected key, cycling when there are fewer by() than keys",
"g.v(\"1\").as(\"a\").out(\"knows\").as(\"b\").select(\"a\", \"b\").by(\"name\") \
reads the name of both a and b",
),
"project" => (
"one by() per key, cycling when there are fewer by() than keys",
"g.v().project(\"name\", \"out\").by(\"name\").by(__.out().count())",
),
"path" => (
"one by() per path object, cycling along the path",
"g.v(\"1\").out().path().by(\"name\")",
),
"simple_path" | "cyclic_path" => (
"one by() per object of the path (or of its from()/to() window), cycling along it, \
before the simple/cyclic test",
"g.v(\"1\").both().both().simple_path().by(\"name\")",
),
"where" => (
"the first by() projects the current object, each following one a step-label operand \
of the predicate in textual order, cycling when there are fewer by() than operands",
"g.v(\"1\").as(\"a\").out().where(P.gt(\"a\")).by(\"age\") compares the age of \
each vertex with the age of a",
),
"tree" => (
"one by() per tree level, cycling along each path",
"g.v(\"1\").out().tree().by(\"name\")",
),
"math" => (
"one by() per variable of the equation, cycling when there are fewer by() than \
variables",
"g.v().as(\"a\").out(\"knows\").as(\"b\").math(\"a + b\").by(\"age\")",
),
_ => (
"one by() per value, cycling",
"g.v(\"1\").out().path().by(\"name\")",
),
};
format!(
"{step}() applies its by() modulators round-robin, like TinkerPop's traversal ring: \
{what}. Each by() is a projection: a property key such as by(\"name\"), T.id / T.label, \
or a traversal such as __.out().count(). An order or Count projects nothing: drop it, \
and sort afterwards with order().by(...) if an order was meant. For example {example}."
)
}
fn dedup_modulator_help(reason: &str) -> String {
use crate::traversal::step::flatbarrier_steps::step_dedup::reason as r;
const WHAT: &str = "dedup() takes one by(): the projection each traverser is deduplicated by, \
a property key such as by(\"name\"), T.id / T.label, or a traversal such as \
__.out().count() whose first result is the key.";
const EXAMPLE: &str = "For example g.v().dedup().by(T.label) keeps the first vertex of each \
label.";
let advice = if reason == r::KIND {
"An order or Count is not a projection: drop it, and sort after dedup() with \
order().by(...) if an order was meant, for example \
g.v().dedup().by(T.label).order().by(\"name\")."
} else if reason == r::TOO_MANY {
"There is no second by(): to deduplicate by several values at once, combine them in one \
traversal, for example g.v().dedup().by(__.values(\"name\", \"age\").fold())."
} else {
"A list of vertex labels, such as by([\"person\", \"software\"]), is not a projection: to \
keep only elements with certain labels, filter them with has_label(...) before the step \
instead, for example g.v().has_label(\"person\", \"software\").dedup().by(\"name\")."
};
format!("{WHAT} {advice} {EXAMPLE}")
}
fn aggregate_modulator_help(step: &str) -> String {
format!(
"{step}() collects one projection of each traverser, so it takes a single by(), for \
example g.v().{step}(\"x\").by(\"name\").cap(\"x\"). To collect several values per \
traverser, project them in one traversal, for example \
g.v().{step}(\"x\").by(__.values(\"name\", \"age\").fold()).cap(\"x\")."
)
}
fn value_map_modulator_help(reason: &str) -> String {
use crate::traversal::step::map_steps::step_value_map::reason as r;
const WHAT: &str = "value_map() wraps each property value in a list, such as {name: [marko], \
age: [29]}, and its by() is a traversal applied to each of those lists; the first result \
replaces the list.";
const EXAMPLE: &str = "For example g.v().value_map().by(__.unfold()) returns the plain \
values, {name: marko, age: 29}; element_map() returns them too, together with id and \
label.";
let advice = if reason == r::TOO_MANY {
"value_map() takes a single by(): it is applied to every property's list. To treat the \
properties differently, select them separately, for example \
g.v().value_map(\"name\").by(__.unfold()), or use project()."
} else if reason == r::KIND {
"A property key, T.id / T.label, an order, a JSON path, Value or Count cannot take a \
list: pass a traversal instead, or select the keys as arguments, for example \
g.v().value_map(\"name\").by(__.unfold())."
} else {
"A list of vertex labels, such as by([\"person\", \"software\"]), is not a traversal: to \
keep only elements with certain labels, filter them with has_label(...) before the step \
instead, for example g.v().has_label(\"person\").value_map()."
};
format!("{WHAT} {advice} {EXAMPLE}")
}
fn group_modulator_help(step: &str, reason: &str) -> String {
use crate::traversal::step::barrier_steps::step_group::reason as r;
const WHAT: &str = "group()'s by() modulators are positional: the first by() reads the \
grouping key from each element, and the second reads the value kept under that key \
(group_count() and by(Count) ask for a count there instead).";
let advice = if reason == r::TOO_MANY {
"group_count() counts the elements per key, so it takes a single by(), the key; there is \
no value projection to give. To group and keep a projection per key, use group() with a \
second by(), for example g.v().group().by(\"name\").by(\"age\") instead of \
g.v().group_count().by(\"name\").by(\"age\")."
} else if reason == r::ORDER {
"An order, such as Order.desc, is not a projection: group() applies each by() to a key \
or a value, it does not sort the result. Drop it, and sort afterward instead, for \
example g.v().group().by(\"name\").unfold().order().by(Order.desc)."
} else {
"A list of vertex labels, such as by([\"person\", \"software\"]), is not a projection: to \
keep only elements with certain labels, filter them with has_label(...) before the step \
instead, for example g.v().has_label(\"person\", \"software\").group().by(\"name\")."
};
format!("{WHAT} {advice} For example g.v().{step}().by(\"name\").")
}
fn glob_path_modulator_help(reason: &str) -> String {
use crate::traversal::step::flatmap_steps::step_glob_path::reason as r;
const POSITIONS: &str = "glob_path()'s by() modulators are positional: the first by() is the \
required match key a named pattern segment is compared with, a property key such as \
by(\"name\") or T.id / T.label; the optional second by() is a non-empty list of vertex \
labels that every visited vertex below the start must carry.";
const EXAMPLE: &str = "For example g.v(\"root\").glob_path(\"**/*.rs\").by(\"name\")\
.by([\"directory\",\"file\"]).";
let advice = if reason == r::MISSING_KEY {
"Add the match key as the first by()."
} else if reason == r::LABELS_AS_KEY {
"The first by() is the key and the label list goes second: put a key such as by(\"name\") \
before the label list."
} else if reason == r::KEY_KIND {
"A traversal, an order, a JSON path, Value or Count cannot be the match key."
} else if reason == r::JPATH_KEY {
"A jpath(..) path reaches into a nested value, but the match key is the stored value of \
one property: pass the plain property name, by(\"name\")."
} else if reason == r::FILTER_KIND {
"The second by() only filters by vertex label: pass a list such as by([\"directory\", \
\"file\"]), or drop it to visit every vertex."
} else if reason == r::EMPTY_LABELS {
"An empty label list would admit no vertex: list at least one label, or drop the second \
by() to visit every vertex."
} else if reason == r::TOO_MANY {
"There is no third by(): drop it."
} else {
""
};
if advice.is_empty() {
format!("{POSITIONS} {EXAMPLE}")
} else {
format!("{POSITIONS} {advice} {EXAMPLE}")
}
}
fn limit_detail(max: Option<u64>, observed: Option<u64>) -> String {
match (max, observed) {
(Some(max), Some(value)) => format!(" (limit {max}, reached {value})"),
(Some(max), None) => format!(" (limit {max})"),
(None, Some(value)) => format!(" (reached {value})"),
(None, None) => String::new(),
}
}
const MEMORY_BUDGET_SETTING: &str = "The budget is a host setting (off by default): the \
graphersal command line takes --memory-limit <bytes|auto>, the Python binding \
Graph(memory_limit=...), a Rust host ExecutionPolicy::with_default(\"memory.limit\", bytes) \
or a preset of graph_scope_with_options; \"auto\" is the cgroup memory limit minus \
'memory.headroom'. Setting it in a query is an administrative option: it needs Update on \
that Option, and is refused when the host locked it.";
fn unit_resource_limit_help(
operation: &str,
limit: &str,
max: Option<u64>,
observed: Option<u64>,
) -> String {
let raised = max.map_or(1_000_000, |max| max.saturating_mul(2).max(1));
format!(
"The {operation} would take the memory in use over the memory budget '{limit}'{}{}, so \
it was stopped and everything it changed was rolled back, instead of the process \
running out of memory. The figure is the process's live heap when the host installs \
the tracking allocator (the graphersal command line does), else the engine's estimate: \
the graph's footprint when the unit started (see g.memory_usage()) plus the elements it \
added. A unit that only removes or rewrites data is never stopped, so make room first: \
drop data you do not need, for example \
g.with(\"memory.limit\", {raised}).v().has_label(\"tmp\").drop(), then import or \
apply again; or give the host more memory. {MEMORY_BUDGET_SETTING}",
max.map(|max| format!(" ({max} bytes)")).unwrap_or_default(),
observed
.map(|observed| format!("; it reached {observed} bytes"))
.unwrap_or_default(),
)
}
fn resource_limit_help(limit: &str, max: Option<u64>, observed: Option<u64>) -> String {
let raised = max.map_or(1_000_000, |max| max.saturating_mul(2).max(1));
match limit {
"traversal.max_traversers" => format!(
"The traversal kept more traversers alive between two steps than \
'traversal.max_traversers' allows{}. This usually means an expansion such as \
out().out().out() or a repeat() multiplies the stream. Narrow it, for example \
g.v().has_label(\"person\").out().limit(100), aggregate with count() or \
group_count() instead of listing, or raise the limit when the traversal source is \
created, for example g.with(\"traversal.max_traversers\", {raised}).v().out().out().count(). \
A host can lock this option with ExecutionPolicy::with_locked(\"traversal.max_traversers\"); \
9223372036854775807 is as good as unlimited. Setting it is an administrative option: \
a script whose host restricts it needs Update on that Option.",
max.map(|max| format!(" ({max})")).unwrap_or_default()
),
"script.max_value_depth" => format!(
"A value the script handed to the engine (a step argument such as inject(x) or \
property(\"k\", x), a parameter, or the script's result) nests arrays and maps \
deeper than 'script.max_value_depth' allows{}. Deeply nested values are read \
recursively and would overflow the native stack, so the depth is bounded where a \
value enters the engine. Flatten the value, for example keep a list of items \
instead of wrapping a list in a list in a loop: let xs = []; for i in 0..10 {{ \
xs.push(i); }} g.inject(xs).count(Scope.local).next(). A trusted host raises the limit \
with ScriptLimits::default().with_max_value_depth({raised}) passed to \
graphersal::script::engine_with_limits(&limits, authorizer); the graphersal \
command line takes --max-value-depth {raised}, the Python binding \
graph.set_limits(max_value_depth={raised}). ScriptLimits::unlimited() keeps this \
bound, because the native stack cannot be raised by configuration.",
max.map(|max| format!(" ({max} levels)"))
.unwrap_or_default()
),
"traversal.max_value_depth" => format!(
"The traversal built or wrote a value that nests arrays, maps or paths deeper than \
'traversal.max_value_depth' allows{}. A repeat() whose body wraps its value again \
on every iteration (repeat(__.fold()), repeat(__.path()), repeat(__.group())) or a \
jpath(..) write with many segments does this. Bound the loop, for example \
g.inject(1).repeat(__.fold()).times(10).next(), write to a shorter path, or raise \
the limit when the traversal source is created, for example \
g.with(\"traversal.max_value_depth\", {raised}).inject(1).repeat(__.fold()).times(200).count(). \
Deeply nested values are read recursively, so keep the limit well below the \
thousands. A host can lock this option with \
ExecutionPolicy::with_locked(\"traversal.max_value_depth\"). Setting it is an \
administrative option: a script whose host restricts it needs Update on that Option.",
max.map(|max| format!(" ({max} levels)"))
.unwrap_or_default()
),
"traversal.max_materialized_bytes" => format!(
"A step had to materialize a value or buffer larger than \
'traversal.max_materialized_bytes' allows{}{}, by the engine's size estimate. \
Steps that expand merged traversers into single items (fold(), aggregate(), \
store(), group() value lists, a terminal list) do this, and so does a repeat() \
whose body doubles its value on every iteration (repeat(__.path()), \
repeat(__.group())). Prefer a step that works on the multiplicity directly: count() \
instead of fold(), for example g.v().repeat(__.both()).times(8).count(); \
group_count() to see how often each result occurs, for example \
g.v().repeat(__.both()).times(8).group_count(); dedup() or limit(n) before the \
materializing step, for example g.v().repeat(__.both()).times(8).dedup().fold(); and \
bound a loop that grows its value, for example \
g.inject(1).repeat(__.path()).times(10).count(). If you have the memory, raise the \
limit when the traversal source is created, for example \
g.with(\"traversal.max_materialized_bytes\", {raised}).v().repeat(__.both()).times(8).fold(). \
A host can lock this option with \
ExecutionPolicy::with_locked(\"traversal.max_materialized_bytes\"). Setting it is an \
administrative option: a script whose host restricts it needs Update on that Option.",
max.map(|max| format!(" ({max} bytes)")).unwrap_or_default(),
observed
.map(|observed| format!("; this one needed at least {observed} bytes"))
.unwrap_or_default(),
),
"memory.limit" => format!(
"The traversal would take the memory in use over the memory budget \
'memory.limit'{}{}, so it was stopped and everything it changed was rolled back, \
instead of the process running out of memory. The figure is the process's live \
heap when the host installs the tracking allocator (the graphersal command line \
does), else the engine's estimate: the graph's footprint (see g.memory_usage()) \
plus what the traversal holds and adds. Make the traversal hold less: count() \
instead of fold(), for example g.v().repeat(__.both()).times(8).count(); \
dedup() or limit(n) before a materializing step, for example \
g.v().out().dedup().fold(); bound a loop that grows its value, for example \
g.inject(1).repeat(__.path()).times(10).count(); write in smaller batches, for example \
g.v().has_label(\"person\").limit(1000).property(\"seen\", true). When the graph \
itself is over the budget, every traversal fails at its first step: drop data you \
do not need under a raised budget, for example \
g.with(\"memory.limit\", {raised}).v().has_label(\"tmp\").drop(), or give the \
host more memory. {MEMORY_BUDGET_SETTING}",
max.map(|max| format!(" ({max} bytes)")).unwrap_or_default(),
observed
.map(|observed| format!("; it reached {observed} bytes"))
.unwrap_or_default(),
),
"traversal.max_string_bytes" => format!(
"A step built a string longer than 'traversal.max_string_bytes' allows{}{}. \
replace(), concat(), conjoin() and format() can multiply the length of their input \
(replace() of a short text by a long one at every match, concat() of a string with \
itself in a loop); the limit stops them before the string is allocated, as \
'script.max_string_size' does for the strings a script builds. Shorten the input or \
the replacement, for example g.v().values(\"name\").replace(\"a\", \"b\"), \
bound a loop that grows a string, for example \
g.inject(\"x\").repeat(__.concat(__.identity())).times(10), or raise the limit when \
the traversal source is created, for example \
g.with(\"traversal.max_string_bytes\", {raised}).v().values(\"name\").replace(\"a\", \"aa\"). \
A host can lock this option with \
ExecutionPolicy::with_locked(\"traversal.max_string_bytes\"). Setting it is an \
administrative option: a script whose host restricts it needs Update on that Option.",
max.map(|max| format!(" ({max} bytes)")).unwrap_or_default(),
observed
.map(|observed| format!("; this one would be {observed} bytes"))
.unwrap_or_default(),
),
other if other.starts_with("script.") => {
let field = other.trim_start_matches("script.");
format!(
"The script ran past its '{other}' limit{}. Bound the loop or the data it builds. \
A trusted host raises the limit with ScriptLimits::default().with_{field}({raised}) \
passed to graphersal::script::engine_with_limits(&limits, authorizer), or removes all limits \
with ScriptLimits::unlimited(). The graphersal command line takes \
--{flag} <n>, for example graphersal --{flag} {raised} -e 'g.v().count().next()'.",
max.map(|max| format!(" of {max}")).unwrap_or_default(),
flag = field.replace('_', "-"),
)
}
other => format!(
"The limit '{other}' was exceeded. Raise it through the option or configuration of \
the same name, for example g.with(\"{other}\", {raised}).v().count()."
),
}
}
fn cancelled_help() -> String {
"The query was stopped on request (the Stop button) and everything it changed was rolled \
back; nothing of it is kept. Run it again to get its results. To bound a slow query instead \
of stopping it by hand, give it a budget: g.with(\"evaluationTimeout\", 5000).v().out().count()."
.to_string()
}
fn bulk_overflow_help(step: &str) -> String {
format!(
"Equal traversers are counted as one record with a multiplicity, and the multiplicity at \
step '{step}' grew beyond 9223372036854775807 (2^63 - 1), the largest exact count. \
Narrow the start set, for example g.v().has_label(\"person\").repeat(__.out()).times(8).count() \
instead of g.v().repeat(__.out()).times(8).count(), or bound the loop with a smaller \
times(n) or an until() condition."
)
}
fn bulk_expansion_limit_help(step: &str, requested: u64, limit: u64) -> String {
format!(
"Step '{step}' needs every value as its own item, but equal traversers are kept as one \
record with a multiplicity (bulk). It has to expand each merged record into one value \
per unit of multiplicity, which would produce {requested} values, more than the limit \
of {limit} set by 'bulk.max_expansion'. Steps such as fold(), aggregate(), store(), \
group() value lists and a terminal list do this. Prefer a step that works on the \
multiplicity directly: count() instead of fold(), for example \
g.v().repeat(__.out()).times(8).count(); group_count() to see how often each result \
occurs, for example g.v().repeat(__.out()).times(8).values(\"name\").group_count(); \
dedup() before the step to keep one item per distinct result, for example \
g.v().repeat(__.out()).times(8).dedup().values(\"name\").fold(); or limit(n) before \
the step to keep only the first n, for example \
g.v().repeat(__.out()).times(8).limit(100).values(\"name\").fold(). If you really need \
all {requested} values and have the memory for them, raise the limit when the traversal \
source is created, for example \
g.with(\"bulk.max_expansion\", {requested}).v().repeat(__.out()).times(8).values(\"name\").fold(). It is an administrative option: a script whose host restricts it needs Update on that Option to set it."
)
}
fn invalid_option_help(key: &str) -> String {
match key {
"bulk.merge" => "Set 'bulk.merge' to true or false when the traversal source is created, \
for example g.with(\"bulk.merge\", false).v().out().out().count(). Merged and \
unmerged runs return the same results; false is a diagnosis aid."
.to_string(),
"traversal.max_traversers" => "Set 'traversal.max_traversers' to a positive integer number \
of traversers when the traversal source is created, for example \
g.with(\"traversal.max_traversers\", 50000000).v().out().out().count(); the default is \
10000000, and 9223372036854775807 is as good as unlimited."
.to_string(),
"traversal.max_materialized_bytes" => "Set 'traversal.max_materialized_bytes' to a \
positive integer number of bytes when the traversal source is created, for example \
g.with(\"traversal.max_materialized_bytes\", 2147483648).v().repeat(__.both()).times(8).fold(); \
the default is 1073741824 (1 GiB; 268435456 on wasm32)."
.to_string(),
"memory.limit" => "Set 'memory.limit' to a positive integer number of bytes, or to \
\"auto\" for the cgroup memory limit of the process minus 'memory.headroom', for \
example g.with(\"memory.limit\", 4294967296).v().out().fold() or \
g.with(\"memory.limit\", \"auto\").v().out().fold(). Without the option there is \
no memory budget (the default)."
.to_string(),
"memory.headroom" => "Set 'memory.headroom' to a non-negative integer number of bytes: \
what memory.limit \"auto\" keeps free below the cgroup limit, for example \
g.with(\"memory.limit\", \"auto\").with(\"memory.headroom\", 268435456).v().out().fold(). \
The default is a tenth of the cgroup limit, at least 67108864 (64 MiB) and at most \
half of the limit."
.to_string(),
"traversal.max_string_bytes" => "Set 'traversal.max_string_bytes' to a positive integer \
number of bytes when the traversal source is created, for example \
g.with(\"traversal.max_string_bytes\", 67108864).v().values(\"name\").replace(\"a\", \"aa\"); \
the default is 16777216 (16 MiB)."
.to_string(),
"traversal.max_value_depth" => "Set 'traversal.max_value_depth' to a positive integer \
number of nesting levels when the traversal source is created, for example \
g.with(\"traversal.max_value_depth\", 256).inject(1).repeat(__.fold()).times(200).count(); \
the default is 128."
.to_string(),
"bulk.max_expansion" => "Set 'bulk.max_expansion' to a positive integer number of values \
when the traversal source is created, for example \
g.with(\"bulk.max_expansion\", 1000000).v().out().fold(); the default is 10000000."
.to_string(),
"bulk.one" => "Set 'bulk.one' to true or false when the traversal source is created, \
for example g.with(\"bulk.one\", true).v().out().barrier().count(), or use \
with_bulk(false), which sets it. With bulk.one a merge keeps bulk 1, so duplicates collapse at barrier()."
.to_string(),
GLOB_PATH_MAX_DEPTH => glob_path_max_depth_help(None),
"random.seed" => "Set 'random.seed' to an integer when the traversal source is created, \
for example g.with(\"random.seed\", 42).v().sample(2); the same seed makes coin(), \
sample() and order().by(Order.shuffle) draw the same values on every run. Without \
the option the draws are seeded from the operating system's entropy."
.to_string(),
"choose().option" => "choose() picks at most one option, so it rejects a Pick.any option \
and a traversal as an option key. Test the selector's value with a predicate or a \
literal instead, for example g.v().choose(__.values(\"age\")).option(P.gt(30), \
__.constant(\"old\")).option(Pick.none, __.constant(\"young\")), or use branch(), \
which accepts a traversal key and Pick.any, for example \
g.v().branch(__.identity()).option(__.has_label(\"software\"), __.values(\"name\")). \
With no matching option choose() passes the traverser through unchanged."
.to_string(),
"render.spelling" => "Set 'render.spelling' to \"snake\" (the default: has_label(), \
group_count()) or \"camel\" (Gremlin camelCase: hasLabel(), groupCount()), for \
example g.with(\"render.spelling\", \"camel\").v().has_label(\"person\").profile(). \
It changes only how .profile() and error messages spell the steps, never a result."
.to_string(),
"repeat.order" => "Set 'repeat.order' to \"bfs\" (breadth first, the default) or \"dfs\" \
(depth first) when the traversal source is created, for example \
g.with(\"repeat.order\", \"dfs\").v(\"1\").repeat(__.out()).times(2)."
.to_string(),
"repeat.max_loops" => "Set 'repeat.max_loops' to a positive integer number of iterations \
when the traversal source is created, for example \
g.with(\"repeat.max_loops\", 100).v(\"1\").repeat(__.out()).until(__.has(\"name\", \"<value>\")); \
the default is 10000 and times(n) is never limited."
.to_string(),
"optimizer.disabled" | "optimizer.enabled" => format!(
"Set 'optimizer.disabled'/'optimizer.enabled' to an array of optimizer rule names \
when the traversal source is created, for example \
g.with(\"optimizer.disabled\", [\"filter_reorder\"]).v().has_label(\"person\").count(); \
every name must be one of the rules a profile lists as \"Optimizer rules applied\" \
(optimizer_rules_applied of g.v().has_label(\"person\").count().profile()) \
({rules} — see the book's Query Optimizer chapter for each rule's default on/off \
state) or the reserved name \"all\", which expands to every one of them — e.g. \
g.with(\"optimizer.disabled\", [\"all\"]).v().has_label(\"person\").count() \
to measure a query's genuinely unoptimized plan; and the same name cannot appear in \
both lists at once.",
rules = crate::traversal::optimizer::KNOWN_RULES
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ")
),
"max_rows" | "max_items" => format!(
"Set '{key}' to a positive integer for this call, for example \
g.v().to_table(#{{{key}: 20}}). To show everything, remove both display limits \
with no_limit: true instead, for example g.v().to_table(#{{no_limit: true}}); \
0 is not a way to say \"unlimited\"."
),
"no_limit" => "no_limit: true removes both display limits, so it cannot be combined \
with max_rows or max_items in the same options map. Use either \
g.v().to_table(#{no_limit: true}) to show everything, or \
g.v().to_table(#{max_rows: 20, max_items: 10}) to set other limits; \
no_limit: false keeps the session default."
.to_string(),
"options" => "Pass the display options as a map, for example \
g.v().to_table(#{max_rows: 20, columns: [\"name\", \"age\"]}), or a list of columns, \
for example g.v().to_table([\"name\", \"age\"])."
.to_string(),
"render.max_rows" | "render.max_items" => format!(
"Set '{key}' to a positive integer, for example g.with(\"{key}\", 20).v(). It is the \
session default of a display limit; to show everything for one call, use \
g.v().to_table(#{{no_limit: true}})."
),
_ => format!(
"Set '{key}' to a non-negative integer number of milliseconds when the traversal \
source is created, for example g.with(\"evaluationTimeout\", 5000).v().count(); \
0 disables the timeout."
),
}
}
const GLOB_PATH_MAX_DEPTH: &str =
crate::traversal::step::flatmap_steps::step_glob_path::MAX_DEPTH_OPTION;
fn split_glob_path_step(step: &str) -> Option<(&str, &str)> {
const PREFIX: &str = "glob_path(\"";
let literal = step.strip_prefix(PREFIX)?;
let mut escaped = false;
for (i, c) in literal.char_indices() {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => return Some(step.split_at(PREFIX.len() + i + 1)),
_ => {}
}
}
None
}
fn runnable_glob_path_options(query: &str) -> String {
query.replace("\", {", "\", #{")
}
fn glob_path_step_with_valid_max_depth(step: &str) -> Option<String> {
const OPTION: &str = ", {max_depth: ";
let (head, rest) = split_glob_path_step(step)?;
let value = rest.strip_prefix(OPTION)?;
let end = value
.char_indices()
.find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && c == '-')))
.map_or(value.len(), |(i, _)| i);
let depth: i128 = value[..end].parse().ok()?;
let valid = if depth < 1 {
1
} else if depth > i128::from(u32::MAX) {
i128::from(u32::MAX)
} else {
return None;
};
Some(format!("{head}{OPTION}{valid}{}", &value[end..]))
}
fn glob_path_max_depth_help(steps: Option<Vec<String>>) -> String {
let corrected = steps.and_then(|steps| {
let mut fixed = false;
let rewritten: Vec<String> = steps
.iter()
.map(
|step| match (fixed, glob_path_step_with_valid_max_depth(step)) {
(false, Some(valid)) => {
fixed = true;
valid
}
_ => step.clone(),
},
)
.collect();
fixed.then(|| runnable_glob_path_options(&rewritten.join(".")))
});
let example = corrected.unwrap_or_else(|| {
"v(\"root\").glob_path(\"**/*.rs\", #{max_depth: 3}).by(\"name\")".to_string()
});
format!(
"max_depth is the deepest level below the start vertex that glob_path() visits: the start \
vertex is level 0 and its children are level 1, so max_depth is an integer from 1 to \
4294967295. Leave it out to walk without a bound. For example g.{example}."
)
}
fn glob_path_query_with_max_depth(steps: &[String]) -> Option<String> {
let position = steps.iter().position(|step| {
split_glob_path_step(step)
.is_some_and(|(head, rest)| head.contains("**") && !rest.starts_with(", {max_depth: "))
})?;
let mut rewritten = steps.to_vec();
let (head, rest) = split_glob_path_step(&steps[position])?;
rewritten[position] = match rest.strip_prefix(", {") {
Some(options) => format!("{head}, {{max_depth: 3, {options}"),
None => format!("{head}, {{max_depth: 3}}{rest}"),
};
Some(runnable_glob_path_options(&rewritten.join(".")))
}
fn locked_option_help(key: &str) -> String {
format!(
"'{key}' is fixed by the execution policy of the environment running this query and \
cannot be set or overridden with g.with(\"{key}\", ..), regardless of the value \
supplied. This is a host-level safety ceiling (see `ExecutionPolicy::with_locked` in \
the embedding application, and the \"Execution Options Reference\" book chapter for \
which keys this instance may lock), not a malformed value — removing or changing the \
g.with(\"{key}\", ..) call in the query has no effect either way, since the policy's own \
default (or, absent one, the engine's default) applies regardless of what the query \
asks for."
)
}
fn permission_denied_help(step: &str, request: &crate::graph::AccessRequest<'_>) -> String {
use crate::graph::{Action, Resource};
let instead = match (request.action, request.resource) {
(Action::Create | Action::Update | Action::Delete, Resource::Data { .. }) => format!(
" A query that may not change the host graph can still build its own: a graph the \
script builds itself is private and always writable, for example \
GraphSource::empty().add_v(\"x\").next() or a subgraph() result; remove \
{step}() from the query to only read."
),
(Action::Read, Resource::Data { .. }) => {
" Without reading data a script can only compute with its own values; it cannot \
traverse the host graph."
.to_string()
}
(_, Resource::Schema) => " The schema is protected separately from the data: \
get_schema(), infer_schema(), validate_schema(..) and diff_schema(..) read it (Read), set_schema(..) and patch_schema(..) change it (Update)."
.to_string(),
(_, Resource::Definition { .. }) => {
" The catalog of definitions (saved queries) is protected separately from the \
data: listing and reading a definition asks Read, defining one Create (Update \
to replace, move or describe it), removing one Delete, calling a saved query \
Execute."
.to_string()
}
(_, Resource::File { .. }) => {
" File access (export_graphml, import_graphml, GraphSource::file, \
Rhai's import) is decided per file. When the host set a file root \
(AccessPolicy::with_file_root), use a relative path inside it, without '..'."
.to_string()
}
(_, Resource::Option { .. }) => {
" The administrative g.with() options (the resource ceilings evaluationTimeout, \
repeat.max_loops, bulk.max_expansion, traversal.max_traversers and the engine \
switches optimizer.disabled, optimizer.enabled, path.analysis, bulk.merge) are \
the host's to set: remove the g.with(..) call; the host's settings apply."
.to_string()
}
_ => String::new(),
};
format!(
"'{step}' needs {request}, which the host running this script does not allow. \
Everything is allowed by default; the host decides with an Authorizer, for example \
the ready-made AccessPolicy::read_only().allow(ActionKind::{action}, \
ResourceKind::{resource}) passed to script::engine_with_limits(..) and \
script::graph_scope_with_options(..) (or eval_value_with_limits), or \
GraphTraversalSource::with_authorizer(..) in Rust.{instead}",
action = request.action.kind(),
resource = request.resource.kind(),
)
}
fn repeat_limit_help(
max_loops: u32,
loop_name: Option<&str>,
body: &str,
steps: Option<Vec<String>>,
) -> String {
let repeat = match loop_name {
Some(name) => format!("repeat(\"{name}\", {body})"),
None => format!("repeat({body})"),
};
let simple = match loop_name {
Some(name) => format!("repeat(\"{name}\", {body}.simple_path())"),
None => format!("repeat({body}.simple_path())"),
};
let steps = steps.filter(|steps| !steps.is_empty());
let start = steps
.as_deref()
.and_then(glob_path_start)
.unwrap_or_else(|| "v()".to_string());
let query = steps
.map(|steps| steps.join("."))
.unwrap_or_else(|| format!("v().{repeat}"));
let raised = u64::from(max_loops)
.saturating_mul(2)
.min(u64::from(u32::MAX));
let repeat_barrier = match loop_name {
Some(name) => format!("repeat(\"{name}\", {body}.barrier())"),
None => format!("repeat({body}.barrier())"),
};
format!(
"The loop ran {max_loops} iterations without stopping: it walks a cycle, or nothing \
ends it.\n\
Bound the number of iterations with times(), for example {repeat}.times(3).\n\
Stop at a target with until(), for example {repeat}.until(__.has(\"name\", \"<value>\")).\n\
On a cyclic graph, drop the paths that revisit an element with simple_path(), for \
example {simple}.\n\
Equal traversers are merged automatically inside repeat(), but path() and simple_path() \
record full paths and prevent that merge, so exponential path counts stay expensive \
with them. A sack merge operator or with_bulk(false) turns automatic merging off: add \
barrier() inside the loop body, for example {repeat_barrier}.\n\
Or raise the limit: g.with(\"repeat.max_loops\", {raised}).{query}. It is an administrative option: a script whose host restricts it needs Update on that Option to set it.\n\
On a tree-shaped graph (directories, org charts, categories), walk the levels below a \
vertex with glob_path() instead: it follows outgoing edges along a pattern, prunes \
branches that cannot match and stops on cycles without a loop limit, for example \
g.{start}.glob_path(\"**/*.rs\").by(\"name\"), or g.{start}.glob_path(\"**\").by(\"name\") \
for every level below; bound how deep it walks with max_depth, for example \
g.{start}.glob_path(\"**\", #{{max_depth: 3}}).by(\"name\")."
)
}
fn glob_path_start(steps: &[String]) -> Option<String> {
let loop_start = steps.iter().position(|step| {
["repeat(", "emit(", "until(", "times("]
.iter()
.any(|prefix| step.starts_with(prefix))
})?;
(loop_start > 0).then(|| steps[..loop_start].join("."))
}
fn unknown_loop_name_help(name: &str, active: &[String]) -> String {
match active.first() {
Some(innermost) => format!(
"The enclosing named loops are: {}. Read one of them, for example \
loops(\"{innermost}\"), or name the loop that loops(\"{name}\") should read: \
repeat(\"{name}\", __.out()).",
active.join(", ")
),
None => format!(
"No enclosing repeat() is named '{name}'. Name the loop, for example \
repeat(\"{name}\", __.out()).until(__.loops(\"{name}\").is(2)), or read the \
innermost loop with loops()."
),
}
}
fn repeat_without_body_help(modulator: &str, prefix: Option<String>) -> String {
let prefix = prefix.unwrap_or_else(|| "g.v()".to_string());
let join = |rest: &str| {
if prefix.ends_with('.') {
format!("{prefix}{rest}")
} else {
format!("{prefix}.{rest}")
}
};
format!(
"Write the loop body with repeat(). Before repeat(), {modulator} is checked before each \
iteration, for example {before}; after it, {modulator} is checked after each \
iteration, for example {after}.",
before = join(&format!("{modulator}.repeat(__.out())")),
after = join(&format!("repeat(__.out()).{modulator}")),
)
}
fn option_without_branch_help(modulator: &str) -> String {
format!(
"{modulator} must follow choose(traversal) or branch(traversal), for example \
g.v().choose(__.label()).option(\"person\", __.out(\"knows\")).option(\"software\", \
__.out(\"created\")), or g.v().branch(__.label()).option(\"person\", __.out(\"knows\")); \
choose(predicate, onTrue, onFalse) (the 3-argument if/then/else form) already has its \
two branches fixed and does not accept further .option() calls. \
option(Merge.onCreate|onMatch, ..) must follow merge_v()/merge_e(), for example \
g.merge_v(#{{\"T.label\": \"person\", \"name\": \"marko\"}}).option(Merge.onCreate, \
#{{\"age\": 29}}); option(Merge.outV|inV, ..) must follow merge_e(), for example \
g.merge_e(#{{\"Direction.OUT\": Merge.outV, \"Direction.IN\": \"2\"}}).option(Merge.outV, \
#{{\"T.id\": \"1\"}})."
)
}
fn invalid_merge_map_help(step: &str, reason: &str) -> String {
let specific = if reason.contains("hidden key: ~id") {
format!(
"Write the id with the reserved key \"T.id\" instead of \"~id\", for example \
g.{step}(#{{\"T.id\": \"1\"}}). "
)
} else if reason.contains("hidden key: ~label")
|| reason.contains("Label can not be a hidden key")
{
format!(
"Write the label with the reserved key \"T.label\" and a plain label value, for \
example g.{step}(#{{\"T.label\": \"person\"}}). "
)
} else if reason.contains("option(onMatch)") {
format!(
"option(Merge.onMatch, ..) only updates properties of the matched element, for \
example g.{step}(#{{\"name\": \"marko\"}}).option(Merge.onMatch, #{{\"age\": 30}}); \
put T.id/T.label into the merge() map or option(Merge.onCreate, ..) instead. "
)
} else if reason.contains("vertex lookup map") {
"The option(Merge.outV|inV, map) map is a vertex search (\"T.id\", \"T.label\" and \
properties), for example option(Merge.outV, #{\"T.id\": \"1\"}); the edge's \
Direction keys belong to the merge_e() map. "
.to_string()
} else if reason.contains("Direction.BOTH") {
"An edge has two named ends: write \"Direction.OUT\" and \"Direction.IN\" instead of \
\"Direction.BOTH\", for example g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": \
\"1\", \"Direction.IN\": \"2\"}). "
.to_string()
} else if reason.contains("cannot take the placeholder") {
"Each end takes its own placeholder: \"Direction.OUT\": Merge.outV and \"Direction.IN\": \
Merge.inV, for example g.v(\"1\").as(\"a\").v(\"2\").as(\"b\").merge_e(#{\"Direction.OUT\": \
Merge.outV, \"Direction.IN\": Merge.inV}).option(Merge.outV, \
__.select(\"a\")).option(Merge.inV, __.select(\"b\")). "
.to_string()
} else if reason.contains("resolves an edge vertex of merge_e()") {
"option(Merge.outV|inV, ..) belongs to merge_e(), for example \
g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": Merge.outV, \"Direction.IN\": \
\"2\"}).option(Merge.outV, #{\"T.id\": \"1\"}); merge_v() takes only \
option(Merge.onCreate, ..) and option(Merge.onMatch, ..). "
.to_string()
} else if reason.contains("Direction keys") {
"Vertices have no endpoints: Direction.OUT/Direction.IN keys belong to merge_e(), for \
example g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": \"1\", \"Direction.IN\": \
\"2\"}). "
.to_string()
} else if reason.contains("produced no map") || reason.contains("expects a map") {
format!(
"Pass a map, null or a traversal that yields a map, for example \
g.{step}(#{{\"T.label\": \"person\", \"name\": \"marko\"}}) or \
g.inject(#{{\"T.label\": \"person\", \"name\": \"marko\"}}).{step}(). "
)
} else {
String::new()
};
let general = if step == "merge_e" {
"A merge_e() map holds property keys plus the reserved keys \"T.id\", \"T.label\", \
\"Direction.OUT\" and \"Direction.IN\", for example g.merge_e(#{\"T.label\": \"knows\", \
\"Direction.OUT\": \"1\", \"Direction.IN\": \"2\"}).option(Merge.onCreate, \
#{\"weight\": 0.5})"
} else {
"A merge_v() map holds property keys plus the reserved keys \"T.id\" and \"T.label\", for \
example g.merge_v(#{\"T.label\": \"person\", \"name\": \"marko\"}).option(Merge.onCreate, \
#{\"age\": 29})"
};
format!(
"{specific}{general}; null or #{{}} matches every element. Keys starting with \"~\" and \
other \"T.\"/\"Direction.\" keys are not allowed."
)
}
fn merge_override_help(step: &str, key: &str) -> String {
let example = if step == "merge_e" {
"g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": \"1\", \"Direction.IN\": \
\"2\"}).option(Merge.onCreate, #{\"weight\": 0.5})"
} else {
"g.merge_v(#{\"T.label\": \"person\", \"name\": \"marko\"}).option(Merge.onCreate, \
#{\"age\": 29})"
};
format!(
"option(Merge.onCreate, ..) can only add keys to the merge() map: a key present in both \
must have the same value there. Remove \"{key}\" from the onCreate map (the created \
element inherits it from merge()), for example {example}, or change it in the merge() \
map itself."
)
}
fn math_underline(expr: &str, column: usize, width: usize) -> String {
const OPEN: usize = 6; let (shown, column) = math_window(expr, column);
format!(
"math(\"{shown}\")\n{}{}",
" ".repeat(OPEN + column.saturating_sub(1)),
"^".repeat(width.clamp(1, crate::value::literal::MAX_LITERAL_CHARS))
)
}
fn math_window(expr: &str, column: usize) -> (String, usize) {
let max = crate::value::literal::MAX_LITERAL_CHARS;
let chars: Vec<char> = expr.chars().collect();
if chars.len() <= max {
return (expr.to_string(), column);
}
let start = column
.saturating_sub(1)
.saturating_sub(max / 2)
.min(chars.len());
let end = (start + max).min(chars.len());
let mut shown = String::new();
let mut shift = start;
if start > 0 {
shown.push('…');
shift -= 1;
}
shown.extend(&chars[start..end]);
if end < chars.len() {
shown.push('…');
}
(shown, column.saturating_sub(shift))
}
fn math_replace_identifier(expr: &str, name: &str, with: &str) -> String {
let mut out = String::with_capacity(expr.len() + with.len());
let mut rest = expr;
let mut prev: Option<char> = None;
let mut quote: Option<char> = None;
while let Some(c) = rest.chars().next() {
if let Some(q) = quote {
out.push(c);
rest = &rest[c.len_utf8()..];
if c == q {
quote = None;
} else if c == '\\'
&& let Some(escaped) = rest.chars().next()
{
out.push(escaped);
rest = &rest[escaped.len_utf8()..];
}
prev = Some(c);
continue;
}
if c == '"' || c == '\'' {
quote = Some(c);
out.push(c);
rest = &rest[1..];
prev = Some(c);
continue;
}
if c.is_ascii_alphabetic() || c == '_' {
let end = rest
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
.unwrap_or(rest.len());
let ident = &rest[..end];
if ident == name && prev != Some('.') {
out.push_str(with);
} else {
out.push_str(ident);
}
rest = &rest[end..];
prev = ident.chars().last();
continue;
}
out.push(c);
rest = &rest[c.len_utf8()..];
prev = Some(c);
}
out
}
fn math_rewrite_query(
steps: Option<&[String]>,
expr: &str,
before: Option<&str>,
new_expr: &str,
) -> Option<String> {
let step = format!("math(\"{expr}\")");
let mut rewritten: Vec<String> = Vec::new();
let mut found = false;
for s in steps? {
if crate::traversal::source::is_display_only(s) {
continue;
}
if !found && *s == step {
found = true;
if let Some(before) = before {
rewritten.push(before.to_string());
}
rewritten.push(format!("math(\"{new_expr}\")"));
} else {
rewritten.push(s.clone());
}
}
found.then(|| format!("g.{}", rewritten.join(".")))
}
fn math_close_functions(
name: &str,
) -> Vec<&'static crate::value::math_expr::registry::FunctionDef> {
fn distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut diagonal = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let above = row[j + 1];
row[j + 1] = (diagonal + usize::from(ca != *cb))
.min(row[j] + 1)
.min(above + 1);
diagonal = above;
}
}
row[b.len()]
}
let lower = name.to_ascii_lowercase();
let mut close: Vec<(
usize,
&'static crate::value::math_expr::registry::FunctionDef,
)> = crate::value::math_expr::registry::FUNCTIONS
.iter()
.map(|f| (distance(&lower, f.name), f))
.filter(|(d, f)| {
*d <= 2
|| (lower.len() >= 2 && (f.name.starts_with(&lower) || lower.starts_with(f.name)))
})
.collect();
close.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.name.cmp(b.1.name)));
close.into_iter().map(|(_, f)| f).collect()
}
fn math_language() -> String {
let functions: Vec<&str> = crate::value::math_expr::registry::FUNCTIONS
.iter()
.map(|f| f.name)
.collect();
format!(
"math() evaluates an equation like TinkerPop does: numbers, the operators + - * / % and \
^ (power), parentheses, the functions {} (log is the natural logarithm), the constants \
pi, e, π and φ, the current value _ and variables that name a key of the current map, a \
side effect or a step label. The result is always a double.",
functions.join(", ")
)
}
fn math_expression_help(expr: &str, kind: &MathErrorKind, steps: Option<Vec<String>>) -> String {
let steps = steps.as_deref();
let language = math_language();
match kind {
MathErrorKind::Syntax => format!(
"{language} Fix the equation at the marked position, for example \
g.v().values(\"age\").math(\"(_ + 1) * 2\")."
),
MathErrorKind::UnknownFunction { name } => {
let close = math_close_functions(name);
match close.first() {
Some(best) => {
let fixed = math_replace_identifier(expr, name, best.name);
let example = math_rewrite_query(steps, expr, None, &fixed)
.unwrap_or_else(|| format!("g.v().values(\"age\").math(\"{fixed}\")"));
format!(
"'{name}' is not a math() function; did you mean {}? For example \
{example}. {language}",
close
.iter()
.map(|f| format!("{} ({})", f.name, f.doc))
.collect::<Vec<_>>()
.join(", ")
)
}
None => format!("'{name}' is not a math() function. {language}"),
}
}
MathErrorKind::UnknownVariable { name } if name == "value" => {
let fixed = math_replace_identifier(expr, "value", "_");
let example = math_rewrite_query(steps, expr, None, &fixed)
.unwrap_or_else(|| format!("g.v().values(\"age\").math(\"{fixed}\")"));
format!(
"The current value is named _ in math(), as in TinkerPop; value is not a name for \
it. For example {example}. {language}"
)
}
MathErrorKind::UnknownVariable { name } => {
let label = format!("as(\"{name}\")");
let example =
math_rewrite_query(steps, expr, Some(&label), expr).unwrap_or_else(|| {
format!("g.v().values(\"age\").as(\"{name}\").math(\"{expr}\")")
});
let nested = math_replace_identifier(expr, name, &format!("_.{name}"));
format!(
"math() reads a variable other than _ from, in this order, a key of the current \
map (project(\"{name}\", ...), value_map(), element_map()), a side effect \
(g.with_side_effect(\"{name}\", 10), aggregate(\"{name}\")) or a step label \
(as(\"{name}\")), and '{name}' is none of these for this traverser. Label the \
value it should read, for example {example}, or declare it as a side effect, \
for example g.with_side_effect(\"{name}\", 10).v().values(\"age\").math(\"{expr}\"). \
To read a key of the current value itself, use the nested form _.{name} (a \
Graphersal extension), for example g.v().element_map().math(\"{nested}\")."
)
}
MathErrorKind::BadPath { path } => format!(
"Nested access such as {path} is a Graphersal extension of math(): after a variable, \
.key or [\"key\"] reads a key of a map (value_map(), element_map(), project(), a \
map-valued property) or a property of a vertex or edge, and [n] an element of a \
list (a negative n counts from the end). The path leads to no value for this \
traverser: check the key or index against the value, for example \
g.v().element_map().limit(1), and keep only traversers that have it before math(), \
for example g.v().has(\"age\").element_map().math(\"_.age * 2\")."
),
MathErrorKind::NotANumber { path, actual } => {
let example = if path == "_" {
math_rewrite_query(steps, expr, Some("as_number(GType.DOUBLE)"), expr)
} else {
None
}
.unwrap_or_else(|| {
format!(
"g.v().values(\"age\").as_string().as_number(GType.DOUBLE).math(\"{expr}\")"
)
});
format!(
"math() computes with numbers only, and {path} is a {actual}. Convert the value \
with as_number(GType.DOUBLE) first, for example {example}, or inside the by() \
that reads it, for example by(__.values(\"age\").as_number(GType.DOUBLE))."
)
}
MathErrorKind::Null { path } => {
let example = if path == "_" { expr } else { "_ + 1" };
format!(
"{path} is null, and math() computes with numbers only. Keep only traversers that \
have the value before math(), for example \
g.v().has(\"age\").math(\"{example}\").by(\"age\"), or replace a missing value \
with a default inside the by(), for example \
by(__.coalesce(__.values(\"age\"), __.constant(0)))."
)
}
MathErrorKind::DivisionByZero => format!(
"math() follows TinkerPop: a zero divisor of / or % (or cot(x) where \
tan(x) is 0) is an error, not infinity. Keep only traversers with a non-zero divisor \
before math(), for example g.v().project(\"a\", \"b\").by(\"age\").by(__.out().count())\
.where(__.select(\"b\").is(P.neq(0))).math(\"a / b\"). The failing equation was \
math(\"{expr}\")."
),
MathErrorKind::Internal => format!(
"This is an internal error of the math() engine, not of the equation math(\"{expr}\"); \
please report it together with the equation."
),
}
}
impl TraverserError {
pub(crate) fn math_expression(
expr: &str,
offset: usize,
width: usize,
kind: MathErrorKind,
message: String,
) -> TraverserError {
let column = expr.get(..offset).map_or(offset, |s| s.chars().count()) + 1;
TraverserError::MathExpression {
expr: expr.to_string(),
offset,
column,
width: width.max(1),
kind,
message,
}
}
}
fn merge_vertex_unresolved_help(direction: crate::value::Direction, reason: &str) -> String {
let (key, token) = match direction {
crate::value::Direction::In => ("Direction.IN", "inV"),
crate::value::Direction::Out | crate::value::Direction::Both => ("Direction.OUT", "outV"),
};
let specific = if reason.contains("could not be resolved") && !reason.contains("option(") {
format!(
"Give \"{key}\" the id of an existing vertex (check it with g.v(id)), or create the \
vertex first, for example g.merge_v(#{{\"T.id\": \"1\"}}). "
)
} else if reason.contains("matched") {
format!(
"The option(Merge.{token}, map) map is a vertex search that must find exactly one \
vertex: name it by id, for example option(Merge.{token}, #{{\"T.id\": \"1\"}}), \
or add properties until only one vertex matches. "
)
} else if reason.contains("option(") {
format!(
"\"{key}\": Merge.{token} is resolved from option(Merge.{token}, ..), which takes a \
vertex search map such as #{{\"T.id\": \"1\"}} or a traversal yielding a vertex, \
for example g.v(\"1\").as(\"a\").v(\"2\").as(\"b\").merge_e(#{{\"T.label\": \
\"knows\", \"Direction.OUT\": Merge.outV, \"Direction.IN\": Merge.inV}})\
.option(Merge.outV, __.select(\"a\")).option(Merge.inV, __.select(\"b\")). "
)
} else {
String::new()
};
format!(
"{specific}An edge merge_e() creates needs both vertices: give \"Direction.OUT\" and \
\"Direction.IN\" an existing vertex id, for example g.merge_e(#{{\"T.label\": \"knows\", \
\"Direction.OUT\": \"1\", \"Direction.IN\": \"2\"}}), in the merge() map or in \
option(Merge.onCreate, ..), or use the placeholders Merge.outV/Merge.inV with \
option(Merge.outV, __.select(\"a\")) / option(Merge.inV, __.select(\"b\"))."
)
}
fn tree_key_clash_help(reason: &str) -> String {
format!(
"{reason}.\n\
tree(\"a\") adds every path to the tree stored under \"a\" and passes the traversers on, so \
the key must be unused or already hold a tree (an earlier tree(\"a\")). aggregate()/store() \
keep a list, with_side_effect(\"a\", Set.of()) a set and with_side_effect(\"a\", 0, Operator.sum) \
a reduced value under their key.\n\
Use another key for the tree, for example g.v().aggregate(\"a\").out().tree(\"t\").cap(\"t\"), \
or read the existing bucket with cap(\"a\")."
)
}
fn group_key_clash_help(reason: &str) -> String {
format!(
"{reason}.\n\
group(\"a\") and group_count(\"a\") add every traverser to the map stored under \"a\" and \
pass the traversers on, so the key must be unused or filled only by group steps that \
reduce a key the same way (two group_count(\"a\"), or group(\"a\") steps with the same kind \
of value by()). aggregate()/store() keep a list, tree(\"a\") a tree and \
with_side_effect(\"a\", ...) a seed under their key.\n\
Use another key for the map, for example \
g.v().aggregate(\"a\").group_count(\"m\").by(T.label).cap(\"m\"), or read both with \
cap(\"a\", \"m\")."
)
}
fn to_graph_help(reason: &str) -> String {
format!(
"{reason}.\n\
to_graph() builds a graph from edges: every edge is copied with both endpoint vertices \
(ids, labels and properties included). Select edges first, for example \
g.e(0).to_graph(), g.v(1).both_e().to_graph() or g.v(1).out_e().has_label(\"knows\").to_graph(). \
subgraph(\"sg\").cap(\"sg\") is the long form of the same result."
)
}
fn subgraph_help(reason: &str) -> String {
format!(
"{reason}.\n\
subgraph(\"sg\") collects edges under the key \"sg\" and cap(\"sg\") returns them as a \
graph, so the stream must be made of edges (select them with out_e()/in_e()/both_e(), for \
example g.v(1).both_e().subgraph(\"sg\").cap(\"sg\")) and the key must be unused. To write into your own graph, declare it with \
with_side_effect(\"sg\", target) where target is another graph (for example \
GraphSource::empty() or an earlier to_graph() result), never the graph you traverse: \
let target = g.e(0).to_graph(); g.with_side_effect(\"sg\", target).e().subgraph(\"sg\").to_list()."
)
}
fn add_e_endpoint_help() -> String {
"After a vertex, add_e(label) creates its edge from that vertex: to(..) names the in-vertex \
(g.v(\"1\").add_e(\"knows\").to(\"2\")), from(..) the out-vertex \
(g.v(\"2\").add_e(\"knows\").from(\"1\")), and neither makes a self-loop \
(g.v(\"1\").add_e(\"self\")). As a start step add_e() has no incoming vertex, so it needs \
both: g.add_e(\"knows\").from(\"1\").to(\"2\"). from()/to() take a vertex id or a step label \
set with as(), for example g.v(\"1\").as(\"a\").v(\"2\").add_e(\"knows\").from(\"a\"). To \
create the edge only when it does not exist yet, use mergeE, for example \
g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": \"1\", \"Direction.IN\": \"2\"})."
.to_string()
}
fn property_map_help() -> String {
"property(map) writes one property per map entry, for example \
g.v(\"1\").property(#{\"name\": \"marko\", \"age\": 29}); null or #{} writes nothing. Keys \
are plain property names: keys starting with \"T.\", \"Direction.\" or \"~\" are reserved. \
To set the id or label of a new vertex, use property(T.id, v) / property(T.label, v) right \
after add_v(), for example g.add_v(\"person\").property(T.id, \"p1\"); a property literally \
named like a reserved key can still be written one at a time, for example \
g.v(\"1\").property(\"T.label\", \"x\")."
.to_string()
}
fn unsupported_cardinality_help(step: &str, on_edge: bool) -> String {
if on_edge && step == "merge_e" {
"Edge properties have no cardinality: drop the Cardinality argument of option() and any \
Cardinality.single(v) wrapper in the merge_e() maps, for example \
g.merge_e(#{\"T.label\": \"knows\", \"Direction.OUT\": \"1\", \"Direction.IN\": \
\"2\"}).option(Merge.onMatch, #{\"weight\": 0.5})."
.to_string()
} else if on_edge {
"Edge properties have no cardinality: drop the Cardinality argument (and any \
Cardinality.single(v) wrapper in a map), for example g.e(\"0\").property(\"weight\", 0.5) \
or g.e(\"0\").property(#{\"weight\": 0.5})."
.to_string()
} else {
"Graphersal stores one value per property key, so only Cardinality.single can be \
written: it replaces the current value, for example \
g.v(\"1\").property(Cardinality.single, \"age\", 30) (the same as \
g.v(\"1\").property(\"age\", 30)). To keep several values, store them as one array \
value: g.v(\"1\").property(\"tags\", [\"a\", \"b\"])."
.to_string()
}
}
fn token_property_outside_add_help(token: &str) -> String {
if token == "T.label" {
"property(T.label, v) sets the label of the vertex add_v() is creating; write it right \
after add_v() (other property() calls may come in between), for example \
g.add_v().property(T.label, \"person\"), or pass the label to add_v(\"person\") directly. \
An edge's label comes from add_e(label), for example g.v(\"1\").add_e(\"knows\").to(\"2\"). \
The label of an existing element cannot be changed with property(); to store data \
under the key \"label\", write a normal property: g.v(\"1\").property(\"label\", \"x\")."
.to_string()
} else {
"property(T.id, v) sets the id of the element add_v()/add_e() is creating; write it right \
after add_v()/add_e() (other property() calls may come in between), for example \
g.add_v(\"person\").property(T.id, \"p1\"). The id of an existing element cannot be \
changed; to store data on it, write a normal property, for example \
g.v(\"1\").property(\"code\", 5)."
.to_string()
}
}
impl TraverserError {
fn failing_level_prefix(&self) -> Option<String> {
let frames = self.step_trace();
let frame = frames.last()?;
let before: Vec<String> = match &frame.logical {
Some(logical) => {
let first = logical.positions.first().copied().unwrap_or(0);
logical.steps.iter().take(first).cloned().collect()
}
None => frame.steps.iter().take(frame.position).cloned().collect(),
};
let start = if frames.len() <= 1 { "g." } else { "__." };
Some(if before.is_empty() {
start.to_string()
} else {
format!("{start}{}", before.join("."))
})
}
fn outermost_steps(&self) -> Option<Vec<String>> {
let frame = self.step_trace().into_iter().next()?;
Some(match &frame.logical {
Some(logical) => logical.steps.clone(),
None => frame.steps.clone(),
})
}
pub fn query(&self) -> Option<String> {
match self {
TraverserError::StepFailed { frame, .. } => frame
.as_ref()
.and_then(|frame| frame.logical.as_ref())
.map(|logical| logical.steps.join(".")),
_ => None,
}
}
pub fn step_trace(&self) -> Vec<&StepFrame> {
let mut frames = Vec::new();
let mut current = self;
while let TraverserError::StepFailed { frame, source, .. } = current {
if let Some(frame) = frame {
frames.push(&**frame);
}
current = source;
}
frames
}
pub fn root_cause(&self) -> &TraverserError {
let mut current = self;
while let TraverserError::StepFailed { source, .. }
| TraverserError::Spelled { source, .. } = current
{
current = source;
}
current
}
fn root_cause_mut(&mut self) -> &mut TraverserError {
match self {
TraverserError::StepFailed { source, .. } | TraverserError::Spelled { source, .. } => {
source.root_cause_mut()
}
other => other,
}
}
pub fn step_location(&self) -> Option<StepLocation> {
let mut segments: Vec<String> = Vec::new();
let mut step_path = Vec::new();
let mut current = self;
while let TraverserError::StepFailed {
step_name,
step_index,
child_index,
source,
..
} = current
{
if let Some(index) = step_index {
if let Some(child) = child_index
&& !segments.is_empty()
{
segments.push(child.to_string());
}
segments.push(index.to_string());
step_path.push(StepPathEntry {
id: segments.join("."),
name: step_name.clone(),
});
}
current = source;
}
let last = step_path.last()?;
Some(StepLocation {
step_id: last.id.clone(),
step_path,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct StepLocation {
pub step_id: String,
pub step_path: Vec<StepPathEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct StepPathEntry {
pub id: String,
pub name: String,
}
impl TraverserError {
pub(crate) fn cast_exception<S: GraphStorage>(
expected: &[&'static str],
value: &TraverserValue<S>,
) -> TraverserError {
TraverserError::Value(ValueError::CastException {
expected: expected.join(" or "),
actual: value.type_name().to_string(),
value: None,
origin: None,
})
}
pub(crate) fn cast_exception_str(expected: &[&'static str], actual: String) -> TraverserError {
TraverserError::Value(ValueError::CastException {
expected: expected.join(" or "),
actual,
value: None,
origin: None,
})
}
pub(crate) fn cast_exception_at(
expected: &[&'static str],
actual: String,
value: Option<String>,
origin: Option<ValueOrigin>,
) -> TraverserError {
TraverserError::Value(ValueError::CastException {
expected: expected.join(" or "),
actual,
value,
origin: origin.map(Box::new),
})
}
pub(crate) fn cast_exception_target(
expected: impl Into<String>,
actual: String,
value: Option<String>,
origin: Option<ValueOrigin>,
) -> TraverserError {
TraverserError::Value(ValueError::CastException {
expected: expected.into(),
actual,
value,
origin: origin.map(Box::new),
})
}
pub(crate) fn label_step_element_kind<S: GraphStorage>(
step: &'static str,
value: &TraverserValue<S>,
) -> TraverserError {
TraverserError::Value(ValueError::LabelStepElementKind {
step,
expected: if step == "set_label" {
"an edge"
} else {
"a vertex"
},
actual: value.kind_name(),
})
}
pub(crate) fn local_element_type(
step: &'static str,
accepts: &'static str,
index: usize,
actual: String,
) -> TraverserError {
TraverserError::Value(ValueError::LocalElementType {
step,
accepts,
index,
actual,
})
}
pub(crate) fn unsupported_cast_target(
target: impl Into<String>,
reason: &'static str,
) -> TraverserError {
TraverserError::Value(ValueError::UnsupportedCastTarget {
target: target.into(),
reason,
})
}
#[allow(dead_code)]
pub(crate) fn property_not_found(key: impl Into<String>) -> TraverserError {
TraverserError::Value(ValueError::PropertyNotFound {
key: key.into(),
element_type: "element",
})
}
pub(crate) fn property_not_found_on_element(
key: impl Into<String>,
element_type: &'static str,
) -> TraverserError {
TraverserError::Value(ValueError::PropertyNotFound {
key: key.into(),
element_type,
})
}
#[allow(dead_code)]
pub(crate) fn wrong_query(reason: impl Into<String>) -> TraverserError {
TraverserError::QueryExecution {
query: "<query>".to_string(),
reason: reason.into(),
}
}
pub(crate) fn not_implemented(feature: &'static str) -> TraverserError {
TraverserError::NotImplemented { feature }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_label_help_names_the_pop_forms() {
let help = ValueError::PropertyNotFound {
key: "x".to_string(),
element_type: "path",
}
.help()
.expect("help");
assert!(help.contains("select(Pop.all, \"x\")"), "{help}");
assert!(
help.contains("Pop.first") && help.contains("Pop.mixed"),
"{help}"
);
}
#[test]
fn operator_failed_message_and_help() {
let incompatible = ValueError::OperatorFailed {
operator: "sum",
kind: OperatorFailure::IncompatibleOperands { expected: "number" },
left: "3 (int64)".into(),
right: "\"a\" (string)".into(),
};
assert_eq!(
incompatible.to_string(),
"Operator 'sum' cannot combine 3 (int64) with \"a\" (string): expected number"
);
let help = incompatible.help().expect("help");
for needle in [
"as_number()",
"as_bool()",
"min and max",
"sumLong takes int64",
"addAll",
] {
assert!(help.contains(needle), "{needle} missing from: {help}");
}
let overflow = ValueError::OperatorFailed {
operator: "sumLong",
kind: OperatorFailure::Overflow,
left: "9223372036854775807 (int64)".into(),
right: "1 (int64)".into(),
};
assert!(overflow.to_string().contains("overflowed"));
assert!(
overflow
.help()
.expect("help")
.contains("does not widen or wrap")
);
let zero = ValueError::OperatorFailed {
operator: "div",
kind: OperatorFailure::DivisionByZero,
left: "1 (int64)".into(),
right: "0 (int64)".into(),
};
assert!(zero.to_string().contains("division by zero"));
}
#[test]
fn sack_arena_exhausted_help_names_the_update_step() {
let help = TraverserError::SackArenaExhausted { capacity: 1 }
.help()
.expect("help");
assert!(
help.contains(
"g.with_sack(0).v().limit(1000).out().sack(Operator.sum).by(__.constant(1)).sack()"
),
"{help}"
);
}
use std::error::Error;
#[test]
fn test_error_enums_stay_small() {
use std::mem::size_of;
const LIMIT: usize = 128;
let sizes = [
("ValueError", size_of::<ValueError>()),
("GraphError", size_of::<GraphError>()),
("IOError", size_of::<IOError>()),
("ScriptError", size_of::<ScriptError>()),
("SchemaError", size_of::<SchemaError>()),
("TraverserError", size_of::<TraverserError>()),
];
for (name, size) in sizes {
assert!(
size <= LIMIT,
"{name} is {size} bytes, above the {LIMIT}-byte bound"
);
}
}
#[test]
fn test_value_error_display_and_help() {
let err = ValueError::CastException {
expected: "string".to_string(),
actual: "number".to_string(),
value: None,
origin: None,
};
assert_eq!(
err.to_string(),
"Cast exception: expected type [string], got type 'number'"
);
assert!(err.help().is_some());
assert!(err.help().unwrap().contains("Check the value type"));
let err_prop = ValueError::PropertyNotFound {
key: "age".to_string(),
element_type: "vertex",
};
assert_eq!(
err_prop.to_string(),
"Property 'age' not found on element type 'vertex'"
);
assert!(err_prop.help().unwrap().contains(".has() filter"));
}
#[test]
fn test_graph_error_display_and_help() {
let err = GraphError::VertexNotFound {
id: "v1".to_string(),
};
assert_eq!(
err.to_string(),
"Vertex with ID 'v1' was not found in the graph"
);
assert!(
err.help()
.unwrap()
.contains("Verify that the vertex ID exists")
);
}
#[test]
fn test_io_error_source_chain_and_help() {
let io_source = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = IOError::FileRead {
path: "/tmp/test.graphml".to_string(),
source: io_source,
};
assert!(
err.to_string()
.contains("Failed to read file '/tmp/test.graphml'")
);
assert!(err.source().is_some());
assert!(
err.help()
.unwrap()
.contains("Check if the file path is correct")
);
}
#[test]
fn test_schema_error_display_and_help() {
let err = SchemaError::UnknownVertexLabel {
label: "User".to_string(),
allowed: vec!["Person".to_string(), "Software".to_string()],
};
assert_eq!(
err.to_string(),
"Unknown vertex label 'User' is not declared in the closed schema"
);
let help = err.help().unwrap();
assert!(help.contains("[Person, Software]"), "{help}");
assert!(help.contains("patch_schema"), "{help}");
let err_edge = SchemaError::UnknownEdgeLabel {
label: "manages".to_string(),
allowed: vec!["knows".to_string(), "created".to_string()],
};
assert_eq!(
err_edge.to_string(),
"Unknown edge label 'manages' is not declared in the closed schema"
);
assert!(err_edge.help().unwrap().contains("[knows, created]"));
let err_topo = SchemaError::InvalidEdgeTopology {
source_label: "Person".to_string(),
edge_label: "created".to_string(),
target_label: "Person".to_string(),
allowed: vec!["(:Person)-[:created]->(:Software)".to_string()],
};
assert_eq!(
err_topo.to_string(),
"Disallowed edge connection: '(:Person)-[:created]->(:Person)' is not defined in schema"
);
let help = err_topo.help().unwrap();
assert!(
help.contains("[(:Person)-[:created]->(:Software)]"),
"{help}"
);
assert!(help.contains("connections"), "{help}");
let err_prop = SchemaError::UndeclaredProperty {
element_type: "vertex",
label: "Person".to_string(),
property_name: "nickname".to_string(),
};
assert_eq!(
err_prop.to_string(),
"Undeclared property 'nickname' on vertex 'Person' is not declared and not admitted by the schema"
);
let help = err_prop.help().unwrap();
assert!(help.contains("\"additionalProperties\": true"), "{help}");
assert!(!help.contains("Partial"), "{help}");
let err_req = SchemaError::MissingRequiredProperty {
element_type: "vertex",
label: "Person".to_string(),
property_name: "name".to_string(),
};
assert_eq!(
err_req.to_string(),
"Missing required property 'name' on vertex 'Person'"
);
let req_help = err_req.help().unwrap();
assert!(
req_help.starts_with(
"Provide a value for 'name' or remove it from the label's \"required\""
)
);
assert!(
req_help.contains("\"default\" is an annotation only"),
"{req_help}"
);
assert!(req_help.contains("add_property_fold"), "{req_help}");
let err_mismatch = SchemaError::TypeMismatch {
element_type: "vertex",
label: "Person".to_string(),
property_name: "age".to_string(),
expected_type: "int64".to_string(),
actual_type: "string".to_string(),
};
assert_eq!(
err_mismatch.to_string(),
"Type mismatch on vertex 'Person.age': expected int64, got string"
);
assert_eq!(
err_mismatch.help(),
Some(
"Convert value to expected type 'int64' before inserting.\n\
In a query, convert the values with values(\"age\").as_number(GType.LONG).\n\
Find every offending value with values(\"age\").is(typeOf(GType.STRING))."
.to_string()
)
);
let err_null = SchemaError::TypeMismatch {
element_type: "vertex",
label: "Person".to_string(),
property_name: "age".to_string(),
expected_type: "int64".to_string(),
actual_type: "null".to_string(),
};
assert!(
err_null
.help()
.unwrap()
.starts_with("'age' does not admit null")
);
let err_constraint = SchemaError::ConstraintViolation {
label: "Person".to_string(),
property_name: "age".to_string(),
constraint_rule: "value >= 0".to_string(),
actual_value: "-5".to_string(),
};
assert_eq!(
err_constraint.to_string(),
"Constraint violation on 'Person.age': value >= 0 (actual: -5)"
);
assert_eq!(
err_constraint.help(),
Some("Ensure value complies with validation constraint: value >= 0.".to_string())
);
let err_def = SchemaError::InvalidSchema {
path: "/vertices/person/schema/properties/age".to_string(),
reason: "unknown keyword 'minimun'".to_string(),
hint: "The supported keywords are: ...".to_string(),
};
assert_eq!(
err_def.to_string(),
"Invalid schema at /vertices/person/schema/properties/age: unknown keyword 'minimun'"
);
let help = err_def.help().unwrap();
assert!(
help.starts_with("The supported keywords are: ..."),
"{help}"
);
assert!(help.contains("JSON Schema 2020-12"), "{help}");
}
#[test]
fn test_script_error_display_and_help() {
let err = ScriptError::UnsupportedStep {
step_name: "unknown_step".to_string(),
};
assert_eq!(
err.to_string(),
"Unsupported step 'unknown_step' in script context"
);
assert!(err.help().unwrap().contains("Verify step name"));
}
#[test]
fn test_traverser_error_propagation_and_cause_chain() {
let val_err = ValueError::PathNotDefined;
let trav_err: TraverserError = val_err.into();
assert_eq!(
trav_err.to_string(),
"Path is not defined or initialized for traverser"
);
assert_eq!(trav_err.help(), ValueError::PathNotDefined.help());
assert!(
trav_err
.help()
.unwrap_or_default()
.contains("g.v(\"1\").as(\"a\").out().select(\"a\")")
);
let step_failed = TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: "out".to_string(),
step_index: None,
child_index: None,
frame: None,
element_id: Some("v1".to_string()),
source: Box::new(trav_err),
};
assert!(
step_failed
.to_string()
.contains("Step 'out' execution failed")
);
assert!(step_failed.source().is_some());
assert_eq!(step_failed.help(), ValueError::PathNotDefined.help());
}
#[test]
fn test_step_frame_marks_every_originating_step() {
let frame = StepFrame::new(vec!["v(labels: \"person\", count_only)".to_string()], 0)
.with_logical(LogicalFrame {
steps: vec![
"v()".to_string(),
"has_label(\"person\")".to_string(),
"count()".to_string(),
],
positions: vec![0, 1, 2],
});
let rendered = frame.render_logical().expect("the plans differ");
let (plan, caret) = rendered.split_once('\n').expect("a caret line is rendered");
assert_eq!(plan, "v().has_label(\"person\").count()");
assert_eq!(
caret,
"^".repeat(plan.chars().count()),
"a fully folded level is underlined as one run"
);
let partial =
StepFrame::new(vec!["a".to_string(), "b".to_string()], 1).with_logical(LogicalFrame {
steps: vec!["a()".to_string(), "b()".to_string(), "c()".to_string()],
positions: vec![2],
});
assert_eq!(
partial.render_logical().expect("the plans differ"),
"a().b().c()\n ^^^"
);
}
#[test]
fn test_step_frame_hides_an_unchanged_plan() {
let frame = StepFrame::new(vec!["v()".to_string(), "max()".to_string()], 1).with_logical(
LogicalFrame {
steps: vec!["v()".to_string(), "max()".to_string()],
positions: vec![1],
},
);
assert_eq!(
frame.render_logical(),
None,
"an untouched level must not be printed twice"
);
}
#[test]
fn test_result_ext_trait() {
let res: Result<(), ValueError> = Err(ValueError::PathNotDefined);
let step_res = res.with_step("values");
assert!(step_res.is_err());
if let Err(boxed) = step_res {
if let TraverserError::StepFailed {
step_name,
element_id,
source,
..
} = *boxed
{
assert_eq!(step_name, "values");
assert_eq!(element_id, None);
assert!(source.to_string().contains("Path is not defined"));
} else {
panic!("Expected StepFailed error variant");
}
} else {
panic!("Expected StepFailed error variant");
}
let res_elem: Result<(), ValueError> = Err(ValueError::PathNotDefined);
let step_elem_res = res_elem.with_step_element("out", "v1");
assert!(step_elem_res.is_err());
if let Err(boxed) = step_elem_res {
if let TraverserError::StepFailed {
step_name,
element_id,
source,
..
} = *boxed
{
assert_eq!(step_name, "out");
assert_eq!(element_id, Some("v1".to_string()));
assert!(source.to_string().contains("Path is not defined"));
} else {
panic!("Expected StepFailed error variant");
}
} else {
panic!("Expected StepFailed error variant");
}
}
#[test]
fn traverser_result_err_side_stays_a_single_pointer() {
assert!(
std::mem::size_of::<TraverserError>() > 24,
"this test assumes TraverserError is large enough that boxing it is worthwhile; \
got {} bytes",
std::mem::size_of::<TraverserError>()
);
assert!(
std::mem::size_of::<TraverserResult<()>>() <= 24,
"TraverserResult<()> grew to {} bytes; Box<TraverserError> should keep it small \
regardless of TraverserError's own size",
std::mem::size_of::<TraverserResult<()>>()
);
}
mod help_wording {
use super::super::*;
use crate::prelude::*;
#[test]
fn an_invalid_type_operation_points_at_the_coercion_steps() {
let err = ValueError::InvalidTypeOperation {
op: "+",
type_a: "string",
type_b: "int64",
};
let report = format_diagnostic(&err);
assert!(
report.contains(
"Ensure operand types are compatible for operation, or convert one side first with .as_string(), .as_bool() or .as_number(GType.LONG)."
),
"the help must name the coercion steps in: {report}"
);
}
fn glob_report(pattern: &str) -> String {
match crate::value::glob_path::GlobPattern::compile(pattern) {
Err(err) => format_diagnostic(&TraverserError::from(err)),
Ok(_) => panic!("'{pattern}' should not compile"),
}
}
#[test]
fn invalid_glob_pattern_display_names_pattern_position_and_reason() {
let err = ValueError::InvalidGlobPattern {
pattern: "a**b".to_string(),
position: 1,
reason: crate::value::glob_path::reason::DOUBLE_STAR_IN_SEGMENT.to_string(),
};
assert_eq!(
err.to_string(),
"Invalid glob pattern 'a**b' at position 1: '**' must be a whole segment"
);
let help = err.help().unwrap_or_default();
assert!(
help.starts_with(
"'**' spans whole levels, so it must stand alone between slashes. Write \
glob_path(\"a*/**/*b\") instead, or plain glob_path(\"**\") to match every \
level below the start vertex. A literal '*' is written '\\*'."
),
"unexpected help: {help}"
);
assert!(help.contains("for example glob_path(\"**/src/*.{rs,toml}\")."));
}
#[test]
fn invalid_glob_pattern_help_rewrites_misplaced_double_stars() {
assert!(glob_report("a**").contains("Write glob_path(\"a*/**\") instead"));
assert!(glob_report("**b").contains("Write glob_path(\"**/*b\") instead"));
assert!(
glob_report("src/a**b/c").contains("Write glob_path(\"src/a*/**/*b/c\") instead")
);
}
#[test]
fn invalid_glob_pattern_help_splits_a_slash_in_alternatives() {
let report = glob_report("{src/a,lib/b}");
assert!(
report.contains("at position 4: alternatives '{..}' cannot contain '/'"),
"{report}"
);
assert!(
report.contains(
"Split the alternative into two queries, for example glob_path(\"src/a\") and \
glob_path(\"lib/b\") instead of glob_path(\"{src/a,lib/b}\"), or use '**' to \
span levels, for example glob_path(\"**/{a,b}\")."
),
"{report}"
);
}
#[test]
fn invalid_glob_pattern_help_explains_reserved_dot_segments() {
let report = glob_report("docs/..");
assert!(
report.contains("'.' and '..' are reserved as whole segments"),
"{report}"
);
assert!(
report.contains(
"To walk back up use .in() or repeat(__.in()) before the step, for example \
g.v(\"x\").in().glob_path(\"docs/*\")."
),
"{report}"
);
}
#[test]
fn invalid_glob_pattern_help_splits_too_many_segments() {
let report = glob_report(&vec!["a"; 64].join("/"));
assert!(
report.contains("at position 126: the pattern has more than 63 segments"),
"{report}"
);
assert!(
report.contains(
"Split the walk into two consecutive steps, each matching below the vertices \
the previous one emits, for example glob_path(\"a/b/c\").glob_path(\"d/e/f\")"
),
"{report}"
);
}
#[test]
fn invalid_glob_pattern_help_closes_classes_and_alternatives() {
let class = glob_report("file[0-9.txt");
assert!(
class.contains("at position 4: unclosed character class '['"),
"{class}"
);
assert!(
class.contains(
"Close the character class with ']' in the same segment, for example \
glob_path(\"file[0-9].txt\"); match a literal '[' with '\\['."
),
"{class}"
);
let alternatives = glob_report("*.{rs,toml");
assert!(
alternatives.contains("at position 2: unclosed alternatives '{'"),
"{alternatives}"
);
assert!(
alternatives.contains(
"Close the alternatives with '}', for example glob_path(\"*.{rs,toml}\"); \
match a literal '{' with '\\{'."
),
"{alternatives}"
);
}
#[test]
fn invalid_script_parameter_help_names_both_binding_forms() {
let err = ScriptError::InvalidParameter {
parameter: "g".to_string(),
message: "'g' is reserved by the script scope for the Gremlin DSL".to_string(),
};
let rendered = format_diagnostic(&err);
assert!(
rendered.contains("Invalid script parameter 'g'"),
"the message must name the offending parameter:\n{rendered}"
);
assert!(
rendered.contains("`Order`") && rendered.contains("token class"),
"the help must describe the token class globals:\n{rendered}"
);
assert!(
rendered.contains("eval_with_params"),
"the help must name the named-parameter entry point:\n{rendered}"
);
assert!(
rendered.contains("eval_with_positional_params"),
"the help must name the positional entry point:\n{rendered}"
);
assert!(
rendered.contains("g.v().has_label(label).to_list()"),
"the help must carry a runnable named-parameter example:\n{rendered}"
);
assert!(
rendered.contains("g.v().has_label($1).to_list()"),
"the help must carry a runnable positional example:\n{rendered}"
);
assert!(
rendered.contains("1-based"),
"the help must state that placeholders start at $1:\n{rendered}"
);
}
#[test]
fn mark_unavailable_help_covers_a_storage_without_a_journal() {
let err = GraphError::mark_unavailable("this storage keeps no journal");
let rendered = format_diagnostic(&err);
assert!(
rendered.contains("Cannot set a mark: this storage keeps no journal"),
"{rendered}"
);
assert!(
rendered.contains("A storage that keeps no journal (a custom GraphStorage"),
"{rendered}"
);
assert!(rendered.contains("g.mark(\"before-import\")"), "{rendered}");
}
#[test]
fn unsupported_graph_file_help_names_both_formats_and_their_extensions() {
let err = IOError::UnsupportedFormat {
path: "graph.csv".to_string(),
};
let rendered = format_diagnostic(&err);
assert!(
rendered.contains("Unsupported graph file format: 'graph.csv'"),
"the message must name the offending file:\n{rendered}"
);
assert!(
rendered.contains("GraphSON") && rendered.contains("GraphML"),
"the help must name both supported graph formats:\n{rendered}"
);
for ext in [".json", ".jsonl", ".graphson", ".xml", ".graphml"] {
assert!(
rendered.contains(ext),
"the help must name the extension {ext}:\n{rendered}"
);
}
assert!(
rendered.contains("GraphSource::file(\"graph.json\")"),
"the help must carry a runnable example:\n{rendered}"
);
}
#[test]
fn graphson_errors_have_help() {
for err in [
IOError::GraphSONParse {
line: 2,
message: "expected ',' or '}' in an object at byte 7".to_string(),
},
IOError::UnsupportedGraphSON {
line: 1,
reason: "this is typed GraphSON 1.0 (@class)".to_string(),
},
IOError::InputTooLarge { line: 1, limit: 64 },
] {
let rendered = format_diagnostic(&err);
assert!(rendered.contains("line"), "{rendered}");
assert!(
err.help()
.is_some_and(|h| h.contains("GraphSON") || h.contains("line form")),
"{rendered}"
);
}
let too_large = IOError::InputTooLarge { line: 1, limit: 64 };
assert!(
too_large
.help()
.is_some_and(|h| h.contains("with_max_text_bytes")),
"{too_large}"
);
}
#[test]
fn path_arena_exhaustion_help_names_limit_and_simple_path() {
let err = TraverserError::PathArenaExhausted { capacity: 4 };
assert!(err.to_string().contains("4 path positions"), "{err}");
let help = err.help().expect("help");
assert!(help.contains("g.v().out().limit(1000).path()"), "{help}");
assert!(help.contains("g.v(\"1\").out().path()"), "{help}");
assert!(help.contains("remove the path consumer"), "{help}");
assert!(
help.contains("g.v().both().both().simple_path().path()"),
"{help}"
);
assert!(
help.contains("g.v().repeat(__.both().simple_path()).times(2).path()"),
"{help}"
);
assert!(
help.contains("g.v().local(__.out().limit(2)).path()"),
"{help}"
);
}
#[test]
fn timeout_help_without_a_query_uses_an_example() {
let err = TraverserError::Timeout {
limit_ms: 250,
elapsed_ms: 251,
};
assert_eq!(
err.to_string(),
"Traversal exceeded evaluationTimeout of 250 ms (ran 251 ms)"
);
let help = err.help().expect("help");
assert!(
help.contains("g.v().limit(1000).out().out().count()"),
"{help}"
);
assert!(
help.contains("g.with(\"evaluationTimeout\", 500).v().out().out().count()"),
"{help}"
);
}
#[test]
fn timeout_help_without_a_repeat_does_not_mention_loops() {
let err = TraverserError::Timeout {
limit_ms: 250,
elapsed_ms: 251,
};
assert!(!err.help().expect("help").contains("repeat()"));
}
#[test]
fn bulk_expansion_limit_help_explains_why_and_what_to_do() {
let err = TraverserError::BulkExpansionLimit {
step: "fold".to_string(),
requested: 2_505_037_961,
limit: 100_000_000,
};
assert_eq!(
err.to_string(),
"Step 'fold' would have to materialize 2505037961 values, more than the limit of \
100000000 (bulk.max_expansion)"
);
let help = err.help().expect("help");
assert!(help.contains("record with a multiplicity"), "{help}");
assert!(
help.contains("2505037961 values, more than the limit of 100000000"),
"{help}"
);
for advice in ["count()", "group_count()", "dedup()", "limit(n)"] {
assert!(help.contains(advice), "{advice}: {help}");
}
assert!(
help.contains(
"g.with(\"bulk.max_expansion\", 2505037961).v().repeat(__.out()).times(8)"
),
"{help}"
);
let wrapped = TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: "fold".to_string(),
step_index: None,
child_index: None,
frame: None,
element_id: None,
source: Box::new(err),
};
assert!(wrapped.help().expect("help").contains("bulk.max_expansion"));
}
#[test]
fn bulk_overflow_help_names_the_step_and_the_remedy() {
let err = TraverserError::BulkOverflow {
step: "count".to_string(),
};
assert!(err.to_string().contains("step 'count'"));
let help = err.help().expect("help");
assert!(help.contains("9223372036854775807"), "{help}");
assert!(help.contains("Narrow the start set"), "{help}");
assert!(help.contains("times(n)"), "{help}");
}
#[test]
fn bulk_option_help_shows_a_runnable_example() {
for key in ["bulk.merge", "bulk.one", "bulk.max_expansion"] {
let err = TraverserError::InvalidOption {
key: key.to_string(),
reason: "bad".to_string(),
};
assert!(
err.help()
.expect("help")
.contains(&format!("g.with(\"{key}\""))
);
}
}
#[test]
fn resource_limit_help_names_the_option_and_shows_an_example() {
let err = TraverserError::ResourceLimitExceeded {
limit: "traversal.max_traversers".to_string(),
max: Some(1000),
observed: Some(1500),
};
assert_eq!(
err.to_string(),
"Resource limit 'traversal.max_traversers' exceeded (limit 1000, reached 1500)"
);
let help = err.help().expect("help");
assert!(
help.contains("g.with(\"traversal.max_traversers\", 2000).v().out().out().count()"),
"{help}"
);
assert!(help.contains("ExecutionPolicy::with_locked"), "{help}");
}
#[test]
fn resource_limit_help_for_a_script_limit_names_the_builder_and_the_flag() {
let err = TraverserError::ResourceLimitExceeded {
limit: "script.max_operations".to_string(),
max: Some(10_000_000),
observed: None,
};
assert_eq!(
err.to_string(),
"Resource limit 'script.max_operations' exceeded (limit 10000000)"
);
let help = err.help().expect("help");
assert!(
help.contains("ScriptLimits::default().with_max_operations(20000000)"),
"{help}"
);
assert!(help.contains("ScriptLimits::unlimited()"), "{help}");
assert!(
help.contains("graphersal --max-operations 20000000 -e 'g.v().count().next()'"),
"{help}"
);
}
#[test]
fn invalid_traverser_cap_help_shows_the_option() {
let err = TraverserError::InvalidOption {
key: "traversal.max_traversers".to_string(),
reason: "expected a positive integer number of values, got 0".to_string(),
};
let help = err.help().expect("help");
assert!(
help.contains(
"g.with(\"traversal.max_traversers\", 50000000).v().out().out().count()"
),
"{help}"
);
}
#[test]
fn repeat_limit_help_keeps_the_loop_name() {
let err = TraverserError::RepeatLimitExceeded {
max_loops: 10_000,
loop_name: Some("walk".to_string()),
body: "__.out()".to_string(),
};
assert_eq!(
err.to_string(),
"repeat() loop 'walk' exceeded the loop limit of 10000 iterations"
);
let help = err.help().expect("help");
assert!(
help.contains("repeat(\"walk\", __.out()).times(3)"),
"{help}"
);
assert!(
help.contains("repeat(\"walk\", __.out().simple_path())"),
"{help}"
);
assert!(
help.contains(
"g.with(\"repeat.max_loops\", 20000).v().repeat(\"walk\", __.out())."
),
"{help}"
);
assert!(
help.ends_with(
"\nOn a tree-shaped graph (directories, org charts, categories), walk the \
levels below a vertex with glob_path() instead: it follows outgoing edges \
along a pattern, prunes branches that cannot match and stops on cycles \
without a loop limit, for example g.v().glob_path(\"**/*.rs\").by(\"name\"), \
or g.v().glob_path(\"**\").by(\"name\") for every level below; bound how \
deep it walks with max_depth, for example \
g.v().glob_path(\"**\", #{max_depth: 3}).by(\"name\")."
),
"{help}"
);
}
#[test]
fn glob_path_max_depth_help_without_a_query_uses_an_example() {
let err = TraverserError::InvalidOption {
key: GLOB_PATH_MAX_DEPTH.to_string(),
reason: "must be at least 1, got 0".to_string(),
};
assert_eq!(
err.to_string(),
"Invalid value for option 'glob_path.max_depth': must be at least 1, got 0"
);
assert_eq!(
err.help().expect("help"),
"max_depth is the deepest level below the start vertex that glob_path() visits: \
the start vertex is level 0 and its children are level 1, so max_depth is an \
integer from 1 to 4294967295. Leave it out to walk without a bound. For example \
g.v(\"root\").glob_path(\"**/*.rs\", #{max_depth: 3}).by(\"name\")."
);
}
#[test]
fn glob_path_option_rewrites_keep_other_steps() {
let steps = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
split_glob_path_step(r#"glob_path("a\", {b", {max_depth: 0}).by("name")"#),
Some((r#"glob_path("a\", {b""#, r#", {max_depth: 0}).by("name")"#))
);
assert_eq!(split_glob_path_step("out()"), None);
assert_eq!(
glob_path_step_with_valid_max_depth(
r#"glob_path("**", {max_depth: -7}).by("name")"#
),
Some(r#"glob_path("**", {max_depth: 1}).by("name")"#.to_string())
);
assert_eq!(
glob_path_step_with_valid_max_depth(r#"glob_path("**", {max_depth: 7})"#),
None
);
assert_eq!(
glob_path_query_with_max_depth(&steps(&[
"v()",
"glob_path(\"src/*\").by(\"name\")"
])),
None
);
assert_eq!(
glob_path_query_with_max_depth(&steps(&[
"v()",
"glob_path(\"**\", {max_depth: 9})"
])),
None
);
assert_eq!(
glob_path_query_with_max_depth(&steps(&[
"v()",
"glob_path(\"**/*.rs\").by(\"name\")",
"count()"
])),
Some(
"v().glob_path(\"**/*.rs\", #{max_depth: 3}).by(\"name\").count()".to_string()
)
);
}
#[test]
fn glob_path_start_keeps_the_steps_before_the_loop() {
let steps = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
glob_path_start(&steps(&[
"v(\"root\")",
"has_label(\"directory\")",
"repeat(__.out())"
])),
Some("v(\"root\").has_label(\"directory\")".to_string())
);
assert_eq!(
glob_path_start(&steps(&["v(\"root\")", "emit()", "repeat(__.out())"])),
Some("v(\"root\")".to_string())
);
assert_eq!(
glob_path_start(&steps(&["v(\"root\")", "local(__.repeat(__.out()))"])),
None
);
assert_eq!(glob_path_start(&steps(&["repeat(__.out())"])), None);
}
#[test]
fn invalid_property_value_help_stores_the_id_instead() {
let graph = GraphSource::tinkerpop_modern();
let err = graph
.write()
.traversal_mut()
.v("1")
.property_by("friend", __::out(None))
.next()
.expect_err("a vertex must not be stored as a property value");
let root = err.root_cause();
assert_eq!(
root.to_string(),
"Property 'friend' cannot hold a vertex: vertices and edges are not property values"
);
assert_eq!(
root.help().expect("help"),
"Store the vertex's id instead of the vertex itself, e.g. \
property(\"friend\", __.out(\"knows\").id()), and look the vertex up again with g.v(id)."
);
let edge_err = GraphError::InvalidPropertyValue {
property_name: "via".to_string(),
kind: "edge",
};
assert_eq!(
edge_err.to_string(),
"Property 'via' cannot hold an edge: vertices and edges are not property values"
);
assert_eq!(
edge_err.help().expect("help"),
"Store the edge's id instead of the edge itself, e.g. \
property(\"via\", __.out_e(\"knows\").id()), and look the edge up again with g.e(id)."
);
}
#[test]
fn coercion_failed_help_names_the_accepted_form() {
let coercion_failed =
|declared_type: &str, actual_value: &str, reason: &'static str| {
SchemaError::CoercionFailed {
element_type: "vertex",
label: "person".to_string(),
property_name: "uid".to_string(),
declared_type: declared_type.to_string(),
actual_value: actual_value.into(),
reason,
}
};
let uuid = coercion_failed(
"uuid",
"\"abc\"",
"not a canonical UUID (8-4-4-4-12 lowercase hex)",
);
assert_eq!(
uuid.to_string(),
"Cannot store \"abc\" in vertex 'person.uid' declared as uuid: not a canonical UUID \
(8-4-4-4-12 lowercase hex)"
);
assert_eq!(
uuid.help().expect("help"),
"UUIDs are written as 8-4-4-4-12 lowercase hex, e.g. \
`550e8400-e29b-41d4-a716-446655440000`."
);
let float = coercion_failed(
"float64",
"9007199254740993",
"integer cannot be represented exactly as float64",
);
assert_eq!(
float.help().expect("help"),
"The integer is too large to be represented exactly as float64; store it in an int64 \
field."
);
}
#[test]
fn type_mismatch_on_uuid_help_names_the_cast_in_both_spellings() {
let err = SchemaError::TypeMismatch {
element_type: "vertex",
label: "person".to_string(),
property_name: "uid".to_string(),
expected_type: "uuid".to_string(),
actual_type: "int64".to_string(),
};
assert_eq!(
err.help().expect("help"),
"Convert value to expected type 'uuid' before inserting.\n\
In a query, convert the values with values(\"uid\").cast(GType.UUID).\n\
Only a canonical UUID string is converted to a uuid on write; convert a string value \
explicitly with .cast(GType.UUID).\n\
Find every offending value with values(\"uid\").is(typeOf(GType.LONG))."
);
let other = SchemaError::TypeMismatch {
element_type: "vertex",
label: "person".to_string(),
property_name: "name".to_string(),
expected_type: "string".to_string(),
actual_type: "uuid".to_string(),
};
assert!(
!other
.help()
.expect("help")
.contains("Only a canonical UUID string")
);
}
#[test]
fn ambiguous_container_import_help_names_both_ways_out() {
let err = IOError::AmbiguousContainerImport {
key: "tags".to_string(),
declared: "union<string|array<int64>>".to_string(),
};
let rendered = format_diagnostic(&err);
assert!(
rendered.contains(
"Property 'tags' is declared as union<string|array<int64>>, a union of an \
array/object and a text type"
),
"the message must name the property and its type:\n{rendered}"
);
let help = err.help().expect("help");
assert!(
help.contains("Declare 'tags' either as an array/object"),
"{help}"
);
assert!(help.contains("before importing"), "{help}");
}
#[test]
fn invalid_json_value_help_shows_the_expected_text() {
let err = IOError::InvalidJsonValue {
key: "config".to_string(),
declared: "object".to_string(),
reason: "expected ',' or '}' in an object at byte 7".to_string(),
};
let rendered = format_diagnostic(&err);
assert!(
rendered.contains(
"Property 'config' is declared as object, but its GraphML value is not valid \
JSON: expected ',' or '}' in an object at byte 7"
),
"{rendered}"
);
let help = err.help().expect("help");
assert!(help.contains(r#"{"cpu":2}"#), "{help}");
assert!(help.contains("declare 'config' as `string`"), "{help}");
}
#[test]
fn non_finite_float_export_help_names_the_repairing_steps() {
let err = IOError::NonFiniteFloat {
key: "w".to_string(),
value: "NaN".to_string(),
};
let rendered = format_diagnostic(&err);
assert!(
rendered.contains("Property 'w' holds the non-finite float NaN"),
"the message must name the property and the value:\n{rendered}"
);
assert!(
rendered.contains(".property(\"w\", 0.0)")
&& rendered.contains(".properties(\"w\").drop()")
&& rendered.contains(".remove_property(\"w\")"),
"the help must carry runnable repairs for the offending key:\n{rendered}"
);
}
}
mod constructed_errors {
use super::super::*;
fn assert_help_rendered(formatted: &str, expected_help: &str) {
for (index, line) in expected_help.lines().enumerate() {
let expected_line = if index == 0 {
format!("Help: {line}")
} else {
format!(" {line}")
};
assert!(
formatted.contains(&expected_line),
"missing help line `{expected_line}` in: {formatted}"
);
}
}
#[test]
fn test_cause_chain_propagation() {
use std::error::Error;
let inner_graph_err = GraphError::VertexNotFound {
id: "v_999".to_string(),
};
let step_err = TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: "out".to_string(),
step_index: None,
child_index: None,
frame: None,
element_id: Some("v_1".to_string()),
source: Box::new(TraverserError::Graph(inner_graph_err)),
};
assert!(step_err.source().is_some());
let cause = step_err.source().unwrap();
assert!(cause.to_string().contains("v_999"));
let formatted = format_diagnostic(&step_err);
assert!(formatted.starts_with("Error: Step 'out' execution failed"));
assert!(
formatted.contains("Caused by: Vertex with ID 'v_999' was not found in the graph")
);
assert!(formatted.contains(
"Help: Verify that the vertex ID exists in the graph before referencing it."
));
}
#[test]
fn test_diagnostic_error_help_coverage() {
let err_v_not_found = GraphError::VertexNotFound {
id: "100".to_string(),
};
assert!(err_v_not_found.help().is_some());
assert!(!err_v_not_found.help().unwrap().is_empty());
let err_prop_not_found = ValueError::PropertyNotFound {
key: "age".to_string(),
element_type: "vertex",
};
assert!(err_prop_not_found.help().is_some());
assert!(!err_prop_not_found.help().unwrap().is_empty());
let err_cast = ValueError::CastException {
expected: "number".to_string(),
actual: "string".to_string(),
value: None,
origin: None,
};
assert!(err_cast.help().is_some());
assert!(!err_cast.help().unwrap().is_empty());
let err_missing_attr = IOError::MissingAttribute {
element: "node".to_string(),
attribute: "id".to_string(),
};
assert!(err_missing_attr.help().is_some());
assert!(!err_missing_attr.help().unwrap().is_empty());
let err_invalid_attr = IOError::InvalidAttributeValue {
key: "weight".to_string(),
attribute: "value".to_string(),
value: "not_a_number".to_string(),
expected_type: "float".to_string(),
};
assert!(err_invalid_attr.help().is_some());
assert!(!err_invalid_attr.help().unwrap().is_empty());
}
#[test]
fn test_schema_error_diagnostics_and_formatting() {
let variants: Vec<(SchemaError, &str, &str)> = vec![
(
SchemaError::UnknownVertexLabel {
label: "Alien".to_string(),
allowed: vec!["Human".to_string(), "Robot".to_string()],
},
"Unknown vertex label 'Alien' is not declared in the closed schema",
"A closed schema admits only declared vertex labels: use one of [Human, Robot]",
),
(
SchemaError::UnknownEdgeLabel {
label: "teleports_to".to_string(),
allowed: vec!["walks_to".to_string(), "flies_to".to_string()],
},
"Unknown edge label 'teleports_to' is not declared in the closed schema",
"A closed schema admits only declared edge labels: use one of [walks_to, flies_to]",
),
(
SchemaError::InvalidEdgeTopology {
source_label: "Human".to_string(),
edge_label: "operates".to_string(),
target_label: "Planet".to_string(),
allowed: vec!["(:Human)-[:operates]->(:Vehicle)".to_string()],
},
"Disallowed edge connection: '(:Human)-[:operates]->(:Planet)' is not defined in schema",
"A closed schema admits an edge only between the labels of a declared connection",
),
(
SchemaError::UndeclaredProperty {
element_type: "vertex",
label: "Human".to_string(),
property_name: "mana".to_string(),
},
"Undeclared property 'mana' on vertex 'Human' is not declared and not admitted by the schema",
"Declare 'mana' in the label's \"properties\"",
),
(
SchemaError::MissingRequiredProperty {
element_type: "edge",
label: "created".to_string(),
property_name: "weight".to_string(),
},
"Missing required property 'weight' on edge 'created'",
"Provide a value for 'weight' or remove it from the label's \"required\"",
),
(
SchemaError::TypeMismatch {
element_type: "vertex",
label: "Robot".to_string(),
property_name: "serial_no".to_string(),
expected_type: "int64".to_string(),
actual_type: "string".to_string(),
},
"Type mismatch on vertex 'Robot.serial_no': expected int64, got string",
"Convert value to expected type 'int64' before inserting.\n\
In a query, convert the values with values(\"serial_no\").as_number(GType.LONG).\n\
Find every offending value with values(\"serial_no\").is(typeOf(GType.STRING)).",
),
(
SchemaError::ConstraintViolation {
label: "Robot".to_string(),
property_name: "battery".to_string(),
constraint_rule: "value <= 100".to_string(),
actual_value: "120".to_string(),
},
"Constraint violation on 'Robot.battery': value <= 100 (actual: 120)",
"Ensure value complies with validation constraint: value <= 100.",
),
(
SchemaError::InvalidSchema {
path: "/mdoe".to_string(),
reason: "unknown key 'mdoe'".to_string(),
hint: "The envelope keys are $schema, meta, mode, vertices and edges."
.to_string(),
},
"Invalid schema at /mdoe: unknown key 'mdoe'",
"The envelope keys are $schema, meta, mode, vertices and edges.",
),
];
for (schema_err, expected_msg, expected_help) in variants {
assert_eq!(schema_err.to_string(), expected_msg);
let help = schema_err.help().expect("schema errors carry help");
assert!(help.starts_with(expected_help), "{help}");
let formatted = format_diagnostic(&schema_err);
assert!(
formatted.contains(&format!("Error: {}", expected_msg)),
"format_diagnostic missing main error message in: {}",
formatted
);
assert_help_rendered(&formatted, expected_help);
let trav_err: TraverserError = schema_err.into();
assert_eq!(trav_err.to_string(), expected_msg);
assert!(trav_err.help().expect("help").starts_with(expected_help));
let formatted_trav = format_diagnostic(&trav_err);
assert!(
formatted_trav.contains(&format!("Error: {}", expected_msg)),
"format_diagnostic on TraverserError missing error message in: {}",
formatted_trav
);
assert_help_rendered(&formatted_trav, expected_help);
}
}
#[test]
fn test_step_failure_context() {
let inner_err = TraverserError::Value(ValueError::PropertyNotFound {
key: "non_existent".to_string(),
element_type: "vertex",
});
let step_err = TraverserError::StepFailed {
spelling: crate::traversal::Spelling::Snake,
step_name: "values".to_string(),
step_index: None,
child_index: None,
frame: None,
element_id: Some("vertex_42".to_string()),
source: Box::new(inner_err),
};
if let TraverserError::StepFailed {
step_name,
element_id,
source,
..
} = &step_err
{
assert_eq!(step_name, "values");
assert_eq!(element_id.as_deref(), Some("vertex_42"));
assert!(source.help().is_some());
} else {
panic!("Expected StepFailed variant");
}
}
}
}