use compact_str::CompactString;
use crate::graph::GraphStorage;
use crate::traversal::step::barrier_steps::numeric::{ValueSource, preview};
use crate::traversal::step::local_scope::{LocalItem, LocalMapped, is_null, map_local_elements};
use crate::value::Scope;
use crate::*;
#[derive(Clone, Debug)]
enum CastTarget {
ValueType(ValueType),
Natural,
Truthy,
Integer,
Rejected(GType, &'static str),
}
impl CastTarget {
fn general(target: GType) -> Self {
match target {
GType::Value(declared) => CastTarget::ValueType(declared),
GType::Number => CastTarget::Natural,
other => CastTarget::Rejected(
other,
"this token names a family or a graph element, not a single conversion target",
),
}
}
fn numeric(target: Option<GType>) -> Self {
match target {
None | Some(GType::Number) => CastTarget::Natural,
Some(GType::Value(ValueType::Int64)) => CastTarget::Integer,
Some(GType::Value(ValueType::Float64)) => CastTarget::ValueType(ValueType::Float64),
Some(other) => CastTarget::Rejected(
other,
"as_number() converts to a number; use as_string(), as_bool() or cast() instead",
),
}
}
fn rendered(&self) -> Option<String> {
match self {
CastTarget::ValueType(declared) => Some(GType::Value(declared.clone()).token()),
CastTarget::Natural | CastTarget::Truthy => None,
CastTarget::Integer => Some(GType::LONG.token()),
CastTarget::Rejected(token, _) => Some(token.token()),
}
}
}
#[derive(Clone)]
struct StepCast {
name: CompactString,
target: CastTarget,
policy: CastPolicy,
rejects_null: bool,
scope: Scope,
}
impl fmt::Display for StepCast {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
fn cast_step<'g, S: GraphStorage + 'g>(
alias: &'static str,
target: CastTarget,
policy: CastPolicy,
render_target: bool,
scope: Scope,
) -> GraphStep<'g, S> {
let mut args: Vec<String> = Vec::new();
if scope == Scope::Local {
args.push("Scope.local".to_string());
}
if render_target && let Some(rendered) = target.rendered() {
args.push(rendered);
}
match &policy {
CastPolicy::Error => {}
CastPolicy::Skip => args.push("CastPolicy.Skip".to_string()),
CastPolicy::Default(value) => args.push(format!(
"CastPolicy.Default({})",
crate::value::literal::rhai_literal(value)
)),
}
let name = CompactString::new(format!("{alias}({})", args.join(", ")));
GraphStep::FlatMap(
alias,
Box::new(StepCast {
name,
target,
policy,
rejects_null: matches!(alias, "as_string" | "as_bool"),
scope,
}),
)
}
macro_rules! cast_builders {
($target:ty) => {
pub fn as_string(self) -> Self {
self.as_string_with(CastPolicy::default())
}
pub fn as_string_with(self, policy: CastPolicy) -> Self {
self.push_step(cast_step(
"as_string",
CastTarget::ValueType(ValueType::String),
policy,
false,
Scope::Global,
))
}
pub fn as_string_scoped(self, scope: Scope) -> Self {
self.push_step(cast_step(
"as_string",
CastTarget::ValueType(ValueType::String),
CastPolicy::default(),
false,
scope,
))
}
pub fn as_bool(self) -> Self {
self.as_bool_with(CastPolicy::default())
}
pub fn as_bool_with(self, policy: CastPolicy) -> Self {
self.push_step(cast_step(
"as_bool",
CastTarget::Truthy,
policy,
false,
Scope::Global,
))
}
pub fn as_number<T: Into<Option<GType>>>(self, target: T) -> Self {
self.as_number_with(target, CastPolicy::default())
}
pub fn as_number_with<T: Into<Option<GType>>>(self, target: T, policy: CastPolicy) -> Self {
self.push_step(cast_step(
"as_number",
CastTarget::numeric(target.into()),
policy,
true,
Scope::Global,
))
}
pub fn cast<T: Into<GType>>(self, target: T) -> Self {
self.cast_with(target, CastPolicy::default())
}
pub fn cast_with<T: Into<GType>>(self, target: T, policy: CastPolicy) -> Self {
self.push_step(cast_step(
"cast",
CastTarget::general(target.into()),
policy,
true,
Scope::Global,
))
}
};
}
impl<'g, S: GraphStorage + 'g> GraphTraversalSource<'g, S> {
cast_builders!(GraphTraversalSource);
}
impl<'g, S: GraphStorage + 'g> AnonymousTraversal<'g, S> {
cast_builders!(AnonymousTraversal);
}
impl __ {
pub fn as_string<'g, S: GraphStorage + 'g>() -> AnonymousTraversal<'g, S> {
AnonymousTraversal::new().as_string()
}
pub fn as_string_scoped<'g, S: GraphStorage + 'g>(scope: Scope) -> AnonymousTraversal<'g, S> {
AnonymousTraversal::new().as_string_scoped(scope)
}
pub fn as_bool<'g, S: GraphStorage + 'g>() -> AnonymousTraversal<'g, S> {
AnonymousTraversal::new().as_bool()
}
pub fn as_number<'g, S: GraphStorage + 'g, T: Into<Option<GType>>>(
target: T,
) -> AnonymousTraversal<'g, S> {
AnonymousTraversal::new().as_number(target)
}
pub fn cast<'g, S: GraphStorage + 'g, T: Into<GType>>(target: T) -> AnonymousTraversal<'g, S> {
AnonymousTraversal::new().cast(target)
}
}
fn to_natural_number(value: &ElementProperty) -> TraverserResult<ElementProperty> {
match value {
ElementProperty::Number(_) | ElementProperty::Null => Ok(value.clone()),
ElementProperty::Boolean(_) => value.cast(&ValueType::Int64),
ElementProperty::String(text) => {
if text.parse::<i64>().is_ok() {
value.cast(&ValueType::Int64)
} else {
value.cast(&ValueType::Float64)
}
}
other => Err(Box::new(TraverserError::cast_exception_target(
"number",
crate::graph::schema::value_type_name(other),
Some(preview(other)),
None,
))),
}
}
fn truthy(value: &ElementProperty) -> TraverserResult<ElementProperty> {
match value {
ElementProperty::Boolean(_) => Ok(value.clone()),
ElementProperty::Number(number) => {
let falsy = match (number.as_i64(), number.as_f64()) {
(Some(int), _) => int == 0,
(None, Some(float)) => float == 0.0 || float.is_nan(),
(None, None) => false,
};
Ok(ElementProperty::Boolean(!falsy))
}
ElementProperty::String(text) if text.eq_ignore_ascii_case("true") => {
Ok(ElementProperty::Boolean(true))
}
ElementProperty::String(text) if text.eq_ignore_ascii_case("false") => {
Ok(ElementProperty::Boolean(false))
}
other => other.cast(&ValueType::Boolean),
}
}
fn truncate_to_int64(value: &ElementProperty) -> TraverserResult<ElementProperty> {
match value {
ElementProperty::Number(number) => match (number.as_i64(), number.as_f64()) {
(Some(_), _) => Ok(value.clone()),
(None, Some(float))
if float.is_finite()
&& (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0)
.contains(&float) =>
{
Ok(ElementProperty::Number(Number::from_i64(
float.trunc() as i64
)))
}
_ => value.cast(&ValueType::Int64),
},
ElementProperty::Null => Ok(ElementProperty::Null),
other => other.cast(&ValueType::Int64),
}
}
impl StepCast {
fn rejects_null(&self) -> bool {
self.rejects_null
}
fn convert<S: GraphStorage>(
&self,
context: &TraverserContext<'_, S>,
source: &TraverserValue<S>,
) -> TraverserResult<Option<ElementProperty>> {
if matches!(self.target, CastTarget::ValueType(ValueType::String))
&& let Some(text) = element_text(context, source)?
{
return Ok(Some(bounded_string(context, text)?));
}
let (value, origin) = read_value(context, source)?;
let converted = match (&value, &self.target) {
(None, target) => Err(Box::new(TraverserError::cast_exception_target(
target_name(target),
source.type_name().to_string(),
None,
None,
))),
(
Some(ElementProperty::Null),
CastTarget::ValueType(ValueType::String) | CastTarget::Truthy,
) if self.rejects_null() => Err(Box::new(TraverserError::cast_exception_target(
target_name(&self.target),
"null".to_string(),
None,
None,
))),
(Some(value), CastTarget::Natural) => to_natural_number(value),
(Some(value), CastTarget::Truthy) => truthy(value),
(Some(value), CastTarget::Integer) => truncate_to_int64(value),
(Some(value), CastTarget::ValueType(declared)) => value.cast(declared),
(Some(_), CastTarget::Rejected(token, reason)) => Err(Box::new(
TraverserError::unsupported_cast_target(token.to_string(), reason),
)),
};
match converted {
Ok(converted) => Ok(Some(bounded_string(context, converted)?)),
Err(err)
if matches!(
*err,
TraverserError::Value(ValueError::UnsupportedCastTarget { .. })
) =>
{
Err(err)
}
Err(err) => match &self.policy {
CastPolicy::Error => Err(Box::new(enrich(*err, context, value.as_ref(), &origin))),
CastPolicy::Skip => Ok(None),
CastPolicy::Default(fallback) => Ok(Some(fallback.clone())),
},
}
}
fn convert_element<S: GraphStorage>(
&self,
context: &TraverserContext<'_, S>,
index: usize,
item: LocalItem<'_, S>,
) -> TraverserResult<ElementProperty> {
let null_element = || {
Box::new(TraverserError::local_element_type(
"asString",
"non-null elements (Can't parse null as a string)",
index,
"null".to_string(),
))
};
let converted = match item {
LocalItem::Stored(ElementProperty::Null) => return Err(null_element()),
LocalItem::Stored(stored) => {
self.convert(context, &TraverserValue::Computed(stored.clone(), None))?
}
LocalItem::Value(value) => {
if is_null(context, value) {
return Err(null_element());
}
self.convert(context, value)?
}
};
Ok(converted.unwrap_or(ElementProperty::Null))
}
}
use crate::traversal::step::local_scope::bounded_string;
impl<'g, S: GraphStorage + 'g> GraphStepFlatMap<'g, S> for StepCast {
#[inline]
fn process(
&self,
context: &mut TraverserContext<'_, S>,
source: Traverser<S>,
output: &mut Vec<Traverser<S>>,
) -> TraverserResult<()> {
if let CastTarget::Rejected(token, reason) = &self.target {
return Err(Box::new(TraverserError::unsupported_cast_target(
token.to_string(),
reason,
)));
}
let path = source.path;
let bulk = source.bulk;
let sack = source.sack;
let value = match self.scope {
Scope::Global => source.value,
Scope::Local => {
let context: &TraverserContext<'_, S> = context;
match map_local_elements(context, source.value, |index, item| {
self.convert_element(context, index, item)
})? {
LocalMapped::Done(list) => {
output.push(Traverser::with_parts(list, path, bulk, sack));
return Ok(());
}
LocalMapped::Single(value) => value,
}
}
};
if let Some(converted) = self.convert(context, &value)? {
output.push(Traverser::with_parts(
TraverserValue::Computed(converted, None),
path,
bulk,
sack,
));
}
Ok(())
}
fn materializes(&self) -> bool {
self.scope == Scope::Local
}
fn clone_box(&self) -> Box<dyn GraphStepFlatMap<'g, S> + 'g> {
Box::new(self.clone())
}
}
pub(crate) fn element_text<S: GraphStorage>(
context: &TraverserContext<'_, S>,
source: &TraverserValue<S>,
) -> TraverserResult<Option<ElementProperty>> {
let storage = &*context.storage;
let text = match source {
TraverserValue::Vertex(vertex) => {
format!(
"v[{}]",
crate::graph::storage::stored_vertex_id(storage, *vertex)?
)
}
TraverserValue::Edge(edge) => {
let id = crate::graph::storage::stored_edge_id(storage, *edge)?;
let label = storage.edge_label(*edge).unwrap_or_default();
match storage.edge_endpoints(*edge) {
Some((out, into)) => format!(
"e[{id}][{}-{label}->{}]",
crate::graph::storage::stored_vertex_id(storage, out)?,
crate::graph::storage::stored_vertex_id(storage, into)?
),
None => format!("e[{id}]"),
}
}
TraverserValue::PathProperty(prop) => {
format!("p[{}->{}]", prop.0, property_text(Some(&prop.1))?)
}
TraverserValue::VertexProperty(key, vertex, PropertyHandleKind::Element) => {
let stored = storage
.vertex_properties(*vertex)
.and_then(|p| p.get(key.as_str()));
format!("vp[{key}->{}]", property_text(stored)?)
}
TraverserValue::EdgeProperty(key, edge, PropertyHandleKind::Element) => {
let stored = storage
.edge_properties(*edge)
.and_then(|p| p.get(key.as_str()));
format!("p[{key}->{}]", property_text(stored)?)
}
_ => return Ok(None),
};
Ok(Some(ElementProperty::from(text)))
}
fn property_text(stored: Option<&ElementProperty>) -> TraverserResult<String> {
match stored {
None | Some(ElementProperty::Null) => Ok("null".to_string()),
Some(value) => match value.cast(&ValueType::String)? {
ElementProperty::String(text) => Ok(text.to_string()),
other => Err(Box::new(TraverserError::cast_exception_target(
"string",
crate::graph::schema::value_type_name(&other),
None,
None,
))),
},
}
}
fn target_name(target: &CastTarget) -> String {
match target {
CastTarget::ValueType(declared) => declared.to_string(),
CastTarget::Natural => "number".to_string(),
CastTarget::Truthy => "boolean".to_string(),
CastTarget::Integer => "int64".to_string(),
CastTarget::Rejected(token, _) => token.to_string(),
}
}
fn read_value<S: GraphStorage>(
context: &TraverserContext<'_, S>,
source: &TraverserValue<S>,
) -> TraverserResult<(Option<ElementProperty>, ValueSource<S>)> {
match source {
TraverserValue::Computed(value, _) => Ok((Some(value.clone()), ValueSource::Anonymous)),
TraverserValue::VertexProperty(prop, vertex_index, _) => {
let origin = ValueSource::Vertex(*vertex_index, Some(prop.clone()));
if prop.eq("id") || prop.eq("label") {
let rendered = Traverser::as_string(context, source)?;
return Ok((Some(to_property(rendered)), origin));
}
let value = context
.storage
.vertex_properties(*vertex_index)
.and_then(|p| p.get(prop.as_str()))
.map(ElementProperty::to_logical)
.unwrap_or(ElementProperty::Null);
Ok((Some(value), origin))
}
TraverserValue::EdgeProperty(prop, edge_index, _) => {
let origin = ValueSource::Edge(*edge_index, Some(prop.clone()));
if prop.eq("id") || prop.eq("label") {
let rendered = Traverser::as_string(context, source)?;
return Ok((Some(to_property(rendered)), origin));
}
let value = context
.storage
.edge_properties(*edge_index)
.and_then(|p| p.get(prop.as_str()))
.map(ElementProperty::to_logical)
.unwrap_or(ElementProperty::Null);
Ok((Some(value), origin))
}
TraverserValue::Vertex(vertex_index) => {
Ok((None, ValueSource::Vertex(*vertex_index, None)))
}
TraverserValue::Edge(edge_index) => Ok((None, ValueSource::Edge(*edge_index, None))),
TraverserValue::Path(_)
| TraverserValue::Array(_)
| TraverserValue::Object(_)
| TraverserValue::Entry(_)
| TraverserValue::Record { .. }
| TraverserValue::Schema(_)
| TraverserValue::Graph(_) => Ok((None, ValueSource::Anonymous)),
TraverserValue::PathProperty(prop) => Ok((Some(prop.1.clone()), ValueSource::Anonymous)),
}
}
fn to_property(rendered: Option<String>) -> ElementProperty {
rendered
.map(ElementProperty::from)
.unwrap_or(ElementProperty::Null)
}
fn enrich<S: GraphStorage>(
err: TraverserError,
context: &TraverserContext<'_, S>,
value: Option<&ElementProperty>,
origin: &ValueSource<S>,
) -> TraverserError {
match err {
TraverserError::Value(ValueError::CastException {
expected, actual, ..
}) => TraverserError::cast_exception_target(
expected,
actual,
value.map(preview),
origin.origin(context),
),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn modern() -> Graph {
GraphSource::tinkerpop_modern()
}
fn modern_with_string_age() -> Graph {
let graph = GraphSource::tinkerpop_modern();
{
let mut writable = graph.write();
let _ = writable
.traversal_mut()
.v("4")
.property("age", "999")
.next();
}
graph
}
#[test]
fn as_string_renders_every_scalar() {
let graph = modern();
let locked = graph.read();
let mut ages = locked
.traversal()
.v(None)
.has_label("person")
.values("age")
.as_string()
.to_list()
.unwrap();
ages.sort_by_key(|value| format!("{value:?}"));
assert_eq!(
ages,
vec![
ElementProperty::from("27"),
ElementProperty::from("29"),
ElementProperty::from("32"),
ElementProperty::from("35"),
]
);
}
#[test]
fn as_number_normalizes_a_mixed_field() {
let graph = modern_with_string_age();
let locked = graph.read();
let max = locked
.traversal()
.v(None)
.has_label("person")
.values("age")
.as_number(GType::LONG)
.max()
.next()
.unwrap();
assert_eq!(max, Some(ElementProperty::from(999i64)));
}
#[test]
fn as_number_without_a_target_keeps_the_natural_type() {
let graph = modern();
let locked = graph.read();
let values = locked
.traversal()
.inject(vec!["12", "1.5"])
.as_number(None)
.to_list()
.unwrap();
assert_eq!(
values,
vec![ElementProperty::from(12i64), ElementProperty::from(1.5f64)]
);
}
#[test]
fn as_bool_takes_the_exact_literals() {
let graph = modern();
let locked = graph.read();
let values = locked
.traversal()
.inject(vec!["true", "false"])
.as_bool()
.to_list()
.unwrap();
assert_eq!(
values,
vec![
ElementProperty::Boolean(true),
ElementProperty::Boolean(false)
]
);
}
#[test]
fn the_default_policy_fails_and_names_the_offending_element() {
let graph = modern_with_string_age();
let locked = graph.read();
let err = locked
.traversal()
.v(None)
.has_label("person")
.values("age")
.as_bool()
.to_list()
.expect_err("an age is not a boolean");
let report = crate::error::format_diagnostic(&err);
assert!(report.contains("boolean"), "missing target in: {report}");
assert!(
report.contains("origin: vertex"),
"missing origin in: {report}"
);
}
#[test]
fn skip_drops_the_values_that_do_not_convert() {
let graph = modern_with_string_age();
let locked = graph.read();
let values = locked
.traversal()
.v(None)
.has_label("person")
.values("name")
.as_number_with(GType::LONG, CastPolicy::Skip)
.to_list()
.unwrap();
assert!(values.is_empty(), "no name parses as a number: {values:?}");
}
#[test]
fn a_default_replaces_the_values_that_do_not_convert() {
let graph = modern();
let locked = graph.read();
let values = locked
.traversal()
.inject(vec!["12", "nope"])
.as_number_with(
GType::LONG,
CastPolicy::Default(ElementProperty::from(0i64)),
)
.to_list()
.unwrap();
assert_eq!(
values,
vec![ElementProperty::from(12i64), ElementProperty::from(0i64)]
);
}
#[test]
fn an_unsupported_target_fails_whatever_the_policy_says() {
for policy in [
CastPolicy::Error,
CastPolicy::Skip,
CastPolicy::Default(ElementProperty::from("fallback")),
] {
let graph = modern();
let locked = graph.read();
let err = locked
.traversal()
.inject(vec!["x"])
.cast_with(
GType::from(ValueType::Array(Box::new(ValueType::String))),
policy.clone(),
)
.to_list()
.expect_err("an unsupported target must never pass the value through");
assert!(
crate::error::format_diagnostic(&err).contains("array<string>"),
"the target must be named for policy {policy:?}"
);
}
}
#[test]
fn a_uuid_cast_failure_honours_the_policy() {
let graph = modern();
let locked = graph.read();
let skipped = locked
.traversal()
.inject(vec!["x"])
.cast_with(GType::UUID, CastPolicy::Skip)
.to_list()
.unwrap();
assert!(skipped.is_empty());
let defaulted = locked
.traversal()
.inject(vec!["x"])
.cast_with(
GType::UUID,
CastPolicy::Default(ElementProperty::from("fallback")),
)
.to_list()
.unwrap();
assert_eq!(defaulted, vec![ElementProperty::from("fallback")]);
let err = locked
.traversal()
.inject(vec!["x"])
.cast_with(GType::UUID, CastPolicy::Error)
.to_list()
.expect_err("a malformed UUID must fail under the default policy");
assert!(crate::error::format_diagnostic(&err).contains("uuid"));
}
#[test]
fn as_number_rejects_a_non_numeric_token() {
let graph = modern();
let locked = graph.read();
let err = locked
.traversal()
.inject(vec!["x"])
.as_number(GType::STRING)
.to_list()
.expect_err("as_number() converts to a number");
assert!(crate::error::format_diagnostic(&err).contains("as_number()"));
}
#[test]
fn a_graph_element_only_converts_to_its_string_form() {
let graph = modern();
let locked = graph.read();
let texts = locked
.traversal()
.v(None)
.limit(1)
.as_string()
.to_list()
.unwrap();
assert_eq!(texts, vec![ElementProperty::from("v[1]")]);
let err = locked
.traversal()
.v(None)
.as_bool()
.to_list()
.expect_err("a vertex is not a boolean");
assert!(
crate::error::format_diagnostic(&err).contains("got type 'vertex'"),
"a vertex must be reported as a vertex"
);
}
#[test]
fn profile_names_the_step_as_written() {
let graph = modern();
let locked = graph.read();
let plan = locked
.traversal()
.v(None)
.has_label("person")
.values("age")
.as_number(GType::LONG)
.max()
.profile()
.unwrap()
.to_string();
assert!(
plan.contains("as_number(GType.LONG)"),
"the alias and its argument must survive optimization: {plan}"
);
let plan = locked
.traversal()
.inject(vec!["1"])
.as_string()
.profile()
.unwrap()
.to_string();
assert!(plan.contains("as_string()"), "plan: {plan}");
let plan = locked
.traversal()
.inject(vec!["1"])
.as_number_with(None, CastPolicy::Skip)
.profile()
.unwrap()
.to_string();
assert!(plan.contains("as_number(CastPolicy.Skip)"), "plan: {plan}");
}
#[test]
fn count_does_not_skip_the_conversion() {
let graph = modern_with_string_age();
let locked = graph.read();
let counted = locked
.traversal()
.v(None)
.has_label("person")
.values("name")
.as_number_with(GType::LONG, CastPolicy::Skip)
.count()
.next()
.unwrap();
assert_eq!(counted, Some(ElementProperty::from(0i64)));
}
#[test]
fn the_anonymous_form_is_available() {
let graph = modern();
let locked = graph.read();
let values = locked
.traversal()
.inject(vec!["12"])
.where_t(__::as_number::<_, GType>(GType::LONG))
.to_list()
.unwrap();
assert_eq!(values, vec![ElementProperty::from("12")]);
}
}