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