Skip to main content

mz_catalog_decode/
create_sql.rs

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