Skip to main content

mz_sql/plan/statement/
ddl.rs

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