Skip to main content

delta_funnel/report/sql_server/
dry_run.rs

1use std::fmt;
2
3use crate::{
4    DeltaSourceReport, LazyTableKind, LoadMode, MssqlDdlPlan, MssqlLifecyclePlan, MssqlSchemaPlan,
5    MssqlTargetTable, OutputStatus, PhaseTimingReport, PlannedMssqlOutput, ReportReasonCode,
6    RowCount, RunMode, SourceUsageStatus, ValidationStatus, WorkflowStatus,
7};
8
9/// Output schema field included in an MSSQL dry-run report.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct MssqlDryRunOutputFieldReport {
12    index: u64,
13    name: String,
14    arrow_type: String,
15    nullable: bool,
16}
17
18impl MssqlDryRunOutputFieldReport {
19    pub(crate) fn from_mapping(mapping: &arrow_tiberius::SchemaMapping) -> Self {
20        Self {
21            index: crate::usize_to_u64_saturating(mapping.arrow().index()),
22            name: mapping.arrow().name().to_owned(),
23            arrow_type: mapping.arrow().data_type().to_string(),
24            nullable: mapping.arrow().nullable(),
25        }
26    }
27
28    /// Returns the zero-based output field index.
29    #[must_use]
30    pub const fn index(&self) -> u64 {
31        self.index
32    }
33
34    /// Returns the output field name.
35    #[must_use]
36    pub fn name(&self) -> &str {
37        &self.name
38    }
39
40    /// Returns the Arrow data type as a stable display string.
41    #[must_use]
42    pub fn arrow_type(&self) -> &str {
43        &self.arrow_type
44    }
45
46    /// Returns true when the output field is nullable.
47    #[must_use]
48    pub const fn nullable(&self) -> bool {
49        self.nullable
50    }
51}
52
53/// SQL identity state included in an MSSQL dry-run output report.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum MssqlDryRunSqlIdentityState {
56    /// A stable SQL identity hash is available.
57    Present,
58    /// No SQL identity applies to the selected lazy table.
59    Absent,
60    /// A SQL identity applies, but could not be reported from available metadata.
61    Unavailable,
62}
63
64impl MssqlDryRunSqlIdentityState {
65    /// Returns a stable lower-snake-case code for report serialization.
66    #[must_use]
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Self::Present => "present",
70            Self::Absent => "absent",
71            Self::Unavailable => "unavailable",
72        }
73    }
74}
75
76impl fmt::Display for MssqlDryRunSqlIdentityState {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter.write_str(self.as_str())
79    }
80}
81
82/// Redacted SQL identity included in an MSSQL dry-run output report.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct MssqlDryRunSqlIdentityReport {
85    state: MssqlDryRunSqlIdentityState,
86    hash: Option<String>,
87    reason: Option<ReportReasonCode>,
88}
89
90impl MssqlDryRunSqlIdentityReport {
91    pub(crate) fn present(hash: String) -> Self {
92        Self {
93            state: MssqlDryRunSqlIdentityState::Present,
94            hash: Some(hash),
95            reason: None,
96        }
97    }
98
99    pub(crate) fn absent() -> Self {
100        Self {
101            state: MssqlDryRunSqlIdentityState::Absent,
102            hash: None,
103            reason: None,
104        }
105    }
106
107    pub(crate) fn unavailable(reason: ReportReasonCode) -> Self {
108        Self {
109            state: MssqlDryRunSqlIdentityState::Unavailable,
110            hash: None,
111            reason: Some(reason),
112        }
113    }
114
115    /// Returns whether a SQL identity hash is present, absent, or unavailable.
116    #[must_use]
117    pub const fn state(&self) -> MssqlDryRunSqlIdentityState {
118        self.state
119    }
120
121    /// Returns the stable SQL identity hash when retained SQL is available.
122    #[must_use]
123    pub fn hash(&self) -> Option<&str> {
124        self.hash.as_deref()
125    }
126
127    /// Returns the reason when SQL identity reporting is unavailable.
128    #[must_use]
129    pub const fn reason(&self) -> Option<ReportReasonCode> {
130        self.reason
131    }
132}
133
134/// Dry-run planning report for one selected MSSQL output.
135///
136/// This report is produced after the session has resolved the output schema and
137/// planned the SQL Server target, but before any row production, SQL Server
138/// lifecycle action, bulk writer construction, or validation I/O.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct MssqlDryRunOutputReport {
141    planned_output: PlannedMssqlOutput,
142    output_schema: Vec<MssqlDryRunOutputFieldReport>,
143    sql_identity: MssqlDryRunSqlIdentityReport,
144    source_usage_status: SourceUsageStatus,
145    used_source_names: Vec<String>,
146    output_row_count: RowCount,
147    output_row_count_reason: Option<ReportReasonCode>,
148    status: OutputStatus,
149    validation_status: ValidationStatus,
150    phase_timings: Vec<PhaseTimingReport>,
151    sql_server_contacted: bool,
152    row_production_started: bool,
153    table_lifecycle_started: bool,
154    bulk_writer_started: bool,
155}
156
157impl MssqlDryRunOutputReport {
158    pub(crate) fn new(
159        planned_output: PlannedMssqlOutput,
160        sql_identity: MssqlDryRunSqlIdentityReport,
161        source_usage_status: SourceUsageStatus,
162        used_source_names: Vec<String>,
163        phase_timings: Vec<PhaseTimingReport>,
164    ) -> Self {
165        let output_schema = planned_output
166            .output_plan()
167            .schema_mappings()
168            .iter()
169            .map(MssqlDryRunOutputFieldReport::from_mapping)
170            .collect();
171
172        Self {
173            planned_output,
174            output_schema,
175            sql_identity,
176            source_usage_status,
177            used_source_names,
178            output_row_count: RowCount::unavailable(),
179            output_row_count_reason: Some(ReportReasonCode::NotExecuted),
180            status: OutputStatus::dry_run_planned(),
181            validation_status: ValidationStatus::skipped(ReportReasonCode::DryRun),
182            phase_timings,
183            sql_server_contacted: false,
184            row_production_started: false,
185            table_lifecycle_started: false,
186            bulk_writer_started: false,
187        }
188    }
189
190    /// Returns the planned output request and target plan.
191    #[must_use]
192    pub const fn planned_output(&self) -> &PlannedMssqlOutput {
193        &self.planned_output
194    }
195
196    /// Returns the selected output name.
197    #[must_use]
198    pub fn output_name(&self) -> &str {
199        self.planned_output.output_plan().output_name()
200    }
201
202    /// Returns the selected lazy table id.
203    #[must_use]
204    pub const fn table_id(&self) -> u64 {
205        self.planned_output.table().id()
206    }
207
208    /// Returns the selected lazy table kind.
209    #[must_use]
210    pub const fn table_kind(&self) -> LazyTableKind {
211        self.planned_output.table().kind()
212    }
213
214    /// Returns the selected lazy table name.
215    #[must_use]
216    pub fn table_name(&self) -> &str {
217        self.planned_output.table().name()
218    }
219
220    /// Returns the planned Arrow output schema in output field order.
221    #[must_use]
222    pub fn output_schema(&self) -> &[MssqlDryRunOutputFieldReport] {
223        &self.output_schema
224    }
225
226    /// Returns the planned SQL Server target table.
227    #[must_use]
228    pub fn target_table(&self) -> &MssqlTargetTable {
229        self.planned_output.output_plan().target_table()
230    }
231
232    /// Returns the requested target load mode.
233    #[must_use]
234    pub fn load_mode(&self) -> LoadMode {
235        self.planned_output.output_plan().load_mode()
236    }
237
238    /// Returns the planned Arrow-to-MSSQL schema mapping artifact.
239    #[must_use]
240    pub fn target_schema_plan(&self) -> &MssqlSchemaPlan {
241        self.planned_output.output_plan().schema_plan()
242    }
243
244    /// Returns the planned SQL Server DDL artifact.
245    #[must_use]
246    pub fn target_ddl_plan(&self) -> &MssqlDdlPlan {
247        self.planned_output.output_plan().ddl_plan()
248    }
249
250    /// Returns the planned SQL Server table lifecycle artifact.
251    #[must_use]
252    pub fn target_lifecycle_plan(&self) -> &MssqlLifecyclePlan {
253        self.planned_output.output_plan().lifecycle_plan()
254    }
255
256    /// Returns the redacted SQL identity for the selected lazy table.
257    #[must_use]
258    pub const fn sql_identity(&self) -> &MssqlDryRunSqlIdentityReport {
259        &self.sql_identity
260    }
261
262    /// Returns known source usage status for this selected output.
263    #[must_use]
264    pub const fn source_usage_status(&self) -> SourceUsageStatus {
265        self.source_usage_status
266    }
267
268    /// Returns registered source names known to be used by this selected output.
269    #[must_use]
270    pub fn used_source_names(&self) -> &[String] {
271        &self.used_source_names
272    }
273
274    /// Returns output row-count evidence for this dry-run output.
275    #[must_use]
276    pub const fn output_row_count(&self) -> RowCount {
277        self.output_row_count
278    }
279
280    /// Returns the stable reason code when output row count is unavailable.
281    #[must_use]
282    pub const fn output_row_count_reason(&self) -> Option<ReportReasonCode> {
283        self.output_row_count_reason
284    }
285
286    /// Returns the dry-run output status.
287    #[must_use]
288    pub const fn status(&self) -> OutputStatus {
289        self.status
290    }
291
292    /// Returns the target validation status for this dry-run output.
293    #[must_use]
294    pub const fn validation_status(&self) -> ValidationStatus {
295        self.validation_status
296    }
297
298    /// Returns per-phase dry-run output timing reports.
299    #[must_use]
300    pub fn phase_timings(&self) -> &[PhaseTimingReport] {
301        &self.phase_timings
302    }
303
304    /// Returns the dry-run action mode.
305    #[must_use]
306    pub const fn run_mode(&self) -> RunMode {
307        RunMode::DryRun
308    }
309
310    /// Returns whether dry-run planning contacted SQL Server.
311    #[must_use]
312    pub const fn sql_server_contacted(&self) -> bool {
313        self.sql_server_contacted
314    }
315
316    /// Returns whether dry-run planning started DataFusion row production.
317    #[must_use]
318    pub const fn row_production_started(&self) -> bool {
319        self.row_production_started
320    }
321
322    /// Returns whether dry-run planning started SQL Server table lifecycle work.
323    #[must_use]
324    pub const fn table_lifecycle_started(&self) -> bool {
325        self.table_lifecycle_started
326    }
327
328    /// Returns whether dry-run planning opened a SQL Server bulk writer.
329    #[must_use]
330    pub const fn bulk_writer_started(&self) -> bool {
331        self.bulk_writer_started
332    }
333}
334
335/// Dry-run planning report for a multi-output MSSQL workflow.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct MssqlDryRunWorkflowReport {
338    outputs: Vec<MssqlDryRunOutputReport>,
339    sources: Vec<DeltaSourceReport>,
340    status: WorkflowStatus,
341    phase_timings: Vec<PhaseTimingReport>,
342}
343
344impl MssqlDryRunWorkflowReport {
345    pub(crate) fn new(
346        outputs: Vec<MssqlDryRunOutputReport>,
347        sources: Vec<DeltaSourceReport>,
348    ) -> Self {
349        let status = if outputs.is_empty() {
350            WorkflowStatus::no_op(ReportReasonCode::NotExecuted)
351        } else {
352            WorkflowStatus::success()
353        };
354
355        Self {
356            outputs,
357            sources,
358            status,
359            phase_timings: Vec::new(),
360        }
361    }
362
363    pub(crate) fn with_phase_timings(mut self, phase_timings: Vec<PhaseTimingReport>) -> Self {
364        self.phase_timings = phase_timings;
365        self
366    }
367
368    /// Returns the dry-run action mode.
369    #[must_use]
370    pub const fn run_mode(&self) -> RunMode {
371        RunMode::DryRun
372    }
373
374    /// Returns the dry-run workflow status.
375    #[must_use]
376    pub const fn status(&self) -> WorkflowStatus {
377        self.status
378    }
379
380    /// Returns the number of selected outputs represented by this report.
381    #[must_use]
382    pub fn len(&self) -> usize {
383        self.outputs.len()
384    }
385
386    /// Returns whether this report contains no selected outputs.
387    #[must_use]
388    pub fn is_empty(&self) -> bool {
389        self.outputs.is_empty()
390    }
391
392    /// Returns per-output dry-run reports in caller-provided order.
393    #[must_use]
394    pub fn outputs(&self) -> &[MssqlDryRunOutputReport] {
395        &self.outputs
396    }
397
398    /// Returns source-level reports in session registration order.
399    #[must_use]
400    pub fn sources(&self) -> &[DeltaSourceReport] {
401        &self.sources
402    }
403
404    /// Returns top-level dry-run workflow phase timing reports.
405    #[must_use]
406    pub fn phase_timings(&self) -> &[PhaseTimingReport] {
407        &self.phase_timings
408    }
409
410    /// Returns whether scan metadata was exhausted for every known query-used source.
411    ///
412    /// This returns false when no source is known to be used by the selected
413    /// outputs, or when any used source only has metadata-only or unavailable
414    /// scan evidence.
415    #[must_use]
416    pub fn query_used_source_scan_metadata_exhausted(&self) -> bool {
417        let mut used_source_seen = false;
418        for source in &self.sources {
419            if source.usage_status() == SourceUsageStatus::Used {
420                used_source_seen = true;
421                if !source.scan_metadata_exhausted() {
422                    return false;
423                }
424            }
425        }
426
427        used_source_seen
428    }
429
430    /// Returns whether dry-run planning contacted SQL Server for any output.
431    #[must_use]
432    pub fn sql_server_contacted(&self) -> bool {
433        self.outputs
434            .iter()
435            .any(MssqlDryRunOutputReport::sql_server_contacted)
436    }
437
438    /// Returns whether dry-run planning started row production for any output.
439    #[must_use]
440    pub fn row_production_started(&self) -> bool {
441        self.outputs
442            .iter()
443            .any(MssqlDryRunOutputReport::row_production_started)
444    }
445
446    /// Returns whether dry-run planning started table lifecycle work for any output.
447    #[must_use]
448    pub fn table_lifecycle_started(&self) -> bool {
449        self.outputs
450            .iter()
451            .any(MssqlDryRunOutputReport::table_lifecycle_started)
452    }
453
454    /// Returns whether dry-run planning opened a bulk writer for any output.
455    #[must_use]
456    pub fn bulk_writer_started(&self) -> bool {
457        self.outputs
458            .iter()
459            .any(MssqlDryRunOutputReport::bulk_writer_started)
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::MssqlDryRunSqlIdentityState;
466
467    #[test]
468    fn sql_identity_status_exposes_stable_codes() {
469        assert_eq!(MssqlDryRunSqlIdentityState::Present.as_str(), "present");
470        assert_eq!(MssqlDryRunSqlIdentityState::Absent.to_string(), "absent");
471        assert_eq!(
472            MssqlDryRunSqlIdentityState::Unavailable.as_str(),
473            "unavailable"
474        );
475    }
476}