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
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ build_test(
"//examples/agent_grounding:notebooks",
"//tests/features:api_features",
"//tests/contracts:geoarrow-interchange-v1.json",
"//tests/fixtures:geoarrow_v1",
"//tests/tck:corpus",
"//tests/release_workflows:sna_phase2_advisory",
"//crates/graphforge-cypher:corpus_data",
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

255 changes: 218 additions & 37 deletions crates/graphforge-api/src/bulk_construction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -988,14 +988,16 @@ fn input_schema(
}
validate_property_name(kind, field.name())?;
if field.metadata().contains_key("ARROW:extension:name") {
spatial_type_from_field(field).ok_or_else(|| {
field_error(
kind,
BulkValidationReason::UnsupportedPropertyType,
field.name(),
"unsupported or non-canonical Arrow extension property",
)
})?;
(spatial_type_from_field(field).is_some() || is_preserved_spatial_field(field))
.then_some(())
.ok_or_else(|| {
field_error(
kind,
BulkValidationReason::UnsupportedPropertyType,
field.name(),
"unsupported Arrow extension property",
)
})?;
} else {
validate_property_type(kind, field.name(), field.data_type())?;
}
Expand Down Expand Up @@ -1115,14 +1117,16 @@ fn validate_batch_schema(
for field in properties {
validate_property_name(kind, field.name())?;
if field.metadata().contains_key("ARROW:extension:name") {
spatial_type_from_field(field).ok_or_else(|| {
field_error(
kind,
BulkValidationReason::UnsupportedPropertyType,
field.name(),
"unsupported or non-canonical Arrow extension property",
)
})?;
(spatial_type_from_field(field).is_some() || is_preserved_spatial_field(field))
.then_some(())
.ok_or_else(|| {
field_error(
kind,
BulkValidationReason::UnsupportedPropertyType,
field.name(),
"unsupported Arrow extension property",
)
})?;
} else {
validate_property_type(kind, field.name(), field.data_type())?;
}
Expand Down Expand Up @@ -1198,15 +1202,6 @@ where
for (field, array) in columns {
owner_validation(field.name(), field)?;
let value = if field.metadata().contains_key("ARROW:extension:name") {
spatial_type_from_field(field).ok_or_else(|| {
row_error(
kind,
BulkValidationReason::PropertyTypeMismatch,
ordinal,
field.name(),
"spatial field metadata is not canonical",
)
})?;
if array.is_null(row) {
PropValue::Null
} else {
Expand Down Expand Up @@ -1245,24 +1240,45 @@ fn preflight_spatial_columns(
columns: &[(&Field, &ArrayRef)],
) -> Result<(), BulkValidationError> {
for (field, array) in columns {
let Some(spatial) = spatial_type_from_field(field) else {
let Some(_) = field.metadata().get("ARROW:extension:name") else {
continue;
};
spatial
.validate_array(
field,
array.as_ref(),
graphforge_ontology::SpatialValidationLimits::default(),
)
.map_err(|error| {
row_error(
if let Some(spatial) = spatial_type_from_field(field) {
spatial
.validate_array(
field,
array.as_ref(),
graphforge_ontology::SpatialValidationLimits::default(),
)
.map_err(|error| {
row_error(
kind,
BulkValidationReason::PropertyTypeMismatch,
first_ordinal,
field.name(),
error.code(),
)
})?;
} else {
let limits = graphforge_ontology::SpatialValidationLimits::default();
let metadata = field
.metadata()
.get("ARROW:extension:metadata")
.and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
.filter(|value| value.get("crs").is_some());
if metadata.is_none()
|| array.len() > limits.max_geometries
|| array.get_array_memory_size() > limits.max_bytes
{
return Err(row_error(
kind,
BulkValidationReason::PropertyTypeMismatch,
first_ordinal,
field.name(),
error.code(),
)
})?;
"preserved spatial field has malformed metadata or exceeds its resource limit",
));
}
}
}
Ok(())
}
Expand All @@ -1289,6 +1305,39 @@ fn spatial_type_from_field(field: &Field) -> Option<graphforge_ontology::Spatial
Some(SpatialType { geometry, crs })
}

fn is_preserved_spatial_field(field: &Field) -> bool {
use graphforge_ontology::{SpatialCrs, SpatialGeometryType, SpatialType};

let Some(metadata) = field.metadata().get("ARROW:extension:metadata") else {
return false;
};
if serde_json::from_str::<serde_json::Value>(metadata)
.ok()
.and_then(|value| value.get("crs").cloned())
.is_none()
{
return false;
}
[
SpatialGeometryType::Point,
SpatialGeometryType::LineString,
SpatialGeometryType::Polygon,
SpatialGeometryType::MultiPoint,
SpatialGeometryType::MultiLineString,
SpatialGeometryType::MultiPolygon,
]
.into_iter()
.any(|geometry| {
SpatialType {
geometry,
crs: SpatialCrs::Epsg4326,
}
.field("spatial", field.is_nullable())
.data_type()
== field.data_type()
})
}

fn property_value_at(array: &dyn Array, row: usize) -> Result<PropValue, String> {
if array.is_null(row) {
return Ok(PropValue::Null);
Expand Down Expand Up @@ -2198,6 +2247,138 @@ mod tests {
assert_eq!(error.message, "GF_SPATIAL_COORDINATE_OUT_OF_RANGE");
}

#[test]
fn preserved_spatial_bulk_preflight_accepts_explicit_metadata_and_rejects_malformed() {
use std::collections::HashMap;

let array: ArrayRef = Arc::new(StructArray::from(vec![
(
Arc::new(Field::new("x", DataType::Float64, false)),
Arc::new(Float64Array::from(vec![-104.9903])) as ArrayRef,
),
(
Arc::new(Field::new("y", DataType::Float64, false)),
Arc::new(Float64Array::from(vec![39.7392])) as ArrayRef,
),
]));
let field =
Field::new("location", array.data_type().clone(), true).with_metadata(HashMap::from([
(
"ARROW:extension:name".into(),
"geoarrow.vendor_point".into(),
),
(
"ARROW:extension:metadata".into(),
"{\"crs\":\"OGC:CRS84\",\"edges\":\"spherical\"}".into(),
),
]));
preflight_spatial_columns(BulkInputKind::Node, 0, &[(&field, &array)]).unwrap();
let value =
normalize_properties(
BulkInputKind::Node,
0,
0,
&[(&field, &array)],
|_, _| Ok(()),
)
.unwrap();
let PropValue::Spatial(value) = &value["location"] else {
panic!("preserved field must remain spatial");
};
assert_eq!(
value.extension_name.as_deref(),
Some("geoarrow.vendor_point")
);
assert_eq!(
value.extension_metadata.as_deref(),
Some("{\"crs\":\"OGC:CRS84\",\"edges\":\"spherical\"}")
);

let malformed =
Field::new("location", array.data_type().clone(), true).with_metadata(HashMap::from([
(
"ARROW:extension:name".into(),
"geoarrow.vendor_point".into(),
),
("ARROW:extension:metadata".into(), "{}".into()),
]));
let error =
preflight_spatial_columns(BulkInputKind::Node, 0, &[(&malformed, &array)]).unwrap_err();
assert_eq!(error.reason, BulkValidationReason::PropertyTypeMismatch);
}

#[test]
fn preserved_spatial_bulk_publish_reopens_and_projects_exact_metadata() {
use std::collections::HashMap;

let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project");
std::fs::create_dir(&path).unwrap();
let extension_name = "geoarrow.vendor_point";
let extension_metadata = "{\"crs\":\"OGC:CRS84\",\"edges\":\"spherical\"}";
let location: ArrayRef = Arc::new(StructArray::from(vec![
(
Arc::new(Field::new("x", DataType::Float64, false)),
Arc::new(Float64Array::from(vec![-104.9903])) as ArrayRef,
),
(
Arc::new(Field::new("y", DataType::Float64, false)),
Arc::new(Float64Array::from(vec![39.7392])) as ArrayRef,
),
]));
let location_field = Field::new("location", location.data_type().clone(), true)
.with_metadata(HashMap::from([
("ARROW:extension:name".into(), extension_name.into()),
("ARROW:extension:metadata".into(), extension_metadata.into()),
]));
let schema = bulk_node_input_schema(vec![location_field]).unwrap();
let input = RecordBatch::try_new(
schema,
vec![
Arc::new(
FixedSizeBinaryArray::try_from_iter([uuid(8_020).as_bytes()].into_iter())
.unwrap(),
),
Arc::new(StringArray::from(vec!["Place"])),
location,
],
)
.unwrap();

let graph = GraphForge::new(path.to_str()).unwrap();
graph
.publish_bulk_nodes(operation(8_021), &[input])
.unwrap();
drop(graph);

let reopened = GraphForge::new(path.to_str()).unwrap();
let result = reopened
.execute("MATCH (n:Place) RETURN n.location AS location")
.unwrap();
let field = result.schema.field_with_name("location").unwrap();
assert_eq!(field.metadata()["ARROW:extension:name"], extension_name);
assert_eq!(
field.metadata()["ARROW:extension:metadata"],
extension_metadata
);
let values = result.batches[0]
.column_by_name("location")
.unwrap()
.as_any()
.downcast_ref::<StructArray>()
.unwrap();
assert_eq!(
values
.column_by_name("x")
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.value(0),
-104.9903
);
}

#[test]
fn wave13_validation_display_and_disappeared_endpoint_are_structured() {
let error = BulkValidationError {
Expand Down
2 changes: 2 additions & 0 deletions crates/graphforge-api/src/construction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ mod tests {
crs: SpatialCrs::Epsg4326,
},
coordinates: SpatialCoordinates::Point([-104.9903, 39.7392]),
extension_name: None,
extension_metadata: None,
});
let source = graph
.add_node(
Expand Down
10 changes: 10 additions & 0 deletions crates/graphforge-api/tests/e2e_baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5135,4 +5135,14 @@ fn certified_spatial_point_and_distance_execute_in_rust() {
.contains("does not implicitly reproject mixed CRS"),
"{error}"
);

let error = gf
.execute("RETURN point({x: 0, y: 0, crs: 'OGC:CRS84'})")
.expect_err("point must reject preserved-only CRS construction");
assert!(
error
.to_string()
.contains("unsupported spatial CRS `OGC:CRS84` for point() computation"),
"{error}"
);
}
11 changes: 9 additions & 2 deletions crates/graphforge-bindings-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,11 +596,18 @@ fn json_to_prop_value(value: &serde_json::Value) -> Result<PropValue> {
.collect::<Result<Vec<_>>>()?,
),
Value::Object(_) => {
PropValue::Spatial(serde_json::from_value(value.clone()).map_err(|error| {
let spatial: graphforge_api::SpatialValue = serde_json::from_value(value.clone())
.map_err(|error| {
to_napi_err(&GfError::Validation(format!(
"invalid canonical spatial property: {error}"
)))
})?;
spatial.validate_interchange_profile().map_err(|error| {
to_napi_err(&GfError::Validation(format!(
"invalid canonical spatial property: {error}"
)))
})?)
})?;
PropValue::Spatial(spatial)
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ const fixture = JSON.parse(
const spatialValue = (entry) => ({
spatial_type: { geometry: entry.geometry, crs: entry.crs },
coordinates: entry.coordinates,
...(entry.preservedOnly
? {
extension_name: entry.extensionName,
extension_metadata: entry.extensionMetadata,
}
: {}),
});

const flattenCoordinates = (value) => {
Expand Down
Loading