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