Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions crates/graphforge-rel/src/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14731,6 +14731,127 @@ mod tests {
}
}

#[test]
fn heterogeneous_map_access_returns_exact_values_and_rejects_non_maps() {
use datafusion::arrow::array::{ArrayRef, StringArray};
use datafusion::scalar::ScalarValue as S;

let map = const_map_scalar(&[
("answer".to_owned(), S::Int64(Some(42))),
("empty".to_owned(), S::Int64(None)),
])
.expect("map scalar");
let encoded = build_het_struct(&[map], 1).expect("tagged map");
let return_type =
het_value_access_return_type(encoded.data_type()).expect("map value type");
let null_value = S::try_from(&return_type).expect("typed null");
let keys = |key: Option<&str>| -> ArrayRef { Arc::new(StringArray::from(vec![key])) };

let found = het_map_access_value(&encoded, &keys(Some("answer")), 0, &null_value)
.expect("existing map key");
assert_eq!(decode_het(&found), Some(S::Int64(Some(42))));

let stored_null = het_map_access_value(&encoded, &keys(Some("empty")), 0, &null_value)
.expect("stored null");
assert_eq!(decode_het(&stored_null), Some(S::Null));
assert_eq!(
het_map_access_value(&encoded, &keys(Some("missing")), 0, &null_value)
.expect("missing key"),
null_value
);
assert_eq!(
het_map_access_value(&encoded, &keys(None), 0, &null_value).expect("null key"),
null_value
);

let non_map = build_het_struct(&[S::Int64(Some(7))], 0).expect("tagged integer");
let error = het_map_access_value(&non_map, &keys(Some("answer")), 0, &null_value)
.expect_err("a tagged integer is not dynamically property-readable");
assert_eq!(
error.to_string(),
"Execution error: invalid argument type: dynamic value access requires a map"
);
}

#[test]
fn dynamic_struct_access_observes_missing_null_type_and_row_null_semantics() {
use datafusion::arrow::array::{Array, ArrayRef, Int64Array, StringArray, StructArray};
use datafusion::arrow::buffer::NullBuffer;
use datafusion::arrow::datatypes::{Field, Fields};
use datafusion::config::ConfigOptions;

let values: ArrayRef = Arc::new(StructArray::new(
Fields::from(vec![Field::new("score", DataType::Int64, true)]),
vec![Arc::new(Int64Array::from(vec![Some(9), None, Some(11)]))],
Some(NullBuffer::from(vec![true, true, false])),
));
let invoke = |keys: ArrayRef| -> datafusion::error::Result<ArrayRef> {
let udf = CypherValueAccess::new();
let result = udf.invoke_with_args(ScalarFunctionArgs {
args: vec![
ColumnarValue::Array(Arc::clone(&values)),
ColumnarValue::Array(keys),
],
arg_fields: vec![
Arc::new(Field::new("value", values.data_type().clone(), true)),
Arc::new(Field::new("key", DataType::Utf8, true)),
],
number_rows: 3,
return_field: Arc::new(Field::new("out", DataType::Int64, true)),
config_options: Arc::new(ConfigOptions::default()),
})?;
match result {
ColumnarValue::Array(array) => Ok(array),
ColumnarValue::Scalar(value) => value.to_array_of_size(3),
}
};

let result = invoke(Arc::new(StringArray::from(vec![
Some("score"),
Some("missing"),
Some("score"),
])))
.expect("dynamic struct access");
let result = result.as_any().downcast_ref::<Int64Array>().expect("Int64");
assert_eq!(result.value(0), 9);
assert!(result.is_null(1), "an absent property is null");
assert!(result.is_null(2), "a null graph-element row is null");

let bad_keys: ArrayRef = Arc::new(Int64Array::from(vec![1, 2, 3]));
let error = invoke(bad_keys).expect_err("numeric property key");
assert!(
error
.to_string()
.contains("dynamic map/property access key must be a string"),
"{error}"
);
}

#[test]
fn temporal_accessor_type_matrix_distinguishes_values_from_properties() {
// Date and duration dispatch through their dedicated lowering paths.
assert!(!temporal_accessor_valid(&DataType::Date32, "year"));
assert!(!temporal_accessor_valid(&DataType::Date32, "timezone"));
assert!(temporal_accessor_valid(
&ScalarValue::Time64Nanosecond(None).data_type(),
"nanosecond"
));
assert!(!temporal_accessor_valid(
&duration_scalar(None).data_type(),
"monthsOfYear"
));
assert!(temporal_accessor_valid(
&localdatetime_scalar(None).data_type(),
"year"
));
assert!(temporal_accessor_valid(
&datetime_scalar(None).data_type(),
"offsetSeconds"
));
assert!(!temporal_accessor_valid(&DataType::Utf8, "year"));
assert!(!temporal_accessor_valid(&DataType::Int64, "day"));
}

