1use std::collections::BTreeMap;
17
18use mz_repr::adt::interval::Interval;
19use mz_repr::strconv;
20use mz_sql_parser::ast::display::AstDisplay;
21use mz_sql_parser::ast::item_refs::collect_item_references;
22use mz_sql_parser::ast::{
23 AstInfo, AvroSchema, ConnectionOption, ConnectionOptionName, CreateConnectionType,
24 CreateSinkConnection, CreateSourceOptionName, CreateSubsourceOptionName, Format,
25 FormatSpecifier, IcebergSinkConfigOptionName, IcebergSinkMode, IndexOptionName,
26 KafkaSinkConfigOptionName, KafkaSourceConfigOptionName, MaterializedViewOptionName,
27 PgConfigOptionName, ProtobufSchema, Raw, RawClusterName, RawItemName, RefreshAtOptionValue,
28 RefreshEveryOptionValue, RefreshOptionValue, SinkEnvelope, SourceEnvelope, SourceErrorPolicy,
29 Statement, TableFromSourceOptionName, TableOptionName, UnresolvedItemName, Value,
30 WithOptionValue,
31};
32use prost::Message as _;
33use serde_json::json;
34
35use crate::durable;
36
37fn 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
73fn 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
94fn 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
125fn 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
146fn insert_refresh_strategies<'a, T: AstInfo + 'a>(
149 info: &mut BTreeMap<&str, serde_json::Value>,
150 options: impl Iterator<Item = &'a Option<WithOptionValue<T>>>,
151) -> Result<(), String> {
152 let mut refresh = Vec::new();
153 for value in options {
154 let Some(WithOptionValue::Refresh(value)) = value else {
155 continue;
156 };
157 refresh.push(match value {
158 RefreshOptionValue::OnCommit => json!({"type": "on-commit"}),
159 RefreshOptionValue::AtCreation => json!({"type": "at", "at": null}),
160 RefreshOptionValue::At(RefreshAtOptionValue { time }) => {
161 json!({"type": "at", "at": time.to_ast_string_stable()})
162 }
163 RefreshOptionValue::Every(RefreshEveryOptionValue {
164 interval,
165 aligned_to,
166 }) => {
167 let interval = durable::interval_literal(interval)
170 .and_then(|interval| {
171 interval
172 .duration()
173 .and_then(|duration| Interval::from_duration(&duration))
174 .map_err(|e| e.to_string())
175 })
176 .map_err(|e| format!("invalid REFRESH EVERY interval: {e}"))?;
177 json!({
178 "type": "every",
179 "interval": interval.to_string(),
180 "aligned_to": aligned_to.as_ref().map(|time| time.to_ast_string_stable()),
181 })
182 }
183 });
184 }
185 if refresh.is_empty() {
188 refresh.push(json!({"type": "on-commit"}));
189 }
190 info.insert("refresh", json!(refresh));
191 Ok(())
192}
193
194pub fn item_details(a: &str) -> Result<serde_json::Value, String> {
199 let stmt = parse_single_statement(a)?;
200
201 let mut info = BTreeMap::<&str, serde_json::Value>::new();
202
203 use mz_sql_parser::ast::Statement::*;
204 let item_type = match stmt {
205 CreateSecret(_) => "secret",
206 CreateConnection(stmt) => {
207 let connection_type = stmt.connection_type.as_str();
208 info.insert("connection_type", json!(connection_type));
209
210 "connection"
211 }
212 CreateView(stmt) => {
213 let mut definition = stmt.definition.query.to_ast_string_stable();
214 definition.push(';');
217 info.insert("definition", json!(definition));
218
219 "view"
220 }
221 CreateMaterializedView(stmt) => {
222 let Some(in_cluster) = stmt.in_cluster else {
223 return Err("missing IN CLUSTER".into());
224 };
225 let cluster_id = match in_cluster {
226 RawClusterName::Unresolved(ident) => ident.into_string(),
227 RawClusterName::Resolved(s) => s,
228 };
229 info.insert("cluster_id", json!(cluster_id));
230
231 let mut definition = stmt.query.to_ast_string_stable();
232 definition.push(';');
233 info.insert("definition", json!(definition));
234
235 insert_retain_history(
236 &mut info,
237 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
238 MaterializedViewOptionName::RetainHistory,
239 )?;
240
241 insert_refresh_strategies(&mut info, stmt.with_options.iter().map(|o| &o.value))?;
242
243 if let Some(target) = stmt.replacement_for {
244 info.insert("replacement_target", json!(item_id(target)?));
245 }
246
247 "materialized-view"
248 }
249 CreateTable(stmt) => {
250 insert_retain_history(
251 &mut info,
252 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
253 TableOptionName::RetainHistory,
254 )?;
255
256 "table"
257 }
258 CreateTableFromSource(stmt) => {
259 let source_id = item_id(stmt.source)?;
260 info.insert("source_id", json!(source_id));
261
262 insert_retain_history(
263 &mut info,
264 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
265 TableFromSourceOptionName::RetainHistory,
266 )?;
267
268 "table"
269 }
270 CreateSource(stmt) => {
271 insert_retain_history(
272 &mut info,
273 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
274 CreateSourceOptionName::RetainHistory,
275 )?;
276
277 let Some(in_cluster) = stmt.in_cluster else {
278 return Err("missing IN CLUSTER".into());
279 };
280 let cluster_id = cluster_id(in_cluster)?;
281 info.insert("cluster_id", json!(cluster_id));
282
283 use mz_sql_parser::ast::CreateSourceConnection::*;
284 let (source_type, connection) = match stmt.connection {
285 Kafka { connection, .. } => ("kafka", Some(connection)),
286 Postgres { connection, .. } => ("postgres", Some(connection)),
287 MySql { connection, .. } => ("mysql", Some(connection)),
288 SqlServer { connection, .. } => ("sql-server", Some(connection)),
289 LoadGenerator { .. } => ("load-generator", None),
290 };
291 info.insert("source_type", json!(source_type));
292 if let Some(conn) = connection {
293 let conn_id = item_id(conn)?;
294 info.insert("connection_id", json!(conn_id));
295 }
296
297 let is_debezium = matches!(
298 stmt.envelope,
299 Some(mz_sql_parser::ast::SourceEnvelope::Debezium)
300 );
301
302 let envelope_type = match &stmt.envelope {
313 Some(envelope) => {
314 use mz_sql_parser::ast::SourceEnvelope::*;
315 Some(match envelope {
316 None => "none",
317 Debezium => "debezium",
318 Upsert { .. } => "upsert",
319 CdcV2 => "materialize",
320 })
321 }
322 None if source_type == "kafka" && stmt.progress_subsource.is_some() => Some("none"),
323 None => None,
324 };
325 if let Some(envelope_type) = envelope_type {
326 info.insert("envelope_type", json!(envelope_type));
327 }
328
329 if let Some(format_spec) = stmt.format {
330 match &format_spec {
331 FormatSpecifier::Bare(fmt) => {
332 if is_debezium {
335 info.insert("key_format", json!(format_name(fmt)));
336 }
337 info.insert("value_format", json!(format_name(fmt)));
338 }
339 FormatSpecifier::KeyValue { key, value } => {
340 info.insert("key_format", json!(format_name(key)));
341 info.insert("value_format", json!(format_name(value)));
342 }
343 }
344 }
345
346 "source"
347 }
348 CreateWebhookSource(stmt) => {
349 if stmt.is_table {
350 "table"
351 } else {
352 info.insert("source_type", json!("webhook"));
353 if let Some(in_cluster) = stmt.in_cluster {
354 let cluster_id = cluster_id(in_cluster)?;
355 info.insert("cluster_id", json!(cluster_id));
356 }
357 "source"
358 }
359 }
360 CreateSubsource(stmt) => {
361 use mz_sql_parser::ast::CreateSubsourceOptionName;
362 let is_progress = stmt
363 .with_options
364 .iter()
365 .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
366 let source_type = if is_progress { "progress" } else { "subsource" };
367 info.insert("source_type", json!(source_type));
368
369 insert_retain_history(
370 &mut info,
371 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
372 CreateSubsourceOptionName::RetainHistory,
373 )?;
374
375 if let Some(of_source) = stmt.of_source {
376 let of_source_id = item_id(of_source)?;
377 info.insert("of_source_id", json!(of_source_id));
378 }
379
380 "subsource"
381 }
382 CreateSink(stmt) => {
392 let Some(in_cluster) = stmt.in_cluster else {
393 return Err("missing IN CLUSTER".into());
394 };
395 info.insert("cluster_id", json!(cluster_id(in_cluster)?));
396
397 match stmt.connection {
398 CreateSinkConnection::Kafka {
399 connection,
400 options,
401 key: sink_key,
402 ..
403 } => {
404 info.insert("sink_type", json!("kafka"));
405 info.insert("connection_id", json!(item_id(connection)?));
406
407 let topic = options
408 .into_iter()
409 .find(|o| o.name == KafkaSinkConfigOptionName::Topic)
410 .and_then(|o| o.value.as_ref().and_then(option_string))
411 .ok_or("kafka sink missing TOPIC")?;
412 info.insert("topic", json!(topic));
413
414 if let Some(envelope) = stmt.envelope {
415 let envelope_type = match envelope {
416 SinkEnvelope::Upsert => "upsert",
417 SinkEnvelope::Debezium => "debezium",
418 };
419 info.insert("envelope_type", json!(envelope_type));
420 }
421
422 if let Some(format_spec) = stmt.format {
423 let (key_format, value_format) = match (&format_spec, sink_key.is_some()) {
428 (FormatSpecifier::Bare(fmt), false) => (None, format_name(fmt)),
429 (FormatSpecifier::Bare(fmt), true) => {
430 (Some(format_name(fmt)), format_name(fmt))
431 }
432 (FormatSpecifier::KeyValue { value, .. }, false) => {
433 (None, format_name(value))
434 }
435 (FormatSpecifier::KeyValue { key, value }, true) => {
436 (Some(format_name(key)), format_name(value))
437 }
438 };
439 if let Some(key_format) = key_format {
440 info.insert("key_format", json!(key_format));
441 }
442 info.insert("value_format", json!(value_format));
443
444 let combined = match key_format {
449 None => value_format.to_string(),
450 Some(key_format)
451 if key_format == value_format
452 && matches!(value_format, "avro" | "json") =>
453 {
454 value_format.to_string()
455 }
456 Some(key_format) => {
457 format!("key-{key_format}-value-{value_format}")
458 }
459 };
460 info.insert("format", json!(combined));
461 }
462 }
463 CreateSinkConnection::Iceberg {
464 catalog_connection,
465 options,
466 ..
467 } => {
468 info.insert("sink_type", json!("iceberg"));
469 info.insert("connection_id", json!(item_id(catalog_connection)?));
471
472 let mut namespace = None;
473 let mut table = None;
474 for option in options {
475 match option.name {
476 IcebergSinkConfigOptionName::Namespace => {
477 namespace = option.value.as_ref().and_then(option_string)
478 }
479 IcebergSinkConfigOptionName::Table => {
480 table = option.value.as_ref().and_then(option_string)
481 }
482 }
483 }
484 info.insert(
485 "namespace",
486 json!(namespace.ok_or("iceberg sink missing NAMESPACE")?),
487 );
488 info.insert("table", json!(table.ok_or("iceberg sink missing TABLE")?));
489
490 if let Some(mode) = stmt.mode {
493 let envelope_type = match mode {
494 IcebergSinkMode::Upsert => "upsert",
495 IcebergSinkMode::Append => "append",
496 };
497 info.insert("envelope_type", json!(envelope_type));
498 }
499 }
500 }
501
502 "sink"
503 }
504 CreateMetricSink(stmt) => {
505 let Some(in_cluster) = stmt.in_cluster else {
506 return Err("missing IN CLUSTER".into());
507 };
508 let cluster_id = cluster_id(in_cluster)?;
509 info.insert("cluster_id", json!(cluster_id));
510 let from_id = item_id(stmt.from)?;
511 info.insert("from_id", json!(from_id));
512 "metric-sink"
513 }
514 CreateIndex(stmt) => {
515 let Some(in_cluster) = stmt.in_cluster else {
516 return Err("missing IN CLUSTER".into());
517 };
518 let cluster_id = cluster_id(in_cluster)?;
519 info.insert("cluster_id", json!(cluster_id));
520 let on_id = item_id(stmt.on_name)?;
521 info.insert("on_id", json!(on_id));
522 insert_retain_history(
523 &mut info,
524 stmt.with_options.iter().map(|o| (&o.name, &o.value)),
525 IndexOptionName::RetainHistory,
526 )?;
527 "index"
528 }
529 CreateType(_) => "type",
530 Select(_)
536 | Insert(_)
537 | Copy(_)
538 | Update(_)
539 | Delete(_)
540 | CreateDatabase(_)
541 | CreateSchema(_)
542 | CreateRole(_)
543 | CreateCluster(_)
544 | CreateClusterReplica(_)
545 | CreateNetworkPolicy(_)
546 | AlterCluster(_)
547 | AlterOwner(_)
548 | AlterObjectRename(_)
549 | AlterObjectSwap(_)
550 | AlterRetainHistory(_)
551 | AlterIndex(_)
552 | AlterSecret(_)
553 | AlterSetCluster(_)
554 | AlterSink(_)
555 | AlterSource(_)
556 | AlterSystemSet(_)
557 | AlterSystemReset(_)
558 | AlterSystemResetAll(_)
559 | AlterConnection(_)
560 | AlterNetworkPolicy(_)
561 | AlterRole(_)
562 | AlterTableAddColumn(_)
563 | AlterMaterializedViewApplyReplacement(_)
564 | Discard(_)
565 | DropObjects(_)
566 | DropOwned(_)
567 | SetVariable(_)
568 | ResetVariable(_)
569 | Show(_)
570 | StartTransaction(_)
571 | SetTransaction(_)
572 | Commit(_)
573 | Rollback(_)
574 | Subscribe(_)
575 | ExplainPlan(_)
576 | ExplainPushdown(_)
577 | ExplainTimestamp(_)
578 | ExplainSinkSchema(_)
579 | ExplainAnalyzeObject(_)
580 | ExplainAnalyzeCluster(_)
581 | Declare(_)
582 | Fetch(_)
583 | Close(_)
584 | Prepare(_)
585 | Execute(_)
586 | ExecuteUnitTest(_)
587 | Deallocate(_)
588 | Raise(_)
589 | GrantRole(_)
590 | RevokeRole(_)
591 | GrantPrivileges(_)
592 | RevokePrivileges(_)
593 | AlterDefaultPrivileges(_)
594 | ReassignOwned(_)
595 | ValidateConnection(_)
596 | Comment(_) => return Err("not a CREATE item statement".into()),
597 };
598 info.insert("type", json!(item_type));
599
600 let info = info.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
601 Ok(info)
602}
603
604pub fn item_references(a: &str) -> Result<serde_json::Value, String> {
612 fn qualified(name: &UnresolvedItemName) -> serde_json::Value {
613 match &name.0[..] {
614 [.., schema, item] => json!({"schema": schema.as_str(), "name": item.as_str()}),
615 [item] => json!({"schema": serde_json::Value::Null, "name": item.as_str()}),
616 [] => json!({"schema": serde_json::Value::Null, "name": ""}),
617 }
618 }
619
620 let stmt = parse_single_statement(a)?;
621
622 let refs = collect_item_references(&stmt);
623 mz_ore::soft_assert_or_log!(
624 refs.named_array_elements.is_empty(),
625 "persisted create_sql should never carry an array type T[] \
626 and resolve its array type in `ids`: {:?}",
627 refs.named_array_elements
628 );
629 Ok(json!({
630 "ids": refs.ids.iter().collect::<Vec<_>>(),
631 "named_funcs": refs.named_funcs.iter().map(qualified).collect::<Vec<_>>(),
632 "named_types": refs.named_types.iter().map(qualified).collect::<Vec<_>>(),
633 "named_relations": refs.named_relations.iter().map(qualified).collect::<Vec<_>>(),
634 }))
635}
636
637#[derive(Clone, PartialEq, ::prost::Message)]
643struct PostgresPublicationDetailsSubset {
644 #[prost(string, tag = "2")]
645 slot: String,
646 #[prost(uint64, optional, tag = "3")]
647 timeline_id: Option<u64>,
648}
649
650pub fn postgres_source_details(a: &str) -> Result<serde_json::Value, String> {
661 let stmt = parse_single_statement(a)?;
662
663 use mz_sql_parser::ast::CreateSourceConnection;
664 use mz_sql_parser::ast::Statement::CreateSource;
665 let options = match stmt {
666 CreateSource(stmt) => match stmt.connection {
667 CreateSourceConnection::Postgres { options, .. } => options,
668 _ => return Ok(serde_json::Value::Null),
669 },
670 _ => return Ok(serde_json::Value::Null),
671 };
672
673 let details_hex = options
674 .into_iter()
675 .find(|opt| opt.name == PgConfigOptionName::Details)
676 .and_then(|opt| match opt.value {
677 Some(WithOptionValue::Value(Value::String(s))) => Some(s),
678 _ => None,
679 })
680 .ok_or("missing DETAILS option on postgres source")?;
681
682 let details_bytes =
683 hex::decode(&details_hex).map_err(|e| format!("DETAILS is not valid hex: {e}"))?;
684
685 let details = PostgresPublicationDetailsSubset::decode(&*details_bytes)
686 .map_err(|e| format!("DETAILS is not a valid publication-details proto: {e}"))?;
687
688 Ok(json!({
689 "slot": details.slot,
690 "timeline_id": details.timeline_id,
691 }))
692}
693
694pub fn kafka_source_details(a: &str) -> Result<serde_json::Value, String> {
706 let stmt = parse_single_statement(a)?;
707
708 use mz_sql_parser::ast::CreateSourceConnection;
709 use mz_sql_parser::ast::Statement::CreateSource;
710 let (connection, options) = match stmt {
711 CreateSource(stmt) => match stmt.connection {
712 CreateSourceConnection::Kafka {
713 connection,
714 options,
715 } => (connection, options),
716 _ => return Ok(serde_json::Value::Null),
717 },
718 _ => return Ok(serde_json::Value::Null),
719 };
720
721 let connection_id = item_id(connection)?;
722
723 let mut topic: Option<String> = None;
724 let mut group_id_prefix: Option<String> = None;
725 for opt in options {
726 let string_value = opt.value.as_ref().and_then(option_string);
727 match opt.name {
728 KafkaSourceConfigOptionName::Topic => topic = string_value,
729 KafkaSourceConfigOptionName::GroupIdPrefix => group_id_prefix = string_value,
730 _ => {}
731 }
732 }
733
734 let topic = topic.ok_or("missing TOPIC option on kafka source")?;
735
736 Ok(json!({
737 "topic": topic,
738 "group_id_prefix": group_id_prefix,
739 "connection_id": connection_id,
740 }))
741}
742
743pub fn source_export_details(a: &str) -> Result<serde_json::Value, String> {
778 fn bare_format_has_key<T: AstInfo>(fmt: &Format<T>) -> bool {
782 match fmt {
783 Format::Avro(AvroSchema::Csr { csr_connection }) => csr_connection
784 .seed
785 .as_ref()
786 .is_some_and(|seed| seed.key_schema.is_some()),
787 Format::Protobuf(ProtobufSchema::Csr { csr_connection }) => csr_connection
788 .seed
789 .as_ref()
790 .is_some_and(|seed| seed.key.is_some()),
791 _ => false,
792 }
793 }
794
795 fn key_value_formats<T: AstInfo>(
796 spec: &FormatSpecifier<T>,
797 ) -> (Option<&'static str>, Option<&'static str>) {
798 match spec {
799 FormatSpecifier::KeyValue { key, value } => {
800 (Some(format_name(key)), Some(format_name(value)))
801 }
802 FormatSpecifier::Bare(fmt) => {
803 let value = Some(format_name(fmt));
804 let key = bare_format_has_key(fmt).then(|| format_name(fmt));
805 (key, value)
806 }
807 }
808 }
809
810 fn envelope_name(envelope: &SourceEnvelope) -> &'static str {
811 match envelope {
812 SourceEnvelope::None => "none",
813 SourceEnvelope::Debezium => "debezium",
814 SourceEnvelope::Upsert {
815 value_decode_err_policy,
816 } => {
817 if value_decode_err_policy
818 .iter()
819 .any(|p| matches!(p, SourceErrorPolicy::Inline { .. }))
820 {
821 "upsert-value-err-inline"
822 } else {
823 "upsert"
824 }
825 }
826 SourceEnvelope::CdcV2 => "materialize",
827 }
828 }
829
830 let stmt = parse_single_statement(a)?;
831
832 use mz_sql_parser::ast::Statement::{CreateSubsource, CreateTableFromSource};
833 match stmt {
834 CreateTableFromSource(stmt) => {
835 let source_id = item_id(stmt.source)?;
836 let external_reference = stmt
837 .external_reference
838 .ok_or("missing external reference on CREATE TABLE FROM SOURCE")?
839 .0
840 .into_iter()
841 .map(|ident| ident.into_string())
842 .collect::<Vec<_>>();
843
844 let envelope_type = stmt.envelope.as_ref().map(envelope_name);
845 let (key_format, value_format) = match &stmt.format {
846 Some(spec) => key_value_formats(spec),
847 None => (None, None),
848 };
849
850 Ok(json!({
851 "source_id": source_id,
852 "external_reference": external_reference,
853 "envelope_type": envelope_type,
854 "key_format": key_format,
855 "value_format": value_format,
856 }))
857 }
858 CreateSubsource(stmt) => {
859 let is_progress = stmt
862 .with_options
863 .iter()
864 .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
865 if is_progress {
866 return Ok(serde_json::Value::Null);
867 }
868
869 let source_id = stmt
870 .of_source
871 .ok_or("non-progress CREATE SUBSOURCE without OF SOURCE")
872 .and_then(item_id)?;
873
874 let external_reference = stmt
875 .with_options
876 .into_iter()
877 .find(|o| matches!(o.name, CreateSubsourceOptionName::ExternalReference))
878 .and_then(|o| match o.value {
879 Some(WithOptionValue::UnresolvedItemName(name)) => Some(name),
880 _ => None,
881 })
882 .ok_or("CREATE SUBSOURCE missing EXTERNAL REFERENCE option")?
883 .0
884 .into_iter()
885 .map(|ident| ident.into_string())
886 .collect::<Vec<_>>();
887
888 Ok(json!({
889 "source_id": source_id,
890 "external_reference": external_reference,
891 "envelope_type": serde_json::Value::Null,
892 "key_format": serde_json::Value::Null,
893 "value_format": serde_json::Value::Null,
894 }))
895 }
896 _ => Ok(serde_json::Value::Null),
897 }
898}
899
900pub fn connection_details(a: &str) -> Result<serde_json::Value, String> {
945 fn broker_addresses<T: AstInfo>(values: &[ConnectionOption<T>]) -> Vec<String> {
950 let mut brokers = Vec::new();
951 for opt in values {
952 match (&opt.name, &opt.value) {
953 (ConnectionOptionName::Broker, Some(WithOptionValue::ConnectionKafkaBroker(b))) => {
954 brokers.push(b.address.clone());
955 }
956 (ConnectionOptionName::Brokers, Some(WithOptionValue::Sequence(seq))) => {
957 for v in seq {
958 if let WithOptionValue::ConnectionKafkaBroker(b) = v {
959 brokers.push(b.address.clone());
960 }
961 }
962 }
963 _ => {}
964 }
965 }
966 brokers
967 }
968
969 fn string_option<T: AstInfo>(
970 values: &[ConnectionOption<T>],
971 name: ConnectionOptionName,
972 ) -> Option<String> {
973 values
974 .iter()
975 .find(|o| o.name == name)
976 .and_then(|o| o.value.as_ref())
977 .and_then(option_string)
978 }
979
980 fn secret_id_option<T: AstInfo<ItemName = RawItemName>>(
984 values: &[ConnectionOption<T>],
985 name: ConnectionOptionName,
986 ) -> Option<String> {
987 values.iter().find_map(|o| match &o.value {
988 Some(WithOptionValue::Secret(RawItemName::Id(id, _, _))) if o.name == name => {
989 Some(id.clone())
990 }
991 _ => None,
992 })
993 }
994
995 let stmt = parse_single_statement(a)?;
996
997 use mz_sql_parser::ast::Statement::CreateConnection;
998 let stmt = match stmt {
999 CreateConnection(stmt) => stmt,
1000 _ => return Ok(serde_json::Value::Null),
1001 };
1002
1003 match stmt.connection_type {
1004 CreateConnectionType::Kafka => Ok(json!({
1005 "brokers": broker_addresses(&stmt.values),
1006 "progress_topic": string_option(&stmt.values, ConnectionOptionName::ProgressTopic),
1007 })),
1008 CreateConnectionType::Ssh => Ok(json!({
1009 "public_key_1": string_option(&stmt.values, ConnectionOptionName::PublicKey1),
1010 "public_key_2": string_option(&stmt.values, ConnectionOptionName::PublicKey2),
1011 })),
1012 CreateConnectionType::Aws => {
1013 let assume_role_arn = string_option(&stmt.values, ConnectionOptionName::AssumeRoleArn);
1014 let auth_kind = if assume_role_arn.is_some() {
1015 "assume-role"
1016 } else {
1017 "credentials"
1018 };
1019 Ok(json!({
1020 "auth_kind": auth_kind,
1021 "endpoint": string_option(&stmt.values, ConnectionOptionName::Endpoint)
1025 .filter(|s| !s.is_empty()),
1026 "region": string_option(&stmt.values, ConnectionOptionName::Region),
1027 "access_key_id": string_option(&stmt.values, ConnectionOptionName::AccessKeyId),
1028 "access_key_id_secret_id":
1029 secret_id_option(&stmt.values, ConnectionOptionName::AccessKeyId),
1030 "secret_access_key_secret_id":
1031 secret_id_option(&stmt.values, ConnectionOptionName::SecretAccessKey),
1032 "session_token": string_option(&stmt.values, ConnectionOptionName::SessionToken),
1033 "session_token_secret_id":
1034 secret_id_option(&stmt.values, ConnectionOptionName::SessionToken),
1035 "assume_role_arn": assume_role_arn,
1036 "assume_role_session_name":
1037 string_option(&stmt.values, ConnectionOptionName::AssumeRoleSessionName),
1038 }))
1039 }
1040 _ => Ok(serde_json::Value::Null),
1041 }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use prost::Message as _;
1047 use serde_json::json;
1048
1049 fn encode_pg_details(slot: &str, timeline_id: Option<u64>) -> String {
1052 let details = super::PostgresPublicationDetailsSubset {
1053 slot: slot.to_string(),
1054 timeline_id,
1055 };
1056 hex::encode(details.encode_to_vec())
1057 }
1058
1059 fn pg_source_sql(details_hex: &str) -> String {
1060 format!(
1061 "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
1062 IN CLUSTER [u42] \
1063 FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
1064 (DETAILS = '{details_hex}', PUBLICATION = 'mz_source') \
1065 FOR ALL TABLES"
1066 )
1067 }
1068
1069 fn kafka_source_sql(with_prefix: bool) -> String {
1070 let prefix_opt = if with_prefix {
1071 ", GROUP ID PREFIX 'my-prefix-'"
1072 } else {
1073 ""
1074 };
1075 format!(
1076 "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1077 IN CLUSTER [u42] \
1078 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1079 (TOPIC 'test'{prefix_opt}) FORMAT TEXT"
1080 )
1081 }
1082
1083 #[mz_ore::test]
1086 #[cfg_attr(miri, ignore)] fn pg_happy_path_with_timeline() {
1088 let hex = encode_pg_details("materialize_abc", Some(42));
1089 let sql = pg_source_sql(&hex);
1090 let out = super::postgres_source_details(&sql).expect("ok");
1091 assert_eq!(out, json!({ "slot": "materialize_abc", "timeline_id": 42 }));
1092 }
1093
1094 #[mz_ore::test]
1095 fn pg_happy_path_null_timeline() {
1096 let hex = encode_pg_details("materialize_legacy", None);
1099 let sql = pg_source_sql(&hex);
1100 let out = super::postgres_source_details(&sql).expect("ok");
1101 assert_eq!(
1102 out,
1103 json!({ "slot": "materialize_legacy", "timeline_id": null }),
1104 );
1105 }
1106
1107 #[mz_ore::test]
1108 fn pg_non_postgres_source_returns_null_jsonb() {
1109 let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1110 IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1111 let out = super::postgres_source_details(sql).expect("ok");
1112 assert_eq!(out, serde_json::Value::Null);
1113 }
1114
1115 #[mz_ore::test]
1116 #[cfg_attr(miri, ignore)] fn pg_non_create_source_returns_null_jsonb() {
1118 let sql = "CREATE VIEW v AS SELECT 1";
1119 let out = super::postgres_source_details(sql).expect("ok");
1120 assert_eq!(out, serde_json::Value::Null);
1121 }
1122
1123 #[mz_ore::test]
1124 fn pg_missing_details_option_errors() {
1125 let sql = "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
1126 IN CLUSTER [u42] \
1127 FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
1128 (PUBLICATION = 'mz_source') FOR ALL TABLES";
1129 let err = super::postgres_source_details(sql).unwrap_err();
1130 assert!(
1131 err.contains("missing DETAILS"),
1132 "wrong error message: {err}"
1133 );
1134 }
1135
1136 #[mz_ore::test]
1137 fn pg_malformed_hex_errors() {
1138 let sql = pg_source_sql("not-hex!!");
1139 let err = super::postgres_source_details(&sql).unwrap_err();
1140 assert!(err.contains("valid hex"), "wrong error message: {err}");
1141 }
1142
1143 #[mz_ore::test]
1144 fn pg_malformed_proto_errors() {
1145 let sql = pg_source_sql("ffff");
1148 let err = super::postgres_source_details(&sql).unwrap_err();
1149 assert!(
1150 err.contains("publication-details proto"),
1151 "wrong error message: {err}"
1152 );
1153 }
1154
1155 #[mz_ore::test]
1158 fn kafka_happy_path_with_prefix() {
1159 let sql = kafka_source_sql(true);
1160 let out = super::kafka_source_details(&sql).expect("ok");
1161 assert_eq!(
1162 out,
1163 json!({
1164 "topic": "test",
1165 "group_id_prefix": "my-prefix-",
1166 "connection_id": "u11",
1167 }),
1168 );
1169 }
1170
1171 #[mz_ore::test]
1172 fn kafka_happy_path_without_prefix() {
1173 let sql = kafka_source_sql(false);
1174 let out = super::kafka_source_details(&sql).expect("ok");
1175 assert_eq!(
1176 out,
1177 json!({
1178 "topic": "test",
1179 "group_id_prefix": null,
1180 "connection_id": "u11",
1181 }),
1182 );
1183 }
1184
1185 #[mz_ore::test]
1186 fn kafka_non_kafka_source_returns_null_jsonb() {
1187 let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1188 IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1189 let out = super::kafka_source_details(sql).expect("ok");
1190 assert_eq!(out, serde_json::Value::Null);
1191 }
1192
1193 #[mz_ore::test]
1194 fn kafka_missing_topic_errors() {
1195 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1196 IN CLUSTER [u42] \
1197 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1198 FORMAT TEXT";
1199 let err = super::kafka_source_details(sql).unwrap_err();
1200 assert!(err.contains("missing TOPIC"), "wrong error message: {err}");
1201 }
1202
1203 #[mz_ore::test]
1204 fn kafka_unquoted_topic_and_prefix() {
1205 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1209 IN CLUSTER [u42] \
1210 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1211 (TOPIC = my_topic, GROUP ID PREFIX = my_prefix) FORMAT TEXT";
1212 let out = super::kafka_source_details(sql).expect("ok");
1213 assert_eq!(out["topic"], json!("my_topic"));
1214 assert_eq!(out["group_id_prefix"], json!("my_prefix"));
1215 }
1216
1217 #[mz_ore::test]
1218 fn kafka_unresolved_connection_errors() {
1219 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1223 IN CLUSTER [u42] \
1224 FROM KAFKA CONNECTION k_conn (TOPIC 'test') FORMAT TEXT";
1225 let err = super::kafka_source_details(sql).unwrap_err();
1226 assert!(
1227 err.contains("unresolved item name"),
1228 "wrong error message: {err}"
1229 );
1230 }
1231
1232 fn table_from_source_sql(reference: &str, suffix: &str) -> String {
1235 format!(
1236 "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1237 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1238 (REFERENCE = {reference}){suffix}"
1239 )
1240 }
1241
1242 #[mz_ore::test]
1243 fn export_table_postgres_style_no_format() {
1244 let sql = table_from_source_sql("\"db\".\"public\".\"t\"", "");
1247 let out = super::source_export_details(&sql).expect("ok");
1248 assert_eq!(
1249 out,
1250 json!({
1251 "source_id": "u1",
1252 "external_reference": ["db", "public", "t"],
1253 "envelope_type": null,
1254 "key_format": null,
1255 "value_format": null,
1256 }),
1257 );
1258 }
1259
1260 #[mz_ore::test]
1261 fn export_table_kafka_bare_value_only() {
1262 let sql = table_from_source_sql("\"topic\"", " FORMAT TEXT ENVELOPE NONE");
1264 let out = super::source_export_details(&sql).expect("ok");
1265 assert_eq!(
1266 out,
1267 json!({
1268 "source_id": "u1",
1269 "external_reference": ["topic"],
1270 "envelope_type": "none",
1271 "key_format": null,
1272 "value_format": "text",
1273 }),
1274 );
1275 }
1276
1277 #[mz_ore::test]
1278 fn export_table_kafka_omitted_envelope_is_null() {
1279 let sql = table_from_source_sql("\"topic\"", " FORMAT TEXT");
1283 let out = super::source_export_details(&sql).expect("ok");
1284 assert_eq!(out["envelope_type"], serde_json::Value::Null);
1285 }
1286
1287 #[mz_ore::test]
1288 fn export_table_kafka_key_value_format() {
1289 let sql = table_from_source_sql(
1290 "\"topic\"",
1291 " KEY FORMAT TEXT VALUE FORMAT TEXT ENVELOPE NONE",
1292 );
1293 let out = super::source_export_details(&sql).expect("ok");
1294 assert_eq!(
1295 out,
1296 json!({
1297 "source_id": "u1",
1298 "external_reference": ["topic"],
1299 "envelope_type": "none",
1300 "key_format": "text",
1301 "value_format": "text",
1302 }),
1303 );
1304 }
1305
1306 #[mz_ore::test]
1307 #[cfg_attr(miri, ignore)] fn export_table_kafka_bare_avro_seed_with_key() {
1309 let sql = table_from_source_sql(
1313 "\"topic\"",
1314 " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1315 CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1316 SEED KEY SCHEMA 'k' VALUE SCHEMA 'v' ENVELOPE UPSERT",
1317 );
1318 let out = super::source_export_details(&sql).expect("ok");
1319 assert_eq!(
1320 out,
1321 json!({
1322 "source_id": "u1",
1323 "external_reference": ["topic"],
1324 "envelope_type": "upsert",
1325 "key_format": "avro",
1326 "value_format": "avro",
1327 }),
1328 );
1329 }
1330
1331 #[mz_ore::test]
1332 #[cfg_attr(miri, ignore)] fn export_table_kafka_bare_avro_seed_without_key() {
1334 let sql = table_from_source_sql(
1336 "\"topic\"",
1337 " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1338 CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1339 SEED VALUE SCHEMA 'v' ENVELOPE NONE",
1340 );
1341 let out = super::source_export_details(&sql).expect("ok");
1342 assert_eq!(
1343 out,
1344 json!({
1345 "source_id": "u1",
1346 "external_reference": ["topic"],
1347 "envelope_type": "none",
1348 "key_format": null,
1349 "value_format": "avro",
1350 }),
1351 );
1352 }
1353
1354 #[mz_ore::test]
1355 fn export_subsource_non_progress() {
1356 let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
1359 OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1360 WITH (EXTERNAL REFERENCE = \"db\".\"public\".\"t\")";
1361 let out = super::source_export_details(sql).expect("ok");
1362 assert_eq!(
1363 out,
1364 json!({
1365 "source_id": "u1",
1366 "external_reference": ["db", "public", "t"],
1367 "envelope_type": null,
1368 "key_format": null,
1369 "value_format": null,
1370 }),
1371 );
1372 }
1373
1374 #[mz_ore::test]
1375 fn export_progress_subsource_returns_null_jsonb() {
1376 let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"progress\" (id int4) \
1377 WITH (PROGRESS)";
1378 let out = super::source_export_details(sql).expect("ok");
1379 assert_eq!(out, serde_json::Value::Null);
1380 }
1381
1382 #[mz_ore::test]
1383 #[cfg_attr(miri, ignore)] fn export_non_source_export_returns_null_jsonb() {
1385 let sql = "CREATE VIEW v AS SELECT 1";
1386 let out = super::source_export_details(sql).expect("ok");
1387 assert_eq!(out, serde_json::Value::Null);
1388 }
1389
1390 #[mz_ore::test]
1391 fn export_unresolved_source_name_errors() {
1392 let sql = "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1393 FROM SOURCE src (REFERENCE = \"topic\")";
1394 let err = super::source_export_details(sql).unwrap_err();
1395 assert!(
1396 err.contains("unresolved item name"),
1397 "wrong error message: {err}"
1398 );
1399 }
1400
1401 #[mz_ore::test]
1404 fn connection_kafka_single_broker_default_progress() {
1405 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1408 (BROKER = 'localhost:9092', SECURITY PROTOCOL = plaintext)";
1409 let out = super::connection_details(sql).expect("ok");
1410 assert_eq!(
1411 out,
1412 json!({
1413 "brokers": ["localhost:9092"],
1414 "progress_topic": null,
1415 }),
1416 );
1417 }
1418
1419 #[mz_ore::test]
1420 fn connection_kafka_explicit_progress_topic() {
1421 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1422 (BROKER = 'localhost:9092', PROGRESS TOPIC = 'override', \
1423 SECURITY PROTOCOL = plaintext)";
1424 let out = super::connection_details(sql).expect("ok");
1425 assert_eq!(
1426 out,
1427 json!({
1428 "brokers": ["localhost:9092"],
1429 "progress_topic": "override",
1430 }),
1431 );
1432 }
1433
1434 #[mz_ore::test]
1435 fn connection_kafka_broker_list() {
1436 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1437 (BROKERS ('b1:9092', 'b2:9092'), SECURITY PROTOCOL = plaintext)";
1438 let out = super::connection_details(sql).expect("ok");
1439 assert_eq!(
1440 out,
1441 json!({
1442 "brokers": ["b1:9092", "b2:9092"],
1443 "progress_topic": null,
1444 }),
1445 );
1446 }
1447
1448 #[mz_ore::test]
1449 fn connection_ssh_public_keys() {
1450 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO SSH TUNNEL \
1451 (HOST = 'ssh.example.com', PORT = 22, USER = 'mz', \
1452 PUBLIC KEY 1 = 'ssh-ed25519 AAAA', PUBLIC KEY 2 = 'ssh-ed25519 BBBB')";
1453 let out = super::connection_details(sql).expect("ok");
1454 assert_eq!(
1455 out,
1456 json!({
1457 "public_key_1": "ssh-ed25519 AAAA",
1458 "public_key_2": "ssh-ed25519 BBBB",
1459 }),
1460 );
1461 }
1462
1463 #[mz_ore::test]
1464 fn connection_aws_credentials_inline_key() {
1465 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1468 (ACCESS KEY ID = 'AKIAEXAMPLE', \
1469 SECRET ACCESS KEY = SECRET [u1 AS \"materialize\".\"public\".\"sk\"])";
1470 let out = super::connection_details(sql).expect("ok");
1471 assert_eq!(
1472 out,
1473 json!({
1474 "auth_kind": "credentials",
1475 "endpoint": null,
1476 "region": null,
1477 "access_key_id": "AKIAEXAMPLE",
1478 "access_key_id_secret_id": null,
1479 "secret_access_key_secret_id": "u1",
1480 "session_token": null,
1481 "session_token_secret_id": null,
1482 "assume_role_arn": null,
1483 "assume_role_session_name": null,
1484 }),
1485 );
1486 }
1487
1488 #[mz_ore::test]
1489 fn connection_aws_credentials_secret_key_and_session_token() {
1490 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1493 (ENDPOINT = 'http://localhost', REGION = 'us-east-1', \
1494 ACCESS KEY ID = SECRET [u1 AS \"materialize\".\"public\".\"ak\"], \
1495 SECRET ACCESS KEY = SECRET [u2 AS \"materialize\".\"public\".\"sk\"], \
1496 SESSION TOKEN = SECRET [u3 AS \"materialize\".\"public\".\"st\"])";
1497 let out = super::connection_details(sql).expect("ok");
1498 assert_eq!(
1499 out,
1500 json!({
1501 "auth_kind": "credentials",
1502 "endpoint": "http://localhost",
1503 "region": "us-east-1",
1504 "access_key_id": null,
1505 "access_key_id_secret_id": "u1",
1506 "secret_access_key_secret_id": "u2",
1507 "session_token": null,
1508 "session_token_secret_id": "u3",
1509 "assume_role_arn": null,
1510 "assume_role_session_name": null,
1511 }),
1512 );
1513 }
1514
1515 #[mz_ore::test]
1516 fn connection_aws_assume_role() {
1517 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1520 (ASSUME ROLE ARN 'arn:aws:iam::123:role/mz', \
1521 ASSUME ROLE SESSION NAME 'sess')";
1522 let out = super::connection_details(sql).expect("ok");
1523 assert_eq!(
1524 out,
1525 json!({
1526 "auth_kind": "assume-role",
1527 "endpoint": null,
1528 "region": null,
1529 "access_key_id": null,
1530 "access_key_id_secret_id": null,
1531 "secret_access_key_secret_id": null,
1532 "session_token": null,
1533 "session_token_secret_id": null,
1534 "assume_role_arn": "arn:aws:iam::123:role/mz",
1535 "assume_role_session_name": "sess",
1536 }),
1537 );
1538 }
1539
1540 #[mz_ore::test]
1541 fn connection_kafka_unquoted_progress_topic() {
1542 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO KAFKA \
1545 (BROKER = 'localhost:9092', PROGRESS TOPIC = my_topic, \
1546 SECURITY PROTOCOL = plaintext)";
1547 let out = super::connection_details(sql).expect("ok");
1548 assert_eq!(out["progress_topic"], json!("my_topic"));
1549 }
1550
1551 #[mz_ore::test]
1552 fn connection_aws_unquoted_option_values() {
1553 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1556 (ENDPOINT = localhost, REGION = useast1, \
1557 ASSUME ROLE ARN 'arn:aws:iam::123:role/mz', \
1558 ASSUME ROLE SESSION NAME = mysession)";
1559 let out = super::connection_details(sql).expect("ok");
1560 assert_eq!(out["endpoint"], json!("localhost"));
1561 assert_eq!(out["region"], json!("useast1"));
1562 assert_eq!(out["assume_role_session_name"], json!("mysession"));
1563 }
1564
1565 #[mz_ore::test]
1566 fn connection_aws_empty_endpoint_is_null() {
1567 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO AWS \
1570 (ENDPOINT = '', ASSUME ROLE ARN 'arn:aws:iam::123:role/mz')";
1571 let out = super::connection_details(sql).expect("ok");
1572 assert_eq!(out["endpoint"], serde_json::Value::Null);
1573 }
1574
1575 #[mz_ore::test]
1576 fn connection_other_type_returns_null_jsonb() {
1577 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"c\" TO POSTGRES \
1579 (HOST = 'db', DATABASE = 'postgres', USER = 'mz')";
1580 let out = super::connection_details(sql).expect("ok");
1581 assert_eq!(out, serde_json::Value::Null);
1582 }
1583
1584 #[mz_ore::test]
1585 #[cfg_attr(miri, ignore)] fn connection_non_connection_returns_null_jsonb() {
1587 let sql = "CREATE VIEW v AS SELECT 1";
1588 let out = super::connection_details(sql).expect("ok");
1589 assert_eq!(out, serde_json::Value::Null);
1590 }
1591
1592 #[mz_ore::test]
1595 fn catalog_kafka_old_syntax_omitted_envelope_defaults_none() {
1596 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1600 IN CLUSTER [u42] \
1601 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1602 (TOPIC 'test') FORMAT TEXT \
1603 EXPOSE PROGRESS AS [u12 AS \"materialize\".\"public\".\"k_progress\"]";
1604 let out = super::item_details(sql).expect("ok");
1605 assert_eq!(out["envelope_type"], json!("none"));
1606 }
1607
1608 #[mz_ore::test]
1609 fn catalog_kafka_old_syntax_explicit_envelope() {
1610 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1611 IN CLUSTER [u42] \
1612 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1613 (TOPIC 'test') FORMAT BYTES ENVELOPE UPSERT \
1614 EXPOSE PROGRESS AS [u12 AS \"materialize\".\"public\".\"k_progress\"]";
1615 let out = super::item_details(sql).expect("ok");
1616 assert_eq!(out["envelope_type"], json!("upsert"));
1617 }
1618
1619 #[mz_ore::test]
1620 fn catalog_kafka_new_syntax_source_omits_envelope_type() {
1621 let sql = "CREATE SOURCE \"materialize\".\"public\".\"k\" \
1625 IN CLUSTER [u42] \
1626 FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"]";
1627 let out = super::item_details(sql).expect("ok");
1628 assert_eq!(out.get("envelope_type"), None);
1629 }
1630
1631 #[mz_ore::test]
1632 fn catalog_non_kafka_source_omits_envelope_type() {
1633 let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1636 IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1637 let out = super::item_details(sql).expect("ok");
1638 assert_eq!(out.get("envelope_type"), None);
1639 }
1640
1641 const AVRO_FORMAT: &str = "FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION \
1644 [u12 AS \"materialize\".\"public\".\"csr_conn\"]";
1645
1646 fn kafka_sink_sql(key: Option<&str>, format: &str, envelope: &str) -> String {
1649 let key_clause = key.map(|k| format!(" KEY ({k})")).unwrap_or_default();
1650 format!(
1651 "CREATE SINK \"materialize\".\"public\".\"snk\" \
1652 IN CLUSTER [u42] \
1653 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1654 INTO KAFKA CONNECTION [u10 AS \"materialize\".\"public\".\"k_conn\"] \
1655 (TOPIC 'sink-topic'){key_clause} {format} ENVELOPE {envelope}"
1656 )
1657 }
1658
1659 fn iceberg_sink_sql(mode: &str) -> String {
1660 format!(
1661 "CREATE SINK \"materialize\".\"public\".\"ice\" \
1662 IN CLUSTER [u42] \
1663 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1664 INTO ICEBERG CATALOG CONNECTION [u20 AS \"materialize\".\"public\".\"cat_conn\"] \
1665 (NAMESPACE 'ns', TABLE 'tbl') \
1666 USING AWS CONNECTION [u21 AS \"materialize\".\"public\".\"aws_conn\"] \
1667 MODE {mode}"
1668 )
1669 }
1670
1671 #[mz_ore::test]
1672 fn sink_kafka_bare_format_without_key() {
1673 let sql = kafka_sink_sql(None, "FORMAT JSON", "DEBEZIUM");
1674 let out = super::item_details(&sql).expect("ok");
1675 assert_eq!(
1676 out,
1677 json!({
1678 "type": "sink",
1679 "sink_type": "kafka",
1680 "cluster_id": "u42",
1681 "connection_id": "u10",
1682 "topic": "sink-topic",
1683 "envelope_type": "debezium",
1684 "format": "json",
1685 "value_format": "json",
1686 }),
1687 );
1688 }
1689
1690 #[mz_ore::test]
1691 fn sink_kafka_bare_format_with_key_derives_key_format() {
1692 let sql = kafka_sink_sql(Some("a"), AVRO_FORMAT, "UPSERT");
1695 let out = super::item_details(&sql).expect("ok");
1696 assert_eq!(
1697 out,
1698 json!({
1699 "type": "sink",
1700 "sink_type": "kafka",
1701 "cluster_id": "u42",
1702 "connection_id": "u10",
1703 "topic": "sink-topic",
1704 "envelope_type": "upsert",
1705 "format": "avro",
1706 "key_format": "avro",
1707 "value_format": "avro",
1708 }),
1709 );
1710 }
1711
1712 #[mz_ore::test]
1713 fn sink_kafka_bare_text_format_with_key_does_not_collapse() {
1714 let sql = kafka_sink_sql(Some("a"), "FORMAT TEXT", "UPSERT");
1717 let out = super::item_details(&sql).expect("ok");
1718 assert_eq!(out["format"], json!("key-text-value-text"));
1719 assert_eq!(out["key_format"], json!("text"));
1720 assert_eq!(out["value_format"], json!("text"));
1721 }
1722
1723 #[mz_ore::test]
1724 fn sink_kafka_key_value_json_collapses() {
1725 let sql = kafka_sink_sql(Some("a"), "KEY FORMAT JSON VALUE FORMAT JSON", "UPSERT");
1726 let out = super::item_details(&sql).expect("ok");
1727 assert_eq!(out["format"], json!("json"));
1728 assert_eq!(out["key_format"], json!("json"));
1729 assert_eq!(out["value_format"], json!("json"));
1730 }
1731
1732 #[mz_ore::test]
1733 fn sink_kafka_key_value_mixed_is_composite() {
1734 let sql = kafka_sink_sql(Some("a"), "KEY FORMAT TEXT VALUE FORMAT BYTES", "UPSERT");
1735 let out = super::item_details(&sql).expect("ok");
1736 assert_eq!(out["format"], json!("key-text-value-bytes"));
1737 assert_eq!(out["key_format"], json!("text"));
1738 assert_eq!(out["value_format"], json!("bytes"));
1739 }
1740
1741 #[mz_ore::test]
1742 fn sink_kafka_key_format_without_key_is_dropped() {
1743 let sql = kafka_sink_sql(None, "KEY FORMAT JSON VALUE FORMAT TEXT", "DEBEZIUM");
1747 let out = super::item_details(&sql).expect("ok");
1748 assert_eq!(out["format"], json!("text"));
1749 assert_eq!(out["key_format"], serde_json::Value::Null);
1750 assert_eq!(out["value_format"], json!("text"));
1751 }
1752
1753 #[mz_ore::test]
1754 fn sink_kafka_missing_topic_errors() {
1755 let sql = "CREATE SINK \"materialize\".\"public\".\"snk\" \
1756 IN CLUSTER [u42] \
1757 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1758 INTO KAFKA CONNECTION [u10 AS \"materialize\".\"public\".\"k_conn\"] \
1759 FORMAT JSON ENVELOPE DEBEZIUM";
1760 let err = super::item_details(sql).unwrap_err();
1761 assert!(err.contains("missing TOPIC"), "wrong error message: {err}");
1762 }
1763
1764 #[mz_ore::test]
1765 fn sink_iceberg_upsert_mode() {
1766 let sql = iceberg_sink_sql("UPSERT");
1767 let out = super::item_details(&sql).expect("ok");
1768 assert_eq!(
1769 out,
1770 json!({
1771 "type": "sink",
1772 "sink_type": "iceberg",
1773 "cluster_id": "u42",
1774 "connection_id": "u20",
1776 "namespace": "ns",
1777 "table": "tbl",
1778 "envelope_type": "upsert",
1779 }),
1780 );
1781 }
1782
1783 #[mz_ore::test]
1784 fn sink_iceberg_append_mode() {
1785 let sql = iceberg_sink_sql("APPEND");
1786 let out = super::item_details(&sql).expect("ok");
1787 assert_eq!(out["envelope_type"], json!("append"));
1789 }
1790
1791 #[mz_ore::test]
1792 fn sink_iceberg_missing_table_errors() {
1793 let sql = "CREATE SINK \"materialize\".\"public\".\"ice\" \
1794 IN CLUSTER [u42] \
1795 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1796 INTO ICEBERG CATALOG CONNECTION [u20 AS \"materialize\".\"public\".\"cat_conn\"] \
1797 (NAMESPACE 'ns') MODE UPSERT";
1798 let err = super::item_details(sql).unwrap_err();
1799 assert!(err.contains("missing TABLE"), "wrong error message: {err}");
1800 }
1801
1802 #[mz_ore::test]
1803 #[cfg_attr(miri, ignore)] fn sink_arm_leaves_other_item_types_alone() {
1805 let sql = "CREATE VIEW \"materialize\".\"public\".\"v\" AS SELECT 1";
1806 let out = super::item_details(sql).expect("ok");
1807 assert_eq!(out, json!({ "type": "view", "definition": "SELECT 1;" }));
1808 }
1809
1810 fn item_type(sql: &str) -> Result<String, String> {
1814 match super::item_details(sql) {
1815 Ok(out) => match out {
1816 serde_json::Value::Object(mut m) => match m.remove("type") {
1817 Some(serde_json::Value::String(s)) => Ok(s),
1818 other => panic!("no string `type` key: {other:?}"),
1819 },
1820 other => panic!("not a JSON object: {other:?}"),
1821 },
1822 Err(msg) => Err(msg),
1823 }
1824 }
1825
1826 fn view_sql(query: &str) -> String {
1827 format!("CREATE VIEW \"materialize\".\"public\".\"v\" AS {query}")
1828 }
1829
1830 fn view_definition(query: &str) -> String {
1832 match super::item_details(&view_sql(query)).expect("ok") {
1833 serde_json::Value::Object(mut m) => match m.remove("definition") {
1834 Some(serde_json::Value::String(s)) => s,
1835 other => panic!("no string `definition` key: {other:?}"),
1836 },
1837 other => panic!("not a JSON object: {other:?}"),
1838 }
1839 }
1840
1841 #[mz_ore::test]
1847 #[cfg_attr(miri, ignore)] fn catalog_item_type_per_statement_kind() {
1849 let cases = [
1850 (
1851 "CREATE TABLE \"materialize\".\"public\".\"t\" (a int4)",
1852 "table",
1853 ),
1854 (
1857 "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1858 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1859 (REFERENCE = \"topic\") FORMAT TEXT",
1860 "table",
1861 ),
1862 (
1863 "CREATE TABLE \"materialize\".\"public\".\"wht\" FROM WEBHOOK BODY FORMAT JSON",
1864 "table",
1865 ),
1866 (
1867 "CREATE VIEW \"materialize\".\"public\".\"v\" AS SELECT 1",
1868 "view",
1869 ),
1870 (
1871 "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" \
1872 IN CLUSTER [u1] AS SELECT 1",
1873 "materialized-view",
1874 ),
1875 (
1876 "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1877 IN CLUSTER [u1] FROM LOAD GENERATOR COUNTER",
1878 "source",
1879 ),
1880 (
1881 "CREATE SOURCE \"materialize\".\"public\".\"wh\" \
1882 IN CLUSTER [u1] FROM WEBHOOK BODY FORMAT JSON",
1883 "source",
1884 ),
1885 (
1886 "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
1887 OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"]",
1888 "subsource",
1889 ),
1890 (
1891 "CREATE SUBSOURCE \"materialize\".\"public\".\"progress\" (id int4) \
1892 WITH (PROGRESS)",
1893 "subsource",
1894 ),
1895 (
1896 "CREATE SINK \"materialize\".\"public\".\"snk\" IN CLUSTER [u1] \
1897 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1898 INTO KAFKA CONNECTION [u2 AS \"materialize\".\"public\".\"c\"] \
1899 (TOPIC 'tp') FORMAT JSON ENVELOPE DEBEZIUM",
1900 "sink",
1901 ),
1902 (
1903 "CREATE INDEX \"i\" IN CLUSTER [u1] \
1904 ON [u1 AS \"materialize\".\"public\".\"t\"] (\"a\")",
1905 "index",
1906 ),
1907 (
1908 "CREATE TYPE \"materialize\".\"public\".\"ty\" AS LIST (ELEMENT TYPE = int4)",
1909 "type",
1910 ),
1911 (
1912 "CREATE SECRET \"materialize\".\"public\".\"s\" AS 'x'",
1913 "secret",
1914 ),
1915 (
1916 "CREATE CONNECTION \"materialize\".\"public\".\"c\" \
1917 TO KAFKA (BROKER 'b', SECURITY PROTOCOL PLAINTEXT)",
1918 "connection",
1919 ),
1920 ];
1921 for (sql, expected) in cases {
1922 assert_eq!(item_type(sql).as_deref(), Ok(expected), "for {sql}");
1923 }
1924 }
1925
1926 #[mz_ore::test]
1930 #[cfg_attr(miri, ignore)] fn catalog_view_definition() {
1932 assert_eq!(view_definition("SELECT 1"), "SELECT 1;");
1935 assert_eq!(
1936 view_definition("WITH c AS (SELECT 1 AS a) SELECT a FROM c"),
1937 "WITH \"c\" AS (SELECT 1 AS \"a\") SELECT \"a\" FROM \"c\";"
1938 );
1939 assert_eq!(
1940 view_definition("SELECT 1 UNION ALL SELECT 2"),
1941 "SELECT 1 UNION ALL SELECT 2;"
1942 );
1943 assert_eq!(
1944 view_definition("SELECT (SELECT max(a) FROM [u1 AS \"materialize\".\"public\".\"t\"])"),
1945 "SELECT (SELECT \"max\"(\"a\") FROM [u1 AS \"materialize\".\"public\".\"t\"]);"
1946 );
1947 assert_eq!(
1950 view_definition(
1951 "SELECT \"a b\", \"héllo\", \"q\"\"x\" \
1952 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1953 WHERE s = 'lit''eral' AND n = 42 ORDER BY 1"
1954 ),
1955 "SELECT \"a b\", \"héllo\", \"q\"\"x\" \
1956 FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1957 WHERE \"s\" = 'lit''eral' AND \"n\" = 42 ORDER BY 1;"
1958 );
1959 }
1960
1961 #[mz_ore::test]
1966 #[cfg_attr(miri, ignore)] fn catalog_view_definition_is_idempotent() {
1968 for query in [
1969 "SELECT 1",
1970 "WITH c AS (SELECT 1 AS a) SELECT a FROM c",
1971 "SELECT 1 UNION ALL SELECT 2",
1972 "SELECT \"a b\", \"q\"\"x\" FROM [u1 AS \"materialize\".\"public\".\"t\"] \
1973 WHERE s = 'lit''eral' ORDER BY 1",
1974 ] {
1975 let once = view_definition(query);
1976 assert_eq!(
1977 view_definition(&once),
1978 once,
1979 "not a fixed point for {query}"
1980 );
1981 }
1982 }
1983
1984 #[mz_ore::test]
1987 #[cfg_attr(miri, ignore)] fn catalog_table_source_id() {
1989 let from_source = super::item_details(
1990 "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1991 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1992 (REFERENCE = \"topic\") FORMAT TEXT",
1993 )
1994 .expect("ok");
1995 assert_eq!(from_source, json!({ "type": "table", "source_id": "u1" }));
1996
1997 for sql in [
1998 "CREATE TABLE \"materialize\".\"public\".\"t\" (a int4)",
1999 "CREATE TABLE \"materialize\".\"public\".\"wht\" FROM WEBHOOK BODY FORMAT JSON",
2000 ] {
2001 assert_eq!(
2002 super::item_details(sql).expect("ok"),
2003 json!({ "type": "table" }),
2004 "for {sql}"
2005 );
2006 }
2007 }
2008
2009 #[mz_ore::test]
2013 #[cfg_attr(miri, ignore)] fn catalog_create_sql_errors() {
2015 assert_eq!(
2016 item_type("this is not sql"),
2017 Err(
2018 "failed to parse create_sql: Expected a keyword at the beginning of a statement, \
2019 found identifier \"this\""
2020 .to_string()
2021 )
2022 );
2023 assert_eq!(
2024 item_type("CREATE TABLE t (a int4); CREATE TABLE u (b int4)"),
2025 Err("expected a single statement, found 2".to_string())
2026 );
2027 assert_eq!(
2030 item_type("SELECT 1"),
2031 Err("not a CREATE item statement".to_string())
2032 );
2033 assert_eq!(
2036 item_type(
2037 "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
2038 FROM SOURCE src (REFERENCE = \"topic\")"
2039 ),
2040 Err("unresolved item name".to_string())
2041 );
2042 }
2043
2044 #[mz_ore::test]
2047 #[cfg_attr(miri, ignore)] fn item_references_view() {
2049 let sql = "CREATE VIEW \"materialize\".\"public\".\"v\" AS \
2050 SELECT \"pg_catalog\".\"abs\"(\"a\"::[s20 AS \"pg_catalog\".\"int4\"]) \
2051 FROM [u1 AS \"materialize\".\"public\".\"t\"]";
2052 let out = super::item_references(sql).expect("ok");
2053 assert_eq!(
2054 out,
2055 json!({
2056 "ids": ["s20", "u1"],
2057 "named_funcs": [{"schema": "pg_catalog", "name": "abs"}],
2058 "named_types": [],
2059 "named_relations": [],
2060 })
2061 );
2062 }
2063
2064 #[mz_ore::test]
2065 #[cfg_attr(miri, ignore)] fn item_references_connection() {
2067 let sql = "CREATE CONNECTION \"materialize\".\"public\".\"kc\" TO KAFKA (\
2068 BROKER 'kafka:9092', \
2069 SSH TUNNEL [u5 AS \"materialize\".\"public\".\"ssh\"], \
2070 SASL PASSWORD = SECRET [u7 AS \"materialize\".\"public\".\"pw\"])";
2071 let out = super::item_references(sql).expect("ok");
2072 assert_eq!(out["ids"], json!(["u5", "u7"]));
2073 assert_eq!(out["named_funcs"], json!([]));
2074 }
2075
2076 #[mz_ore::test]
2077 #[cfg_attr(miri, ignore)] fn item_references_cte_shadowing() {
2079 let sql = "CREATE VIEW \"materialize\".\"public\".\"v\" AS \
2080 WITH \"c\" AS (SELECT * FROM [u1 AS \"materialize\".\"public\".\"t\"]) \
2081 SELECT * FROM \"c\"";
2082 let out = super::item_references(sql).expect("ok");
2083 assert_eq!(out["ids"], json!(["u1"]));
2084 assert_eq!(out["named_relations"], json!([]));
2085 }
2086
2087 #[mz_ore::test]
2088 #[cfg_attr(miri, ignore)] fn item_references_name_based_relation_surfaces() {
2090 let sql = "CREATE VIEW \"materialize\".\"public\".\"v\" AS \
2093 SELECT * FROM \"mz_catalog\".\"mz_tables\"";
2094 let out = super::item_references(sql).expect("ok");
2095 assert_eq!(
2096 out["named_relations"],
2097 json!([{"schema": "mz_catalog", "name": "mz_tables"}])
2098 );
2099 }
2100
2101 #[mz_ore::test]
2102 fn item_references_parse_error() {
2103 let err = super::item_references("NOT SQL").unwrap_err();
2104 assert!(
2105 err.contains("failed to parse"),
2106 "wrong error message: {err}"
2107 );
2108 }
2109
2110 #[mz_ore::test]
2111 fn catalog_retain_history_absent_is_omitted() {
2112 let sql = "CREATE TABLE \"materialize\".\"public\".\"t\" (\"a\" pg_catalog.int4)";
2113 let out = super::item_details(sql).expect("ok");
2114 assert_eq!(out.get("retain_history_millis"), None);
2115 }
2116
2117 #[mz_ore::test]
2118 fn catalog_retain_history_spellings() {
2119 for (option, millis) in [
2122 ("'1h'", 3_600_000u64),
2123 ("90", 90_000),
2124 ("INTERVAL '2' DAY", 172_800_000),
2125 ("INTERVAL '1:30' MINUTE TO SECOND", 90_000),
2126 ] {
2127 let sql = format!(
2128 "CREATE TABLE \"materialize\".\"public\".\"t\" (\"a\" pg_catalog.int4) \
2129 WITH (RETAIN HISTORY = FOR {option})"
2130 );
2131 let out = super::item_details(&sql).expect("ok");
2132 assert_eq!(out["retain_history_millis"], json!(millis), "{option}");
2133 }
2134 }
2135
2136 #[mz_ore::test]
2137 fn catalog_retain_history_zero_disables_compaction() {
2138 for option in ["'0'", "0", "NULL"] {
2139 let sql = format!(
2140 "CREATE TABLE \"materialize\".\"public\".\"t\" (\"a\" pg_catalog.int4) \
2141 WITH (RETAIN HISTORY = FOR {option})"
2142 );
2143 let out = super::item_details(&sql).expect("ok");
2144 assert_eq!(out["retain_history_millis"], json!(u64::MAX), "{option}");
2145 }
2146 }
2147
2148 #[mz_ore::test]
2149 fn catalog_retain_history_on_each_item_kind() {
2150 let cases = [
2151 (
2152 "CREATE SOURCE \"materialize\".\"public\".\"s\" IN CLUSTER [u1] \
2153 FROM LOAD GENERATOR COUNTER WITH (RETAIN HISTORY = FOR '2h')",
2154 7_200_000u64,
2155 ),
2156 (
2157 "CREATE INDEX \"t_idx\" IN CLUSTER [u1] \
2158 ON [u2 AS \"materialize\".\"public\".\"t\"] (\"a\") \
2159 WITH (RETAIN HISTORY = FOR '3h')",
2160 10_800_000,
2161 ),
2162 (
2163 "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" IN CLUSTER [u1] \
2164 WITH (RETAIN HISTORY = FOR '4h', REFRESH = ON COMMIT) AS SELECT 1",
2165 14_400_000,
2166 ),
2167 (
2168 "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
2169 OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
2170 WITH (EXTERNAL REFERENCE = \"db\".\"public\".\"t\", RETAIN HISTORY = FOR '5h')",
2171 18_000_000,
2172 ),
2173 (
2174 "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
2175 FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
2176 (REFERENCE = \"db\".\"public\".\"t\") WITH (RETAIN HISTORY = FOR '6h')",
2177 21_600_000,
2178 ),
2179 ];
2180 for (sql, millis) in cases {
2181 let out = super::item_details(sql).expect("ok");
2182 assert_eq!(out["retain_history_millis"], json!(millis), "{sql}");
2183 }
2184 }
2185
2186 const MZ_TIMESTAMP: &str = "[s1 AS \"mz_catalog\".\"mz_timestamp\"]";
2187
2188 #[mz_ore::test]
2189 fn catalog_refresh_omitted_means_on_commit() {
2190 let sql = "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" IN CLUSTER [u1] \
2191 AS SELECT 1";
2192 let out = super::item_details(sql).expect("ok");
2193 assert_eq!(out["refresh"], json!([{"type": "on-commit"}]));
2194 }
2195
2196 #[mz_ore::test]
2197 fn catalog_refresh_one_entry_per_option() {
2198 let sql = format!(
2199 "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" IN CLUSTER [u1] \
2200 WITH (REFRESH = ON COMMIT, \
2201 REFRESH = AT 32472144000000::{MZ_TIMESTAMP}, \
2202 REFRESH = EVERY '1 day' ALIGNED TO 946684800000::{MZ_TIMESTAMP}, \
2203 REFRESH = EVERY '90 minutes' ALIGNED TO '2000-01-01 00:00:00+00') \
2204 AS SELECT 1"
2205 );
2206 let out = super::item_details(&sql).expect("ok");
2207 assert_eq!(
2208 out["refresh"],
2209 json!([
2210 {"type": "on-commit"},
2211 {"type": "at", "at": format!("32472144000000::{MZ_TIMESTAMP}")},
2212 {
2213 "type": "every",
2214 "interval": "24:00:00",
2215 "aligned_to": format!("946684800000::{MZ_TIMESTAMP}"),
2216 },
2217 {
2218 "type": "every",
2219 "interval": "01:30:00",
2220 "aligned_to": "'2000-01-01 00:00:00+00'",
2221 },
2222 ])
2223 );
2224 }
2225
2226 #[mz_ore::test]
2227 fn catalog_refresh_times_read_as_written() {
2228 let sql = format!(
2229 "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" IN CLUSTER [u1] \
2230 WITH (REFRESH = AT ((1790696789484::{MZ_TIMESTAMP})), \
2231 REFRESH = AT 946684800000::{MZ_TIMESTAMP}::[s46 AS \"pg_catalog\".\"text\"]::[s22 AS \"pg_catalog\".\"int8\"] + 2000, \
2232 REFRESH = EVERY '1 day' ALIGNED TO '2000-01-01 00:00:00+00'::[s52 AS \"pg_catalog\".\"timestamptz\"] + INTERVAL '1 hour') \
2233 AS SELECT 1"
2234 );
2235 let out = super::item_details(&sql).expect("ok");
2236 assert_eq!(
2237 out["refresh"],
2238 json!([
2239 {"type": "at", "at": format!("((1790696789484::{MZ_TIMESTAMP}))")},
2240 {
2241 "type": "at",
2242 "at": format!(
2243 "946684800000::{MZ_TIMESTAMP}::[s46 AS \"pg_catalog\".\"text\"]::[s22 AS \"pg_catalog\".\"int8\"] + 2000"
2244 ),
2245 },
2246 {
2247 "type": "every",
2248 "interval": "24:00:00",
2249 "aligned_to": "'2000-01-01 00:00:00+00'::[s52 AS \"pg_catalog\".\"timestamptz\"] + INTERVAL '1 hour'",
2250 },
2251 ])
2252 );
2253 }
2254
2255 #[mz_ore::test]
2256 fn catalog_replacement_target() {
2257 let mv_sql_without_replacement = "CREATE MATERIALIZED VIEW \"materialize\".\"public\".\"mv\" IN CLUSTER [u1] \
2258 AS SELECT 1";
2259 let out = super::item_details(mv_sql_without_replacement).expect("ok");
2260 assert_eq!(out.get("replacement_target"), None);
2261
2262 let mv_sql_with_replacement = "CREATE REPLACEMENT MATERIALIZED VIEW \"materialize\".\"public\".\"rp\" \
2263 FOR [u7 AS \"materialize\".\"public\".\"mv\"] IN CLUSTER [u1] \
2264 WITH (REFRESH = ON COMMIT) AS SELECT 1";
2265 let out = super::item_details(mv_sql_with_replacement).expect("ok");
2266 assert_eq!(out["replacement_target"], json!("u7"));
2267 }
2268}