Skip to main content

delta_funnel/report/
json.rs

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