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