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
1 change: 1 addition & 0 deletions crates/sqlbuild-attachments/src/audits/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub(crate) mod parameters;
pub(crate) mod policies;
pub(crate) mod rendering;
42 changes: 29 additions & 13 deletions crates/sqlbuild-attachments/src/audits/_helpers/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,22 @@ pub(crate) fn parameter_matches(sql: &str) -> Option<Vec<ParameterMatch<'_>>> {
Some(matches)
}

/// Python's `render_sql_argument_value` for one value.
pub(crate) fn render_value(value: &ArgumentValue, quoted: bool) -> String {
match value {
/// Python's `render_sql_argument_value` for one value; `None` where it holds an opaque value.
pub(crate) fn render_value(value: &ArgumentValue, quoted: bool) -> Option<String> {
Some(match value {
ArgumentValue::List(items) => items
.iter()
.map(|item| render_value(item, quoted))
.collect::<Vec<String>>()
.collect::<Option<Vec<String>>>()?
.join(", "),
ArgumentValue::Boolean(true) => "TRUE".to_owned(),
ArgumentValue::Boolean(false) => "FALSE".to_owned(),
ArgumentValue::Null => "NULL".to_owned(),
ArgumentValue::Number(text) => text.clone(),
ArgumentValue::Text(text) if quoted => format!("'{}'", text.replace('\'', "''")),
ArgumentValue::Text(text) => text.clone(),
}
ArgumentValue::Opaque => return None,
})
}

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

/// Python's `render_parameterized_sql`, or None where Python raises or decides.
/// Why rendering stops: Python's missing-argument or value error, or input Python must judge.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum RenderStop {
MissingArgument(String),
/// The argument whose value Python cannot render.
UnsupportedValue(String),
Deferred,
}

/// Python's `render_parameterized_sql`; unused arguments under `reject_unused` defer.
pub(crate) fn render_parameterized_sql(
sql: &str,
arguments: &[(String, ArgumentValue)],
reject_unused: bool,
) -> Option<String> {
let matches = parameter_matches(sql)?;
) -> Result<String, RenderStop> {
let matches = parameter_matches(sql).ok_or(RenderStop::Deferred)?;
if reject_unused {
for (name, _) in arguments {
if !matches.iter().any(|item| item.name == name.as_str()) {
return None;
return Err(RenderStop::Deferred);
}
}
}
let mut rendered: String = String::with_capacity(sql.len());
let mut previous_end: usize = 0;
for item in &matches {
let (_, value) = arguments
let Some((_, value)) = arguments
.iter()
.find(|(name, _)| name.as_str() == item.name)?;
.find(|(name, _)| name.as_str() == item.name)
else {
return Err(RenderStop::MissingArgument(item.name.to_owned()));
};
rendered.push_str(&sql[previous_end..item.start]);
rendered.push_str(&render_value(value, item.quoted));
let Some(text) = render_value(value, item.quoted) else {
return Err(RenderStop::UnsupportedValue(item.name.to_owned()));
};
rendered.push_str(&text);
previous_end = item.end;
}
rendered.push_str(&sql[previous_end..]);
Some(rendered)
Ok(rendered)
}
48 changes: 37 additions & 11 deletions crates/sqlbuild-attachments/src/audits/_helpers/policies.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,59 @@
//! Python's `resolve_audit_severity` and `resolve_audit_run_scope`.
//! Python's `resolve_audit_severity` and `resolve_audit_run_scope`, with their errors.

use crate::audits::models::PolicySource;

const SEVERITIES: [&str; 2] = ["warn", "error"];
const RUN_SCOPES: [&str; 2] = ["final", "delta_and_final"];
/// Python lists run scopes sorted.
const RUN_SCOPES: [&str; 2] = ["delta_and_final", "final"];
const SETTINGS_FILE: &str = "sqlbuild_project.toml";

/// The severity text; unknown values defer so Python raises.
/// The severity text, or Python's error naming the unknown value.
pub(crate) fn resolve_severity(
instance: Option<&str>,
default: Option<&str>,
) -> Option<&'static str> {
audit_label: &str,
) -> Result<&'static str, String> {
match (instance, default) {
(Some(value), _) | (None, Some(value)) => known(&SEVERITIES, value),
(None, None) => Some("error"),
(Some(value), _) => known(&SEVERITIES, value).ok_or_else(|| {
format!(
"{audit_label}: unknown severity '{value}'; valid values: {}",
SEVERITIES.join(", ")
)
}),
(None, Some(value)) => known(&SEVERITIES, value)
.ok_or_else(|| unknown_setting("default_audit_severity", value, &SEVERITIES)),
(None, None) => Ok("error"),
}
}

