Skip to main content

mz_expr/scalar/func/impls/
jsonb.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use mz_expr_derive::sqlfunc;
14use mz_repr::adt::jsonb::{Jsonb, JsonbRef};
15use mz_repr::adt::mz_acl_item::{AclMode, MzAclItem};
16use mz_repr::adt::numeric::{self, Numeric, NumericMaxScale};
17use mz_repr::role_id::RoleId;
18use mz_repr::{ArrayRustType, Datum, Row, RowPacker, SqlColumnType, SqlScalarType, strconv};
19use mz_sql_parser::ast::display::AstDisplay;
20use mz_sql_parser::ast::{
21    AstInfo, AvroSchema, CreateSubsourceOptionName, Format, FormatSpecifier,
22    KafkaSourceConfigOptionName, PgConfigOptionName, ProtobufSchema, RawClusterName, RawItemName,
23    SourceEnvelope, SourceErrorPolicy, Value, WithOptionValue,
24};
25use prost::Message as _;
26use serde::{Deserialize, Serialize};
27use serde_json::json;
28
29use crate::EvalError;
30use crate::scalar::func::EagerUnaryFunc;
31use crate::scalar::func::impls::numeric::*;
32
33#[sqlfunc(
34    sqlname = "jsonb_to_text",
35    preserves_uniqueness = false,
36    inverse = to_unary!(super::CastStringToJsonb)
37)]
38pub fn cast_jsonb_to_string<'a>(a: JsonbRef<'a>) -> String {
39    let mut buf = String::new();
40    strconv::format_jsonb(&mut buf, a);
41    buf
42}
43
44#[sqlfunc(sqlname = "jsonb_to_smallint", is_monotone = true)]
45fn cast_jsonb_to_int16<'a>(a: JsonbRef<'a>) -> Result<i16, EvalError> {
46    match a.into_datum() {
47        Datum::Numeric(a) => cast_numeric_to_int16(a.into_inner()),
48        datum => Err(EvalError::InvalidJsonbCast {
49            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
50            to: "smallint".into(),
51        }),
52    }
53}
54
55#[sqlfunc(sqlname = "jsonb_to_integer", is_monotone = true)]
56fn cast_jsonb_to_int32<'a>(a: JsonbRef<'a>) -> Result<i32, EvalError> {
57    match a.into_datum() {
58        Datum::Numeric(a) => cast_numeric_to_int32(a.into_inner()),
59        datum => Err(EvalError::InvalidJsonbCast {
60            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
61            to: "integer".into(),
62        }),
63    }
64}
65
66#[sqlfunc(sqlname = "jsonb_to_bigint", is_monotone = true)]
67fn cast_jsonb_to_int64<'a>(a: JsonbRef<'a>) -> Result<i64, EvalError> {
68    match a.into_datum() {
69        Datum::Numeric(a) => cast_numeric_to_int64(a.into_inner()),
70        datum => Err(EvalError::InvalidJsonbCast {
71            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
72            to: "bigint".into(),
73        }),
74    }
75}
76
77#[sqlfunc(sqlname = "jsonb_to_real", is_monotone = true)]
78fn cast_jsonb_to_float32<'a>(a: JsonbRef<'a>) -> Result<f32, EvalError> {
79    match a.into_datum() {
80        Datum::Numeric(a) => cast_numeric_to_float32(a.into_inner()),
81        datum => Err(EvalError::InvalidJsonbCast {
82            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
83            to: "real".into(),
84        }),
85    }
86}
87
88#[sqlfunc(sqlname = "jsonb_to_double", is_monotone = true)]
89fn cast_jsonb_to_float64<'a>(a: JsonbRef<'a>) -> Result<f64, EvalError> {
90    match a.into_datum() {
91        Datum::Numeric(a) => cast_numeric_to_float64(a.into_inner()),
92        datum => Err(EvalError::InvalidJsonbCast {
93            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
94            to: "double precision".into(),
95        }),
96    }
97}
98
99#[derive(
100    Ord,
101    PartialOrd,
102    Clone,
103    Debug,
104    Eq,
105    PartialEq,
106    Serialize,
107    Deserialize,
108    Hash
109)]
110pub struct CastJsonbToNumeric(pub Option<NumericMaxScale>);
111
112impl EagerUnaryFunc for CastJsonbToNumeric {
113    type Input<'a> = JsonbRef<'a>;
114    type Output<'a> = Result<Numeric, EvalError>;
115
116    fn call<'a>(&self, a: Self::Input<'a>) -> Self::Output<'a> {
117        match a.into_datum() {
118            Datum::Numeric(mut num) => match self.0 {
119                None => Ok(num.into_inner()),
120                Some(scale) => {
121                    if numeric::rescale(&mut num.0, scale.into_u8()).is_err() {
122                        return Err(EvalError::NumericFieldOverflow);
123                    };
124                    Ok(num.into_inner())
125                }
126            },
127            datum => Err(EvalError::InvalidJsonbCast {
128                from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
129                to: "numeric".into(),
130            }),
131        }
132    }
133
134    fn output_sql_type(&self, input: SqlColumnType) -> SqlColumnType {
135        SqlScalarType::Numeric { max_scale: self.0 }.nullable(input.nullable)
136    }
137
138    fn is_monotone(&self) -> bool {
139        true
140    }
141}
142
143impl fmt::Display for CastJsonbToNumeric {
144    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
145        f.write_str("jsonb_to_numeric")
146    }
147}
148
149#[sqlfunc(sqlname = "jsonb_to_boolean", is_monotone = true)]
150fn cast_jsonb_to_bool<'a>(a: JsonbRef<'a>) -> Result<bool, EvalError> {
151    match a.into_datum() {
152        Datum::True => Ok(true),
153        Datum::False => Ok(false),
154        datum => Err(EvalError::InvalidJsonbCast {
155            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
156            to: "boolean".into(),
157        }),
158    }
159}
160
161#[sqlfunc(sqlname = "jsonbable_to_jsonb")]
162fn cast_jsonbable_to_jsonb<'a>(a: JsonbRef<'a>) -> JsonbRef<'a> {
163    match a.into_datum() {
164        Datum::Numeric(n) => {
165            let n = n.into_inner();
166            let datum = if n.is_finite() {
167                Datum::from(n)
168            } else if n.is_nan() {
169                Datum::String("NaN")
170            } else if n.is_negative() {
171                Datum::String("-Infinity")
172            } else {
173                Datum::String("Infinity")
174            };
175            JsonbRef::from_datum(datum)
176        }
177        datum => JsonbRef::from_datum(datum),
178    }
179}
180
181#[sqlfunc]
182fn jsonb_array_length<'a>(a: JsonbRef<'a>) -> Result<Option<i32>, EvalError> {
183    match a.into_datum() {
184        Datum::List(list) => {
185            let count = list.iter().count();
186            match i32::try_from(count) {
187                Ok(len) => Ok(Some(len)),
188                Err(_) => Err(EvalError::Int32OutOfRange(count.to_string().into())),
189            }
190        }
191        _ => Ok(None),
192    }
193}
194
195#[sqlfunc]
196fn jsonb_typeof<'a>(a: JsonbRef<'a>) -> &'a str {
197    match a.into_datum() {
198        Datum::Map(_) => "object",
199        Datum::List(_) => "array",
200        Datum::String(_) => "string",
201        Datum::Numeric(_) => "number",
202        Datum::True | Datum::False => "boolean",
203        Datum::JsonNull => "null",
204        d => panic!("Not jsonb: {:?}", d),
205    }
206}
207
208#[sqlfunc]
209fn jsonb_strip_nulls<'a>(a: JsonbRef<'a>) -> Jsonb {
210    fn strip_nulls(a: Datum, row: &mut RowPacker) {
211        match a {
212            Datum::Map(dict) => row.push_dict_with(|row| {
213                for (k, v) in dict.iter() {
214                    match v {
215                        Datum::JsonNull => (),
216                        _ => {
217                            row.push(Datum::String(k));
218                            strip_nulls(v, row);
219                        }
220                    }
221                }
222            }),
223            Datum::List(list) => row.push_list_with(|row| {
224                for elem in list.iter() {
225                    strip_nulls(elem, row);
226                }
227            }),
228            _ => row.push(a),
229        }
230    }
231    let mut row = Row::default();
232    strip_nulls(a.into_datum(), &mut row.packer());
233    Jsonb::from_row(row)
234}
235
236#[sqlfunc]
237fn jsonb_pretty<'a>(a: JsonbRef<'a>) -> String {
238    let mut buf = String::new();
239    strconv::format_jsonb_pretty(&mut buf, a);
240    buf
241}
242
243/// Converts a JSONB `Datum` into a `u64`.
244fn jsonb_datum_to_u64<'a>(d: Datum<'a>) -> Result<u64, String> {
245    let Datum::Numeric(n) = d else {
246        return Err("expected numeric value".into());
247    };
248
249    let mut cx = numeric::cx_datum();
250    cx.try_into_u64(n.0)
251        .map_err(|_| format!("number out of u64 range: {n}"))
252}
253
254/// Decodes a JSONB object of shape `{"bitflags": <u64>}` into an `AclMode`.
255///
256/// Shared decoder for `parse_catalog_privileges` (which embeds the object as
257/// the `acl_mode` field of each privilege) and `parse_catalog_acl_mode` (which
258/// receives the object at the top level).
259fn jsonb_datum_to_acl_mode(d: Datum) -> Result<AclMode, String> {
260    let Datum::Map(dict) = d else {
261        return Err(format!("unexpected acl_mode: {d}"));
262    };
263    let mut bits = None;
264    for (key, val) in dict.iter() {
265        match key {
266            "bitflags" => bits = Some(jsonb_datum_to_u64(val)?),
267            other => return Err(format!("unexpected acl_mode field: {other}")),
268        }
269    }
270    let bits = bits.ok_or_else(|| "missing acl_mode bitflags".to_string())?;
271    AclMode::from_bits(bits).ok_or_else(|| format!("invalid acl_mode bitflags: {bits}"))
272}
273
274/// Converts a JSONB `Datum` into a `RoleId`.
275fn jsonb_datum_to_role_id(d: Datum) -> Result<RoleId, String> {
276    match d {
277        Datum::String("Public") => Ok(RoleId::Public),
278        Datum::String(other) => Err(format!("unexpected role ID variant: {other}")),
279        Datum::Map(dict) => {
280            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
281            let n = jsonb_datum_to_u64(val)?;
282            match key {
283                "User" => Ok(RoleId::User(n)),
284                "System" => Ok(RoleId::System(n)),
285                "Predefined" => Ok(RoleId::Predefined(n)),
286                other => Err(format!("unexpected role ID variant: {other}")),
287            }
288        }
289        _ => Err("expected string or object".into()),
290    }
291}
292
293/// Converts a catalog JSON-serialized ID value into the appropriate string format.
294///
295/// Supports all of Materialize's various ID types of the form `<prefix><u64>`.
296#[sqlfunc]
297fn parse_catalog_id<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
298    let parse = || match a.into_datum() {
299        // Unit variant, e.g. "Public"
300        Datum::String(variant) => match variant {
301            "Explain" => Ok("e".to_string()),
302            "Public" => Ok("p".to_string()),
303            other => Err(format!("unexpected ID variant: {other}")),
304        },
305        // Newtype variant, e.g. {"User": 1}
306        Datum::Map(dict) => {
307            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
308            let prefix = match key {
309                "IntrospectionSourceIndex" => "si",
310                "Predefined" => "g",
311                "System" => "s",
312                "Transient" => "t",
313                "User" => "u",
314                other => return Err(format!("unexpected ID variant: {other}")),
315            };
316            let n = jsonb_datum_to_u64(val)?;
317            Ok(format!("{prefix}{n}"))
318        }
319        _ => Err("expected string or object".into()),
320    };
321
322    parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))
323}
324
325/// Converts a catalog JSON-serialized privilege array into an `mz_aclitem[]`.
326#[sqlfunc]
327fn parse_catalog_privileges<'a>(a: JsonbRef<'a>) -> Result<ArrayRustType<MzAclItem>, EvalError> {
328    let parse_one = |datum| match datum {
329        Datum::Map(dict) => {
330            let mut grantee = None;
331            let mut grantor = None;
332            let mut acl_mode = None;
333            for (key, val) in dict.iter() {
334                match key {
335                    "grantee" => {
336                        let id = jsonb_datum_to_role_id(val)?;
337                        grantee = Some(id);
338                    }
339                    "grantor" => {
340                        let id = jsonb_datum_to_role_id(val)?;
341                        grantor = Some(id);
342                    }
343                    "acl_mode" => {
344                        acl_mode = Some(jsonb_datum_to_acl_mode(val)?);
345                    }
346                    other => return Err(format!("unexpected privilege field: {other}")),
347                }
348            }
349            Ok(MzAclItem {
350                grantee: grantee.ok_or_else(|| format!("missing grantee: {dict:?}"))?,
351                grantor: grantor.ok_or_else(|| "missing grantor in privilege".to_string())?,
352                acl_mode: acl_mode.ok_or_else(|| "missing acl_mode in privilege".to_string())?,
353            })
354        }
355        other => Err(format!("expected object in array, found: {other}")),
356    };
357
358    let parse = || match a.into_datum() {
359        Datum::List(list) => {
360            let mut result = Vec::new();
361            for item in list.iter() {
362                result.push(parse_one(item)?);
363            }
364            Ok(result)
365        }
366        _ => Err("expected array".to_string()),
367    };
368
369    parse()
370        .map(ArrayRustType)
371        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
372}
373
374/// Converts a catalog JSON-serialized `AclMode` bitflags object into a
375/// PostgreSQL ACL char-code string (e.g. `{"bitflags": 514}` → `"ar"`).
376#[sqlfunc]
377fn parse_catalog_acl_mode<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
378    jsonb_datum_to_acl_mode(a.into_datum())
379        .map(|mode| mode.to_string())
380        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
381}
382
383/// Parses a catalog `create_sql` string into a JSONB object.
384///
385/// The returned JSONB does not fully reflect the parsed SQL and instead contains only fields
386/// required by current callers.
387///
388// TODO: This function isn't parsing JSONB and therefore shouldn't live in the `jsonb` module.
389//       Consider moving all the `parse_catalog_*` functions into their own module.
390#[sqlfunc]
391fn parse_catalog_create_sql<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
392    fn get_cluster_id(in_cluster: RawClusterName) -> Result<String, &'static str> {
393        match in_cluster {
394            RawClusterName::Resolved(s) => Ok(s),
395            RawClusterName::Unresolved(_) => Err("unresolved cluster name"),
396        }
397    }
398
399    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
400        match item {
401            RawItemName::Id(id, _, _) => Ok(id),
402            RawItemName::Name(_) => Err("unresolved item name"),
403        }
404    }
405
406    fn format_name<T: AstInfo>(fmt: &Format<T>) -> &'static str {
407        match fmt {
408            Format::Bytes => "bytes",
409            Format::Avro(_) => "avro",
410            Format::Protobuf(_) => "protobuf",
411            Format::Regex(_) => "regex",
412            Format::Csv { .. } => "csv",
413            Format::Json { .. } => "json",
414            Format::Text => "text",
415        }
416    }
417
418    let parse = || -> Result<serde_json::Value, String> {
419        let mut stmts = mz_sql_parser::parser::parse_statements(a)
420            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
421        let stmt = match stmts.len() {
422            1 => stmts.remove(0).ast,
423            n => return Err(format!("expected a single statement, found {n}")),
424        };
425
426        let mut info = BTreeMap::<&str, serde_json::Value>::new();
427
428        use mz_sql_parser::ast::Statement::*;
429        let item_type = match stmt {
430            CreateSecret(_) => "secret",
431            CreateConnection(stmt) => {
432                let connection_type = stmt.connection_type.as_str();
433                info.insert("connection_type", json!(connection_type));
434
435                "connection"
436            }
437            CreateView(_) => "view",
438            CreateMaterializedView(stmt) => {
439                let Some(in_cluster) = stmt.in_cluster else {
440                    return Err("missing IN CLUSTER".into());
441                };
442                let cluster_id = match in_cluster {
443                    RawClusterName::Unresolved(ident) => ident.into_string(),
444                    RawClusterName::Resolved(s) => s,
445                };
446                info.insert("cluster_id", json!(cluster_id));
447
448                let mut definition = stmt.query.to_ast_string_stable();
449                definition.push(';');
450                info.insert("definition", json!(definition));
451
452                "materialized-view"
453            }
454            CreateTable(_) | CreateTableFromSource(_) => "table",
455            CreateSource(stmt) => {
456                let Some(in_cluster) = stmt.in_cluster else {
457                    return Err("missing IN CLUSTER".into());
458                };
459                let cluster_id = get_cluster_id(in_cluster)?;
460                info.insert("cluster_id", json!(cluster_id));
461
462                use mz_sql_parser::ast::CreateSourceConnection::*;
463                let (source_type, connection) = match stmt.connection {
464                    Kafka { connection, .. } => ("kafka", Some(connection)),
465                    Postgres { connection, .. } => ("postgres", Some(connection)),
466                    MySql { connection, .. } => ("mysql", Some(connection)),
467                    SqlServer { connection, .. } => ("sql-server", Some(connection)),
468                    LoadGenerator { .. } => ("load-generator", None),
469                };
470                info.insert("source_type", json!(source_type));
471                if let Some(conn) = connection {
472                    let conn_id = get_item_id(conn)?;
473                    info.insert("connection_id", json!(conn_id));
474                }
475
476                let is_debezium = matches!(
477                    stmt.envelope,
478                    Some(mz_sql_parser::ast::SourceEnvelope::Debezium)
479                );
480
481                if let Some(envelope) = stmt.envelope {
482                    use mz_sql_parser::ast::SourceEnvelope::*;
483                    let envelope_type = match envelope {
484                        None => "none",
485                        Debezium => "debezium",
486                        Upsert { .. } => "upsert",
487                        CdcV2 => "materialize",
488                    };
489                    info.insert("envelope_type", json!(envelope_type));
490                }
491
492                if let Some(format_spec) = stmt.format {
493                    match &format_spec {
494                        FormatSpecifier::Bare(fmt) => {
495                            // Debezium sources with a single format spec implicitly use
496                            // the same format for both key and value.
497                            if is_debezium {
498                                info.insert("key_format", json!(format_name(fmt)));
499                            }
500                            info.insert("value_format", json!(format_name(fmt)));
501                        }
502                        FormatSpecifier::KeyValue { key, value } => {
503                            info.insert("key_format", json!(format_name(key)));
504                            info.insert("value_format", json!(format_name(value)));
505                        }
506                    }
507                }
508
509                "source"
510            }
511            CreateWebhookSource(stmt) => {
512                if stmt.is_table {
513                    "table"
514                } else {
515                    info.insert("source_type", json!("webhook"));
516                    if let Some(in_cluster) = stmt.in_cluster {
517                        let cluster_id = get_cluster_id(in_cluster)?;
518                        info.insert("cluster_id", json!(cluster_id));
519                    }
520                    "source"
521                }
522            }
523            CreateSubsource(stmt) => {
524                use mz_sql_parser::ast::CreateSubsourceOptionName;
525                let is_progress = stmt
526                    .with_options
527                    .iter()
528                    .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
529                let source_type = if is_progress { "progress" } else { "subsource" };
530                info.insert("source_type", json!(source_type));
531
532                if let Some(of_source) = stmt.of_source {
533                    let of_source_id = get_item_id(of_source)?;
534                    info.insert("of_source_id", json!(of_source_id));
535                }
536
537                "subsource"
538            }
539            CreateSink(_) => "sink",
540            CreateIndex(stmt) => {
541                let Some(in_cluster) = stmt.in_cluster else {
542                    return Err("missing IN CLUSTER".into());
543                };
544                let cluster_id = get_cluster_id(in_cluster)?;
545                info.insert("cluster_id", json!(cluster_id));
546                let on_id = get_item_id(stmt.on_name)?;
547                info.insert("on_id", json!(on_id));
548                "index"
549            }
550            CreateType(_) => "type",
551            _ => return Err("not a CREATE item statement".into()),
552        };
553        info.insert("type", json!(item_type));
554
555        let info = info.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
556        Ok(info)
557    };
558
559    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
560    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
561    Ok(jsonb)
562}
563
564/// Minimal decoder for `ProtoPostgresSourcePublicationDetails`. The
565/// canonical proto lives in `mz-storage-types`, which depends on
566/// `mz-expr`, so we redeclare the two tags we read here. Upstream tag
567/// renumbers slip past silently. The `mz_postgres_sources` lockdown
568/// SLTs catch them.
569#[derive(Clone, PartialEq, ::prost::Message)]
570struct PostgresPublicationDetailsSubset {
571    #[prost(string, tag = "2")]
572    slot: String,
573    #[prost(uint64, optional, tag = "3")]
574    timeline_id: Option<u64>,
575}
576
577/// Extracts postgres source publication details (slot, timeline_id) from a
578/// catalog `create_sql`. Returns:
579///
580/// - jsonb `{"slot": <text>, "timeline_id": <u64 | null>}` for
581///   `CREATE SOURCE ... FROM POSTGRES CONNECTION ... (DETAILS = ...)` statements.
582/// - jsonb `null` for any other statement.
583///
584/// Errors if the statement fails to parse, is a postgres source without
585/// a `DETAILS` option, or if the `DETAILS` value can't be hex- and
586/// proto-decoded.
587#[sqlfunc]
588fn parse_postgres_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
589    let parse = || -> Result<serde_json::Value, String> {
590        let mut stmts = mz_sql_parser::parser::parse_statements(a)
591            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
592        let stmt = match stmts.len() {
593            1 => stmts.remove(0).ast,
594            n => return Err(format!("expected a single statement, found {n}")),
595        };
596
597        use mz_sql_parser::ast::CreateSourceConnection;
598        use mz_sql_parser::ast::Statement::CreateSource;
599        let options = match stmt {
600            CreateSource(stmt) => match stmt.connection {
601                CreateSourceConnection::Postgres { options, .. } => options,
602                _ => return Ok(serde_json::Value::Null),
603            },
604            _ => return Ok(serde_json::Value::Null),
605        };
606
607        let details_hex = options
608            .into_iter()
609            .find(|opt| opt.name == PgConfigOptionName::Details)
610            .and_then(|opt| match opt.value {
611                Some(WithOptionValue::Value(Value::String(s))) => Some(s),
612                _ => None,
613            })
614            .ok_or("missing DETAILS option on postgres source")?;
615
616        let details_bytes =
617            hex::decode(&details_hex).map_err(|e| format!("DETAILS is not valid hex: {e}"))?;
618
619        let details = PostgresPublicationDetailsSubset::decode(&*details_bytes)
620            .map_err(|e| format!("DETAILS is not a valid publication-details proto: {e}"))?;
621
622        Ok(json!({
623            "slot": details.slot,
624            "timeline_id": details.timeline_id,
625        }))
626    };
627
628    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
629    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
630    Ok(jsonb)
631}
632
633/// Extracts kafka source configuration (topic, group id prefix, connection
634/// id) from a catalog `create_sql`. Returns:
635///
636/// - jsonb `{"topic": <text>, "group_id_prefix": <text | null>, "connection_id": <text>}`
637///   for `CREATE SOURCE ... FROM KAFKA CONNECTION ... (TOPIC = ..., [GROUP ID PREFIX = ...])`
638///   statements.
639/// - jsonb `null` for any other statement.
640///
641/// Errors if the statement fails to parse, is a kafka source without a
642/// `TOPIC` option, or references an unresolved connection name (i.e. one
643/// that hasn't been through purification).
644#[sqlfunc]
645fn parse_kafka_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
646    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
647        match item {
648            RawItemName::Id(id, _, _) => Ok(id),
649            RawItemName::Name(_) => Err("unresolved item name"),
650        }
651    }
652
653    let parse = || -> Result<serde_json::Value, String> {
654        let mut stmts = mz_sql_parser::parser::parse_statements(a)
655            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
656        let stmt = match stmts.len() {
657            1 => stmts.remove(0).ast,
658            n => return Err(format!("expected a single statement, found {n}")),
659        };
660
661        use mz_sql_parser::ast::CreateSourceConnection;
662        use mz_sql_parser::ast::Statement::CreateSource;
663        let (connection, options) = match stmt {
664            CreateSource(stmt) => match stmt.connection {
665                CreateSourceConnection::Kafka {
666                    connection,
667                    options,
668                } => (connection, options),
669                _ => return Ok(serde_json::Value::Null),
670            },
671            _ => return Ok(serde_json::Value::Null),
672        };
673
674        let connection_id = get_item_id(connection)?;
675
676        let mut topic: Option<String> = None;
677        let mut group_id_prefix: Option<String> = None;
678        for opt in options {
679            let string_value = match opt.value {
680                Some(WithOptionValue::Value(Value::String(s))) => Some(s),
681                _ => None,
682            };
683            match opt.name {
684                KafkaSourceConfigOptionName::Topic => topic = string_value,
685                KafkaSourceConfigOptionName::GroupIdPrefix => group_id_prefix = string_value,
686                _ => {}
687            }
688        }
689
690        let topic = topic.ok_or("missing TOPIC option on kafka source")?;
691
692        Ok(json!({
693            "topic": topic,
694            "group_id_prefix": group_id_prefix,
695            "connection_id": connection_id,
696        }))
697    };
698
699    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
700    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
701    Ok(jsonb)
702}
703
704/// Extracts source-export (source table) metadata from a catalog `create_sql`.
705///
706/// Returns, for a `CREATE TABLE ... FROM SOURCE` or a non-progress
707/// `CREATE SUBSOURCE ... OF SOURCE ...` statement:
708///
709/// ```json
710/// {
711///   "source_id": "<parent source item id>",
712///   "external_reference": ["part1", "part2", ...],
713///   "envelope_type": <text | null>,
714///   "key_format": <text | null>,
715///   "value_format": <text | null>
716/// }
717/// ```
718///
719/// `envelope_type`, `key_format`, and `value_format` are always null for a
720/// `CREATE SUBSOURCE` (the postgres/mysql/sql-server exports that use the old
721/// subsource syntax carry neither format nor envelope). They may also be null
722/// for a `CREATE TABLE ... FROM SOURCE` that omits FORMAT/ENVELOPE.
723///
724/// Returns jsonb `null` for progress subsources and for any statement that is
725/// not a source export. The caller distinguishes the four source-table views
726/// by joining `source_id` against `mz_sources` and filtering on the parent's
727/// type, so this helper stays connection-type agnostic.
728///
729/// Errors if the statement fails to parse, references an unresolved item name,
730/// or is a non-progress subsource missing its OF SOURCE or EXTERNAL REFERENCE.
731///
732/// The `key_format`/`value_format` derivation mirrors the runtime
733/// `DataSourceDesc::formats()` that the removed `pack_kafka_source_tables_update`
734/// packer read. A bare FORMAT only carries a key when it resolves to an
735/// encoding that has one, which among bare formats is only Avro or Protobuf
736/// read from a Confluent Schema Registry whose purified seed carries a key
737/// schema. A KEY FORMAT ... VALUE FORMAT ... spec always carries both.
738#[sqlfunc]
739fn parse_source_export_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
740    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
741        match item {
742            RawItemName::Id(id, _, _) => Ok(id),
743            RawItemName::Name(_) => Err("unresolved item name"),
744        }
745    }
746
747    fn format_name<T: AstInfo>(fmt: &Format<T>) -> &'static str {
748        match fmt {
749            Format::Bytes => "bytes",
750            Format::Avro(_) => "avro",
751            Format::Protobuf(_) => "protobuf",
752            Format::Regex(_) => "regex",
753            Format::Csv { .. } => "csv",
754            Format::Json { .. } => "json",
755            Format::Text => "text",
756        }
757    }
758
759    // A bare FORMAT resolves to an encoding with a key only for Avro or
760    // Protobuf read from a schema registry whose purified seed carries a key
761    // schema. Every other bare format is value-only.
762    fn bare_format_has_key<T: AstInfo>(fmt: &Format<T>) -> bool {
763        match fmt {
764            Format::Avro(AvroSchema::Csr { csr_connection }) => csr_connection
765                .seed
766                .as_ref()
767                .is_some_and(|seed| seed.key_schema.is_some()),
768            Format::Protobuf(ProtobufSchema::Csr { csr_connection }) => csr_connection
769                .seed
770                .as_ref()
771                .is_some_and(|seed| seed.key.is_some()),
772            _ => false,
773        }
774    }
775
776    fn key_value_formats<T: AstInfo>(
777        spec: &FormatSpecifier<T>,
778    ) -> (Option<&'static str>, Option<&'static str>) {
779        match spec {
780            FormatSpecifier::KeyValue { key, value } => {
781                (Some(format_name(key)), Some(format_name(value)))
782            }
783            FormatSpecifier::Bare(fmt) => {
784                let value = Some(format_name(fmt));
785                let key = bare_format_has_key(fmt).then(|| format_name(fmt));
786                (key, value)
787            }
788        }
789    }
790
791    fn envelope_name(envelope: &SourceEnvelope) -> &'static str {
792        match envelope {
793            SourceEnvelope::None => "none",
794            SourceEnvelope::Debezium => "debezium",
795            SourceEnvelope::Upsert {
796                value_decode_err_policy,
797            } => {
798                if value_decode_err_policy
799                    .iter()
800                    .any(|p| matches!(p, SourceErrorPolicy::Inline { .. }))
801                {
802                    "upsert-value-err-inline"
803                } else {
804                    "upsert"
805                }
806            }
807            SourceEnvelope::CdcV2 => "materialize",
808        }
809    }
810
811    let parse = || -> Result<serde_json::Value, String> {
812        let mut stmts = mz_sql_parser::parser::parse_statements(a)
813            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
814        let stmt = match stmts.len() {
815            1 => stmts.remove(0).ast,
816            n => return Err(format!("expected a single statement, found {n}")),
817        };
818
819        use mz_sql_parser::ast::Statement::{CreateSubsource, CreateTableFromSource};
820        match stmt {
821            CreateTableFromSource(stmt) => {
822                let source_id = get_item_id(stmt.source)?;
823                let external_reference = stmt
824                    .external_reference
825                    .ok_or("missing external reference on CREATE TABLE FROM SOURCE")?
826                    .0
827                    .into_iter()
828                    .map(|ident| ident.into_string())
829                    .collect::<Vec<_>>();
830
831                let envelope_type = stmt.envelope.as_ref().map(envelope_name);
832                let (key_format, value_format) = match &stmt.format {
833                    Some(spec) => key_value_formats(spec),
834                    None => (None, None),
835                };
836
837                Ok(json!({
838                    "source_id": source_id,
839                    "external_reference": external_reference,
840                    "envelope_type": envelope_type,
841                    "key_format": key_format,
842                    "value_format": value_format,
843                }))
844            }
845            CreateSubsource(stmt) => {
846                // Progress subsources track ingestion progress and are not
847                // source tables. They have no external reference.
848                let is_progress = stmt
849                    .with_options
850                    .iter()
851                    .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
852                if is_progress {
853                    return Ok(serde_json::Value::Null);
854                }
855
856                let source_id = stmt
857                    .of_source
858                    .ok_or("non-progress CREATE SUBSOURCE without OF SOURCE")
859                    .and_then(get_item_id)?;
860
861                let external_reference = stmt
862                    .with_options
863                    .into_iter()
864                    .find(|o| matches!(o.name, CreateSubsourceOptionName::ExternalReference))
865                    .and_then(|o| match o.value {
866                        Some(WithOptionValue::UnresolvedItemName(name)) => Some(name),
867                        _ => None,
868                    })
869                    .ok_or("CREATE SUBSOURCE missing EXTERNAL REFERENCE option")?
870                    .0
871                    .into_iter()
872                    .map(|ident| ident.into_string())
873                    .collect::<Vec<_>>();
874
875                Ok(json!({
876                    "source_id": source_id,
877                    "external_reference": external_reference,
878                    "envelope_type": serde_json::Value::Null,
879                    "key_format": serde_json::Value::Null,
880                    "value_format": serde_json::Value::Null,
881                }))
882            }
883            _ => Ok(serde_json::Value::Null),
884        }
885    };
886
887    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
888    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
889    Ok(jsonb)
890}
891
892#[cfg(test)]
893mod tests {
894    use mz_repr::adt::jsonb::Jsonb;
895    use prost::Message as _;
896    use serde_json::json;
897
898    use crate::EvalError;
899
900    /// Encode the two proto fields our decoder cares about, using the same
901    /// tag numbering as the canonical proto.
902    fn encode_pg_details(slot: &str, timeline_id: Option<u64>) -> String {
903        let details = super::PostgresPublicationDetailsSubset {
904            slot: slot.to_string(),
905            timeline_id,
906        };
907        hex::encode(details.encode_to_vec())
908    }
909
910    fn pg_source_sql(details_hex: &str) -> String {
911        format!(
912            "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
913             IN CLUSTER [u42] \
914             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
915             (DETAILS = '{details_hex}', PUBLICATION = 'mz_source') \
916             FOR ALL TABLES"
917        )
918    }
919
920    fn kafka_source_sql(with_prefix: bool) -> String {
921        let prefix_opt = if with_prefix {
922            ", GROUP ID PREFIX 'my-prefix-'"
923        } else {
924            ""
925        };
926        format!(
927            "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
928             IN CLUSTER [u42] \
929             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
930             (TOPIC 'test'{prefix_opt}) FORMAT TEXT"
931        )
932    }
933
934    fn as_serde(jsonb: Jsonb) -> serde_json::Value {
935        jsonb.as_ref().to_serde_json()
936    }
937
938    // --- parse_postgres_source_details ---------------------------------------
939
940    #[mz_ore::test]
941    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
942    fn pg_happy_path_with_timeline() {
943        let hex = encode_pg_details("materialize_abc", Some(42));
944        let sql = pg_source_sql(&hex);
945        let out = super::parse_postgres_source_details(&sql).expect("ok");
946        assert_eq!(
947            as_serde(out),
948            json!({ "slot": "materialize_abc", "timeline_id": 42 }),
949        );
950    }
951
952    #[mz_ore::test]
953    fn pg_happy_path_null_timeline() {
954        // Pre-2024 sources have no timeline_id field. The decoder must
955        // surface that as JSON null, not error.
956        let hex = encode_pg_details("materialize_legacy", None);
957        let sql = pg_source_sql(&hex);
958        let out = super::parse_postgres_source_details(&sql).expect("ok");
959        assert_eq!(
960            as_serde(out),
961            json!({ "slot": "materialize_legacy", "timeline_id": null }),
962        );
963    }
964
965    #[mz_ore::test]
966    fn pg_non_postgres_source_returns_null_jsonb() {
967        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
968             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
969        let out = super::parse_postgres_source_details(sql).expect("ok");
970        assert_eq!(as_serde(out), serde_json::Value::Null);
971    }
972
973    #[mz_ore::test]
974    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
975    fn pg_non_create_source_returns_null_jsonb() {
976        let sql = "CREATE VIEW v AS SELECT 1";
977        let out = super::parse_postgres_source_details(sql).expect("ok");
978        assert_eq!(as_serde(out), serde_json::Value::Null);
979    }
980
981    #[mz_ore::test]
982    fn pg_missing_details_option_errors() {
983        let sql = "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
984             IN CLUSTER [u42] \
985             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
986             (PUBLICATION = 'mz_source') FOR ALL TABLES";
987        let err = super::parse_postgres_source_details(sql).unwrap_err();
988        assert!(
989            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing DETAILS")),
990            "wrong error variant/message"
991        );
992    }
993
994    #[mz_ore::test]
995    fn pg_malformed_hex_errors() {
996        let sql = pg_source_sql("not-hex!!");
997        let err = super::parse_postgres_source_details(&sql).unwrap_err();
998        assert!(
999            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("valid hex")),
1000            "wrong error variant/message"
1001        );
1002    }
1003
1004    #[mz_ore::test]
1005    fn pg_malformed_proto_errors() {
1006        // Valid hex, garbage bytes. Prost decoding fails on unexpected wire
1007        // format.
1008        let sql = pg_source_sql("ffff");
1009        let err = super::parse_postgres_source_details(&sql).unwrap_err();
1010        assert!(
1011            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("publication-details proto")),
1012            "wrong error variant/message"
1013        );
1014    }
1015
1016    // --- parse_kafka_source_details ------------------------------------------
1017
1018    #[mz_ore::test]
1019    fn kafka_happy_path_with_prefix() {
1020        let sql = kafka_source_sql(true);
1021        let out = super::parse_kafka_source_details(&sql).expect("ok");
1022        assert_eq!(
1023            as_serde(out),
1024            json!({
1025                "topic": "test",
1026                "group_id_prefix": "my-prefix-",
1027                "connection_id": "u11",
1028            }),
1029        );
1030    }
1031
1032    #[mz_ore::test]
1033    fn kafka_happy_path_without_prefix() {
1034        let sql = kafka_source_sql(false);
1035        let out = super::parse_kafka_source_details(&sql).expect("ok");
1036        assert_eq!(
1037            as_serde(out),
1038            json!({
1039                "topic": "test",
1040                "group_id_prefix": null,
1041                "connection_id": "u11",
1042            }),
1043        );
1044    }
1045
1046    #[mz_ore::test]
1047    fn kafka_non_kafka_source_returns_null_jsonb() {
1048        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
1049             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
1050        let out = super::parse_kafka_source_details(sql).expect("ok");
1051        assert_eq!(as_serde(out), serde_json::Value::Null);
1052    }
1053
1054    #[mz_ore::test]
1055    fn kafka_missing_topic_errors() {
1056        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1057             IN CLUSTER [u42] \
1058             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
1059             FORMAT TEXT";
1060        let err = super::parse_kafka_source_details(sql).unwrap_err();
1061        assert!(
1062            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing TOPIC")),
1063            "wrong error variant/message"
1064        );
1065    }
1066
1067    #[mz_ore::test]
1068    fn kafka_unresolved_connection_errors() {
1069        // A bare-name connection reference never happens after purification,
1070        // but the decoder must reject it explicitly rather than silently
1071        // dropping the connection_id.
1072        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
1073             IN CLUSTER [u42] \
1074             FROM KAFKA CONNECTION k_conn (TOPIC 'test') FORMAT TEXT";
1075        let err = super::parse_kafka_source_details(sql).unwrap_err();
1076        assert!(
1077            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("unresolved item name")),
1078            "wrong error variant/message"
1079        );
1080    }
1081
1082    // --- parse_source_export_details -----------------------------------------
1083
1084    fn table_from_source_sql(reference: &str, suffix: &str) -> String {
1085        format!(
1086            "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1087             FROM SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1088             (REFERENCE = {reference}){suffix}"
1089        )
1090    }
1091
1092    #[mz_ore::test]
1093    fn export_table_postgres_style_no_format() {
1094        // Postgres/mysql/sql-server tables carry a multi-part external
1095        // reference and no format or envelope.
1096        let sql = table_from_source_sql("\"db\".\"public\".\"t\"", "");
1097        let out = super::parse_source_export_details(&sql).expect("ok");
1098        assert_eq!(
1099            as_serde(out),
1100            json!({
1101                "source_id": "u1",
1102                "external_reference": ["db", "public", "t"],
1103                "envelope_type": null,
1104                "key_format": null,
1105                "value_format": null,
1106            }),
1107        );
1108    }
1109
1110    #[mz_ore::test]
1111    fn export_table_kafka_bare_value_only() {
1112        // A bare non-registry FORMAT is value-only: no key format.
1113        let sql = table_from_source_sql("\"topic\"", " FORMAT TEXT ENVELOPE NONE");
1114        let out = super::parse_source_export_details(&sql).expect("ok");
1115        assert_eq!(
1116            as_serde(out),
1117            json!({
1118                "source_id": "u1",
1119                "external_reference": ["topic"],
1120                "envelope_type": "none",
1121                "key_format": null,
1122                "value_format": "text",
1123            }),
1124        );
1125    }
1126
1127    #[mz_ore::test]
1128    fn export_table_kafka_key_value_format() {
1129        let sql = table_from_source_sql(
1130            "\"topic\"",
1131            " KEY FORMAT TEXT VALUE FORMAT TEXT ENVELOPE NONE",
1132        );
1133        let out = super::parse_source_export_details(&sql).expect("ok");
1134        assert_eq!(
1135            as_serde(out),
1136            json!({
1137                "source_id": "u1",
1138                "external_reference": ["topic"],
1139                "envelope_type": "none",
1140                "key_format": "text",
1141                "value_format": "text",
1142            }),
1143        );
1144    }
1145
1146    #[mz_ore::test]
1147    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1148    fn export_table_kafka_bare_avro_seed_with_key() {
1149        // A bare Avro CSR format whose seed carries a key schema resolves to
1150        // an encoding with a key, so key_format mirrors value_format. This is
1151        // the upsert/debezium path.
1152        let sql = table_from_source_sql(
1153            "\"topic\"",
1154            " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1155             CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1156             SEED KEY SCHEMA 'k' VALUE SCHEMA 'v' ENVELOPE UPSERT",
1157        );
1158        let out = super::parse_source_export_details(&sql).expect("ok");
1159        assert_eq!(
1160            as_serde(out),
1161            json!({
1162                "source_id": "u1",
1163                "external_reference": ["topic"],
1164                "envelope_type": "upsert",
1165                "key_format": "avro",
1166                "value_format": "avro",
1167            }),
1168        );
1169    }
1170
1171    #[mz_ore::test]
1172    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1173    fn export_table_kafka_bare_avro_seed_without_key() {
1174        // A bare Avro CSR seed with only a value schema is value-only.
1175        let sql = table_from_source_sql(
1176            "\"topic\"",
1177            " FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY \
1178             CONNECTION [u5 AS \"materialize\".\"public\".\"csr\"] \
1179             SEED VALUE SCHEMA 'v' ENVELOPE NONE",
1180        );
1181        let out = super::parse_source_export_details(&sql).expect("ok");
1182        assert_eq!(
1183            as_serde(out),
1184            json!({
1185                "source_id": "u1",
1186                "external_reference": ["topic"],
1187                "envelope_type": "none",
1188                "key_format": null,
1189                "value_format": "avro",
1190            }),
1191        );
1192    }
1193
1194    #[mz_ore::test]
1195    fn export_subsource_non_progress() {
1196        // Old-syntax subsource: external reference lives in a WITH option, and
1197        // there is never a format or envelope.
1198        let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"sub\" (id int4) \
1199             OF SOURCE [u1 AS \"materialize\".\"public\".\"src\"] \
1200             WITH (EXTERNAL REFERENCE = \"db\".\"public\".\"t\")";
1201        let out = super::parse_source_export_details(sql).expect("ok");
1202        assert_eq!(
1203            as_serde(out),
1204            json!({
1205                "source_id": "u1",
1206                "external_reference": ["db", "public", "t"],
1207                "envelope_type": null,
1208                "key_format": null,
1209                "value_format": null,
1210            }),
1211        );
1212    }
1213
1214    #[mz_ore::test]
1215    fn export_progress_subsource_returns_null_jsonb() {
1216        let sql = "CREATE SUBSOURCE \"materialize\".\"public\".\"progress\" (id int4) \
1217             WITH (PROGRESS)";
1218        let out = super::parse_source_export_details(sql).expect("ok");
1219        assert_eq!(as_serde(out), serde_json::Value::Null);
1220    }
1221
1222    #[mz_ore::test]
1223    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1224    fn export_non_source_export_returns_null_jsonb() {
1225        let sql = "CREATE VIEW v AS SELECT 1";
1226        let out = super::parse_source_export_details(sql).expect("ok");
1227        assert_eq!(as_serde(out), serde_json::Value::Null);
1228    }
1229
1230    #[mz_ore::test]
1231    fn export_unresolved_source_name_errors() {
1232        let sql = "CREATE TABLE \"materialize\".\"public\".\"tbl\" \
1233             FROM SOURCE src (REFERENCE = \"topic\")";
1234        let err = super::parse_source_export_details(sql).unwrap_err();
1235        assert!(
1236            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("unresolved item name")),
1237            "wrong error variant/message"
1238        );
1239    }
1240}