1use crate::DeltaFunnelError;
4
5use super::{LoadMode, MssqlDdlPlan, MssqlSchemaPlan, MssqlTargetSummary};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MssqlTargetTableState {
10 Exists,
12 Absent,
14 ExistsOrAbsent,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum MssqlLifecycleGuardrailPolicy {
21 AllowedAfterPlanning,
23 ForbiddenAfterPlanningFailure,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MssqlLifecycleExecutionGuardrail {
30 DeltaSourceScanExecution,
32 DataFusionPhysicalPlanExecution,
34 SqlServerConnectionAttempt,
36 TargetTableExistenceProbe,
38 CreateTableDdlExecution,
40 BulkWriterConstruction,
42 RecordBatchHandoffPolling,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct MssqlLifecyclePlan {
49 target: MssqlTargetSummary,
50 expected_target_state: MssqlTargetTableState,
51 create_table_sql_required: bool,
52 create_table_sql_present: bool,
53 executable_in_mvp: bool,
54 guardrail_policy: MssqlLifecycleGuardrailPolicy,
55 execution_guardrails: &'static [MssqlLifecycleExecutionGuardrail],
56}
57
58impl MssqlLifecyclePlan {
59 #[must_use]
61 pub fn target(&self) -> &MssqlTargetSummary {
62 &self.target
63 }
64
65 #[must_use]
67 pub const fn expected_target_state(&self) -> MssqlTargetTableState {
68 self.expected_target_state
69 }
70
71 #[must_use]
73 pub const fn create_table_sql_required(&self) -> bool {
74 self.create_table_sql_required
75 }
76
77 #[must_use]
79 pub const fn create_table_sql_present(&self) -> bool {
80 self.create_table_sql_present
81 }
82
83 #[must_use]
85 pub const fn executable_in_mvp(&self) -> bool {
86 self.executable_in_mvp
87 }
88
89 #[must_use]
91 pub const fn guardrail_policy(&self) -> MssqlLifecycleGuardrailPolicy {
92 self.guardrail_policy
93 }
94
95 #[must_use]
97 pub const fn execution_guardrails(&self) -> &[MssqlLifecycleExecutionGuardrail] {
98 self.execution_guardrails
99 }
100}
101
102const APPEND_EXISTING_EXECUTION_GUARDRAILS: &[MssqlLifecycleExecutionGuardrail] = &[
103 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
104 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
105 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
106 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
107 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
108 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
109];
110
111const CREATE_AND_LOAD_EXECUTION_GUARDRAILS: &[MssqlLifecycleExecutionGuardrail] = &[
112 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
113 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
114 MssqlLifecycleExecutionGuardrail::CreateTableDdlExecution,
115 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
116 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
117 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
118 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
119];
120
121const REPLACE_EXECUTION_GUARDRAILS: &[MssqlLifecycleExecutionGuardrail] = &[
122 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
123 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
124 MssqlLifecycleExecutionGuardrail::CreateTableDdlExecution,
125 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
126 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
127 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
128 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
129];
130
131pub fn plan_mssql_lifecycle(
137 schema_plan: &MssqlSchemaPlan,
138 ddl_plan: Option<&MssqlDdlPlan>,
139) -> Result<MssqlLifecyclePlan, DeltaFunnelError> {
140 let target = schema_plan.target();
141
142 if let Some(ddl_plan) = ddl_plan {
143 ensure_matching_target(target, ddl_plan.target())?;
144 }
145
146 match target.load_mode() {
147 LoadMode::AppendExisting => plan_append_existing_lifecycle(target, ddl_plan),
148 LoadMode::CreateAndLoad => plan_create_and_load_lifecycle(target, ddl_plan),
149 LoadMode::Replace => plan_replace_lifecycle(target, ddl_plan),
150 }
151}
152
153fn plan_append_existing_lifecycle(
154 target: &MssqlTargetSummary,
155 ddl_plan: Option<&MssqlDdlPlan>,
156) -> Result<MssqlLifecyclePlan, DeltaFunnelError> {
157 if ddl_plan.and_then(MssqlDdlPlan::create_table_sql).is_some() {
158 return Err(lifecycle_error(
159 target,
160 "append-existing lifecycle must not carry create-table SQL",
161 ));
162 }
163
164 Ok(MssqlLifecyclePlan {
165 target: target.clone(),
166 expected_target_state: MssqlTargetTableState::Exists,
167 create_table_sql_required: false,
168 create_table_sql_present: false,
169 executable_in_mvp: true,
170 guardrail_policy: MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning,
171 execution_guardrails: APPEND_EXISTING_EXECUTION_GUARDRAILS,
172 })
173}
174
175fn plan_create_and_load_lifecycle(
176 target: &MssqlTargetSummary,
177 ddl_plan: Option<&MssqlDdlPlan>,
178) -> Result<MssqlLifecyclePlan, DeltaFunnelError> {
179 let create_table_sql_present = ddl_plan.and_then(MssqlDdlPlan::create_table_sql).is_some();
180 if !create_table_sql_present {
181 return Err(lifecycle_error(
182 target,
183 "create-and-load lifecycle requires planned create-table SQL",
184 ));
185 }
186
187 Ok(MssqlLifecyclePlan {
188 target: target.clone(),
189 expected_target_state: MssqlTargetTableState::Absent,
190 create_table_sql_required: true,
191 create_table_sql_present,
192 executable_in_mvp: true,
193 guardrail_policy: MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning,
194 execution_guardrails: CREATE_AND_LOAD_EXECUTION_GUARDRAILS,
195 })
196}
197
198fn plan_replace_lifecycle(
199 target: &MssqlTargetSummary,
200 ddl_plan: Option<&MssqlDdlPlan>,
201) -> Result<MssqlLifecyclePlan, DeltaFunnelError> {
202 let create_table_sql_present = ddl_plan
203 .map(MssqlDdlPlan::create_table_sql_present)
204 .unwrap_or(false);
205 if !create_table_sql_present {
206 return Err(lifecycle_error(
207 target,
208 "replace lifecycle requires planned create-table SQL",
209 ));
210 }
211
212 Ok(MssqlLifecyclePlan {
213 target: target.clone(),
214 expected_target_state: MssqlTargetTableState::ExistsOrAbsent,
215 create_table_sql_required: true,
216 create_table_sql_present,
217 executable_in_mvp: true,
218 guardrail_policy: MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning,
219 execution_guardrails: REPLACE_EXECUTION_GUARDRAILS,
220 })
221}
222
223fn ensure_matching_target(
224 schema_target: &MssqlTargetSummary,
225 ddl_target: &MssqlTargetSummary,
226) -> Result<(), DeltaFunnelError> {
227 if schema_target.output_name() != ddl_target.output_name()
228 || schema_target.table() != ddl_target.table()
229 || schema_target.connection_source() != ddl_target.connection_source()
230 || schema_target.connection() != ddl_target.connection()
231 {
232 return Err(lifecycle_error(
233 schema_target,
234 "schema plan and DDL plan targets must match",
235 ));
236 }
237
238 Ok(())
239}
240
241fn lifecycle_error(target: &MssqlTargetSummary, message: impl Into<String>) -> DeltaFunnelError {
242 DeltaFunnelError::MssqlLifecyclePlanning {
243 output_name: target.output_name().to_owned(),
244 message: message.into(),
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use arrow_schema::{DataType, Field, Schema};
251 use arrow_tiberius::PlanOptions;
252
253 use super::*;
254 use crate::{
255 MssqlConnectionConfig, MssqlTargetConfig, MssqlTargetResolutionContext, MssqlTargetTable,
256 plan_mssql_create_table_ddl, plan_mssql_output_schema,
257 };
258
259 fn secret_connection(label: &str) -> Result<MssqlConnectionConfig, DeltaFunnelError> {
260 Ok(MssqlConnectionConfig::new(
261 "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
262 )?
263 .with_display_label(label))
264 }
265
266 fn schema_plan(
267 output_name: &str,
268 load_mode: LoadMode,
269 table: MssqlTargetTable,
270 ) -> Result<MssqlSchemaPlan, DeltaFunnelError> {
271 let connection = secret_connection("warehouse-primary")?;
272 let target = MssqlTargetConfig::new(table)
273 .with_load_mode(load_mode)
274 .resolve(MssqlTargetResolutionContext {
275 output_name: Some(output_name),
276 default_connection: Some(&connection),
277 })?;
278 let schema = Schema::new(vec![
279 Field::new("order_id", DataType::Int64, false),
280 Field::new("status", DataType::Utf8, true),
281 ]);
282
283 plan_mssql_output_schema(&schema, &target, PlanOptions::default())
284 }
285
286 #[test]
287 fn append_existing_reports_expected_existing_behavior() -> Result<(), DeltaFunnelError> {
288 let schema_plan = schema_plan(
289 "orders_output",
290 LoadMode::AppendExisting,
291 MssqlTargetTable::new("dbo", "orders")?,
292 )?;
293 let ddl_plan = plan_mssql_create_table_ddl(&schema_plan)?;
294
295 let lifecycle = plan_mssql_lifecycle(&schema_plan, Some(&ddl_plan))?;
296
297 assert_eq!(lifecycle.target().output_name(), "orders_output");
298 assert_eq!(
299 lifecycle.expected_target_state(),
300 MssqlTargetTableState::Exists
301 );
302 assert!(!lifecycle.create_table_sql_required());
303 assert!(!lifecycle.create_table_sql_present());
304 assert!(lifecycle.executable_in_mvp());
305 assert_eq!(
306 lifecycle.guardrail_policy(),
307 MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning
308 );
309 assert_eq!(
310 lifecycle.execution_guardrails(),
311 APPEND_EXISTING_EXECUTION_GUARDRAILS
312 );
313 Ok(())
314 }
315
316 #[test]
317 fn create_and_load_reports_expected_absent_behavior() -> Result<(), DeltaFunnelError> {
318 let primary_schema_plan = schema_plan(
319 "orders_output",
320 LoadMode::CreateAndLoad,
321 MssqlTargetTable::new("dbo", "orders")?,
322 )?;
323 let ddl_plan = plan_mssql_create_table_ddl(&primary_schema_plan)?;
324
325 let lifecycle = plan_mssql_lifecycle(&primary_schema_plan, Some(&ddl_plan))?;
326
327 assert_eq!(
328 lifecycle.expected_target_state(),
329 MssqlTargetTableState::Absent
330 );
331 assert!(lifecycle.create_table_sql_required());
332 assert!(lifecycle.create_table_sql_present());
333 assert!(lifecycle.executable_in_mvp());
334 assert_eq!(
335 lifecycle.guardrail_policy(),
336 MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning
337 );
338 assert_eq!(
339 lifecycle.execution_guardrails(),
340 CREATE_AND_LOAD_EXECUTION_GUARDRAILS
341 );
342 Ok(())
343 }
344
345 #[test]
346 fn lifecycle_reports_guarded_execution_by_load_mode() -> Result<(), DeltaFunnelError> {
347 let append_schema_plan = schema_plan(
348 "append_output",
349 LoadMode::AppendExisting,
350 MssqlTargetTable::new("dbo", "orders")?,
351 )?;
352 let append_ddl_plan = plan_mssql_create_table_ddl(&append_schema_plan)?;
353 let append_lifecycle = plan_mssql_lifecycle(&append_schema_plan, Some(&append_ddl_plan))?;
354
355 assert_eq!(
356 append_lifecycle.execution_guardrails(),
357 &[
358 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
359 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
360 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
361 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
362 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
363 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
364 ]
365 );
366
367 let create_schema_plan = schema_plan(
368 "create_output",
369 LoadMode::CreateAndLoad,
370 MssqlTargetTable::new("dbo", "orders_archive")?,
371 )?;
372 let create_ddl_plan = plan_mssql_create_table_ddl(&create_schema_plan)?;
373 let create_lifecycle = plan_mssql_lifecycle(&create_schema_plan, Some(&create_ddl_plan))?;
374
375 assert_eq!(
376 create_lifecycle.execution_guardrails(),
377 &[
378 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
379 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
380 MssqlLifecycleExecutionGuardrail::CreateTableDdlExecution,
381 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
382 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
383 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
384 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
385 ]
386 );
387 Ok(())
388 }
389
390 #[test]
391 fn create_and_load_requires_planned_create_table_sql() -> Result<(), DeltaFunnelError> {
392 let primary_schema_plan = schema_plan(
393 "orders_output",
394 LoadMode::CreateAndLoad,
395 MssqlTargetTable::new("dbo", "orders")?,
396 )?;
397
398 let error = plan_mssql_lifecycle(&primary_schema_plan, None)
399 .err()
400 .ok_or_else(|| DeltaFunnelError::Config {
401 message: "expected missing create-table SQL error".to_owned(),
402 })?;
403
404 assert!(matches!(
405 error,
406 DeltaFunnelError::MssqlLifecyclePlanning { .. }
407 ));
408 assert!(
409 error
410 .to_string()
411 .contains("requires planned create-table SQL")
412 );
413 Ok(())
414 }
415
416 #[test]
417 fn replace_reports_flexible_target_state_and_create_sql() -> Result<(), DeltaFunnelError> {
418 let schema_plan = schema_plan(
419 "orders_output",
420 LoadMode::Replace,
421 MssqlTargetTable::new("dbo", "orders")?,
422 )?;
423 let ddl_plan = plan_mssql_create_table_ddl(&schema_plan)?;
424
425 let lifecycle = plan_mssql_lifecycle(&schema_plan, Some(&ddl_plan))?;
426
427 assert_eq!(
428 lifecycle.expected_target_state(),
429 MssqlTargetTableState::ExistsOrAbsent
430 );
431 assert!(lifecycle.create_table_sql_required());
432 assert!(lifecycle.create_table_sql_present());
433 assert!(lifecycle.executable_in_mvp());
434 assert_eq!(
435 lifecycle.guardrail_policy(),
436 MssqlLifecycleGuardrailPolicy::AllowedAfterPlanning
437 );
438 assert_eq!(
439 lifecycle.execution_guardrails(),
440 REPLACE_EXECUTION_GUARDRAILS
441 );
442 Ok(())
443 }
444
445 #[test]
446 fn replace_requires_planned_create_table_sql() -> Result<(), DeltaFunnelError> {
447 let schema_plan = schema_plan(
448 "orders_output",
449 LoadMode::Replace,
450 MssqlTargetTable::new("dbo", "orders")?,
451 )?;
452
453 let error = plan_mssql_lifecycle(&schema_plan, None)
454 .err()
455 .ok_or_else(|| DeltaFunnelError::Config {
456 message: "expected missing replace create-table SQL error".to_owned(),
457 })?;
458
459 assert!(matches!(
460 error,
461 DeltaFunnelError::MssqlLifecyclePlanning { .. }
462 ));
463 assert!(
464 error
465 .to_string()
466 .contains("requires planned create-table SQL")
467 );
468 Ok(())
469 }
470
471 #[test]
472 fn lifecycle_failures_block_guarded_execution() -> Result<(), DeltaFunnelError> {
473 let missing_create_sql_plan = schema_plan(
474 "create_output",
475 LoadMode::CreateAndLoad,
476 MssqlTargetTable::new("dbo", "orders_archive")?,
477 )?;
478 let append_plan = schema_plan(
479 "append_output",
480 LoadMode::AppendExisting,
481 MssqlTargetTable::new("dbo", "orders_existing")?,
482 )?;
483 let create_plan = schema_plan(
484 "append_output",
485 LoadMode::CreateAndLoad,
486 MssqlTargetTable::new("dbo", "orders_existing")?,
487 )?;
488 let contradictory_ddl_plan = plan_mssql_create_table_ddl(&create_plan)?;
489
490 let cases = [
491 (
492 "missing create-table SQL",
493 plan_mssql_lifecycle(&missing_create_sql_plan, None),
494 ),
495 (
496 "append-existing create-table SQL contradiction",
497 plan_mssql_lifecycle(&append_plan, Some(&contradictory_ddl_plan)),
498 ),
499 ];
500
501 for (case_name, result) in cases {
502 let mut attempted_execution = Vec::new();
503 let error = match result {
504 Ok(_lifecycle) => {
505 fake_guarded_execution(ALL_GUARDED_EXECUTION, &mut attempted_execution);
506 return Err(DeltaFunnelError::Config {
507 message: format!("expected {case_name} lifecycle error"),
508 });
509 }
510 Err(error) => error,
511 };
512
513 assert!(matches!(
514 error,
515 DeltaFunnelError::MssqlLifecyclePlanning { .. }
516 ));
517 assert!(attempted_execution.is_empty());
518 }
519
520 Ok(())
521 }
522
523 #[test]
524 fn append_existing_rejects_create_table_sql_contradiction() -> Result<(), DeltaFunnelError> {
525 let append_plan = schema_plan(
526 "orders_output",
527 LoadMode::AppendExisting,
528 MssqlTargetTable::new("dbo", "orders")?,
529 )?;
530 let create_plan = schema_plan(
531 "orders_output",
532 LoadMode::CreateAndLoad,
533 MssqlTargetTable::new("dbo", "orders")?,
534 )?;
535 let ddl_plan = plan_mssql_create_table_ddl(&create_plan)?;
536
537 let error = plan_mssql_lifecycle(&append_plan, Some(&ddl_plan))
538 .err()
539 .ok_or_else(|| DeltaFunnelError::Config {
540 message: "expected append/create SQL contradiction".to_owned(),
541 })?;
542
543 assert!(
544 error
545 .to_string()
546 .contains("must not carry create-table SQL")
547 );
548 Ok(())
549 }
550
551 #[test]
552 fn mismatched_schema_and_ddl_targets_are_rejected() -> Result<(), DeltaFunnelError> {
553 let primary_schema_plan = schema_plan(
554 "orders_output",
555 LoadMode::CreateAndLoad,
556 MssqlTargetTable::new("dbo", "orders")?,
557 )?;
558 let other_schema_plan = schema_plan(
559 "other_output",
560 LoadMode::CreateAndLoad,
561 MssqlTargetTable::new("dbo", "other_orders")?,
562 )?;
563 let ddl_plan = plan_mssql_create_table_ddl(&other_schema_plan)?;
564
565 let error = plan_mssql_lifecycle(&primary_schema_plan, Some(&ddl_plan))
566 .err()
567 .ok_or_else(|| DeltaFunnelError::Config {
568 message: "expected target mismatch error".to_owned(),
569 })?;
570
571 assert!(error.to_string().contains("targets must match"));
572 Ok(())
573 }
574
575 #[test]
576 fn reports_and_errors_do_not_expose_connection_secrets() -> Result<(), DeltaFunnelError> {
577 let create_schema_plan = schema_plan(
578 "orders_output",
579 LoadMode::CreateAndLoad,
580 MssqlTargetTable::new("dbo", "orders")?,
581 )?;
582 let ddl_plan = plan_mssql_create_table_ddl(&create_schema_plan)?;
583 let lifecycle = plan_mssql_lifecycle(&create_schema_plan, Some(&ddl_plan))?;
584
585 let combined = format!("{lifecycle:?}");
586 assert!(!combined.contains("secret-token"));
587 assert!(!combined.contains("password"));
588 assert!(!combined.contains("server=tcp"));
589 assert!(combined.contains("warehouse-primary"));
590 Ok(())
591 }
592
593 const ALL_GUARDED_EXECUTION: &[MssqlLifecycleExecutionGuardrail] = &[
594 MssqlLifecycleExecutionGuardrail::DeltaSourceScanExecution,
595 MssqlLifecycleExecutionGuardrail::DataFusionPhysicalPlanExecution,
596 MssqlLifecycleExecutionGuardrail::SqlServerConnectionAttempt,
597 MssqlLifecycleExecutionGuardrail::TargetTableExistenceProbe,
598 MssqlLifecycleExecutionGuardrail::CreateTableDdlExecution,
599 MssqlLifecycleExecutionGuardrail::BulkWriterConstruction,
600 MssqlLifecycleExecutionGuardrail::RecordBatchHandoffPolling,
601 ];
602
603 fn fake_guarded_execution(
604 guardrails: &[MssqlLifecycleExecutionGuardrail],
605 attempted_execution: &mut Vec<MssqlLifecycleExecutionGuardrail>,
606 ) {
607 attempted_execution.extend(guardrails.iter().copied());
608 }
609}