Skip to main content

mz_sql/pure/
mysql.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//! MySQL utilities for SQL purification.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use mz_mysql_util::{
15    MySqlError, MySqlTableDesc, QualifiedTableRef, SYSTEM_SCHEMAS, validate_source_privileges,
16};
17use mz_proto::RustType;
18use mz_sql_parser::ast::display::AstDisplay;
19use mz_sql_parser::ast::{
20    ColumnDef, CreateSubsourceOption, CreateSubsourceOptionName, CreateSubsourceStatement,
21    ExternalReferences, Ident, MySqlConfigOptionName, TableConstraint, WithOptionValue,
22};
23use mz_sql_parser::ast::{UnresolvedItemName, Value};
24use mz_storage_types::sources::{SourceExportStatementDetails, SourceReferenceResolver};
25use prost::Message;
26
27use crate::names::Aug;
28use crate::plan::{PlanError, StatementContext};
29use crate::pure::{MySqlSourcePurificationError, ResolvedItemName};
30
31use super::references::RetrievedSourceReferences;
32use super::{
33    FilterConstraints, PurifiedExportDetails, PurifiedSourceExport, RequestedSourceExport,
34    SourceReferencePolicy,
35};
36
37/// The name of the fake database that we use for MySQL sources
38/// to fit our model of a 3-layer catalog. MySQL doesn't have a concept
39/// of databases AND schemas, it treats both as the same thing.
40pub(crate) static MYSQL_DATABASE_FAKE_NAME: &str = "mysql";
41
42/// Convert an unresolved item name to a qualified table reference.
43pub(super) fn external_reference_to_table(
44    name: &UnresolvedItemName,
45) -> Result<QualifiedTableRef<'_>, MySqlSourcePurificationError> {
46    if name.0.len() != 2 {
47        Err(MySqlSourcePurificationError::InvalidTableReference(
48            name.to_string(),
49        ))?
50    }
51    Ok(QualifiedTableRef {
52        schema_name: name.0[0].as_str(),
53        table_name: name.0[1].as_str(),
54    })
55}
56
57pub fn generate_create_subsource_statements(
58    scx: &StatementContext,
59    source_name: ResolvedItemName,
60    requested_subsources: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
61) -> Result<Vec<CreateSubsourceStatement<Aug>>, PlanError> {
62    let mut subsources = Vec::with_capacity(requested_subsources.len());
63
64    for (subsource_name, purified_export) in requested_subsources {
65        let MySqlExportStatementValues {
66            columns,
67            constraints,
68            text_columns,
69            exclude_columns,
70            details,
71            external_reference,
72        } = generate_source_export_statement_values(scx, purified_export)?;
73
74        let mut with_options = vec![
75            CreateSubsourceOption {
76                name: CreateSubsourceOptionName::ExternalReference,
77                value: Some(WithOptionValue::UnresolvedItemName(external_reference)),
78            },
79            CreateSubsourceOption {
80                name: CreateSubsourceOptionName::Details,
81                value: Some(WithOptionValue::Value(Value::String(hex::encode(
82                    details.into_proto().encode_to_vec(),
83                )))),
84            },
85        ];
86
87        if let Some(text_columns) = text_columns {
88            with_options.push(CreateSubsourceOption {
89                name: CreateSubsourceOptionName::TextColumns,
90                value: Some(WithOptionValue::Sequence(text_columns)),
91            });
92        }
93
94        if let Some(exclude_columns) = exclude_columns {
95            with_options.push(CreateSubsourceOption {
96                name: CreateSubsourceOptionName::ExcludeColumns,
97                value: Some(WithOptionValue::Sequence(exclude_columns)),
98            });
99        }
100
101        // Create the subsource statement
102        let subsource = CreateSubsourceStatement {
103            name: subsource_name,
104            columns,
105            of_source: Some(source_name.clone()),
106            constraints,
107            if_not_exists: false,
108            with_options,
109        };
110        subsources.push(subsource);
111    }
112
113    Ok(subsources)
114}
115
116pub(super) struct MySqlExportStatementValues {
117    pub(super) columns: Vec<ColumnDef<Aug>>,
118    pub(super) constraints: Vec<TableConstraint<Aug>>,
119    pub(super) text_columns: Option<Vec<WithOptionValue<Aug>>>,
120    pub(super) exclude_columns: Option<Vec<WithOptionValue<Aug>>>,
121    pub(super) details: SourceExportStatementDetails,
122    pub(super) external_reference: UnresolvedItemName,
123}
124
125pub(super) fn generate_source_export_statement_values(
126    scx: &StatementContext,
127    purified_export: PurifiedSourceExport,
128) -> Result<MySqlExportStatementValues, PlanError> {
129    let PurifiedExportDetails::MySql {
130        table,
131        text_columns,
132        exclude_columns,
133        initial_gtid_set,
134        binlog_full_metadata,
135    } = purified_export.details
136    else {
137        bail_internal!("purified export details must be mysql");
138    };
139
140    // Figure out the schema of the subsource
141    let mut columns = vec![];
142    for c in table.columns.iter() {
143        match c.column_type {
144            // This column is intentionally ignored, so we don't generate a column for it in
145            // the subsource.
146            None => {}
147            Some(ref column_type) => {
148                let name = Ident::new(&c.name)?;
149
150                let ty = mz_pgrepr::Type::from(&column_type.scalar_type);
151                let data_type = scx.resolve_type(ty)?;
152                let mut col_options = vec![];
153
154                if !column_type.nullable {
155                    col_options.push(mz_sql_parser::ast::ColumnOptionDef {
156                        name: None,
157                        option: mz_sql_parser::ast::ColumnOption::NotNull,
158                    });
159                }
160                columns.push(ColumnDef {
161                    name,
162                    data_type,
163                    collation: None,
164                    options: col_options,
165                });
166            }
167        }
168    }
169
170    let mut constraints = vec![];
171    for key in table.keys.iter() {
172        let columns: Result<Vec<Ident>, _> = key.columns.iter().map(Ident::new).collect();
173
174        let constraint = mz_sql_parser::ast::TableConstraint::Unique {
175            name: Some(Ident::new(&key.name)?),
176            columns: columns?,
177            is_primary: key.is_primary,
178            // MySQL always permits multiple nulls values in unique indexes.
179            nulls_not_distinct: false,
180        };
181
182        // We take the first constraint available to be the primary key.
183        if key.is_primary {
184            constraints.insert(0, constraint);
185        } else {
186            constraints.push(constraint);
187        }
188    }
189
190    let details = SourceExportStatementDetails::MySql {
191        table,
192        initial_gtid_set,
193        binlog_full_metadata,
194    };
195
196    let text_columns = text_columns.map(|mut columns| {
197        columns.sort();
198        columns
199            .into_iter()
200            .map(WithOptionValue::Ident::<Aug>)
201            .collect()
202    });
203
204    let exclude_columns = exclude_columns.map(|mut columns| {
205        columns.sort();
206        columns
207            .into_iter()
208            .map(WithOptionValue::Ident::<Aug>)
209            .collect()
210    });
211    Ok(MySqlExportStatementValues {
212        columns,
213        constraints,
214        text_columns,
215        exclude_columns,
216        details,
217        external_reference: purified_export.external_reference,
218    })
219}
220
221/// Map a list of column references to a map of table references to column names.
222pub(super) fn map_column_refs<'a>(
223    cols: &'a [UnresolvedItemName],
224    option_type: MySqlConfigOptionName,
225) -> Result<BTreeMap<QualifiedTableRef<'a>, BTreeSet<&'a str>>, PlanError> {
226    let mut table_to_cols = BTreeMap::new();
227    for name in cols.iter() {
228        // We only support fully qualified references for now (e.g. `schema_name.table_name.column_name`)
229        if name.0.len() == 3 {
230            let key = mz_mysql_util::QualifiedTableRef {
231                schema_name: name.0[0].as_str(),
232                table_name: name.0[1].as_str(),
233            };
234            let new = table_to_cols
235                .entry(key)
236                .or_insert_with(BTreeSet::new)
237                .insert(name.0[2].as_str());
238            if !new {
239                return Err(PlanError::InvalidOptionValue {
240                    option_name: option_type.to_ast_string_simple(),
241                    err: Box::new(PlanError::UnexpectedDuplicateReference { name: name.clone() }),
242                });
243            }
244        } else {
245            return Err(PlanError::InvalidOptionValue {
246                option_name: option_type.to_ast_string_simple(),
247                err: Box::new(PlanError::UnderqualifiedColumnName(name.to_string())),
248            });
249        }
250    }
251    Ok(table_to_cols)
252}
253
254/// Normalize column references to a sorted, deduplicated options list of column names.
255pub(super) fn normalize_column_refs(
256    cols: Vec<UnresolvedItemName>,
257    reference_resolver: &SourceReferenceResolver,
258    tables: &[RequestedSourceExport<MySqlTableDesc>],
259    option_name: &str,
260) -> Result<Vec<WithOptionValue<Aug>>, MySqlSourcePurificationError> {
261    let (seq, unknown): (Vec<_>, Vec<_>) = cols.into_iter().partition(|name| {
262        let (column_name, qual) = name.0.split_last().expect("non-empty");
263        match reference_resolver.resolve_idx(qual) {
264            // TODO: this needs to also introduce the maximum qualification on
265            // the columns, i.e. ensure they have the schema name.
266            Ok(idx) => tables[idx]
267                .meta
268                .columns
269                .iter()
270                .any(|n| &n.name == column_name.as_str()),
271            Err(_) => false,
272        }
273    });
274
275    if !unknown.is_empty() {
276        return Err(MySqlSourcePurificationError::DanglingColumns {
277            option_name: option_name.to_string(),
278            items: unknown,
279        });
280    }
281
282    let mut seq: Vec<_> = seq
283        .into_iter()
284        .map(WithOptionValue::UnresolvedItemName)
285        .collect();
286    seq.sort();
287    seq.dedup();
288    Ok(seq)
289}
290
291pub(super) async fn validate_requested_references_privileges(
292    requested_external_references: impl Iterator<Item = &UnresolvedItemName>,
293    conn: &mut mz_mysql_util::MySqlConn,
294) -> Result<(), PlanError> {
295    // Ensure that we have correct privileges on all tables; we have to do this before we
296    // start snapshotting because if we discover we cannot `SELECT` from a table while
297    // snapshotting, we break the entire source.
298    let tables_to_check_permissions = requested_external_references
299        .map(external_reference_to_table)
300        .collect::<Result<Vec<_>, _>>()?;
301
302    validate_source_privileges(&mut *conn, &tables_to_check_permissions)
303        .await
304        .map_err(|err| match err {
305            MySqlError::MissingPrivileges(missing_privileges) => {
306                MySqlSourcePurificationError::UserLacksPrivileges(
307                    missing_privileges
308                        .into_iter()
309                        .map(|mp| (mp.privilege, mp.qualified_table_name))
310                        .collect(),
311                )
312                .into()
313            }
314            _ => PlanError::MySqlConnectionErr { cause: err.into() },
315        })?;
316
317    Ok(())
318}
319
320pub(super) struct PurifiedSourceExports {
321    /// map of source export names to the details of the export
322    pub(super) source_exports: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
323    // NOTE(roshan): The text columns and exclude columns are already part of their
324    // appropriate `source_exports` above, but these are returned to allow
325    // round-tripping a `CREATE SOURCE` statement while we still allow creating
326    // implicit subsources from `CREATE SOURCE`. Remove once
327    // fully deprecating that feature and forcing users to use explicit
328    // `CREATE TABLE .. FROM SOURCE` statements.
329    pub(super) normalized_text_columns: Vec<WithOptionValue<Aug>>,
330    pub(super) normalized_exclude_columns: Vec<WithOptionValue<Aug>>,
331}
332
333// Purify the requested external references, returning a set of purified
334// source exports corresponding to external tables, and and additional
335// fields necessary to generate relevant statements and update statement options
336pub(super) async fn purify_source_exports(
337    conn: &mut mz_mysql_util::MySqlConn,
338    retrieved_references: &RetrievedSourceReferences,
339    requested_references: &Option<ExternalReferences>,
340    text_columns: Vec<UnresolvedItemName>,
341    exclude_columns: Vec<UnresolvedItemName>,
342    // NOTE: constraints may only be excluded when exactly one export is
343    // purified, as `CREATE TABLE .. FROM SOURCE` does, since constraint names
344    // are validated against that single table's keys. Enforced below.
345    filter_constraints: &FilterConstraints,
346    exclude_all_not_null_constraints: bool,
347    unresolved_source_name: &UnresolvedItemName,
348    initial_gtid_set: String,
349    reference_policy: &SourceReferencePolicy,
350    binlog_full_metadata: bool,
351) -> Result<PurifiedSourceExports, PlanError> {
352    let requested_exports = match requested_references.as_ref() {
353        Some(requested) if matches!(reference_policy, SourceReferencePolicy::NotAllowed) => {
354            Err(PlanError::UseTablesForSources(requested.to_string()))?
355        }
356        Some(requested) => retrieved_references
357            .requested_source_exports(Some(requested), unresolved_source_name)?,
358        None => {
359            if matches!(reference_policy, SourceReferencePolicy::Required) {
360                Err(MySqlSourcePurificationError::RequiresExternalReferences)?
361            }
362
363            // If no external reference is specified, it does not make sense to include
364            // or text or exclude columns.
365            if !text_columns.is_empty() {
366                Err(
367                    MySqlSourcePurificationError::UnnecessaryOptionsWithoutReferences(
368                        "TEXT COLUMNS".to_string(),
369                    ),
370                )?
371            }
372            if !exclude_columns.is_empty() {
373                Err(
374                    MySqlSourcePurificationError::UnnecessaryOptionsWithoutReferences(
375                        "EXCLUDE COLUMNS".to_string(),
376                    ),
377                )?
378            }
379
380            return Ok(PurifiedSourceExports {
381                source_exports: BTreeMap::new(),
382                normalized_text_columns: vec![],
383                normalized_exclude_columns: vec![],
384            });
385        }
386    };
387
388    if requested_exports.is_empty() {
389        sql_bail!(
390            "MySQL source must ingest at least one table, but {} matched none",
391            requested_references
392                .as_ref()
393                .unwrap()
394                .to_ast_string_simple()
395        );
396    }
397
398    super::validate_source_export_names(&requested_exports)?;
399
400    if filter_constraints.excludes_any() && requested_exports.len() != 1 {
401        sql_bail!(
402            "EXCLUDE CONSTRAINTS and EXCLUDE ALL CONSTRAINTS apply to exactly one table, \
403             but {} tables were referenced",
404            requested_exports.len()
405        );
406    }
407
408    let text_cols_map = map_column_refs(&text_columns, MySqlConfigOptionName::TextColumns)?;
409    let exclude_columns_map =
410        map_column_refs(&exclude_columns, MySqlConfigOptionName::ExcludeColumns)?;
411
412    // Convert the raw tables into a format that we can use to generate source exports
413    // using any applicable text_columns and exclude_columns.
414    let tables = requested_exports
415        .into_iter()
416        .map(|requested_export| {
417            let table = requested_export.meta.mysql_table().expect("is mysql");
418            let table_ref = table.table_ref();
419
420            if let FilterConstraints::Exclude(names) = filter_constraints {
421                let missing: Vec<_> = names
422                    .iter()
423                    .filter(|n| !table.keys.iter().any(|k| &&k.name == n))
424                    .cloned()
425                    .collect();
426                if !missing.is_empty() {
427                    return Err(MySqlSourcePurificationError::ConstraintsNotFound {
428                        table: format!("{}.{}", table.schema_name, table.name),
429                        constraints: missing,
430                    }
431                    .into());
432                }
433            }
434
435            // we are cloning the BTreeSet<&str> so we can avoid a borrow on `table` here
436            let text_cols = text_cols_map.get(&table_ref).map(|s| s.clone());
437            let exclude_columns = exclude_columns_map.get(&table_ref).map(|s| s.clone());
438            let parsed_table = table
439                .clone()
440                .to_desc(text_cols.as_ref(), exclude_columns.as_ref())
441                .map_err(|err| match err {
442                    mz_mysql_util::MySqlError::UnsupportedDataTypes { columns } => {
443                        PlanError::from(MySqlSourcePurificationError::UnrecognizedTypes {
444                            cols: columns
445                                .into_iter()
446                                .map(|c| (c.qualified_table_name, c.column_name, c.column_type))
447                                .collect(),
448                        })
449                    }
450                    mz_mysql_util::MySqlError::DuplicatedColumnNames {
451                        qualified_table_name,
452                        columns,
453                    } => PlanError::from(MySqlSourcePurificationError::DuplicatedColumnNames(
454                        qualified_table_name,
455                        columns,
456                    )),
457                    _ => err.into(),
458                })?;
459            let mut parsed_table = parsed_table;
460            match filter_constraints {
461                FilterConstraints::Exclude(names) => {
462                    parsed_table.keys.retain(|k| !names.contains(&k.name));
463                }
464                FilterConstraints::ExcludeAll => {
465                    // Marking columns as nullable allows dropping (and adding) the
466                    // NOT NULL constraint without an outage.
467                    parsed_table.keys.clear();
468                    for c in &mut parsed_table.columns {
469                        if let Some(column_type) = &mut c.column_type {
470                            column_type.nullable = true;
471                        }
472                    }
473                }
474            }
475            if exclude_all_not_null_constraints {
476                // Columns of a retained primary key stay NOT NULL. The planner
477                // declares them NOT NULL in the table's `RelationDesc`
478                // regardless of this desc, so a nullable column here would
479                // drop the decoder's NULL check and let a NULL reach persist
480                // in a column it treats as non-nullable, which panics.
481                // The reason we need this to avoid panics for MySQL sources
482                // and not for Postgres sources is because schemas are compared
483                // by name/column in MySQL. Postgres on the other hand has an OID
484                // that would differ and prevent this case.
485                let pk_cols: BTreeSet<&str> = parsed_table
486                    .keys
487                    .iter()
488                    .filter(|k| k.is_primary)
489                    .flat_map(|k| k.columns.iter().map(String::as_str))
490                    .collect();
491                for c in &mut parsed_table.columns {
492                    if pk_cols.contains(c.name.as_str()) {
493                        continue;
494                    }
495                    if let Some(column_type) = &mut c.column_type {
496                        column_type.nullable = true;
497                    }
498                }
499            }
500            Ok(requested_export.change_meta(parsed_table))
501        })
502        .collect::<Result<Vec<_>, PlanError>>()?;
503
504    if tables.is_empty() {
505        Err(MySqlSourcePurificationError::EmptyDatabase)?;
506    }
507
508    validate_requested_references_privileges(tables.iter().map(|t| &t.external_reference), conn)
509        .await?;
510
511    let reference_resolver = SourceReferenceResolver::new(
512        MYSQL_DATABASE_FAKE_NAME,
513        &tables.iter().map(|r| &r.meta).collect::<Vec<_>>(),
514    )?;
515
516    // Normalize column options and remove unused column references.
517    let normalized_text_columns = normalize_column_refs(
518        text_columns.clone(),
519        &reference_resolver,
520        &tables,
521        "TEXT COLUMNS",
522    )?;
523    let normalized_exclude_columns = normalize_column_refs(
524        exclude_columns.clone(),
525        &reference_resolver,
526        &tables,
527        "EXCLUDE COLUMNS",
528    )?;
529
530    let source_exports = tables
531        .into_iter()
532        .map(|r| {
533            let table_ref = mz_mysql_util::QualifiedTableRef {
534                schema_name: r.meta.schema_name.as_str(),
535                table_name: r.meta.name.as_str(),
536            };
537            (
538                r.name,
539                PurifiedSourceExport {
540                    external_reference: r.external_reference,
541                    details: PurifiedExportDetails::MySql {
542                        table: r.meta.clone(),
543                        text_columns: text_cols_map.get(&table_ref).map(|cols| {
544                            cols.iter()
545                                .map(|c| Ident::new(*c).expect("validated above"))
546                                .collect()
547                        }),
548                        exclude_columns: exclude_columns_map.get(&table_ref).map(|cols| {
549                            cols.iter()
550                                .map(|c| Ident::new(*c).expect("validated above"))
551                                .collect()
552                        }),
553                        initial_gtid_set: initial_gtid_set.clone(),
554                        binlog_full_metadata,
555                    },
556                },
557            )
558        })
559        .collect();
560
561    Ok(PurifiedSourceExports {
562        source_exports,
563        normalized_text_columns,
564        normalized_exclude_columns,
565    })
566}
567
568/// Checks if the requested external references contain any explicit references
569/// to system schemas, since these are otherwise default excluded when retrieving
570/// tables from MySQL.
571pub(super) fn references_system_schemas(requested_references: &Option<ExternalReferences>) -> bool {
572    match requested_references {
573        Some(requested) => match requested {
574            ExternalReferences::All => false,
575            ExternalReferences::SubsetSchemas(schemas) => schemas
576                .iter()
577                .any(|schema| SYSTEM_SCHEMAS.contains(&schema.as_str())),
578            ExternalReferences::SubsetTables(tables) => tables.iter().any(|table| {
579                SYSTEM_SCHEMAS.contains(
580                    &external_reference_to_table(&table.reference)
581                        .map(|t| t.schema_name)
582                        .unwrap_or_default(),
583                )
584            }),
585        },
586        None => false,
587    }
588}
589
590pub async fn ensure_binlog_full_metadata(
591    conn: &mut mz_mysql_util::MySqlConn,
592) -> Result<(), MySqlSourcePurificationError> {
593    let version = mz_mysql_util::query_sys_var(conn, "version")
594        .await
595        .map_err(|err| MySqlSourcePurificationError::InvalidConnection(err.into()))?;
596    if version_compare::compare_to(&version, "8.0.1", version_compare::Cmp::Lt).map_err(|_| {
597        MySqlSourcePurificationError::UnsupportedMySqlVersion {
598            version: version.clone(),
599        }
600    })? {
601        Err(MySqlSourcePurificationError::UnsupportedMySqlVersion { version })?;
602    }
603    let binlog_metadata_setting = mz_mysql_util::query_sys_var(conn, "binlog_row_metadata")
604        .await
605        .map_err(|err| MySqlSourcePurificationError::InvalidConnection(err.into()))?;
606    let binlog_full_metadata = binlog_metadata_setting.eq_ignore_ascii_case("FULL");
607    if !binlog_full_metadata {
608        Err(
609            MySqlSourcePurificationError::UnsupportedBinlogMetadataSetting {
610                setting: binlog_metadata_setting,
611            },
612        )
613    } else {
614        Ok(())
615    }
616}
617
618pub async fn is_binlog_full_metadata(
619    conn: &mut mz_mysql_util::MySqlConn,
620) -> Result<bool, MySqlSourcePurificationError> {
621    match ensure_binlog_full_metadata(conn).await {
622        Ok(_) => Ok(true),
623        // If we get an InvalidConnection error, we have failed to query the upstream for
624        // version or binlog format information, which is fatal to purification.
625        // Other types of errors simply indicate that binlow_row_metadata is not full for some reason.
626        Err(MySqlSourcePurificationError::InvalidConnection(err)) => {
627            Err(MySqlSourcePurificationError::InvalidConnection(err))
628        }
629        Err(err) => {
630            tracing::info!(
631                error = ?err,
632                "unable to verify MySQL binlog format, proceeding without full metadata"
633            );
634            Ok(false)
635        }
636    }
637}