Skip to main content

mz_deploy/client/
type_info.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//! Column-schema introspection for the data-contract and type-checking systems.
11//!
12//! Methods on [`TypeInfoClient`] query the Materialize system catalog for
13//! external dependencies and `CREATE TABLE FROM SOURCE` tables, returning their
14//! column names, types, nullability, object kinds, and comments as a
15//! `Types` snapshot.
16//!
17//! Plain `CREATE TABLE` objects are excluded — their schemas are derived from
18//! the SQL AST during type checking and do not need server queries.
19//!
20//! - **`lock`** uses [`query_types_for_objects`](TypeInfoClient::query_types_for_objects)
21//!   to generate `types.lock` for declared dependencies and source tables,
22//!   retrieving column types, object kind, and comments from the catalog in a
23//!   single query per object.
24//! - **`query_external_types`** delegates to `query_types_for_objects`, extracting
25//!   object lists from the compiled project graph.
26
27use crate::client::connection::TypeInfoClient;
28use crate::client::errors::ConnectionError;
29use crate::client::{humanized_type, quote_identifier};
30use crate::project::ir::object_id::ObjectId;
31use crate::types::{ColumnType, DataType, ObjectKind, Types};
32use crate::verbose;
33use itertools::Itertools;
34use serde::Deserialize;
35use std::collections::{BTreeMap, BTreeSet};
36
37/// Columns probed per `pg_typeof` statement.
38const PROBE_CHUNK: usize = 32;
39
40/// Per-object payload returned by the catalog query in `query_types_for_objects`.
41#[derive(Deserialize)]
42struct CatalogObjectInfo {
43    object_type: ObjectKind,
44    object_comment: Option<String>,
45    columns: Vec<CatalogColumnInfo>,
46}
47
48#[derive(Deserialize)]
49struct CatalogColumnInfo {
50    name: String,
51    /// `format_type` output: the scalar spelling with its modifiers, or a
52    /// pseudo-type token for a type the catalog cannot spell.
53    r#type: String,
54    nullable: bool,
55    position: i64,
56    comment: Option<String>,
57}
58
59impl TypeInfoClient<'_> {
60    /// Replace pseudo-type tokens in `tables` with the column's real type.
61    ///
62    /// `mz_columns` reports `record`, `list`, and `map` for types it cannot
63    /// spell, discarding a record's fields and a container's element type.
64    /// `pg_typeof` is the only surface that describes them, and it is a
65    /// plan-time constant (`mz_sql::func`), so wrapping the column in a scalar
66    /// subquery types it without reading any rows.
67    ///
68    /// Unlike the rest of `lock`, this reads through a cluster and needs
69    /// `SELECT` on the dependency. A failure therefore leaves the column at its
70    /// pseudo token and warns, rather than failing the lock: reconstruction
71    /// reports a precise error later, and only if something depends on the
72    /// column.
73    async fn resolve_pseudo_types(
74        &self,
75        lossy: &[(ObjectId, String)],
76        tables: &mut BTreeMap<ObjectId, BTreeMap<String, ColumnType>>,
77    ) {
78        let mut unresolved: Vec<String> = Vec::new();
79        for chunk in lossy.chunks(PROBE_CHUNK) {
80            let resolved = match self.probe_types(chunk).await {
81                Ok(resolved) => resolved,
82                // One bad column poisons its whole statement, so fall back to
83                // probing the chunk's columns one at a time.
84                Err(err) => {
85                    verbose!("batched type probe failed, retrying per column: {}", err);
86                    let mut resolved = Vec::new();
87                    for target in chunk {
88                        resolved.push(match self.probe_types(std::slice::from_ref(target)).await {
89                            Ok(one) => one.into_iter().next().flatten(),
90                            Err(err) => {
91                                verbose!(
92                                    "type probe for {}.{} failed: {}",
93                                    target.0,
94                                    target.1,
95                                    err
96                                );
97                                None
98                            }
99                        });
100                    }
101                    resolved
102                }
103            };
104
105            for ((object, column), rendered) in chunk.iter().zip_eq(resolved) {
106                let resolved = rendered
107                    .as_deref()
108                    .and_then(|r| humanized_type::parse(r).ok());
109                match resolved {
110                    Some(r#type) => {
111                        if let Some(col) = tables.get_mut(object).and_then(|c| c.get_mut(column)) {
112                            col.r#type = r#type;
113                        }
114                    }
115                    None => unresolved.push(format!("{}.{}", object, column)),
116                }
117            }
118        }
119
120        if !unresolved.is_empty() {
121            crate::cli::progress::warn(&format!(
122                "could not determine the full type of {}; re-run `mz-deploy lock` with access to these objects, or typechecking will report them as unreconstructible",
123                unresolved.join(", ")
124            ));
125        }
126    }
127
128    /// `pg_typeof` for each target, in order, as one statement.
129    async fn probe_types(
130        &self,
131        targets: &[(ObjectId, String)],
132    ) -> Result<Vec<Option<String>>, ConnectionError> {
133        let projections: Vec<String> = targets
134            .iter()
135            .enumerate()
136            .map(|(i, (object, column))| {
137                format!(
138                    "pg_typeof((SELECT {} FROM {} LIMIT 0)) AS t{}",
139                    quote_identifier(column),
140                    qualified_name(object),
141                    i
142                )
143            })
144            .collect();
145        let rows = self
146            .client
147            .query(&format!("SELECT {}", projections.join(", ")), &[])
148            .await?;
149        let Some(row) = rows.first() else {
150            return Ok(vec![None; targets.len()]);
151        };
152        Ok((0..targets.len()).map(|i| row.get(i)).collect())
153    }
154
155    /// Resolve the column schema, kind, and comments for `objects` plus
156    /// `source_tables` in a single catalog query.
157    ///
158    /// Joins `mz_catalog.mz_columns`, `mz_catalog.mz_objects`,
159    /// `mz_catalog.mz_schemas`, `mz_catalog.mz_databases`, and
160    /// `mz_internal.mz_comments` to retrieve columns, types, nullability,
161    /// object kind, and both object-level and column-level comments. Each input
162    /// triple `(database, schema, object)` is expanded from a single `jsonb`
163    /// parameter via `jsonb_array_elements`, and the per-object metadata is
164    /// returned as a `jsonb` blob deserialized by serde on the client.
165    ///
166    /// Returns `(types, missing)` where `missing` lists any input objects that
167    /// did not exist in the target catalog. The `lock` command surfaces those
168    /// as `DeclaredDependenciesMissing`.
169    ///
170    /// Source tables are always recorded as `ObjectKind::Table` regardless of
171    /// the catalog's `o.type`. Objects without columns (e.g. secrets,
172    /// connections) appear in the result with an empty column map.
173    pub async fn query_types_for_objects(
174        &self,
175        objects: &[ObjectId],
176        source_tables: &[ObjectId],
177    ) -> Result<(Types, Vec<ObjectId>), ConnectionError> {
178        let source_table_set: BTreeSet<&ObjectId> = source_tables.iter().collect();
179        let all_oids: Vec<&ObjectId> = objects.iter().chain(source_tables.iter()).collect();
180
181        if all_oids.is_empty() {
182            return Ok((
183                Types {
184                    tables: BTreeMap::new(),
185                    kinds: BTreeMap::new(),
186                    comments: BTreeMap::new(),
187                },
188                Vec::new(),
189            ));
190        }
191
192        // Pass the (db, schema, obj) triples as a single jsonb array. Materialize's
193        // unnest takes one array, so jsonb_array_elements is the cleanest way to
194        // expand the input into a row per object.
195        let input_json = serde_json::Value::Array(
196            all_oids
197                .iter()
198                .map(|o| {
199                    serde_json::json!({
200                        "db": o.database(),
201                        "sch": o.schema(),
202                        "obj": o.object(),
203                    })
204                })
205                .collect(),
206        );
207
208        let rows = self
209            .client
210            .query(
211                "WITH input AS ( \
212                    SELECT \
213                        elem->>'db' AS db, \
214                        elem->>'sch' AS sch, \
215                        elem->>'obj' AS obj \
216                    FROM jsonb_array_elements($1) AS elem \
217                 ) \
218                 SELECT \
219                    i.db AS db, \
220                    i.sch AS sch, \
221                    i.obj AS obj, \
222                    jsonb_build_object( \
223                        'object_type', o.type, \
224                        'object_comment', obj_comment.comment, \
225                        'columns', COALESCE( \
226                            jsonb_agg(jsonb_build_object( \
227                                'name', c.name, \
228                                'type', pg_catalog.format_type(c.type_oid, c.type_mod), \
229                                'nullable', c.nullable, \
230                                'position', c.position::int8, \
231                                'comment', col_comment.comment \
232                            )) FILTER (WHERE c.id IS NOT NULL), \
233                            '[]'::jsonb \
234                        ) \
235                    )::text AS data \
236                 FROM input i \
237                 JOIN mz_catalog.mz_schemas s ON s.name = i.sch \
238                 LEFT JOIN mz_catalog.mz_databases d ON d.id = s.database_id \
239                 JOIN mz_catalog.mz_objects o \
240                    ON o.schema_id = s.id AND o.name = i.obj \
241                 LEFT JOIN mz_catalog.mz_columns c ON c.id = o.id \
242                 LEFT JOIN mz_internal.mz_comments obj_comment \
243                    ON o.id = obj_comment.id AND obj_comment.object_sub_id IS NULL \
244                 LEFT JOIN mz_internal.mz_comments col_comment \
245                    ON c.id = col_comment.id AND col_comment.object_sub_id = c.position \
246                 WHERE (i.db IS NULL AND s.database_id IS NULL) \
247                    OR (i.db IS NOT NULL AND d.name = i.db) \
248                 GROUP BY i.db, i.sch, i.obj, o.type, obj_comment.comment",
249                &[&input_json],
250            )
251            .await?;
252
253        let mut tables = BTreeMap::new();
254        let mut kinds = BTreeMap::new();
255        let mut comments = BTreeMap::new();
256        let mut found = BTreeSet::new();
257        let mut lossy: Vec<(ObjectId, String)> = Vec::new();
258
259        for row in &rows {
260            let db: Option<String> = row.get("db");
261            let sch: String = row.get("sch");
262            let obj: String = row.get("obj");
263            let oid = match db {
264                Some(db) => ObjectId::new(db, sch, obj),
265                None => ObjectId::new_system(sch, obj),
266            };
267            let data: String = row.get("data");
268            let info: CatalogObjectInfo = serde_json::from_str(&data).map_err(|e| {
269                ConnectionError::Message(format!(
270                    "failed to decode catalog metadata for {}: {}",
271                    oid, e
272                ))
273            })?;
274
275            let kind = if source_table_set.contains(&oid) {
276                ObjectKind::Table
277            } else {
278                info.object_type
279            };
280            kinds.insert(oid.clone(), kind);
281
282            if let Some(comment) = info.object_comment {
283                comments.insert(oid.clone(), comment);
284            }
285
286            let mut columns = BTreeMap::new();
287            for col in info.columns {
288                let r#type = DataType::Named(col.r#type);
289                if r#type.is_pseudo_token() {
290                    lossy.push((oid.clone(), col.name.clone()));
291                }
292                columns.insert(
293                    col.name,
294                    ColumnType {
295                        r#type,
296                        nullable: col.nullable,
297                        position: usize::try_from(col.position).unwrap_or(0),
298                        comment: col.comment,
299                    },
300                );
301            }
302            tables.insert(oid.clone(), columns);
303            found.insert(oid);
304        }
305
306        self.resolve_pseudo_types(&lossy, &mut tables).await;
307
308        let missing: Vec<ObjectId> = all_oids
309            .iter()
310            .filter(|o| !found.contains(**o))
311            .map(|o| (*o).clone())
312            .collect();
313
314        Ok((
315            Types {
316                tables,
317                kinds,
318                comments,
319            },
320            missing,
321        ))
322    }
323}
324
325/// Quote an object for use in a query, dropping the database for the two-part
326/// system-schema form.
327fn qualified_name(object: &ObjectId) -> String {
328    match object.database() {
329        Some(db) => format!(
330            "{}.{}.{}",
331            quote_identifier(db),
332            quote_identifier(object.schema()),
333            quote_identifier(object.object()),
334        ),
335        None => format!(
336            "{}.{}",
337            quote_identifier(object.schema()),
338            quote_identifier(object.object()),
339        ),
340    }
341}