#[test]
fn scalar_to_ir_literal_round_trips_a_list() {
// A homogeneous list now stores (#1006): scalar List → IrLiteral::List
Expand Down
155 changes: 155 additions & 0 deletions crates/graphforge-rel/src/lowerer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5153,6 +5153,161 @@ mod tests {
);
}

#[test]
#[allow(clippy::too_many_lines)]
fn created_rows_schema_preserves_input_skips_references_and_types_minted_nodes() {
use std::collections::HashMap;

use datafusion::arrow::datatypes::{DataType, Field};
use datafusion::common::{DFSchema, TableReference};
use datafusion::logical_expr::{col, lit};
use graphforge_plan::ResolvedNodeSpec;

let input = Arc::new(
DFSchema::new_with_metadata(
vec![(
Some(TableReference::bare("input")),
Arc::new(Field::new("seed", DataType::Int64, false)),
)],
HashMap::new(),
)
.unwrap(),
);
let reference = ResolvedNodeSpec {
var: 1,
label_ids: vec![7],
label_names: vec!["Existing".into()],
properties: vec![("ignored".into(), IrLiteral::Int(1))],
computed_properties: vec![],
is_reference: true,
};
let minted = ResolvedNodeSpec {
var: 2,
label_ids: vec![8, 9],
label_names: vec!["New".into(), "Tagged".into()],
properties: vec![("active".into(), IrLiteral::Bool(true))],
computed_properties: vec![("copied_seed".into(), col("seed") + lit(1_i64))],
is_reference: false,
};

let schema = GraphPlanLowerer::created_rows_schema(&[reference, minted], &input).unwrap();
let fields: Vec<_> = schema
.iter()
.map(|(qualifier, field)| {
(
qualifier.map(ToString::to_string),
field.name().clone(),
field.data_type().clone(),
field.is_nullable(),
)
})
.collect();

assert_eq!(
fields.len(),
7,
"one input plus four identity and two property fields"
);
assert_eq!(
fields[0],
(Some("input".into()), "seed".into(), DataType::Int64, false)
);
assert_eq!(
fields[1..5]
.iter()
.map(|(q, name, ty, nullable)| { (q.clone(), name.clone(), ty.clone(), *nullable) })
.collect::<Vec<_>>(),
vec![
(
Some("var_2".into()),
"node_uuid".into(),
DataType::FixedSizeBinary(16),
false
),
(
Some("var_2".into()),
"node_id".into(),
DataType::UInt64,
false,
),
(
Some("var_2".into()),
"type_id".into(),
DataType::UInt32,
false,
),
(
Some("var_2".into()),
"type_ids".into(),
DataType::List(Arc::new(Field::new("item", DataType::UInt32, false))),
false,
),
]
);
assert_eq!(
fields[5],
(
Some("var_2".into()),
"active".into(),
DataType::Boolean,
true
)
);
assert_eq!(
fields[6],
(
Some("var_2".into()),
"copied_seed".into(),
DataType::Int64,
true,
)
);
assert!(
fields
.iter()
.all(|(q, _, _, _)| q.as_deref() != Some("var_1")),
"reference nodes are passed through only and never duplicated"
);
}

#[test]
fn created_rows_schema_rejects_reserved_and_unbound_computed_properties() {
use graphforge_plan::ResolvedNodeSpec;

let input = Arc::new(datafusion::common::DFSchema::empty());
for reserved in ["node_uuid", "node_id", "type_id", "type_ids"] {
let spec = ResolvedNodeSpec {
var: 3,
label_ids: vec![],
label_names: vec![],
properties: vec![(reserved.into(), IrLiteral::Null)],
computed_properties: vec![],
is_reference: false,
};
let error = GraphPlanLowerer::created_rows_schema(&[spec], &input).unwrap_err();
assert_eq!(
error.to_string(),
format!(
"unsupported expression: CREATE property `{reserved}` collides with a reserved node topology field"
)
);
}

let unbound = ResolvedNodeSpec {
var: 4,
label_ids: vec![],
label_names: vec![],
properties: vec![],
computed_properties: vec![("value".into(), datafusion::logical_expr::col("missing"))],
is_reference: false,
};
let error = GraphPlanLowerer::created_rows_schema(&[unbound], &input).unwrap_err();
assert!(
error.to_string().contains("No field named missing"),
"unbound computed properties must retain the DataFusion schema error: {error}"
);
}

// -----------------------------------------------------------------------
// DELETE lowering (#740)
// -----------------------------------------------------------------------
Expand Down
Loading