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