Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions crates/sqlquack-syntax-coverage/src/cli/_helpers/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ pub(crate) fn snapshot_command(
say("FAILED, a snapshot of a tree with problems claims nothing");
return Err(ExitCode::FAILURE);
}
print(true, &snapshot(&check, &coverage.file_hashes, &commit), &[]);
print(true, &snapshot(&check, &coverage, &commit), &[]);
Ok(ExitCode::SUCCESS)
}

Expand All @@ -339,7 +339,7 @@ fn read_snapshot(path: &Path) -> Result<Snapshot, ExitCode> {

/// Lines naming each non-empty list of the diff.
fn diff_lines(diff: &SnapshotDiff) -> Vec<String> {
let lists: [(&str, &Vec<String>); 9] = [
let lists: [(&str, &Vec<String>); 10] = [
("REGRESSION forms lost", &diff.forms_lost),
("REGRESSION obligations lost", &diff.obligations_lost),
(
Expand All @@ -350,6 +350,10 @@ fn diff_lines(diff: &SnapshotDiff) -> Vec<String> {
"REGRESSION Snowflake syntax errors the parser now accepts",
&diff.syntax_error_cases_accepted,
),
(
"DOCUMENTED (D113) Snowflake syntax errors the parser now accepts, for the auditor",
&diff.documented_cases_accepted,
),
("CATALOGUE files changed", &diff.catalogue_files_changed),
("CATALOGUE forms added", &diff.forms_added),
("CATALOGUE forms removed", &diff.forms_removed),
Expand Down Expand Up @@ -378,7 +382,7 @@ pub(crate) fn diff(
if !check.problems.is_empty() {
return Err(ExitCode::FAILURE);
}
snapshot(&check, &coverage.file_hashes, &base.catalogue_commit)
snapshot(&check, &coverage, &base.catalogue_commit)
}
};
let diff: SnapshotDiff = diff_snapshots(&base, &head);
Expand Down
34 changes: 31 additions & 3 deletions crates/sqlquack-syntax-coverage/src/formats/_helpers/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ use crate::formats::_helpers::records::{
};
use crate::formats::_helpers::retired::{retire, withdrawn_receipts};
use crate::formats::constants::{
ALIASES_FILE, CASES_DIR, CATALOGUE_DIR, INDEXES_FILE, LIMITS_FILE, OBLIGATIONS_DIR,
RECEIPT_REPORT_SUFFIX, RECEIPTS_DIR, REVIEWS_DIR, SOURCES_FILE, UNOBSERVABLE_DIR,
ALIASES_FILE, CASES_DIR, CATALOGUE_DIR, INDEXES_FILE, LIMITS_FILE, MARKS, MARKS_DIR,
OBLIGATIONS_DIR, RECEIPT_REPORT_SUFFIX, RECEIPTS_DIR, REVIEWS_DIR, SOURCES_FILE,
UNOBSERVABLE_DIR,
};
use crate::formats::models::{
Alias, AliasesFile, Case, CatalogueFile, Diagnostic, Index, IndexesFile, LimitsFile,
Alias, AliasesFile, Case, CatalogueFile, Diagnostic, Index, IndexesFile, LimitsFile, Mark,
Obligation, ObligationsFile, Reading, Receipt, Review, ReviewsFile, Source, SourcesFile,
Unobservable, UnobservableFile,
};
Expand Down Expand Up @@ -182,6 +183,29 @@ pub(crate) fn load_cases(root: &Path) -> Section<BTreeMap<String, Vec<Case>>> {
Section::of(records, declared, problems)
}

/// `marks/<family>.jsonl`: optional, each mark one of MARKS and each case marked at most once.
pub(crate) fn load_marks(root: &Path) -> Section<Vec<Mark>> {
let (paths, others) = listed(&root.join(MARKS_DIR), "jsonl");
let mut problems: Vec<String> = others.iter().map(|path| stray(path, "jsonl")).collect();
let mut records: Vec<Mark> = Vec::new();
for path in paths {
let (marks, read_problems) = read_jsonl::<Mark>(&path);
problems.extend(read_problems);
records.extend(marks);
}
problems.extend(
records
.iter()
.filter(|mark| !MARKS.contains(&mark.mark.as_str()))
.map(|mark| format!("mark {}: unknown mark {:?}", mark.case_id, mark.mark)),
);
let declared: Vec<String> = records
.iter()
.map(|mark| format!("mark {}", mark.case_id))
.collect();
Section::of(records, declared, problems)
}

/// Every receipts file, less the receipts superseded or withdrawn by the run reports beside them.
pub(crate) fn load_receipts(root: &Path) -> Section<(Vec<Receipt>, Vec<String>)> {
let (paths, others) = listed(&root.join(RECEIPTS_DIR), "jsonl");
Expand Down Expand Up @@ -296,6 +320,7 @@ pub(crate) struct Sections {
pub(crate) reviews: Section<Vec<Review>>,
pub(crate) unobservable: Section<(Vec<Unobservable>, Vec<Diagnostic>)>,
pub(crate) readings: Section<Vec<Reading>>,
pub(crate) marks: Section<Vec<Mark>>,
}

impl Sections {
Expand All @@ -312,6 +337,7 @@ impl Sections {
&self.reviews.problems,
&self.unobservable.problems,
&self.readings.problems,
&self.marks.problems,
]
.into_iter()
.flatten()
Expand All @@ -325,6 +351,7 @@ impl Sections {
&self.obligations.declared,
&self.cases.declared,
&self.reviews.declared,
&self.marks.declared,
]
.into_iter()
.flatten()
Expand All @@ -348,6 +375,7 @@ pub(crate) fn load_sections(root: &Path) -> Sections {
reviews: load_reviews(root),
unobservable: load_unobservable(root),
readings: load_readings(root),
marks: load_marks(root),
sources,
}
}
10 changes: 10 additions & 0 deletions crates/sqlquack-syntax-coverage/src/formats/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ pub const RECEIPTS_DIR: &str = "receipts";
pub const REVIEWS_DIR: &str = "reviews";
/// Obligations Snowflake answers only with an internal error, one optional TOML file per family.
pub const UNOBSERVABLE_DIR: &str = "unobservable";
/// The catalogue's case marks, one optional JSON Lines file per family (decision D135).
pub const MARKS_DIR: &str = "marks";
/// A case that follows its documentation against Snowflake's syntax error (D113).
pub const MARK_DOCUMENTED: &str = "documented (D113)";
/// Every mark a case can carry.
pub const MARKS: [&str; 3] = [
MARK_DOCUMENTED,
"settled accept (D133)",
"settled reject (D133)",
];
/// Hand-added limits, suppressions and grammar readings; the checker loads the readings.
pub const LIMITS_FILE: &str = "limits.toml";
/// Every family, each with a catalogue, an obligation and a case file.
Expand Down
40 changes: 38 additions & 2 deletions crates/sqlquack-syntax-coverage/src/formats/main/load_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ use std::collections::BTreeMap;
use std::path::Path;

use crate::formats::_helpers::sections::{Sections, load_sections};
use crate::formats::models::{Definition, Form, SyntaxCoverage};
use crate::formats::constants::MARK_DOCUMENTED;
use crate::formats::models::{Case, Definition, Form, SyntaxCoverage};

/// Every file under `root`, or every format problem found; nothing is skipped or defaulted.
pub fn load_coverage(root: &Path) -> Result<SyntaxCoverage, Vec<String>> {
let sections: Sections = load_sections(root);
let problems: Vec<String> = sections.problems();
let mut problems: Vec<String> = sections.problems();
problems.extend(unmatched_marks(&sections));
if !problems.is_empty() {
return Err(problems);
}
Expand Down Expand Up @@ -38,5 +40,39 @@ pub fn load_coverage(root: &Path) -> Result<SyntaxCoverage, Vec<String>> {
diagnostics: sections.unobservable.records.1,
readings: sections.readings.records,
file_hashes,
documented_cases: sections
.marks
.records
.into_iter()
.filter(|mark| mark.mark == MARK_DOCUMENTED)
.map(|mark| mark.case_id)
.collect(),
})
}

/// Marks naming no case, or a case whose SQL or obligations differ from the mark's.
fn unmatched_marks(sections: &Sections) -> Vec<String> {
let cases: BTreeMap<&str, &Case> = sections
.cases
.records
.values()
.flatten()
.map(|case| (case.case_id.as_str(), case))
.collect();
sections
.marks
.records
.iter()
.filter(|mark| {
cases.get(mark.case_id.as_str()).is_none_or(|case| {
case.sql_sha256 != mark.sql_sha256 || case.obligations != mark.obligations
})
})
.map(|mark| {
format!(
"mark {}: matches no case's SQL and obligations",
mark.case_id
)
})
.collect()
}
14 changes: 13 additions & 1 deletion crates/sqlquack-syntax-coverage/src/formats/models.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The syntax-coverage files: sources, catalogue, obligations, cases, receipts and reviews.

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand Down Expand Up @@ -193,6 +193,16 @@ pub enum ObligationKind {
Extreme,
}

/// One line of `marks/<family>.jsonl`: how the catalogue settled one case.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Mark {
pub case_id: String,
pub sql_sha256: String,
pub obligations: Vec<String>,
pub mark: String,
}

/// One line of `cases/<family>.jsonl`.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -561,4 +571,6 @@ pub struct SyntaxCoverage {
pub readings: Vec<Reading>,
/// SHA-256 of the sources, aliases, catalogue and obligation files, by relative path.
pub file_hashes: BTreeMap<String, String>,
/// Cases marked documented (D113), whose acceptance against a syntax error is intended.
pub documented_cases: BTreeSet<String>,
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,28 @@ fn given_a_valid_tree_when_loading_then_every_record_is_kept() {
#[test]
fn given_a_format_fault_when_loading_then_the_problem_is_named() {
let test_cases = [
FormatFaultTestCase {
description: "a mark whose SQL hash is not its case's",
mutate: |root| {
write(
root,
"marks/query.jsonl",
"{\"case_id\":\"select-from\",\"sql_sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"obligations\":[\"sf.query.select.o002\"],\"mark\":\"documented (D113)\"}\n",
)
},
expected_problem: "mark select-from: matches no case's SQL and obligations",
},
FormatFaultTestCase {
description: "a mark the catalogue does not define",
mutate: |root| {
write(
root,
"marks/query.jsonl",
"{\"case_id\":\"select-from\",\"sql_sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"obligations\":[],\"mark\":\"documented\"}\n",
)
},
expected_problem: "mark select-from: unknown mark \"documented\"",
},
FormatFaultTestCase {
description: "a deleted family catalogue",
mutate: |root| std::fs::remove_file(root.join("catalogue/dml.toml")).expect("remove"),
Expand Down
15 changes: 12 additions & 3 deletions crates/sqlquack-syntax-coverage/src/report/main/diff_snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ pub fn diff_snapshots(base: &Snapshot, head: &Snapshot) -> SnapshotDiff {
let forms: BTreeSet<&String> = base.forms.keys().chain(head.forms.keys()).collect();
let in_base = |id: &String| base.forms.get(id);
let in_head = |id: &String| head.forms.get(id);
let flipped: Vec<String> = base
.syntax_error_cases_rejected
.intersection(&head.syntax_error_cases_accepted)
.cloned()
.collect();
SnapshotDiff {
catalogue_files_changed: paths
.into_iter()
Expand Down Expand Up @@ -59,10 +64,14 @@ pub fn diff_snapshots(base: &Snapshot, head: &Snapshot) -> SnapshotDiff {
.intersection(&head.refused_cases)
.cloned()
.collect(),
syntax_error_cases_accepted: base
.syntax_error_cases_rejected
.intersection(&head.syntax_error_cases_accepted)
syntax_error_cases_accepted: flipped
.iter()
.filter(|id| !head.documented_cases.contains(*id))
.cloned()
.collect(),
documented_cases_accepted: flipped
.into_iter()
.filter(|id| head.documented_cases.contains(id))
.collect(),
}
}
11 changes: 7 additions & 4 deletions crates/sqlquack-syntax-coverage/src/report/main/snapshot.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
//! `snapshot`: the claims of one check, bound to the catalogue it ran on.

use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeSet;

use crate::check::models::{CheckResult, LocalVerdict, SnowflakeSyntax};
use crate::formats::constants::{ALIASES_FILE, CATALOGUE_DIR, SNAPSHOT_FORMAT, SOURCES_FILE};
use crate::formats::models::SyntaxCoverage;
use crate::report::models::Snapshot;

/// Whether a coverage path belongs to the catalogue rather than to obligations or evidence.
Expand Down Expand Up @@ -31,16 +32,17 @@ fn by_snowflake(
.collect()
}

/// The snapshot of `check`; `file_hashes` are the loaded files' hashes by relative path.
/// The snapshot of `check` on `coverage`, the tree it checked.
pub fn snapshot(
check: &CheckResult,
file_hashes: &BTreeMap<String, String>,
coverage: &SyntaxCoverage,
catalogue_commit: &str,
) -> Snapshot {
Snapshot {
format: SNAPSHOT_FORMAT.to_owned(),
catalogue_commit: catalogue_commit.to_owned(),
catalogue_files: file_hashes
catalogue_files: coverage
.file_hashes
.iter()
.filter(|(path, _)| is_catalogue_file(path))
.map(|(path, hash)| (path.clone(), hash.clone()))
Expand All @@ -62,5 +64,6 @@ pub fn snapshot(
refused_cases: by_snowflake(check, SnowflakeSyntax::Parsed, false),
syntax_error_cases_rejected: by_snowflake(check, SnowflakeSyntax::Refused, false),
syntax_error_cases_accepted: by_snowflake(check, SnowflakeSyntax::Refused, true),
documented_cases: coverage.documented_cases.clone(),
}
}
7 changes: 6 additions & 1 deletion crates/sqlquack-syntax-coverage/src/report/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ pub struct Snapshot {
/// Receipted cases Snowflake refused as syntax that the parser accepts, opaque or not.
#[serde(default)]
pub syntax_error_cases_accepted: BTreeSet<String>,
/// Cases the catalogue marks documented (D113): their acceptance against a syntax error is intended.
#[serde(default)]
pub documented_cases: BTreeSet<String>,
}

/// One snapshot against a later one; coverage and catalogue changes kept apart.
Expand All @@ -88,6 +91,8 @@ pub struct SnapshotDiff {
pub obligations_gained: Vec<String>,
/// Cases Snowflake parsed that the base parser accepted, opaque or not, and the head rejects.
pub parsed_cases_refused: Vec<String>,
/// Cases Snowflake refused as syntax that the base parser rejected and the head accepts.
/// Cases Snowflake refused as syntax that the base parser rejected and the head accepts, less documented ones.
pub syntax_error_cases_accepted: Vec<String>,
/// The same flips on cases marked documented (D113): listed for the auditor, not failing (D135).
pub documented_cases_accepted: Vec<String>,
}
21 changes: 20 additions & 1 deletion crates/sqlquack-syntax-coverage/src/report/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub(crate) fn base() -> Snapshot {
refused_cases: BTreeSet::from(["sf.query.with.k2".to_owned()]),
syntax_error_cases_rejected: BTreeSet::from(["sf.query.select.k3".to_owned()]),
syntax_error_cases_accepted: BTreeSet::new(),
documented_cases: BTreeSet::new(),
}
}

Expand Down Expand Up @@ -67,12 +68,30 @@ pub(crate) fn observed_select_from(root: &Path, errno: i64) {
);
}

/// Marks `select-from` with `mark` in `marks/query.jsonl`.
pub(crate) fn mark_select_from(root: &Path, mark: &str) {
let coverage = load_coverage(root).expect("load");
let case = coverage.cases["query"]
.iter()
.find(|case| case.case_id == "select-from")
.expect("select-from");
let line: String = serde_json::json!({
"case_id": case.case_id,
"sql_sha256": case.sql_sha256,
"obligations": case.obligations,
"mark": mark,
})
.to_string();
std::fs::create_dir_all(root.join("marks")).expect("marks directory");
std::fs::write(root.join("marks/query.jsonl"), format!("{line}\n")).expect("write marks");
}

/// The snapshot of the tree at `root` under `parse`.
pub(crate) fn snapshot_with(root: &Path, parse: fn(&str) -> LocalVerdict) -> Snapshot {
let coverage = load_coverage(root).expect("load");
snapshot(
&check_coverage(&coverage, Some(TREE), &parse),
&coverage.file_hashes,
&coverage,
"catalogue",
)
}
Loading