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