use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::common::{DFSchema, DFSchemaRef};
use datafusion::error::Result;
use datafusion::logical_expr::{Expr, ExprSchemable, LogicalPlan, UserDefinedLogicalNodeCore};
use ursa_core::catalog::Call;
use super::functions::{display_call, plan};
use super::traversal::Traversal;
pub const WEIGHTED_SOURCE: &str = "__ursa_source";
pub const WEIGHTED_DESTINATION: &str = "__ursa_destination";
pub fn weight_column(i: usize) -> String {
format!("__ursa_weight_{i}")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Output {
pub name: String,
pub call: Call,
pub field: usize,
pub weight: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct GraphAlgorithmNode {
pub graph: String,
pub input: Arc<LogicalPlan>,
pub weighted: Option<Arc<LogicalPlan>>,
pub vertices: Option<Arc<LogicalPlan>>,
pub values: Option<Arc<LogicalPlan>>,
pub source: Expr,
pub destination: Expr,
pub weights: Vec<String>,
pub outputs: Vec<Output>,
pub per_node: bool,
pub id_type: DataType,
schema: DFSchemaRef,
}
impl GraphAlgorithmNode {
#[allow(clippy::too_many_arguments)]
pub fn try_new(
graph: String,
input: Arc<LogicalPlan>,
weighted: Option<Arc<LogicalPlan>>,
vertices: Option<Arc<LogicalPlan>>,
values: Option<Arc<LogicalPlan>>,
source: Expr,
destination: Expr,
weights: Vec<String>,
outputs: Vec<Output>,
per_node: bool,
) -> Result<Self> {
let id_type = endpoint_id_type(&graph, &input, &source, &destination)?;
check_vertices(&graph, vertices.as_deref())?;
if let Some(values) = &values {
let columns = values.schema().fields().len();
if columns != 2 {
return Err(plan(format!(
"the node values of graph '{graph}' must be two columns, node ids then \
values; the query returns {columns}"
)));
}
}
let mut fields = Vec::new();
if per_node {
fields.push(Field::new("id", id_type.clone(), false));
}
for o in &outputs {
let spec = super::functions::outputs(o.call.spec())[o.field];
let ty = match (&o.call, &values) {
(Call::NeighborAgg { agg, .. }, Some(values)) => {
super::kernels::neighbors_agg_type(*agg, values.schema().field(1).data_type())
}
_ => spec.ty.data_type(),
};
fields.push(Field::new(&o.name, ty, spec.nullable));
}
let schema = Arc::new(DFSchema::try_from(Schema::new(fields))?);
Ok(Self {
graph,
input,
weighted,
vertices,
values,
source,
destination,
weights,
outputs,
per_node,
id_type,
schema,
})
}
pub fn endpoint_indices(&self) -> Result<(usize, usize)> {
endpoint_positions(&self.graph, &self.input, &self.source, &self.destination)
}
pub(crate) fn display_outputs(&self) -> String {
let outputs: Vec<String> = self
.outputs
.iter()
.map(|o| {
let weight = o.weight.map(|i| self.weights[i].as_str());
let call = display_call(&o.call, weight);
let spec = o.call.spec();
if spec.outputs.len() == 1 && o.name == spec.outputs[0].name {
call
} else {
format!("{}: {call}", o.name)
}
})
.collect();
outputs.join(", ")
}
}
pub(crate) fn endpoint_id_type(
graph: &str,
input: &LogicalPlan,
source: &Expr,
destination: &Expr,
) -> Result<DataType> {
let source_type = source.get_type(input.schema())?;
let destination_type = destination.get_type(input.schema())?;
match (id_type(&source_type), id_type(&destination_type)) {
(Some(s), Some(d)) if s == d => Ok(s),
(Some(_), Some(_)) => Err(plan(format!(
"graph '{graph}': the source and destination columns must have one id type \
(both integer or both string); got {source_type} and {destination_type}"
))),
(None, _) | (_, None) => {
let bad = if id_type(&source_type).is_none() {
source_type
} else {
destination_type
};
Err(plan(format!(
"graph '{graph}': node ids must be an integer or string column; got {bad}"
)))
}
}
}
pub(crate) fn check_vertices(graph: &str, vertices: Option<&LogicalPlan>) -> Result<()> {
match vertices.map(|v| v.schema().fields().len()) {
None | Some(1) => Ok(()),
Some(n) => Err(plan(format!(
"graph '{graph}': the vertex keys must be one column; got {n}"
))),
}
}
fn endpoint_positions(
graph: &str,
input: &LogicalPlan,
source: &Expr,
destination: &Expr,
) -> Result<(usize, usize)> {
let index = |e: &Expr| -> Result<usize> {
match e {
Expr::Column(c) => input.schema().index_of_column(c),
other => Err(plan(format!(
"graph '{graph}': an edge endpoint must be a column; got {other}"
))),
}
};
Ok((index(source)?, index(destination)?))
}
pub(crate) fn id_type(t: &DataType) -> Option<DataType> {
ursa_io::ids::canonical_id_type(t)
}
type Key<'a> = (&'a String, &'a Vec<Output>, &'a Vec<String>, bool);
impl GraphAlgorithmNode {
fn key(&self) -> Key<'_> {
(&self.graph, &self.outputs, &self.weights, self.per_node)
}
}
impl PartialEq for GraphAlgorithmNode {
fn eq(&self, other: &Self) -> bool {
self.key() == other.key()
&& self.source == other.source
&& self.destination == other.destination
&& self.input == other.input
&& self.weighted == other.weighted
&& self.vertices == other.vertices
&& self.values == other.values
}
}
impl Eq for GraphAlgorithmNode {}
impl Hash for GraphAlgorithmNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.key().hash(state);
self.source.hash(state);
self.destination.hash(state);
self.input.hash(state);
self.weighted.hash(state);
self.vertices.hash(state);
self.values.hash(state);
}
}
impl PartialOrd for GraphAlgorithmNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.key().cmp(&other.key()) {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.source.partial_cmp(&other.source)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.destination.partial_cmp(&other.destination)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.input.partial_cmp(&other.input)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.weighted.partial_cmp(&other.weighted)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.vertices.partial_cmp(&other.vertices)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
self.values.partial_cmp(&other.values)
}
}
impl UserDefinedLogicalNodeCore for GraphAlgorithmNode {
fn name(&self) -> &str {
"GraphAlgorithm"
}
fn inputs(&self) -> Vec<&LogicalPlan> {
let mut inputs = vec![self.input.as_ref()];
inputs.extend(self.weighted.as_deref());
inputs.extend(self.vertices.as_deref());
inputs.extend(self.values.as_deref());
inputs
}
fn schema(&self) -> &DFSchemaRef {
&self.schema
}
fn expressions(&self) -> Vec<Expr> {
vec![self.source.clone(), self.destination.clone()]
}
fn fmt_for_explain(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"GraphAlgorithm: graph={}, edges=({}, {}), outputs=[{}]",
self.graph,
self.source,
self.destination,
self.display_outputs()
)
}
fn with_exprs_and_inputs(&self, exprs: Vec<Expr>, inputs: Vec<LogicalPlan>) -> Result<Self> {
let [source, destination]: [Expr; 2] = exprs
.try_into()
.map_err(|_| plan("GraphAlgorithm takes two expressions".into()))?;
let expected = 1
+ usize::from(self.weighted.is_some())
+ usize::from(self.vertices.is_some())
+ usize::from(self.values.is_some());
if inputs.len() != expected {
return Err(plan(format!(
"GraphAlgorithm takes {expected} inputs; got {}",
inputs.len()
)));
}
let mut inputs = inputs.into_iter().map(Arc::new);
let input = inputs.next().expect("checked above");
let weighted = self.weighted.as_ref().and_then(|_| inputs.next());
let vertices = self.vertices.as_ref().and_then(|_| inputs.next());
let values = self.values.as_ref().and_then(|_| inputs.next());
Self::try_new(
self.graph.clone(),
input,
weighted,
vertices,
values,
source,
destination,
self.weights.clone(),
self.outputs.clone(),
self.per_node,
)
}
fn necessary_children_exprs(&self, _output_columns: &[usize]) -> Option<Vec<Vec<usize>>> {
let (source, destination) = self.endpoint_indices().ok()?;
let mut needed = vec![source, destination];
needed.sort_unstable();
needed.dedup();
let mut children = vec![needed];
if let Some(weighted) = &self.weighted {
children.push((0..weighted.schema().fields().len()).collect());
}
if self.vertices.is_some() {
children.push(vec![0]);
}
if self.values.is_some() {
children.push(vec![0, 1]);
}
Some(children)
}
fn prevent_predicate_push_down_columns(&self) -> std::collections::HashSet<String> {
self.schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect()
}
}
#[derive(Debug, Clone)]
pub struct GraphTraversalNode {
pub graph: String,
pub traversal: Traversal,
pub input: Arc<LogicalPlan>,
pub weighted: Option<Arc<LogicalPlan>>,
pub vertices: Option<Arc<LogicalPlan>>,
pub weight: Option<String>,
pub nodes: Vec<Arc<LogicalPlan>>,
pub written: Vec<String>,
pub source: Expr,
pub destination: Expr,
pub id_type: DataType,
schema: DFSchemaRef,
}
impl GraphTraversalNode {
#[allow(clippy::too_many_arguments)]
pub fn try_new(
graph: String,
traversal: Traversal,
input: Arc<LogicalPlan>,
weighted: Option<Arc<LogicalPlan>>,
vertices: Option<Arc<LogicalPlan>>,
weight: Option<String>,
nodes: Vec<Arc<LogicalPlan>>,
written: Vec<String>,
source: Expr,
destination: Expr,
) -> Result<Self> {
let id_type = endpoint_id_type(&graph, &input, &source, &destination)?;
check_vertices(&graph, vertices.as_deref())?;
let spec = traversal.spec();
let expected = spec.node_params().count();
if nodes.len() != expected || written.len() != expected {
return Err(plan(format!(
"{}() takes {expected} node-id inputs; got {}",
spec.name,
nodes.len()
)));
}
if weighted.is_some() != weight.is_some() {
return Err(plan(format!(
"{}(): a weighted plan needs its weight expression",
spec.name
)));
}
for (p, n) in spec.node_params().zip(&nodes) {
let columns = n.schema().fields().len();
if columns != 1 {
return Err(plan(format!(
"{}({} =>) must be one column of node ids; the query returns {columns} \
columns",
spec.name, p.name
)));
}
}
let schema = Arc::new(DFSchema::try_from(spec.schema(&id_type))?);
Ok(Self {
graph,
traversal,
input,
weighted,
vertices,
weight,
nodes,
written,
source,
destination,
id_type,
schema,
})
}
pub fn endpoint_indices(&self) -> Result<(usize, usize)> {
endpoint_positions(&self.graph, &self.input, &self.source, &self.destination)
}
pub(crate) fn display_call(&self) -> String {
self.traversal
.display(&self.written, self.weight.as_deref())
}
}
type TraversalKey<'a> = (
&'a String,
&'a Traversal,
&'a Option<String>,
&'a Vec<String>,
);
impl GraphTraversalNode {
fn key(&self) -> TraversalKey<'_> {
(&self.graph, &self.traversal, &self.weight, &self.written)
}
}
impl PartialEq for GraphTraversalNode {
fn eq(&self, other: &Self) -> bool {
self.key() == other.key()
&& self.source == other.source
&& self.destination == other.destination
&& self.input == other.input
&& self.weighted == other.weighted
&& self.vertices == other.vertices
&& self.nodes == other.nodes
}
}
impl Eq for GraphTraversalNode {}
impl Hash for GraphTraversalNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.key().hash(state);
self.source.hash(state);
self.destination.hash(state);
self.input.hash(state);
self.weighted.hash(state);
self.vertices.hash(state);
self.nodes.hash(state);
}
}
impl PartialOrd for GraphTraversalNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.key().cmp(&other.key()) {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.source.partial_cmp(&other.source)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.destination.partial_cmp(&other.destination)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.input.partial_cmp(&other.input)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.weighted.partial_cmp(&other.weighted)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
match self.vertices.partial_cmp(&other.vertices)? {
Ordering::Equal => {}
unequal => return Some(unequal),
}
self.nodes.partial_cmp(&other.nodes)
}
}
impl UserDefinedLogicalNodeCore for GraphTraversalNode {
fn name(&self) -> &str {
"GraphTraversal"
}
fn inputs(&self) -> Vec<&LogicalPlan> {
let mut inputs = vec![self.input.as_ref()];
inputs.extend(self.weighted.as_deref());
inputs.extend(self.vertices.as_deref());
inputs.extend(self.nodes.iter().map(Arc::as_ref));
inputs
}
fn schema(&self) -> &DFSchemaRef {
&self.schema
}
fn expressions(&self) -> Vec<Expr> {
vec![self.source.clone(), self.destination.clone()]
}
fn fmt_for_explain(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"GraphTraversal: graph={}, edges=({}, {}), call={}",
self.graph,
self.source,
self.destination,
self.display_call()
)
}
fn with_exprs_and_inputs(&self, exprs: Vec<Expr>, inputs: Vec<LogicalPlan>) -> Result<Self> {
let [source, destination]: [Expr; 2] = exprs
.try_into()
.map_err(|_| plan("GraphTraversal takes two expressions".into()))?;
let weighted = usize::from(self.weighted.is_some());
let vertices = usize::from(self.vertices.is_some());
let expected = 1 + weighted + vertices + self.nodes.len();
if inputs.len() != expected {
return Err(plan(format!(
"GraphTraversal takes {expected} inputs; got {}",
inputs.len()
)));
}
let mut inputs = inputs.into_iter().map(Arc::new);
let input = inputs.next().expect("checked above");
let weighted = (weighted == 1).then(|| inputs.next().expect("checked above"));
let vertices = (vertices == 1).then(|| inputs.next().expect("checked above"));
Self::try_new(
self.graph.clone(),
self.traversal,
input,
weighted,
vertices,
self.weight.clone(),
inputs.collect(),
self.written.clone(),
source,
destination,
)
}
fn necessary_children_exprs(&self, _output_columns: &[usize]) -> Option<Vec<Vec<usize>>> {
let (source, destination) = self.endpoint_indices().ok()?;
let mut needed = vec![source, destination];
needed.sort_unstable();
needed.dedup();
let mut children = vec![needed];
if let Some(weighted) = &self.weighted {
children.push((0..weighted.schema().fields().len()).collect());
}
if self.vertices.is_some() {
children.push(vec![0]);
}
children.extend(self.nodes.iter().map(|_| vec![0]));
Some(children)
}
fn prevent_predicate_push_down_columns(&self) -> std::collections::HashSet<String> {
self.schema
.fields()
.iter()
.map(|f| f.name().clone())
.collect()
}
}