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