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
17 changes: 12 additions & 5 deletions crates/sqlquack-syntax-coverage/src/check/_helpers/fingerprints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::collections::BTreeMap;

use crate::check::models::{CaseResult, LocalVerdict, ObligationResult};
use crate::formats::main::content_sha256::content_sha256;
use crate::formats::models::{Form, Obligation};
use crate::formats::models::{Form, Obligation, Reading};

/// The local verdict as fingerprint text: `accepted` with its tree hash, `rejected`, or the failure.
fn verdict_text(local: &LocalVerdict) -> String {
Expand Down Expand Up @@ -38,8 +38,15 @@ pub(crate) fn form_fingerprint(obligations: &[ObligationResult]) -> String {
content_sha256(text.as_bytes())
}

/// SHA-256 of the form's catalogue entry and own obligations as compact JSON; empty if unserializable.
pub(crate) fn form_definition(form: &Form, obligations: &[&Obligation]) -> String {
serde_json::to_vec(&(form, obligations))
.map_or_else(|_| String::new(), |bytes| content_sha256(&bytes))
/// SHA-256 of the form's catalogue entry, own obligations and any reading as JSON; empty on failure.
pub(crate) fn form_definition(
form: &Form,
obligations: &[&Obligation],
reading: Option<&Reading>,
) -> String {
let bytes = match reading {
Some(reading) => serde_json::to_vec(&(form, obligations, reading)),
None => serde_json::to_vec(&(form, obligations)),
};
bytes.map_or_else(|_| String::new(), |bytes| content_sha256(&bytes))
}
8 changes: 4 additions & 4 deletions crates/sqlquack-syntax-coverage/src/check/_helpers/forms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ fn obligation_result(obligation: &Obligation, joined: &Joined) -> ObligationResu
.iter()
.filter_map(|case_id| joined.cases.get(case_id).cloned())
.collect();
let unobservable: Option<Reason> = joined.unobservable.get(obligation.id.as_str()).copied();
let reason: Option<Reason> = match () {
() if cases.is_empty() => Some(Reason::MissingCase),
() if joined.unobservable.contains(obligation.id.as_str()) => {
Some(Reason::UnverifiableInternalError)
}
() if unobservable.is_some() => unobservable,
() => cases.iter().filter_map(|case| case.reason).min(),
};
ObligationResult {
Expand Down Expand Up @@ -62,7 +61,8 @@ pub(crate) fn form_result(form: &Form, family: &str, context: &FamilyContext) ->
.map(|obligation| obligation_result(obligation, context.joined))
.collect();
let fingerprint: String = form_fingerprint(&obligations);
let definition_sha256: String = form_definition(form, declared);
let reading = context.joined.readings.get(form.id.as_str()).copied();
let definition_sha256: String = form_definition(form, declared, reading);
let review: Option<Reason> =
review_reason(&form.id, (&fingerprint, &definition_sha256), context);
let reasons: Vec<Reason> = form_reasons(&obligations, review);
Expand Down
17 changes: 14 additions & 3 deletions crates/sqlquack-syntax-coverage/src/check/_helpers/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
use std::collections::BTreeMap;

use crate::check::_helpers::receipts::case_result;
use crate::check::models::{CaseResult, Joined, LocalVerdict};
use crate::check::models::{CaseResult, Joined, LocalVerdict, Reason};
use crate::formats::main::obligation_form::obligation_form;
use crate::formats::models::{Form, Obligation, Receipt, SyntaxCoverage};
use crate::formats::models::{Form, Obligation, Receipt, SyntaxCoverage, UnobservableCause};

fn forms_by_id(coverage: &SyntaxCoverage) -> BTreeMap<&str, &Form> {
coverage
Expand Down Expand Up @@ -75,7 +75,18 @@ pub(crate) fn join<'a>(
unobservable: coverage
.unobservable
.iter()
.map(|entry| entry.obligation.as_str())
.map(|entry| {
let reason: Reason = match entry.cause {
Some(UnobservableCause::NotSubmitted) => Reason::UnverifiableNotSubmitted,
None => Reason::UnverifiableInternalError,
};
(entry.obligation.as_str(), reason)
})
.collect(),
readings: coverage
.readings
.iter()
.map(|reading| (reading.form.as_str(), reading))
.collect(),
}
}
10 changes: 8 additions & 2 deletions crates/sqlquack-syntax-coverage/src/check/_helpers/receipts.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! One case's result: its receipts against the current parser verdict.

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

use crate::check::models::{CaseResult, LocalVerdict, Reason};
use crate::formats::models::{Case, Expect, Phase, Receipt};
use crate::formats::models::{AccountSetting, Case, Expect, Phase, Receipt};

/// A syntax receipt bound to exactly this case's SQL, setup, session and capture, with a query id.
fn admissible(case: &Case, receipt: &Receipt) -> bool {
Expand Down Expand Up @@ -84,5 +84,11 @@ pub(crate) fn case_result(case: &Case, receipts: &[&Receipt], local: LocalVerdic
local,
reason,
semantic_only,
account_state: receipts
.iter()
.filter(|receipt| admissible(case, receipt))
.flat_map(|receipt| receipt.account_state.iter().flatten())
.map(|(name, setting)| (name.clone(), setting.clone()))
.collect::<BTreeMap<String, AccountSetting>>(),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ pub(crate) fn reference_problems(
}
}
}
for reading in &coverage.readings {
if !forms.contains_key(reading.form.as_str()) {
problems.push(format!("reading {}: form is not catalogued", reading.form));
}
}
let sources: BTreeSet<&str> = coverage
.sources
.iter()
Expand Down
15 changes: 12 additions & 3 deletions crates/sqlquack-syntax-coverage/src/check/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::formats::models::{Form, Obligation, ObligationKind, Phase, Review};
use crate::formats::models::{
AccountSetting, Form, Obligation, ObligationKind, Phase, Reading, Review,
};

/// The current parser's verdict on one case's SQL.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -48,6 +50,8 @@ pub enum Reason {
MissingCase,
/// Unverifiable: Snowflake answers the construct only with an internal error (decision D87).
UnverifiableInternalError,
/// Unverifiable: the cases are never submitted, such as side-effecting calls a guard holds.
UnverifiableNotSubmitted,
/// A citing case has no receipt.
MissingReceipt,
/// A citing extreme case awaits Snowflake's answer (`expect = "observe"`).
Expand Down Expand Up @@ -85,6 +89,9 @@ pub struct CaseResult {
pub reason: Option<Reason>,
/// Agrees, while Snowflake stopped after parsing (semantic, runtime or unsupported phase).
pub semantic_only: bool,
/// The account parameters its admissible receipts ran under (decision D120).
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub account_state: BTreeMap<String, AccountSetting>,
}

/// One obligation's result.
Expand Down Expand Up @@ -156,8 +163,10 @@ pub(crate) struct Joined<'a> {
pub(crate) citing: BTreeMap<&'a str, Vec<&'a str>>,
/// Each case's result by case id.
pub(crate) cases: BTreeMap<&'a str, CaseResult>,
/// Obligations listed as unobservable, which never count as satisfied.
pub(crate) unobservable: BTreeSet<&'a str>,
/// Obligations listed as unobservable, with the reason they report; never satisfied.
pub(crate) unobservable: BTreeMap<&'a str, Reason>,
/// Each form's reading by form id.
pub(crate) readings: BTreeMap<&'a str, &'a Reading>,
}

/// What every form of one family is judged against besides its own obligations.
Expand Down
26 changes: 26 additions & 0 deletions crates/sqlquack-syntax-coverage/src/check/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ pub(crate) fn verified_tree(root: &Path) {
"receipts/run-1.jsonl",
&format!("{}\n", receipts.join("\n")),
);
write(
root,
"limits.toml",
&format!(
"[[reading]]\nform = \"sf.query.select\"\ngrammar_sha256 = \"{SHA_A}\"\n\
grammar = [\"SELECT <expr> read as one expression\"]\nreason = \"r\"\n\
[[reading]]\nform = \"sf.query.select.top\"\ngrammar_sha256 = \"{SHA_A}\"\n\
grammar = [\"SELECT TOP <n> <expr> read as one count\"]\nreason = \"r\"\n"
),
);
review(root);
}

