Skip to main content

mz_deploy/client/
humanized_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//! Decoding Materialize's humanized type syntax.
11//!
12//! `pg_typeof` is the only server surface that describes an anonymous record,
13//! and it describes it the way `EXPLAIN` does: `record(a: integer,b: text?)`,
14//! where a trailing `?` marks a nullable field. This module turns that back
15//! into a [`DataType`] so `mz-deploy lock` can record the real type.
16//!
17//! The syntax is not designed to be read back. Field names are rendered
18//! unquoted, so a name containing a delimiter is genuinely ambiguous; such a
19//! type is rejected rather than guessed at, and the caller falls back to the
20//! catalog's lossy spelling.
21
22use crate::types::{DataType, RecordField};
23use thiserror::Error;
24
25/// Characters that would make a field name ambiguous in unquoted output.
26const AMBIGUOUS: &[char] = &[',', ':', '?', '(', ')', '[', ']'];
27
28#[derive(Error, Debug)]
29#[error("cannot decode type `{input}`: {reason}")]
30pub(crate) struct HumanizedTypeError {
31    input: String,
32    reason: String,
33}
34
35impl HumanizedTypeError {
36    fn new(input: &str, reason: impl Into<String>) -> Self {
37        HumanizedTypeError {
38            input: input.to_string(),
39            reason: reason.into(),
40        }
41    }
42}
43
44/// Parse a type as rendered by `pg_typeof`.
45pub(crate) fn parse(input: &str) -> Result<DataType, HumanizedTypeError> {
46    parse_inner(input.trim(), input)
47}
48
49fn parse_inner(s: &str, input: &str) -> Result<DataType, HumanizedTypeError> {
50    if let Some(body) = enclosed(s, "record(", ')') {
51        let mut fields = Vec::new();
52        for part in split_top_level(body) {
53            fields.push(parse_field(part, input)?);
54        }
55        if fields.is_empty() {
56            return Err(HumanizedTypeError::new(input, "record has no fields"));
57        }
58        return Ok(DataType::Record(fields));
59    }
60
61    if let Some(body) = enclosed(s, "map[", ']') {
62        let Some(arrow) = find_top_level(body, "=>") else {
63            return Err(HumanizedTypeError::new(input, "map has no value type"));
64        };
65        return Ok(DataType::Map(Box::new(parse_inner(
66            body[arrow + 2..].trim(),
67            input,
68        )?)));
69    }
70
71    if let Some(element) = s.strip_suffix("[]") {
72        return Ok(DataType::Array(Box::new(parse_inner(element, input)?)));
73    }
74    if let Some(element) = s.strip_suffix(" list") {
75        return Ok(DataType::List(Box::new(parse_inner(element, input)?)));
76    }
77
78    if s.is_empty() {
79        return Err(HumanizedTypeError::new(input, "empty type"));
80    }
81    Ok(DataType::Named(s.to_string()))
82}
83
84fn parse_field(part: &str, input: &str) -> Result<RecordField, HumanizedTypeError> {
85    let Some(sep) = find_top_level(part, ": ") else {
86        return Err(HumanizedTypeError::new(
87            input,
88            format!("record field `{}` has no type", part.trim()),
89        ));
90    };
91    let name = part[..sep].trim();
92    if name.is_empty() || name.contains(AMBIGUOUS) {
93        return Err(HumanizedTypeError::new(
94            input,
95            format!("record field name `{}` is ambiguous unquoted", name),
96        ));
97    }
98    let mut rest = part[sep + 2..].trim();
99    let nullable = rest.ends_with('?') && depth_at(rest, rest.len() - 1) == 0;
100    if nullable {
101        rest = &rest[..rest.len() - 1];
102    }
103    Ok(RecordField {
104        name: name.to_string(),
105        r#type: parse_inner(rest, input)?,
106        nullable,
107    })
108}
109
110/// The body of `s` when it is exactly `prefix` … `close`, where `close` is the
111/// bracket matching the one `prefix` ends with and lands on the final
112/// character.
113///
114/// This is what separates `map[text=>int]` from `map[text=>int][]`, whose
115/// matching bracket closes early.
116fn enclosed<'a>(s: &'a str, prefix: &str, close: char) -> Option<&'a str> {
117    let body = s.strip_prefix(prefix)?;
118    let mut depth = 1;
119    for (i, c) in body.char_indices() {
120        match c {
121            '(' | '[' => depth += 1,
122            ')' | ']' => {
123                depth -= 1;
124                if depth == 0 {
125                    return (c == close && i + c.len_utf8() == body.len()).then_some(&body[..i]);
126                }
127            }
128            _ => {}
129        }
130    }
131    None
132}
133
134/// Bracket depth immediately before byte offset `at`.
135fn depth_at(s: &str, at: usize) -> i32 {
136    let mut depth = 0;
137    for (i, c) in s.char_indices() {
138        if i >= at {
139            break;
140        }
141        match c {
142            '(' | '[' => depth += 1,
143            ')' | ']' => depth -= 1,
144            _ => {}
145        }
146    }
147    depth
148}
149
150/// Split on commas that are not inside brackets.
151fn split_top_level(s: &str) -> Vec<&str> {
152    let mut parts = Vec::new();
153    let mut depth = 0;
154    let mut start = 0;
155    for (i, c) in s.char_indices() {
156        match c {
157            '(' | '[' => depth += 1,
158            ')' | ']' => depth -= 1,
159            ',' if depth == 0 => {
160                parts.push(&s[start..i]);
161                start = i + 1;
162            }
163            _ => {}
164        }
165    }
166    if !s.is_empty() {
167        parts.push(&s[start..]);
168    }
169    parts
170}
171
172/// Offset of the first `needle` that is not inside brackets.
173fn find_top_level(s: &str, needle: &str) -> Option<usize> {
174    let mut depth = 0;
175    for (i, c) in s.char_indices() {
176        match c {
177            '(' | '[' => depth += 1,
178            ')' | ']' => depth -= 1,
179            _ => {}
180        }
181        if depth == 0 && s[i..].starts_with(needle) {
182            return Some(i);
183        }
184    }
185    None
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// Every string here was produced by `pg_typeof` against a live
193    /// Materialize, so parsing them back is what the capture path relies on.
194    #[mz_ore::test]
195    fn pg_typeof_output_round_trips() {
196        for input in [
197            "integer",
198            "bigint",
199            "timestamp without time zone",
200            "character varying",
201            "record(a: integer,b: text?)",
202            "record(a: integer,b: text?,c: bigint list?)",
203            "record(a: integer,b: text?,nested: record(x: uint8,y: numeric?))",
204            "bigint list",
205            "text list list",
206            "map[text=>integer]",
207            "integer[]",
208            "record(a: integer,b: text?) list",
209            "record(a: integer,b: text?)[]",
210        ] {
211            let parsed = parse(input).unwrap_or_else(|e| panic!("{input}: {e}"));
212            assert_eq!(parsed.to_string(), input, "round trip changed {input}");
213        }
214    }
215
216    #[mz_ore::test]
217    fn map_of_array_is_not_mistaken_for_an_array_of_map() {
218        assert_eq!(
219            parse("map[text=>integer[]]").unwrap().to_string(),
220            "map[text=>integer[]]"
221        );
222        assert_eq!(
223            parse("map[text=>integer][]").unwrap().to_string(),
224            "map[text=>integer][]"
225        );
226    }
227
228    #[mz_ore::test]
229    fn nullability_is_read_per_field() {
230        let parsed = parse("record(a: integer,b: record(x: text)?)").unwrap();
231        let DataType::Record(fields) = parsed else {
232            panic!("expected a record");
233        };
234        assert!(!fields[0].nullable);
235        assert!(fields[1].nullable, "the `?` applies to the nested record");
236    }
237
238    /// Field names are rendered unquoted, so a name holding a delimiter is
239    /// genuinely ambiguous. Fail rather than guess.
240    #[mz_ore::test]
241    fn ambiguous_field_names_are_rejected() {
242        for input in ["record(a,b: integer)", "record(: integer)", "record()"] {
243            assert!(parse(input).is_err(), "{input} should not parse");
244        }
245    }
246}