Skip to main content

mz_expr/scalar/func/impls/
jsonb.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
10use std::collections::BTreeMap;
11use std::fmt;
12
13use mz_expr_derive::sqlfunc;
14use mz_repr::adt::jsonb::{Jsonb, JsonbRef};
15use mz_repr::adt::mz_acl_item::{AclMode, MzAclItem};
16use mz_repr::adt::numeric::{self, Numeric, NumericMaxScale};
17use mz_repr::role_id::RoleId;
18use mz_repr::{ArrayRustType, Datum, Row, RowPacker, SqlColumnType, SqlScalarType, strconv};
19use mz_sql_parser::ast::display::AstDisplay;
20use mz_sql_parser::ast::{
21    AstInfo, AvroSchema, ConnectionOption, ConnectionOptionName, CreateConnectionType,
22    CreateSinkConnection, CreateSubsourceOptionName, Format, FormatSpecifier,
23    IcebergSinkConfigOptionName, IcebergSinkMode, KafkaSinkConfigOptionName,
24    KafkaSourceConfigOptionName, PgConfigOptionName, ProtobufSchema, RawClusterName, RawItemName,
25    SinkEnvelope, SourceEnvelope, SourceErrorPolicy, UnresolvedItemName, Value, WithOptionValue,
26};
27use prost::Message as _;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30
31use crate::EvalError;
32use crate::scalar::func::EagerUnaryFunc;
33use crate::scalar::func::impls::numeric::*;
34
35#[sqlfunc(
36    sqlname = "jsonb_to_text",
37    preserves_uniqueness = false,
38    inverse = to_unary!(super::CastStringToJsonb)
39)]
40pub fn cast_jsonb_to_string<'a>(a: JsonbRef<'a>) -> String {
41    let mut buf = String::new();
42    strconv::format_jsonb(&mut buf, a);
43    buf
44}
45
46#[sqlfunc(sqlname = "jsonb_to_smallint", is_monotone = true)]
47fn cast_jsonb_to_int16<'a>(a: JsonbRef<'a>) -> Result<i16, EvalError> {
48    match a.into_datum() {
49        Datum::Numeric(a) => cast_numeric_to_int16(a.into_inner()),
50        datum => Err(EvalError::InvalidJsonbCast {
51            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
52            to: "smallint".into(),
53        }),
54    }
55}
56
57#[sqlfunc(sqlname = "jsonb_to_integer", is_monotone = true)]
58fn cast_jsonb_to_int32<'a>(a: JsonbRef<'a>) -> Result<i32, EvalError> {
59    match a.into_datum() {
60        Datum::Numeric(a) => cast_numeric_to_int32(a.into_inner()),
61        datum => Err(EvalError::InvalidJsonbCast {
62            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
63            to: "integer".into(),
64        }),
65    }
66}
67
68#[sqlfunc(sqlname = "jsonb_to_bigint", is_monotone = true)]
69fn cast_jsonb_to_int64<'a>(a: JsonbRef<'a>) -> Result<i64, EvalError> {
70    match a.into_datum() {
71        Datum::Numeric(a) => cast_numeric_to_int64(a.into_inner()),
72        datum => Err(EvalError::InvalidJsonbCast {
73            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
74            to: "bigint".into(),
75        }),
76    }
77}
78
79#[sqlfunc(sqlname = "jsonb_to_real", is_monotone = true)]
80fn cast_jsonb_to_float32<'a>(a: JsonbRef<'a>) -> Result<f32, EvalError> {
81    match a.into_datum() {
82        Datum::Numeric(a) => cast_numeric_to_float32(a.into_inner()),
83        datum => Err(EvalError::InvalidJsonbCast {
84            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
85            to: "real".into(),
86        }),
87    }
88}
89
90#[sqlfunc(sqlname = "jsonb_to_double", is_monotone = true)]
91fn cast_jsonb_to_float64<'a>(a: JsonbRef<'a>) -> Result<f64, EvalError> {
92    match a.into_datum() {
93        Datum::Numeric(a) => cast_numeric_to_float64(a.into_inner()),
94        datum => Err(EvalError::InvalidJsonbCast {
95            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
96            to: "double precision".into(),
97        }),
98    }
99}
100
101#[derive(
102    Ord,
103    PartialOrd,
104    Clone,
105    Debug,
106    Eq,
107    PartialEq,
108    Serialize,
109    Deserialize,
110    Hash
111)]
112pub struct CastJsonbToNumeric(pub Option<NumericMaxScale>);
113
114impl EagerUnaryFunc for CastJsonbToNumeric {
115    type Input<'a> = JsonbRef<'a>;
116    type Output<'a> = Result<Numeric, EvalError>;
117
118    fn call<'a>(&self, a: Self::Input<'a>) -> Self::Output<'a> {
119        match a.into_datum() {
120            Datum::Numeric(mut num) => match self.0 {
121                None => Ok(num.into_inner()),
122                Some(scale) => {
123                    if numeric::rescale(&mut num.0, scale.into_u8()).is_err() {
124                        return Err(EvalError::NumericFieldOverflow);
125                    };
126                    Ok(num.into_inner())
127                }
128            },
129            datum => Err(EvalError::InvalidJsonbCast {
130                from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
131                to: "numeric".into(),
132            }),
133        }
134    }
135
136    fn output_sql_type(&self, input: SqlColumnType) -> SqlColumnType {
137        SqlScalarType::Numeric { max_scale: self.0 }.nullable(input.nullable)
138    }
139
140    fn is_monotone(&self) -> bool {
141        true
142    }
143}
144
145impl fmt::Display for CastJsonbToNumeric {
146    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
147        f.write_str("jsonb_to_numeric")
148    }
149}
150
151#[sqlfunc(sqlname = "jsonb_to_boolean", is_monotone = true)]
152fn cast_jsonb_to_bool<'a>(a: JsonbRef<'a>) -> Result<bool, EvalError> {
153    match a.into_datum() {
154        Datum::True => Ok(true),
155        Datum::False => Ok(false),
156        datum => Err(EvalError::InvalidJsonbCast {
157            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
158            to: "boolean".into(),
159        }),
160    }
161}
162
163#[sqlfunc(sqlname = "jsonbable_to_jsonb")]
164fn cast_jsonbable_to_jsonb<'a>(a: JsonbRef<'a>) -> JsonbRef<'a> {
165    match a.into_datum() {
166        Datum::Numeric(n) => {
167            let n = n.into_inner();
168            let datum = if n.is_finite() {
169                Datum::from(n)
170            } else if n.is_nan() {
171                Datum::String("NaN")
172            } else if n.is_negative() {
173                Datum::String("-Infinity")
174            } else {
175                Datum::String("Infinity")
176            };
177            JsonbRef::from_datum(datum)
178        }
179        datum => JsonbRef::from_datum(datum),
180    }
181}
182
183#[sqlfunc]
184fn jsonb_array_length<'a>(a: JsonbRef<'a>) -> Result<Option<i32>, EvalError> {
185    match a.into_datum() {
186        Datum::List(list) => {
187            let count = list.iter().count();
188            match i32::try_from(count) {
189                Ok(len) => Ok(Some(len)),
190                Err(_) => Err(EvalError::Int32OutOfRange(count.to_string().into())),
191            }
192        }
193        _ => Ok(None),
194    }
195}
196
197#[sqlfunc]
198fn jsonb_typeof<'a>(a: JsonbRef<'a>) -> &'a str {
199    match a.into_datum() {
200        Datum::Map(_) => "object",
201        Datum::List(_) => "array",
202        Datum::String(_) => "string",
203        Datum::Numeric(_) => "number",
204        Datum::True | Datum::False => "boolean",
205        Datum::JsonNull => "null",
206        d => panic!("Not jsonb: {:?}", d),
207    }
208}
209
210#[sqlfunc]
211fn jsonb_strip_nulls<'a>(a: JsonbRef<'a>) -> Jsonb {
212    fn strip_nulls(a: Datum, row: &mut RowPacker) {
213        match a {
214            Datum::Map(dict) => row.push_dict_with(|row| {
215                for (k, v) in dict.iter() {
216                    match v {
217                        Datum::JsonNull => (),
218                        _ => {
219                            row.push(Datum::String(k));
220                            strip_nulls(v, row);
221                        }
222                    }
223                }
224            }),
225            Datum::List(list) => row.push_list_with(|row| {
226                for elem in list.iter() {
227                    strip_nulls(elem, row);
228                }
229            }),
230            _ => row.push(a),
231        }
232    }
233    let mut row = Row::default();
234    strip_nulls(a.into_datum(), &mut row.packer());
235    Jsonb::from_row(row)
236}
237
238// NOTE: no budget pre-check, see the exception on `crate::func::check_build_fits_budget`.
239#[sqlfunc]
240fn jsonb_pretty<'a>(a: JsonbRef<'a>) -> String {
241    let mut buf = String::new();
242    strconv::format_jsonb_pretty(&mut buf, a);
243    buf
244}
245
246/// Converts a JSONB `Datum` into a `u64`.
247fn jsonb_datum_to_u64<'a>(d: Datum<'a>) -> Result<u64, String> {
248    let Datum::Numeric(n) = d else {
249        return Err("expected numeric value".into());
250    };
251
252    let mut cx = numeric::cx_datum();
253    cx.try_into_u64(n.0)
254        .map_err(|_| format!("number out of u64 range: {n}"))
255}
256
257/// Decodes a JSONB object of shape `{"bitflags": <u64>}` into an `AclMode`.
258///
259/// Shared decoder for `parse_catalog_privileges` (which embeds the object as
260/// the `acl_mode` field of each privilege) and `parse_catalog_acl_mode` (which
261/// receives the object at the top level).
262fn jsonb_datum_to_acl_mode(d: Datum) -> Result<AclMode, String> {
263    let Datum::Map(dict) = d else {
264        return Err(format!("unexpected acl_mode: {d}"));
265    };
266    let mut bits = None;
267    for (key, val) in dict.iter() {
268        match key {
269            "bitflags" => bits = Some(jsonb_datum_to_u64(val)?),
270            other => return Err(format!("unexpected acl_mode field: {other}")),
271        }
272    }
273    let bits = bits.ok_or_else(|| "missing acl_mode bitflags".to_string())?;
274    AclMode::from_bits(bits).ok_or_else(|| format!("invalid acl_mode bitflags: {bits}"))
275}
276
277/// Converts a JSONB `Datum` into a `RoleId`.
278fn jsonb_datum_to_role_id(d: Datum) -> Result<RoleId, String> {
279    match d {
280        Datum::String("Public") => Ok(RoleId::Public),
281        Datum::String(other) => Err(format!("unexpected role ID variant: {other}")),
282        Datum::Map(dict) => {
283            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
284            let n = jsonb_datum_to_u64(val)?;
285            match key {
286                "User" => Ok(RoleId::User(n)),
287                "System" => Ok(RoleId::System(n)),
288                "Predefined" => Ok(RoleId::Predefined(n)),
289                other => Err(format!("unexpected role ID variant: {other}")),
290            }
291        }
292        _ => Err("expected string or object".into()),
293    }
294}
295
296/// Converts a catalog JSON-serialized ID value into the appropriate string format.
297///
298/// Supports all of Materialize's various ID types of the form `<prefix><u64>`.
299#[sqlfunc]
300fn parse_catalog_id<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
301    let parse = || match a.into_datum() {
302        // Unit variant, e.g. "Public"
303        Datum::String(variant) => match variant {
304            "Explain" => Ok("e".to_string()),
305            "Public" => Ok("p".to_string()),
306            other => Err(format!("unexpected ID variant: {other}")),
307        },
308        // Newtype variant, e.g. {"User": 1}
309        Datum::Map(dict) => {
310            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
311            let prefix = match key {
312                "IntrospectionSourceIndex" => "si",
313                "Predefined" => "g",
314                "System" => "s",
315                "Transient" => "t",
316                "User" => "u",
317                other => return Err(format!("unexpected ID variant: {other}")),
318            };
319            let n = jsonb_datum_to_u64(val)?;
320            Ok(format!("{prefix}{n}"))
321        }
322        _ => Err("expected string or object".into()),
323    };
324
325    parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))
326}
327
328/// Converts a catalog JSON-serialized privilege array into an `mz_aclitem[]`.
329#[sqlfunc]
330fn parse_catalog_privileges<'a>(a: JsonbRef<'a>) -> Result<ArrayRustType<MzAclItem>, EvalError> {
331    let parse_one = |datum| match datum {
332        Datum::Map(dict) => {
333            let mut grantee = None;
334            let mut grantor = None;
335            let mut acl_mode = None;
336            for (key, val) in dict.iter() {
337                match key {
338                    "grantee" => {
339                        let id = jsonb_datum_to_role_id(val)?;
340                        grantee = Some(id);
341                    }
342                    "grantor" => {
343                        let id = jsonb_datum_to_role_id(val)?;
344                        grantor = Some(id);
345                    }
346                    "acl_mode" => {
347                        acl_mode = Some(jsonb_datum_to_acl_mode(val)?);
348                    }
349                    other => return Err(format!("unexpected privilege field: {other}")),
350                }
351            }
352            Ok(MzAclItem {
353                grantee: grantee.ok_or_else(|| format!("missing grantee: {dict:?}"))?,
354                grantor: grantor.ok_or_else(|| "missing grantor in privilege".to_string())?,
355                acl_mode: acl_mode.ok_or_else(|| "missing acl_mode in privilege".to_string())?,
356            })
357        }
358        other => Err(format!("expected object in array, found: {other}")),
359    };
360
361    let parse = || match a.into_datum() {
362        Datum::List(list) => {
363            let mut result = Vec::new();
364            for item in list.iter() {
365                result.push(parse_one(item)?);
366            }
367            Ok(result)
368        }
369        _ => Err("expected array".to_string()),
370    };
371
372    parse()
373        .map(ArrayRustType)
374        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
375}
376
377/// Converts a catalog JSON-serialized `AclMode` bitflags object into a
378/// PostgreSQL ACL char-code string (e.g. `{"bitflags": 514}` → `"ar"`).
379#[sqlfunc]
380fn parse_catalog_acl_mode<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
381    jsonb_datum_to_acl_mode(a.into_datum())
382        .map(|mode| mode.to_string())
383        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
384}
385
386/// Extracts the string form of a `WithOptionValue`, matching how the planner
387/// coerces option values. The blanket `TryFromValue<WithOptionValue<T>>` impl in
388/// `src/sql/src/plan/with_options.rs` accepts a quoted string, a bare identifier,
389/// and a 1-part unresolved item name, all yielding a string. Any other variant
390/// yields None.
391///
392/// `AstDisplay` does not re-quote bare identifiers or 1-part names, so those
393/// forms persist unquoted in `create_sql`. Matching only `Value::String` here
394/// would miss them and silently fall back to a default, so the catalog-raw
395/// parsers below route their string options through this helper.
396fn option_string<T: AstInfo>(value: &WithOptionValue<T>) -> Option<String> {
397    match value {
398        WithOptionValue::Value(Value::String(s)) => Some(s.clone()),
399        WithOptionValue::Ident(ident) => Some(ident.clone().into_string()),
400        WithOptionValue::UnresolvedItemName(UnresolvedItemName(parts)) if parts.len() == 1 => {
401            Some(parts[0].clone().into_string())
402        }
403        _ => None,
404    }
405}
406
407/// Parses a catalog `create_sql` string into a JSONB object.
408///
409/// The returned JSONB does not fully reflect the parsed SQL and instead contains only fields
410/// required by current callers.
411///
412// TODO: This function isn't parsing JSONB and therefore shouldn't live in the `jsonb` module.
413//       Consider moving all the `parse_catalog_*` functions into their own module.
414#[sqlfunc]
415fn parse_catalog_create_sql<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
416    fn get_cluster_id(in_cluster: RawClusterName) -> Result<String, &'static str> {
417        match in_cluster {
418            RawClusterName::Resolved(s) => Ok(s),
419            RawClusterName::Unresolved(_) => Err("unresolved cluster name"),
420        }
421    }
422
423    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
424        match item {
425            RawItemName::Id(id, _, _) => Ok(id),
426            RawItemName::Name(_) => Err("unresolved item name"),
427        }
428    }
429
430    fn format_name<T: AstInfo>(fmt: &Format<T>) -> &'static str {
431        match fmt {
432            Format::Bytes => "bytes",
433            Format::Avro(_) => "avro",
434            Format::Protobuf(_) => "protobuf",
435            Format::Regex(_) => "regex",
436            Format::Csv { .. } => "csv",
437            Format::Json { .. } => "json",
438            Format::Text => "text",
439        }
440    }
441
442    let parse = || -> Result<serde_json::Value, String> {
443        let mut stmts = mz_sql_parser::parser::parse_statements(a)
444            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
445        let stmt = match stmts.len() {
446            1 => stmts.remove(0).ast,
447            n => return Err(format!("expected a single statement, found {n}")),
448        };
449
450        let mut info = BTreeMap::<&str, serde_json::Value>::new();
451
452        use mz_sql_parser::ast::Statement::*;
453        let item_type = match stmt {
454            CreateSecret(_) => "secret",
455            CreateConnection(stmt) => {
456                let connection_type = stmt.connection_type.as_str();
457                info.insert("connection_type", json!(connection_type));
458
459                "connection"
460            }
461            CreateView(stmt) => {
462                let mut definition = stmt.definition.query.to_ast_string_stable();
463                // PostgreSQL appends a semicolon in `pg_views.definition`, we
464                // do the same for compatibility's sake.
465                definition.push(';');
466                info.insert("definition", json!(definition));
467
468                "view"
469            }
470            CreateMaterializedView(stmt) => {
471                let Some(in_cluster) = stmt.in_cluster else {
472                    return Err("missing IN CLUSTER".into());
473                };
474                let cluster_id = match in_cluster {
475                    RawClusterName::Unresolved(ident) => ident.into_string(),
476                    RawClusterName::Resolved(s) => s,
477                };
478                info.insert("cluster_id", json!(cluster_id));
479
480                let mut definition = stmt.query.to_ast_string_stable();
481                definition.push(';');
482                info.insert("definition", json!(definition));
483
484                "materialized-view"
485            }
486            CreateTable(_) => "table",
487            CreateTableFromSource(stmt) => {
488                let source_id = get_item_id(stmt.source)?;
489                info.insert("source_id", json!(source_id));
490
491                "table"
492            }
493            CreateSource(stmt) => {
494                let Some(in_cluster) = stmt.in_cluster else {
495                    return Err("missing IN CLUSTER".into());
496                };
497                let cluster_id = get_cluster_id(in_cluster)?;
498                info.insert("cluster_id", json!(cluster_id));
499
500                use mz_sql_parser::ast::CreateSourceConnection::*;
501                let (source_type, connection) = match stmt.connection {
502                    Kafka { connection, .. } => ("kafka", Some(connection)),
503                    Postgres { connection, .. } => ("postgres", Some(connection)),
504                    MySql { connection, .. } => ("mysql", Some(connection)),
505                    SqlServer { connection, .. } => ("sql-server", Some(connection)),
506                    LoadGenerator { .. } => ("load-generator", None),
507                };
508                info.insert("source_type", json!(source_type));
509                if let Some(conn) = connection {
510                    let conn_id = get_item_id(conn)?;
511                    info.insert("connection_id", json!(conn_id));
512                }
513
514                let is_debezium = matches!(
515                    stmt.envelope,
516                    Some(mz_sql_parser::ast::SourceEnvelope::Debezium)
517                );
518
519                // An old-syntax kafka source ingests into its own relation, so an
520                // omitted ENVELOPE means the default ENVELOPE NONE and the pre-MV
521                // packer reported 'none'. A new-syntax source (no progress
522                // subsource, hence no EXPOSE PROGRESS AS in create_sql) ingests
523                // nothing itself. Its envelopes live on the per-table exports, so
524                // its own envelope_type stays absent (SQL NULL), matching released
525                // behavior. `progress_subsource.is_some()` is the planner's own
526                // old-vs-new discriminator (see `OldSyntaxIngestion` in
527                // plan_create_source). Non-kafka sources carry no envelope either.
528                // See the `mz_sources.envelope_type` column.
529                let envelope_type = match &stmt.envelope {
530                    Some(envelope) => {
531                        use mz_sql_parser::ast::SourceEnvelope::*;
532                        Some(match envelope {
533                            None => "none",
534                            Debezium => "debezium",
535                            Upsert { .. } => "upsert",
536                            CdcV2 => "materialize",
537                        })
538                    }
539                    None if source_type == "kafka" && stmt.progress_subsource.is_some() => {
540                        Some("none")
541                    }
542                    None => None,
543                };
544                if let Some(envelope_type) = envelope_type {
545                    info.insert("envelope_type", json!(envelope_type));
546                }
547
548                if let Some(format_spec) = stmt.format {
549                    match &format_spec {
550                        FormatSpecifier::Bare(fmt) => {
551                            // Debezium sources with a single format spec implicitly use
552                            // the same format for both key and value.
553                            if is_debezium {
554                                info.insert("key_format", json!(format_name(fmt)));
555                            }
556                            info.insert("value_format", json!(format_name(fmt)));
557                        }
558                        FormatSpecifier::KeyValue { key, value } => {
559                            info.insert("key_format", json!(format_name(key)));
560                            info.insert("value_format", json!(format_name(value)));
561                        }
562                    }
563                }
564
565                "source"
566            }
567            CreateWebhookSource(stmt) => {
568                if stmt.is_table {
569                    "table"
570                } else {
571                    info.insert("source_type", json!("webhook"));
572                    if let Some(in_cluster) = stmt.in_cluster {
573                        let cluster_id = get_cluster_id(in_cluster)?;
574                        info.insert("cluster_id", json!(cluster_id));
575                    }
576                    "source"
577                }
578            }
579            CreateSubsource(stmt) => {
580                use mz_sql_parser::ast::CreateSubsourceOptionName;
581                let is_progress = stmt
582                    .with_options
583                    .iter()
584                    .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
585                let source_type = if is_progress { "progress" } else { "subsource" };
586                info.insert("source_type", json!(source_type));
587
588                if let Some(of_source) = stmt.of_source {
589                    let of_source_id = get_item_id(of_source)?;
590                    info.insert("of_source_id", json!(of_source_id));
591                }
592
593                "subsource"
594            }
595            // Everything the mz_sinks, mz_kafka_sinks and mz_iceberg_sinks
596            // views read. The Rust side of each value lives in `Sink` and
597            // `StorageSinkConnection`, so those and this have to move together.
598            //
599            // NOTE: we bail below if a sink has no resolved `IN CLUSTER`, no
600            // `TOPIC` on kafka, or no `NAMESPACE`/`TABLE` on iceberg. Planning
601            // guarantees all four. But if one ever slipped through it would
602            // take down every view built on this function, not just the sink
603            // ones.
604            CreateSink(stmt) => {
605                let Some(in_cluster) = stmt.in_cluster else {
606                    return Err("missing IN CLUSTER".into());
607                };
608                info.insert("cluster_id", json!(get_cluster_id(in_cluster)?));
609
610                match stmt.connection {
611                    CreateSinkConnection::Kafka {
612                        connection,
613                        options,
614                        key: sink_key,
615                        ..
616                    } => {
617                        info.insert("sink_type", json!("kafka"));
618                        info.insert("connection_id", json!(get_item_id(connection)?));
619
620                        let topic = options
621                            .into_iter()
622                            .find(|o| o.name == KafkaSinkConfigOptionName::Topic)
623                            .and_then(|o| o.value.as_ref().and_then(option_string))
624                            .ok_or("kafka sink missing TOPIC")?;
625                        info.insert("topic", json!(topic));
626
627                        if let Some(envelope) = stmt.envelope {
628                            let envelope_type = match envelope {
629                                SinkEnvelope::Upsert => "upsert",
630                                SinkEnvelope::Debezium => "debezium",
631                            };
632                            info.insert("envelope_type", json!(envelope_type));
633                        }
634
635                        if let Some(format_spec) = stmt.format {
636                            // A key format only survives if the sink has a
637                            // `KEY`. Without one `kafka_sink_builder` throws
638                            // away the key half of a key/value spec, and does
639                            // not copy a bare spec over to the key either.
640                            let (key_format, value_format) =
641                                match (&format_spec, sink_key.is_some()) {
642                                    (FormatSpecifier::Bare(fmt), false) => (None, format_name(fmt)),
643                                    (FormatSpecifier::Bare(fmt), true) => {
644                                        (Some(format_name(fmt)), format_name(fmt))
645                                    }
646                                    (FormatSpecifier::KeyValue { value, .. }, false) => {
647                                        (None, format_name(value))
648                                    }
649                                    (FormatSpecifier::KeyValue { key, value }, true) => {
650                                        (Some(format_name(key)), format_name(value))
651                                    }
652                                };
653                            if let Some(key_format) = key_format {
654                                info.insert("key_format", json!(key_format));
655                            }
656                            info.insert("value_format", json!(value_format));
657
658                            // The deprecated combined `format`. Only avro/avro
659                            // and json/json collapse to a single name.
660                            // Everything else, text/text and bytes/bytes
661                            // included, gets the composite form.
662                            let combined = match key_format {
663                                None => value_format.to_string(),
664                                Some(key_format)
665                                    if key_format == value_format
666                                        && matches!(value_format, "avro" | "json") =>
667                                {
668                                    value_format.to_string()
669                                }
670                                Some(key_format) => {
671                                    format!("key-{key_format}-value-{value_format}")
672                                }
673                            };
674                            info.insert("format", json!(combined));
675                        }
676                    }
677                    CreateSinkConnection::Iceberg {
678                        catalog_connection,
679                        options,
680                        ..
681                    } => {
682                        info.insert("sink_type", json!("iceberg"));
683                        // The catalog connection, not the optional AWS one.
684                        info.insert("connection_id", json!(get_item_id(catalog_connection)?));
685
686                        let mut namespace = None;
687                        let mut table = None;
688                        for option in options {
689                            match option.name {
690                                IcebergSinkConfigOptionName::Namespace => {
691                                    namespace = option.value.as_ref().and_then(option_string)
692                                }
693                                IcebergSinkConfigOptionName::Table => {
694                                    table = option.value.as_ref().and_then(option_string)
695                                }
696                            }
697                        }
698                        info.insert(
699                            "namespace",
700                            json!(namespace.ok_or("iceberg sink missing NAMESPACE")?),
701                        );
702                        info.insert("table", json!(table.ok_or("iceberg sink missing TABLE")?));
703
704                        // Iceberg spells the envelope `MODE`, and has no format
705                        // columns at all.
706                        if let Some(mode) = stmt.mode {
707                            let envelope_type = match mode {
708                                IcebergSinkMode::Upsert => "upsert",
709                                IcebergSinkMode::Append => "append",
710                            };
711                            info.insert("envelope_type", json!(envelope_type));
712                        }
713                    }
714                }
715
716                "sink"
717            }
718            CreateMetricSink(stmt) => {
719                let Some(in_cluster) = stmt.in_cluster else {
720                    return Err("missing IN CLUSTER".into());
721                };
722                let cluster_id = get_cluster_id(in_cluster)?;
723                info.insert("cluster_id", json!(cluster_id));
724                let from_id = get_item_id(stmt.from)?;
725                info.insert("from_id", json!(from_id));
726                "metric-sink"
727            }
728            CreateIndex(stmt) => {
729                let Some(in_cluster) = stmt.in_cluster else {
730                    return Err("missing IN CLUSTER".into());
731                };
732                let cluster_id = get_cluster_id(in_cluster)?;
733                info.insert("cluster_id", json!(cluster_id));
734                let on_id = get_item_id(stmt.on_name)?;
735                info.insert("on_id", json!(on_id));
736                "index"
737            }
738            CreateType(_) => "type",
739            // NOTE: every statement that creates a catalog item needs an arm above. These
740            // catalog views run this over every item row before their type filter drops the
741            // unwanted rows, so one unclassified `create_sql` takes out `mz_objects`,
742            // `mz_indexes`, and every sibling view at once. The match is exhaustive to make
743            // that a compile error here, not a runtime failure.
744            Select(_)
745            | Insert(_)
746            | Copy(_)
747            | Update(_)
748            | Delete(_)
749            | CreateDatabase(_)
750            | CreateSchema(_)
751            | CreateRole(_)
752            | CreateCluster(_)
753            | CreateClusterReplica(_)
754            | CreateNetworkPolicy(_)
755            | AlterCluster(_)
756            | AlterOwner(_)
757            | AlterObjectRename(_)
758            | AlterObjectSwap(_)
759            | AlterRetainHistory(_)
760            | AlterIndex(_)
761            | AlterSecret(_)
762            | AlterSetCluster(_)
763            | AlterSink(_)
764            | AlterSource(_)
765            | AlterSystemSet(_)
766            | AlterSystemReset(_)
767            | AlterSystemResetAll(_)
768            | AlterConnection(_)
769            | AlterNetworkPolicy(_)
770            | AlterRole(_)
771            | AlterTableAddColumn(_)
772            | AlterMaterializedViewApplyReplacement(_)
773            | Discard(_)
774            | DropObjects(_)
775            | DropOwned(_)
776            | SetVariable(_)
777            | ResetVariable(_)
778            | Show(_)
779            | StartTransaction(_)
780            | SetTransaction(_)
781            | Commit(_)
782            | Rollback(_)
783            | Subscribe(_)
784            | ExplainPlan(_)
785            | ExplainPushdown(_)
786            | ExplainTimestamp(_)
787            | ExplainSinkSchema(_)
788            | ExplainAnalyzeObject(_)
789            | ExplainAnalyzeCluster(_)
790            | Declare(_)
791            | Fetch(_)
792            | Close(_)
793            | Prepare(_)
794            | Execute(_)
795            | ExecuteUnitTest(_)
796            | Deallocate(_)
797            | Raise(_)
798            | GrantRole(_)
799            | RevokeRole(_)
800            | GrantPrivileges(_)
801            | RevokePrivileges(_)
802            | AlterDefaultPrivileges(_)
803            | ReassignOwned(_)
804            | ValidateConnection(_)
805            | Comment(_) => return Err("not a CREATE item statement".into()),
806        };
807        info.insert("type", json!(item_type));
808
809        let info = info.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
810        Ok(info)
811    };
812
813    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
814    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
815    Ok(jsonb)
816}
817
818/// Minimal decoder for `ProtoPostgresSourcePublicationDetails`. The
819/// canonical proto lives in `mz-storage-types`, which depends on
820/// `mz-expr`, so we redeclare the two tags we read here. Upstream tag
821/// renumbers slip past silently. The `mz_postgres_sources` lockdown
822/// SLTs catch them.
823#[derive(Clone, PartialEq, ::prost::Message)]
824struct PostgresPublicationDetailsSubset {
825    #[prost(string, tag = "2")]
826    slot: String,
827    #[prost(uint64, optional, tag = "3")]
828    timeline_id: Option<u64>,
829}
830
831/// Extracts postgres source publication details (slot, timeline_id) from a
832/// catalog `create_sql`. Returns:
833///
834/// - jsonb `{"slot": <text>, "timeline_id": <u64 | null>}` for
835///   `CREATE SOURCE ... FROM POSTGRES CONNECTION ... (DETAILS = ...)` statements.
836/// - jsonb `null` for any other statement.
837///
838/// Errors if the statement fails to parse, is a postgres source without
839/// a `DETAILS` option, or if the `DETAILS` value can't be hex- and
840/// proto-decoded.
841#[sqlfunc]
842fn parse_postgres_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
843    let parse = || -> Result<serde_json::Value, String> {
844        let mut stmts = mz_sql_parser::parser::parse_statements(a)
845            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
846        let stmt = match stmts.len() {
847            1 => stmts.remove(0).ast,
848            n => return Err(format!("expected a single statement, found {n}")),
849        };
850
851        use mz_sql_parser::ast::CreateSourceConnection;
852        use mz_sql_parser::ast::Statement::CreateSource;
853        let options = match stmt {
854            CreateSource(stmt) => match stmt.connection {
855                CreateSourceConnection::Postgres { options, .. } => options,
856                _ => return Ok(serde_json::Value::Null),
857            },
858            _ => return Ok(serde_json::Value::Null),
859        };
860
861        let details_hex = options
862            .into_iter()
863            .find(|opt| opt.name == PgConfigOptionName::Details)
864            .and_then(|opt| match opt.value {
865                Some(WithOptionValue::Value(Value::String(s))) => Some(s),
866                _ => None,
867            })
868            .ok_or("missing DETAILS option on postgres source")?;
869
870        let details_bytes =
871            hex::decode(&details_hex).map_err(|e| format!("DETAILS is not valid hex: {e}"))?;
872
873        let details = PostgresPublicationDetailsSubset::decode(&*details_bytes)
874            .map_err(|e| format!("DETAILS is not a valid publication-details proto: {e}"))?;
875
876        Ok(json!({
877            "slot": details.slot,
878            "timeline_id": details.timeline_id,
879        }))
880    };
881
882    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
883    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
884    Ok(jsonb)
885}
886
887/// Extracts kafka source configuration (topic, group id prefix, connection
888/// id) from a catalog `create_sql`. Returns:
889///
890/// - jsonb `{"topic": <text>, "group_id_prefix": <text | null>, "connection_id": <text>}`
891///   for `CREATE SOURCE ... FROM KAFKA CONNECTION ... (TOPIC = ..., [GROUP ID PREFIX = ...])`
892///   statements.
893/// - jsonb `null` for any other statement.
894///
895/// Errors if the statement fails to parse, is a kafka source without a
896/// `TOPIC` option, or references an unresolved connection name (i.e. one
897/// that hasn't been through purification).
898#[sqlfunc]
899fn parse_kafka_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
900    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
901        match item {
902            RawItemName::Id(id, _, _) => Ok(id),
903            RawItemName::Name(_) => Err("unresolved item name"),
904        }
905    }
906
907    let parse = || -> Result<serde_json::Value, String> {
908        let mut stmts = mz_sql_parser::parser::parse_statements(a)
909            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
910        let stmt = match stmts.len() {
911            1 => stmts.remove(0).ast,
912            n => return Err(format!("expected a single statement, found {n}")),
913        };
914
915        use mz_sql_parser::ast::CreateSourceConnection;
916        use mz_sql_parser::ast::Statement::CreateSource;
917        let (connection, options) = match stmt {
918            CreateSource(stmt) => match stmt.connection {
919                CreateSourceConnection::Kafka {
920                    connection,
921                    options,
922                } => (connection, options),
923                _ => return Ok(serde_json::Value::Null),
924            },
925            _ => return Ok(serde_json::Value::Null),
926        };
927
928        let connection_id = get_item_id(connection)?;
929
930        let mut topic: Option<String> = None;
931        let mut group_id_prefix: Option<String> = None;
932        for opt in options {
933            let string_value = opt.value.as_ref().and_then(option_string);
934            match opt.name {
935                KafkaSourceConfigOptionName::Topic => topic = string_value,
936                KafkaSourceConfigOptionName::GroupIdPrefix => group_id_prefix = string_value,
937                _ => {}
938            }
939        }
940
941        let topic = topic.ok_or("missing TOPIC option on kafka source")?;
942
943        Ok(json!({
944            "topic": topic,
945            "group_id_prefix": group_id_prefix,
946            "connection_id": connection_id,
947        }))
948    };
949
950    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
951    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
952    Ok(jsonb)
953}
954
955/// Extracts source-export (source table) metadata from a catalog `create_sql`.
956///
957/// Returns, for a `CREATE TABLE ... FROM SOURCE` or a non-progress
958/// `CREATE SUBSOURCE ... OF SOURCE ...` statement:
959///
960/// ```json
961/// {
962///   "source_id": "<parent source item id>",
963///   "external_reference": ["part1", "part2", ...],
964///   "envelope_type": <text | null>,
965///   "key_format": <text | null>,
966///   "value_format": <text | null>
967/// }
968/// ```
969///
970/// `envelope_type`, `key_format`, and `value_format` are always null for a
971/// `CREATE SUBSOURCE` (the postgres/mysql/sql-server exports that use the old
972/// subsource syntax carry neither format nor envelope). They may also be null
973/// for a `CREATE TABLE ... FROM SOURCE` that omits FORMAT/ENVELOPE.
974///
975/// Returns jsonb `null` for progress subsources and for any statement that is
976/// not a source export. The caller distinguishes the four source-table views
977/// by joining `source_id` against `mz_sources` and filtering on the parent's
978/// type, so this helper stays connection-type agnostic.
979///
980/// Errors if the statement fails to parse, references an unresolved item name,
981/// or is a non-progress subsource missing its OF SOURCE or EXTERNAL REFERENCE.
982///
983/// The `key_format`/`value_format` derivation mirrors the runtime
984/// `DataSourceDesc::formats()` that the removed `pack_kafka_source_tables_update`
985/// packer read. A bare FORMAT only carries a key when it resolves to an
986/// encoding that has one, which among bare formats is only Avro or Protobuf
987/// read from a Confluent Schema Registry whose purified seed carries a key
988/// schema. A KEY FORMAT ... VALUE FORMAT ... spec always carries both.
989#[sqlfunc]
990fn parse_source_export_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
991    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
992        match item {
993            RawItemName::Id(id, _, _) => Ok(id),
994            RawItemName::Name(_) => Err("unresolved item name"),
995        }
996    }
997
998    fn format_name<T: AstInfo>(fmt: &Format<T>) -> &'static str {
999        match fmt {
1000            Format::Bytes => "bytes",
1001            Format::Avro(_) => "avro",
1002            Format::Protobuf(_) => "protobuf",
1003            Format::Regex(_) => "regex",
1004            Format::Csv { .. } => "csv",
1005            Format::Json { .. } => "json",
1006            Format::Text => "text",
1007        }
1008    }
1009
1010    // A bare FORMAT resolves to an encoding with a key only for Avro or
1011    // Protobuf read from a schema registry whose purified seed carries a key
1012    // schema. Every other bare format is value-only.
1013    fn bare_format_has_key<T: AstInfo>(fmt: &Format<T>) -> bool {
1014        match fmt {
1015            Format::Avro(AvroSchema::Csr { csr_connection }) => csr_connection
1016                .seed
1017                .as_ref()
1018                .is_some_and(|seed| seed.key_schema.is_some()),
1019            Format::Protobuf(ProtobufSchema::Csr { csr_connection }) => csr_connection
1020                .seed
1021                .as_ref()
1022                .is_some_and(|seed| seed.key.is_some()),
1023            _ => false,
1024        }
1025    }
1026
1027    fn key_value_formats<T: AstInfo>(
1028        spec: &FormatSpecifier<T>,
1029    ) -> (Option<&'static str>, Option<&'static str>) {
1030        match spec {
1031            FormatSpecifier::KeyValue { key, value } => {
1032                (Some(format_name(key)), Some(format_name(value)))
1033            }
1034            FormatSpecifier::Bare(fmt) => {
1035                let value = Some(format_name(fmt));
1036                let key = bare_format_has_key(fmt).then(|| format_name(fmt));
1037                (key, value)
1038            }
1039        }
1040    }
1041
1042    fn envelope_name(envelope: &SourceEnvelope) -> &'static str {
1043        match envelope {
1044            SourceEnvelope::None => "none",
1045            SourceEnvelope::Debezium => "debezium",
1046            SourceEnvelope::Upsert {
1047                value_decode_err_policy,
1048            } => {
1049                if value_decode_err_policy
1050                    .iter()
1051                    .any(|p| matches!(p, SourceErrorPolicy::Inline { .. }))
1052                {
1053                    "upsert-value-err-inline"
1054                } else {
1055                    "upsert"
1056                }
1057            }
1058            SourceEnvelope::CdcV2 => "materialize",
1059        }
1060    }
1061
1062    let parse = || -> Result<serde_json::Value, String> {
1063        let mut stmts = mz_sql_parser::parser::parse_statements(a)
1064            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
1065        let stmt = match stmts.len() {
1066            1 => stmts.remove(0).ast,
1067            n => return Err(format!("expected a single statement, found {n}")),
1068        };
1069
1070        use mz_sql_parser::ast::Statement::{CreateSubsource, CreateTableFromSource};
1071        match stmt {
1072            CreateTableFromSource(stmt) => {
1073                let source_id = get_item_id(stmt.source)?;
1074                let external_reference = stmt
1075                    .external_reference
1076                    .ok_or("missing external reference on CREATE TABLE FROM SOURCE")?
1077                    .0
1078                    .into_iter()
1079                    .map(|ident| ident.into_string())
1080                    .collect::<Vec<_>>();
1081
1082                let envelope_type = stmt.envelope.as_ref().map(envelope_name);
1083                let (key_format, value_format) = match &stmt.format {
1084                    Some(spec) => key_value_formats(spec),
1085                    None => (None, None),
1086                };
1087
1088                Ok(json!({
1089                    "source_id": source_id,
1090                    "external_reference": external_reference,
1091                    "envelope_type": envelope_type,
1092                    "key_format": key_format,
1093                    "value_format": value_format,
1094                }))
1095            }
1096            CreateSubsource(stmt) => {
1097                // Progress subsources track ingestion progress and are not
1098                // source tables. They have no external reference.
1099                let is_progress = stmt
1100                    .with_options
1101                    .iter()
1102                    .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
1103                if is_progress {
1104                    return Ok(serde_json::Value::Null);
1105                }
1106
1107                let source_id = stmt
1108                    .of_source
1109                    .ok_or("non-progress CREATE SUBSOURCE without OF SOURCE")
1110                    .and_then(get_item_id)?;
1111
1112                let external_reference = stmt
1113                    .with_options
1114                    .into_iter()
1115                    .find(|o| matches!(o.name, CreateSubsourceOptionName::ExternalReference))
1116                    .and_then(|o| match o.value {
1117                        Some(WithOptionValue::UnresolvedItemName(name)) => Some(name),
1118                        _ => None,
1119                    })
1120                    .ok_or("CREATE SUBSOURCE missing EXTERNAL REFERENCE option")?
1121                    .0
1122                    .into_iter()
1123                    .map(|ident| ident.into_string())
1124                    .collect::<Vec<_>>();
1125
1126                Ok(json!({
1127                    "source_id": source_id,
1128                    "external_reference": external_reference,
1129                    "envelope_type": serde_json::Value::Null,
1130                    "key_format": serde_json::Value::Null,
1131                    "value_format": serde_json::Value::Null,
1132                }))
1133            }
1134            _ => Ok(serde_json::Value::Null),
1135        }
1136    };
1137
1138    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
1139    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
1140    Ok(jsonb)
1141}
1142
1143/// Extracts connection-detail metadata from a catalog `create_sql`.
1144///
1145/// Returns a per-connection-type object with the fields that the
1146/// `mz_kafka_connections`, `mz_ssh_tunnel_connections`, and `mz_aws_connections`
1147/// builtin views need. For everything else (other connection types, including
1148/// aws-privatelink whose only detail is context-derived, and non-connection
1149/// statements) it returns jsonb `null`, so callers filter on `IS NOT NULL` and
1150/// gate on the connection type separately (via
1151/// `parse_catalog_create_sql(...)->>'connection_type'`, the way `mz_connections`
1152/// already does).
1153///
1154/// The shape per type:
1155///
1156/// ```json
1157/// // kafka
1158/// { "brokers": ["host:port", ...], "progress_topic": <text | null> }
1159/// // ssh-tunnel
1160/// { "public_key_1": "<text>", "public_key_2": "<text>" }
1161/// // aws
1162/// {
1163///   "auth_kind": "credentials" | "assume-role",
1164///   "endpoint": <text | null>, "region": <text | null>,
1165///   "access_key_id": <text | null>, "access_key_id_secret_id": <text | null>,
1166///   "secret_access_key_secret_id": <text | null>,
1167///   "session_token": <text | null>, "session_token_secret_id": <text | null>,
1168///   "assume_role_arn": <text | null>, "assume_role_session_name": <text | null>
1169/// }
1170/// ```
1171///
1172/// `progress_topic` is null when the connection does not set an explicit
1173/// `PROGRESS TOPIC`. The default (`_materialize-progress-<env>-<conn_id>`) is
1174/// reconstructed by the view, not here, because it needs the environment id and
1175/// the connection's own id. Values derived only from environment context
1176/// (AWS principal, external id, trust policy, privatelink principal) are also
1177/// left to the view. This keeps the helper a pure function of the `create_sql`.
1178///
1179/// For aws, an option is either an inline value or a secret reference. Inline
1180/// values land in `access_key_id`/`session_token`; a secret reference lands in
1181/// the matching `*_secret_id` as the referenced secret's catalog item id (the
1182/// persisted `create_sql` stores resolved references as `[uNNN AS name]`).
1183/// `auth_kind` is `assume-role` when `ASSUME ROLE ARN` is present, else
1184/// `credentials`, matching the `AwsAuth` variant the removed packer read.
1185///
1186/// Errors if the statement fails to parse.
1187#[sqlfunc]
1188fn parse_connection_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
1189    // The persisted `create_sql` stores an inline broker as a single `BROKER`
1190    // option and a broker list as a `BROKERS (...)` sequence. Either way we
1191    // only need the addresses, which are present regardless of the tunnel
1192    // (direct, SSH, or PrivateLink).
1193    fn broker_addresses<T: AstInfo>(values: &[ConnectionOption<T>]) -> Vec<String> {
1194        let mut brokers = Vec::new();
1195        for opt in values {
1196            match (&opt.name, &opt.value) {
1197                (ConnectionOptionName::Broker, Some(WithOptionValue::ConnectionKafkaBroker(b))) => {
1198                    brokers.push(b.address.clone());
1199                }
1200                (ConnectionOptionName::Brokers, Some(WithOptionValue::Sequence(seq))) => {
1201                    for v in seq {
1202                        if let WithOptionValue::ConnectionKafkaBroker(b) = v {
1203                            brokers.push(b.address.clone());
1204                        }
1205                    }
1206                }
1207                _ => {}
1208            }
1209        }
1210        brokers
1211    }
1212
1213    fn string_option<T: AstInfo>(
1214        values: &[ConnectionOption<T>],
1215        name: ConnectionOptionName,
1216    ) -> Option<String> {
1217        values
1218            .iter()
1219            .find(|o| o.name == name)
1220            .and_then(|o| o.value.as_ref())
1221            .and_then(option_string)
1222    }
1223
1224    // The catalog id of the secret a `SECRET ...` option references. Resolved
1225    // references persist as `RawItemName::Id`, so an unresolved name yields
1226    // None (the same treatment `parse_source_export_details` gives item names).
1227    fn secret_id_option<T: AstInfo<ItemName = RawItemName>>(
1228        values: &[ConnectionOption<T>],
1229        name: ConnectionOptionName,
1230    ) -> Option<String> {
1231        values.iter().find_map(|o| match &o.value {
1232            Some(WithOptionValue::Secret(RawItemName::Id(id, _, _))) if o.name == name => {
1233                Some(id.clone())
1234            }
1235            _ => None,
1236        })
1237    }
1238
1239    let parse = || -> Result<serde_json::Value, String> {
1240        let mut stmts = mz_sql_parser::parser::parse_statements(a)
1241            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
1242        let stmt = match stmts.len() {
1243            1 => stmts.remove(0).ast,
1244            n => return Err(format!("expected a single statement, found {n}")),
1245        };
1246
1247        use mz_sql_parser::ast::Statement::CreateConnection;
1248        let stmt = match stmt {
1249            CreateConnection(stmt) => stmt,
1250            _ => return Ok(serde_json::Value::Null),
1251        };
1252
1253        match stmt.connection_type {
1254            CreateConnectionType::Kafka => Ok(json!({
1255                "brokers": broker_addresses(&stmt.values),
1256                "progress_topic": string_option(&stmt.values, ConnectionOptionName::ProgressTopic),
1257            })),
1258            CreateConnectionType::Ssh => Ok(json!({
1259                "public_key_1": string_option(&stmt.values, ConnectionOptionName::PublicKey1),
1260                "public_key_2": string_option(&stmt.values, ConnectionOptionName::PublicKey2),
1261            })),
1262            CreateConnectionType::Aws => {
1263                let assume_role_arn =
1264                    string_option(&stmt.values, ConnectionOptionName::AssumeRoleArn);
1265                let auth_kind = if assume_role_arn.is_some() {
1266                    "assume-role"
1267                } else {
1268                    "credentials"
1269                };
1270                Ok(json!({
1271                    "auth_kind": auth_kind,
1272                    // Planning coerces an empty ENDPOINT to None (see
1273                    // `src/sql/src/plan/statement/ddl/connection.rs`), so the
1274                    // removed packer wrote NULL for `ENDPOINT = ''`. Match that.
1275                    "endpoint": string_option(&stmt.values, ConnectionOptionName::Endpoint)
1276                        .filter(|s| !s.is_empty()),
1277                    "region": string_option(&stmt.values, ConnectionOptionName::Region),
1278                    "access_key_id": string_option(&stmt.values, ConnectionOptionName::AccessKeyId),
1279                    "access_key_id_secret_id":
1280                        secret_id_option(&stmt.values, ConnectionOptionName::AccessKeyId),
1281                    "secret_access_key_secret_id":
1282                        secret_id_option(&stmt.values, ConnectionOptionName::SecretAccessKey),
1283                    "session_token": string_option(&stmt.values, ConnectionOptionName::SessionToken),
1284                    "session_token_secret_id":
1285                        secret_id_option(&stmt.values, ConnectionOptionName::SessionToken),
1286                    "assume_role_arn": assume_role_arn,
1287                    "assume_role_session_name":
1288                        string_option(&stmt.values, ConnectionOptionName::AssumeRoleSessionName),
1289                }))
1290            }
1291            _ => Ok(serde_json::Value::Null),
1292        }
1293    };
1294
1295    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
1296    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
1297    Ok(jsonb)
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use mz_repr::adt::jsonb::Jsonb;
1303    use prost::Message as _;
1304    use serde_json::json;
1305
1306    use crate::EvalError;
1307
1308    /// Encode the two proto fields our decoder cares about, using the same
1309    /// tag numbering as the canonical proto.
1310    fn encode_pg_details(slot: &str, timeline_id: Option<u64>) -> String {
1311        let details = super::PostgresPublicationDetailsSubset {
1312            slot: slot.to_string(),
1313            timeline_id,
1314        };
1315        hex::encode(details.encode_to_vec())
1316    }
1317
1318    fn pg_source_sql(details_hex: &str) -> String {
1319        format!(
1320            "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
1321             IN CLUSTER [u42] \
1322             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
1323             (DETAILS = '{details_hex}', PUBLICATION = 'mz_source') \
1324             FOR ALL TABLES"
1325        )
1326    }
1327
1328    fn kafka_source_sql(with_prefix: bool) -> String {
1329        let prefix_opt = if with_prefix {
1330            ", GROUP ID PREFIX 'my-prefix-'"
1331        } else {
1332            ""
1333        };
1334        format!(
1335            "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1336             IN CLUSTER [u42] \
1337             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1338             (TOPIC 'test'{prefix_opt}) FORMAT TEXT"
1339        )
1340    }
1341
1342    fn as_serde(jsonb: Jsonb) -> serde_json::Value {
1343        jsonb.as_ref().to_serde_json()
1344    }
1345
1346    // --- parse_postgres_source_details ---------------------------------------
1347
1348    #[mz_ore::test]
1349    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
1350    fn pg_happy_path_with_timeline() {
1351        let hex = encode_pg_details("materialize_abc", Some(42));
1352        let sql = pg_source_sql(&hex);
1353        let out = super::parse_postgres_source_details(&sql).expect("ok");
1354        assert_eq!(
1355            as_serde(out),
1356            json!({ "slot": "materialize_abc", "timeline_id": 42 }),
1357        );
1358    }
1359
1360    #[mz_ore::test]
1361    fn pg_happy_path_null_timeline() {
1362        // Pre-2024 sources have no timeline_id field. The decoder must
1363        // surface that as JSON null, not error.
1364        let hex = encode_pg_details("materialize_legacy", None);
1365        let sql = pg_source_sql(&hex);
1366        let out = super::parse_postgres_source_details(&sql).expect("ok");
1367        assert_eq!(
1368            as_serde(out),
1369            json!({ "slot": "materialize_legacy", "timeline_id": null }),
1370        );
1371    }
1372
1373    #[mz_ore::test]
1374    fn pg_non_postgres_source_returns_null_jsonb() {
1375        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1376             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1377        let out = super::parse_postgres_source_details(sql).expect("ok");
1378        assert_eq!(as_serde(out), serde_json::Value::Null);
1379    }
1380
1381    #[mz_ore::test]
1382    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1383    fn pg_non_create_source_returns_null_jsonb() {
1384        let sql = "CREATE VIEW v AS SELECT 1";
1385        let out = super::parse_postgres_source_details(sql).expect("ok");
1386        assert_eq!(as_serde(out), serde_json::Value::Null);
1387    }
1388
1389    #[mz_ore::test]
1390    fn pg_missing_details_option_errors() {
1391        let sql = "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
1392             IN CLUSTER [u42] \
1393             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
1394             (PUBLICATION = 'mz_source') FOR ALL TABLES";
1395        let err = super::parse_postgres_source_details(sql).unwrap_err();
1396        assert!(
1397            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing DETAILS")),
1398            "wrong error variant/message"
1399        );
1400    }
1401
1402    #[mz_ore::test]
1403    fn pg_malformed_hex_errors() {
1404        let sql = pg_source_sql("not-hex!!");
1405        let err = super::parse_postgres_source_details(&sql).unwrap_err();
1406        assert!(
1407            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("valid hex")),
1408            "wrong error variant/message"
1409        );
1410    }
1411
1412    #[mz_ore::test]
1413    fn pg_malformed_proto_errors() {
1414        // Valid hex, garbage bytes. Prost decoding fails on unexpected wire
1415        // format.
1416        let sql = pg_source_sql("ffff");
1417        let err = super::parse_postgres_source_details(&sql).unwrap_err();
1418        assert!(
1419            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("publication-details proto")),
1420            "wrong error variant/message"
1421        );
1422    }
1423
1424    // --- parse_kafka_source_details ------------------------------------------
1425
1426    #[mz_ore::test]
1427    fn kafka_happy_path_with_prefix() {
1428        let sql = kafka_source_sql(true);
1429        let out = super::parse_kafka_source_details(&sql).expect("ok");
1430        assert_eq!(
1431            as_serde(out),
1432            json!({
1433                "topic": "test",
1434                "group_id_prefix": "my-prefix-",
1435                "connection_id": "u11",
1436            }),
1437        );
1438    }
1439
1440    #[mz_ore::test]
1441    fn kafka_happy_path_without_prefix() {
1442        let sql = kafka_source_sql(false);
1443        let out = super::parse_kafka_source_details(&sql).expect("ok");
1444        assert_eq!(
1445            as_serde(out),
1446            json!({
1447                "topic": "test",
1448                "group_id_prefix": null,
1449                "connection_id": "u11",
1450            }),
1451        );
1452    }
1453
1454    #[mz_ore::test]
1455    fn kafka_non_kafka_source_returns_null_jsonb() {
1456        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1457             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1458        let out = super::parse_kafka_source_details(sql).expect("ok");
1459        assert_eq!(as_serde(out), serde_json::Value::Null);
1460    }
1461
1462    #[mz_ore::test]
1463    fn kafka_missing_topic_errors() {
1464        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1465             IN CLUSTER [u42] \
1466             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1467             FORMAT TEXT";
1468        let err = super::parse_kafka_source_details(sql).unwrap_err();
1469        assert!(
1470            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing TOPIC")),
1471            "wrong error variant/message"
1472        );
1473    }
1474
1475    #[mz_ore::test]
1476    fn kafka_unquoted_topic_and_prefix() {
1477        // Planning accepts a bare identifier for TOPIC / GROUP ID PREFIX, and it
1478        // persists unquoted in create_sql. Matching only quoted strings would
1479        // drop TOPIC and error the whole mz_kafka_source_tables view.
1480        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1481             IN CLUSTER [u42] \
1482             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1483             (TOPIC = my_topic, GROUP ID PREFIX = my_prefix) FORMAT TEXT";
1484        let out = super::parse_kafka_source_details(sql).expect("ok");
1485        let out = as_serde(out);
1486        assert_eq!(out["topic"], json!("my_topic"));
1487        assert_eq!(out["group_id_prefix"], json!("my_prefix"));
1488    }
1489
1490    #[mz_ore::test]
1491    fn kafka_unresolved_connection_errors() {
1492        // A bare-name connection reference never happens after purification,
1493        // but the decoder must reject it explicitly rather than silently
1494        // dropping the connection_id.
1495        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1496             IN CLUSTER [u42] \
1497             FROM KAFKA CONNECTION k_conn (TOPIC 'test') FORMAT TEXT";
1498        let err = super::parse_kafka_source_details(sql).unwrap_err();
1499        assert!(
1500            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("unresolved item name")),
1501            "wrong error variant/message"
1502        );
1503    }
1504
1505    // --- parse_source_export_details -----------------------------------------
1506
1507    fn table_from_source_sql(reference: &str, suffix: &str) -> String {
1508        format!(
1509            "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1510             FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1511             (REFERENCE = {reference}){suffix}"
1512        )
1513    }
1514
1515    #[mz_ore::test]
1516    fn export_table_postgres_style_no_format() {
1517        // Postgres/mysql/sql-server tables carry a multi-part external
1518        // reference and no format or envelope.
1519        let sql = table_from_source_sql("\"db\".\"public\".\"t\"", "");
1520        let out = super::parse_source_export_details(&sql).expect("ok");
1521        assert_eq!(
1522            as_serde(out),
1523            json!({
1524                "source_id": "u1",
1525                "external_reference": ["db", "public", "t"],
1526                "envelope_type": null,
1527                "key_format": null,
1528                "value_format": null,
1529            }),
1530        );
1531    }
1532
1533    #[mz_ore::test]
1534    fn export_table_kafka_bare_value_only() {
1535        // A bare non-registry FORMAT is value-only: no key format.
1536        let sql = table_from_source_sql("\"topic\"", " FORMAT TEXT ENVELOPE NONE");
1537        let out = super::parse_source_export_details(&sql).expect("ok");
1538        assert_eq!(
1539            as_serde(out),
1540            json!({
1541                "source_id": "u1",
1542                "external_reference": ["topic"],
1543                "envelope_type": "none",
1544                "key_format": null,
1545                "value_format": "text",
1546            }),
1547        );
1548    }
1549
1550    #[mz_ore::test]
1551    fn export_table_kafka_omitted_envelope_is_null() {
1552        // Omitting ENVELOPE persists as absent in create_sql, so this
1553        // source-type-agnostic helper reports null. The mz_kafka_source_tables
1554        // view is responsible for defaulting kafka's null envelope to 'none'.
1555        let sql = table_from_source_sql("\"topic\"", " FORMAT TEXT");
1556        let out = super::parse_source_export_details(&sql).expect("ok");
1557        assert_eq!(as_serde(out)["envelope_type"], serde_json::Value::Null);
1558    }
1559
1560    #[mz_ore::test]
1561    fn export_table_kafka_key_value_format() {
1562        let sql = table_from_source_sql(
1563            "\"topic\"",
1564            " KEY FORMAT TEXT VALUE FORMAT TEXT ENVELOPE NONE",
1565        );
1566        let out = super::parse_source_export_details(&sql).expect("ok");
1567        assert_eq!(
1568            as_serde(out),
1569            json!({
1570                "source_id": "u1",
1571                "external_reference": ["topic"],
1572                "envelope_type": "none",
1573                "key_format": "text",
1574                "value_format": "text",
1575            }),
1576        );
1577    }
1578
1579    #[mz_ore::test]
1580    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1581    fn export_table_kafka_bare_avro_seed_with_key() {
1582        // A bare Avro CSR format whose seed carries a key schema resolves to
1583        // an encoding with a key, so key_format mirrors value_format. This is
1584        // the upsert/debezium path.
1585        let sql = table_from_source_sql(
1586            "\"topic\"",
1587            " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1588             CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1589             SEED KEY SCHEMA 'k' VALUE SCHEMA 'v' ENVELOPE UPSERT",
1590        );
1591        let out = super::parse_source_export_details(&sql).expect("ok");
1592        assert_eq!(
1593            as_serde(out),
1594            json!({
1595                "source_id": "u1",
1596                "external_reference": ["topic"],
1597                "envelope_type": "upsert",
1598                "key_format": "avro",
1599                "value_format": "avro",
1600            }),
1601        );
1602    }
1603
1604    #[mz_ore::test]
1605    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1606    fn export_table_kafka_bare_avro_seed_without_key() {
1607        // A bare Avro CSR seed with only a value schema is value-only.
1608        let sql = table_from_source_sql(
1609            "\"topic\"",
1610            " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1611             CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1612             SEED VALUE SCHEMA 'v' ENVELOPE NONE",
1613        );
1614        let out = super::parse_source_export_details(&sql).expect("ok");
1615        assert_eq!(
1616            as_serde(out),
1617            json!({
1618                "source_id": "u1",
1619                "external_reference": ["topic"],
1620                "envelope_type": "none",
1621                "key_format": null,
1622                "value_format": "avro",
1623            }),
1624        );
1625    }
1626
1627    #[mz_ore::test]
1628    fn export_subsource_non_progress() {
1629        // Old-syntax subsource: external reference lives in a WITH option, and
1630        // there is never a format or envelope.
1631        let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
1632             OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1633             WITH (EXTERNAL REFERENCE = \"db\".\"public\".\"t\")";
1634        let out = super::parse_source_export_details(sql).expect("ok");
1635        assert_eq!(
1636            as_serde(out),
1637            json!({
1638                "source_id": "u1",
1639                "external_reference": ["db", "public", "t"],
1640                "envelope_type": null,
1641                "key_format": null,
1642                "value_format": null,
1643            }),
1644        );
1645    }
1646
1647    #[mz_ore::test]
1648    fn export_progress_subsource_returns_null_jsonb() {
1649        let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"progress\" (id int4) \
1650             WITH (PROGRESS)";
1651        let out = super::parse_source_export_details(sql).expect("ok");
1652        assert_eq!(as_serde(out), serde_json::Value::Null);
1653    }
1654
1655    #[mz_ore::test]
1656    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1657    fn export_non_source_export_returns_null_jsonb() {
1658        let sql = "CREATE VIEW v AS SELECT 1";
1659        let out = super::parse_source_export_details(sql).expect("ok");
1660        assert_eq!(as_serde(out), serde_json::Value::Null);
1661    }
1662
1663    #[mz_ore::test]
1664    fn export_unresolved_source_name_errors() {
1665        let sql = "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1666             FROM SOURCE src (REFERENCE = \"topic\")";
1667        let err = super::parse_source_export_details(sql).unwrap_err();
1668        assert!(
1669            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("unresolved item name")),
1670            "wrong error variant/message"
1671        );
1672    }
1673
1674    // --- parse_connection_details --------------------------------------------
1675
1676    #[mz_ore::test]
1677    fn connection_kafka_single_broker_default_progress() {
1678        // No explicit PROGRESS TOPIC: the helper leaves it null and the view
1679        // reconstructs the default.
1680        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1681             (BROKER = 'localhost:9092', SECURITY PROTOCOL = plaintext)";
1682        let out = super::parse_connection_details(sql).expect("ok");
1683        assert_eq!(
1684            as_serde(out),
1685            json!({
1686                "brokers": ["localhost:9092"],
1687                "progress_topic": null,
1688            }),
1689        );
1690    }
1691
1692    #[mz_ore::test]
1693    fn connection_kafka_explicit_progress_topic() {
1694        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1695             (BROKER = 'localhost:9092', PROGRESS TOPIC = 'override', \
1696              SECURITY PROTOCOL = plaintext)";
1697        let out = super::parse_connection_details(sql).expect("ok");
1698        assert_eq!(
1699            as_serde(out),
1700            json!({
1701                "brokers": ["localhost:9092"],
1702                "progress_topic": "override",
1703            }),
1704        );
1705    }
1706
1707    #[mz_ore::test]
1708    fn connection_kafka_broker_list() {
1709        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1710             (BROKERS ('b1:9092', 'b2:9092'), SECURITY PROTOCOL = plaintext)";
1711        let out = super::parse_connection_details(sql).expect("ok");
1712        assert_eq!(
1713            as_serde(out),
1714            json!({
1715                "brokers": ["b1:9092", "b2:9092"],
1716                "progress_topic": null,
1717            }),
1718        );
1719    }
1720
1721    #[mz_ore::test]
1722    fn connection_ssh_public_keys() {
1723        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO SSH TUNNEL \
1724             (HOST = 'ssh.example.com', PORT = 22, USER = 'mz', \
1725              PUBLIC KEY 1 = 'ssh-ed25519 AAAA', PUBLIC KEY 2 = 'ssh-ed25519 BBBB')";
1726        let out = super::parse_connection_details(sql).expect("ok");
1727        assert_eq!(
1728            as_serde(out),
1729            json!({
1730                "public_key_1": "ssh-ed25519 AAAA",
1731                "public_key_2": "ssh-ed25519 BBBB",
1732            }),
1733        );
1734    }
1735
1736    #[mz_ore::test]
1737    fn connection_aws_credentials_inline_key() {
1738        // Inline ACCESS KEY ID, secret SECRET ACCESS KEY. Assume-role columns
1739        // stay null and auth_kind is credentials.
1740        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1741             (ACCESS KEY ID = 'AKIAEXAMPLE', \
1742              SECRET ACCESS KEY = SECRET [u1 AS \"materialize\".\"public\".\"sk\"])";
1743        let out = super::parse_connection_details(sql).expect("ok");
1744        assert_eq!(
1745            as_serde(out),
1746            json!({
1747                "auth_kind": "credentials",
1748                "endpoint": null,
1749                "region": null,
1750                "access_key_id": "AKIAEXAMPLE",
1751                "access_key_id_secret_id": null,
1752                "secret_access_key_secret_id": "u1",
1753                "session_token": null,
1754                "session_token_secret_id": null,
1755                "assume_role_arn": null,
1756                "assume_role_session_name": null,
1757            }),
1758        );
1759    }
1760
1761    #[mz_ore::test]
1762    fn connection_aws_credentials_secret_key_and_session_token() {
1763        // Every credential provided as a secret reference lands in the matching
1764        // *_secret_id column as the referenced secret's catalog id.
1765        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1766             (ENDPOINT = 'http://localhost', REGION = 'us-east-1', \
1767              ACCESS KEY ID = SECRET [u1 AS \"materialize\".\"public\".\"ak\"], \
1768              SECRET ACCESS KEY = SECRET [u2 AS \"materialize\".\"public\".\"sk\"], \
1769              SESSION TOKEN = SECRET [u3 AS \"materialize\".\"public\".\"st\"])";
1770        let out = super::parse_connection_details(sql).expect("ok");
1771        assert_eq!(
1772            as_serde(out),
1773            json!({
1774                "auth_kind": "credentials",
1775                "endpoint": "http://localhost",
1776                "region": "us-east-1",
1777                "access_key_id": null,
1778                "access_key_id_secret_id": "u1",
1779                "secret_access_key_secret_id": "u2",
1780                "session_token": null,
1781                "session_token_secret_id": "u3",
1782                "assume_role_arn": null,
1783                "assume_role_session_name": null,
1784            }),
1785        );
1786    }
1787
1788    #[mz_ore::test]
1789    fn connection_aws_assume_role() {
1790        // Assume-role sets auth_kind and the assume-role columns; credential
1791        // columns stay null.
1792        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1793             (ASSUME ROLE ARN 'arn:aws:iam::123:role/mz', \
1794              ASSUME ROLE SESSION NAME 'sess')";
1795        let out = super::parse_connection_details(sql).expect("ok");
1796        assert_eq!(
1797            as_serde(out),
1798            json!({
1799                "auth_kind": "assume-role",
1800                "endpoint": null,
1801                "region": null,
1802                "access_key_id": null,
1803                "access_key_id_secret_id": null,
1804                "secret_access_key_secret_id": null,
1805                "session_token": null,
1806                "session_token_secret_id": null,
1807                "assume_role_arn": "arn:aws:iam::123:role/mz",
1808                "assume_role_session_name": "sess",
1809            }),
1810        );
1811    }
1812
1813    #[mz_ore::test]
1814    fn connection_kafka_unquoted_progress_topic() {
1815        // A bare identifier PROGRESS TOPIC persists unquoted in create_sql. The
1816        // helper must surface it, else the view substitutes the default topic.
1817        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1818             (BROKER = 'localhost:9092', PROGRESS TOPIC = my_topic, \
1819              SECURITY PROTOCOL = plaintext)";
1820        let out = super::parse_connection_details(sql).expect("ok");
1821        assert_eq!(as_serde(out)["progress_topic"], json!("my_topic"));
1822    }
1823
1824    #[mz_ore::test]
1825    fn connection_aws_unquoted_option_values() {
1826        // Planning accepts bare identifiers for these options and persists them
1827        // unquoted. The helper must surface them, not fall back to NULL.
1828        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1829             (ENDPOINT = localhost, REGION = useast1, \
1830              ASSUME ROLE ARN 'arn:aws:iam::123:role/mz', \
1831              ASSUME ROLE SESSION NAME = mysession)";
1832        let out = super::parse_connection_details(sql).expect("ok");
1833        let out = as_serde(out);
1834        assert_eq!(out["endpoint"], json!("localhost"));
1835        assert_eq!(out["region"], json!("useast1"));
1836        assert_eq!(out["assume_role_session_name"], json!("mysession"));
1837    }
1838
1839    #[mz_ore::test]
1840    fn connection_aws_empty_endpoint_is_null() {
1841        // Planning coerces ENDPOINT = '' to None, so the packer wrote NULL. The
1842        // view must match, not report an empty string.
1843        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1844             (ENDPOINT = '', ASSUME ROLE ARN 'arn:aws:iam::123:role/mz')";
1845        let out = super::parse_connection_details(sql).expect("ok");
1846        assert_eq!(as_serde(out)["endpoint"], serde_json::Value::Null);
1847    }
1848
1849    #[mz_ore::test]
1850    fn connection_other_type_returns_null_jsonb() {
1851        // A connection type without a detail view (postgres) yields null.
1852        let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO POSTGRES \
1853             (HOST = 'db', DATABASE = 'postgres', USER = 'mz')";
1854        let out = super::parse_connection_details(sql).expect("ok");
1855        assert_eq!(as_serde(out), serde_json::Value::Null);
1856    }
1857
1858    #[mz_ore::test]
1859    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1860    fn connection_non_connection_returns_null_jsonb() {
1861        let sql = "CREATE VIEW v AS SELECT 1";
1862        let out = super::parse_connection_details(sql).expect("ok");
1863        assert_eq!(as_serde(out), serde_json::Value::Null);
1864    }
1865
1866    // --- parse_catalog_create_sql envelope_type ------------------------------
1867
1868    #[mz_ore::test]
1869    fn catalog_kafka_old_syntax_omitted_envelope_defaults_none() {
1870        // An old-syntax kafka source (carries EXPOSE PROGRESS AS) ingests into
1871        // its own relation, so an omitted ENVELOPE means the default NONE, which
1872        // the pre-MV packer reported as 'none'.
1873        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1874             IN CLUSTER [u42] \
1875             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1876             (TOPIC 'test') FORMAT TEXT \
1877             EXPOSE PROGRESS AS [u12 AS \"materialize\".\"public\".\"k_progress\"]";
1878        let out = super::parse_catalog_create_sql(sql).expect("ok");
1879        assert_eq!(as_serde(out)["envelope_type"], json!("none"));
1880    }
1881
1882    #[mz_ore::test]
1883    fn catalog_kafka_old_syntax_explicit_envelope() {
1884        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1885             IN CLUSTER [u42] \
1886             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1887             (TOPIC 'test') FORMAT BYTES ENVELOPE UPSERT \
1888             EXPOSE PROGRESS AS [u12 AS \"materialize\".\"public\".\"k_progress\"]";
1889        let out = super::parse_catalog_create_sql(sql).expect("ok");
1890        assert_eq!(as_serde(out)["envelope_type"], json!("upsert"));
1891    }
1892
1893    #[mz_ore::test]
1894    fn catalog_kafka_new_syntax_source_omits_envelope_type() {
1895        // A new-syntax kafka source has no progress subsource (no EXPOSE PROGRESS
1896        // AS). It ingests nothing itself. Envelopes live on the per-table exports,
1897        // so its own envelope_type stays absent (SQL NULL).
1898        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1899             IN CLUSTER [u42] \
1900             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"]";
1901        let out = super::parse_catalog_create_sql(sql).expect("ok");
1902        assert_eq!(as_serde(out).get("envelope_type"), None);
1903    }
1904
1905    #[mz_ore::test]
1906    fn catalog_non_kafka_source_omits_envelope_type() {
1907        // Non-kafka sources carry no envelope, so envelope_type stays absent
1908        // (SQL NULL), not 'none'.
1909        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1910             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1911        let out = super::parse_catalog_create_sql(sql).expect("ok");
1912        assert_eq!(as_serde(out).get("envelope_type"), None);
1913    }
1914
1915    // --- parse_catalog_create_sql, CreateSink arm ----------------------------
1916
1917    const AVRO_FORMAT: &str = "FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION \
1918         [u12 AS \"materialize\".\"public\".\"csr_conn\"]";
1919
1920    /// A persisted kafka-sink `create_sql`: resolved names, and a `TOPIC` that
1921    /// planning guarantees.
1922    fn kafka_sink_sql(key: Option<&str>, format: &str, envelope: &str) -> String {
1923        let key_clause = key.map(|k| format!(" KEY ({k})")).unwrap_or_default();
1924        format!(
1925            "CREATE SINK \"materialize\".\"public\".\"snk\" \
1926             IN CLUSTER [u42] \
1927             FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1928             INTO KAFKA CONNECTION [u10 AS \"materialize\".\"public\".\"k_conn\"] \
1929             (TOPIC 'sink-topic'){key_clause} {format} ENVELOPE {envelope}"
1930        )
1931    }
1932
1933    fn iceberg_sink_sql(mode: &str) -> String {
1934        format!(
1935            "CREATE SINK \"materialize\".\"public\".\"ice\" \
1936             IN CLUSTER [u42] \
1937             FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1938             INTO ICEBERG CATALOG CONNECTION [u20 AS \"materialize\".\"public\".\"cat_conn\"] \
1939             (NAMESPACE 'ns', TABLE 'tbl') \
1940             USING AWS CONNECTION [u21 AS \"materialize\".\"public\".\"aws_conn\"] \
1941             MODE {mode}"
1942        )
1943    }
1944
1945    #[mz_ore::test]
1946    fn sink_kafka_bare_format_without_key() {
1947        let sql = kafka_sink_sql(None, "FORMAT JSON", "DEBEZIUM");
1948        let out = super::parse_catalog_create_sql(&sql).expect("ok");
1949        assert_eq!(
1950            as_serde(out),
1951            json!({
1952                "type": "sink",
1953                "sink_type": "kafka",
1954                "cluster_id": "u42",
1955                "connection_id": "u10",
1956                "topic": "sink-topic",
1957                "envelope_type": "debezium",
1958                "format": "json",
1959                "value_format": "json",
1960            }),
1961        );
1962    }
1963
1964    #[mz_ore::test]
1965    fn sink_kafka_bare_format_with_key_derives_key_format() {
1966        // A bare format applies to the key too once the sink has a KEY, which
1967        // is what makes the deprecated `format` column collapse to `avro`.
1968        let sql = kafka_sink_sql(Some("a"), AVRO_FORMAT, "UPSERT");
1969        let out = super::parse_catalog_create_sql(&sql).expect("ok");
1970        assert_eq!(
1971            as_serde(out),
1972            json!({
1973                "type": "sink",
1974                "sink_type": "kafka",
1975                "cluster_id": "u42",
1976                "connection_id": "u10",
1977                "topic": "sink-topic",
1978                "envelope_type": "upsert",
1979                "format": "avro",
1980                "key_format": "avro",
1981                "value_format": "avro",
1982            }),
1983        );
1984    }
1985
1986    #[mz_ore::test]
1987    fn sink_kafka_bare_text_format_with_key_does_not_collapse() {
1988        // Only avro/avro and json/json collapse, so a keyed text sink reports
1989        // the composite form even though both halves are `text`.
1990        let sql = kafka_sink_sql(Some("a"), "FORMAT TEXT", "UPSERT");
1991        let out = super::parse_catalog_create_sql(&sql).expect("ok");
1992        let out = as_serde(out);
1993        assert_eq!(out["format"], json!("key-text-value-text"));
1994        assert_eq!(out["key_format"], json!("text"));
1995        assert_eq!(out["value_format"], json!("text"));
1996    }
1997
1998    #[mz_ore::test]
1999    fn sink_kafka_key_value_json_collapses() {
2000        let sql = kafka_sink_sql(Some("a"), "KEY FORMAT JSON VALUE FORMAT JSON", "UPSERT");
2001        let out = as_serde(super::parse_catalog_create_sql(&sql).expect("ok"));
2002        assert_eq!(out["format"], json!("json"));
2003        assert_eq!(out["key_format"], json!("json"));
2004        assert_eq!(out["value_format"], json!("json"));
2005    }
2006
2007    #[mz_ore::test]
2008    fn sink_kafka_key_value_mixed_is_composite() {
2009        let sql = kafka_sink_sql(Some("a"), "KEY FORMAT TEXT VALUE FORMAT BYTES", "UPSERT");
2010        let out = as_serde(super::parse_catalog_create_sql(&sql).expect("ok"));
2011        assert_eq!(out["format"], json!("key-text-value-bytes"));
2012        assert_eq!(out["key_format"], json!("text"));
2013        assert_eq!(out["value_format"], json!("bytes"));
2014    }
2015
2016    #[mz_ore::test]
2017    fn sink_kafka_key_format_without_key_is_dropped() {
2018        // `kafka_sink_builder` ignores the key half of the format spec when the
2019        // sink has no KEY, so neither `key_format` nor the composite `format`
2020        // may reflect it.
2021        let sql = kafka_sink_sql(None, "KEY FORMAT JSON VALUE FORMAT TEXT", "DEBEZIUM");
2022        let out = as_serde(super::parse_catalog_create_sql(&sql).expect("ok"));
2023        assert_eq!(out["format"], json!("text"));
2024        assert_eq!(out["key_format"], serde_json::Value::Null);
2025        assert_eq!(out["value_format"], json!("text"));
2026    }
2027
2028    #[mz_ore::test]
2029    fn sink_kafka_missing_topic_errors() {
2030        let sql = "CREATE SINK \"materialize\".\"public\".\"snk\" \
2031             IN CLUSTER [u42] \
2032             FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2033             INTO KAFKA CONNECTION [u10 AS \"materialize\".\"public\".\"k_conn\"] \
2034             FORMAT JSON ENVELOPE DEBEZIUM";
2035        let err = super::parse_catalog_create_sql(sql).unwrap_err();
2036        assert!(
2037            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing TOPIC")),
2038            "wrong error variant/message"
2039        );
2040    }
2041
2042    #[mz_ore::test]
2043    fn sink_iceberg_upsert_mode() {
2044        let sql = iceberg_sink_sql("UPSERT");
2045        let out = super::parse_catalog_create_sql(&sql).expect("ok");
2046        assert_eq!(
2047            as_serde(out),
2048            json!({
2049                "type": "sink",
2050                "sink_type": "iceberg",
2051                "cluster_id": "u42",
2052                // The catalog connection, never the AWS connection (u21).
2053                "connection_id": "u20",
2054                "namespace": "ns",
2055                "table": "tbl",
2056                "envelope_type": "upsert",
2057            }),
2058        );
2059    }
2060
2061    #[mz_ore::test]
2062    fn sink_iceberg_append_mode() {
2063        let sql = iceberg_sink_sql("APPEND");
2064        let out = as_serde(super::parse_catalog_create_sql(&sql).expect("ok"));
2065        // `append` is reachable only through an iceberg sink's MODE.
2066        assert_eq!(out["envelope_type"], json!("append"));
2067    }
2068
2069    #[mz_ore::test]
2070    fn sink_iceberg_missing_table_errors() {
2071        let sql = "CREATE SINK \"materialize\".\"public\".\"ice\" \
2072             IN CLUSTER [u42] \
2073             FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2074             INTO ICEBERG CATALOG CONNECTION [u20 AS \"materialize\".\"public\".\"cat_conn\"] \
2075             (NAMESPACE 'ns') MODE UPSERT";
2076        let err = super::parse_catalog_create_sql(sql).unwrap_err();
2077        assert!(
2078            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing TABLE")),
2079            "wrong error variant/message"
2080        );
2081    }
2082
2083    #[mz_ore::test]
2084    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2085    fn sink_arm_leaves_other_item_types_alone() {
2086        let sql = "CREATE VIEW \"materialize\".\"public\".\"v\" AS SELECT 1";
2087        let out = super::parse_catalog_create_sql(sql).expect("ok");
2088        assert_eq!(
2089            as_serde(out),
2090            json!({ "type": "view", "definition": "SELECT 1;" })
2091        );
2092    }
2093
2094    // --- parse_catalog_create_sql --------------------------------------------
2095
2096    /// `type` for a `create_sql`, or the error message if parsing failed.
2097    fn item_type(sql: &str) -> Result<String, String> {
2098        match super::parse_catalog_create_sql(sql) {
2099            Ok(out) => match as_serde(out) {
2100                serde_json::Value::Object(mut m) => match m.remove("type") {
2101                    Some(serde_json::Value::String(s)) => Ok(s),
2102                    other => panic!("no string `type` key: {other:?}"),
2103                },
2104                other => panic!("not a JSON object: {other:?}"),
2105            },
2106            Err(EvalError::InvalidCatalogJson(msg)) => Err(msg.to_string()),
2107            Err(e) => panic!("unexpected error variant: {e:?}"),
2108        }
2109    }
2110
2111    fn view_sql(query: &str) -> String {
2112        format!("CREATE VIEW \"materialize\".\"public\".\"v\" AS {query}")
2113    }
2114
2115    /// `definition` for a `CREATE VIEW` whose query is `query`.
2116    fn view_definition(query: &str) -> String {
2117        match as_serde(super::parse_catalog_create_sql(&view_sql(query)).expect("ok")) {
2118            serde_json::Value::Object(mut m) => match m.remove("definition") {
2119                Some(serde_json::Value::String(s)) => s,
2120                other => panic!("no string `definition` key: {other:?}"),
2121            },
2122            other => panic!("not a JSON object: {other:?}"),
2123        }
2124    }
2125
2126    /// `mz_tables` and `mz_views` select rows by
2127    /// `parse_catalog_create_sql(...)->>'type'`, and the function runs over
2128    /// every `Item` row in the catalog, so a statement kind that changes its
2129    /// reported type silently gains or loses rows in those relations. Pin the
2130    /// type of every kind the catalog can hold.
2131    #[mz_ore::test]
2132    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2133    fn catalog_item_type_per_statement_kind() {
2134        let cases = [
2135            (
2136                "CREATE TABLE \"materialize\".\"public\".\"t\" (a int4)",
2137                "table",
2138            ),
2139            // A table created from a source, and a webhook table, are both
2140            // `table`, so both land in mz_tables.
2141            (
2142                "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
2143                 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
2144                 (REFERENCE = \"topic\") FORMAT TEXT",
2145                "table",
2146            ),
2147            (
2148                "CREATE TABLE \"materialize\".\"public\".\"wht\" FROM WEBHOOK BODY FORMAT JSON",
2149                "table",
2150            ),
2151            (
2152                "CREATE VIEW \"materialize\".\"public\".\"v\" AS SELECT 1",
2153                "view",
2154            ),
2155            (
2156                "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" \
2157                 IN CLUSTER [u1] AS SELECT 1",
2158                "materialized-view",
2159            ),
2160            (
2161                "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
2162                 IN CLUSTER [u1] FROM LOAD GENERATOR COUNTER",
2163                "source",
2164            ),
2165            (
2166                "CREATE SOURCE \"materialize\".\"public\".\"wh\" \
2167                 IN CLUSTER [u1] FROM WEBHOOK BODY FORMAT JSON",
2168                "source",
2169            ),
2170            (
2171                "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
2172                 OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"]",
2173                "subsource",
2174            ),
2175            (
2176                "CREATE SUBSOURCE \"materialize\".\"public\".\"progress\" (id int4) \
2177                 WITH (PROGRESS)",
2178                "subsource",
2179            ),
2180            (
2181                "CREATE SINK \"materialize\".\"public\".\"snk\" IN CLUSTER [u1] \
2182                 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2183                 INTO KAFKA CONNECTION [u2 AS \"materialize\".\"public\".\"c\"] \
2184                 (TOPIC 'tp') FORMAT JSON ENVELOPE DEBEZIUM",
2185                "sink",
2186            ),
2187            (
2188                "CREATE INDEX \"i\" IN CLUSTER [u1] \
2189                 ON [u1 AS \"materialize\".\"public\".\"t\"] (\"a\")",
2190                "index",
2191            ),
2192            (
2193                "CREATE TYPE \"materialize\".\"public\".\"ty\" AS LIST (ELEMENT TYPE = int4)",
2194                "type",
2195            ),
2196            (
2197                "CREATE SECRET \"materialize\".\"public\".\"s\" AS 'x'",
2198                "secret",
2199            ),
2200            (
2201                "CREATE CONNECTION \"materialize\".\"public\".\"c\" \
2202                 TO KAFKA (BROKER 'b', SECURITY PROTOCOL PLAINTEXT)",
2203                "connection",
2204            ),
2205        ];
2206        for (sql, expected) in cases {
2207            assert_eq!(item_type(sql).as_deref(), Ok(expected), "for {sql}");
2208        }
2209    }
2210
2211    /// `mz_views.definition` is produced here. It used to be produced by
2212    /// `pack_view_update` in the adapter, so the exact rendering is a
2213    /// compatibility surface: `pg_views.definition` reads it.
2214    #[mz_ore::test]
2215    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2216    fn catalog_view_definition() {
2217        // Identifiers and function names come back fully quoted, literals
2218        // untouched, and PostgreSQL's trailing semicolon is appended.
2219        assert_eq!(view_definition("SELECT 1"), "SELECT 1;");
2220        assert_eq!(
2221            view_definition("WITH c AS (SELECT 1 AS a) SELECT a FROM c"),
2222            "WITH \"c\" AS (SELECT 1 AS \"a\") SELECT \"a\" FROM \"c\";"
2223        );
2224        assert_eq!(
2225            view_definition("SELECT 1 UNION ALL SELECT 2"),
2226            "SELECT 1 UNION ALL SELECT 2;"
2227        );
2228        assert_eq!(
2229            view_definition("SELECT (SELECT max(a) FROM [u1 AS \"materialize\".\"public\".\"t\"])"),
2230            "SELECT (SELECT \"max\"(\"a\") FROM [u1 AS \"materialize\".\"public\".\"t\"]);"
2231        );
2232        // Identifiers needing quotes, an embedded double quote, non-ASCII, an
2233        // embedded single quote in a literal, and ORDER BY all survive.
2234        assert_eq!(
2235            view_definition(
2236                "SELECT \"a b\", \"héllo\", \"q\"\"x\" \
2237                 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2238                 WHERE s = 'lit''eral' AND n = 42 ORDER BY 1"
2239            ),
2240            "SELECT \"a b\", \"héllo\", \"q\"\"x\" \
2241             FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2242             WHERE \"s\" = 'lit''eral' AND \"n\" = 42 ORDER BY 1;"
2243        );
2244    }
2245
2246    /// The rendering must be a fixed point: `pg_views` consumers re-issue
2247    /// `definition` as the body of a new view, so a second pass through the
2248    /// parser has to produce the identical string. The trailing `;` is part of
2249    /// what gets re-parsed.
2250    #[mz_ore::test]
2251    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2252    fn catalog_view_definition_is_idempotent() {
2253        for query in [
2254            "SELECT 1",
2255            "WITH c AS (SELECT 1 AS a) SELECT a FROM c",
2256            "SELECT 1 UNION ALL SELECT 2",
2257            "SELECT \"a b\", \"q\"\"x\" FROM [u1 AS \"materialize\".\"public\".\"t\"] \
2258             WHERE s = 'lit''eral' ORDER BY 1",
2259        ] {
2260            let once = view_definition(query);
2261            assert_eq!(
2262                view_definition(&once),
2263                once,
2264                "not a fixed point for {query}"
2265            );
2266        }
2267    }
2268
2269    /// `mz_tables.source_id` comes from this key. A table with no source must
2270    /// omit it entirely, so the MV's `->>'source_id'` yields SQL NULL.
2271    #[mz_ore::test]
2272    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2273    fn catalog_table_source_id() {
2274        let from_source = as_serde(
2275            super::parse_catalog_create_sql(
2276                "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
2277                 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
2278                 (REFERENCE = \"topic\") FORMAT TEXT",
2279            )
2280            .expect("ok"),
2281        );
2282        assert_eq!(from_source, json!({ "type": "table", "source_id": "u1" }));
2283
2284        for sql in [
2285            "CREATE TABLE \"materialize\".\"public\".\"t\" (a int4)",
2286            "CREATE TABLE \"materialize\".\"public\".\"wht\" FROM WEBHOOK BODY FORMAT JSON",
2287        ] {
2288            assert_eq!(
2289                as_serde(super::parse_catalog_create_sql(sql).expect("ok")),
2290                json!({ "type": "table" }),
2291                "for {sql}"
2292            );
2293        }
2294    }
2295
2296    /// Every error here is fatal to the whole of `mz_tables`/`mz_views`, not to
2297    /// one row: the MVs call this function inside their `WHERE` clause, so an
2298    /// item the parser rejects makes the relation unreadable for everyone.
2299    #[mz_ore::test]
2300    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
2301    fn catalog_create_sql_errors() {
2302        assert_eq!(
2303            item_type("this is not sql"),
2304            Err(
2305                "failed to parse create_sql: Expected a keyword at the beginning of a statement, \
2306                 found identifier \"this\""
2307                    .to_string()
2308            )
2309        );
2310        assert_eq!(
2311            item_type("CREATE TABLE t (a int4); CREATE TABLE u (b int4)"),
2312            Err("expected a single statement, found 2".to_string())
2313        );
2314        // A statement that is not a CREATE of a catalog item, e.g. if a future
2315        // change persists something else in an Item record.
2316        assert_eq!(
2317            item_type("SELECT 1"),
2318            Err("not a CREATE item statement".to_string())
2319        );
2320        // Catalog `create_sql` always names items by id. An unresolved name
2321        // means the record was written wrong.
2322        assert_eq!(
2323            item_type(
2324                "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
2325                 FROM SOURCE src (REFERENCE = \"topic\")"
2326            ),
2327            Err("unresolved item name".to_string())
2328        );
2329    }
2330}