Expand Down Expand Up @@ -381,6 +391,22 @@ pub(crate) fn unobservable(root: &Path, obligation: &str, case_id: &str, sql: &s
);
}

/// Lists `obligation` as never submitted, citing `case_id` with the hash of `sql`, with `runs`.
pub(crate) fn not_submitted(root: &Path, obligation: &str, case_id: &str, sql: &str, runs: &str) {
write(
root,
"unobservable/query.toml",
&format!(
"[[unobservable]]\nobligation = \"{obligation}\"\nform = \"sf.query.select\"\n\
construct = \"a construct\"\nreason = \"not submitted: held by the capture guard\"\n\
cause = \"not_submitted\"\n\
cases = [{{ case_id = \"{case_id}\", sql_sha256 = \"{}\" }}]\n\
runs = [{runs}]\nprobes = []\n",
sha256(sql)
),
);
}

/// Adds a note to the obligation `id` of kind `kind`, an edit the obligations file alone sees.
pub(crate) fn note_obligation(root: &Path, id: &str, kind: &str) {
let declared: String = format!("id = \"{id}\"\nkind = \"{kind}\"\nrequired = true\n");
Expand Down
140 changes: 138 additions & 2 deletions crates/sqlquack-syntax-coverage/src/check/tests/test_check_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ use crate::check::main::check_coverage::check_coverage;
use crate::check::models::{FormState, Reason};
use crate::check::tests::helpers::{
OTHER_TREE, SETUP, TREE, append_receipt, case_in_zone, case_line, crashing,
crashing_on_select_from, drop_lines, exclusion_review, form, hanging, move_form,
crashing_on_select_from, drop_lines, exclusion_review, form, hanging, move_form, not_submitted,
note_obligation, obligation, opaque, pin_definitions, pin_fingerprints, read, receipt_line,
rejecting, replace, reshaped, rewrite_select_17, select_17, select_17_setup_sha256, sha256,
structural, unobservable, verified_tree, with_fields, write,
};
use crate::check::tests::test_types::CheckTestCase;
use crate::check::tests::test_types::{AccountStateTestCase, CheckTestCase, Parameters};
use crate::formats::main::load_coverage::load_coverage;

#[test]
Expand Down Expand Up @@ -521,6 +521,47 @@ fn given_a_changed_tree_when_checking_then_the_form_fails_closed_with_its_reason
expected_reason: Some(Reason::UnverifiableInternalError),
expected_problem: None,
},
CheckTestCase {
description: "a never-submitted obligation whose case otherwise agrees",
mutate: |root| {
not_submitted(root, "sf.query.select.o001", "select-17", "SELECT 17", "")
},
parse: structural,
parser_tree: Some(TREE),
form: "sf.query.select",
expected_state: FormState::Unverified,
expected_reason: Some(Reason::UnverifiableNotSubmitted),
expected_problem: None,
},
CheckTestCase {
description: "a never-submitted entry whose case hash is stale",
mutate: |root| {
not_submitted(root, "sf.query.select.o001", "select-17", "SELECT 18", "")
},
parse: structural,
parser_tree: Some(TREE),
form: "sf.query.select",
expected_state: FormState::Unverified,
expected_reason: Some(Reason::UnverifiableNotSubmitted),
expected_problem: Some("case select-17 is not a case with that sql_sha256"),
},
CheckTestCase {
description: "a reading of a form that is not catalogued",
mutate: |root| {
replace(
root,
"limits.toml",
"form = \"sf.query.select.top\"",
"form = \"sf.query.tops\"",
)
},
parse: structural,
parser_tree: Some(TREE),
form: "sf.query.select",
expected_state: FormState::Verified,
expected_reason: None,
expected_problem: Some("reading sf.query.tops: form is not catalogued"),
},
CheckTestCase {
description: "an unobservable entry naming an undeclared obligation",
mutate: |root| unobservable(root, "sf.query.select.o009", "select-17", "SELECT 17"),
Expand Down Expand Up @@ -989,6 +1030,37 @@ fn given_a_review_binding_each_form_when_its_family_files_change_then_only_that_
expected_reason: Some(Reason::StaleReview),
expected_problem: None,
},
CheckTestCase {
description: "the form's own reading edited after review",
mutate: |root| {
pin_definitions(root);
replace(
root,
"limits.toml",
"read as one expression",
"read as a list",
);
},
parse: structural,
parser_tree: Some(TREE),
form: "sf.query.select",
expected_state: FormState::Unverified,
expected_reason: Some(Reason::StaleReview),
expected_problem: None,
},
CheckTestCase {
description: "another form's reading edited in the same limits file",
mutate: |root| {
pin_definitions(root);
replace(root, "limits.toml", "read as one count", "read as a list");
},
parse: structural,
parser_tree: Some(TREE),
form: "sf.query.select",
expected_state: FormState::Verified,
expected_reason: None,
expected_problem: None,
},
CheckTestCase {
description: "a review binding the family's files still goes stale on another form's edit",
mutate: |root| note_obligation(root, "sf.query.select.top.o001", "positive"),
Expand Down Expand Up @@ -1033,3 +1105,67 @@ fn given_a_review_binding_each_form_when_its_family_files_change_then_only_that_
);
}
}

