1use 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
32pub 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}
45pub 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
68pub 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
83pub 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
137pub 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
172pub 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
185pub(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
207fn 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
233pub 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
256pub 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 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 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 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
358static 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
393pub async fn get_tables_for_capture_instance(
395 client: &mut Client,
396 capture_instances: impl IntoIterator<Item = &str>,
397) -> Result<Vec<SqlServerTableRaw>, SqlServerError> {
398 let params: SmallVec<[_; 1]> = capture_instances.into_iter().collect();
401 if params.is_empty() {
403 return Ok(Vec::default());
404 }
405
406 #[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 .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, ¶ms_dyn[..])
425 .await?;
426
427 let tables = deserialize_table_columns_to_raw_tables(&result)?;
428
429 Ok(tables)
430}
431
432pub 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 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 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
479pub 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 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
573pub 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
586pub async fn get_latest_restore_history_id(
593 client: &mut Client,
594) -> Result<Option<i32>, SqlServerError> {
595 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#[derive(Debug)]
618pub struct DDLEvent {
619 pub lsn: Lsn,
620 pub ddl_command: Arc<str>,
621}
622
623impl DDLEvent {
624 pub fn is_compatible(&self, included_columns: &[Arc<str>]) -> bool {
631 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 Some(t) if t.eq_ignore_ascii_case("column") => {
648 let mut all_excluded = true;
649 while let Some(tok) = peekable.next() {
650 match tok.to_ascii_lowercase().as_str() {
652 "if" | "exists" | "," | "column" => continue,
653 col_str => {
654 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 !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 None => false,
687 _ => true,
689 }
690 }
691 _ => true,
692 }
693 }
694 compatible
695 }
696 _ => true,
697 }
698 }
699}
700
701pub 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 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 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
768pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum EngineEdition {
792 PersonalOrDesktop,
794 Standard,
796 Enterprise,
798 Express,
800 AzureSqlDatabase,
802 AzureSynapseAnalytics,
804 AzureSqlManagedInstance,
806 AzureSqlEdge,
808 AzureSynapseServerlessSqlPool,
810 Other(i32),
813}
814
815impl EngineEdition {
816 pub fn has_sql_server_agent(&self) -> bool {
820 !matches!(self, EngineEdition::AzureSqlDatabase)
821 }
822
823 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
847pub 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
874pub 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
903fn 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 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
972pub 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
990pub 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
1021fn 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
1049pub 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 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, ¶ms_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 assert_eq!(EngineEdition::from(42), EngineEdition::Other(42));
1152
1153 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}