1use dataprof_core::{
2 ColumnProfile, DataSource, ExecutionMetadata, QualityScoreWeights, SemanticHintBinding,
3 TextLengthUnit,
4};
5use dataprof_metrics::{
6 AccuracyMetrics, CompletenessMetrics, ConsistencyMetrics, MetricConfidence, PrecisionMetrics,
7 QualityAssessment, QualityMetrics, TimelinessMetrics, ValidityMetrics,
8};
9
10pub const REPORT_SCHEMA_VERSION: u32 = 1;
25
26#[derive(
34 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
35)]
36#[serde(tag = "state", rename_all = "snake_case")]
37pub enum QualityAnalysisStatus {
38 Computed,
40 NotRequested,
42 NoData,
45 WithheldByProjection,
51 Failed {
54 error: String,
56 },
57 Unrecorded,
61}
62
63#[derive(
76 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
77)]
78#[non_exhaustive]
79pub struct MetricSemantics {
80 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 deserialize_with = "present_value"
88 )]
89 #[schemars(with = "TextLengthUnit")]
90 pub text_length_unit: Option<TextLengthUnit>,
91}
92
93fn present_value<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
96where
97 D: serde::Deserializer<'de>,
98 T: serde::Deserialize<'de>,
99{
100 T::deserialize(deserializer).map(Some)
101}
102
103impl MetricSemantics {
104 pub fn current() -> Self {
106 Self {
107 text_length_unit: Some(TextLengthUnit::UnicodeScalar),
108 }
109 }
110}
111
112#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
119pub struct ProfileReport {
120 #[schemars(schema_with = "schema_version_schema")]
125 pub schema_version: u32,
126 pub id: String,
128 pub timestamp: String,
130 pub data_source: DataSource,
132 pub column_profiles: Vec<ColumnProfile>,
134 pub execution: ExecutionMetadata,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub quality: Option<QualityAssessment>,
139 pub quality_status: QualityAnalysisStatus,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub semantic_hint_bindings: Vec<SemanticHintBinding>,
162 #[serde(skip_serializing_if = "Option::is_none")]
169 #[schemars(with = "MetricSemantics")]
170 pub metric_semantics: Option<MetricSemantics>,
171}
172
173fn schema_version_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
174 schemars::json_schema!({
175 "type": "integer",
176 "const": REPORT_SCHEMA_VERSION,
177 "minimum": 0
178 })
179}
180
181#[allow(dead_code)]
189#[derive(serde::Serialize, schemars::JsonSchema)]
190#[serde(untagged)]
191#[schemars(title = "ProfileReport")]
192enum SerializedProfileReport {
193 Rust(Box<ProfileReport>),
194 Python(Box<PythonProfileReportDocument>),
195}
196
197#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
198struct PythonProfileReportDocument {
199 #[schemars(schema_with = "schema_version_schema")]
200 schema_version: u32,
201 source: String,
202 source_type: PythonSourceType,
203 execution: PythonExecutionDocument,
204 columns: Vec<PythonColumnDocument>,
205 quality: Option<PythonQualityDocument>,
206 quality_status: QualityAnalysisStatus,
207 #[serde(skip_serializing_if = "Vec::is_empty")]
208 semantic_hint_bindings: Vec<SemanticHintBinding>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 #[schemars(with = "MetricSemantics")]
211 metric_semantics: Option<MetricSemantics>,
212}
213
214#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
215#[serde(rename_all = "lowercase")]
216enum PythonSourceType {
217 File,
218 Query,
219 Dataframe,
220 Stream,
221 Bytes,
222}
223
224#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
225struct PythonExecutionDocument {
226 engine: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 recovery_events: Option<Vec<dataprof_core::execution::RecoveryEvent>>,
229 rows_processed: usize,
230 columns_detected: usize,
231 scan_time_ms: u128,
232 source_exhausted: bool,
233 truncation_reason: Option<String>,
234 bytes_consumed: Option<u64>,
235 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
236 throughput_rows_sec: Option<f64>,
237 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
238 memory_peak_mb: Option<f64>,
239 error_count: usize,
240 ragged_row_count: usize,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 unterminated_quote: Option<bool>,
243 sampling_applied: bool,
244 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
245 sampling_ratio: Option<f64>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 sampled_row_ranges: Option<Vec<[u64; 2]>>,
248}
249
250#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
251struct PythonColumnDocument {
252 name: String,
253 data_type: PythonDataType,
254 total_count: usize,
255 null_count: usize,
256 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
257 null_percentage: Option<f64>,
258 unique_count: Option<usize>,
259 unique_count_is_approximate: Option<bool>,
260 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4_opt")]
261 uniqueness_ratio: Option<f64>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 invalid_count: Option<usize>,
264 #[serde(skip_serializing_if = "Option::is_none")]
270 type_homogeneity: Option<dataprof_core::TypeHomogeneity>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 locale_number_count: Option<usize>,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 stats: Option<PythonColumnStatsDocument>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 patterns: Option<Vec<PythonPatternDocument>>,
277}
278
279#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
280#[serde(rename_all = "lowercase")]
281enum PythonDataType {
282 String,
283 Identifier,
284 Integer,
285 Float,
286 Date,
287 Boolean,
288 Nested,
289}
290
291#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
292struct PythonColumnStatsDocument {
293 #[serde(skip_serializing_if = "Option::is_none")]
294 min: Option<f64>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 max: Option<f64>,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 mean: Option<f64>,
299 #[serde(
302 default,
303 deserialize_with = "deserialize_present_nullable_stat",
304 skip_serializing_if = "Option::is_none"
305 )]
306 #[schemars(with = "Option<f64>")]
307 std_dev: Option<Option<f64>>,
308 #[serde(
309 default,
310 deserialize_with = "deserialize_present_nullable_stat",
311 skip_serializing_if = "Option::is_none"
312 )]
313 #[schemars(with = "Option<f64>")]
314 variance: Option<Option<f64>>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 median: Option<f64>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 mode: Option<f64>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 skewness: Option<f64>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 kurtosis: Option<f64>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 coefficient_of_variation: Option<f64>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 quartiles: Option<dataprof_core::Quartiles>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 is_approximate: Option<bool>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 outlier_count: Option<usize>,
331 #[serde(skip_serializing_if = "Option::is_none")]
336 min_length: Option<usize>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 max_length: Option<usize>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 avg_length: Option<f64>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 true_count: Option<usize>,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 false_count: Option<usize>,
347 #[serde(skip_serializing_if = "Option::is_none")]
348 true_ratio: Option<f64>,
349}
350
351fn deserialize_present_nullable_stat<'de, D>(
352 deserializer: D,
353) -> Result<Option<Option<f64>>, D::Error>
354where
355 D: serde::Deserializer<'de>,
356{
357 serde::Deserialize::deserialize(deserializer).map(Some)
358}
359
360#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
361struct PythonPatternDocument {
362 name: String,
363 regex: String,
364 match_count: usize,
365 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
366 match_percentage: f64,
367 category: dataprof_core::PatternCategory,
368 #[serde(serialize_with = "dataprof_core::serde_helpers::round_4")]
369 confidence: f64,
370}
371
372#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
373struct PythonQualityDocument {
374 overall_score: Option<f64>,
377 assessed_dimensions: Vec<PythonQualityDimension>,
378 dimension_scores: std::collections::BTreeMap<PythonQualityDimension, Option<f64>>,
379 low_sample_warning: bool,
380 #[serde(skip_serializing_if = "Option::is_none")]
386 sampled_dimensions: Option<Vec<String>>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
391 score_bounds: Option<dataprof_metrics::ScoreBounds>,
392 #[serde(default, skip_serializing_if = "QualityScoreWeights::is_default")]
397 score_weights: QualityScoreWeights,
398 #[serde(skip_serializing_if = "Option::is_none")]
399 completeness: Option<CompletenessMetrics>,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 consistency: Option<ConsistencyMetrics>,
402 #[serde(skip_serializing_if = "Option::is_none")]
403 uniqueness: Option<PythonUniquenessDocument>,
404 #[serde(skip_serializing_if = "Option::is_none")]
405 accuracy: Option<AccuracyMetrics>,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 timeliness: Option<TimelinessMetrics>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 validity: Option<ValidityMetrics>,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 precision: Option<PrecisionMetrics>,
412}
413
414#[derive(
415 Debug,
416 Clone,
417 PartialEq,
418 Eq,
419 PartialOrd,
420 Ord,
421 Hash,
422 serde::Serialize,
423 serde::Deserialize,
424 schemars::JsonSchema,
425)]
426#[serde(rename_all = "lowercase")]
427enum PythonQualityDimension {
428 Completeness,
429 Consistency,
430 Uniqueness,
431 Accuracy,
432 Timeliness,
433 Validity,
434 Precision,
435}
436
437#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
438struct PythonUniquenessDocument {
439 duplicate_rows: usize,
440 key_uniqueness: f64,
441 high_cardinality_warning: bool,
442 rows_checked: usize,
443 key_column: Option<String>,
444 duplicate_rows_approximate: bool,
445}
446
447impl PythonProfileReportDocument {
448 fn from_report(report: &ProfileReport) -> serde_json::Result<Self> {
449 use dataprof_core::{ColumnStats, DataType, TruncationReason};
450
451 let columns = report
454 .column_profiles
455 .iter()
456 .map(|column| {
457 let stats = match &column.stats {
458 ColumnStats::Numeric(stats) => Some(serde_json::to_value(stats)?),
459 ColumnStats::Text(stats) if column.total_count > 0 => {
460 Some(serde_json::to_value(stats)?)
461 }
462 ColumnStats::Boolean(stats) => Some(serde_json::to_value(stats)?),
463 _ => None,
464 }
465 .map(serde_json::from_value)
466 .transpose()?;
467 Ok(PythonColumnDocument {
468 name: column.name.clone(),
469 data_type: match column.data_type {
470 DataType::String => PythonDataType::String,
471 DataType::Identifier => PythonDataType::Identifier,
472 DataType::Integer => PythonDataType::Integer,
473 DataType::Float => PythonDataType::Float,
474 DataType::Date => PythonDataType::Date,
475 DataType::Boolean => PythonDataType::Boolean,
476 DataType::Nested => PythonDataType::Nested,
477 },
478 total_count: column.total_count,
479 null_count: column.null_count,
480 null_percentage: (column.total_count > 0)
481 .then(|| column.null_count as f64 / column.total_count as f64 * 100.0),
482 unique_count: column.unique_count,
483 unique_count_is_approximate: column.unique_count_is_approximate,
484 uniqueness_ratio: column
485 .unique_count
486 .filter(|_| column.total_count > 0)
487 .map(|count| count as f64 / column.total_count as f64),
488 invalid_count: column.invalid_count,
489 type_homogeneity: column.type_homogeneity,
490 locale_number_count: column.locale_number_count,
491 stats,
492 patterns: column.patterns.as_ref().map(|patterns| {
493 patterns
494 .iter()
495 .map(|pattern| PythonPatternDocument {
496 name: pattern.name.clone(),
497 regex: pattern.regex.clone(),
498 match_count: pattern.match_count,
499 match_percentage: pattern.match_percentage,
500 category: pattern.category.clone(),
501 confidence: pattern.confidence,
502 })
503 .collect()
504 }),
505 })
506 })
507 .collect::<serde_json::Result<Vec<_>>>()?;
508
509 let mut execution = serde_json::to_value(&report.execution)?;
510 execution["truncation_reason"] =
511 serde_json::to_value(report.execution.truncation_reason.as_ref().map(|reason| {
512 match reason {
513 TruncationReason::MaxRows(n) => format!("max_rows({n})"),
514 TruncationReason::MaxBytes(n) => format!("max_bytes({n})"),
515 TruncationReason::MemoryPressure => "memory_pressure".to_string(),
516 TruncationReason::StopCondition(s) => format!("stop_condition({s})"),
517 TruncationReason::StreamClosed => "stream_closed".to_string(),
518 TruncationReason::Timeout => "timeout".to_string(),
519 }
520 }))?;
521
522 let quality = report
523 .quality
524 .as_ref()
525 .map(|assessment| {
526 let metrics = &assessment.metrics;
527 let mut document = serde_json::to_value(metrics)?;
528 let scores = serde_json::to_value(assessment.scores())?;
529 document["overall_score"] = scores["overall_score"].clone();
530 let known: Vec<String> = dataprof_core::QualityDimension::all()
534 .into_iter()
535 .map(|dimension| dimension.to_string())
536 .collect();
537 document["dimension_scores"] = scores["dimension_scores"]
538 .as_object()
539 .expect("dimension scores serialize as an object")
540 .iter()
541 .filter(|(name, _)| known.contains(name))
542 .map(|(name, score)| (name.clone(), score.clone()))
543 .collect();
544 document["assessed_dimensions"] =
545 serde_json::to_value(metrics.assessed_dimensions())?;
546 document["low_sample_warning"] =
547 serde_json::Value::Bool(metrics.low_sample_warning);
548 if let Some(sampled) = assessment.sampled_dimensions() {
549 document["sampled_dimensions"] = serde_json::to_value(sampled)?;
550 }
551 if let Some(bounds) = assessment.score_bounds() {
552 document["score_bounds"] = serde_json::to_value(bounds)?;
553 }
554 if let Some(uniqueness) = document.get_mut("uniqueness") {
556 uniqueness["duplicate_rows_approximate"] = serde_json::Value::Bool(
557 metrics
558 .uniqueness
559 .as_ref()
560 .expect("serialized uniqueness is present")
561 .duplicate_rows_approximate,
562 );
563 }
564 serde_json::from_value(document)
565 })
566 .transpose()?;
567
568 Ok(Self {
569 schema_version: report.schema_version,
570 source: report.data_source.identifier(),
571 source_type: match report.data_source {
572 DataSource::File { .. } => PythonSourceType::File,
573 DataSource::Query { .. } => PythonSourceType::Query,
574 DataSource::DataFrame { .. } => PythonSourceType::Dataframe,
575 DataSource::Stream { .. } => PythonSourceType::Stream,
576 DataSource::Bytes { .. } => PythonSourceType::Bytes,
577 },
578 execution: serde_json::from_value(execution)?,
579 columns,
580 quality,
581 quality_status: report.quality_status.clone(),
582 semantic_hint_bindings: report.semantic_hint_bindings.clone(),
583 metric_semantics: report.metric_semantics.clone(),
584 })
585 }
586}
587
588#[doc(hidden)]
594pub fn profile_report_schema_document() -> serde_json::Value {
595 let settings = schemars::generate::SchemaSettings::draft2020_12().for_serialize();
596 let schema = settings
597 .into_generator()
598 .into_root_schema_for::<SerializedProfileReport>();
599 let mut document =
600 serde_json::to_value(schema).expect("a Schemars schema must serialize to a JSON document");
601 let object = document
602 .as_object_mut()
603 .expect("a root Schemars schema must be a JSON object");
604 object.insert(
605 "$id".to_string(),
606 serde_json::Value::String(format!(
607 "https://andreabozzo.github.io/dataprof/schema/profile-report.v{REPORT_SCHEMA_VERSION}.schema.json"
608 )),
609 );
610 make_compatibility_defaults_optional(&mut document);
611 allow_additive_properties(&mut document);
612 canonicalize_key_order(&mut document);
613 document
614}
615
616fn canonicalize_key_order(value: &mut serde_json::Value) {
625 match value {
626 serde_json::Value::Object(object) => {
627 for child in object.values_mut() {
628 canonicalize_key_order(child);
629 }
630 let mut sorted: Vec<(String, serde_json::Value)> =
631 std::mem::take(object).into_iter().collect();
632 sorted.sort_by(|(left, _), (right, _)| left.cmp(right));
633 object.extend(sorted);
634 }
635 serde_json::Value::Array(items) => {
636 for item in items {
637 canonicalize_key_order(item);
638 }
639 }
640 _ => {}
641 }
642}
643
644fn make_compatibility_defaults_optional(document: &mut serde_json::Value) {
651 for (pointer, field) in [
652 ("/$defs/ExecutionMetadata/required", "ragged_row_count"),
653 ("/$defs/ProfileReport/required", "quality_status"),
654 (
655 "/$defs/PythonProfileReportDocument/required",
656 "quality_status",
657 ),
658 (
659 "/$defs/PythonQualityDocument/required",
660 "sampled_dimensions",
661 ),
662 ("/$defs/QualityAssessment/required", "scores"),
663 ("/$defs/QualityAssessment/required", "score_bounds"),
665 ] {
666 if let Some(required) = document
667 .pointer_mut(pointer)
668 .and_then(serde_json::Value::as_array_mut)
669 {
670 required.retain(|declared| declared.as_str() != Some(field));
671 }
672 }
673}
674
675fn allow_additive_properties(value: &mut serde_json::Value) {
676 match value {
677 serde_json::Value::Object(object) => {
678 if object.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
679 object.remove("additionalProperties");
680 }
681 for child in object.values_mut() {
682 allow_additive_properties(child);
683 }
684 }
685 serde_json::Value::Array(items) => {
686 for item in items {
687 allow_additive_properties(item);
688 }
689 }
690 _ => {}
691 }
692}
693
694impl ProfileReport {
695 pub fn to_json(&self) -> serde_json::Result<String> {
698 let mut document = serde_json::to_value(self)?;
699 canonicalize_key_order(&mut document);
700 serde_json::to_string_pretty(&document)
701 }
702
703 #[doc(hidden)]
706 pub fn summary_json(&self) -> serde_json::Result<String> {
707 let summary = PythonProfileReportDocument::from_report(self)?;
708 serde_json::to_string(&summary)
709 }
710
711 pub fn new(
713 data_source: DataSource,
714 column_profiles: Vec<ColumnProfile>,
715 execution: ExecutionMetadata,
716 quality: Option<QualityAssessment>,
717 ) -> Self {
718 Self {
719 schema_version: REPORT_SCHEMA_VERSION,
720 id: uuid::Uuid::new_v4().to_string(),
721 timestamp: chrono::Utc::now().to_rfc3339(),
722 data_source,
723 column_profiles,
724 execution,
725 quality_status: match quality {
726 Some(_) => QualityAnalysisStatus::Computed,
727 None => QualityAnalysisStatus::NotRequested,
731 },
732 quality,
733 semantic_hint_bindings: Vec::new(),
734 metric_semantics: Some(MetricSemantics::current()),
735 }
736 }
737
738 pub(crate) fn with_quality_status(mut self, status: QualityAnalysisStatus) -> Self {
748 self.quality_status = status;
749 self
750 }
751
752 pub fn with_semantic_hint_bindings(mut self, bindings: Vec<SemanticHintBinding>) -> Self {
754 self.semantic_hint_bindings = bindings;
755 self
756 }
757
758 pub fn with_id(mut self, id: impl Into<String>) -> Self {
760 self.id = id.into();
761 self
762 }
763
764 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
766 self.timestamp = timestamp.into();
767 self
768 }
769
770 pub fn quality_score(&self) -> Option<f64> {
775 self.quality.as_ref().and_then(|q| q.score())
776 }
777
778 pub fn source_identifier(&self) -> String {
780 self.data_source.identifier()
781 }
782
783 fn check_quality_pairing(self) -> Result<Self, String> {
794 let assessed = self.quality.is_some();
795 let claims_computed = matches!(self.quality_status, QualityAnalysisStatus::Computed);
796 if claims_computed == assessed {
797 return Ok(self);
798 }
799 Err(if assessed {
800 format!(
801 "report carries a quality assessment but reports quality_status \
802 {:?}; only `computed` may accompany an assessment",
803 self.quality_status
804 )
805 } else {
806 "report reports quality_status `computed` but carries no quality assessment".to_string()
807 })
808 }
809}
810
811#[derive(serde::Deserialize)]
816struct ProfileReportFields {
817 #[serde(default)]
818 schema_version: u32,
819 id: String,
820 timestamp: String,
821 data_source: DataSource,
822 column_profiles: Vec<ColumnProfile>,
823 #[serde(alias = "scan_info")]
824 execution: ExecutionMetadata,
825 #[serde(
826 alias = "data_quality_metrics",
827 default,
828 deserialize_with = "deserialize_quality_compat"
829 )]
830 quality: Option<QualityAssessment>,
831 #[serde(default)]
832 quality_status: Option<QualityAnalysisStatus>,
833 #[serde(default)]
834 semantic_hint_bindings: Vec<SemanticHintBinding>,
835 #[serde(default)]
836 metric_semantics: Option<MetricSemantics>,
837}
838
839impl From<ProfileReportFields> for ProfileReport {
840 fn from(fields: ProfileReportFields) -> Self {
841 Self {
842 schema_version: fields.schema_version,
843 id: fields.id,
844 timestamp: fields.timestamp,
845 data_source: fields.data_source,
846 column_profiles: fields.column_profiles,
847 execution: fields.execution,
848 quality_status: fields.quality_status.unwrap_or({
851 if fields.quality.is_some() {
852 QualityAnalysisStatus::Computed
853 } else {
854 QualityAnalysisStatus::Unrecorded
855 }
856 }),
857 quality: fields.quality,
858 semantic_hint_bindings: fields.semantic_hint_bindings,
859 metric_semantics: fields.metric_semantics,
860 }
861 }
862}
863
864impl<'de> serde::Deserialize<'de> for ProfileReport {
870 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
871 where
872 D: serde::Deserializer<'de>,
873 {
874 use serde::de::Error;
875
876 let value = serde_json::Value::deserialize(deserializer)?;
877 match value.get("schema_version") {
878 None => {}
880 Some(serde_json::Value::Number(n)) if n.as_u64().is_some() => {
881 let version = n.as_u64().unwrap_or_default();
882 if version > u64::from(REPORT_SCHEMA_VERSION) {
883 return Err(D::Error::custom(format!(
884 "report schema version {version} is newer than the latest supported \
885 version {REPORT_SCHEMA_VERSION}; upgrade dataprof to read this report"
886 )));
887 }
888 }
889 Some(other) => {
891 return Err(D::Error::custom(format!(
892 "report schema_version must be a non-negative integer, got {other}"
893 )));
894 }
895 }
896 if value.get("quality_status") == Some(&serde_json::Value::Null) {
901 return Err(D::Error::custom(
902 "report quality_status must be an object; an explicit null is malformed, \
903 not a document written before the field existed",
904 ));
905 }
906 if value.get("metric_semantics") == Some(&serde_json::Value::Null) {
909 return Err(D::Error::custom(
910 "report metric_semantics must be an object; an explicit null is malformed, not a document written before the field existed",
911 ));
912 }
913 let report = ProfileReportFields::deserialize(value)
914 .map(ProfileReport::from)
915 .map_err(D::Error::custom)?;
916 report.check_quality_pairing().map_err(D::Error::custom)
917 }
918}
919
920fn deserialize_quality_compat<'de, D>(
923 deserializer: D,
924) -> Result<Option<QualityAssessment>, D::Error>
925where
926 D: serde::Deserializer<'de>,
927{
928 use serde::Deserialize;
929
930 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
931 match value {
932 None => Ok(None),
933 Some(v) => {
934 if v.get("metrics").is_some() && v.get("confidence").is_some() {
935 let assessment: QualityAssessment =
936 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
937 Ok(Some(assessment))
938 } else {
939 let metrics: QualityMetrics =
940 serde_json::from_value(v).map_err(serde::de::Error::custom)?;
941 Ok(Some(QualityAssessment::new(
946 metrics,
947 MetricConfidence::Unrecorded,
948 )))
949 }
950 }
951 }
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use dataprof_core::FileFormat;
958 use dataprof_metrics::MetricConfidence;
959 use serde_json::json;
960
961 fn report_without_quality() -> ProfileReport {
962 ProfileReport::new(
963 DataSource::File {
964 path: "test.csv".to_string(),
965 format: FileFormat::Csv,
966 size_bytes: 1024,
967 modified_at: None,
968 parquet_metadata: None,
969 },
970 vec![],
971 ExecutionMetadata::new(100, 5, 50),
972 None,
973 )
974 }
975
976 #[test]
980 fn every_status_has_a_stable_wire_name() {
981 let cases = [
982 (QualityAnalysisStatus::Computed, "computed"),
983 (QualityAnalysisStatus::NotRequested, "not_requested"),
984 (QualityAnalysisStatus::NoData, "no_data"),
985 (
986 QualityAnalysisStatus::WithheldByProjection,
987 "withheld_by_projection",
988 ),
989 (
990 QualityAnalysisStatus::Failed {
991 error: "boom".to_string(),
992 },
993 "failed",
994 ),
995 (QualityAnalysisStatus::Unrecorded, "unrecorded"),
996 ];
997
998 for (status, name) in cases {
999 let value = serde_json::to_value(&status).unwrap();
1000 assert_eq!(value.get("state"), Some(&json!(name)), "{status:?}");
1001 let restored: QualityAnalysisStatus = serde_json::from_value(value).unwrap();
1002 assert_eq!(restored, status);
1003 }
1004 }
1005
1006 #[test]
1010 fn contradictory_status_and_assessment_fail_to_decode() {
1011 let with_assessment =
1012 report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1013 error: "boom".to_string(),
1014 });
1015 let mut document = serde_json::to_value(&with_assessment).unwrap();
1016 document.as_object_mut().unwrap().insert(
1017 "quality".to_string(),
1018 serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1019 );
1020 let error = serde_json::from_value::<ProfileReport>(document)
1021 .expect_err("an assessment under a `failed` status is not a readable report");
1022 assert!(
1023 error.to_string().contains("only `computed`"),
1024 "unhelpful error: {error}"
1025 );
1026
1027 let mut document = serde_json::to_value(
1028 report_without_quality().with_quality_status(QualityAnalysisStatus::Computed),
1029 )
1030 .unwrap();
1031 document.as_object_mut().unwrap().remove("quality");
1032 let error = serde_json::from_value::<ProfileReport>(document)
1033 .expect_err("`computed` without an assessment is not a readable report");
1034 assert!(
1035 error.to_string().contains("carries no quality assessment"),
1036 "unhelpful error: {error}"
1037 );
1038 }
1039
1040 #[test]
1044 fn an_explicit_null_status_is_malformed_not_legacy() {
1045 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1046 document
1047 .as_object_mut()
1048 .unwrap()
1049 .insert("quality_status".to_string(), serde_json::Value::Null);
1050
1051 let error = serde_json::from_value::<ProfileReport>(document)
1052 .expect_err("an explicit null is not a readable status");
1053 assert!(
1054 error.to_string().contains("explicit null is malformed"),
1055 "unhelpful error: {error}"
1056 );
1057 }
1058
1059 fn summary_value(report: &ProfileReport) -> serde_json::Value {
1060 serde_json::from_str(&report.summary_json().unwrap()).unwrap()
1061 }
1062
1063 #[test]
1067 fn metric_semantics_are_recorded_and_absence_survives_a_roundtrip() {
1068 let report = report_without_quality();
1069 assert_eq!(report.metric_semantics, Some(MetricSemantics::current()));
1070 let mut document = serde_json::to_value(&report).unwrap();
1071 let recorded = json!({"text_length_unit": "unicode_scalar"});
1072 assert_eq!(document["metric_semantics"], recorded);
1073 assert_eq!(summary_value(&report)["metric_semantics"], recorded);
1074
1075 document.as_object_mut().unwrap().remove("metric_semantics");
1076 let legacy: ProfileReport = serde_json::from_value(document).unwrap();
1077 assert_eq!(legacy.metric_semantics, None);
1078 assert!(
1079 serde_json::to_value(&legacy)
1080 .unwrap()
1081 .get("metric_semantics")
1082 .is_none()
1083 );
1084 assert!(summary_value(&legacy).get("metric_semantics").is_none());
1085 }
1086
1087 #[test]
1088 fn malformed_metric_semantics_fail_to_decode() {
1089 for value in [
1090 serde_json::Value::Null,
1091 json!("unicode_scalar"),
1092 json!({"text_length_unit": "utf8_byte"}),
1093 json!({"text_length_unit": null}),
1094 ] {
1095 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1096 document["metric_semantics"] = value.clone();
1097 assert!(
1098 serde_json::from_value::<ProfileReport>(document).is_err(),
1099 "{value} decoded"
1100 );
1101 }
1102 }
1103
1104 #[test]
1106 fn unknown_metric_definitions_are_ignored() {
1107 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1108 document["metric_semantics"]["grapheme_policy"] = json!("extended");
1109 let restored: ProfileReport = serde_json::from_value(document).unwrap();
1110 assert_eq!(restored.metric_semantics, Some(MetricSemantics::current()));
1111 }
1112
1113 #[test]
1114 fn quality_status_survives_a_json_roundtrip() {
1115 let report = report_without_quality().with_quality_status(QualityAnalysisStatus::Failed {
1116 error: "Metrics calculation failed: no data columns found".to_string(),
1117 });
1118
1119 let json = serde_json::to_string(&report).unwrap();
1120 let restored: ProfileReport = serde_json::from_str(&json).unwrap();
1121
1122 assert_eq!(restored.quality_status, report.quality_status);
1123 assert!(restored.quality.is_none());
1124 }
1125
1126 #[test]
1129 fn serialized_failure_and_skip_differ() {
1130 let failed = serde_json::to_value(report_without_quality().with_quality_status(
1131 QualityAnalysisStatus::Failed {
1132 error: "boom".to_string(),
1133 },
1134 ))
1135 .unwrap();
1136 let skipped = serde_json::to_value(
1137 report_without_quality().with_quality_status(QualityAnalysisStatus::NotRequested),
1138 )
1139 .unwrap();
1140
1141 assert_eq!(
1142 failed.get("quality_status"),
1143 Some(&json!({"state": "failed", "error": "boom"}))
1144 );
1145 assert_eq!(
1146 skipped.get("quality_status"),
1147 Some(&json!({"state": "not_requested"}))
1148 );
1149 assert!(failed.get("quality").is_none());
1150 assert!(skipped.get("quality").is_none());
1151 }
1152
1153 #[test]
1156 fn documents_without_the_field_read_back_honestly() {
1157 let mut document = serde_json::to_value(report_without_quality()).unwrap();
1158 document.as_object_mut().unwrap().remove("quality_status");
1159
1160 let restored: ProfileReport = serde_json::from_value(document.clone()).unwrap();
1161 assert_eq!(restored.quality_status, QualityAnalysisStatus::Unrecorded);
1162
1163 document.as_object_mut().unwrap().insert(
1164 "quality".to_string(),
1165 serde_json::to_value(QualityAssessment::exact(QualityMetrics::empty())).unwrap(),
1166 );
1167 let restored: ProfileReport = serde_json::from_value(document).unwrap();
1168 assert_eq!(restored.quality_status, QualityAnalysisStatus::Computed);
1169 }
1170
1171 #[test]
1172 fn test_profile_report_json_roundtrip() {
1173 let report = ProfileReport::new(
1174 DataSource::File {
1175 path: "test.csv".to_string(),
1176 format: FileFormat::Csv,
1177 size_bytes: 1024,
1178 modified_at: None,
1179 parquet_metadata: None,
1180 },
1181 vec![],
1182 ExecutionMetadata::new(100, 5, 50),
1183 Some(QualityAssessment::exact(QualityMetrics::empty())),
1184 );
1185
1186 let json = serde_json::to_string(&report).unwrap();
1187 let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1188
1189 assert_eq!(deserialized.id, report.id);
1190 assert_eq!(deserialized.timestamp, report.timestamp);
1191 assert_eq!(deserialized.source_identifier(), "test.csv");
1192 assert_eq!(deserialized.execution.rows_processed, 100);
1193 assert!(deserialized.quality.is_some());
1194 assert_eq!(deserialized.schema_version, REPORT_SCHEMA_VERSION);
1195 }
1196
1197 #[test]
1200 fn summary_keeps_custom_score_weights_and_omits_defaults() {
1201 let summary_quality = |weights| {
1202 let mut metrics = QualityMetrics::empty();
1203 metrics.score_weights = weights;
1204 let report = ProfileReport::new(
1205 DataSource::File {
1206 path: "test.csv".to_string(),
1207 format: FileFormat::Csv,
1208 size_bytes: 1024,
1209 modified_at: None,
1210 parquet_metadata: None,
1211 },
1212 vec![],
1213 ExecutionMetadata::new(100, 5, 50),
1214 Some(QualityAssessment::exact(metrics)),
1215 );
1216 let summary: serde_json::Value =
1217 serde_json::from_str(&report.summary_json().unwrap()).unwrap();
1218 summary["quality"].clone()
1219 };
1220
1221 let custom = QualityScoreWeights {
1222 completeness: 1.0,
1223 consistency: 0.0,
1224 uniqueness: 0.0,
1225 accuracy: 0.0,
1226 timeliness: 0.0,
1227 validity: 0.0,
1228 precision: 3.0,
1229 };
1230 assert_eq!(
1231 summary_quality(custom)["score_weights"],
1232 serde_json::to_value(custom).unwrap()
1233 );
1234 assert!(
1236 summary_quality(QualityScoreWeights::default())
1237 .get("score_weights")
1238 .is_none()
1239 );
1240 }
1241
1242 #[test]
1243 fn test_serialized_report_carries_schema_version() {
1244 let report = ProfileReport::new(
1245 DataSource::File {
1246 path: "test.csv".to_string(),
1247 format: FileFormat::Csv,
1248 size_bytes: 1024,
1249 modified_at: None,
1250 parquet_metadata: None,
1251 },
1252 vec![],
1253 ExecutionMetadata::new(100, 5, 50),
1254 None,
1255 );
1256
1257 let value = serde_json::to_value(&report).unwrap();
1258 assert_eq!(
1259 value.get("schema_version").and_then(|v| v.as_u64()),
1260 Some(u64::from(REPORT_SCHEMA_VERSION))
1261 );
1262 }
1263
1264 #[test]
1265 fn test_profile_report_without_quality() {
1266 let report = ProfileReport::new(
1267 DataSource::File {
1268 path: "test.csv".to_string(),
1269 format: FileFormat::Csv,
1270 size_bytes: 1024,
1271 modified_at: None,
1272 parquet_metadata: None,
1273 },
1274 vec![],
1275 ExecutionMetadata::new(100, 5, 50),
1276 None,
1277 );
1278
1279 let json = serde_json::to_string(&report).unwrap();
1280 let deserialized: ProfileReport = serde_json::from_str(&json).unwrap();
1281
1282 assert!(deserialized.quality.is_none());
1283 assert_eq!(deserialized.execution.rows_processed, 100);
1284 }
1285
1286 #[test]
1287 fn test_profile_report_deserializes_legacy_quality_metrics() {
1288 let json = json!({
1289 "id": "legacy-report",
1290 "timestamp": "2026-05-22T10:00:00Z",
1291 "data_source": {
1292 "type": "file",
1293 "path": "test.csv",
1294 "format": "csv",
1295 "size_bytes": 42
1296 },
1297 "column_profiles": [],
1298 "scan_info": {
1299 "rows_processed": 10,
1300 "columns_detected": 2,
1301 "scan_time_ms": 5,
1302 "error_count": 0,
1303 "source_exhausted": true,
1304 "sampling_applied": false
1305 },
1306 "data_quality_metrics": {
1307 "completeness": {
1308 "missing_values_ratio": 0.0,
1309 "complete_records_ratio": 100.0,
1310 "null_columns": []
1311 }
1312 }
1313 });
1314
1315 let report: ProfileReport = serde_json::from_value(json).unwrap();
1316
1317 assert_eq!(report.id, "legacy-report");
1318 assert_eq!(report.schema_version, 0);
1321 assert_eq!(report.execution.rows_processed, 10);
1322 assert!(report.quality_score().is_none());
1325 let quality = report
1326 .quality
1327 .expect("expected legacy quality to deserialize");
1328 assert!(matches!(quality.confidence, MetricConfidence::NotAssessed));
1331 let completeness = quality
1332 .metrics
1333 .completeness
1334 .as_ref()
1335 .expect("legacy completeness facts should deserialize");
1336 assert!((completeness.complete_records_ratio - 100.0).abs() < 0.01);
1337 assert!(quality.metrics.assessed_dimensions().is_empty());
1338 }
1339
1340 fn current_document() -> serde_json::Value {
1341 json!({
1342 "schema_version": REPORT_SCHEMA_VERSION,
1343 "id": "current-report",
1344 "timestamp": "2026-07-16T10:00:00Z",
1345 "data_source": {
1346 "type": "file",
1347 "path": "test.csv",
1348 "format": "csv",
1349 "size_bytes": 42
1350 },
1351 "column_profiles": [],
1352 "execution": {
1353 "rows_processed": 10,
1354 "columns_detected": 2,
1355 "scan_time_ms": 5,
1356 "error_count": 0,
1357 "source_exhausted": true,
1358 "sampling_applied": false
1359 }
1360 })
1361 }
1362
1363 #[test]
1364 fn test_additive_fields_from_newer_writer_are_ignored() {
1365 let mut json = current_document();
1368 json["a_future_additive_field"] = json!({"anything": true});
1369 json["column_profiles"] = json!([]);
1370
1371 let report: ProfileReport = serde_json::from_value(json).unwrap();
1372 assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
1373 assert_eq!(report.id, "current-report");
1374 }
1375
1376 #[test]
1377 fn test_unsupported_future_schema_version_fails_explicitly() {
1378 let mut json = current_document();
1379 json["schema_version"] = json!(REPORT_SCHEMA_VERSION + 1);
1380
1381 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1382 let msg = err.to_string();
1383 assert!(
1384 msg.contains("schema version") && msg.contains("upgrade dataprof"),
1385 "expected an actionable schema-version error, got: {msg}"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_version_error_wins_over_structural_errors() {
1391 let json_text = format!(
1395 r#"{{"column_profiles": "not-an-array", "schema_version": {}}}"#,
1396 REPORT_SCHEMA_VERSION + 1
1397 );
1398 let err = serde_json::from_str::<ProfileReport>(&json_text).unwrap_err();
1399 let msg = err.to_string();
1400 assert!(
1401 msg.contains("upgrade dataprof"),
1402 "expected the schema-version error to win, got: {msg}"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_null_schema_version_is_malformed_not_legacy() {
1408 let mut json = current_document();
1409 json["schema_version"] = json!(null);
1410
1411 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1412 let msg = err.to_string();
1413 assert!(
1414 msg.contains("schema_version must be a non-negative integer"),
1415 "expected explicit rejection of null schema_version, got: {msg}"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_non_integer_schema_version_is_rejected() {
1421 for bad in [json!("1"), json!(1.5), json!(-1), json!(true)] {
1422 let mut json = current_document();
1423 json["schema_version"] = bad.clone();
1424 let err = serde_json::from_value::<ProfileReport>(json).unwrap_err();
1425 assert!(
1426 err.to_string().contains("non-negative integer"),
1427 "expected rejection of {bad}, got: {err}"
1428 );
1429 }
1430 }
1431}