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, 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
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 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
155fn 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 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 if refresh.is_empty() {
197 refresh.push(json!({"type": "on-commit"}));
198 }
199 info.insert("refresh", json!(refresh));
200 Ok(())
201}
202
203pub 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 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 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 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 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 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 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 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 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 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 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
639pub 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#[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
685pub 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
729pub 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
778pub fn source_export_details(a: &str) -> Result<serde_json::Value, String> {
813 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 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
935pub fn connection_details(a: &str) -> Result<serde_json::Value, String> {
980 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 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 "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 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 #[mz_ore::test]
1121 #[cfg_attr(miri, ignore)] 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 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)] 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 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 #[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 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 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 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 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 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 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)] fn export_table_kafka_bare_avro_seed_with_key() {
1344 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)] fn export_table_kafka_bare_avro_seed_without_key() {
1369 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 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)] 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 #[mz_ore::test]
1439 fn connection_kafka_single_broker_default_progress() {
1440 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 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 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 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 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 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 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 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)] 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 #[mz_ore::test]
1630 fn catalog_kafka_old_syntax_omitted_envelope_defaults_none() {
1631 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 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 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 const AVRO_FORMAT: &str = "FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION \
1679 [u12 AS \"materialize\".\"public\".\"csr_conn\"]";
1680
1681 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 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 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 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 "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 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)] 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 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 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_ore::test]
1882 #[cfg_attr(miri, ignore)] fn catalog_item_type_per_statement_kind() {
1884 let cases = [
1885 (
1886 "CREATE TABLE \"materialize\".\"public\".\"t\" (a int4)",
1887 "table",
1888 ),
1889 (
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_ore::test]
1965 #[cfg_attr(miri, ignore)] fn catalog_view_definition() {
1967 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 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 #[mz_ore::test]
2001 #[cfg_attr(miri, ignore)] 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_ore::test]
2022 #[cfg_attr(miri, ignore)] 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 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 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 #[mz_ore::test]
2090 #[cfg_attr(miri, ignore)] 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 assert_eq!(
2107 item_type("SELECT 1"),
2108 Err("not a CREATE item statement".to_string())
2109 );
2110 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 #[mz_ore::test]
2124 #[cfg_attr(miri, ignore)] 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)] 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)] 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)] fn item_references_name_based_relation_surfaces() {
2167 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 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}