From 509a57871d09a8dc388071cc7065830b8cfa26f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20R=2E=20H=C3=B6lzlwimmer?= Date: Fri, 25 Sep 2026 19:58:47 +0000 Subject: [PATCH 1/2] feat(arrow-schema): add fixed closedness range canonical extension type Add the arrow.fixed_closedness_range canonical extension type: a bounded interval over an orderable type T with a single type-level closedness ("left", "right", "both" or "neither") shared by all values, stored as a two-field lower/upper struct where a null bound means an unbounded side. --- .../canonical/fixed_closedness_range.rs | 597 ++++++++++++++++++ arrow-schema/src/extension/canonical/mod.rs | 16 + 2 files changed, 613 insertions(+) create mode 100644 arrow-schema/src/extension/canonical/fixed_closedness_range.rs diff --git a/arrow-schema/src/extension/canonical/fixed_closedness_range.rs b/arrow-schema/src/extension/canonical/fixed_closedness_range.rs new file mode 100644 index 000000000000..b11264827f96 --- /dev/null +++ b/arrow-schema/src/extension/canonical/fixed_closedness_range.rs @@ -0,0 +1,597 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Fixed closedness range +//! +//! + +use serde_core::de::{IgnoredAny, MapAccess, Visitor}; +use serde_core::ser::SerializeStruct; +use serde_core::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ArrowError, DataType, Fields, extension::ExtensionType}; + +/// Which bound(s) of a [`FixedClosednessRange`] interval are inclusive. +/// +/// Uses the same vocabulary as pandas: "left", "right", "both", "neither". +/// A null (unbounded) bound is always treated as exclusive, regardless of +/// this value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangeClosed { + /// The left (lower) endpoint is included; the right (upper) is excluded. + Left, + /// The left (lower) endpoint is excluded; the right (upper) is included. + Right, + /// Both endpoints are included (closed interval). + Both, + /// Neither endpoint is included (open interval). + Neither, +} + +impl Serialize for RangeClosed { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(match self { + RangeClosed::Left => "left", + RangeClosed::Right => "right", + RangeClosed::Both => "both", + RangeClosed::Neither => "neither", + }) + } +} + +struct RangeClosedVisitor; + +impl Visitor<'_> for RangeClosedVisitor { + type Value = RangeClosed; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("one of \"left\", \"right\", \"both\", \"neither\"") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde_core::de::Error, + { + match value { + "left" => Ok(RangeClosed::Left), + "right" => Ok(RangeClosed::Right), + "both" => Ok(RangeClosed::Both), + "neither" => Ok(RangeClosed::Neither), + _ => Err(serde_core::de::Error::unknown_variant( + value, + &["left", "right", "both", "neither"], + )), + } + } +} + +impl<'de> Deserialize<'de> for RangeClosed { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(RangeClosedVisitor) + } +} + +/// Extension type metadata for [`FixedClosednessRange`]. +#[derive(Debug, Clone, PartialEq)] +pub struct FixedClosednessRangeMetadata { + /// Whether the interval endpoints are included or excluded. + closed: RangeClosed, +} + +impl FixedClosednessRangeMetadata { + /// Returns a new `FixedClosednessRangeMetadata`. + pub fn new(closed: RangeClosed) -> Self { + FixedClosednessRangeMetadata { closed } + } + + /// Returns whether the interval endpoints are included or excluded. + pub fn closed(&self) -> RangeClosed { + self.closed + } +} + +impl Serialize for FixedClosednessRangeMetadata { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("FixedClosednessRangeMetadata", 1)?; + state.serialize_field("closed", &self.closed)?; + state.end() + } +} + +#[derive(Debug)] +enum MetadataField { + Closed, + /// Any key other than `closed`. Unknown keys are ignored to allow + /// forward-compatible extensions, as required by the specification. + Other, +} + +struct MetadataFieldVisitor; + +impl Visitor<'_> for MetadataFieldVisitor { + type Value = MetadataField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a metadata field name") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde_core::de::Error, + { + match value { + "closed" => Ok(MetadataField::Closed), + _ => Ok(MetadataField::Other), + } + } +} + +impl<'de> Deserialize<'de> for MetadataField { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_identifier(MetadataFieldVisitor) + } +} + +struct FixedClosednessRangeMetadataVisitor; + +impl<'de> Visitor<'de> for FixedClosednessRangeMetadataVisitor { + type Value = FixedClosednessRangeMetadata; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("struct FixedClosednessRangeMetadata") + } + + fn visit_seq(self, mut seq: V) -> Result + where + V: serde_core::de::SeqAccess<'de>, + { + let closed = seq + .next_element()? + .ok_or_else(|| serde_core::de::Error::invalid_length(0, &self))?; + Ok(FixedClosednessRangeMetadata { closed }) + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut closed = None; + + while let Some(key) = map.next_key()? { + match key { + MetadataField::Closed => { + if closed.is_some() { + return Err(serde_core::de::Error::duplicate_field("closed")); + } + closed = Some(map.next_value()?); + } + MetadataField::Other => { + // Unknown keys are ignored for forward compatibility. + map.next_value::()?; + } + } + } + + let closed = closed.ok_or_else(|| serde_core::de::Error::missing_field("closed"))?; + Ok(FixedClosednessRangeMetadata { closed }) + } +} + +impl<'de> Deserialize<'de> for FixedClosednessRangeMetadata { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_struct( + "FixedClosednessRangeMetadata", + &["closed"], + FixedClosednessRangeMetadataVisitor, + ) + } +} + +/// The extension type for a bounded set (mathematical interval) whose +/// closedness is a type-level parameter shared by all values. +/// +/// Extension name: `arrow.fixed_closedness_range`. +/// +/// A range is a bounded set (mathematical interval) defined by a lower and +/// an upper bound over an orderable value type T. T may be any orderable +/// Arrow type, for example an integer, floating-point, decimal, date, time, +/// timestamp, duration, string or binary type. This specification defines +/// only the storage layout, not the order of any type. +/// +/// The storage type is a `Struct` with exactly two fields, in order: +/// - `lower` (type T): the lower bound. +/// - `upper` (type T): the upper bound. +/// +/// Both fields must share the same data type T. Each bound may +/// independently be nullable or non-nullable: a nullable bound can hold +/// null to represent an unbounded (infinite) endpoint on that side, while a +/// non-nullable bound is always finite. A null bound is always treated as +/// exclusive, regardless of the `closed` parameter: only a null bound means +/// the range is unbounded on that side. +/// +/// The `closed` parameter specifies which non-null bound(s) are inclusive; +/// see [`RangeClosed`]. It is required and is not defaulted on the wire: an +/// empty metadata string, or a JSON object without a `closed` key, is +/// invalid. +/// +/// Ranges whose closedness differs per value use the companion +/// `arrow.variable_closedness_range` extension type instead. +/// +/// +#[derive(Debug, Clone, PartialEq)] +pub struct FixedClosednessRange(FixedClosednessRangeMetadata); + +impl FixedClosednessRange { + /// Returns a new `FixedClosednessRange` extension type. + pub fn new(closed: RangeClosed) -> Self { + Self(FixedClosednessRangeMetadata::new(closed)) + } + + /// Returns whether the interval endpoints are included or excluded. + pub fn closed(&self) -> RangeClosed { + self.0.closed() + } +} + +impl From for FixedClosednessRange { + fn from(value: FixedClosednessRangeMetadata) -> Self { + Self(value) + } +} + +/// Validates that `data_type` is an acceptable storage type for +/// `arrow.fixed_closedness_range`. +/// +/// Checks that the data type is a struct with exactly two fields named +/// "lower" and "upper", both sharing the same data type. Each bound may be +/// nullable or non-nullable independently; nullability is not required. +fn validate_storage(data_type: &DataType) -> Result<(), ArrowError> { + let fields: &Fields = match data_type { + DataType::Struct(fields) => fields, + other => { + return Err(ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange data type mismatch, expected Struct, found {other}" + ))); + } + }; + + if fields.len() != 2 { + return Err(ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange data type mismatch, expected Struct with 2 fields, found {} field(s)", + fields.len() + ))); + } + + let lower = &fields[0]; + let upper = &fields[1]; + + if lower.name() != "lower" { + return Err(ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange data type mismatch, expected first field named \"lower\", found \"{}\"", + lower.name() + ))); + } + + if upper.name() != "upper" { + return Err(ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange data type mismatch, expected second field named \"upper\", found \"{}\"", + upper.name() + ))); + } + + if lower.data_type() != upper.data_type() { + return Err(ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange data type mismatch, \"lower\" and \"upper\" fields must have the same data type, found \"{}\" and \"{}\"", + lower.data_type(), + upper.data_type() + ))); + } + + Ok(()) +} + +impl ExtensionType for FixedClosednessRange { + const NAME: &'static str = "arrow.fixed_closedness_range"; + + type Metadata = FixedClosednessRangeMetadata; + + fn metadata(&self) -> &Self::Metadata { + &self.0 + } + + fn serialize_metadata(&self) -> Option { + Some(serde_json::to_string(self.metadata()).expect("metadata serialization")) + } + + fn deserialize_metadata(metadata: Option<&str>) -> Result { + metadata.map_or_else( + || { + Err(ArrowError::InvalidArgumentError( + "FixedClosednessRange extension type requires metadata".to_owned(), + )) + }, + |value| { + serde_json::from_str(value).map_err(|e| { + ArrowError::InvalidArgumentError(format!( + "FixedClosednessRange metadata deserialization failed: {e}" + )) + }) + }, + ) + } + + fn supports_data_type(&self, data_type: &DataType) -> Result<(), ArrowError> { + validate_storage(data_type) + } + + fn try_new(data_type: &DataType, metadata: Self::Metadata) -> Result { + validate_storage(data_type)?; + Ok(Self::from(metadata)) + } + + fn validate(data_type: &DataType, _metadata: Self::Metadata) -> Result<(), ArrowError> { + validate_storage(data_type) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "canonical_extension_types")] + use crate::extension::CanonicalExtensionType; + use crate::{ + Field, + extension::{EXTENSION_TYPE_METADATA_KEY, EXTENSION_TYPE_NAME_KEY}, + }; + + use super::*; + + fn make_range_struct(value_type: DataType) -> DataType { + DataType::Struct( + [ + Field::new("lower", value_type.clone(), true), + Field::new("upper", value_type, true), + ] + .into_iter() + .collect(), + ) + } + + #[test] + fn valid() -> Result<(), ArrowError> { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = make_range_struct(DataType::Int32); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(range.clone())?; + assert_eq!(field.try_extension_type::()?, range); + #[cfg(feature = "canonical_extension_types")] + assert_eq!( + field.try_canonical_extension_type()?, + CanonicalExtensionType::FixedClosednessRange(range) + ); + Ok(()) + } + + #[test] + fn valid_string_value_type() -> Result<(), ArrowError> { + // T may be any orderable Arrow type, including a string type. + let range = FixedClosednessRange::new(RangeClosed::Left); + let storage = make_range_struct(DataType::Utf8); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(range.clone())?; + assert_eq!(field.try_extension_type::()?, range); + Ok(()) + } + + #[test] + fn roundtrip_all_closed_values() -> Result<(), ArrowError> { + let storage = make_range_struct(DataType::Int32); + for closed in [ + RangeClosed::Left, + RangeClosed::Right, + RangeClosed::Both, + RangeClosed::Neither, + ] { + let range = FixedClosednessRange::new(closed); + let mut field = Field::new("", storage.clone(), false); + field.try_with_extension_type(range.clone())?; + let recovered = field.try_extension_type::()?; + assert_eq!(recovered.closed(), closed); + } + Ok(()) + } + + #[test] + #[should_panic(expected = "Extension type name missing")] + fn missing_name() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false) + .with_metadata([(EXTENSION_TYPE_METADATA_KEY, r#"{"closed":"both"}"#)]); + field.extension_type::(); + } + + #[test] + #[should_panic(expected = "FixedClosednessRange extension type requires metadata")] + fn missing_metadata() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false) + .with_metadata([(EXTENSION_TYPE_NAME_KEY, FixedClosednessRange::NAME)]); + field.extension_type::(); + } + + #[test] + #[should_panic(expected = "FixedClosednessRange metadata deserialization failed")] + fn invalid_metadata_bad_closed_string() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, FixedClosednessRange::NAME), + (EXTENSION_TYPE_METADATA_KEY, r#"{"closed":"invalid"}"#), + ]); + field.extension_type::(); + } + + #[test] + #[should_panic(expected = "FixedClosednessRange metadata deserialization failed")] + fn invalid_metadata_missing_closed_key() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, FixedClosednessRange::NAME), + (EXTENSION_TYPE_METADATA_KEY, r"{}"), + ]); + field.extension_type::(); + } + + #[test] + fn unknown_metadata_keys_are_ignored() -> Result<(), ArrowError> { + // Additional keys in the JSON object should be ignored to allow + // forward-compatible extensions. + let range = FixedClosednessRange::new(RangeClosed::Right); + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, FixedClosednessRange::NAME), + ( + EXTENSION_TYPE_METADATA_KEY, + r#"{"closed":"right","extra":42}"#, + ), + ]); + assert_eq!(field.try_extension_type::()?, range); + Ok(()) + } + + #[test] + #[should_panic( + expected = "FixedClosednessRange data type mismatch, expected Struct, found Int32" + )] + fn invalid_storage_non_struct() { + let range = FixedClosednessRange::new(RangeClosed::Both); + let field = Field::new("", DataType::Int32, false); + field.with_extension_type(range); + } + + #[test] + #[should_panic( + expected = "FixedClosednessRange data type mismatch, expected Struct with 2 fields" + )] + fn invalid_storage_wrong_field_count() { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = + DataType::Struct(std::iter::once(Field::new("lower", DataType::Int32, true)).collect()); + let field = Field::new("", storage, false); + field.with_extension_type(range); + } + + #[test] + #[should_panic( + expected = "FixedClosednessRange data type mismatch, expected first field named \"lower\"" + )] + fn invalid_storage_wrong_field_names() { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = DataType::Struct( + [ + Field::new("start", DataType::Int32, true), + Field::new("end", DataType::Int32, true), + ] + .into_iter() + .collect(), + ); + let field = Field::new("", storage, false); + field.with_extension_type(range); + } + + #[test] + fn accepts_non_nullable_lower() -> Result<(), ArrowError> { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, false), + Field::new("upper", DataType::Int32, true), + ] + .into_iter() + .collect(), + ); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(range.clone())?; + assert_eq!(field.try_extension_type::()?, range); + Ok(()) + } + + #[test] + fn accepts_non_nullable_upper() -> Result<(), ArrowError> { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, false), + ] + .into_iter() + .collect(), + ); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(range.clone())?; + assert_eq!(field.try_extension_type::()?, range); + Ok(()) + } + + #[test] + fn accepts_both_non_nullable() -> Result<(), ArrowError> { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, false), + Field::new("upper", DataType::Int32, false), + ] + .into_iter() + .collect(), + ); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(range.clone())?; + assert_eq!(field.try_extension_type::()?, range); + Ok(()) + } + + #[test] + #[should_panic( + expected = "FixedClosednessRange data type mismatch, \"lower\" and \"upper\" fields must have the same data type" + )] + fn invalid_storage_mismatched_types() { + let range = FixedClosednessRange::new(RangeClosed::Both); + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int64, true), + ] + .into_iter() + .collect(), + ); + let field = Field::new("", storage, false); + field.with_extension_type(range); + } +} diff --git a/arrow-schema/src/extension/canonical/mod.rs b/arrow-schema/src/extension/canonical/mod.rs index a68169c7015e..083e37340ac9 100644 --- a/arrow-schema/src/extension/canonical/mod.rs +++ b/arrow-schema/src/extension/canonical/mod.rs @@ -27,6 +27,8 @@ mod bool8; pub use bool8::Bool8; +mod fixed_closedness_range; +pub use fixed_closedness_range::{FixedClosednessRange, FixedClosednessRangeMetadata, RangeClosed}; mod fixed_shape_tensor; pub use fixed_shape_tensor::{FixedShapeTensor, FixedShapeTensorMetadata}; mod json; @@ -75,6 +77,11 @@ pub enum CanonicalExtensionType { /// Opaque(Opaque), + /// The extension type for `FixedClosednessRange`. + /// + /// + FixedClosednessRange(FixedClosednessRange), + /// The extension type for `Bool8`. /// /// @@ -103,6 +110,9 @@ impl TryFrom<&Field> for CanonicalExtensionType { Json::NAME => value.try_extension_type::().map(Into::into), Uuid::NAME => value.try_extension_type::().map(Into::into), Opaque::NAME => value.try_extension_type::().map(Into::into), + FixedClosednessRange::NAME => value + .try_extension_type::() + .map(Into::into), Bool8::NAME => value.try_extension_type::().map(Into::into), TimestampWithOffset::NAME => value .try_extension_type::() @@ -153,6 +163,12 @@ impl From for CanonicalExtensionType { } } +impl From for CanonicalExtensionType { + fn from(value: FixedClosednessRange) -> Self { + CanonicalExtensionType::FixedClosednessRange(value) + } +} + impl From for CanonicalExtensionType { fn from(value: Bool8) -> Self { CanonicalExtensionType::Bool8(value) From 5b17b7604424da18e5e500f87268ed5039c3bf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20R=2E=20H=C3=B6lzlwimmer?= Date: Fri, 25 Sep 2026 19:59:11 +0000 Subject: [PATCH 2/2] feat(arrow-schema): add variable closedness range canonical extension type Add the arrow.variable_closedness_range canonical extension type, the companion of arrow.fixed_closedness_range for ranges that cannot be canonicalized to a single closedness: each value carries its own lower_inc/upper_inc inclusivity flags alongside the lower/upper bounds, mirroring PostgreSQL's internal range representation. --- arrow-schema/src/extension/canonical/mod.rs | 16 + .../canonical/variable_closedness_range.rs | 472 ++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 arrow-schema/src/extension/canonical/variable_closedness_range.rs diff --git a/arrow-schema/src/extension/canonical/mod.rs b/arrow-schema/src/extension/canonical/mod.rs index 083e37340ac9..f62757315e37 100644 --- a/arrow-schema/src/extension/canonical/mod.rs +++ b/arrow-schema/src/extension/canonical/mod.rs @@ -39,6 +39,8 @@ mod timestamp_with_offset; pub use timestamp_with_offset::TimestampWithOffset; mod uuid; pub use uuid::Uuid; +mod variable_closedness_range; +pub use variable_closedness_range::VariableClosednessRange; mod variable_shape_tensor; pub use variable_shape_tensor::{VariableShapeTensor, VariableShapeTensorMetadata}; @@ -82,6 +84,11 @@ pub enum CanonicalExtensionType { /// FixedClosednessRange(FixedClosednessRange), + /// The extension type for `VariableClosednessRange`. + /// + /// + VariableClosednessRange(VariableClosednessRange), + /// The extension type for `Bool8`. /// /// @@ -113,6 +120,9 @@ impl TryFrom<&Field> for CanonicalExtensionType { FixedClosednessRange::NAME => value .try_extension_type::() .map(Into::into), + VariableClosednessRange::NAME => value + .try_extension_type::() + .map(Into::into), Bool8::NAME => value.try_extension_type::().map(Into::into), TimestampWithOffset::NAME => value .try_extension_type::() @@ -169,6 +179,12 @@ impl From for CanonicalExtensionType { } } +impl From for CanonicalExtensionType { + fn from(value: VariableClosednessRange) -> Self { + CanonicalExtensionType::VariableClosednessRange(value) + } +} + impl From for CanonicalExtensionType { fn from(value: Bool8) -> Self { CanonicalExtensionType::Bool8(value) diff --git a/arrow-schema/src/extension/canonical/variable_closedness_range.rs b/arrow-schema/src/extension/canonical/variable_closedness_range.rs new file mode 100644 index 000000000000..49a590aff87f --- /dev/null +++ b/arrow-schema/src/extension/canonical/variable_closedness_range.rs @@ -0,0 +1,472 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Variable closedness range +//! +//! + +use crate::{ArrowError, DataType, Fields, extension::ExtensionType}; + +/// The extension type for a bounded set (mathematical interval) whose bound +/// inclusivity is recorded per value rather than as a single type-level +/// parameter. +/// +/// Extension name: `arrow.variable_closedness_range`. +/// +/// A range is a bounded set (mathematical interval) defined by a lower and +/// an upper bound over an orderable value type T. T may be any orderable +/// Arrow type, for example an integer, floating-point, decimal, date, time, +/// timestamp, duration, string or binary type. This specification defines +/// only the storage layout, not the order of any type. +/// +/// The storage type is a `Struct` with exactly four fields, in order: +/// - `lower` (type T): the lower bound. +/// - `upper` (type T): the upper bound. +/// - `lower_inc` (non-nullable `Boolean`): whether the lower bound is +/// inclusive for that value. +/// - `upper_inc` (non-nullable `Boolean`): whether the upper bound is +/// inclusive for that value. +/// +/// `lower` and `upper` must share the same data type T. Each of them may +/// independently be nullable or non-nullable: a nullable bound can hold +/// null to represent an unbounded (infinite) endpoint on that side, while a +/// non-nullable bound is always finite. A null bound is always treated as +/// exclusive, regardless of its `lower_inc` / `upper_inc` flag: only a null +/// bound means the range is unbounded on that side. `lower_inc` and +/// `upper_inc` are always non-nullable. +/// +/// This type has no type-level parameters: inclusivity is carried per value +/// in the `lower_inc` and `upper_inc` fields rather than fixed by the type. +/// The extension metadata therefore serializes to the empty JSON object +/// `{}`. +/// +/// Ranges that canonicalize to a single closedness shared by all values use +/// the companion `arrow.fixed_closedness_range` extension type instead. +/// +/// +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct VariableClosednessRange; + +/// Validates that `data_type` is an acceptable storage type for +/// `arrow.variable_closedness_range`. +/// +/// Checks that the data type is a struct with exactly four fields named +/// "lower", "upper", "lower_inc" and "upper_inc", in that order. "lower" and +/// "upper" must share the same data type; each may be nullable or +/// non-nullable independently. "lower_inc" and "upper_inc" must be +/// non-nullable `Boolean`. +fn validate_storage(data_type: &DataType) -> Result<(), ArrowError> { + let fields: &Fields = match data_type { + DataType::Struct(fields) => fields, + other => { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected Struct, found {other}" + ))); + } + }; + + if fields.len() != 4 { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected Struct with 4 fields, found {} field(s)", + fields.len() + ))); + } + + let lower = &fields[0]; + let upper = &fields[1]; + let lower_inc = &fields[2]; + let upper_inc = &fields[3]; + + if lower.name() != "lower" { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected first field named \"lower\", found \"{}\"", + lower.name() + ))); + } + + if upper.name() != "upper" { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected second field named \"upper\", found \"{}\"", + upper.name() + ))); + } + + if lower_inc.name() != "lower_inc" { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected third field named \"lower_inc\", found \"{}\"", + lower_inc.name() + ))); + } + + if upper_inc.name() != "upper_inc" { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, expected fourth field named \"upper_inc\", found \"{}\"", + upper_inc.name() + ))); + } + + if lower.data_type() != upper.data_type() { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, \"lower\" and \"upper\" fields must have the same data type, found \"{}\" and \"{}\"", + lower.data_type(), + upper.data_type() + ))); + } + + if lower_inc.data_type() != &DataType::Boolean || upper_inc.data_type() != &DataType::Boolean { + return Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange data type mismatch, \"lower_inc\" and \"upper_inc\" fields must be Boolean, found \"{}\" and \"{}\"", + lower_inc.data_type(), + upper_inc.data_type() + ))); + } + + if lower_inc.is_nullable() || upper_inc.is_nullable() { + return Err(ArrowError::InvalidArgumentError( + "VariableClosednessRange data type mismatch, \"lower_inc\" and \"upper_inc\" fields must be non-nullable".to_owned(), + )); + } + + Ok(()) +} + +/// Validates that `metadata`, if present, is either empty or a valid JSON +/// object. +/// +/// This type has no type-level parameters, so the metadata carries no +/// information. A missing metadata key and an empty string are both +/// accepted, as is any JSON object (unknown keys are ignored for +/// forward-compatibility). Anything else, such as malformed JSON or a JSON +/// value that is not an object, is rejected. +fn validate_metadata(metadata: Option<&str>) -> Result<(), ArrowError> { + match metadata { + None => Ok(()), + Some("") => Ok(()), + Some(value) => { + let parsed: serde_json::Value = serde_json::from_str(value).map_err(|e| { + ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange metadata deserialization failed: {e}" + )) + })?; + if parsed.is_object() { + Ok(()) + } else { + Err(ArrowError::InvalidArgumentError(format!( + "VariableClosednessRange metadata must be a JSON object, found \"{value}\"" + ))) + } + } + } +} + +impl ExtensionType for VariableClosednessRange { + const NAME: &'static str = "arrow.variable_closedness_range"; + + type Metadata = (); + + fn metadata(&self) -> &Self::Metadata { + &() + } + + fn serialize_metadata(&self) -> Option { + // There are no type-level parameters, but the metadata is still + // emitted explicitly as the empty JSON object for forward-compat. + Some("{}".to_owned()) + } + + fn deserialize_metadata(metadata: Option<&str>) -> Result { + validate_metadata(metadata) + } + + fn supports_data_type(&self, data_type: &DataType) -> Result<(), ArrowError> { + validate_storage(data_type) + } + + fn try_new(data_type: &DataType, _metadata: Self::Metadata) -> Result { + Self.supports_data_type(data_type).map(|()| Self) + } + + fn validate(data_type: &DataType, _metadata: Self::Metadata) -> Result<(), ArrowError> { + Self.supports_data_type(data_type) + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "canonical_extension_types")] + use crate::extension::CanonicalExtensionType; + use crate::{ + Field, + extension::{EXTENSION_TYPE_METADATA_KEY, EXTENSION_TYPE_NAME_KEY}, + }; + + use super::*; + + fn make_range_struct(value_type: DataType) -> DataType { + DataType::Struct( + [ + Field::new("lower", value_type.clone(), true), + Field::new("upper", value_type, true), + Field::new("lower_inc", DataType::Boolean, false), + Field::new("upper_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ) + } + + #[test] + fn valid() -> Result<(), ArrowError> { + let storage = make_range_struct(DataType::Int32); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(VariableClosednessRange)?; + field.try_extension_type::()?; + #[cfg(feature = "canonical_extension_types")] + assert_eq!( + field.try_canonical_extension_type()?, + CanonicalExtensionType::VariableClosednessRange(VariableClosednessRange) + ); + Ok(()) + } + + #[test] + fn valid_string_value_type() -> Result<(), ArrowError> { + // T may be any orderable Arrow type, including a string type. + let storage = make_range_struct(DataType::Utf8); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(VariableClosednessRange)?; + field.try_extension_type::()?; + Ok(()) + } + + #[test] + fn metadata_serializes_to_empty_object() -> Result<(), ArrowError> { + let storage = make_range_struct(DataType::Int32); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(VariableClosednessRange)?; + assert_eq!( + field.metadata().get(EXTENSION_TYPE_METADATA_KEY), + Some(&"{}".to_owned()) + ); + Ok(()) + } + + #[test] + #[should_panic(expected = "Extension type name missing")] + fn missing_name() { + let storage = make_range_struct(DataType::Int32); + let field = + Field::new("", storage, false).with_metadata([(EXTENSION_TYPE_METADATA_KEY, "{}")]); + field.extension_type::(); + } + + #[test] + fn missing_metadata_is_accepted() -> Result<(), ArrowError> { + // Unlike FixedClosednessRange, this type has no type-level + // parameters, so a missing metadata key is equivalent to `{}`. + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false) + .with_metadata([(EXTENSION_TYPE_NAME_KEY, VariableClosednessRange::NAME)]); + field.try_extension_type::()?; + Ok(()) + } + + #[test] + fn empty_and_object_and_unknown_keys_metadata_are_accepted() -> Result<(), ArrowError> { + let storage = make_range_struct(DataType::Int32); + for serialized in ["", "{}", r#"{"extra":42}"#] { + let field = Field::new("", storage.clone(), false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, VariableClosednessRange::NAME), + (EXTENSION_TYPE_METADATA_KEY, serialized), + ]); + field.try_extension_type::()?; + } + Ok(()) + } + + #[test] + #[should_panic(expected = "VariableClosednessRange metadata deserialization failed")] + fn invalid_metadata_malformed_json() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, VariableClosednessRange::NAME), + (EXTENSION_TYPE_METADATA_KEY, "{"), + ]); + field.extension_type::(); + } + + #[test] + #[should_panic(expected = "VariableClosednessRange metadata must be a JSON object")] + fn invalid_metadata_not_an_object() { + let storage = make_range_struct(DataType::Int32); + let field = Field::new("", storage, false).with_metadata([ + (EXTENSION_TYPE_NAME_KEY, VariableClosednessRange::NAME), + (EXTENSION_TYPE_METADATA_KEY, "[]"), + ]); + field.extension_type::(); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, expected Struct, found Int32" + )] + fn invalid_storage_non_struct() { + Field::new("", DataType::Int32, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, expected Struct with 4 fields" + )] + fn invalid_storage_wrong_field_count() { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, true), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, expected first field named \"lower\"" + )] + fn invalid_storage_wrong_field_names() { + let storage = DataType::Struct( + [ + Field::new("start", DataType::Int32, true), + Field::new("upper", DataType::Int32, true), + Field::new("lower_inc", DataType::Boolean, false), + Field::new("upper_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, expected third field named \"lower_inc\"" + )] + fn invalid_storage_wrong_inc_field_order() { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, true), + Field::new("upper_inc", DataType::Boolean, false), + Field::new("lower_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, \"lower\" and \"upper\" fields must have the same data type" + )] + fn invalid_storage_mismatched_bound_types() { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int64, true), + Field::new("lower_inc", DataType::Boolean, false), + Field::new("upper_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, \"lower_inc\" and \"upper_inc\" fields must be Boolean" + )] + fn invalid_storage_non_boolean_flags() { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, true), + Field::new("lower_inc", DataType::Int8, false), + Field::new("upper_inc", DataType::Int8, false), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + #[should_panic( + expected = "VariableClosednessRange data type mismatch, \"lower_inc\" and \"upper_inc\" fields must be non-nullable" + )] + fn invalid_storage_nullable_flags() { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, true), + Field::new("lower_inc", DataType::Boolean, true), + Field::new("upper_inc", DataType::Boolean, true), + ] + .into_iter() + .collect(), + ); + Field::new("", storage, false).with_extension_type(VariableClosednessRange); + } + + #[test] + fn accepts_non_nullable_bounds() -> Result<(), ArrowError> { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, false), + Field::new("upper", DataType::Int32, false), + Field::new("lower_inc", DataType::Boolean, false), + Field::new("upper_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(VariableClosednessRange)?; + field.try_extension_type::()?; + Ok(()) + } + + #[test] + fn accepts_asymmetric_bound_nullability() -> Result<(), ArrowError> { + let storage = DataType::Struct( + [ + Field::new("lower", DataType::Int32, true), + Field::new("upper", DataType::Int32, false), + Field::new("lower_inc", DataType::Boolean, false), + Field::new("upper_inc", DataType::Boolean, false), + ] + .into_iter() + .collect(), + ); + let mut field = Field::new("", storage, false); + field.try_with_extension_type(VariableClosednessRange)?; + field.try_extension_type::()?; + Ok(()) + } +}