Skip to main content

mz_deploy/
types.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//! Data-contract system for external dependencies.
11//!
12//! When a project references objects it does not own (e.g. tables created by an
13//! upstream ingestion pipeline), mz-deploy needs to know their column schemas so
14//! it can type-check views that depend on them. This module manages that contract
15//! through the `types.lock` file.
16//!
17//! ## Lock File Lifecycle
18//!
19//! 1. **Capture** — Column schemas are queried from the live environment and
20//!    written to `types.lock`.
21//! 2. **Compile** — The lock file is loaded and its schemas are used to resolve
22//!    external dependency columns during compilation.
23//! 3. **Validate** — During incremental typechecking, external dependency
24//!    schemas are provided to the validation backend when dirty objects
25//!    reference them.
26//!
27//! ## Compiler Integration
28//!
29//! Incremental runtime typechecking is owned by
30//! [`crate::project::compiler::typecheck`]. That subsystem persists per-object
31//! validation artifacts for consumers such as `explain` and the LSP.
32//!
33//! This module owns the `types.lock` contract format and the type vocabulary
34//! it records.
35//!
36//! ## Key Types
37//!
38//! - [`Types`] — In-memory representation of a `types.lock` file: a map from
39//!   fully-qualified object names to column schemas, plus optional
40//!   object-level comments from `COMMENT ON` in the source database.
41//! - [`ColumnType`] — A single column's type, nullability, and optional
42//!   `COMMENT ON COLUMN` description.
43//! - [`data_type::DataType`] — A column's type. Structural rather than a type
44//!   name, because a record, an anonymous list, and an anonymous map have no
45//!   spelling the SQL grammar accepts.
46//! - [`stub`] — Turns a recorded schema back into a relation.
47
48pub(crate) mod data_type;
49pub(crate) mod stub;
50
51pub(crate) use data_type::{DataType, RecordField};
52
53use crate::project::ir::object_id::ObjectId;
54use data_type::{FieldLock, TypeLock, TypeLockError};
55use serde::{Deserialize, Serialize};
56use std::collections::BTreeMap;
57use std::fmt;
58use std::fs;
59use std::path::{Path, PathBuf};
60use std::str::FromStr;
61use thiserror::Error;
62
63/// The kind of database object recorded in a `types.lock` entry.
64///
65/// `TableFromSource` is treated as `Table` from a contract perspective — both
66/// represent row-producing relations that can serve as FK targets.
67#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
68#[serde(rename_all = "kebab-case")]
69pub enum ObjectKind {
70    Table,
71    View,
72    MaterializedView,
73    Source,
74    Sink,
75    Secret,
76    Connection,
77}
78
79impl FromStr for ObjectKind {
80    type Err = String;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        match s {
84            "table" => Ok(ObjectKind::Table),
85            "view" => Ok(ObjectKind::View),
86            "materialized-view" => Ok(ObjectKind::MaterializedView),
87            "source" => Ok(ObjectKind::Source),
88            "sink" => Ok(ObjectKind::Sink),
89            "secret" => Ok(ObjectKind::Secret),
90            "connection" => Ok(ObjectKind::Connection),
91            _ => Err(format!("unknown object kind: {}", s)),
92        }
93    }
94}
95
96impl ObjectKind {
97    /// Parse from the kebab-case string stored in SQLite.
98    pub fn from_db_str(s: &str) -> Self {
99        match s {
100            "table" => ObjectKind::Table,
101            "view" => ObjectKind::View,
102            "materialized-view" => ObjectKind::MaterializedView,
103            "source" => ObjectKind::Source,
104            "sink" => ObjectKind::Sink,
105            "secret" => ObjectKind::Secret,
106            "connection" => ObjectKind::Connection,
107            _ => ObjectKind::Table,
108        }
109    }
110
111    /// Returns the kebab-case string matching the serde serialization format.
112    pub fn as_str(self) -> &'static str {
113        match self {
114            ObjectKind::Table => "table",
115            ObjectKind::View => "view",
116            ObjectKind::MaterializedView => "materialized-view",
117            ObjectKind::Source => "source",
118            ObjectKind::Sink => "sink",
119            ObjectKind::Secret => "secret",
120            ObjectKind::Connection => "connection",
121        }
122    }
123}
124
125impl fmt::Display for ObjectKind {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            ObjectKind::Table => write!(f, "table"),
129            ObjectKind::View => write!(f, "view"),
130            ObjectKind::MaterializedView => write!(f, "materialized view"),
131            ObjectKind::Source => write!(f, "source"),
132            ObjectKind::Sink => write!(f, "sink"),
133            ObjectKind::Secret => write!(f, "secret"),
134            ObjectKind::Connection => write!(f, "connection"),
135        }
136    }
137}
138
139/// Directory name for mz-deploy build artifacts.
140pub(crate) const BUILD_DIR: &str = "target";
141
142/// Highest `types.lock` format this binary understands.
143///
144/// Version 2 records structured types; version 1 recorded a bare SQL type name
145/// per column, which cannot express a record. A version 1 file still loads: its
146/// type names parse as [`DataType::Named`], and only the columns whose name was
147/// a pseudo-type token are unreconstructible.
148pub(crate) const LOCK_VERSION: u8 = 2;
149
150/// Errors that can occur when reading, writing, or parsing `types.lock` files.
151#[derive(Error, Debug)]
152pub enum TypesError {
153    #[error(transparent)]
154    BuildArtifactFailed(#[from] crate::project::compiler::cache::CacheError),
155
156    #[error("failed to read types.lock at {path}")]
157    FileReadFailed {
158        path: PathBuf,
159        #[source]
160        source: std::io::Error,
161    },
162    #[error("failed to write types.lock at {path}")]
163    FileWriteFailed {
164        path: PathBuf,
165        #[source]
166        source: std::io::Error,
167    },
168    #[error("failed to parse types.lock at {path}")]
169    ParseFailed {
170        path: PathBuf,
171        #[source]
172        source: toml::de::Error,
173    },
174    #[error(
175        "types.lock at {path} was written by a newer mz-deploy (format version {version}, this binary understands {supported}); upgrade mz-deploy"
176    )]
177    UnsupportedLockVersion {
178        path: PathBuf,
179        version: u8,
180        supported: u8,
181    },
182    #[error("types.lock at {path}: column `{column}` of `{object}` has an invalid type")]
183    InvalidColumnType {
184        path: PathBuf,
185        object: String,
186        column: String,
187        #[source]
188        source: TypeLockError,
189    },
190    #[error("failed to create directory {path}")]
191    DirectoryCreationFailed {
192        path: PathBuf,
193        #[source]
194        source: std::io::Error,
195    },
196    #[error(transparent)]
197    DependencyError(#[from] crate::project::error::DependencyError),
198}
199
200/// A single column's type, nullability, and optional comment in a data contract.
201#[derive(Debug, Clone, PartialEq)]
202pub struct ColumnType {
203    /// The column's type, structured so that it survives the round trip through
204    /// the lock file and the build artifact.
205    pub r#type: DataType,
206    pub nullable: bool,
207    /// Original column position from the database schema.
208    pub position: usize,
209    /// Optional `COMMENT ON COLUMN` description from the source database.
210    pub comment: Option<String>,
211}
212
213/// In-memory representation of a `types.lock` file.
214///
215/// Maps `ObjectId` (fully-qualified `database.schema.object`) to column
216/// schemas. Used for type-checking views against external dependencies.
217/// Optionally includes object-level and column-level comments from
218/// `COMMENT ON` statements in the source database.
219#[derive(Debug, Clone, PartialEq)]
220pub struct Types {
221    pub tables: BTreeMap<ObjectId, BTreeMap<String, ColumnType>>,
222    pub kinds: BTreeMap<ObjectId, ObjectKind>,
223    /// Object-level comments from `COMMENT ON` in the source database.
224    pub comments: BTreeMap<ObjectId, String>,
225}
226
227impl Default for Types {
228    fn default() -> Self {
229        Types {
230            tables: BTreeMap::new(),
231            kinds: BTreeMap::new(),
232            comments: BTreeMap::new(),
233        }
234    }
235}
236
237/// TOML serialization format for types.lock
238#[derive(Serialize, Deserialize)]
239struct TypesLock {
240    version: u8,
241    #[serde(default)]
242    table: Vec<ObjectLock>,
243    #[serde(default)]
244    view: Vec<ObjectLock>,
245    #[serde(default, rename = "materialized-view")]
246    materialized_view: Vec<ObjectLock>,
247    #[serde(default)]
248    source: Vec<ObjectLock>,
249    #[serde(default)]
250    sink: Vec<ObjectLock>,
251    #[serde(default)]
252    secret: Vec<ObjectLock>,
253    #[serde(default)]
254    connection: Vec<ObjectLock>,
255}
256
257impl Default for TypesLock {
258    fn default() -> Self {
259        Self {
260            version: LOCK_VERSION,
261            table: vec![],
262            view: vec![],
263            materialized_view: vec![],
264            source: vec![],
265            sink: vec![],
266            secret: vec![],
267            connection: vec![],
268        }
269    }
270}
271
272impl TypesLock {
273    /// Collect all objects paired with their kind, consuming the lock.
274    fn into_objects(self) -> Vec<(ObjectKind, ObjectLock)> {
275        let kinds = [
276            (ObjectKind::Table, self.table),
277            (ObjectKind::View, self.view),
278            (ObjectKind::MaterializedView, self.materialized_view),
279            (ObjectKind::Source, self.source),
280            (ObjectKind::Sink, self.sink),
281            (ObjectKind::Secret, self.secret),
282            (ObjectKind::Connection, self.connection),
283        ];
284        kinds
285            .into_iter()
286            .flat_map(|(kind, objs)| objs.into_iter().map(move |obj| (kind, obj)))
287            .collect()
288    }
289
290    /// Return a mutable reference to the vec for a given kind.
291    fn vec_for_kind(&mut self, kind: ObjectKind) -> &mut Vec<ObjectLock> {
292        match kind {
293            ObjectKind::Table => &mut self.table,
294            ObjectKind::View => &mut self.view,
295            ObjectKind::MaterializedView => &mut self.materialized_view,
296            ObjectKind::Source => &mut self.source,
297            ObjectKind::Sink => &mut self.sink,
298            ObjectKind::Secret => &mut self.secret,
299            ObjectKind::Connection => &mut self.connection,
300        }
301    }
302}
303
304#[derive(Serialize, Deserialize)]
305struct ObjectLock {
306    name: ObjectId,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    comment: Option<String>,
309    columns: Vec<ColumnLock>,
310}
311
312/// On-disk form of a column: its name and nullability widened over the
313/// `type`/`of`/`fields` keys of [`crate::types::data_type`].
314#[derive(Serialize, Deserialize)]
315struct ColumnLock {
316    name: String,
317    #[serde(rename = "type")]
318    type_name: String,
319    nullable: bool,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    comment: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    of: Option<Box<TypeLock>>,
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    fields: Vec<FieldLock>,
326}
327
328impl ColumnLock {
329    fn new(name: String, column: &ColumnType) -> Self {
330        let (type_name, of, fields) = data_type::split_data_type(&column.r#type);
331        ColumnLock {
332            name,
333            type_name,
334            nullable: column.nullable,
335            comment: column.comment.clone(),
336            of,
337            fields,
338        }
339    }
340
341    fn into_column_type(self, position: usize) -> Result<(String, ColumnType), TypeLockError> {
342        let r#type = data_type::join_data_type(self.type_name, self.of, self.fields)?;
343        Ok((
344            self.name,
345            ColumnType {
346                r#type,
347                nullable: self.nullable,
348                position,
349                comment: self.comment,
350            },
351        ))
352    }
353}
354
355impl From<&Types> for TypesLock {
356    fn from(types: &Types) -> Self {
357        let mut lock = TypesLock {
358            version: LOCK_VERSION,
359            table: Vec::new(),
360            view: Vec::new(),
361            materialized_view: Vec::new(),
362            source: Vec::new(),
363            sink: Vec::new(),
364            secret: Vec::new(),
365            connection: Vec::new(),
366        };
367
368        for (id, columns) in &types.tables {
369            let mut cols: Vec<_> = columns.iter().collect();
370            cols.sort_by_key(|(_, ct)| ct.position);
371            let cols: Vec<ColumnLock> = cols
372                .into_iter()
373                .map(|(col_name, col_type)| ColumnLock::new(col_name.clone(), col_type))
374                .collect();
375
376            let kind = types
377                .kinds
378                .get(id)
379                .unwrap_or_else(|| panic!("no kind for type {}", id.clone()));
380            let comment = types.comments.get(id).cloned();
381
382            let obj = ObjectLock {
383                name: id.clone(),
384                comment,
385                columns: cols,
386            };
387
388            lock.vec_for_kind(*kind).push(obj);
389        }
390
391        lock
392    }
393}
394
395impl TypesLock {
396    /// Convert into the in-memory form, reporting the object and column of any
397    /// type whose structural payload does not match its tag.
398    fn into_types(self, path: &Path) -> Result<Types, TypesError> {
399        let mut tables = BTreeMap::new();
400        let mut kinds = BTreeMap::new();
401        let mut comments = BTreeMap::new();
402        for (kind, obj) in self.into_objects() {
403            let id = obj.name;
404            let mut columns = BTreeMap::new();
405            for (position, col) in obj.columns.into_iter().enumerate() {
406                let object = id.to_string();
407                let column = col.name.clone();
408                let (name, column_type) = col.into_column_type(position).map_err(|source| {
409                    TypesError::InvalidColumnType {
410                        path: path.to_path_buf(),
411                        object,
412                        column,
413                        source,
414                    }
415                })?;
416                columns.insert(name, column_type);
417            }
418            kinds.insert(id.clone(), kind);
419            if let Some(comment) = obj.comment {
420                comments.insert(id.clone(), comment);
421            }
422            tables.insert(id, columns);
423        }
424
425        Ok(Types {
426            tables,
427            kinds,
428            comments,
429        })
430    }
431}
432
433/// Escape a string for use as a TOML basic string value.
434fn escape_toml_string(s: &str) -> String {
435    let mut out = String::with_capacity(s.len());
436    for c in s.chars() {
437        match c {
438            '\\' => out.push_str("\\\\"),
439            '"' => out.push_str("\\\""),
440            '\n' => out.push_str("\\n"),
441            '\r' => out.push_str("\\r"),
442            '\t' => out.push_str("\\t"),
443            c if c.is_control() => {
444                out.push_str(&format!("\\u{:04X}", u32::from(c)));
445            }
446            c => out.push(c),
447        }
448    }
449    out
450}
451
452/// Hand-format a `TypesLock` as TOML with per-kind sections and inline columns.
453fn write_toml(lock: &TypesLock) -> String {
454    let mut out = String::new();
455    out.push_str("# This file is automatically @generated by mz-deploy.\n");
456    out.push_str("# It is not intended for manual editing.\n");
457    out.push_str(&format!("version = {}\n", lock.version));
458
459    let sections: &[(ObjectKind, &Vec<ObjectLock>)] = &[
460        (ObjectKind::Secret, &lock.secret),
461        (ObjectKind::Connection, &lock.connection),
462        (ObjectKind::Source, &lock.source),
463        (ObjectKind::Table, &lock.table),
464        (ObjectKind::View, &lock.view),
465        (ObjectKind::MaterializedView, &lock.materialized_view),
466        (ObjectKind::Sink, &lock.sink),
467    ];
468
469    for (kind, objs) in sections {
470        for obj in *objs {
471            out.push('\n');
472            out.push_str(&format!("[[{}]]\n", kind.as_str()));
473            out.push_str(&format!(
474                "name = \"{}\"\n",
475                escape_toml_string(&obj.name.to_string())
476            ));
477            if let Some(comment) = &obj.comment {
478                out.push_str(&format!("comment = \"{}\"\n", escape_toml_string(comment)));
479            }
480            out.push_str("columns = [\n");
481            for col in &obj.columns {
482                write_column(&mut out, col, COLUMN_INDENT);
483            }
484            out.push_str("]\n");
485        }
486    }
487
488    out
489}
490
491/// Indentation of a column entry inside a `columns` array.
492const COLUMN_INDENT: usize = 4;
493
494/// Render one column as a TOML inline table, breaking a record's field list
495/// across lines.
496///
497/// A newline inside the nested `fields` array is legal TOML: it sits within an
498/// array value, not between the inline table's own braces.
499fn write_column(out: &mut String, col: &ColumnLock, indent: usize) {
500    let pad = " ".repeat(indent);
501    out.push_str(&pad);
502    out.push_str(&format!(
503        "{{ name = \"{}\", type = \"{}\", nullable = {}",
504        escape_toml_string(&col.name),
505        escape_toml_string(&col.type_name),
506        col.nullable,
507    ));
508    if let Some(comment) = &col.comment {
509        out.push_str(&format!(", comment = \"{}\"", escape_toml_string(comment)));
510    }
511    if let Some(of) = &col.of {
512        out.push_str(", of = ");
513        write_inline_type(out, of);
514    }
515    if !col.fields.is_empty() {
516        out.push_str(", fields = [\n");
517        for field in &col.fields {
518            write_field(out, field, indent + COLUMN_INDENT);
519        }
520        out.push_str(&pad);
521        out.push(']');
522    }
523    out.push_str(" },\n");
524}
525
526/// Render one record field. Fields carry no comment, so this is [`write_column`]
527/// over the field's own keys.
528fn write_field(out: &mut String, field: &FieldLock, indent: usize) {
529    write_column(
530        out,
531        &ColumnLock {
532            name: field.name.clone(),
533            type_name: field.type_name.clone(),
534            nullable: field.nullable,
535            comment: None,
536            of: field.of.clone(),
537            fields: field.fields.clone(),
538        },
539        indent,
540    );
541}
542
543/// Render a container's element type as a single-line inline table.
544fn write_inline_type(out: &mut String, ty: &TypeLock) {
545    out.push_str(&format!("{{ type = \"{}\"", escape_toml_string(&ty.name)));
546    if let Some(of) = &ty.of {
547        out.push_str(", of = ");
548        write_inline_type(out, of);
549    }
550    if !ty.fields.is_empty() {
551        out.push_str(", fields = [");
552        for (i, field) in ty.fields.iter().enumerate() {
553            if i > 0 {
554                out.push_str(", ");
555            }
556            let mut buf = String::new();
557            write_field(&mut buf, field, 0);
558            out.push_str(buf.trim_end().trim_end_matches(','));
559        }
560        out.push(']');
561    }
562    out.push_str(" }");
563}
564
565/// Load the types.lock file from the specified directory.
566/// Returns an error if the file doesn't exist or cannot be parsed.
567pub(crate) fn load_types_lock(directory: &Path) -> Result<Types, TypesError> {
568    let path = directory.join("types.lock");
569
570    let contents = fs::read_to_string(&path).map_err(|source| TypesError::FileReadFailed {
571        path: path.clone(),
572        source,
573    })?;
574
575    let lock: TypesLock = toml::from_str(&contents).map_err(|source| TypesError::ParseFailed {
576        path: path.clone(),
577        source,
578    })?;
579    if lock.version > LOCK_VERSION {
580        return Err(TypesError::UnsupportedLockVersion {
581            path,
582            version: lock.version,
583            supported: LOCK_VERSION,
584        });
585    }
586    lock.into_types(&path)
587}
588
589impl Types {
590    /// Write the types.lock file to the specified directory.
591    /// Overwrites any existing file at that location.
592    pub fn write_types_lock(&self, directory: &Path) -> Result<(), TypesError> {
593        let path = directory.join("types.lock");
594
595        let lock = TypesLock::from(self);
596        let contents = write_toml(&lock);
597
598        fs::write(&path, contents).map_err(|source| TypesError::FileWriteFailed { path, source })
599    }
600
601    /// Get the column schema for an object.
602    pub fn get_table(&self, id: &ObjectId) -> Option<&BTreeMap<String, ColumnType>> {
603        self.tables.get(id)
604    }
605
606    /// Get the object kind for an object.
607    ///
608    /// Returns `Table` if the id is not in the kinds map, which can happen
609    /// when `Types` is constructed programmatically (e.g., from `type_info`).
610    pub fn get_kind(&self, id: &ObjectId) -> ObjectKind {
611        self.kinds.get(id).copied().unwrap_or(ObjectKind::Table)
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use std::collections::BTreeMap;
619
620    #[mz_ore::test]
621    fn test_write_and_read_types_lock_round_trip() {
622        let mut tables = BTreeMap::new();
623
624        let mut order_cols = BTreeMap::new();
625        order_cols.insert(
626            "amount".to_string(),
627            ColumnType {
628                r#type: DataType::named("numeric"),
629                nullable: true,
630                position: 0,
631                comment: None,
632            },
633        );
634        order_cols.insert(
635            "id".to_string(),
636            ColumnType {
637                r#type: DataType::named("integer"),
638                nullable: false,
639                position: 1,
640                comment: None,
641            },
642        );
643        order_cols.insert(
644            "user_id".to_string(),
645            ColumnType {
646                r#type: DataType::named("integer"),
647                nullable: true,
648                position: 2,
649                comment: None,
650            },
651        );
652        tables.insert("app.ingest.orders".parse::<ObjectId>().unwrap(), order_cols);
653
654        let mut user_cols = BTreeMap::new();
655        user_cols.insert(
656            "name".to_string(),
657            ColumnType {
658                r#type: DataType::named("text"),
659                nullable: true,
660                position: 0,
661                comment: None,
662            },
663        );
664        user_cols.insert(
665            "user_id".to_string(),
666            ColumnType {
667                r#type: DataType::named("integer"),
668                nullable: false,
669                position: 1,
670                comment: None,
671            },
672        );
673        tables.insert("app.ingest.users".parse::<ObjectId>().unwrap(), user_cols);
674
675        let mut kinds = BTreeMap::new();
676        kinds.insert(
677            "app.ingest.orders".parse::<ObjectId>().unwrap(),
678            ObjectKind::Table,
679        );
680        kinds.insert(
681            "app.ingest.users".parse::<ObjectId>().unwrap(),
682            ObjectKind::Table,
683        );
684
685        let types = Types {
686            tables,
687            kinds,
688            comments: BTreeMap::new(),
689        };
690
691        let dir = tempfile::tempdir().expect("failed to create temp dir");
692        types
693            .write_types_lock(dir.path())
694            .expect("failed to write types.lock");
695
696        let loaded = load_types_lock(dir.path()).expect("failed to load types.lock");
697        assert_eq!(types, loaded);
698    }
699
700    #[mz_ore::test]
701    fn test_round_trip_with_kind() {
702        let mut tables = BTreeMap::new();
703        let mut cols = BTreeMap::new();
704        cols.insert(
705            "id".to_string(),
706            ColumnType {
707                r#type: DataType::named("integer"),
708                nullable: false,
709                position: 0,
710                comment: None,
711            },
712        );
713        tables.insert(
714            "app.ingest.orders".parse::<ObjectId>().unwrap(),
715            cols.clone(),
716        );
717        tables.insert(
718            "app.ingest.order_summary".parse::<ObjectId>().unwrap(),
719            cols,
720        );
721
722        let mut kinds = BTreeMap::new();
723        kinds.insert(
724            "app.ingest.orders".parse::<ObjectId>().unwrap(),
725            ObjectKind::Table,
726        );
727        kinds.insert(
728            "app.ingest.order_summary".parse::<ObjectId>().unwrap(),
729            ObjectKind::MaterializedView,
730        );
731
732        let types = Types {
733            tables,
734            kinds,
735            comments: BTreeMap::new(),
736        };
737
738        let dir = tempfile::tempdir().expect("failed to create temp dir");
739        types
740            .write_types_lock(dir.path())
741            .expect("failed to write types.lock");
742
743        let loaded = load_types_lock(dir.path()).expect("failed to load types.lock");
744        assert_eq!(types, loaded);
745    }
746
747    #[mz_ore::test]
748    fn test_round_trip_with_comments() {
749        let mut tables = BTreeMap::new();
750        let mut cols = BTreeMap::new();
751        cols.insert(
752            "id".to_string(),
753            ColumnType {
754                r#type: DataType::named("integer"),
755                nullable: false,
756                position: 0,
757                comment: Some("Primary key".to_string()),
758            },
759        );
760        cols.insert(
761            "name".to_string(),
762            ColumnType {
763                r#type: DataType::named("text"),
764                nullable: true,
765                position: 1,
766                comment: None,
767            },
768        );
769        tables.insert("app.ingest.orders".parse::<ObjectId>().unwrap(), cols);
770
771        let mut kinds = BTreeMap::new();
772        kinds.insert(
773            "app.ingest.orders".parse::<ObjectId>().unwrap(),
774            ObjectKind::Table,
775        );
776
777        let mut comments = BTreeMap::new();
778        comments.insert(
779            "app.ingest.orders".parse::<ObjectId>().unwrap(),
780            "All incoming customer orders".to_string(),
781        );
782
783        let types = Types {
784            tables,
785            kinds,
786            comments,
787        };
788
789        let dir = tempfile::tempdir().expect("failed to create temp dir");
790        types
791            .write_types_lock(dir.path())
792            .expect("failed to write types.lock");
793
794        let loaded = load_types_lock(dir.path()).expect("failed to load types.lock");
795        assert_eq!(types, loaded);
796    }
797
798    #[mz_ore::test]
799    fn test_backward_compat_no_comments() {
800        // A types.lock file without comment fields should parse successfully
801        let toml = r#"
802version = 1
803
804[[table]]
805name = "app.ingest.orders"
806columns = [
807    { name = "id", type = "integer", nullable = false },
808]
809"#;
810        let dir = tempfile::tempdir().expect("failed to create temp dir");
811        fs::write(dir.path().join("types.lock"), toml).unwrap();
812
813        let loaded = load_types_lock(dir.path()).expect("should parse without comments");
814        assert_eq!(loaded.tables.len(), 1);
815        assert!(loaded.comments.is_empty());
816        let cols = loaded
817            .tables
818            .get(&"app.ingest.orders".parse::<ObjectId>().unwrap())
819            .unwrap();
820        assert!(cols.get("id").unwrap().comment.is_none());
821    }
822
823    /// A record's fields are written as an array nested inside the column's
824    /// inline table. The newlines are inside an array value, which TOML allows;
825    /// this pins that the `toml` crate agrees.
826    #[mz_ore::test]
827    fn structured_types_round_trip_through_the_lock_file() {
828        let payload = DataType::Record(vec![
829            RecordField {
830                name: "a".into(),
831                r#type: DataType::named("integer"),
832                nullable: false,
833            },
834            RecordField {
835                name: "n".into(),
836                r#type: DataType::Record(vec![RecordField {
837                    name: "x".into(),
838                    r#type: DataType::List(Box::new(DataType::named("uint8"))),
839                    nullable: true,
840                }]),
841                nullable: true,
842            },
843        ]);
844        let columns = BTreeMap::from([
845            (
846                "payload".to_string(),
847                ColumnType {
848                    r#type: payload,
849                    nullable: false,
850                    position: 0,
851                    comment: Some("nested".into()),
852                },
853            ),
854            (
855                "tags".to_string(),
856                ColumnType {
857                    r#type: DataType::List(Box::new(DataType::named("text"))),
858                    nullable: true,
859                    position: 1,
860                    comment: None,
861                },
862            ),
863            (
864                "grid".to_string(),
865                ColumnType {
866                    r#type: DataType::Array(Box::new(DataType::Map(Box::new(DataType::named(
867                        "int4",
868                    ))))),
869                    nullable: true,
870                    position: 2,
871                    comment: None,
872                },
873            ),
874        ]);
875
876        let id: ObjectId = "app.public.events".parse().unwrap();
877        let types = Types {
878            tables: BTreeMap::from([(id.clone(), columns)]),
879            kinds: BTreeMap::from([(id, ObjectKind::Table)]),
880            comments: BTreeMap::new(),
881        };
882
883        let dir = tempfile::tempdir().expect("failed to create temp dir");
884        types
885            .write_types_lock(dir.path())
886            .expect("failed to write types.lock");
887        let loaded = load_types_lock(dir.path()).expect("failed to load types.lock");
888        assert_eq!(types, loaded);
889    }
890
891    /// A lock file written before structured types still loads: its type names
892    /// become plain named types.
893    #[mz_ore::test]
894    fn version_1_lock_file_loads() {
895        let dir = tempfile::tempdir().expect("failed to create temp dir");
896        fs::write(
897            dir.path().join("types.lock"),
898            "version = 1\n\n[[table]]\nname = \"app.public.events\"\ncolumns = [\n    \
899             { name = \"id\", type = \"integer\", nullable = true },\n]\n",
900        )
901        .unwrap();
902
903        let loaded = load_types_lock(dir.path()).expect("a version 1 file still loads");
904        let events = &loaded.tables[&"app.public.events".parse::<ObjectId>().unwrap()];
905        assert_eq!(events["id"].r#type, DataType::named("integer"));
906    }
907
908    #[mz_ore::test]
909    fn newer_lock_file_is_refused() {
910        let dir = tempfile::tempdir().expect("failed to create temp dir");
911        fs::write(
912            dir.path().join("types.lock"),
913            format!("version = {}\n", LOCK_VERSION + 1),
914        )
915        .unwrap();
916
917        let err = load_types_lock(dir.path()).expect_err("a newer format is refused");
918        assert!(
919            err.to_string().contains("upgrade mz-deploy"),
920            "unexpected error: {err}"
921        );
922    }
923
924    /// `lock` leaves a column at its pseudo-type token when it cannot probe the
925    /// column, so that token has to survive a write and read back. Rejecting it
926    /// would make the file `lock` just wrote unreadable, and every caller
927    /// defaults a load failure to an empty contract.
928    #[mz_ore::test]
929    fn pseudo_type_tokens_round_trip() {
930        let columns: BTreeMap<String, ColumnType> = ["record", "list", "map"]
931            .into_iter()
932            .enumerate()
933            .map(|(position, token)| {
934                (
935                    token.to_string(),
936                    ColumnType {
937                        r#type: DataType::named(token),
938                        nullable: true,
939                        position,
940                        comment: None,
941                    },
942                )
943            })
944            .collect();
945
946        let id: ObjectId = "app.public.wide".parse().unwrap();
947        let types = Types {
948            tables: BTreeMap::from([(id.clone(), columns)]),
949            kinds: BTreeMap::from([(id, ObjectKind::View)]),
950            comments: BTreeMap::new(),
951        };
952
953        let dir = tempfile::tempdir().expect("failed to create temp dir");
954        types
955            .write_types_lock(dir.path())
956            .expect("failed to write types.lock");
957        let loaded = load_types_lock(dir.path()).expect("a pseudo token must load back");
958        assert_eq!(types, loaded);
959    }
960
961    /// A version 1 file records those same tokens, since that is what
962    /// `mz_columns.type` reported.
963    #[mz_ore::test]
964    fn version_1_pseudo_type_tokens_load() {
965        let dir = tempfile::tempdir().expect("failed to create temp dir");
966        fs::write(
967            dir.path().join("types.lock"),
968            "version = 1\n\n[[table]]\nname = \"app.public.events\"\ncolumns = [\n    \
969             { name = \"payload\", type = \"record\", nullable = true },\n    \
970             { name = \"tags\", type = \"list\", nullable = true },\n]\n",
971        )
972        .unwrap();
973
974        let loaded = load_types_lock(dir.path()).expect("a version 1 file still loads");
975        let events = &loaded.tables[&"app.public.events".parse::<ObjectId>().unwrap()];
976        assert_eq!(events["payload"].r#type, DataType::named("record"));
977        assert_eq!(events["tags"].r#type, DataType::named("list"));
978    }
979
980    #[mz_ore::test]
981    fn structural_payload_on_the_wrong_type_is_rejected() {
982        let dir = tempfile::tempdir().expect("failed to create temp dir");
983        fs::write(
984            dir.path().join("types.lock"),
985            "version = 2\n\n[[table]]\nname = \"app.public.events\"\ncolumns = [\n    \
986             { name = \"id\", type = \"integer\", nullable = true, fields = [\n        \
987             { name = \"a\", type = \"int4\", nullable = true },\n    ] },\n]\n",
988        )
989        .unwrap();
990
991        let err = load_types_lock(dir.path()).expect_err("fields on a scalar is rejected");
992        let message = err.to_string();
993        assert!(
994            message.contains("events") && message.contains("id"),
995            "error should name the object and column: {message}"
996        );
997    }
998}