Skip to main content

delta_funnel/sql_server/
target.rs

1//! SQL Server target configuration and redacted connection resolution.
2
3use std::fmt;
4
5use crate::{
6    DeltaFunnelError,
7    error::{MissingMssqlConnectionSnafu, MssqlTargetConfigSnafu},
8    support::sanitize_text_for_display,
9};
10
11use snafu::ensure;
12
13/// SQL Server table lifecycle mode requested for one selected output.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum LoadMode {
16    /// Write rows into an already existing target table.
17    #[default]
18    AppendExisting,
19    /// Plan a target table definition and then load rows into the new table.
20    CreateAndLoad,
21    /// Write rows to a staging table, validate them, then swap it into the target name.
22    Replace,
23}
24
25/// A SQL Server connection configuration with secret-bearing material hidden from display.
26#[derive(Clone, PartialEq, Eq)]
27pub struct MssqlConnectionConfig {
28    connection_string: String,
29    display_label: Option<String>,
30}
31
32impl MssqlConnectionConfig {
33    /// Creates a connection config from the raw connection string used by later I/O code.
34    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    /// Adds a caller-provided non-secret label for diagnostics and reports.
51    #[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    /// Returns the raw connection string for later connection construction.
58    ///
59    /// Callers should avoid formatting this value in errors, logs, or reports.
60    #[must_use]
61    pub fn connection_string(&self) -> &str {
62        &self.connection_string
63    }
64
65    /// Returns a redacted summary safe for diagnostics and planning reports.
66    #[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/// Redacted SQL Server connection context suitable for reports and errors.
91#[derive(Clone, PartialEq, Eq)]
92pub struct MssqlConnectionSummary {
93    display_label: Option<String>,
94}
95
96impl MssqlConnectionSummary {
97    /// Returns the optional caller-provided non-secret display label.
98    #[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/// SQL Server table identity before arrow-tiberius identifier validation.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct MssqlTargetTable {
134    schema: Option<String>,
135    table: String,
136}
137
138impl MssqlTargetTable {
139    /// Creates a schema-qualified target table identity.
140    ///
141    /// Full SQL Server identifier validation is delegated to `arrow-tiberius`
142    /// in the later DDL planning slice. This constructor only rejects empty
143    /// config fields so target identity cannot collapse into an ambiguous string.
144    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    /// Creates an unqualified target table identity.
172    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    /// Returns the optional target schema name.
189    #[must_use]
190    pub fn schema(&self) -> Option<&str> {
191        self.schema.as_deref()
192    }
193
194    /// Returns the target table name.
195    #[must_use]
196    pub fn table(&self) -> &str {
197        &self.table
198    }
199}
200
201/// Target configuration for writing one selected output to SQL Server.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct MssqlTargetConfig {
204    table: MssqlTargetTable,
205    load_mode: LoadMode,
206    connection: Option<MssqlConnectionConfig>,
207}
208
209impl MssqlTargetConfig {
210    /// Creates an append-existing target config for the given table identity.
211    #[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    /// Sets the requested target lifecycle mode.
221    #[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    /// Sets a per-output connection override.
228    #[must_use]
229    pub fn with_connection(mut self, connection: MssqlConnectionConfig) -> Self {
230        self.connection = Some(connection);
231        self
232    }
233
234    /// Returns the target table identity.
235    #[must_use]
236    pub fn table(&self) -> &MssqlTargetTable {
237        &self.table
238    }
239
240    /// Returns the requested target lifecycle mode.
241    #[must_use]
242    pub fn load_mode(&self) -> LoadMode {
243        self.load_mode
244    }
245
246    /// Returns the optional per-output connection override.
247    #[must_use]
248    pub fn connection(&self) -> Option<&MssqlConnectionConfig> {
249        self.connection.as_ref()
250    }
251
252    /// Resolves this target against context-level defaults.
253    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/// Context defaults available while resolving one selected output target.
285#[derive(Debug, Clone, Copy, Default)]
286pub struct MssqlTargetResolutionContext<'a> {
287    /// Selected output name used for reports and errors.
288    pub output_name: Option<&'a str>,
289    /// Context-level default connection used when the target has no override.
290    pub default_connection: Option<&'a MssqlConnectionConfig>,
291}
292
293/// Where an effective SQL Server connection came from.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum MssqlConnectionSource {
296    /// The selected output target supplied its own connection.
297    TargetOverride,
298    /// The selected output used the context/session default connection.
299    ContextDefault,
300}
301
302/// Resolved SQL Server target for one selected output.
303#[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    /// Returns the selected output name associated with this target.
314    #[must_use]
315    pub fn output_name(&self) -> &str {
316        &self.output_name
317    }
318
319    /// Returns the target table identity.
320    #[must_use]
321    pub fn table(&self) -> &MssqlTargetTable {
322        &self.table
323    }
324
325    /// Returns the requested target lifecycle mode.
326    #[must_use]
327    pub fn load_mode(&self) -> LoadMode {
328        self.load_mode
329    }
330
331    /// Returns the effective connection.
332    #[must_use]
333    pub fn connection(&self) -> &MssqlConnectionConfig {
334        &self.connection
335    }
336
337    /// Returns where the effective connection came from.
338    #[must_use]
339    pub fn connection_source(&self) -> MssqlConnectionSource {
340        self.connection_source
341    }
342
343    /// Returns a redacted planning summary for diagnostics and Python-facing reports.
344    #[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/// Redacted report for one resolved SQL Server target.
370#[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    /// Returns the selected output name.
381    #[must_use]
382    pub fn output_name(&self) -> &str {
383        &self.output_name
384    }
385
386    /// Returns the target table identity.
387    #[must_use]
388    pub fn table(&self) -> &MssqlTargetTable {
389        &self.table
390    }
391
392    /// Returns the requested target lifecycle mode.
393    #[must_use]
394    pub fn load_mode(&self) -> LoadMode {
395        self.load_mode
396    }
397
398    /// Returns where the effective connection came from.
399    #[must_use]
400    pub fn connection_source(&self) -> MssqlConnectionSource {
401        self.connection_source
402    }
403
404    /// Returns the redacted effective connection summary.
405    #[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}