Skip to main content

mz_mysql_util/
desc.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
10use std::collections::BTreeSet;
11
12use mz_proto::{ProtoType, RustType, TryFromProtoError};
13use mz_repr::SqlColumnType;
14#[cfg(any(test, feature = "proptest"))]
15use proptest::prelude::any;
16#[cfg(any(test, feature = "proptest"))]
17use proptest_derive::Arbitrary;
18use serde::{Deserialize, Serialize};
19
20use self::proto_my_sql_column_desc::Meta;
21use crate::schema_change::{KeyRef, SchemaChange, SchemaChangeError};
22
23include!(concat!(env!("OUT_DIR"), "/mz_mysql_util.rs"));
24
25#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
26#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
27pub struct MySqlTableDesc {
28    /// In MySQL the schema and database of a table are synonymous.
29    pub schema_name: String,
30    /// The name of the table.
31    pub name: String,
32    /// Columns for the table
33    ///
34    /// The index of each column is based on its `ordinal_position`
35    /// reported by the information_schema.columns table, which defines
36    /// the order of column values when received in a row.
37    #[cfg_attr(
38        any(test, feature = "proptest"),
39        proptest(strategy = "proptest::collection::vec(any::<MySqlColumnDesc>(), 0..4)")
40    )]
41    pub columns: Vec<MySqlColumnDesc>,
42    /// Applicable keys for this table (i.e. primary key and unique
43    /// constraints).
44    #[cfg_attr(
45        any(test, feature = "proptest"),
46        proptest(strategy = "proptest::collection::btree_set(any::<MySqlKeyDesc>(), 0..4)")
47    )]
48    pub keys: BTreeSet<MySqlKeyDesc>,
49}
50
51impl RustType<ProtoMySqlTableDesc> for MySqlTableDesc {
52    fn into_proto(&self) -> ProtoMySqlTableDesc {
53        ProtoMySqlTableDesc {
54            schema_name: self.schema_name.clone(),
55            name: self.name.clone(),
56            columns: self.columns.iter().map(|c| c.into_proto()).collect(),
57            keys: self.keys.iter().map(|c| c.into_proto()).collect(),
58        }
59    }
60
61    fn from_proto(proto: ProtoMySqlTableDesc) -> Result<Self, TryFromProtoError> {
62        Ok(Self {
63            schema_name: proto.schema_name,
64            name: proto.name,
65            columns: proto
66                .columns
67                .into_iter()
68                .map(MySqlColumnDesc::from_proto)
69                .collect::<Result<_, _>>()?,
70            keys: proto
71                .keys
72                .into_iter()
73                .map(MySqlKeyDesc::from_proto)
74                .collect::<Result<_, _>>()?,
75        })
76    }
77}
78
79impl MySqlTableDesc {
80    /// Determines if two `MySqlTableDesc` are compatible with one another in
81    /// a way that Materialize can handle.
82    ///
83    /// Currently this means that the values are equal except for the following
84    /// exceptions:
85    /// - `self`'s columns are a prefix of `other`'s columns.
86    /// - `self`'s keys are all present in `other`
87    ///
88    /// On incompatibility, the error describes the first mismatch found and,
89    /// where possible, how to recover from it. The error text becomes the
90    /// permanent, user-visible error for the stalled table, so it must stand
91    /// on its own.
92    pub fn determine_compatibility(
93        &self,
94        other: &MySqlTableDesc,
95        binlog_full_metadata: bool,
96    ) -> Result<(), SchemaChangeError> {
97        if self == other {
98            return Ok(());
99        }
100
101        if self.schema_name != other.schema_name || self.name != other.name {
102            return Err(self.build_schema_change_error(SchemaChange::TableRenamed));
103        }
104
105        // In the case that we don't have full binlog row metadata, `columns` is ordered by the
106        // ordinal position of each column in the table, so as long as `self.columns` is a
107        // compatible prefix of `other.columns`, we can ignore extra columns from `other.columns`.
108        //
109        // If we do have full metadata, then we can match columns by name and just check that all
110        // columns in `self.columns` are present and compatible with columns in `other.columns`.
111        for (i, self_column) in self.columns.iter().enumerate() {
112            if self_column.column_type.is_none() {
113                // This is an excluded column and can be ignored.
114                continue;
115            }
116            let wire_idx = if !binlog_full_metadata {
117                // No column name metadata, so we match by index.
118                (i < other.columns.len()).then_some(i)
119            } else {
120                // This means the row from the binlog has column name included in the metadata,
121                // so we can match on that instead of position.
122                other
123                    .columns
124                    .iter()
125                    .position(|oc| oc.name.as_str() == self_column.name.as_str())
126            };
127
128            let dropped_column_error = || {
129                self.build_schema_change_error(SchemaChange::ColumnDropped {
130                    column: self_column.name.clone(),
131                })
132            };
133            let wire_idx = match wire_idx {
134                Some(idx) => idx,
135                None => return Err(dropped_column_error()),
136            };
137            let other_column = other
138                .columns
139                .get(wire_idx)
140                .ok_or_else(dropped_column_error)?;
141            if let Some(change) = self_column.get_incompatible_schema_change(other_column) {
142                return Err(self.build_schema_change_error(change));
143            }
144        }
145        // Our keys are all still present in exactly the same shape.
146        // TODO: Implement a more relaxed key compatibility check:
147        // We should check that for all keys that we know about there exists an upstream key whose
148        // set of columns is a subset of the set of columns of the key we know about. For example
149        // if we had previously discovered that the table had two compound unique keys, key1 made
150        // up of columns (a, b) and key2 made up of columns (a, c) but now the table only has a
151        // single unique key of just the column a then it's compatible because {a} ⊆ {a, b} and
152        // {a} ⊆ {a, c}.
153        if let Some(key) = self.keys.difference(&other.keys).next() {
154            let still_exists = other.keys.iter().any(|k| k.name == key.name);
155            let key = KeyRef {
156                name: key.name.clone(),
157                is_primary: key.is_primary,
158                columns: key.columns.clone(),
159            };
160            let change = if still_exists {
161                SchemaChange::KeyAltered { key }
162            } else {
163                SchemaChange::KeyDropped { key }
164            };
165            return Err(self.build_schema_change_error(change));
166        }
167
168        Ok(())
169    }
170
171    pub fn build_schema_change_error(&self, change: SchemaChange) -> SchemaChangeError {
172        SchemaChangeError {
173            schema_name: self.schema_name.clone(),
174            name: self.name.clone(),
175            change,
176        }
177    }
178}
179
180#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
181#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
182pub struct MySqlColumnMetaEnum {
183    #[cfg_attr(
184        any(test, feature = "proptest"),
185        proptest(strategy = "proptest::collection::vec(any::<String>(), 0..3)")
186    )]
187    pub values: Vec<String>,
188}
189
190impl RustType<ProtoMySqlColumnMetaEnum> for MySqlColumnMetaEnum {
191    fn into_proto(&self) -> ProtoMySqlColumnMetaEnum {
192        ProtoMySqlColumnMetaEnum {
193            values: self.values.clone(),
194        }
195    }
196
197    fn from_proto(proto: ProtoMySqlColumnMetaEnum) -> Result<Self, TryFromProtoError> {
198        Ok(Self {
199            values: proto.values,
200        })
201    }
202}
203
204trait IsCompatible {
205    fn is_compatible(&self, other: &Self) -> bool;
206}
207
208#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
209#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
210pub enum MySqlColumnMeta {
211    /// The described column is an enum, with the given possible values.
212    Enum(MySqlColumnMetaEnum),
213    /// The described column is a json value.
214    Json,
215    /// The described column is a year value.
216    Year,
217    /// The described column is a date value.
218    Date,
219    /// The described column is a timestamp value with a set precision.
220    Timestamp(u32),
221    /// The described column is a `bit` column, with the given possibly precision.
222    Bit(u32),
223}
224
225impl IsCompatible for Option<MySqlColumnMeta> {
226    fn is_compatible(&self, other: &Option<MySqlColumnMeta>) -> bool {
227        match (self, other) {
228            (None, None) => true,
229            (Some(_), None) => false,
230            (None, Some(_)) => false,
231            (Some(MySqlColumnMeta::Enum(self_enum)), Some(MySqlColumnMeta::Enum(other_enum))) => {
232                // so as long as `self.values` is a compatible prefix of `other.values`, we can
233                // ignore extra values from `other.values`.
234                match other_enum.values.get(0..self_enum.values.len()) {
235                    Some(prefix) => self_enum.values == prefix,
236                    None => false,
237                }
238            }
239            (Some(MySqlColumnMeta::Json), Some(MySqlColumnMeta::Json)) => true,
240            (Some(MySqlColumnMeta::Year), Some(MySqlColumnMeta::Year)) => true,
241            (Some(MySqlColumnMeta::Date), Some(MySqlColumnMeta::Date)) => true,
242            // Timestamps are compatible as long as we don't lose precision
243            (
244                Some(MySqlColumnMeta::Timestamp(precision)),
245                Some(MySqlColumnMeta::Timestamp(other_precision)),
246            ) => precision <= other_precision,
247            // We always cast bit columns to u64's and the max precision of a bit column
248            // is 64 bits, so any bit column is always compatible with another.
249            (Some(MySqlColumnMeta::Bit(_)), Some(MySqlColumnMeta::Bit(_))) => true,
250            _ => false,
251        }
252    }
253}
254
255#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
256#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
257pub struct MySqlColumnDesc {
258    /// The name of the column.
259    pub name: String,
260    /// The intended data type of this column within Materialize
261    /// If this is None, the column is intended to be skipped within Materialize
262    pub column_type: Option<SqlColumnType>,
263    /// Optional metadata about the column that may be necessary for decoding
264    pub meta: Option<MySqlColumnMeta>,
265}
266
267impl RustType<ProtoMySqlColumnDesc> for MySqlColumnDesc {
268    fn into_proto(&self) -> ProtoMySqlColumnDesc {
269        ProtoMySqlColumnDesc {
270            name: self.name.clone(),
271            column_type: self.column_type.into_proto(),
272            meta: self.meta.as_ref().and_then(|meta| match meta {
273                MySqlColumnMeta::Enum(e) => Some(Meta::Enum(e.into_proto())),
274                MySqlColumnMeta::Json => Some(Meta::Json(ProtoMySqlColumnMetaJson {})),
275                MySqlColumnMeta::Year => Some(Meta::Year(ProtoMySqlColumnMetaYear {})),
276                MySqlColumnMeta::Date => Some(Meta::Date(ProtoMySqlColumnMetaDate {})),
277                MySqlColumnMeta::Timestamp(precision) => {
278                    Some(Meta::Timestamp(ProtoMySqlColumnMetaTimestamp {
279                        precision: *precision,
280                    }))
281                }
282                MySqlColumnMeta::Bit(precision) => Some(Meta::Bit(ProtoMySqlColumnMetaBit {
283                    precision: *precision,
284                })),
285            }),
286        }
287    }
288
289    fn from_proto(proto: ProtoMySqlColumnDesc) -> Result<Self, TryFromProtoError> {
290        Ok(Self {
291            name: proto.name,
292            column_type: proto.column_type.into_rust()?,
293            meta: proto
294                .meta
295                .and_then(|meta| match meta {
296                    Meta::Enum(e) => Some(
297                        MySqlColumnMetaEnum::from_proto(e)
298                            .and_then(|e| Ok(MySqlColumnMeta::Enum(e))),
299                    ),
300                    Meta::Json(_) => Some(Ok(MySqlColumnMeta::Json)),
301                    Meta::Year(_) => Some(Ok(MySqlColumnMeta::Year)),
302                    Meta::Date(_) => Some(Ok(MySqlColumnMeta::Date)),
303                    Meta::Timestamp(e) => Some(Ok(MySqlColumnMeta::Timestamp(e.precision))),
304                    Meta::Bit(e) => Some(Ok(MySqlColumnMeta::Bit(e.precision))),
305                })
306                .transpose()?,
307        })
308    }
309}
310
311impl MySqlColumnDesc {
312    /// The first change that makes a column shaped like `other` incompatible
313    /// with `self`, or `None` if `other` can be ingested as `self`.
314    fn get_incompatible_schema_change(&self, other: &MySqlColumnDesc) -> Option<SchemaChange> {
315        let column = self.name.clone();
316        if self.name != other.name {
317            return Some(SchemaChange::ColumnDropped { column });
318        }
319        if !self.meta.is_compatible(&other.meta) {
320            return Some(SchemaChange::ColumnTypeChanged { column });
321        }
322        match (&self.column_type, &other.column_type) {
323            (None, None) => None,
324            (Some(self_type), Some(other_type)) => {
325                if self_type.scalar_type != other_type.scalar_type {
326                    return Some(SchemaChange::ColumnTypeChanged { column });
327                }
328                // A nullable column stays compatible when upstream adds NOT
329                // NULL. Only losing a NOT NULL that we recorded is a change.
330                if !self_type.nullable && other_type.nullable {
331                    return Some(SchemaChange::NotNullDropped { column });
332                }
333                None
334            }
335            (Some(_), None) | (None, Some(_)) => Some(SchemaChange::ColumnTypeChanged { column }),
336        }
337    }
338}
339
340#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Ord, PartialOrd)]
341#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
342pub struct MySqlKeyDesc {
343    /// The name of the index.
344    pub name: String,
345    /// Whether or not this key is the primary key.
346    pub is_primary: bool,
347    /// The columns that make up the key.
348    #[cfg_attr(
349        any(test, feature = "proptest"),
350        proptest(strategy = "proptest::collection::vec(any::<String>(), 0..4)")
351    )]
352    pub columns: Vec<String>,
353}
354
355impl RustType<ProtoMySqlKeyDesc> for MySqlKeyDesc {
356    fn into_proto(&self) -> ProtoMySqlKeyDesc {
357        ProtoMySqlKeyDesc {
358            name: self.name.clone(),
359            is_primary: self.is_primary.clone(),
360            columns: self.columns.clone(),
361        }
362    }
363
364    fn from_proto(proto: ProtoMySqlKeyDesc) -> Result<Self, TryFromProtoError> {
365        Ok(Self {
366            name: proto.name,
367            is_primary: proto.is_primary,
368            columns: proto.columns,
369        })
370    }
371}