Skip to main content

mz_deploy/types/
data_type.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! The type of a column in a data contract.
11//!
12//! [`DataType`] is the in-memory form. [`TypeLock`] and [`FieldLock`] are its
13//! on-disk form, shared by `types.lock` and the build artifact, and are the
14//! only place the tag vocabulary (`array`, `list`, `map`, `record`) is defined.
15
16use serde::{Deserialize, Serialize};
17use std::fmt;
18use thiserror::Error;
19
20/// On-disk tag for [`DataType::Array`].
21const ARRAY_TAG: &str = "array";
22/// On-disk tag for [`DataType::List`].
23const LIST_TAG: &str = "list";
24/// On-disk tag for [`DataType::Map`].
25const MAP_TAG: &str = "map";
26/// On-disk tag for [`DataType::Record`].
27const RECORD_TAG: &str = "record";
28
29/// A column's type, described structurally.
30///
31/// [`Named`] covers every type the SQL grammar can spell directly. The other
32/// variants exist because it cannot: an anonymous record has no data-type
33/// syntax at all, and the catalog reports an anonymous list or map without its
34/// element type.
35///
36/// A user-defined type is a [`Named`] holding its fully-qualified name. That
37/// qualification is load-bearing on disk: it is what keeps a user type called
38/// `record` from colliding with the tag for an anonymous record.
39///
40/// [`Named`]: DataType::Named
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum DataType {
43    /// A type spelled directly in SQL, e.g. `integer`, `numeric(39,2)`,
44    /// `app.public.my_type`.
45    Named(String),
46    Array(Box<DataType>),
47    List(Box<DataType>),
48    /// Materialize map keys are always `text`, so only the value type varies.
49    Map(Box<DataType>),
50    Record(Vec<RecordField>),
51}
52
53/// One field of a [`DataType::Record`].
54///
55/// Fields carry their own nullability because `SqlScalarType::Record` holds a
56/// full column type per field, while its list, map, and array variants hold a
57/// bare scalar type.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RecordField {
60    pub name: String,
61    pub r#type: DataType,
62    pub nullable: bool,
63}
64
65impl DataType {
66    /// Shorthand for a leaf type.
67    pub fn named(name: impl Into<String>) -> Self {
68        DataType::Named(name.into())
69    }
70
71    /// Whether a record appears anywhere in this type.
72    ///
73    /// A schema free of records is expressible as a `CREATE TABLE`; one
74    /// containing a record is not, and has to be built out of helper relations.
75    pub fn contains_record(&self) -> bool {
76        match self {
77            DataType::Named(_) => false,
78            DataType::Array(inner) | DataType::List(inner) | DataType::Map(inner) => {
79                inner.contains_record()
80            }
81            DataType::Record(_) => true,
82        }
83    }
84
85    /// Whether this type is one of the pseudo-type tokens the catalog reports
86    /// in place of a structural type it cannot spell.
87    ///
88    /// These are exactly the columns that need a `pg_typeof` probe at capture
89    /// time, and the ones no stub can be built from.
90    pub fn is_pseudo_token(&self) -> bool {
91        match self {
92            DataType::Named(name) => name == RECORD_TAG || name == LIST_TAG || name == MAP_TAG,
93            _ => false,
94        }
95    }
96
97    /// Whether a pseudo-type token appears anywhere in this type.
98    pub fn contains_pseudo_token(&self) -> bool {
99        match self {
100            DataType::Named(_) => self.is_pseudo_token(),
101            DataType::Array(inner) | DataType::List(inner) | DataType::Map(inner) => {
102                inner.contains_pseudo_token()
103            }
104            DataType::Record(fields) => fields.iter().any(|f| f.r#type.contains_pseudo_token()),
105        }
106    }
107}
108
109/// Renders the type as Materialize humanizes it, which is valid data-type
110/// syntax for every variant except [`DataType::Record`].
111///
112/// This is a display and hashing form. It is never parsed back: structure is
113/// recovered from [`TypeLock`], not from this string.
114impl fmt::Display for DataType {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            DataType::Named(name) => f.write_str(name),
118            DataType::Array(inner) => write!(f, "{}[]", inner),
119            DataType::List(inner) => write!(f, "{} list", inner),
120            DataType::Map(value) => write!(f, "map[text=>{}]", value),
121            DataType::Record(fields) => {
122                f.write_str("record(")?;
123                for (i, field) in fields.iter().enumerate() {
124                    if i > 0 {
125                        f.write_str(",")?;
126                    }
127                    write!(f, "{}: {}", field.name, field.r#type)?;
128                    if field.nullable {
129                        f.write_str("?")?;
130                    }
131                }
132                f.write_str(")")
133            }
134        }
135    }
136}
137
138impl DataType {
139    /// Serialize for storage in a text column of the build artifact.
140    pub(crate) fn to_json(&self) -> String {
141        serde_json::to_string(&TypeLock::from_data_type(self))
142            .expect("TypeLock is always serializable")
143    }
144
145    /// Parse a value written by [`DataType::to_json`].
146    pub(crate) fn from_json(json: &str) -> Result<DataType, DataTypeJsonError> {
147        serde_json::from_str::<TypeLock>(json)?
148            .into_data_type()
149            .map_err(DataTypeJsonError::Structure)
150    }
151}
152
153/// A stored type that could not be read back.
154#[derive(Error, Debug)]
155pub(crate) enum DataTypeJsonError {
156    #[error("malformed stored type")]
157    Malformed(#[from] serde_json::Error),
158    #[error(transparent)]
159    Structure(TypeLockError),
160}
161
162/// A structural payload that does not belong on the type it was recorded with.
163#[derive(Error, Debug)]
164pub enum TypeLockError {
165    #[error("type `{tag}` does not take an element type")]
166    UnexpectedElement { tag: String },
167    #[error("type `{tag}` requires an element type")]
168    MissingElement { tag: String },
169    #[error("type `{tag}` does not take fields")]
170    UnexpectedFields { tag: String },
171}
172
173/// On-disk form of a [`DataType`]: the type's name, plus the structural payload
174/// the SQL grammar cannot spell.
175///
176/// `of` carries the element type of an array or list and the value type of a
177/// map; `fields` carries a record's fields. Exactly one of them is present, and
178/// only for the tags that take it.
179#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
180pub(crate) struct TypeLock {
181    #[serde(rename = "type")]
182    pub name: String,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub of: Option<Box<TypeLock>>,
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub fields: Vec<FieldLock>,
187}
188
189/// On-disk form of a [`RecordField`]: a [`TypeLock`] widened with the field's
190/// name and nullability.
191#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
192pub(crate) struct FieldLock {
193    pub name: String,
194    #[serde(rename = "type")]
195    pub type_name: String,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub of: Option<Box<TypeLock>>,
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub fields: Vec<FieldLock>,
200    pub nullable: bool,
201}
202
203impl TypeLock {
204    pub(crate) fn from_data_type(r#type: &DataType) -> Self {
205        let (name, of, fields) = split_data_type(r#type);
206        TypeLock { name, of, fields }
207    }
208
209    pub(crate) fn into_data_type(self) -> Result<DataType, TypeLockError> {
210        join_data_type(self.name, self.of, self.fields)
211    }
212}
213
214impl FieldLock {
215    pub(crate) fn from_record_field(field: &RecordField) -> Self {
216        let (type_name, of, fields) = split_data_type(&field.r#type);
217        FieldLock {
218            name: field.name.clone(),
219            type_name,
220            of,
221            fields,
222            nullable: field.nullable,
223        }
224    }
225
226    pub(crate) fn into_record_field(self) -> Result<RecordField, TypeLockError> {
227        Ok(RecordField {
228            name: self.name,
229            r#type: join_data_type(self.type_name, self.of, self.fields)?,
230            nullable: self.nullable,
231        })
232    }
233}
234
235/// Decompose a type into the three keys the on-disk form carries.
236///
237/// Callers that widen the type with a name and nullability, such as a column
238/// entry, use this directly instead of going through [`TypeLock`].
239pub(crate) fn split_data_type(
240    r#type: &DataType,
241) -> (String, Option<Box<TypeLock>>, Vec<FieldLock>) {
242    match r#type {
243        DataType::Named(name) => (name.clone(), None, Vec::new()),
244        DataType::Array(inner) => (
245            ARRAY_TAG.into(),
246            Some(Box::new(TypeLock::from_data_type(inner))),
247            Vec::new(),
248        ),
249        DataType::List(inner) => (
250            LIST_TAG.into(),
251            Some(Box::new(TypeLock::from_data_type(inner))),
252            Vec::new(),
253        ),
254        DataType::Map(value) => (
255            MAP_TAG.into(),
256            Some(Box::new(TypeLock::from_data_type(value))),
257            Vec::new(),
258        ),
259        DataType::Record(fields) => (
260            RECORD_TAG.into(),
261            None,
262            fields.iter().map(FieldLock::from_record_field).collect(),
263        ),
264    }
265}
266
267/// Rebuild a type from the three on-disk keys, rejecting a payload that does
268/// not belong on the named tag.
269pub(crate) fn join_data_type(
270    name: String,
271    of: Option<Box<TypeLock>>,
272    fields: Vec<FieldLock>,
273) -> Result<DataType, TypeLockError> {
274    enum Tag {
275        Array,
276        List,
277        Map,
278        Record,
279        Named,
280    }
281
282    let tag = match name.as_str() {
283        ARRAY_TAG => Tag::Array,
284        LIST_TAG => Tag::List,
285        MAP_TAG => Tag::Map,
286        RECORD_TAG => Tag::Record,
287        _ => Tag::Named,
288    };
289
290    let takes_element = matches!(tag, Tag::Array | Tag::List | Tag::Map);
291    if of.is_some() && !takes_element {
292        return Err(TypeLockError::UnexpectedElement { tag: name });
293    }
294    if !fields.is_empty() && !matches!(tag, Tag::Record) {
295        return Err(TypeLockError::UnexpectedFields { tag: name });
296    }
297
298    // A container or record tag with no payload is the pseudo-type token the
299    // catalog reports for a type it cannot spell, which `lock` records when it
300    // cannot probe the column. Keep it as a `Named` type: the type is then
301    // reported against the column that uses it, rather than making the whole
302    // file unreadable.
303    match tag {
304        Tag::List | Tag::Map if of.is_none() => return Ok(DataType::Named(name)),
305        Tag::Record if fields.is_empty() => return Ok(DataType::Named(name)),
306        _ => {}
307    }
308
309    let element = match of {
310        Some(of) => Some(Box::new(of.into_data_type()?)),
311        None if takes_element => return Err(TypeLockError::MissingElement { tag: name }),
312        None => None,
313    };
314
315    Ok(match tag {
316        // `element` is `Some` for every arm that reads it: `takes_element` is
317        // true exactly there, and a missing element already returned above.
318        Tag::Array => DataType::Array(element.expect("array element")),
319        Tag::List => DataType::List(element.expect("list element")),
320        Tag::Map => DataType::Map(element.expect("map value")),
321        Tag::Record => DataType::Record(
322            fields
323                .into_iter()
324                .map(FieldLock::into_record_field)
325                .collect::<Result<_, _>>()?,
326        ),
327        Tag::Named => DataType::Named(name),
328    })
329}