Skip to content

Commit 47bc863

Browse files
committed
feat(native): report attachment errors natively
1 parent 37df06a commit 47bc863

81 files changed

Lines changed: 2143 additions & 535 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub(crate) mod parameters;
22
pub(crate) mod policies;
3+
pub(crate) mod rendering;

‎crates/sqlbuild-attachments/src/audits/_helpers/parameters.rs‎

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,22 @@ pub(crate) fn parameter_matches(sql: &str) -> Option<Vec<ParameterMatch<'_>>> {
6060
Some(matches)
6161
}
6262

63-
/// Python's `render_sql_argument_value` for one value.
64-
pub(crate) fn render_value(value: &ArgumentValue, quoted: bool) -> String {
65-
match value {
63+
/// Python's `render_sql_argument_value` for one value; `None` where it holds an opaque value.
64+
pub(crate) fn render_value(value: &ArgumentValue, quoted: bool) -> Option<String> {
65+
Some(match value {
6666
ArgumentValue::List(items) => items
6767
.iter()
6868
.map(|item| render_value(item, quoted))
69-
.collect::<Vec<String>>()
69+
.collect::<Option<Vec<String>>>()?
7070
.join(", "),
7171
ArgumentValue::Boolean(true) => "TRUE".to_owned(),
7272
ArgumentValue::Boolean(false) => "FALSE".to_owned(),
7373
ArgumentValue::Null => "NULL".to_owned(),
7474
ArgumentValue::Number(text) => text.clone(),
7575
ArgumentValue::Text(text) if quoted => format!("'{}'", text.replace('\'', "''")),
7676
ArgumentValue::Text(text) => text.clone(),
77-
}
77+
ArgumentValue::Opaque => return None,
78+
})
7879
}
7980

8081
/// The end of `[A-Za-z_][A-Za-z0-9_]*` at `start`.
@@ -110,30 +111,45 @@ fn opens_call(bytes: &[u8], mut index: usize) -> Option<bool> {
110111
Some(false)
111112
}
112113

113-
/// Python's `render_parameterized_sql`, or None where Python raises or decides.
114+
/// Why rendering stops: Python's missing-argument or value error, or input Python must judge.
115+
#[derive(Clone, Debug, PartialEq, Eq)]
116+
pub(crate) enum RenderStop {
117+
MissingArgument(String),
118+
/// The argument whose value Python cannot render.
119+
UnsupportedValue(String),
120+
Deferred,
121+
}
122+
123+
/// Python's `render_parameterized_sql`; unused arguments under `reject_unused` defer.
114124
pub(crate) fn render_parameterized_sql(
115125
sql: &str,
116126
arguments: &[(String, ArgumentValue)],
117127
reject_unused: bool,
118-
) -> Option<String> {
119-
let matches = parameter_matches(sql)?;
128+
) -> Result<String, RenderStop> {
129+
let matches = parameter_matches(sql).ok_or(RenderStop::Deferred)?;
120130
if reject_unused {
121131
for (name, _) in arguments {
122132
if !matches.iter().any(|item| item.name == name.as_str()) {
123-
return None;
133+
return Err(RenderStop::Deferred);
124134
}
125135
}
126136
}
127137
let mut rendered: String = String::with_capacity(sql.len());
128138
let mut previous_end: usize = 0;
129139
for item in &matches {
130-
let (_, value) = arguments
140+
let Some((_, value)) = arguments
131141
.iter()
132-
.find(|(name, _)| name.as_str() == item.name)?;
142+
.find(|(name, _)| name.as_str() == item.name)
143+
else {
144+
return Err(RenderStop::MissingArgument(item.name.to_owned()));
145+
};
133146
rendered.push_str(&sql[previous_end..item.start]);
134-
rendered.push_str(&render_value(value, item.quoted));
147+
let Some(text) = render_value(value, item.quoted) else {
148+
return Err(RenderStop::UnsupportedValue(item.name.to_owned()));
149+
};
150+
rendered.push_str(&text);
135151
previous_end = item.end;
136152
}
137153
rendered.push_str(&sql[previous_end..]);
138-
Some(rendered)
154+
Ok(rendered)
139155
}
Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,59 @@
1-
//! Python's `resolve_audit_severity` and `resolve_audit_run_scope`.
1+
//! Python's `resolve_audit_severity` and `resolve_audit_run_scope`, with their errors.
22
33
use crate::audits::models::PolicySource;
44

55
const SEVERITIES: [&str; 2] = ["warn", "error"];
6-
const RUN_SCOPES: [&str; 2] = ["final", "delta_and_final"];
6+
/// Python lists run scopes sorted.
7+
const RUN_SCOPES: [&str; 2] = ["delta_and_final", "final"];
8+
const SETTINGS_FILE: &str = "sqlbuild_project.toml";
79

8-
/// The severity text; unknown values defer so Python raises.
10+
/// The severity text, or Python's error naming the unknown value.
911
pub(crate) fn resolve_severity(
1012
instance: Option<&str>,
1113
default: Option<&str>,
12-
) -> Option<&'static str> {
14+
audit_label: &str,
15+
) -> Result<&'static str, String> {
1316
match (instance, default) {
14-
(Some(value), _) | (None, Some(value)) => known(&SEVERITIES, value),
15-
(None, None) => Some("error"),
17+
(Some(value), _) => known(&SEVERITIES, value).ok_or_else(|| {
18+
format!(
19+
"{audit_label}: unknown severity '{value}'; valid values: {}",
20+
SEVERITIES.join(", ")
21+
)
22+
}),
23+
(None, Some(value)) => known(&SEVERITIES, value)
24+
.ok_or_else(|| unknown_setting("default_audit_severity", value, &SEVERITIES)),
25+
(None, None) => Ok("error"),
1626
}
1727
}
1828

