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