Skip to main content

qubit_metadata/schema/
metadata_schema.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! [`MetadataSchema`] — schema validation for metadata and filters.
9
10// Implements filter validation and its private compatibility checks.
11mod filter_validation;
12
13#[cfg(feature = "json")]
14use std::cell::RefCell;
15use std::collections::BTreeMap;
16use std::fmt;
17#[cfg(feature = "json")]
18use std::io::Write;
19#[cfg(feature = "json")]
20use std::rc::Rc;
21
22#[cfg(feature = "json")]
23use qubit_budget::json::JsonDecodeSession;
24#[cfg(feature = "json")]
25use qubit_budget::json::JsonEncodeLimits;
26#[cfg(feature = "json")]
27use qubit_budget::json::JsonEncodeSession;
28use qubit_datatype::DataType;
29#[cfg(feature = "json")]
30use qubit_json::decode::JsonDecoder;
31#[cfg(feature = "json")]
32use qubit_json::encode::JsonEncoder;
33use qubit_redact::Redact;
34use qubit_redact::RedactionWriter;
35use qubit_redact::Redactor;
36use qubit_value::Value;
37use serde::Deserialize;
38use serde::Deserializer;
39use serde::Serialize;
40use serde::Serializer;
41use serde::de;
42use serde::ser::Error as SerError;
43
44use crate::Metadata;
45use crate::MetadataError;
46use crate::MetadataResult;
47use crate::MetadataValidationError;
48use crate::MetadataValidationResult;
49use crate::constants::STRICT_STRING_MAP_MAX_ENTRIES;
50use crate::constants::STRICT_STRING_MAP_MAX_KEY_BYTES;
51#[cfg(feature = "json")]
52use crate::metadata_limits::MetadataLimits;
53use crate::schema::MetadataField;
54use crate::schema::MetadataSchemaBuilder;
55use crate::schema::UnknownFilterFieldPolicy;
56use crate::schema::UnknownMetadataFieldPolicy;
57use crate::schema::internal::SchemaFields;
58use crate::wire::METADATA_SCHEMA_WIRE_VERSION_V1;
59use crate::wire::MetadataSchemaWireV1;
60#[cfg(feature = "json")]
61use crate::wire::MetadataSchemaWireV1Seed;
62use crate::wire::StrictStringMap;
63#[cfg(feature = "json")]
64use crate::wire::StrictStringMapSeed;
65
66/// Schema for metadata fields.
67///
68/// A schema declares valid keys, their concrete [`DataType`], and whether they
69/// are required. It can validate actual [`Metadata`] values and validate that a
70/// [`crate::MetadataFilter`] references known fields with compatible operators.
71///
72/// # Examples
73///
74/// ```
75/// use qubit_datatype::DataType;
76/// use qubit_metadata::Metadata;
77/// use qubit_metadata::MetadataSchema;
78///
79/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
80/// let metadata = Metadata::new().with("tenant", "acme");
81/// let schema = MetadataSchema::builder()
82///     .required("tenant", DataType::String)
83///     .build()?;
84/// schema.validate(&metadata)?;
85/// # Ok(())
86/// # }
87/// ```
88#[derive(Clone, PartialEq, Eq)]
89pub struct MetadataSchema {
90    /// Field definitions keyed by metadata key.
91    fields: BTreeMap<String, MetadataField>,
92    /// How validation handles unknown metadata keys.
93    unknown_metadata_field_policy: UnknownMetadataFieldPolicy,
94    /// How validation handles unknown filter keys.
95    unknown_filter_field_policy: UnknownFilterFieldPolicy,
96}
97
98impl fmt::Debug for MetadataSchema {
99    /// Writes the strict-policy diagnostic representation.
100    ///
101    /// # Parameters
102    ///
103    /// * `formatter` - Formatter receiving the redacted representation.
104    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105        let output = Redactor::strict().redact_text(self);
106        let text = output.text_or_marker("<redaction incomplete>");
107        formatter.write_str(text.as_ref())
108    }
109}
110
111impl Redact for MetadataSchema {
112    /// Writes schema fields through one cumulative diagnostic budget.
113    ///
114    /// # Parameters
115    ///
116    /// * `writer` - Redaction writer receiving the schema representation.
117    fn write_redacted(&self, writer: &mut RedactionWriter<'_>) {
118        writer.record("MetadataSchema", |fields| {
119            fields.nested("fields", &SchemaFields(&self.fields));
120            fields.unmarked("unknown_metadata_field_policy", || self.unknown_metadata_field_policy);
121            fields.unmarked("unknown_filter_field_policy", || self.unknown_filter_field_policy);
122        });
123    }
124}
125
126impl MetadataSchema {
127    /// Creates a schema builder.
128    ///
129    /// # Returns
130    ///
131    /// An empty schema builder using the default unknown-field policy.
132    #[inline]
133    #[must_use]
134    pub fn builder() -> MetadataSchemaBuilder {
135        MetadataSchemaBuilder::default()
136    }
137
138    /// Creates a schema from field definitions and unknown-field policies.
139    ///
140    /// # Parameters
141    ///
142    /// * `fields` - Field definitions keyed by metadata key.
143    /// * `unknown_metadata_field_policy` - Policy for undeclared metadata keys.
144    /// * `unknown_filter_field_policy` - Policy for undeclared filter keys.
145    ///
146    /// # Returns
147    ///
148    /// A new immutable schema.
149    #[must_use]
150    #[inline]
151    pub(crate) fn new(
152        fields: BTreeMap<String, MetadataField>,
153        unknown_metadata_field_policy: UnknownMetadataFieldPolicy,
154        unknown_filter_field_policy: UnknownFilterFieldPolicy,
155    ) -> Self {
156        Self {
157            fields,
158            unknown_metadata_field_policy,
159            unknown_filter_field_policy,
160        }
161    }
162
163    /// Decodes a strict metadata-schema JSON envelope using the metadata
164    /// profile.
165    ///
166    /// # Parameters
167    ///
168    /// * `input` - Complete untrusted JSON input.
169    ///
170    /// # Returns
171    ///
172    /// The decoded metadata schema.
173    ///
174    /// # Errors
175    ///
176    /// Returns `Budget` or `Quantity` when shared JSON traversal exceeds a
177    /// configured limit, `Domain` for schema field/key limits,
178    /// `UnsupportedVersion` for a version mismatch, and `InvalidLimits` for
179    /// invalid metadata limits. Empty input and lexical JSON errors return
180    /// `Syntax`; shared decoder failures such as invalid UTF-8, unexpected
181    /// top-level types, or deserialization failures return `Decode`.
182    #[cfg(feature = "json")]
183    #[inline]
184    pub fn decode_json_slice(input: &[u8]) -> Result<Self, crate::MetadataWireDecodeError> {
185        Self::decode_json_slice_with_limits(input, MetadataLimits::default())
186    }
187
188    /// Decodes a strict metadata-schema JSON envelope after applying
189    /// `limits`.
190    ///
191    /// # Parameters
192    ///
193    /// * `input` - Complete untrusted JSON input.
194    /// * `limits` - Shared JSON limits for this decoding session.
195    ///
196    /// # Returns
197    ///
198    /// The decoded metadata schema.
199    ///
200    /// # Errors
201    ///
202    /// Returns `Budget` or `Quantity` when shared JSON traversal exceeds a
203    /// configured limit, `Domain` for schema field/key limits,
204    /// `UnsupportedVersion` for a version mismatch, and `InvalidLimits` for
205    /// invalid metadata limits. Empty input and lexical JSON errors return
206    /// `Syntax`; shared decoder failures such as invalid UTF-8, unexpected
207    /// top-level types, or deserialization failures return `Decode`.
208    #[cfg(feature = "json")]
209    pub fn decode_json_slice_with_limits(
210        input: &[u8],
211        limits: MetadataLimits,
212    ) -> Result<Self, crate::MetadataWireDecodeError> {
213        limits
214            .validate()
215            .map_err(crate::MetadataWireDecodeError::InvalidLimits)?;
216        let mut decoder = JsonDecoder::new(JsonDecodeSession::from_limits(limits.json_decode()));
217        let error_slot = Rc::new(RefCell::new(None));
218        let wire = decoder
219            .decode_unique_seed_utf8(
220                MetadataSchemaWireV1Seed::new(
221                    StrictStringMapSeed::new(limits.max_schema_fields(), limits.max_key_bytes())
222                        .with_error_slot(Rc::clone(&error_slot)),
223                ),
224                input,
225            )
226            .map_err(|error| {
227                error_slot.borrow_mut().take().map_or_else(
228                    || Into::<crate::MetadataWireDecodeError>::into(error),
229                    crate::MetadataWireDecodeError::Domain,
230                )
231            })?;
232        if wire.version != METADATA_SCHEMA_WIRE_VERSION_V1 {
233            return Err(crate::MetadataWireDecodeError::UnsupportedVersion {
234                expected: METADATA_SCHEMA_WIRE_VERSION_V1,
235                actual: wire.version,
236            });
237        }
238        Ok(Self::new(
239            wire.fields.into_inner(),
240            wire.unknown_metadata_field_policy,
241            wire.unknown_filter_field_policy,
242        ))
243    }
244
245    /// Encodes this schema with the default JSON budget profile
246    /// ([`crate::default_json_encode_limits`]).
247    ///
248    /// # Errors
249    ///
250    /// Returns [`crate::MetadataWireEncodeError::Domain`] when the schema
251    /// exceeds a V1 wire limit, before checking JSON output budgets.
252    /// Returns [`crate::MetadataWireEncodeError`] when encoding exceeds a
253    /// default JSON budget or serialization fails.
254    ///
255    /// # Returns
256    ///
257    /// The compact JSON bytes representing this schema.
258    #[cfg(feature = "json")]
259    pub fn to_json_vec(&self) -> Result<Vec<u8>, crate::MetadataWireEncodeError> {
260        self.to_json_vec_with_limits(crate::metadata_limits::default_json_encode_limits())
261    }
262
263    /// Encodes this schema with caller-provided JSON budgets.
264    ///
265    /// # Parameters
266    ///
267    /// * `limits` - Output and JSON-value budgets for this operation.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`crate::MetadataWireEncodeError`] when a schema field count or
272    /// key exceeds the V1 wire limit, encoding exceeds a JSON budget, or
273    /// serialization fails. Schema wire-limit errors are reported as
274    /// `Domain(MetadataError::WireLimitExceeded { .. })` before JSON budgets.
275    ///
276    /// # Returns
277    ///
278    /// The compact JSON bytes representing this schema.
279    #[cfg(feature = "json")]
280    pub fn to_json_vec_with_limits(&self, limits: JsonEncodeLimits) -> Result<Vec<u8>, crate::MetadataWireEncodeError> {
281        self.validate_wire_contract()
282            .map_err(crate::MetadataWireEncodeError::Domain)?;
283        let session = JsonEncodeSession::from_limits(limits);
284        JsonEncoder::new(session).to_vec(self).map_err(Into::into)
285    }
286
287    /// Encodes this schema to a writer with the default JSON budget profile
288    /// ([`crate::default_json_encode_limits`]).
289    ///
290    /// The complete compact JSON document is buffered and validated before
291    /// the first write to `writer`. A budget or serialization failure leaves
292    /// the destination untouched by this operation. A destination I/O error
293    /// may leave a written prefix; this method does not provide atomic writes
294    /// or rollback. Applications own persistence and retry semantics. Decode
295    /// limits do not affect output budgets; use
296    /// [`Self::to_json_writer_with_limits`] to provide explicit output budgets.
297    ///
298    /// # Returns
299    ///
300    /// `Ok(())` after the buffered document is written successfully.
301    ///
302    /// # Type Parameters
303    ///
304    /// * `W` - Writer receiving the encoded JSON document.
305    ///
306    /// # Parameters
307    ///
308    /// * `writer` - Destination receiving the complete compact JSON document.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`crate::MetadataWireEncodeError`] when a schema field count or
313    /// key exceeds the V1 wire limit, a JSON budget or serialization failure
314    /// occurs before writing, or the destination writer rejects bytes. Schema
315    /// wire-limit errors are reported as
316    /// `Domain(MetadataError::WireLimitExceeded { .. })` before JSON budgets.
317    #[cfg(feature = "json")]
318    pub fn to_json_writer<W>(&self, writer: W) -> Result<(), crate::MetadataWireEncodeError>
319    where
320        W: Write,
321    {
322        self.to_json_writer_with_limits(writer, crate::metadata_limits::default_json_encode_limits())
323    }
324
325    /// Encodes this schema to a writer with caller-provided JSON budgets.
326    ///
327    /// # Parameters
328    ///
329    /// * `writer` - Destination receiving the complete compact JSON document.
330    /// * `limits` - Explicit output and JSON-value budgets for this operation.
331    ///
332    /// The complete compact JSON document is buffered and validated before
333    /// the first write to `writer`. A budget or serialization failure leaves
334    /// the destination untouched by this operation. A destination I/O error
335    /// may leave a written prefix; this method does not provide atomic writes
336    /// or rollback. Applications own persistence and retry semantics. Output
337    /// budgets come only from the explicitly supplied `limits`; decode limits
338    /// do not affect them.
339    ///
340    /// # Returns
341    ///
342    /// `Ok(())` after the buffered document is written successfully.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`crate::MetadataWireEncodeError::Domain`] when the schema
347    /// exceeds a V1 wire limit. Wire-contract validation runs before JSON
348    /// output budgets. Budget or serialization failures occur before any
349    /// destination write; destination I/O errors may leave a written prefix.
350    ///
351    /// # Type Parameters
352    ///
353    /// * `W` - Writer receiving the encoded JSON document.
354    #[cfg(feature = "json")]
355    pub fn to_json_writer_with_limits<W>(
356        &self,
357        mut writer: W,
358        limits: JsonEncodeLimits,
359    ) -> Result<(), crate::MetadataWireEncodeError>
360    where
361        W: Write,
362    {
363        self.validate_wire_contract()
364            .map_err(crate::MetadataWireEncodeError::Domain)?;
365        let session = JsonEncodeSession::from_limits(limits);
366        JsonEncoder::new(session)
367            .write_buffered(&mut writer, self)
368            .map_err(Into::into)
369    }
370
371    /// Returns the field definition for `key`.
372    ///
373    /// # Parameters
374    ///
375    /// * `key` - Metadata key to look up.
376    ///
377    /// # Returns
378    ///
379    /// `Some` field definition for a declared key; otherwise, `None`.
380    #[inline]
381    #[must_use]
382    pub fn field(&self, key: &str) -> Option<&MetadataField> {
383        self.fields.get(key)
384    }
385
386    /// Returns the declared data type for `key`.
387    ///
388    /// # Parameters
389    ///
390    /// * `key` - Metadata key to look up.
391    ///
392    /// # Returns
393    ///
394    /// `Some` declared data type for a known key; otherwise, `None`.
395    #[inline]
396    #[must_use]
397    pub fn field_type(&self, key: &str) -> Option<DataType> {
398        self.field(key).map(MetadataField::data_type)
399    }
400
401    /// Returns the policy for undeclared metadata fields.
402    ///
403    /// # Returns
404    ///
405    /// The policy applied to undeclared metadata keys.
406    #[inline]
407    pub fn unknown_metadata_field_policy(&self) -> UnknownMetadataFieldPolicy {
408        self.unknown_metadata_field_policy
409    }
410
411    /// Returns the policy for undeclared filter fields.
412    ///
413    /// # Returns
414    ///
415    /// The policy applied to filter keys not declared in this schema.
416    #[inline]
417    #[must_use]
418    pub fn unknown_filter_field_policy(&self) -> UnknownFilterFieldPolicy {
419        self.unknown_filter_field_policy
420    }
421
422    /// Returns an iterator over schema fields in key-sorted order.
423    ///
424    /// # Returns
425    ///
426    /// An iterator yielding key and field-definition pairs.
427    #[inline]
428    #[must_use = "the schema field iterator must be consumed to inspect fields"]
429    pub fn fields(&self) -> impl Iterator<Item = (&str, &MetadataField)> {
430        self.fields.iter().map(|(key, field)| (key.as_str(), field))
431    }
432
433    /// Validates a metadata object against this schema.
434    ///
435    /// # Parameters
436    ///
437    /// * `meta` - Metadata object to validate.
438    ///
439    /// # Returns
440    ///
441    /// `Ok(())` when every entry satisfies the schema.
442    ///
443    /// # Errors
444    ///
445    /// Returns an aggregate error containing every required-field,
446    /// declared-type, and unknown-field issue discovered during this
447    /// validation pass.
448    pub fn validate(&self, meta: &Metadata) -> MetadataValidationResult<()> {
449        let mut issues = Vec::new();
450        for (key, field) in &self.fields {
451            if field.is_required() && meta.get_raw(key).is_none_or(Value::is_unset) {
452                issues.push(MetadataError::MissingRequiredField {
453                    key: key.clone(),
454                    expected: field.data_type(),
455                });
456            }
457        }
458
459        for (key, value) in meta.iter() {
460            if self
461                .field(key)
462                .is_some_and(|field| field.is_required() && value.is_unset())
463            {
464                continue;
465            }
466            if let Err(error) = self.validate_entry(key, value) {
467                issues.push(error);
468            }
469        }
470        if let Some(error) = MetadataValidationError::from_issues(issues) {
471            Err(error)
472        } else {
473            Ok(())
474        }
475    }
476
477    /// Validates one metadata entry against this schema.
478    ///
479    /// # Parameters
480    ///
481    /// * `key` - Metadata key to validate.
482    /// * `value` - Stored value to validate.
483    ///
484    /// # Returns
485    ///
486    /// `Ok(())` when the entry is accepted.
487    ///
488    /// # Errors
489    ///
490    /// Returns [`MetadataError::MissingRequiredField`] when a required field
491    /// stores an unset value, before checking its declared type. Returns
492    /// [`MetadataError::UnknownField`] for a rejected undeclared key, or
493    /// [`MetadataError::TypeMismatch`] for a declared type mismatch.
494    pub(crate) fn validate_entry(&self, key: &str, value: &Value) -> MetadataResult<()> {
495        match self.field(key) {
496            Some(field) if field.is_required() && value.is_unset() => Err(MetadataError::MissingRequiredField {
497                key: key.to_string(),
498                expected: field.data_type(),
499            }),
500            Some(field) if field.data_type() != value.data_type() => {
501                Err(MetadataError::type_mismatch(key, field.data_type(), value.data_type()))
502            }
503            Some(_) => Ok(()),
504            None if matches!(self.unknown_metadata_field_policy, UnknownMetadataFieldPolicy::Reject) => {
505                Err(MetadataError::UnknownField { key: key.to_string() })
506            }
507            None => Ok(()),
508        }
509    }
510
511    /// Validates that this schema fits the strict V1 wire contract.
512    ///
513    /// This preflight checks the same field-count and key-byte limits enforced
514    /// by [`Serialize::serialize`], allowing callers to reject invalid schemas
515    /// before crossing a persistence or network boundary.
516    ///
517    /// # Returns
518    ///
519    /// `Ok(())` when every field can satisfy the schema map limits.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`MetadataError::WireLimitExceeded`] when the field count or a
524    /// field key exceeds the strict V1 limit.
525    #[inline]
526    pub fn validate_wire_contract(&self) -> MetadataResult<()> {
527        if self.fields.len() > STRICT_STRING_MAP_MAX_ENTRIES {
528            return Err(MetadataError::WireLimitExceeded {
529                kind: crate::MetadataWireLimitKind::Entries,
530                value: self.fields.len(),
531                maximum: STRICT_STRING_MAP_MAX_ENTRIES,
532            });
533        }
534        if let Some(key) = self
535            .fields
536            .keys()
537            .find(|key| key.len() > STRICT_STRING_MAP_MAX_KEY_BYTES)
538        {
539            return Err(MetadataError::WireLimitExceeded {
540                kind: crate::MetadataWireLimitKind::KeyBytes,
541                value: key.len(),
542                maximum: STRICT_STRING_MAP_MAX_KEY_BYTES,
543            });
544        }
545        Ok(())
546    }
547}
548
549impl Serialize for MetadataSchema {
550    /// Serializes this schema as its strict v1 envelope.
551    ///
552    /// # Type Parameters
553    ///
554    /// * `S` - Serializer receiving the schema envelope.
555    ///
556    /// # Parameters
557    ///
558    /// * `serializer` - Destination serializer for the schema.
559    ///
560    /// # Returns
561    ///
562    /// The serializer's success value after encoding the strict v1 envelope.
563    ///
564    /// # Errors
565    ///
566    /// Returns the serializer's error when the schema exceeds wire limits or
567    /// serialization fails.
568    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
569    where
570        S: Serializer,
571    {
572        self.validate_wire_contract().map_err(<S::Error as SerError>::custom)?;
573        MetadataSchemaWireV1 {
574            version: METADATA_SCHEMA_WIRE_VERSION_V1,
575            fields: &self.fields,
576            unknown_metadata_field_policy: self.unknown_metadata_field_policy,
577            unknown_filter_field_policy: self.unknown_filter_field_policy,
578        }
579        .serialize(serializer)
580    }
581}
582
583impl<'de> Deserialize<'de> for MetadataSchema {
584    /// Deserializes only the strict v1 schema envelope.
585    ///
586    /// # Type Parameters
587    ///
588    /// * `D` - Deserializer providing the schema envelope.
589    ///
590    /// # Parameters
591    ///
592    /// * `deserializer` - Source of the serialized schema.
593    ///
594    /// # Returns
595    ///
596    /// The schema decoded from the strict v1 envelope.
597    ///
598    /// # Errors
599    ///
600    /// Returns the deserializer's error when the envelope is malformed or its
601    /// version is unsupported.
602    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
603    where
604        D: Deserializer<'de>,
605    {
606        let wire: MetadataSchemaWireV1<StrictStringMap<MetadataField>> =
607            MetadataSchemaWireV1::deserialize(deserializer)?;
608        if wire.version != METADATA_SCHEMA_WIRE_VERSION_V1 {
609            return Err(de::Error::custom("unsupported MetadataSchema wire format version"));
610        }
611        Ok(Self::new(
612            wire.fields.into_inner(),
613            wire.unknown_metadata_field_policy,
614            wire.unknown_filter_field_policy,
615        ))
616    }
617}
618
619impl Default for MetadataSchema {
620    /// Creates an empty schema that rejects undeclared metadata and filter
621    /// fields.
622    #[inline]
623    fn default() -> Self {
624        Self {
625            fields: BTreeMap::new(),
626            unknown_metadata_field_policy: UnknownMetadataFieldPolicy::Reject,
627            unknown_filter_field_policy: UnknownFilterFieldPolicy::Reject,
628        }
629    }
630}