Skip to main content

dataprof_core/
quality.rs

1/// ISO 25012 quality dimensions that can be selectively requested.
2#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
3#[serde(rename_all = "lowercase")]
4pub enum QualityDimension {
5    Completeness,
6    Consistency,
7    Uniqueness,
8    Accuracy,
9    Timeliness,
10    Validity,
11    Precision,
12}
13
14impl QualityDimension {
15    /// All currently implemented dimensions.
16    pub fn all() -> Vec<Self> {
17        vec![
18            Self::Completeness,
19            Self::Consistency,
20            Self::Uniqueness,
21            Self::Accuracy,
22            Self::Timeliness,
23            Self::Validity,
24            Self::Precision,
25        ]
26    }
27}
28
29impl std::str::FromStr for QualityDimension {
30    type Err = String;
31
32    fn from_str(s: &str) -> Result<Self, Self::Err> {
33        match s.to_lowercase().as_str() {
34            "completeness" => Ok(Self::Completeness),
35            "consistency" => Ok(Self::Consistency),
36            "uniqueness" => Ok(Self::Uniqueness),
37            "accuracy" => Ok(Self::Accuracy),
38            "timeliness" => Ok(Self::Timeliness),
39            "validity" => Ok(Self::Validity),
40            "precision" => Ok(Self::Precision),
41            _ => Err(format!("Unknown quality dimension: {s}")),
42        }
43    }
44}
45
46impl std::fmt::Display for QualityDimension {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Completeness => write!(f, "completeness"),
50            Self::Consistency => write!(f, "consistency"),
51            Self::Uniqueness => write!(f, "uniqueness"),
52            Self::Accuracy => write!(f, "accuracy"),
53            Self::Timeliness => write!(f, "timeliness"),
54            Self::Validity => write!(f, "validity"),
55            Self::Precision => write!(f, "precision"),
56        }
57    }
58}
59
60/// What the range and sign rules of the accuracy score count, as recorded in
61/// a report's metric semantics.
62///
63/// Only the current definition has a value. Through 0.12 every rule failure
64/// counted, a value breaking two rules twice, and the failures were divided
65/// by the numeric values of every column, so clean columns diluted a broken
66/// one; those releases recorded no definition.
67#[derive(
68    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
69)]
70#[serde(rename_all = "snake_case")]
71pub enum RangeRuleCount {
72    /// Values breaking at least one rule, each counted once, over the values
73    /// of the columns a rule applies to.
74    ViolatingValues,
75}
76
77/// What `key_uniqueness` divides the key column's distinct values by, as
78/// recorded in a report's metric semantics.
79///
80/// Only the current definition has a value. Through 0.12 it divided by every
81/// row, nulls included, so a nullable key lost uniqueness for each null the
82/// completeness score already counted; those releases recorded no
83/// definition.
84#[derive(
85    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
86)]
87#[serde(rename_all = "snake_case")]
88pub enum KeyUniquenessUnits {
89    /// The key column's non-null values.
90    NonNullValues,
91}
92
93/// What `outlier_ratio` is measured over, as recorded in a report's metric
94/// semantics.
95///
96/// Only the current definition has a value. Through 0.12 a source in which no
97/// column reached `outlier_min_samples` reported `outlier_ratio` 0, and its
98/// numeric values alone made the accuracy dimension assessed; those releases
99/// recorded no definition.
100#[derive(
101    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
102)]
103#[serde(rename_all = "snake_case")]
104pub enum OutlierUnits {
105    /// The values of the columns that reached `outlier_min_samples`. With
106    /// none, the component is absent rather than 0.
107    TestedValues,
108}
109
110/// Which column `key_uniqueness` may measure, as recorded in a report's
111/// metric semantics.
112///
113/// Only the current definition has a value. Through 0.12, with no column
114/// declared, the first column whose name had an `id`, `key` or `pk` word was
115/// the key, so a foreign key listed first was scored as the table key and
116/// column order moved the score; those releases recorded no definition.
117#[derive(
118    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
119)]
120#[serde(rename_all = "snake_case")]
121pub enum KeyColumnSource {
122    /// The first `identifier_columns` entry the source has. With none
123    /// declared there is no key component.
124    Declared,
125}
126
127/// High-level categories of analysis that can be selectively enabled.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
129#[serde(rename_all = "lowercase")]
130pub enum MetricPack {
131    /// Column names, data types, null counts — always included.
132    Schema,
133    /// Numeric stats (min/max/mean/median/std_dev/quartiles), text lengths.
134    Statistics,
135    /// Regex pattern detection (email, phone, UUID, etc.).
136    Patterns,
137    /// ISO 25012 quality dimensions (completeness, consistency, etc.).
138    Quality,
139}
140
141impl MetricPack {
142    /// All available metric packs.
143    pub fn all() -> Vec<Self> {
144        vec![
145            Self::Schema,
146            Self::Statistics,
147            Self::Patterns,
148            Self::Quality,
149        ]
150    }
151
152    /// Whether statistics should be computed given the selected packs.
153    pub fn include_statistics(packs: Option<&[Self]>) -> bool {
154        match packs {
155            None => true,
156            Some(p) => p.contains(&Self::Statistics),
157        }
158    }
159
160    /// Whether pattern detection should run given the selected packs.
161    pub fn include_patterns(packs: Option<&[Self]>) -> bool {
162        match packs {
163            None => true,
164            Some(p) => p.contains(&Self::Patterns),
165        }
166    }
167
168    /// Whether quality metrics should be computed given the selected packs.
169    pub fn include_quality(packs: Option<&[Self]>) -> bool {
170        match packs {
171            None => true,
172            Some(p) => p.contains(&Self::Quality),
173        }
174    }
175
176    /// Resolve the packs to compute, folding in an explicit quality-dimension
177    /// selection.
178    ///
179    /// Requesting an empty set of dimensions (`Some(&[])`) says "assess no
180    /// dimension", which is the same statement as not selecting the quality
181    /// pack: nothing is analyzed, so the report carries no quality. Without
182    /// this, an empty selection produces a quality object whose dimensions are
183    /// all absent — an artifact that reads like a measured result.
184    ///
185    /// This is deliberately about what was *requested*. A caller that asks for
186    /// dimensions and gets none back (an empty dataset, no date column) was
187    /// still analyzed, and keeps a quality object reporting exactly that.
188    pub fn resolve_with_dimensions(
189        packs: Option<&[Self]>,
190        dimensions: Option<&[QualityDimension]>,
191    ) -> Option<Vec<Self>> {
192        if !dimensions.is_some_and(<[QualityDimension]>::is_empty) {
193            return packs.map(<[Self]>::to_vec);
194        }
195        // `None` packs means "all packs"; make that explicit before removing
196        // Quality, so the result still selects everything else.
197        let selected = packs.map_or_else(Self::all, <[Self]>::to_vec);
198        Some(
199            selected
200                .into_iter()
201                .filter(|pack| *pack != Self::Quality)
202                .collect(),
203        )
204    }
205}
206
207impl std::str::FromStr for MetricPack {
208    type Err = String;
209
210    fn from_str(s: &str) -> Result<Self, Self::Err> {
211        match s.to_lowercase().as_str() {
212            "schema" => Ok(Self::Schema),
213            "statistics" => Ok(Self::Statistics),
214            "patterns" => Ok(Self::Patterns),
215            "quality" => Ok(Self::Quality),
216            _ => Err(format!(
217                "Unknown metric pack: {s}. Valid packs: schema, statistics, patterns, quality"
218            )),
219        }
220    }
221}
222
223impl std::fmt::Display for MetricPack {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        match self {
226            Self::Schema => write!(f, "schema"),
227            Self::Statistics => write!(f, "statistics"),
228            Self::Patterns => write!(f, "patterns"),
229            Self::Quality => write!(f, "quality"),
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_metric_pack_include_helpers_none_means_all() {
240        assert!(MetricPack::include_statistics(None));
241        assert!(MetricPack::include_patterns(None));
242        assert!(MetricPack::include_quality(None));
243    }
244
245    #[test]
246    fn test_resolve_with_dimensions_empty_selection_drops_quality() {
247        // Requesting no dimension is the same statement as not asking for
248        // quality at all, so the pack must fall out and the report carry no
249        // quality object.
250        let resolved = MetricPack::resolve_with_dimensions(None, Some(&[])).unwrap();
251        assert!(!resolved.contains(&MetricPack::Quality));
252        assert!(!MetricPack::include_quality(Some(&resolved)));
253        // The other packs survive: only quality was deselected.
254        assert!(resolved.contains(&MetricPack::Schema));
255        assert!(resolved.contains(&MetricPack::Statistics));
256        assert!(resolved.contains(&MetricPack::Patterns));
257    }
258
259    #[test]
260    fn test_resolve_with_dimensions_empty_selection_keeps_explicit_packs() {
261        let packs = vec![MetricPack::Schema, MetricPack::Quality];
262        let resolved = MetricPack::resolve_with_dimensions(Some(&packs), Some(&[])).unwrap();
263        assert_eq!(resolved, vec![MetricPack::Schema]);
264    }
265
266    #[test]
267    fn test_resolve_with_dimensions_non_empty_selection_is_untouched() {
268        // A real selection says which dimensions to assess, not whether to.
269        let dims = vec![QualityDimension::Completeness];
270        assert_eq!(MetricPack::resolve_with_dimensions(None, Some(&dims)), None);
271        assert!(MetricPack::include_quality(None));
272
273        let packs = vec![MetricPack::Quality];
274        let resolved = MetricPack::resolve_with_dimensions(Some(&packs), Some(&dims)).unwrap();
275        assert!(MetricPack::include_quality(Some(&resolved)));
276    }
277
278    #[test]
279    fn test_resolve_with_dimensions_absent_selection_is_untouched() {
280        // `None` means "all dimensions", which is not the same as "none".
281        assert_eq!(MetricPack::resolve_with_dimensions(None, None), None);
282        let packs = vec![MetricPack::Quality];
283        assert_eq!(
284            MetricPack::resolve_with_dimensions(Some(&packs), None),
285            Some(packs)
286        );
287    }
288
289    #[test]
290    fn test_metric_pack_include_helpers_selective() {
291        let packs = vec![MetricPack::Schema, MetricPack::Quality];
292        assert!(!MetricPack::include_statistics(Some(&packs)));
293        assert!(!MetricPack::include_patterns(Some(&packs)));
294        assert!(MetricPack::include_quality(Some(&packs)));
295    }
296
297    #[test]
298    fn test_metric_pack_from_str() {
299        assert_eq!(
300            "statistics".parse::<MetricPack>().unwrap(),
301            MetricPack::Statistics
302        );
303        assert_eq!(
304            "QUALITY".parse::<MetricPack>().unwrap(),
305            MetricPack::Quality
306        );
307        assert!("invalid".parse::<MetricPack>().is_err());
308    }
309}