Skip to main content

delta_funnel/report/
json.rs

1use serde_json::{Value, json};
2
3use crate::{
4    DeltaProviderReadStatsSnapshot, DeltaProviderReaderBackend, DeltaSourceReport, FileCount,
5    LazyTableKind, LoadMode, MssqlDryRunOutputFieldReport, MssqlDryRunOutputReport,
6    MssqlDryRunSqlIdentityReport, MssqlDryRunWorkflowReport, MssqlOutputBatchValidationReport,
7    MssqlOutputFieldReport, MssqlOutputWriteStatus, MssqlTargetCleanupStatus, MssqlTargetTable,
8    MssqlWorkflowWriteReport, MssqlWriteFailureContext, MssqlWriteFailureReport, MssqlWritePhase,
9    MssqlWriteReport, MssqlWriteSkippedReason, MssqlWriteSkippedReport, MssqlWriteStats,
10    OutputStatus, PhaseStatus, PhaseTimingReport, ReportReasonCode, RowCount, RunMode,
11    ValidationStatus, WorkflowStatus, WriteAllCacheAliasReport, WriteAllCacheAliasStatus,
12    WriteAllCacheCandidateSkip, WriteAllCacheCandidateSkipReason, WriteAllCacheReport,
13    WriteAllNoCacheReason, WriteAllReport,
14};
15
16impl RowCount {
17    /// Returns a JSON-compatible shape that preserves count kind and value.
18    #[must_use]
19    pub fn to_json_value(self) -> Value {
20        count_value(self.kind().as_str(), self.value())
21    }
22}
23
24impl FileCount {
25    /// Returns a JSON-compatible shape that preserves count kind and value.
26    #[must_use]
27    pub fn to_json_value(self) -> Value {
28        count_value(self.kind().as_str(), self.value())
29    }
30}
31
32impl ValidationStatus {
33    /// Returns a JSON-compatible shape that preserves status kind and reason.
34    #[must_use]
35    pub fn to_json_value(self) -> Value {
36        status_value(self.kind().as_str(), self.reason())
37    }
38}
39
40impl PhaseStatus {
41    /// Returns a JSON-compatible shape that preserves phase status kind and reason.
42    #[must_use]
43    pub fn to_json_value(self) -> Value {
44        status_value(self.kind().as_str(), self.reason())
45    }
46}
47
48impl OutputStatus {
49    /// Returns a JSON-compatible shape that preserves output status semantics.
50    #[must_use]
51    pub fn to_json_value(self) -> Value {
52        json!({
53            "kind": self.kind().as_str(),
54            "reason": reason_value(self.reason()),
55            "validation": self.validation().map(ValidationStatus::to_json_value),
56        })
57    }
58}
59
60impl WorkflowStatus {
61    /// Returns a JSON-compatible shape that preserves workflow status semantics.
62    #[must_use]
63    pub fn to_json_value(self) -> Value {
64        status_value(self.kind().as_str(), self.reason())
65    }
66}
67
68impl PhaseTimingReport {
69    /// Returns a JSON-compatible shape with structured status and elapsed time.
70    #[must_use]
71    pub fn to_json_value(&self) -> Value {
72        json!({
73            "phase_name": self.phase_name(),
74            "status": self.status().to_json_value(),
75            "elapsed_micros": self.elapsed_micros(),
76        })
77    }
78}
79
80impl DeltaSourceReport {
81    /// Returns the source report as a JSON-compatible Python shape.
82    #[must_use]
83    pub fn to_json_value(&self) -> Value {
84        let protocol = self.protocol();
85        let scheduling = self.scheduling();
86
87        json!({
88            "source_name": self.source_name(),
89            "source_uri": self.source_uri(),
90            "snapshot_version": self.snapshot_version(),
91            "protocol": {
92                "source_name": protocol.source_name,
93                "table_uri": protocol.table_uri,
94                "snapshot_version": protocol.snapshot_version,
95                "min_reader_version": protocol.min_reader_version,
96                "min_writer_version": protocol.min_writer_version,
97                "reader_features": protocol.reader_features,
98                "writer_features": protocol.writer_features,
99            },
100            "scheduling": {
101                "query_target_partitions": scheduling.query_target_partitions(),
102                "reader_backend": reader_backend(scheduling.reader_backend()),
103                "max_concurrent_file_reads_per_scan": scheduling.max_concurrent_file_reads_per_scan(),
104                "max_concurrent_file_reads_per_partition": scheduling.max_concurrent_file_reads_per_partition(),
105                "output_buffer_capacity_per_partition": scheduling.output_buffer_capacity_per_partition(),
106                "native_async_prefetch_file_count_per_partition": scheduling.native_async_prefetch_file_count_per_partition(),
107            },
108            "file_count": count_with_reason_value(
109                self.file_count().kind().as_str(),
110                self.file_count().value(),
111                self.file_count_reason()
112            ),
113            "scan_metadata_exhausted": self.scan_metadata_exhausted(),
114            "usage_status": self.usage_status().as_str(),
115            "used_by_output_names": self.used_by_output_names(),
116            "provider_read_stats_available": self.provider_read_stats().is_some(),
117            "provider_read_stats": self.provider_read_stats().map(provider_read_stats_value),
118            "provider_stats_reason": reason_value(self.provider_stats_reason()),
119            "phase_timings": phase_timings_value(self.phase_timings()),
120        })
121    }
122}
123
124impl MssqlDryRunOutputFieldReport {
125    /// Returns the dry-run output field report as a JSON-compatible Python shape.
126    #[must_use]
127    pub fn to_json_value(&self) -> Value {
128        json!({
129            "index": self.index(),
130            "name": self.name(),
131            "arrow_type": self.arrow_type(),
132            "nullable": self.nullable(),
133        })
134    }
135}
136
137impl MssqlDryRunSqlIdentityReport {
138    /// Returns the dry-run SQL identity report as a JSON-compatible Python shape.
139    #[must_use]
140    pub fn to_json_value(&self) -> Value {
141        json!({
142            "state": self.state().as_str(),
143            "hash": self.hash(),
144            "reason": reason_value(self.reason()),
145        })
146    }
147}
148
149impl MssqlDryRunOutputReport {
150    /// Returns the dry-run output report as a JSON-compatible Python shape.
151    #[must_use]
152    pub fn to_json_value(&self) -> Value {
153        json!({
154            "output_name": self.output_name(),
155            "run_mode": run_mode(self.run_mode()),
156            "status": self.status().to_json_value(),
157            "table": {
158                "id": self.table_id(),
159                "kind": lazy_table_kind(self.table_kind()),
160                "name": self.table_name(),
161            },
162            "target_table": target_table_value(self.target_table()),
163            "load_mode": load_mode(self.load_mode()),
164            "output_schema": self.output_schema()
165                .iter()
166                .map(MssqlDryRunOutputFieldReport::to_json_value)
167                .collect::<Vec<_>>(),
168            "target_schema_plan": {
169                "output_field_count": self.target_schema_plan().mappings().len(),
170                "diagnostic_count": self.target_schema_plan().diagnostic_reports().len(),
171            },
172            "target_ddl_plan": {
173                "create_table_sql_present": self.target_ddl_plan().create_table_sql_present(),
174            },
175            "target_lifecycle_plan": {
176                "create_table_sql_required": self.target_lifecycle_plan().create_table_sql_required(),
177                "create_table_sql_present": self.target_lifecycle_plan().create_table_sql_present(),
178                "executable_in_mvp": self.target_lifecycle_plan().executable_in_mvp(),
179            },
180            "sql_identity": self.sql_identity().to_json_value(),
181            "source_usage_status": self.source_usage_status().as_str(),
182            "used_source_names": self.used_source_names(),
183            "output_row_count": count_with_reason_value(
184                self.output_row_count().kind().as_str(),
185                self.output_row_count().value(),
186                self.output_row_count_reason()
187            ),
188            "validation_status": self.validation_status().to_json_value(),
189            "phase_timings": phase_timings_value(self.phase_timings()),
190            "dry_run": {
191                "sql_server_contacted": self.sql_server_contacted(),
192                "row_production_started": self.row_production_started(),
193                "table_lifecycle_started": self.table_lifecycle_started(),
194                "bulk_writer_started": self.bulk_writer_started(),
195            },
196        })
197    }
198}
199
200impl MssqlDryRunWorkflowReport {
201    /// Returns the dry-run workflow report as a JSON-compatible Python shape.
202    #[must_use]
203    pub fn to_json_value(&self) -> Value {
204        json!({
205            "run_mode": run_mode(self.run_mode()),
206            "status": self.status().to_json_value(),
207            "output_count": self.len(),
208            "query_used_source_scan_metadata_exhausted": self.query_used_source_scan_metadata_exhausted(),
209            "sources": self.sources()
210                .iter()
211                .map(DeltaSourceReport::to_json_value)
212                .collect::<Vec<_>>(),
213            "outputs": self.outputs()
214                .iter()
215                .map(MssqlDryRunOutputReport::to_json_value)
216                .collect::<Vec<_>>(),
217            "phase_timings": phase_timings_value(self.phase_timings()),
218            "dry_run": {
219                "sql_server_contacted": self.sql_server_contacted(),
220                "row_production_started": self.row_production_started(),
221                "table_lifecycle_started": self.table_lifecycle_started(),
222                "bulk_writer_started": self.bulk_writer_started(),
223            },
224        })
225    }
226}
227
228impl MssqlOutputFieldReport {
229    /// Returns the execute output field report as a JSON-compatible Python shape.
230    #[must_use]
231    pub fn to_json_value(&self) -> Value {
232        json!({
233            "index": self.index(),
234            "name": self.name(),
235            "arrow_type": self.arrow_type(),
236            "nullable": self.nullable(),
237        })
238    }
239}
240
241impl MssqlWriteStats {
242    /// Returns SQL Server write stats as a JSON-compatible Python shape.
243    #[must_use]
244    pub fn to_json_value(&self) -> Value {
245        json!({
246            "output_name": self.output_name(),
247            "rows_written": self.rows_written(),
248            "batches_written": self.batches_written(),
249            "elapsed_ms": self.elapsed_ms(),
250        })
251    }
252}
253
254impl MssqlWriteReport {
255    /// Returns the execute output report as a JSON-compatible Python shape.
256    #[must_use]
257    pub fn to_json_value(&self) -> Value {
258        json!({
259            "output_name": self.output_name(),
260            "run_mode": run_mode(RunMode::Execute),
261            "target_table": target_table_value(self.target_table()),
262            "load_mode": load_mode(self.load_mode()),
263            "connection_source": connection_source(self.connection_source()),
264            "connection": {
265                "display_label": self.connection().display_label(),
266            },
267            "output_schema": self.output_schema()
268                .iter()
269                .map(MssqlOutputFieldReport::to_json_value)
270                .collect::<Vec<_>>(),
271            "output_row_count": self.output_row_count().to_json_value(),
272            "target_row_count_before_write": self.target_row_count_before_write().to_json_value(),
273            "target_row_count_after_write": self.target_row_count_after_write().to_json_value(),
274            "target_row_count": self.target_row_count().to_json_value(),
275            "validation_status": self.validation_status().to_json_value(),
276            "batch_shaping": batch_shaping_value(self.batch_shaping()),
277            "phase_timings": phase_timings_value(self.phase_timings()),
278            "write_stats": self.stats().to_json_value(),
279            "partial_write_possible": self.partial_write_possible(),
280            "cleanup": cleanup_status(self.cleanup()),
281        })
282    }
283}
284
285impl MssqlOutputBatchValidationReport {
286    /// Returns the output batch validation report as a JSON-compatible Python shape.
287    #[must_use]
288    pub fn to_json_value(&self) -> Value {
289        json!({
290            "output_name": self.output_name(),
291            "target_table": target_table_value(self.target_table()),
292            "load_mode": load_mode(self.load_mode()),
293            "connection_source": connection_source(self.connection_source()),
294            "connection": {
295                "display_label": self.connection().display_label(),
296            },
297        })
298    }
299}
300
301impl MssqlOutputWriteStatus {
302    /// Returns one workflow output status as a JSON-compatible Python shape.
303    #[must_use]
304    pub fn to_json_value(&self) -> Value {
305        match self {
306            Self::Succeeded(report) => json!({
307                "kind": "succeeded",
308                "output_name": self.output_name(),
309                "target_table": target_table_value(self.target_table()),
310                "load_mode": load_mode(self.load_mode()),
311                "connection_source": connection_source(self.connection_source()),
312                "output_row_count": self.output_row_count().to_json_value(),
313                "target_row_count": self.target_row_count().to_json_value(),
314                "validation_status": self.validation_status().to_json_value(),
315                "batch_shaping": batch_shaping_value(self.batch_shaping()),
316                "phase_timings": phase_timings_value(self.phase_timings()),
317                "report": report.to_json_value(),
318            }),
319            Self::Failed(report) => json!({
320                "kind": "failed",
321                "output_name": self.output_name(),
322                "target_table": target_table_value(self.target_table()),
323                "load_mode": load_mode(self.load_mode()),
324                "connection_source": connection_source(self.connection_source()),
325                "output_row_count": self.output_row_count().to_json_value(),
326                "target_row_count": self.target_row_count().to_json_value(),
327                "validation_status": self.validation_status().to_json_value(),
328                "batch_shaping": batch_shaping_value(self.batch_shaping()),
329                "phase_timings": phase_timings_value(self.phase_timings()),
330                "failure": report.to_json_value(),
331            }),
332            Self::Skipped(report) => json!({
333                "kind": "skipped",
334                "output_name": self.output_name(),
335                "target_table": target_table_value(self.target_table()),
336                "load_mode": load_mode(self.load_mode()),
337                "connection_source": connection_source(self.connection_source()),
338                "output_row_count": self.output_row_count().to_json_value(),
339                "target_row_count": self.target_row_count().to_json_value(),
340                "validation_status": self.validation_status().to_json_value(),
341                "batch_shaping": batch_shaping_value(self.batch_shaping()),
342                "phase_timings": phase_timings_value(self.phase_timings()),
343                "skipped": report.to_json_value(),
344            }),
345        }
346    }
347}
348
349impl MssqlWorkflowWriteReport {
350    /// Returns the SQL Server workflow report as a JSON-compatible Python shape.
351    #[must_use]
352    pub fn to_json_value(&self) -> Value {
353        json!({
354            "output_count": self.len(),
355            "all_succeeded": self.all_succeeded(),
356            "succeeded_count": self.succeeded_count(),
357            "failed_count": self.failed_count(),
358            "skipped_count": self.skipped_count(),
359            "outputs": self.outputs()
360                .iter()
361                .map(MssqlOutputWriteStatus::to_json_value)
362                .collect::<Vec<_>>(),
363        })
364    }
365}
366
367impl MssqlWriteFailureReport {
368    /// Returns one failed workflow output report as a JSON-compatible Python shape.
369    #[must_use]
370    pub fn to_json_value(&self) -> Value {
371        json!({
372            "output_name": self.output_name(),
373            "target_table": target_table_value(self.target().table()),
374            "load_mode": load_mode(self.target().load_mode()),
375            "connection_source": connection_source(self.target().connection_source()),
376            "error": self.error(),
377            "context": self.context().map(MssqlWriteFailureContext::to_json_value),
378            "output_row_count": self.output_row_count().to_json_value(),
379            "target_row_count": self.target_row_count().to_json_value(),
380            "validation_status": self.validation_status().to_json_value(),
381            "batch_shaping": batch_shaping_value(self.batch_shaping()),
382            "phase_timings": phase_timings_value(self.phase_timings()),
383        })
384    }
385}
386
387impl MssqlWriteFailureContext {
388    /// Returns phase-aware write failure context as a JSON-compatible Python shape.
389    #[must_use]
390    pub fn to_json_value(&self) -> Value {
391        json!({
392            "phase": write_phase(self.phase()),
393            "output_name": self.output_name(),
394            "target_table": target_table_value(self.target_table()),
395            "load_mode": load_mode(self.load_mode()),
396            "connection_source": connection_source(self.connection_source()),
397            "connection": {
398                "display_label": self.connection().display_label(),
399            },
400            "write_stats": self.stats().to_json_value(),
401            "output_row_count": self.output_row_count().to_json_value(),
402            "target_row_count_before_write": self.target_row_count_before_write().to_json_value(),
403            "target_row_count_after_write": self.target_row_count_after_write().to_json_value(),
404            "target_row_count": self.target_row_count().to_json_value(),
405            "validation_status": self.validation_status().to_json_value(),
406            "batch_shaping": batch_shaping_value(self.batch_shaping()),
407            "partial_write_possible": self.partial_write_possible(),
408            "cleanup": cleanup_status(self.cleanup()),
409            "cleanup_error": self.cleanup_error(),
410            "diagnostics": self.diagnostics()
411                .iter()
412                .map(write_diagnostic_value)
413                .collect::<Vec<_>>(),
414            "phase_timings": phase_timings_value(self.phase_timings()),
415            "report": self.report().to_json_value(),
416        })
417    }
418}
419
420impl MssqlWriteSkippedReport {
421    /// Returns one skipped workflow output report as a JSON-compatible Python shape.
422    #[must_use]
423    pub fn to_json_value(&self) -> Value {
424        json!({
425            "output_name": self.output_name(),
426            "target_table": target_table_value(self.target().table()),
427            "load_mode": load_mode(self.target().load_mode()),
428            "connection_source": connection_source(self.target().connection_source()),
429            "reason": skipped_reason_value(self.reason()),
430            "output_row_count": self.output_row_count().to_json_value(),
431            "target_row_count": self.target_row_count().to_json_value(),
432            "validation_status": self.validation_status().to_json_value(),
433            "batch_shaping": batch_shaping_value(self.batch_shaping()),
434            "phase_timings": phase_timings_value(self.phase_timings()),
435        })
436    }
437}
438
439impl WriteAllReport {
440    /// Returns the write-all report as a JSON-compatible Python shape.
441    #[must_use]
442    pub fn to_json_value(&self) -> Value {
443        json!({
444            "workflow": self.workflow().to_json_value(),
445            "cache": self.cache().to_json_value(),
446            "sources": self.sources()
447                .iter()
448                .map(DeltaSourceReport::to_json_value)
449                .collect::<Vec<_>>(),
450            "phase_timings": phase_timings_value(self.phase_timings()),
451            "output_count": self.len(),
452            "all_succeeded": self.all_succeeded(),
453            "succeeded_count": self.succeeded_count(),
454            "failed_count": self.failed_count(),
455            "skipped_count": self.skipped_count(),
456        })
457    }
458}
459
460impl WriteAllCacheReport {
461    /// Returns write-all cache metadata as a JSON-compatible Python shape.
462    #[must_use]
463    pub fn to_json_value(&self) -> Value {
464        match self {
465            Self::Disabled => json!({
466                "kind": "disabled",
467                "reason": null,
468                "aliases": [],
469                "skipped_candidates": [],
470            }),
471            Self::NoCache {
472                reason,
473                skipped_candidates,
474            } => json!({
475                "kind": "no_cache",
476                "reason": no_cache_reason(*reason),
477                "aliases": [],
478                "skipped_candidates": skipped_candidates
479                    .iter()
480                    .map(WriteAllCacheCandidateSkip::to_json_value)
481                    .collect::<Vec<_>>(),
482            }),
483            Self::CacheAliases {
484                aliases,
485                skipped_candidates,
486            } => json!({
487                "kind": "cache_aliases",
488                "reason": null,
489                "aliases": aliases
490                    .iter()
491                    .map(WriteAllCacheAliasReport::to_json_value)
492                    .collect::<Vec<_>>(),
493                "skipped_candidates": skipped_candidates
494                    .iter()
495                    .map(WriteAllCacheCandidateSkip::to_json_value)
496                    .collect::<Vec<_>>(),
497            }),
498        }
499    }
500}
501
502impl WriteAllCacheAliasReport {
503    /// Returns one selected write-all cache alias as a JSON-compatible Python shape.
504    #[must_use]
505    pub fn to_json_value(&self) -> Value {
506        json!({
507            "table_id": self.table_id(),
508            "alias": self.alias(),
509            "output_indexes": self.output_indexes(),
510            "status": cache_alias_status(self.status()),
511        })
512    }
513}
514
515impl WriteAllCacheCandidateSkip {
516    /// Returns one skipped write-all cache candidate as a JSON-compatible Python shape.
517    #[must_use]
518    pub fn to_json_value(&self) -> Value {
519        json!({
520            "table_id": self.table_id(),
521            "alias": self.alias(),
522            "reason": cache_candidate_skip_reason(self.reason()),
523        })
524    }
525}
526
527fn count_value(kind: &str, value: Option<u64>) -> Value {
528    json!({
529        "kind": kind,
530        "value": value,
531    })
532}
533
534fn count_with_reason_value(
535    kind: &str,
536    value: Option<u64>,
537    reason: Option<ReportReasonCode>,
538) -> Value {
539    json!({
540        "kind": kind,
541        "value": value,
542        "reason": reason_value(reason),
543    })
544}
545
546fn phase_timings_value(timings: &[PhaseTimingReport]) -> Vec<Value> {
547    timings
548        .iter()
549        .map(PhaseTimingReport::to_json_value)
550        .collect()
551}
552
553fn status_value(kind: &str, reason: Option<ReportReasonCode>) -> Value {
554    json!({
555        "kind": kind,
556        "reason": reason_value(reason),
557    })
558}
559
560fn reason_value(reason: Option<ReportReasonCode>) -> Option<&'static str> {
561    reason.map(ReportReasonCode::as_str)
562}
563
564fn run_mode(mode: RunMode) -> &'static str {
565    match mode {
566        RunMode::Execute => "execute",
567        RunMode::DryRun => "dry_run",
568    }
569}
570
571fn lazy_table_kind(kind: LazyTableKind) -> &'static str {
572    match kind {
573        LazyTableKind::DeltaSource => "delta_source",
574        LazyTableKind::DerivedSql => "derived_sql",
575    }
576}
577
578fn load_mode(mode: LoadMode) -> &'static str {
579    match mode {
580        LoadMode::AppendExisting => "append_existing",
581        LoadMode::CreateAndLoad => "create_and_load",
582        LoadMode::Replace => "replace",
583    }
584}
585
586fn target_table_value(table: &MssqlTargetTable) -> Value {
587    json!({
588        "schema": table.schema(),
589        "table": table.table(),
590    })
591}
592
593fn connection_source(source: crate::MssqlConnectionSource) -> &'static str {
594    match source {
595        crate::MssqlConnectionSource::TargetOverride => "target_override",
596        crate::MssqlConnectionSource::ContextDefault => "context_default",
597    }
598}
599
600fn cleanup_status(status: MssqlTargetCleanupStatus) -> &'static str {
601    match status {
602        MssqlTargetCleanupStatus::NotApplicable => "not_applicable",
603        MssqlTargetCleanupStatus::NotAttempted => "not_attempted",
604        MssqlTargetCleanupStatus::Succeeded => "succeeded",
605        MssqlTargetCleanupStatus::Failed => "failed",
606    }
607}
608
609fn write_phase(phase: MssqlWritePhase) -> &'static str {
610    match phase {
611        MssqlWritePhase::Connect => "connect",
612        MssqlWritePhase::PrepareTargetLifecycle => "prepare_target_lifecycle",
613        MssqlWritePhase::InitializeWriter => "initialize_writer",
614        MssqlWritePhase::PollBatchStream => "poll_batch_stream",
615        MssqlWritePhase::ValidateBatchSchema => "validate_batch_schema",
616        MssqlWritePhase::WriteBatch => "write_batch",
617        MssqlWritePhase::Finalize => "finalize",
618        MssqlWritePhase::Validation => "validation",
619        MssqlWritePhase::SwapTarget => "swap_target",
620        MssqlWritePhase::Cleanup => "cleanup",
621    }
622}
623
624fn write_diagnostic_value(
625    diagnostic: &crate::report::sql_server::write::MssqlWriteDiagnostic,
626) -> Value {
627    json!({
628        "severity": diagnostic_severity(diagnostic.severity()),
629        "code": diagnostic_code(diagnostic.code()),
630        "message": diagnostic.message(),
631        "field": diagnostic.field().map(|field| json!({
632            "index": field.index(),
633            "name": field.name(),
634        })),
635        "row": diagnostic.row(),
636    })
637}
638
639fn diagnostic_severity(severity: arrow_tiberius::DiagnosticSeverity) -> &'static str {
640    match severity {
641        arrow_tiberius::DiagnosticSeverity::Warning => "warning",
642        arrow_tiberius::DiagnosticSeverity::Error => "error",
643    }
644}
645
646fn diagnostic_code(code: arrow_tiberius::DiagnosticCode) -> &'static str {
647    match code {
648        arrow_tiberius::DiagnosticCode::UnsupportedArrowType => "unsupported_arrow_type",
649        arrow_tiberius::DiagnosticCode::LossyConversionRequiresPolicy => {
650            "lossy_conversion_requires_policy"
651        }
652        arrow_tiberius::DiagnosticCode::PolicyApplied => "policy_applied",
653        arrow_tiberius::DiagnosticCode::IdentifierInvalid => "identifier_invalid",
654        arrow_tiberius::DiagnosticCode::IdentifierTooLong => "identifier_too_long",
655        arrow_tiberius::DiagnosticCode::DecimalOutOfRange => "decimal_out_of_range",
656        arrow_tiberius::DiagnosticCode::IntegerOutOfRange => "integer_out_of_range",
657        arrow_tiberius::DiagnosticCode::TimestampOutOfRange => "timestamp_out_of_range",
658        arrow_tiberius::DiagnosticCode::TimezoneUnsupported => "timezone_unsupported",
659        arrow_tiberius::DiagnosticCode::SchemaMismatch => "schema_mismatch",
660        arrow_tiberius::DiagnosticCode::BackendUnavailable => "backend_unavailable",
661        arrow_tiberius::DiagnosticCode::ProfileDependentConversion => {
662            "profile_dependent_conversion"
663        }
664        arrow_tiberius::DiagnosticCode::ObservedDataRequired => "observed_data_required",
665        arrow_tiberius::DiagnosticCode::ValueConversionUnsupported => {
666            "value_conversion_unsupported"
667        }
668        arrow_tiberius::DiagnosticCode::ValueTypeMismatch => "value_type_mismatch",
669        arrow_tiberius::DiagnosticCode::NullInNonNullableColumn => "null_in_non_nullable_column",
670        arrow_tiberius::DiagnosticCode::NonFiniteFloat => "non_finite_float",
671        arrow_tiberius::DiagnosticCode::ValueTooLong => "value_too_long",
672        arrow_tiberius::DiagnosticCode::RowIndexOutOfBounds => "row_index_out_of_bounds",
673        arrow_tiberius::DiagnosticCode::DirectEncodingInvalidPayload => {
674            "direct_encoding_invalid_payload"
675        }
676        arrow_tiberius::DiagnosticCode::DirectEncodingUnsupportedMapping => {
677            "direct_encoding_unsupported_mapping"
678        }
679        arrow_tiberius::DiagnosticCode::DirectEncodingUnsupportedBatch => {
680            "direct_encoding_unsupported_batch"
681        }
682        _ => "unknown",
683    }
684}
685
686fn batch_shaping_value(report: crate::MssqlBatchShapingReport) -> Value {
687    json!({
688        "status": report.status().to_json_value(),
689        "input_batches": report.input_batches(),
690        "input_rows": report.input_rows(),
691        "output_batches": report.output_batches(),
692        "output_rows": report.output_rows(),
693    })
694}
695
696fn provider_read_stats_value(stats: &DeltaProviderReadStatsSnapshot) -> Value {
697    json!({
698        "source_name": stats.source_name,
699        "snapshot_version": stats.snapshot_version,
700        "reader_backend": reader_backend(stats.reader_backend),
701        "scan_metadata_exhausted": stats.scan_metadata_exhausted,
702        "scan_partitions_planned": stats.scan_partitions_planned,
703        "files_planned": stats.files_planned,
704        "estimated_rows": stats.estimated_rows,
705        "estimated_bytes": stats.estimated_bytes,
706        "datafusion_output_batch_size": stats.datafusion_output_batch_size,
707        "scan_partitions_started": stats.scan_partitions_started,
708        "scan_partitions_completed": stats.scan_partitions_completed,
709        "files_started": stats.files_started,
710        "files_completed": stats.files_completed,
711        "dynamic_partition_files_pruned": stats.dynamic_partition_files_pruned,
712        "dynamic_partition_files_kept": stats.dynamic_partition_files_kept,
713        "dynamic_filters_received": stats.dynamic_filters_received,
714        "dynamic_filters_accepted": stats.dynamic_filters_accepted,
715        "dynamic_filters_unsupported": stats.dynamic_filters_unsupported,
716        "dynamic_filter_snapshots": stats.dynamic_filter_snapshots,
717        "dynamic_partition_files_not_pruned_missing_metadata": stats.dynamic_partition_files_not_pruned_missing_metadata,
718        "dynamic_partition_files_not_pruned_unsupported_expression": stats.dynamic_partition_files_not_pruned_unsupported_expression,
719        "batches_produced": stats.batches_produced,
720        "rows_produced": stats.rows_produced,
721        "deletion_vector_payloads_loaded": stats.deletion_vector_payloads_loaded,
722        "deletion_vectors_applied": stats.deletion_vectors_applied,
723        "deletion_vector_rows_deleted": stats.deletion_vector_rows_deleted,
724        "deletion_vector_failures": stats.deletion_vector_failures,
725        "deletion_vector_rejections": stats.deletion_vector_rejections,
726    })
727}
728
729fn skipped_reason_value(reason: &MssqlWriteSkippedReason) -> Value {
730    match reason {
731        MssqlWriteSkippedReason::PreviousOutputFailed { failed_output_name } => json!({
732            "kind": "previous_output_failed",
733            "failed_output_name": failed_output_name,
734        }),
735    }
736}
737
738fn reader_backend(backend: DeltaProviderReaderBackend) -> &'static str {
739    match backend {
740        DeltaProviderReaderBackend::OfficialKernel => "official_kernel",
741        DeltaProviderReaderBackend::NativeAsync => "native_async",
742    }
743}
744
745fn no_cache_reason(reason: WriteAllNoCacheReason) -> &'static str {
746    match reason {
747        WriteAllNoCacheReason::FewerThanTwoOutputs => "fewer_than_two_outputs",
748        WriteAllNoCacheReason::NoSharedRegisteredDerivedAlias => {
749            "no_shared_registered_derived_alias"
750        }
751        WriteAllNoCacheReason::AmbiguousSharedDerivedAlias => "ambiguous_shared_derived_alias",
752    }
753}
754
755fn cache_alias_status(status: WriteAllCacheAliasStatus) -> &'static str {
756    match status {
757        WriteAllCacheAliasStatus::Selected => "selected",
758        WriteAllCacheAliasStatus::MaterializedAndRestored => "materialized_and_restored",
759    }
760}
761
762fn cache_candidate_skip_reason(reason: &WriteAllCacheCandidateSkipReason) -> Value {
763    match reason {
764        WriteAllCacheCandidateSkipReason::NotShared { output_count } => json!({
765            "kind": "not_shared",
766            "output_count": output_count,
767        }),
768        WriteAllCacheCandidateSkipReason::MissingSqlText => json!({
769            "kind": "missing_sql_text",
770        }),
771        WriteAllCacheCandidateSkipReason::IncompleteLineage => json!({
772            "kind": "incomplete_lineage",
773        }),
774        WriteAllCacheCandidateSkipReason::CoveredByDeeperSharedAlias { selected_table_id } => {
775            json!({
776                "kind": "covered_by_deeper_shared_alias",
777                "selected_table_id": selected_table_id,
778            })
779        }
780        WriteAllCacheCandidateSkipReason::AmbiguousDepth => json!({
781            "kind": "ambiguous_depth",
782        }),
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use std::{
789        collections::VecDeque,
790        error::Error,
791        fs,
792        path::PathBuf,
793        sync::Arc,
794        time::{Duration, SystemTime, UNIX_EPOCH},
795    };
796
797    use async_trait::async_trait;
798    use futures_util::stream;
799    use serde_json::{Value, json};
800
801    use super::*;
802    use crate::MssqlWorkflowOutputWriter;
803    use crate::{
804        DeltaFunnelSession, DeltaProtocolReport, DeltaProviderScanExecutionOptions,
805        DeltaProviderSchedulingReport, DeltaSourceConfig, MssqlConnectionConfig,
806        MssqlOutputBatchStream, MssqlOutputTarget, MssqlOutputWriteJob, MssqlSchemaPlanOptions,
807        MssqlTargetConfig, MssqlTargetOutputPlan, MssqlTargetResolutionContext,
808        MssqlWorkflowWriteOptions, MssqlWriteOptions, OutputWritePlan, QueryOptions,
809        ResolvedMssqlTarget, SessionOptions, ValidationOptions, plan_mssql_target_for_output,
810        write_mssql_outputs_with_writer,
811    };
812    use arrow_schema::{DataType, Field, Schema, SchemaRef};
813    use arrow_tiberius::PlanOptions;
814
815    type TestResult<T> = Result<T, Box<dyn Error + Send + Sync + 'static>>;
816
817    struct FakeWorkflowWriter {
818        outcomes: VecDeque<Result<MssqlWriteReport, crate::DeltaFunnelError>>,
819    }
820
821    impl FakeWorkflowWriter {
822        fn new(outcomes: Vec<Result<MssqlWriteReport, crate::DeltaFunnelError>>) -> Self {
823            Self {
824                outcomes: outcomes.into(),
825            }
826        }
827    }
828
829    #[async_trait]
830    impl MssqlWorkflowOutputWriter for FakeWorkflowWriter {
831        async fn write_output(
832            &mut self,
833            _output_schema: SchemaRef,
834            _resolved_target: ResolvedMssqlTarget,
835            _schema_options: MssqlSchemaPlanOptions,
836            _batches: MssqlOutputBatchStream,
837            _write_options: MssqlWriteOptions,
838            _validation_options: ValidationOptions,
839        ) -> Result<MssqlWriteReport, crate::DeltaFunnelError> {
840            self.outcomes.pop_front().ok_or_else(|| {
841                crate::DeltaFunnelError::MssqlWorkflowPlanning {
842                    message: "missing fake writer outcome".to_owned(),
843                }
844            })?
845        }
846    }
847
848    struct DeltaLogFixture {
849        path: PathBuf,
850    }
851
852    impl DeltaLogFixture {
853        fn new(name: &str) -> TestResult<Self> {
854            let path = env_unique_path(name)?;
855            let log_dir = path.join("_delta_log");
856            fs::create_dir_all(&log_dir)?;
857            fs::write(
858                log_dir.join("00000000000000000000.json"),
859                format!(
860                    "{}\n{}\n",
861                    r#"{"protocol":{"minReaderVersion":1,"minWriterVersion":2}}"#,
862                    metadata_json()
863                ),
864            )?;
865
866            Ok(Self { path })
867        }
868
869        fn uri(&self) -> String {
870            self.path.to_string_lossy().to_string()
871        }
872    }
873
874    impl Drop for DeltaLogFixture {
875        fn drop(&mut self) {
876            let _ = fs::remove_dir_all(&self.path);
877        }
878    }
879
880    #[test]
881    fn row_count_json_preserves_kind_and_value() {
882        assert_eq!(
883            RowCount::exact(3).to_json_value(),
884            json!({"kind": "exact", "value": 3})
885        );
886        assert_eq!(
887            RowCount::estimated(5).to_json_value(),
888            json!({"kind": "estimated", "value": 5})
889        );
890        assert_eq!(
891            RowCount::partial(2).to_json_value(),
892            json!({"kind": "partial", "value": 2})
893        );
894        assert_eq!(
895            RowCount::unavailable().to_json_value(),
896            json!({"kind": "unavailable", "value": null})
897        );
898    }
899
900    #[test]
901    fn file_count_json_preserves_non_numeric_kinds() {
902        assert_eq!(
903            FileCount::skipped().to_json_value(),
904            json!({"kind": "skipped", "value": null})
905        );
906        assert_eq!(
907            FileCount::not_executed().to_json_value(),
908            json!({"kind": "not_executed", "value": null})
909        );
910    }
911
912    #[test]
913    fn status_json_preserves_stable_kind_and_reason_strings() {
914        assert_eq!(
915            ValidationStatus::skipped(ReportReasonCode::DryRun).to_json_value(),
916            json!({"kind": "skipped", "reason": "dry_run"})
917        );
918        assert_eq!(
919            PhaseStatus::not_started(ReportReasonCode::NotExecuted).to_json_value(),
920            json!({"kind": "not_started", "reason": "not_executed"})
921        );
922        assert_eq!(
923            WorkflowStatus::no_op(ReportReasonCode::NotExecuted).to_json_value(),
924            json!({"kind": "no_op", "reason": "not_executed"})
925        );
926    }
927
928    #[test]
929    fn output_status_json_preserves_nested_validation_status() {
930        assert_eq!(
931            OutputStatus::validation_failed(ValidationStatus::required_but_failed(
932                ReportReasonCode::MissingExactOutputRows
933            ))
934            .to_json_value(),
935            json!({
936                "kind": "validation_failed",
937                "reason": null,
938                "validation": {
939                    "kind": "required_but_failed",
940                    "reason": "missing_exact_output_rows"
941                }
942            })
943        );
944    }
945
946    #[test]
947    fn phase_timing_json_is_json_round_trippable() -> Result<(), serde_json::Error> {
948        let value =
949            PhaseTimingReport::completed("load_sources", Duration::from_micros(42)).to_json_value();
950
951        assert_eq!(
952            value,
953            json!({
954                "phase_name": "load_sources",
955                "status": {"kind": "completed", "reason": null},
956                "elapsed_micros": 42
957            })
958        );
959        serde_json::from_str::<Value>(&serde_json::to_string(&value)?).map(|_| ())
960    }
961
962    #[tokio::test]
963    async fn dry_run_workflow_json_exposes_sources_outputs_and_safe_diagnostics() -> TestResult<()>
964    {
965        let orders = DeltaLogFixture::new("orders-json-report")?;
966        let mut session = session_with_default_connection()?;
967        session.delta_lake(DeltaSourceConfig::new("orders", orders.uri()))?;
968        let selected_orders = session
969            .table_from_sql("select id, region from orders")
970            .await?;
971        let output = OutputWritePlan::new(
972            selected_orders,
973            MssqlOutputTarget::new(
974                "orders_output",
975                MssqlTargetConfig::new(MssqlTargetTable::unqualified("orders_sink")?),
976                RunMode::DryRun,
977            ),
978        );
979
980        let value = session.dry_run_all_to_mssql(&[output])?.to_json_value();
981
982        assert_eq!(value["run_mode"], "dry_run");
983        assert_eq!(value["status"], json!({"kind": "success", "reason": null}));
984        assert_eq!(value["output_count"], 1);
985        assert_eq!(value["sources"][0]["source_name"], "orders");
986        assert_eq!(
987            value["sources"][0]["file_count"],
988            json!({"kind": "unavailable", "value": null, "reason": "cost_avoidance"})
989        );
990        assert_eq!(value["sources"][0]["provider_read_stats_available"], false);
991        assert_eq!(value["sources"][0]["provider_stats_reason"], "not_executed");
992        assert_eq!(value["outputs"][0]["output_name"], "orders_output");
993        assert_eq!(value["outputs"][0]["status"]["kind"], "dry_run_planned");
994        assert_eq!(value["outputs"][0]["target_table"]["table"], "orders_sink");
995        assert_eq!(value["outputs"][0]["output_schema"][0]["name"], "id");
996        assert_eq!(
997            value["outputs"][0]["output_row_count"],
998            json!({"kind": "unavailable", "value": null, "reason": "not_executed"})
999        );
1000        assert_eq!(
1001            value["outputs"][0]["validation_status"],
1002            json!({"kind": "skipped", "reason": "dry_run"})
1003        );
1004        assert_eq!(
1005            value["outputs"][0]["dry_run"]["sql_server_contacted"],
1006            false
1007        );
1008        assert_json_safe(&value)?;
1009        assert_no_secret_or_raw_sql_text(&value);
1010
1011        Ok(())
1012    }
1013
1014    #[test]
1015    fn execute_write_report_json_exposes_stats_counts_and_validation() -> TestResult<()> {
1016        let output_plan = output_plan()?;
1017        let report = MssqlWriteReport::from_output_plan(
1018            &output_plan,
1019            42,
1020            3,
1021            125,
1022            false,
1023            MssqlTargetCleanupStatus::NotApplicable,
1024        )
1025        .with_target_delta_validation(
1026            RowCount::exact(10),
1027            RowCount::exact(52),
1028            ValidationStatus::passed(),
1029            PhaseTimingReport::completed("mssql_target_validation", Duration::from_micros(7)),
1030        );
1031
1032        let value = report.to_json_value();
1033
1034        assert_eq!(value["run_mode"], "execute");
1035        assert!(value.get("status").is_none());
1036        assert_eq!(value["output_name"], "orders_output");
1037        assert_eq!(value["target_table"]["schema"], "dbo");
1038        assert_eq!(value["target_table"]["table"], "orders");
1039        assert_eq!(value["connection_source"], "context_default");
1040        assert_eq!(value["connection"]["display_label"], "warehouse");
1041        assert_eq!(value["output_schema"][0]["name"], "id");
1042        assert_eq!(
1043            value["output_row_count"],
1044            json!({"kind": "exact", "value": 42})
1045        );
1046        assert_eq!(
1047            value["target_row_count_before_write"],
1048            json!({"kind": "exact", "value": 10})
1049        );
1050        assert_eq!(
1051            value["target_row_count_after_write"],
1052            json!({"kind": "exact", "value": 52})
1053        );
1054        assert_eq!(
1055            value["validation_status"],
1056            json!({"kind": "passed", "reason": null})
1057        );
1058        assert_eq!(value["batch_shaping"]["input_batches"], 3);
1059        assert_eq!(value["batch_shaping"]["input_rows"], 42);
1060        assert_eq!(value["write_stats"]["rows_written"], 42);
1061        assert_eq!(value["write_stats"]["batches_written"], 3);
1062        assert_eq!(value["write_stats"]["elapsed_ms"], 125);
1063        assert_eq!(value["cleanup"], "not_applicable");
1064        assert_json_safe(&value)?;
1065        assert_no_secret_or_raw_sql_text(&value);
1066
1067        Ok(())
1068    }
1069
1070    #[test]
1071    fn batch_validation_report_json_exposes_safe_target_context() -> TestResult<()> {
1072        let value =
1073            MssqlOutputBatchValidationReport::from_output_plan(&output_plan()?).to_json_value();
1074
1075        assert_eq!(value["output_name"], "orders_output");
1076        assert_eq!(
1077            value["target_table"],
1078            json!({"schema": "dbo", "table": "orders"})
1079        );
1080        assert_eq!(value["connection_source"], "context_default");
1081        assert_eq!(value["connection"]["display_label"], "warehouse");
1082        assert_json_safe(&value)?;
1083        assert_no_secret_or_raw_sql_text(&value);
1084
1085        Ok(())
1086    }
1087
1088    #[test]
1089    fn workflow_output_status_json_wraps_successful_write_report() -> TestResult<()> {
1090        let output_plan = output_plan()?;
1091        let report = MssqlWriteReport::from_output_plan(
1092            &output_plan,
1093            7,
1094            1,
1095            25,
1096            false,
1097            MssqlTargetCleanupStatus::NotApplicable,
1098        );
1099
1100        let value = MssqlOutputWriteStatus::Succeeded(report).to_json_value();
1101
1102        assert_eq!(value["kind"], "succeeded");
1103        assert_eq!(value["output_name"], "orders_output");
1104        assert_eq!(
1105            value["output_row_count"],
1106            json!({"kind": "exact", "value": 7})
1107        );
1108        assert_eq!(value["report"]["write_stats"]["rows_written"], 7);
1109        assert_json_safe(&value)?;
1110        assert_no_secret_or_raw_sql_text(&value);
1111
1112        Ok(())
1113    }
1114
1115    #[test]
1116    fn failure_context_json_exposes_structured_context_without_success_status() -> TestResult<()> {
1117        let output_plan = output_plan()?;
1118        let context = MssqlWriteFailureContext::from_output_plan(
1119            &output_plan,
1120            MssqlWritePhase::WriteBatch,
1121            4,
1122            1,
1123            25,
1124            true,
1125            MssqlTargetCleanupStatus::Failed,
1126        );
1127
1128        let value = context.to_json_value();
1129
1130        assert_eq!(value["phase"], "write_batch");
1131        assert_eq!(
1132            value["output_row_count"],
1133            json!({"kind": "partial", "value": 4})
1134        );
1135        assert_eq!(value["partial_write_possible"], true);
1136        assert_eq!(value["cleanup"], "failed");
1137        assert!(value["report"].get("status").is_none());
1138        assert_json_safe(&value)?;
1139        assert_no_secret_or_raw_sql_text(&value);
1140
1141        Ok(())
1142    }
1143
1144    #[tokio::test]
1145    async fn workflow_json_covers_real_failed_and_skipped_statuses() -> TestResult<()> {
1146        let first = output_plan_named("first_output")?;
1147        let second = output_plan_named("second_output")?;
1148        let third = output_plan_named("third_output")?;
1149        let first_report = MssqlWriteReport::from_output_plan(
1150            &first,
1151            7,
1152            1,
1153            25,
1154            false,
1155            MssqlTargetCleanupStatus::NotApplicable,
1156        );
1157        let failure_context = MssqlWriteFailureContext::from_output_plan(
1158            &second,
1159            MssqlWritePhase::WriteBatch,
1160            4,
1161            1,
1162            25,
1163            true,
1164            MssqlTargetCleanupStatus::NotApplicable,
1165        );
1166        let failure = crate::DeltaFunnelError::MssqlWritePhase {
1167            context: Box::new(failure_context),
1168            message: "failed to write output".to_owned(),
1169        };
1170        let writer = FakeWorkflowWriter::new(vec![Ok(first_report), Err(failure)]);
1171
1172        let report = write_mssql_outputs_with_writer(
1173            vec![job(first)?, job(second)?, job(third)?],
1174            MssqlWorkflowWriteOptions::default(),
1175            writer,
1176        )
1177        .await?;
1178
1179        let value = report.to_json_value();
1180
1181        assert_eq!(value["succeeded_count"], 1);
1182        assert_eq!(value["failed_count"], 1);
1183        assert_eq!(value["skipped_count"], 1);
1184        assert_eq!(value["outputs"][0]["kind"], "succeeded");
1185        assert_eq!(value["outputs"][1]["kind"], "failed");
1186        assert_eq!(
1187            value["outputs"][1]["failure"]["context"]["phase"],
1188            "write_batch"
1189        );
1190        assert_eq!(
1191            value["outputs"][1]["failure"]["context"]["output_row_count"],
1192            json!({"kind": "partial", "value": 4})
1193        );
1194        assert_eq!(value["outputs"][2]["kind"], "skipped");
1195        assert_eq!(
1196            value["outputs"][2]["skipped"]["reason"],
1197            json!({
1198                "kind": "previous_output_failed",
1199                "failed_output_name": "second_output"
1200            })
1201        );
1202        assert_json_safe(&value)?;
1203        assert_no_secret_or_raw_sql_text(&value);
1204
1205        Ok(())
1206    }
1207
1208    #[test]
1209    fn write_all_cache_json_preserves_decision_aliases_and_skip_reasons() -> TestResult<()> {
1210        let value = WriteAllCacheReport::CacheAliases {
1211            aliases: vec![WriteAllCacheAliasReport::new(
1212                9,
1213                "shared_orders",
1214                vec![0, 2],
1215                WriteAllCacheAliasStatus::MaterializedAndRestored,
1216            )],
1217            skipped_candidates: vec![
1218                WriteAllCacheCandidateSkip::new(
1219                    7,
1220                    "lonely_orders",
1221                    WriteAllCacheCandidateSkipReason::NotShared { output_count: 1 },
1222                ),
1223                WriteAllCacheCandidateSkip::new(
1224                    8,
1225                    "missing_sql_orders",
1226                    WriteAllCacheCandidateSkipReason::MissingSqlText,
1227                ),
1228            ],
1229        }
1230        .to_json_value();
1231
1232        assert_eq!(value["kind"], "cache_aliases");
1233        assert_eq!(value["aliases"][0]["alias"], "shared_orders");
1234        assert_eq!(value["aliases"][0]["status"], "materialized_and_restored");
1235        assert_eq!(
1236            value["skipped_candidates"][0]["reason"],
1237            json!({"kind": "not_shared", "output_count": 1})
1238        );
1239        assert_eq!(
1240            value["skipped_candidates"][1]["reason"],
1241            json!({"kind": "missing_sql_text"})
1242        );
1243        assert_json_safe(&value)?;
1244        assert_no_secret_or_raw_sql_text(&value);
1245
1246        Ok(())
1247    }
1248
1249    #[test]
1250    fn source_report_json_exposes_provider_read_stats_details() -> TestResult<()> {
1251        let source = DeltaSourceReport::metadata_only(
1252            "orders",
1253            "s3://user:password@example.com/tmp/orders?token=secret#debug",
1254            3,
1255            DeltaProtocolReport {
1256                source_name: "orders".to_owned(),
1257                table_uri: "s3://example.com/tmp/orders".to_owned(),
1258                snapshot_version: 3,
1259                min_reader_version: 1,
1260                min_writer_version: 2,
1261                reader_features: vec!["deletionVectors".to_owned()],
1262                writer_features: Vec::new(),
1263            },
1264            DeltaProviderSchedulingReport::from_options(
1265                QueryOptions {
1266                    target_partitions: Some(4),
1267                    output_batch_size: Some(128),
1268                },
1269                DeltaProviderScanExecutionOptions::default(),
1270            ),
1271        )
1272        .with_provider_read_stats(provider_read_stats_snapshot());
1273
1274        let value = source.to_json_value();
1275
1276        assert_eq!(value["source_uri"], "s3://example.com/tmp/orders");
1277        assert_eq!(
1278            value["protocol"]["table_uri"],
1279            "s3://example.com/tmp/orders"
1280        );
1281        assert_eq!(
1282            value["file_count"],
1283            json!({"kind": "exact", "value": 5, "reason": null})
1284        );
1285        assert_eq!(value["provider_read_stats_available"], true);
1286        assert_eq!(value["provider_stats_reason"], Value::Null);
1287        assert_eq!(
1288            value["provider_read_stats"]["reader_backend"],
1289            "native_async"
1290        );
1291        assert_eq!(value["provider_read_stats"]["files_planned"], 5);
1292        assert_eq!(value["provider_read_stats"]["rows_produced"], 10);
1293        assert_eq!(
1294            value["provider_read_stats"]["dynamic_partition_files_pruned"],
1295            2
1296        );
1297        assert_json_safe(&value)?;
1298        assert_no_secret_or_raw_sql_text(&value);
1299
1300        Ok(())
1301    }
1302
1303    fn session_with_default_connection() -> Result<DeltaFunnelSession, crate::DeltaFunnelError> {
1304        let connection = MssqlConnectionConfig::new(
1305            "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
1306        )?
1307        .with_display_label("warehouse");
1308        DeltaFunnelSession::new(SessionOptions::new().with_default_mssql_connection(connection))
1309    }
1310
1311    fn output_plan() -> Result<MssqlTargetOutputPlan, crate::DeltaFunnelError> {
1312        output_plan_with_table("orders_output", "orders")
1313    }
1314
1315    fn output_plan_named(
1316        output_name: &str,
1317    ) -> Result<MssqlTargetOutputPlan, crate::DeltaFunnelError> {
1318        output_plan_with_table(output_name, format!("{output_name}_orders"))
1319    }
1320
1321    fn output_plan_with_table(
1322        output_name: &str,
1323        table_name: impl Into<String>,
1324    ) -> Result<MssqlTargetOutputPlan, crate::DeltaFunnelError> {
1325        let connection = MssqlConnectionConfig::new(
1326            "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
1327        )?
1328        .with_display_label("warehouse");
1329        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", table_name)?);
1330
1331        plan_mssql_target_for_output(
1332            orders_schema(),
1333            output_name,
1334            &target_config,
1335            Some(&connection),
1336            PlanOptions::default(),
1337        )
1338    }
1339
1340    fn job(
1341        output_plan: MssqlTargetOutputPlan,
1342    ) -> Result<MssqlOutputWriteJob, crate::DeltaFunnelError> {
1343        Ok(MssqlOutputWriteJob::with_default_write_options(
1344            orders_schema_ref(),
1345            resolved_target(output_plan)?,
1346            MssqlSchemaPlanOptions::default(),
1347            || async { Ok(stream::empty()) },
1348        ))
1349    }
1350
1351    fn resolved_target(
1352        output_plan: MssqlTargetOutputPlan,
1353    ) -> Result<ResolvedMssqlTarget, crate::DeltaFunnelError> {
1354        let connection = MssqlConnectionConfig::new(
1355            "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
1356        )?
1357        .with_display_label("warehouse");
1358
1359        MssqlTargetConfig::new(output_plan.target_table().clone())
1360            .with_load_mode(output_plan.load_mode())
1361            .resolve(MssqlTargetResolutionContext {
1362                output_name: Some(output_plan.output_name()),
1363                default_connection: Some(&connection),
1364            })
1365    }
1366
1367    fn orders_schema_ref() -> SchemaRef {
1368        Arc::new(orders_schema())
1369    }
1370
1371    fn orders_schema() -> Schema {
1372        Schema::new(vec![
1373            Field::new("id", DataType::Int64, false),
1374            Field::new("region", DataType::Utf8, true),
1375        ])
1376    }
1377
1378    fn provider_read_stats_snapshot() -> DeltaProviderReadStatsSnapshot {
1379        DeltaProviderReadStatsSnapshot {
1380            source_name: "orders".to_owned(),
1381            snapshot_version: 3,
1382            reader_backend: DeltaProviderReaderBackend::NativeAsync,
1383            scan_metadata_exhausted: Some(true),
1384            scan_partitions_planned: 4,
1385            files_planned: 5,
1386            estimated_rows: Some(99),
1387            estimated_bytes: Some(2048),
1388            datafusion_output_batch_size: Some(128),
1389            scan_partitions_started: 4,
1390            scan_partitions_completed: 4,
1391            files_started: 5,
1392            files_completed: 5,
1393            dynamic_partition_files_pruned: 2,
1394            dynamic_partition_files_kept: 3,
1395            dynamic_filters_received: 1,
1396            dynamic_filters_accepted: 1,
1397            dynamic_filters_unsupported: 0,
1398            dynamic_filter_snapshots: 1,
1399            dynamic_partition_files_not_pruned_missing_metadata: 0,
1400            dynamic_partition_files_not_pruned_unsupported_expression: 0,
1401            batches_produced: 2,
1402            rows_produced: 10,
1403            deletion_vector_payloads_loaded: 1,
1404            deletion_vectors_applied: 1,
1405            deletion_vector_rows_deleted: 2,
1406            deletion_vector_failures: 0,
1407            deletion_vector_rejections: 0,
1408        }
1409    }
1410
1411    fn metadata_json() -> String {
1412        format!(
1413            r#"{{"metaData":{{"id":"delta-funnel-json-test","format":{{"provider":"parquet","options":{{}}}},"schemaString":"{{\"type\":\"struct\",\"fields\":{SCHEMA_FIELDS_JSON}}}","partitionColumns":[],"configuration":{{}},"createdTime":1587968585495}}}}"#
1414        )
1415    }
1416
1417    const SCHEMA_FIELDS_JSON: &str = r#"[{\"name\":\"id\",\"type\":\"integer\",\"nullable\":false,\"metadata\":{}},{\"name\":\"region\",\"type\":\"string\",\"nullable\":true,\"metadata\":{}}]"#;
1418
1419    fn env_unique_path(name: &str) -> TestResult<PathBuf> {
1420        let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
1421        Ok(std::env::temp_dir().join(format!(
1422            "delta-funnel-json-report-{}-{name}-{nanos}",
1423            std::process::id()
1424        )))
1425    }
1426
1427    fn assert_json_safe(value: &Value) -> TestResult<()> {
1428        serde_json::from_str::<Value>(&serde_json::to_string(value)?)?;
1429        Ok(())
1430    }
1431
1432    fn assert_no_secret_or_raw_sql_text(value: &Value) {
1433        let text = value.to_string();
1434        assert!(!text.contains("secret-token"));
1435        assert!(!text.contains("password"));
1436        assert!(!text.contains("server=tcp"));
1437        assert!(!text.contains("select id"));
1438    }
1439}