#[test]
fn given_a_receipt_with_account_state_when_checking_then_only_its_case_carries_it() {
let test_cases = [
AccountStateTestCase {
description: "a parameter with its value",
account_state: ",\"account_state\":{\"CORTEX_MODELS_ALLOWLIST\":\
{\"value\":\"NONE\",\"basis\":\"D120: set by sa-01\"}}",
expected_cases: vec![
("select-17", vec![("CORTEX_MODELS_ALLOWLIST", Some("NONE"))]),
("select-from", vec![]),
("select-top", vec![]),
],
},
AccountStateTestCase {
description: "a parameter whose value is null for want of a usable recorded_at",
account_state: ",\"account_state\":{\"CORTEX_MODELS_ALLOWLIST\":\
{\"value\":null,\"basis\":\"D120: no usable recorded_at\"}}",
expected_cases: vec![
("select-17", vec![("CORTEX_MODELS_ALLOWLIST", None)]),
("select-from", vec![]),
("select-top", vec![]),
],
},
];

for test_case in test_cases {
let directory = tempfile::tempdir().expect("temporary directory");
verified_tree(directory.path());
replace(
directory.path(),
"receipts/run-1.jsonl",
"\"phase_basis\":\"errno\"",
&format!("\"phase_basis\":\"errno\"{}", test_case.account_state),
);
let coverage = load_coverage(directory.path())
.unwrap_or_else(|problems| panic!("{}: {problems:#?}", test_case.description));
let check = check_coverage(&coverage, Some(TREE), &structural);
let cases: Vec<(&str, Parameters)> = check
.forms
.iter()
.flat_map(|form| &form.obligations)
.flat_map(|obligation| &obligation.cases)
.map(|case| {
let state = case
.account_state
.iter()
.map(|(name, setting)| (name.as_str(), setting.value.as_deref()))
.collect();
(case.case_id.as_str(), state)
})
.collect();
assert_eq!(cases, test_case.expected_cases, "{}", test_case.description);
assert!(
check
.forms
.iter()
.filter(|form| form.family == "query")
.all(|form| form.state == FormState::Verified),
"{}: account state is provenance and changes no verdict",
test_case.description
);
}
}
12 changes: 12 additions & 0 deletions crates/sqlquack-syntax-coverage/src/check/tests/test_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,15 @@ pub(crate) struct CheckTestCase {
/// A fragment of a reference problem; none when there must be no problem.
pub(crate) expected_problem: Option<&'static str>,
}

/// Account parameter names with their values.
pub(crate) type Parameters<'a> = Vec<(&'a str, Option<&'a str>)>;

/// A receipt `account_state` fragment and the account state each case must then carry.
pub(crate) struct AccountStateTestCase {
pub(crate) description: &'static str,
/// Inserted after the first receipt's `phase_basis`, the receipt of `select-17`.
pub(crate) account_state: &'static str,
/// Each case id with its parameter names and values, in case-id order.
pub(crate) expected_cases: Vec<(&'static str, Parameters<'static>)>,
}
Loading