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, Format, FormatSpecifier, KafkaSourceConfigOptionName, PgConfigOptionName,
22    RawClusterName, RawItemName, Value, WithOptionValue,
23};
24use prost::Message as _;
25use serde::{Deserialize, Serialize};
26use serde_json::json;
27
28use crate::EvalError;
29use crate::scalar::func::EagerUnaryFunc;
30use crate::scalar::func::impls::numeric::*;
31
32#[sqlfunc(
33    sqlname = "jsonb_to_text",
34    preserves_uniqueness = false,
35    inverse = to_unary!(super::CastStringToJsonb)
36)]
37pub fn cast_jsonb_to_string<'a>(a: JsonbRef<'a>) -> String {
38    let mut buf = String::new();
39    strconv::format_jsonb(&mut buf, a);
40    buf
41}
42
43#[sqlfunc(sqlname = "jsonb_to_smallint", is_monotone = true)]
44fn cast_jsonb_to_int16<'a>(a: JsonbRef<'a>) -> Result<i16, EvalError> {
45    match a.into_datum() {
46        Datum::Numeric(a) => cast_numeric_to_int16(a.into_inner()),
47        datum => Err(EvalError::InvalidJsonbCast {
48            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
49            to: "smallint".into(),
50        }),
51    }
52}
53
54#[sqlfunc(sqlname = "jsonb_to_integer", is_monotone = true)]
55fn cast_jsonb_to_int32<'a>(a: JsonbRef<'a>) -> Result<i32, EvalError> {
56    match a.into_datum() {
57        Datum::Numeric(a) => cast_numeric_to_int32(a.into_inner()),
58        datum => Err(EvalError::InvalidJsonbCast {
59            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
60            to: "integer".into(),
61        }),
62    }
63}
64
65#[sqlfunc(sqlname = "jsonb_to_bigint", is_monotone = true)]
66fn cast_jsonb_to_int64<'a>(a: JsonbRef<'a>) -> Result<i64, EvalError> {
67    match a.into_datum() {
68        Datum::Numeric(a) => cast_numeric_to_int64(a.into_inner()),
69        datum => Err(EvalError::InvalidJsonbCast {
70            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
71            to: "bigint".into(),
72        }),
73    }
74}
75
76#[sqlfunc(sqlname = "jsonb_to_real", is_monotone = true)]
77fn cast_jsonb_to_float32<'a>(a: JsonbRef<'a>) -> Result<f32, EvalError> {
78    match a.into_datum() {
79        Datum::Numeric(a) => cast_numeric_to_float32(a.into_inner()),
80        datum => Err(EvalError::InvalidJsonbCast {
81            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
82            to: "real".into(),
83        }),
84    }
85}
86
87#[sqlfunc(sqlname = "jsonb_to_double", is_monotone = true)]
88fn cast_jsonb_to_float64<'a>(a: JsonbRef<'a>) -> Result<f64, EvalError> {
89    match a.into_datum() {
90        Datum::Numeric(a) => cast_numeric_to_float64(a.into_inner()),
91        datum => Err(EvalError::InvalidJsonbCast {
92            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
93            to: "double precision".into(),
94        }),
95    }
96}
97
98#[derive(
99    Ord,
100    PartialOrd,
101    Clone,
102    Debug,
103    Eq,
104    PartialEq,
105    Serialize,
106    Deserialize,
107    Hash
108)]
109pub struct CastJsonbToNumeric(pub Option<NumericMaxScale>);
110
111impl EagerUnaryFunc for CastJsonbToNumeric {
112    type Input<'a> = JsonbRef<'a>;
113    type Output<'a> = Result<Numeric, EvalError>;
114
115    fn call<'a>(&self, a: Self::Input<'a>) -> Self::Output<'a> {
116        match a.into_datum() {
117            Datum::Numeric(mut num) => match self.0 {
118                None => Ok(num.into_inner()),
119                Some(scale) => {
120                    if numeric::rescale(&mut num.0, scale.into_u8()).is_err() {
121                        return Err(EvalError::NumericFieldOverflow);
122                    };
123                    Ok(num.into_inner())
124                }
125            },
126            datum => Err(EvalError::InvalidJsonbCast {
127                from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
128                to: "numeric".into(),
129            }),
130        }
131    }
132
133    fn output_sql_type(&self, input: SqlColumnType) -> SqlColumnType {
134        SqlScalarType::Numeric { max_scale: self.0 }.nullable(input.nullable)
135    }
136
137    fn is_monotone(&self) -> bool {
138        true
139    }
140}
141
142impl fmt::Display for CastJsonbToNumeric {
143    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
144        f.write_str("jsonb_to_numeric")
145    }
146}
147
148#[sqlfunc(sqlname = "jsonb_to_boolean", is_monotone = true)]
149fn cast_jsonb_to_bool<'a>(a: JsonbRef<'a>) -> Result<bool, EvalError> {
150    match a.into_datum() {
151        Datum::True => Ok(true),
152        Datum::False => Ok(false),
153        datum => Err(EvalError::InvalidJsonbCast {
154            from: jsonb_typeof(JsonbRef::from_datum(datum)).into(),
155            to: "boolean".into(),
156        }),
157    }
158}
159
160#[sqlfunc(sqlname = "jsonbable_to_jsonb")]
161fn cast_jsonbable_to_jsonb<'a>(a: JsonbRef<'a>) -> JsonbRef<'a> {
162    match a.into_datum() {
163        Datum::Numeric(n) => {
164            let n = n.into_inner();
165            let datum = if n.is_finite() {
166                Datum::from(n)
167            } else if n.is_nan() {
168                Datum::String("NaN")
169            } else if n.is_negative() {
170                Datum::String("-Infinity")
171            } else {
172                Datum::String("Infinity")
173            };
174            JsonbRef::from_datum(datum)
175        }
176        datum => JsonbRef::from_datum(datum),
177    }
178}
179
180#[sqlfunc]
181fn jsonb_array_length<'a>(a: JsonbRef<'a>) -> Result<Option<i32>, EvalError> {
182    match a.into_datum() {
183        Datum::List(list) => {
184            let count = list.iter().count();
185            match i32::try_from(count) {
186                Ok(len) => Ok(Some(len)),
187                Err(_) => Err(EvalError::Int32OutOfRange(count.to_string().into())),
188            }
189        }
190        _ => Ok(None),
191    }
192}
193
194#[sqlfunc]
195fn jsonb_typeof<'a>(a: JsonbRef<'a>) -> &'a str {
196    match a.into_datum() {
197        Datum::Map(_) => "object",
198        Datum::List(_) => "array",
199        Datum::String(_) => "string",
200        Datum::Numeric(_) => "number",
201        Datum::True | Datum::False => "boolean",
202        Datum::JsonNull => "null",
203        d => panic!("Not jsonb: {:?}", d),
204    }
205}
206
207#[sqlfunc]
208fn jsonb_strip_nulls<'a>(a: JsonbRef<'a>) -> Jsonb {
209    fn strip_nulls(a: Datum, row: &mut RowPacker) {
210        match a {
211            Datum::Map(dict) => row.push_dict_with(|row| {
212                for (k, v) in dict.iter() {
213                    match v {
214                        Datum::JsonNull => (),
215                        _ => {
216                            row.push(Datum::String(k));
217                            strip_nulls(v, row);
218                        }
219                    }
220                }
221            }),
222            Datum::List(list) => row.push_list_with(|row| {
223                for elem in list.iter() {
224                    strip_nulls(elem, row);
225                }
226            }),
227            _ => row.push(a),
228        }
229    }
230    let mut row = Row::default();
231    strip_nulls(a.into_datum(), &mut row.packer());
232    Jsonb::from_row(row)
233}
234
235#[sqlfunc]
236fn jsonb_pretty<'a>(a: JsonbRef<'a>) -> String {
237    let mut buf = String::new();
238    strconv::format_jsonb_pretty(&mut buf, a);
239    buf
240}
241
242/// Converts a JSONB `Datum` into a `u64`.
243fn jsonb_datum_to_u64<'a>(d: Datum<'a>) -> Result<u64, String> {
244    let Datum::Numeric(n) = d else {
245        return Err("expected numeric value".into());
246    };
247
248    let mut cx = numeric::cx_datum();
249    cx.try_into_u64(n.0)
250        .map_err(|_| format!("number out of u64 range: {n}"))
251}
252
253/// Decodes a JSONB object of shape `{"bitflags": <u64>}` into an `AclMode`.
254///
255/// Shared decoder for `parse_catalog_privileges` (which embeds the object as
256/// the `acl_mode` field of each privilege) and `parse_catalog_acl_mode` (which
257/// receives the object at the top level).
258fn jsonb_datum_to_acl_mode(d: Datum) -> Result<AclMode, String> {
259    let Datum::Map(dict) = d else {
260        return Err(format!("unexpected acl_mode: {d}"));
261    };
262    let mut bits = None;
263    for (key, val) in dict.iter() {
264        match key {
265            "bitflags" => bits = Some(jsonb_datum_to_u64(val)?),
266            other => return Err(format!("unexpected acl_mode field: {other}")),
267        }
268    }
269    let bits = bits.ok_or_else(|| "missing acl_mode bitflags".to_string())?;
270    AclMode::from_bits(bits).ok_or_else(|| format!("invalid acl_mode bitflags: {bits}"))
271}
272
273/// Converts a JSONB `Datum` into a `RoleId`.
274fn jsonb_datum_to_role_id(d: Datum) -> Result<RoleId, String> {
275    match d {
276        Datum::String("Public") => Ok(RoleId::Public),
277        Datum::String(other) => Err(format!("unexpected role ID variant: {other}")),
278        Datum::Map(dict) => {
279            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
280            let n = jsonb_datum_to_u64(val)?;
281            match key {
282                "User" => Ok(RoleId::User(n)),
283                "System" => Ok(RoleId::System(n)),
284                "Predefined" => Ok(RoleId::Predefined(n)),
285                other => Err(format!("unexpected role ID variant: {other}")),
286            }
287        }
288        _ => Err("expected string or object".into()),
289    }
290}
291
292/// Converts a catalog JSON-serialized ID value into the appropriate string format.
293///
294/// Supports all of Materialize's various ID types of the form `<prefix><u64>`.
295#[sqlfunc]
296fn parse_catalog_id<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
297    let parse = || match a.into_datum() {
298        // Unit variant, e.g. "Public"
299        Datum::String(variant) => match variant {
300            "Explain" => Ok("e".to_string()),
301            "Public" => Ok("p".to_string()),
302            other => Err(format!("unexpected ID variant: {other}")),
303        },
304        // Newtype variant, e.g. {"User": 1}
305        Datum::Map(dict) => {
306            let (key, val) = dict.iter().next().ok_or_else(|| "empty".to_string())?;
307            let prefix = match key {
308                "IntrospectionSourceIndex" => "si",
309                "Predefined" => "g",
310                "System" => "s",
311                "Transient" => "t",
312                "User" => "u",
313                other => return Err(format!("unexpected ID variant: {other}")),
314            };
315            let n = jsonb_datum_to_u64(val)?;
316            Ok(format!("{prefix}{n}"))
317        }
318        _ => Err("expected string or object".into()),
319    };
320
321    parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))
322}
323
324/// Converts a catalog JSON-serialized privilege array into an `mz_aclitem[]`.
325#[sqlfunc]
326fn parse_catalog_privileges<'a>(a: JsonbRef<'a>) -> Result<ArrayRustType<MzAclItem>, EvalError> {
327    let parse_one = |datum| match datum {
328        Datum::Map(dict) => {
329            let mut grantee = None;
330            let mut grantor = None;
331            let mut acl_mode = None;
332            for (key, val) in dict.iter() {
333                match key {
334                    "grantee" => {
335                        let id = jsonb_datum_to_role_id(val)?;
336                        grantee = Some(id);
337                    }
338                    "grantor" => {
339                        let id = jsonb_datum_to_role_id(val)?;
340                        grantor = Some(id);
341                    }
342                    "acl_mode" => {
343                        acl_mode = Some(jsonb_datum_to_acl_mode(val)?);
344                    }
345                    other => return Err(format!("unexpected privilege field: {other}")),
346                }
347            }
348            Ok(MzAclItem {
349                grantee: grantee.ok_or_else(|| format!("missing grantee: {dict:?}"))?,
350                grantor: grantor.ok_or_else(|| "missing grantor in privilege".to_string())?,
351                acl_mode: acl_mode.ok_or_else(|| "missing acl_mode in privilege".to_string())?,
352            })
353        }
354        other => Err(format!("expected object in array, found: {other}")),
355    };
356
357    let parse = || match a.into_datum() {
358        Datum::List(list) => {
359            let mut result = Vec::new();
360            for item in list.iter() {
361                result.push(parse_one(item)?);
362            }
363            Ok(result)
364        }
365        _ => Err("expected array".to_string()),
366    };
367
368    parse()
369        .map(ArrayRustType)
370        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
371}
372
373/// Converts a catalog JSON-serialized `AclMode` bitflags object into a
374/// PostgreSQL ACL char-code string (e.g. `{"bitflags": 514}` → `"ar"`).
375#[sqlfunc]
376fn parse_catalog_acl_mode<'a>(a: JsonbRef<'a>) -> Result<String, EvalError> {
377    jsonb_datum_to_acl_mode(a.into_datum())
378        .map(|mode| mode.to_string())
379        .map_err(|e| EvalError::InvalidCatalogJson(e.into()))
380}
381
382/// Parses a catalog `create_sql` string into a JSONB object.
383///
384/// The returned JSONB does not fully reflect the parsed SQL and instead contains only fields
385/// required by current callers.
386///
387// TODO: This function isn't parsing JSONB and therefore shouldn't live in the `jsonb` module.
388//       Consider moving all the `parse_catalog_*` functions into their own module.
389#[sqlfunc]
390fn parse_catalog_create_sql<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
391    fn get_cluster_id(in_cluster: RawClusterName) -> Result<String, &'static str> {
392        match in_cluster {
393            RawClusterName::Resolved(s) => Ok(s),
394            RawClusterName::Unresolved(_) => Err("unresolved cluster name"),
395        }
396    }
397
398    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
399        match item {
400            RawItemName::Id(id, _, _) => Ok(id),
401            RawItemName::Name(_) => Err("unresolved item name"),
402        }
403    }
404
405    fn format_name<T: AstInfo>(fmt: &Format<T>) -> &'static str {
406        match fmt {
407            Format::Bytes => "bytes",
408            Format::Avro(_) => "avro",
409            Format::Protobuf(_) => "protobuf",
410            Format::Regex(_) => "regex",
411            Format::Csv { .. } => "csv",
412            Format::Json { .. } => "json",
413            Format::Text => "text",
414        }
415    }
416
417    let parse = || -> Result<serde_json::Value, String> {
418        let mut stmts = mz_sql_parser::parser::parse_statements(a)
419            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
420        let stmt = match stmts.len() {
421            1 => stmts.remove(0).ast,
422            n => return Err(format!("expected a single statement, found {n}")),
423        };
424
425        let mut info = BTreeMap::<&str, serde_json::Value>::new();
426
427        use mz_sql_parser::ast::Statement::*;
428        let item_type = match stmt {
429            CreateSecret(_) => "secret",
430            CreateConnection(stmt) => {
431                let connection_type = stmt.connection_type.as_str();
432                info.insert("connection_type", json!(connection_type));
433
434                "connection"
435            }
436            CreateView(_) => "view",
437            CreateMaterializedView(stmt) => {
438                let Some(in_cluster) = stmt.in_cluster else {
439                    return Err("missing IN CLUSTER".into());
440                };
441                let cluster_id = match in_cluster {
442                    RawClusterName::Unresolved(ident) => ident.into_string(),
443                    RawClusterName::Resolved(s) => s,
444                };
445                info.insert("cluster_id", json!(cluster_id));
446
447                let mut definition = stmt.query.to_ast_string_stable();
448                definition.push(';');
449                info.insert("definition", json!(definition));
450
451                "materialized-view"
452            }
453            CreateTable(_) | CreateTableFromSource(_) => "table",
454            CreateSource(stmt) => {
455                let Some(in_cluster) = stmt.in_cluster else {
456                    return Err("missing IN CLUSTER".into());
457                };
458                let cluster_id = get_cluster_id(in_cluster)?;
459                info.insert("cluster_id", json!(cluster_id));
460
461                use mz_sql_parser::ast::CreateSourceConnection::*;
462                let (source_type, connection) = match stmt.connection {
463                    Kafka { connection, .. } => ("kafka", Some(connection)),
464                    Postgres { connection, .. } => ("postgres", Some(connection)),
465                    MySql { connection, .. } => ("mysql", Some(connection)),
466                    SqlServer { connection, .. } => ("sql-server", Some(connection)),
467                    LoadGenerator { .. } => ("load-generator", None),
468                };
469                info.insert("source_type", json!(source_type));
470                if let Some(conn) = connection {
471                    let conn_id = get_item_id(conn)?;
472                    info.insert("connection_id", json!(conn_id));
473                }
474
475                let is_debezium = matches!(
476                    stmt.envelope,
477                    Some(mz_sql_parser::ast::SourceEnvelope::Debezium)
478                );
479
480                if let Some(envelope) = stmt.envelope {
481                    use mz_sql_parser::ast::SourceEnvelope::*;
482                    let envelope_type = match envelope {
483                        None => "none",
484                        Debezium => "debezium",
485                        Upsert { .. } => "upsert",
486                        CdcV2 => "materialize",
487                    };
488                    info.insert("envelope_type", json!(envelope_type));
489                }
490
491                if let Some(format_spec) = stmt.format {
492                    match &format_spec {
493                        FormatSpecifier::Bare(fmt) => {
494                            // Debezium sources with a single format spec implicitly use
495                            // the same format for both key and value.
496                            if is_debezium {
497                                info.insert("key_format", json!(format_name(fmt)));
498                            }
499                            info.insert("value_format", json!(format_name(fmt)));
500                        }
501                        FormatSpecifier::KeyValue { key, value } => {
502                            info.insert("key_format", json!(format_name(key)));
503                            info.insert("value_format", json!(format_name(value)));
504                        }
505                    }
506                }
507
508                "source"
509            }
510            CreateWebhookSource(stmt) => {
511                if stmt.is_table {
512                    "table"
513                } else {
514                    info.insert("source_type", json!("webhook"));
515                    if let Some(in_cluster) = stmt.in_cluster {
516                        let cluster_id = get_cluster_id(in_cluster)?;
517                        info.insert("cluster_id", json!(cluster_id));
518                    }
519                    "source"
520                }
521            }
522            CreateSubsource(stmt) => {
523                use mz_sql_parser::ast::CreateSubsourceOptionName;
524                let is_progress = stmt
525                    .with_options
526                    .iter()
527                    .any(|o| matches!(o.name, CreateSubsourceOptionName::Progress));
528                let source_type = if is_progress { "progress" } else { "subsource" };
529                info.insert("source_type", json!(source_type));
530
531                if let Some(of_source) = stmt.of_source {
532                    let of_source_id = get_item_id(of_source)?;
533                    info.insert("of_source_id", json!(of_source_id));
534                }
535
536                "subsource"
537            }
538            CreateSink(_) => "sink",
539            CreateIndex(stmt) => {
540                let Some(in_cluster) = stmt.in_cluster else {
541                    return Err("missing IN CLUSTER".into());
542                };
543                let cluster_id = get_cluster_id(in_cluster)?;
544                info.insert("cluster_id", json!(cluster_id));
545                let on_id = get_item_id(stmt.on_name)?;
546                info.insert("on_id", json!(on_id));
547                "index"
548            }
549            CreateType(_) => "type",
550            _ => return Err("not a CREATE item statement".into()),
551        };
552        info.insert("type", json!(item_type));
553
554        let info = info.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
555        Ok(info)
556    };
557
558    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
559    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
560    Ok(jsonb)
561}
562
563/// Minimal decoder for `ProtoPostgresSourcePublicationDetails`. The
564/// canonical proto lives in `mz-storage-types`, which depends on
565/// `mz-expr`, so we redeclare the two tags we read here. Upstream tag
566/// renumbers slip past silently. The `mz_postgres_sources` lockdown
567/// SLTs catch them.
568#[derive(Clone, PartialEq, ::prost::Message)]
569struct PostgresPublicationDetailsSubset {
570    #[prost(string, tag = "2")]
571    slot: String,
572    #[prost(uint64, optional, tag = "3")]
573    timeline_id: Option<u64>,
574}
575
576/// Extracts postgres source publication details (slot, timeline_id) from a
577/// catalog `create_sql`. Returns:
578///
579/// - jsonb `{"slot": <text>, "timeline_id": <u64 | null>}` for
580///   `CREATE SOURCE ... FROM POSTGRES CONNECTION ... (DETAILS = ...)` statements.
581/// - jsonb `null` for any other statement.
582///
583/// Errors if the statement fails to parse, is a postgres source without
584/// a `DETAILS` option, or if the `DETAILS` value can't be hex- and
585/// proto-decoded.
586#[sqlfunc]
587fn parse_postgres_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
588    let parse = || -> Result<serde_json::Value, String> {
589        let mut stmts = mz_sql_parser::parser::parse_statements(a)
590            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
591        let stmt = match stmts.len() {
592            1 => stmts.remove(0).ast,
593            n => return Err(format!("expected a single statement, found {n}")),
594        };
595
596        use mz_sql_parser::ast::CreateSourceConnection;
597        use mz_sql_parser::ast::Statement::CreateSource;
598        let options = match stmt {
599            CreateSource(stmt) => match stmt.connection {
600                CreateSourceConnection::Postgres { options, .. } => options,
601                _ => return Ok(serde_json::Value::Null),
602            },
603            _ => return Ok(serde_json::Value::Null),
604        };
605
606        let details_hex = options
607            .into_iter()
608            .find(|opt| opt.name == PgConfigOptionName::Details)
609            .and_then(|opt| match opt.value {
610                Some(WithOptionValue::Value(Value::String(s))) => Some(s),
611                _ => None,
612            })
613            .ok_or("missing DETAILS option on postgres source")?;
614
615        let details_bytes =
616            hex::decode(&details_hex).map_err(|e| format!("DETAILS is not valid hex: {e}"))?;
617
618        let details = PostgresPublicationDetailsSubset::decode(&*details_bytes)
619            .map_err(|e| format!("DETAILS is not a valid publication-details proto: {e}"))?;
620
621        Ok(json!({
622            "slot": details.slot,
623            "timeline_id": details.timeline_id,
624        }))
625    };
626
627    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
628    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
629    Ok(jsonb)
630}
631
632/// Extracts kafka source configuration (topic, group id prefix, connection
633/// id) from a catalog `create_sql`. Returns:
634///
635/// - jsonb `{"topic": <text>, "group_id_prefix": <text | null>, "connection_id": <text>}`
636///   for `CREATE SOURCE ... FROM KAFKA CONNECTION ... (TOPIC = ..., [GROUP ID PREFIX = ...])`
637///   statements.
638/// - jsonb `null` for any other statement.
639///
640/// Errors if the statement fails to parse, is a kafka source without a
641/// `TOPIC` option, or references an unresolved connection name (i.e. one
642/// that hasn't been through purification).
643#[sqlfunc]
644fn parse_kafka_source_details<'a>(a: &'a str) -> Result<Jsonb, EvalError> {
645    fn get_item_id(item: RawItemName) -> Result<String, &'static str> {
646        match item {
647            RawItemName::Id(id, _, _) => Ok(id),
648            RawItemName::Name(_) => Err("unresolved item name"),
649        }
650    }
651
652    let parse = || -> Result<serde_json::Value, String> {
653        let mut stmts = mz_sql_parser::parser::parse_statements(a)
654            .map_err(|e| format!("failed to parse create_sql: {e}"))?;
655        let stmt = match stmts.len() {
656            1 => stmts.remove(0).ast,
657            n => return Err(format!("expected a single statement, found {n}")),
658        };
659
660        use mz_sql_parser::ast::CreateSourceConnection;
661        use mz_sql_parser::ast::Statement::CreateSource;
662        let (connection, options) = match stmt {
663            CreateSource(stmt) => match stmt.connection {
664                CreateSourceConnection::Kafka {
665                    connection,
666                    options,
667                } => (connection, options),
668                _ => return Ok(serde_json::Value::Null),
669            },
670            _ => return Ok(serde_json::Value::Null),
671        };
672
673        let connection_id = get_item_id(connection)?;
674
675        let mut topic: Option<String> = None;
676        let mut group_id_prefix: Option<String> = None;
677        for opt in options {
678            let string_value = match opt.value {
679                Some(WithOptionValue::Value(Value::String(s))) => Some(s),
680                _ => None,
681            };
682            match opt.name {
683                KafkaSourceConfigOptionName::Topic => topic = string_value,
684                KafkaSourceConfigOptionName::GroupIdPrefix => group_id_prefix = string_value,
685                _ => {}
686            }
687        }
688
689        let topic = topic.ok_or("missing TOPIC option on kafka source")?;
690
691        Ok(json!({
692            "topic": topic,
693            "group_id_prefix": group_id_prefix,
694            "connection_id": connection_id,
695        }))
696    };
697
698    let val = parse().map_err(|e| EvalError::InvalidCatalogJson(e.into()))?;
699    let jsonb = Jsonb::from_serde_json(val).expect("valid JSONB");
700    Ok(jsonb)
701}
702
703#[cfg(test)]
704mod tests {
705    use mz_repr::adt::jsonb::Jsonb;
706    use prost::Message as _;
707    use serde_json::json;
708
709    use crate::EvalError;
710
711    /// Encode the two proto fields our decoder cares about, using the same
712    /// tag numbering as the canonical proto.
713    fn encode_pg_details(slot: &str, timeline_id: Option<u64>) -> String {
714        let details = super::PostgresPublicationDetailsSubset {
715            slot: slot.to_string(),
716            timeline_id,
717        };
718        hex::encode(details.encode_to_vec())
719    }
720
721    fn pg_source_sql(details_hex: &str) -> String {
722        format!(
723            "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
724             IN CLUSTER [u42] \
725             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
726             (DETAILS = '{details_hex}', PUBLICATION = 'mz_source') \
727             FOR ALL TABLES"
728        )
729    }
730
731    fn kafka_source_sql(with_prefix: bool) -> String {
732        let prefix_opt = if with_prefix {
733            ", GROUP ID PREFIX 'my-prefix-'"
734        } else {
735            ""
736        };
737        format!(
738            "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
739             IN CLUSTER [u42] \
740             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
741             (TOPIC 'test'{prefix_opt}) FORMAT TEXT"
742        )
743    }
744
745    fn as_serde(jsonb: Jsonb) -> serde_json::Value {
746        jsonb.as_ref().to_serde_json()
747    }
748
749    // --- parse_postgres_source_details ---------------------------------------
750
751    #[mz_ore::test]
752    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
753    fn pg_happy_path_with_timeline() {
754        let hex = encode_pg_details("materialize_abc", Some(42));
755        let sql = pg_source_sql(&hex);
756        let out = super::parse_postgres_source_details(&sql).expect("ok");
757        assert_eq!(
758            as_serde(out),
759            json!({ "slot": "materialize_abc", "timeline_id": 42 }),
760        );
761    }
762
763    #[mz_ore::test]
764    fn pg_happy_path_null_timeline() {
765        // Pre-2024 sources have no timeline_id field. The decoder must
766        // surface that as JSON null, not error.
767        let hex = encode_pg_details("materialize_legacy", None);
768        let sql = pg_source_sql(&hex);
769        let out = super::parse_postgres_source_details(&sql).expect("ok");
770        assert_eq!(
771            as_serde(out),
772            json!({ "slot": "materialize_legacy", "timeline_id": null }),
773        );
774    }
775
776    #[mz_ore::test]
777    fn pg_non_postgres_source_returns_null_jsonb() {
778        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
779             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
780        let out = super::parse_postgres_source_details(sql).expect("ok");
781        assert_eq!(as_serde(out), serde_json::Value::Null);
782    }
783
784    #[mz_ore::test]
785    #[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
786    fn pg_non_create_source_returns_null_jsonb() {
787        let sql = "CREATE VIEW v AS SELECT 1";
788        let out = super::parse_postgres_source_details(sql).expect("ok");
789        assert_eq!(as_serde(out), serde_json::Value::Null);
790    }
791
792    #[mz_ore::test]
793    fn pg_missing_details_option_errors() {
794        let sql = "CREATE SOURCE \"materialize\".\"public\".\"pg_src\" \
795             IN CLUSTER [u42] \
796             FROM POSTGRES CONNECTION [u10 AS \"materialize\".\"public\".\"pg_conn\"] \
797             (PUBLICATION = 'mz_source') FOR ALL TABLES";
798        let err = super::parse_postgres_source_details(sql).unwrap_err();
799        assert!(
800            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing DETAILS")),
801            "wrong error variant/message"
802        );
803    }
804
805    #[mz_ore::test]
806    fn pg_malformed_hex_errors() {
807        let sql = pg_source_sql("not-hex!!");
808        let err = super::parse_postgres_source_details(&sql).unwrap_err();
809        assert!(
810            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("valid hex")),
811            "wrong error variant/message"
812        );
813    }
814
815    #[mz_ore::test]
816    fn pg_malformed_proto_errors() {
817        // Valid hex, garbage bytes. Prost decoding fails on unexpected wire
818        // format.
819        let sql = pg_source_sql("ffff");
820        let err = super::parse_postgres_source_details(&sql).unwrap_err();
821        assert!(
822            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("publication-details proto")),
823            "wrong error variant/message"
824        );
825    }
826
827    // --- parse_kafka_source_details ------------------------------------------
828
829    #[mz_ore::test]
830    fn kafka_happy_path_with_prefix() {
831        let sql = kafka_source_sql(true);
832        let out = super::parse_kafka_source_details(&sql).expect("ok");
833        assert_eq!(
834            as_serde(out),
835            json!({
836                "topic": "test",
837                "group_id_prefix": "my-prefix-",
838                "connection_id": "u11",
839            }),
840        );
841    }
842
843    #[mz_ore::test]
844    fn kafka_happy_path_without_prefix() {
845        let sql = kafka_source_sql(false);
846        let out = super::parse_kafka_source_details(&sql).expect("ok");
847        assert_eq!(
848            as_serde(out),
849            json!({
850                "topic": "test",
851                "group_id_prefix": null,
852                "connection_id": "u11",
853            }),
854        );
855    }
856
857    #[mz_ore::test]
858    fn kafka_non_kafka_source_returns_null_jsonb() {
859        let sql = "CREATE SOURCE \"materialize\".\"public\".\"lg\" \
860             IN CLUSTER [u42] FROM LOAD GENERATOR COUNTER";
861        let out = super::parse_kafka_source_details(sql).expect("ok");
862        assert_eq!(as_serde(out), serde_json::Value::Null);
863    }
864
865    #[mz_ore::test]
866    fn kafka_missing_topic_errors() {
867        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
868             IN CLUSTER [u42] \
869             FROM KAFKA CONNECTION [u11 AS \"materialize\".\"public\".\"k_conn\"] \
870             FORMAT TEXT";
871        let err = super::parse_kafka_source_details(sql).unwrap_err();
872        assert!(
873            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("missing TOPIC")),
874            "wrong error variant/message"
875        );
876    }
877
878    #[mz_ore::test]
879    fn kafka_unresolved_connection_errors() {
880        // A bare-name connection reference never happens after purification,
881        // but the decoder must reject it explicitly rather than silently
882        // dropping the connection_id.
883        let sql = "CREATE SOURCE \"materialize\".\"public\".\"k_src\" \
884             IN CLUSTER [u42] \
885             FROM KAFKA CONNECTION k_conn (TOPIC 'test') FORMAT TEXT";
886        let err = super::parse_kafka_source_details(sql).unwrap_err();
887        assert!(
888            matches!(err, EvalError::InvalidCatalogJson(msg) if msg.contains("unresolved item name")),
889            "wrong error variant/message"
890        );
891    }
892}