Skip to main content

pgdrift_core/
drift.rs

1use crate::stats::FieldStats;
2use crate::types::JsonType;
3use serde::Serialize;
4use std::collections::HashMap;
5
6/// Severity level for drift issues
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
8pub enum Severity {
9    Info,
10    Warning,
11    Critical, // Add more if we need to
12}
13
14impl std::fmt::Display for Severity {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            Severity::Info => write!(f, "Info"),
18            Severity::Warning => write!(f, "Warning"),
19            Severity::Critical => write!(f, "Critical"),
20        }
21    }
22}
23
24/// Types of drift issues that we can detect
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub enum DriftIssue {
27    /// Field appears with multiple types, minority type excee=ds threshodl
28    TypeInconsistency {
29        path: String,
30        types: HashMap<JsonType, TypeDistribution>,
31        minority_percentage: f64,
32    },
33    /// Field appear in very few samples (< 10% threshold)
34    GhostKey {
35        path: String,
36        density: f64,
37        occurunces: u64,
38        total_samples: u64,
39    },
40    /// Optional field with moderate presence (10-80%)
41    SparseField {
42        path: String,
43        density: f64,
44        occurrences: u64,
45        total_samples: u64,
46    },
47    /// Expected high density key with unexpected gaps (80-95%)
48    MissingKey {
49        path: String,
50        density: f64,
51        expected_occurrences: u64,
52        actual_occurrences: u64,
53    },
54    /// Schema changes detected - versions or naming inconsistency
55    SchemaEvolution {
56        path: String,
57        pattern: EvolutionPattern,
58    },
59}
60
61/// Type distribution
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct TypeDistribution {
64    pub json_type: JsonType,
65    pub count: u64,
66    pub percentage: f64,
67}
68
69/// Evolution pattern for existing schema
70#[derive(Debug, Clone, PartialEq, Serialize)]
71pub enum EvolutionPattern {
72    /// Version markers
73    VersionMarker { marker_path: String },
74    /// Deprecated or legacy naming patters
75    DeprecatedNaming { old_path: String, new_path: String },
76    /// Mutually exclusive field
77    MutuallyExclusive { paths: Vec<String> },
78}
79
80impl DriftIssue {
81    /// Get the severity of the issues
82    pub fn severity(&self) -> Severity {
83        match self {
84            DriftIssue::TypeInconsistency {
85                minority_percentage,
86                ..
87            } => {
88                if *minority_percentage >= 10.0 {
89                    Severity::Critical
90                } else if *minority_percentage >= 5.0 {
91                    Severity::Warning
92                } else {
93                    Severity::Info
94                }
95            }
96            DriftIssue::MissingKey { density, .. } => {
97                if *density < 0.90 {
98                    Severity::Critical
99                } else if *density < 0.95 {
100                    Severity::Warning
101                } else {
102                    Severity::Info
103                }
104            }
105            DriftIssue::GhostKey { .. } => Severity::Info,
106            DriftIssue::SparseField { .. } => Severity::Info,
107            DriftIssue::SchemaEvolution { .. } => Severity::Warning,
108        }
109    }
110
111    /// Get the path of the issue
112    pub fn path(&self) -> &str {
113        match self {
114            DriftIssue::TypeInconsistency { path, .. } => path,
115            DriftIssue::GhostKey { path, .. } => path,
116            DriftIssue::SparseField { path, .. } => path,
117            DriftIssue::MissingKey { path, .. } => path,
118            DriftIssue::SchemaEvolution { path, .. } => path,
119        }
120    }
121
122    /// Get description of the issue
123    pub fn description(&self) -> String {
124        match self {
125            DriftIssue::TypeInconsistency {
126                types,
127                minority_percentage,
128                ..
129            } => {
130                let mut type_list: Vec<_> = types.values().collect();
131                type_list.sort_by(|a, b| b.percentage.partial_cmp(&a.percentage).unwrap());
132                let type_sts: Vec<String> = type_list
133                    .iter()
134                    .map(|td| format!("{}:{:.1}", td.json_type, td.percentage))
135                    .collect();
136                format!(
137                    "Type inconsistency (minority: {:.1}%: {}",
138                    minority_percentage,
139                    type_sts.join(", ")
140                )
141            }
142            DriftIssue::GhostKey {
143                density,
144                occurunces,
145                total_samples,
146                ..
147            } => {
148                format!(
149                    "Ghost key: {:.2}% present ({}/{} samples)",
150                    density * 100.0,
151                    occurunces,
152                    total_samples
153                )
154            }
155            DriftIssue::SparseField {
156                density,
157                occurrences,
158                total_samples,
159                ..
160            } => {
161                format!(
162                    "Sparse field: {:.2}% present ({}/{} samples)",
163                    density * 100.0,
164                    occurrences,
165                    total_samples
166                )
167            }
168            DriftIssue::MissingKey {
169                density,
170                actual_occurrences,
171                expected_occurrences,
172                ..
173            } => {
174                let missing_count = expected_occurrences - actual_occurrences;
175                let missing_percentage = (1.0 - density) * 100.0;
176                format!(
177                    "Missing key: {:.2}% missing ({}/{} samples missing field)",
178                    missing_percentage, missing_count, expected_occurrences
179                )
180            }
181
182            DriftIssue::SchemaEvolution { pattern, .. } => match pattern {
183                EvolutionPattern::VersionMarker { marker_path } => {
184                    format!("Schema evolution: version marker '{}'", marker_path)
185                }
186                EvolutionPattern::DeprecatedNaming { old_path, new_path } => {
187                    format!(
188                        "Schema evolution: deprecated field '{}' → '{}'",
189                        old_path, new_path
190                    )
191                }
192                EvolutionPattern::MutuallyExclusive { paths } => {
193                    format!(
194                        "Schema evolution: mutually exclusive fields: {}",
195                        paths.join(", ")
196                    )
197                }
198            },
199        }
200    }
201}
202
203/// Configuration for drift detection thresholds
204///
205/// TODO: Make these thresholds configurable via:
206/// 1. Config file (~/.config/pgdrift/config.toml or .pgdrift.toml in project root)
207/// 2. Command-line arguments (--ghost-key-threshold, --sparse-field-threshold, etc.)
208///
209/// Current hardcoded thresholds can cause boundary issues when field density
210/// is exactly at a threshold (e.g., 80%). User-configurable thresholds would allow
211/// tuning for specific use cases and avoid false positives/negatives.
212#[derive(Debug, Clone)]
213pub struct DriftConfig {
214    /// Minimum percentage for minority type to trigger type inconsistency (default: 5.0%)
215    pub type_inconsistency_threshold: f64,
216    /// Maximum density for ghost key detection (default: 0.10 = 10%)
217    pub ghost_key_threshold: f64,
218    /// Maximum density for sparse field detection (default: 0.80 = 80%)
219    pub sparse_field_threshold: f64,
220    /// Minimum density for missing key detection (default: 0.95 = 95%)
221    pub missing_key_threshold: f64,
222    /// Whether to detect schema evolution patterns
223    pub detect_schema_evolution: bool,
224}
225
226impl Default for DriftConfig {
227    fn default() -> Self {
228        Self {
229            type_inconsistency_threshold: 5.0,
230            ghost_key_threshold: 0.10,
231            sparse_field_threshold: 0.80,
232            missing_key_threshold: 0.95,
233            detect_schema_evolution: true,
234        }
235    }
236}
237
238/// Analyze field statistics and detect drift
239pub fn detect_drift(stats: &HashMap<String, FieldStats>, config: &DriftConfig) -> Vec<DriftIssue> {
240    let mut issues = Vec::new();
241    for field_stats in stats.values() {
242        if let Some(issue) = detect_type_inconsistency(field_stats, config) {
243            issues.push(issue);
244        }
245        if let Some(issue) = detect_ghost_key(field_stats, config) {
246            issues.push(issue);
247        }
248        if let Some(issue) = detect_sparse_field(field_stats, config) {
249            issues.push(issue);
250        }
251        if let Some(issue) = detect_missing_key(field_stats, config) {
252            issues.push(issue);
253        }
254    }
255
256    if config.detect_schema_evolution {
257        issues.extend(detect_schema_evolution(stats));
258    }
259
260    issues.sort_by(|a, b| {
261        b.severity()
262            .cmp(&a.severity())
263            .then_with(|| a.path().cmp(b.path()))
264    });
265
266    issues
267}
268
269/// Detect type inconsistency: field appears as multiple types
270fn detect_type_inconsistency(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
271    // Need at least 2 different types
272    if stats.types.len() < 2 {
273        return None;
274    }
275
276    let total_typed: u64 = stats.types.values().sum();
277    if total_typed == 0 {
278        return None;
279    }
280
281    // Calculate type distributions
282    let mut type_distributions: HashMap<JsonType, TypeDistribution> = HashMap::new();
283    for (json_type, count) in &stats.types {
284        let percentage = (*count as f64 / total_typed as f64) * 100.0;
285        type_distributions.insert(
286            *json_type,
287            TypeDistribution {
288                json_type: *json_type,
289                count: *count,
290                percentage,
291            },
292        );
293    }
294
295    // Find minority types (not the most common)
296    let max_count = stats.types.values().max().copied().unwrap_or(0);
297    let minority_count: u64 = stats.types.values().filter(|&&c| c != max_count).sum();
298
299    let minority_percentage = (minority_count as f64 / total_typed as f64) * 100.0;
300
301    // Only report if minority exceeds threshold
302    if minority_percentage >= config.type_inconsistency_threshold {
303        Some(DriftIssue::TypeInconsistency {
304            path: stats.path.clone(),
305            types: type_distributions,
306            minority_percentage,
307        })
308    } else {
309        None
310    }
311}
312
313/// Detect ghost keys: fields with very low density
314fn detect_ghost_key(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
315    if stats.density <= config.ghost_key_threshold && stats.density > 0.0 {
316        Some(DriftIssue::GhostKey {
317            path: stats.path.clone(),
318            density: stats.density,
319            occurunces: stats.occurrences,
320            total_samples: stats.total_samples,
321        })
322    } else {
323        None
324    }
325}
326
327/// Detect sparse fields: optional fields with moderate presence (10-80%)
328fn detect_sparse_field(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
329    // Fields between ghost threshold and sparse threshold
330    if stats.density > config.ghost_key_threshold && stats.density <= config.sparse_field_threshold
331    {
332        Some(DriftIssue::SparseField {
333            path: stats.path.clone(),
334            density: stats.density,
335            occurrences: stats.occurrences,
336            total_samples: stats.total_samples,
337        })
338    } else {
339        None
340    }
341}
342
343/// Detect missing keys: expected fields (high density) with gaps (80-95%)
344fn detect_missing_key(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
345    // Only check fields that should be present (density between sparse and missing thresholds)
346    if stats.density > config.sparse_field_threshold && stats.density < config.missing_key_threshold
347    {
348        let expected_occurrences = stats.total_samples;
349        Some(DriftIssue::MissingKey {
350            path: stats.path.clone(),
351            density: stats.density,
352            expected_occurrences,
353            actual_occurrences: stats.occurrences,
354        })
355    } else {
356        None
357    }
358}
359
360/// Detect schema evolution patterns
361fn detect_schema_evolution(stats: &HashMap<String, FieldStats>) -> Vec<DriftIssue> {
362    // TODO: probably need to rework this. Too many assumptions, maybe not even relevent
363    let mut issues = Vec::new();
364
365    // Check for version markers
366    let version_markers = ["version", "schema_version", "v", "api_version"];
367    for path in stats.keys() {
368        let path_segments: Vec<&str> = path.split('.').collect();
369        for marker in &version_markers {
370            // Check if any path segment exactly matches the version marker
371            if path_segments
372                .iter()
373                .any(|seg| seg.to_lowercase() == *marker)
374            {
375                issues.push(DriftIssue::SchemaEvolution {
376                    path: path.clone(),
377                    pattern: EvolutionPattern::VersionMarker {
378                        marker_path: path.clone(),
379                    },
380                });
381                break;
382            }
383        }
384    }
385
386    // Check for deprecated/legacy naming
387    let deprecated_prefixes = ["old_", "legacy_", "deprecated_"];
388    for path in stats.keys() {
389        for prefix in &deprecated_prefixes {
390            if path.to_lowercase().starts_with(prefix) {
391                // Try to find the new field (without prefix)
392                let potential_new = path.replacen(prefix, "", 1);
393                if stats.contains_key(&potential_new) {
394                    issues.push(DriftIssue::SchemaEvolution {
395                        path: path.clone(),
396                        pattern: EvolutionPattern::DeprecatedNaming {
397                            old_path: path.clone(),
398                            new_path: potential_new,
399                        },
400                    });
401                }
402                break;
403            }
404        }
405    }
406
407    // Check for mutually exclusive fields (same base path, different variants)
408    // This is a more complex pattern - simplified version here
409    // Making a lot of assuptions at this point
410    let mut path_families: HashMap<String, Vec<String>> = HashMap::new();
411    for path in stats.keys() {
412        // Group by base path (e.g., "user.address" for "user.address_v1" and "user.address_v2")
413        if let Some(base) = path.rsplit_once('_').map(|(base, _)| base) {
414            path_families
415                .entry(base.to_string())
416                .or_default()
417                .push(path.clone());
418        }
419    }
420
421    for (base, paths) in path_families {
422        if paths.len() >= 2 {
423            // Check if they're mutually exclusive (sum of densities ~= max individual density)
424            let densities: Vec<f64> = paths
425                .iter()
426                .filter_map(|p| stats.get(p).map(|s| s.density))
427                .collect();
428            if densities.len() >= 2 {
429                let sum: f64 = densities.iter().sum();
430                let max = densities.iter().copied().fold(0.0f64, f64::max);
431                // If sum is close to max, they're likely mutually exclusive
432                if (sum - max).abs() < 0.1 {
433                    issues.push(DriftIssue::SchemaEvolution {
434                        path: base,
435                        pattern: EvolutionPattern::MutuallyExclusive { paths },
436                    });
437                }
438            }
439        }
440    }
441
442    issues
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn create_field_stats(
450        path: &str,
451        occurrences: u64,
452        total_samples: u64,
453        types: Vec<(JsonType, u64)>,
454    ) -> FieldStats {
455        let mut stats = FieldStats::new(path.to_string(), 1);
456        stats.occurrences = occurrences;
457        stats.total_samples = total_samples;
458        stats.density = occurrences as f64 / total_samples as f64;
459        for (json_type, count) in types {
460            stats.types.insert(json_type, count);
461        }
462        stats
463    }
464
465    #[test]
466    fn test_severity_ordering() {
467        assert!(Severity::Critical > Severity::Warning);
468        assert!(Severity::Warning > Severity::Info);
469    }
470
471    #[test]
472    fn test_type_inconsistency_detection() {
473        let config = DriftConfig::default();
474
475        // 92% string, 8% number - should trigger (>5% minority)
476        let stats = create_field_stats(
477            "user.age",
478            100,
479            100,
480            vec![(JsonType::String, 92), (JsonType::Number, 8)],
481        );
482
483        let issue = detect_type_inconsistency(&stats, &config);
484        assert!(issue.is_some());
485
486        let issue = issue.unwrap();
487        assert_eq!(issue.severity(), Severity::Warning);
488        assert!(matches!(issue, DriftIssue::TypeInconsistency { .. }));
489
490        if let DriftIssue::TypeInconsistency {
491            minority_percentage,
492            types,
493            ..
494        } = issue
495        {
496            assert_eq!(minority_percentage, 8.0);
497            assert_eq!(types.len(), 2);
498        }
499    }
500
501    #[test]
502    fn test_type_inconsistency_critical_threshold() {
503        let config = DriftConfig::default();
504
505        // 85% string, 15% number - should be Critical (≥10% minority)
506        let stats = create_field_stats(
507            "user.age",
508            100,
509            100,
510            vec![(JsonType::String, 85), (JsonType::Number, 15)],
511        );
512
513        let issue = detect_type_inconsistency(&stats, &config).unwrap();
514        assert_eq!(issue.severity(), Severity::Critical);
515    }
516
517    #[test]
518    fn test_type_inconsistency_below_threshold() {
519        let config = DriftConfig::default();
520
521        // 98% string, 2% number - should NOT trigger (<5% minority)
522        let stats = create_field_stats(
523            "user.name",
524            100,
525            100,
526            vec![(JsonType::String, 98), (JsonType::Number, 2)],
527        );
528
529        let issue = detect_type_inconsistency(&stats, &config);
530        assert!(issue.is_none());
531    }
532
533    #[test]
534    fn test_ghost_key_detection() {
535        let config = DriftConfig::default();
536
537        // 0.5% density - ghost key
538        let stats = create_field_stats("billing.legacy_plan", 5, 1000, vec![(JsonType::String, 5)]);
539
540        let issue = detect_ghost_key(&stats, &config);
541        assert!(issue.is_some());
542
543        let issue = issue.unwrap();
544        assert!(matches!(issue, DriftIssue::GhostKey { .. }));
545        assert_eq!(issue.severity(), Severity::Info);
546
547        if let DriftIssue::GhostKey {
548            density,
549            occurunces,
550            total_samples,
551            ..
552        } = issue
553        {
554            assert_eq!(density, 0.005);
555            assert_eq!(occurunces, 5);
556            assert_eq!(total_samples, 1000);
557        }
558    }
559
560    #[test]
561    fn test_ghost_key_below_threshold() {
562        let config = DriftConfig::default();
563
564        // 15% density - NOT a ghost key (>10% threshold)
565        let stats =
566            create_field_stats("user.middle_name", 150, 1000, vec![(JsonType::String, 150)]);
567
568        let issue = detect_ghost_key(&stats, &config);
569        assert!(issue.is_none());
570    }
571
572    #[test]
573    fn test_missing_key_detection() {
574        let config = DriftConfig::default();
575
576        // 85% density - missing key (expected >95%)
577        let stats = create_field_stats("user.email", 850, 1000, vec![(JsonType::String, 850)]);
578
579        let issue = detect_missing_key(&stats, &config);
580        assert!(issue.is_some());
581
582        let issue = issue.unwrap();
583        assert!(matches!(issue, DriftIssue::MissingKey { .. }));
584        assert_eq!(issue.severity(), Severity::Critical);
585    }
586
587    #[test]
588    fn test_missing_key_warning_threshold() {
589        let config = DriftConfig::default();
590
591        // 92% density - missing key warning (90-95%)
592        let stats = create_field_stats("user.phone", 920, 1000, vec![(JsonType::String, 920)]);
593
594        let issue = detect_missing_key(&stats, &config);
595        assert!(issue.is_some());
596        assert_eq!(issue.unwrap().severity(), Severity::Warning);
597    }
598
599    #[test]
600    fn test_missing_key_above_threshold() {
601        let config = DriftConfig::default();
602
603        // 98% density - NOT a missing key (>95%)
604        let stats = create_field_stats("user.id", 980, 1000, vec![(JsonType::Number, 980)]);
605
606        let issue = detect_missing_key(&stats, &config);
607        assert!(issue.is_none());
608    }
609
610    #[test]
611    fn test_schema_evolution_version_marker() {
612        let mut stats = HashMap::new();
613        stats.insert(
614            "schema_version".to_string(),
615            create_field_stats("schema_version", 1000, 1000, vec![(JsonType::Number, 1000)]),
616        );
617
618        let issues = detect_schema_evolution(&stats);
619        assert_eq!(issues.len(), 1);
620        assert!(matches!(
621            issues[0],
622            DriftIssue::SchemaEvolution {
623                pattern: EvolutionPattern::VersionMarker { .. },
624                ..
625            }
626        ));
627    }
628
629    #[test]
630    fn test_schema_evolution_deprecated_naming() {
631        let mut stats = HashMap::new();
632        stats.insert(
633            "legacy_address".to_string(),
634            create_field_stats("legacy_address", 100, 1000, vec![(JsonType::String, 100)]),
635        );
636        stats.insert(
637            "address".to_string(),
638            create_field_stats("address", 900, 1000, vec![(JsonType::String, 900)]),
639        );
640
641        let issues = detect_schema_evolution(&stats);
642        assert!(!issues.is_empty());
643
644        let deprecated = issues.iter().find(|i| {
645            matches!(
646                i,
647                DriftIssue::SchemaEvolution {
648                    pattern: EvolutionPattern::DeprecatedNaming { .. },
649                    ..
650                }
651            )
652        });
653        assert!(deprecated.is_some());
654    }
655
656    #[test]
657    fn test_detect_drift_comprehensive() {
658        let mut stats = HashMap::new();
659
660        // Type inconsistency
661        stats.insert(
662            "user.age".to_string(),
663            create_field_stats(
664                "user.age",
665                100,
666                100,
667                vec![(JsonType::String, 92), (JsonType::Number, 8)],
668            ),
669        );
670
671        // Ghost key
672        stats.insert(
673            "billing.legacy_plan".to_string(),
674            create_field_stats("billing.legacy_plan", 5, 1000, vec![(JsonType::String, 5)]),
675        );
676
677        // Missing key
678        stats.insert(
679            "user.email".to_string(),
680            create_field_stats("user.email", 850, 1000, vec![(JsonType::String, 850)]),
681        );
682
683        // Version marker
684        stats.insert(
685            "version".to_string(),
686            create_field_stats("version", 1000, 1000, vec![(JsonType::Number, 1000)]),
687        );
688
689        let config = DriftConfig::default();
690        let issues = detect_drift(&stats, &config);
691
692        // Should find at least 4 issues (type inconsistency, ghost, missing, version)
693        assert!(issues.len() >= 4);
694
695        // Verify sorted by severity (Critical first)
696        let severities: Vec<Severity> = issues.iter().map(|i| i.severity()).collect();
697        let mut sorted_severities = severities.clone();
698        sorted_severities.sort_by(|a, b| b.cmp(a));
699        assert_eq!(severities, sorted_severities);
700    }
701
702    #[test]
703    fn test_drift_issue_description() {
704        let issue = DriftIssue::TypeInconsistency {
705            path: "user.age".to_string(),
706            types: {
707                let mut map = HashMap::new();
708                map.insert(
709                    JsonType::String,
710                    TypeDistribution {
711                        json_type: JsonType::String,
712                        count: 92,
713                        percentage: 92.0,
714                    },
715                );
716                map.insert(
717                    JsonType::Number,
718                    TypeDistribution {
719                        json_type: JsonType::Number,
720                        count: 8,
721                        percentage: 8.0,
722                    },
723                );
724                map
725            },
726            minority_percentage: 8.0,
727        };
728
729        let desc = issue.description();
730        assert!(desc.contains("Type inconsistency"));
731        assert!(desc.contains("8.0%"));
732    }
733
734    #[test]
735    fn test_custom_config_thresholds() {
736        let config = DriftConfig {
737            type_inconsistency_threshold: 10.0,
738            ghost_key_threshold: 0.005,
739            sparse_field_threshold: 0.70,
740            missing_key_threshold: 0.99,
741            detect_schema_evolution: false,
742        };
743
744        // 8% minority - should NOT trigger with 10% threshold
745        let stats = create_field_stats(
746            "user.age",
747            100,
748            100,
749            vec![(JsonType::String, 92), (JsonType::Number, 8)],
750        );
751
752        let issue = detect_type_inconsistency(&stats, &config);
753        assert!(issue.is_none());
754    }
755}