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}