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