1use std::fmt;
4
5use crate::{
6 DeltaFunnelError,
7 error::{MissingMssqlConnectionSnafu, MssqlTargetConfigSnafu},
8 support::sanitize_text_for_display,
9};
10
11use snafu::ensure;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum LoadMode {
16 #[default]
18 AppendExisting,
19 CreateAndLoad,
21 Replace,
23}
24
25#[derive(Clone, PartialEq, Eq)]
27pub struct MssqlConnectionConfig {
28 connection_string: String,
29 display_label: Option<String>,
30}
31
32impl MssqlConnectionConfig {
33 pub fn new(connection_string: impl Into<String>) -> Result<Self, DeltaFunnelError> {
35 let connection_string = connection_string.into();
36 ensure!(
37 !connection_string.trim().is_empty(),
38 MssqlTargetConfigSnafu {
39 option: "connection.connection_string",
40 message: "connection string must not be empty"
41 }
42 );
43
44 Ok(Self {
45 connection_string,
46 display_label: None,
47 })
48 }
49
50 #[must_use]
52 pub fn with_display_label(mut self, label: impl Into<String>) -> Self {
53 self.display_label = Some(label.into());
54 self
55 }
56
57 #[must_use]
61 pub fn connection_string(&self) -> &str {
62 &self.connection_string
63 }
64
65 #[must_use]
67 pub fn summary(&self) -> MssqlConnectionSummary {
68 MssqlConnectionSummary {
69 display_label: self.display_label.clone(),
70 }
71 }
72}
73
74impl fmt::Debug for MssqlConnectionConfig {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 formatter
77 .debug_struct("MssqlConnectionConfig")
78 .field("connection_string", &"<redacted>")
79 .field("summary", &self.summary())
80 .finish()
81 }
82}
83
84impl fmt::Display for MssqlConnectionConfig {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 self.summary().fmt(formatter)
87 }
88}
89
90#[derive(Clone, PartialEq, Eq)]
92pub struct MssqlConnectionSummary {
93 display_label: Option<String>,
94}
95
96impl MssqlConnectionSummary {
97 #[must_use]
99 pub fn display_label(&self) -> Option<&str> {
100 self.display_label.as_deref()
101 }
102}
103
104impl fmt::Debug for MssqlConnectionSummary {
105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106 formatter
107 .debug_struct("MssqlConnectionSummary")
108 .field(
109 "display_label",
110 &self.display_label.as_deref().map(sanitize_text_for_display),
111 )
112 .finish()
113 }
114}
115
116impl fmt::Display for MssqlConnectionSummary {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self.display_label.as_deref() {
119 Some(label) => {
120 write!(
121 formatter,
122 "MSSQL connection `{}`",
123 sanitize_text_for_display(label)
124 )
125 }
126 None => formatter.write_str("MSSQL connection <redacted>"),
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct MssqlTargetTable {
134 schema: Option<String>,
135 table: String,
136}
137
138impl MssqlTargetTable {
139 pub fn new(
145 schema: impl Into<String>,
146 table: impl Into<String>,
147 ) -> Result<Self, DeltaFunnelError> {
148 let schema = schema.into();
149 let table = table.into();
150 ensure!(
151 !schema.trim().is_empty(),
152 MssqlTargetConfigSnafu {
153 option: "target.schema",
154 message: "schema must not be empty"
155 }
156 );
157 ensure!(
158 !table.trim().is_empty(),
159 MssqlTargetConfigSnafu {
160 option: "target.table",
161 message: "table must not be empty"
162 }
163 );
164
165 Ok(Self {
166 schema: Some(schema),
167 table,
168 })
169 }
170
171 pub fn unqualified(table: impl Into<String>) -> Result<Self, DeltaFunnelError> {
173 let table = table.into();
174 ensure!(
175 !table.trim().is_empty(),
176 MssqlTargetConfigSnafu {
177 option: "target.table",
178 message: "table must not be empty"
179 }
180 );
181
182 Ok(Self {
183 schema: None,
184 table,
185 })
186 }
187
188 #[must_use]
190 pub fn schema(&self) -> Option<&str> {
191 self.schema.as_deref()
192 }
193
194 #[must_use]
196 pub fn table(&self) -> &str {
197 &self.table
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct MssqlTargetConfig {
204 table: MssqlTargetTable,
205 load_mode: LoadMode,
206 connection: Option<MssqlConnectionConfig>,
207}
208
209impl MssqlTargetConfig {
210 #[must_use]
212 pub fn new(table: MssqlTargetTable) -> Self {
213 Self {
214 table,
215 load_mode: LoadMode::default(),
216 connection: None,
217 }
218 }
219
220 #[must_use]
222 pub fn with_load_mode(mut self, load_mode: LoadMode) -> Self {
223 self.load_mode = load_mode;
224 self
225 }
226
227 #[must_use]
229 pub fn with_connection(mut self, connection: MssqlConnectionConfig) -> Self {
230 self.connection = Some(connection);
231 self
232 }
233
234 #[must_use]
236 pub fn table(&self) -> &MssqlTargetTable {
237 &self.table
238 }
239
240 #[must_use]
242 pub fn load_mode(&self) -> LoadMode {
243 self.load_mode
244 }
245
246 #[must_use]
248 pub fn connection(&self) -> Option<&MssqlConnectionConfig> {
249 self.connection.as_ref()
250 }
251
252 pub fn resolve(
254 &self,
255 context: MssqlTargetResolutionContext<'_>,
256 ) -> Result<ResolvedMssqlTarget, DeltaFunnelError> {
257 let output_name = context.output_name.unwrap_or("<unnamed>").to_owned();
258 let (connection, connection_source) = self
259 .connection
260 .as_ref()
261 .map(|connection| (connection, MssqlConnectionSource::TargetOverride))
262 .or_else(|| {
263 context
264 .default_connection
265 .map(|connection| (connection, MssqlConnectionSource::ContextDefault))
266 })
267 .ok_or_else(|| {
268 MissingMssqlConnectionSnafu {
269 output_name: output_name.clone(),
270 }
271 .build()
272 })?;
273
274 Ok(ResolvedMssqlTarget {
275 output_name,
276 table: self.table.clone(),
277 load_mode: self.load_mode,
278 connection: connection.clone(),
279 connection_source,
280 })
281 }
282}
283
284#[derive(Debug, Clone, Copy, Default)]
286pub struct MssqlTargetResolutionContext<'a> {
287 pub output_name: Option<&'a str>,
289 pub default_connection: Option<&'a MssqlConnectionConfig>,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum MssqlConnectionSource {
296 TargetOverride,
298 ContextDefault,
300}
301
302#[derive(Clone, PartialEq, Eq)]
304pub struct ResolvedMssqlTarget {
305 output_name: String,
306 table: MssqlTargetTable,
307 load_mode: LoadMode,
308 connection: MssqlConnectionConfig,
309 connection_source: MssqlConnectionSource,
310}
311
312impl ResolvedMssqlTarget {
313 #[must_use]
315 pub fn output_name(&self) -> &str {
316 &self.output_name
317 }
318
319 #[must_use]
321 pub fn table(&self) -> &MssqlTargetTable {
322 &self.table
323 }
324
325 #[must_use]
327 pub fn load_mode(&self) -> LoadMode {
328 self.load_mode
329 }
330
331 #[must_use]
333 pub fn connection(&self) -> &MssqlConnectionConfig {
334 &self.connection
335 }
336
337 #[must_use]
339 pub fn connection_source(&self) -> MssqlConnectionSource {
340 self.connection_source
341 }
342
343 #[must_use]
345 pub fn summary(&self) -> MssqlTargetSummary {
346 MssqlTargetSummary {
347 output_name: self.output_name.clone(),
348 table: self.table.clone(),
349 load_mode: self.load_mode,
350 connection_source: self.connection_source,
351 connection: self.connection.summary(),
352 }
353 }
354}
355
356impl fmt::Debug for ResolvedMssqlTarget {
357 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
358 formatter
359 .debug_struct("ResolvedMssqlTarget")
360 .field("output_name", &sanitize_text_for_display(&self.output_name))
361 .field("table", &self.table)
362 .field("load_mode", &self.load_mode)
363 .field("connection", &self.connection.summary())
364 .field("connection_source", &self.connection_source)
365 .finish()
366 }
367}
368
369#[derive(Clone, PartialEq, Eq)]
371pub struct MssqlTargetSummary {
372 output_name: String,
373 table: MssqlTargetTable,
374 load_mode: LoadMode,
375 connection_source: MssqlConnectionSource,
376 connection: MssqlConnectionSummary,
377}
378
379impl MssqlTargetSummary {
380 #[must_use]
382 pub fn output_name(&self) -> &str {
383 &self.output_name
384 }
385
386 #[must_use]
388 pub fn table(&self) -> &MssqlTargetTable {
389 &self.table
390 }
391
392 #[must_use]
394 pub fn load_mode(&self) -> LoadMode {
395 self.load_mode
396 }
397
398 #[must_use]
400 pub fn connection_source(&self) -> MssqlConnectionSource {
401 self.connection_source
402 }
403
404 #[must_use]
406 pub fn connection(&self) -> &MssqlConnectionSummary {
407 &self.connection
408 }
409}
410
411impl fmt::Debug for MssqlTargetSummary {
412 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
413 formatter
414 .debug_struct("MssqlTargetSummary")
415 .field("output_name", &sanitize_text_for_display(&self.output_name))
416 .field("table", &self.table)
417 .field("load_mode", &self.load_mode)
418 .field("connection_source", &self.connection_source)
419 .field("connection", &self.connection)
420 .finish()
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn secret_connection(label: &str) -> Result<MssqlConnectionConfig, DeltaFunnelError> {
429 Ok(MssqlConnectionConfig::new(
430 "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
431 )?
432 .with_display_label(label))
433 }
434
435 #[test]
436 fn target_override_connection_wins_over_context_default() -> Result<(), DeltaFunnelError> {
437 let default_connection = secret_connection("context-default")?;
438 let override_connection = secret_connection("target-override")?;
439 let target = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "west_orders")?)
440 .with_connection(override_connection.clone());
441
442 let resolved = target.resolve(MssqlTargetResolutionContext {
443 output_name: Some("west"),
444 default_connection: Some(&default_connection),
445 })?;
446
447 assert_eq!(
448 resolved.connection().connection_string(),
449 override_connection.connection_string()
450 );
451 assert_eq!(
452 resolved.connection_source(),
453 MssqlConnectionSource::TargetOverride
454 );
455 assert_eq!(resolved.output_name(), "west");
456 Ok(())
457 }
458
459 #[test]
460 fn context_default_connection_is_used_without_override() -> Result<(), DeltaFunnelError> {
461 let default_connection = secret_connection("context-default")?;
462 let target = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "east_orders")?);
463
464 let resolved = target.resolve(MssqlTargetResolutionContext {
465 output_name: Some("east"),
466 default_connection: Some(&default_connection),
467 })?;
468
469 assert_eq!(
470 resolved.connection().connection_string(),
471 default_connection.connection_string()
472 );
473 assert_eq!(
474 resolved.connection_source(),
475 MssqlConnectionSource::ContextDefault
476 );
477 Ok(())
478 }
479
480 #[test]
481 fn missing_effective_connection_returns_structured_error() -> Result<(), DeltaFunnelError> {
482 let target = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "west_orders")?);
483
484 let error = target
485 .resolve(MssqlTargetResolutionContext {
486 output_name: Some("west\norders"),
487 default_connection: None,
488 })
489 .err();
490
491 assert!(matches!(
492 error,
493 Some(DeltaFunnelError::MissingMssqlConnection { .. })
494 ));
495 let display = error
496 .map(|error| error.to_string())
497 .unwrap_or_else(|| "expected error".to_owned());
498 assert!(!display.contains('\n'));
499 assert!(display.contains(r"west\norders"));
500 Ok(())
501 }
502
503 #[test]
504 fn targets_resolve_independently_for_multiple_outputs() -> Result<(), DeltaFunnelError> {
505 let default_connection = secret_connection("context-default")?;
506 let east_connection = secret_connection("east-override")?;
507 let west = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "west_orders")?);
508 let east = MssqlTargetConfig::new(MssqlTargetTable::new("reporting", "east_orders")?)
509 .with_load_mode(LoadMode::CreateAndLoad)
510 .with_connection(east_connection.clone());
511
512 let resolved_west = west.resolve(MssqlTargetResolutionContext {
513 output_name: Some("west"),
514 default_connection: Some(&default_connection),
515 })?;
516 let resolved_east = east.resolve(MssqlTargetResolutionContext {
517 output_name: Some("east"),
518 default_connection: Some(&default_connection),
519 })?;
520
521 assert_eq!(resolved_west.table().schema(), Some("dbo"));
522 assert_eq!(resolved_west.table().table(), "west_orders");
523 assert_eq!(resolved_west.load_mode(), LoadMode::AppendExisting);
524 assert_eq!(
525 resolved_west.connection_source(),
526 MssqlConnectionSource::ContextDefault
527 );
528
529 assert_eq!(resolved_east.table().schema(), Some("reporting"));
530 assert_eq!(resolved_east.table().table(), "east_orders");
531 assert_eq!(resolved_east.load_mode(), LoadMode::CreateAndLoad);
532 assert_eq!(
533 resolved_east.connection_source(),
534 MssqlConnectionSource::TargetOverride
535 );
536 assert_eq!(
537 resolved_east.connection().connection_string(),
538 east_connection.connection_string()
539 );
540 Ok(())
541 }
542
543 #[test]
544 fn load_modes_are_explicit() {
545 assert_eq!(LoadMode::default(), LoadMode::AppendExisting);
546 assert_eq!(LoadMode::AppendExisting, LoadMode::AppendExisting);
547 assert_eq!(LoadMode::CreateAndLoad, LoadMode::CreateAndLoad);
548 assert_eq!(LoadMode::Replace, LoadMode::Replace);
549 }
550
551 #[test]
552 fn connection_debug_display_and_reports_redact_secret_material() -> Result<(), DeltaFunnelError>
553 {
554 let connection = secret_connection("warehouse-primary")?;
555 let target = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?)
556 .with_connection(connection.clone());
557 let resolved = target.resolve(MssqlTargetResolutionContext {
558 output_name: Some("orders"),
559 default_connection: None,
560 })?;
561
562 let combined = format!(
563 "{connection:?}\n{connection}\n{resolved:?}\n{:?}",
564 resolved.summary()
565 );
566
567 assert!(!combined.contains("secret-token"));
568 assert!(!combined.contains("password"));
569 assert!(!combined.contains("server=tcp"));
570 assert!(combined.contains("warehouse-primary"));
571 Ok(())
572 }
573
574 #[test]
575 fn table_identity_keeps_schema_and_table_separate() -> Result<(), DeltaFunnelError> {
576 let qualified = MssqlTargetTable::new("dbo", "orders")?;
577 let unqualified = MssqlTargetTable::unqualified("orders")?;
578
579 assert_eq!(qualified.schema(), Some("dbo"));
580 assert_eq!(qualified.table(), "orders");
581 assert_eq!(unqualified.schema(), None);
582 assert_eq!(unqualified.table(), "orders");
583 Ok(())
584 }
585
586 #[test]
587 fn empty_table_identity_is_rejected() {
588 assert!(matches!(
589 MssqlConnectionConfig::new(" "),
590 Err(DeltaFunnelError::MssqlTargetConfig { .. })
591 ));
592 assert!(matches!(
593 MssqlTargetTable::new(" ", "orders"),
594 Err(DeltaFunnelError::MssqlTargetConfig { .. })
595 ));
596 assert!(matches!(
597 MssqlTargetTable::unqualified(" "),
598 Err(DeltaFunnelError::MssqlTargetConfig { .. })
599 ));
600 }
601}