19-
/// Where the run scope comes from; unknown values defer so Python raises.
29+
/// Where the run scope comes from, or Python's error naming the unknown value.
2030
pub(crate) fn resolve_run_scope(
2131
instance: Option<&str>,
2232
default: Option<&str>,
23-
) -> Option<PolicySource> {
33+
) -> Result<PolicySource, String> {
2434
match (instance, default) {
25-
(Some(value), _) => known(&RUN_SCOPES, value).map(|_| PolicySource::Instance),
26-
(None, Some(value)) => known(&RUN_SCOPES, value).map(|_| PolicySource::Default),
27-
(None, None) => Some(PolicySource::Fallback),
35+
(Some(value), _) => known(&RUN_SCOPES, value)
36+
.map(|_| PolicySource::Instance)
37+
.ok_or_else(|| {
38+
format!(
39+
"unknown audit run_scope '{value}'; valid values: {}",
40+
RUN_SCOPES.join(", ")
41+
)
42+
}),
43+
(None, Some(value)) => known(&RUN_SCOPES, value)
44+
.map(|_| PolicySource::Default)
45+
.ok_or_else(|| unknown_setting("default_audit_run_scope", value, &RUN_SCOPES)),
46+
(None, None) => Ok(PolicySource::Fallback),
2847
}
2948
}
3049

