Skip to main content

mz_sql/pure/
postgres.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//! Postgres utilities for SQL purification.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use mz_postgres_util::desc::PostgresTableDesc;
15use mz_proto::RustType;
16use mz_repr::{Datum, ReprColumnType, ReprScalarType, Row, SqlScalarType};
17use mz_sql_parser::ast::display::AstDisplay;
18use mz_sql_parser::ast::{
19    ColumnDef, CreateSubsourceOption, CreateSubsourceOptionName, CreateSubsourceStatement,
20    ExternalReferences, Ident, PgConfigOptionName, TableConstraint, UnresolvedItemName, Value,
21    WithOptionValue,
22};
23use mz_storage_types::sources::casts::{CastFunc, StorageScalarExpr};
24use mz_storage_types::sources::postgres::CastType;
25use mz_storage_types::sources::{MzOffset, SourceExportStatementDetails};
26use prost::Message;
27use tokio_postgres::Client;
28use tokio_postgres::types::Oid;
29
30use crate::names::{Aug, ResolvedItemName};
31use crate::normalize;
32use crate::plan::{PlanError, StatementContext};
33
34use super::error::PgSourcePurificationError;
35use super::references::RetrievedSourceReferences;
36use super::{
37    FilterConstraints, PartialItemName, PurifiedExportDetails, PurifiedSourceExport,
38    SourceReferencePolicy,
39};
40
41/// Ensure that we have select permissions on all tables; we have to do this before we
42/// start snapshotting because if we discover we cannot `COPY` from a table while
43/// snapshotting, we break the entire source.
44pub(super) async fn validate_requested_references_privileges(
45    client: &Client,
46    table_oids: &[Oid],
47) -> Result<(), PlanError> {
48    privileges::check_table_privileges(client, table_oids).await?;
49    privileges::check_rls_privileges(client, table_oids).await?;
50    replica_identity::check_replica_identity_full(client, table_oids).await?;
51
52    Ok(())
53}
54
55/// Map a list of column references to a map of table oids to column names.
56///
57/// Additionally, modify `columns` so that they contain database-qualified
58/// references to the columns.
59pub(super) fn map_column_refs(
60    retrieved_references: &RetrievedSourceReferences,
61    columns: &mut [UnresolvedItemName],
62    option_type: PgConfigOptionName,
63) -> Result<BTreeMap<u32, BTreeSet<String>>, PlanError> {
64    let mut cols_map: BTreeMap<u32, BTreeSet<String>> = BTreeMap::new();
65
66    for name in columns {
67        let (qual, col) = match name.0.split_last().expect("must have at least one element") {
68            (col, []) => {
69                return Err(PlanError::InvalidOptionValue {
70                    option_name: option_type.to_ast_string_simple(),
71                    err: Box::new(PlanError::UnderqualifiedColumnName(
72                        col.as_str().to_string(),
73                    )),
74                });
75            }
76            (col, qual) => (qual.to_vec(), col.as_str().to_string()),
77        };
78
79        let resolved_reference = retrieved_references.resolve_name(&qual)?;
80        let mut fully_qualified_name =
81            resolved_reference
82                .external_reference()
83                .map_err(|e| PlanError::InvalidOptionValue {
84                    option_name: option_type.to_ast_string_simple(),
85                    err: Box::new(e.into()),
86                })?;
87
88        let desc = resolved_reference
89            .postgres_desc()
90            .expect("known to be postgres");
91
92        if !desc.columns.iter().any(|column| column.name == col) {
93            let column = mz_repr::ColumnName::from(col);
94            let similar = desc
95                .columns
96                .iter()
97                .filter_map(|c| {
98                    let c_name = mz_repr::ColumnName::from(c.name.clone());
99                    c_name.is_similar(&column).then_some(c_name)
100                })
101                .collect();
102            return Err(PlanError::InvalidOptionValue {
103                option_name: option_type.to_ast_string_simple(),
104                err: Box::new(PlanError::UnknownColumn {
105                    table: Some(
106                        normalize::unresolved_item_name(fully_qualified_name)
107                            .expect("known to be of valid len"),
108                    ),
109                    column,
110                    similar,
111                }),
112            });
113        }
114
115        // Rewrite fully qualified name.
116        let col_ident = Ident::new(col.as_str().to_string())?;
117        fully_qualified_name.0.push(col_ident);
118        *name = fully_qualified_name;
119
120        let new = cols_map
121            .entry(desc.oid)
122            .or_default()
123            .insert(col.as_str().to_string());
124
125        if !new {
126            return Err(PlanError::InvalidOptionValue {
127                option_name: option_type.to_ast_string_simple(),
128                err: Box::new(PlanError::UnexpectedDuplicateReference { name: name.clone() }),
129            });
130        }
131    }
132
133    Ok(cols_map)
134}
135
136pub fn generate_create_subsource_statements(
137    scx: &StatementContext,
138    source_name: ResolvedItemName,
139    requested_subsources: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
140) -> Result<Vec<CreateSubsourceStatement<Aug>>, PlanError> {
141    // Aggregate all unrecognized types.
142    let mut unsupported_cols = vec![];
143
144    // Now that we have an explicit list of validated requested subsources we can create them
145    let mut subsources = Vec::with_capacity(requested_subsources.len());
146
147    for (subsource_name, purified_export) in requested_subsources {
148        let PostgresExportStatementValues {
149            columns,
150            constraints,
151            text_columns,
152            exclude_columns,
153            details,
154            external_reference,
155        } = generate_source_export_statement_values(scx, purified_export, &mut unsupported_cols)?;
156
157        let mut with_options = vec![
158            CreateSubsourceOption {
159                name: CreateSubsourceOptionName::ExternalReference,
160                value: Some(WithOptionValue::UnresolvedItemName(external_reference)),
161            },
162            CreateSubsourceOption {
163                name: CreateSubsourceOptionName::Details,
164                value: Some(WithOptionValue::Value(Value::String(hex::encode(
165                    details.into_proto().encode_to_vec(),
166                )))),
167            },
168        ];
169
170        if let Some(text_columns) = text_columns {
171            with_options.push(CreateSubsourceOption {
172                name: CreateSubsourceOptionName::TextColumns,
173                value: Some(WithOptionValue::Sequence(text_columns)),
174            });
175        }
176
177        if let Some(exclude_columns) = exclude_columns {
178            with_options.push(CreateSubsourceOption {
179                name: CreateSubsourceOptionName::ExcludeColumns,
180                value: Some(WithOptionValue::Sequence(exclude_columns)),
181            });
182        }
183
184        // Create the subsource statement
185        let subsource = CreateSubsourceStatement {
186            name: subsource_name,
187            columns,
188            // We might not know the primary source's `GlobalId` yet; if not,
189            // we'll fill it in once we generate it.
190            of_source: Some(source_name.clone()),
191            // TODO(petrosagg): nothing stops us from getting the constraints of the
192            // upstream tables and mirroring them here which will lead to more optimization
193            // opportunities if for example there is a primary key or an index.
194            //
195            // If we ever do that we must triple check that we will get notified *in the
196            // replication stream*, if our assumptions change. Failure to do that could
197            // mean that an upstream table that started with an index was then altered to
198            // one without and now we're producing garbage data.
199            constraints,
200            if_not_exists: false,
201            with_options,
202        };
203        subsources.push(subsource);
204    }
205
206    if !unsupported_cols.is_empty() {
207        unsupported_cols.sort();
208        Err(PgSourcePurificationError::UnrecognizedTypes {
209            cols: unsupported_cols,
210        })?;
211    }
212
213    Ok(subsources)
214}
215
216pub(super) struct PostgresExportStatementValues {
217    pub(super) columns: Vec<ColumnDef<Aug>>,
218    pub(super) constraints: Vec<TableConstraint<Aug>>,
219    pub(super) text_columns: Option<Vec<WithOptionValue<Aug>>>,
220    pub(super) exclude_columns: Option<Vec<WithOptionValue<Aug>>>,
221    pub(super) details: SourceExportStatementDetails,
222    pub(super) external_reference: UnresolvedItemName,
223}
224
225pub(super) fn generate_source_export_statement_values(
226    scx: &StatementContext,
227    purified_export: PurifiedSourceExport,
228    unsupported_cols: &mut Vec<(String, mz_repr::adt::system::Oid)>,
229) -> Result<PostgresExportStatementValues, PlanError> {
230    let PurifiedExportDetails::Postgres {
231        table,
232        text_columns,
233        exclude_columns,
234        initial_lsn,
235    } = purified_export.details
236    else {
237        bail_internal!("purified export details must be postgres");
238    };
239
240    let text_column_set = BTreeSet::from_iter(text_columns.iter().flatten().map(Ident::as_str));
241    let exclude_column_set =
242        BTreeSet::from_iter(exclude_columns.iter().flatten().map(Ident::as_str));
243
244    // Figure out the schema of the subsource
245    let mut columns = vec![];
246    for c in table.columns.iter() {
247        let name = Ident::new(c.name.clone())?;
248
249        if exclude_column_set.contains(c.name.as_str()) {
250            continue;
251        }
252
253        let ty = if text_column_set.contains(c.name.as_str()) {
254            mz_pgrepr::Type::Text
255        } else {
256            match mz_pgrepr::Type::from_oid_and_typmod(c.type_oid, c.type_mod) {
257                Ok(t) => t,
258                Err(_) => {
259                    let mut full_name = purified_export.external_reference.0.clone();
260                    full_name.push(name);
261                    unsupported_cols.push((
262                        UnresolvedItemName(full_name).to_ast_string_simple(),
263                        mz_repr::adt::system::Oid(c.type_oid),
264                    ));
265                    continue;
266                }
267            }
268        };
269
270        let data_type = scx.resolve_type(ty)?;
271        let mut options = vec![];
272
273        if !c.nullable {
274            options.push(mz_sql_parser::ast::ColumnOptionDef {
275                name: None,
276                option: mz_sql_parser::ast::ColumnOption::NotNull,
277            });
278        }
279
280        columns.push(ColumnDef {
281            name,
282            data_type,
283            collation: None,
284            options,
285        });
286    }
287
288    let mut constraints = vec![];
289    for key in table.keys.clone() {
290        let mut key_columns = vec![];
291        let mut all_key_cols_included = true;
292
293        for col_num in key.cols {
294            match table.columns.iter().find(|col| col.col_num == col_num) {
295                Some(col) => {
296                    let ident = Ident::new(col.name.clone())?;
297                    key_columns.push(ident);
298                }
299                None => {
300                    all_key_cols_included = false;
301                    break;
302                }
303            }
304        }
305        if !all_key_cols_included {
306            continue;
307        }
308
309        let constraint = mz_sql_parser::ast::TableConstraint::Unique {
310            name: Some(Ident::new(key.name)?),
311            columns: key_columns,
312            is_primary: key.is_primary,
313            nulls_not_distinct: key.nulls_not_distinct,
314        };
315
316        // We take the first constraint available to be the primary key.
317        if key.is_primary {
318            constraints.insert(0, constraint);
319        } else {
320            constraints.push(constraint);
321        }
322    }
323    // Newly purified exports always take the full-range oid cast. The flag is
324    // persisted in the statement details so replanning keeps the choice stable
325    // for the lifetime of the export, while exports whose details predate the
326    // flag decode as `false` and stay on the legacy cast.
327    let details = SourceExportStatementDetails::Postgres {
328        table,
329        cast_oid_full_range: true,
330        initial_lsn: Some(initial_lsn),
331    };
332
333    let text_columns = text_columns.map(|mut columns| {
334        columns.sort();
335        columns
336            .into_iter()
337            .map(WithOptionValue::Ident::<Aug>)
338            .collect()
339    });
340
341    let exclude_columns = exclude_columns.map(|mut columns| {
342        columns.sort();
343        columns
344            .into_iter()
345            .map(WithOptionValue::Ident::<Aug>)
346            .collect()
347    });
348
349    Ok(PostgresExportStatementValues {
350        columns,
351        constraints,
352        text_columns,
353        exclude_columns,
354        details,
355        external_reference: purified_export.external_reference,
356    })
357}
358
359pub(super) struct PurifiedSourceExports {
360    pub(super) source_exports: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
361    // NOTE(roshan): The text columns are already part of their
362    // appropriate `source_exports` above, but these are returned to allow
363    // round-tripping a `CREATE SOURCE` statement while we still allow creating
364    // implicit subsources from `CREATE SOURCE`. Remove once
365    // fully deprecating that feature and forcing users to use explicit
366    // `CREATE TABLE .. FROM SOURCE` statements.
367    pub(super) normalized_text_columns: Vec<WithOptionValue<Aug>>,
368}
369
370/// Purifies the requested external references, returning the purified source exports that
371/// correspond to external tables along with the additional fields needed to generate the relevant
372/// statements and update statement options.
373///
374/// `initial_lsn` is an upper bound on the upstream LSN whose schemas `retrieved_references`
375/// describe, and the point from which the purified exports interpret the replication stream. It
376/// must be read after `retrieved_references`, so that it accounts for any schema change they
377/// already reflect. Reading it earlier leaves a window in which replication trusts a Relation
378/// message describing a schema older than the one captured here.
379///
380/// `filter_constraints` only excludes anything for `CREATE TABLE .. FROM SOURCE`, which purifies
381/// exactly one export, so constraint names are validated against that single table's constraints.
382pub(super) async fn purify_source_exports(
383    client: &Client,
384    retrieved_references: &RetrievedSourceReferences,
385    requested_references: &Option<ExternalReferences>,
386    mut text_columns: Vec<UnresolvedItemName>,
387    mut exclude_columns: Vec<UnresolvedItemName>,
388    filter_constraints: &FilterConstraints,
389    exclude_all_not_null_constraints: bool,
390    unresolved_source_name: &UnresolvedItemName,
391    reference_policy: &SourceReferencePolicy,
392    initial_lsn: MzOffset,
393) -> Result<PurifiedSourceExports, PlanError> {
394    let requested_exports = match requested_references.as_ref() {
395        Some(requested) if matches!(reference_policy, SourceReferencePolicy::NotAllowed) => {
396            Err(PlanError::UseTablesForSources(requested.to_string()))?
397        }
398        Some(requested) => retrieved_references
399            .requested_source_exports(Some(requested), unresolved_source_name)?,
400        None => {
401            if matches!(reference_policy, SourceReferencePolicy::Required) {
402                Err(PgSourcePurificationError::RequiresExternalReferences)?
403            }
404
405            // If no external reference is specified, it does not make sense to include
406            // text columns.
407            if !text_columns.is_empty() {
408                Err(
409                    PgSourcePurificationError::UnnecessaryOptionsWithoutReferences(
410                        "TEXT COLUMNS".to_string(),
411                    ),
412                )?
413            }
414
415            // If no external reference is specified, it does not make sense to include
416            // exclude columns.
417            if !exclude_columns.is_empty() {
418                Err(
419                    PgSourcePurificationError::UnnecessaryOptionsWithoutReferences(
420                        "EXCLUDE COLUMNS".to_string(),
421                    ),
422                )?
423            }
424
425            return Ok(PurifiedSourceExports {
426                source_exports: BTreeMap::new(),
427                normalized_text_columns: vec![],
428            });
429        }
430    };
431
432    if requested_exports.is_empty() {
433        sql_bail!(
434            "[internal error]: Postgres reference {} did not match any tables",
435            requested_references
436                .as_ref()
437                .unwrap()
438                .to_ast_string_simple()
439        );
440    }
441
442    super::validate_source_export_names(&requested_exports)?;
443
444    if filter_constraints.excludes_any() && requested_exports.len() != 1 {
445        sql_bail!(
446            "EXCLUDE CONSTRAINTS and EXCLUDE ALL CONSTRAINTS apply to exactly one table, \
447             but {} tables were referenced",
448            requested_exports.len()
449        );
450    }
451
452    let table_oids: Vec<_> = requested_exports
453        .iter()
454        .map(|r| r.meta.postgres_desc().expect("is postgres").oid)
455        .collect();
456
457    validate_requested_references_privileges(client, &table_oids).await?;
458
459    let mut text_column_map = map_column_refs(
460        retrieved_references,
461        &mut text_columns,
462        PgConfigOptionName::TextColumns,
463    )?;
464    let mut exclude_column_map = map_column_refs(
465        retrieved_references,
466        &mut exclude_columns,
467        PgConfigOptionName::ExcludeColumns,
468    )?;
469
470    // Normalize options to contain full qualified values.
471    text_columns.sort();
472    text_columns.dedup();
473    let normalized_text_columns: Vec<_> = text_columns
474        .into_iter()
475        .map(WithOptionValue::UnresolvedItemName)
476        .collect();
477
478    let source_exports = requested_exports
479        .into_iter()
480        .map(|r| {
481            let mut desc = r.meta.postgres_desc().expect("known postgres").clone();
482            let text_columns = text_column_map.remove(&desc.oid);
483            let exclude_columns = exclude_column_map.remove(&desc.oid);
484
485            if let FilterConstraints::Exclude(names) = filter_constraints {
486                let missing: Vec<_> = names
487                    .iter()
488                    .filter(|n| !desc.keys.iter().any(|k| &&k.name == n))
489                    .cloned()
490                    .collect();
491                if !missing.is_empty() {
492                    return Err(PgSourcePurificationError::ConstraintsNotFound {
493                        table: PartialItemName {
494                            database: None,
495                            schema: Some(desc.namespace.clone()),
496                            item: desc.name.clone(),
497                        },
498                        constraints: missing,
499                    });
500                }
501            }
502
503            if let Some(exclude_cols) = &exclude_columns {
504                let excluded_col_nums: BTreeSet<u16> = desc
505                    .columns
506                    .iter()
507                    .filter(|c| exclude_cols.contains(&c.name))
508                    .map(|c| c.col_num)
509                    .collect();
510                desc.columns.retain(|c| !exclude_cols.contains(&c.name));
511                // A key naming an excluded column can never be re-verified against
512                // upstream, since the column (and any constraint on it) can be
513                // dropped independently of the columns we still track. Drop such
514                // keys now rather than carrying a stale key that will look like an
515                // incompatible schema change once the excluded column disappears.
516                desc.keys
517                    .retain(|k| k.cols.iter().all(|c| !excluded_col_nums.contains(c)));
518            }
519
520            match filter_constraints {
521                FilterConstraints::Exclude(names) => {
522                    desc.keys.retain(|k| !names.contains(&k.name));
523                }
524                FilterConstraints::ExcludeAll => {
525                    // Marking columns as nullable allows dropping (and adding) the
526                    // NOT NULL constraint without an outage.
527                    desc.keys.clear();
528                    for c in &mut desc.columns {
529                        c.nullable = true;
530                    }
531                }
532            }
533            if exclude_all_not_null_constraints {
534                for c in &mut desc.columns {
535                    c.nullable = true;
536                }
537            }
538
539            if let (Some(text_cols), Some(exclude_cols)) = (&text_columns, &exclude_columns) {
540                let intersection: Vec<_> = text_cols.intersection(exclude_cols).collect();
541                if !intersection.is_empty() {
542                    return Err(PgSourcePurificationError::DuplicatedColumnNames(
543                        intersection.iter().map(|s| (*s).to_string()).collect(),
544                    ));
545                }
546            }
547            Ok((
548                r.name,
549                PurifiedSourceExport {
550                    external_reference: r.external_reference,
551                    details: PurifiedExportDetails::Postgres {
552                        text_columns: text_columns.map(|v| {
553                            v.into_iter()
554                                .map(|s| Ident::new(s).expect("validated above"))
555                                .collect()
556                        }),
557                        exclude_columns: exclude_columns.map(|v| {
558                            v.into_iter()
559                                .map(|s| Ident::new(s).expect("validated above"))
560                                .collect()
561                        }),
562                        table: desc,
563                        initial_lsn,
564                    },
565                },
566            ))
567        })
568        .collect::<Result<BTreeMap<_, _>, _>>()?;
569
570    if !text_column_map.is_empty() {
571        // If any any item was not removed from the text_column_map, it wasn't being
572        // added.
573        let mut dangling_text_column_refs = vec![];
574        let all_references = retrieved_references.all_references();
575
576        for id in text_column_map.keys() {
577            let desc = all_references
578                .iter()
579                .find_map(|reference| {
580                    let desc = reference.postgres_desc().expect("is postgres");
581                    if desc.oid == *id { Some(desc) } else { None }
582                })
583                .expect("validated when generating text columns");
584
585            dangling_text_column_refs.push(PartialItemName {
586                database: None,
587                schema: Some(desc.namespace.clone()),
588                item: desc.name.clone(),
589            });
590        }
591
592        dangling_text_column_refs.sort();
593        return Err(PlanError::from(
594            PgSourcePurificationError::DanglingTextColumns {
595                items: dangling_text_column_refs,
596            },
597        ));
598    }
599
600    if !exclude_column_map.is_empty() {
601        // If any any item was not removed from the exclude_column_map, it wasn't being
602        // added.
603        let mut dangling_exclude_column_refs = vec![];
604        let all_references = retrieved_references.all_references();
605
606        for id in exclude_column_map.keys() {
607            let desc = all_references
608                .iter()
609                .find_map(|reference| {
610                    let desc = reference.postgres_desc().expect("is postgres");
611                    if desc.oid == *id { Some(desc) } else { None }
612                })
613                .expect("validated when generating exclude columns");
614
615            dangling_exclude_column_refs.push(PartialItemName {
616                database: None,
617                schema: Some(desc.namespace.clone()),
618                item: desc.name.clone(),
619            });
620        }
621
622        dangling_exclude_column_refs.sort();
623        return Err(PlanError::from(
624            PgSourcePurificationError::DanglingExcludeColumns {
625                items: dangling_exclude_column_refs,
626            },
627        ));
628    }
629
630    Ok(PurifiedSourceExports {
631        source_exports,
632        normalized_text_columns,
633    })
634}
635
636pub(crate) fn generate_column_casts(
637    scx: &StatementContext,
638    table: &PostgresTableDesc,
639    text_columns: &Vec<Ident>,
640    cast_oid_full_range: bool,
641) -> Result<Vec<(CastType, StorageScalarExpr)>, PlanError> {
642    // Generate the cast expressions required to convert the text encoded columns into
643    // the appropriate target types, creating a Vec<StorageScalarExpr>.
644    // The postgres source reader will then eval each of those on the incoming rows.
645
646    let text_columns = BTreeSet::from_iter(text_columns.iter().map(Ident::as_str));
647
648    let mut table_cast = vec![];
649    for (i, column) in table.columns.iter().enumerate() {
650        let (cast_type, ty) = if text_columns.contains(column.name.as_str()) {
651            // Treat the column as text if it was referenced in
652            // `TEXT COLUMNS`. This is the only place we need to
653            // perform this logic; even if the type is unsupported,
654            // we'll be able to ingest its values as text in
655            // storage.
656            (CastType::Text, mz_pgrepr::Type::Text)
657        } else {
658            match mz_pgrepr::Type::from_oid_and_typmod(column.type_oid, column.type_mod) {
659                Ok(t) => (CastType::Natural, t),
660                // If this reference survived purification, we
661                // do not expect it to be from a table that the
662                // user will consume., i.e. expect this table to
663                // be filtered out of table casts.
664                Err(_) => {
665                    table_cast.push((
666                        CastType::Natural,
667                        StorageScalarExpr::ErrorIfNull(
668                            Box::new(StorageScalarExpr::Literal(
669                                Row::pack_slice(&[Datum::Null]),
670                                ReprColumnType {
671                                    nullable: true,
672                                    scalar_type: ReprScalarType::String,
673                                },
674                            )),
675                            format!("Unsupported type with OID {}", column.type_oid),
676                        ),
677                    ));
678                    continue;
679                }
680            }
681        };
682
683        let cast_expr = match pg_type_to_cast_func(scx, &ty, cast_oid_full_range) {
684            Ok(None) => {
685                // No cast needed (e.g. Text → String identity).
686                StorageScalarExpr::Column(i)
687            }
688            Ok(Some(cast_func)) => {
689                StorageScalarExpr::CallUnary(cast_func, Box::new(StorageScalarExpr::Column(i)))
690            }
691            Err(PlanError::TableContainsUningestableTypes { type_, .. }) => {
692                // We expect only reg* types and similar to encounter
693                // this. Users can ingest the data as text if they need
694                // to. This is acceptable because we don't expect the
695                // OIDs from an external PG source to be unilaterally
696                // usable in resolving item names in MZ.
697                return Err(PlanError::TableContainsUningestableTypes {
698                    name: table.name.to_string(),
699                    type_,
700                    column: column.name.to_string(),
701                });
702            }
703            Err(e) => return Err(e),
704        };
705
706        let cast = if column.nullable {
707            cast_expr
708        } else {
709            // We must enforce nullability constraint on cast
710            // because PG replication stream does not propagate
711            // constraint changes and we want to error subsource if
712            // e.g. the constraint is dropped and we don't notice
713            // it.
714            let message = format!(
715                "PG column {}.{}.{} contained NULL data, despite having NOT NULL constraint",
716                table.namespace, table.name, column.name
717            );
718            StorageScalarExpr::ErrorIfNull(Box::new(cast_expr), message)
719        };
720
721        table_cast.push((cast_type, cast));
722    }
723    Ok(table_cast)
724}
725
726/// Resolve a PG type to its corresponding `SqlScalarType` via the catalog.
727fn resolve_pg_type_to_scalar_type(
728    scx: &StatementContext,
729    ty: &mz_pgrepr::Type,
730) -> Result<SqlScalarType, PlanError> {
731    let data_type = scx.resolve_type(ty.clone())?;
732    crate::plan::query::scalar_type_from_sql(scx, &data_type)
733}
734
735/// Map a PG type to the corresponding `CastFunc` variant. Returns:
736/// - `Ok(Some(func))` for types that need a cast
737/// - `Ok(None)` for types that need no cast (Text → String identity)
738/// - `Err(PlanError::TableContainsUningestableTypes { .. })` for types
739///   that cannot be ingested. The error uses placeholder strings for
740///   table/column name; callers with context should use
741///   `pg_type_to_cast_func_or_uningestable` instead.
742fn pg_type_to_cast_func(
743    scx: &StatementContext,
744    ty: &mz_pgrepr::Type,
745    cast_oid_full_range: bool,
746) -> Result<Option<CastFunc>, PlanError> {
747    use mz_pgrepr::Type;
748
749    let cast_func = match ty {
750        Type::Bool => CastFunc::CastStringToBool,
751        Type::Bytea => CastFunc::CastStringToBytes,
752        Type::Char => CastFunc::CastStringToPgLegacyChar,
753        Type::Date => CastFunc::CastStringToDate,
754        Type::Float4 => CastFunc::CastStringToFloat32,
755        Type::Float8 => CastFunc::CastStringToFloat64,
756        Type::Int2 => CastFunc::CastStringToInt16,
757        Type::Int4 => CastFunc::CastStringToInt32,
758        Type::Int8 => CastFunc::CastStringToInt64,
759        Type::UInt2 => CastFunc::CastStringToUint16,
760        Type::UInt4 => CastFunc::CastStringToUint32,
761        Type::UInt8 => CastFunc::CastStringToUint64,
762        Type::Interval { .. } => CastFunc::CastStringToInterval,
763        Type::Jsonb => CastFunc::CastStringToJsonb,
764        Type::Name => CastFunc::CastStringToPgLegacyName,
765        Type::Numeric { .. } => {
766            // Resolve through the catalog to get the repr NumericMaxScale type.
767            let scalar_type = resolve_pg_type_to_scalar_type(scx, ty)?;
768            match scalar_type {
769                SqlScalarType::Numeric { max_scale } => CastFunc::CastStringToNumeric(max_scale),
770                _ => unreachable!("Numeric must resolve to Numeric"),
771            }
772        }
773        Type::Oid => {
774            if cast_oid_full_range {
775                CastFunc::CastStringToOidFullRange
776            } else {
777                CastFunc::CastStringToOid
778            }
779        }
780        Type::Text => return Ok(None),
781        Type::BpChar { .. } => {
782            // Resolve through the catalog to get the repr CharLength type.
783            let scalar_type = resolve_pg_type_to_scalar_type(scx, ty)?;
784            match scalar_type {
785                SqlScalarType::Char { length } => CastFunc::CastStringToChar {
786                    length,
787                    fail_on_len: true,
788                },
789                _ => unreachable!("BpChar must resolve to Char"),
790            }
791        }
792        Type::VarChar { .. } => {
793            // Resolve through the catalog to get the repr VarCharMaxLength type.
794            let scalar_type = resolve_pg_type_to_scalar_type(scx, ty)?;
795            match scalar_type {
796                SqlScalarType::VarChar { max_length } => CastFunc::CastStringToVarChar {
797                    length: max_length,
798                    fail_on_len: true,
799                },
800                _ => unreachable!("VarChar must resolve to VarChar"),
801            }
802        }
803        Type::Time { .. } => {
804            // Time precision is not yet fully supported; resolve_type strips precision.
805            CastFunc::CastStringToTime
806        }
807        Type::Timestamp { .. } => {
808            // Resolve through the catalog to get the repr TimestampPrecision type.
809            let scalar_type = resolve_pg_type_to_scalar_type(scx, ty)?;
810            match scalar_type {
811                SqlScalarType::Timestamp { precision } => {
812                    CastFunc::CastStringToTimestamp(precision)
813                }
814                _ => unreachable!("Timestamp must resolve to Timestamp"),
815            }
816        }
817        Type::TimestampTz { .. } => {
818            // Resolve through the catalog to get the repr TimestampPrecision type.
819            let scalar_type = resolve_pg_type_to_scalar_type(scx, ty)?;
820            match scalar_type {
821                SqlScalarType::TimestampTz { precision } => {
822                    CastFunc::CastStringToTimestampTz(precision)
823                }
824                _ => unreachable!("TimestampTz must resolve to TimestampTz"),
825            }
826        }
827        Type::Uuid => CastFunc::CastStringToUuid,
828        Type::Int2Vector => CastFunc::CastStringToInt2Vector,
829        Type::MzTimestamp => CastFunc::CastStringToMzTimestamp,
830        // JSON is ingested as JSONB (same as the old plan_cast path).
831        Type::Json => CastFunc::CastStringToJsonb,
832        Type::Array(elem) => {
833            let return_ty = resolve_pg_type_to_scalar_type(scx, ty)?;
834            let elem_cast = build_element_cast_expr(scx, elem, cast_oid_full_range)?;
835            CastFunc::CastStringToArray {
836                return_ty,
837                cast_expr: Box::new(elem_cast),
838            }
839        }
840        Type::List(elem) => {
841            let return_ty = resolve_pg_type_to_scalar_type(scx, ty)?;
842            let elem_cast = build_element_cast_expr(scx, elem, cast_oid_full_range)?;
843            CastFunc::CastStringToList {
844                return_ty,
845                cast_expr: Box::new(elem_cast),
846            }
847        }
848        Type::Map { value_type } => {
849            let return_ty = resolve_pg_type_to_scalar_type(scx, ty)?;
850            let value_cast = build_element_cast_expr(scx, value_type, cast_oid_full_range)?;
851            CastFunc::CastStringToMap {
852                return_ty,
853                cast_expr: Box::new(value_cast),
854            }
855        }
856        Type::Range { element_type } => {
857            let return_ty = resolve_pg_type_to_scalar_type(scx, ty)?;
858            let elem_cast = build_element_cast_expr(scx, element_type, cast_oid_full_range)?;
859            CastFunc::CastStringToRange {
860                return_ty,
861                cast_expr: Box::new(elem_cast),
862            }
863        }
864        // reg* types require subquery-based casts that storage cannot
865        // evaluate. Users can ingest them as text via TEXT COLUMNS.
866        Type::RegType | Type::RegClass | Type::RegProc => {
867            return Err(PlanError::TableContainsUningestableTypes {
868                name: String::new(),
869                type_: ty.name().to_string(),
870                column: String::new(),
871            });
872        }
873        other => {
874            return Err(PlanError::TableContainsUningestableTypes {
875                name: String::new(),
876                type_: other.name().to_string(),
877                column: String::new(),
878            });
879        }
880    };
881    Ok(Some(cast_func))
882}
883
884/// Build the element cast expression for container types (Array, List, Map,
885/// Range). The element expression operates on a single-column input row
886/// containing the text-encoded element at column 0.
887fn build_element_cast_expr(
888    scx: &StatementContext,
889    elem_ty: &mz_pgrepr::Type,
890    cast_oid_full_range: bool,
891) -> Result<StorageScalarExpr, PlanError> {
892    match pg_type_to_cast_func(scx, elem_ty, cast_oid_full_range)? {
893        None => Ok(StorageScalarExpr::Column(0)),
894        Some(cast_func) => Ok(StorageScalarExpr::CallUnary(
895            cast_func,
896            Box::new(StorageScalarExpr::Column(0)),
897        )),
898    }
899}
900
901mod privileges {
902    use mz_postgres_util::{PostgresError, query, sql};
903
904    use super::*;
905    use crate::plan::PlanError;
906    use crate::pure::PgSourcePurificationError;
907
908    async fn check_schema_privileges(client: &Client, table_oids: &[Oid]) -> Result<(), PlanError> {
909        let invalid_schema_privileges_rows = query(
910            client,
911            sql!(
912                "
913                WITH distinct_namespace AS (
914                    SELECT
915                        DISTINCT n.oid, n.nspname AS schema_name
916                    FROM unnest($1::OID[]) AS oids (oid)
917                    JOIN pg_class AS c ON c.oid = oids.oid
918                    JOIN pg_namespace AS n ON c.relnamespace = n.oid
919                )
920                SELECT d.schema_name
921                FROM distinct_namespace AS d
922                WHERE
923                    NOT has_schema_privilege(CURRENT_USER::TEXT, d.oid, 'usage')"
924            ),
925            &[&table_oids],
926        )
927        .await?;
928
929        let mut invalid_schema_privileges = invalid_schema_privileges_rows
930            .into_iter()
931            .map(|row| row.get("schema_name"))
932            .collect::<Vec<String>>();
933
934        if invalid_schema_privileges.is_empty() {
935            Ok(())
936        } else {
937            invalid_schema_privileges.sort();
938            Err(PgSourcePurificationError::UserLacksUsageOnSchemas {
939                schemas: invalid_schema_privileges,
940            })?
941        }
942    }
943
944    /// Ensure that the user specified in `config` has:
945    ///
946    /// -`SELECT` privileges for the identified `tables`.
947    ///
948    ///  `tables`'s elements should be of the structure `[<schema name>, <table name>]`.
949    ///
950    /// - `USAGE` privileges on the schemas references in `tables`.
951    ///
952    /// # Panics
953    /// If `config` does not specify a user.
954    pub async fn check_table_privileges(
955        client: &Client,
956        table_oids: &[Oid],
957    ) -> Result<(), PlanError> {
958        check_schema_privileges(client, table_oids).await?;
959
960        let invalid_table_privileges_rows = query(
961            client,
962            sql!(
963                "
964            SELECT
965                format('%I.%I', n.nspname, c.relname) AS schema_qualified_table_name
966             FROM unnest($1::oid[]) AS oids (oid)
967             JOIN
968                 pg_class c ON c.oid = oids.oid
969             JOIN
970                 pg_namespace n ON c.relnamespace = n.oid
971             WHERE NOT has_table_privilege(CURRENT_USER::text, c.oid, 'select')"
972            ),
973            &[&table_oids],
974        )
975        .await?;
976
977        let mut invalid_table_privileges = invalid_table_privileges_rows
978            .into_iter()
979            .map(|row| row.get("schema_qualified_table_name"))
980            .collect::<Vec<String>>();
981
982        if invalid_table_privileges.is_empty() {
983            Ok(())
984        } else {
985            invalid_table_privileges.sort();
986            Err(PgSourcePurificationError::UserLacksSelectOnTables {
987                tables: invalid_table_privileges,
988            })?
989        }
990    }
991
992    /// Ensure that the user specified in `config` can read data from tables if row level security
993    /// (RLS) is enabled. If the user/role does not have the BYPASSRLS attribute set, there is
994    /// the possibility that MZ may not be able to read all data during the snapshot, which would
995    /// result in missing data.
996    pub async fn check_rls_privileges(
997        client: &Client,
998        table_oids: &[Oid],
999    ) -> Result<(), PlanError> {
1000        match mz_postgres_util::validate_no_rls_policies(client, table_oids).await {
1001            Ok(_) => Ok(()),
1002            Err(err) => match err {
1003                // This is a little gross to do, but PlanError::PostgresConnectionErr implements
1004                // From<PostgresError>, and the error in that case would be
1005                // "failed to connect to PostgreSQL database", which doesn't make any sense.
1006                PostgresError::BypassRLSRequired(tables) => {
1007                    Err(PgSourcePurificationError::BypassRLSRequired { tables })?
1008                }
1009                _ => Err(err)?,
1010            },
1011        }
1012    }
1013}
1014
1015mod replica_identity {
1016    use mz_postgres_util::{query, sql};
1017
1018    use super::*;
1019    use crate::plan::PlanError;
1020    use crate::pure::PgSourcePurificationError;
1021
1022    /// Ensures that all provided OIDs are tables with `REPLICA IDENTITY FULL`.
1023    pub async fn check_replica_identity_full(
1024        client: &Client,
1025        table_oids: &[Oid],
1026    ) -> Result<(), PlanError> {
1027        let invalid_replica_identity_rows = query(
1028            client,
1029            sql!(
1030                "
1031            SELECT
1032                format('%I.%I', n.nspname, c.relname) AS schema_qualified_table_name
1033             FROM unnest($1::oid[]) AS oids (oid)
1034             JOIN
1035                 pg_class c ON c.oid = oids.oid
1036             JOIN
1037                 pg_namespace n ON c.relnamespace = n.oid
1038             WHERE relreplident != 'f' OR relreplident IS NULL;"
1039            ),
1040            &[&table_oids],
1041        )
1042        .await?;
1043
1044        let mut invalid_replica_identity = invalid_replica_identity_rows
1045            .into_iter()
1046            .map(|row| row.get("schema_qualified_table_name"))
1047            .collect::<Vec<String>>();
1048
1049        if invalid_replica_identity.is_empty() {
1050            Ok(())
1051        } else {
1052            invalid_replica_identity.sort();
1053            Err(PgSourcePurificationError::NotTablesWReplicaIdentityFull {
1054                items: invalid_replica_identity,
1055            })?
1056        }
1057    }
1058}