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}