#![cfg(feature = "sqlite")]
use std::io::Write;
use dataprof::{
ColumnStats, DatabaseConfig, Locale, MetricPack, ProfileReport, Profiler, QualityDimension,
SemanticHints,
};
use tempfile::NamedTempFile;
const RECORDS: [(i64, &str, f64); 5] = [
(1, "20121", 10.5),
(2, "00184", 21.0),
(3, "10121", 33.5),
(4, "80132", 42.0),
(5, "50122", 55.5),
];
const QUERY: &str = "SELECT * FROM parity";
async fn sqlite_fixture() -> (tempfile::TempDir, String) {
use sqlx::sqlite::SqlitePoolOptions;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("parity.db");
std::fs::File::create(&db_path).unwrap();
let db_path = db_path.display().to_string();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect(&format!("sqlite://{db_path}"))
.await
.unwrap();
sqlx::query("CREATE TABLE parity (id INTEGER, cap TEXT, amount REAL)")
.execute(&pool)
.await
.unwrap();
for (id, cap, amount) in RECORDS {
sqlx::query("INSERT INTO parity (id, cap, amount) VALUES (?, ?, ?)")
.bind(id)
.bind(cap)
.bind(amount)
.execute(&pool)
.await
.unwrap();
}
pool.close().await;
(dir, db_path)
}
fn csv_fixture() -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(".csv").unwrap();
writeln!(file, "id,cap,amount").unwrap();
for (id, cap, amount) in RECORDS {
writeln!(file, "{id},{cap},{amount}").unwrap();
}
file.flush().unwrap();
file
}
fn sqlite_profiler(db_path: &str) -> Profiler {
Profiler::new().database(DatabaseConfig {
connection_string: db_path.to_string(),
load_credentials_from_env: false,
..Default::default()
})
}
fn pattern_names(report: &ProfileReport, column: &str) -> Option<Vec<String>> {
let profile = report
.column_profiles
.iter()
.find(|c| c.name == column)
.unwrap_or_else(|| panic!("column {column} missing from report"));
profile
.patterns
.as_ref()
.map(|patterns| patterns.iter().map(|p| p.name.clone()).collect())
}
#[tokio::test]
async fn schema_pack_omits_statistics_patterns_and_quality() {
let (_dir, db_path) = sqlite_fixture().await;
let report = sqlite_profiler(&db_path)
.metric_packs(vec![MetricPack::Schema])
.analyze_query(QUERY)
.await
.expect("query profiling should succeed");
assert!(
report.quality.is_none(),
"quality must be absent when the quality pack is deselected"
);
for profile in &report.column_profiles {
assert!(
matches!(profile.stats, ColumnStats::None),
"column {} kept statistics under metrics=[schema]",
profile.name
);
assert!(
profile.patterns.is_none(),
"column {} kept patterns under metrics=[schema]",
profile.name
);
}
assert_eq!(report.column_profiles.len(), 3);
}
#[tokio::test]
async fn empty_dimension_selection_yields_absent_quality() {
let (_dir, db_path) = sqlite_fixture().await;
let report = sqlite_profiler(&db_path)
.quality_dimensions(vec![])
.analyze_query(QUERY)
.await
.expect("query profiling should succeed");
assert!(
report.quality.is_none(),
"quality_dimensions=[] must mean 'not analyzed', not an empty assessment"
);
assert!(
report.quality_score().is_none(),
"a report with no quality assessment has no score"
);
assert!(
!matches!(
report
.column_profiles
.iter()
.find(|c| c.name == "amount")
.unwrap()
.stats,
ColumnStats::None
),
"statistics must survive an empty dimension selection"
);
}
#[tokio::test]
async fn a_narrowed_dimension_selection_still_reports_quality() {
let (_dir, db_path) = sqlite_fixture().await;
let report = sqlite_profiler(&db_path)
.quality_dimensions(vec![QualityDimension::Completeness])
.analyze_query(QUERY)
.await
.expect("query profiling should succeed");
assert!(
report.quality.is_some(),
"a narrowed dimension selection is still an analysis"
);
}
#[tokio::test]
async fn locale_reaches_pattern_detection_and_matches_csv() {
let (_dir, db_path) = sqlite_fixture().await;
let csv = csv_fixture();
let plain = sqlite_profiler(&db_path)
.analyze_query(QUERY)
.await
.expect("query profiling should succeed");
let localized = sqlite_profiler(&db_path)
.locale(Locale::It)
.analyze_query(QUERY)
.await
.expect("localized query profiling should succeed");
let plain_patterns = pattern_names(&plain, "cap").expect("patterns detected by default");
let localized_patterns =
pattern_names(&localized, "cap").expect("patterns detected by default");
assert!(
plain_patterns.iter().any(|p| p.contains("ZIP Code (US)")),
"without a locale the US pattern should still match, got {plain_patterns:?}"
);
assert!(
!localized_patterns
.iter()
.any(|p| p.contains("ZIP Code (US)")),
"locale=IT must suppress the US pattern, got {localized_patterns:?}"
);
let csv_localized = Profiler::new()
.locale(Locale::It)
.analyze_file(csv.path())
.expect("csv profiling should succeed");
assert_eq!(
pattern_names(&localized, "cap"),
pattern_names(&csv_localized, "cap"),
"a query and a CSV holding the same rows disagree on locale-ranked patterns"
);
}
#[tokio::test]
async fn no_quality_entry_point_keeps_statistics_and_patterns() {
let (_dir, db_path) = sqlite_fixture().await;
let report = sqlite_profiler(&db_path)
.locale(Locale::It)
.analyze_query_no_quality(QUERY)
.await
.expect("query profiling should succeed");
assert!(report.quality.is_none(), "this entry point skips quality");
let patterns = pattern_names(&report, "cap").expect("patterns still detected");
assert!(
!patterns.iter().any(|p| p.contains("ZIP Code (US)")),
"locale must still apply without the quality pack, got {patterns:?}"
);
assert!(
!matches!(
report
.column_profiles
.iter()
.find(|c| c.name == "amount")
.unwrap()
.stats,
ColumnStats::None
),
"statistics must survive skipping quality"
);
}
const UNOPENABLE_DB: &str = "/nonexistent/path/to.db";
#[tokio::test]
async fn semantic_hints_are_rejected_before_connecting() {
let profiler = Profiler::new()
.database(DatabaseConfig {
connection_string: UNOPENABLE_DB.to_string(),
load_credentials_from_env: false,
..Default::default()
})
.positive_columns(vec!["amount".to_string()]);
let error = profiler
.analyze_query(QUERY)
.await
.expect_err("semantic hints are unsupported for database profiling");
assert!(
error.to_string().contains("positive_columns"),
"expected the hint rejection, got: {error}"
);
}
#[tokio::test]
async fn the_database_entry_point_rejects_hints_itself() {
let options = dataprof::AnalysisOptions::default()
.with_semantic_hints(SemanticHints::new(vec!["amount".to_string()], vec![]));
let config = DatabaseConfig {
connection_string: UNOPENABLE_DB.to_string(),
load_credentials_from_env: false,
..Default::default()
};
let error = dataprof::analyze_database_with_options(config, QUERY, &options)
.await
.expect_err("semantic hints are unsupported for database profiling");
assert!(
error.to_string().contains("positive_columns"),
"expected the hint rejection, got: {error}"
);
}