50+
fn unknown_setting(setting: &str, value: &str, valid: &[&str]) -> String {
51+
format!(
52+
"settings.{setting} in {SETTINGS_FILE}: unknown value '{value}'; valid values: {}",
53+
valid.join(", ")
54+
)
55+
}
56+
3157
fn known(values: &[&'static str], value: &str) -> Option<&'static str> {
3258
values.iter().copied().find(|candidate| *candidate == value)
3359
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! Python's argument merge, SQL rendering and policy resolution for one attachment.
2+
3+
use crate::audits::_helpers::parameters::{RenderStop, render_parameterized_sql};
4+
use crate::audits::_helpers::policies::{resolve_run_scope, resolve_severity};
5+
use crate::audits::models::{ArgumentValue, AuditAttachment, PolicySource};
6+
7+
/// Severity first, then run scope, as Python resolves them after expanding the SQL.
8+
pub(crate) fn policies(
9+
attachment: &AuditAttachment,
10+
) -> Result<(&'static str, PolicySource), String> {
11+
let severity: &'static str = if attachment.has_thresholds {
12+
if attachment.threshold_error {
13+
"error"
14+
} else {
15+
"warn"
16+
}
17+
} else {
18+
resolve_severity(
19+
attachment.instance_severity.as_deref(),
20+
attachment.default_severity.as_deref(),
21+
&format!(
22+
"{} audit '{}'",
23+
attachment.owner_label, attachment.definition_name
24+
),
25+
)?
26+
};
27+
let run_scope_source: PolicySource = resolve_run_scope(
28+
attachment.instance_run_scope.as_deref(),
29+
attachment.default_run_scope.as_deref(),
30+
)?;
31+
Ok((severity, run_scope_source))
32+
}
33+
34+
/// One SQL text rendered, Python's missing-argument error, or None to defer.
35+
pub(crate) fn rendered(
36+
attachment: &AuditAttachment,
37+
sql: &str,
38+
arguments: &[(String, ArgumentValue)],
39+
) -> Option<Result<String, String>> {
40+
match render_parameterized_sql(sql, arguments, false) {
41+
Ok(rendered) => Some(Ok(rendered)),
42+
Err(RenderStop::MissingArgument(name)) => Some(Err(format!(
43+
"{} is missing argument '{name}' for generic audit '{}'",
44+
attachment.owner_label, attachment.definition_name
45+
))),
46+
Err(RenderStop::UnsupportedValue(name)) => Some(Err(format!(
47+
"{} generic audit '{}' argument '{name}' uses an unsupported value",
48+
attachment.owner_label, attachment.definition_name
49+
))),
50+
Err(RenderStop::Deferred) => None,
51+
}
52+
}
53+
54+
/// Python's `merge_audit_arguments`; None where only Python's `!=` can compare the values.
55+
pub(crate) fn merged_arguments(
56+
attachment: &AuditAttachment,
57+
) -> Option<Result<Vec<(String, ArgumentValue)>, String>> {
58+
let mut merged: Vec<(String, ArgumentValue)> = attachment.implicit_arguments.clone();
59+
for (name, value) in &attachment.explicit_arguments {
60+
match merged.iter_mut().find(|(existing, _)| existing == name) {
61+
Some((_, existing)) => {
62+
if python_unequal(existing, value)? {
63+
return Some(Err(format!(
64+
"{} audit '{}' must not override implicit {name} from attached context",
65+
attachment.owner_label, attachment.definition_name
66+
)));
67+
}
68+
}
69+
None => merged.push((name.clone(), value.clone())),
70+
}
71+
}
72+
Some(Ok(merged))
73+
}
74+
75+
/// Python's `left != right`, or None for opaque values and numbers differing only in text.
76+
fn python_unequal(left: &ArgumentValue, right: &ArgumentValue) -> Option<bool> {
77+
if matches!(left, ArgumentValue::Opaque) || matches!(right, ArgumentValue::Opaque) {
78+
return None;
79+
}
80+
if left == right {
81+
return Some(false);
82+
}
83+
match (left, right) {
84+
(ArgumentValue::List(left), ArgumentValue::List(right)) => {
85+
if left.len() != right.len() {
86+
return Some(true);
87+
}
88+
let mut undecided: bool = false;
89+
for (left, right) in left.iter().zip(right) {
90+
match python_unequal(left, right) {
91+
Some(true) => return Some(true),
92+
Some(false) => {}
93+
None => undecided = true,
94+
}
95+
}
96+
if undecided { None } else { Some(false) }
97+
}
98+
(
99+
ArgumentValue::Number(_) | ArgumentValue::Boolean(_),
100+
ArgumentValue::Number(_) | ArgumentValue::Boolean(_),
101+
) if !matches!(
102+
(left, right),
103+
(ArgumentValue::Boolean(_), ArgumentValue::Boolean(_))
104+
) =>
105+
{
106+
None
107+
}
108+
_ => Some(true),
109+
}
110+
}
Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,36 @@
11
//! Python's attached generic audit rendering: argument merge, SQL, severity and run scope.
22
3-
use crate::audits::_helpers::parameters::render_parameterized_sql;
4-
use crate::audits::_helpers::policies::{resolve_run_scope, resolve_severity};
5-
use crate::audits::models::{ArgumentValue, AuditAttachment, PolicySource, RenderedAudit};
3+
use crate::audits::_helpers::rendering::{merged_arguments, policies, rendered};
4+
use crate::audits::models::{ArgumentValue, AuditAttachment, AuditRendering, RenderedAudit};
65

7-
/// Render one attachment as Python does, or None where Python raises or decides.
6+
/// Render one attachment as Python does, with its errors, or None where Python must decide.
87
#[must_use]
9-
pub fn render_attached_audit(attachment: &AuditAttachment) -> Option<RenderedAudit> {
8+
pub fn render_attached_audit(attachment: &AuditAttachment) -> Option<AuditRendering> {
109
if attachment.measurement {
1110
if attachment.instance_severity.is_some() || !attachment.has_thresholds {
1211
return None;
1312
}
1413
} else if attachment.has_thresholds || attachment.has_minimum_samples {
1514
return None;
1615
}
17-
let arguments: Vec<(String, ArgumentValue)> = merged_arguments(attachment)?;
18-
let sql_body: String = render_parameterized_sql(&attachment.sql_body, &arguments, false)?;
16+
let arguments: Vec<(String, ArgumentValue)> = match merged_arguments(attachment)? {
17+
Ok(arguments) => arguments,
18+
Err(message) => return Some(AuditRendering::Failed(message)),
19+
};
20+
let sql_body: String = match rendered(attachment, &attachment.sql_body, &arguments)? {
21+
Ok(sql) => sql,
22+
Err(message) => return Some(AuditRendering::Failed(message)),
23+
};
1924
let evidence_sql: Option<String> = match &attachment.evidence_sql {
20-
Some(evidence) => Some(render_parameterized_sql(evidence, &arguments, false)?),
25+
Some(evidence) => match rendered(attachment, evidence, &arguments)? {
26+
Ok(sql) => Some(sql),
27+
Err(message) => return Some(AuditRendering::Failed(message)),
28+
},
2129
None => None,
2230
};
23-
let severity: &'static str = if attachment.has_thresholds {
24-
if attachment.threshold_error {
25-
"error"
26-
} else {
27-
"warn"
28-
}
29-
} else {
30-
resolve_severity(
31-
attachment.instance_severity.as_deref(),
32-
attachment.default_severity.as_deref(),
33-
)?
34-
};
35-
let run_scope_source: PolicySource = resolve_run_scope(
36-
attachment.instance_run_scope.as_deref(),
37-
attachment.default_run_scope.as_deref(),
38-
)?;
39-
Some(RenderedAudit {
31+
Some(AuditRendering::Rendered(RenderedAudit {
4032
sql_body,
4133
evidence_sql,
42-
severity,
43-
run_scope_source,
44-
})
45-
}
46-
47-
/// Python's `merge_audit_arguments`; values that differ only as Python objects defer.
48-
fn merged_arguments(attachment: &AuditAttachment) -> Option<Vec<(String, ArgumentValue)>> {
49-
let mut merged: Vec<(String, ArgumentValue)> = attachment.implicit_arguments.clone();
50-
for (name, value) in &attachment.explicit_arguments {
51-
match merged.iter_mut().find(|(existing, _)| existing == name) {
52-
Some((_, existing)) if existing == value => {}
53-
Some(_) => return None,
54-
None => merged.push((name.clone(), value.clone())),
55-
}
56-
}
57-
Some(merged)
34+
policies: policies(attachment),
35+
}))
5836
}

‎crates/sqlbuild-attachments/src/audits/models.rs‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ pub enum ArgumentValue {
88
Number(String),
99
Text(String),
1010
List(Vec<ArgumentValue>),
11+
/// A value Python cannot render, such as a mapping; rendering it is Python's error.
12+
Opaque,
1113
}
1214

1315
/// Where a resolved run scope came from, so Python keeps its own value object.
@@ -21,6 +23,9 @@ pub enum PolicySource {
2123
/// One generic audit attachment: definition SQL, arguments and authored policies.
2224
#[derive(Clone, Debug, PartialEq, Eq)]
2325
pub struct AuditAttachment {
26+
/// Python's `str(owner_file)` and the generic audit name, which its error messages quote.
27+
pub owner_label: String,
28+
pub definition_name: String,
2429
pub sql_body: String,
2530
pub evidence_sql: Option<String>,
2631
pub implicit_arguments: Vec<(String, ArgumentValue)>,
@@ -35,12 +40,18 @@ pub struct AuditAttachment {
3540
pub default_run_scope: Option<String>,
3641
}
3742

38-
/// The rendered SQL and the policies one attachment resolves to.
43+
/// The rendered SQL and the policies one attachment resolves to; severity is `warn` or `error`.
3944
#[derive(Clone, Debug, PartialEq, Eq)]
4045
pub struct RenderedAudit {
4146
pub sql_body: String,
4247
pub evidence_sql: Option<String>,
43-
/// `warn` or `error`.
44-
pub severity: &'static str,
45-
pub run_scope_source: PolicySource,
48+
/// `(severity, run scope source)`, or the error Python raises once the SQL is expanded.
49+
pub policies: Result<(&'static str, PolicySource), String>,
50+
}
51+
52+
/// Python's rendering of one attachment: its SQL and policies, or the error rendering raises.
53+
#[derive(Clone, Debug, PartialEq, Eq)]
54+
pub enum AuditRendering {
55+
Rendered(RenderedAudit),
56+
Failed(String),
4657
}

0 commit comments

Comments
 (0)