Skip to main content

keldra_api/
typed_json.rs

1//! Storage-neutral validation for the public Typed JSON protobuf contract.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::{self, Display, Formatter};
5
6use crate::v1::index_field::FieldType;
7use crate::v1::{
8    IndexField, IndexFieldCapability, IndexFieldCardinality, IndexOrderDirection, TextAnalyzer,
9    TypedJsonIndexSpec,
10};
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct TypedJsonValidationError(&'static str);
14
15impl Display for TypedJsonValidationError {
16    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
17        formatter.write_str(self.0)
18    }
19}
20
21impl std::error::Error for TypedJsonValidationError {}
22
23fn invalid(message: &'static str) -> TypedJsonValidationError {
24    TypedJsonValidationError(message)
25}
26
27/// Validate the complete storage-neutral protobuf shape shared by clients and
28/// servers. Execution engines may additionally compile engine-specific values
29/// such as date patterns, but must call this authority first.
30pub fn validate_typed_json_specification(
31    specification: &TypedJsonIndexSpec,
32) -> Result<(), TypedJsonValidationError> {
33    if specification.fields.is_empty() {
34        return Err(invalid("typed JSON index needs at least one field"));
35    }
36    let mut fields = BTreeMap::new();
37    for field in &specification.fields {
38        validate_field(field)?;
39        if fields.insert(field.name.as_str(), field).is_some() {
40            return Err(invalid("typed JSON field names must be unique"));
41        }
42    }
43
44    let mut ordered = BTreeSet::new();
45    for order in &specification.physical_order {
46        require_text(&order.field, "physical-order field is required")?;
47        if !ordered.insert(order.field.as_str()) {
48            return Err(invalid("physical-order field names must be unique"));
49        }
50        let field = fields.get(order.field.as_str()).ok_or_else(|| {
51            invalid("physical order names a field outside the typed JSON definition")
52        })?;
53        if field.cardinality != IndexFieldCardinality::Single as i32 {
54            return Err(invalid(
55                "physical order requires single-valued typed JSON fields",
56            ));
57        }
58        if !field
59            .capabilities
60            .contains(&(IndexFieldCapability::Order as i32))
61        {
62            return Err(invalid(
63                "physical order requires the typed JSON field to declare ORDER",
64            ));
65        }
66        IndexOrderDirection::try_from(order.direction)
67            .map_err(|_| invalid("physical order direction is unknown"))?;
68    }
69    Ok(())
70}
71
72fn validate_field(field: &IndexField) -> Result<(), TypedJsonValidationError> {
73    require_text(&field.name, "index field name is required")?;
74    if !field.json_pointer.is_empty()
75        && (!field.json_pointer.starts_with('/') || field.json_pointer.contains('\0'))
76    {
77        return Err(invalid("JSON pointer must be empty or begin with '/'"));
78    }
79    let cardinality = IndexFieldCardinality::try_from(field.cardinality)
80        .map_err(|_| invalid("typed JSON field cardinality is unknown"))?;
81    let field_type = field
82        .field_type
83        .as_ref()
84        .ok_or_else(|| invalid("typed JSON field type is required"))?;
85    if let FieldType::Text(text) = field_type {
86        TextAnalyzer::try_from(text.analyzer)
87            .map_err(|_| invalid("typed JSON text analyzer is unknown"))?;
88    }
89    if field.capabilities.is_empty() {
90        return Err(invalid("typed JSON field needs at least one capability"));
91    }
92    let mut capabilities = BTreeSet::new();
93    for encoded in &field.capabilities {
94        let capability = IndexFieldCapability::try_from(*encoded)
95            .map_err(|_| invalid("typed JSON field capability is unknown"))?;
96        if !capabilities.insert(capability) {
97            return Err(invalid("typed JSON field capabilities must be unique"));
98        }
99        if !capability_allowed(field_type, capability) {
100            return Err(invalid(
101                "typed JSON field capability is invalid for its field type",
102            ));
103        }
104    }
105    if cardinality == IndexFieldCardinality::Multi
106        && capabilities.contains(&IndexFieldCapability::Order)
107    {
108        return Err(invalid(
109            "multi-valued typed JSON fields cannot declare ORDER",
110        ));
111    }
112    Ok(())
113}
114
115fn capability_allowed(field_type: &FieldType, capability: IndexFieldCapability) -> bool {
116    match field_type {
117        FieldType::Boolean(_) => matches!(
118            capability,
119            IndexFieldCapability::Exact | IndexFieldCapability::Facet
120        ),
121        FieldType::SignedInteger(_) | FieldType::UnsignedInteger(_) | FieldType::Float(_) => {
122            matches!(
123                capability,
124                IndexFieldCapability::Exact
125                    | IndexFieldCapability::Range
126                    | IndexFieldCapability::Order
127                    | IndexFieldCapability::Facet
128                    | IndexFieldCapability::Aggregate
129            )
130        }
131        FieldType::Keyword(_) => matches!(
132            capability,
133            IndexFieldCapability::Exact
134                | IndexFieldCapability::Prefix
135                | IndexFieldCapability::Range
136                | IndexFieldCapability::Order
137                | IndexFieldCapability::Facet
138        ),
139        FieldType::Text(_) => capability == IndexFieldCapability::FullText,
140        FieldType::Date(_) => matches!(
141            capability,
142            IndexFieldCapability::Exact
143                | IndexFieldCapability::Range
144                | IndexFieldCapability::Order
145                | IndexFieldCapability::Facet
146        ),
147    }
148}
149
150fn require_text(value: &str, message: &'static str) -> Result<(), TypedJsonValidationError> {
151    if value.is_empty() || value.contains('\0') {
152        Err(invalid(message))
153    } else {
154        Ok(())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::v1::index_field::FieldType;
161    use crate::v1::{
162        IndexField, IndexFieldCapability, IndexFieldCardinality, KeywordIndexField,
163        TypedJsonIndexSpec,
164    };
165
166    use super::validate_typed_json_specification;
167
168    #[test]
169    fn rejects_duplicate_fields_and_unorderable_physical_order() {
170        let field = IndexField {
171            name: "id".into(),
172            json_pointer: "/id".into(),
173            cardinality: IndexFieldCardinality::Single as i32,
174            capabilities: vec![IndexFieldCapability::Exact as i32],
175            field_type: Some(FieldType::Keyword(KeywordIndexField {})),
176        };
177        let mut specification = TypedJsonIndexSpec {
178            fields: vec![field.clone(), field],
179            physical_order: Vec::new(),
180        };
181        assert!(validate_typed_json_specification(&specification).is_err());
182        specification.fields.pop();
183        specification.physical_order.push(crate::v1::IndexOrder {
184            field: "id".into(),
185            direction: crate::v1::IndexOrderDirection::Ascending as i32,
186        });
187        assert!(validate_typed_json_specification(&specification).is_err());
188    }
189}