Skip to main content

mz_deploy/types/
stub.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//! DDL that recreates a recorded column schema as a relation.
11//!
12//! Used both by the in-process typechecking catalog and by the container the
13//! `explain` command stages against, so it lives beside the contract rather
14//! than inside either consumer.
15//!
16//! A schema of ordinary types is a `CREATE TABLE`. A record has no data-type
17//! syntax, so a schema containing one becomes a view over helper tables: a
18//! relation alias in expression position plans to a record with that relation's
19//! field names and nullability (`mz_sql::plan::query::plan_identifier`), which
20//! is the only way to spell an anonymous record in SQL.
21
22use crate::client::quote_identifier;
23use crate::project::ir::object_id::ObjectId;
24use crate::types::{ColumnType, DataType, RecordField};
25use std::collections::BTreeMap;
26use thiserror::Error;
27
28/// A recorded type that cannot be turned back into a relation.
29#[derive(Error, Debug)]
30pub(crate) enum StubError {
31    #[error(
32        "`{object}`.`{column}` is recorded as the pseudo-type `{found}`, which does not describe the column's real type; re-run `mz-deploy lock` to capture it"
33    )]
34    UnreconstructibleType {
35        object: ObjectId,
36        column: String,
37        found: String,
38    },
39    #[error(
40        "`{object}`.`{column}` has type `{found}`; a record inside a container is not supported"
41    )]
42    RecordInContainer {
43        object: ObjectId,
44        column: String,
45        found: String,
46    },
47}
48
49/// Where a stub and its helper relations are created.
50pub(crate) struct StubTarget {
51    /// Fully-qualified, quoted name of the stub relation itself.
52    pub name: String,
53    /// Quoted, dot-terminated qualification for helper relations, e.g.
54    /// `"db"."schema".`.
55    pub helper_prefix: String,
56    /// Distinguishes this stub's helpers from another's in the same schema.
57    pub helper_stem: String,
58}
59
60/// Allocates names for the helper relations and aliases of one stub.
61///
62/// A nested derived table shares the enclosing scope, so every alias must be
63/// distinct across the whole stub, not just within one nesting level. Drawing
64/// all of them from one counter guarantees that.
65struct StubNames<'a> {
66    target: &'a StubTarget,
67    next: usize,
68}
69
70impl StubNames<'_> {
71    fn take(&mut self) -> usize {
72        let n = self.next;
73        self.next += 1;
74        n
75    }
76
77    fn helper(&mut self) -> String {
78        let n = self.take();
79        format!(
80            "{}{}",
81            self.target.helper_prefix,
82            quote_identifier(&format!("{}_h{}", self.target.helper_stem, n))
83        )
84    }
85}
86
87/// The statements that recreate `columns` as `target.name`.
88///
89/// Helper relations come first and the stub itself is always last, so callers
90/// execute the sequence in order.
91pub(crate) fn build_stub_statements(
92    object_id: &ObjectId,
93    target: &StubTarget,
94    columns: &BTreeMap<String, ColumnType>,
95) -> Result<Vec<String>, StubError> {
96    // `columns` is keyed by name, so iterating it directly yields alphabetical
97    // order. The schema's real column order lives in `ColumnType::position`.
98    let mut ordered: Vec<_> = columns.iter().collect();
99    ordered.sort_by_key(|(_, ct)| ct.position);
100
101    for (name, column) in &ordered {
102        check_reconstructible(object_id, name, &column.r#type)?;
103    }
104
105    if !ordered.iter().any(|(_, ct)| ct.r#type.contains_record()) {
106        let defs: Vec<String> = ordered
107            .iter()
108            .map(|(name, ct)| column_def(name, &ct.r#type, ct.nullable))
109            .collect();
110        return Ok(vec![format!(
111            "CREATE TABLE {} ({})",
112            target.name,
113            defs.join(", ")
114        )]);
115    }
116
117    let fields: Vec<RecordField> = ordered
118        .iter()
119        .map(|(name, ct)| RecordField {
120            name: (*name).clone(),
121            r#type: ct.r#type.clone(),
122            nullable: ct.nullable,
123        })
124        .collect();
125
126    let mut names = StubNames { target, next: 0 };
127    let mut statements = Vec::new();
128    let select = build_select(&fields, &mut names, &mut statements);
129    statements.push(format!("CREATE VIEW {} AS {}", target.name, select));
130    Ok(statements)
131}
132
133/// Reject a type the builder cannot express, naming the column that carries it.
134fn check_reconstructible(
135    object_id: &ObjectId,
136    column: &str,
137    r#type: &DataType,
138) -> Result<(), StubError> {
139    if r#type.contains_pseudo_token() {
140        return Err(StubError::UnreconstructibleType {
141            object: object_id.clone(),
142            column: column.to_string(),
143            found: r#type.to_string(),
144        });
145    }
146    match r#type {
147        DataType::Array(inner) | DataType::List(inner) | DataType::Map(inner)
148            if inner.contains_record() =>
149        {
150            Err(StubError::RecordInContainer {
151                object: object_id.clone(),
152                column: column.to_string(),
153                found: r#type.to_string(),
154            })
155        }
156        DataType::Record(fields) => fields
157            .iter()
158            .try_for_each(|field| check_reconstructible(object_id, column, &field.r#type)),
159        _ => Ok(()),
160    }
161}
162
163/// `"name" <type>[ NOT NULL]` for a table column.
164fn column_def(name: &str, r#type: &DataType, nullable: bool) -> String {
165    format!(
166        "{} {}{}",
167        quote_identifier(name),
168        r#type,
169        if nullable { "" } else { " NOT NULL" }
170    )
171}
172
173/// A `SELECT` whose output columns are exactly `fields`.
174///
175/// Appends the helper tables it needs to `statements`, always before the
176/// statement that will reference them.
177fn build_select(
178    fields: &[RecordField],
179    names: &mut StubNames<'_>,
180    statements: &mut Vec<String>,
181) -> String {
182    let scalars: Vec<&RecordField> = fields
183        .iter()
184        .filter(|f| !f.r#type.contains_record())
185        .collect();
186
187    // A helper table is what carries exact field nullability, which a `SELECT`
188    // of literals cannot express. It is only needed when there is a scalar
189    // field to put in it.
190    let mut from = Vec::new();
191    let base = if scalars.is_empty() {
192        String::new()
193    } else {
194        let helper = names.helper();
195        let defs: Vec<String> = scalars
196            .iter()
197            .map(|f| column_def(&f.name, &f.r#type, f.nullable))
198            .collect();
199        statements.push(format!("CREATE TABLE {} ({})", helper, defs.join(", ")));
200        let base = quote_identifier(&format!("__b{}", names.take()));
201        from.push(format!("{} AS {}", helper, base));
202        base
203    };
204
205    let mut projection = Vec::new();
206    for field in fields {
207        let alias = quote_identifier(&field.name);
208        match &field.r#type {
209            DataType::Record(inner) => {
210                let inner_select = build_select(inner, names, statements);
211                let record = quote_identifier(&format!("__r{}", names.take()));
212                if field.nullable {
213                    // A scalar subquery is nullable; a plain FROM alias is not.
214                    projection.push(format!(
215                        "(SELECT {record} FROM ({inner_select}) AS {record} LIMIT 1) AS {alias}"
216                    ));
217                } else {
218                    from.push(format!("({}) AS {}", inner_select, record));
219                    projection.push(format!("{} AS {}", record, alias));
220                }
221            }
222            _ => projection.push(format!("{}.{} AS {}", base, alias, alias)),
223        }
224    }
225
226    if from.is_empty() {
227        return format!("SELECT {}", projection.join(", "));
228    }
229    format!("SELECT {} FROM {}", projection.join(", "), from.join(", "))
230}