Skip to main content

mz_sql/plan/statement/
ddl.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//! Data definition language (DDL).
11//!
12//! This module houses the handlers for statements that modify the catalog, like
13//! `ALTER`, `CREATE`, and `DROP`.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt::Write;
17use std::iter;
18use std::num::NonZeroU32;
19use std::time::Duration;
20
21use itertools::Itertools;
22use mz_adapter_types::compaction::{CompactionWindow, DEFAULT_LOGICAL_COMPACTION_WINDOW_DURATION};
23use mz_arrow_util::builder::ArrowBuilder;
24use mz_auth::password::Password;
25use mz_controller_types::{ClusterId, DEFAULT_REPLICA_LOGGING_INTERVAL, ReplicaId};
26use mz_expr::{CollectionPlan, UnmaterializableFunc};
27use mz_interchange::avro::{AvroSchemaGenerator, DocTarget};
28use mz_ore::cast::{CastFrom, TryCastFrom};
29use mz_ore::collections::{CollectionExt, HashSet};
30use mz_ore::num::NonNeg;
31use mz_ore::str::StrExt;
32use mz_ore::{soft_assert_or_log, soft_panic_or_log};
33use mz_proto::RustType;
34use mz_repr::adt::interval::Interval;
35use mz_repr::adt::mz_acl_item::{MzAclItem, PrivilegeMap};
36use mz_repr::network_policy_id::NetworkPolicyId;
37use mz_repr::optimize::OptimizerFeatureOverrides;
38use mz_repr::refresh_schedule::{RefreshEvery, RefreshSchedule};
39use mz_repr::role_id::RoleId;
40use mz_repr::{
41    CatalogItemId, ColumnName, RelationDesc, RelationVersion, RelationVersionSelector,
42    SqlColumnType, SqlRelationType, SqlScalarType, Timestamp, VersionedRelationDesc,
43    preserves_order, strconv,
44};
45use mz_sql_parser::ast::{
46    self, AlterClusterAction, AlterClusterStatement, AlterConnectionAction, AlterConnectionOption,
47    AlterConnectionOptionName, AlterConnectionStatement, AlterIndexAction, AlterIndexStatement,
48    AlterMaterializedViewApplyReplacementStatement, AlterNetworkPolicyStatement,
49    AlterObjectRenameStatement, AlterObjectSwapStatement, AlterRetainHistoryStatement,
50    AlterRoleOption, AlterRoleStatement, AlterSecretStatement, AlterSetClusterStatement,
51    AlterSinkAction, AlterSinkStatement, AlterSourceAction, AlterSourceAddSubsourceOption,
52    AlterSourceAddSubsourceOptionName, AlterSourceStatement, AlterSystemResetAllStatement,
53    AlterSystemResetStatement, AlterSystemSetStatement, AlterTableAddColumnStatement, AvroSchema,
54    AvroSchemaOption, AvroSchemaOptionName, ClusterAlterOption, ClusterAlterOptionName,
55    ClusterAlterOptionValue, ClusterAlterUntilReadyOption, ClusterAlterUntilReadyOptionName,
56    ClusterFeature, ClusterFeatureName, ClusterOption, ClusterOptionName,
57    ClusterScheduleOptionValue, ColumnDef, ColumnOption, CommentObjectType, CommentStatement,
58    ConnectionOption, ConnectionOptionName, CreateClusterReplicaStatement, CreateClusterStatement,
59    CreateConnectionOption, CreateConnectionOptionName, CreateConnectionStatement,
60    CreateConnectionType, CreateDatabaseStatement, CreateIndexStatement,
61    CreateMaterializedViewStatement, CreateNetworkPolicyStatement, CreateRoleStatement,
62    CreateSchemaStatement, CreateSecretStatement, CreateSinkConnection, CreateSinkOption,
63    CreateSinkOptionName, CreateSinkStatement, CreateSourceConnection, CreateSourceOption,
64    CreateSourceOptionName, CreateSourceStatement, CreateSubsourceOption,
65    CreateSubsourceOptionName, CreateSubsourceStatement, CreateTableFromSourceStatement,
66    CreateTableStatement, CreateTypeAs, CreateTypeListOption, CreateTypeListOptionName,
67    CreateTypeMapOption, CreateTypeMapOptionName, CreateTypeStatement, CreateViewStatement,
68    CreateWebhookSourceStatement, CsrConfigOption, CsrConfigOptionName, CsrConnection,
69    CsrConnectionAvro, CsrConnectionProtobuf, CsrSeedProtobuf, CsvColumns, DeferredItemName,
70    DocOnIdentifier, DocOnSchema, DropObjectsStatement, DropOwnedStatement, Expr, Format,
71    FormatSpecifier, GlueAvroOption, GlueAvroOptionName, IcebergSinkConfigOption, Ident,
72    IfExistsBehavior, IndexOption, IndexOptionName, KafkaSinkConfigOption, KeyConstraint,
73    LoadGeneratorOption, LoadGeneratorOptionName, MaterializedViewOption,
74    MaterializedViewOptionName, MySqlConfigOption, MySqlConfigOptionName, NetworkPolicyOption,
75    NetworkPolicyOptionName, NetworkPolicyRuleDefinition, NetworkPolicyRuleOption,
76    NetworkPolicyRuleOptionName, PgConfigOption, PgConfigOptionName, ProtobufSchema,
77    QualifiedReplica, RefreshAtOptionValue, RefreshEveryOptionValue, RefreshOptionValue,
78    ReplicaDefinition, ReplicaOption, ReplicaOptionName, RoleAttribute, SetRoleVar,
79    SourceErrorPolicy, SourceIncludeMetadata, SqlServerConfigOption, SqlServerConfigOptionName,
80    Statement, TableConstraint, TableFromSourceColumns, TableFromSourceOption,
81    TableFromSourceOptionName, TableOption, TableOptionName, UnresolvedDatabaseName,
82    UnresolvedItemName, UnresolvedObjectName, UnresolvedSchemaName, Value, ViewDefinition,
83    WithOptionValue,
84};
85use mz_sql_parser::ident;
86use mz_sql_parser::parser::StatementParseResult;
87use mz_storage_types::connections::inline::ReferencedConnection;
88use mz_storage_types::connections::{Connection, KafkaTopicOptions};
89use mz_storage_types::sinks::{
90    IcebergSinkConnection, KafkaIdStyle, KafkaSinkConnection, KafkaSinkFormat, KafkaSinkFormatType,
91    SinkEnvelope, StorageSinkConnection, iceberg_type_overrides,
92};
93use mz_storage_types::sources::encoding::{
94    AvroEncoding, ColumnSpec, CsvEncoding, DataEncoding, ProtobufEncoding, RegexEncoding,
95    SourceDataEncoding, included_column_desc,
96};
97use mz_storage_types::sources::envelope::{
98    KeyEnvelope, NoneEnvelope, SourceEnvelope, UnplannedSourceEnvelope, UpsertStyle,
99};
100use mz_storage_types::sources::kafka::{
101    KafkaMetadataKind, KafkaSourceConnection, KafkaSourceExportDetails, kafka_metadata_columns_desc,
102};
103use mz_storage_types::sources::load_generator::{
104    KeyValueLoadGenerator, LOAD_GENERATOR_KEY_VALUE_OFFSET_DEFAULT, LoadGenerator,
105    LoadGeneratorOutput, LoadGeneratorSourceConnection, LoadGeneratorSourceExportDetails,
106};
107use mz_storage_types::sources::mysql::{
108    MySqlSourceConnection, MySqlSourceDetails, ProtoMySqlSourceDetails,
109};
110use mz_storage_types::sources::postgres::{
111    PostgresSourceConnection, PostgresSourcePublicationDetails,
112    ProtoPostgresSourcePublicationDetails,
113};
114use mz_storage_types::sources::sql_server::{
115    ProtoSqlServerSourceExtras, SqlServerSourceExportDetails,
116};
117use mz_storage_types::sources::{
118    GenericSourceConnection, MySqlSourceExportDetails, PostgresSourceExportDetails,
119    ProtoSourceExportStatementDetails, SourceConnection, SourceDesc, SourceExportDataConfig,
120    SourceExportDetails, SourceExportStatementDetails, SqlServerSourceConnection,
121    SqlServerSourceExtras, Timeline,
122};
123use mz_storage_types::wire_format::WireFormat;
124use prost::Message;
125
126use crate::ast::display::AstDisplay;
127use crate::catalog::{
128    CatalogCluster, CatalogDatabase, CatalogError, CatalogItem, CatalogItemType,
129    CatalogRecordField, CatalogType, CatalogTypeDetails, ObjectType, SystemObjectType,
130};
131use crate::iceberg::IcebergSinkConfigOptionExtracted;
132use crate::kafka_util::{KafkaSinkConfigOptionExtracted, KafkaSourceConfigOptionExtracted};
133use crate::names::{
134    Aug, CommentObjectId, DatabaseId, DependencyIds, ObjectId, PartialItemName, QualifiedItemName,
135    ResolvedClusterName, ResolvedColumnReference, ResolvedDataType, ResolvedDatabaseSpecifier,
136    ResolvedItemName, ResolvedNetworkPolicyName, SchemaSpecifier, SystemObjectId,
137};
138use crate::normalize::{self, ident};
139use crate::plan::error::PlanError;
140use crate::plan::query::{
141    ExprContext, QueryLifetime, plan_expr, scalar_type_from_catalog, scalar_type_from_sql,
142};
143use crate::plan::scope::Scope;
144use crate::plan::statement::ddl::connection::{INALTERABLE_OPTIONS, MUTUALLY_EXCLUSIVE_SETS};
145use crate::plan::statement::{StatementContext, StatementDesc, scl};
146use crate::plan::typeconv::CastContext;
147use crate::plan::with_options::{OptionalDuration, OptionalString, TryFromValue};
148use crate::plan::{
149    AlterClusterPlan, AlterClusterPlanStrategy, AlterClusterRenamePlan,
150    AlterClusterReplicaRenamePlan, AlterClusterSwapPlan, AlterConnectionPlan, AlterItemRenamePlan,
151    AlterMaterializedViewApplyReplacementPlan, AlterNetworkPolicyPlan, AlterNoopPlan,
152    AlterOptionParameter, AlterRetainHistoryPlan, AlterRolePlan, AlterSchemaRenamePlan,
153    AlterSchemaSwapPlan, AlterSecretPlan, AlterSetClusterPlan, AlterSinkPlan,
154    AlterSourceTimestampIntervalPlan, AlterSystemResetAllPlan, AlterSystemResetPlan,
155    AlterSystemSetPlan, AlterTablePlan, ClusterSchedule, CommentPlan, ComputeReplicaConfig,
156    ComputeReplicaIntrospectionConfig, ConnectionDetails, CreateClusterManagedPlan,
157    CreateClusterPlan, CreateClusterReplicaPlan, CreateClusterUnmanagedPlan, CreateClusterVariant,
158    CreateConnectionPlan, CreateDatabasePlan, CreateIndexPlan, CreateMaterializedViewPlan,
159    CreateNetworkPolicyPlan, CreateRolePlan, CreateSchemaPlan, CreateSecretPlan, CreateSinkPlan,
160    CreateSourcePlan, CreateTablePlan, CreateTypePlan, CreateViewPlan, DataSourceDesc,
161    DropObjectsPlan, DropOwnedPlan, HirRelationExpr, Index, MaterializedView, NetworkPolicyRule,
162    NetworkPolicyRuleAction, NetworkPolicyRuleDirection, Plan, PlanClusterOption, PlanNotice,
163    PolicyAddress, QueryContext, ReplicaConfig, Secret, Sink, Source, Table, TableDataSource, Type,
164    VariableValue, View, WebhookBodyFormat, WebhookHeaderFilters, WebhookHeaders,
165    WebhookValidation, literal, plan_utils, query, transform_ast,
166};
167use crate::session::vars::{
168    self, ENABLE_CLUSTER_SCHEDULE_REFRESH, ENABLE_COLLECTION_PARTITION_BY,
169    ENABLE_CREATE_TABLE_FROM_SOURCE, ENABLE_KAFKA_SINK_HEADERS, ENABLE_REFRESH_EVERY_MVS,
170    ENABLE_REPLICA_TARGETED_MATERIALIZED_VIEWS, VarInput,
171};
172use crate::{names, parse};
173
174mod connection;
175
176// TODO: Figure out what the maximum number of columns we can actually support is, and set that.
177//
178// The real max is probably higher than this, but it's easier to relax a constraint than make it
179// more strict.
180const MAX_NUM_COLUMNS: usize = 256;
181
182const MAX_KAFKA_TOPIC_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
183const MIN_KAFKA_TOPIC_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
184
185static MANAGED_REPLICA_PATTERN: std::sync::LazyLock<regex::Regex> =
186    std::sync::LazyLock::new(|| regex::Regex::new(r"^r(\d)+$").unwrap());
187
188/// Given a relation desc and a column list, checks that:
189/// - the column list is a prefix of the desc;
190/// - all the listed columns are types that have meaningful Persist-level ordering.
191fn check_partition_by(desc: &RelationDesc, mut partition_by: Vec<Ident>) -> Result<(), PlanError> {
192    if partition_by.len() > desc.len() {
193        tracing::error!(
194            "PARTITION BY contains more columns than the relation. (expected at most {}, got {})",
195            desc.len(),
196            partition_by.len()
197        );
198        partition_by.truncate(desc.len());
199    }
200
201    let desc_prefix = desc.iter().take(partition_by.len());
202    for (idx, ((desc_name, desc_type), partition_name)) in
203        desc_prefix.zip_eq(partition_by).enumerate()
204    {
205        let partition_name = normalize::column_name(partition_name);
206        if *desc_name != partition_name {
207            sql_bail!(
208                "PARTITION BY columns should be a prefix of the relation's columns (expected {desc_name} at index {idx}, got {partition_name})"
209            );
210        }
211        if !preserves_order(&desc_type.scalar_type) {
212            sql_bail!("PARTITION BY column {partition_name} has unsupported type");
213        }
214    }
215    Ok(())
216}
217
218pub fn describe_create_database(
219    _: &StatementContext,
220    _: CreateDatabaseStatement,
221) -> Result<StatementDesc, PlanError> {
222    Ok(StatementDesc::new(None))
223}
224
225pub fn plan_create_database(
226    _: &StatementContext,
227    CreateDatabaseStatement {
228        name,
229        if_not_exists,
230    }: CreateDatabaseStatement,
231) -> Result<Plan, PlanError> {
232    Ok(Plan::CreateDatabase(CreateDatabasePlan {
233        name: normalize::ident(name.0),
234        if_not_exists,
235    }))
236}
237
238pub fn describe_create_schema(
239    _: &StatementContext,
240    _: CreateSchemaStatement,
241) -> Result<StatementDesc, PlanError> {
242    Ok(StatementDesc::new(None))
243}
244
245pub fn plan_create_schema(
246    scx: &StatementContext,
247    CreateSchemaStatement {
248        mut name,
249        if_not_exists,
250    }: CreateSchemaStatement,
251) -> Result<Plan, PlanError> {
252    if name.0.len() > 2 {
253        sql_bail!("schema name {} has more than two components", name);
254    }
255    let schema_name = normalize::ident(
256        name.0
257            .pop()
258            .expect("names always have at least one component"),
259    );
260    let database_spec = match name.0.pop() {
261        None => match scx.catalog.active_database() {
262            Some(id) => ResolvedDatabaseSpecifier::Id(id.clone()),
263            None => sql_bail!("no database specified and no active database"),
264        },
265        Some(n) => match scx.resolve_database(&UnresolvedDatabaseName(n.clone())) {
266            Ok(database) => ResolvedDatabaseSpecifier::Id(database.id()),
267            Err(_) => sql_bail!("invalid database {}", n.as_str()),
268        },
269    };
270    Ok(Plan::CreateSchema(CreateSchemaPlan {
271        database_spec,
272        schema_name,
273        if_not_exists,
274    }))
275}
276
277pub fn describe_create_table(
278    _: &StatementContext,
279    _: CreateTableStatement<Aug>,
280) -> Result<StatementDesc, PlanError> {
281    Ok(StatementDesc::new(None))
282}
283
284pub fn plan_create_table(
285    scx: &StatementContext,
286    stmt: CreateTableStatement<Aug>,
287) -> Result<Plan, PlanError> {
288    let CreateTableStatement {
289        name,
290        columns,
291        constraints,
292        if_not_exists,
293        temporary,
294        with_options,
295    } = &stmt;
296
297    let names: Vec<_> = columns
298        .iter()
299        .filter(|c| {
300            // This set of `names` is used to create the initial RelationDesc.
301            // Columns that have been added at later versions of the table will
302            // get added further below.
303            let is_versioned = c
304                .options
305                .iter()
306                .any(|o| matches!(o.option, ColumnOption::Versioned { .. }));
307            !is_versioned
308        })
309        .map(|c| normalize::column_name(c.name.clone()))
310        .collect();
311
312    if let Some(dup) = names.iter().duplicates().next() {
313        sql_bail!("column {} specified more than once", dup.quoted());
314    }
315
316    // Build initial relation type that handles declared data types
317    // and NOT NULL constraints.
318    let mut column_types = Vec::with_capacity(columns.len());
319    let mut defaults = Vec::with_capacity(columns.len());
320    let mut changes = BTreeMap::new();
321    let mut keys = Vec::new();
322
323    for (i, c) in columns.into_iter().enumerate() {
324        let aug_data_type = &c.data_type;
325        let ty = query::scalar_type_from_sql(scx, aug_data_type)?;
326        let mut nullable = true;
327        let mut default = Expr::null();
328        let mut versioned = false;
329        for option in &c.options {
330            match &option.option {
331                ColumnOption::NotNull => nullable = false,
332                ColumnOption::Default(expr) => {
333                    // Ensure expression can be planned and yields the correct
334                    // type.
335                    let mut expr = expr.clone();
336                    transform_ast::transform(scx, &mut expr)?;
337                    let _ = query::plan_default_expr(scx, &expr, &ty)?;
338                    default = expr.clone();
339                }
340                ColumnOption::Unique { is_primary } => {
341                    keys.push(vec![i]);
342                    if *is_primary {
343                        nullable = false;
344                    }
345                }
346                ColumnOption::Versioned { action, version } => {
347                    let version = RelationVersion::from(*version);
348                    versioned = true;
349
350                    let name = normalize::column_name(c.name.clone());
351                    let typ = ty.clone().nullable(nullable);
352
353                    changes.insert(version, (action.clone(), name, typ));
354                }
355                other => {
356                    bail_unsupported!(format!("CREATE TABLE with column constraint: {}", other))
357                }
358            }
359        }
360        // TODO(alter_table): This assumes all versioned columns are at the
361        // end. This will no longer be true when we support dropping columns.
362        if !versioned {
363            column_types.push(ty.nullable(nullable));
364        }
365        defaults.push(default);
366    }
367
368    let mut seen_primary = false;
369    'c: for constraint in constraints {
370        match constraint {
371            TableConstraint::Unique {
372                name: _,
373                columns,
374                is_primary,
375                nulls_not_distinct,
376            } => {
377                if seen_primary && *is_primary {
378                    sql_bail!(
379                        "multiple primary keys for table {} are not allowed",
380                        name.to_ast_string_stable()
381                    );
382                }
383                seen_primary = *is_primary || seen_primary;
384
385                let mut key = vec![];
386                for column in columns {
387                    let column = normalize::column_name(column.clone());
388                    match names.iter().position(|name| *name == column) {
389                        None => sql_bail!("unknown column in constraint: {}", column),
390                        Some(i) => {
391                            let nullable = &mut column_types[i].nullable;
392                            if *is_primary {
393                                if *nulls_not_distinct {
394                                    sql_bail!(
395                                        "[internal error] PRIMARY KEY does not support NULLS NOT DISTINCT"
396                                    );
397                                }
398
399                                *nullable = false;
400                            } else if !(*nulls_not_distinct || !*nullable) {
401                                // Non-primary key unique constraints are only keys if all of their
402                                // columns are `NOT NULL` or the constraint is `NULLS NOT DISTINCT`.
403                                break 'c;
404                            }
405
406                            key.push(i);
407                        }
408                    }
409                }
410
411                if *is_primary {
412                    keys.insert(0, key);
413                } else {
414                    keys.push(key);
415                }
416            }
417            TableConstraint::ForeignKey { .. } => {
418                // Foreign key constraints are not presently enforced. We allow
419                // them with feature flags for sqllogictest's sake.
420                scx.require_feature_flag(&vars::UNSAFE_ENABLE_TABLE_FOREIGN_KEY)?
421            }
422            TableConstraint::Check { .. } => {
423                // Check constraints are not presently enforced. We allow them
424                // with feature flags for sqllogictest's sake.
425                scx.require_feature_flag(&vars::UNSAFE_ENABLE_TABLE_CHECK_CONSTRAINT)?
426            }
427        }
428    }
429
430    if !keys.is_empty() {
431        // Unique constraints are not presently enforced. We allow them with feature flags for
432        // sqllogictest's sake.
433        scx.require_feature_flag(&vars::UNSAFE_ENABLE_TABLE_KEYS)?
434    }
435
436    let typ = SqlRelationType::new(column_types).with_keys(keys);
437
438    let temporary = *temporary;
439    let name = if temporary {
440        scx.allocate_temporary_qualified_name(normalize::unresolved_item_name(name.to_owned())?)?
441    } else {
442        scx.allocate_qualified_name(normalize::unresolved_item_name(name.to_owned())?)?
443    };
444
445    // Check for an object in the catalog with this same name
446    let full_name = scx.catalog.resolve_full_name(&name);
447    let partial_name = PartialItemName::from(full_name.clone());
448    // For PostgreSQL compatibility, we need to prevent creating tables when
449    // there is an existing object *or* type of the same name.
450    if let (false, Ok(item)) = (
451        if_not_exists,
452        scx.catalog.resolve_item_or_type(&partial_name),
453    ) {
454        return Err(PlanError::ItemAlreadyExists {
455            name: full_name.to_string(),
456            item_type: item.item_type(),
457        });
458    }
459
460    let desc = RelationDesc::new(typ, names);
461    let mut desc = VersionedRelationDesc::new(desc);
462    for (version, (_action, name, typ)) in changes.into_iter() {
463        let new_version = desc.add_column(name, typ);
464        if version != new_version {
465            return Err(PlanError::InvalidTable {
466                name: full_name.item,
467            });
468        }
469    }
470
471    let create_sql = normalize::create_statement(scx, Statement::CreateTable(stmt.clone()))?;
472
473    // Table options should only consider the original columns, since those
474    // were the only ones in scope when the table was created.
475    //
476    // TODO(alter_table): Will need to reconsider this when we support ALTERing
477    // the PARTITION BY columns.
478    let original_desc = desc.at_version(RelationVersionSelector::Specific(RelationVersion::root()));
479    let options = plan_table_options(scx, &original_desc, with_options.clone())?;
480
481    let compaction_window = options.iter().find_map(|o| {
482        #[allow(irrefutable_let_patterns)]
483        if let crate::plan::TableOption::RetainHistory(lcw) = o {
484            Some(lcw.clone())
485        } else {
486            None
487        }
488    });
489
490    let table = Table {
491        create_sql,
492        desc,
493        temporary,
494        compaction_window,
495        data_source: TableDataSource::TableWrites { defaults },
496    };
497    Ok(Plan::CreateTable(CreateTablePlan {
498        name,
499        table,
500        if_not_exists: *if_not_exists,
501    }))
502}
503
504pub fn describe_create_table_from_source(
505    _: &StatementContext,
506    _: CreateTableFromSourceStatement<Aug>,
507) -> Result<StatementDesc, PlanError> {
508    Ok(StatementDesc::new(None))
509}
510
511pub fn describe_create_webhook_source(
512    _: &StatementContext,
513    _: CreateWebhookSourceStatement<Aug>,
514) -> Result<StatementDesc, PlanError> {
515    Ok(StatementDesc::new(None))
516}
517
518pub fn describe_create_source(
519    _: &StatementContext,
520    _: CreateSourceStatement<Aug>,
521) -> Result<StatementDesc, PlanError> {
522    Ok(StatementDesc::new(None))
523}
524
525pub fn describe_create_subsource(
526    _: &StatementContext,
527    _: CreateSubsourceStatement<Aug>,
528) -> Result<StatementDesc, PlanError> {
529    Ok(StatementDesc::new(None))
530}
531
532generate_extracted_config!(
533    CreateSourceOption,
534    (TimestampInterval, Duration),
535    (RetainHistory, OptionalDuration)
536);
537
538generate_extracted_config!(
539    PgConfigOption,
540    (Details, String),
541    (Publication, String),
542    (TextColumns, Vec::<UnresolvedItemName>, Default(vec![])),
543    (ExcludeColumns, Vec::<UnresolvedItemName>, Default(vec![]))
544);
545
546generate_extracted_config!(
547    MySqlConfigOption,
548    (Details, String),
549    (TextColumns, Vec::<UnresolvedItemName>, Default(vec![])),
550    (ExcludeColumns, Vec::<UnresolvedItemName>, Default(vec![]))
551);
552
553generate_extracted_config!(
554    SqlServerConfigOption,
555    (Details, String),
556    (TextColumns, Vec::<UnresolvedItemName>, Default(vec![])),
557    (ExcludeColumns, Vec::<UnresolvedItemName>, Default(vec![]))
558);
559
560pub fn plan_create_webhook_source(
561    scx: &StatementContext,
562    mut stmt: CreateWebhookSourceStatement<Aug>,
563) -> Result<Plan, PlanError> {
564    if stmt.is_table {
565        scx.require_feature_flag(&ENABLE_CREATE_TABLE_FROM_SOURCE)?;
566    }
567
568    // We will rewrite the cluster if one is not provided, so we must use the `in_cluster` value
569    // we plan to normalize when we canonicalize the create statement.
570    let in_cluster = source_sink_cluster_config(scx, &mut stmt.in_cluster)?;
571    let create_sql =
572        normalize::create_statement(scx, Statement::CreateWebhookSource(stmt.clone()))?;
573
574    let CreateWebhookSourceStatement {
575        name,
576        if_not_exists,
577        body_format,
578        include_headers,
579        validate_using,
580        is_table,
581        // We resolved `in_cluster` above, so we want to ignore it here.
582        in_cluster: _,
583    } = stmt;
584
585    let validate_using = validate_using
586        .map(|stmt| query::plan_webhook_validate_using(scx, stmt))
587        .transpose()?;
588    if let Some(WebhookValidation { expression, .. }) = &validate_using {
589        // If the validation expression doesn't reference any part of the request, then we should
590        // return an error because it's almost definitely wrong.
591        if !expression.contains_column() {
592            return Err(PlanError::WebhookValidationDoesNotUseColumns);
593        }
594        // Validation expressions cannot contain unmaterializable functions, except `now()`. We
595        // allow calls to `now()` because some webhook providers recommend rejecting requests that
596        // are older than a certain threshold.
597        if expression.contains_unmaterializable_except(&[UnmaterializableFunc::CurrentTimestamp]) {
598            return Err(PlanError::WebhookValidationNonDeterministic);
599        }
600    }
601
602    let body_format = match body_format {
603        Format::Bytes => WebhookBodyFormat::Bytes,
604        Format::Json { array } => WebhookBodyFormat::Json { array },
605        Format::Text => WebhookBodyFormat::Text,
606        // TODO(parkmycar): Make an issue to support more types, or change this to NeverSupported.
607        ty => {
608            return Err(PlanError::Unsupported {
609                feature: format!("{ty} is not a valid BODY FORMAT for a WEBHOOK source"),
610                discussion_no: None,
611            });
612        }
613    };
614
615    let mut column_ty = vec![
616        // Always include the body of the request as the first column.
617        SqlColumnType {
618            scalar_type: SqlScalarType::from(body_format),
619            nullable: false,
620        },
621    ];
622    let mut column_names = vec!["body".to_string()];
623
624    let mut headers = WebhookHeaders::default();
625
626    // Include a `headers` column, possibly filtered.
627    if let Some(filters) = include_headers.column {
628        column_ty.push(SqlColumnType {
629            scalar_type: SqlScalarType::Map {
630                value_type: Box::new(SqlScalarType::String),
631                custom_id: None,
632            },
633            nullable: false,
634        });
635        column_names.push("headers".to_string());
636
637        let (allow, block): (BTreeSet<_>, BTreeSet<_>) =
638            filters.into_iter().partition_map(|filter| {
639                if filter.block {
640                    itertools::Either::Right(filter.header_name)
641                } else {
642                    itertools::Either::Left(filter.header_name)
643                }
644            });
645        headers.header_column = Some(WebhookHeaderFilters { allow, block });
646    }
647
648    // Map headers to specific columns.
649    for header in include_headers.mappings {
650        let scalar_type = header
651            .use_bytes
652            .then_some(SqlScalarType::Bytes)
653            .unwrap_or(SqlScalarType::String);
654        column_ty.push(SqlColumnType {
655            scalar_type,
656            nullable: true,
657        });
658        column_names.push(header.column_name.into_string());
659
660        let column_idx = column_ty.len() - 1;
661        // Double check we're consistent with column names.
662        assert_eq!(
663            column_idx,
664            column_names.len() - 1,
665            "header column names and types don't match"
666        );
667        headers
668            .mapped_headers
669            .insert(column_idx, (header.header_name, header.use_bytes));
670    }
671
672    // Validate our columns.
673    let mut unique_check = HashSet::with_capacity(column_names.len());
674    for name in &column_names {
675        if !unique_check.insert(name) {
676            return Err(PlanError::AmbiguousColumn(name.clone().into()));
677        }
678    }
679    if column_names.len() > MAX_NUM_COLUMNS {
680        return Err(PlanError::TooManyColumns {
681            max_num_columns: MAX_NUM_COLUMNS,
682            req_num_columns: column_names.len(),
683        });
684    }
685
686    let typ = SqlRelationType::new(column_ty);
687    let desc = RelationDesc::new(typ, column_names);
688
689    // Check for an object in the catalog with this same name
690    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name)?)?;
691    let full_name = scx.catalog.resolve_full_name(&name);
692    let partial_name = PartialItemName::from(full_name.clone());
693    if let (false, Ok(item)) = (if_not_exists, scx.catalog.resolve_item(&partial_name)) {
694        return Err(PlanError::ItemAlreadyExists {
695            name: full_name.to_string(),
696            item_type: item.item_type(),
697        });
698    }
699
700    // Note(parkmycar): We don't currently support specifying a timeline for Webhook sources. As
701    // such, we always use a default of EpochMilliseconds.
702    let timeline = Timeline::EpochMilliseconds;
703
704    let plan = if is_table {
705        let data_source = DataSourceDesc::Webhook {
706            validate_using,
707            body_format,
708            headers,
709            cluster_id: Some(in_cluster.id()),
710        };
711        let data_source = TableDataSource::DataSource {
712            desc: data_source,
713            timeline,
714        };
715        Plan::CreateTable(CreateTablePlan {
716            name,
717            if_not_exists,
718            table: Table {
719                create_sql,
720                desc: VersionedRelationDesc::new(desc),
721                temporary: false,
722                compaction_window: None,
723                data_source,
724            },
725        })
726    } else {
727        let data_source = DataSourceDesc::Webhook {
728            validate_using,
729            body_format,
730            headers,
731            // Important: The cluster is set at the `Source` level.
732            cluster_id: None,
733        };
734        Plan::CreateSource(CreateSourcePlan {
735            name,
736            source: Source {
737                create_sql,
738                data_source,
739                desc,
740                compaction_window: None,
741            },
742            if_not_exists,
743            timeline,
744            in_cluster: Some(in_cluster.id()),
745        })
746    };
747
748    Ok(plan)
749}
750
751pub fn plan_create_source(
752    scx: &StatementContext,
753    mut stmt: CreateSourceStatement<Aug>,
754) -> Result<Plan, PlanError> {
755    let CreateSourceStatement {
756        name,
757        in_cluster: _,
758        col_names,
759        connection: source_connection,
760        envelope,
761        if_not_exists,
762        format,
763        key_constraint,
764        include_metadata,
765        with_options,
766        external_references: referenced_subsources,
767        progress_subsource,
768    } = &stmt;
769
770    mz_ore::soft_assert_or_log!(
771        referenced_subsources.is_none(),
772        "referenced subsources must be cleared in purification"
773    );
774
775    let force_source_table_syntax = scx.catalog.system_vars().enable_create_table_from_source()
776        && scx.catalog.system_vars().force_source_table_syntax();
777
778    // If the new source table syntax is forced all the options related to the primary
779    // source output should be un-set.
780    if force_source_table_syntax {
781        if envelope.is_some() || format.is_some() || !include_metadata.is_empty() {
782            Err(PlanError::UseTablesForSources(
783                "CREATE SOURCE (ENVELOPE|FORMAT|INCLUDE)".to_string(),
784            ))?;
785        }
786    }
787
788    let envelope = envelope.clone().unwrap_or(ast::SourceEnvelope::None);
789
790    if !matches!(source_connection, CreateSourceConnection::Kafka { .. })
791        && include_metadata
792            .iter()
793            .any(|sic| matches!(sic, SourceIncludeMetadata::Headers { .. }))
794    {
795        // TODO(guswynn): should this be `bail_unsupported!`?
796        sql_bail!("INCLUDE HEADERS with non-Kafka sources not supported");
797    }
798    if !matches!(
799        source_connection,
800        CreateSourceConnection::Kafka { .. } | CreateSourceConnection::LoadGenerator { .. }
801    ) && !include_metadata.is_empty()
802    {
803        bail_unsupported!("INCLUDE metadata with non-Kafka sources");
804    }
805
806    if !include_metadata.is_empty()
807        && !matches!(
808            envelope,
809            ast::SourceEnvelope::Upsert { .. }
810                | ast::SourceEnvelope::None
811                | ast::SourceEnvelope::Debezium
812        )
813    {
814        sql_bail!("INCLUDE <metadata> requires ENVELOPE (NONE|UPSERT|DEBEZIUM)");
815    }
816
817    let external_connection =
818        plan_generic_source_connection(scx, source_connection, include_metadata)?;
819
820    let CreateSourceOptionExtracted {
821        timestamp_interval,
822        retain_history,
823        seen: _,
824    } = CreateSourceOptionExtracted::try_from(with_options.clone())?;
825
826    let metadata_columns_desc = match external_connection {
827        GenericSourceConnection::Kafka(KafkaSourceConnection {
828            ref metadata_columns,
829            ..
830        }) => kafka_metadata_columns_desc(metadata_columns),
831        _ => vec![],
832    };
833
834    // Generate the relation description for the primary export of the source.
835    let (mut desc, envelope, encoding) = apply_source_envelope_encoding(
836        scx,
837        &envelope,
838        format,
839        Some(external_connection.default_key_desc()),
840        external_connection.default_value_desc(),
841        include_metadata,
842        metadata_columns_desc,
843        &external_connection,
844    )?;
845    plan_utils::maybe_rename_columns(format!("source {}", name), &mut desc, col_names)?;
846
847    let names: Vec<_> = desc.iter_names().cloned().collect();
848    if let Some(dup) = names.iter().duplicates().next() {
849        sql_bail!("column {} specified more than once", dup.quoted());
850    }
851
852    // Apply user-specified key constraint
853    if let Some(KeyConstraint::PrimaryKeyNotEnforced { columns }) = key_constraint.clone() {
854        // Don't remove this without addressing
855        // https://github.com/MaterializeInc/database-issues/issues/4371.
856        scx.require_feature_flag(&vars::ENABLE_PRIMARY_KEY_NOT_ENFORCED)?;
857
858        let key_columns = columns
859            .into_iter()
860            .map(normalize::column_name)
861            .collect::<Vec<_>>();
862
863        let mut uniq = BTreeSet::new();
864        for col in key_columns.iter() {
865            if !uniq.insert(col) {
866                sql_bail!("Repeated column name in source key constraint: {}", col);
867            }
868        }
869
870        let key_indices = key_columns
871            .iter()
872            .map(|col| {
873                let name_idx = desc
874                    .get_by_name(col)
875                    .map(|(idx, _type)| idx)
876                    .ok_or_else(|| sql_err!("No such column in source key constraint: {}", col))?;
877                if desc.get_unambiguous_name(name_idx).is_none() {
878                    sql_bail!("Ambiguous column in source key constraint: {}", col);
879                }
880                Ok(name_idx)
881            })
882            .collect::<Result<Vec<_>, _>>()?;
883
884        if !desc.typ().keys.is_empty() {
885            return Err(key_constraint_err(&desc, &key_columns));
886        } else {
887            desc = desc.with_key(key_indices);
888        }
889    }
890
891    let timestamp_interval = match timestamp_interval {
892        Some(duration) => {
893            // Only validate bounds for new statements (pcx is Some), not during
894            // catalog deserialization (pcx is None). Previously persisted sources
895            // may have intervals that no longer fall within the current bounds.
896            if scx.pcx.is_some() {
897                let min = scx.catalog.system_vars().min_timestamp_interval();
898                let max = scx.catalog.system_vars().max_timestamp_interval();
899                if duration < min || duration > max {
900                    return Err(PlanError::InvalidTimestampInterval {
901                        min,
902                        max,
903                        requested: duration,
904                    });
905                }
906            }
907            duration
908        }
909        None => scx.catalog.system_vars().default_timestamp_interval(),
910    };
911
912    let (desc, data_source) = match progress_subsource {
913        Some(name) => {
914            let DeferredItemName::Named(name) = name else {
915                sql_bail!("[internal error] progress subsource must be named during purification");
916            };
917            let ResolvedItemName::Item { id, .. } = name else {
918                sql_bail!("[internal error] invalid target id");
919            };
920
921            let details = match external_connection {
922                GenericSourceConnection::Kafka(ref c) => {
923                    SourceExportDetails::Kafka(KafkaSourceExportDetails {
924                        metadata_columns: c.metadata_columns.clone(),
925                    })
926                }
927                GenericSourceConnection::LoadGenerator(ref c) => match c.load_generator {
928                    LoadGenerator::Auction
929                    | LoadGenerator::Marketing
930                    | LoadGenerator::Tpch { .. } => SourceExportDetails::None,
931                    LoadGenerator::Counter { .. }
932                    | LoadGenerator::Clock
933                    | LoadGenerator::Datums
934                    | LoadGenerator::KeyValue(_) => {
935                        SourceExportDetails::LoadGenerator(LoadGeneratorSourceExportDetails {
936                            output: LoadGeneratorOutput::Default,
937                        })
938                    }
939                },
940                GenericSourceConnection::Postgres(_)
941                | GenericSourceConnection::MySql(_)
942                | GenericSourceConnection::SqlServer(_) => SourceExportDetails::None,
943            };
944
945            let data_source = DataSourceDesc::OldSyntaxIngestion {
946                desc: SourceDesc {
947                    connection: external_connection,
948                    timestamp_interval,
949                },
950                progress_subsource: *id,
951                data_config: SourceExportDataConfig {
952                    encoding,
953                    envelope: envelope.clone(),
954                },
955                details,
956            };
957            (desc, data_source)
958        }
959        None => {
960            let desc = external_connection.timestamp_desc();
961            let data_source = DataSourceDesc::Ingestion(SourceDesc {
962                connection: external_connection,
963                timestamp_interval,
964            });
965            (desc, data_source)
966        }
967    };
968
969    let if_not_exists = *if_not_exists;
970    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name.clone())?)?;
971
972    // Check for an object in the catalog with this same name
973    let full_name = scx.catalog.resolve_full_name(&name);
974    let partial_name = PartialItemName::from(full_name.clone());
975    // For PostgreSQL compatibility, we need to prevent creating sources when
976    // there is an existing object *or* type of the same name.
977    if let (false, Ok(item)) = (
978        if_not_exists,
979        scx.catalog.resolve_item_or_type(&partial_name),
980    ) {
981        return Err(PlanError::ItemAlreadyExists {
982            name: full_name.to_string(),
983            item_type: item.item_type(),
984        });
985    }
986
987    // We will rewrite the cluster if one is not provided, so we must use the
988    // `in_cluster` value we plan to normalize when we canonicalize the create
989    // statement.
990    let in_cluster = source_sink_cluster_config(scx, &mut stmt.in_cluster)?;
991
992    let create_sql = normalize::create_statement(scx, Statement::CreateSource(stmt))?;
993
994    // Determine a default timeline for the source.
995    let timeline = match envelope {
996        SourceEnvelope::CdcV2 => {
997            Timeline::External(scx.catalog.resolve_full_name(&name).to_string())
998        }
999        _ => Timeline::EpochMilliseconds,
1000    };
1001
1002    let compaction_window = plan_retain_history_option(scx, retain_history)?;
1003
1004    let source = Source {
1005        create_sql,
1006        data_source,
1007        desc,
1008        compaction_window,
1009    };
1010
1011    Ok(Plan::CreateSource(CreateSourcePlan {
1012        name,
1013        source,
1014        if_not_exists,
1015        timeline,
1016        in_cluster: Some(in_cluster.id()),
1017    }))
1018}
1019
1020pub fn plan_generic_source_connection(
1021    scx: &StatementContext<'_>,
1022    source_connection: &CreateSourceConnection<Aug>,
1023    include_metadata: &Vec<SourceIncludeMetadata>,
1024) -> Result<GenericSourceConnection<ReferencedConnection>, PlanError> {
1025    Ok(match source_connection {
1026        CreateSourceConnection::Kafka {
1027            connection,
1028            options,
1029        } => GenericSourceConnection::Kafka(plan_kafka_source_connection(
1030            scx,
1031            connection,
1032            options,
1033            include_metadata,
1034        )?),
1035        CreateSourceConnection::Postgres {
1036            connection,
1037            options,
1038        } => GenericSourceConnection::Postgres(plan_postgres_source_connection(
1039            scx, connection, options,
1040        )?),
1041        CreateSourceConnection::SqlServer {
1042            connection,
1043            options,
1044        } => GenericSourceConnection::SqlServer(plan_sqlserver_source_connection(
1045            scx, connection, options,
1046        )?),
1047        CreateSourceConnection::MySql {
1048            connection,
1049            options,
1050        } => {
1051            GenericSourceConnection::MySql(plan_mysql_source_connection(scx, connection, options)?)
1052        }
1053        CreateSourceConnection::LoadGenerator { generator, options } => {
1054            GenericSourceConnection::LoadGenerator(plan_load_generator_source_connection(
1055                scx,
1056                generator,
1057                options,
1058                include_metadata,
1059            )?)
1060        }
1061    })
1062}
1063
1064fn plan_load_generator_source_connection(
1065    scx: &StatementContext<'_>,
1066    generator: &ast::LoadGenerator,
1067    options: &Vec<LoadGeneratorOption<Aug>>,
1068    include_metadata: &Vec<SourceIncludeMetadata>,
1069) -> Result<LoadGeneratorSourceConnection, PlanError> {
1070    let load_generator =
1071        load_generator_ast_to_generator(scx, generator, options, include_metadata)?;
1072    let LoadGeneratorOptionExtracted {
1073        tick_interval,
1074        as_of,
1075        up_to,
1076        ..
1077    } = options.clone().try_into()?;
1078    let tick_micros = match tick_interval {
1079        Some(interval) => Some(interval.as_micros().try_into()?),
1080        None => None,
1081    };
1082    if up_to < as_of {
1083        sql_bail!("UP TO cannot be less than AS OF");
1084    }
1085    Ok(LoadGeneratorSourceConnection {
1086        load_generator,
1087        tick_micros,
1088        as_of,
1089        up_to,
1090    })
1091}
1092
1093fn plan_mysql_source_connection(
1094    scx: &StatementContext<'_>,
1095    connection: &ResolvedItemName,
1096    options: &Vec<MySqlConfigOption<Aug>>,
1097) -> Result<MySqlSourceConnection<ReferencedConnection>, PlanError> {
1098    let connection_item = scx.get_item_by_resolved_name(connection)?;
1099    match connection_item.connection()? {
1100        Connection::MySql(connection) => connection,
1101        _ => sql_bail!(
1102            "{} is not a MySQL connection",
1103            scx.catalog.resolve_full_name(connection_item.name())
1104        ),
1105    };
1106    let MySqlConfigOptionExtracted {
1107        details,
1108        // text/exclude columns are already part of the source-exports and are only included
1109        // in these options for round-tripping of a `CREATE SOURCE` statement. This should
1110        // be removed once we drop support for implicitly created subsources.
1111        text_columns: _,
1112        exclude_columns: _,
1113        seen: _,
1114    } = options.clone().try_into()?;
1115    let details = details
1116        .as_ref()
1117        .ok_or_else(|| internal_err!("MySQL source missing details"))?;
1118    let details = hex::decode(details).map_err(|e| sql_err!("{}", e))?;
1119    let details = ProtoMySqlSourceDetails::decode(&*details).map_err(|e| sql_err!("{}", e))?;
1120    let details = MySqlSourceDetails::from_proto(details).map_err(|e| sql_err!("{}", e))?;
1121    Ok(MySqlSourceConnection {
1122        connection: connection_item.id(),
1123        connection_id: connection_item.id(),
1124        details,
1125    })
1126}
1127
1128fn plan_sqlserver_source_connection(
1129    scx: &StatementContext<'_>,
1130    connection: &ResolvedItemName,
1131    options: &Vec<SqlServerConfigOption<Aug>>,
1132) -> Result<SqlServerSourceConnection<ReferencedConnection>, PlanError> {
1133    let connection_item = scx.get_item_by_resolved_name(connection)?;
1134    match connection_item.connection()? {
1135        Connection::SqlServer(connection) => connection,
1136        _ => sql_bail!(
1137            "{} is not a SQL Server connection",
1138            scx.catalog.resolve_full_name(connection_item.name())
1139        ),
1140    };
1141    let SqlServerConfigOptionExtracted { details, .. } = options.clone().try_into()?;
1142    let details = details
1143        .as_ref()
1144        .ok_or_else(|| internal_err!("SQL Server source missing details"))?;
1145    let extras = hex::decode(details)
1146        .map_err(|e| sql_err!("{e}"))
1147        .and_then(|raw| ProtoSqlServerSourceExtras::decode(&*raw).map_err(|e| sql_err!("{e}")))
1148        .and_then(|proto| SqlServerSourceExtras::from_proto(proto).map_err(|e| sql_err!("{e}")))?;
1149    Ok(SqlServerSourceConnection {
1150        connection_id: connection_item.id(),
1151        connection: connection_item.id(),
1152        extras,
1153    })
1154}
1155
1156fn plan_postgres_source_connection(
1157    scx: &StatementContext<'_>,
1158    connection: &ResolvedItemName,
1159    options: &Vec<PgConfigOption<Aug>>,
1160) -> Result<PostgresSourceConnection<ReferencedConnection>, PlanError> {
1161    let connection_item = scx.get_item_by_resolved_name(connection)?;
1162    let PgConfigOptionExtracted {
1163        details,
1164        publication,
1165        // text columns are already part of the source-exports and are only included
1166        // in these options for round-tripping of a `CREATE SOURCE` statement. This should
1167        // be removed once we drop support for implicitly created subsources.
1168        text_columns: _,
1169        // exclude columns are already part of the source-exports and are only included
1170        // in these options for round-tripping of a `CREATE SOURCE` statement. This should
1171        // be removed once we drop support for implicitly created subsources.
1172        exclude_columns: _,
1173        seen: _,
1174    } = options.clone().try_into()?;
1175    let details = details
1176        .as_ref()
1177        .ok_or_else(|| internal_err!("Postgres source missing details"))?;
1178    let details = hex::decode(details).map_err(|e| sql_err!("{}", e))?;
1179    let details =
1180        ProtoPostgresSourcePublicationDetails::decode(&*details).map_err(|e| sql_err!("{}", e))?;
1181    let publication_details =
1182        PostgresSourcePublicationDetails::from_proto(details).map_err(|e| sql_err!("{}", e))?;
1183    Ok(PostgresSourceConnection {
1184        connection: connection_item.id(),
1185        connection_id: connection_item.id(),
1186        // Validated during purification.
1187        publication: publication.ok_or_else(|| internal_err!("PUBLICATION option is required"))?,
1188        publication_details,
1189    })
1190}
1191
1192fn plan_kafka_source_connection(
1193    scx: &StatementContext<'_>,
1194    connection_name: &ResolvedItemName,
1195    options: &Vec<ast::KafkaSourceConfigOption<Aug>>,
1196    include_metadata: &Vec<SourceIncludeMetadata>,
1197) -> Result<KafkaSourceConnection<ReferencedConnection>, PlanError> {
1198    let connection_item = scx.get_item_by_resolved_name(connection_name)?;
1199    if !matches!(connection_item.connection()?, Connection::Kafka(_)) {
1200        sql_bail!(
1201            "{} is not a kafka connection",
1202            scx.catalog.resolve_full_name(connection_item.name())
1203        )
1204    }
1205    let KafkaSourceConfigOptionExtracted {
1206        group_id_prefix,
1207        topic,
1208        topic_metadata_refresh_interval,
1209        start_timestamp: _, // purified into `start_offset`
1210        start_offset,
1211        seen: _,
1212    }: KafkaSourceConfigOptionExtracted = options.clone().try_into()?;
1213    // Validated during purification.
1214    let topic = topic.ok_or_else(|| internal_err!("TOPIC option is required"))?;
1215    let mut start_offsets = BTreeMap::new();
1216    if let Some(offsets) = start_offset {
1217        for (part, offset) in offsets.iter().enumerate() {
1218            if *offset < 0 {
1219                sql_bail!("START OFFSET must be a nonnegative integer");
1220            }
1221            start_offsets.insert(i32::try_from(part)?, *offset);
1222        }
1223    }
1224    if topic_metadata_refresh_interval > Duration::from_secs(60 * 60) {
1225        // This is a librdkafka-enforced restriction that, if violated,
1226        // would result in a runtime error for the source.
1227        sql_bail!("TOPIC METADATA REFRESH INTERVAL cannot be greater than 1 hour");
1228    }
1229    if topic_metadata_refresh_interval < Duration::from_secs(1) {
1230        // This is a librdkafka-enforced restriction that, if violated,
1231        // would result in a runtime error for the source.
1232        sql_bail!("TOPIC METADATA REFRESH INTERVAL must be at least 1 second");
1233    }
1234    let metadata_columns = include_metadata
1235        .into_iter()
1236        .flat_map(|item| match item {
1237            SourceIncludeMetadata::Timestamp { alias } => {
1238                let name = match alias {
1239                    Some(name) => name.to_string(),
1240                    None => "timestamp".to_owned(),
1241                };
1242                Some((name, KafkaMetadataKind::Timestamp))
1243            }
1244            SourceIncludeMetadata::Partition { alias } => {
1245                let name = match alias {
1246                    Some(name) => name.to_string(),
1247                    None => "partition".to_owned(),
1248                };
1249                Some((name, KafkaMetadataKind::Partition))
1250            }
1251            SourceIncludeMetadata::Offset { alias } => {
1252                let name = match alias {
1253                    Some(name) => name.to_string(),
1254                    None => "offset".to_owned(),
1255                };
1256                Some((name, KafkaMetadataKind::Offset))
1257            }
1258            SourceIncludeMetadata::Headers { alias } => {
1259                let name = match alias {
1260                    Some(name) => name.to_string(),
1261                    None => "headers".to_owned(),
1262                };
1263                Some((name, KafkaMetadataKind::Headers))
1264            }
1265            SourceIncludeMetadata::Header {
1266                alias,
1267                key,
1268                use_bytes,
1269            } => Some((
1270                alias.to_string(),
1271                KafkaMetadataKind::Header {
1272                    key: key.clone(),
1273                    use_bytes: *use_bytes,
1274                },
1275            )),
1276            SourceIncludeMetadata::Key { .. } => {
1277                // handled below
1278                None
1279            }
1280        })
1281        .collect();
1282    Ok(KafkaSourceConnection {
1283        connection: connection_item.id(),
1284        connection_id: connection_item.id(),
1285        topic,
1286        start_offsets,
1287        group_id_prefix,
1288        topic_metadata_refresh_interval,
1289        metadata_columns,
1290    })
1291}
1292
1293fn apply_source_envelope_encoding(
1294    scx: &StatementContext,
1295    envelope: &ast::SourceEnvelope,
1296    format: &Option<FormatSpecifier<Aug>>,
1297    key_desc: Option<RelationDesc>,
1298    value_desc: RelationDesc,
1299    include_metadata: &[SourceIncludeMetadata],
1300    metadata_columns_desc: Vec<(&str, SqlColumnType)>,
1301    source_connection: &GenericSourceConnection<ReferencedConnection>,
1302) -> Result<
1303    (
1304        RelationDesc,
1305        SourceEnvelope,
1306        Option<SourceDataEncoding<ReferencedConnection>>,
1307    ),
1308    PlanError,
1309> {
1310    let encoding = match format {
1311        Some(format) => Some(get_encoding(scx, format, envelope)?),
1312        None => None,
1313    };
1314
1315    let (key_desc, value_desc) = match &encoding {
1316        Some(encoding) => {
1317            // If we are applying an encoding we need to ensure that the incoming value_desc is a
1318            // single column of type bytes.
1319            match value_desc.typ().columns() {
1320                [typ] => match typ.scalar_type {
1321                    SqlScalarType::Bytes => {}
1322                    _ => sql_bail!(
1323                        "The schema produced by the source is incompatible with format decoding"
1324                    ),
1325                },
1326                _ => sql_bail!(
1327                    "The schema produced by the source is incompatible with format decoding"
1328                ),
1329            }
1330
1331            let (key_desc, value_desc) = encoding.desc()?;
1332
1333            // TODO(petrosagg): This piece of code seems to be making a statement about the
1334            // nullability of the NONE envelope when the source is Kafka. As written, the code
1335            // misses opportunities to mark columns as not nullable and is over conservative. For
1336            // example in the case of `FORMAT BYTES ENVELOPE NONE` the output is indeed
1337            // non-nullable but we will mark it as nullable anyway. This kind of crude reasoning
1338            // should be replaced with precise type-level reasoning.
1339            let key_desc = key_desc.map(|desc| {
1340                let is_kafka = matches!(source_connection, GenericSourceConnection::Kafka(_));
1341                let is_envelope_none = matches!(envelope, ast::SourceEnvelope::None);
1342                if is_kafka && is_envelope_none {
1343                    RelationDesc::from_names_and_types(
1344                        desc.into_iter()
1345                            .map(|(name, typ)| (name, typ.nullable(true))),
1346                    )
1347                } else {
1348                    desc
1349                }
1350            });
1351            (key_desc, value_desc)
1352        }
1353        None => (key_desc, value_desc),
1354    };
1355
1356    // KEY VALUE load generators are the only UPSERT source that
1357    // has no encoding but defaults to `INCLUDE KEY`.
1358    //
1359    // As discussed
1360    // <https://github.com/MaterializeInc/materialize/pull/26246#issuecomment-2023558097>,
1361    // removing this special case amounts to deciding how to handle null keys
1362    // from sources, in a holistic way. We aren't yet prepared to do this, so we leave
1363    // this special case in.
1364    //
1365    // Note that this is safe because this generator is
1366    // 1. The only source with no encoding that can have its key included.
1367    // 2. Never produces null keys (or values, for that matter).
1368    let key_envelope_no_encoding = matches!(
1369        source_connection,
1370        GenericSourceConnection::LoadGenerator(LoadGeneratorSourceConnection {
1371            load_generator: LoadGenerator::KeyValue(_),
1372            ..
1373        })
1374    );
1375    let mut key_envelope = get_key_envelope(
1376        include_metadata,
1377        encoding.as_ref(),
1378        key_envelope_no_encoding,
1379    )?;
1380
1381    match (&envelope, &key_envelope) {
1382        (ast::SourceEnvelope::Debezium, KeyEnvelope::None) => {}
1383        (ast::SourceEnvelope::Debezium, _) => sql_bail!(
1384            "Cannot use INCLUDE KEY with ENVELOPE DEBEZIUM: Debezium values include all keys."
1385        ),
1386        _ => {}
1387    };
1388
1389    // Not all source envelopes are compatible with all source connections.
1390    // Whoever constructs the source ingestion pipeline is responsible for
1391    // choosing compatible envelopes and connections.
1392    //
1393    // TODO(guswynn): ambiguously assert which connections and envelopes are
1394    // compatible in typechecking
1395    //
1396    // TODO: remove bails as more support for upsert is added.
1397    let envelope = match &envelope {
1398        // TODO: fixup key envelope
1399        ast::SourceEnvelope::None => UnplannedSourceEnvelope::None(key_envelope),
1400        ast::SourceEnvelope::Debezium => {
1401            //TODO check that key envelope is not set
1402            let after_idx = match typecheck_debezium(&value_desc) {
1403                Ok((_before_idx, after_idx)) => Ok(after_idx),
1404                Err(type_err) => match encoding.as_ref().map(|e| &e.value) {
1405                    Some(DataEncoding::Avro(_)) => Err(type_err),
1406                    _ => Err(sql_err!(
1407                        "ENVELOPE DEBEZIUM requires that VALUE FORMAT is set to AVRO"
1408                    )),
1409                },
1410            }?;
1411
1412            UnplannedSourceEnvelope::Upsert {
1413                style: UpsertStyle::Debezium { after_idx },
1414            }
1415        }
1416        ast::SourceEnvelope::Upsert {
1417            value_decode_err_policy,
1418        } => {
1419            let key_encoding = match encoding.as_ref().and_then(|e| e.key.as_ref()) {
1420                None => {
1421                    if !key_envelope_no_encoding {
1422                        bail_unsupported!(format!(
1423                            "UPSERT requires a key/value format: {:?}",
1424                            format
1425                        ))
1426                    }
1427                    None
1428                }
1429                Some(key_encoding) => Some(key_encoding),
1430            };
1431            // `ENVELOPE UPSERT` implies `INCLUDE KEY`, if it is not explicitly
1432            // specified.
1433            if key_envelope == KeyEnvelope::None {
1434                key_envelope = get_unnamed_key_envelope(key_encoding)?;
1435            }
1436            // If the value decode error policy is not set we use the default upsert style.
1437            let style = match value_decode_err_policy.as_slice() {
1438                [] => UpsertStyle::Default(key_envelope),
1439                [SourceErrorPolicy::Inline { alias }] => {
1440                    scx.require_feature_flag(&vars::ENABLE_ENVELOPE_UPSERT_INLINE_ERRORS)?;
1441                    UpsertStyle::ValueErrInline {
1442                        key_envelope,
1443                        error_column: alias
1444                            .as_ref()
1445                            .map_or_else(|| "error".to_string(), |a| a.to_string()),
1446                    }
1447                }
1448                _ => {
1449                    bail_unsupported!("ENVELOPE UPSERT with unsupported value decode error policy")
1450                }
1451            };
1452
1453            UnplannedSourceEnvelope::Upsert { style }
1454        }
1455        ast::SourceEnvelope::CdcV2 => {
1456            scx.require_feature_flag(&vars::ENABLE_ENVELOPE_MATERIALIZE)?;
1457            //TODO check that key envelope is not set
1458            match format {
1459                Some(FormatSpecifier::Bare(Format::Avro(_))) => {}
1460                _ => bail_unsupported!("non-Avro-encoded ENVELOPE MATERIALIZE"),
1461            }
1462            UnplannedSourceEnvelope::CdcV2
1463        }
1464    };
1465
1466    let metadata_desc = included_column_desc(metadata_columns_desc);
1467    let (envelope, desc) = envelope.desc(key_desc, value_desc, metadata_desc)?;
1468
1469    Ok((desc, envelope, encoding))
1470}
1471
1472/// Plans the RelationDesc for a source export (subsource or table) that has a defined list
1473/// of columns and constraints.
1474fn plan_source_export_desc(
1475    scx: &StatementContext,
1476    name: &UnresolvedItemName,
1477    columns: &Vec<ColumnDef<Aug>>,
1478    constraints: &Vec<TableConstraint<Aug>>,
1479) -> Result<RelationDesc, PlanError> {
1480    let names: Vec<_> = columns
1481        .iter()
1482        .map(|c| normalize::column_name(c.name.clone()))
1483        .collect();
1484
1485    if let Some(dup) = names.iter().duplicates().next() {
1486        sql_bail!("column {} specified more than once", dup.quoted());
1487    }
1488
1489    // Build initial relation type that handles declared data types
1490    // and NOT NULL constraints.
1491    let mut column_types = Vec::with_capacity(columns.len());
1492    let mut keys = Vec::new();
1493
1494    for (i, c) in columns.into_iter().enumerate() {
1495        let aug_data_type = &c.data_type;
1496        let ty = query::scalar_type_from_sql(scx, aug_data_type)?;
1497        let mut nullable = true;
1498        for option in &c.options {
1499            match &option.option {
1500                ColumnOption::NotNull => nullable = false,
1501                ColumnOption::Default(_) => {
1502                    bail_unsupported!("Source export with default value")
1503                }
1504                ColumnOption::Unique { is_primary } => {
1505                    keys.push(vec![i]);
1506                    if *is_primary {
1507                        nullable = false;
1508                    }
1509                }
1510                other => {
1511                    bail_unsupported!(format!("Source export with column constraint: {}", other))
1512                }
1513            }
1514        }
1515        column_types.push(ty.nullable(nullable));
1516    }
1517
1518    let mut seen_primary = false;
1519    'c: for constraint in constraints {
1520        match constraint {
1521            TableConstraint::Unique {
1522                name: _,
1523                columns,
1524                is_primary,
1525                nulls_not_distinct,
1526            } => {
1527                if seen_primary && *is_primary {
1528                    sql_bail!(
1529                        "multiple primary keys for source export {} are not allowed",
1530                        name.to_ast_string_stable()
1531                    );
1532                }
1533                seen_primary = *is_primary || seen_primary;
1534
1535                let mut key = vec![];
1536                for column in columns {
1537                    let column = normalize::column_name(column.clone());
1538                    match names.iter().position(|name| *name == column) {
1539                        None => sql_bail!("unknown column in constraint: {}", column),
1540                        Some(i) => {
1541                            let nullable = &mut column_types[i].nullable;
1542                            if *is_primary {
1543                                if *nulls_not_distinct {
1544                                    sql_bail!(
1545                                        "[internal error] PRIMARY KEY does not support NULLS NOT DISTINCT"
1546                                    );
1547                                }
1548                                *nullable = false;
1549                            } else if !(*nulls_not_distinct || !*nullable) {
1550                                // Non-primary key unique constraints are only keys if all of their
1551                                // columns are `NOT NULL` or the constraint is `NULLS NOT DISTINCT`.
1552                                break 'c;
1553                            }
1554
1555                            key.push(i);
1556                        }
1557                    }
1558                }
1559
1560                if *is_primary {
1561                    keys.insert(0, key);
1562                } else {
1563                    keys.push(key);
1564                }
1565            }
1566            TableConstraint::ForeignKey { .. } => {
1567                bail_unsupported!("Source export with a foreign key")
1568            }
1569            TableConstraint::Check { .. } => {
1570                bail_unsupported!("Source export with a check constraint")
1571            }
1572        }
1573    }
1574
1575    let typ = SqlRelationType::new(column_types).with_keys(keys);
1576    let desc = RelationDesc::new(typ, names);
1577    Ok(desc)
1578}
1579
1580generate_extracted_config!(
1581    CreateSubsourceOption,
1582    (Progress, bool, Default(false)),
1583    (ExternalReference, UnresolvedItemName),
1584    (RetainHistory, OptionalDuration),
1585    (TextColumns, Vec::<Ident>, Default(vec![])),
1586    (ExcludeColumns, Vec::<Ident>, Default(vec![])),
1587    (Details, String)
1588);
1589
1590pub fn plan_create_subsource(
1591    scx: &StatementContext,
1592    stmt: CreateSubsourceStatement<Aug>,
1593) -> Result<Plan, PlanError> {
1594    let CreateSubsourceStatement {
1595        name,
1596        columns,
1597        of_source,
1598        constraints,
1599        if_not_exists,
1600        with_options,
1601    } = &stmt;
1602
1603    let CreateSubsourceOptionExtracted {
1604        progress,
1605        retain_history,
1606        external_reference,
1607        text_columns,
1608        exclude_columns,
1609        details,
1610        seen: _,
1611    } = with_options.clone().try_into()?;
1612
1613    // This invariant is enforced during purification; we are responsible for
1614    // creating the AST for subsources as a response to CREATE SOURCE
1615    // statements, so this would fire in integration testing if we failed to
1616    // uphold it.
1617    if !(progress ^ (external_reference.is_some() && of_source.is_some())) {
1618        bail_internal!(
1619            "CREATE SUBSOURCE statement must specify either PROGRESS or REFERENCES option"
1620        );
1621    }
1622
1623    let desc = plan_source_export_desc(scx, name, columns, constraints)?;
1624
1625    let data_source = if let Some(source_reference) = of_source {
1626        // If the new source table syntax is forced we should not be creating any non-progress
1627        // subsources.
1628        if scx.catalog.system_vars().enable_create_table_from_source()
1629            && scx.catalog.system_vars().force_source_table_syntax()
1630        {
1631            Err(PlanError::UseTablesForSources(
1632                "CREATE SUBSOURCE".to_string(),
1633            ))?;
1634        }
1635
1636        // This is a subsource with the "natural" dependency order, i.e. it is
1637        // not a legacy subsource with the inverted structure.
1638        let ingestion_id = *source_reference.item_id();
1639        let external_reference = external_reference.ok_or_else(|| {
1640            sql_err!("CREATE SUBSOURCE with REFERENCES requires EXTERNAL REFERENCE option")
1641        })?;
1642
1643        // Decode the details option stored on the subsource statement, which contains information
1644        // created during the purification process.
1645        let details = details
1646            .as_ref()
1647            .ok_or_else(|| internal_err!("source-export subsource missing details"))?;
1648        let details = hex::decode(details).map_err(|e| sql_err!("{}", e))?;
1649        let details =
1650            ProtoSourceExportStatementDetails::decode(&*details).map_err(|e| sql_err!("{}", e))?;
1651        let details =
1652            SourceExportStatementDetails::from_proto(details).map_err(|e| sql_err!("{}", e))?;
1653        let details = match details {
1654            SourceExportStatementDetails::Postgres {
1655                table,
1656                cast_oid_full_range,
1657            } => SourceExportDetails::Postgres(PostgresSourceExportDetails {
1658                column_casts: crate::pure::postgres::generate_column_casts(
1659                    scx,
1660                    &table,
1661                    &text_columns,
1662                    cast_oid_full_range,
1663                )?,
1664                table,
1665            }),
1666            SourceExportStatementDetails::MySql {
1667                table,
1668                initial_gtid_set,
1669                binlog_full_metadata,
1670            } => SourceExportDetails::MySql(MySqlSourceExportDetails {
1671                table,
1672                initial_gtid_set,
1673                text_columns: text_columns.into_iter().map(|c| c.into_string()).collect(),
1674                exclude_columns: exclude_columns
1675                    .into_iter()
1676                    .map(|c| c.into_string())
1677                    .collect(),
1678                binlog_full_metadata,
1679            }),
1680            SourceExportStatementDetails::SqlServer {
1681                table,
1682                capture_instance,
1683                initial_lsn,
1684            } => SourceExportDetails::SqlServer(SqlServerSourceExportDetails {
1685                capture_instance,
1686                table,
1687                initial_lsn,
1688                text_columns: text_columns.into_iter().map(|c| c.into_string()).collect(),
1689                exclude_columns: exclude_columns
1690                    .into_iter()
1691                    .map(|c| c.into_string())
1692                    .collect(),
1693            }),
1694            SourceExportStatementDetails::LoadGenerator { output } => {
1695                SourceExportDetails::LoadGenerator(LoadGeneratorSourceExportDetails { output })
1696            }
1697            SourceExportStatementDetails::Kafka {} => {
1698                bail_unsupported!("subsources cannot reference Kafka sources")
1699            }
1700        };
1701        DataSourceDesc::IngestionExport {
1702            ingestion_id,
1703            external_reference,
1704            details,
1705            // Subsources don't currently support non-default envelopes / encoding
1706            data_config: SourceExportDataConfig {
1707                envelope: SourceEnvelope::None(NoneEnvelope {
1708                    key_envelope: KeyEnvelope::None,
1709                    key_arity: 0,
1710                }),
1711                encoding: None,
1712            },
1713        }
1714    } else if progress {
1715        DataSourceDesc::Progress
1716    } else {
1717        sql_bail!("CREATE SUBSOURCE must specify one of PROGRESS or REFERENCES option")
1718    };
1719
1720    let if_not_exists = *if_not_exists;
1721    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name.clone())?)?;
1722
1723    let create_sql = normalize::create_statement(scx, Statement::CreateSubsource(stmt))?;
1724
1725    let compaction_window = plan_retain_history_option(scx, retain_history)?;
1726    let source = Source {
1727        create_sql,
1728        data_source,
1729        desc,
1730        compaction_window,
1731    };
1732
1733    Ok(Plan::CreateSource(CreateSourcePlan {
1734        name,
1735        source,
1736        if_not_exists,
1737        timeline: Timeline::EpochMilliseconds,
1738        in_cluster: None,
1739    }))
1740}
1741
1742generate_extracted_config!(
1743    TableFromSourceOption,
1744    (TextColumns, Vec::<Ident>, Default(vec![])),
1745    (ExcludeColumns, Vec::<Ident>, Default(vec![])),
1746    (PartitionBy, Vec<Ident>),
1747    (RetainHistory, OptionalDuration),
1748    (Details, String)
1749);
1750
1751pub fn plan_create_table_from_source(
1752    scx: &StatementContext,
1753    stmt: CreateTableFromSourceStatement<Aug>,
1754) -> Result<Plan, PlanError> {
1755    if !scx.catalog.system_vars().enable_create_table_from_source() {
1756        sql_bail!("CREATE TABLE ... FROM SOURCE is not supported");
1757    }
1758
1759    let CreateTableFromSourceStatement {
1760        name,
1761        columns,
1762        constraints,
1763        if_not_exists,
1764        source,
1765        external_reference,
1766        envelope,
1767        format,
1768        include_metadata,
1769        with_options,
1770    } = &stmt;
1771
1772    let envelope = envelope.clone().unwrap_or(ast::SourceEnvelope::None);
1773
1774    let TableFromSourceOptionExtracted {
1775        text_columns,
1776        exclude_columns,
1777        retain_history,
1778        partition_by,
1779        details,
1780        seen: _,
1781    } = with_options.clone().try_into()?;
1782
1783    let source_item = scx.get_item_by_resolved_name(source)?;
1784    let ingestion_id = source_item.id();
1785
1786    // Decode the details option stored on the statement, which contains information
1787    // created during the purification process.
1788    let details = details
1789        .as_ref()
1790        .ok_or_else(|| internal_err!("source-export missing details"))?;
1791    let details = hex::decode(details).map_err(|e| sql_err!("{}", e))?;
1792    let details =
1793        ProtoSourceExportStatementDetails::decode(&*details).map_err(|e| sql_err!("{}", e))?;
1794    let details =
1795        SourceExportStatementDetails::from_proto(details).map_err(|e| sql_err!("{}", e))?;
1796
1797    if !matches!(details, SourceExportStatementDetails::Kafka { .. })
1798        && include_metadata
1799            .iter()
1800            .any(|sic| matches!(sic, SourceIncludeMetadata::Headers { .. }))
1801    {
1802        // TODO(guswynn): should this be `bail_unsupported!`?
1803        sql_bail!("INCLUDE HEADERS with non-Kafka source table not supported");
1804    }
1805    if !matches!(
1806        details,
1807        SourceExportStatementDetails::Kafka { .. }
1808            | SourceExportStatementDetails::LoadGenerator { .. }
1809    ) && !include_metadata.is_empty()
1810    {
1811        bail_unsupported!("INCLUDE metadata with non-Kafka source table");
1812    }
1813
1814    let details = match details {
1815        SourceExportStatementDetails::Postgres {
1816            table,
1817            cast_oid_full_range,
1818        } => SourceExportDetails::Postgres(PostgresSourceExportDetails {
1819            column_casts: crate::pure::postgres::generate_column_casts(
1820                scx,
1821                &table,
1822                &text_columns,
1823                cast_oid_full_range,
1824            )?,
1825            table,
1826        }),
1827        SourceExportStatementDetails::MySql {
1828            table,
1829            initial_gtid_set,
1830            binlog_full_metadata,
1831        } => SourceExportDetails::MySql(MySqlSourceExportDetails {
1832            table,
1833            initial_gtid_set,
1834            text_columns: text_columns.into_iter().map(|c| c.into_string()).collect(),
1835            exclude_columns: exclude_columns
1836                .into_iter()
1837                .map(|c| c.into_string())
1838                .collect(),
1839            binlog_full_metadata,
1840        }),
1841        SourceExportStatementDetails::SqlServer {
1842            table,
1843            capture_instance,
1844            initial_lsn,
1845        } => SourceExportDetails::SqlServer(SqlServerSourceExportDetails {
1846            table,
1847            capture_instance,
1848            initial_lsn,
1849            text_columns: text_columns.into_iter().map(|c| c.into_string()).collect(),
1850            exclude_columns: exclude_columns
1851                .into_iter()
1852                .map(|c| c.into_string())
1853                .collect(),
1854        }),
1855        SourceExportStatementDetails::LoadGenerator { output } => {
1856            SourceExportDetails::LoadGenerator(LoadGeneratorSourceExportDetails { output })
1857        }
1858        SourceExportStatementDetails::Kafka {} => {
1859            if !include_metadata.is_empty()
1860                && !matches!(
1861                    envelope,
1862                    ast::SourceEnvelope::Upsert { .. }
1863                        | ast::SourceEnvelope::None
1864                        | ast::SourceEnvelope::Debezium
1865                )
1866            {
1867                // TODO(guswynn): should this be `bail_unsupported!`?
1868                sql_bail!("INCLUDE <metadata> requires ENVELOPE (NONE|UPSERT|DEBEZIUM)");
1869            }
1870
1871            let metadata_columns = include_metadata
1872                .into_iter()
1873                .flat_map(|item| match item {
1874                    SourceIncludeMetadata::Timestamp { alias } => {
1875                        let name = match alias {
1876                            Some(name) => name.to_string(),
1877                            None => "timestamp".to_owned(),
1878                        };
1879                        Some((name, KafkaMetadataKind::Timestamp))
1880                    }
1881                    SourceIncludeMetadata::Partition { alias } => {
1882                        let name = match alias {
1883                            Some(name) => name.to_string(),
1884                            None => "partition".to_owned(),
1885                        };
1886                        Some((name, KafkaMetadataKind::Partition))
1887                    }
1888                    SourceIncludeMetadata::Offset { alias } => {
1889                        let name = match alias {
1890                            Some(name) => name.to_string(),
1891                            None => "offset".to_owned(),
1892                        };
1893                        Some((name, KafkaMetadataKind::Offset))
1894                    }
1895                    SourceIncludeMetadata::Headers { alias } => {
1896                        let name = match alias {
1897                            Some(name) => name.to_string(),
1898                            None => "headers".to_owned(),
1899                        };
1900                        Some((name, KafkaMetadataKind::Headers))
1901                    }
1902                    SourceIncludeMetadata::Header {
1903                        alias,
1904                        key,
1905                        use_bytes,
1906                    } => Some((
1907                        alias.to_string(),
1908                        KafkaMetadataKind::Header {
1909                            key: key.clone(),
1910                            use_bytes: *use_bytes,
1911                        },
1912                    )),
1913                    SourceIncludeMetadata::Key { .. } => {
1914                        // handled below
1915                        None
1916                    }
1917                })
1918                .collect();
1919
1920            SourceExportDetails::Kafka(KafkaSourceExportDetails { metadata_columns })
1921        }
1922    };
1923
1924    let source_connection = &source_item
1925        .source_desc()?
1926        .ok_or_else(|| sql_err!("item is not a source"))?
1927        .connection;
1928
1929    // Some source-types (e.g. postgres, mysql, multi-output load-gen sources) define a value_schema
1930    // during purification and define the `columns` and `constraints` fields for the statement,
1931    // whereas other source-types (e.g. kafka, single-output load-gen sources) do not, so instead
1932    // we use the source connection's default schema.
1933    let (key_desc, value_desc) =
1934        if matches!(columns, TableFromSourceColumns::Defined(_)) || !constraints.is_empty() {
1935            let columns = match columns {
1936                TableFromSourceColumns::Defined(columns) => columns,
1937                _ => bail_internal!("expected column definitions to be present"),
1938            };
1939            let desc = plan_source_export_desc(scx, name, columns, constraints)?;
1940            (None, desc)
1941        } else {
1942            let key_desc = source_connection.default_key_desc();
1943            let value_desc = source_connection.default_value_desc();
1944            (Some(key_desc), value_desc)
1945        };
1946
1947    let metadata_columns_desc = match &details {
1948        SourceExportDetails::Kafka(KafkaSourceExportDetails {
1949            metadata_columns, ..
1950        }) => kafka_metadata_columns_desc(metadata_columns),
1951        _ => vec![],
1952    };
1953
1954    let (mut desc, envelope, encoding) = apply_source_envelope_encoding(
1955        scx,
1956        &envelope,
1957        format,
1958        key_desc,
1959        value_desc,
1960        include_metadata,
1961        metadata_columns_desc,
1962        source_connection,
1963    )?;
1964    if let TableFromSourceColumns::Named(col_names) = columns {
1965        plan_utils::maybe_rename_columns(format!("source table {}", name), &mut desc, col_names)?;
1966    }
1967
1968    let names: Vec<_> = desc.iter_names().cloned().collect();
1969    if let Some(dup) = names.iter().duplicates().next() {
1970        sql_bail!("column {} specified more than once", dup.quoted());
1971    }
1972
1973    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name.clone())?)?;
1974
1975    // Allow users to specify a timeline. If they do not, determine a default
1976    // timeline for the source.
1977    let timeline = match envelope {
1978        SourceEnvelope::CdcV2 => {
1979            Timeline::External(scx.catalog.resolve_full_name(&name).to_string())
1980        }
1981        _ => Timeline::EpochMilliseconds,
1982    };
1983
1984    if let Some(partition_by) = partition_by {
1985        scx.require_feature_flag(&ENABLE_COLLECTION_PARTITION_BY)?;
1986        check_partition_by(&desc, partition_by)?;
1987    }
1988
1989    let data_source = DataSourceDesc::IngestionExport {
1990        ingestion_id,
1991        // Populated during purification.
1992        external_reference: external_reference
1993            .as_ref()
1994            .ok_or_else(|| sql_err!("EXTERNAL REFERENCE is required"))?
1995            .clone(),
1996        details,
1997        data_config: SourceExportDataConfig { envelope, encoding },
1998    };
1999
2000    let if_not_exists = *if_not_exists;
2001
2002    let create_sql = normalize::create_statement(scx, Statement::CreateTableFromSource(stmt))?;
2003
2004    let compaction_window = plan_retain_history_option(scx, retain_history)?;
2005    let table = Table {
2006        create_sql,
2007        desc: VersionedRelationDesc::new(desc),
2008        temporary: false,
2009        compaction_window,
2010        data_source: TableDataSource::DataSource {
2011            desc: data_source,
2012            timeline,
2013        },
2014    };
2015
2016    Ok(Plan::CreateTable(CreateTablePlan {
2017        name,
2018        table,
2019        if_not_exists,
2020    }))
2021}
2022
2023generate_extracted_config!(
2024    LoadGeneratorOption,
2025    (TickInterval, Duration),
2026    (AsOf, u64, Default(0_u64)),
2027    (UpTo, u64, Default(u64::MAX)),
2028    (ScaleFactor, f64),
2029    (MaxCardinality, u64),
2030    (Keys, u64),
2031    (SnapshotRounds, u64),
2032    (TransactionalSnapshot, bool),
2033    (ValueSize, u64),
2034    (Seed, u64),
2035    (Partitions, u64),
2036    (BatchSize, u64)
2037);
2038
2039impl LoadGeneratorOptionExtracted {
2040    pub(super) fn ensure_only_valid_options(
2041        &self,
2042        loadgen: &ast::LoadGenerator,
2043    ) -> Result<(), PlanError> {
2044        use mz_sql_parser::ast::LoadGeneratorOptionName::*;
2045
2046        let mut options = self.seen.clone();
2047
2048        let permitted_options: &[_] = match loadgen {
2049            ast::LoadGenerator::Auction => &[TickInterval, AsOf, UpTo],
2050            ast::LoadGenerator::Clock => &[TickInterval, AsOf, UpTo],
2051            ast::LoadGenerator::Counter => &[TickInterval, AsOf, UpTo, MaxCardinality],
2052            ast::LoadGenerator::Marketing => &[TickInterval, AsOf, UpTo],
2053            ast::LoadGenerator::Datums => &[TickInterval, AsOf, UpTo],
2054            ast::LoadGenerator::Tpch => &[TickInterval, AsOf, UpTo, ScaleFactor],
2055            ast::LoadGenerator::KeyValue => &[
2056                TickInterval,
2057                Keys,
2058                SnapshotRounds,
2059                TransactionalSnapshot,
2060                ValueSize,
2061                Seed,
2062                Partitions,
2063                BatchSize,
2064            ],
2065        };
2066
2067        for o in permitted_options {
2068            options.remove(o);
2069        }
2070
2071        if !options.is_empty() {
2072            sql_bail!(
2073                "{} load generators do not support {} values",
2074                loadgen,
2075                options.iter().join(", ")
2076            )
2077        }
2078
2079        Ok(())
2080    }
2081}
2082
2083pub(crate) fn load_generator_ast_to_generator(
2084    scx: &StatementContext,
2085    loadgen: &ast::LoadGenerator,
2086    options: &[LoadGeneratorOption<Aug>],
2087    include_metadata: &[SourceIncludeMetadata],
2088) -> Result<LoadGenerator, PlanError> {
2089    let extracted: LoadGeneratorOptionExtracted = options.to_vec().try_into()?;
2090    extracted.ensure_only_valid_options(loadgen)?;
2091
2092    if loadgen != &ast::LoadGenerator::KeyValue && !include_metadata.is_empty() {
2093        sql_bail!("INCLUDE metadata only supported with `KEY VALUE` load generators");
2094    }
2095
2096    let load_generator = match loadgen {
2097        ast::LoadGenerator::Auction => LoadGenerator::Auction,
2098        ast::LoadGenerator::Clock => {
2099            scx.require_feature_flag(&vars::ENABLE_LOAD_GENERATOR_CLOCK)?;
2100            LoadGenerator::Clock
2101        }
2102        ast::LoadGenerator::Counter => {
2103            scx.require_feature_flag(&vars::ENABLE_LOAD_GENERATOR_COUNTER)?;
2104            let LoadGeneratorOptionExtracted {
2105                max_cardinality, ..
2106            } = extracted;
2107            LoadGenerator::Counter { max_cardinality }
2108        }
2109        ast::LoadGenerator::Marketing => LoadGenerator::Marketing,
2110        ast::LoadGenerator::Datums => {
2111            scx.require_feature_flag(&vars::ENABLE_LOAD_GENERATOR_DATUMS)?;
2112            LoadGenerator::Datums
2113        }
2114        ast::LoadGenerator::Tpch => {
2115            let LoadGeneratorOptionExtracted { scale_factor, .. } = extracted;
2116
2117            // Default to 0.01 scale factor (=10MB).
2118            let sf: f64 = scale_factor.unwrap_or(0.01);
2119            if !sf.is_finite() || sf < 0.0 {
2120                sql_bail!("unsupported scale factor {sf}");
2121            }
2122
2123            let f_to_i = |multiplier: f64| -> Result<i64, PlanError> {
2124                let total = (sf * multiplier).floor();
2125                let mut i = i64::try_cast_from(total)
2126                    .ok_or_else(|| sql_err!("unsupported scale factor {sf}"))?;
2127                if i < 1 {
2128                    i = 1;
2129                }
2130                Ok(i)
2131            };
2132
2133            // The multiplications here are safely unchecked because they will
2134            // overflow to infinity, which will be caught by f64_to_i64.
2135            let count_supplier = f_to_i(10_000f64)?;
2136            let count_part = f_to_i(200_000f64)?;
2137            let count_customer = f_to_i(150_000f64)?;
2138            let count_orders = f_to_i(150_000f64 * 10f64)?;
2139            let count_clerk = f_to_i(1_000f64)?;
2140
2141            LoadGenerator::Tpch {
2142                count_supplier,
2143                count_part,
2144                count_customer,
2145                count_orders,
2146                count_clerk,
2147            }
2148        }
2149        mz_sql_parser::ast::LoadGenerator::KeyValue => {
2150            scx.require_feature_flag(&vars::ENABLE_LOAD_GENERATOR_KEY_VALUE)?;
2151            let LoadGeneratorOptionExtracted {
2152                keys,
2153                snapshot_rounds,
2154                transactional_snapshot,
2155                value_size,
2156                tick_interval,
2157                seed,
2158                partitions,
2159                batch_size,
2160                ..
2161            } = extracted;
2162
2163            let mut include_offset = None;
2164            for im in include_metadata {
2165                match im {
2166                    SourceIncludeMetadata::Offset { alias } => {
2167                        include_offset = match alias {
2168                            Some(alias) => Some(alias.to_string()),
2169                            None => Some(LOAD_GENERATOR_KEY_VALUE_OFFSET_DEFAULT.to_string()),
2170                        }
2171                    }
2172                    SourceIncludeMetadata::Key { .. } => continue,
2173
2174                    _ => {
2175                        sql_bail!("only `INCLUDE OFFSET` and `INCLUDE KEY` is supported");
2176                    }
2177                };
2178            }
2179
2180            let lgkv = KeyValueLoadGenerator {
2181                keys: keys.ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires KEYS"))?,
2182                snapshot_rounds: snapshot_rounds
2183                    .ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires SNAPSHOT ROUNDS"))?,
2184                // Defaults to true.
2185                transactional_snapshot: transactional_snapshot.unwrap_or(true),
2186                value_size: value_size
2187                    .ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires VALUE SIZE"))?,
2188                partitions: partitions
2189                    .ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires PARTITIONS"))?,
2190                tick_interval,
2191                batch_size: batch_size
2192                    .ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires BATCH SIZE"))?,
2193                seed: seed.ok_or_else(|| sql_err!("LOAD GENERATOR KEY VALUE requires SEED"))?,
2194                include_offset,
2195            };
2196
2197            if lgkv.keys == 0
2198                || lgkv.partitions == 0
2199                || lgkv.value_size == 0
2200                || lgkv.batch_size == 0
2201            {
2202                sql_bail!("LOAD GENERATOR KEY VALUE options must be non-zero")
2203            }
2204
2205            if lgkv.keys % lgkv.partitions != 0 {
2206                sql_bail!("KEYS must be a multiple of PARTITIONS")
2207            }
2208
2209            if lgkv.batch_size > lgkv.keys {
2210                sql_bail!("KEYS must be larger than BATCH SIZE")
2211            }
2212
2213            // This constraints simplifies the source implementation.
2214            // We can lift it later.
2215            if (lgkv.keys / lgkv.partitions) % lgkv.batch_size != 0 {
2216                sql_bail!("PARTITIONS * BATCH SIZE must be a divisor of KEYS")
2217            }
2218
2219            if lgkv.snapshot_rounds == 0 {
2220                sql_bail!("SNAPSHOT ROUNDS must be larger than 0")
2221            }
2222
2223            LoadGenerator::KeyValue(lgkv)
2224        }
2225    };
2226
2227    Ok(load_generator)
2228}
2229
2230fn typecheck_debezium(value_desc: &RelationDesc) -> Result<(Option<usize>, usize), PlanError> {
2231    let before = value_desc.get_by_name(&"before".into());
2232    let (after_idx, after_ty) = value_desc
2233        .get_by_name(&"after".into())
2234        .ok_or_else(|| sql_err!("'after' column missing from debezium input"))?;
2235    let before_idx = if let Some((before_idx, before_ty)) = before {
2236        if !matches!(before_ty.scalar_type, SqlScalarType::Record { .. }) {
2237            sql_bail!("'before' column must be of type record");
2238        }
2239        if before_ty != after_ty {
2240            sql_bail!("'before' type differs from 'after' column");
2241        }
2242        Some(before_idx)
2243    } else {
2244        None
2245    };
2246    Ok((before_idx, after_idx))
2247}
2248
2249fn get_encoding(
2250    scx: &StatementContext,
2251    format: &FormatSpecifier<Aug>,
2252    envelope: &ast::SourceEnvelope,
2253) -> Result<SourceDataEncoding<ReferencedConnection>, PlanError> {
2254    let encoding = match format {
2255        FormatSpecifier::Bare(format) => get_encoding_inner(scx, format)?,
2256        FormatSpecifier::KeyValue { key, value } => {
2257            let key = {
2258                let encoding = get_encoding_inner(scx, key)?;
2259                Some(encoding.key.unwrap_or(encoding.value))
2260            };
2261            let value = get_encoding_inner(scx, value)?.value;
2262            SourceDataEncoding { key, value }
2263        }
2264    };
2265
2266    let requires_keyvalue = matches!(
2267        envelope,
2268        ast::SourceEnvelope::Debezium | ast::SourceEnvelope::Upsert { .. }
2269    );
2270    let is_keyvalue = encoding.key.is_some();
2271    if requires_keyvalue && !is_keyvalue {
2272        sql_bail!("ENVELOPE [DEBEZIUM] UPSERT requires that KEY FORMAT be specified");
2273    };
2274
2275    Ok(encoding)
2276}
2277
2278/// Determine the cluster ID to use for this item.
2279///
2280/// If `in_cluster` is `None` we will update it to refer to the default cluster.
2281/// Because of this, do not normalize/canonicalize the create SQL statement
2282/// until after calling this function.
2283fn source_sink_cluster_config<'a, 'ctx>(
2284    scx: &'a StatementContext<'ctx>,
2285    in_cluster: &mut Option<ResolvedClusterName>,
2286) -> Result<&'a dyn CatalogCluster<'ctx>, PlanError> {
2287    let cluster = match in_cluster {
2288        None => {
2289            let cluster = scx.catalog.resolve_cluster(None)?;
2290            *in_cluster = Some(ResolvedClusterName {
2291                id: cluster.id(),
2292                print_name: None,
2293            });
2294            cluster
2295        }
2296        Some(in_cluster) => scx.catalog.get_cluster(in_cluster.id),
2297    };
2298
2299    Ok(cluster)
2300}
2301
2302generate_extracted_config!(AvroSchemaOption, (ConfluentWireFormat, bool, Default(true)));
2303
2304generate_extracted_config!(GlueAvroOption, (SchemaName, String));
2305
2306#[derive(Debug)]
2307pub struct Schema {
2308    pub key_schema: Option<String>,
2309    pub value_schema: String,
2310    /// Reference schemas for the key schema, in dependency order.
2311    pub key_reference_schemas: Vec<String>,
2312    /// Reference schemas for the value schema, in dependency order.
2313    pub value_reference_schemas: Vec<String>,
2314    /// Wire-format dispatch and the registry connection to fetch writer
2315    /// schemas from. Built directly by each `AvroSchema` variant rather
2316    /// than reconstructed from a (csr_connection, bool) pair downstream.
2317    pub wire_format: WireFormat<ReferencedConnection>,
2318}
2319
2320fn get_encoding_inner(
2321    scx: &StatementContext,
2322    format: &Format<Aug>,
2323) -> Result<SourceDataEncoding<ReferencedConnection>, PlanError> {
2324    let value = match format {
2325        Format::Bytes => DataEncoding::Bytes,
2326        Format::Avro(schema) => {
2327            let Schema {
2328                key_schema,
2329                value_schema,
2330                key_reference_schemas,
2331                value_reference_schemas,
2332                wire_format,
2333            } = match schema {
2334                // TODO(jldlaughlin): we need a way to pass in primary key information
2335                // when building a source from a string or file.
2336                AvroSchema::InlineSchema {
2337                    schema: ast::Schema { schema },
2338                    with_options,
2339                } => {
2340                    let AvroSchemaOptionExtracted {
2341                        confluent_wire_format,
2342                        ..
2343                    } = with_options.clone().try_into()?;
2344                    let wire_format = if confluent_wire_format {
2345                        WireFormat::Confluent { registry: None }
2346                    } else {
2347                        WireFormat::None
2348                    };
2349                    Schema {
2350                        key_schema: None,
2351                        value_schema: schema.clone(),
2352                        key_reference_schemas: vec![],
2353                        value_reference_schemas: vec![],
2354                        wire_format,
2355                    }
2356                }
2357                AvroSchema::Csr {
2358                    csr_connection:
2359                        CsrConnectionAvro {
2360                            connection,
2361                            seed,
2362                            key_strategy: _,
2363                            value_strategy: _,
2364                        },
2365                } => {
2366                    let item = scx.get_item_by_resolved_name(&connection.connection)?;
2367                    let csr_connection = match item.connection()? {
2368                        Connection::Csr(_) => item.id(),
2369                        _ => {
2370                            sql_bail!(
2371                                "{} is not a Confluent Schema Registry connection",
2372                                scx.catalog
2373                                    .resolve_full_name(item.name())
2374                                    .to_string()
2375                                    .quoted()
2376                            )
2377                        }
2378                    };
2379
2380                    if let Some(seed) = seed {
2381                        Schema {
2382                            key_schema: seed.key_schema.clone(),
2383                            value_schema: seed.value_schema.clone(),
2384                            key_reference_schemas: seed.key_reference_schemas.clone(),
2385                            value_reference_schemas: seed.value_reference_schemas.clone(),
2386                            wire_format: WireFormat::Confluent {
2387                                registry: Some(csr_connection),
2388                            },
2389                        }
2390                    } else {
2391                        sql_bail!("Avro CSR seed resolution has not been performed")
2392                    }
2393                }
2394                AvroSchema::Glue {
2395                    connection,
2396                    with_options: _,
2397                    seed,
2398                } => {
2399                    let item = scx.get_item_by_resolved_name(connection)?;
2400                    let glue_connection = match item.connection()? {
2401                        Connection::GlueSchemaRegistry(_) => item.id(),
2402                        _ => {
2403                            sql_bail!(
2404                                "{} is not an AWS Glue Schema Registry connection",
2405                                scx.catalog
2406                                    .resolve_full_name(item.name())
2407                                    .to_string()
2408                                    .quoted()
2409                            )
2410                        }
2411                    };
2412
2413                    // `SCHEMA NAME` requiredness is enforced during
2414                    // purification, which also populates `seed`. By the time
2415                    // planning runs the option is guaranteed present.
2416                    let Some(seed) = seed else {
2417                        sql_bail!("Avro Glue seed resolution has not been performed");
2418                    };
2419
2420                    Schema {
2421                        key_schema: None,
2422                        value_schema: seed.value_schema.clone(),
2423                        key_reference_schemas: vec![],
2424                        value_reference_schemas: vec![],
2425                        wire_format: WireFormat::Glue {
2426                            registry: Some(glue_connection),
2427                        },
2428                    }
2429                }
2430            };
2431
2432            if let Some(key_schema) = key_schema {
2433                return Ok(SourceDataEncoding {
2434                    key: Some(DataEncoding::Avro(AvroEncoding {
2435                        schema: key_schema,
2436                        reference_schemas: key_reference_schemas,
2437                        wire_format: wire_format.clone(),
2438                    })),
2439                    value: DataEncoding::Avro(AvroEncoding {
2440                        schema: value_schema,
2441                        reference_schemas: value_reference_schemas,
2442                        wire_format,
2443                    }),
2444                });
2445            } else {
2446                DataEncoding::Avro(AvroEncoding {
2447                    schema: value_schema,
2448                    reference_schemas: value_reference_schemas,
2449                    wire_format,
2450                })
2451            }
2452        }
2453        Format::Protobuf(schema) => match schema {
2454            ProtobufSchema::Csr {
2455                csr_connection:
2456                    CsrConnectionProtobuf {
2457                        connection:
2458                            CsrConnection {
2459                                connection,
2460                                options,
2461                            },
2462                        seed,
2463                    },
2464            } => {
2465                if let Some(CsrSeedProtobuf { key, value }) = seed {
2466                    let item = scx.get_item_by_resolved_name(connection)?;
2467                    let _ = match item.connection()? {
2468                        Connection::Csr(connection) => connection,
2469                        _ => {
2470                            sql_bail!(
2471                                "{} is not a schema registry connection",
2472                                scx.catalog
2473                                    .resolve_full_name(item.name())
2474                                    .to_string()
2475                                    .quoted()
2476                            )
2477                        }
2478                    };
2479
2480                    if !options.is_empty() {
2481                        sql_bail!("Protobuf CSR connections do not support any options");
2482                    }
2483
2484                    let value = DataEncoding::Protobuf(ProtobufEncoding {
2485                        descriptors: strconv::parse_bytes(&value.schema)?,
2486                        message_name: value.message_name.clone(),
2487                        confluent_wire_format: true,
2488                    });
2489                    if let Some(key) = key {
2490                        return Ok(SourceDataEncoding {
2491                            key: Some(DataEncoding::Protobuf(ProtobufEncoding {
2492                                descriptors: strconv::parse_bytes(&key.schema)?,
2493                                message_name: key.message_name.clone(),
2494                                confluent_wire_format: true,
2495                            })),
2496                            value,
2497                        });
2498                    }
2499                    value
2500                } else {
2501                    sql_bail!("Protobuf CSR seed resolution has not been performed")
2502                }
2503            }
2504            ProtobufSchema::InlineSchema {
2505                message_name,
2506                schema: ast::Schema { schema },
2507            } => {
2508                let descriptors = strconv::parse_bytes(schema)?;
2509
2510                DataEncoding::Protobuf(ProtobufEncoding {
2511                    descriptors,
2512                    message_name: message_name.to_owned(),
2513                    confluent_wire_format: false,
2514                })
2515            }
2516        },
2517        Format::Regex(regex) => DataEncoding::Regex(RegexEncoding {
2518            regex: mz_repr::adt::regex::Regex::new(regex, false)
2519                .map_err(|e| sql_err!("parsing regex: {e}"))?,
2520        }),
2521        Format::Csv { columns, delimiter } => {
2522            let columns = match columns {
2523                CsvColumns::Header { names } => {
2524                    if names.is_empty() {
2525                        sql_bail!("[internal error] column spec should get names in purify")
2526                    }
2527                    ColumnSpec::Header {
2528                        names: names.iter().cloned().map(|n| n.into_string()).collect(),
2529                    }
2530                }
2531                CsvColumns::Count(n) => ColumnSpec::Count(usize::cast_from(*n)),
2532            };
2533            DataEncoding::Csv(CsvEncoding {
2534                columns,
2535                delimiter: u8::try_from(*delimiter)
2536                    .map_err(|_| sql_err!("CSV delimiter must be an ASCII character"))?,
2537            })
2538        }
2539        Format::Json { array: false } => DataEncoding::Json,
2540        Format::Json { array: true } => bail_unsupported!("JSON ARRAY format in sources"),
2541        Format::Text => DataEncoding::Text,
2542    };
2543    Ok(SourceDataEncoding { key: None, value })
2544}
2545
2546/// Extract the key envelope, if it is requested
2547fn get_key_envelope(
2548    included_items: &[SourceIncludeMetadata],
2549    encoding: Option<&SourceDataEncoding<ReferencedConnection>>,
2550    key_envelope_no_encoding: bool,
2551) -> Result<KeyEnvelope, PlanError> {
2552    let key_definition = included_items
2553        .iter()
2554        .find(|i| matches!(i, SourceIncludeMetadata::Key { .. }));
2555    if let Some(SourceIncludeMetadata::Key { alias }) = key_definition {
2556        match (alias, encoding.and_then(|e| e.key.as_ref())) {
2557            (Some(name), Some(_)) => Ok(KeyEnvelope::Named(name.as_str().to_string())),
2558            (None, Some(key)) => get_unnamed_key_envelope(Some(key)),
2559            (Some(name), _) if key_envelope_no_encoding => {
2560                Ok(KeyEnvelope::Named(name.as_str().to_string()))
2561            }
2562            (None, _) if key_envelope_no_encoding => get_unnamed_key_envelope(None),
2563            (_, None) => {
2564                // `kd.alias` == `None` means `INCLUDE KEY`
2565                // `kd.alias` == `Some(_) means INCLUDE KEY AS ___`
2566                // These both make sense with the same error message
2567                sql_bail!(
2568                    "INCLUDE KEY requires specifying KEY FORMAT .. VALUE FORMAT, \
2569                        got bare FORMAT"
2570                );
2571            }
2572        }
2573    } else {
2574        Ok(KeyEnvelope::None)
2575    }
2576}
2577
2578/// Gets the key envelope for a given key encoding when no name for the key has
2579/// been requested by the user.
2580fn get_unnamed_key_envelope(
2581    key: Option<&DataEncoding<ReferencedConnection>>,
2582) -> Result<KeyEnvelope, PlanError> {
2583    // If the key is requested but comes from an unnamed type then it gets the name "key"
2584    //
2585    // Otherwise it gets the names of the columns in the type
2586    let is_composite = match key {
2587        Some(DataEncoding::Bytes | DataEncoding::Json | DataEncoding::Text) => false,
2588        Some(
2589            DataEncoding::Avro(_)
2590            | DataEncoding::Csv(_)
2591            | DataEncoding::Protobuf(_)
2592            | DataEncoding::Regex { .. },
2593        ) => true,
2594        None => false,
2595    };
2596
2597    if is_composite {
2598        Ok(KeyEnvelope::Flattened)
2599    } else {
2600        Ok(KeyEnvelope::Named("key".to_string()))
2601    }
2602}
2603
2604pub fn describe_create_view(
2605    _: &StatementContext,
2606    _: CreateViewStatement<Aug>,
2607) -> Result<StatementDesc, PlanError> {
2608    Ok(StatementDesc::new(None))
2609}
2610
2611pub fn plan_view(
2612    scx: &StatementContext,
2613    def: &mut ViewDefinition<Aug>,
2614    temporary: bool,
2615) -> Result<(QualifiedItemName, View), PlanError> {
2616    let create_sql = normalize::create_statement(
2617        scx,
2618        Statement::CreateView(CreateViewStatement {
2619            if_exists: IfExistsBehavior::Error,
2620            temporary,
2621            definition: def.clone(),
2622        }),
2623    )?;
2624
2625    let ViewDefinition {
2626        name,
2627        columns,
2628        query,
2629    } = def;
2630
2631    let query::PlannedRootQuery {
2632        expr,
2633        mut desc,
2634        finishing,
2635        scope: _,
2636    } = query::plan_root_query(scx, query.clone(), QueryLifetime::View)?;
2637    // We get back a trivial finishing, because `plan_root_query` applies the given finishing.
2638    // Note: Earlier, we were thinking to maybe persist the finishing information with the view
2639    // here to help with database-issues#236. However, in the meantime, there might be a better
2640    // approach to solve database-issues#236:
2641    // https://github.com/MaterializeInc/database-issues/issues/236#issuecomment-1688293709
2642    assert!(HirRelationExpr::is_trivial_row_set_finishing_hir(
2643        &finishing,
2644        expr.arity()
2645    ));
2646    if expr.contains_parameters()? {
2647        return Err(PlanError::ParameterNotAllowed("views".to_string()));
2648    }
2649
2650    let dependencies = expr
2651        .depends_on()
2652        .into_iter()
2653        .map(|gid| scx.catalog.resolve_item_id(&gid))
2654        .collect();
2655
2656    let name = if temporary {
2657        scx.allocate_temporary_qualified_name(normalize::unresolved_item_name(name.to_owned())?)?
2658    } else {
2659        scx.allocate_qualified_name(normalize::unresolved_item_name(name.to_owned())?)?
2660    };
2661
2662    plan_utils::maybe_rename_columns(
2663        format!("view {}", scx.catalog.resolve_full_name(&name)),
2664        &mut desc,
2665        columns,
2666    )?;
2667    let names: Vec<ColumnName> = desc.iter_names().cloned().collect();
2668
2669    if let Some(dup) = names.iter().duplicates().next() {
2670        sql_bail!("column {} specified more than once", dup.quoted());
2671    }
2672
2673    let view = View {
2674        create_sql,
2675        expr,
2676        dependencies,
2677        column_names: names,
2678        temporary,
2679    };
2680
2681    Ok((name, view))
2682}
2683
2684pub fn plan_create_view(
2685    scx: &StatementContext,
2686    mut stmt: CreateViewStatement<Aug>,
2687) -> Result<Plan, PlanError> {
2688    let CreateViewStatement {
2689        temporary,
2690        if_exists,
2691        definition,
2692    } = &mut stmt;
2693    let (name, view) = plan_view(scx, definition, *temporary)?;
2694
2695    // Override the statement-level IfExistsBehavior with Skip if this is
2696    // explicitly requested in the PlanContext (the default is `false`).
2697    let ignore_if_exists_errors = scx.pcx().map_or(false, |pcx| pcx.ignore_if_exists_errors);
2698
2699    let replace = if *if_exists == IfExistsBehavior::Replace && !ignore_if_exists_errors {
2700        let if_exists = true;
2701        let cascade = false;
2702        let maybe_item_to_drop = plan_drop_item(
2703            scx,
2704            ObjectType::View,
2705            if_exists,
2706            definition.name.clone(),
2707            cascade,
2708        )?;
2709
2710        // Check if the new View depends on the item that we would be replacing.
2711        if let Some(id) = maybe_item_to_drop {
2712            let dependencies = view.expr.depends_on();
2713            let invalid_drop = scx
2714                .get_item(&id)
2715                .global_ids()
2716                .any(|gid| dependencies.contains(&gid));
2717            if invalid_drop {
2718                let item = scx.catalog.get_item(&id);
2719                sql_bail!(
2720                    "cannot replace view {0}: depended upon by new {0} definition",
2721                    scx.catalog.resolve_full_name(item.name())
2722                );
2723            }
2724
2725            Some(id)
2726        } else {
2727            None
2728        }
2729    } else {
2730        None
2731    };
2732    let drop_ids = replace
2733        .map(|id| {
2734            scx.catalog
2735                .item_dependents(id)
2736                .into_iter()
2737                .map(|id| id.unwrap_item_id())
2738                .collect()
2739        })
2740        .unwrap_or_default();
2741
2742    validate_view_dependencies(scx, &view.dependencies.0)?;
2743
2744    // Check for an object in the catalog with this same name
2745    let full_name = scx.catalog.resolve_full_name(&name);
2746    let partial_name = PartialItemName::from(full_name.clone());
2747    // For PostgreSQL compatibility, we need to prevent creating views when
2748    // there is an existing object *or* type of the same name.
2749    if let (Ok(item), IfExistsBehavior::Error, false) = (
2750        scx.catalog.resolve_item_or_type(&partial_name),
2751        *if_exists,
2752        ignore_if_exists_errors,
2753    ) {
2754        return Err(PlanError::ItemAlreadyExists {
2755            name: full_name.to_string(),
2756            item_type: item.item_type(),
2757        });
2758    }
2759
2760    Ok(Plan::CreateView(CreateViewPlan {
2761        name,
2762        view,
2763        replace,
2764        drop_ids,
2765        if_not_exists: *if_exists == IfExistsBehavior::Skip,
2766        ambiguous_columns: *scx.ambiguous_columns.borrow(),
2767    }))
2768}
2769
2770/// Validate the dependencies of a (materialized) view.
2771fn validate_view_dependencies(
2772    scx: &StatementContext,
2773    dependencies: &BTreeSet<CatalogItemId>,
2774) -> Result<(), PlanError> {
2775    for id in dependencies {
2776        let item = scx.catalog.get_item(id);
2777        if item.replacement_target().is_some() {
2778            let name = scx.catalog.minimal_qualification(item.name());
2779            return Err(PlanError::InvalidDependency {
2780                name: name.to_string(),
2781                item_type: format!("replacement {}", item.item_type()),
2782            });
2783        }
2784    }
2785
2786    Ok(())
2787}
2788
2789pub fn describe_create_materialized_view(
2790    _: &StatementContext,
2791    _: CreateMaterializedViewStatement<Aug>,
2792) -> Result<StatementDesc, PlanError> {
2793    Ok(StatementDesc::new(None))
2794}
2795
2796pub fn describe_create_network_policy(
2797    _: &StatementContext,
2798    _: CreateNetworkPolicyStatement<Aug>,
2799) -> Result<StatementDesc, PlanError> {
2800    Ok(StatementDesc::new(None))
2801}
2802
2803pub fn describe_alter_network_policy(
2804    _: &StatementContext,
2805    _: AlterNetworkPolicyStatement<Aug>,
2806) -> Result<StatementDesc, PlanError> {
2807    Ok(StatementDesc::new(None))
2808}
2809
2810pub fn plan_create_materialized_view(
2811    scx: &StatementContext,
2812    mut stmt: CreateMaterializedViewStatement<Aug>,
2813) -> Result<Plan, PlanError> {
2814    let cluster_id =
2815        crate::plan::statement::resolve_cluster_for_materialized_view(scx.catalog, &stmt)?;
2816    stmt.in_cluster = Some(ResolvedClusterName {
2817        id: cluster_id,
2818        print_name: None,
2819    });
2820
2821    let target_replica = match &stmt.in_cluster_replica {
2822        Some(replica_name) => {
2823            scx.require_feature_flag(&ENABLE_REPLICA_TARGETED_MATERIALIZED_VIEWS)?;
2824
2825            let cluster = scx.catalog.get_cluster(cluster_id);
2826            let replica_id = cluster
2827                .replica_ids()
2828                .get(replica_name.as_str())
2829                .copied()
2830                .ok_or_else(|| {
2831                    CatalogError::UnknownClusterReplica(replica_name.as_str().to_string())
2832                })?;
2833            Some(replica_id)
2834        }
2835        None => None,
2836    };
2837
2838    let create_sql =
2839        normalize::create_statement(scx, Statement::CreateMaterializedView(stmt.clone()))?;
2840
2841    let partial_name = normalize::unresolved_item_name(stmt.name)?;
2842    let name = scx.allocate_qualified_name(partial_name.clone())?;
2843
2844    let query::PlannedRootQuery {
2845        expr,
2846        mut desc,
2847        finishing,
2848        scope: _,
2849    } = query::plan_root_query(scx, stmt.query, QueryLifetime::MaterializedView)?;
2850    // We get back a trivial finishing, see comment in `plan_view`.
2851    assert!(HirRelationExpr::is_trivial_row_set_finishing_hir(
2852        &finishing,
2853        expr.arity()
2854    ));
2855    if expr.contains_parameters()? {
2856        return Err(PlanError::ParameterNotAllowed(
2857            "materialized views".to_string(),
2858        ));
2859    }
2860
2861    plan_utils::maybe_rename_columns(
2862        format!("materialized view {}", scx.catalog.resolve_full_name(&name)),
2863        &mut desc,
2864        &stmt.columns,
2865    )?;
2866    let column_names: Vec<ColumnName> = desc.iter_names().cloned().collect();
2867
2868    let MaterializedViewOptionExtracted {
2869        assert_not_null,
2870        partition_by,
2871        retain_history,
2872        refresh,
2873        seen: _,
2874    }: MaterializedViewOptionExtracted = stmt.with_options.try_into()?;
2875
2876    if let Some(partition_by) = partition_by {
2877        scx.require_feature_flag(&ENABLE_COLLECTION_PARTITION_BY)?;
2878        check_partition_by(&desc, partition_by)?;
2879    }
2880
2881    let refresh_schedule = {
2882        let mut refresh_schedule = RefreshSchedule::default();
2883        let mut on_commits_seen = 0;
2884        for refresh_option_value in refresh {
2885            if !matches!(refresh_option_value, RefreshOptionValue::OnCommit) {
2886                scx.require_feature_flag(&ENABLE_REFRESH_EVERY_MVS)?;
2887            }
2888            match refresh_option_value {
2889                RefreshOptionValue::OnCommit => {
2890                    on_commits_seen += 1;
2891                }
2892                RefreshOptionValue::AtCreation => {
2893                    soft_panic_or_log!("REFRESH AT CREATION should have been purified away");
2894                    bail_internal!("REFRESH AT CREATION should have been purified away")
2895                }
2896                RefreshOptionValue::At(RefreshAtOptionValue { mut time }) => {
2897                    transform_ast::transform(scx, &mut time)?; // Desugar the expression
2898                    let ecx = &ExprContext {
2899                        qcx: &QueryContext::root(scx, QueryLifetime::OneShot),
2900                        name: "REFRESH AT",
2901                        scope: &Scope::empty(),
2902                        relation_type: &SqlRelationType::empty(),
2903                        allow_aggregates: false,
2904                        allow_subqueries: false,
2905                        allow_parameters: false,
2906                        allow_windows: false,
2907                    };
2908                    let hir = plan_expr(ecx, &time)?.cast_to(
2909                        ecx,
2910                        CastContext::Assignment,
2911                        &SqlScalarType::MzTimestamp,
2912                    )?;
2913                    // (mz_now was purified away to a literal earlier)
2914                    let timestamp = hir
2915                        .into_literal_mz_timestamp()
2916                        .ok_or_else(|| PlanError::InvalidRefreshAt)?;
2917                    refresh_schedule.ats.push(timestamp);
2918                }
2919                RefreshOptionValue::Every(RefreshEveryOptionValue {
2920                    interval,
2921                    aligned_to,
2922                }) => {
2923                    let interval = Interval::try_from_value(Value::Interval(interval))?;
2924                    if interval.as_microseconds() <= 0 {
2925                        sql_bail!("REFRESH interval must be positive; got: {}", interval);
2926                    }
2927                    if interval.months != 0 {
2928                        // This limitation is because we want Intervals to be cleanly convertable
2929                        // to a unix epoch timestamp difference. When the interval involves months, then
2930                        // this is not true anymore, because months have variable lengths.
2931                        // See `Timestamp::round_up`.
2932                        sql_bail!("REFRESH interval must not involve units larger than days");
2933                    }
2934                    let interval = interval.duration()?;
2935                    // `Interval::from_duration` (needed to unparse the interval, e.g. for
2936                    // `mz_materialized_view_refresh_strategies`) requires the micros to fit
2937                    // in an i64, which is a tighter bound than `Duration::duration` enforces.
2938                    // Reject too-large intervals here to avoid panicking later.
2939                    if u64::try_from(interval.as_millis()).is_err()
2940                        || Interval::from_duration(&interval).is_err()
2941                    {
2942                        sql_bail!("REFRESH interval too large");
2943                    }
2944                    if interval.as_micros() < 1000 {
2945                        sql_bail!("REFRESH interval must be at least 1 ms")
2946                    }
2947
2948                    let mut aligned_to = match aligned_to {
2949                        Some(aligned_to) => aligned_to,
2950                        None => {
2951                            soft_panic_or_log!(
2952                                "ALIGNED TO should have been filled in by purification"
2953                            );
2954                            sql_bail!(
2955                                "INTERNAL ERROR: ALIGNED TO should have been filled in by purification"
2956                            )
2957                        }
2958                    };
2959
2960                    // Desugar the `aligned_to` expression
2961                    transform_ast::transform(scx, &mut aligned_to)?;
2962
2963                    let ecx = &ExprContext {
2964                        qcx: &QueryContext::root(scx, QueryLifetime::OneShot),
2965                        name: "REFRESH EVERY ... ALIGNED TO",
2966                        scope: &Scope::empty(),
2967                        relation_type: &SqlRelationType::empty(),
2968                        allow_aggregates: false,
2969                        allow_subqueries: false,
2970                        allow_parameters: false,
2971                        allow_windows: false,
2972                    };
2973                    let aligned_to_hir = plan_expr(ecx, &aligned_to)?.cast_to(
2974                        ecx,
2975                        CastContext::Assignment,
2976                        &SqlScalarType::MzTimestamp,
2977                    )?;
2978                    // (mz_now was purified away to a literal earlier)
2979                    let aligned_to_const = aligned_to_hir
2980                        .into_literal_mz_timestamp()
2981                        .ok_or_else(|| PlanError::InvalidRefreshEveryAlignedTo)?;
2982
2983                    refresh_schedule.everies.push(RefreshEvery {
2984                        interval,
2985                        aligned_to: aligned_to_const,
2986                    });
2987                }
2988            }
2989        }
2990
2991        if on_commits_seen > 1 {
2992            sql_bail!("REFRESH ON COMMIT cannot be specified multiple times");
2993        }
2994        if on_commits_seen > 0 && refresh_schedule != RefreshSchedule::default() {
2995            sql_bail!("REFRESH ON COMMIT is not compatible with any of the other REFRESH options");
2996        }
2997
2998        if refresh_schedule == RefreshSchedule::default() {
2999            None
3000        } else {
3001            Some(refresh_schedule)
3002        }
3003    };
3004
3005    let as_of = stmt.as_of.map(Timestamp::from);
3006    let compaction_window = plan_retain_history_option(scx, retain_history)?;
3007    let mut non_null_assertions = assert_not_null
3008        .into_iter()
3009        .map(normalize::column_name)
3010        .map(|assertion_name| {
3011            column_names
3012                .iter()
3013                .position(|col| col == &assertion_name)
3014                .ok_or_else(|| {
3015                    sql_err!(
3016                        "column {} in ASSERT NOT NULL option not found",
3017                        assertion_name.quoted()
3018                    )
3019                })
3020        })
3021        .collect::<Result<Vec<_>, _>>()?;
3022    non_null_assertions.sort();
3023    if let Some(dup) = non_null_assertions.iter().duplicates().next() {
3024        let dup = &column_names[*dup];
3025        sql_bail!("duplicate column {} in non-null assertions", dup.quoted());
3026    }
3027
3028    if let Some(dup) = column_names.iter().duplicates().next() {
3029        sql_bail!("column {} specified more than once", dup.quoted());
3030    }
3031
3032    // Override the statement-level IfExistsBehavior with Skip if this is
3033    // explicitly requested in the PlanContext (the default is `false`).
3034    let if_exists = match scx.pcx().map(|pcx| pcx.ignore_if_exists_errors) {
3035        Ok(true) => IfExistsBehavior::Skip,
3036        _ => stmt.if_exists,
3037    };
3038
3039    let mut replace = None;
3040    let mut if_not_exists = false;
3041    match if_exists {
3042        IfExistsBehavior::Replace => {
3043            let if_exists = true;
3044            let cascade = false;
3045            let replace_id = plan_drop_item(
3046                scx,
3047                ObjectType::MaterializedView,
3048                if_exists,
3049                partial_name.clone().into(),
3050                cascade,
3051            )?;
3052
3053            // Check if the new Materialized View depends on the item that we would be replacing.
3054            if let Some(id) = replace_id {
3055                let dependencies = expr.depends_on();
3056                let invalid_drop = scx
3057                    .get_item(&id)
3058                    .global_ids()
3059                    .any(|gid| dependencies.contains(&gid));
3060                if invalid_drop {
3061                    let item = scx.catalog.get_item(&id);
3062                    sql_bail!(
3063                        "cannot replace materialized view {0}: depended upon by new {0} definition",
3064                        scx.catalog.resolve_full_name(item.name())
3065                    );
3066                }
3067                replace = Some(id);
3068            }
3069        }
3070        IfExistsBehavior::Skip => if_not_exists = true,
3071        IfExistsBehavior::Error => (),
3072    }
3073    let drop_ids = replace
3074        .map(|id| {
3075            scx.catalog
3076                .item_dependents(id)
3077                .into_iter()
3078                .map(|id| id.unwrap_item_id())
3079                .collect()
3080        })
3081        .unwrap_or_default();
3082    let mut dependencies: BTreeSet<_> = expr
3083        .depends_on()
3084        .into_iter()
3085        .map(|gid| scx.catalog.resolve_item_id(&gid))
3086        .collect();
3087
3088    // Validate the replacement target, if one is given.
3089    let mut replacement_target = None;
3090    if let Some(target_name) = &stmt.replacement_for {
3091        scx.require_feature_flag(&vars::ENABLE_REPLACEMENT_MATERIALIZED_VIEWS)?;
3092
3093        let target = scx.get_item_by_resolved_name(target_name)?;
3094        if target.item_type() != CatalogItemType::MaterializedView {
3095            return Err(PlanError::InvalidReplacement {
3096                item_type: target.item_type(),
3097                item_name: scx.catalog.minimal_qualification(target.name()),
3098                replacement_type: CatalogItemType::MaterializedView,
3099                replacement_name: partial_name,
3100            });
3101        }
3102        if target.id().is_system() {
3103            sql_bail!(
3104                "cannot replace {} because it is required by the database system",
3105                scx.catalog.minimal_qualification(target.name()),
3106            );
3107        }
3108
3109        // Check for dependency cycles.
3110        for dependent in scx.catalog.item_dependents(target.id()) {
3111            if let ObjectId::Item(id) = dependent
3112                && dependencies.contains(&id)
3113            {
3114                sql_bail!(
3115                    "replacement would cause {} to depend on itself",
3116                    scx.catalog.minimal_qualification(target.name()),
3117                );
3118            }
3119        }
3120
3121        dependencies.insert(target.id());
3122
3123        for use_id in target.used_by() {
3124            let use_item = scx.get_item(use_id);
3125            if use_item.replacement_target() == Some(target.id()) {
3126                sql_bail!(
3127                    "cannot replace {} because it already has a replacement: {}",
3128                    scx.catalog.minimal_qualification(target.name()),
3129                    scx.catalog.minimal_qualification(use_item.name()),
3130                );
3131            }
3132        }
3133
3134        replacement_target = Some(target.id());
3135    }
3136
3137    validate_view_dependencies(scx, &dependencies)?;
3138
3139    // Check for an object in the catalog with this same name
3140    let full_name = scx.catalog.resolve_full_name(&name);
3141    let partial_name = PartialItemName::from(full_name.clone());
3142    // For PostgreSQL compatibility, we need to prevent creating materialized
3143    // views when there is an existing object *or* type of the same name.
3144    if let (IfExistsBehavior::Error, Ok(item)) =
3145        (if_exists, scx.catalog.resolve_item_or_type(&partial_name))
3146    {
3147        return Err(PlanError::ItemAlreadyExists {
3148            name: full_name.to_string(),
3149            item_type: item.item_type(),
3150        });
3151    }
3152
3153    Ok(Plan::CreateMaterializedView(CreateMaterializedViewPlan {
3154        name,
3155        materialized_view: MaterializedView {
3156            create_sql,
3157            expr,
3158            dependencies: DependencyIds(dependencies),
3159            column_names,
3160            replacement_target,
3161            cluster_id,
3162            target_replica,
3163            non_null_assertions,
3164            compaction_window,
3165            refresh_schedule,
3166            as_of,
3167        },
3168        replace,
3169        drop_ids,
3170        if_not_exists,
3171        ambiguous_columns: *scx.ambiguous_columns.borrow(),
3172    }))
3173}
3174
3175generate_extracted_config!(
3176    MaterializedViewOption,
3177    (AssertNotNull, Ident, AllowMultiple),
3178    (PartitionBy, Vec<Ident>),
3179    (RetainHistory, OptionalDuration),
3180    (Refresh, RefreshOptionValue<Aug>, AllowMultiple)
3181);
3182
3183pub fn describe_create_sink(
3184    _: &StatementContext,
3185    _: CreateSinkStatement<Aug>,
3186) -> Result<StatementDesc, PlanError> {
3187    Ok(StatementDesc::new(None))
3188}
3189
3190generate_extracted_config!(
3191    CreateSinkOption,
3192    (Snapshot, bool),
3193    (PartitionStrategy, String),
3194    (Version, u64),
3195    (CommitInterval, Duration)
3196);
3197
3198pub fn plan_create_sink(
3199    scx: &StatementContext,
3200    stmt: CreateSinkStatement<Aug>,
3201) -> Result<Plan, PlanError> {
3202    // Check for an object in the catalog with this same name
3203    let Some(name) = stmt.name.clone() else {
3204        return Err(PlanError::MissingName(CatalogItemType::Sink));
3205    };
3206    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name)?)?;
3207    let full_name = scx.catalog.resolve_full_name(&name);
3208    let partial_name = PartialItemName::from(full_name.clone());
3209    if let (false, Ok(item)) = (stmt.if_not_exists, scx.catalog.resolve_item(&partial_name)) {
3210        return Err(PlanError::ItemAlreadyExists {
3211            name: full_name.to_string(),
3212            item_type: item.item_type(),
3213        });
3214    }
3215
3216    plan_sink(scx, stmt)
3217}
3218
3219/// This function will plan a sink as if it does not exist in the catalog. This is so the planning
3220/// logic is reused by both CREATE SINK and ALTER SINK planning. It is the responsibility of the
3221/// callers (plan_create_sink and plan_alter_sink) to check for name collisions if this is
3222/// important.
3223fn plan_sink(
3224    scx: &StatementContext,
3225    mut stmt: CreateSinkStatement<Aug>,
3226) -> Result<Plan, PlanError> {
3227    let CreateSinkStatement {
3228        name,
3229        in_cluster: _,
3230        from,
3231        connection,
3232        format,
3233        envelope,
3234        mode,
3235        if_not_exists,
3236        with_options,
3237    } = stmt.clone();
3238
3239    let Some(name) = name else {
3240        return Err(PlanError::MissingName(CatalogItemType::Sink));
3241    };
3242    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name)?)?;
3243
3244    let envelope = match (&connection, envelope, mode) {
3245        // Kafka sinks use ENVELOPE
3246        (CreateSinkConnection::Kafka { .. }, Some(ast::SinkEnvelope::Upsert), None) => {
3247            SinkEnvelope::Upsert
3248        }
3249        (CreateSinkConnection::Kafka { .. }, Some(ast::SinkEnvelope::Debezium), None) => {
3250            SinkEnvelope::Debezium
3251        }
3252        (CreateSinkConnection::Kafka { .. }, None, None) => {
3253            sql_bail!("ENVELOPE clause is required")
3254        }
3255        (CreateSinkConnection::Kafka { .. }, _, Some(_)) => {
3256            sql_bail!("MODE is not supported for Kafka sinks, use ENVELOPE instead")
3257        }
3258        // Iceberg sinks use MODE
3259        (CreateSinkConnection::Iceberg { .. }, None, Some(ast::IcebergSinkMode::Upsert)) => {
3260            SinkEnvelope::Upsert
3261        }
3262        (CreateSinkConnection::Iceberg { .. }, None, Some(ast::IcebergSinkMode::Append)) => {
3263            SinkEnvelope::Append
3264        }
3265        (CreateSinkConnection::Iceberg { .. }, None, None) => {
3266            sql_bail!("MODE clause is required")
3267        }
3268        (CreateSinkConnection::Iceberg { .. }, Some(_), _) => {
3269            sql_bail!("ENVELOPE is not supported for Iceberg sinks, use MODE instead")
3270        }
3271    };
3272
3273    let from_name = &from;
3274    let from = scx.get_item_by_resolved_name(&from)?;
3275
3276    {
3277        use CatalogItemType::*;
3278        match from.item_type() {
3279            Table | Source | MaterializedView => {
3280                if from.replacement_target().is_some() {
3281                    let name = scx.catalog.minimal_qualification(from.name());
3282                    return Err(PlanError::InvalidSinkFrom {
3283                        name: name.to_string(),
3284                        item_type: format!("replacement {}", from.item_type()),
3285                    });
3286                }
3287            }
3288            Sink | View | Index | Type | Func | Secret | Connection => {
3289                let name = scx.catalog.minimal_qualification(from.name());
3290                return Err(PlanError::InvalidSinkFrom {
3291                    name: name.to_string(),
3292                    item_type: from.item_type().to_string(),
3293                });
3294            }
3295        }
3296    }
3297
3298    if from.id().is_system() {
3299        bail_unsupported!("creating a sink directly on a catalog object");
3300    }
3301
3302    let desc = from
3303        .relation_desc()
3304        .ok_or_else(|| sql_err!("item does not have a relation description"))?;
3305    let key_indices = match &connection {
3306        CreateSinkConnection::Kafka { key: Some(key), .. }
3307        | CreateSinkConnection::Iceberg { key: Some(key), .. } => {
3308            let key_columns = key
3309                .key_columns
3310                .clone()
3311                .into_iter()
3312                .map(normalize::column_name)
3313                .collect::<Vec<_>>();
3314            let mut uniq = BTreeSet::new();
3315            for col in key_columns.iter() {
3316                if !uniq.insert(col) {
3317                    sql_bail!("duplicate column referenced in KEY: {}", col);
3318                }
3319            }
3320            let indices = key_columns
3321                .iter()
3322                .map(|col| {
3323                    let name_idx =
3324                        desc.get_by_name(col)
3325                            .map(|(idx, _type)| idx)
3326                            .ok_or_else(|| {
3327                                sql_err!("column referenced in KEY does not exist: {}", col)
3328                            })?;
3329                    if desc.get_unambiguous_name(name_idx).is_none() {
3330                        sql_bail!("column referenced in KEY is ambiguous: {}", col);
3331                    }
3332                    Ok(name_idx)
3333                })
3334                .collect::<Result<Vec<_>, _>>()?;
3335
3336            // Iceberg equality deletes require primitive, non-float key columns.
3337            // Use an allow-list so that new types are rejected by default.
3338            if matches!(&connection, CreateSinkConnection::Iceberg { .. }) {
3339                let cols: Vec<_> = desc.iter().collect();
3340                for &idx in &indices {
3341                    let (col_name, col_type) = cols[idx];
3342                    let scalar = &col_type.scalar_type;
3343                    let is_valid = matches!(
3344                        scalar,
3345                        // integers
3346                        SqlScalarType::Bool
3347                            | SqlScalarType::Int16
3348                            | SqlScalarType::Int32
3349                            | SqlScalarType::Int64
3350                            | SqlScalarType::UInt16
3351                            | SqlScalarType::UInt32
3352                            | SqlScalarType::UInt64
3353                            // decimal / numeric
3354                            | SqlScalarType::Numeric { .. }
3355                            // date / time
3356                            | SqlScalarType::Date
3357                            | SqlScalarType::Time
3358                            | SqlScalarType::Timestamp { .. }
3359                            | SqlScalarType::TimestampTz { .. }
3360                            | SqlScalarType::Interval
3361                            | SqlScalarType::MzTimestamp
3362                            // string-like
3363                            | SqlScalarType::String
3364                            | SqlScalarType::Char { .. }
3365                            | SqlScalarType::VarChar { .. }
3366                            | SqlScalarType::PgLegacyChar
3367                            | SqlScalarType::PgLegacyName
3368                            | SqlScalarType::Bytes
3369                            | SqlScalarType::Jsonb
3370                            // identifiers
3371                            | SqlScalarType::Uuid
3372                            | SqlScalarType::Oid
3373                            | SqlScalarType::RegProc
3374                            | SqlScalarType::RegType
3375                            | SqlScalarType::RegClass
3376                            | SqlScalarType::MzAclItem
3377                            | SqlScalarType::AclItem
3378                            | SqlScalarType::Int2Vector
3379                    );
3380                    if !is_valid {
3381                        return Err(PlanError::IcebergSinkUnsupportedKeyType {
3382                            column: col_name.to_string(),
3383                            column_type: format!("{:?}", scalar),
3384                        });
3385                    }
3386                }
3387            }
3388
3389            let is_valid_key = desc
3390                .typ()
3391                .keys
3392                .iter()
3393                .any(|key_columns| key_columns.iter().all(|column| indices.contains(column)));
3394
3395            if !is_valid_key && envelope == SinkEnvelope::Upsert {
3396                if key.not_enforced {
3397                    scx.catalog
3398                        .add_notice(PlanNotice::UpsertSinkKeyNotEnforced {
3399                            key: key_columns.clone(),
3400                            name: name.item.clone(),
3401                        })
3402                } else {
3403                    return Err(PlanError::UpsertSinkWithInvalidKey {
3404                        name: from_name.full_name_str(),
3405                        desired_key: key_columns.iter().map(|c| c.to_string()).collect(),
3406                        valid_keys: desc
3407                            .typ()
3408                            .keys
3409                            .iter()
3410                            .map(|key| {
3411                                key.iter()
3412                                    .map(|col| desc.get_name(*col).as_str().into())
3413                                    .collect()
3414                            })
3415                            .collect(),
3416                    });
3417                }
3418            }
3419            Some(indices)
3420        }
3421        CreateSinkConnection::Kafka { key: None, .. }
3422        | CreateSinkConnection::Iceberg { key: None, .. } => None,
3423    };
3424
3425    if key_indices.is_some() && envelope == SinkEnvelope::Append {
3426        sql_bail!("KEY is not supported for MODE APPEND Iceberg sinks");
3427    }
3428
3429    // Reject input columns that clash with the columns MODE APPEND adds to the Iceberg table.
3430    if envelope == SinkEnvelope::Append {
3431        if let CreateSinkConnection::Iceberg { .. } = &connection {
3432            use mz_storage_types::sinks::{
3433                ICEBERG_APPEND_DIFF_COLUMN, ICEBERG_APPEND_TIMESTAMP_COLUMN,
3434            };
3435            for (col_name, _) in desc.iter() {
3436                if col_name.as_str() == ICEBERG_APPEND_DIFF_COLUMN
3437                    || col_name.as_str() == ICEBERG_APPEND_TIMESTAMP_COLUMN
3438                {
3439                    sql_bail!(
3440                        "column {} conflicts with the system column that MODE APPEND \
3441                         adds to the Iceberg table",
3442                        col_name.quoted()
3443                    );
3444                }
3445            }
3446        }
3447    }
3448
3449    let headers_index = match &connection {
3450        CreateSinkConnection::Kafka {
3451            headers: Some(headers),
3452            ..
3453        } => {
3454            scx.require_feature_flag(&ENABLE_KAFKA_SINK_HEADERS)?;
3455
3456            match envelope {
3457                SinkEnvelope::Upsert | SinkEnvelope::Append => (),
3458                SinkEnvelope::Debezium => {
3459                    sql_bail!("HEADERS option is not supported with ENVELOPE DEBEZIUM")
3460                }
3461            };
3462
3463            let headers = normalize::column_name(headers.clone());
3464            let (idx, ty) = desc
3465                .get_by_name(&headers)
3466                .ok_or_else(|| sql_err!("HEADERS column ({}) is unknown", headers))?;
3467
3468            if desc.get_unambiguous_name(idx).is_none() {
3469                sql_bail!("HEADERS column ({}) is ambiguous", headers);
3470            }
3471
3472            match &ty.scalar_type {
3473                SqlScalarType::Map { value_type, .. }
3474                    if matches!(&**value_type, SqlScalarType::String | SqlScalarType::Bytes) => {}
3475                _ => sql_bail!(
3476                    "HEADERS column must have type map[text => text] or map[text => bytea]"
3477                ),
3478            }
3479
3480            Some(idx)
3481        }
3482        _ => None,
3483    };
3484
3485    // pick the first valid natural relation key, if any
3486    let relation_key_indices = desc.typ().keys.get(0).cloned();
3487
3488    let key_desc_and_indices = key_indices.map(|key_indices| {
3489        let cols = desc
3490            .iter()
3491            .map(|(name, ty)| (name.clone(), ty.clone()))
3492            .collect::<Vec<_>>();
3493        let (names, types): (Vec<_>, Vec<_>) =
3494            key_indices.iter().map(|&idx| cols[idx].clone()).unzip();
3495        let typ = SqlRelationType::new(types);
3496        (RelationDesc::new(typ, names), key_indices)
3497    });
3498
3499    if key_desc_and_indices.is_none() && envelope == SinkEnvelope::Upsert {
3500        return Err(PlanError::UpsertSinkWithoutKey);
3501    }
3502
3503    let CreateSinkOptionExtracted {
3504        snapshot,
3505        version,
3506        partition_strategy: _,
3507        seen: _,
3508        commit_interval,
3509    } = with_options.try_into()?;
3510
3511    let connection_builder = match connection {
3512        CreateSinkConnection::Kafka {
3513            connection,
3514            options,
3515            ..
3516        } => kafka_sink_builder(
3517            scx,
3518            connection,
3519            options,
3520            format,
3521            relation_key_indices,
3522            key_desc_and_indices,
3523            headers_index,
3524            desc.into_owned(),
3525            envelope,
3526            from.id(),
3527            commit_interval,
3528        )?,
3529        CreateSinkConnection::Iceberg {
3530            catalog_connection,
3531            aws_connection,
3532            options,
3533            ..
3534        } => iceberg_sink_builder(
3535            scx,
3536            catalog_connection,
3537            aws_connection,
3538            options,
3539            relation_key_indices,
3540            key_desc_and_indices,
3541            commit_interval,
3542            &desc,
3543        )?,
3544    };
3545
3546    // WITH SNAPSHOT defaults to true
3547    let with_snapshot = snapshot.unwrap_or(true);
3548    // VERSION defaults to 0
3549    let version = version.unwrap_or(0);
3550
3551    // We will rewrite the cluster if one is not provided, so we must use the
3552    // `in_cluster` value we plan to normalize when we canonicalize the create
3553    // statement.
3554    let in_cluster = source_sink_cluster_config(scx, &mut stmt.in_cluster)?;
3555    let create_sql = normalize::create_statement(scx, Statement::CreateSink(stmt))?;
3556
3557    Ok(Plan::CreateSink(CreateSinkPlan {
3558        name,
3559        sink: Sink {
3560            create_sql,
3561            from: from.global_id(),
3562            connection: connection_builder,
3563            envelope,
3564            version,
3565            commit_interval,
3566        },
3567        with_snapshot,
3568        if_not_exists,
3569        in_cluster: in_cluster.id(),
3570    }))
3571}
3572
3573fn key_constraint_err(desc: &RelationDesc, user_keys: &[ColumnName]) -> PlanError {
3574    let user_keys = user_keys.iter().map(|column| column.as_str()).join(", ");
3575
3576    let existing_keys = desc
3577        .typ()
3578        .keys
3579        .iter()
3580        .map(|key_columns| {
3581            key_columns
3582                .iter()
3583                .map(|col| desc.get_name(*col).as_str())
3584                .join(", ")
3585        })
3586        .join(", ");
3587
3588    sql_err!(
3589        "Key constraint ({}) conflicts with existing key ({})",
3590        user_keys,
3591        existing_keys
3592    )
3593}
3594
3595/// Creating this by hand instead of using generate_extracted_config! macro
3596/// because the macro doesn't support parameterized enums. See <https://github.com/MaterializeInc/database-issues/issues/6698>
3597#[derive(Debug, Default, PartialEq, Clone)]
3598pub struct CsrConfigOptionExtracted {
3599    seen: ::std::collections::BTreeSet<CsrConfigOptionName<Aug>>,
3600    pub(crate) avro_key_fullname: Option<String>,
3601    pub(crate) avro_value_fullname: Option<String>,
3602    pub(crate) null_defaults: bool,
3603    pub(crate) value_doc_options: BTreeMap<DocTarget, String>,
3604    pub(crate) key_doc_options: BTreeMap<DocTarget, String>,
3605    pub(crate) key_compatibility_level: Option<mz_ccsr::CompatibilityLevel>,
3606    pub(crate) value_compatibility_level: Option<mz_ccsr::CompatibilityLevel>,
3607}
3608
3609impl std::convert::TryFrom<Vec<CsrConfigOption<Aug>>> for CsrConfigOptionExtracted {
3610    type Error = crate::plan::PlanError;
3611    fn try_from(v: Vec<CsrConfigOption<Aug>>) -> Result<CsrConfigOptionExtracted, Self::Error> {
3612        let mut extracted = CsrConfigOptionExtracted::default();
3613        let mut common_doc_comments = BTreeMap::new();
3614        for option in v {
3615            if !extracted.seen.insert(option.name.clone()) {
3616                return Err(PlanError::Unstructured({
3617                    format!("{} specified more than once", option.name)
3618                }));
3619            }
3620            let option_name = option.name.clone();
3621            let option_name_str = option_name.to_ast_string_simple();
3622            let better_error = |e: PlanError| PlanError::InvalidOptionValue {
3623                option_name: option_name.to_ast_string_simple(),
3624                err: e.into(),
3625            };
3626            let to_compatibility_level = |val: Option<WithOptionValue<Aug>>| {
3627                val.map(|s| match s {
3628                    WithOptionValue::Value(Value::String(s)) => {
3629                        mz_ccsr::CompatibilityLevel::try_from(s.to_uppercase().as_str())
3630                    }
3631                    _ => Err("must be a string".to_string()),
3632                })
3633                .transpose()
3634                .map_err(PlanError::Unstructured)
3635                .map_err(better_error)
3636            };
3637            match option.name {
3638                CsrConfigOptionName::AvroKeyFullname => {
3639                    extracted.avro_key_fullname =
3640                        <Option<String>>::try_from_value(option.value).map_err(better_error)?;
3641                }
3642                CsrConfigOptionName::AvroValueFullname => {
3643                    extracted.avro_value_fullname =
3644                        <Option<String>>::try_from_value(option.value).map_err(better_error)?;
3645                }
3646                CsrConfigOptionName::NullDefaults => {
3647                    extracted.null_defaults =
3648                        <bool>::try_from_value(option.value).map_err(better_error)?;
3649                }
3650                CsrConfigOptionName::AvroDocOn(doc_on) => {
3651                    let value = String::try_from_value(option.value.ok_or_else(|| {
3652                        PlanError::InvalidOptionValue {
3653                            option_name: option_name_str,
3654                            err: Box::new(PlanError::Unstructured("cannot be empty".to_string())),
3655                        }
3656                    })?)
3657                    .map_err(better_error)?;
3658                    let key = match doc_on.identifier {
3659                        DocOnIdentifier::Column(ast::ColumnName {
3660                            relation: ResolvedItemName::Item { id, .. },
3661                            column: ResolvedColumnReference::Column { name, index: _ },
3662                        }) => DocTarget::Field {
3663                            object_id: id,
3664                            column_name: name,
3665                        },
3666                        DocOnIdentifier::Type(ResolvedItemName::Item { id, .. }) => {
3667                            DocTarget::Type(id)
3668                        }
3669                        _ => sql_bail!("invalid DOC ON identifier"),
3670                    };
3671
3672                    match doc_on.for_schema {
3673                        DocOnSchema::KeyOnly => {
3674                            extracted.key_doc_options.insert(key, value);
3675                        }
3676                        DocOnSchema::ValueOnly => {
3677                            extracted.value_doc_options.insert(key, value);
3678                        }
3679                        DocOnSchema::All => {
3680                            common_doc_comments.insert(key, value);
3681                        }
3682                    }
3683                }
3684                CsrConfigOptionName::KeyCompatibilityLevel => {
3685                    extracted.key_compatibility_level = to_compatibility_level(option.value)?;
3686                }
3687                CsrConfigOptionName::ValueCompatibilityLevel => {
3688                    extracted.value_compatibility_level = to_compatibility_level(option.value)?;
3689                }
3690            }
3691        }
3692
3693        for (key, value) in common_doc_comments {
3694            if !extracted.key_doc_options.contains_key(&key) {
3695                extracted.key_doc_options.insert(key.clone(), value.clone());
3696            }
3697            if !extracted.value_doc_options.contains_key(&key) {
3698                extracted.value_doc_options.insert(key, value);
3699            }
3700        }
3701        Ok(extracted)
3702    }
3703}
3704
3705fn iceberg_sink_builder(
3706    scx: &StatementContext,
3707    catalog_connection: ResolvedItemName,
3708    storage_connection: Option<ResolvedItemName>,
3709    options: Vec<IcebergSinkConfigOption<Aug>>,
3710    relation_key_indices: Option<Vec<usize>>,
3711    key_desc_and_indices: Option<(RelationDesc, Vec<usize>)>,
3712    commit_interval: Option<Duration>,
3713    desc: &RelationDesc,
3714) -> Result<StorageSinkConnection<ReferencedConnection>, PlanError> {
3715    // Reject types that arrow-rs's parquet writer cannot handle, before
3716    // sink creation. Pass the iceberg overrides so types iceberg remaps
3717    // (e.g. interval -> string) don't trip the check.
3718    ArrowBuilder::validate_desc_for_parquet(desc, iceberg_type_overrides)
3719        .map_err(|e| sql_err!("{}", e))?;
3720
3721    let catalog_connection_item = scx.get_item_by_resolved_name(&catalog_connection)?;
3722    let catalog_connection_id = catalog_connection_item.id();
3723    if !matches!(
3724        catalog_connection_item.connection()?,
3725        Connection::IcebergCatalog(_)
3726    ) {
3727        sql_bail!(
3728            "{} is not an iceberg catalog connection",
3729            scx.catalog
3730                .resolve_full_name(catalog_connection_item.name())
3731                .to_string()
3732                .quoted()
3733        );
3734    };
3735
3736    let storage_connection_item = storage_connection
3737        .map(|c| scx.get_item_by_resolved_name(&c))
3738        .transpose()?;
3739    let storage_connection_id = storage_connection_item.as_ref().map(|c| c.id());
3740    if let Some(c) = &storage_connection_item
3741        && !matches!(c.connection()?, Connection::Aws(_))
3742    {
3743        sql_bail!(
3744            "{} is not an AWS connection",
3745            scx.catalog.resolve_full_name(c.name()).to_string().quoted()
3746        );
3747    }
3748
3749    let IcebergSinkConfigOptionExtracted {
3750        table,
3751        namespace,
3752        seen: _,
3753    }: IcebergSinkConfigOptionExtracted = options.try_into()?;
3754
3755    let Some(table) = table else {
3756        sql_bail!("Iceberg sink must specify TABLE");
3757    };
3758    let Some(namespace) = namespace else {
3759        sql_bail!("Iceberg sink must specify NAMESPACE");
3760    };
3761    if commit_interval.is_none() {
3762        sql_bail!("Iceberg sink must specify COMMIT INTERVAL");
3763    }
3764
3765    Ok(StorageSinkConnection::Iceberg(IcebergSinkConnection {
3766        catalog_connection_id,
3767        catalog_connection: catalog_connection_id,
3768        storage_connection_id,
3769        storage_connection: storage_connection_id,
3770        table,
3771        namespace,
3772        relation_key_indices,
3773        key_desc_and_indices,
3774    }))
3775}
3776
3777fn kafka_sink_builder(
3778    scx: &StatementContext,
3779    connection: ResolvedItemName,
3780    options: Vec<KafkaSinkConfigOption<Aug>>,
3781    format: Option<FormatSpecifier<Aug>>,
3782    relation_key_indices: Option<Vec<usize>>,
3783    key_desc_and_indices: Option<(RelationDesc, Vec<usize>)>,
3784    headers_index: Option<usize>,
3785    value_desc: RelationDesc,
3786    envelope: SinkEnvelope,
3787    sink_from: CatalogItemId,
3788    commit_interval: Option<Duration>,
3789) -> Result<StorageSinkConnection<ReferencedConnection>, PlanError> {
3790    // Get Kafka connection.
3791    let connection_item = scx.get_item_by_resolved_name(&connection)?;
3792    let connection_id = connection_item.id();
3793    match connection_item.connection()? {
3794        Connection::Kafka(_) => (),
3795        _ => sql_bail!(
3796            "{} is not a kafka connection",
3797            scx.catalog.resolve_full_name(connection_item.name())
3798        ),
3799    };
3800
3801    if commit_interval.is_some() {
3802        sql_bail!("COMMIT INTERVAL option is not supported with KAFKA sinks");
3803    }
3804
3805    let KafkaSinkConfigOptionExtracted {
3806        topic,
3807        compression_type,
3808        partition_by,
3809        progress_group_id_prefix,
3810        transactional_id_prefix,
3811        legacy_ids,
3812        topic_config,
3813        topic_metadata_refresh_interval,
3814        topic_partition_count,
3815        topic_replication_factor,
3816        seen: _,
3817    }: KafkaSinkConfigOptionExtracted = options.try_into()?;
3818
3819    let transactional_id = match (transactional_id_prefix, legacy_ids) {
3820        (Some(_), Some(true)) => {
3821            sql_bail!("LEGACY IDS cannot be used at the same time as TRANSACTIONAL ID PREFIX")
3822        }
3823        (None, Some(true)) => KafkaIdStyle::Legacy,
3824        (prefix, _) => KafkaIdStyle::Prefix(prefix),
3825    };
3826
3827    let progress_group_id = match (progress_group_id_prefix, legacy_ids) {
3828        (Some(_), Some(true)) => {
3829            sql_bail!("LEGACY IDS cannot be used at the same time as PROGRESS GROUP ID PREFIX")
3830        }
3831        (None, Some(true)) => KafkaIdStyle::Legacy,
3832        (prefix, _) => KafkaIdStyle::Prefix(prefix),
3833    };
3834
3835    let topic_name = topic.ok_or_else(|| sql_err!("KAFKA CONNECTION must specify TOPIC"))?;
3836
3837    if topic_metadata_refresh_interval > MAX_KAFKA_TOPIC_METADATA_REFRESH_INTERVAL {
3838        // This is a librdkafka-enforced restriction that, if violated,
3839        // would result in a runtime error for the source.
3840        sql_bail!("TOPIC METADATA REFRESH INTERVAL cannot be greater than 1 hour");
3841    } else if topic_metadata_refresh_interval < MIN_KAFKA_TOPIC_METADATA_REFRESH_INTERVAL {
3842        // We enforce a minimum of 1 second here to prevent excessive refreshes, and ensure that
3843        // tokio::time::interval receives a valid (positive) duration.
3844        sql_bail!("TOPIC METADATA REFRESH INTERVAL must be at least 1 second");
3845    }
3846
3847    let assert_positive = |val: Option<i32>, name: &str| {
3848        if let Some(val) = val {
3849            if val <= 0 {
3850                sql_bail!("{} must be a positive integer", name);
3851            }
3852        }
3853        val.map(NonNeg::try_from)
3854            .transpose()
3855            .map_err(|_| PlanError::Unstructured(format!("{} must be a positive integer", name)))
3856    };
3857    let topic_partition_count = assert_positive(topic_partition_count, "TOPIC PARTITION COUNT")?;
3858    let topic_replication_factor =
3859        assert_positive(topic_replication_factor, "TOPIC REPLICATION FACTOR")?;
3860
3861    // Helper method to parse avro connection options for format specifiers that use avro
3862    // for either key or value encoding.
3863    let gen_avro_schema_options = |conn| {
3864        let CsrConnectionAvro {
3865            connection:
3866                CsrConnection {
3867                    connection,
3868                    options,
3869                },
3870            seed,
3871            key_strategy,
3872            value_strategy,
3873        } = conn;
3874        if seed.is_some() {
3875            sql_bail!("SEED option does not make sense with sinks");
3876        }
3877        if key_strategy.is_some() {
3878            sql_bail!("KEY STRATEGY option does not make sense with sinks");
3879        }
3880        if value_strategy.is_some() {
3881            sql_bail!("VALUE STRATEGY option does not make sense with sinks");
3882        }
3883
3884        let item = scx.get_item_by_resolved_name(&connection)?;
3885        let csr_connection = match item.connection()? {
3886            Connection::Csr(_) => item.id(),
3887            _ => {
3888                sql_bail!(
3889                    "{} is not a schema registry connection",
3890                    scx.catalog
3891                        .resolve_full_name(item.name())
3892                        .to_string()
3893                        .quoted()
3894                )
3895            }
3896        };
3897        let extracted_options: CsrConfigOptionExtracted = options.try_into()?;
3898
3899        if key_desc_and_indices.is_none() && extracted_options.avro_key_fullname.is_some() {
3900            sql_bail!("Cannot specify AVRO KEY FULLNAME without a corresponding KEY field");
3901        }
3902
3903        if key_desc_and_indices.is_some()
3904            && (extracted_options.avro_key_fullname.is_some()
3905                ^ extracted_options.avro_value_fullname.is_some())
3906        {
3907            sql_bail!(
3908                "Must specify both AVRO KEY FULLNAME and AVRO VALUE FULLNAME when specifying generated schema names"
3909            );
3910        }
3911
3912        Ok((csr_connection, extracted_options))
3913    };
3914
3915    let map_format = |format: Format<Aug>, desc: &RelationDesc, is_key: bool| match format {
3916        Format::Json { array: false } => Ok::<_, PlanError>(KafkaSinkFormatType::Json),
3917        Format::Bytes if desc.arity() == 1 => {
3918            let col_type = &desc.typ().column_types[0].scalar_type;
3919            if !mz_pgrepr::Value::can_encode_binary(col_type) {
3920                bail_unsupported!(format!(
3921                    "BYTES format with non-encodable type: {:?}",
3922                    col_type
3923                ));
3924            }
3925
3926            Ok(KafkaSinkFormatType::Bytes)
3927        }
3928        Format::Text if desc.arity() == 1 => Ok(KafkaSinkFormatType::Text),
3929        Format::Bytes | Format::Text => {
3930            bail_unsupported!("BYTES or TEXT format with multiple columns")
3931        }
3932        Format::Json { array: true } => bail_unsupported!("JSON ARRAY format in sinks"),
3933        Format::Avro(AvroSchema::Csr { csr_connection }) => {
3934            let (csr_connection, options) = gen_avro_schema_options(csr_connection)?;
3935            let schema = if is_key {
3936                AvroSchemaGenerator::new(
3937                    desc.clone(),
3938                    false,
3939                    options.key_doc_options,
3940                    options.avro_key_fullname.as_deref().unwrap_or("row"),
3941                    options.null_defaults,
3942                    Some(sink_from),
3943                    false,
3944                )?
3945                .schema()
3946                .to_string()
3947            } else {
3948                AvroSchemaGenerator::new(
3949                    desc.clone(),
3950                    matches!(envelope, SinkEnvelope::Debezium),
3951                    options.value_doc_options,
3952                    options.avro_value_fullname.as_deref().unwrap_or("envelope"),
3953                    options.null_defaults,
3954                    Some(sink_from),
3955                    true,
3956                )?
3957                .schema()
3958                .to_string()
3959            };
3960            Ok(KafkaSinkFormatType::Avro {
3961                schema,
3962                compatibility_level: if is_key {
3963                    options.key_compatibility_level
3964                } else {
3965                    options.value_compatibility_level
3966                },
3967                wire_format: WireFormat::Confluent {
3968                    registry: Some(csr_connection),
3969                },
3970            })
3971        }
3972        format => bail_unsupported!(format!("sink format {:?}", format)),
3973    };
3974
3975    let partition_by = match &partition_by {
3976        Some(partition_by) => {
3977            let mut scope = Scope::from_source(None, value_desc.iter_names());
3978
3979            match envelope {
3980                SinkEnvelope::Upsert | SinkEnvelope::Append => (),
3981                SinkEnvelope::Debezium => {
3982                    let key_indices: HashSet<_> = key_desc_and_indices
3983                        .as_ref()
3984                        .map(|(_desc, indices)| indices.as_slice())
3985                        .unwrap_or_default()
3986                        .into_iter()
3987                        .collect();
3988                    for (i, item) in scope.items.iter_mut().enumerate() {
3989                        if !key_indices.contains(&i) {
3990                            item.error_if_referenced = Some(|_table, column| {
3991                                PlanError::InvalidPartitionByEnvelopeDebezium {
3992                                    column_name: column.to_string(),
3993                                }
3994                            });
3995                        }
3996                    }
3997                }
3998            };
3999
4000            let ecx = &ExprContext {
4001                qcx: &QueryContext::root(scx, QueryLifetime::OneShot),
4002                name: "PARTITION BY",
4003                scope: &scope,
4004                relation_type: value_desc.typ(),
4005                allow_aggregates: false,
4006                allow_subqueries: false,
4007                allow_parameters: false,
4008                allow_windows: false,
4009            };
4010            let expr = plan_expr(ecx, partition_by)?.cast_to(
4011                ecx,
4012                CastContext::Assignment,
4013                &SqlScalarType::UInt64,
4014            )?;
4015            let expr = expr.lower_uncorrelated(scx.catalog.system_vars())?;
4016
4017            Some(expr)
4018        }
4019        _ => None,
4020    };
4021
4022    // Map from the format specifier of the statement to the individual key/value formats for the sink.
4023    let format = match format {
4024        Some(FormatSpecifier::KeyValue { key, value }) => {
4025            let key_format = match key_desc_and_indices.as_ref() {
4026                Some((desc, _indices)) => Some(map_format(key, desc, true)?),
4027                None => None,
4028            };
4029            KafkaSinkFormat {
4030                value_format: map_format(value, &value_desc, false)?,
4031                key_format,
4032            }
4033        }
4034        Some(FormatSpecifier::Bare(format)) => {
4035            let key_format = match key_desc_and_indices.as_ref() {
4036                Some((desc, _indices)) => Some(map_format(format.clone(), desc, true)?),
4037                None => None,
4038            };
4039            KafkaSinkFormat {
4040                value_format: map_format(format, &value_desc, false)?,
4041                key_format,
4042            }
4043        }
4044        None => bail_unsupported!("sink without format"),
4045    };
4046
4047    Ok(StorageSinkConnection::Kafka(KafkaSinkConnection {
4048        connection_id,
4049        connection: connection_id,
4050        format,
4051        topic: topic_name,
4052        relation_key_indices,
4053        key_desc_and_indices,
4054        headers_index,
4055        value_desc,
4056        partition_by,
4057        compression_type,
4058        progress_group_id,
4059        transactional_id,
4060        topic_options: KafkaTopicOptions {
4061            partition_count: topic_partition_count,
4062            replication_factor: topic_replication_factor,
4063            topic_config: topic_config.unwrap_or_default(),
4064        },
4065        topic_metadata_refresh_interval,
4066    }))
4067}
4068
4069pub fn describe_create_index(
4070    _: &StatementContext,
4071    _: CreateIndexStatement<Aug>,
4072) -> Result<StatementDesc, PlanError> {
4073    Ok(StatementDesc::new(None))
4074}
4075
4076pub fn plan_create_index(
4077    scx: &StatementContext,
4078    mut stmt: CreateIndexStatement<Aug>,
4079) -> Result<Plan, PlanError> {
4080    let CreateIndexStatement {
4081        name,
4082        on_name,
4083        in_cluster,
4084        key_parts,
4085        with_options,
4086        if_not_exists,
4087    } = &mut stmt;
4088    let on = scx.get_item_by_resolved_name(on_name)?;
4089
4090    {
4091        use CatalogItemType::*;
4092        match on.item_type() {
4093            Table | Source | View | MaterializedView => {
4094                if on.replacement_target().is_some() {
4095                    sql_bail!(
4096                        "index cannot be created on {} because it is a replacement {}",
4097                        on_name.full_name_str(),
4098                        on.item_type(),
4099                    );
4100                }
4101            }
4102            Sink | Index | Type | Func | Secret | Connection => {
4103                sql_bail!(
4104                    "index cannot be created on {} because it is a {}",
4105                    on_name.full_name_str(),
4106                    on.item_type(),
4107                );
4108            }
4109        }
4110    }
4111
4112    let on_desc = on
4113        .relation_desc()
4114        .ok_or_else(|| sql_err!("item does not have a relation description"))?;
4115
4116    let filled_key_parts = match key_parts {
4117        Some(kp) => kp.to_vec(),
4118        None => {
4119            // `key_parts` is None if we're creating a "default" index.
4120            // Precompute which column names are unambiguous in a single pass,
4121            // avoiding the O(n * k) cost of calling get_unambiguous_name per
4122            // key column.
4123            let mut name_counts = BTreeMap::new();
4124            for name in on_desc.iter_names() {
4125                *name_counts.entry(name).or_insert(0usize) += 1;
4126            }
4127            let key = on_desc.typ().default_key();
4128            key.iter()
4129                .map(|i| {
4130                    let name = on_desc.get_name(*i);
4131                    if name_counts.get(name).copied() == Some(1) {
4132                        Expr::Identifier(vec![name.clone().into()])
4133                    } else {
4134                        Expr::Value(Value::Number((i + 1).to_string()))
4135                    }
4136                })
4137                .collect()
4138        }
4139    };
4140    let keys = query::plan_index_exprs(scx, &on_desc, filled_key_parts.clone())?;
4141
4142    let index_name = if let Some(name) = name {
4143        QualifiedItemName {
4144            qualifiers: on.name().qualifiers.clone(),
4145            item: normalize::ident(name.clone()),
4146        }
4147    } else {
4148        let mut idx_name = QualifiedItemName {
4149            qualifiers: on.name().qualifiers.clone(),
4150            item: on.name().item.clone(),
4151        };
4152        if key_parts.is_none() {
4153            // We're trying to create the "default" index.
4154            idx_name.item += "_primary_idx";
4155        } else {
4156            // Use PG schema for automatically naming indexes:
4157            // `<table>_<_-separated indexed expressions>_idx`
4158            let index_name_col_suffix = keys
4159                .iter()
4160                .map(|k| match k {
4161                    mz_expr::MirScalarExpr::Column(i, name) => {
4162                        match (on_desc.get_unambiguous_name(*i), &name.0) {
4163                            (Some(col_name), _) => col_name.to_string(),
4164                            (None, Some(name)) => name.to_string(),
4165                            (None, None) => format!("{}", i + 1),
4166                        }
4167                    }
4168                    _ => "expr".to_string(),
4169                })
4170                .join("_");
4171            write!(idx_name.item, "_{index_name_col_suffix}_idx")
4172                .expect("write on strings cannot fail");
4173            idx_name.item = normalize::ident(Ident::new(&idx_name.item)?)
4174        }
4175
4176        if !*if_not_exists {
4177            scx.catalog.find_available_name(idx_name)
4178        } else {
4179            idx_name
4180        }
4181    };
4182
4183    // Check for an object in the catalog with this same name
4184    let full_name = scx.catalog.resolve_full_name(&index_name);
4185    let partial_name = PartialItemName::from(full_name.clone());
4186    // For PostgreSQL compatibility, we need to prevent creating indexes when
4187    // there is an existing object *or* type of the same name.
4188    //
4189    // Technically, we only need to prevent coexistence of indexes and types
4190    // that have an associated relation (record types but not list/map types).
4191    // Enforcing that would be more complicated, though. It's backwards
4192    // compatible to weaken this restriction in the future.
4193    if let (Ok(item), false, false) = (
4194        scx.catalog.resolve_item_or_type(&partial_name),
4195        *if_not_exists,
4196        scx.pcx().map_or(false, |pcx| pcx.ignore_if_exists_errors),
4197    ) {
4198        return Err(PlanError::ItemAlreadyExists {
4199            name: full_name.to_string(),
4200            item_type: item.item_type(),
4201        });
4202    }
4203
4204    let options = plan_index_options(scx, with_options.clone())?;
4205    let cluster_id = match in_cluster {
4206        None => scx.resolve_cluster(None)?.id(),
4207        Some(in_cluster) => in_cluster.id,
4208    };
4209
4210    *in_cluster = Some(ResolvedClusterName {
4211        id: cluster_id,
4212        print_name: None,
4213    });
4214
4215    // Normalize `stmt`.
4216    *name = Some(Ident::new(index_name.item.clone())?);
4217    *key_parts = Some(filled_key_parts);
4218    let if_not_exists = *if_not_exists;
4219
4220    let create_sql = normalize::create_statement(scx, Statement::CreateIndex(stmt))?;
4221    let compaction_window = options.iter().find_map(|o| {
4222        #[allow(irrefutable_let_patterns)]
4223        if let crate::plan::IndexOption::RetainHistory(lcw) = o {
4224            Some(lcw.clone())
4225        } else {
4226            None
4227        }
4228    });
4229
4230    Ok(Plan::CreateIndex(CreateIndexPlan {
4231        name: index_name,
4232        index: Index {
4233            create_sql,
4234            on: on.global_id(),
4235            keys,
4236            cluster_id,
4237            compaction_window,
4238        },
4239        if_not_exists,
4240    }))
4241}
4242
4243pub fn describe_create_type(
4244    _: &StatementContext,
4245    _: CreateTypeStatement<Aug>,
4246) -> Result<StatementDesc, PlanError> {
4247    Ok(StatementDesc::new(None))
4248}
4249
4250pub fn plan_create_type(
4251    scx: &StatementContext,
4252    stmt: CreateTypeStatement<Aug>,
4253) -> Result<Plan, PlanError> {
4254    let create_sql = normalize::create_statement(scx, Statement::CreateType(stmt.clone()))?;
4255    let CreateTypeStatement { name, as_type, .. } = stmt;
4256
4257    fn validate_data_type(
4258        scx: &StatementContext,
4259        data_type: ResolvedDataType,
4260        as_type: &str,
4261        key: &str,
4262    ) -> Result<(CatalogItemId, Vec<i64>), PlanError> {
4263        let (id, modifiers) = match data_type {
4264            ResolvedDataType::Named { id, modifiers, .. } => (id, modifiers),
4265            _ => sql_bail!(
4266                "CREATE TYPE ... AS {}option {} can only use named data types, but \
4267                        found unnamed data type {}. Use CREATE TYPE to create a named type first",
4268                as_type,
4269                key,
4270                data_type.human_readable_name(),
4271            ),
4272        };
4273
4274        let item = scx.catalog.get_item(&id);
4275        match item.type_details() {
4276            None => sql_bail!(
4277                "{} must be of class type, but received {} which is of class {}",
4278                key,
4279                scx.catalog.resolve_full_name(item.name()),
4280                item.item_type()
4281            ),
4282            Some(CatalogTypeDetails {
4283                typ: CatalogType::Char,
4284                ..
4285            }) => {
4286                bail_unsupported!("embedding char type in a list or map")
4287            }
4288            _ => {
4289                // Validate that the modifiers are actually valid.
4290                scalar_type_from_catalog(scx.catalog, id, &modifiers)?;
4291
4292                Ok((id, modifiers))
4293            }
4294        }
4295    }
4296
4297    let inner = match as_type {
4298        CreateTypeAs::List { options } => {
4299            let CreateTypeListOptionExtracted {
4300                element_type,
4301                seen: _,
4302            } = CreateTypeListOptionExtracted::try_from(options)?;
4303            let element_type =
4304                element_type.ok_or_else(|| sql_err!("ELEMENT TYPE option is required"))?;
4305            let (id, modifiers) = validate_data_type(scx, element_type, "LIST ", "ELEMENT TYPE")?;
4306            CatalogType::List {
4307                element_reference: id,
4308                element_modifiers: modifiers,
4309            }
4310        }
4311        CreateTypeAs::Map { options } => {
4312            let CreateTypeMapOptionExtracted {
4313                key_type,
4314                value_type,
4315                seen: _,
4316            } = CreateTypeMapOptionExtracted::try_from(options)?;
4317            let key_type = key_type.ok_or_else(|| sql_err!("KEY TYPE option is required"))?;
4318            let value_type = value_type.ok_or_else(|| sql_err!("VALUE TYPE option is required"))?;
4319            let (key_id, key_modifiers) = validate_data_type(scx, key_type, "MAP ", "KEY TYPE")?;
4320            let (value_id, value_modifiers) =
4321                validate_data_type(scx, value_type, "MAP ", "VALUE TYPE")?;
4322            CatalogType::Map {
4323                key_reference: key_id,
4324                key_modifiers,
4325                value_reference: value_id,
4326                value_modifiers,
4327            }
4328        }
4329        CreateTypeAs::Record { column_defs } => {
4330            let mut fields = vec![];
4331            for column_def in column_defs {
4332                let data_type = column_def.data_type;
4333                let key = ident(column_def.name.clone());
4334                let (id, modifiers) = validate_data_type(scx, data_type, "", &key)?;
4335                fields.push(CatalogRecordField {
4336                    name: ColumnName::from(key.clone()),
4337                    type_reference: id,
4338                    type_modifiers: modifiers,
4339                });
4340            }
4341            CatalogType::Record { fields }
4342        }
4343    };
4344
4345    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name)?)?;
4346
4347    // Check for an object in the catalog with this same name
4348    let full_name = scx.catalog.resolve_full_name(&name);
4349    let partial_name = PartialItemName::from(full_name.clone());
4350    // For PostgreSQL compatibility, we need to prevent creating types when
4351    // there is an existing object *or* type of the same name.
4352    if let Ok(item) = scx.catalog.resolve_item_or_type(&partial_name) {
4353        if item.item_type().conflicts_with_type() {
4354            return Err(PlanError::ItemAlreadyExists {
4355                name: full_name.to_string(),
4356                item_type: item.item_type(),
4357            });
4358        }
4359    }
4360
4361    Ok(Plan::CreateType(CreateTypePlan {
4362        name,
4363        typ: Type { create_sql, inner },
4364    }))
4365}
4366
4367generate_extracted_config!(CreateTypeListOption, (ElementType, ResolvedDataType));
4368
4369generate_extracted_config!(
4370    CreateTypeMapOption,
4371    (KeyType, ResolvedDataType),
4372    (ValueType, ResolvedDataType)
4373);
4374
4375#[derive(Debug)]
4376pub enum PlannedAlterRoleOption {
4377    Attributes(PlannedRoleAttributes),
4378    Variable(PlannedRoleVariable),
4379}
4380
4381#[derive(Debug, Clone)]
4382pub struct PlannedRoleAttributes {
4383    pub inherit: Option<bool>,
4384    pub password: Option<Password>,
4385    pub scram_iterations: Option<NonZeroU32>,
4386    /// `nopassword` is set to true if the password is from the parser is None.
4387    /// This is semantically different than not supplying a password at all,
4388    /// to allow for unsetting a password.
4389    pub nopassword: Option<bool>,
4390    pub superuser: Option<bool>,
4391    pub login: Option<bool>,
4392}
4393
4394fn plan_role_attributes(
4395    options: Vec<RoleAttribute>,
4396    scx: &StatementContext,
4397) -> Result<PlannedRoleAttributes, PlanError> {
4398    let mut planned_attributes = PlannedRoleAttributes {
4399        inherit: None,
4400        password: None,
4401        scram_iterations: None,
4402        superuser: None,
4403        login: None,
4404        nopassword: None,
4405    };
4406
4407    for option in options {
4408        match option {
4409            RoleAttribute::Inherit | RoleAttribute::NoInherit
4410                if planned_attributes.inherit.is_some() =>
4411            {
4412                sql_bail!("conflicting or redundant options");
4413            }
4414            RoleAttribute::CreateCluster | RoleAttribute::NoCreateCluster => {
4415                bail_never_supported!(
4416                    "CREATECLUSTER attribute",
4417                    "sql/create-role/#details",
4418                    "Use system privileges instead."
4419                );
4420            }
4421            RoleAttribute::CreateDB | RoleAttribute::NoCreateDB => {
4422                bail_never_supported!(
4423                    "CREATEDB attribute",
4424                    "sql/create-role/#details",
4425                    "Use system privileges instead."
4426                );
4427            }
4428            RoleAttribute::CreateRole | RoleAttribute::NoCreateRole => {
4429                bail_never_supported!(
4430                    "CREATEROLE attribute",
4431                    "sql/create-role/#details",
4432                    "Use system privileges instead."
4433                );
4434            }
4435            RoleAttribute::Password(_) if planned_attributes.password.is_some() => {
4436                sql_bail!("conflicting or redundant options");
4437            }
4438
4439            RoleAttribute::Inherit => planned_attributes.inherit = Some(true),
4440            RoleAttribute::NoInherit => planned_attributes.inherit = Some(false),
4441            RoleAttribute::Password(password) => {
4442                if let Some(password) = password {
4443                    planned_attributes.password = Some(password.into());
4444                    planned_attributes.scram_iterations =
4445                        Some(scx.catalog.system_vars().scram_iterations())
4446                } else {
4447                    planned_attributes.nopassword = Some(true);
4448                }
4449            }
4450            RoleAttribute::SuperUser => {
4451                if planned_attributes.superuser == Some(false) {
4452                    sql_bail!("conflicting or redundant options");
4453                }
4454                planned_attributes.superuser = Some(true);
4455            }
4456            RoleAttribute::NoSuperUser => {
4457                if planned_attributes.superuser == Some(true) {
4458                    sql_bail!("conflicting or redundant options");
4459                }
4460                planned_attributes.superuser = Some(false);
4461            }
4462            RoleAttribute::Login => {
4463                if planned_attributes.login == Some(false) {
4464                    sql_bail!("conflicting or redundant options");
4465                }
4466                planned_attributes.login = Some(true);
4467            }
4468            RoleAttribute::NoLogin => {
4469                if planned_attributes.login == Some(true) {
4470                    sql_bail!("conflicting or redundant options");
4471                }
4472                planned_attributes.login = Some(false);
4473            }
4474        }
4475    }
4476    if planned_attributes.inherit == Some(false) {
4477        bail_unsupported!("non inherit roles");
4478    }
4479
4480    Ok(planned_attributes)
4481}
4482
4483#[derive(Debug)]
4484pub enum PlannedRoleVariable {
4485    Set { name: String, value: VariableValue },
4486    Reset { name: String },
4487}
4488
4489impl PlannedRoleVariable {
4490    pub fn name(&self) -> &str {
4491        match self {
4492            PlannedRoleVariable::Set { name, .. } => name,
4493            PlannedRoleVariable::Reset { name } => name,
4494        }
4495    }
4496}
4497
4498fn plan_role_variable(
4499    scx: &StatementContext,
4500    variable: SetRoleVar,
4501) -> Result<PlannedRoleVariable, PlanError> {
4502    let plan = match variable {
4503        SetRoleVar::Set { name, value } => {
4504            let name = name.to_string();
4505            let value = scl::plan_set_variable_to(value)?;
4506            // Gate feature-flagged isolation levels, matching the `SET` and
4507            // connection-option paths in `SessionVars::set`.
4508            if let VariableValue::Values(values) = &value {
4509                vars::check_transaction_isolation_feature_flag(
4510                    &name,
4511                    VarInput::SqlSet(values),
4512                    scx.catalog.system_vars(),
4513                )?;
4514            }
4515            PlannedRoleVariable::Set { name, value }
4516        }
4517        SetRoleVar::Reset { name } => PlannedRoleVariable::Reset {
4518            name: name.to_string(),
4519        },
4520    };
4521    Ok(plan)
4522}
4523
4524pub fn describe_create_role(
4525    _: &StatementContext,
4526    _: CreateRoleStatement,
4527) -> Result<StatementDesc, PlanError> {
4528    Ok(StatementDesc::new(None))
4529}
4530
4531pub fn plan_create_role(
4532    scx: &StatementContext,
4533    CreateRoleStatement { name, options }: CreateRoleStatement,
4534) -> Result<Plan, PlanError> {
4535    let attributes = plan_role_attributes(options, scx)?;
4536    Ok(Plan::CreateRole(CreateRolePlan {
4537        name: normalize::ident(name),
4538        attributes: attributes.into(),
4539    }))
4540}
4541
4542pub fn plan_create_network_policy(
4543    ctx: &StatementContext,
4544    CreateNetworkPolicyStatement { name, options }: CreateNetworkPolicyStatement<Aug>,
4545) -> Result<Plan, PlanError> {
4546    ctx.require_feature_flag(&vars::ENABLE_NETWORK_POLICIES)?;
4547    let policy_options: NetworkPolicyOptionExtracted = options.try_into()?;
4548
4549    let Some(rule_defs) = policy_options.rules else {
4550        sql_bail!("RULES must be specified when creating network policies.");
4551    };
4552
4553    let mut rules = vec![];
4554    for NetworkPolicyRuleDefinition { name, options } in rule_defs {
4555        let NetworkPolicyRuleOptionExtracted {
4556            seen: _,
4557            direction,
4558            action,
4559            address,
4560        } = options.try_into()?;
4561        let (direction, action, address) = match (direction, action, address) {
4562            (Some(direction), Some(action), Some(address)) => (
4563                NetworkPolicyRuleDirection::try_from(direction.as_str())?,
4564                NetworkPolicyRuleAction::try_from(action.as_str())?,
4565                PolicyAddress::try_from(address.as_str())?,
4566            ),
4567            (_, _, _) => {
4568                sql_bail!("Direction, Address, and Action must specified when creating a rule")
4569            }
4570        };
4571        rules.push(NetworkPolicyRule {
4572            name: normalize::ident(name),
4573            direction,
4574            action,
4575            address,
4576        });
4577    }
4578
4579    if rules.len()
4580        > ctx
4581            .catalog
4582            .system_vars()
4583            .max_rules_per_network_policy()
4584            .try_into()?
4585    {
4586        sql_bail!("RULES count exceeds max_rules_per_network_policy.")
4587    }
4588
4589    Ok(Plan::CreateNetworkPolicy(CreateNetworkPolicyPlan {
4590        name: normalize::ident(name),
4591        rules,
4592    }))
4593}
4594
4595pub fn plan_alter_network_policy(
4596    ctx: &StatementContext,
4597    AlterNetworkPolicyStatement { name, options }: AlterNetworkPolicyStatement<Aug>,
4598) -> Result<Plan, PlanError> {
4599    ctx.require_feature_flag(&vars::ENABLE_NETWORK_POLICIES)?;
4600
4601    let policy_options: NetworkPolicyOptionExtracted = options.try_into()?;
4602    let policy = ctx.catalog.resolve_network_policy(&name.to_string())?;
4603
4604    let Some(rule_defs) = policy_options.rules else {
4605        sql_bail!("RULES must be specified when creating network policies.");
4606    };
4607
4608    let mut rules = vec![];
4609    for NetworkPolicyRuleDefinition { name, options } in rule_defs {
4610        let NetworkPolicyRuleOptionExtracted {
4611            seen: _,
4612            direction,
4613            action,
4614            address,
4615        } = options.try_into()?;
4616
4617        let (direction, action, address) = match (direction, action, address) {
4618            (Some(direction), Some(action), Some(address)) => (
4619                NetworkPolicyRuleDirection::try_from(direction.as_str())?,
4620                NetworkPolicyRuleAction::try_from(action.as_str())?,
4621                PolicyAddress::try_from(address.as_str())?,
4622            ),
4623            (_, _, _) => {
4624                sql_bail!("Direction, Address, and Action must specified when creating a rule")
4625            }
4626        };
4627        rules.push(NetworkPolicyRule {
4628            name: normalize::ident(name),
4629            direction,
4630            action,
4631            address,
4632        });
4633    }
4634    if rules.len()
4635        > ctx
4636            .catalog
4637            .system_vars()
4638            .max_rules_per_network_policy()
4639            .try_into()?
4640    {
4641        sql_bail!("RULES count exceeds max_rules_per_network_policy.")
4642    }
4643
4644    Ok(Plan::AlterNetworkPolicy(AlterNetworkPolicyPlan {
4645        id: policy.id(),
4646        name: normalize::ident(name),
4647        rules,
4648    }))
4649}
4650
4651pub fn describe_create_cluster(
4652    _: &StatementContext,
4653    _: CreateClusterStatement<Aug>,
4654) -> Result<StatementDesc, PlanError> {
4655    Ok(StatementDesc::new(None))
4656}
4657
4658// WARNING:
4659// DO NOT set any `Default` value here using the built-in mechanism of `generate_extracted_config`!
4660// These options are also used in ALTER CLUSTER, where not giving an option means that the value of
4661// that option stays the same. If you were to give a default value here, then not giving that option
4662// to ALTER CLUSTER would always reset the value of that option to the default.
4663generate_extracted_config!(
4664    ClusterOption,
4665    (AvailabilityZones, Vec<String>),
4666    (Disk, bool),
4667    (IntrospectionDebugging, bool),
4668    (IntrospectionInterval, OptionalDuration),
4669    (Managed, bool),
4670    (Replicas, Vec<ReplicaDefinition<Aug>>),
4671    (ReplicationFactor, u32),
4672    (Size, String),
4673    (Schedule, ClusterScheduleOptionValue),
4674    (WorkloadClass, OptionalString)
4675);
4676
4677generate_extracted_config!(
4678    NetworkPolicyOption,
4679    (Rules, Vec<NetworkPolicyRuleDefinition<Aug>>)
4680);
4681
4682generate_extracted_config!(
4683    NetworkPolicyRuleOption,
4684    (Direction, String),
4685    (Action, String),
4686    (Address, String)
4687);
4688
4689generate_extracted_config!(ClusterAlterOption, (Wait, ClusterAlterOptionValue<Aug>));
4690
4691generate_extracted_config!(
4692    ClusterAlterUntilReadyOption,
4693    (Timeout, Duration),
4694    (OnTimeout, String)
4695);
4696
4697generate_extracted_config!(
4698    ClusterFeature,
4699    (ReoptimizeImportedViews, Option<bool>, Default(None)),
4700    (EnableEagerDeltaJoins, Option<bool>, Default(None)),
4701    (EnableNewOuterJoinLowering, Option<bool>, Default(None)),
4702    (EnableVariadicLeftJoinLowering, Option<bool>, Default(None)),
4703    (EnableLetrecFixpointAnalysis, Option<bool>, Default(None)),
4704    (EnableJoinPrioritizeArranged, Option<bool>, Default(None)),
4705    (
4706        EnableProjectionPushdownAfterRelationCse,
4707        Option<bool>,
4708        Default(None)
4709    )
4710);
4711
4712/// Convert a [`CreateClusterStatement`] into a [`Plan`].
4713///
4714/// The reverse of [`unplan_create_cluster`].
4715pub fn plan_create_cluster(
4716    scx: &StatementContext,
4717    stmt: CreateClusterStatement<Aug>,
4718) -> Result<Plan, PlanError> {
4719    let plan = plan_create_cluster_inner(scx, stmt)?;
4720
4721    // Roundtrip through unplan and make sure that we end up with the same plan.
4722    if let CreateClusterVariant::Managed(_) = &plan.variant {
4723        let stmt = unplan_create_cluster(scx, plan.clone())
4724            .map_err(|e| PlanError::Replan(e.to_string()))?;
4725        let create_sql = stmt.to_ast_string_stable();
4726        let stmt = parse::parse(&create_sql)
4727            .map_err(|e| PlanError::Replan(e.to_string()))?
4728            .into_element()
4729            .ast;
4730        let (stmt, _resolved_ids) =
4731            names::resolve(scx.catalog, stmt).map_err(|e| PlanError::Replan(e.to_string()))?;
4732        let stmt = match stmt {
4733            Statement::CreateCluster(stmt) => stmt,
4734            stmt => {
4735                return Err(PlanError::Replan(format!(
4736                    "replan does not match: plan={plan:?}, create_sql={create_sql:?}, stmt={stmt:?}"
4737                )));
4738            }
4739        };
4740        let replan =
4741            plan_create_cluster_inner(scx, stmt).map_err(|e| PlanError::Replan(e.to_string()))?;
4742        if plan != replan {
4743            return Err(PlanError::Replan(format!(
4744                "replan does not match: plan={plan:?}, replan={replan:?}"
4745            )));
4746        }
4747    }
4748
4749    Ok(Plan::CreateCluster(plan))
4750}
4751
4752pub fn plan_create_cluster_inner(
4753    scx: &StatementContext,
4754    CreateClusterStatement {
4755        name,
4756        options,
4757        features,
4758    }: CreateClusterStatement<Aug>,
4759) -> Result<CreateClusterPlan, PlanError> {
4760    let ClusterOptionExtracted {
4761        availability_zones,
4762        introspection_debugging,
4763        introspection_interval,
4764        managed,
4765        replicas,
4766        replication_factor,
4767        seen: _,
4768        size,
4769        disk,
4770        schedule,
4771        workload_class,
4772    }: ClusterOptionExtracted = options.try_into()?;
4773
4774    let managed = managed.unwrap_or_else(|| replicas.is_none());
4775
4776    if !scx.catalog.active_role_id().is_system() {
4777        if !features.is_empty() {
4778            sql_bail!("FEATURES not supported for non-system users");
4779        }
4780        if workload_class.is_some() {
4781            sql_bail!("WORKLOAD CLASS not supported for non-system users");
4782        }
4783    }
4784
4785    let schedule = schedule.unwrap_or(ClusterScheduleOptionValue::Manual);
4786    let workload_class = workload_class.and_then(|v| v.0);
4787
4788    if managed {
4789        if replicas.is_some() {
4790            sql_bail!("REPLICAS not supported for managed clusters");
4791        }
4792        let Some(size) = size else {
4793            sql_bail!("SIZE must be specified for managed clusters");
4794        };
4795
4796        if disk.is_some() {
4797            // The `DISK` option is a no-op for legacy cluster sizes and was never allowed for
4798            // `cc` sizes. The long term plan is to phase out the legacy sizes, at which point
4799            // we'll be able to remove the `DISK` option entirely.
4800            if scx.catalog.is_cluster_size_cc(&size) {
4801                sql_bail!(
4802                    "DISK option not supported for modern cluster sizes because disk is always enabled"
4803                );
4804            }
4805
4806            scx.catalog
4807                .add_notice(PlanNotice::ReplicaDiskOptionDeprecated);
4808        }
4809
4810        let compute = plan_compute_replica_config(
4811            introspection_interval,
4812            introspection_debugging.unwrap_or(false),
4813        )?;
4814
4815        let replication_factor = if matches!(schedule, ClusterScheduleOptionValue::Manual) {
4816            replication_factor.unwrap_or_else(|| {
4817                scx.catalog
4818                    .system_vars()
4819                    .default_cluster_replication_factor()
4820            })
4821        } else {
4822            scx.require_feature_flag(&ENABLE_CLUSTER_SCHEDULE_REFRESH)?;
4823            if replication_factor.is_some() {
4824                sql_bail!(
4825                    "REPLICATION FACTOR cannot be given together with any SCHEDULE other than MANUAL"
4826                );
4827            }
4828            // If we have a non-trivial schedule, then let's not have any replicas initially,
4829            // to avoid quickly going back and forth if the schedule doesn't want a replica
4830            // initially.
4831            0
4832        };
4833        let availability_zones = availability_zones.unwrap_or_default();
4834
4835        if !availability_zones.is_empty() {
4836            scx.require_feature_flag(&vars::ENABLE_MANAGED_CLUSTER_AVAILABILITY_ZONES)?;
4837        }
4838
4839        // Plan OptimizerFeatureOverrides.
4840        let ClusterFeatureExtracted {
4841            reoptimize_imported_views,
4842            enable_eager_delta_joins,
4843            enable_new_outer_join_lowering,
4844            enable_variadic_left_join_lowering,
4845            enable_letrec_fixpoint_analysis,
4846            enable_join_prioritize_arranged,
4847            enable_projection_pushdown_after_relation_cse,
4848            seen: _,
4849        } = ClusterFeatureExtracted::try_from(features)?;
4850        let optimizer_feature_overrides = OptimizerFeatureOverrides {
4851            reoptimize_imported_views,
4852            enable_eager_delta_joins,
4853            enable_new_outer_join_lowering,
4854            enable_variadic_left_join_lowering,
4855            enable_letrec_fixpoint_analysis,
4856            enable_join_prioritize_arranged,
4857            enable_projection_pushdown_after_relation_cse,
4858            ..Default::default()
4859        };
4860
4861        let schedule = plan_cluster_schedule(schedule)?;
4862
4863        Ok(CreateClusterPlan {
4864            name: normalize::ident(name),
4865            variant: CreateClusterVariant::Managed(CreateClusterManagedPlan {
4866                replication_factor,
4867                size,
4868                availability_zones,
4869                compute,
4870                optimizer_feature_overrides,
4871                schedule,
4872            }),
4873            workload_class,
4874        })
4875    } else {
4876        let Some(replica_defs) = replicas else {
4877            sql_bail!("REPLICAS must be specified for unmanaged clusters");
4878        };
4879        if availability_zones.is_some() {
4880            sql_bail!("AVAILABILITY ZONES not supported for unmanaged clusters");
4881        }
4882        if replication_factor.is_some() {
4883            sql_bail!("REPLICATION FACTOR not supported for unmanaged clusters");
4884        }
4885        if introspection_debugging.is_some() {
4886            sql_bail!("INTROSPECTION DEBUGGING not supported for unmanaged clusters");
4887        }
4888        if introspection_interval.is_some() {
4889            sql_bail!("INTROSPECTION INTERVAL not supported for unmanaged clusters");
4890        }
4891        if size.is_some() {
4892            sql_bail!("SIZE not supported for unmanaged clusters");
4893        }
4894        if disk.is_some() {
4895            sql_bail!("DISK not supported for unmanaged clusters");
4896        }
4897        if !features.is_empty() {
4898            sql_bail!("FEATURES not supported for unmanaged clusters");
4899        }
4900        if !matches!(schedule, ClusterScheduleOptionValue::Manual) {
4901            sql_bail!(
4902                "cluster schedules other than MANUAL are not supported for unmanaged clusters"
4903            );
4904        }
4905
4906        let mut replicas = vec![];
4907        for ReplicaDefinition { name, options } in replica_defs {
4908            replicas.push((normalize::ident(name), plan_replica_config(scx, options)?));
4909        }
4910
4911        Ok(CreateClusterPlan {
4912            name: normalize::ident(name),
4913            variant: CreateClusterVariant::Unmanaged(CreateClusterUnmanagedPlan { replicas }),
4914            workload_class,
4915        })
4916    }
4917}
4918
4919/// Convert a [`CreateClusterPlan`] into a [`CreateClusterStatement`].
4920///
4921/// The reverse of [`plan_create_cluster`].
4922pub fn unplan_create_cluster(
4923    scx: &StatementContext,
4924    CreateClusterPlan {
4925        name,
4926        variant,
4927        workload_class,
4928    }: CreateClusterPlan,
4929) -> Result<CreateClusterStatement<Aug>, PlanError> {
4930    match variant {
4931        CreateClusterVariant::Managed(CreateClusterManagedPlan {
4932            replication_factor,
4933            size,
4934            availability_zones,
4935            compute,
4936            optimizer_feature_overrides,
4937            schedule,
4938        }) => {
4939            let schedule = unplan_cluster_schedule(schedule);
4940            let OptimizerFeatureOverrides {
4941                enable_reduce_mfp_fusion: _,
4942                enable_cardinality_estimates: _,
4943                persist_fast_path_limit: _,
4944                reoptimize_imported_views,
4945                enable_eager_delta_joins,
4946                enable_new_outer_join_lowering,
4947                enable_variadic_left_join_lowering,
4948                enable_letrec_fixpoint_analysis,
4949                enable_join_prioritize_arranged,
4950                enable_projection_pushdown_after_relation_cse,
4951                enable_less_reduce_in_eqprop: _,
4952                enable_dequadratic_eqprop_map: _,
4953                enable_eq_classes_withholding_errors: _,
4954                enable_fast_path_plan_insights: _,
4955                enable_cast_elimination: _,
4956                enable_case_literal_transform: _,
4957                enable_simplify_quantified_comparisons: _,
4958                enable_coalesce_case_transform: _,
4959                enable_will_distinct_propagation: _,
4960                enable_fixed_correlated_cte_lowering: _,
4961            } = optimizer_feature_overrides;
4962            // The ones from above that don't occur below are not wired up to cluster features.
4963            let features_extracted = ClusterFeatureExtracted {
4964                // Seen is ignored when unplanning.
4965                seen: Default::default(),
4966                reoptimize_imported_views,
4967                enable_eager_delta_joins,
4968                enable_new_outer_join_lowering,
4969                enable_variadic_left_join_lowering,
4970                enable_letrec_fixpoint_analysis,
4971                enable_join_prioritize_arranged,
4972                enable_projection_pushdown_after_relation_cse,
4973            };
4974            let features = features_extracted.into_values(scx.catalog);
4975            let availability_zones = if availability_zones.is_empty() {
4976                None
4977            } else {
4978                Some(availability_zones)
4979            };
4980            let (introspection_interval, introspection_debugging) =
4981                unplan_compute_replica_config(compute);
4982            // Replication factor cannot be explicitly specified with a refresh schedule, it's
4983            // always 1 or less.
4984            let replication_factor = match &schedule {
4985                ClusterScheduleOptionValue::Manual => Some(replication_factor),
4986                ClusterScheduleOptionValue::Refresh { .. } => {
4987                    // A cluster with a refresh schedule is turned On/Off by the cluster scheduling
4988                    // policy, so its replication factor should always be 0 or 1, and CREATE/ALTER
4989                    // reject setting both a non-MANUAL schedule and a higher replication factor. If
4990                    // we nevertheless find one (e.g., a cluster left in an invalid state by an
4991                    // older version), log loudly rather than crashing the coordinator: the
4992                    // replication factor is omitted from the rendered statement regardless.
4993                    soft_assert_or_log!(
4994                        replication_factor <= 1,
4995                        "replication factor, {replication_factor:?}, must be <= 1 with a refresh schedule"
4996                    );
4997                    None
4998                }
4999            };
5000            let workload_class = workload_class.map(|s| OptionalString(Some(s)));
5001            let options_extracted = ClusterOptionExtracted {
5002                // Seen is ignored when unplanning.
5003                seen: Default::default(),
5004                availability_zones,
5005                disk: None,
5006                introspection_debugging: Some(introspection_debugging),
5007                introspection_interval,
5008                managed: Some(true),
5009                replicas: None,
5010                replication_factor,
5011                size: Some(size),
5012                schedule: Some(schedule),
5013                workload_class,
5014            };
5015            let options = options_extracted.into_values(scx.catalog);
5016            let name = Ident::new_unchecked(name);
5017            Ok(CreateClusterStatement {
5018                name,
5019                options,
5020                features,
5021            })
5022        }
5023        CreateClusterVariant::Unmanaged(_) => {
5024            bail_unsupported!("SHOW CREATE for unmanaged clusters")
5025        }
5026    }
5027}
5028
5029generate_extracted_config!(
5030    ReplicaOption,
5031    (AvailabilityZone, String),
5032    (BilledAs, String),
5033    (ComputeAddresses, Vec<String>),
5034    (ComputectlAddresses, Vec<String>),
5035    (Disk, bool),
5036    (Internal, bool, Default(false)),
5037    (IntrospectionDebugging, bool, Default(false)),
5038    (IntrospectionInterval, OptionalDuration),
5039    (Size, String),
5040    (StorageAddresses, Vec<String>),
5041    (StoragectlAddresses, Vec<String>),
5042    (Workers, u16)
5043);
5044
5045fn plan_replica_config(
5046    scx: &StatementContext,
5047    options: Vec<ReplicaOption<Aug>>,
5048) -> Result<ReplicaConfig, PlanError> {
5049    let ReplicaOptionExtracted {
5050        availability_zone,
5051        billed_as,
5052        computectl_addresses,
5053        disk,
5054        internal,
5055        introspection_debugging,
5056        introspection_interval,
5057        size,
5058        storagectl_addresses,
5059        ..
5060    }: ReplicaOptionExtracted = options.try_into()?;
5061
5062    let compute = plan_compute_replica_config(introspection_interval, introspection_debugging)?;
5063
5064    match (
5065        size,
5066        availability_zone,
5067        billed_as,
5068        storagectl_addresses,
5069        computectl_addresses,
5070    ) {
5071        // Common cases we expect end users to hit.
5072        (None, _, None, None, None) => {
5073            // We don't mention the unmanaged options in the error message
5074            // because they are only available in unsafe mode.
5075            sql_bail!("SIZE option must be specified");
5076        }
5077        (Some(size), availability_zone, billed_as, None, None) => {
5078            if disk.is_some() {
5079                // The `DISK` option is a no-op for legacy cluster sizes and was never allowed for
5080                // `cc` sizes. The long term plan is to phase out the legacy sizes, at which point
5081                // we'll be able to remove the `DISK` option entirely.
5082                if scx.catalog.is_cluster_size_cc(&size) {
5083                    sql_bail!(
5084                        "DISK option not supported for modern cluster sizes because disk is always enabled"
5085                    );
5086                }
5087
5088                scx.catalog
5089                    .add_notice(PlanNotice::ReplicaDiskOptionDeprecated);
5090            }
5091
5092            Ok(ReplicaConfig::Orchestrated {
5093                size,
5094                availability_zone,
5095                compute,
5096                billed_as,
5097                internal,
5098            })
5099        }
5100
5101        (None, None, None, storagectl_addresses, computectl_addresses) => {
5102            scx.require_feature_flag(&vars::UNSAFE_ENABLE_UNORCHESTRATED_CLUSTER_REPLICAS)?;
5103
5104            // When manually testing Materialize in unsafe mode, it's easy to
5105            // accidentally omit one of these options, so we try to produce
5106            // helpful error messages.
5107            let Some(storagectl_addrs) = storagectl_addresses else {
5108                sql_bail!("missing STORAGECTL ADDRESSES option");
5109            };
5110            let Some(computectl_addrs) = computectl_addresses else {
5111                sql_bail!("missing COMPUTECTL ADDRESSES option");
5112            };
5113
5114            if storagectl_addrs.len() != computectl_addrs.len() {
5115                sql_bail!(
5116                    "COMPUTECTL ADDRESSES and STORAGECTL ADDRESSES must have the same length"
5117                );
5118            }
5119
5120            if disk.is_some() {
5121                sql_bail!("DISK can't be specified for unorchestrated clusters");
5122            }
5123
5124            Ok(ReplicaConfig::Unorchestrated {
5125                storagectl_addrs,
5126                computectl_addrs,
5127                compute,
5128            })
5129        }
5130        _ => {
5131            // We don't bother trying to produce a more helpful error message
5132            // here because no user is likely to hit this path.
5133            sql_bail!("invalid mixture of orchestrated and unorchestrated replica options");
5134        }
5135    }
5136}
5137
5138/// Convert an [`Option<OptionalDuration>`] and [`bool`] into a [`ComputeReplicaConfig`].
5139///
5140/// The reverse of [`unplan_compute_replica_config`].
5141fn plan_compute_replica_config(
5142    introspection_interval: Option<OptionalDuration>,
5143    introspection_debugging: bool,
5144) -> Result<ComputeReplicaConfig, PlanError> {
5145    let introspection_interval = introspection_interval
5146        .map(|OptionalDuration(i)| i)
5147        .unwrap_or(Some(DEFAULT_REPLICA_LOGGING_INTERVAL));
5148    let introspection = match introspection_interval {
5149        Some(interval) => Some(ComputeReplicaIntrospectionConfig {
5150            interval,
5151            debugging: introspection_debugging,
5152        }),
5153        None if introspection_debugging => {
5154            sql_bail!("INTROSPECTION DEBUGGING cannot be specified without INTROSPECTION INTERVAL")
5155        }
5156        None => None,
5157    };
5158    let compute = ComputeReplicaConfig { introspection };
5159    Ok(compute)
5160}
5161
5162/// Convert a [`ComputeReplicaConfig`] into an [`Option<OptionalDuration>`] and [`bool`].
5163///
5164/// The reverse of [`plan_compute_replica_config`].
5165fn unplan_compute_replica_config(
5166    compute_replica_config: ComputeReplicaConfig,
5167) -> (Option<OptionalDuration>, bool) {
5168    match compute_replica_config.introspection {
5169        Some(ComputeReplicaIntrospectionConfig {
5170            debugging,
5171            interval,
5172        }) => (Some(OptionalDuration(Some(interval))), debugging),
5173        None => (Some(OptionalDuration(None)), false),
5174    }
5175}
5176
5177/// Convert a [`ClusterScheduleOptionValue`] into a [`ClusterSchedule`].
5178///
5179/// The reverse of [`unplan_cluster_schedule`].
5180fn plan_cluster_schedule(
5181    schedule: ClusterScheduleOptionValue,
5182) -> Result<ClusterSchedule, PlanError> {
5183    Ok(match schedule {
5184        ClusterScheduleOptionValue::Manual => ClusterSchedule::Manual,
5185        // If `HYDRATION TIME ESTIMATE` is not explicitly given, we default to 0.
5186        ClusterScheduleOptionValue::Refresh {
5187            hydration_time_estimate: None,
5188        } => ClusterSchedule::Refresh {
5189            hydration_time_estimate: Duration::from_millis(0),
5190        },
5191        // Otherwise we convert the `IntervalValue` to a `Duration`.
5192        ClusterScheduleOptionValue::Refresh {
5193            hydration_time_estimate: Some(interval_value),
5194        } => {
5195            let interval = Interval::try_from_value(Value::Interval(interval_value))?;
5196            if interval.as_microseconds() < 0 {
5197                sql_bail!(
5198                    "HYDRATION TIME ESTIMATE must be non-negative; got: {}",
5199                    interval
5200                );
5201            }
5202            if interval.months != 0 {
5203                // This limitation is because we want this interval to be cleanly convertable
5204                // to a unix epoch timestamp difference. When the interval involves months, then
5205                // this is not true anymore, because months have variable lengths.
5206                sql_bail!("HYDRATION TIME ESTIMATE must not involve units larger than days");
5207            }
5208            let duration = interval.duration()?;
5209            if u64::try_from(duration.as_millis()).is_err()
5210                || Interval::from_duration(&duration).is_err()
5211            {
5212                sql_bail!("HYDRATION TIME ESTIMATE too large");
5213            }
5214            ClusterSchedule::Refresh {
5215                hydration_time_estimate: duration,
5216            }
5217        }
5218    })
5219}
5220
5221/// Convert a [`ClusterSchedule`] into a [`ClusterScheduleOptionValue`].
5222///
5223/// The reverse of [`plan_cluster_schedule`].
5224fn unplan_cluster_schedule(schedule: ClusterSchedule) -> ClusterScheduleOptionValue {
5225    match schedule {
5226        ClusterSchedule::Manual => ClusterScheduleOptionValue::Manual,
5227        ClusterSchedule::Refresh {
5228            hydration_time_estimate,
5229        } => {
5230            let interval = Interval::from_duration(&hydration_time_estimate)
5231                .expect("planning ensured that this is convertible back to Interval");
5232            let interval_value = literal::unplan_interval(&interval);
5233            ClusterScheduleOptionValue::Refresh {
5234                hydration_time_estimate: Some(interval_value),
5235            }
5236        }
5237    }
5238}
5239
5240pub fn describe_create_cluster_replica(
5241    _: &StatementContext,
5242    _: CreateClusterReplicaStatement<Aug>,
5243) -> Result<StatementDesc, PlanError> {
5244    Ok(StatementDesc::new(None))
5245}
5246
5247pub fn plan_create_cluster_replica(
5248    scx: &StatementContext,
5249    CreateClusterReplicaStatement {
5250        definition: ReplicaDefinition { name, options },
5251        of_cluster,
5252    }: CreateClusterReplicaStatement<Aug>,
5253) -> Result<Plan, PlanError> {
5254    let cluster = scx
5255        .catalog
5256        .resolve_cluster(Some(&normalize::ident(of_cluster)))?;
5257
5258    let config = plan_replica_config(scx, options)?;
5259
5260    if let ReplicaConfig::Orchestrated { internal: true, .. } = &config {
5261        if MANAGED_REPLICA_PATTERN.is_match(name.as_str()) {
5262            return Err(PlanError::MangedReplicaName(name.into_string()));
5263        }
5264    } else {
5265        ensure_cluster_is_not_managed(scx, cluster.id())?;
5266    }
5267
5268    Ok(Plan::CreateClusterReplica(CreateClusterReplicaPlan {
5269        name: normalize::ident(name),
5270        cluster_id: cluster.id(),
5271        config,
5272    }))
5273}
5274
5275pub fn describe_create_secret(
5276    _: &StatementContext,
5277    _: CreateSecretStatement<Aug>,
5278) -> Result<StatementDesc, PlanError> {
5279    Ok(StatementDesc::new(None))
5280}
5281
5282pub fn plan_create_secret(
5283    scx: &StatementContext,
5284    stmt: CreateSecretStatement<Aug>,
5285) -> Result<Plan, PlanError> {
5286    let CreateSecretStatement {
5287        name,
5288        if_not_exists,
5289        value,
5290    } = &stmt;
5291
5292    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name.to_owned())?)?;
5293    let mut create_sql_statement = stmt.clone();
5294    create_sql_statement.value = Expr::Value(Value::String("********".to_string()));
5295    let create_sql =
5296        normalize::create_statement(scx, Statement::CreateSecret(create_sql_statement))?;
5297    let secret_as = query::plan_secret_as(scx, value.clone())?;
5298
5299    let secret = Secret {
5300        create_sql,
5301        secret_as,
5302    };
5303
5304    Ok(Plan::CreateSecret(CreateSecretPlan {
5305        name,
5306        secret,
5307        if_not_exists: *if_not_exists,
5308    }))
5309}
5310
5311pub fn describe_create_connection(
5312    _: &StatementContext,
5313    _: CreateConnectionStatement<Aug>,
5314) -> Result<StatementDesc, PlanError> {
5315    Ok(StatementDesc::new(None))
5316}
5317
5318generate_extracted_config!(CreateConnectionOption, (Validate, bool));
5319
5320pub fn plan_create_connection(
5321    scx: &StatementContext,
5322    mut stmt: CreateConnectionStatement<Aug>,
5323) -> Result<Plan, PlanError> {
5324    let CreateConnectionStatement {
5325        name,
5326        connection_type,
5327        values,
5328        if_not_exists,
5329        with_options,
5330    } = stmt.clone();
5331    let connection_options_extracted = connection::ConnectionOptionExtracted::try_from(values)?;
5332    let details = connection_options_extracted.try_into_connection_details(scx, connection_type)?;
5333    let name = scx.allocate_qualified_name(normalize::unresolved_item_name(name)?)?;
5334
5335    let options = CreateConnectionOptionExtracted::try_from(with_options)?;
5336    if options.validate.is_some() {
5337        scx.require_feature_flag(&vars::ENABLE_CONNECTION_VALIDATION_SYNTAX)?;
5338    }
5339    let validate = match options.validate {
5340        Some(val) => val,
5341        None => {
5342            scx.catalog
5343                .system_vars()
5344                .enable_default_connection_validation()
5345                && details.to_connection().validate_by_default()
5346        }
5347    };
5348
5349    // Check for an object in the catalog with this same name
5350    let full_name = scx.catalog.resolve_full_name(&name);
5351    let partial_name = PartialItemName::from(full_name.clone());
5352    if let (false, Ok(item)) = (if_not_exists, scx.catalog.resolve_item(&partial_name)) {
5353        return Err(PlanError::ItemAlreadyExists {
5354            name: full_name.to_string(),
5355            item_type: item.item_type(),
5356        });
5357    }
5358
5359    // For SSH connections, overwrite the public key options based on the
5360    // connection details, in case we generated new keys during planning.
5361    if let ConnectionDetails::Ssh { key_1, key_2, .. } = &details {
5362        stmt.values.retain(|v| {
5363            v.name != ConnectionOptionName::PublicKey1 && v.name != ConnectionOptionName::PublicKey2
5364        });
5365        stmt.values.push(ConnectionOption {
5366            name: ConnectionOptionName::PublicKey1,
5367            value: Some(WithOptionValue::Value(Value::String(key_1.public_key()))),
5368        });
5369        stmt.values.push(ConnectionOption {
5370            name: ConnectionOptionName::PublicKey2,
5371            value: Some(WithOptionValue::Value(Value::String(key_2.public_key()))),
5372        });
5373    }
5374    let create_sql = normalize::create_statement(scx, Statement::CreateConnection(stmt))?;
5375
5376    let plan = CreateConnectionPlan {
5377        name,
5378        if_not_exists,
5379        connection: crate::plan::Connection {
5380            create_sql,
5381            details,
5382        },
5383        validate,
5384    };
5385    Ok(Plan::CreateConnection(plan))
5386}
5387
5388fn plan_drop_database(
5389    scx: &StatementContext,
5390    if_exists: bool,
5391    name: &UnresolvedDatabaseName,
5392    cascade: bool,
5393) -> Result<Option<DatabaseId>, PlanError> {
5394    Ok(match resolve_database(scx, name, if_exists)? {
5395        Some(database) => {
5396            if !cascade && database.has_schemas() {
5397                sql_bail!(
5398                    "database '{}' cannot be dropped with RESTRICT while it contains schemas",
5399                    name,
5400                );
5401            }
5402            Some(database.id())
5403        }
5404        None => None,
5405    })
5406}
5407
5408pub fn describe_drop_objects(
5409    _: &StatementContext,
5410    _: DropObjectsStatement,
5411) -> Result<StatementDesc, PlanError> {
5412    Ok(StatementDesc::new(None))
5413}
5414
5415pub fn plan_drop_objects(
5416    scx: &mut StatementContext,
5417    DropObjectsStatement {
5418        object_type,
5419        if_exists,
5420        names,
5421        cascade,
5422    }: DropObjectsStatement,
5423) -> Result<Plan, PlanError> {
5424    if object_type == mz_sql_parser::ast::ObjectType::Func {
5425        bail_unsupported!("DROP FUNCTION");
5426    }
5427    let object_type = object_type.into();
5428
5429    let mut referenced_ids = Vec::new();
5430    for name in names {
5431        let id = match &name {
5432            UnresolvedObjectName::Cluster(name) => {
5433                plan_drop_cluster(scx, if_exists, name, cascade)?.map(ObjectId::Cluster)
5434            }
5435            UnresolvedObjectName::ClusterReplica(name) => {
5436                plan_drop_cluster_replica(scx, if_exists, name)?.map(ObjectId::ClusterReplica)
5437            }
5438            UnresolvedObjectName::Database(name) => {
5439                plan_drop_database(scx, if_exists, name, cascade)?.map(ObjectId::Database)
5440            }
5441            UnresolvedObjectName::Schema(name) => {
5442                plan_drop_schema(scx, if_exists, name, cascade)?.map(ObjectId::Schema)
5443            }
5444            UnresolvedObjectName::Role(name) => {
5445                plan_drop_role(scx, if_exists, name)?.map(ObjectId::Role)
5446            }
5447            UnresolvedObjectName::Item(name) => {
5448                // Defer the dependency check until all names are resolved, so a
5449                // dependent that is itself being dropped in this same statement
5450                // does not block a non-cascade drop.
5451                plan_drop_item_name(scx, object_type, if_exists, name.clone())?.map(ObjectId::Item)
5452            }
5453            UnresolvedObjectName::NetworkPolicy(name) => {
5454                plan_drop_network_policy(scx, if_exists, name)?.map(ObjectId::NetworkPolicy)
5455            }
5456        };
5457        match id {
5458            Some(id) => referenced_ids.push(id),
5459            None => scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
5460                name: name.to_ast_string_simple(),
5461                object_type,
5462            }),
5463        }
5464    }
5465
5466    // Now that the full set of explicitly-named items is known, run the
5467    // non-cascade dependency check. A dependent that is itself being dropped in
5468    // this statement does not block the drop, matching PostgreSQL.
5469    if !cascade {
5470        let dropped_items: BTreeSet<CatalogItemId> = referenced_ids
5471            .iter()
5472            .filter_map(|id| match id {
5473                ObjectId::Item(id) => Some(*id),
5474                _ => None,
5475            })
5476            .collect();
5477        for id in &dropped_items {
5478            let catalog_item = scx.catalog.get_item(id);
5479            ensure_no_blocking_dependents(scx, object_type, catalog_item, &dropped_items)?;
5480        }
5481    }
5482
5483    let drop_ids = scx.catalog.object_dependents(&referenced_ids);
5484
5485    Ok(Plan::DropObjects(DropObjectsPlan {
5486        referenced_ids,
5487        drop_ids,
5488        object_type,
5489    }))
5490}
5491
5492fn plan_drop_schema(
5493    scx: &StatementContext,
5494    if_exists: bool,
5495    name: &UnresolvedSchemaName,
5496    cascade: bool,
5497) -> Result<Option<(ResolvedDatabaseSpecifier, SchemaSpecifier)>, PlanError> {
5498    // Special case for mz_temp: with lazy temporary schema creation, the temp
5499    // schema may not exist yet, but we still need to return the correct error.
5500    // Check the schema name directly against MZ_TEMP_SCHEMA.
5501    let normalized = normalize::unresolved_schema_name(name.clone())?;
5502    if normalized.database.is_none() && normalized.schema == mz_repr::namespaces::MZ_TEMP_SCHEMA {
5503        sql_bail!("cannot drop schema {name} because it is a temporary schema",)
5504    }
5505
5506    Ok(match resolve_schema(scx, name.clone(), if_exists)? {
5507        Some((database_spec, schema_spec)) => {
5508            if let ResolvedDatabaseSpecifier::Ambient = database_spec {
5509                sql_bail!(
5510                    "cannot drop schema {name} because it is required by the database system",
5511                );
5512            }
5513            if let SchemaSpecifier::Temporary = schema_spec {
5514                sql_bail!("cannot drop schema {name} because it is a temporary schema",)
5515            }
5516            let schema = scx.get_schema(&database_spec, &schema_spec);
5517            if !cascade && schema.has_items() {
5518                let full_schema_name = scx.catalog.resolve_full_schema_name(schema.name());
5519                sql_bail!(
5520                    "schema '{}' cannot be dropped without CASCADE while it contains objects",
5521                    full_schema_name
5522                );
5523            }
5524            Some((database_spec, schema_spec))
5525        }
5526        None => None,
5527    })
5528}
5529
5530fn plan_drop_role(
5531    scx: &StatementContext,
5532    if_exists: bool,
5533    name: &Ident,
5534) -> Result<Option<RoleId>, PlanError> {
5535    match scx.catalog.resolve_role(name.as_str()) {
5536        Ok(role) => {
5537            let id = role.id();
5538            if &id == scx.catalog.active_role_id() {
5539                sql_bail!("current role cannot be dropped");
5540            }
5541            for role in scx.catalog.get_roles() {
5542                for (member_id, grantor_id) in role.membership() {
5543                    if &id == grantor_id {
5544                        let member_role = scx.catalog.get_role(member_id);
5545                        sql_bail!(
5546                            "cannot drop role {}: still depended up by membership of role {} in role {}",
5547                            name.as_str(),
5548                            role.name(),
5549                            member_role.name()
5550                        );
5551                    }
5552                }
5553            }
5554            Ok(Some(role.id()))
5555        }
5556        Err(_) if if_exists => Ok(None),
5557        Err(e) => Err(e.into()),
5558    }
5559}
5560
5561fn plan_drop_cluster(
5562    scx: &StatementContext,
5563    if_exists: bool,
5564    name: &Ident,
5565    cascade: bool,
5566) -> Result<Option<ClusterId>, PlanError> {
5567    Ok(match resolve_cluster(scx, name, if_exists)? {
5568        Some(cluster) => {
5569            if !cascade && !cluster.bound_objects().is_empty() {
5570                return Err(PlanError::DependentObjectsStillExist {
5571                    object_type: "cluster".to_string(),
5572                    object_name: cluster.name().to_string(),
5573                    dependents: Vec::new(),
5574                });
5575            }
5576            Some(cluster.id())
5577        }
5578        None => None,
5579    })
5580}
5581
5582fn plan_drop_network_policy(
5583    scx: &StatementContext,
5584    if_exists: bool,
5585    name: &Ident,
5586) -> Result<Option<NetworkPolicyId>, PlanError> {
5587    match scx.catalog.resolve_network_policy(name.as_str()) {
5588        Ok(policy) => {
5589            // TODO(network_policy): When we support role based network policies, check if any role
5590            // currently has the specified policy set.
5591            if scx.catalog.system_vars().default_network_policy_name() == policy.name() {
5592                Err(PlanError::NetworkPolicyInUse)
5593            } else {
5594                Ok(Some(policy.id()))
5595            }
5596        }
5597        Err(_) if if_exists => Ok(None),
5598        Err(e) => Err(e.into()),
5599    }
5600}
5601
5602fn plan_drop_cluster_replica(
5603    scx: &StatementContext,
5604    if_exists: bool,
5605    name: &QualifiedReplica,
5606) -> Result<Option<(ClusterId, ReplicaId)>, PlanError> {
5607    let cluster = resolve_cluster_replica(scx, name, if_exists)?;
5608    Ok(cluster.map(|(cluster, replica_id)| (cluster.id(), replica_id)))
5609}
5610
5611/// Returns the [`CatalogItemId`] of the item we should drop, if it exists.
5612fn plan_drop_item(
5613    scx: &StatementContext,
5614    object_type: ObjectType,
5615    if_exists: bool,
5616    name: UnresolvedItemName,
5617    cascade: bool,
5618) -> Result<Option<CatalogItemId>, PlanError> {
5619    let Some(id) = plan_drop_item_name(scx, object_type, if_exists, name)? else {
5620        return Ok(None);
5621    };
5622    if !cascade {
5623        let catalog_item = scx.catalog.get_item(&id);
5624        ensure_no_blocking_dependents(scx, object_type, catalog_item, &BTreeSet::new())?;
5625    }
5626    Ok(Some(id))
5627}
5628
5629/// Resolves `name` to the [`CatalogItemId`] of the item to drop, performing the
5630/// system-object check but *not* the dependency check. Returns `None` if the
5631/// item does not exist and `if_exists` is set.
5632fn plan_drop_item_name(
5633    scx: &StatementContext,
5634    object_type: ObjectType,
5635    if_exists: bool,
5636    name: UnresolvedItemName,
5637) -> Result<Option<CatalogItemId>, PlanError> {
5638    let resolved = match resolve_item_or_type(scx, object_type, name, if_exists) {
5639        Ok(r) => r,
5640        // Return a more helpful error on `DROP VIEW <materialized-view>`.
5641        Err(PlanError::MismatchedObjectType {
5642            name,
5643            is_type: ObjectType::MaterializedView,
5644            expected_type: ObjectType::View,
5645        }) => {
5646            return Err(PlanError::DropViewOnMaterializedView(name.to_string()));
5647        }
5648        e => e?,
5649    };
5650
5651    Ok(match resolved {
5652        Some(catalog_item) => {
5653            if catalog_item.id().is_system() {
5654                sql_bail!(
5655                    "cannot drop {} {} because it is required by the database system",
5656                    catalog_item.item_type(),
5657                    scx.catalog.minimal_qualification(catalog_item.name()),
5658                );
5659            }
5660            Some(catalog_item.id())
5661        }
5662        None => None,
5663    })
5664}
5665
5666/// Errors if dropping `catalog_item` would leave a dangling dependent, i.e. an
5667/// object that depends on it and is not itself being dropped. Dependents whose
5668/// ids are in `also_dropped` are ignored, since they are being dropped as part
5669/// of the same statement.
5670fn ensure_no_blocking_dependents(
5671    scx: &StatementContext,
5672    object_type: ObjectType,
5673    catalog_item: &dyn CatalogItem,
5674    also_dropped: &BTreeSet<CatalogItemId>,
5675) -> Result<(), PlanError> {
5676    for id in catalog_item.used_by() {
5677        if also_dropped.contains(id) {
5678            continue;
5679        }
5680        let dep = scx.catalog.get_item(id);
5681        if dependency_prevents_drop(object_type, dep) {
5682            return Err(PlanError::DependentObjectsStillExist {
5683                object_type: catalog_item.item_type().to_string(),
5684                object_name: scx
5685                    .catalog
5686                    .minimal_qualification(catalog_item.name())
5687                    .to_string(),
5688                dependents: vec![(
5689                    dep.item_type().to_string(),
5690                    scx.catalog.minimal_qualification(dep.name()).to_string(),
5691                )],
5692            });
5693        }
5694    }
5695    // TODO(jkosh44) It would be nice to also check if any active subscribe or pending peek
5696    //  relies on entry. Unfortunately, we don't have that information readily available.
5697    Ok(())
5698}
5699
5700/// Does the dependency `dep` prevent a drop of a non-cascade query?
5701fn dependency_prevents_drop(object_type: ObjectType, dep: &dyn CatalogItem) -> bool {
5702    match object_type {
5703        ObjectType::Type => true,
5704        ObjectType::Table
5705        | ObjectType::View
5706        | ObjectType::MaterializedView
5707        | ObjectType::Source
5708        | ObjectType::Sink
5709        | ObjectType::Index
5710        | ObjectType::Role
5711        | ObjectType::Cluster
5712        | ObjectType::ClusterReplica
5713        | ObjectType::Secret
5714        | ObjectType::Connection
5715        | ObjectType::Database
5716        | ObjectType::Schema
5717        | ObjectType::Func
5718        | ObjectType::NetworkPolicy => match dep.item_type() {
5719            CatalogItemType::Func
5720            | CatalogItemType::Table
5721            | CatalogItemType::Source
5722            | CatalogItemType::View
5723            | CatalogItemType::MaterializedView
5724            | CatalogItemType::Sink
5725            | CatalogItemType::Type
5726            | CatalogItemType::Secret
5727            | CatalogItemType::Connection => true,
5728            CatalogItemType::Index => false,
5729        },
5730    }
5731}
5732
5733pub fn describe_alter_index_options(
5734    _: &StatementContext,
5735    _: AlterIndexStatement<Aug>,
5736) -> Result<StatementDesc, PlanError> {
5737    Ok(StatementDesc::new(None))
5738}
5739
5740pub fn describe_drop_owned(
5741    _: &StatementContext,
5742    _: DropOwnedStatement<Aug>,
5743) -> Result<StatementDesc, PlanError> {
5744    Ok(StatementDesc::new(None))
5745}
5746
5747pub fn plan_drop_owned(
5748    scx: &StatementContext,
5749    drop: DropOwnedStatement<Aug>,
5750) -> Result<Plan, PlanError> {
5751    let cascade = drop.cascade();
5752    let role_ids: BTreeSet<_> = drop.role_names.into_iter().map(|role| role.id).collect();
5753    let mut drop_ids = Vec::new();
5754    let mut privilege_revokes = Vec::new();
5755    let mut default_privilege_revokes = Vec::new();
5756
5757    fn update_privilege_revokes(
5758        object_id: SystemObjectId,
5759        privileges: &PrivilegeMap,
5760        role_ids: &BTreeSet<RoleId>,
5761        privilege_revokes: &mut Vec<(SystemObjectId, MzAclItem)>,
5762    ) {
5763        privilege_revokes.extend(iter::zip(
5764            iter::repeat(object_id),
5765            privileges
5766                .all_values()
5767                .filter(|privilege| role_ids.contains(&privilege.grantee))
5768                .cloned(),
5769        ));
5770    }
5771
5772    // Replicas
5773    for replica in scx.catalog.get_cluster_replicas() {
5774        if role_ids.contains(&replica.owner_id()) {
5775            drop_ids.push((replica.cluster_id(), replica.replica_id()).into());
5776        }
5777    }
5778
5779    // Clusters
5780    for cluster in scx.catalog.get_clusters() {
5781        if role_ids.contains(&cluster.owner_id()) {
5782            // Note: CASCADE is not required for replicas.
5783            if !cascade {
5784                let non_owned_bound_objects: Vec<_> = cluster
5785                    .bound_objects()
5786                    .into_iter()
5787                    .map(|item_id| scx.catalog.get_item(item_id))
5788                    .filter(|item| !role_ids.contains(&item.owner_id()))
5789                    .collect();
5790                if !non_owned_bound_objects.is_empty() {
5791                    let names: Vec<_> = non_owned_bound_objects
5792                        .into_iter()
5793                        .map(|item| {
5794                            (
5795                                item.item_type().to_string(),
5796                                scx.catalog.resolve_full_name(item.name()).to_string(),
5797                            )
5798                        })
5799                        .collect();
5800                    return Err(PlanError::DependentObjectsStillExist {
5801                        object_type: "cluster".to_string(),
5802                        object_name: cluster.name().to_string(),
5803                        dependents: names,
5804                    });
5805                }
5806            }
5807            drop_ids.push(cluster.id().into());
5808        }
5809        update_privilege_revokes(
5810            SystemObjectId::Object(cluster.id().into()),
5811            cluster.privileges(),
5812            &role_ids,
5813            &mut privilege_revokes,
5814        );
5815    }
5816
5817    // Items
5818    for item in scx.catalog.get_items() {
5819        if role_ids.contains(&item.owner_id()) {
5820            if !cascade {
5821                // Checks if any items still depend on this one, returning an error if so.
5822                let check_if_dependents_exist = |used_by: &[CatalogItemId]| {
5823                    let non_owned_dependencies: Vec<_> = used_by
5824                        .into_iter()
5825                        .map(|item_id| scx.catalog.get_item(item_id))
5826                        .filter(|item| dependency_prevents_drop(item.item_type().into(), *item))
5827                        .filter(|item| !role_ids.contains(&item.owner_id()))
5828                        .collect();
5829                    if !non_owned_dependencies.is_empty() {
5830                        let names: Vec<_> = non_owned_dependencies
5831                            .into_iter()
5832                            .map(|item| {
5833                                let item_typ = item.item_type().to_string();
5834                                let item_name =
5835                                    scx.catalog.resolve_full_name(item.name()).to_string();
5836                                (item_typ, item_name)
5837                            })
5838                            .collect();
5839                        Err(PlanError::DependentObjectsStillExist {
5840                            object_type: item.item_type().to_string(),
5841                            object_name: scx
5842                                .catalog
5843                                .resolve_full_name(item.name())
5844                                .to_string()
5845                                .to_string(),
5846                            dependents: names,
5847                        })
5848                    } else {
5849                        Ok(())
5850                    }
5851                };
5852
5853                // When this item gets dropped it will also drop its progress source, so we need to
5854                // check the users of those.
5855                if let Some(id) = item.progress_id() {
5856                    let progress_item = scx.catalog.get_item(&id);
5857                    check_if_dependents_exist(progress_item.used_by())?;
5858                }
5859                check_if_dependents_exist(item.used_by())?;
5860            }
5861            drop_ids.push(item.id().into());
5862        }
5863        update_privilege_revokes(
5864            SystemObjectId::Object(item.id().into()),
5865            item.privileges(),
5866            &role_ids,
5867            &mut privilege_revokes,
5868        );
5869    }
5870
5871    // Schemas
5872    for schema in scx.catalog.get_schemas() {
5873        if !schema.id().is_temporary() {
5874            if role_ids.contains(&schema.owner_id()) {
5875                if !cascade {
5876                    let non_owned_dependencies: Vec<_> = schema
5877                        .item_ids()
5878                        .map(|item_id| scx.catalog.get_item(&item_id))
5879                        .filter(|item| dependency_prevents_drop(item.item_type().into(), *item))
5880                        .filter(|item| !role_ids.contains(&item.owner_id()))
5881                        .collect();
5882                    if !non_owned_dependencies.is_empty() {
5883                        let full_schema_name = scx.catalog.resolve_full_schema_name(schema.name());
5884                        sql_bail!(
5885                            "schema {} cannot be dropped without CASCADE while it contains non-owned objects",
5886                            full_schema_name.to_string().quoted()
5887                        );
5888                    }
5889                }
5890                drop_ids.push((*schema.database(), *schema.id()).into())
5891            }
5892            update_privilege_revokes(
5893                SystemObjectId::Object((*schema.database(), *schema.id()).into()),
5894                schema.privileges(),
5895                &role_ids,
5896                &mut privilege_revokes,
5897            );
5898        }
5899    }
5900
5901    // Databases
5902    for database in scx.catalog.get_databases() {
5903        if role_ids.contains(&database.owner_id()) {
5904            if !cascade {
5905                let non_owned_schemas: Vec<_> = database
5906                    .schemas()
5907                    .into_iter()
5908                    .filter(|schema| !role_ids.contains(&schema.owner_id()))
5909                    .collect();
5910                if !non_owned_schemas.is_empty() {
5911                    sql_bail!(
5912                        "database {} cannot be dropped without CASCADE while it contains non-owned schemas",
5913                        database.name().quoted(),
5914                    );
5915                }
5916            }
5917            drop_ids.push(database.id().into());
5918        }
5919        update_privilege_revokes(
5920            SystemObjectId::Object(database.id().into()),
5921            database.privileges(),
5922            &role_ids,
5923            &mut privilege_revokes,
5924        );
5925    }
5926
5927    // Network policies
5928    for network_policy in scx.catalog.get_network_policies() {
5929        if role_ids.contains(&network_policy.owner_id()) {
5930            drop_ids.push(ObjectId::NetworkPolicy(network_policy.id()));
5931        }
5932        update_privilege_revokes(
5933            SystemObjectId::Object(ObjectId::NetworkPolicy(network_policy.id())),
5934            network_policy.privileges(),
5935            &role_ids,
5936            &mut privilege_revokes,
5937        );
5938    }
5939
5940    // System
5941    update_privilege_revokes(
5942        SystemObjectId::System,
5943        scx.catalog.get_system_privileges(),
5944        &role_ids,
5945        &mut privilege_revokes,
5946    );
5947
5948    for (default_privilege_object, default_privilege_acl_items) in
5949        scx.catalog.get_default_privileges()
5950    {
5951        for default_privilege_acl_item in default_privilege_acl_items {
5952            if role_ids.contains(&default_privilege_object.role_id)
5953                || role_ids.contains(&default_privilege_acl_item.grantee)
5954            {
5955                default_privilege_revokes.push((
5956                    default_privilege_object.clone(),
5957                    default_privilege_acl_item.clone(),
5958                ));
5959            }
5960        }
5961    }
5962
5963    let drop_ids = scx.catalog.object_dependents(&drop_ids);
5964
5965    let system_ids: Vec<_> = drop_ids.iter().filter(|id| id.is_system()).collect();
5966    if !system_ids.is_empty() {
5967        let mut owners = system_ids
5968            .into_iter()
5969            .filter_map(|object_id| scx.catalog.get_owner_id(object_id))
5970            .collect::<BTreeSet<_>>()
5971            .into_iter()
5972            .map(|role_id| scx.catalog.get_role(&role_id).name().quoted());
5973        sql_bail!(
5974            "cannot drop objects owned by role {} because they are required by the database system",
5975            owners.join(", "),
5976        );
5977    }
5978
5979    Ok(Plan::DropOwned(DropOwnedPlan {
5980        role_ids: role_ids.into_iter().collect(),
5981        drop_ids,
5982        privilege_revokes,
5983        default_privilege_revokes,
5984    }))
5985}
5986
5987fn plan_retain_history_option(
5988    scx: &StatementContext,
5989    retain_history: Option<OptionalDuration>,
5990) -> Result<Option<CompactionWindow>, PlanError> {
5991    if let Some(OptionalDuration(lcw)) = retain_history {
5992        Ok(Some(plan_retain_history(scx, lcw)?))
5993    } else {
5994        Ok(None)
5995    }
5996}
5997
5998// Convert a specified RETAIN HISTORY option into a compaction window. `None` corresponds to
5999// `DisableCompaction`. A zero duration will error. This is because the `OptionalDuration` type
6000// already converts the zero duration into `None`. This function must not be called in the `RESET
6001// (RETAIN HISTORY)` path, which should be handled by the outer `Option<OptionalDuration>` being
6002// `None`.
6003fn plan_retain_history(
6004    scx: &StatementContext,
6005    lcw: Option<Duration>,
6006) -> Result<CompactionWindow, PlanError> {
6007    scx.require_feature_flag(&vars::ENABLE_LOGICAL_COMPACTION_WINDOW)?;
6008    match lcw {
6009        // A zero duration has already been converted to `None` by `OptionalDuration` (and means
6010        // disable compaction), and should never occur here. Furthermore, some things actually do
6011        // break when this is set to real zero:
6012        // https://github.com/MaterializeInc/database-issues/issues/3798.
6013        Some(Duration::ZERO) => Err(PlanError::InvalidOptionValue {
6014            option_name: "RETAIN HISTORY".to_string(),
6015            err: Box::new(PlanError::Unstructured(
6016                "internal error: unexpectedly zero".to_string(),
6017            )),
6018        }),
6019        Some(duration) => {
6020            // Error if the duration is low and enable_unlimited_retain_history is not set (which
6021            // should only be possible during testing).
6022            if duration < DEFAULT_LOGICAL_COMPACTION_WINDOW_DURATION
6023                && scx
6024                    .require_feature_flag(&vars::ENABLE_UNLIMITED_RETAIN_HISTORY)
6025                    .is_err()
6026            {
6027                return Err(PlanError::RetainHistoryLow {
6028                    limit: DEFAULT_LOGICAL_COMPACTION_WINDOW_DURATION,
6029                });
6030            }
6031            Ok(duration.try_into()?)
6032        }
6033        // In the past `RETAIN HISTORY FOR '0'` meant disable compaction. Disabling compaction seems
6034        // to be a bad choice, so prevent it.
6035        None => {
6036            if scx
6037                .require_feature_flag(&vars::ENABLE_UNLIMITED_RETAIN_HISTORY)
6038                .is_err()
6039            {
6040                Err(PlanError::RetainHistoryRequired)
6041            } else {
6042                Ok(CompactionWindow::DisableCompaction)
6043            }
6044        }
6045    }
6046}
6047
6048generate_extracted_config!(IndexOption, (RetainHistory, OptionalDuration));
6049
6050fn plan_index_options(
6051    scx: &StatementContext,
6052    with_opts: Vec<IndexOption<Aug>>,
6053) -> Result<Vec<crate::plan::IndexOption>, PlanError> {
6054    if !with_opts.is_empty() {
6055        // Index options are not durable.
6056        scx.require_feature_flag(&vars::ENABLE_INDEX_OPTIONS)?;
6057    }
6058
6059    let IndexOptionExtracted { retain_history, .. }: IndexOptionExtracted = with_opts.try_into()?;
6060
6061    let mut out = Vec::with_capacity(1);
6062    if let Some(cw) = plan_retain_history_option(scx, retain_history)? {
6063        out.push(crate::plan::IndexOption::RetainHistory(cw));
6064    }
6065    Ok(out)
6066}
6067
6068generate_extracted_config!(
6069    TableOption,
6070    (PartitionBy, Vec<Ident>),
6071    (RetainHistory, OptionalDuration),
6072    (RedactedTest, String)
6073);
6074
6075fn plan_table_options(
6076    scx: &StatementContext,
6077    desc: &RelationDesc,
6078    with_opts: Vec<TableOption<Aug>>,
6079) -> Result<Vec<crate::plan::TableOption>, PlanError> {
6080    let TableOptionExtracted {
6081        partition_by,
6082        retain_history,
6083        redacted_test,
6084        ..
6085    }: TableOptionExtracted = with_opts.try_into()?;
6086
6087    if let Some(partition_by) = partition_by {
6088        scx.require_feature_flag(&ENABLE_COLLECTION_PARTITION_BY)?;
6089        check_partition_by(desc, partition_by)?;
6090    }
6091
6092    if redacted_test.is_some() {
6093        scx.require_feature_flag(&vars::ENABLE_REDACTED_TEST_OPTION)?;
6094    }
6095
6096    let mut out = Vec::with_capacity(1);
6097    if let Some(cw) = plan_retain_history_option(scx, retain_history)? {
6098        out.push(crate::plan::TableOption::RetainHistory(cw));
6099    }
6100    Ok(out)
6101}
6102
6103pub fn plan_alter_index_options(
6104    scx: &mut StatementContext,
6105    AlterIndexStatement {
6106        index_name,
6107        if_exists,
6108        action,
6109    }: AlterIndexStatement<Aug>,
6110) -> Result<Plan, PlanError> {
6111    let object_type = ObjectType::Index;
6112    match action {
6113        AlterIndexAction::ResetOptions(options) => {
6114            let mut options = options.into_iter();
6115            if let Some(opt) = options.next() {
6116                match opt {
6117                    IndexOptionName::RetainHistory => {
6118                        if options.next().is_some() {
6119                            sql_bail!("RETAIN HISTORY must be only option");
6120                        }
6121                        return alter_retain_history(
6122                            scx,
6123                            object_type,
6124                            if_exists,
6125                            UnresolvedObjectName::Item(index_name),
6126                            None,
6127                        );
6128                    }
6129                }
6130            }
6131            sql_bail!("expected option");
6132        }
6133        AlterIndexAction::SetOptions(options) => {
6134            let mut options = options.into_iter();
6135            if let Some(opt) = options.next() {
6136                match opt.name {
6137                    IndexOptionName::RetainHistory => {
6138                        if options.next().is_some() {
6139                            sql_bail!("RETAIN HISTORY must be only option");
6140                        }
6141                        return alter_retain_history(
6142                            scx,
6143                            object_type,
6144                            if_exists,
6145                            UnresolvedObjectName::Item(index_name),
6146                            opt.value,
6147                        );
6148                    }
6149                }
6150            }
6151            sql_bail!("expected option");
6152        }
6153    }
6154}
6155
6156pub fn describe_alter_cluster_set_options(
6157    _: &StatementContext,
6158    _: AlterClusterStatement<Aug>,
6159) -> Result<StatementDesc, PlanError> {
6160    Ok(StatementDesc::new(None))
6161}
6162
6163pub fn plan_alter_cluster(
6164    scx: &mut StatementContext,
6165    AlterClusterStatement {
6166        name,
6167        action,
6168        if_exists,
6169    }: AlterClusterStatement<Aug>,
6170) -> Result<Plan, PlanError> {
6171    let cluster = match resolve_cluster(scx, &name, if_exists)? {
6172        Some(entry) => entry,
6173        None => {
6174            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6175                name: name.to_ast_string_simple(),
6176                object_type: ObjectType::Cluster,
6177            });
6178
6179            return Ok(Plan::AlterNoop(AlterNoopPlan {
6180                object_type: ObjectType::Cluster,
6181            }));
6182        }
6183    };
6184
6185    let mut options: PlanClusterOption = Default::default();
6186    let mut alter_strategy: AlterClusterPlanStrategy = AlterClusterPlanStrategy::None;
6187
6188    match action {
6189        AlterClusterAction::SetOptions {
6190            options: set_options,
6191            with_options,
6192        } => {
6193            let ClusterOptionExtracted {
6194                availability_zones,
6195                introspection_debugging,
6196                introspection_interval,
6197                managed,
6198                replicas: replica_defs,
6199                replication_factor,
6200                seen: _,
6201                size,
6202                disk,
6203                schedule,
6204                workload_class,
6205            }: ClusterOptionExtracted = set_options.try_into()?;
6206
6207            if !scx.catalog.active_role_id().is_system() {
6208                if workload_class.is_some() {
6209                    sql_bail!("WORKLOAD CLASS not supported for non-system users");
6210                }
6211            }
6212
6213            match managed.unwrap_or_else(|| cluster.is_managed()) {
6214                true => {
6215                    let alter_strategy_extracted =
6216                        ClusterAlterOptionExtracted::try_from(with_options)?;
6217                    alter_strategy = AlterClusterPlanStrategy::try_from(alter_strategy_extracted)?;
6218
6219                    match alter_strategy {
6220                        AlterClusterPlanStrategy::None => {}
6221                        _ => {
6222                            scx.require_feature_flag(
6223                                &crate::session::vars::ENABLE_ZERO_DOWNTIME_CLUSTER_RECONFIGURATION,
6224                            )?;
6225                        }
6226                    }
6227
6228                    if replica_defs.is_some() {
6229                        sql_bail!("REPLICAS not supported for managed clusters");
6230                    }
6231                    if schedule.is_some()
6232                        && !matches!(schedule, Some(ClusterScheduleOptionValue::Manual))
6233                    {
6234                        scx.require_feature_flag(&ENABLE_CLUSTER_SCHEDULE_REFRESH)?;
6235
6236                        // A cluster with a non-MANUAL schedule is automatically turned On/Off by
6237                        // the cluster scheduling policy, which means its replication factor is
6238                        // always 0 or 1. If the cluster currently has a higher replication factor
6239                        // and the user is not lowering it in the same statement (which would be
6240                        // rejected just below), then reject the schedule change: otherwise we'd
6241                        // leave the cluster in an invalid state with both a non-MANUAL schedule and
6242                        // a replication factor > 1 (which would, e.g., make SHOW CREATE CLUSTER
6243                        // panic).
6244                        if replication_factor.is_none()
6245                            && cluster.replication_factor().is_some_and(|rf| rf > 1)
6246                        {
6247                            sql_bail!(
6248                                "SCHEDULE cannot be set to anything other than MANUAL while the \
6249                                cluster's REPLICATION FACTOR is greater than 1; \
6250                                set the REPLICATION FACTOR to 1 first"
6251                            );
6252                        }
6253                    }
6254
6255                    if replication_factor.is_some() {
6256                        if schedule.is_some()
6257                            && !matches!(schedule, Some(ClusterScheduleOptionValue::Manual))
6258                        {
6259                            sql_bail!(
6260                                "REPLICATION FACTOR cannot be given together with any SCHEDULE other than MANUAL"
6261                            );
6262                        }
6263                        if let Some(current_schedule) = cluster.schedule() {
6264                            if !matches!(current_schedule, ClusterSchedule::Manual) {
6265                                sql_bail!(
6266                                    "REPLICATION FACTOR cannot be set if the cluster SCHEDULE is anything other than MANUAL"
6267                                );
6268                            }
6269                        }
6270                    }
6271                }
6272                false => {
6273                    if !with_options.is_empty() {
6274                        sql_bail!("ALTER... WITH not supported for unmanaged clusters");
6275                    }
6276                    if availability_zones.is_some() {
6277                        sql_bail!("AVAILABILITY ZONES not supported for unmanaged clusters");
6278                    }
6279                    if replication_factor.is_some() {
6280                        sql_bail!("REPLICATION FACTOR not supported for unmanaged clusters");
6281                    }
6282                    if introspection_debugging.is_some() {
6283                        sql_bail!("INTROSPECTION DEBUGGING not supported for unmanaged clusters");
6284                    }
6285                    if introspection_interval.is_some() {
6286                        sql_bail!("INTROSPECTION INTERVAL not supported for unmanaged clusters");
6287                    }
6288                    if size.is_some() {
6289                        sql_bail!("SIZE not supported for unmanaged clusters");
6290                    }
6291                    if disk.is_some() {
6292                        sql_bail!("DISK not supported for unmanaged clusters");
6293                    }
6294                    if schedule.is_some()
6295                        && !matches!(schedule, Some(ClusterScheduleOptionValue::Manual))
6296                    {
6297                        sql_bail!(
6298                            "cluster schedules other than MANUAL are not supported for unmanaged clusters"
6299                        );
6300                    }
6301                    if let Some(current_schedule) = cluster.schedule() {
6302                        if !matches!(current_schedule, ClusterSchedule::Manual)
6303                            && schedule.is_none()
6304                        {
6305                            sql_bail!(
6306                                "when switching a cluster to unmanaged, if the managed \
6307                                cluster's SCHEDULE is anything other than MANUAL, you have to \
6308                                explicitly set the SCHEDULE to MANUAL"
6309                            );
6310                        }
6311                    }
6312                }
6313            }
6314
6315            let mut replicas = vec![];
6316            for ReplicaDefinition { name, options } in
6317                replica_defs.into_iter().flat_map(Vec::into_iter)
6318            {
6319                replicas.push((normalize::ident(name), plan_replica_config(scx, options)?));
6320            }
6321
6322            if let Some(managed) = managed {
6323                options.managed = AlterOptionParameter::Set(managed);
6324            }
6325            if let Some(replication_factor) = replication_factor {
6326                options.replication_factor = AlterOptionParameter::Set(replication_factor);
6327            }
6328            if let Some(size) = &size {
6329                options.size = AlterOptionParameter::Set(size.clone());
6330            }
6331            if let Some(availability_zones) = availability_zones {
6332                options.availability_zones = AlterOptionParameter::Set(availability_zones);
6333            }
6334            if let Some(introspection_debugging) = introspection_debugging {
6335                options.introspection_debugging =
6336                    AlterOptionParameter::Set(introspection_debugging);
6337            }
6338            if let Some(introspection_interval) = introspection_interval {
6339                options.introspection_interval = AlterOptionParameter::Set(introspection_interval);
6340            }
6341            if disk.is_some() {
6342                // The `DISK` option is a no-op for legacy cluster sizes and was never allowed for
6343                // `cc` sizes. The long term plan is to phase out the legacy sizes, at which point
6344                // we'll be able to remove the `DISK` option entirely.
6345                let size = match size.as_deref() {
6346                    Some(s) => s,
6347                    None => cluster
6348                        .managed_size()
6349                        .ok_or_else(|| sql_err!("cluster is not managed"))?,
6350                };
6351                if scx.catalog.is_cluster_size_cc(size) {
6352                    sql_bail!(
6353                        "DISK option not supported for modern cluster sizes because disk is always enabled"
6354                    );
6355                }
6356
6357                scx.catalog
6358                    .add_notice(PlanNotice::ReplicaDiskOptionDeprecated);
6359            }
6360            if !replicas.is_empty() {
6361                options.replicas = AlterOptionParameter::Set(replicas);
6362            }
6363            if let Some(schedule) = schedule {
6364                options.schedule = AlterOptionParameter::Set(plan_cluster_schedule(schedule)?);
6365            }
6366            if let Some(workload_class) = workload_class {
6367                options.workload_class = AlterOptionParameter::Set(workload_class.0);
6368            }
6369        }
6370        AlterClusterAction::ResetOptions(reset_options) => {
6371            use AlterOptionParameter::Reset;
6372            use ClusterOptionName::*;
6373
6374            if !scx.catalog.active_role_id().is_system() {
6375                if reset_options.contains(&WorkloadClass) {
6376                    sql_bail!("WORKLOAD CLASS not supported for non-system users");
6377                }
6378            }
6379
6380            for option in reset_options {
6381                match option {
6382                    AvailabilityZones => options.availability_zones = Reset,
6383                    Disk => scx
6384                        .catalog
6385                        .add_notice(PlanNotice::ReplicaDiskOptionDeprecated),
6386                    IntrospectionInterval => options.introspection_interval = Reset,
6387                    IntrospectionDebugging => options.introspection_debugging = Reset,
6388                    Managed => options.managed = Reset,
6389                    Replicas => options.replicas = Reset,
6390                    ReplicationFactor => options.replication_factor = Reset,
6391                    Size => options.size = Reset,
6392                    Schedule => options.schedule = Reset,
6393                    WorkloadClass => options.workload_class = Reset,
6394                }
6395            }
6396        }
6397    }
6398    Ok(Plan::AlterCluster(AlterClusterPlan {
6399        id: cluster.id(),
6400        name: cluster.name().to_string(),
6401        options,
6402        strategy: alter_strategy,
6403    }))
6404}
6405
6406pub fn describe_alter_set_cluster(
6407    _: &StatementContext,
6408    _: AlterSetClusterStatement<Aug>,
6409) -> Result<StatementDesc, PlanError> {
6410    Ok(StatementDesc::new(None))
6411}
6412
6413pub fn plan_alter_item_set_cluster(
6414    scx: &StatementContext,
6415    AlterSetClusterStatement {
6416        if_exists,
6417        set_cluster: in_cluster_name,
6418        name,
6419        object_type,
6420    }: AlterSetClusterStatement<Aug>,
6421) -> Result<Plan, PlanError> {
6422    scx.require_feature_flag(&vars::ENABLE_ALTER_SET_CLUSTER)?;
6423
6424    let object_type = object_type.into();
6425
6426    // Prevent access to `SET CLUSTER` for unsupported objects.
6427    match object_type {
6428        ObjectType::MaterializedView => {}
6429        ObjectType::Index | ObjectType::Sink | ObjectType::Source => {
6430            bail_unsupported!(29606, format!("ALTER {object_type} SET CLUSTER"))
6431        }
6432        ObjectType::Table
6433        | ObjectType::View
6434        | ObjectType::Type
6435        | ObjectType::Role
6436        | ObjectType::Cluster
6437        | ObjectType::ClusterReplica
6438        | ObjectType::Secret
6439        | ObjectType::Connection
6440        | ObjectType::Database
6441        | ObjectType::Schema
6442        | ObjectType::Func
6443        | ObjectType::NetworkPolicy => {
6444            bail_never_supported!(
6445                format!("ALTER {object_type} SET CLUSTER"),
6446                "sql/alter-set-cluster/",
6447                format!("{object_type} has no associated cluster")
6448            )
6449        }
6450    }
6451
6452    let in_cluster = scx.catalog.get_cluster(in_cluster_name.id);
6453
6454    match resolve_item_or_type(scx, object_type, name.clone(), if_exists)? {
6455        Some(entry) => {
6456            let current_cluster = entry.cluster_id();
6457            let Some(current_cluster) = current_cluster else {
6458                sql_bail!("No cluster associated with {name}");
6459            };
6460
6461            if current_cluster == in_cluster.id() {
6462                Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6463            } else {
6464                Ok(Plan::AlterSetCluster(AlterSetClusterPlan {
6465                    id: entry.id(),
6466                    set_cluster: in_cluster.id(),
6467                }))
6468            }
6469        }
6470        None => {
6471            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6472                name: name.to_ast_string_simple(),
6473                object_type,
6474            });
6475
6476            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6477        }
6478    }
6479}
6480
6481pub fn describe_alter_object_rename(
6482    _: &StatementContext,
6483    _: AlterObjectRenameStatement,
6484) -> Result<StatementDesc, PlanError> {
6485    Ok(StatementDesc::new(None))
6486}
6487
6488pub fn plan_alter_object_rename(
6489    scx: &mut StatementContext,
6490    AlterObjectRenameStatement {
6491        name,
6492        object_type,
6493        to_item_name,
6494        if_exists,
6495    }: AlterObjectRenameStatement,
6496) -> Result<Plan, PlanError> {
6497    let object_type = object_type.into();
6498    match (object_type, name) {
6499        (
6500            ObjectType::View
6501            | ObjectType::MaterializedView
6502            | ObjectType::Table
6503            | ObjectType::Source
6504            | ObjectType::Index
6505            | ObjectType::Sink
6506            | ObjectType::Secret
6507            | ObjectType::Connection,
6508            UnresolvedObjectName::Item(name),
6509        ) => plan_alter_item_rename(scx, object_type, name, to_item_name, if_exists),
6510        (ObjectType::Cluster, UnresolvedObjectName::Cluster(name)) => {
6511            plan_alter_cluster_rename(scx, object_type, name, to_item_name, if_exists)
6512        }
6513        (ObjectType::ClusterReplica, UnresolvedObjectName::ClusterReplica(name)) => {
6514            plan_alter_cluster_replica_rename(scx, object_type, name, to_item_name, if_exists)
6515        }
6516        (ObjectType::Schema, UnresolvedObjectName::Schema(name)) => {
6517            plan_alter_schema_rename(scx, name, to_item_name, if_exists)
6518        }
6519        (object_type, name) => {
6520            // The earlier dispatch + name resolution should make this
6521            // combination impossible.
6522            bail_internal!("invalid object type '{object_type}' for ALTER RENAME with name {name}")
6523        }
6524    }
6525}
6526
6527pub fn plan_alter_schema_rename(
6528    scx: &mut StatementContext,
6529    name: UnresolvedSchemaName,
6530    to_schema_name: Ident,
6531    if_exists: bool,
6532) -> Result<Plan, PlanError> {
6533    // Special case for mz_temp: with lazy temporary schema creation, the temp
6534    // schema may not exist yet, but we still need to return the correct error.
6535    // Check the schema name directly against MZ_TEMP_SCHEMA.
6536    let normalized = normalize::unresolved_schema_name(name.clone())?;
6537    if normalized.database.is_none() && normalized.schema == mz_repr::namespaces::MZ_TEMP_SCHEMA {
6538        sql_bail!(
6539            "cannot rename schemas in the ambient database: {:?}",
6540            mz_repr::namespaces::MZ_TEMP_SCHEMA
6541        );
6542    }
6543
6544    let Some((db_spec, schema_spec)) = resolve_schema(scx, name.clone(), if_exists)? else {
6545        let object_type = ObjectType::Schema;
6546        scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6547            name: name.to_ast_string_simple(),
6548            object_type,
6549        });
6550        return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
6551    };
6552
6553    // Make sure the name is unique.
6554    if scx
6555        .resolve_schema_in_database(&db_spec, &to_schema_name)
6556        .is_ok()
6557    {
6558        return Err(PlanError::Catalog(CatalogError::SchemaAlreadyExists(
6559            to_schema_name.clone().into_string(),
6560        )));
6561    }
6562
6563    // Prevent users from renaming system related schemas.
6564    let schema = scx.catalog.get_schema(&db_spec, &schema_spec);
6565    if schema.id().is_system() {
6566        bail_never_supported!(format!("renaming the {} schema", schema.name().schema))
6567    }
6568
6569    Ok(Plan::AlterSchemaRename(AlterSchemaRenamePlan {
6570        cur_schema_spec: (db_spec, schema_spec),
6571        new_schema_name: to_schema_name.into_string(),
6572    }))
6573}
6574
6575pub fn plan_alter_schema_swap<F>(
6576    scx: &mut StatementContext,
6577    name_a: UnresolvedSchemaName,
6578    name_b: Ident,
6579    if_exists: bool,
6580    gen_temp_suffix: F,
6581) -> Result<Plan, PlanError>
6582where
6583    F: Fn(&dyn Fn(&str) -> bool) -> Result<String, PlanError>,
6584{
6585    // Special case for mz_temp: with lazy temporary schema creation, the temp
6586    // schema may not exist yet, but we still need to return the correct error.
6587    // Check the schema name directly against MZ_TEMP_SCHEMA.
6588    let normalized_a = normalize::unresolved_schema_name(name_a.clone())?;
6589    if normalized_a.database.is_none() && normalized_a.schema == mz_repr::namespaces::MZ_TEMP_SCHEMA
6590    {
6591        sql_bail!("cannot swap schemas that are in the ambient database");
6592    }
6593    // Also check name_b (the target schema name)
6594    let name_b_str = normalize::ident_ref(&name_b);
6595    if name_b_str == mz_repr::namespaces::MZ_TEMP_SCHEMA {
6596        sql_bail!("cannot swap schemas that are in the ambient database");
6597    }
6598
6599    let schema_a = match scx.resolve_schema(name_a.clone()) {
6600        Ok(schema) => schema,
6601        Err(_) if if_exists => {
6602            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6603                name: name_a.to_ast_string_simple(),
6604                object_type: ObjectType::Schema,
6605            });
6606            return Ok(Plan::AlterNoop(AlterNoopPlan {
6607                object_type: ObjectType::Schema,
6608            }));
6609        }
6610        Err(e) => return Err(e),
6611    };
6612
6613    let db_spec = schema_a.database().clone();
6614    if matches!(db_spec, ResolvedDatabaseSpecifier::Ambient) {
6615        sql_bail!("cannot swap schemas that are in the ambient database");
6616    };
6617    let schema_b = scx.resolve_schema_in_database(&db_spec, &name_b)?;
6618
6619    // We cannot swap system schemas.
6620    if schema_a.id().is_system() || schema_b.id().is_system() {
6621        bail_never_supported!("swapping a system schema".to_string())
6622    }
6623
6624    // Generate a temporary name we can swap schema_a to.
6625    //
6626    // 'check' returns if the temp schema name would be valid.
6627    const SCHEMA_SWAP_PREFIX: &str = "mz_schema_swap_";
6628    let check = |temp_suffix: &str| {
6629        let mut temp_name = ident!(SCHEMA_SWAP_PREFIX);
6630        temp_name.append_lossy(temp_suffix);
6631        scx.resolve_schema_in_database(&db_spec, &temp_name)
6632            .is_err()
6633    };
6634    let temp_suffix = gen_temp_suffix(&check)?;
6635    let name_temp = format!("{SCHEMA_SWAP_PREFIX}{temp_suffix}");
6636
6637    Ok(Plan::AlterSchemaSwap(AlterSchemaSwapPlan {
6638        schema_a_spec: (*schema_a.database(), *schema_a.id()),
6639        schema_a_name: schema_a.name().schema.to_string(),
6640        schema_b_spec: (*schema_b.database(), *schema_b.id()),
6641        schema_b_name: schema_b.name().schema.to_string(),
6642        name_temp,
6643    }))
6644}
6645
6646pub fn plan_alter_item_rename(
6647    scx: &mut StatementContext,
6648    object_type: ObjectType,
6649    name: UnresolvedItemName,
6650    to_item_name: Ident,
6651    if_exists: bool,
6652) -> Result<Plan, PlanError> {
6653    let resolved = match resolve_item_or_type(scx, object_type, name.clone(), if_exists) {
6654        Ok(r) => r,
6655        // Return a more helpful error on `DROP VIEW <materialized-view>`.
6656        Err(PlanError::MismatchedObjectType {
6657            name,
6658            is_type: ObjectType::MaterializedView,
6659            expected_type: ObjectType::View,
6660        }) => {
6661            return Err(PlanError::AlterViewOnMaterializedView(name.to_string()));
6662        }
6663        e => e?,
6664    };
6665
6666    match resolved {
6667        Some(entry) => {
6668            let full_name = scx.catalog.resolve_full_name(entry.name());
6669            let item_type = entry.item_type();
6670
6671            let proposed_name = QualifiedItemName {
6672                qualifiers: entry.name().qualifiers.clone(),
6673                item: to_item_name.clone().into_string(),
6674            };
6675
6676            // For PostgreSQL compatibility, items and types cannot have
6677            // overlapping names in a variety of situations. See the comment on
6678            // `CatalogItemType::conflicts_with_type` for details.
6679            let conflicting_type_exists;
6680            let conflicting_item_exists;
6681            if item_type == CatalogItemType::Type {
6682                conflicting_type_exists = scx.catalog.get_type_by_name(&proposed_name).is_some();
6683                conflicting_item_exists = scx
6684                    .catalog
6685                    .get_item_by_name(&proposed_name)
6686                    .map(|item| item.item_type().conflicts_with_type())
6687                    .unwrap_or(false);
6688            } else {
6689                conflicting_type_exists = item_type.conflicts_with_type()
6690                    && scx.catalog.get_type_by_name(&proposed_name).is_some();
6691                conflicting_item_exists = scx.catalog.get_item_by_name(&proposed_name).is_some();
6692            };
6693            if conflicting_type_exists || conflicting_item_exists {
6694                sql_bail!("catalog item '{}' already exists", to_item_name);
6695            }
6696
6697            Ok(Plan::AlterItemRename(AlterItemRenamePlan {
6698                id: entry.id(),
6699                current_full_name: full_name,
6700                to_name: normalize::ident(to_item_name),
6701                object_type,
6702            }))
6703        }
6704        None => {
6705            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6706                name: name.to_ast_string_simple(),
6707                object_type,
6708            });
6709
6710            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6711        }
6712    }
6713}
6714
6715pub fn plan_alter_cluster_rename(
6716    scx: &mut StatementContext,
6717    object_type: ObjectType,
6718    name: Ident,
6719    to_name: Ident,
6720    if_exists: bool,
6721) -> Result<Plan, PlanError> {
6722    match resolve_cluster(scx, &name, if_exists)? {
6723        Some(entry) => Ok(Plan::AlterClusterRename(AlterClusterRenamePlan {
6724            id: entry.id(),
6725            name: entry.name().to_string(),
6726            to_name: ident(to_name),
6727        })),
6728        None => {
6729            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6730                name: name.to_ast_string_simple(),
6731                object_type,
6732            });
6733
6734            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6735        }
6736    }
6737}
6738
6739pub fn plan_alter_cluster_swap<F>(
6740    scx: &mut StatementContext,
6741    name_a: Ident,
6742    name_b: Ident,
6743    if_exists: bool,
6744    gen_temp_suffix: F,
6745) -> Result<Plan, PlanError>
6746where
6747    F: Fn(&dyn Fn(&str) -> bool) -> Result<String, PlanError>,
6748{
6749    let cluster_a = match scx.resolve_cluster(Some(&name_a)) {
6750        Ok(cluster) => cluster,
6751        Err(_) if if_exists => {
6752            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6753                name: name_a.to_ast_string_simple(),
6754                object_type: ObjectType::Cluster,
6755            });
6756            return Ok(Plan::AlterNoop(AlterNoopPlan {
6757                object_type: ObjectType::Cluster,
6758            }));
6759        }
6760        Err(e) => return Err(e),
6761    };
6762    let cluster_b = scx.resolve_cluster(Some(&name_b))?;
6763
6764    const CLUSTER_SWAP_PREFIX: &str = "mz_cluster_swap_";
6765    let check = |temp_suffix: &str| {
6766        let mut temp_name = ident!(CLUSTER_SWAP_PREFIX);
6767        temp_name.append_lossy(temp_suffix);
6768        match scx.catalog.resolve_cluster(Some(temp_name.as_str())) {
6769            // Temp name does not exist, so we can use it.
6770            Err(CatalogError::UnknownCluster(_)) => true,
6771            // Temp name already exists!
6772            Ok(_) | Err(_) => false,
6773        }
6774    };
6775    let temp_suffix = gen_temp_suffix(&check)?;
6776    let name_temp = format!("{CLUSTER_SWAP_PREFIX}{temp_suffix}");
6777
6778    Ok(Plan::AlterClusterSwap(AlterClusterSwapPlan {
6779        id_a: cluster_a.id(),
6780        id_b: cluster_b.id(),
6781        name_a: name_a.into_string(),
6782        name_b: name_b.into_string(),
6783        name_temp,
6784    }))
6785}
6786
6787pub fn plan_alter_cluster_replica_rename(
6788    scx: &mut StatementContext,
6789    object_type: ObjectType,
6790    name: QualifiedReplica,
6791    to_item_name: Ident,
6792    if_exists: bool,
6793) -> Result<Plan, PlanError> {
6794    match resolve_cluster_replica(scx, &name, if_exists)? {
6795        Some((cluster, replica)) => {
6796            ensure_cluster_is_not_managed(scx, cluster.id())?;
6797            Ok(Plan::AlterClusterReplicaRename(
6798                AlterClusterReplicaRenamePlan {
6799                    cluster_id: cluster.id(),
6800                    replica_id: replica,
6801                    name: QualifiedReplica {
6802                        cluster: Ident::new(cluster.name())?,
6803                        replica: name.replica,
6804                    },
6805                    to_name: normalize::ident(to_item_name),
6806                },
6807            ))
6808        }
6809        None => {
6810            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6811                name: name.to_ast_string_simple(),
6812                object_type,
6813            });
6814
6815            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6816        }
6817    }
6818}
6819
6820pub fn describe_alter_object_swap(
6821    _: &StatementContext,
6822    _: AlterObjectSwapStatement,
6823) -> Result<StatementDesc, PlanError> {
6824    Ok(StatementDesc::new(None))
6825}
6826
6827pub fn plan_alter_object_swap(
6828    scx: &mut StatementContext,
6829    stmt: AlterObjectSwapStatement,
6830) -> Result<Plan, PlanError> {
6831    scx.require_feature_flag(&vars::ENABLE_ALTER_SWAP)?;
6832
6833    let AlterObjectSwapStatement {
6834        object_type,
6835        if_exists,
6836        name_a,
6837        name_b,
6838    } = stmt;
6839    let object_type = object_type.into();
6840
6841    // We'll try 10 times to generate a temporary suffix.
6842    let gen_temp_suffix = |check_fn: &dyn Fn(&str) -> bool| {
6843        let mut attempts = 0;
6844        let name_temp = loop {
6845            attempts += 1;
6846            if attempts > 10 {
6847                tracing::warn!("Unable to generate temp id for swapping");
6848                sql_bail!("unable to swap!");
6849            }
6850
6851            // Call the provided closure to make sure this name is unique!
6852            let short_id = mz_ore::id_gen::temp_id();
6853            if check_fn(&short_id) {
6854                break short_id;
6855            }
6856        };
6857
6858        Ok(name_temp)
6859    };
6860
6861    match (object_type, name_a, name_b) {
6862        (ObjectType::Schema, UnresolvedObjectName::Schema(name_a), name_b) => {
6863            plan_alter_schema_swap(scx, name_a, name_b, if_exists, gen_temp_suffix)
6864        }
6865        (ObjectType::Cluster, UnresolvedObjectName::Cluster(name_a), name_b) => {
6866            plan_alter_cluster_swap(scx, name_a, name_b, if_exists, gen_temp_suffix)
6867        }
6868        (ObjectType::Schema | ObjectType::Cluster, _, _) => {
6869            bail_internal!("name type does not match object type for ALTER SWAP")
6870        }
6871        (
6872            ObjectType::Table
6873            | ObjectType::View
6874            | ObjectType::MaterializedView
6875            | ObjectType::Source
6876            | ObjectType::Sink
6877            | ObjectType::Index
6878            | ObjectType::Type
6879            | ObjectType::Role
6880            | ObjectType::ClusterReplica
6881            | ObjectType::Secret
6882            | ObjectType::Connection
6883            | ObjectType::Database
6884            | ObjectType::Func
6885            | ObjectType::NetworkPolicy,
6886            _,
6887            _,
6888        ) => Err(PlanError::Unsupported {
6889            feature: format!("ALTER {object_type} .. SWAP WITH ..."),
6890            discussion_no: None,
6891        }),
6892    }
6893}
6894
6895pub fn describe_alter_retain_history(
6896    _: &StatementContext,
6897    _: AlterRetainHistoryStatement<Aug>,
6898) -> Result<StatementDesc, PlanError> {
6899    Ok(StatementDesc::new(None))
6900}
6901
6902pub fn plan_alter_retain_history(
6903    scx: &StatementContext,
6904    AlterRetainHistoryStatement {
6905        object_type,
6906        if_exists,
6907        name,
6908        history,
6909    }: AlterRetainHistoryStatement<Aug>,
6910) -> Result<Plan, PlanError> {
6911    alter_retain_history(scx, object_type.into(), if_exists, name, history)
6912}
6913
6914fn alter_retain_history(
6915    scx: &StatementContext,
6916    object_type: ObjectType,
6917    if_exists: bool,
6918    name: UnresolvedObjectName,
6919    history: Option<WithOptionValue<Aug>>,
6920) -> Result<Plan, PlanError> {
6921    let name = match (object_type, name) {
6922        (
6923            // View gets a special error below.
6924            ObjectType::View
6925            | ObjectType::MaterializedView
6926            | ObjectType::Table
6927            | ObjectType::Source
6928            | ObjectType::Index,
6929            UnresolvedObjectName::Item(name),
6930        ) => name,
6931        (object_type, _) => {
6932            bail_unsupported!(format!("RETAIN HISTORY on {object_type}"))
6933        }
6934    };
6935    match resolve_item_or_type(scx, object_type, name.clone(), if_exists)? {
6936        Some(entry) => {
6937            let full_name = scx.catalog.resolve_full_name(entry.name());
6938            let item_type = entry.item_type();
6939
6940            // Return a more helpful error on `ALTER VIEW <materialized-view>`.
6941            if object_type == ObjectType::View && item_type == CatalogItemType::MaterializedView {
6942                return Err(PlanError::AlterViewOnMaterializedView(
6943                    full_name.to_string(),
6944                ));
6945            } else if object_type == ObjectType::View {
6946                sql_bail!("{object_type} does not support RETAIN HISTORY")
6947            } else if object_type != item_type {
6948                sql_bail!(
6949                    "\"{}\" is a {} not a {}",
6950                    full_name,
6951                    entry.item_type(),
6952                    format!("{object_type}").to_lowercase()
6953                )
6954            }
6955
6956            // Save the original value so we can write it back down in the create_sql catalog item.
6957            let (value, lcw) = match &history {
6958                Some(WithOptionValue::RetainHistoryFor(value)) => {
6959                    let window = OptionalDuration::try_from_value(value.clone())?;
6960                    (Some(value.clone()), window.0)
6961                }
6962                // None is RESET, so use the default CW.
6963                None => (None, Some(DEFAULT_LOGICAL_COMPACTION_WINDOW_DURATION)),
6964                _ => sql_bail!("unexpected value type for RETAIN HISTORY"),
6965            };
6966            let window = plan_retain_history(scx, lcw)?;
6967
6968            Ok(Plan::AlterRetainHistory(AlterRetainHistoryPlan {
6969                id: entry.id(),
6970                value,
6971                window,
6972                object_type,
6973            }))
6974        }
6975        None => {
6976            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
6977                name: name.to_ast_string_simple(),
6978                object_type,
6979            });
6980
6981            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
6982        }
6983    }
6984}
6985
6986fn alter_source_timestamp_interval(
6987    scx: &StatementContext,
6988    if_exists: bool,
6989    source_name: UnresolvedItemName,
6990    value: Option<WithOptionValue<Aug>>,
6991) -> Result<Plan, PlanError> {
6992    let object_type = ObjectType::Source;
6993    match resolve_item_or_type(scx, object_type, source_name.clone(), if_exists)? {
6994        Some(entry) => {
6995            let full_name = scx.catalog.resolve_full_name(entry.name());
6996            if entry.item_type() != CatalogItemType::Source {
6997                sql_bail!(
6998                    "\"{}\" is a {} not a {}",
6999                    full_name,
7000                    entry.item_type(),
7001                    format!("{object_type}").to_lowercase()
7002                )
7003            }
7004
7005            match value {
7006                Some(val) => {
7007                    let val = match val {
7008                        WithOptionValue::Value(v) => v,
7009                        _ => sql_bail!("TIMESTAMP INTERVAL requires an interval value"),
7010                    };
7011                    let duration = Duration::try_from_value(val.clone())?;
7012
7013                    let min = scx.catalog.system_vars().min_timestamp_interval();
7014                    let max = scx.catalog.system_vars().max_timestamp_interval();
7015                    if duration < min || duration > max {
7016                        return Err(PlanError::InvalidTimestampInterval {
7017                            min,
7018                            max,
7019                            requested: duration,
7020                        });
7021                    }
7022
7023                    Ok(Plan::AlterSourceTimestampInterval(
7024                        AlterSourceTimestampIntervalPlan {
7025                            id: entry.id(),
7026                            value: Some(val),
7027                            interval: duration,
7028                        },
7029                    ))
7030                }
7031                None => {
7032                    let interval = scx.catalog.system_vars().default_timestamp_interval();
7033                    Ok(Plan::AlterSourceTimestampInterval(
7034                        AlterSourceTimestampIntervalPlan {
7035                            id: entry.id(),
7036                            value: None,
7037                            interval,
7038                        },
7039                    ))
7040                }
7041            }
7042        }
7043        None => {
7044            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7045                name: source_name.to_ast_string_simple(),
7046                object_type,
7047            });
7048
7049            Ok(Plan::AlterNoop(AlterNoopPlan { object_type }))
7050        }
7051    }
7052}
7053
7054pub fn describe_alter_secret_options(
7055    _: &StatementContext,
7056    _: AlterSecretStatement<Aug>,
7057) -> Result<StatementDesc, PlanError> {
7058    Ok(StatementDesc::new(None))
7059}
7060
7061pub fn plan_alter_secret(
7062    scx: &mut StatementContext,
7063    stmt: AlterSecretStatement<Aug>,
7064) -> Result<Plan, PlanError> {
7065    let AlterSecretStatement {
7066        name,
7067        if_exists,
7068        value,
7069    } = stmt;
7070    let object_type = ObjectType::Secret;
7071    let id = match resolve_item_or_type(scx, object_type, name.clone(), if_exists)? {
7072        Some(entry) => entry.id(),
7073        None => {
7074            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7075                name: name.to_string(),
7076                object_type,
7077            });
7078
7079            return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7080        }
7081    };
7082
7083    let secret_as = query::plan_secret_as(scx, value)?;
7084
7085    Ok(Plan::AlterSecret(AlterSecretPlan { id, secret_as }))
7086}
7087
7088pub fn describe_alter_connection(
7089    _: &StatementContext,
7090    _: AlterConnectionStatement<Aug>,
7091) -> Result<StatementDesc, PlanError> {
7092    Ok(StatementDesc::new(None))
7093}
7094
7095generate_extracted_config!(AlterConnectionOption, (Validate, bool));
7096
7097pub fn plan_alter_connection(
7098    scx: &StatementContext,
7099    stmt: AlterConnectionStatement<Aug>,
7100) -> Result<Plan, PlanError> {
7101    let AlterConnectionStatement {
7102        name,
7103        if_exists,
7104        actions,
7105        with_options,
7106    } = stmt;
7107    let conn_name = normalize::unresolved_item_name(name)?;
7108    let entry = match scx.catalog.resolve_item(&conn_name) {
7109        Ok(entry) => entry,
7110        Err(_) if if_exists => {
7111            scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7112                name: conn_name.to_string(),
7113                object_type: ObjectType::Connection,
7114            });
7115
7116            return Ok(Plan::AlterNoop(AlterNoopPlan {
7117                object_type: ObjectType::Connection,
7118            }));
7119        }
7120        Err(e) => return Err(e.into()),
7121    };
7122
7123    let connection = entry.connection()?;
7124
7125    if actions
7126        .iter()
7127        .any(|action| matches!(action, AlterConnectionAction::RotateKeys))
7128    {
7129        if actions.len() > 1 {
7130            sql_bail!("cannot specify any other actions alongside ALTER CONNECTION...ROTATE KEYS");
7131        }
7132
7133        if !with_options.is_empty() {
7134            sql_bail!(
7135                "ALTER CONNECTION...ROTATE KEYS does not support WITH ({})",
7136                with_options
7137                    .iter()
7138                    .map(|o| o.to_ast_string_simple())
7139                    .join(", ")
7140            );
7141        }
7142
7143        if !matches!(connection, Connection::Ssh(_)) {
7144            sql_bail!(
7145                "{} is not an SSH connection",
7146                scx.catalog.resolve_full_name(entry.name())
7147            )
7148        }
7149
7150        return Ok(Plan::AlterConnection(AlterConnectionPlan {
7151            id: entry.id(),
7152            action: crate::plan::AlterConnectionAction::RotateKeys,
7153        }));
7154    }
7155
7156    let options = AlterConnectionOptionExtracted::try_from(with_options)?;
7157    if options.validate.is_some() {
7158        scx.require_feature_flag(&vars::ENABLE_CONNECTION_VALIDATION_SYNTAX)?;
7159    }
7160
7161    let validate = match options.validate {
7162        Some(val) => val,
7163        None => {
7164            scx.catalog
7165                .system_vars()
7166                .enable_default_connection_validation()
7167                && connection.validate_by_default()
7168        }
7169    };
7170
7171    let connection_type = match connection {
7172        Connection::Aws(_) => CreateConnectionType::Aws,
7173        Connection::AwsPrivatelink(_) => CreateConnectionType::AwsPrivatelink,
7174        Connection::Gcp(_) => CreateConnectionType::Gcp,
7175        Connection::Kafka(_) => CreateConnectionType::Kafka,
7176        Connection::Csr(_) => CreateConnectionType::Csr,
7177        Connection::GlueSchemaRegistry(_) => CreateConnectionType::GlueSchemaRegistry,
7178        Connection::Postgres(_) => CreateConnectionType::Postgres,
7179        Connection::Ssh(_) => CreateConnectionType::Ssh,
7180        Connection::MySql(_) => CreateConnectionType::MySql,
7181        Connection::SqlServer(_) => CreateConnectionType::SqlServer,
7182        Connection::IcebergCatalog(_) => CreateConnectionType::IcebergCatalog,
7183    };
7184
7185    // Collect all options irrespective of action taken on them.
7186    let specified_options: BTreeSet<_> = actions
7187        .iter()
7188        .map(|action: &AlterConnectionAction<Aug>| match action {
7189            AlterConnectionAction::SetOption(option) => Ok(option.name.clone()),
7190            AlterConnectionAction::DropOption(name) => Ok(name.clone()),
7191            AlterConnectionAction::RotateKeys => {
7192                Err(internal_err!("RotateKeys is handled separately above"))
7193            }
7194        })
7195        .collect::<Result<_, PlanError>>()?;
7196
7197    for invalid in INALTERABLE_OPTIONS {
7198        if specified_options.contains(invalid) {
7199            sql_bail!("cannot ALTER {} option {}", connection_type, invalid);
7200        }
7201    }
7202
7203    connection::validate_options_per_connection_type(connection_type, specified_options)?;
7204
7205    // Partition operations into set and drop.
7206    let mut set_options_vec: Vec<_> = Vec::new();
7207    let mut drop_options: BTreeSet<_> = BTreeSet::new();
7208    for action in actions {
7209        match action {
7210            AlterConnectionAction::SetOption(option) => set_options_vec.push(option),
7211            AlterConnectionAction::DropOption(name) => {
7212                drop_options.insert(name);
7213            }
7214            AlterConnectionAction::RotateKeys => {
7215                bail_internal!("RotateKeys is handled separately above")
7216            }
7217        }
7218    }
7219
7220    let set_options: BTreeMap<_, _> = set_options_vec
7221        .clone()
7222        .into_iter()
7223        .map(|option| (option.name, option.value))
7224        .collect();
7225
7226    // Type check values + avoid duplicates; we don't want to e.g. let users
7227    // drop and set the same option in the same statement, so treating drops as
7228    // sets here is fine.
7229    let connection_options_extracted =
7230        connection::ConnectionOptionExtracted::try_from(set_options_vec)?;
7231
7232    let duplicates: Vec<_> = connection_options_extracted
7233        .seen
7234        .intersection(&drop_options)
7235        .collect();
7236
7237    if !duplicates.is_empty() {
7238        sql_bail!(
7239            "cannot both SET and DROP/RESET options {}",
7240            duplicates
7241                .iter()
7242                .map(|option| option.to_string())
7243                .join(", ")
7244        )
7245    }
7246
7247    for mutually_exclusive_options in MUTUALLY_EXCLUSIVE_SETS {
7248        let set_options_count = mutually_exclusive_options
7249            .iter()
7250            .filter(|o| set_options.contains_key(o))
7251            .count();
7252        let drop_options_count = mutually_exclusive_options
7253            .iter()
7254            .filter(|o| drop_options.contains(o))
7255            .count();
7256
7257        // Disallow setting _and_ resetting mutually exclusive options
7258        if set_options_count > 0 && drop_options_count > 0 {
7259            sql_bail!(
7260                "cannot both SET and DROP/RESET mutually exclusive {} options {}",
7261                connection_type,
7262                mutually_exclusive_options
7263                    .iter()
7264                    .map(|option| option.to_string())
7265                    .join(", ")
7266            )
7267        }
7268
7269        // If any option is either set or dropped, ensure all mutually exclusive
7270        // options are dropped. We do this "behind the scenes", even though we
7271        // disallow users from performing the same action because this is the
7272        // mechanism by which we overwrite values elsewhere in the code.
7273        if set_options_count > 0 || drop_options_count > 0 {
7274            drop_options.extend(mutually_exclusive_options.iter().cloned());
7275        }
7276
7277        // n.b. if mutually exclusive options are set, those will error when we
7278        // try to replan the connection.
7279    }
7280
7281    Ok(Plan::AlterConnection(AlterConnectionPlan {
7282        id: entry.id(),
7283        action: crate::plan::AlterConnectionAction::AlterOptions {
7284            set_options,
7285            drop_options,
7286            validate,
7287        },
7288    }))
7289}
7290
7291pub fn describe_alter_sink(
7292    _: &StatementContext,
7293    _: AlterSinkStatement<Aug>,
7294) -> Result<StatementDesc, PlanError> {
7295    Ok(StatementDesc::new(None))
7296}
7297
7298pub fn plan_alter_sink(
7299    scx: &mut StatementContext,
7300    stmt: AlterSinkStatement<Aug>,
7301) -> Result<Plan, PlanError> {
7302    let AlterSinkStatement {
7303        sink_name,
7304        if_exists,
7305        action,
7306    } = stmt;
7307
7308    let object_type = ObjectType::Sink;
7309    let item = resolve_item_or_type(scx, object_type, sink_name.clone(), if_exists)?;
7310
7311    let Some(item) = item else {
7312        scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7313            name: sink_name.to_string(),
7314            object_type,
7315        });
7316
7317        return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7318    };
7319    // Always ALTER objects from their latest version.
7320    let item = item.at_version(RelationVersionSelector::Latest);
7321
7322    match action {
7323        AlterSinkAction::ChangeRelation(new_from) => {
7324            // First we reconstruct the original CREATE SINK statement
7325            let create_sql = item.create_sql();
7326            let stmts = mz_sql_parser::parser::parse_statements(create_sql)?;
7327            let [stmt]: [StatementParseResult; 1] = stmts
7328                .try_into()
7329                .map_err(|_| internal_err!("create SQL of sink was not exactly one statement"))?;
7330            let Statement::CreateSink(stmt) = stmt.ast else {
7331                bail_internal!("create SQL of sink is not a CREATE SINK statement");
7332            };
7333
7334            // Then resolve and swap the resolved from relation to the new one
7335            let (mut stmt, _) = crate::names::resolve(scx.catalog, stmt)?;
7336            stmt.from = new_from;
7337
7338            // Finally re-plan the modified create sink statement to verify the new configuration is valid
7339            let Plan::CreateSink(mut plan) = plan_sink(scx, stmt)? else {
7340                bail_internal!("plan_sink did not produce a CreateSink plan");
7341            };
7342
7343            plan.sink.version += 1;
7344
7345            Ok(Plan::AlterSink(AlterSinkPlan {
7346                item_id: item.id(),
7347                global_id: item.global_id(),
7348                sink: plan.sink,
7349                with_snapshot: plan.with_snapshot,
7350                in_cluster: plan.in_cluster,
7351            }))
7352        }
7353        AlterSinkAction::SetOptions(_) => bail_unsupported!("ALTER SINK SET options"),
7354        AlterSinkAction::ResetOptions(_) => bail_unsupported!("ALTER SINK RESET option"),
7355    }
7356}
7357
7358pub fn describe_alter_source(
7359    _: &StatementContext,
7360    _: AlterSourceStatement<Aug>,
7361) -> Result<StatementDesc, PlanError> {
7362    // TODO: put the options here, right?
7363    Ok(StatementDesc::new(None))
7364}
7365
7366generate_extracted_config!(
7367    AlterSourceAddSubsourceOption,
7368    (TextColumns, Vec::<UnresolvedItemName>, Default(vec![])),
7369    (ExcludeColumns, Vec::<UnresolvedItemName>, Default(vec![])),
7370    (Details, String)
7371);
7372
7373pub fn plan_alter_source(
7374    scx: &mut StatementContext,
7375    stmt: AlterSourceStatement<Aug>,
7376) -> Result<Plan, PlanError> {
7377    let AlterSourceStatement {
7378        source_name,
7379        if_exists,
7380        action,
7381    } = stmt;
7382    let object_type = ObjectType::Source;
7383
7384    if resolve_item_or_type(scx, object_type, source_name.clone(), if_exists)?.is_none() {
7385        scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7386            name: source_name.to_string(),
7387            object_type,
7388        });
7389
7390        return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7391    }
7392
7393    match action {
7394        AlterSourceAction::SetOptions(options) => {
7395            let mut options = options.into_iter();
7396            let option = options
7397                .next()
7398                .ok_or_else(|| sql_err!("ALTER SOURCE SET requires at least one option"))?;
7399            if option.name == CreateSourceOptionName::RetainHistory {
7400                if options.next().is_some() {
7401                    sql_bail!("RETAIN HISTORY must be only option");
7402                }
7403                return alter_retain_history(
7404                    scx,
7405                    object_type,
7406                    if_exists,
7407                    UnresolvedObjectName::Item(source_name),
7408                    option.value,
7409                );
7410            }
7411            if option.name == CreateSourceOptionName::TimestampInterval {
7412                if options.next().is_some() {
7413                    sql_bail!("TIMESTAMP INTERVAL must be only option");
7414                }
7415                return alter_source_timestamp_interval(scx, if_exists, source_name, option.value);
7416            }
7417            // n.b we use this statement in purification in a way that cannot be
7418            // planned directly.
7419            sql_bail!(
7420                "Cannot modify the {} of a SOURCE.",
7421                option.name.to_ast_string_simple()
7422            );
7423        }
7424        AlterSourceAction::ResetOptions(reset) => {
7425            let mut options = reset.into_iter();
7426            let option = options
7427                .next()
7428                .ok_or_else(|| sql_err!("ALTER SOURCE RESET requires at least one option"))?;
7429            if option == CreateSourceOptionName::RetainHistory {
7430                if options.next().is_some() {
7431                    sql_bail!("RETAIN HISTORY must be only option");
7432                }
7433                return alter_retain_history(
7434                    scx,
7435                    object_type,
7436                    if_exists,
7437                    UnresolvedObjectName::Item(source_name),
7438                    None,
7439                );
7440            }
7441            if option == CreateSourceOptionName::TimestampInterval {
7442                if options.next().is_some() {
7443                    sql_bail!("TIMESTAMP INTERVAL must be only option");
7444                }
7445                return alter_source_timestamp_interval(scx, if_exists, source_name, None);
7446            }
7447            sql_bail!(
7448                "Cannot modify the {} of a SOURCE.",
7449                option.to_ast_string_simple()
7450            );
7451        }
7452        AlterSourceAction::DropSubsources { .. } => {
7453            sql_bail!("ALTER SOURCE...DROP SUBSOURCE no longer supported; use DROP SOURCE")
7454        }
7455        AlterSourceAction::AddSubsources { .. } => {
7456            sql_bail!("ALTER SOURCE...ADD SUBSOURCE must be purified before planning")
7457        }
7458        AlterSourceAction::RefreshReferences => {
7459            sql_bail!("ALTER SOURCE...REFRESH REFERENCES must be purified before planning")
7460        }
7461    };
7462}
7463
7464pub fn describe_alter_system_set(
7465    _: &StatementContext,
7466    _: AlterSystemSetStatement,
7467) -> Result<StatementDesc, PlanError> {
7468    Ok(StatementDesc::new(None))
7469}
7470
7471pub fn plan_alter_system_set(
7472    _: &StatementContext,
7473    AlterSystemSetStatement { name, to }: AlterSystemSetStatement,
7474) -> Result<Plan, PlanError> {
7475    let name = name.to_string();
7476    Ok(Plan::AlterSystemSet(AlterSystemSetPlan {
7477        name,
7478        value: scl::plan_set_variable_to(to)?,
7479    }))
7480}
7481
7482pub fn describe_alter_system_reset(
7483    _: &StatementContext,
7484    _: AlterSystemResetStatement,
7485) -> Result<StatementDesc, PlanError> {
7486    Ok(StatementDesc::new(None))
7487}
7488
7489pub fn plan_alter_system_reset(
7490    _: &StatementContext,
7491    AlterSystemResetStatement { name }: AlterSystemResetStatement,
7492) -> Result<Plan, PlanError> {
7493    let name = name.to_string();
7494    Ok(Plan::AlterSystemReset(AlterSystemResetPlan { name }))
7495}
7496
7497pub fn describe_alter_system_reset_all(
7498    _: &StatementContext,
7499    _: AlterSystemResetAllStatement,
7500) -> Result<StatementDesc, PlanError> {
7501    Ok(StatementDesc::new(None))
7502}
7503
7504pub fn plan_alter_system_reset_all(
7505    _: &StatementContext,
7506    _: AlterSystemResetAllStatement,
7507) -> Result<Plan, PlanError> {
7508    Ok(Plan::AlterSystemResetAll(AlterSystemResetAllPlan {}))
7509}
7510
7511pub fn describe_alter_role(
7512    _: &StatementContext,
7513    _: AlterRoleStatement<Aug>,
7514) -> Result<StatementDesc, PlanError> {
7515    Ok(StatementDesc::new(None))
7516}
7517
7518pub fn plan_alter_role(
7519    scx: &StatementContext,
7520    AlterRoleStatement { name, option }: AlterRoleStatement<Aug>,
7521) -> Result<Plan, PlanError> {
7522    let option = match option {
7523        AlterRoleOption::Attributes(attrs) => {
7524            let attrs = plan_role_attributes(attrs, scx)?;
7525            PlannedAlterRoleOption::Attributes(attrs)
7526        }
7527        AlterRoleOption::Variable(variable) => {
7528            let var = plan_role_variable(scx, variable)?;
7529            PlannedAlterRoleOption::Variable(var)
7530        }
7531    };
7532
7533    Ok(Plan::AlterRole(AlterRolePlan {
7534        id: name.id,
7535        name: name.name,
7536        option,
7537    }))
7538}
7539
7540pub fn describe_alter_table_add_column(
7541    _: &StatementContext,
7542    _: AlterTableAddColumnStatement<Aug>,
7543) -> Result<StatementDesc, PlanError> {
7544    Ok(StatementDesc::new(None))
7545}
7546
7547pub fn plan_alter_table_add_column(
7548    scx: &StatementContext,
7549    stmt: AlterTableAddColumnStatement<Aug>,
7550) -> Result<Plan, PlanError> {
7551    let AlterTableAddColumnStatement {
7552        if_exists,
7553        name,
7554        if_col_not_exist,
7555        column_name,
7556        data_type,
7557    } = stmt;
7558    let object_type = ObjectType::Table;
7559
7560    scx.require_feature_flag(&vars::ENABLE_ALTER_TABLE_ADD_COLUMN)?;
7561
7562    let (relation_id, item_name, desc) =
7563        match resolve_item_or_type(scx, object_type, name.clone(), if_exists)? {
7564            Some(item) => {
7565                // Always add columns to the latest version of the item.
7566                let item_name = scx.catalog.resolve_full_name(item.name());
7567                let item = item.at_version(RelationVersionSelector::Latest);
7568                let desc = item
7569                    .relation_desc()
7570                    .ok_or_else(|| sql_err!("item does not have a relation description"))?
7571                    .into_owned();
7572                (item.id(), item_name, desc)
7573            }
7574            None => {
7575                scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7576                    name: name.to_ast_string_simple(),
7577                    object_type,
7578                });
7579                return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7580            }
7581        };
7582
7583    let column_name = ColumnName::from(column_name.as_str());
7584    if desc.get_by_name(&column_name).is_some() {
7585        if if_col_not_exist {
7586            scx.catalog.add_notice(PlanNotice::ColumnAlreadyExists {
7587                column_name: column_name.to_string(),
7588                object_name: item_name.item,
7589            });
7590            return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7591        } else {
7592            return Err(PlanError::ColumnAlreadyExists {
7593                column_name,
7594                object_name: item_name.item,
7595            });
7596        }
7597    }
7598
7599    let scalar_type = scalar_type_from_sql(scx, &data_type)?;
7600    // TODO(alter_table): Support non-nullable columns with default values.
7601    let column_type = scalar_type.nullable(true);
7602    // "unresolve" our data type so we can later update the persisted create_sql.
7603    let raw_sql_type = mz_sql_parser::parser::parse_data_type(&data_type.to_ast_string_stable())?;
7604
7605    Ok(Plan::AlterTableAddColumn(AlterTablePlan {
7606        relation_id,
7607        column_name,
7608        column_type,
7609        raw_sql_type,
7610    }))
7611}
7612
7613pub fn describe_alter_materialized_view_apply_replacement(
7614    _: &StatementContext,
7615    _: AlterMaterializedViewApplyReplacementStatement,
7616) -> Result<StatementDesc, PlanError> {
7617    Ok(StatementDesc::new(None))
7618}
7619
7620pub fn plan_alter_materialized_view_apply_replacement(
7621    scx: &StatementContext,
7622    stmt: AlterMaterializedViewApplyReplacementStatement,
7623) -> Result<Plan, PlanError> {
7624    let AlterMaterializedViewApplyReplacementStatement {
7625        if_exists,
7626        name,
7627        replacement_name,
7628    } = stmt;
7629
7630    scx.require_feature_flag(&vars::ENABLE_REPLACEMENT_MATERIALIZED_VIEWS)?;
7631
7632    let object_type = ObjectType::MaterializedView;
7633    let Some(mv) = resolve_item_or_type(scx, object_type, name.clone(), if_exists)? else {
7634        scx.catalog.add_notice(PlanNotice::ObjectDoesNotExist {
7635            name: name.to_ast_string_simple(),
7636            object_type,
7637        });
7638        return Ok(Plan::AlterNoop(AlterNoopPlan { object_type }));
7639    };
7640
7641    let replacement = resolve_item_or_type(scx, object_type, replacement_name, false)?
7642        .ok_or_else(|| sql_err!("replacement materialized view does not exist"))?;
7643
7644    if replacement.replacement_target() != Some(mv.id()) {
7645        return Err(PlanError::InvalidReplacement {
7646            item_type: mv.item_type(),
7647            item_name: scx.catalog.minimal_qualification(mv.name()),
7648            replacement_type: replacement.item_type(),
7649            replacement_name: scx.catalog.minimal_qualification(replacement.name()),
7650        });
7651    }
7652
7653    Ok(Plan::AlterMaterializedViewApplyReplacement(
7654        AlterMaterializedViewApplyReplacementPlan {
7655            id: mv.id(),
7656            replacement_id: replacement.id(),
7657        },
7658    ))
7659}
7660
7661pub fn describe_comment(
7662    _: &StatementContext,
7663    _: CommentStatement<Aug>,
7664) -> Result<StatementDesc, PlanError> {
7665    Ok(StatementDesc::new(None))
7666}
7667
7668pub fn plan_comment(
7669    scx: &mut StatementContext,
7670    stmt: CommentStatement<Aug>,
7671) -> Result<Plan, PlanError> {
7672    const MAX_COMMENT_LENGTH: usize = 1024;
7673
7674    let CommentStatement { object, comment } = stmt;
7675
7676    // TODO(parkmycar): Make max comment length configurable.
7677    if let Some(c) = &comment {
7678        if c.len() > 1024 {
7679            return Err(PlanError::CommentTooLong {
7680                length: c.len(),
7681                max_size: MAX_COMMENT_LENGTH,
7682            });
7683        }
7684    }
7685
7686    let (object_id, column_pos) = match &object {
7687        com_ty @ CommentObjectType::Table { name }
7688        | com_ty @ CommentObjectType::View { name }
7689        | com_ty @ CommentObjectType::MaterializedView { name }
7690        | com_ty @ CommentObjectType::Index { name }
7691        | com_ty @ CommentObjectType::Func { name }
7692        | com_ty @ CommentObjectType::Connection { name }
7693        | com_ty @ CommentObjectType::Source { name }
7694        | com_ty @ CommentObjectType::Sink { name }
7695        | com_ty @ CommentObjectType::Secret { name } => {
7696            let item = scx.get_item_by_resolved_name(name)?;
7697            match (com_ty, item.item_type()) {
7698                (CommentObjectType::Table { .. }, CatalogItemType::Table) => {
7699                    (CommentObjectId::Table(item.id()), None)
7700                }
7701                (CommentObjectType::View { .. }, CatalogItemType::View) => {
7702                    (CommentObjectId::View(item.id()), None)
7703                }
7704                (CommentObjectType::MaterializedView { .. }, CatalogItemType::MaterializedView) => {
7705                    (CommentObjectId::MaterializedView(item.id()), None)
7706                }
7707                (CommentObjectType::Index { .. }, CatalogItemType::Index) => {
7708                    (CommentObjectId::Index(item.id()), None)
7709                }
7710                (CommentObjectType::Func { .. }, CatalogItemType::Func) => {
7711                    (CommentObjectId::Func(item.id()), None)
7712                }
7713                (CommentObjectType::Connection { .. }, CatalogItemType::Connection) => {
7714                    (CommentObjectId::Connection(item.id()), None)
7715                }
7716                (CommentObjectType::Source { .. }, CatalogItemType::Source) => {
7717                    (CommentObjectId::Source(item.id()), None)
7718                }
7719                (CommentObjectType::Sink { .. }, CatalogItemType::Sink) => {
7720                    (CommentObjectId::Sink(item.id()), None)
7721                }
7722                (CommentObjectType::Secret { .. }, CatalogItemType::Secret) => {
7723                    (CommentObjectId::Secret(item.id()), None)
7724                }
7725                (com_ty, cat_ty) => {
7726                    let expected_type = match com_ty {
7727                        CommentObjectType::Table { .. } => ObjectType::Table,
7728                        CommentObjectType::View { .. } => ObjectType::View,
7729                        CommentObjectType::MaterializedView { .. } => ObjectType::MaterializedView,
7730                        CommentObjectType::Index { .. } => ObjectType::Index,
7731                        CommentObjectType::Func { .. } => ObjectType::Func,
7732                        CommentObjectType::Connection { .. } => ObjectType::Connection,
7733                        CommentObjectType::Source { .. } => ObjectType::Source,
7734                        CommentObjectType::Sink { .. } => ObjectType::Sink,
7735                        CommentObjectType::Secret { .. } => ObjectType::Secret,
7736                        _ => sql_bail!("cannot comment on this object type"),
7737                    };
7738
7739                    return Err(PlanError::InvalidObjectType {
7740                        expected_type: SystemObjectType::Object(expected_type),
7741                        actual_type: SystemObjectType::Object(cat_ty.into()),
7742                        object_name: item.name().item.clone(),
7743                    });
7744                }
7745            }
7746        }
7747        CommentObjectType::Type { ty } => match ty {
7748            ResolvedDataType::AnonymousList(_) | ResolvedDataType::AnonymousMap { .. } => {
7749                sql_bail!("cannot comment on anonymous list or map type");
7750            }
7751            ResolvedDataType::Named { id, modifiers, .. } => {
7752                if !modifiers.is_empty() {
7753                    sql_bail!("cannot comment on type with modifiers");
7754                }
7755                (CommentObjectId::Type(*id), None)
7756            }
7757            ResolvedDataType::Error => bail_internal!("unresolved data type"),
7758        },
7759        CommentObjectType::Column { name } => {
7760            let (item, pos) = scx.get_column_by_resolved_name(name)?;
7761            match item.item_type() {
7762                CatalogItemType::Table => (CommentObjectId::Table(item.id()), Some(pos + 1)),
7763                CatalogItemType::Source => (CommentObjectId::Source(item.id()), Some(pos + 1)),
7764                CatalogItemType::View => (CommentObjectId::View(item.id()), Some(pos + 1)),
7765                CatalogItemType::MaterializedView => {
7766                    (CommentObjectId::MaterializedView(item.id()), Some(pos + 1))
7767                }
7768                CatalogItemType::Type => (CommentObjectId::Type(item.id()), Some(pos + 1)),
7769                r => {
7770                    return Err(PlanError::Unsupported {
7771                        feature: format!("Specifying comments on a column of {r}"),
7772                        discussion_no: None,
7773                    });
7774                }
7775            }
7776        }
7777        CommentObjectType::Role { name } => (CommentObjectId::Role(name.id), None),
7778        CommentObjectType::Database { name } => {
7779            (CommentObjectId::Database(*name.database_id()), None)
7780        }
7781        CommentObjectType::Schema { name } => {
7782            // Temporary schemas cannot have comments - they are connection-specific
7783            // and transient. With lazy temporary schema creation, the temp schema
7784            // may not exist yet, but we still need to return the correct error.
7785            if matches!(name.schema_spec(), SchemaSpecifier::Temporary) {
7786                sql_bail!(
7787                    "cannot comment on schema {} because it is a temporary schema",
7788                    mz_repr::namespaces::MZ_TEMP_SCHEMA
7789                );
7790            }
7791            (
7792                CommentObjectId::Schema((*name.database_spec(), *name.schema_spec())),
7793                None,
7794            )
7795        }
7796        CommentObjectType::Cluster { name } => (CommentObjectId::Cluster(name.id), None),
7797        CommentObjectType::ClusterReplica { name } => {
7798            let replica = scx.catalog.resolve_cluster_replica(name)?;
7799            (
7800                CommentObjectId::ClusterReplica((replica.cluster_id(), replica.replica_id())),
7801                None,
7802            )
7803        }
7804        CommentObjectType::NetworkPolicy { name } => {
7805            (CommentObjectId::NetworkPolicy(name.id), None)
7806        }
7807    };
7808
7809    // Note: the `mz_comments` table uses an `Int4` for the column position, but in the catalog storage we
7810    // store a `usize` which would be a `Uint8`. We guard against a safe conversion here because
7811    // it's the easiest place to raise an error.
7812    //
7813    // TODO(parkmycar): https://github.com/MaterializeInc/database-issues/issues/6711.
7814    if let Some(p) = column_pos {
7815        i32::try_from(p).map_err(|_| PlanError::TooManyColumns {
7816            max_num_columns: MAX_NUM_COLUMNS,
7817            req_num_columns: p,
7818        })?;
7819    }
7820
7821    Ok(Plan::Comment(CommentPlan {
7822        object_id,
7823        sub_component: column_pos,
7824        comment,
7825    }))
7826}
7827
7828pub(crate) fn resolve_cluster<'a>(
7829    scx: &'a StatementContext,
7830    name: &'a Ident,
7831    if_exists: bool,
7832) -> Result<Option<&'a dyn CatalogCluster<'a>>, PlanError> {
7833    match scx.resolve_cluster(Some(name)) {
7834        Ok(cluster) => Ok(Some(cluster)),
7835        Err(_) if if_exists => Ok(None),
7836        Err(e) => Err(e),
7837    }
7838}
7839
7840pub(crate) fn resolve_cluster_replica<'a>(
7841    scx: &'a StatementContext,
7842    name: &QualifiedReplica,
7843    if_exists: bool,
7844) -> Result<Option<(&'a dyn CatalogCluster<'a>, ReplicaId)>, PlanError> {
7845    match scx.resolve_cluster(Some(&name.cluster)) {
7846        Ok(cluster) => match cluster.replica_ids().get(name.replica.as_str()) {
7847            Some(replica_id) => Ok(Some((cluster, *replica_id))),
7848            None if if_exists => Ok(None),
7849            None => Err(sql_err!(
7850                "CLUSTER {} has no CLUSTER REPLICA named {}",
7851                cluster.name(),
7852                name.replica.as_str().quoted(),
7853            )),
7854        },
7855        Err(_) if if_exists => Ok(None),
7856        Err(e) => Err(e),
7857    }
7858}
7859
7860pub(crate) fn resolve_database<'a>(
7861    scx: &'a StatementContext,
7862    name: &'a UnresolvedDatabaseName,
7863    if_exists: bool,
7864) -> Result<Option<&'a dyn CatalogDatabase>, PlanError> {
7865    match scx.resolve_database(name) {
7866        Ok(database) => Ok(Some(database)),
7867        Err(_) if if_exists => Ok(None),
7868        Err(e) => Err(e),
7869    }
7870}
7871
7872pub(crate) fn resolve_schema<'a>(
7873    scx: &'a StatementContext,
7874    name: UnresolvedSchemaName,
7875    if_exists: bool,
7876) -> Result<Option<(ResolvedDatabaseSpecifier, SchemaSpecifier)>, PlanError> {
7877    match scx.resolve_schema(name) {
7878        Ok(schema) => Ok(Some((schema.database().clone(), schema.id().clone()))),
7879        Err(_) if if_exists => Ok(None),
7880        Err(e) => Err(e),
7881    }
7882}
7883
7884pub(crate) fn resolve_network_policy<'a>(
7885    scx: &'a StatementContext,
7886    name: Ident,
7887    if_exists: bool,
7888) -> Result<Option<ResolvedNetworkPolicyName>, PlanError> {
7889    match scx.catalog.resolve_network_policy(&name.to_string()) {
7890        Ok(policy) => Ok(Some(ResolvedNetworkPolicyName {
7891            id: policy.id(),
7892            name: policy.name().to_string(),
7893        })),
7894        Err(_) if if_exists => Ok(None),
7895        Err(e) => Err(e.into()),
7896    }
7897}
7898
7899pub(crate) fn resolve_item_or_type<'a>(
7900    scx: &'a StatementContext,
7901    object_type: ObjectType,
7902    name: UnresolvedItemName,
7903    if_exists: bool,
7904) -> Result<Option<&'a dyn CatalogItem>, PlanError> {
7905    let name = normalize::unresolved_item_name(name)?;
7906    let catalog_item = match object_type {
7907        ObjectType::Type => scx.catalog.resolve_type(&name),
7908        ObjectType::Table
7909        | ObjectType::View
7910        | ObjectType::MaterializedView
7911        | ObjectType::Source
7912        | ObjectType::Sink
7913        | ObjectType::Index
7914        | ObjectType::Role
7915        | ObjectType::Cluster
7916        | ObjectType::ClusterReplica
7917        | ObjectType::Secret
7918        | ObjectType::Connection
7919        | ObjectType::Database
7920        | ObjectType::Schema
7921        | ObjectType::Func
7922        | ObjectType::NetworkPolicy => scx.catalog.resolve_item(&name),
7923    };
7924
7925    match catalog_item {
7926        Ok(item) => {
7927            let is_type = ObjectType::from(item.item_type());
7928            if object_type == is_type {
7929                Ok(Some(item))
7930            } else {
7931                Err(PlanError::MismatchedObjectType {
7932                    name: scx.catalog.minimal_qualification(item.name()),
7933                    is_type,
7934                    expected_type: object_type,
7935                })
7936            }
7937        }
7938        Err(_) if if_exists => Ok(None),
7939        Err(e) => Err(e.into()),
7940    }
7941}
7942
7943/// Returns an error if the given cluster is a managed cluster
7944fn ensure_cluster_is_not_managed(
7945    scx: &StatementContext,
7946    cluster_id: ClusterId,
7947) -> Result<(), PlanError> {
7948    let cluster = scx.catalog.get_cluster(cluster_id);
7949    if cluster.is_managed() {
7950        Err(PlanError::ManagedCluster {
7951            cluster_name: cluster.name().to_string(),
7952        })
7953    } else {
7954        Ok(())
7955    }
7956}