Skip to main content

mz_sql/
pure.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! SQL purification.
11//!
12//! See the [crate-level documentation](crate) for details.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16use std::iter;
17use std::path::Path;
18use std::sync::Arc;
19
20use anyhow::anyhow;
21use itertools::Itertools;
22use mz_adapter_types::dyncfgs::ENABLE_S3_TABLES_REGION_CHECK;
23use mz_ccsr::{Client, GetBySubjectError};
24use mz_cloud_provider::CloudProvider;
25use mz_controller_types::ClusterId;
26use mz_kafka_util::client::MzClientContext;
27use mz_mysql_util::MySqlTableDesc;
28use mz_ore::collections::CollectionExt;
29use mz_ore::error::ErrorExt;
30use mz_ore::future::InTask;
31use mz_ore::iter::IteratorExt;
32use mz_ore::str::StrExt;
33use mz_postgres_util::desc::PostgresTableDesc;
34use mz_proto::RustType;
35use mz_repr::{CatalogItemId, RelationDesc, RelationVersionSelector, Timestamp, strconv};
36use mz_sql_parser::ast::display::AstDisplay;
37use mz_sql_parser::ast::visit::{Visit, visit_function};
38use mz_sql_parser::ast::visit_mut::{VisitMut, visit_expr_mut};
39use mz_sql_parser::ast::{
40    AlterSourceAction, AlterSourceAddSubsourceOptionName, AlterSourceStatement, AvroDocOn,
41    CastFailureMode, ColumnName, CreateMaterializedViewStatement, CreateSinkConnection,
42    CreateSinkOptionName, CreateSinkStatement, CreateSourceOptionName, CreateSubsourceOption,
43    CreateSubsourceOptionName, CreateTableFromSourceStatement, CsrConfigOption,
44    CsrConfigOptionName, CsrConnection, CsrSeedAvro, CsrSeedProtobuf, CsrSeedProtobufSchema,
45    DeferredItemName, DocOnIdentifier, DocOnSchema, Expr, Function, FunctionArgs, GlueAvroOption,
46    GlueAvroSeed, Ident, KafkaSourceConfigOption, KafkaSourceConfigOptionName, LoadGenerator,
47    LoadGeneratorOption, LoadGeneratorOptionName, MaterializedViewOption,
48    MaterializedViewOptionName, MySqlConfigOption, MySqlConfigOptionName, PgConfigOption,
49    PgConfigOptionName, RawItemName, ReaderSchemaSelectionStrategy, RefreshAtOptionValue,
50    RefreshEveryOptionValue, RefreshOptionValue, SourceEnvelope, SqlServerConfigOption,
51    SqlServerConfigOptionName, Statement, TableFromSourceColumns, TableFromSourceOption,
52    TableFromSourceOptionName, UnresolvedItemName,
53};
54use mz_sql_server_util::desc::SqlServerTableDesc;
55use mz_storage_types::configuration::StorageConfiguration;
56use mz_storage_types::connections::Connection;
57use mz_storage_types::connections::inline::IntoInlineConnection;
58use mz_storage_types::errors::ContextCreationError;
59use mz_storage_types::sources::load_generator::LoadGeneratorOutput;
60use mz_storage_types::sources::mysql::MySqlSourceDetails;
61use mz_storage_types::sources::postgres::PostgresSourcePublicationDetails;
62use mz_storage_types::sources::{
63    GenericSourceConnection, MzOffset, SourceConnection, SourceDesc, SourceExportStatementDetails,
64    SqlServerSourceExtras,
65};
66use prost::Message;
67use protobuf_native::MessageLite;
68use protobuf_native::compiler::{SourceTreeDescriptorDatabase, VirtualSourceTree};
69use rdkafka::admin::AdminClient;
70use references::{RetrievedSourceReferences, SourceReferenceClient};
71use uuid::Uuid;
72
73use crate::ast::{
74    AlterSourceAddSubsourceOption, AvroSchema, CreateSourceConnection, CreateSourceStatement,
75    CreateSubsourceStatement, CsrConnectionAvro, CsrConnectionProtobuf, ExternalReferenceExport,
76    ExternalReferences, Format, FormatSpecifier, ProtobufSchema, Value, WithOptionValue,
77};
78use crate::catalog::{CatalogItemType, SessionCatalog};
79use crate::kafka_util::{KafkaSinkConfigOptionExtracted, KafkaSourceConfigOptionExtracted};
80use crate::names::{
81    Aug, FullItemName, PartialItemName, ResolvedColumnReference, ResolvedDataType, ResolvedIds,
82    ResolvedItemName,
83};
84use crate::plan::error::PlanError;
85use crate::plan::statement::ddl::load_generator_ast_to_generator;
86use crate::plan::{SourceReferences, StatementContext};
87use crate::pure::error::{IcebergSinkPurificationError, SqlServerSourcePurificationError};
88use crate::pure::mysql::{ensure_binlog_full_metadata, is_binlog_full_metadata};
89use crate::{kafka_util, normalize};
90
91use self::error::{
92    CsrPurificationError, KafkaSinkPurificationError, KafkaSourcePurificationError,
93    LoadGeneratorSourcePurificationError, MySqlSourcePurificationError, PgSourcePurificationError,
94};
95
96pub(crate) mod error;
97mod references;
98
99pub mod mysql;
100pub mod postgres;
101pub mod sql_server;
102
103pub(crate) struct RequestedSourceExport<T> {
104    external_reference: UnresolvedItemName,
105    name: UnresolvedItemName,
106    meta: T,
107}
108
109impl<T: fmt::Debug> fmt::Debug for RequestedSourceExport<T> {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.debug_struct("RequestedSourceExport")
112            .field("external_reference", &self.external_reference)
113            .field("name", &self.name)
114            .field("meta", &self.meta)
115            .finish()
116    }
117}
118
119impl<T> RequestedSourceExport<T> {
120    fn change_meta<F>(self, new_meta: F) -> RequestedSourceExport<F> {
121        RequestedSourceExport {
122            external_reference: self.external_reference,
123            name: self.name,
124            meta: new_meta,
125        }
126    }
127}
128
129/// Generates a subsource name by prepending source schema name if present
130///
131/// For eg. if source is `a.b`, then `a` will be prepended to the subsource name
132/// so that it's generated in the same schema as source
133fn source_export_name_gen(
134    source_name: &UnresolvedItemName,
135    subsource_name: &str,
136) -> Result<UnresolvedItemName, PlanError> {
137    let mut partial = normalize::unresolved_item_name(source_name.clone())?;
138    partial.item = subsource_name.to_string();
139    Ok(UnresolvedItemName::from(partial))
140}
141
142// TODO(database-issues#8620): Remove once subsources are removed
143/// Validates the requested subsources do not have name conflicts with each other
144/// and that the same upstream table is not referenced multiple times.
145fn validate_source_export_names<T>(
146    requested_source_exports: &[RequestedSourceExport<T>],
147) -> Result<(), PlanError> {
148    // This condition would get caught during the catalog transaction, but produces a
149    // vague, non-contextual error. Instead, error here so we can suggest to the user
150    // how to fix the problem.
151    if let Some(name) = requested_source_exports
152        .iter()
153        .map(|subsource| &subsource.name)
154        .duplicates()
155        .next()
156        .cloned()
157    {
158        let mut upstream_references: Vec<_> = requested_source_exports
159            .into_iter()
160            .filter_map(|subsource| {
161                if &subsource.name == &name {
162                    Some(subsource.external_reference.clone())
163                } else {
164                    None
165                }
166            })
167            .collect();
168
169        upstream_references.sort();
170
171        Err(PlanError::SubsourceNameConflict {
172            name,
173            upstream_references,
174        })?;
175    }
176
177    // We disallow subsource statements from referencing the same upstream table, but we allow
178    // `CREATE TABLE .. FROM SOURCE` statements to do so. Since `CREATE TABLE .. FROM SOURCE`
179    // purification will only provide 1 `requested_source_export`, we can leave this here without
180    // needing to differentiate between the two types of statements.
181    // TODO(roshan): Remove this when auto-generated subsources are deprecated.
182    if let Some(name) = requested_source_exports
183        .iter()
184        .map(|export| &export.external_reference)
185        .duplicates()
186        .next()
187        .cloned()
188    {
189        let mut target_names: Vec<_> = requested_source_exports
190            .into_iter()
191            .filter_map(|export| {
192                if &export.external_reference == &name {
193                    Some(export.name.clone())
194                } else {
195                    None
196                }
197            })
198            .collect();
199
200        target_names.sort();
201
202        Err(PlanError::SubsourceDuplicateReference { name, target_names })?;
203    }
204
205    Ok(())
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub enum PurifiedStatement {
210    PurifiedCreateSource {
211        // The progress subsource, if we are offloading progress info to a separate relation
212        create_progress_subsource_stmt: Option<CreateSubsourceStatement<Aug>>,
213        create_source_stmt: CreateSourceStatement<Aug>,
214        // Map of subsource names to external details
215        subsources: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
216        /// All the available upstream references that can be added as tables
217        /// to this primary source.
218        available_source_references: SourceReferences,
219    },
220    PurifiedAlterSource {
221        alter_source_stmt: AlterSourceStatement<Aug>,
222    },
223    PurifiedAlterSourceAddSubsources {
224        // This just saves us an annoying catalog lookup
225        source_name: ResolvedItemName,
226        /// Options that we will need the values of to update the source's
227        /// definition.
228        options: Vec<AlterSourceAddSubsourceOption<Aug>>,
229        // Map of subsource names to external details
230        subsources: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
231    },
232    PurifiedAlterSourceRefreshReferences {
233        source_name: ResolvedItemName,
234        /// The updated available upstream references for the primary source.
235        available_source_references: SourceReferences,
236    },
237    PurifiedCreateSink(CreateSinkStatement<Aug>),
238    PurifiedCreateTableFromSource {
239        stmt: CreateTableFromSourceStatement<Aug>,
240    },
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct PurifiedSourceExport {
245    pub external_reference: UnresolvedItemName,
246    pub details: PurifiedExportDetails,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum PurifiedExportDetails {
251    MySql {
252        table: MySqlTableDesc,
253        text_columns: Option<Vec<Ident>>,
254        exclude_columns: Option<Vec<Ident>>,
255        initial_gtid_set: String,
256        binlog_full_metadata: bool,
257    },
258    Postgres {
259        table: PostgresTableDesc,
260        text_columns: Option<Vec<Ident>>,
261        exclude_columns: Option<Vec<Ident>>,
262        initial_lsn: MzOffset,
263    },
264    SqlServer {
265        table: SqlServerTableDesc,
266        text_columns: Option<Vec<Ident>>,
267        excl_columns: Option<Vec<Ident>>,
268        capture_instance: Arc<str>,
269        initial_lsn: mz_sql_server_util::cdc::Lsn,
270    },
271    Kafka {},
272    LoadGenerator {
273        table: Option<RelationDesc>,
274        output: LoadGeneratorOutput,
275    },
276}
277
278/// The existing source a statement drives, and how the statement uses it.
279///
280/// Resolved from the statement rather than from `ResolvedIds`: `ALTER SOURCE`
281/// carries its target as an `UnresolvedItemName` that name resolution never
282/// records.
283#[derive(Debug, Clone, Copy)]
284pub enum StatementSource {
285    Altered(CatalogItemId),
286    Read(CatalogItemId),
287}
288
289impl StatementSource {
290    pub fn id(&self) -> CatalogItemId {
291        match self {
292            StatementSource::Altered(id) | StatementSource::Read(id) => *id,
293        }
294    }
295}
296
297/// Resolves the existing source that `stmt` drives, mirroring how the
298/// purification paths below resolve it themselves.
299///
300/// `None` means the statement drives no such source: it names its connection
301/// outright (`CREATE SOURCE`, `CREATE SINK`), or the name does not resolve to a
302/// source. Purification reports the latter itself, and bails on it before
303/// opening any connection. A [`Statement`] variant that instead reaches
304/// upstream through an existing catalog item needs an arm here, or callers see
305/// no source at all.
306pub fn statement_source(
307    catalog: &impl SessionCatalog,
308    stmt: &Statement<Aug>,
309) -> Option<StatementSource> {
310    let scx = StatementContext::new(None, catalog);
311    match stmt {
312        Statement::AlterSource(stmt) => {
313            let item = scx
314                .resolve_item(RawItemName::Name(stmt.source_name.clone()))
315                .ok()?;
316            (item.item_type() == CatalogItemType::Source)
317                .then(|| StatementSource::Altered(item.id()))
318        }
319        Statement::CreateTableFromSource(stmt) => {
320            let item = scx.get_item_by_resolved_name(&stmt.source).ok()?;
321            (item.item_type() == CatalogItemType::Source).then(|| StatementSource::Read(item.id()))
322        }
323        _ => None,
324    }
325}
326
327/// Purifies a statement, removing any dependencies on external state.
328///
329/// See the section on [purification](crate#purification) in the crate
330/// documentation for details.
331///
332/// Note that this doesn't handle CREATE MATERIALIZED VIEW, which is
333/// handled by [purify_create_materialized_view_options] instead.
334/// This could be made more consistent by a refactoring discussed here:
335/// <https://github.com/MaterializeInc/materialize/pull/23870#discussion_r1435922709>
336pub async fn purify_statement(
337    catalog: impl SessionCatalog,
338    now: u64,
339    stmt: Statement<Aug>,
340    storage_configuration: &StorageConfiguration,
341) -> (Result<PurifiedStatement, PlanError>, Option<ClusterId>) {
342    match stmt {
343        Statement::CreateSource(stmt) => {
344            let cluster_id = stmt.in_cluster.as_ref().map(|cluster| cluster.id.clone());
345            (
346                purify_create_source(catalog, now, stmt, storage_configuration).await,
347                cluster_id,
348            )
349        }
350        Statement::AlterSource(stmt) => (
351            purify_alter_source(catalog, stmt, storage_configuration).await,
352            None,
353        ),
354        Statement::CreateSink(stmt) => {
355            let cluster_id = stmt.in_cluster.as_ref().map(|cluster| cluster.id.clone());
356            (
357                purify_create_sink(catalog, stmt, storage_configuration).await,
358                cluster_id,
359            )
360        }
361        Statement::CreateTableFromSource(stmt) => (
362            purify_create_table_from_source(catalog, stmt, storage_configuration).await,
363            None,
364        ),
365        o => (
366            Err(internal_err!(
367                "unexpected statement type in purification: {:?}",
368                o
369            )),
370            None,
371        ),
372    }
373}
374
375/// Injects `DOC ON` comments into all Avro formats that are using a schema
376/// registry by finding all SQL `COMMENT`s that are attached to the sink's
377/// underlying materialized view (as well as any types referenced by that
378/// underlying materialized view).
379pub(crate) fn purify_create_sink_avro_doc_on_options(
380    catalog: &dyn SessionCatalog,
381    from_id: CatalogItemId,
382    format: &mut Option<FormatSpecifier<Aug>>,
383) -> Result<(), PlanError> {
384    // Collect all objects referenced by the sink.
385    let from = catalog.get_item(&from_id);
386    let object_ids = from
387        .references()
388        .items()
389        .copied()
390        .chain_one(from.id())
391        .collect::<Vec<_>>();
392
393    // Collect all Avro formats that use a schema registry, as well as a set of
394    // all identifiers named in user-provided `DOC ON` options.
395    let mut avro_format_options = vec![];
396    for_each_format(format, |doc_on_schema, fmt| match fmt {
397        Format::Avro(AvroSchema::InlineSchema { .. })
398        | Format::Avro(AvroSchema::Glue { .. })
399        | Format::Bytes
400        | Format::Csv { .. }
401        | Format::Json { .. }
402        | Format::Protobuf(..)
403        | Format::Regex(..)
404        | Format::Text => (),
405        Format::Avro(AvroSchema::Csr {
406            csr_connection: CsrConnectionAvro { connection, .. },
407        }) => {
408            avro_format_options.push((doc_on_schema, &mut connection.options));
409        }
410    });
411
412    // For each Avro format in the sink, inject the appropriate `DOC ON` options
413    // for each item referenced by the sink.
414    for (for_schema, options) in avro_format_options {
415        let user_provided_comments = options
416            .iter()
417            .filter_map(|CsrConfigOption { name, .. }| match name {
418                CsrConfigOptionName::AvroDocOn(doc_on) => Some(doc_on.clone()),
419                _ => None,
420            })
421            .collect::<BTreeSet<_>>();
422
423        // Adding existing comments if not already provided by user
424        for object_id in &object_ids {
425            // Always add comments to the latest version of the item.
426            let item = catalog
427                .get_item(object_id)
428                .at_version(RelationVersionSelector::Latest);
429            let full_resolved_name = ResolvedItemName::Item {
430                id: *object_id,
431                qualifiers: item.name().qualifiers.clone(),
432                full_name: catalog.resolve_full_name(item.name()),
433                print_id: !matches!(item.item_type(), CatalogItemType::Func),
434                version: RelationVersionSelector::Latest,
435            };
436
437            if let Some(comments_map) = catalog.get_item_comments(object_id) {
438                // Attach comment for the item itself, if the user has not
439                // already provided an overriding `DOC ON` option for the item.
440                let doc_on_item_key = AvroDocOn {
441                    identifier: DocOnIdentifier::Type(full_resolved_name.clone()),
442                    for_schema,
443                };
444                if !user_provided_comments.contains(&doc_on_item_key) {
445                    if let Some(root_comment) = comments_map.get(&None) {
446                        options.push(CsrConfigOption {
447                            name: CsrConfigOptionName::AvroDocOn(doc_on_item_key),
448                            value: Some(mz_sql_parser::ast::WithOptionValue::Value(Value::String(
449                                root_comment.clone(),
450                            ))),
451                        });
452                    }
453                }
454
455                // Attach comment for each column in the item, if the user has
456                // not already provided an overriding `DOC ON` option for the
457                // column.
458                let column_descs = match item.type_details() {
459                    Some(details) => details.typ.desc(catalog).unwrap_or_default(),
460                    None => item.relation_desc().map(|d| d.into_owned()),
461                };
462
463                if let Some(desc) = column_descs {
464                    for (pos, column_name) in desc.iter_names().enumerate() {
465                        if let Some(comment_str) = comments_map.get(&Some(pos + 1)) {
466                            let doc_on_column_key = AvroDocOn {
467                                identifier: DocOnIdentifier::Column(ColumnName {
468                                    relation: full_resolved_name.clone(),
469                                    column: ResolvedColumnReference::Column {
470                                        name: column_name.to_owned(),
471                                        index: pos,
472                                    },
473                                }),
474                                for_schema,
475                            };
476                            if !user_provided_comments.contains(&doc_on_column_key) {
477                                options.push(CsrConfigOption {
478                                    name: CsrConfigOptionName::AvroDocOn(doc_on_column_key),
479                                    value: Some(mz_sql_parser::ast::WithOptionValue::Value(
480                                        Value::String(comment_str.clone()),
481                                    )),
482                                });
483                            }
484                        }
485                    }
486                }
487            }
488        }
489    }
490
491    Ok(())
492}
493
494/// Checks that the sink described in the statement can connect to its external
495/// resources.
496async fn purify_create_sink(
497    catalog: impl SessionCatalog,
498    mut create_sink_stmt: CreateSinkStatement<Aug>,
499    storage_configuration: &StorageConfiguration,
500) -> Result<PurifiedStatement, PlanError> {
501    // General purification
502    let CreateSinkStatement {
503        connection,
504        format,
505        with_options,
506        name: _,
507        in_cluster: _,
508        if_not_exists: _,
509        from,
510        envelope: _,
511        mode: _,
512    } = &mut create_sink_stmt;
513
514    // The list of options that the user is allowed to specify.
515    const USER_ALLOWED_WITH_OPTIONS: &[CreateSinkOptionName] = &[
516        CreateSinkOptionName::Snapshot,
517        CreateSinkOptionName::CommitInterval,
518    ];
519
520    if let Some(op) = with_options
521        .iter()
522        .find(|op| !USER_ALLOWED_WITH_OPTIONS.contains(&op.name))
523    {
524        sql_bail!(
525            "CREATE SINK...WITH ({}..) is not allowed",
526            op.name.to_ast_string_simple(),
527        )
528    }
529
530    match &connection {
531        CreateSinkConnection::Kafka {
532            connection,
533            options,
534            key: _,
535            headers: _,
536        } => {
537            // We must not leave any state behind in the Kafka broker, so just ensure that
538            // we can connect. This means we don't ensure that we can create the topic and
539            // introduces TOCTOU errors, but creating an inoperable sink is infinitely
540            // preferable to leaking state in users' environments.
541            let scx = StatementContext::new(None, &catalog);
542            let connection = {
543                let item = scx.get_item_by_resolved_name(connection)?;
544                // Get Kafka connection
545                match item.connection()? {
546                    Connection::Kafka(connection) => {
547                        connection.clone().into_inline_connection(scx.catalog)
548                    }
549                    _ => sql_bail!(
550                        "{} is not a kafka connection",
551                        scx.catalog.resolve_full_name(item.name())
552                    ),
553                }
554            };
555
556            let extracted_options: KafkaSinkConfigOptionExtracted = options.clone().try_into()?;
557
558            if extracted_options.legacy_ids == Some(true) {
559                sql_bail!("LEGACY IDs option is not supported");
560            }
561
562            let client: AdminClient<_> = connection
563                .create_with_context(
564                    storage_configuration,
565                    MzClientContext::default(),
566                    &BTreeMap::new(),
567                    InTask::No,
568                )
569                .await
570                .map_err(|e| {
571                    // anyhow doesn't support Clone, so not trivial to move into PlanError
572                    KafkaSinkPurificationError::AdminClientError(Arc::new(e))
573                })?;
574
575            let metadata = client
576                .inner()
577                .fetch_metadata(
578                    None,
579                    storage_configuration
580                        .parameters
581                        .kafka_timeout_config
582                        .fetch_metadata_timeout,
583                )
584                .map_err(|e| {
585                    KafkaSinkPurificationError::AdminClientError(Arc::new(
586                        ContextCreationError::KafkaError(e),
587                    ))
588                })?;
589
590            if metadata.brokers().len() == 0 {
591                Err(KafkaSinkPurificationError::ZeroBrokers)?;
592            }
593        }
594        CreateSinkConnection::Iceberg {
595            catalog_connection,
596            aws_connection,
597            ..
598        } => {
599            let scx = StatementContext::new(None, &catalog);
600            let connection = {
601                let item = scx.get_item_by_resolved_name(catalog_connection)?;
602                // Get Iceberg connection
603                match item.connection()? {
604                    Connection::IcebergCatalog(connection) => {
605                        connection.clone().into_inline_connection(scx.catalog)
606                    }
607                    _ => sql_bail!(
608                        "{} is not an iceberg connection",
609                        scx.catalog.resolve_full_name(item.name())
610                    ),
611                }
612            };
613
614            // For S3 Tables connections in the Materialize Cloud product, verify the
615            // AWS region matches the environment's region. This check only applies when
616            // the enable_s3_tables_region_check dyncfg is set.
617            if let Some(s3tables) = connection.s3tables_catalog() {
618                let enable_region_check =
619                    ENABLE_S3_TABLES_REGION_CHECK.get(scx.catalog.system_vars().dyncfgs());
620                if enable_region_check {
621                    let env_id = &catalog.config().environment_id;
622                    if matches!(env_id.cloud_provider(), CloudProvider::Aws) {
623                        let env_region = env_id.cloud_provider_region();
624                        // Later on we default to "us-east-1" if the region is not set on the S3 Tables
625                        // connection, so we need to do the same check here.
626                        let s3_tables_region = s3tables
627                            .aws_connection
628                            .connection
629                            .region
630                            .clone()
631                            .unwrap_or_else(|| "us-east-1".to_string());
632                        if s3_tables_region != env_region {
633                            Err(IcebergSinkPurificationError::S3TablesRegionMismatch {
634                                s3_tables_region,
635                                environment_region: env_region.to_string(),
636                            })?;
637                        }
638                    }
639                }
640            }
641
642            // Validate the sink's (optional) AWS connection even though we never use it.
643            // TODO(kynan): If we do start using the sink's creds, check again that this validation
644            //   accurately reflects what we need.
645            //   Consider rolling the storage creds validation into the catalog connection's "connect" fn,
646            //   which already validates the catalog creds (currently also used for the storage layer).
647            if let Some(aws_connection) = aws_connection {
648                let aws_conn_id = aws_connection.item_id();
649                let aws_connection = {
650                    let item = scx.get_item_by_resolved_name(aws_connection)?;
651                    // Get AWS connection
652                    match item.connection()? {
653                        Connection::Aws(aws_connection) => aws_connection.clone(),
654                        _ => sql_bail!(
655                            "{} is not an aws connection",
656                            scx.catalog.resolve_full_name(item.name())
657                        ),
658                    }
659                };
660
661                let _sdk_config = aws_connection
662                    .load_sdk_config(
663                        &storage_configuration.connection_context,
664                        aws_conn_id.clone(),
665                        InTask::No,
666                        mz_storage_types::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES
667                            .get(storage_configuration.config_set()),
668                    )
669                    .await
670                    .map_err(|e| IcebergSinkPurificationError::AwsSdkContextError(Arc::new(e)))?;
671            }
672
673            // Now that we've validated the sink's storage creds (if they exist)
674            // we _could_ use them to build a complete Iceberg client (both catalog and storage).
675            // TODO(kynan): Actually use those sink-specific creds here instead of ignoring them.
676            // Purification only proves the catalog is reachable, so it needs no table-scoped
677            // storage credentials.
678            let _catalog = connection
679                .connect(storage_configuration, InTask::No, None)
680                .await
681                .map_err(|e| IcebergSinkPurificationError::CatalogError(Arc::new(e)))?;
682        }
683    }
684
685    let mut csr_connection_ids = BTreeSet::new();
686    for_each_format(format, |_, fmt| match fmt {
687        Format::Avro(AvroSchema::InlineSchema { .. })
688        | Format::Avro(AvroSchema::Glue { .. })
689        | Format::Bytes
690        | Format::Csv { .. }
691        | Format::Json { .. }
692        | Format::Protobuf(ProtobufSchema::InlineSchema { .. })
693        | Format::Regex(..)
694        | Format::Text => (),
695        Format::Avro(AvroSchema::Csr {
696            csr_connection: CsrConnectionAvro { connection, .. },
697        })
698        | Format::Protobuf(ProtobufSchema::Csr {
699            csr_connection: CsrConnectionProtobuf { connection, .. },
700        }) => {
701            csr_connection_ids.insert(*connection.connection.item_id());
702        }
703    });
704
705    let scx = StatementContext::new(None, &catalog);
706    for csr_connection_id in csr_connection_ids {
707        let connection = {
708            let item = scx.get_item(&csr_connection_id);
709            // Get Kafka connection
710            match item.connection()? {
711                Connection::Csr(connection) => connection.clone().into_inline_connection(&catalog),
712                _ => Err(CsrPurificationError::NotCsrConnection(
713                    scx.catalog.resolve_full_name(item.name()),
714                ))?,
715            }
716        };
717
718        let client = connection
719            .connect(storage_configuration, InTask::No)
720            .await
721            .map_err(|e| CsrPurificationError::ClientError(Arc::new(e)))?;
722
723        client
724            .list_subjects()
725            .await
726            .map_err(|e| CsrPurificationError::ListSubjectsError(Arc::new(e)))?;
727    }
728
729    purify_create_sink_avro_doc_on_options(&catalog, *from.item_id(), format)?;
730
731    Ok(PurifiedStatement::PurifiedCreateSink(create_sink_stmt))
732}
733
734/// Runs a function on each format within the format specifier.
735///
736/// The function is provided with a `DocOnSchema` that indicates whether the
737/// format is for the key, value, or both.
738///
739// TODO(benesch): rename `DocOnSchema` to the more general `FormatRestriction`.
740fn for_each_format<'a, F>(format: &'a mut Option<FormatSpecifier<Aug>>, mut f: F)
741where
742    F: FnMut(DocOnSchema, &'a mut Format<Aug>),
743{
744    match format {
745        None => (),
746        Some(FormatSpecifier::Bare(fmt)) => f(DocOnSchema::All, fmt),
747        Some(FormatSpecifier::KeyValue { key, value }) => {
748            f(DocOnSchema::KeyOnly, key);
749            f(DocOnSchema::ValueOnly, value);
750        }
751    }
752}
753
754/// Defines whether purification should enforce that at least one valid source
755/// reference is provided on the provided statement.
756#[derive(Debug, Copy, Clone, PartialEq, Eq)]
757pub(crate) enum SourceReferencePolicy {
758    /// Don't allow source references to be provided. This is used for
759    /// enforcing that `CREATE SOURCE` statements don't create subsources.
760    NotAllowed,
761    /// Allow empty references, such as when creating a source that
762    /// will have tables added afterwards.
763    Optional,
764    /// Require that at least one reference is resolved to an upstream
765    /// object.
766    Required,
767}
768
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub(crate) enum FilterConstraints {
771    ExcludeAll,
772    Exclude(BTreeSet<String>),
773}
774
775impl FilterConstraints {
776    pub(crate) fn none() -> Self {
777        FilterConstraints::Exclude(BTreeSet::new())
778    }
779
780    fn from_options(
781        exclude_constraints: Vec<String>,
782        exclude_all_constraints: bool,
783    ) -> Result<Self, PlanError> {
784        if !exclude_all_constraints {
785            return Ok(FilterConstraints::Exclude(
786                exclude_constraints.into_iter().collect(),
787            ));
788        }
789        if !exclude_constraints.is_empty() {
790            sql_bail!("EXCLUDE ALL CONSTRAINTS cannot be combined with EXCLUDE CONSTRAINTS");
791        }
792        Ok(FilterConstraints::ExcludeAll)
793    }
794
795    pub(crate) fn excludes_any(&self) -> bool {
796        match self {
797            FilterConstraints::ExcludeAll => true,
798            FilterConstraints::Exclude(names) => !names.is_empty(),
799        }
800    }
801}
802
803async fn purify_create_source(
804    catalog: impl SessionCatalog,
805    now: u64,
806    mut create_source_stmt: CreateSourceStatement<Aug>,
807    storage_configuration: &StorageConfiguration,
808) -> Result<PurifiedStatement, PlanError> {
809    let CreateSourceStatement {
810        name: source_name,
811        col_names,
812        key_constraint,
813        connection: source_connection,
814        format,
815        envelope,
816        include_metadata,
817        external_references,
818        progress_subsource,
819        with_options,
820        ..
821    } = &mut create_source_stmt;
822
823    let uses_old_syntax = !col_names.is_empty()
824        || key_constraint.is_some()
825        || format.is_some()
826        || envelope.is_some()
827        || !include_metadata.is_empty()
828        || external_references.is_some()
829        || progress_subsource.is_some();
830
831    if let Some(DeferredItemName::Named(_)) = progress_subsource {
832        sql_bail!("Cannot manually ID qualify progress subsource")
833    }
834
835    let mut requested_subsource_map = BTreeMap::new();
836
837    let progress_desc = match &source_connection {
838        CreateSourceConnection::Kafka { .. } => {
839            &mz_storage_types::sources::kafka::KAFKA_PROGRESS_DESC
840        }
841        CreateSourceConnection::Postgres { .. } => {
842            &mz_storage_types::sources::postgres::PG_PROGRESS_DESC
843        }
844        CreateSourceConnection::SqlServer { .. } => {
845            &mz_storage_types::sources::sql_server::SQL_SERVER_PROGRESS_DESC
846        }
847        CreateSourceConnection::MySql { .. } => {
848            &mz_storage_types::sources::mysql::MYSQL_PROGRESS_DESC
849        }
850        CreateSourceConnection::LoadGenerator { .. } => {
851            &mz_storage_types::sources::load_generator::LOAD_GEN_PROGRESS_DESC
852        }
853    };
854    let scx = StatementContext::new(None, &catalog);
855
856    // Depending on if the user must or can use the `CREATE TABLE .. FROM SOURCE` statement
857    // to add tables after this source is created we might need to enforce that
858    // auto-generated subsources are created or not created by this source statement.
859    let reference_policy = if scx.catalog.system_vars().enable_create_table_from_source()
860        && scx.catalog.system_vars().force_source_table_syntax()
861    {
862        SourceReferencePolicy::NotAllowed
863    } else if scx.catalog.system_vars().enable_create_table_from_source() {
864        SourceReferencePolicy::Optional
865    } else {
866        SourceReferencePolicy::Required
867    };
868
869    let mut format_options = SourceFormatOptions::Default;
870
871    let retrieved_source_references: RetrievedSourceReferences;
872
873    match source_connection {
874        CreateSourceConnection::Kafka {
875            connection,
876            options: base_with_options,
877            ..
878        } => {
879            if let Some(external_references) = external_references {
880                Err(KafkaSourcePurificationError::ReferencedSubsources(
881                    external_references.clone(),
882                ))?;
883            }
884
885            let connection = {
886                let item = scx.get_item_by_resolved_name(connection)?;
887                // Get Kafka connection
888                match item.connection()? {
889                    Connection::Kafka(connection) => {
890                        connection.clone().into_inline_connection(&catalog)
891                    }
892                    _ => Err(KafkaSourcePurificationError::NotKafkaConnection(
893                        scx.catalog.resolve_full_name(item.name()),
894                    ))?,
895                }
896            };
897
898            let extracted_options: KafkaSourceConfigOptionExtracted =
899                base_with_options.clone().try_into()?;
900
901            let topic = extracted_options
902                .topic
903                .ok_or(KafkaSourcePurificationError::ConnectionMissingTopic)?;
904
905            let consumer = connection
906                .create_with_context(
907                    storage_configuration,
908                    MzClientContext::default(),
909                    &BTreeMap::new(),
910                    InTask::No,
911                )
912                .await
913                .map_err(|e| {
914                    // anyhow doesn't support Clone, so not trivial to move into PlanError
915                    KafkaSourcePurificationError::KafkaConsumerError(
916                        e.display_with_causes().to_string(),
917                    )
918                })?;
919            let consumer = Arc::new(consumer);
920
921            match (
922                extracted_options.start_offset,
923                extracted_options.start_timestamp,
924            ) {
925                (None, None) => {
926                    // Validate that the topic at least exists.
927                    kafka_util::ensure_topic_exists(
928                        Arc::clone(&consumer),
929                        &topic,
930                        storage_configuration
931                            .parameters
932                            .kafka_timeout_config
933                            .fetch_metadata_timeout,
934                    )
935                    .await?;
936                }
937                (Some(_), Some(_)) => {
938                    sql_bail!("cannot specify START TIMESTAMP and START OFFSET at same time")
939                }
940                (Some(start_offsets), None) => {
941                    // Validate the start offsets.
942                    kafka_util::validate_start_offsets(
943                        Arc::clone(&consumer),
944                        &topic,
945                        start_offsets,
946                        storage_configuration
947                            .parameters
948                            .kafka_timeout_config
949                            .fetch_metadata_timeout,
950                    )
951                    .await?;
952                }
953                (None, Some(time_offset)) => {
954                    // Translate `START TIMESTAMP` to a start offset.
955                    let start_offsets = kafka_util::lookup_start_offsets(
956                        Arc::clone(&consumer),
957                        &topic,
958                        time_offset,
959                        now,
960                        storage_configuration
961                            .parameters
962                            .kafka_timeout_config
963                            .fetch_metadata_timeout,
964                    )
965                    .await?;
966
967                    base_with_options.retain(|val| {
968                        !matches!(val.name, KafkaSourceConfigOptionName::StartTimestamp)
969                    });
970                    base_with_options.push(KafkaSourceConfigOption {
971                        name: KafkaSourceConfigOptionName::StartOffset,
972                        value: Some(WithOptionValue::Sequence(
973                            start_offsets
974                                .iter()
975                                .map(|offset| {
976                                    WithOptionValue::Value(Value::Number(offset.to_string()))
977                                })
978                                .collect(),
979                        )),
980                    });
981                }
982            }
983
984            let reference_client = SourceReferenceClient::Kafka { topic: &topic };
985            retrieved_source_references = reference_client.get_source_references().await?;
986
987            format_options = SourceFormatOptions::Kafka { topic };
988        }
989        CreateSourceConnection::Postgres {
990            connection,
991            options,
992        } => {
993            let connection_item = scx.get_item_by_resolved_name(connection)?;
994            let connection = match connection_item.connection().map_err(PlanError::from)? {
995                Connection::Postgres(connection) => {
996                    connection.clone().into_inline_connection(&catalog)
997                }
998                _ => Err(PgSourcePurificationError::NotPgConnection(
999                    scx.catalog.resolve_full_name(connection_item.name()),
1000                ))?,
1001            };
1002            let crate::plan::statement::PgConfigOptionExtracted {
1003                publication,
1004                text_columns,
1005                exclude_columns,
1006                details,
1007                ..
1008            } = options.clone().try_into()?;
1009            let publication =
1010                publication.ok_or(PgSourcePurificationError::ConnectionMissingPublication)?;
1011
1012            if details.is_some() {
1013                Err(PgSourcePurificationError::UserSpecifiedDetails)?;
1014            }
1015
1016            let client = connection
1017                .validate(connection_item.id(), storage_configuration)
1018                .await?;
1019
1020            let reference_client = SourceReferenceClient::Postgres {
1021                client: &client,
1022                publication: &publication,
1023                database: &connection.database,
1024            };
1025            retrieved_source_references = reference_client.get_source_references().await?;
1026
1027            // Record whether the upstream server is a physical replica, which changes how we can determine
1028            // the latest LSN.
1029            let is_physical_replica = mz_postgres_util::get_is_in_recovery(&client).await?;
1030
1031            // Read after the references above, so it bounds the LSN their schemas belong to.
1032            let initial_lsn = MzOffset::from(
1033                mz_postgres_util::fetch_max_lsn(&client, is_physical_replica).await?,
1034            );
1035
1036            let postgres::PurifiedSourceExports {
1037                source_exports: subsources,
1038                normalized_text_columns,
1039            } = postgres::purify_source_exports(
1040                &client,
1041                &retrieved_source_references,
1042                external_references,
1043                text_columns,
1044                exclude_columns,
1045                &FilterConstraints::none(),
1046                false,
1047                source_name,
1048                &reference_policy,
1049                initial_lsn,
1050            )
1051            .await?;
1052
1053            if let Some(text_cols_option) = options
1054                .iter_mut()
1055                .find(|option| option.name == PgConfigOptionName::TextColumns)
1056            {
1057                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1058            }
1059
1060            requested_subsource_map.extend(subsources);
1061
1062            // Record the active replication timeline_id to allow detection of a future upstream
1063            // point-in-time-recovery that will put the source into an error state.
1064            let timeline_id = mz_postgres_util::get_timeline_id(&client).await?;
1065
1066            // Remove any old detail references
1067            options.retain(|PgConfigOption { name, .. }| name != &PgConfigOptionName::Details);
1068            let details = PostgresSourcePublicationDetails {
1069                slot: format!(
1070                    "materialize_{}",
1071                    Uuid::new_v4().to_string().replace('-', "")
1072                ),
1073                timeline_id: Some(timeline_id),
1074                database: connection.database,
1075                is_physical_replica: Some(is_physical_replica),
1076            };
1077            options.push(PgConfigOption {
1078                name: PgConfigOptionName::Details,
1079                value: Some(WithOptionValue::Value(Value::String(hex::encode(
1080                    details.into_proto().encode_to_vec(),
1081                )))),
1082            })
1083        }
1084        CreateSourceConnection::SqlServer {
1085            connection,
1086            options,
1087        } => {
1088            // Connect to the upstream SQL Server instance so we can validate
1089            // we're compatible with CDC.
1090            let connection_item = scx.get_item_by_resolved_name(connection)?;
1091            let connection = match connection_item.connection()? {
1092                Connection::SqlServer(connection) => {
1093                    connection.clone().into_inline_connection(&catalog)
1094                }
1095                _ => Err(SqlServerSourcePurificationError::NotSqlServerConnection(
1096                    scx.catalog.resolve_full_name(connection_item.name()),
1097                ))?,
1098            };
1099            let crate::plan::statement::ddl::SqlServerConfigOptionExtracted {
1100                details,
1101                text_columns,
1102                exclude_columns,
1103                seen: _,
1104            } = options.clone().try_into()?;
1105
1106            if details.is_some() {
1107                Err(SqlServerSourcePurificationError::UserSpecifiedDetails)?;
1108            }
1109
1110            let mut client = connection
1111                .validate(connection_item.id(), storage_configuration)
1112                .await?;
1113
1114            let database: Arc<str> = connection.database.into();
1115            let reference_client = SourceReferenceClient::SqlServer {
1116                client: &mut client,
1117                database: Arc::clone(&database),
1118            };
1119            retrieved_source_references = reference_client.get_source_references().await?;
1120            tracing::debug!(?retrieved_source_references, "got source references");
1121
1122            let timeout = mz_storage_types::sources::sql_server::MAX_LSN_WAIT
1123                .get(storage_configuration.config_set());
1124
1125            let purified_source_exports = sql_server::purify_source_exports(
1126                &*database,
1127                &mut client,
1128                &retrieved_source_references,
1129                external_references,
1130                &text_columns,
1131                &exclude_columns,
1132                source_name,
1133                timeout,
1134                &reference_policy,
1135            )
1136            .await?;
1137
1138            let sql_server::PurifiedSourceExports {
1139                source_exports,
1140                normalized_text_columns,
1141                normalized_excl_columns,
1142            } = purified_source_exports;
1143
1144            // Update our set of requested source exports.
1145            requested_subsource_map.extend(source_exports);
1146
1147            // Record the most recent restore_history_id, or none if the system has never been
1148            // restored.
1149
1150            let restore_history_id =
1151                mz_sql_server_util::inspect::get_latest_restore_history_id(&mut client).await?;
1152            let details = SqlServerSourceExtras { restore_history_id };
1153
1154            options.retain(|SqlServerConfigOption { name, .. }| {
1155                name != &SqlServerConfigOptionName::Details
1156            });
1157            options.push(SqlServerConfigOption {
1158                name: SqlServerConfigOptionName::Details,
1159                value: Some(WithOptionValue::Value(Value::String(hex::encode(
1160                    details.into_proto().encode_to_vec(),
1161                )))),
1162            });
1163
1164            // Update our 'TEXT' and 'EXCLUDE' column options with the purified and normalized set.
1165            if let Some(text_cols_option) = options
1166                .iter_mut()
1167                .find(|option| option.name == SqlServerConfigOptionName::TextColumns)
1168            {
1169                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1170            }
1171            if let Some(excl_cols_option) = options
1172                .iter_mut()
1173                .find(|option| option.name == SqlServerConfigOptionName::ExcludeColumns)
1174            {
1175                excl_cols_option.value = Some(WithOptionValue::Sequence(normalized_excl_columns));
1176            }
1177        }
1178        CreateSourceConnection::MySql {
1179            connection,
1180            options,
1181        } => {
1182            let connection_item = scx.get_item_by_resolved_name(connection)?;
1183            let connection = match connection_item.connection()? {
1184                Connection::MySql(connection) => {
1185                    connection.clone().into_inline_connection(&catalog)
1186                }
1187                _ => Err(MySqlSourcePurificationError::NotMySqlConnection(
1188                    scx.catalog.resolve_full_name(connection_item.name()),
1189                ))?,
1190            };
1191            let crate::plan::statement::ddl::MySqlConfigOptionExtracted {
1192                details,
1193                text_columns,
1194                exclude_columns,
1195                seen: _,
1196            } = options.clone().try_into()?;
1197
1198            if details.is_some() {
1199                Err(MySqlSourcePurificationError::UserSpecifiedDetails)?;
1200            }
1201
1202            let mut conn = connection
1203                .validate(connection_item.id(), storage_configuration)
1204                .await
1205                .map_err(MySqlSourcePurificationError::InvalidConnection)?;
1206
1207            // Retrieve the current @gtid_executed value of the server to mark as the effective
1208            // initial snapshot point such that we can ensure consistency if the initial source
1209            // snapshot is broken up over multiple points in time.
1210            let initial_gtid_set =
1211                mz_mysql_util::query_sys_var(&mut conn, "global.gtid_executed").await?;
1212
1213            let binlog_full_metadata = is_binlog_full_metadata(&mut conn).await?;
1214
1215            let reference_client = SourceReferenceClient::MySql {
1216                conn: &mut conn,
1217                include_system_schemas: mysql::references_system_schemas(external_references),
1218            };
1219            retrieved_source_references = reference_client.get_source_references().await?;
1220
1221            let mysql::PurifiedSourceExports {
1222                source_exports: subsources,
1223                normalized_text_columns,
1224                normalized_exclude_columns,
1225            } = mysql::purify_source_exports(
1226                &mut conn,
1227                &retrieved_source_references,
1228                external_references,
1229                text_columns,
1230                exclude_columns,
1231                &FilterConstraints::none(),
1232                false,
1233                source_name,
1234                initial_gtid_set.clone(),
1235                &reference_policy,
1236                binlog_full_metadata,
1237            )
1238            .await?;
1239            requested_subsource_map.extend(subsources);
1240
1241            // We don't have any fields in this details struct but keep this around for
1242            // conformity with postgres and in-case we end up needing it again in the future.
1243            let details = MySqlSourceDetails {};
1244            // Update options with the purified details
1245            options
1246                .retain(|MySqlConfigOption { name, .. }| name != &MySqlConfigOptionName::Details);
1247            options.push(MySqlConfigOption {
1248                name: MySqlConfigOptionName::Details,
1249                value: Some(WithOptionValue::Value(Value::String(hex::encode(
1250                    details.into_proto().encode_to_vec(),
1251                )))),
1252            });
1253
1254            if let Some(text_cols_option) = options
1255                .iter_mut()
1256                .find(|option| option.name == MySqlConfigOptionName::TextColumns)
1257            {
1258                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1259            }
1260            if let Some(exclude_cols_option) = options
1261                .iter_mut()
1262                .find(|option| option.name == MySqlConfigOptionName::ExcludeColumns)
1263            {
1264                exclude_cols_option.value =
1265                    Some(WithOptionValue::Sequence(normalized_exclude_columns));
1266            }
1267        }
1268        CreateSourceConnection::LoadGenerator { generator, options } => {
1269            let load_generator =
1270                load_generator_ast_to_generator(&scx, generator, options, include_metadata)?;
1271
1272            let reference_client = SourceReferenceClient::LoadGenerator {
1273                generator: &load_generator,
1274            };
1275            retrieved_source_references = reference_client.get_source_references().await?;
1276            // Filter to the references that need to be created as 'subsources', which
1277            // doesn't include the default output for single-output sources.
1278            // TODO(database-issues#8620): Remove once subsources are removed
1279            let multi_output_sources =
1280                retrieved_source_references
1281                    .all_references()
1282                    .iter()
1283                    .any(|r| {
1284                        matches!(
1285                            r.load_generator_output(),
1286                            Some(output) if output != &LoadGeneratorOutput::Default
1287                        )
1288                    });
1289
1290            match external_references {
1291                Some(requested)
1292                    if matches!(reference_policy, SourceReferencePolicy::NotAllowed) =>
1293                {
1294                    Err(PlanError::UseTablesForSources(requested.to_string()))?
1295                }
1296                Some(requested) if !multi_output_sources => match requested {
1297                    ExternalReferences::SubsetTables(_) => {
1298                        Err(LoadGeneratorSourcePurificationError::ForTables)?
1299                    }
1300                    ExternalReferences::SubsetSchemas(_) => {
1301                        Err(LoadGeneratorSourcePurificationError::ForSchemas)?
1302                    }
1303                    ExternalReferences::All => {
1304                        Err(LoadGeneratorSourcePurificationError::ForAllTables)?
1305                    }
1306                },
1307                Some(requested) => {
1308                    let requested_exports = retrieved_source_references
1309                        .requested_source_exports(Some(requested), source_name)?;
1310                    for export in requested_exports {
1311                        requested_subsource_map.insert(
1312                            export.name,
1313                            PurifiedSourceExport {
1314                                external_reference: export.external_reference,
1315                                details: PurifiedExportDetails::LoadGenerator {
1316                                    table: export
1317                                        .meta
1318                                        .load_generator_desc()
1319                                        .ok_or_else(|| {
1320                                            internal_err!(
1321                                                "expected load generator source reference"
1322                                            )
1323                                        })?
1324                                        .clone(),
1325                                    output: export
1326                                        .meta
1327                                        .load_generator_output()
1328                                        .ok_or_else(|| {
1329                                            internal_err!(
1330                                                "expected load generator source reference"
1331                                            )
1332                                        })?
1333                                        .clone(),
1334                                },
1335                            },
1336                        );
1337                    }
1338                }
1339                None => {
1340                    if multi_output_sources
1341                        && matches!(reference_policy, SourceReferencePolicy::Required)
1342                    {
1343                        Err(LoadGeneratorSourcePurificationError::MultiOutputRequiresForAllTables)?
1344                    }
1345                }
1346            }
1347
1348            if let LoadGenerator::Clock = generator {
1349                if !options
1350                    .iter()
1351                    .any(|p| p.name == LoadGeneratorOptionName::AsOf)
1352                {
1353                    let now = catalog.now();
1354                    options.push(LoadGeneratorOption {
1355                        name: LoadGeneratorOptionName::AsOf,
1356                        value: Some(WithOptionValue::Value(Value::Number(now.to_string()))),
1357                    });
1358                }
1359            }
1360        }
1361    }
1362
1363    // Now that we know which subsources to create alongside this
1364    // statement, remove the references so it is not canonicalized as
1365    // part of the `CREATE SOURCE` statement in the catalog.
1366    *external_references = None;
1367
1368    // Generate progress subsource for old syntax
1369    let create_progress_subsource_stmt = if uses_old_syntax {
1370        // Take name from input or generate name
1371        let name = match progress_subsource {
1372            Some(name) => match name {
1373                DeferredItemName::Deferred(name) => name.clone(),
1374                // Already checked for this value above.
1375                DeferredItemName::Named(_) => {
1376                    sql_bail!("progress subsource name cannot be a resolved name")
1377                }
1378            },
1379            None => {
1380                let (item, prefix) = source_name
1381                    .0
1382                    .split_last()
1383                    .ok_or_else(|| sql_err!("source name must have at least one component"))?;
1384                let item_name =
1385                    Ident::try_generate_name(item.to_string(), "_progress", |candidate| {
1386                        let mut suggested_name = prefix.to_vec();
1387                        suggested_name.push(candidate.clone());
1388
1389                        let partial =
1390                            normalize::unresolved_item_name(UnresolvedItemName(suggested_name))?;
1391                        let qualified = scx.allocate_qualified_name(partial)?;
1392                        let item_exists = scx.catalog.get_item_by_name(&qualified).is_some();
1393                        let type_exists = scx.catalog.get_type_by_name(&qualified).is_some();
1394                        Ok::<_, PlanError>(!item_exists && !type_exists)
1395                    })?;
1396
1397                let mut full_name = prefix.to_vec();
1398                full_name.push(item_name);
1399                let full_name = normalize::unresolved_item_name(UnresolvedItemName(full_name))?;
1400                let qualified_name = scx.allocate_qualified_name(full_name)?;
1401                let full_name = scx.catalog.resolve_full_name(&qualified_name);
1402
1403                UnresolvedItemName::from(full_name.clone())
1404            }
1405        };
1406
1407        let (columns, constraints) = scx.relation_desc_into_table_defs(progress_desc)?;
1408
1409        // Create the subsource statement
1410        let mut progress_with_options: Vec<_> = with_options
1411            .iter()
1412            .filter_map(|opt| match opt.name {
1413                CreateSourceOptionName::TimestampInterval => None,
1414                CreateSourceOptionName::RetainHistory => Some(CreateSubsourceOption {
1415                    name: CreateSubsourceOptionName::RetainHistory,
1416                    value: opt.value.clone(),
1417                }),
1418            })
1419            .collect();
1420        progress_with_options.push(CreateSubsourceOption {
1421            name: CreateSubsourceOptionName::Progress,
1422            value: Some(WithOptionValue::Value(Value::Boolean(true))),
1423        });
1424
1425        Some(CreateSubsourceStatement {
1426            name,
1427            columns,
1428            // Progress subsources do not refer to the source to which they belong.
1429            // Instead the primary source depends on it (the opposite is true of
1430            // ingestion exports, which depend on the primary source).
1431            of_source: None,
1432            constraints,
1433            if_not_exists: false,
1434            with_options: progress_with_options,
1435        })
1436    } else {
1437        None
1438    };
1439
1440    purify_source_format(
1441        &catalog,
1442        format,
1443        &format_options,
1444        envelope,
1445        storage_configuration,
1446    )
1447    .await?;
1448
1449    Ok(PurifiedStatement::PurifiedCreateSource {
1450        create_progress_subsource_stmt,
1451        create_source_stmt,
1452        subsources: requested_subsource_map,
1453        available_source_references: retrieved_source_references.available_source_references(),
1454    })
1455}
1456
1457/// On success, returns the details on new subsources and updated
1458/// 'options' that sequencing expects for handling `ALTER SOURCE` statements.
1459async fn purify_alter_source(
1460    catalog: impl SessionCatalog,
1461    stmt: AlterSourceStatement<Aug>,
1462    storage_configuration: &StorageConfiguration,
1463) -> Result<PurifiedStatement, PlanError> {
1464    let scx = StatementContext::new(None, &catalog);
1465    let AlterSourceStatement {
1466        source_name: unresolved_source_name,
1467        action,
1468        if_exists,
1469    } = stmt;
1470
1471    // Get name.
1472    let item = match scx.resolve_item(RawItemName::Name(unresolved_source_name.clone())) {
1473        Ok(item) => item,
1474        Err(_) if if_exists => {
1475            return Ok(PurifiedStatement::PurifiedAlterSource {
1476                alter_source_stmt: AlterSourceStatement {
1477                    source_name: unresolved_source_name,
1478                    action,
1479                    if_exists,
1480                },
1481            });
1482        }
1483        Err(e) => return Err(e),
1484    };
1485
1486    // Ensure it's an ingestion-based and alterable source.
1487    let desc = match item.source_desc()? {
1488        Some(desc) => desc.clone().into_inline_connection(scx.catalog),
1489        None => {
1490            sql_bail!("cannot ALTER this type of source")
1491        }
1492    };
1493
1494    let source_name = item.name();
1495
1496    let resolved_source_name = ResolvedItemName::Item {
1497        id: item.id(),
1498        qualifiers: item.name().qualifiers.clone(),
1499        full_name: scx.catalog.resolve_full_name(source_name),
1500        print_id: true,
1501        version: RelationVersionSelector::Latest,
1502    };
1503
1504    let partial_name = scx.catalog.minimal_qualification(source_name);
1505
1506    match action {
1507        AlterSourceAction::AddSubsources {
1508            external_references,
1509            options,
1510        } => {
1511            if scx.catalog.system_vars().enable_create_table_from_source()
1512                && scx.catalog.system_vars().force_source_table_syntax()
1513            {
1514                Err(PlanError::UseTablesForSources(
1515                    "ALTER SOURCE .. ADD SUBSOURCES ..".to_string(),
1516                ))?;
1517            }
1518
1519            purify_alter_source_add_subsources(
1520                external_references,
1521                options,
1522                desc,
1523                partial_name,
1524                unresolved_source_name,
1525                resolved_source_name,
1526                storage_configuration,
1527            )
1528            .await
1529        }
1530        AlterSourceAction::RefreshReferences => {
1531            purify_alter_source_refresh_references(
1532                desc,
1533                resolved_source_name,
1534                storage_configuration,
1535            )
1536            .await
1537        }
1538        _ => Ok(PurifiedStatement::PurifiedAlterSource {
1539            alter_source_stmt: AlterSourceStatement {
1540                source_name: unresolved_source_name,
1541                action,
1542                if_exists,
1543            },
1544        }),
1545    }
1546}
1547
1548// TODO(database-issues#8620): Remove once subsources are removed
1549/// Equivalent to `purify_create_source` but for `AlterSourceStatement`.
1550async fn purify_alter_source_add_subsources(
1551    external_references: Vec<ExternalReferenceExport>,
1552    mut options: Vec<AlterSourceAddSubsourceOption<Aug>>,
1553    desc: SourceDesc,
1554    partial_source_name: PartialItemName,
1555    unresolved_source_name: UnresolvedItemName,
1556    resolved_source_name: ResolvedItemName,
1557    storage_configuration: &StorageConfiguration,
1558) -> Result<PurifiedStatement, PlanError> {
1559    // Validate this is a source that can have subsources added.
1560    let connection_id = match &desc.connection {
1561        GenericSourceConnection::Postgres(c) => c.connection_id,
1562        GenericSourceConnection::MySql(c) => c.connection_id,
1563        GenericSourceConnection::SqlServer(c) => c.connection_id,
1564        _ => sql_bail!(
1565            "source {} does not support ALTER SOURCE.",
1566            partial_source_name
1567        ),
1568    };
1569
1570    let crate::plan::statement::ddl::AlterSourceAddSubsourceOptionExtracted {
1571        text_columns,
1572        exclude_columns,
1573        details,
1574        seen: _,
1575    } = options.clone().try_into()?;
1576    if details.is_some() {
1577        sql_bail!("DETAILS option cannot be explicitly set");
1578    }
1579
1580    let mut requested_subsource_map = BTreeMap::new();
1581
1582    match desc.connection {
1583        GenericSourceConnection::Postgres(pg_source_connection) => {
1584            // Get PostgresConnection for generating subsources.
1585            let pg_connection = &pg_source_connection.connection;
1586
1587            let client = pg_connection
1588                .validate(connection_id, storage_configuration)
1589                .await?;
1590
1591            let reference_client = SourceReferenceClient::Postgres {
1592                client: &client,
1593                publication: &pg_source_connection.publication,
1594                database: &pg_connection.database,
1595            };
1596            let retrieved_source_references = reference_client.get_source_references().await?;
1597
1598            // Read after the references above, so it bounds the LSN their schemas belong to.
1599            let initial_lsn = MzOffset::from(
1600                mz_postgres_util::fetch_max_lsn(
1601                    &client,
1602                    pg_source_connection
1603                        .publication_details
1604                        .get_is_physical_replica(),
1605                )
1606                .await?,
1607            );
1608
1609            let postgres::PurifiedSourceExports {
1610                source_exports: subsources,
1611                normalized_text_columns,
1612            } = postgres::purify_source_exports(
1613                &client,
1614                &retrieved_source_references,
1615                &Some(ExternalReferences::SubsetTables(external_references)),
1616                text_columns,
1617                exclude_columns,
1618                &FilterConstraints::none(),
1619                false,
1620                &unresolved_source_name,
1621                &SourceReferencePolicy::Required,
1622                initial_lsn,
1623            )
1624            .await?;
1625
1626            if let Some(text_cols_option) = options
1627                .iter_mut()
1628                .find(|option| option.name == AlterSourceAddSubsourceOptionName::TextColumns)
1629            {
1630                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1631            }
1632
1633            requested_subsource_map.extend(subsources);
1634        }
1635        GenericSourceConnection::MySql(mysql_source_connection) => {
1636            let mysql_connection = &mysql_source_connection.connection;
1637            let config = mysql_connection
1638                .config(
1639                    &storage_configuration.connection_context.secrets_reader,
1640                    storage_configuration,
1641                    InTask::No,
1642                )
1643                .await?;
1644
1645            let mut conn = config
1646                .connect(
1647                    "mysql purification",
1648                    &storage_configuration.connection_context.ssh_tunnel_manager,
1649                )
1650                .await?;
1651
1652            // Retrieve the current @gtid_executed value of the server to mark as the effective
1653            // initial snapshot point for these subsources.
1654            let initial_gtid_set =
1655                mz_mysql_util::query_sys_var(&mut conn, "global.gtid_executed").await?;
1656
1657            let binlog_full_metadata = is_binlog_full_metadata(&mut conn).await?;
1658
1659            let requested_references = Some(ExternalReferences::SubsetTables(external_references));
1660
1661            let reference_client = SourceReferenceClient::MySql {
1662                conn: &mut conn,
1663                include_system_schemas: mysql::references_system_schemas(&requested_references),
1664            };
1665            let retrieved_source_references = reference_client.get_source_references().await?;
1666
1667            let mysql::PurifiedSourceExports {
1668                source_exports: subsources,
1669                normalized_text_columns,
1670                normalized_exclude_columns,
1671            } = mysql::purify_source_exports(
1672                &mut conn,
1673                &retrieved_source_references,
1674                &requested_references,
1675                text_columns,
1676                exclude_columns,
1677                &FilterConstraints::none(),
1678                false,
1679                &unresolved_source_name,
1680                initial_gtid_set,
1681                &SourceReferencePolicy::Required,
1682                binlog_full_metadata,
1683            )
1684            .await?;
1685            requested_subsource_map.extend(subsources);
1686
1687            // Update options with the purified details
1688            if let Some(text_cols_option) = options
1689                .iter_mut()
1690                .find(|option| option.name == AlterSourceAddSubsourceOptionName::TextColumns)
1691            {
1692                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1693            }
1694            if let Some(exclude_cols_option) = options
1695                .iter_mut()
1696                .find(|option| option.name == AlterSourceAddSubsourceOptionName::ExcludeColumns)
1697            {
1698                exclude_cols_option.value =
1699                    Some(WithOptionValue::Sequence(normalized_exclude_columns));
1700            }
1701        }
1702        GenericSourceConnection::SqlServer(sql_server_source) => {
1703            // Open a connection to the upstream SQL Server instance.
1704            let sql_server_connection = &sql_server_source.connection;
1705            let config = sql_server_connection
1706                .resolve_config(
1707                    &storage_configuration.connection_context.secrets_reader,
1708                    storage_configuration,
1709                    InTask::No,
1710                )
1711                .await?;
1712            let mut client = mz_sql_server_util::Client::connect(config).await?;
1713
1714            // Query the upstream SQL Server instance for available tables to replicate.
1715            let database = sql_server_connection.database.clone().into();
1716            let source_references = SourceReferenceClient::SqlServer {
1717                client: &mut client,
1718                database: Arc::clone(&database),
1719            }
1720            .get_source_references()
1721            .await?;
1722            let requested_references = Some(ExternalReferences::SubsetTables(external_references));
1723
1724            let timeout = mz_storage_types::sources::sql_server::MAX_LSN_WAIT
1725                .get(storage_configuration.config_set());
1726
1727            let result = sql_server::purify_source_exports(
1728                &*database,
1729                &mut client,
1730                &source_references,
1731                &requested_references,
1732                &text_columns,
1733                &exclude_columns,
1734                &unresolved_source_name,
1735                timeout,
1736                &SourceReferencePolicy::Required,
1737            )
1738            .await;
1739            let sql_server::PurifiedSourceExports {
1740                source_exports,
1741                normalized_text_columns,
1742                normalized_excl_columns,
1743            } = result?;
1744
1745            // Add the new exports to our subsource map.
1746            requested_subsource_map.extend(source_exports);
1747
1748            // Update options on the CREATE SOURCE statement with the purified details.
1749            if let Some(text_cols_option) = options
1750                .iter_mut()
1751                .find(|option| option.name == AlterSourceAddSubsourceOptionName::TextColumns)
1752            {
1753                text_cols_option.value = Some(WithOptionValue::Sequence(normalized_text_columns));
1754            }
1755            if let Some(exclude_cols_option) = options
1756                .iter_mut()
1757                .find(|option| option.name == AlterSourceAddSubsourceOptionName::ExcludeColumns)
1758            {
1759                exclude_cols_option.value =
1760                    Some(WithOptionValue::Sequence(normalized_excl_columns));
1761            }
1762        }
1763        // The source kind was already established earlier in this function;
1764        // reaching this catch-all is an internal invariant violation.
1765        _ => bail_internal!("source does not support ALTER SOURCE...ADD SUBSOURCE"),
1766    };
1767
1768    Ok(PurifiedStatement::PurifiedAlterSourceAddSubsources {
1769        source_name: resolved_source_name,
1770        options,
1771        subsources: requested_subsource_map,
1772    })
1773}
1774
1775async fn purify_alter_source_refresh_references(
1776    desc: SourceDesc,
1777    resolved_source_name: ResolvedItemName,
1778    storage_configuration: &StorageConfiguration,
1779) -> Result<PurifiedStatement, PlanError> {
1780    let retrieved_source_references = match desc.connection {
1781        GenericSourceConnection::Postgres(pg_source_connection) => {
1782            // Get PostgresConnection for generating subsources.
1783            let pg_connection = &pg_source_connection.connection;
1784
1785            let config = pg_connection
1786                .config(
1787                    &storage_configuration.connection_context.secrets_reader,
1788                    storage_configuration,
1789                    InTask::No,
1790                )
1791                .await?;
1792
1793            let client = config
1794                .connect(
1795                    "postgres_purification",
1796                    &storage_configuration.connection_context.ssh_tunnel_manager,
1797                )
1798                .await?;
1799            let reference_client = SourceReferenceClient::Postgres {
1800                client: &client,
1801                publication: &pg_source_connection.publication,
1802                database: &pg_connection.database,
1803            };
1804            reference_client.get_source_references().await?
1805        }
1806        GenericSourceConnection::MySql(mysql_source_connection) => {
1807            let mysql_connection = &mysql_source_connection.connection;
1808            let config = mysql_connection
1809                .config(
1810                    &storage_configuration.connection_context.secrets_reader,
1811                    storage_configuration,
1812                    InTask::No,
1813                )
1814                .await?;
1815
1816            let mut conn = config
1817                .connect(
1818                    "mysql purification",
1819                    &storage_configuration.connection_context.ssh_tunnel_manager,
1820                )
1821                .await?;
1822
1823            let reference_client = SourceReferenceClient::MySql {
1824                conn: &mut conn,
1825                include_system_schemas: false,
1826            };
1827            reference_client.get_source_references().await?
1828        }
1829        GenericSourceConnection::SqlServer(sql_server_source) => {
1830            // Open a connection to the upstream SQL Server instance.
1831            let sql_server_connection = &sql_server_source.connection;
1832            let config = sql_server_connection
1833                .resolve_config(
1834                    &storage_configuration.connection_context.secrets_reader,
1835                    storage_configuration,
1836                    InTask::No,
1837                )
1838                .await?;
1839            let mut client = mz_sql_server_util::Client::connect(config).await?;
1840
1841            // Query the upstream SQL Server instance for available tables to replicate.
1842            let source_references = SourceReferenceClient::SqlServer {
1843                client: &mut client,
1844                database: sql_server_connection.database.clone().into(),
1845            }
1846            .get_source_references()
1847            .await?;
1848            source_references
1849        }
1850        GenericSourceConnection::LoadGenerator(load_gen_connection) => {
1851            let reference_client = SourceReferenceClient::LoadGenerator {
1852                generator: &load_gen_connection.load_generator,
1853            };
1854            reference_client.get_source_references().await?
1855        }
1856        GenericSourceConnection::Kafka(kafka_conn) => {
1857            let reference_client = SourceReferenceClient::Kafka {
1858                topic: &kafka_conn.topic,
1859            };
1860            reference_client.get_source_references().await?
1861        }
1862    };
1863    Ok(PurifiedStatement::PurifiedAlterSourceRefreshReferences {
1864        source_name: resolved_source_name,
1865        available_source_references: retrieved_source_references.available_source_references(),
1866    })
1867}
1868
1869async fn purify_create_table_from_source(
1870    catalog: impl SessionCatalog,
1871    mut stmt: CreateTableFromSourceStatement<Aug>,
1872    storage_configuration: &StorageConfiguration,
1873) -> Result<PurifiedStatement, PlanError> {
1874    let scx = StatementContext::new(None, &catalog);
1875    let CreateTableFromSourceStatement {
1876        name: _,
1877        columns,
1878        constraints,
1879        source: source_name,
1880        if_not_exists: _,
1881        external_reference,
1882        format,
1883        envelope,
1884        include_metadata: _,
1885        with_options,
1886    } = &mut stmt;
1887
1888    // Columns and constraints cannot be specified by the user but will be populated below.
1889    if matches!(columns, TableFromSourceColumns::Defined(_)) {
1890        sql_bail!("CREATE TABLE .. FROM SOURCE column definitions cannot be specified directly");
1891    }
1892    if !constraints.is_empty() {
1893        sql_bail!(
1894            "CREATE TABLE .. FROM SOURCE constraint definitions cannot be specified directly"
1895        );
1896    }
1897
1898    // Get the source item
1899    let item = match scx.get_item_by_resolved_name(source_name) {
1900        Ok(item) => item,
1901        Err(e) => return Err(e),
1902    };
1903
1904    // Ensure it's an ingestion-based and alterable source.
1905    let desc = match item.source_desc()? {
1906        Some(desc) => desc.clone().into_inline_connection(scx.catalog),
1907        None => {
1908            sql_bail!("cannot ALTER this type of source")
1909        }
1910    };
1911    let unresolved_source_name: UnresolvedItemName = source_name.full_item_name().clone().into();
1912
1913    let crate::plan::statement::ddl::TableFromSourceOptionExtracted {
1914        text_columns,
1915        exclude_columns,
1916        exclude_constraints,
1917        exclude_all_constraints,
1918        exclude_all_not_null_constraints,
1919        retain_history: _,
1920        details,
1921        partition_by: _,
1922        seen: _,
1923    } = with_options.clone().try_into()?;
1924    if details.is_some() {
1925        sql_bail!("DETAILS option cannot be explicitly set");
1926    }
1927
1928    let filter_constraints =
1929        FilterConstraints::from_options(exclude_constraints, exclude_all_constraints)?;
1930    if filter_constraints.excludes_any() || exclude_all_not_null_constraints {
1931        scx.require_feature_flag(&crate::session::vars::ENABLE_EXCLUDE_CONSTRAINTS_OPTION)?;
1932    }
1933
1934    // Our text column values are unqualified (just column names), but the purification methods below
1935    // expect to match the fully-qualified names against the full set of tables in upstream, so we
1936    // need to qualify them using the external reference first.
1937    let qualified_text_columns = text_columns
1938        .iter()
1939        .map(|col| {
1940            UnresolvedItemName(
1941                external_reference
1942                    .as_ref()
1943                    .map(|er| er.0.iter().chain_one(col).map(|i| i.clone()).collect())
1944                    .unwrap_or_else(|| vec![col.clone()]),
1945            )
1946        })
1947        .collect_vec();
1948    let qualified_exclude_columns = exclude_columns
1949        .iter()
1950        .map(|col| {
1951            UnresolvedItemName(
1952                external_reference
1953                    .as_ref()
1954                    .map(|er| er.0.iter().chain_one(col).map(|i| i.clone()).collect())
1955                    .unwrap_or_else(|| vec![col.clone()]),
1956            )
1957        })
1958        .collect_vec();
1959
1960    // Should be overriden below if a source-specific format is required.
1961    let mut format_options = SourceFormatOptions::Default;
1962
1963    let retrieved_source_references: RetrievedSourceReferences;
1964
1965    let requested_references = external_reference.as_ref().map(|ref_name| {
1966        ExternalReferences::SubsetTables(vec![ExternalReferenceExport {
1967            reference: ref_name.clone(),
1968            alias: None,
1969        }])
1970    });
1971
1972    if (filter_constraints.excludes_any() || exclude_all_not_null_constraints)
1973        && !matches!(
1974            desc.connection,
1975            GenericSourceConnection::Postgres(_) | GenericSourceConnection::MySql(_)
1976        )
1977    {
1978        sql_bail!(
1979            "EXCLUDE CONSTRAINTS is not supported for {} sources",
1980            desc.connection.name()
1981        );
1982    }
1983
1984    // Run purification work specific to each source type: resolve the external reference to
1985    // a fully qualified name and obtain the appropriate details for the source-export statement
1986    let purified_export = match desc.connection {
1987        GenericSourceConnection::Postgres(pg_source_connection) => {
1988            // Get PostgresConnection for generating subsources.
1989            let pg_connection = &pg_source_connection.connection;
1990
1991            let client = pg_connection
1992                .validate(pg_source_connection.connection_id, storage_configuration)
1993                .await?;
1994
1995            let reference_client = SourceReferenceClient::Postgres {
1996                client: &client,
1997                publication: &pg_source_connection.publication,
1998                database: &pg_connection.database,
1999            };
2000            retrieved_source_references = reference_client.get_source_references().await?;
2001
2002            // Read after the references above, so it bounds the LSN their schemas belong to.
2003            let initial_lsn = MzOffset::from(
2004                mz_postgres_util::fetch_max_lsn(
2005                    &client,
2006                    pg_source_connection
2007                        .publication_details
2008                        .get_is_physical_replica(),
2009                )
2010                .await?,
2011            );
2012
2013            let postgres::PurifiedSourceExports {
2014                source_exports,
2015                // TODO(database-issues#8620): Remove once subsources are removed
2016                // This `normalized_text_columns` is not relevant for us and is only returned for
2017                // `CREATE SOURCE` statements that automatically generate subsources
2018                normalized_text_columns: _,
2019            } = postgres::purify_source_exports(
2020                &client,
2021                &retrieved_source_references,
2022                &requested_references,
2023                qualified_text_columns,
2024                qualified_exclude_columns,
2025                &filter_constraints,
2026                exclude_all_not_null_constraints,
2027                &unresolved_source_name,
2028                &SourceReferencePolicy::Required,
2029                initial_lsn,
2030            )
2031            .await?;
2032            // There should be exactly one source_export returned for this statement
2033            let (_, purified_export) = source_exports.into_element();
2034            purified_export
2035        }
2036        GenericSourceConnection::MySql(mysql_source_connection) => {
2037            let mysql_connection = &mysql_source_connection.connection;
2038            let config = mysql_connection
2039                .config(
2040                    &storage_configuration.connection_context.secrets_reader,
2041                    storage_configuration,
2042                    InTask::No,
2043                )
2044                .await?;
2045
2046            let mut conn = config
2047                .connect(
2048                    "mysql purification",
2049                    &storage_configuration.connection_context.ssh_tunnel_manager,
2050                )
2051                .await?;
2052
2053            ensure_binlog_full_metadata(&mut conn).await?;
2054            let binlog_full_metadata = true;
2055
2056            // Retrieve the current @gtid_executed value of the server to mark as the effective
2057            // initial snapshot point for this table.
2058            let initial_gtid_set =
2059                mz_mysql_util::query_sys_var(&mut conn, "global.gtid_executed").await?;
2060
2061            let reference_client = SourceReferenceClient::MySql {
2062                conn: &mut conn,
2063                include_system_schemas: mysql::references_system_schemas(&requested_references),
2064            };
2065            retrieved_source_references = reference_client.get_source_references().await?;
2066
2067            let mysql::PurifiedSourceExports {
2068                source_exports,
2069                // TODO(database-issues#8620): Remove once subsources are removed
2070                // `normalized_text/exclude_columns` is not relevant for us and is only returned for
2071                // `CREATE SOURCE` statements that automatically generate subsources
2072                normalized_text_columns: _,
2073                normalized_exclude_columns: _,
2074            } = mysql::purify_source_exports(
2075                &mut conn,
2076                &retrieved_source_references,
2077                &requested_references,
2078                qualified_text_columns,
2079                qualified_exclude_columns,
2080                &filter_constraints,
2081                exclude_all_not_null_constraints,
2082                &unresolved_source_name,
2083                initial_gtid_set,
2084                &SourceReferencePolicy::Required,
2085                binlog_full_metadata,
2086            )
2087            .await?;
2088            // There should be exactly one source_export returned for this statement
2089            let (_, purified_export) = source_exports.into_element();
2090            purified_export
2091        }
2092        GenericSourceConnection::SqlServer(sql_server_source) => {
2093            let connection = sql_server_source.connection;
2094            let config = connection
2095                .resolve_config(
2096                    &storage_configuration.connection_context.secrets_reader,
2097                    storage_configuration,
2098                    InTask::No,
2099                )
2100                .await?;
2101            let mut client = mz_sql_server_util::Client::connect(config).await?;
2102
2103            let database: Arc<str> = connection.database.into();
2104            let reference_client = SourceReferenceClient::SqlServer {
2105                client: &mut client,
2106                database: Arc::clone(&database),
2107            };
2108            retrieved_source_references = reference_client.get_source_references().await?;
2109            tracing::debug!(?retrieved_source_references, "got source references");
2110
2111            let timeout = mz_storage_types::sources::sql_server::MAX_LSN_WAIT
2112                .get(storage_configuration.config_set());
2113
2114            let purified_source_exports = sql_server::purify_source_exports(
2115                &*database,
2116                &mut client,
2117                &retrieved_source_references,
2118                &requested_references,
2119                &qualified_text_columns,
2120                &qualified_exclude_columns,
2121                &unresolved_source_name,
2122                timeout,
2123                &SourceReferencePolicy::Required,
2124            )
2125            .await?;
2126
2127            // There should be exactly one source_export returned for this statement
2128            let (_, purified_export) = purified_source_exports.source_exports.into_element();
2129            purified_export
2130        }
2131        GenericSourceConnection::LoadGenerator(load_gen_connection) => {
2132            let reference_client = SourceReferenceClient::LoadGenerator {
2133                generator: &load_gen_connection.load_generator,
2134            };
2135            retrieved_source_references = reference_client.get_source_references().await?;
2136
2137            let requested_exports = retrieved_source_references
2138                .requested_source_exports(requested_references.as_ref(), &unresolved_source_name)?;
2139            // There should be exactly one source_export returned
2140            let export = requested_exports.into_element();
2141            PurifiedSourceExport {
2142                external_reference: export.external_reference,
2143                details: PurifiedExportDetails::LoadGenerator {
2144                    table: export
2145                        .meta
2146                        .load_generator_desc()
2147                        .ok_or_else(|| internal_err!("expected load generator source reference"))?
2148                        .clone(),
2149                    output: export
2150                        .meta
2151                        .load_generator_output()
2152                        .ok_or_else(|| internal_err!("expected load generator source reference"))?
2153                        .clone(),
2154                },
2155            }
2156        }
2157        GenericSourceConnection::Kafka(kafka_conn) => {
2158            let reference_client = SourceReferenceClient::Kafka {
2159                topic: &kafka_conn.topic,
2160            };
2161            retrieved_source_references = reference_client.get_source_references().await?;
2162            let requested_exports = retrieved_source_references
2163                .requested_source_exports(requested_references.as_ref(), &unresolved_source_name)?;
2164            // There should be exactly one source_export returned
2165            let export = requested_exports.into_element();
2166
2167            format_options = SourceFormatOptions::Kafka {
2168                topic: kafka_conn.topic.clone(),
2169            };
2170            PurifiedSourceExport {
2171                external_reference: export.external_reference,
2172                details: PurifiedExportDetails::Kafka {},
2173            }
2174        }
2175    };
2176
2177    purify_source_format(
2178        &catalog,
2179        format,
2180        &format_options,
2181        envelope,
2182        storage_configuration,
2183    )
2184    .await?;
2185
2186    // Update the external reference in the statement to the resolved fully-qualified
2187    // external reference
2188    *external_reference = Some(purified_export.external_reference.clone());
2189
2190    // Update options in the statement using the purified export details
2191    match &purified_export.details {
2192        PurifiedExportDetails::Postgres { .. } => {
2193            let mut unsupported_cols = vec![];
2194            let postgres::PostgresExportStatementValues {
2195                columns: gen_columns,
2196                constraints: gen_constraints,
2197                text_columns: gen_text_columns,
2198                exclude_columns: gen_exclude_columns,
2199                details: gen_details,
2200                external_reference: _,
2201            } = postgres::generate_source_export_statement_values(
2202                &scx,
2203                purified_export,
2204                &mut unsupported_cols,
2205            )?;
2206            if !unsupported_cols.is_empty() {
2207                unsupported_cols.sort();
2208                Err(PgSourcePurificationError::UnrecognizedTypes {
2209                    cols: unsupported_cols,
2210                })?;
2211            }
2212
2213            if let Some(text_cols_option) = with_options
2214                .iter_mut()
2215                .find(|option| option.name == TableFromSourceOptionName::TextColumns)
2216            {
2217                if let Some(gen_text_columns) = gen_text_columns {
2218                    text_cols_option.value = Some(WithOptionValue::Sequence(gen_text_columns));
2219                }
2220            }
2221            if let Some(exclude_cols_option) = with_options
2222                .iter_mut()
2223                .find(|option| option.name == TableFromSourceOptionName::ExcludeColumns)
2224            {
2225                if let Some(gen_exclude_columns) = gen_exclude_columns {
2226                    exclude_cols_option.value =
2227                        Some(WithOptionValue::Sequence(gen_exclude_columns));
2228                }
2229            }
2230            match columns {
2231                TableFromSourceColumns::Defined(_) => {
2232                    // Purification is what populates the `Defined` variant;
2233                    // reaching this is an internal invariant violation.
2234                    bail_internal!(
2235                        "column definitions cannot be explicitly set for this source type"
2236                    )
2237                }
2238                TableFromSourceColumns::NotSpecified => {
2239                    *columns = TableFromSourceColumns::Defined(gen_columns);
2240                    *constraints = gen_constraints;
2241                }
2242                TableFromSourceColumns::Named(_) => {
2243                    sql_bail!("columns cannot be named for Postgres sources")
2244                }
2245            }
2246            with_options.push(TableFromSourceOption {
2247                name: TableFromSourceOptionName::Details,
2248                value: Some(WithOptionValue::Value(Value::String(hex::encode(
2249                    gen_details.into_proto().encode_to_vec(),
2250                )))),
2251            })
2252        }
2253        PurifiedExportDetails::MySql { .. } => {
2254            let mysql::MySqlExportStatementValues {
2255                columns: gen_columns,
2256                constraints: gen_constraints,
2257                text_columns: gen_text_columns,
2258                exclude_columns: gen_exclude_columns,
2259                details: gen_details,
2260                external_reference: _,
2261            } = mysql::generate_source_export_statement_values(&scx, purified_export)?;
2262
2263            if let Some(text_cols_option) = with_options
2264                .iter_mut()
2265                .find(|option| option.name == TableFromSourceOptionName::TextColumns)
2266            {
2267                if let Some(gen_text_columns) = gen_text_columns {
2268                    text_cols_option.value = Some(WithOptionValue::Sequence(gen_text_columns));
2269                }
2270            }
2271            if let Some(exclude_cols_option) = with_options
2272                .iter_mut()
2273                .find(|option| option.name == TableFromSourceOptionName::ExcludeColumns)
2274            {
2275                if let Some(gen_exclude_columns) = gen_exclude_columns {
2276                    exclude_cols_option.value =
2277                        Some(WithOptionValue::Sequence(gen_exclude_columns));
2278                }
2279            }
2280            match columns {
2281                TableFromSourceColumns::Defined(_) => {
2282                    // Purification is what populates the `Defined` variant;
2283                    // reaching this is an internal invariant violation.
2284                    bail_internal!(
2285                        "column definitions cannot be explicitly set for this source type"
2286                    )
2287                }
2288                TableFromSourceColumns::NotSpecified => {
2289                    *columns = TableFromSourceColumns::Defined(gen_columns);
2290                    *constraints = gen_constraints;
2291                }
2292                TableFromSourceColumns::Named(_) => {
2293                    sql_bail!("columns cannot be named for MySQL sources")
2294                }
2295            }
2296            with_options.push(TableFromSourceOption {
2297                name: TableFromSourceOptionName::Details,
2298                value: Some(WithOptionValue::Value(Value::String(hex::encode(
2299                    gen_details.into_proto().encode_to_vec(),
2300                )))),
2301            })
2302        }
2303        PurifiedExportDetails::SqlServer { .. } => {
2304            let sql_server::SqlServerExportStatementValues {
2305                columns: gen_columns,
2306                constraints: gen_constraints,
2307                text_columns: gen_text_columns,
2308                excl_columns: gen_excl_columns,
2309                details: gen_details,
2310                external_reference: _,
2311            } = sql_server::generate_source_export_statement_values(&scx, purified_export)?;
2312
2313            if let Some(text_cols_option) = with_options
2314                .iter_mut()
2315                .find(|opt| opt.name == TableFromSourceOptionName::TextColumns)
2316            {
2317                if let Some(gen_text_columns) = gen_text_columns {
2318                    text_cols_option.value = Some(WithOptionValue::Sequence(gen_text_columns));
2319                }
2320            }
2321            if let Some(exclude_cols_option) = with_options
2322                .iter_mut()
2323                .find(|opt| opt.name == TableFromSourceOptionName::ExcludeColumns)
2324            {
2325                if let Some(gen_excl_columns) = gen_excl_columns {
2326                    exclude_cols_option.value = Some(WithOptionValue::Sequence(gen_excl_columns));
2327                }
2328            }
2329
2330            match columns {
2331                TableFromSourceColumns::NotSpecified => {
2332                    *columns = TableFromSourceColumns::Defined(gen_columns);
2333                    *constraints = gen_constraints;
2334                }
2335                TableFromSourceColumns::Named(_) => {
2336                    sql_bail!("columns cannot be named for SQL Server sources")
2337                }
2338                TableFromSourceColumns::Defined(_) => {
2339                    // Purification is what populates the `Defined` variant;
2340                    // reaching this is an internal invariant violation.
2341                    bail_internal!(
2342                        "column definitions cannot be explicitly set for this source type"
2343                    )
2344                }
2345            }
2346
2347            with_options.push(TableFromSourceOption {
2348                name: TableFromSourceOptionName::Details,
2349                value: Some(WithOptionValue::Value(Value::String(hex::encode(
2350                    gen_details.into_proto().encode_to_vec(),
2351                )))),
2352            })
2353        }
2354        PurifiedExportDetails::LoadGenerator { .. } => {
2355            let (desc, output) = match purified_export.details {
2356                PurifiedExportDetails::LoadGenerator { table, output } => (table, output),
2357                _ => bail_internal!("purified export details must be load generator"),
2358            };
2359            // We only determine the table description for multi-output load generator sources here,
2360            // whereas single-output load generators will have their relation description
2361            // determined during statment planning as envelope and format options may affect their
2362            // schema.
2363            if let Some(desc) = desc {
2364                let (gen_columns, gen_constraints) = scx.relation_desc_into_table_defs(&desc)?;
2365                match columns {
2366                    // Purification is what populates the `Defined` variant;
2367                    // reaching this is an internal invariant violation.
2368                    TableFromSourceColumns::Defined(_) => bail_internal!(
2369                        "column definitions cannot be explicitly set for this source type"
2370                    ),
2371                    TableFromSourceColumns::NotSpecified => {
2372                        *columns = TableFromSourceColumns::Defined(gen_columns);
2373                        *constraints = gen_constraints;
2374                    }
2375                    TableFromSourceColumns::Named(_) => {
2376                        sql_bail!("columns cannot be named for multi-output load generator sources")
2377                    }
2378                }
2379            }
2380            let details = SourceExportStatementDetails::LoadGenerator { output };
2381            with_options.push(TableFromSourceOption {
2382                name: TableFromSourceOptionName::Details,
2383                value: Some(WithOptionValue::Value(Value::String(hex::encode(
2384                    details.into_proto().encode_to_vec(),
2385                )))),
2386            })
2387        }
2388        PurifiedExportDetails::Kafka {} => {
2389            // NOTE: Kafka tables have their 'schemas' purified into the statement inside the
2390            // format field, so we don't specify any columns or constraints to be stored
2391            // on the statement here. The RelationDesc will be determined during planning.
2392            let details = SourceExportStatementDetails::Kafka {};
2393            with_options.push(TableFromSourceOption {
2394                name: TableFromSourceOptionName::Details,
2395                value: Some(WithOptionValue::Value(Value::String(hex::encode(
2396                    details.into_proto().encode_to_vec(),
2397                )))),
2398            })
2399        }
2400    };
2401
2402    // TODO: We might as well use the retrieved available references to update the source
2403    // available references table in the catalog, so plumb this through.
2404    // available_source_references: retrieved_source_references.available_source_references(),
2405    Ok(PurifiedStatement::PurifiedCreateTableFromSource { stmt })
2406}
2407
2408enum SourceFormatOptions {
2409    Default,
2410    Kafka { topic: String },
2411}
2412
2413async fn purify_source_format(
2414    catalog: &dyn SessionCatalog,
2415    format: &mut Option<FormatSpecifier<Aug>>,
2416    options: &SourceFormatOptions,
2417    envelope: &Option<SourceEnvelope>,
2418    storage_configuration: &StorageConfiguration,
2419) -> Result<(), PlanError> {
2420    if matches!(format, Some(FormatSpecifier::KeyValue { .. }))
2421        && !matches!(options, SourceFormatOptions::Kafka { .. })
2422    {
2423        sql_bail!("Kafka sources are the only source type that can provide KEY/VALUE formats")
2424    }
2425
2426    match format.as_mut() {
2427        None => {}
2428        Some(FormatSpecifier::Bare(format)) => {
2429            purify_source_format_single(catalog, format, options, envelope, storage_configuration)
2430                .await?;
2431        }
2432
2433        Some(FormatSpecifier::KeyValue { key, value: val }) => {
2434            purify_source_format_single(catalog, key, options, envelope, storage_configuration)
2435                .await?;
2436            purify_source_format_single(catalog, val, options, envelope, storage_configuration)
2437                .await?;
2438        }
2439    }
2440    Ok(())
2441}
2442
2443async fn purify_source_format_single(
2444    catalog: &dyn SessionCatalog,
2445    format: &mut Format<Aug>,
2446    options: &SourceFormatOptions,
2447    envelope: &Option<SourceEnvelope>,
2448    storage_configuration: &StorageConfiguration,
2449) -> Result<(), PlanError> {
2450    match format {
2451        Format::Avro(schema) => match schema {
2452            AvroSchema::Csr { csr_connection } => {
2453                purify_csr_connection_avro(
2454                    catalog,
2455                    options,
2456                    csr_connection,
2457                    envelope,
2458                    storage_configuration,
2459                )
2460                .await?
2461            }
2462            AvroSchema::InlineSchema { .. } => {}
2463            AvroSchema::Glue {
2464                connection,
2465                with_options,
2466                seed,
2467            } => {
2468                purify_glue_connection_avro(
2469                    catalog,
2470                    options,
2471                    connection,
2472                    with_options,
2473                    seed,
2474                    storage_configuration,
2475                )
2476                .await?
2477            }
2478        },
2479        Format::Protobuf(schema) => match schema {
2480            ProtobufSchema::Csr { csr_connection } => {
2481                purify_csr_connection_proto(
2482                    catalog,
2483                    options,
2484                    csr_connection,
2485                    envelope,
2486                    storage_configuration,
2487                )
2488                .await?;
2489            }
2490            ProtobufSchema::InlineSchema { .. } => {}
2491        },
2492        Format::Bytes
2493        | Format::Regex(_)
2494        | Format::Json { .. }
2495        | Format::Text
2496        | Format::Csv { .. } => (),
2497    }
2498    Ok(())
2499}
2500
2501pub fn generate_subsource_statements(
2502    scx: &StatementContext,
2503    source_name: ResolvedItemName,
2504    subsources: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
2505) -> Result<Vec<CreateSubsourceStatement<Aug>>, PlanError> {
2506    // get the first subsource to determine the connection type
2507    if subsources.is_empty() {
2508        return Ok(vec![]);
2509    }
2510    let (_, purified_export) = subsources
2511        .iter()
2512        .next()
2513        .ok_or_else(|| internal_err!("expected at least one subsource"))?;
2514
2515    let statements = match &purified_export.details {
2516        PurifiedExportDetails::Postgres { .. } => {
2517            crate::pure::postgres::generate_create_subsource_statements(
2518                scx,
2519                source_name,
2520                subsources,
2521            )?
2522        }
2523        PurifiedExportDetails::MySql { .. } => {
2524            crate::pure::mysql::generate_create_subsource_statements(scx, source_name, subsources)?
2525        }
2526        PurifiedExportDetails::SqlServer { .. } => {
2527            crate::pure::sql_server::generate_create_subsource_statements(
2528                scx,
2529                source_name,
2530                subsources,
2531            )?
2532        }
2533        PurifiedExportDetails::LoadGenerator { .. } => {
2534            let mut subsource_stmts = Vec::with_capacity(subsources.len());
2535            for (subsource_name, purified_export) in subsources {
2536                let (desc, output) = match purified_export.details {
2537                    PurifiedExportDetails::LoadGenerator { table, output } => (table, output),
2538                    _ => {
2539                        bail_internal!("purified export details must be load generator")
2540                    }
2541                };
2542                let desc = desc.ok_or_else(|| {
2543                    internal_err!(
2544                        "subsources cannot be generated for single-output load generators"
2545                    )
2546                })?;
2547
2548                let (columns, table_constraints) = scx.relation_desc_into_table_defs(&desc)?;
2549                let details = SourceExportStatementDetails::LoadGenerator { output };
2550                // Create the subsource statement
2551                let subsource = CreateSubsourceStatement {
2552                    name: subsource_name,
2553                    columns,
2554                    of_source: Some(source_name.clone()),
2555                    // unlike sources that come from an external upstream, we
2556                    // have more leniency to introduce different constraints
2557                    // every time the load generator is run; i.e. we are not as
2558                    // worried about introducing junk data.
2559                    constraints: table_constraints,
2560                    if_not_exists: false,
2561                    with_options: vec![
2562                        CreateSubsourceOption {
2563                            name: CreateSubsourceOptionName::ExternalReference,
2564                            value: Some(WithOptionValue::UnresolvedItemName(
2565                                purified_export.external_reference,
2566                            )),
2567                        },
2568                        CreateSubsourceOption {
2569                            name: CreateSubsourceOptionName::Details,
2570                            value: Some(WithOptionValue::Value(Value::String(hex::encode(
2571                                details.into_proto().encode_to_vec(),
2572                            )))),
2573                        },
2574                    ],
2575                };
2576                subsource_stmts.push(subsource);
2577            }
2578
2579            subsource_stmts
2580        }
2581        PurifiedExportDetails::Kafka { .. } => {
2582            // TODO: as part of database-issues#8322, Kafka sources will begin
2583            // producing data––we'll need to understand the schema
2584            // of the output here.
2585            if !subsources.is_empty() {
2586                bail_internal!("Kafka sources do not produce data-bearing subsources");
2587            }
2588            vec![]
2589        }
2590    };
2591    Ok(statements)
2592}
2593
2594async fn purify_csr_connection_proto(
2595    catalog: &dyn SessionCatalog,
2596    options: &SourceFormatOptions,
2597    csr_connection: &mut CsrConnectionProtobuf<Aug>,
2598    envelope: &Option<SourceEnvelope>,
2599    storage_configuration: &StorageConfiguration,
2600) -> Result<(), PlanError> {
2601    let SourceFormatOptions::Kafka { topic } = options else {
2602        sql_bail!("Confluent Schema Registry is only supported with Kafka sources")
2603    };
2604
2605    let CsrConnectionProtobuf {
2606        seed,
2607        connection: CsrConnection {
2608            connection,
2609            options: _,
2610        },
2611    } = csr_connection;
2612    match seed {
2613        None => {
2614            let scx = StatementContext::new(None, &*catalog);
2615
2616            let ccsr_connection = match scx.get_item_by_resolved_name(connection)?.connection()? {
2617                Connection::Csr(connection) => connection.clone().into_inline_connection(catalog),
2618                _ => sql_bail!("{} is not a schema registry connection", connection),
2619            };
2620
2621            let ccsr_client = ccsr_connection
2622                .connect(storage_configuration, InTask::No)
2623                .await
2624                .map_err(|e| CsrPurificationError::ClientError(Arc::new(e)))?;
2625
2626            let value = compile_proto(&format!("{}-value", topic), &ccsr_client).await?;
2627            let key = compile_proto(&format!("{}-key", topic), &ccsr_client)
2628                .await
2629                .ok();
2630
2631            if matches!(envelope, Some(SourceEnvelope::Debezium)) && key.is_none() {
2632                sql_bail!("Key schema is required for ENVELOPE DEBEZIUM");
2633            }
2634
2635            *seed = Some(CsrSeedProtobuf { value, key });
2636        }
2637        Some(_) => (),
2638    }
2639
2640    Ok(())
2641}
2642
2643async fn purify_csr_connection_avro(
2644    catalog: &dyn SessionCatalog,
2645    options: &SourceFormatOptions,
2646    csr_connection: &mut CsrConnectionAvro<Aug>,
2647    envelope: &Option<SourceEnvelope>,
2648    storage_configuration: &StorageConfiguration,
2649) -> Result<(), PlanError> {
2650    let SourceFormatOptions::Kafka { topic } = options else {
2651        sql_bail!("Confluent Schema Registry is only supported with Kafka sources")
2652    };
2653
2654    let CsrConnectionAvro {
2655        connection: CsrConnection { connection, .. },
2656        seed,
2657        key_strategy,
2658        value_strategy,
2659    } = csr_connection;
2660    if seed.is_none() {
2661        let scx = StatementContext::new(None, &*catalog);
2662        let csr_connection = match scx.get_item_by_resolved_name(connection)?.connection()? {
2663            Connection::Csr(connection) => connection.clone().into_inline_connection(catalog),
2664            _ => sql_bail!("{} is not a schema registry connection", connection),
2665        };
2666        let ccsr_client = csr_connection
2667            .connect(storage_configuration, InTask::No)
2668            .await
2669            .map_err(|e| CsrPurificationError::ClientError(Arc::new(e)))?;
2670
2671        let Schema {
2672            key_schema,
2673            value_schema,
2674            key_reference_schemas,
2675            value_reference_schemas,
2676        } = get_remote_csr_schema(
2677            &ccsr_client,
2678            key_strategy.clone().unwrap_or_default(),
2679            value_strategy.clone().unwrap_or_default(),
2680            topic,
2681        )
2682        .await?;
2683        if matches!(envelope, Some(SourceEnvelope::Debezium)) && key_schema.is_none() {
2684            sql_bail!("Key schema is required for ENVELOPE DEBEZIUM");
2685        }
2686
2687        *seed = Some(CsrSeedAvro {
2688            key_schema,
2689            value_schema,
2690            key_reference_schemas,
2691            value_reference_schemas,
2692        })
2693    }
2694
2695    Ok(())
2696}
2697
2698async fn purify_glue_connection_avro(
2699    catalog: &dyn SessionCatalog,
2700    options: &SourceFormatOptions,
2701    connection: &ResolvedItemName,
2702    with_options: &[GlueAvroOption<Aug>],
2703    seed: &mut Option<GlueAvroSeed>,
2704    storage_configuration: &StorageConfiguration,
2705) -> Result<(), PlanError> {
2706    use crate::pure::error::GluePurificationError;
2707    let SourceFormatOptions::Kafka { .. } = options else {
2708        sql_bail!("AWS Glue Schema Registry is only supported with Kafka sources")
2709    };
2710
2711    let scx = StatementContext::new(None, &*catalog);
2712    let item = scx.get_item_by_resolved_name(connection)?;
2713    let full_name = scx.catalog.resolve_full_name(item.name());
2714    // Structural validation below runs unconditionally — even when a seed is
2715    // already present. The seed is not proof the rest of the statement is
2716    // well-formed: the grammar accepts a user-written `SEED VALUE SCHEMA`, so a
2717    // seeded statement may have arrived straight from a user rather than from
2718    // our own re-parsed `create_sql`. Only the Glue *fetch* is safe to skip
2719    // when seeded; the connection-type and `SCHEMA NAME` checks are not.
2720    let Connection::GlueSchemaRegistry(gsr_connection) = item.connection()? else {
2721        return Err(GluePurificationError::NotGlueConnection(full_name).into());
2722    };
2723
2724    // Pull `SCHEMA NAME` out of the option bag. Required. Use the shared
2725    // extractor (rather than a hand-rolled match) so non-string values get a
2726    // proper "invalid value" error instead of being silently dropped.
2727    let crate::plan::statement::ddl::GlueAvroOptionExtracted {
2728        schema_name,
2729        key_schema_name,
2730        value_schema_name,
2731        key_compatibility_level,
2732        value_compatibility_level,
2733        seen: _,
2734    } = with_options.to_vec().try_into()?;
2735    // The per-side and compatibility options are sink-only. A source reads a
2736    // single writer schema, so reject them here rather than silently ignoring.
2737    if key_schema_name.is_some()
2738        || value_schema_name.is_some()
2739        || key_compatibility_level.is_some()
2740        || value_compatibility_level.is_some()
2741    {
2742        sql_bail!(
2743            "KEY SCHEMA NAME, VALUE SCHEMA NAME, KEY COMPATIBILITY LEVEL, and VALUE \
2744             COMPATIBILITY LEVEL are not supported for AWS Glue Schema Registry sources, \
2745             use SCHEMA NAME instead"
2746        );
2747    }
2748    let schema_name = schema_name.ok_or(GluePurificationError::MissingSchemaName)?;
2749
2750    if seed.is_some() {
2751        // The schema is already pinned — either we purified this statement
2752        // before and re-parsed it from persisted create_sql, or a user supplied
2753        // the seed directly. Either way the reader schema is fixed, so skip the
2754        // Glue lookup. (The structural checks above still ran.)
2755        return Ok(());
2756    }
2757    let gsr_connection = gsr_connection.into_inline_connection(catalog);
2758
2759    // Build the SDK config the same way the storage decoder does at runtime
2760    // — same auth, region, and endpoint override resolution.
2761    let enforce_external_addresses = mz_storage_types::dyncfgs::ENFORCE_EXTERNAL_ADDRESSES
2762        .get(storage_configuration.config_set());
2763    let sdk_config = gsr_connection
2764        .aws_connection
2765        .connection
2766        .load_sdk_config(
2767            &storage_configuration.connection_context,
2768            gsr_connection.aws_connection.connection_id,
2769            // We are in a normal tokio context during purification.
2770            InTask::No,
2771            enforce_external_addresses,
2772        )
2773        .await
2774        .map_err(|e| GluePurificationError::LoadSdkConfigError(Arc::new(e)))?;
2775    let glue_client = mz_aws_glue_schema_registry::ClientConfig::new(sdk_config).build();
2776
2777    let version = glue_client
2778        .get_schema_version_latest_by_name(&gsr_connection.registry_name, &schema_name)
2779        .await
2780        .map_err(|e| GluePurificationError::SchemaLookupError {
2781            registry: gsr_connection.registry_name.clone(),
2782            schema: schema_name.clone(),
2783            cause: Arc::new(e),
2784        })?;
2785    // The runtime decode path only handles Avro; reject other formats at
2786    // planning time so the failure mode is a clear SQL error, not a
2787    // permanent decode error on every record.
2788    match &version.data_format {
2789        Some(mz_aws_glue_schema_registry::DataFormat::Avro) => {}
2790        other => {
2791            return Err(GluePurificationError::UnsupportedDataFormat {
2792                registry: gsr_connection.registry_name.clone(),
2793                schema: schema_name.clone(),
2794                format: other
2795                    .as_ref()
2796                    .map(|f| f.as_str().to_string())
2797                    .unwrap_or_else(|| "<unspecified>".to_string()),
2798            }
2799            .into());
2800        }
2801    }
2802    let value_schema =
2803        version
2804            .definition
2805            .ok_or_else(|| GluePurificationError::EmptyDefinition {
2806                registry: gsr_connection.registry_name.clone(),
2807                schema: schema_name.clone(),
2808            })?;
2809
2810    *seed = Some(GlueAvroSeed { value_schema });
2811    Ok(())
2812}
2813
2814#[derive(Debug)]
2815pub struct Schema {
2816    pub key_schema: Option<String>,
2817    pub value_schema: String,
2818    /// Reference schemas for the key schema, in dependency order.
2819    pub key_reference_schemas: Vec<String>,
2820    /// Reference schemas for the value schema, in dependency order.
2821    pub value_reference_schemas: Vec<String>,
2822}
2823
2824/// Result of fetching a schema, including any referenced schemas.
2825struct SchemaWithReferences {
2826    /// The primary schema.
2827    schema: String,
2828    /// Reference schemas in dependency order.
2829    references: Vec<String>,
2830}
2831
2832async fn get_schema_with_strategy(
2833    client: &Client,
2834    strategy: ReaderSchemaSelectionStrategy,
2835    subject: &str,
2836) -> Result<Option<SchemaWithReferences>, PlanError> {
2837    match strategy {
2838        ReaderSchemaSelectionStrategy::Latest => {
2839            // Use get_subject_and_references to also fetch referenced schemas
2840            match client.get_subject_and_references(subject).await {
2841                Ok((primary, dependencies)) => Ok(Some(SchemaWithReferences {
2842                    schema: primary.schema.raw,
2843                    references: dependencies.into_iter().map(|s| s.schema.raw).collect(),
2844                })),
2845                Err(GetBySubjectError::SubjectNotFound)
2846                | Err(GetBySubjectError::VersionNotFound(_)) => Ok(None),
2847                Err(e) => Err(PlanError::FetchingCsrSchemaFailed {
2848                    schema_lookup: format!("subject {}", subject.quoted()),
2849                    cause: Arc::new(e),
2850                }),
2851            }
2852        }
2853        // It's possible that CSR was provided with an inline strategy, but without a subject
2854        // or schema id to look up, there isn't a clean way to lookup references for the schema.
2855        // This could be done with extra work, but at this point, it isn't clear this is needed.
2856        ReaderSchemaSelectionStrategy::Inline(raw) => Ok(Some(SchemaWithReferences {
2857            schema: raw,
2858            references: vec![],
2859        })),
2860        ReaderSchemaSelectionStrategy::ById(id) => {
2861            match client.get_subject_and_references_by_id(id).await {
2862                Ok((primary, dependencies)) => Ok(Some(SchemaWithReferences {
2863                    schema: primary.schema.raw,
2864                    references: dependencies.into_iter().map(|s| s.schema.raw).collect(),
2865                })),
2866                Err(GetBySubjectError::SubjectNotFound)
2867                | Err(GetBySubjectError::VersionNotFound(_)) => Ok(None),
2868                Err(e) => Err(PlanError::FetchingCsrSchemaFailed {
2869                    schema_lookup: format!("subject {}", subject.quoted()),
2870                    cause: Arc::new(e),
2871                }),
2872            }
2873        }
2874    }
2875}
2876
2877async fn get_remote_csr_schema(
2878    ccsr_client: &mz_ccsr::Client,
2879    key_strategy: ReaderSchemaSelectionStrategy,
2880    value_strategy: ReaderSchemaSelectionStrategy,
2881    topic: &str,
2882) -> Result<Schema, PlanError> {
2883    let value_schema_name = format!("{}-value", topic);
2884    let value_result =
2885        get_schema_with_strategy(ccsr_client, value_strategy, &value_schema_name).await?;
2886    let value_result = value_result.ok_or_else(|| anyhow!("No value schema found"))?;
2887
2888    let key_subject = format!("{}-key", topic);
2889    let key_result = get_schema_with_strategy(ccsr_client, key_strategy, &key_subject).await?;
2890    Ok(Schema {
2891        key_schema: key_result.as_ref().map(|r| r.schema.clone()),
2892        value_schema: value_result.schema,
2893        key_reference_schemas: key_result.map(|r| r.references).unwrap_or_default(),
2894        value_reference_schemas: value_result.references,
2895    })
2896}
2897
2898/// Collect protobuf message descriptor from CSR and compile the descriptor.
2899async fn compile_proto(
2900    subject_name: &String,
2901    ccsr_client: &Client,
2902) -> Result<CsrSeedProtobufSchema, PlanError> {
2903    let (primary_subject, dependency_subjects) = ccsr_client
2904        .get_subject_and_references(subject_name)
2905        .await
2906        .map_err(|e| PlanError::FetchingCsrSchemaFailed {
2907            schema_lookup: format!("subject {}", subject_name.quoted()),
2908            cause: Arc::new(e),
2909        })?;
2910
2911    // Compile .proto files into a file descriptor set.
2912    let mut source_tree = VirtualSourceTree::new();
2913
2914    // Add well-known types (e.g., google/protobuf/timestamp.proto) to the source
2915    // tree. These are implicitly available to protoc but are typically not
2916    // registered in the schema registry.
2917    source_tree.as_mut().map_well_known_types();
2918
2919    for subject in iter::once(&primary_subject).chain(dependency_subjects.iter()) {
2920        source_tree.as_mut().add_file(
2921            Path::new(&subject.name),
2922            subject.schema.raw.as_bytes().to_vec(),
2923        );
2924    }
2925    let mut db = SourceTreeDescriptorDatabase::new(source_tree.as_mut());
2926    let fds = db
2927        .as_mut()
2928        .build_file_descriptor_set(&[Path::new(&primary_subject.name)])
2929        .map_err(|cause| PlanError::InvalidProtobufSchema { cause })?;
2930
2931    // Ensure there is exactly one message in the file.
2932    let primary_fd = fds.file(0);
2933    let message_name = match primary_fd.message_type_size() {
2934        1 => String::from_utf8_lossy(primary_fd.message_type(0).name()).into_owned(),
2935        0 => bail_unsupported!(29603, "Protobuf schemas with no messages"),
2936        _ => bail_unsupported!(29603, "Protobuf schemas with multiple messages"),
2937    };
2938
2939    // Encode the file descriptor set into a SQL byte string.
2940    let bytes = &fds
2941        .serialize()
2942        .map_err(|cause| PlanError::InvalidProtobufSchema { cause })?;
2943    let mut schema = String::new();
2944    strconv::format_bytes(&mut schema, bytes);
2945
2946    Ok(CsrSeedProtobufSchema {
2947        schema,
2948        message_name,
2949    })
2950}
2951
2952const MZ_NOW_NAME: &str = "mz_now";
2953const MZ_NOW_SCHEMA: &str = "mz_catalog";
2954
2955/// Purifies a CREATE MATERIALIZED VIEW statement. Additionally, it adjusts `resolved_ids` if
2956/// references to ids appear or disappear during the purification.
2957///
2958/// Note that in contrast with [`purify_statement`], this doesn't need to be async, because
2959/// this function is not making any network calls.
2960pub fn purify_create_materialized_view_options(
2961    catalog: impl SessionCatalog,
2962    mz_now: Option<Timestamp>,
2963    cmvs: &mut CreateMaterializedViewStatement<Aug>,
2964    resolved_ids: &mut ResolvedIds,
2965) {
2966    // 0. Preparations:
2967    // Prepare an expression that calls `mz_now()`, which we can insert in various later steps.
2968    let (mz_now_id, mz_now_expr) = {
2969        let item = catalog
2970            .resolve_function(&PartialItemName {
2971                database: None,
2972                schema: Some(MZ_NOW_SCHEMA.to_string()),
2973                item: MZ_NOW_NAME.to_string(),
2974            })
2975            .expect("we should be able to resolve mz_now");
2976        (
2977            item.id(),
2978            Expr::Function(Function {
2979                name: ResolvedItemName::Item {
2980                    id: item.id(),
2981                    qualifiers: item.name().qualifiers.clone(),
2982                    full_name: catalog.resolve_full_name(item.name()),
2983                    print_id: false,
2984                    version: RelationVersionSelector::Latest,
2985                },
2986                args: FunctionArgs::Args {
2987                    args: Vec::new(),
2988                    order_by: Vec::new(),
2989                },
2990                filter: None,
2991                over: None,
2992                distinct: false,
2993            }),
2994        )
2995    };
2996    // Prepare the `mz_timestamp` type.
2997    let (mz_timestamp_id, mz_timestamp_type) = {
2998        let item = catalog.get_system_type("mz_timestamp");
2999        let full_name = catalog.resolve_full_name(item.name());
3000        (
3001            item.id(),
3002            ResolvedDataType::Named {
3003                id: item.id(),
3004                qualifiers: item.name().qualifiers.clone(),
3005                full_name,
3006                modifiers: vec![],
3007                print_id: true,
3008            },
3009        )
3010    };
3011
3012    let mut introduced_mz_timestamp = false;
3013
3014    for option in cmvs.with_options.iter_mut() {
3015        // 1. Purify `REFRESH AT CREATION` to `REFRESH AT mz_now()`.
3016        if matches!(
3017            option.value,
3018            Some(WithOptionValue::Refresh(RefreshOptionValue::AtCreation))
3019        ) {
3020            option.value = Some(WithOptionValue::Refresh(RefreshOptionValue::At(
3021                RefreshAtOptionValue {
3022                    time: mz_now_expr.clone(),
3023                },
3024            )));
3025        }
3026
3027        // 2. If `REFRESH EVERY` doesn't have an `ALIGNED TO`, then add `ALIGNED TO mz_now()`.
3028        if let Some(WithOptionValue::Refresh(RefreshOptionValue::Every(
3029            RefreshEveryOptionValue { aligned_to, .. },
3030        ))) = &mut option.value
3031        {
3032            if aligned_to.is_none() {
3033                *aligned_to = Some(mz_now_expr.clone());
3034            }
3035        }
3036
3037        // 3. Substitute `mz_now()` with the timestamp chosen for the CREATE MATERIALIZED VIEW
3038        // statement. (This has to happen after the above steps, which might introduce `mz_now()`.)
3039        match &mut option.value {
3040            Some(WithOptionValue::Refresh(RefreshOptionValue::At(RefreshAtOptionValue {
3041                time,
3042            }))) => {
3043                let mut visitor = MzNowPurifierVisitor::new(mz_now, mz_timestamp_type.clone());
3044                visitor.visit_expr_mut(time);
3045                introduced_mz_timestamp |= visitor.introduced_mz_timestamp;
3046            }
3047            Some(WithOptionValue::Refresh(RefreshOptionValue::Every(
3048                RefreshEveryOptionValue {
3049                    interval: _,
3050                    aligned_to: Some(aligned_to),
3051                },
3052            ))) => {
3053                let mut visitor = MzNowPurifierVisitor::new(mz_now, mz_timestamp_type.clone());
3054                visitor.visit_expr_mut(aligned_to);
3055                introduced_mz_timestamp |= visitor.introduced_mz_timestamp;
3056            }
3057            _ => {}
3058        }
3059    }
3060
3061    // 4. If the user didn't give any REFRESH option, then default to ON COMMIT.
3062    if !cmvs.with_options.iter().any(|o| {
3063        matches!(
3064            o,
3065            MaterializedViewOption {
3066                value: Some(WithOptionValue::Refresh(..)),
3067                ..
3068            }
3069        )
3070    }) {
3071        cmvs.with_options.push(MaterializedViewOption {
3072            name: MaterializedViewOptionName::Refresh,
3073            value: Some(WithOptionValue::Refresh(RefreshOptionValue::OnCommit)),
3074        })
3075    }
3076
3077    // 5. Attend to `resolved_ids`: The purification might have
3078    // - added references to `mz_timestamp`;
3079    // - removed references to `mz_now`.
3080    if introduced_mz_timestamp {
3081        resolved_ids.add_item(mz_timestamp_id);
3082    }
3083    // Even though we always remove `mz_now()` from the `with_options`, there might be `mz_now()`
3084    // remaining in the main query expression of the MV, so let's visit the entire statement to look
3085    // for `mz_now()` everywhere.
3086    let mut visitor = ExprContainsTemporalVisitor::new();
3087    visitor.visit_create_materialized_view_statement(cmvs);
3088    if !visitor.contains_temporal {
3089        resolved_ids.remove_item(&mz_now_id);
3090    }
3091}
3092
3093/// Returns true if the [MaterializedViewOption] either already involves `mz_now()` or will involve
3094/// after purification.
3095pub fn materialized_view_option_contains_temporal(mvo: &MaterializedViewOption<Aug>) -> bool {
3096    match &mvo.value {
3097        Some(WithOptionValue::Refresh(RefreshOptionValue::At(RefreshAtOptionValue { time }))) => {
3098            let mut visitor = ExprContainsTemporalVisitor::new();
3099            visitor.visit_expr(time);
3100            visitor.contains_temporal
3101        }
3102        Some(WithOptionValue::Refresh(RefreshOptionValue::Every(RefreshEveryOptionValue {
3103            interval: _,
3104            aligned_to: Some(aligned_to),
3105        }))) => {
3106            let mut visitor = ExprContainsTemporalVisitor::new();
3107            visitor.visit_expr(aligned_to);
3108            visitor.contains_temporal
3109        }
3110        Some(WithOptionValue::Refresh(RefreshOptionValue::Every(RefreshEveryOptionValue {
3111            interval: _,
3112            aligned_to: None,
3113        }))) => {
3114            // For a `REFRESH EVERY` without an `ALIGNED TO`, purification will default the
3115            // `ALIGNED TO` to `mz_now()`.
3116            true
3117        }
3118        Some(WithOptionValue::Refresh(RefreshOptionValue::AtCreation)) => {
3119            // `REFRESH AT CREATION` will be purified to `REFRESH AT mz_now()`.
3120            true
3121        }
3122        _ => false,
3123    }
3124}
3125
3126/// Determines whether the AST involves `mz_now()`.
3127struct ExprContainsTemporalVisitor {
3128    pub contains_temporal: bool,
3129}
3130
3131impl ExprContainsTemporalVisitor {
3132    pub fn new() -> ExprContainsTemporalVisitor {
3133        ExprContainsTemporalVisitor {
3134            contains_temporal: false,
3135        }
3136    }
3137}
3138
3139impl Visit<'_, Aug> for ExprContainsTemporalVisitor {
3140    fn visit_function(&mut self, func: &Function<Aug>) {
3141        self.contains_temporal |= func.name.full_item_name().item == MZ_NOW_NAME;
3142        visit_function(self, func);
3143    }
3144}
3145
3146struct MzNowPurifierVisitor {
3147    pub mz_now: Option<Timestamp>,
3148    pub mz_timestamp_type: ResolvedDataType,
3149    pub introduced_mz_timestamp: bool,
3150}
3151
3152impl MzNowPurifierVisitor {
3153    pub fn new(
3154        mz_now: Option<Timestamp>,
3155        mz_timestamp_type: ResolvedDataType,
3156    ) -> MzNowPurifierVisitor {
3157        MzNowPurifierVisitor {
3158            mz_now,
3159            mz_timestamp_type,
3160            introduced_mz_timestamp: false,
3161        }
3162    }
3163}
3164
3165impl VisitMut<'_, Aug> for MzNowPurifierVisitor {
3166    fn visit_expr_mut(&mut self, expr: &'_ mut Expr<Aug>) {
3167        match expr {
3168            Expr::Function(Function {
3169                name:
3170                    ResolvedItemName::Item {
3171                        full_name: FullItemName { item, .. },
3172                        ..
3173                    },
3174                ..
3175            }) if item == &MZ_NOW_NAME.to_string() => {
3176                let mz_now = self.mz_now.expect(
3177                    "we should have chosen a timestamp if the expression contains mz_now()",
3178                );
3179                // We substitute `mz_now()` with number + a cast to `mz_timestamp`. The cast is to
3180                // not alter the type of the expression.
3181                *expr = Expr::Cast {
3182                    expr: Box::new(Expr::Value(Value::Number(mz_now.to_string()))),
3183                    data_type: self.mz_timestamp_type.clone(),
3184                    failure_mode: CastFailureMode::Error,
3185                };
3186                self.introduced_mz_timestamp = true;
3187            }
3188            _ => visit_expr_mut(self, expr),
3189        }
3190    }
3191}