1use std::sync::OnceLock;
2
3use dataprof_core::{
4 AnalysisOptions, ColumnProfile, DataSource, ExecutionMetadata, KeyColumnSource,
5 KeyUniquenessUnits, Locale, NullTokenSet, OutlierUnits, PatternMatchCount, QualityScoreWeights,
6 RangeRuleCount, SemanticHintBinding, ShapeMomentEstimator, TextLengthUnit,
7};
8use dataprof_metrics::{
9 AccuracyMetrics, CompletenessMetrics, ConsistencyMetrics, MetricConfidence, PrecisionMetrics,
10 QualityAssessment, QualityMetrics, TimelinessMetrics, ValidityMetrics,
11};
12
13pub const REPORT_SCHEMA_VERSION: u32 = 1;
28
29#[derive(
37 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
38)]
39#[serde(tag = "state", rename_all = "snake_case")]
40pub enum QualityAnalysisStatus {
41 Computed,
43 NotRequested,
45 NoData,
48 WithheldByProjection,
54 Failed {
57 error: String,
59 },
60 Unrecorded,
64}
65
66#[derive(
79 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
80)]
81#[non_exhaustive]
82pub struct MetricSemantics {
83 #[serde(
88 default,
89 skip_serializing_if = "Option::is_none",
90 deserialize_with = "present_value"
91 )]
92 #[schemars(with = "TextLengthUnit")]
93 pub text_length_unit: Option<TextLengthUnit>,
94 #[serde(
100 default,
101 skip_serializing_if = "Option::is_none",
102 deserialize_with = "present_value"
103 )]
104 #[schemars(with = "NullTokenSet")]
105 pub null_tokens: Option<NullTokenSet>,
106 #[serde(
111 default,
112 skip_serializing_if = "Option::is_none",
113 deserialize_with = "present_value"
114 )]
115 #[schemars(with = "PatternMatchCount")]
116 pub pattern_match_count: Option<PatternMatchCount>,
117 #[serde(
122 default,
123 skip_serializing_if = "Option::is_none",
124 deserialize_with = "present_value"
125 )]
126 #[schemars(with = "ShapeMomentEstimator")]
127 pub shape_moments: Option<ShapeMomentEstimator>,
128 #[serde(
133 default,
134 skip_serializing_if = "Option::is_none",
135 deserialize_with = "present_value"
136 )]
137 #[schemars(with = "RangeRuleCount")]
138 pub range_rule_count: Option<RangeRuleCount>,
139 #[serde(
144 default,
145 skip_serializing_if = "Option::is_none",
146 deserialize_with = "present_value"
147 )]
148 #[schemars(with = "KeyUniquenessUnits")]
149 pub key_uniqueness_units: Option<KeyUniquenessUnits>,
150 #[serde(
155 default,
156 skip_serializing_if = "Option::is_none",
157 deserialize_with = "present_value"
158 )]
159 #[schemars(with = "OutlierUnits")]
160 pub outlier_units: Option<OutlierUnits>,
161 #[serde(
166 default,
167 skip_serializing_if = "Option::is_none",
168 deserialize_with = "present_value"
169 )]
170 #[schemars(with = "KeyColumnSource")]
171 pub key_column_source: Option<KeyColumnSource>,
172}
173
174pub(crate) fn present_value<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
177where
178 D: serde::Deserializer<'de>,
179 T: serde::Deserialize<'de>,
180{
181 T::deserialize(deserializer).map(Some)
182}
183
184impl MetricSemantics {
185 pub fn current() -> Self {
187 Self {
188 text_length_unit: Some(TextLengthUnit::UnicodeScalar),
189 null_tokens: Some(NullTokenSet::CommonMarkers),
190 pattern_match_count: Some(PatternMatchCount::Validated),
191 shape_moments: Some(ShapeMomentEstimator::AdjustedSample),
192 range_rule_count: Some(RangeRuleCount::ViolatingValues),
193 key_uniqueness_units: Some(KeyUniquenessUnits::NonNullValues),
194 outlier_units: Some(OutlierUnits::TestedValues),
195 key_column_source: Some(KeyColumnSource::Declared),
196 }
197 }
198
199 pub(crate) fn is_fully_recorded(&self) -> bool {
204 let Self {
205 text_length_unit,
206 null_tokens,
207 pattern_match_count,
208 shape_moments,
209 range_rule_count,
210 key_uniqueness_units,
211 outlier_units,
212 key_column_source,
213 } = self;
214 text_length_unit.is_some()
215 && null_tokens.is_some()
216 && pattern_match_count.is_some()
217 && shape_moments.is_some()
218 && range_rule_count.is_some()
219 && key_uniqueness_units.is_some()
220 && outlier_units.is_some()
221 && key_column_source.is_some()
222 }
223}
224
225#[derive(
228 Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
229)]
230#[non_exhaustive]
231pub enum PatternLocale {
232 #[serde(rename = "none")]
234 Unset,
235 #[serde(rename = "CA")]
237 Ca,
238 #[serde(rename = "DE")]
240 De,
241 #[serde(rename = "FR")]
243 Fr,
244 #[serde(rename = "GB")]
246 Gb,
247 #[serde(rename = "IT")]
249 It,
250 #[serde(rename = "US")]
252 Us,
253}
254
255impl From<Option<Locale>> for PatternLocale {
256 fn from(locale: Option<Locale>) -> Self {
257 match locale {
258 None => Self::Unset,
259 Some(Locale::Ca) => Self::Ca,
260 Some(Locale::De) => Self::De,
261 Some(Locale::Fr) => Self::Fr,
262 Some(Locale::Gb) => Self::Gb,
263 Some(Locale::It) => Self::It,
264 Some(Locale::Us) => Self::Us,
265 }
266 }
267}
268
269impl PatternLocale {
270 pub fn locale(self) -> Option<Locale> {
272 match self {
273 Self::Unset => None,
274 Self::Ca => Some(Locale::Ca),
275 Self::De => Some(Locale::De),
276 Self::Fr => Some(Locale::Fr),
277 Self::Gb => Some(Locale::Gb),
278 Self::It => Some(Locale::It),
279 Self::Us => Some(Locale::Us),
280 }
281 }
282}
283
284#[derive(
291 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
292)]
293#[serde(tag = "selection", rename_all = "snake_case")]
294#[non_exhaustive]
295pub enum ColumnSelection {
296 All,
298 #[non_exhaustive]
300 Listed {
301 names: Vec<String>,
303 },
304}
305
306impl ColumnSelection {
307 pub fn from_options(options: &AnalysisOptions) -> Self {
309 match options.columns() {
310 None => Self::All,
311 Some(names) => Self::Listed {
312 names: names.to_vec(),
313 },
314 }
315 }
316
317 pub fn includes(&self, column: &str) -> bool {
320 match self {
321 Self::All => true,
322 Self::Listed { names } => names.iter().any(|name| name == column),
323 }
324 }
325}
326
327#[derive(
336 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
337)]
338#[non_exhaustive]
339pub struct AnalysisSettings {
340 pub pattern_locale: PatternLocale,
343 pub columns: ColumnSelection,
347}
348
349impl AnalysisSettings {
350 pub fn from_options(options: &AnalysisOptions) -> Self {
352 Self {
353 pattern_locale: options.locale().into(),
354 columns: ColumnSelection::from_options(options),
355 }
356 }
357}
358
359#[derive(
370 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
371)]
372#[non_exhaustive]
373pub struct ReportGenerator {
374 pub version: String,
376 #[serde(
378 default,
379 skip_serializing_if = "Option::is_none",
380 deserialize_with = "present_value"
381 )]
382 #[schemars(with = "String")]
383 pub commit: Option<String>,
384 #[serde(
387 default,
388 skip_serializing_if = "Option::is_none",
389 deserialize_with = "present_value"
390 )]
391 #[schemars(with = "bool")]
392 pub dirty: Option<bool>,
393}
394
395static BUILD_IDENTITY: OnceLock<(Option<&'static str>, Option<bool>)> = OnceLock::new();
397
398#[doc(hidden)]
406pub fn record_build_identity(commit: Option<&'static str>, dirty: Option<bool>) {
407 let _ = BUILD_IDENTITY.set((commit, dirty));
408}
409
410impl ReportGenerator {
411 pub fn current() -> Self {
413 let (commit, dirty) = BUILD_IDENTITY.get().copied().unwrap_or((None, None));
414 Self {
415 version: env!("CARGO_PKG_VERSION").to_string(),
416 commit: commit.map(str::to_string),
417 dirty,
418 }
419 }
420}
421
422#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
429pub struct ProfileReport {
430 #[schemars(schema_with = "schema_version_schema")]
435 pub schema_version: u32,
436 pub id: String,
438 pub timestamp: String,
440 pub data_source: DataSource,
442 pub column_profiles: Vec<ColumnProfile>,
444 pub execution: ExecutionMetadata,
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub quality: Option<QualityAssessment>,
449 pub quality_status: QualityAnalysisStatus,
464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 pub semantic_hint_bindings: Vec<SemanticHintBinding>,
472 #[serde(skip_serializing_if = "Option::is_none")]
479 #[schemars(with = "MetricSemantics")]
480 pub metric_semantics: Option<MetricSemantics>,
481 #[serde(skip_serializing_if = "Option::is_none")]
488 #[schemars(with = "AnalysisSettings")]
489 pub analysis_settings: Option<AnalysisSettings>,
490 #[serde(skip_serializing_if = "Option::is_none")]
497 #[schemars(with = "ReportGenerator")]
498 pub generator: Option<ReportGenerator>,
499}
500
501fn schema_version_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
502 schemars::json_schema!({
503 "type": "integer",
504 "const": REPORT_SCHEMA_VERSION,
505 "minimum": 0
506 })
507}
508
509#[allow(dead_code)]
517#[derive(serde::Serialize, schemars::JsonSchema)]
518#[serde(untagged)]
519#[schemars(title = "ProfileReport")]
520enum SerializedProfileReport {
521 Rust(Box<ProfileReport>),
522 Python(Box<PythonProfileReportDocument>),
523}
524
525#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
526struct PythonProfileReportDocument {
527 #[schemars(schema_with = "schema_version_schema")]
528 schema_version: u32,
529 source: String,
530 source_type: PythonSourceType,
531 execution: PythonExecutionDocument,
532 columns: Vec<PythonColumnDocument>,
533 quality: Option<PythonQualityDocument>,
534 quality_status: QualityAnalysisStatus,
535 #[serde(skip_serializing_if = "Vec::is_empty")]
536 semantic_hint_bindings: Vec<SemanticHintBinding>,
537 #[serde(skip_serializing_if = "Option::is_none")]
538 #[schemars(with = "MetricSemantics")]
539 metric_semantics: Option<MetricSemantics>,
540 #[serde(skip_serializing_if = "Option::is_none")]
541 #[schemars(with = "AnalysisSettings")]
542 analysis_settings: Option<AnalysisSettings>,
543 #[serde(skip_serializing_if = "Option::is_none")]
544 #[schemars(with = "ReportGenerator")]
545 generator: Option<ReportGenerator>,
546}
547
548#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
549#[serde(rename_all = "lowercase")]
550enum PythonSourceType {
551 File,
552 Query,
553 Dataframe,
554 Stream,
555 Bytes,
556}
557
558#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
559struct PythonExecutionDocument {
560 engine: Option<String>,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 recovery_events: Option<Vec<dataprof_core::execution::RecoveryEvent>>,
563 rows_processed: usize,
564 columns_detected: usize,
565 scan_time_ms: u128,
566 source_exhausted: bool,
567 truncation_reason: Option<String>,
568 bytes_consumed: Option<u64>,
569 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
570 throughput_rows_sec: Option<f64>,
571 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
572 memory_peak_mb: Option<f64>,
573 error_count: usize,
574 ragged_row_count: usize,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 unterminated_quote: Option<bool>,
577 sampling_applied: bool,
578 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
579 sampling_ratio: Option<f64>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 sampled_row_ranges: Option<Vec<[u64; 2]>>,
582}
583
584#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
585struct PythonColumnDocument {
586 name: String,
587 data_type: PythonDataType,
588 total_count: usize,
589 null_count: usize,
590 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
591 null_percentage: Option<f64>,
592 unique_count: Option<usize>,
593 unique_count_is_approximate: Option<bool>,
594 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
595 uniqueness_ratio: Option<f64>,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 invalid_count: Option<usize>,
598 #[serde(skip_serializing_if = "Option::is_none")]
604 type_homogeneity: Option<dataprof_core::TypeHomogeneity>,
605 #[serde(skip_serializing_if = "Option::is_none")]
606 locale_number_count: Option<usize>,
607 #[serde(skip_serializing_if = "Option::is_none")]
608 stats: Option<PythonColumnStatsDocument>,
609 #[serde(skip_serializing_if = "Option::is_none")]
610 patterns: Option<Vec<PythonPatternDocument>>,
611}
612
613#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
614#[serde(rename_all = "lowercase")]
615enum PythonDataType {
616 String,
617 Identifier,
618 Integer,
619 Float,
620 Date,
621 Boolean,
622 Nested,
623}
624
625#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
626struct PythonColumnStatsDocument {
627 #[serde(skip_serializing_if = "Option::is_none")]
628 min: Option<f64>,
629 #[serde(skip_serializing_if = "Option::is_none")]
630 max: Option<f64>,
631 #[serde(skip_serializing_if = "Option::is_none")]
632 mean: Option<f64>,
633 #[serde(
636 default,
637 deserialize_with = "deserialize_present_nullable_stat",
638 skip_serializing_if = "Option::is_none"
639 )]
640 #[schemars(with = "Option<f64>")]
641 std_dev: Option<Option<f64>>,
642 #[serde(
643 default,
644 deserialize_with = "deserialize_present_nullable_stat",
645 skip_serializing_if = "Option::is_none"
646 )]
647 #[schemars(with = "Option<f64>")]
648 variance: Option<Option<f64>>,
649 #[serde(skip_serializing_if = "Option::is_none")]
650 median: Option<f64>,
651 #[serde(skip_serializing_if = "Option::is_none")]
652 mode: Option<f64>,
653 #[serde(skip_serializing_if = "Option::is_none")]
654 skewness: Option<f64>,
655 #[serde(skip_serializing_if = "Option::is_none")]
656 kurtosis: Option<f64>,
657 #[serde(skip_serializing_if = "Option::is_none")]
658 coefficient_of_variation: Option<f64>,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 quartiles: Option<dataprof_core::Quartiles>,
661 #[serde(skip_serializing_if = "Option::is_none")]
662 is_approximate: Option<bool>,
663 #[serde(skip_serializing_if = "Option::is_none")]
664 outlier_count: Option<usize>,
665 #[serde(skip_serializing_if = "Option::is_none")]
670 min_length: Option<usize>,
671 #[serde(skip_serializing_if = "Option::is_none")]
673 max_length: Option<usize>,
674 #[serde(skip_serializing_if = "Option::is_none")]
676 avg_length: Option<f64>,
677 #[serde(skip_serializing_if = "Option::is_none")]
678 true_count: Option<usize>,
679 #[serde(skip_serializing_if = "Option::is_none")]
680 false_count: Option<usize>,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 true_ratio: Option<f64>,
683}
684
685fn deserialize_present_nullable_stat<'de, D>(
686 deserializer: D,
687) -> Result<Option<Option<f64>>, D::Error>
688where
689 D: serde::Deserializer<'de>,
690{
691 serde::Deserialize::deserialize(deserializer).map(Some)
692}
693
694#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
695struct PythonPatternDocument {
696 name: String,
697 regex: String,
698 match_count: usize,
699 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
700 match_percentage: f64,
701 category: dataprof_core::PatternCategory,
702 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4")]
703 confidence: f64,
704}
705
706#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
707struct PythonQualityDocument {
708 overall_score: Option<f64>,
711 assessed_dimensions: Vec<PythonQualityDimension>,
712 dimension_scores: std::collections::BTreeMap<PythonQualityDimension, Option<f64>>,
713 low_sample_warning: bool,
714 #[serde(skip_serializing_if = "Option::is_none")]
720 sampled_dimensions: Option<Vec<String>>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
725 score_bounds: Option<dataprof_metrics::ScoreBounds>,
726 #[serde(default)]
732 score_weights: QualityScoreWeights,
733 #[serde(skip_serializing_if = "Option::is_none")]
734 completeness: Option<CompletenessMetrics>,
735 #[serde(skip_serializing_if = "Option::is_none")]
736 consistency: Option<ConsistencyMetrics>,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 uniqueness: Option<PythonUniquenessDocument>,
739 #[serde(skip_serializing_if = "Option::is_none")]
740 accuracy: Option<AccuracyMetrics>,
741 #[serde(skip_serializing_if = "Option::is_none")]
742 timeliness: Option<TimelinessMetrics>,
743 #[serde(skip_serializing_if = "Option::is_none")]
744 validity: Option<ValidityMetrics>,
745 #[serde(skip_serializing_if = "Option::is_none")]
746 precision: Option<PrecisionMetrics>,
747}
748
749#[derive(
750 Debug,
751 Clone,
752 PartialEq,
753 Eq,
754 PartialOrd,
755 Ord,
756 Hash,
757 serde::Serialize,
758 serde::Deserialize,
759 schemars::JsonSchema,
760)]
761#[serde(rename_all = "lowercase")]
762enum PythonQualityDimension {
763 Completeness,
764 Consistency,
765 Uniqueness,
766 Accuracy,
767 Timeliness,
768 Validity,
769 Precision,
770}
771
772#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
773struct PythonUniquenessDocument {
774 duplicate_rows: usize,
775 key_uniqueness: f64,
776 high_cardinality_warning: bool,
777 rows_checked: usize,
778 key_column: Option<String>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
780 key_values_checked: Option<usize>,
781 duplicate_rows_approximate: bool,
782}
783
784impl PythonProfileReportDocument {
785 fn from_report(report: &ProfileReport) -> serde_json::Result<Self> {
786 use dataprof_core::{ColumnStats, DataType, TruncationReason};
787
788 let columns = report
791 .column_profiles
792 .iter()
793 .map(|column| {
794 let stats = match &column.stats {
795 ColumnStats::Numeric(stats) => Some(serde_json::to_value(stats)?),
796 ColumnStats::Text(stats) if column.total_count > 0 => {
797 Some(serde_json::to_value(stats)?)
798 }
799 ColumnStats::Boolean(stats) => Some(serde_json::to_value(stats)?),
800 _ => None,
801 }
802 .map(serde_json::from_value)
803 .transpose()?;
804 Ok(PythonColumnDocument {
805 name: column.name.clone(),
806 data_type: match column.data_type {
807 DataType::String => PythonDataType::String,
808 DataType::Identifier => PythonDataType::Identifier,
809 DataType::Integer => PythonDataType::Integer,
810 DataType::Float => PythonDataType::Float,
811 DataType::Date => PythonDataType::Date,
812 DataType::Boolean => PythonDataType::Boolean,
813 DataType::Nested => PythonDataType::Nested,
814 },
815 total_count: column.total_count,
816 null_count: column.null_count,
817 null_percentage: (column.total_count > 0)
818 .then(|| column.null_count as f64 / column.total_count as f64 * 100.0),
819 unique_count: column.unique_count,
820 unique_count_is_approximate: column.unique_count_is_approximate,
821 uniqueness_ratio: column
822 .unique_count
823 .filter(|_| column.total_count > 0)
824 .map(|count| count as f64 / column.total_count as f64),
825 invalid_count: column.invalid_count,
826 type_homogeneity: column.type_homogeneity,
827 locale_number_count: column.locale_number_count,
828 stats,
829 patterns: column.patterns.as_ref().map(|patterns| {
830 patterns
831 .iter()
832 .map(|pattern| PythonPatternDocument {
833 name: pattern.name.clone(),
834 regex: pattern.regex.clone(),
835 match_count: pattern.match_count,
836 match_percentage: pattern.match_percentage,
837 category: pattern.category.clone(),
838 confidence: pattern.confidence,
839 })
840 .collect()
841 }),
842 })
843 })
844 .collect::<serde_json::Result<Vec<_>>>()?;
845
846 let mut execution = serde_json::to_value(&report.execution)?;
847 execution["truncation_reason"] =
848 serde_json::to_value(report.execution.truncation_reason.as_ref().map(|reason| {
849 match reason {
850 TruncationReason::MaxRows(n) => format!("max_rows({n})"),
851 TruncationReason::MaxBytes(n) => format!("max_bytes({n})"),
852 TruncationReason::MemoryPressure => "memory_pressure".to_string(),
853 TruncationReason::StopCondition(s) => format!("stop_condition({s})"),
854 TruncationReason::StreamClosed => "stream_closed".to_string(),
855 TruncationReason::Timeout => "timeout".to_string(),
856 }
857 }))?;
858
859 let quality = report
860 .quality
861 .as_ref()
862 .map(|assessment| {
863 let metrics = &assessment.metrics;
864 let mut document = serde_json::to_value(metrics)?;
865 let scores = serde_json::to_value(assessment.scores())?;
866 document["overall_score"] = scores["overall_score"].clone();
867 let known: Vec<String> = dataprof_core::QualityDimension::all()
871 .into_iter()
872 .map(|dimension| dimension.to_string())
873 .collect();
874 document["dimension_scores"] = scores["dimension_scores"]
875 .as_object()
876 .expect("dimension scores serialize as an object")
877 .iter()
878 .filter(|(name, _)| known.contains(name))
879 .map(|(name, score)| (name.clone(), score.clone()))
880 .collect();
881 document["assessed_dimensions"] =
882 serde_json::to_value(metrics.assessed_dimensions())?;
883 document["low_sample_warning"] =
884 serde_json::Value::Bool(metrics.low_sample_warning);
885 if let Some(sampled) = assessment.sampled_dimensions() {
886 document["sampled_dimensions"] = serde_json::to_value(sampled)?;
887 }
888 if let Some(bounds) = assessment.score_bounds() {
889 document["score_bounds"] = serde_json::to_value(bounds)?;
890 }
891 if let Some(uniqueness) = document.get_mut("uniqueness") {
893 uniqueness["duplicate_rows_approximate"] = serde_json::Value::Bool(
894 metrics
895 .uniqueness
896 .as_ref()
897 .expect("serialized uniqueness is present")
898 .duplicate_rows_approximate,
899 );
900 }
901 serde_json::from_value(document)
902 })
903 .transpose()?;
904
905 Ok(Self {
906 schema_version: report.schema_version,
907 source: report.data_source.identifier(),
908 source_type: match report.data_source {
909 DataSource::File { .. } => PythonSourceType::File,
910 DataSource::Query { .. } => PythonSourceType::Query,
911 DataSource::DataFrame { .. } => PythonSourceType::Dataframe,
912 DataSource::Stream { .. } => PythonSourceType::Stream,
913 DataSource::Bytes { .. } => PythonSourceType::Bytes,
914 },
915 execution: serde_json::from_value(execution)?,
916 columns,
917 quality,
918 quality_status: report.quality_status.clone(),
919 semantic_hint_bindings: report.semantic_hint_bindings.clone(),
920 metric_semantics: report.metric_semantics.clone(),
921 analysis_settings: report.analysis_settings.clone(),
922 generator: report.generator.clone(),
923 })
924 }
925}
926
927#[doc(hidden)]
933pub fn profile_report_schema_document() -> serde_json::Value {
934 let settings = schemars::generate::SchemaSettings::draft2020_12().for_serialize();
935 let schema = settings
936 .into_generator()
937 .into_root_schema_for::<SerializedProfileReport>();
938 let mut document =
939 serde_json::to_value(schema).expect("a Schemars schema must serialize to a JSON document");
940 let object = document
941 .as_object_mut()
942 .expect("a root Schemars schema must be a JSON object");
943 object.insert(
944 "$id".to_string(),
945 serde_json::Value::String(format!(
946 "https://andreabozzo.github.io/dataprof/schema/profile-report.v{REPORT_SCHEMA_VERSION}.schema.json"
947 )),
948 );
949 make_compatibility_defaults_optional(&mut document);
950 allow_additive_properties(&mut document);
951 canonicalize_key_order(&mut document);
952 document
953}
954
955fn canonicalize_key_order(value: &mut serde_json::Value) {
964 match value {
965 serde_json::Value::Object(object) => {
966 for child in object.values_mut() {
967 canonicalize_key_order(child);
968 }
969 let mut sorted: Vec<(String, serde_json::Value)> =
970 std::mem::take(object).into_iter().collect();
971 sorted.sort_by(|(left, _), (right, _)| left.cmp(right));
972 object.extend(sorted);
973 }
974 serde_json::Value::Array(items) => {
975 for item in items {
976 canonicalize_key_order(item);
977 }
978 }
979 _ => {}
980 }
981}
982
983fn make_compatibility_defaults_optional(document: &mut serde_json::Value) {
990 for (pointer, field) in [
991 ("/$defs/ExecutionMetadata/required", "ragged_row_count"),
992 ("/$defs/ProfileReport/required", "quality_status"),
993 (
994 "/$defs/PythonProfileReportDocument/required",
995 "quality_status",
996 ),
997 (
998 "/$defs/PythonQualityDocument/required",
999 "sampled_dimensions",
1000 ),
1001 ("/$defs/QualityAssessment/required", "scores"),
1002 ("/$defs/QualityMetrics/required", "score_weights"),
1004 ("/$defs/PythonQualityDocument/required", "score_weights"),
1005 ("/$defs/QualityAssessment/required", "score_bounds"),
1007 ("/$defs/AccuracyMetrics/required", "rule_values_checked"),
1009 ("/$defs/AccuracyMetrics/required", "rule_violating_values"),
1010 ] {
1011 if let Some(required) = document
1012 .pointer_mut(pointer)
1013 .and_then(serde_json::Value::as_array_mut)
1014 {
1015 required.retain(|declared| declared.as_str() != Some(field));
1016 }
1017 }
1018 if let Some(definitions) = document
1020 .pointer_mut("/$defs")
1021 .and_then(serde_json::Value::as_object_mut)
1022 {
1023 for definition in definitions.values_mut() {
1024 if let Some(object) = definition.as_object_mut()
1025 && object
1026 .get("required")
1027 .and_then(serde_json::Value::as_array)
1028 .is_some_and(Vec::is_empty)
1029 {
1030 object.remove("required");
1031 }
1032 }
1033 }
1034}
1035
1036fn allow_additive_properties(value: &mut serde_json::Value) {
1037 match value {
1038 serde_json::Value::Object(object) => {
1039 if object.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
1040 object.remove("additionalProperties");
1041 }
1042 for child in object.values_mut() {
1043 allow_additive_properties(child);
1044 }
1045 }
1046 serde_json::Value::Array(items) => {
1047 for item in items {
1048 allow_additive_properties(item);
1049 }
1050 }
1051 _ => {}
1052 }
1053}
1054
1055impl ProfileReport {
1056 pub fn to_json(&self) -> serde_json::Result<String> {
1059 let mut document = serde_json::to_value(self)?;
1060 canonicalize_key_order(&mut document);
1061 serde_json::to_string_pretty(&document)
1062 }
1063
1064 #[doc(hidden)]
1067 pub fn summary_json(&self) -> serde_json::Result<String> {
1068 let summary = PythonProfileReportDocument::from_report(self)?;
1069 serde_json::to_string(&summary)
1070 }
1071
1072 pub fn new(
1074 data_source: DataSource,
1075 column_profiles: Vec<ColumnProfile>,
1076 execution: ExecutionMetadata,
1077 quality: Option<QualityAssessment>,
1078 ) -> Self {
1079 Self {
1080 schema_version: REPORT_SCHEMA_VERSION,
1081 id: uuid::Uuid::new_v4().to_string(),
1082 timestamp: chrono::Utc::now().to_rfc3339(),
1083 data_source,
1084 column_profiles,
1085 execution,
1086 quality_status: match quality {
1087 Some(_) => QualityAnalysisStatus::Computed,
1088 None => QualityAnalysisStatus::NotRequested,
1092 },
1093 quality,
1094 semantic_hint_bindings: Vec::new(),
1095 metric_semantics: Some(MetricSemantics::current()),
1096 analysis_settings: None,
1099 generator: Some(ReportGenerator::current()),
1100 }
1101 }
1102
1103 pub(crate) fn with_analysis_settings(mut self, settings: Option<AnalysisSettings>) -> Self {
1105 self.analysis_settings = settings;
1106 self
1107 }
1108
1109 pub(crate) fn with_quality_status(mut self, status: QualityAnalysisStatus) -> Self {
1119 self.quality_status = status;
1120 self
1121 }
1122
1123 pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
1125 self.semantic_hint_bindings = bindings;
1126 self
1127 }
1128
1129 pub fn with_id(mut self, id: impl Into<String>) -> Self {
1131 self.id = id.into();
1132 self
1133 }
1134
1135 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
1137 self.timestamp = timestamp.into();
1138 self
1139 }
1140
1141 pub fn quality_score(&self) -> Option<f64> {
1146 self.quality.as_ref().and_then(|q| q.score())
1147 }
1148
1149 pub fn source_identifier(&self) -> String {
1151 self.data_source.identifier()
1152 }
1153
1154 fn check_quality_pairing(self) -> Result<Self, String> {
1165 let assessed = self.quality.is_some();
1166 let claims_computed = matches!(self.quality_status, QualityAnalysisStatus::Computed);
1167 if claims_computed == assessed {
1168 return Ok(self);
1169 }
1170 Err(if assessed {
1171 format!(
1172 "report carries a quality assessment but reports quality_status \
1173 {:?}; only `computed` may accompany an assessment",
1174 self.quality_status
1175 )
1176 } else {
1177 "report reports quality_status `computed` but carries no quality assessment".to_string()
1178 })
1179 }
1180}
1181
1182#[derive(serde::Deserialize)]
1187struct ProfileReportFields {
1188 #[serde(default)]
1189 schema_version: u32,
1190 id: String,
1191 timestamp: String,
1192 data_source: DataSource,
1193 column_profiles: Vec<ColumnProfile>,
1194 #[serde(alias = "scan_info")]
1195 execution: ExecutionMetadata,
1196 #[serde(
1197 alias = "data_quality_metrics",
1198 default,
1199 deserialize_with = "deserialize_quality_compat"
1200 )]
1201 quality: Option<QualityAssessment>,
1202 #[serde(default)]
1203 quality_status: Option<QualityAnalysisStatus>,
1204 #[serde(default)]
1205 semantic_hint_bindings: Vec<SemanticHintBinding>,
1206 #[serde(default)]
1207 metric_semantics: Option<MetricSemantics>,
1208 #[serde(default)]
1209 analysis_settings: Option<AnalysisSettings>,
1210 #[serde(default)]
1211 generator: Option<ReportGenerator>,
1212}
1213
1214impl From<ProfileReportFields> for ProfileReport {
1215 fn from(fields: ProfileReportFields) -> Self {
1216 Self {
1217 schema_version: fields.schema_version,
1218 id: fields.id,
1219 timestamp: fields.timestamp,
1220 data_source: fields.data_source,
1221 column_profiles: fields.column_profiles,
1222 execution: fields.execution,
1223 quality_status: fields.quality_status.unwrap_or({
1226 if fields.quality.is_some() {
1227 QualityAnalysisStatus::Computed
1228 } else {
1229 QualityAnalysisStatus::Unrecorded
1230 }
1231 }),
1232 quality: fields.quality,
1233 semantic_hint_bindings: fields.semantic_hint_bindings,
1234 metric_semantics: fields.metric_semantics,
1235 analysis_settings: fields.analysis_settings,
1236 generator: fields.generator,
1237 }
1238 }
1239}
1240
1241impl<'de> serde::Deserialize<'de> for ProfileReport {
1247 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1248 where
1249 D: serde::Deserializer<'de>,
1250 {
1251 use serde::de::Error;
1252
1253 let value = serde_json::Value::deserialize(deserializer)?;
1254 match value.get("schema_version") {
1255 None => {}
1257 Some(serde_json::Value::Number(n)) if n.as_u64().is_some() => {
1258 let version = n.as_u64().unwrap_or_default();
1259 if version > u64::from(REPORT_SCHEMA_VERSION) {
1260 return Err(D::Error::custom(format!(
1261 "report schema version {version} is newer than the latest supported \
1262 version {REPORT_SCHEMA_VERSION}; upgrade dataprof to read this report"
1263 )));
1264 }
1265 }
1266 Some(other) => {
1268 return Err(D::Error::custom(format!(
1269 "report schema_version must be a non-negative integer, got {other}"
1270 )));
1271 }
1272 }
1273 if value.get("quality_status") == Some(&serde_json::Value::Null) {
1278 return Err(D::Error::custom(
1279 "report quality_status must be an object; an explicit null is malformed, \
1280 not a document written before the field existed",
1281 ));
1282 }
1283 for key in ["metric_semantics", "analysis_settings", "generator"] {
1286 if value.get(key) == Some(&serde_json::Value::Null) {
1287 return Err(D::Error::custom(format!(
1288 "report {key} must be an object; an explicit null is malformed, \
1289 not a document written before the field existed"
1290 )));
1291 }
1292 }
1293 let report = ProfileReportFields::deserialize(value)
1294 .map(ProfileReport::from)
1295 .map_err(D::Error::custom)?;
1296 report.check_quality_pairing().map_err(D::Error::custom)
1297 }
1298}
1299
1300fn deserialize_quality_compat<'de, D>(
1303 deserializer: D,
1304) -> Result<Option<QualityAssessment>, D::Error>
1305where
1306 D: serde::Deserializer<'de>,
1307{
1308 use serde::Deserialize;
1309
1310 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
1311 match value {
1312 None => Ok(None),
1313 Some(mut v) => {
1314 if v.get("metrics").is_some() || v.get("confidence").is_some() {
1318 if let Some(object) = v.as_object_mut() {
1319 object
1322 .entry("confidence")
1323 .or_insert_with(|| serde_json::json!("Unrecorded"));
1324 }
1325 let assessment: QualityAssessment =
1326 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1327 Ok(Some(assessment))
1328 } else {
1329 let metrics: QualityMetrics =
1330 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
1331 Ok(Some(QualityAssessment::new(
1336 metrics,
1337 MetricConfidence::Unrecorded,
1338 )))
1339 }
1340 }
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use dataprof_core::FileFormat;
1348 use dataprof_metrics::MetricConfidence;
1349 use serde_json::json;
1350
1351 fn report_without_quality() -> ProfileReport {
1352 ProfileReport::new(
1353 DataSource::File {
1354 path: "test.csv".to_string(),
1355 format: FileFormat::Csv,
1356 size_bytes: 1024,
1357 modified_at: None,
1358 parquet_metadata: None,
1359 },
1360 vec![],
1361 ExecutionMetadata::new(100, 5, 50),
1362 None,
1363 )
1364 }
1365
1366 #[test]
1370 fn every_status_has_a_stable_wire_name() {
1371 let cases = [
1372 (QualityAnalysisStatus::Computed, "computed"),
1373 (QualityAnalysisStatus::NotRequested, "not_requested"),
1374 (QualityAnalysisStatus::NoData, "no_data"),
1375 (
1376 QualityAnalysisStatus::WithheldByProjection,
1377 "withheld_by_projection",
1378 ),
1379 (
1380 QualityAnalysisStatus::Failed {
1381 error: "boom".to_string(),
1382 },
1383 "failed",
1384 ),
1385 (QualityAnalysisStatus::Unrecorded, "unrecorded"),
1386 ];
1387
1388 for (status, name) in cases {
1389 let value = serde_json::to_value(&status).unwrap();
1390 assert_eq!(value.get("state"), Some(&json!(name)), "{status:?}");
1391 let restored: QualityAnalysisStatus = serde_json::from_value(value).unwrap();
1392 assert_eq!(restored, status);
1393 }
1394 }
1395
1396 #[test]
1400 fn contradictory_status_and_assessment_fail_to_decode() {
1401 let with_assessment =
1402 report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1403 error: "boom".to_string(),
1404 });
1405 let mut document = serde_json::to_value(&with_assessment).unwrap();
1406 document.as_object_mut().unwrap().insert(
1407 "quality".to_string(),
1408 serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1409 );
1410 let error = serde_json::from_value::<ProfileReport>(document)
1411 .expect_err("an assessment under a `failed` status is not a readable report");
1412 assert!(
1413 error.to_string().contains("only `computed`"),
1414 "unhelpful error: {error}"
1415 );
1416
1417 let mut document = serde_json::to_value(
1418 report_without_quality().with_quality_status(QualityAnalysisStatus::Computed),
1419 )
1420 .unwrap();
1421 document.as_object_mut().unwrap().remove("quality");
1422 let error = serde_json::from_value::<ProfileReport>(document)
1423 .expect_err("`computed` without an assessment is not a readable report");
1424 assert!(
1425 error.to_string().contains("carries no quality assessment"),
1426 "unhelpful error: {error}"
1427 );
1428 }
1429
1430 #[test]
1434 fn an_explicit_null_status_is_malformed_not_legacy() {
1435 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1436 document
1437 .as_object_mut()
1438 .unwrap()
1439 .insert("quality_status".to_string(), serde_json::Value::Null);
1440
1441 let error = serde_json::from_value::<ProfileReport>(document)
1442 .expect_err("an explicit null is not a readable status");
1443 assert!(
1444 error.to_string().contains("explicit null is malformed"),
1445 "unhelpful error: {error}"
1446 );
1447 }
1448
1449 fn summary_value(report: &ProfileReport) -> serde_json::Value {
1450 serde_json::from_str(&report.summary_json().unwrap()).unwrap()
1451 }
1452
1453 #[test]
1457 fn metric_semantics_are_recorded_and_absence_survives_a_roundtrip() {
1458 let report = report_without_quality();
1459 assert_eq!(report.metric_semantics, Some(MetricSemantics::current()));
1460 let mut document = serde_json::to_value(&report).unwrap();
1461 let recorded = json!({
1462 "text_length_unit": "unicode_scalar",
1463 "null_tokens": "common_markers",
1464 "pattern_match_count": "validated",
1465 "shape_moments": "adjusted_sample",
1466 "range_rule_count": "violating_values",
1467 "key_uniqueness_units": "non_null_values",
1468 "outlier_units": "tested_values",
1469 "key_column_source": "declared",
1470 });
1471 assert_eq!(document["metric_semantics"], recorded);
1472 assert_eq!(summary_value(&report)["metric_semantics"], recorded);
1473
1474 document.as_object_mut().unwrap().remove("metric_semantics");
1475 let legacy: ProfileReport = serde_json::from_value(document).unwrap();
1476 assert_eq!(legacy.metric_semantics, None);
1477 assert!(
1478 serde_json::to_value(&legacy)
1479 .unwrap()
1480 .get("metric_semantics")
1481 .is_none()
1482 );
1483 assert!(summary_value(&legacy).get("metric_semantics").is_none());
1484 }
1485
1486 #[test]
1487 fn malformed_metric_semantics_fail_to_decode() {
1488 for value in [
1489 serde_json::Value::Null,
1490 json!("unicode_scalar"),
1491 json!({"text_length_unit": "utf8_byte"}),
1492 json!({"text_length_unit": null}),
1493 json!({"null_tokens": "empty_null_nan"}),
1494 json!({"null_tokens": null}),
1495 json!({"pattern_match_count": "regex"}),
1496 json!({"pattern_match_count": null}),
1497 json!({"shape_moments": "population"}),
1498 json!({"shape_moments": null}),
1499 json!({"range_rule_count": "rule_failures"}),
1500 json!({"range_rule_count": null}),
1501 json!({"key_uniqueness_units": "rows"}),
1502 json!({"key_uniqueness_units": null}),
1503 json!({"outlier_units": "numeric_values"}),
1504 json!({"outlier_units": null}),
1505 json!({"key_column_source": "name_heuristic"}),
1506 json!({"key_column_source": null}),
1507 ] {
1508 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1509 document["metric_semantics"] = value.clone();
1510 assert!(
1511 serde_json::from_value::<ProfileReport>(document).is_err(),
1512 "{value} decoded"
1513 );
1514 }
1515 }
1516
1517 #[test]
1519 fn unknown_metric_definitions_are_ignored() {
1520 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1521 document["metric_semantics"]["grapheme_policy"] = json!("extended");
1522 let restored: ProfileReport = serde_json::from_value(document).unwrap();
1523 assert_eq!(restored.metric_semantics, Some(MetricSemantics::current()));
1524 }
1525
1526 #[test]
1527 fn quality_status_survives_a_json_roundtrip() {
1528 let report = report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1529 error: "Metrics calculation failed: no data columns found".to_string(),
1530 });
1531
1532 let json = serde_json::to_string(&report).unwrap();
1533 let restored: ProfileReport = serde_json::from_str(&json).unwrap();
1534
1535 assert_eq!(restored.quality_status, report.quality_status);
1536 assert!(restored.quality.is_none());
1537 }
1538
1539 #[test]
1542 fn serialized_failure_and_skip_differ() {
1543 let failed = serde_json::to_value(report_without_quality().with_quality_status(
1544 QualityAnalysisStatus::Failed {
1545 error: "boom".to_string(),
1546 },
1547 ))
1548 .unwrap();
1549 let skipped = serde_json::to_value(
1550 report_without_quality().with_quality_status(QualityAnalysisStatus::NotRequested),
1551 )
1552 .unwrap();
1553
1554 assert_eq!(
1555 failed.get("quality_status"),
1556 Some(&json!({"state": "failed", "error": "boom"}))
1557 );
1558 assert_eq!(
1559 skipped.get("quality_status"),
1560 Some(&json!({"state": "not_requested"}))
1561 );
1562 assert!(failed.get("quality").is_none());
1563 assert!(skipped.get("quality").is_none());
1564 }
1565
1566 #[test]
1569 fn documents_without_the_field_read_back_honestly() {
1570 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1571 document.as_object_mut().unwrap().remove("quality_status");
1572
1573 let restored: ProfileReport = serde_json::from_value(document.clone()).unwrap();
1574 assert_eq!(restored.quality_status, QualityAnalysisStatus::Unrecorded);
1575
1576 document.as_object_mut().unwrap().insert(
1577 "quality".to_string(),
1578 serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1579 );
1580 let restored: ProfileReport = serde_json::from_value(document).unwrap();
1581 assert_eq!(restored.quality_status, QualityAnalysisStatus::Computed);
1582 }
1583
1584 #[test]
1585 fn test_profile_report_json_roundtrip() {
1586 let report = ProfileReport::new(
1587 DataSource::File {
1588 path: "test.csv".to_string(),
1589 format: FileFormat::Csv,
1590 size_bytes: 1024,
1591 modified_at: None,
1592 parquet_metadata: None,
1593 },
1594 vec![],
1595 ExecutionMetadata::new(100, 5, 50),
1596 Some(QualityAssessment::exact(QualityMetrics::empty())),
1597 );
1598
1599 let json = serde_json::to_string(&report).unwrap();
1600 let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1601
1602 assert_eq!(deserialized.id, report.id);
1603 assert_eq!(deserialized.timestamp, report.timestamp);
1604 assert_eq!(deserialized.source_identifier(), "test.csv");
1605 assert_eq!(deserialized.execution.rows_processed, 100);
1606 assert!(deserialized.quality.is_some());
1607 assert_eq!(deserialized.schema_version, REPORT_SCHEMA_VERSION);
1608 }
1609
1610 #[test]
1613 fn summary_names_its_score_weights_including_defaults() {
1614 let summary_quality = |weights| {
1615 let mut metrics = QualityMetrics::empty();
1616 metrics.score_weights = weights;
1617 let report = ProfileReport::new(
1618 DataSource::File {
1619 path: "test.csv".to_string(),
1620 format: FileFormat::Csv,
1621 size_bytes: 1024,
1622 modified_at: None,
1623 parquet_metadata: None,
1624 },
1625 vec![],
1626 ExecutionMetadata::new(100, 5, 50),
1627 Some(QualityAssessment::exact(metrics)),
1628 );
1629 let summary: serde_json::Value =
1630 serde_json::from_str(&report.summary_json().unwrap()).unwrap();
1631 summary["quality"].clone()
1632 };
1633
1634 let custom = QualityScoreWeights {
1635 completeness: 1.0,
1636 consistency: 0.0,
1637 uniqueness: 0.0,
1638 accuracy: 0.0,
1639 timeliness: 0.0,
1640 validity: 0.0,
1641 precision: 3.0,
1642 };
1643 assert_eq!(
1644 summary_quality(custom)["score_weights"],
1645 serde_json::to_value(custom).unwrap()
1646 );
1647 assert_eq!(
1650 summary_quality(QualityScoreWeights::default())["score_weights"],
1651 serde_json::to_value(QualityScoreWeights::default()).unwrap()
1652 );
1653 }
1654
1655 #[test]
1656 fn test_serialized_report_carries_schema_version() {
1657 let report = ProfileReport::new(
1658 DataSource::File {
1659 path: "test.csv".to_string(),
1660 format: FileFormat::Csv,
1661 size_bytes: 1024,
1662 modified_at: None,
1663 parquet_metadata: None,
1664 },
1665 vec![],
1666 ExecutionMetadata::new(100, 5, 50),
1667 None,
1668 );
1669
1670 let value = serde_json::to_value(&report).unwrap();
1671 assert_eq!(
1672 value.get("schema_version").and_then(|v| v.as_u64()),
1673 Some(u64::from(REPORT_SCHEMA_VERSION))
1674 );
1675 }
1676
1677 #[test]
1678 fn test_profile_report_without_quality() {
1679 let report = ProfileReport::new(
1680 DataSource::File {
1681 path: "test.csv".to_string(),
1682 format: FileFormat::Csv,
1683 size_bytes: 1024,
1684 modified_at: None,
1685 parquet_metadata: None,
1686 },
1687 vec![],
1688 ExecutionMetadata::new(100, 5, 50),
1689 None,
1690 );
1691
1692 let json = serde_json::to_string(&report).unwrap();
1693 let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1694
1695 assert!(deserialized.quality.is_none());
1696 assert_eq!(deserialized.execution.rows_processed, 100);
1697 }
1698
1699 #[test]
1700 fn test_profile_report_deserializes_legacy_quality_metrics() {
1701 let json = json!({
1702 "id": "legacy-report",
1703 "timestamp": "2026-05-22T10:00:00Z",
1704 "data_source": {
1705 "type": "file",
1706 "path": "test.csv",
1707 "format": "csv",
1708 "size_bytes": 42
1709 },
1710 "column_profiles": [],
1711 "scan_info": {
1712 "rows_processed": 10,
1713 "columns_detected": 2,
1714 "scan_time_ms": 5,
1715 "error_count": 0,
1716 "source_exhausted": true,
1717 "sampling_applied": false
1718 },
1719 "data_quality_metrics": {
1720 "completeness": {
1721 "missing_values_ratio": 0.0,
1722 "complete_records_ratio": 100.0,
1723 "null_columns": []
1724 }
1725 }
1726 });
1727
1728 let report: ProfileReport = serde_json::from_value(json).unwrap();
1729
1730 assert_eq!(report.id, "legacy-report");
1731 assert_eq!(report.schema_version, 0);
1734 assert_eq!(report.execution.rows_processed, 10);
1735 assert!(report.quality_score().is_none());
1738 let quality = report
1739 .quality
1740 .expect("expected legacy quality to deserialize");
1741 assert!(matches!(quality.confidence, MetricConfidence::NotAssessed));
1744 let completeness = quality
1745 .metrics
1746 .completeness
1747 .as_ref()
1748 .expect("legacy completeness facts should deserialize");
1749 assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1750 assert!(quality.metrics.assessed_dimensions().is_empty());
1751 }
1752
1753 #[test]
1757 fn a_canonical_quality_without_confidence_keeps_its_metrics() {
1758 let mut document = current_document();
1759 document["quality"] = json!({
1760 "metrics": {
1761 "completeness": {
1762 "missing_values_ratio": 0.0,
1763 "complete_records_ratio": 100.0,
1764 "null_columns": []
1765 }
1766 }
1767 });
1768
1769 let report: ProfileReport = serde_json::from_value(document).unwrap();
1770
1771 let quality = report.quality.expect("the assessment survives");
1772 let completeness = quality
1773 .metrics
1774 .completeness
1775 .as_ref()
1776 .expect("the metrics survive");
1777 assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1778 assert!(!matches!(quality.confidence, MetricConfidence::Exact));
1779 }
1780
1781 #[test]
1784 fn a_canonical_quality_without_metrics_is_malformed() {
1785 let mut document = current_document();
1786 document["quality"] = json!({ "confidence": "Exact" });
1787
1788 assert!(serde_json::from_value::<ProfileReport>(document).is_err());
1789 }
1790
1791 fn current_document() -> serde_json::Value {
1792 json!({
1793 "schema_version": REPORT_SCHEMA_VERSION,
1794 "id": "current-report",
1795 "timestamp": "2026-07-16T10:00:00Z",
1796 "data_source": {
1797 "type": "file",
1798 "path": "test.csv",
1799 "format": "csv",
1800 "size_bytes": 42
1801 },
1802 "column_profiles": [],
1803 "execution": {
1804 "rows_processed": 10,
1805 "columns_detected": 2,
1806 "scan_time_ms": 5,
1807 "error_count": 0,
1808 "source_exhausted": true,
1809 "sampling_applied": false
1810 }
1811 })
1812 }
1813
1814 #[test]
1815 fn test_additive_fields_from_newer_writer_are_ignored() {
1816 let mut json = current_document();
1819 json["a_future_additive_field"] = json!({"anything": true});
1820 json["column_profiles"] = json!([]);
1821
1822 let report: ProfileReport = serde_json::from_value(json).unwrap();
1823 assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
1824 assert_eq!(report.id, "current-report");
1825 }
1826
1827 #[test]
1828 fn test_unsupported_future_schema_version_fails_explicitly() {
1829 let mut json = current_document();
1830 json["schema_version"] = json!(REPORT_SCHEMA_VERSION + 1);
1831
1832 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1833 let msg = err.to_string();
1834 assert!(
1835 msg.contains("schema version") && msg.contains("upgrade dataprof"),
1836 "expected an actionable schema-version error, got: {msg}"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_version_error_wins_over_structural_errors() {
1842 let json_text = format!(
1846 r#"{{"column_profiles": "not-an-array", "schema_version": {}}}"#,
1847 REPORT_SCHEMA_VERSION + 1
1848 );
1849 let err = serde_json::from_str::<ProfileReport>(&json_text).unwrap_err();
1850 let msg = err.to_string();
1851 assert!(
1852 msg.contains("upgrade dataprof"),
1853 "expected the schema-version error to win, got: {msg}"
1854 );
1855 }
1856
1857 #[test]
1858 fn test_null_schema_version_is_malformed_not_legacy() {
1859 let mut json = current_document();
1860 json["schema_version"] = json!(null);
1861
1862 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1863 let msg = err.to_string();
1864 assert!(
1865 msg.contains("schema_version must be a non-negative integer"),
1866 "expected explicit rejection of null schema_version, got: {msg}"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_non_integer_schema_version_is_rejected() {
1872 for bad in [json!("1"), json!(1.5), json!(-1), json!(true)] {
1873 let mut json = current_document();
1874 json["schema_version"] = bad.clone();
1875 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1876 assert!(
1877 err.to_string().contains("non-negative integer"),
1878 "expected rejection of {bad}, got: {err}"
1879 );
1880 }
1881 }
1882}