/// Where the run scope comes from; unknown values defer so Python raises.
/// Where the run scope comes from, or Python's error naming the unknown value.
pub(crate) fn resolve_run_scope(
instance: Option<&str>,
default: Option<&str>,
) -> Option<PolicySource> {
) -> Result<PolicySource, String> {
match (instance, default) {
(Some(value), _) => known(&RUN_SCOPES, value).map(|_| PolicySource::Instance),
(None, Some(value)) => known(&RUN_SCOPES, value).map(|_| PolicySource::Default),
(None, None) => Some(PolicySource::Fallback),
(Some(value), _) => known(&RUN_SCOPES, value)
.map(|_| PolicySource::Instance)
.ok_or_else(|| {
format!(
"unknown audit run_scope '{value}'; valid values: {}",
RUN_SCOPES.join(", ")
)
}),
(None, Some(value)) => known(&RUN_SCOPES, value)
.map(|_| PolicySource::Default)
.ok_or_else(|| unknown_setting("default_audit_run_scope", value, &RUN_SCOPES)),
(None, None) => Ok(PolicySource::Fallback),
}
}

fn unknown_setting(setting: &str, value: &str, valid: &[&str]) -> String {
format!(
"settings.{setting} in {SETTINGS_FILE}: unknown value '{value}'; valid values: {}",
valid.join(", ")
)
}

