Skip to main content

mz_sql_server_util/
inspect.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//! Useful queries to inspect the state of a SQL Server instance.
11
12use anyhow::Context;
13use chrono::NaiveDateTime;
14use futures::Stream;
15use itertools::Itertools;
16use mz_ore::cast::CastFrom;
17use mz_ore::retry::RetryResult;
18use smallvec::SmallVec;
19use std::collections::BTreeMap;
20use std::fmt;
21use std::sync::Arc;
22use std::time::Duration;
23use tiberius::numeric::Numeric;
24
25use crate::cdc::{Lsn, RowFilterOption};
26use crate::desc::{
27    SqlServerCaptureInstanceRaw, SqlServerColumnRaw, SqlServerQualifiedTableName,
28    SqlServerTableConstraintRaw, SqlServerTableRaw,
29};
30use crate::{Client, SqlServerError, quote_identifier};
31
32/// Returns the minimum log sequence number for the specified `capture_instance`.
33///
34/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-cdc-get-min-lsn-transact-sql?view=sql-server-ver16>
35pub async fn get_min_lsn(
36    client: &mut Client,
37    capture_instance: &str,
38) -> Result<Lsn, SqlServerError> {
39    static MIN_LSN_QUERY: &str = "SELECT sys.fn_cdc_get_min_lsn(@P1);";
40    let result = client.query(MIN_LSN_QUERY, &[&capture_instance]).await?;
41
42    mz_ore::soft_assert_eq_or_log!(result.len(), 1);
43    parse_lsn(&result[..1])
44}
45/// Returns the minimum log sequence number for the specified `capture_instance`, retrying
46/// if the log sequence number is not available.
47///
48/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-cdc-get-min-lsn-transact-sql?view=sql-server-ver16>
49pub async fn get_min_lsn_retry(
50    client: &mut Client,
51    capture_instance: &str,
52    max_retry_duration: Duration,
53) -> Result<Lsn, SqlServerError> {
54    let (_client, lsn_result) = mz_ore::retry::Retry::default()
55        .max_duration(max_retry_duration)
56        .retry_async_with_state(client, |_, client| async {
57            let result = crate::inspect::get_min_lsn(client, capture_instance).await;
58            (client, map_null_lsn_to_retry(result))
59        })
60        .await;
61    let Ok(lsn) = lsn_result else {
62        tracing::warn!("database did not report a minimum LSN in time");
63        return lsn_result;
64    };
65    Ok(lsn)
66}
67
68/// Returns the maximum log sequence number for the entire database.
69/// This implementation relies on CDC, which is asynchronous, so may
70/// return an LSN that is less than the maximum LSN of SQL server.
71///
72/// See:
73/// - <https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-cdc-get-max-lsn-transact-sql?view=sql-server-ver16>
74/// - <https://groups.google.com/g/debezium/c/47Yg2r166KM/m/lHqtRF2xAQAJ?pli=1>
75pub async fn get_max_lsn(client: &mut Client) -> Result<Lsn, SqlServerError> {
76    static MAX_LSN_QUERY: &str = "SELECT sys.fn_cdc_get_max_lsn();";
77    let result = client.simple_query(MAX_LSN_QUERY).await?;
78
79    mz_ore::soft_assert_eq_or_log!(result.len(), 1);
80    parse_lsn(&result[..1])
81}
82
83/// Retrieves the minumum [`Lsn`] (start_lsn field) from `cdc.change_tables`
84/// for the specified capture instances.
85///
86/// This is based on the `sys.fn_cdc_get_min_lsn` implementation, which has logic
87/// that we want to bypass. Specifically, `sys.fn_cdc_get_min_lsn` returns NULL
88/// if the `start_lsn` in `cdc.change_tables` is less than or equal to the LSN
89/// returned by `sys.fn_cdc_get_max_lsn`.
90///
91/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-tables/cdc-change-tables-transact-sql?view=sql-server-ver16>
92pub async fn get_min_lsns(
93    client: &mut Client,
94    capture_instances: impl IntoIterator<Item = &str>,
95) -> Result<BTreeMap<Arc<str>, Lsn>, SqlServerError> {
96    let capture_instances: SmallVec<[_; 1]> = capture_instances.into_iter().collect();
97    let values: Vec<_> = capture_instances
98        .iter()
99        .map(|ci| {
100            let ci: &dyn tiberius::ToSql = ci;
101            ci
102        })
103        .collect();
104    let args = (0..capture_instances.len())
105        .map(|i| format!("@P{}", i + 1))
106        .collect::<Vec<_>>()
107        .join(",");
108    let stmt = format!(
109        "SELECT capture_instance, start_lsn FROM cdc.change_tables WHERE capture_instance IN ({args});"
110    );
111    let result = client.query(stmt, &values).await?;
112    let min_lsns = result
113        .into_iter()
114        .map(|row| {
115            let capture_instance: Arc<str> = row
116                .try_get::<&str, _>("capture_instance")?
117                .ok_or_else(|| {
118                    SqlServerError::ProgrammingError(
119                        "missing column 'capture_instance'".to_string(),
120                    )
121                })?
122                .into();
123            let start_lsn: &[u8] = row.try_get("start_lsn")?.ok_or_else(|| {
124                SqlServerError::ProgrammingError("missing column 'start_lsn'".to_string())
125            })?;
126            let min_lsn = Lsn::try_from(start_lsn).map_err(|msg| SqlServerError::InvalidData {
127                column_name: "lsn".to_string(),
128                error: format!("Error parsing LSN for {capture_instance}: {msg}"),
129            })?;
130            Ok::<_, SqlServerError>((capture_instance, min_lsn))
131        })
132        .collect::<Result<_, _>>()?;
133
134    Ok(min_lsns)
135}
136
137/// Returns the maximum log sequence number for the entire database, retrying
138/// if the log sequence number is not available. This implementation relies on
139/// CDC, which is asynchronous, so may return an LSN that is less than the
140/// maximum LSN of SQL server.
141///
142/// See:
143/// - <https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-cdc-get-max-lsn-transact-sql?view=sql-server-ver16>
144/// - <https://groups.google.com/g/debezium/c/47Yg2r166KM/m/lHqtRF2xAQAJ?pli=1>
145pub async fn get_max_lsn_retry(
146    client: &mut Client,
147    max_retry_duration: Duration,
148) -> Result<Lsn, SqlServerError> {
149    let (_client, lsn_result) = mz_ore::retry::Retry::default()
150        .max_duration(max_retry_duration)
151        .retry_async_with_state(client, |_, client| async {
152            let result = crate::inspect::get_max_lsn(client).await;
153            (client, map_null_lsn_to_retry(result))
154        })
155        .await;
156
157    let Ok(lsn) = lsn_result else {
158        tracing::warn!("database did not report a maximum LSN in time");
159        return lsn_result;
160    };
161    Ok(lsn)
162}
163
164fn map_null_lsn_to_retry<T>(result: Result<T, SqlServerError>) -> RetryResult<T, SqlServerError> {
165    match result {
166        Ok(val) => RetryResult::Ok(val),
167        Err(err @ SqlServerError::NullLsn) => RetryResult::RetryableErr(err),
168        Err(other) => RetryResult::FatalErr(other),
169    }
170}
171
172/// Increments the log sequence number.
173///
174/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-cdc-increment-lsn-transact-sql?view=sql-server-ver16>
175pub async fn increment_lsn(client: &mut Client, lsn: Lsn) -> Result<Lsn, SqlServerError> {
176    static INCREMENT_LSN_QUERY: &str = "SELECT sys.fn_cdc_increment_lsn(@P1);";
177    let result = client
178        .query(INCREMENT_LSN_QUERY, &[&lsn.as_bytes().as_slice()])
179        .await?;
180
181    mz_ore::soft_assert_eq_or_log!(result.len(), 1);
182    parse_lsn(&result[..1])
183}
184
185/// Parse an [`Lsn`] in Decimal(25,0) format of the provided [`tiberius::Row`].
186///
187/// Returns an error if the provided slice doesn't have exactly one row.
188pub(crate) fn parse_numeric_lsn(row: &[tiberius::Row]) -> Result<Lsn, SqlServerError> {
189    match row {
190        [r] => {
191            let numeric_lsn = r
192                .try_get::<Numeric, _>(0)?
193                .ok_or_else(|| SqlServerError::NullLsn)?;
194            let lsn = Lsn::try_from(numeric_lsn).map_err(|msg| SqlServerError::InvalidData {
195                column_name: "lsn".to_string(),
196                error: msg,
197            })?;
198            Ok(lsn)
199        }
200        other => Err(SqlServerError::InvalidData {
201            column_name: "lsn".to_string(),
202            error: format!("expected 1 column, got {other:?}"),
203        }),
204    }
205}
206
207/// Parse an [`Lsn`] from the first column of the provided [`tiberius::Row`].
208///
209/// Returns an error if the provided slice doesn't have exactly one row.
210fn parse_lsn(result: &[tiberius::Row]) -> Result<Lsn, SqlServerError> {
211    match result {
212        [row] => {
213            let val = row
214                .try_get::<&[u8], _>(0)?
215                .ok_or_else(|| SqlServerError::NullLsn)?;
216            if val.is_empty() {
217                Err(SqlServerError::NullLsn)
218            } else {
219                let lsn = Lsn::try_from(val).map_err(|msg| SqlServerError::InvalidData {
220                    column_name: "lsn".to_string(),
221                    error: msg,
222                })?;
223                Ok(lsn)
224            }
225        }
226        other => Err(SqlServerError::InvalidData {
227            column_name: "lsn".to_string(),
228            error: format!("expected 1 column, got {other:?}"),
229        }),
230    }
231}
232
233/// Queries the specified capture instance and returns all changes from
234/// `[start_lsn, end_lsn)`, ordered by `start_lsn` in an ascending fashion.
235pub fn get_changes_asc(
236    client: &mut Client,
237    capture_instance: &str,
238    start_lsn: Lsn,
239    end_lsn: Lsn,
240    filter: RowFilterOption,
241) -> impl Stream<Item = Result<tiberius::Row, SqlServerError>> + Send {
242    const START_LSN_COLUMN: &str = "__$start_lsn";
243    let query = format!(
244        "SELECT * FROM cdc.{function}(@P1, @P2, N'{filter}') ORDER BY {START_LSN_COLUMN} ASC;",
245        function = quote_identifier(&format!("fn_cdc_get_all_changes_{capture_instance}"))
246    );
247    client.query_streaming(
248        query,
249        &[
250            &start_lsn.as_bytes().as_slice(),
251            &end_lsn.as_bytes().as_slice(),
252        ],
253    )
254}
255
256/// Cleans up the change table associated with the specified `capture_instance` by
257/// deleting `max_deletes` entries with a `start_lsn` less than `low_water_mark`.
258///
259/// Note: At the moment cleanup is kind of "best effort".  If this query succeeds
260/// then at most `max_delete` rows were deleted, but the number of actual rows
261/// deleted is not returned as part of the query. The number of rows _should_ be
262/// present in an informational message (i.e. a Notice) that is returned, but
263/// [`tiberius`] doesn't expose these to us.
264///
265/// TODO(sql_server2): Update [`tiberius`] to return informational messages so we
266/// can determine how many rows got deleted.
267///
268/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-cleanup-change-table-transact-sql?view=sql-server-ver16>.
269pub async fn cleanup_change_table(
270    client: &mut Client,
271    capture_instance: &str,
272    low_water_mark: &Lsn,
273    max_deletes: u32,
274) -> Result<(), SqlServerError> {
275    static GET_LSN_QUERY: &str =
276        "SELECT MAX(start_lsn) FROM cdc.lsn_time_mapping WHERE start_lsn <= @P1";
277    static CLEANUP_QUERY: &str = "
278DECLARE @mz_cleanup_status_bit BIT;
279SET @mz_cleanup_status_bit = 0;
280EXEC sys.sp_cdc_cleanup_change_table
281    @capture_instance = @P1,
282    @low_water_mark = @P2,
283    @threshold = @P3,
284    @fCleanupFailed = @mz_cleanup_status_bit OUTPUT;
285SELECT @mz_cleanup_status_bit;
286    ";
287
288    let max_deletes = i64::cast_from(max_deletes);
289
290    // First we need to get a valid LSN as our low watermark. If we try to cleanup
291    // a change table with an LSN that doesn't exist in the `cdc.lsn_time_mapping`
292    // table we'll get an error code `22964`.
293    let result = client
294        .query(GET_LSN_QUERY, &[&low_water_mark.as_bytes().as_slice()])
295        .await?;
296    let low_water_mark_to_use = match &result[..] {
297        [row] => row
298            .try_get::<&[u8], _>(0)?
299            .ok_or_else(|| SqlServerError::InvalidData {
300                column_name: "mz_cleanup_status_bit".to_string(),
301                error: "expected a bool, found NULL".to_string(),
302            })?,
303        other => Err(SqlServerError::ProgrammingError(format!(
304            "expected one row for low water mark, found {other:?}"
305        )))?,
306    };
307
308    // Once we get a valid LSN that is less than or equal to the provided watermark
309    // we can clean up the specified change table!
310    let result = client
311        .query(
312            CLEANUP_QUERY,
313            &[&capture_instance, &low_water_mark_to_use, &max_deletes],
314        )
315        .await;
316
317    let rows = match result {
318        Ok(rows) => rows,
319        Err(SqlServerError::SqlServer(e)) => {
320            // See these remarks from the SQL Server Documentation.
321            //
322            // <https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sys-sp-cdc-cleanup-change-table-transact-sql?view=sql-server-ver16#remarks>.
323            let already_cleaned_up = e.code().map(|code| code == 22957).unwrap_or(false);
324
325            if already_cleaned_up {
326                return Ok(());
327            } else {
328                return Err(SqlServerError::SqlServer(e));
329            }
330        }
331        Err(other) => return Err(other),
332    };
333
334    match &rows[..] {
335        [row] => {
336            let failure =
337                row.try_get::<bool, _>(0)?
338                    .ok_or_else(|| SqlServerError::InvalidData {
339                        column_name: "mz_cleanup_status_bit".to_string(),
340                        error: "expected a bool, found NULL".to_string(),
341                    })?;
342
343            if failure {
344                Err(super::cdc::CdcError::CleanupFailed {
345                    capture_instance: capture_instance.to_string(),
346                    low_water_mark: *low_water_mark,
347                })?
348            } else {
349                Ok(())
350            }
351        }
352        other => Err(SqlServerError::ProgrammingError(format!(
353            "expected one status row, found {other:?}"
354        ))),
355    }
356}
357
358// Retrieves all columns in tables that have CDC (Change Data Capture) enabled.
359//
360// Returns metadata needed to create an instance of ['SqlServerTableRaw`].
361//
362// The query joins several system tables:
363// - sys.tables: Source tables in the database
364// - sys.schemas: Schema information for proper table identification
365// - sys.columns: Column definitions including nullability
366// - sys.types: Data type information for each column
367// - cdc.change_tables: CDC configuration linking capture instances to source tables
368//
369// For each column, it returns:
370// - Table identification (schema_name, table_name, capture_instance)
371// - Column metadata (name, type, nullable, max_length, precision, scale)
372static GET_COLUMNS_FOR_TABLES_WITH_CDC_QUERY: &str = "
373SELECT
374    s.name as schema_name,
375    t.name as table_name,
376    ch.capture_instance as capture_instance,
377    ch.create_date as capture_instance_create_date,
378    ch.index_name as capture_instance_index_name,
379    c.name as col_name,
380    ty.name as col_type,
381    c.is_nullable as col_nullable,
382    c.max_length as col_max_length,
383    c.precision as col_precision,
384    c.scale as col_scale,
385    c.is_computed as col_is_computed
386FROM sys.tables t
387JOIN sys.schemas s ON t.schema_id = s.schema_id
388JOIN sys.columns c ON t.object_id = c.object_id
389JOIN sys.types ty ON c.user_type_id = ty.user_type_id
390JOIN cdc.change_tables ch ON t.object_id = ch.source_object_id
391";
392
393/// Returns the table metadata for the tables that are tracked by the specified `capture_instance`s.
394pub async fn get_tables_for_capture_instance(
395    client: &mut Client,
396    capture_instances: impl IntoIterator<Item = &str>,
397) -> Result<Vec<SqlServerTableRaw>, SqlServerError> {
398    // SQL Server does not have support for array types, so we need to manually construct
399    // the parameterized query.
400    let params: SmallVec<[_; 1]> = capture_instances.into_iter().collect();
401    // If there are no tables to check for just return an empty list.
402    if params.is_empty() {
403        return Ok(Vec::default());
404    }
405
406    // TODO(sql_server3): Remove this redundant collection.
407    #[allow(clippy::as_conversions)]
408    let params_dyn: SmallVec<[_; 1]> = params
409        .iter()
410        .map(|instance| instance as &dyn tiberius::ToSql)
411        .collect();
412    let param_indexes = params
413        .iter()
414        .enumerate()
415        // Params are 1-based indexed.
416        .map(|(idx, _)| format!("@P{}", idx + 1))
417        .join(", ");
418
419    let table_for_capture_instance_query = format!(
420        "{GET_COLUMNS_FOR_TABLES_WITH_CDC_QUERY} WHERE ch.capture_instance IN ({param_indexes});"
421    );
422
423    let result = client
424        .query(&table_for_capture_instance_query, &params_dyn[..])
425        .await?;
426
427    let tables = deserialize_table_columns_to_raw_tables(&result)?;
428
429    Ok(tables)
430}
431
432/// Retrieves column metdata from the CDC table maintained by the provided capture instance. The
433/// resulting column information collection is similar to the information collected for the
434/// upstream table, with the exclusion of nullability and primary key constraints, which contain
435/// static values for CDC columns. CDC table schema is automatically generated and does not attempt
436/// to enforce the same constraints on the data as the upstream table.
437pub async fn get_cdc_table_columns(
438    client: &mut Client,
439    capture_instance: &str,
440) -> Result<BTreeMap<Arc<str>, SqlServerColumnRaw>, SqlServerError> {
441    static CDC_COLUMNS_QUERY: &str = "SELECT \
442        c.name AS col_name, \
443        t.name AS col_type, \
444        c.max_length AS col_max_length, \
445        c.precision AS col_precision, \
446        c.scale AS col_scale, \
447        c.is_computed as col_is_computed \
448    FROM \
449        sys.columns AS c \
450    JOIN sys.types AS t ON c.system_type_id = t.system_type_id AND c.user_type_id = t.user_type_id \
451    WHERE \
452        c.object_id = OBJECT_ID(@P1) AND c.name NOT LIKE '__$%' \
453    ORDER BY c.column_id;";
454    // Strings passed into OBJECT_ID must be escaped
455    let cdc_table_name = format!(
456        "cdc.{table_name}",
457        table_name = quote_identifier(&format!("{capture_instance}_CT"))
458    );
459    let result = client.query(CDC_COLUMNS_QUERY, &[&cdc_table_name]).await?;
460    let mut columns = BTreeMap::new();
461    for row in result.iter() {
462        let column_name: Arc<str> = get_value::<&str>(row, "col_name")?.into();
463        // Reusing this struct even though some of the fields aren't needed because it simplifies
464        // comparison with the upstream table metadata
465        let column = SqlServerColumnRaw {
466            name: Arc::clone(&column_name),
467            data_type: get_value::<&str>(row, "col_type")?.into(),
468            is_nullable: true,
469            max_length: get_value(row, "col_max_length")?,
470            precision: get_value(row, "col_precision")?,
471            scale: get_value(row, "col_scale")?,
472            is_computed: get_value(row, "col_is_computed")?,
473        };
474        columns.insert(column_name, column);
475    }
476    Ok(columns)
477}
478
479/// Retrieve primary key and unique constraints for the given tables.  Tables should be provided as
480/// an interator of tuples, where each tuple contains is `(schema_name, table_name)`.
481pub async fn get_constraints_for_tables(
482    client: &mut Client,
483    schema_table_list: impl Iterator<Item = &(Arc<str>, Arc<str>)>,
484) -> Result<BTreeMap<(Arc<str>, Arc<str>), Vec<SqlServerTableConstraintRaw>>, SqlServerError> {
485    let qualified_table_names: Vec<_> = schema_table_list
486        .map(|(schema, table)| {
487            format!(
488                "{quoted_schema}.{quoted_table}",
489                quoted_schema = quote_identifier(schema),
490                quoted_table = quote_identifier(table)
491            )
492        })
493        .collect();
494
495    if qualified_table_names.is_empty() {
496        return Ok(Default::default());
497    }
498
499    let params = (1..qualified_table_names.len() + 1)
500        .map(|idx| format!("@P{}", idx))
501        .join(", ");
502
503    // KEY_COLUMN_USAGE (not CONSTRAINT_COLUMN_USAGE) because it exposes
504    // ORDINAL_POSITION, letting us preserve composite-key column order.
505    let query = format!(
506        "SELECT \
507        tc.table_schema, \
508        tc.table_name, \
509        kcu.column_name, \
510        tc.constraint_name, \
511        tc.constraint_type \
512    FROM information_schema.table_constraints tc \
513    JOIN information_schema.key_column_usage kcu \
514        ON kcu.constraint_schema = tc.constraint_schema \
515        AND kcu.constraint_name = tc.constraint_name \
516        AND kcu.table_schema = tc.table_schema \
517        AND kcu.table_name = tc.table_name \
518    WHERE
519        QUOTENAME(tc.table_schema) + '.' + QUOTENAME(tc.table_name) IN ({params})
520        AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
521    ORDER BY tc.table_schema, tc.table_name, tc.constraint_name, kcu.ordinal_position"
522    );
523
524    let query_params: Vec<_> = qualified_table_names
525        .iter()
526        .map(|qualified_name| {
527            let name: &dyn tiberius::ToSql = qualified_name;
528            name
529        })
530        .collect();
531
532    tracing::debug!("query = {query} params = {qualified_table_names:?}");
533    let result = client.query(query, &query_params).await?;
534
535    let mut contraints_by_table: BTreeMap<_, BTreeMap<_, Vec<_>>> = BTreeMap::new();
536    for row in result {
537        let schema_name: Arc<str> = get_value::<&str>(&row, "table_schema")?.into();
538        let table_name: Arc<str> = get_value::<&str>(&row, "table_name")?.into();
539        let column_name = get_value::<&str>(&row, "column_name")?.into();
540        let constraint_name = get_value::<&str>(&row, "constraint_name")?.into();
541        let constraint_type = get_value::<&str>(&row, "constraint_type")?.into();
542
543        contraints_by_table
544            .entry((Arc::clone(&schema_name), Arc::clone(&table_name)))
545            .or_default()
546            .entry((constraint_name, constraint_type))
547            .or_default()
548            .push(column_name);
549    }
550    Ok(contraints_by_table
551        .into_iter()
552        .inspect(|((schema_name, table_name), constraints)| {
553            tracing::debug!("table {schema_name}.{table_name} constraints: {constraints:?}")
554        })
555        .map(|(qualified_name, constraints)| {
556            (
557                qualified_name,
558                constraints
559                    .into_iter()
560                    .map(|((constraint_name, constraint_type), columns)| {
561                        SqlServerTableConstraintRaw {
562                            constraint_name,
563                            constraint_type,
564                            columns,
565                        }
566                    })
567                    .collect(),
568            )
569        })
570        .collect())
571}
572
573/// Ensure change data capture (CDC) is enabled for the database the provided
574/// `client` is currently connected to.
575///
576/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/track-changes/enable-and-disable-change-data-capture-sql-server?view=sql-server-ver16>
577pub async fn ensure_database_cdc_enabled(client: &mut Client) -> Result<(), SqlServerError> {
578    static DATABASE_CDC_ENABLED_QUERY: &str =
579        "SELECT is_cdc_enabled FROM sys.databases WHERE database_id = DB_ID();";
580    let result = client.simple_query(DATABASE_CDC_ENABLED_QUERY).await?;
581
582    check_system_result(&result, "database CDC".to_string(), true)?;
583    Ok(())
584}
585
586/// Retrieves the largest `restore_history_id` from SQL Server for the current database.  The
587/// `restore_history_id` column is of type `IDENTITY(1,1)` based on `EXEC sp_help restorehistory`.
588/// We expect it to start at 1 and be incremented by 1, with possible gaps in values.
589/// See:
590/// - <https://learn.microsoft.com/en-us/sql/relational-databases/system-tables/restorehistory-transact-sql?view=sql-server-ver17>
591/// - <https://learn.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql-identity-property?view=sql-server-ver17>
592pub async fn get_latest_restore_history_id(
593    client: &mut Client,
594) -> Result<Option<i32>, SqlServerError> {
595    // Azure SQL Database does not expose `msdb`, so restore history is
596    // unavailable and restore detection does not apply.
597    if !client.engine_edition().await?.has_restore_history() {
598        return Ok(None);
599    }
600
601    static LATEST_RESTORE_ID_QUERY: &str = "SELECT TOP 1 restore_history_id \
602        FROM msdb.dbo.restorehistory \
603        WHERE destination_database_name = DB_NAME() \
604        ORDER BY restore_history_id DESC;";
605    let result = client.simple_query(LATEST_RESTORE_ID_QUERY).await?;
606
607    match &result[..] {
608        [] => Ok(None),
609        [row] => Ok(row.try_get::<i32, _>(0)?),
610        other => Err(SqlServerError::InvariantViolated(format!(
611            "expected one row, got {other:?}"
612        ))),
613    }
614}
615
616/// A DDL event collected from the `cdc.ddl_history` table.
617#[derive(Debug)]
618pub struct DDLEvent {
619    pub lsn: Lsn,
620    pub ddl_command: Arc<str>,
621}
622
623impl DDLEvent {
624    /// Returns true if the DDL event is a compatible change, or false if it is not.
625    /// This performs a naive parsing of the DDL command looking for modification of columns
626    ///  1. ALTER TABLE .. ALTER COLUMN
627    ///  2. ALTER TABLE .. DROP COLUMN
628    ///
629    /// See <https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-table-transact-sql?view=sql-server-ver17>
630    pub fn is_compatible(&self, included_columns: &[Arc<str>]) -> bool {
631        // TODO (maz): This is currently a basic check that doesn't take into account type changes.
632        // At some point, we will need to move this to SqlServerTableDesc and expand it.
633        let mut words = self.ddl_command.split_ascii_whitespace();
634        match (
635            words.next().map(str::to_ascii_lowercase).as_deref(),
636            words.next().map(str::to_ascii_lowercase).as_deref(),
637        ) {
638            (Some("alter"), Some("table")) => {
639                let mut peekable = words.peekable();
640                let mut compatible = true;
641                while compatible && let Some(token) = peekable.next() {
642                    compatible = match token.to_ascii_lowercase().as_str() {
643                        "alter" | "drop" => {
644                            let target = peekable.next();
645                            match target {
646                                // Targeting a column
647                                Some(t) if t.eq_ignore_ascii_case("column") => {
648                                    let mut all_excluded = true;
649                                    while let Some(tok) = peekable.next() {
650                                        // The column name(s) can be preceeded by the pair of keywords "IF EXISTS", so we want to skip those.
651                                        match tok.to_ascii_lowercase().as_str() {
652                                            "if" | "exists" | "," | "column" => continue,
653                                            col_str => {
654                                                // If any column is in the included list, then it is not okay to alter/drop it
655                                                // The col_str token may be a comma-separated list of columns as whitespace is not required
656                                                // between column names in SQL Server DDL.
657                                                if !col_str.trim_matches(',').split(',').all(
658                                                    |col_name| {
659                                                        !included_columns.iter().any(|included| {
660                                                            included.eq_ignore_ascii_case(
661                                                                col_name.trim_matches(
662                                                                    ['[', ']', '"'].as_ref(),
663                                                                ),
664                                                            )
665                                                        })
666                                                    },
667                                                ) {
668                                                    all_excluded = false;
669                                                    break;
670                                                }
671                                                // If this is the only/last column, then we can break out of the while loop.
672                                                // Check if this string has no trailing comma, and if not, peek to see if the next token
673                                                // contains a leading comma.
674                                                if !col_str.ends_with(",") {
675                                                    match peekable.peek() {
676                                                        Some(x) if x.starts_with(",") => continue,
677                                                        _ => break,
678                                                    }
679                                                }
680                                            }
681                                        };
682                                    }
683                                    all_excluded
684                                }
685                                // No target token after "alter" or "drop"
686                                None => false,
687                                // Other targets are considered compatible
688                                _ => true,
689                            }
690                        }
691                        _ => true,
692                    }
693                }
694                compatible
695            }
696            _ => true,
697        }
698    }
699}
700
701/// Returns DDL changes made to the source table for the given capture instance.  This follows the
702/// same convention as `cdc.fn_cdc_get_all_changes_<capture_instance>`, in that the range is
703/// inclusive, i.e. `[from_lsn, to_lsn]`. The events are returned in ascending order of
704/// LSN.
705pub async fn get_ddl_history(
706    client: &mut Client,
707    capture_instance: &str,
708    from_lsn: &Lsn,
709    to_lsn: &Lsn,
710) -> Result<BTreeMap<SqlServerQualifiedTableName, Vec<DDLEvent>>, SqlServerError> {
711    // We query the ddl_history table instead of using the stored procedure as there doesn't
712    // appear to be a way to apply filters or projections against output of the stored procedure
713    // without an intermediate table.
714    static DDL_HISTORY_QUERY: &str = "SELECT \
715                s.name AS schema_name, \
716                t.name AS table_name, \
717                dh.ddl_lsn, \
718                dh.ddl_command
719            FROM \
720                cdc.change_tables ct \
721            JOIN cdc.ddl_history dh ON dh.object_id = ct.object_id \
722            JOIN sys.tables t ON t.object_id = dh.source_object_id \
723            JOIN sys.schemas s ON s.schema_id = t.schema_id \
724            WHERE \
725                ct.capture_instance = @P1 \
726                AND dh.ddl_lsn >= @P2 \
727                AND dh.ddl_lsn <= @P3 \
728            ORDER BY ddl_lsn;";
729
730    let result = client
731        .query(
732            DDL_HISTORY_QUERY,
733            &[
734                &capture_instance,
735                &from_lsn.as_bytes().as_slice(),
736                &to_lsn.as_bytes().as_slice(),
737            ],
738        )
739        .await?;
740
741    // SQL server doesn't support array types, and using string_agg to collect LSN
742    // would require more parsing, so we opt for a BTreeMap to accumulate the results.
743    let mut collector: BTreeMap<_, Vec<_>> = BTreeMap::new();
744    for row in result.iter() {
745        let schema_name: Arc<str> = get_value::<&str>(row, "schema_name")?.into();
746        let table_name: Arc<str> = get_value::<&str>(row, "table_name")?.into();
747        let lsn: &[u8] = get_value::<&[u8]>(row, "ddl_lsn")?;
748        let ddl_command: Arc<str> = get_value::<&str>(row, "ddl_command")?.into();
749
750        let qualified_table_name = SqlServerQualifiedTableName {
751            schema_name,
752            table_name,
753        };
754        let lsn = Lsn::try_from(lsn).map_err(|lsn_err| SqlServerError::InvalidData {
755            column_name: "ddl_lsn".to_string(),
756            error: lsn_err,
757        })?;
758
759        collector
760            .entry(qualified_table_name)
761            .or_default()
762            .push(DDLEvent { lsn, ddl_command });
763    }
764
765    Ok(collector)
766}
767
768/// Ensure the `SNAPSHOT` transaction isolation level is enabled for the
769/// database the provided `client` is currently connected to.
770///
771/// See: <https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql?view=sql-server-ver16>
772pub async fn ensure_snapshot_isolation_enabled(client: &mut Client) -> Result<(), SqlServerError> {
773    static SNAPSHOT_ISOLATION_QUERY: &str =
774        "SELECT snapshot_isolation_state FROM sys.databases WHERE database_id = DB_ID();";
775    let result = client.simple_query(SNAPSHOT_ISOLATION_QUERY).await?;
776
777    check_system_result(&result, "snapshot isolation".to_string(), 1u8)?;
778    Ok(())
779}
780
781/// The engine edition of a SQL Server instance, as reported by
782/// `SERVERPROPERTY('EngineEdition')`.
783///
784/// Materialize adjusts some CDC setup and monitoring behavior based on the
785/// edition. Azure SQL Database in particular has no SQL Server Agent and no
786/// accessible `msdb`, so the agent and restore-history checks do not apply
787/// there.
788///
789/// See: <https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql?view=sql-server-ver17>
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum EngineEdition {
792    /// Personal or Desktop Engine (1).
793    PersonalOrDesktop,
794    /// Standard, Web, or Business Intelligence (2).
795    Standard,
796    /// Enterprise, Enterprise Eval, or Developer (3).
797    Enterprise,
798    /// Express and Express-derived editions (4).
799    Express,
800    /// Azure SQL Database (5).
801    AzureSqlDatabase,
802    /// Azure Synapse Analytics (6).
803    AzureSynapseAnalytics,
804    /// Azure SQL Managed Instance (8).
805    AzureSqlManagedInstance,
806    /// Azure SQL Edge (9).
807    AzureSqlEdge,
808    /// Azure Synapse serverless SQL pool (11).
809    AzureSynapseServerlessSqlPool,
810    /// An edition not otherwise recognized, carrying the raw
811    /// `SERVERPROPERTY('EngineEdition')` value.
812    Other(i32),
813}
814
815impl EngineEdition {
816    /// Whether this edition runs a SQL Server Agent that hosts the CDC capture
817    /// and cleanup jobs. Azure SQL Database uses an internal scheduler instead
818    /// and does not expose `sys.dm_server_services`.
819    pub fn has_sql_server_agent(&self) -> bool {
820        !matches!(self, EngineEdition::AzureSqlDatabase)
821    }
822
823    /// Whether `msdb.dbo.restorehistory` is queryable. Azure SQL Database does
824    /// not expose `msdb`, so restore history is unavailable there.
825    pub fn has_restore_history(&self) -> bool {
826        !matches!(self, EngineEdition::AzureSqlDatabase)
827    }
828}
829
830impl From<i32> for EngineEdition {
831    fn from(value: i32) -> Self {
832        match value {
833            1 => EngineEdition::PersonalOrDesktop,
834            2 => EngineEdition::Standard,
835            3 => EngineEdition::Enterprise,
836            4 => EngineEdition::Express,
837            5 => EngineEdition::AzureSqlDatabase,
838            6 => EngineEdition::AzureSynapseAnalytics,
839            8 => EngineEdition::AzureSqlManagedInstance,
840            9 => EngineEdition::AzureSqlEdge,
841            11 => EngineEdition::AzureSynapseServerlessSqlPool,
842            other => EngineEdition::Other(other),
843        }
844    }
845}
846
847/// Returns the [`EngineEdition`] of the SQL Server instance the `client` is
848/// connected to.
849///
850/// tiberius does not surface the engine edition, so we query
851/// `SERVERPROPERTY('EngineEdition')`, which is returned as a `sql_variant` and
852/// cast to an integer.
853///
854/// See: <https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql?view=sql-server-ver17>
855pub async fn get_engine_edition(client: &mut Client) -> Result<EngineEdition, SqlServerError> {
856    static ENGINE_EDITION_QUERY: &str = "SELECT CAST(SERVERPROPERTY('EngineEdition') AS INT);";
857    let result = client.simple_query(ENGINE_EDITION_QUERY).await?;
858
859    match &result[..] {
860        [row] => {
861            let raw = row.try_get::<i32, _>(0)?.ok_or_else(|| {
862                SqlServerError::InvariantViolated(
863                    "SERVERPROPERTY('EngineEdition') returned NULL".to_string(),
864                )
865            })?;
866            Ok(EngineEdition::from(raw))
867        }
868        other => Err(SqlServerError::InvariantViolated(format!(
869            "expected one row, got {other:?}"
870        ))),
871    }
872}
873
874/// Ensure the SQL Server Agent is running.
875///
876/// Azure SQL Database has no SQL Server Agent (CDC is driven by an internal
877/// scheduler and `sys.dm_server_services` is unavailable), so the check is
878/// skipped for that edition.
879///
880/// See: <https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-server-services-transact-sql?view=azuresqldb-current&viewFallbackFrom=sql-server-ver17>
881pub async fn ensure_sql_server_agent_running(client: &mut Client) -> Result<(), SqlServerError> {
882    if !client.engine_edition().await?.has_sql_server_agent() {
883        return Ok(());
884    }
885
886    static AGENT_STATUS_QUERY: &str = "SELECT status_desc FROM sys.dm_server_services WHERE servicename LIKE 'SQL Server Agent%';";
887    let result = client.simple_query(AGENT_STATUS_QUERY).await?;
888
889    check_system_result(&result, "SQL Server Agent status".to_string(), "Running")?;
890    Ok(())
891}
892
893pub async fn get_tables(client: &mut Client) -> Result<Vec<SqlServerTableRaw>, SqlServerError> {
894    let result = client
895        .simple_query(&format!("{GET_COLUMNS_FOR_TABLES_WITH_CDC_QUERY};"))
896        .await?;
897
898    let tables = deserialize_table_columns_to_raw_tables(&result)?;
899
900    Ok(tables)
901}
902
903// Helper function to retrieve value from a row.
904fn get_value<'a, T: tiberius::FromSql<'a>>(
905    row: &'a tiberius::Row,
906    name: &'static str,
907) -> Result<T, SqlServerError> {
908    row.try_get(name)?
909        .ok_or(SqlServerError::MissingColumn(name))
910}
911fn deserialize_table_columns_to_raw_tables(
912    rows: &[tiberius::Row],
913) -> Result<Vec<SqlServerTableRaw>, SqlServerError> {
914    // Group our columns by (schema, name).
915    let mut tables = BTreeMap::default();
916    for row in rows {
917        let schema_name: Arc<str> = get_value::<&str>(row, "schema_name")?.into();
918        let table_name: Arc<str> = get_value::<&str>(row, "table_name")?.into();
919        let capture_instance: Arc<str> = get_value::<&str>(row, "capture_instance")?.into();
920        let capture_instance_create_date: NaiveDateTime =
921            get_value::<NaiveDateTime>(row, "capture_instance_create_date")?;
922        let capture_instance_index_name: Option<Arc<str>> = row
923            .try_get::<&str, _>("capture_instance_index_name")?
924            .map(Into::into);
925
926        let column_name = get_value::<&str>(row, "col_name")?.into();
927        let column = SqlServerColumnRaw {
928            name: Arc::clone(&column_name),
929            data_type: get_value::<&str>(row, "col_type")?.into(),
930            is_nullable: get_value(row, "col_nullable")?,
931            max_length: get_value(row, "col_max_length")?,
932            precision: get_value(row, "col_precision")?,
933            scale: get_value(row, "col_scale")?,
934            is_computed: get_value(row, "col_is_computed")?,
935        };
936
937        let columns: &mut Vec<_> = tables
938            .entry((
939                Arc::clone(&schema_name),
940                Arc::clone(&table_name),
941                Arc::clone(&capture_instance),
942                capture_instance_create_date,
943                capture_instance_index_name,
944            ))
945            .or_default();
946        columns.push(column);
947    }
948
949    let raw_tables = tables
950        .into_iter()
951        .map(
952            |(
953                (schema, name, capture_instance, capture_instance_create_date, index_name),
954                columns,
955            )| {
956                SqlServerTableRaw {
957                    schema_name: schema,
958                    name,
959                    capture_instance: Arc::new(SqlServerCaptureInstanceRaw {
960                        name: capture_instance,
961                        create_date: capture_instance_create_date.into(),
962                        index_name,
963                    }),
964                    columns: columns.into(),
965                }
966            },
967        )
968        .collect();
969    Ok(raw_tables)
970}
971
972/// Return a [`Stream`] that is the entire snapshot of the specified table.
973pub fn snapshot(
974    client: &mut Client,
975    table: &SqlServerTableRaw,
976) -> impl Stream<Item = Result<tiberius::Row, SqlServerError>> {
977    let cols = table
978        .columns
979        .iter()
980        .map(|SqlServerColumnRaw { name, .. }| quote_identifier(name))
981        .join(",");
982    let query = format!(
983        "SELECT {cols} FROM {schema_name}.{table_name};",
984        schema_name = quote_identifier(&table.schema_name),
985        table_name = quote_identifier(&table.name)
986    );
987    client.query_streaming(query, &[])
988}
989
990/// Returns the total number of rows present in the specified table.
991pub async fn snapshot_size(
992    client: &mut Client,
993    schema: &str,
994    table: &str,
995) -> Result<usize, SqlServerError> {
996    let query = format!(
997        "SELECT COUNT(*) FROM {schema_name}.{table_name};",
998        schema_name = quote_identifier(schema),
999        table_name = quote_identifier(table)
1000    );
1001    let result = client.query(query, &[]).await?;
1002
1003    match &result[..] {
1004        [row] => match row.try_get::<i32, _>(0)? {
1005            Some(count @ 0..) => Ok(usize::try_from(count).expect("known to fit")),
1006            Some(negative) => Err(SqlServerError::InvalidData {
1007                column_name: "count".to_string(),
1008                error: format!("found negative count: {negative}"),
1009            }),
1010            None => Err(SqlServerError::InvalidData {
1011                column_name: "count".to_string(),
1012                error: "expected a value found NULL".to_string(),
1013            }),
1014        },
1015        other => Err(SqlServerError::InvariantViolated(format!(
1016            "expected one row, got {other:?}"
1017        ))),
1018    }
1019}
1020
1021/// Helper function to parse an expected result from a "system" query.
1022fn check_system_result<'a, T>(
1023    result: &'a SmallVec<[tiberius::Row; 1]>,
1024    name: String,
1025    expected: T,
1026) -> Result<(), SqlServerError>
1027where
1028    T: tiberius::FromSql<'a> + Copy + fmt::Debug + fmt::Display + PartialEq,
1029{
1030    match &result[..] {
1031        [row] => {
1032            let result: Option<T> = row.try_get(0)?;
1033            if result == Some(expected) {
1034                Ok(())
1035            } else {
1036                Err(SqlServerError::InvalidSystemSetting {
1037                    name,
1038                    expected: expected.to_string(),
1039                    actual: format!("{result:?}"),
1040                })
1041            }
1042        }
1043        other => Err(SqlServerError::InvariantViolated(format!(
1044            "expected 1 row, got {other:?}"
1045        ))),
1046    }
1047}
1048
1049/// Return a Result that is empty if all tables, columns, and capture instances
1050/// have the necessary permissions to and an error if any table, column,
1051/// or capture instance does not have the necessary permissions
1052/// for tracking changes.
1053pub async fn validate_source_privileges(
1054    client: &mut Client,
1055    capture_instances: impl IntoIterator<Item = &str>,
1056) -> Result<(), SqlServerError> {
1057    let params: SmallVec<[_; 1]> = capture_instances.into_iter().collect();
1058
1059    if params.is_empty() {
1060        return Ok(());
1061    }
1062
1063    let params_dyn: SmallVec<[_; 1]> = params
1064        .iter()
1065        .map(|instance| {
1066            let instance: &dyn tiberius::ToSql = instance;
1067            instance
1068        })
1069        .collect();
1070
1071    let param_indexes = (1..params.len() + 1)
1072        .map(|idx| format!("@P{}", idx))
1073        .join(", ");
1074
1075    // NB(ptravers): we rely on HAS_PERMS_BY_NAME to check both table and column permissions.
1076    let capture_instance_query = format!(
1077            "
1078        SELECT
1079            SCHEMA_NAME(o.schema_id) + '.' + o.name AS qualified_table_name,
1080            ct.capture_instance AS capture_instance,
1081            COALESCE(HAS_PERMS_BY_NAME(SCHEMA_NAME(o.schema_id) + '.' + o.name, 'OBJECT', 'SELECT'), 0) AS table_select,
1082            COALESCE(HAS_PERMS_BY_NAME('cdc.' + QUOTENAME(ct.capture_instance + '_CT') , 'OBJECT', 'SELECT'), 0) AS capture_table_select
1083        FROM cdc.change_tables ct
1084        JOIN sys.objects o ON o.object_id = ct.source_object_id
1085        WHERE ct.capture_instance IN ({param_indexes});
1086            "
1087        );
1088
1089    let rows = client
1090        .query(capture_instance_query, &params_dyn[..])
1091        .await?;
1092
1093    let mut capture_instances_without_perms = vec![];
1094    let mut tables_without_perms = vec![];
1095
1096    for row in rows {
1097        let table: &str = row
1098            .try_get("qualified_table_name")
1099            .context("getting table column")?
1100            .ok_or_else(|| anyhow::anyhow!("no table column?"))?;
1101
1102        let capture_instance: &str = row
1103            .try_get("capture_instance")
1104            .context("getting capture_instance column")?
1105            .ok_or_else(|| anyhow::anyhow!("no capture_instance column?"))?;
1106
1107        let permitted_table: i32 = row
1108            .try_get("table_select")
1109            .context("getting table_select column")?
1110            .ok_or_else(|| anyhow::anyhow!("no table_select column?"))?;
1111
1112        let permitted_capture_instance: i32 = row
1113            .try_get("capture_table_select")
1114            .context("getting capture_table_select column")?
1115            .ok_or_else(|| anyhow::anyhow!("no capture_table_select column?"))?;
1116
1117        if permitted_table == 0 {
1118            tables_without_perms.push(table.to_string());
1119        }
1120
1121        if permitted_capture_instance == 0 {
1122            capture_instances_without_perms.push(capture_instance.to_string());
1123        }
1124    }
1125
1126    if !capture_instances_without_perms.is_empty() || !tables_without_perms.is_empty() {
1127        return Err(SqlServerError::AuthorizationError {
1128            tables: tables_without_perms.join(", "),
1129            capture_instances: capture_instances_without_perms.join(", "),
1130        });
1131    }
1132
1133    Ok(())
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::{DDLEvent, EngineEdition};
1139    use std::sync::Arc;
1140
1141    #[mz_ore::test]
1142    fn test_engine_edition_mapping() {
1143        assert_eq!(EngineEdition::from(2), EngineEdition::Standard);
1144        assert_eq!(EngineEdition::from(3), EngineEdition::Enterprise);
1145        assert_eq!(EngineEdition::from(5), EngineEdition::AzureSqlDatabase);
1146        assert_eq!(
1147            EngineEdition::from(8),
1148            EngineEdition::AzureSqlManagedInstance
1149        );
1150        // Unrecognized values are preserved rather than dropped.
1151        assert_eq!(EngineEdition::from(42), EngineEdition::Other(42));
1152
1153        // Only Azure SQL Database lacks an agent and `msdb` restore history.
1154        assert!(!EngineEdition::AzureSqlDatabase.has_sql_server_agent());
1155        assert!(!EngineEdition::AzureSqlDatabase.has_restore_history());
1156        for edition in [
1157            EngineEdition::Enterprise,
1158            EngineEdition::AzureSqlManagedInstance,
1159            EngineEdition::Other(42),
1160        ] {
1161            assert!(edition.has_sql_server_agent());
1162            assert!(edition.has_restore_history());
1163        }
1164    }
1165
1166    #[mz_ore::test]
1167    fn test_ddl_event_is_compatible() {
1168        fn test_case(ddl_command: &str, included_columns: &[Arc<str>], expected: bool) {
1169            let ddl_event = DDLEvent {
1170                lsn: Default::default(),
1171                ddl_command: ddl_command.into(),
1172            };
1173            let result = ddl_event.is_compatible(included_columns);
1174            assert_eq!(
1175                result, expected,
1176                "DDL command '{}' with included_columns {:?} expected to be {}, got {}",
1177                ddl_command, included_columns, expected, result
1178            );
1179        }
1180
1181        let included_columns = vec![Arc::from("col3"), Arc::from("col4"), Arc::from("col4")];
1182
1183        test_case(
1184            "ALTER TABLE my_table ALTER COLUMN col1 INT",
1185            &included_columns,
1186            true,
1187        );
1188        test_case(
1189            "ALTER TABLE my_table DROP COLUMN col2",
1190            &included_columns,
1191            true,
1192        );
1193        test_case(
1194            "ALTER TABLE my_table ALTER COLUMN col3 INT",
1195            &included_columns,
1196            false,
1197        );
1198        test_case(
1199            "ALTER TABLE my_table DROP COLUMN col4 INT",
1200            &included_columns,
1201            false,
1202        );
1203        test_case(
1204            "CREATE INDEX idx_my_index ON my_table(col1)",
1205            &included_columns,
1206            true,
1207        );
1208        test_case(
1209            "DROP INDEX idx_my_index ON my_table",
1210            &included_columns,
1211            true,
1212        );
1213        test_case(
1214            "ALTER TABLE my_table ADD COLUMN col5 INT",
1215            &included_columns,
1216            true,
1217        );
1218        test_case(
1219            "ALTER TABLE my_table DROP COLUMN col1, col2",
1220            &included_columns,
1221            true,
1222        );
1223        test_case(
1224            "ALTER TABLE my_table DROP COLUMN col3, col2",
1225            &included_columns,
1226            false,
1227        );
1228        test_case(
1229            "ALTER TABLE my_table DROP COLUMN col3, col4",
1230            &included_columns,
1231            false,
1232        );
1233        test_case(
1234            "ALTER TABLE my_table DROP COLUMN IF EXISTS col1, col2",
1235            &included_columns,
1236            true,
1237        );
1238        test_case(
1239            "ALTER TABLE my_table DROP CONSTRAINT constraint_name",
1240            &included_columns,
1241            true,
1242        );
1243        test_case(
1244            "ALTER TABLE my_table DROP COLUMN col1,col3",
1245            &included_columns,
1246            false,
1247        );
1248        test_case(
1249            "ALTER TABLE my_table DROP COLUMN col1,col2",
1250            &included_columns,
1251            true,
1252        );
1253        test_case(
1254            "ALTER TABLE my_table DROP COLUMN col1 ,col2",
1255            &included_columns,
1256            true,
1257        );
1258        test_case(
1259            "ALTER TABLE my_table DROP COLUMN col1 , col2",
1260            &included_columns,
1261            true,
1262        );
1263        test_case(
1264            "ALTER TABLE my_table DROP COLUMN col1 , col3",
1265            &included_columns,
1266            false,
1267        );
1268        test_case(
1269            "ALTER TABLE my_table DROP COLUMN col1 , COLUMN col3",
1270            &included_columns,
1271            false,
1272        );
1273    }
1274}