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