Skip to main content

radixdb_core/
schema_descriptor.rs

1use std::collections::BTreeMap;
2use std::fmt::Write;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7pub const SCHEMA_DESCRIPTOR_VERSION: &str = "radixdb.schema.v1";
8
9#[derive(Debug, thiserror::Error)]
10pub enum DescriptorError {
11    #[error("unsupported schema descriptor version '{0}'")]
12    UnsupportedVersion(String),
13    #[error("schema descriptor kind mismatch: expected {expected:?}, got {actual:?}")]
14    KindMismatch {
15        expected: DescriptorKind,
16        actual: DescriptorKind,
17    },
18    #[error("schema descriptor JSON error: {0}")]
19    Json(#[from] serde_json::Error),
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum DescriptorKind {
25    Table,
26    Database,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct DescriptorEnvelope<T> {
31    pub descriptor: String,
32    pub kind: DescriptorKind,
33    pub payload: T,
34}
35
36impl<T> DescriptorEnvelope<T>
37where
38    T: Serialize,
39{
40    pub fn new(kind: DescriptorKind, payload: T) -> Self {
41        Self {
42            descriptor: SCHEMA_DESCRIPTOR_VERSION.to_string(),
43            kind,
44            payload,
45        }
46    }
47
48    pub fn to_json(&self) -> Result<String, DescriptorError> {
49        Ok(serde_json::to_string(self)?)
50    }
51
52    pub fn to_pretty_json(&self) -> Result<String, DescriptorError> {
53        Ok(serde_json::to_string_pretty(self)?)
54    }
55}
56
57impl<T> DescriptorEnvelope<T>
58where
59    T: for<'de> Deserialize<'de>,
60{
61    pub fn from_json(json: &str, expected: DescriptorKind) -> Result<Self, DescriptorError> {
62        let envelope: Self = serde_json::from_str(json)?;
63        if envelope.descriptor != SCHEMA_DESCRIPTOR_VERSION {
64            return Err(DescriptorError::UnsupportedVersion(envelope.descriptor));
65        }
66        if envelope.kind != expected {
67            return Err(DescriptorError::KindMismatch {
68                expected,
69                actual: envelope.kind,
70            });
71        }
72        Ok(envelope)
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct DatabaseDescriptor {
78    pub schema_generation: u64,
79    pub fingerprint: String,
80    pub tables: Vec<TableDescriptor>,
81    pub views: Vec<ViewDescriptor>,
82    /// Durable SQL procedures visible to the descriptor caller.
83    ///
84    /// The field is additive to `radixdb.schema.v1`: old descriptors omit it
85    /// and retain their original fingerprint, while current producers include
86    /// it whenever at least one procedure exists.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub procedures: Vec<ProcedureDescriptor>,
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub extensions: BTreeMap<String, serde_json::Value>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct TableDescriptor {
95    pub catalog_id: String,
96    pub name: String,
97    pub schema_generation: u64,
98    pub fingerprint: String,
99    pub created_at: String,
100    pub updated_at: String,
101    pub columns: Vec<ColumnDescriptor>,
102    pub constraints: Vec<ConstraintDescriptor>,
103    pub indexes: Vec<IndexDescriptor>,
104    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
105    pub extensions: BTreeMap<String, serde_json::Value>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct ColumnDescriptor {
110    pub ordinal: u32,
111    pub name: String,
112    pub data_type: DataTypeDescriptor,
113    pub nullable: bool,
114    pub auto_increment: bool,
115    pub default_expression: Option<String>,
116    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117    pub extensions: BTreeMap<String, serde_json::Value>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(tag = "type", rename_all = "snake_case")]
122pub enum DataTypeDescriptor {
123    Null,
124    Integer,
125    Float,
126    DoublePrecision,
127    Text {
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        max_chars: Option<u32>,
130    },
131    Boolean,
132    /// UTC instant (`TIMESTAMP WITH TIME ZONE`).
133    Timestamp,
134    /// Civil date and time (`TIMESTAMP WITHOUT TIME ZONE`).
135    CivilTimestamp,
136    /// Civil time of day (`TIME WITHOUT TIME ZONE`).
137    Time,
138    Date,
139    Json,
140    Uuid,
141    Bytes,
142    Decimal {
143        precision: Option<u8>,
144        scale: Option<u8>,
145    },
146    Vector {
147        dimensions: u16,
148    },
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct ConstraintDescriptor {
153    pub id: u64,
154    pub name: String,
155    #[serde(flatten)]
156    pub definition: ConstraintDefinition,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(tag = "constraint_type", rename_all = "snake_case")]
161pub enum ConstraintDefinition {
162    PrimaryKey {
163        columns: Vec<String>,
164    },
165    Unique {
166        columns: Vec<String>,
167        owned_index: String,
168    },
169    ForeignKey {
170        columns: Vec<String>,
171        referenced_table: String,
172        referenced_columns: Vec<String>,
173        on_delete: ForeignKeyActionDescriptor,
174        on_update: ForeignKeyActionDescriptor,
175    },
176    Check {
177        column: Option<String>,
178        expression: String,
179        ordinal: u32,
180    },
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum ForeignKeyActionDescriptor {
186    Restrict,
187    Cascade,
188    SetNull,
189    NoAction,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct IndexDescriptor {
194    pub name: String,
195    pub method: String,
196    pub columns: Vec<String>,
197    pub unique: bool,
198    pub predicate: Option<String>,
199    pub options: BTreeMap<String, serde_json::Value>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct ViewDescriptor {
204    pub name: String,
205    pub query: String,
206    pub dependencies: Vec<String>,
207    pub result_columns: Vec<ResultColumnDescriptor>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ResultColumnDescriptor {
212    pub name: String,
213    pub data_type: DataTypeDescriptor,
214    pub nullable: bool,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ProcedureDescriptor {
219    pub catalog_id: String,
220    pub name: String,
221    pub definition_revision: u64,
222    pub fingerprint: String,
223    pub source_sha256: String,
224    pub language: String,
225    pub language_version: u16,
226    pub compiler_abi: u32,
227    pub runtime_abi: u32,
228    pub security: RoutineSecurityDescriptor,
229    pub volatility: RoutineVolatilityDescriptor,
230    pub arguments: Vec<RoutineArgumentDescriptor>,
231    pub result: RoutineResultDescriptor,
232    pub resource_policy: RoutineResourcePolicyDescriptor,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct RoutineArgumentDescriptor {
237    pub ordinal: u32,
238    pub name: String,
239    pub mode: RoutineArgumentModeDescriptor,
240    pub sql_type: String,
241    /// `None` denotes a catalog-defined extension type. Its stable SQL name is
242    /// still carried in `sql_type`; application generators may reject it until
243    /// an explicit codec binding is configured.
244    pub data_type: Option<DataTypeDescriptor>,
245    pub nullable: bool,
246    pub default_expression: Option<String>,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum RoutineArgumentModeDescriptor {
252    In,
253    Out,
254    InOut,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(tag = "kind", rename_all = "snake_case")]
259pub enum RoutineResultDescriptor {
260    Void,
261    Scalar {
262        sql_type: String,
263        data_type: Option<DataTypeDescriptor>,
264        nullable: bool,
265    },
266    Table {
267        columns: Vec<RoutineResultColumnDescriptor>,
268    },
269    Trigger,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct RoutineResultColumnDescriptor {
274    pub ordinal: u32,
275    pub name: String,
276    pub sql_type: String,
277    pub data_type: Option<DataTypeDescriptor>,
278    pub nullable: bool,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283pub enum RoutineSecurityDescriptor {
284    Invoker,
285    Definer,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum RoutineVolatilityDescriptor {
291    Immutable,
292    Stable,
293    Volatile,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297pub struct RoutineResourcePolicyDescriptor {
298    pub instructions: u64,
299    pub heap_bytes: u64,
300    pub frames: u32,
301    pub sql_statements: u64,
302    pub rows: u64,
303    pub result_bytes: u64,
304    pub deadline_ms: u64,
305}
306
307impl TableDescriptor {
308    pub fn to_json(&self) -> Result<String, DescriptorError> {
309        DescriptorEnvelope::new(DescriptorKind::Table, self.clone()).to_json()
310    }
311
312    pub fn from_json(json: &str) -> Result<Self, DescriptorError> {
313        Ok(DescriptorEnvelope::<Self>::from_json(json, DescriptorKind::Table)?.payload)
314    }
315
316    pub fn form_descriptor(&self) -> crate::TableFormDescriptor {
317        crate::TableFormDescriptor::from_table(self)
318    }
319
320    /// Compute the schema fingerprint with the fingerprint field itself blank.
321    pub fn computed_fingerprint(&self) -> Result<String, DescriptorError> {
322        let mut canonical = self.clone();
323        canonical.fingerprint.clear();
324        canonical_fingerprint(&canonical)
325    }
326
327    pub fn refresh_fingerprint(&mut self) -> Result<(), DescriptorError> {
328        self.fingerprint = self.computed_fingerprint()?;
329        Ok(())
330    }
331}
332
333impl DatabaseDescriptor {
334    pub fn to_json(&self) -> Result<String, DescriptorError> {
335        DescriptorEnvelope::new(DescriptorKind::Database, self.clone()).to_json()
336    }
337
338    pub fn from_json(json: &str) -> Result<Self, DescriptorError> {
339        Ok(DescriptorEnvelope::<Self>::from_json(json, DescriptorKind::Database)?.payload)
340    }
341
342    /// Compute the database fingerprint from its ordered, already-fingerprinted
343    /// table/view catalog while excluding the fingerprint field itself.
344    pub fn computed_fingerprint(&self) -> Result<String, DescriptorError> {
345        let mut canonical = self.clone();
346        canonical.fingerprint.clear();
347        canonical_fingerprint(&canonical)
348    }
349
350    pub fn refresh_fingerprint(&mut self) -> Result<(), DescriptorError> {
351        self.fingerprint = self.computed_fingerprint()?;
352        Ok(())
353    }
354}
355
356impl ProcedureDescriptor {
357    /// Compute the procedure contract fingerprint with the fingerprint field
358    /// itself blank. Source text is represented only by `source_sha256`.
359    pub fn computed_fingerprint(&self) -> Result<String, DescriptorError> {
360        let mut canonical = self.clone();
361        canonical.fingerprint.clear();
362        canonical_fingerprint(&canonical)
363    }
364
365    pub fn refresh_fingerprint(&mut self) -> Result<(), DescriptorError> {
366        self.fingerprint = self.computed_fingerprint()?;
367        Ok(())
368    }
369}
370
371pub fn canonical_fingerprint<T: Serialize>(value: &T) -> Result<String, DescriptorError> {
372    let encoded = serde_json::to_vec(value)?;
373    let digest = Sha256::digest(encoded);
374    let mut fingerprint = String::with_capacity(digest.len() * 2);
375    for byte in digest {
376        write!(&mut fingerprint, "{byte:02x}").expect("writing to String cannot fail");
377    }
378    Ok(fingerprint)
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn descriptor_envelope_round_trip_is_versioned_and_canonical() {
387        let descriptor = TableDescriptor {
388            catalog_id: "018f2b34-7a10-7cc2-8f3a-9d4b5c6d7e01".to_string(),
389            name: "people".to_string(),
390            schema_generation: 7,
391            fingerprint: "abc".to_string(),
392            created_at: "2026-08-21T00:00:00Z".to_string(),
393            updated_at: "2026-08-21T00:00:00Z".to_string(),
394            columns: Vec::new(),
395            constraints: Vec::new(),
396            indexes: Vec::new(),
397            extensions: BTreeMap::new(),
398        };
399        let envelope = DescriptorEnvelope::new(DescriptorKind::Table, descriptor.clone());
400        let json = envelope.to_json().unwrap();
401        assert_eq!(envelope.to_json().unwrap(), json);
402        let decoded =
403            DescriptorEnvelope::<TableDescriptor>::from_json(&json, DescriptorKind::Table).unwrap();
404        assert_eq!(decoded.payload, descriptor);
405        assert_eq!(
406            TableDescriptor::from_json(&descriptor.to_json().unwrap()).unwrap(),
407            descriptor
408        );
409        assert!(
410            DescriptorEnvelope::<TableDescriptor>::from_json(&json, DescriptorKind::Database)
411                .is_err()
412        );
413    }
414
415    #[test]
416    fn text_descriptor_preserves_old_unbounded_json_and_bounded_identity() {
417        let unbounded = DataTypeDescriptor::Text { max_chars: None };
418        assert_eq!(
419            serde_json::to_string(&unbounded).unwrap(),
420            r#"{"type":"text"}"#
421        );
422        assert_eq!(
423            serde_json::from_str::<DataTypeDescriptor>(r#"{"type":"text"}"#).unwrap(),
424            unbounded
425        );
426
427        let bounded = DataTypeDescriptor::Text {
428            max_chars: Some(255),
429        };
430        assert_eq!(
431            serde_json::to_string(&bounded).unwrap(),
432            r#"{"type":"text","max_chars":255}"#
433        );
434        assert_ne!(
435            canonical_fingerprint(&unbounded).unwrap(),
436            canonical_fingerprint(&bounded).unwrap()
437        );
438    }
439
440    #[test]
441    fn double_precision_has_a_distinct_stable_descriptor_identity() {
442        let double = DataTypeDescriptor::DoublePrecision;
443        assert_eq!(
444            serde_json::to_string(&double).unwrap(),
445            r#"{"type":"double_precision"}"#
446        );
447        assert_ne!(
448            canonical_fingerprint(&DataTypeDescriptor::Float).unwrap(),
449            canonical_fingerprint(&double).unwrap()
450        );
451    }
452}