1use 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
37pub(crate) static MYSQL_DATABASE_FAKE_NAME: &str = "mysql";
41
42pub(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 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 let mut columns = vec![];
142 for c in table.columns.iter() {
143 match c.column_type {
144 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 nulls_not_distinct: false,
180 };
181
182 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
221pub(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 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
254pub(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 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 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 pub(super) source_exports: BTreeMap<UnresolvedItemName, PurifiedSourceExport>,
323 pub(super) normalized_text_columns: Vec<WithOptionValue<Aug>>,
330 pub(super) normalized_exclude_columns: Vec<WithOptionValue<Aug>>,
331}
332
333pub(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 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 !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 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 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 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 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 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
568pub(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 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}