fn known(values: &[&'static str], value: &str) -> Option<&'static str> {
values.iter().copied().find(|candidate| *candidate == value)
}
119 changes: 119 additions & 0 deletions crates/sqlbuild-attachments/src/audits/_helpers/rendering.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Python's argument merge, SQL rendering and policy resolution for one attachment.

use crate::audits::_helpers::parameters::{RenderStop, render_parameterized_sql};
use crate::audits::_helpers::policies::{resolve_run_scope, resolve_severity};
use crate::audits::models::{ArgumentValue, AuditAttachment, PolicySource};

/// Severity first, then run scope, as Python resolves them after expanding the SQL.
pub(crate) fn policies(
attachment: &AuditAttachment,
) -> Result<(&'static str, PolicySource), String> {
let severity: &'static str = if attachment.has_thresholds {
if attachment.threshold_error {
"error"
} else {
"warn"
}
} else {
resolve_severity(
attachment.instance_severity.as_deref(),
attachment.default_severity.as_deref(),
&format!(
"{} audit '{}'",
attachment.owner_label, attachment.definition_name
),
)?
};
let run_scope_source: PolicySource = resolve_run_scope(
attachment.instance_run_scope.as_deref(),
attachment.default_run_scope.as_deref(),
)?;
Ok((severity, run_scope_source))
}

/// One SQL text rendered, Python's missing-argument error, or None to defer.
pub(crate) fn rendered(
attachment: &AuditAttachment,
sql: &str,
arguments: &[(String, ArgumentValue)],
) -> Option<Result<String, String>> {
match render_parameterized_sql(sql, arguments, false) {
Ok(rendered) => Some(Ok(rendered)),
Err(RenderStop::MissingArgument(name)) => Some(Err(format!(
"{} is missing argument '{name}' for generic audit '{}'",
attachment.owner_label, attachment.definition_name
))),
Err(RenderStop::UnsupportedValue(name)) => Some(Err(format!(
"{} generic audit '{}' argument '{name}' uses an unsupported value",
attachment.owner_label, attachment.definition_name
))),
Err(RenderStop::Deferred) => None,
}
}

/// Python's `merge_audit_arguments`; None where only Python's `!=` can compare the values.
pub(crate) fn merged_arguments(
attachment: &AuditAttachment,
) -> Option<Result<Vec<(String, ArgumentValue)>, String>> {
let mut merged: Vec<(String, ArgumentValue)> = attachment.implicit_arguments.clone();
for (name, value) in &attachment.explicit_arguments {
match merged.iter_mut().find(|(existing, _)| existing == name) {
Some((_, existing)) => {
if python_unequal(existing, value)? {
return Some(Err(format!(
"{} audit '{}' must not override implicit {name} from attached context",
attachment.owner_label, attachment.definition_name
)));
}
}
None => merged.push((name.clone(), value.clone())),
}
}
Some(Ok(merged))
}

/// Python's `left != right`, or None for nested opaque values and numbers differing in text.
fn python_unequal(left: &ArgumentValue, right: &ArgumentValue) -> Option<bool> {
if contains_opaque(left) || contains_opaque(right) {
return None;
}
if left == right {
return Some(false);
}
match (left, right) {
(ArgumentValue::List(left), ArgumentValue::List(right)) => {
if left.len() != right.len() {
return Some(true);
}
let mut undecided: bool = false;
for (left, right) in left.iter().zip(right) {
match python_unequal(left, right) {
Some(true) => return Some(true),
Some(false) => {}
None => undecided = true,
}
}
if undecided { None } else { Some(false) }
}
(
ArgumentValue::Number(_) | ArgumentValue::Boolean(_),
ArgumentValue::Number(_) | ArgumentValue::Boolean(_),
) if !matches!(
(left, right),
(ArgumentValue::Boolean(_), ArgumentValue::Boolean(_))
) =>
{
None
}
_ => Some(true),
}
}

/// Whether `value` holds an opaque value at any depth, which only Python can compare.
fn contains_opaque(value: &ArgumentValue) -> bool {
match value {
ArgumentValue::Opaque => true,
ArgumentValue::List(items) => items.iter().any(contains_opaque),
_ => false,
}
}
Original file line number Diff line number Diff line change
@@ -1,58 +1,36 @@
//! Python's attached generic audit rendering: argument merge, SQL, severity and run scope.

use crate::audits::_helpers::parameters::render_parameterized_sql;
use crate::audits::_helpers::policies::{resolve_run_scope, resolve_severity};
use crate::audits::models::{ArgumentValue, AuditAttachment, PolicySource, RenderedAudit};
use crate::audits::_helpers::rendering::{merged_arguments, policies, rendered};
use crate::audits::models::{ArgumentValue, AuditAttachment, AuditRendering, RenderedAudit};

/// Render one attachment as Python does, or None where Python raises or decides.
/// Render one attachment as Python does, with its errors, or None where Python must decide.
#[must_use]
pub fn render_attached_audit(attachment: &AuditAttachment) -> Option<RenderedAudit> {
pub fn render_attached_audit(attachment: &AuditAttachment) -> Option<AuditRendering> {
if attachment.measurement {
if attachment.instance_severity.is_some() || !attachment.has_thresholds {
return None;
}
} else if attachment.has_thresholds || attachment.has_minimum_samples {
return None;
}
let arguments: Vec<(String, ArgumentValue)> = merged_arguments(attachment)?;
let sql_body: String = render_parameterized_sql(&attachment.sql_body, &arguments, false)?;
let arguments: Vec<(String, ArgumentValue)> = match merged_arguments(attachment)? {
Ok(arguments) => arguments,
Err(message) => return Some(AuditRendering::Failed(message)),
};
let sql_body: String = match rendered(attachment, &attachment.sql_body, &arguments)? {
Ok(sql) => sql,
Err(message) => return Some(AuditRendering::Failed(message)),
};
let evidence_sql: Option<String> = match &attachment.evidence_sql {
Some(evidence) => Some(render_parameterized_sql(evidence, &arguments, false)?),
Some(evidence) => match rendered(attachment, evidence, &arguments)? {
Ok(sql) => Some(sql),
Err(message) => return Some(AuditRendering::Failed(message)),
},
None => None,
};
let severity: &'static str = if attachment.has_thresholds {
if attachment.threshold_error {
"error"
} else {
"warn"
}
} else {
resolve_severity(
attachment.instance_severity.as_deref(),
attachment.default_severity.as_deref(),
)?
};
let run_scope_source: PolicySource = resolve_run_scope(
attachment.instance_run_scope.as_deref(),
attachment.default_run_scope.as_deref(),
)?;
Some(RenderedAudit {
Some(AuditRendering::Rendered(RenderedAudit {
sql_body,
evidence_sql,
severity,
run_scope_source,
})
}

/// Python's `merge_audit_arguments`; values that differ only as Python objects defer.
fn merged_arguments(attachment: &AuditAttachment) -> Option<Vec<(String, ArgumentValue)>> {
let mut merged: Vec<(String, ArgumentValue)> = attachment.implicit_arguments.clone();
for (name, value) in &attachment.explicit_arguments {
match merged.iter_mut().find(|(existing, _)| existing == name) {
Some((_, existing)) if existing == value => {}
Some(_) => return None,
None => merged.push((name.clone(), value.clone())),
}
}
Some(merged)
policies: policies(attachment),
}))
}
19 changes: 15 additions & 4 deletions crates/sqlbuild-attachments/src/audits/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub enum ArgumentValue {
Number(String),
Text(String),
List(Vec<ArgumentValue>),
/// A value Python cannot render, such as a mapping; rendering it is Python's error.
Opaque,
}

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

/// The rendered SQL and the policies one attachment resolves to.
/// The rendered SQL and the policies one attachment resolves to; severity is `warn` or `error`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderedAudit {
pub sql_body: String,
pub evidence_sql: Option<String>,
/// `warn` or `error`.
pub severity: &'static str,
pub run_scope_source: PolicySource,
/// `(severity, run scope source)`, or the error Python raises once the SQL is expanded.
pub policies: Result<(&'static str, PolicySource), String>,
}

/// Python's rendering of one attachment: its SQL and policies, or the error rendering raises.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AuditRendering {
Rendered(RenderedAudit),
Failed(String),
}
Loading
Loading