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