Skip to main content

laminar_core/schema_binding/
mod.rs

1//! Durable connector contracts. Native schemas remain separate from Arrow read/input schemas.
2
3use std::collections::BTreeMap;
4
5use arrow_schema::Schema;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10/// Current schema contract and declarative mapping representation.
11pub const SCHEMA_BINDING_VERSION: u16 = 1;
12/// Maximum canonical size of one connector contract, including native references.
13pub const MAX_SCHEMA_BINDING_BYTES: usize = 4 * 1024 * 1024;
14/// Maximum number of logical or external fields in one binding.
15pub const MAX_SCHEMA_FIELDS: usize = 4096;
16/// Maximum transitive native schema artifacts in one binding.
17pub const MAX_SCHEMA_REFERENCES: usize = 64;
18
19/// Direction in which a connector applies its schema contract.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SchemaDirection {
23    /// External data is projected into a logical read contract.
24    Source,
25    /// Bound query output is mapped into a writer contract.
26    Sink,
27}
28
29/// Authority that established the immutable logical schema.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum SchemaOrigin {
33    /// User-supplied fields, including legacy catalog reconstruction.
34    Explicit,
35    /// A fixed connector protocol or generator definition.
36    BuiltIn,
37    /// Authoritative registry, database, table, or file metadata.
38    Metadata,
39    /// The output of a query bound against the catalog.
40    Query,
41    /// A separately authorized, bounded sample; never an authoritative constraint.
42    Sample,
43}
44
45/// One immutable native schema, including the semantics Arrow cannot express.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct NativeSchema {
49    /// Codec or native metadata representation (for example `avro` or `iceberg`).
50    pub format: String,
51    /// Stable resource identity and concrete schema selection, without credentials.
52    pub identity: BTreeMap<String, String>,
53    /// Complete native definition, preserving ordered fields, unions, defaults and IDs.
54    pub definition: serde_json::Value,
55    /// Transitive definitions in deterministic order.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub references: Vec<NativeSchemaArtifact>,
58}
59
60/// A transitive native definition and its scoped identity.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct NativeSchemaArtifact {
64    /// Native name used by the referring definition.
65    pub name: String,
66    /// Scoped concrete version/identifier.
67    pub identity: BTreeMap<String, String>,
68    /// Complete definition, including its own reference declarations.
69    pub definition: serde_json::Value,
70}
71
72/// Declarative mapping compiled once by the connector when it activates.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct SchemaFieldMapping {
76    /// Field in the read or query-input contract.
77    pub logical: String,
78    /// Field in the external or serialization contract.
79    pub external: String,
80}
81
82/// Immutable connector binding for one catalog generation.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct SchemaBinding {
86    /// Version of this encoding and mapping semantics.
87    pub version: u16,
88    /// Connector type, independent of credentials and connection handles.
89    pub connector: String,
90    /// Source or sink binding.
91    pub direction: SchemaDirection,
92    /// Origin of the logical contract.
93    pub origin: SchemaOrigin,
94    /// Complete logical reader or bound query-output schema.
95    pub logical: Schema,
96    /// Native reader/writer fields, when an external authority exists.
97    pub external: Option<Schema>,
98    /// Value/table/protocol contract, distinct from observed message writers.
99    pub value: Option<NativeSchema>,
100    /// Independently selected key contract, when the codec uses one.
101    pub key: Option<NativeSchema>,
102    /// Field mapping in logical field order.
103    pub mapping: Vec<SchemaFieldMapping>,
104    /// Engine changelog fields consumed by a sink and excluded from business writes.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub control_fields: Vec<String>,
107}
108
109/// Structural or directional incompatibility in a resolved schema contract.
110#[derive(Debug, Error)]
111pub enum SchemaBindingError {
112    /// Invalid contract, unsupported representation, or resource limit.
113    #[error("invalid schema contract: {0}")]
114    Invalid(String),
115    /// A field cannot be read or written under the declared policy.
116    #[error("incompatible schema contract: {0}")]
117    Incompatible(String),
118    /// Encoding failed.
119    #[error("schema contract encoding: {0}")]
120    Encoding(#[from] serde_json::Error),
121}
122
123impl SchemaBinding {
124    /// Construct a logical-only contract; no external mutation or inference occurs.
125    ///
126    /// # Errors
127    /// Rejects empty/duplicate fields and contracts exceeding the representation bounds.
128    pub fn logical(
129        connector: impl Into<String>,
130        direction: SchemaDirection,
131        origin: SchemaOrigin,
132        logical: Schema,
133    ) -> Result<Self, SchemaBindingError> {
134        let mapping = logical
135            .fields()
136            .iter()
137            .map(|field| SchemaFieldMapping {
138                logical: field.name().clone(),
139                external: field.name().clone(),
140            })
141            .collect();
142        let binding = Self {
143            version: SCHEMA_BINDING_VERSION,
144            connector: connector.into(),
145            direction,
146            origin,
147            logical,
148            external: None,
149            value: None,
150            key: None,
151            mapping,
152            control_fields: Vec::new(),
153        };
154        binding.canonical_bytes()?;
155        Ok(binding)
156    }
157
158    /// Bind fields by exact name and type. Sources may project external fields;
159    /// sinks must supply every writer field and may not discard extra input fields.
160    /// Casts, defaults and nullable fills require a destination-specific policy.
161    ///
162    /// # Errors
163    /// Rejects missing fields, differing types or unsafe directional nullability.
164    pub fn bind_external(&mut self, external: Schema) -> Result<(), SchemaBindingError> {
165        validation::validate_schema(&external)?;
166        let mapping = validation::mapping(
167            self.direction,
168            &self.logical,
169            &external,
170            &self.control_fields,
171        )?;
172        self.external = Some(external);
173        self.mapping = mapping;
174        Ok(())
175    }
176
177    /// Validate structure and produce deterministic JSON without reordering fields or unions.
178    ///
179    /// # Errors
180    /// Rejects invalid versions, incomplete mappings, and oversized contracts.
181    pub fn canonical_bytes(&self) -> Result<Vec<u8>, SchemaBindingError> {
182        validation::validate(self)?;
183        let mut value = serde_json::to_value(self)?;
184        value.sort_all_objects();
185        let bytes = serde_json::to_vec(&value)?;
186        if bytes.len() > MAX_SCHEMA_BINDING_BYTES {
187            return Err(SchemaBindingError::Invalid(format!(
188                "contract exceeds {MAX_SCHEMA_BINDING_BYTES} bytes"
189            )));
190        }
191        Ok(bytes)
192    }
193
194    /// SHA-256 of the versioned canonical encoding; native identity is also retained.
195    ///
196    /// # Errors
197    /// Returns structural or encoding errors for an invalid contract.
198    pub fn fingerprint(&self) -> Result<String, SchemaBindingError> {
199        const HEX: &[u8; 16] = b"0123456789abcdef";
200        let digest = Sha256::digest(self.canonical_bytes()?);
201        let mut fingerprint = String::with_capacity(64);
202        for byte in digest {
203            fingerprint.push(char::from(HEX[usize::from(byte >> 4)]));
204            fingerprint.push(char::from(HEX[usize::from(byte & 15)]));
205        }
206        Ok(fingerprint)
207    }
208
209    /// Decode and validate a persisted contract without contacting its external authority.
210    ///
211    /// # Errors
212    /// Rejects malformed, unsupported, or oversized representations.
213    pub fn decode(bytes: &[u8]) -> Result<Self, SchemaBindingError> {
214        if bytes.len() > MAX_SCHEMA_BINDING_BYTES {
215            return Err(SchemaBindingError::Invalid(
216                "encoded contract is oversized".into(),
217            ));
218        }
219        let binding: Self = serde_json::from_slice(bytes)?;
220        binding.canonical_bytes()?;
221        Ok(binding)
222    }
223}
224
225#[cfg(test)]
226mod tests;
227mod validation;
228pub use validation::same_logical_type;