Skip to content

Commit 19ae79e

Browse files
authored
feat(native): report model config errors natively (#1042)
* feat(native): report model config errors natively * fix(native): let the macro bridge decide native validator errors
1 parent 50c830f commit 19ae79e

78 files changed

Lines changed: 3427 additions & 1440 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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! The first config field, in Python's walk order, whose string holds a macro call.
2+
3+
use crate::config_presence::_helpers::macro_calls::macro_call;
4+
use crate::config_presence::models::{MacroPath, Presence};
5+
use crate::types::{AuthoredNode, NodeKind};
6+
7+
/// Walk config values as `validate_no_macros_in_config_value` does, skipping the `skipped` keys.
8+
pub fn first_macro_path<N: AuthoredNode>(values: &N, skipped: &[&str]) -> MacroPath {
9+
let mut entries = values.entries();
10+
entries.retain(|(key, _)| !skipped.iter().any(|name| key.is_text(name)));
11+
walk_entries(&entries, &[])
12+
}
13+
14+
fn walk<N: AuthoredNode>(node: &N, path: &[String]) -> MacroPath {
15+
match node.kind() {
16+
NodeKind::Str => match node.with_text(macro_call) {
17+
Some(Presence::Present) => MacroPath::Found(path.to_vec()),
18+
Some(Presence::Absent) => MacroPath::Absent,
19+
Some(Presence::Deferred) | None => MacroPath::Deferred,
20+
},
21+
NodeKind::Map => walk_entries(&node.entries(), path),
22+
NodeKind::List | NodeKind::Tuple => {
23+
for item in node.items() {
24+
let found = walk(&item, path);
25+
if found != MacroPath::Absent {
26+
return found;
27+
}
28+
}
29+
MacroPath::Absent
30+
}
31+
_ => MacroPath::Absent,
32+
}
33+
}
34+
35+
fn walk_entries<N: AuthoredNode>(entries: &[(N, N)], path: &[String]) -> MacroPath {
36+
for (key, value) in entries {
37+
if key.kind() != NodeKind::Str {
38+
continue;
39+
}
40+
let Some(name) = key.text() else {
41+
return MacroPath::Deferred;
42+
};
43+
let mut child: Vec<String> = path.to_vec();
44+
child.push(name);
45+
let found = walk(value, &child);
46+
if found != MacroPath::Absent {
47+
return found;
48+
}
49+
}
50+
MacroPath::Absent
51+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod contains_macro_call;
22
pub mod contains_template;
3+
pub mod first_macro_path;

‎crates/sqlbuild-model-config/src/config_presence/models.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,13 @@ pub enum Presence {
88
/// A string needs Python's Unicode `\s` class to decide.
99
Deferred,
1010
}
11+
12+
/// Where Python's config walk first finds a macro call.
13+
#[derive(Clone, Debug, PartialEq, Eq)]
14+
pub enum MacroPath {
15+
/// The mapping keys leading to the first string holding a macro call.
16+
Found(Vec<String>),
17+
Absent,
18+
/// A string before any match needs Python's Unicode rules to decide.
19+
Deferred,
20+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//! Errors the native config stages report exactly as the Python stages raise them.
2+
3+
/// The Python exception class one error raises.
4+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5+
pub enum ErrorClass {
6+
/// `CompileInputError`.
7+
CompileInput,
8+
/// `ConfigValueTypeError` for a config key that must hold a string.
9+
ConfigValueType,
10+
/// `ResourceIdentityError` for an identity that is not canonical snake_case.
11+
ResourceIdentity,
12+
/// `DiscoveryConflictError` for equally specific path-default keys.
13+
DiscoveryConflict,
14+
}
15+
16+
/// One error with the class, message, code and help the Python stage raises.
17+
#[derive(Clone, Debug, PartialEq, Eq)]
18+
pub struct ConfigError {
19+
pub class: ErrorClass,
20+
/// The message; empty for `ConfigValueTypeError`, which Python words from the value's type.
21+
pub message: String,
22+
/// The diagnostic code when it is not the class default.
23+
pub code: Option<&'static str>,
24+
pub help: Option<String>,
25+
/// The config key a `ConfigValueTypeError` names.
26+
pub key: Option<String>,
27+
}
28+
29+
impl ConfigError {
30+
/// A `CompileInputError` with this message.
31+
pub fn compile(message: String) -> Self {
32+
Self {
33+
class: ErrorClass::CompileInput,
34+
message,
35+
code: None,
36+
help: None,
37+
key: None,
38+
}
39+
}
40+
41+
/// The `ConfigValueTypeError` `get_config_str` raises for a non-string `key`.
42+
pub fn config_value_type(key: &str) -> Self {
43+
Self {
44+
class: ErrorClass::ConfigValueType,
45+
message: String::new(),
46+
code: None,
47+
help: None,
48+
key: Some(key.to_owned()),
49+
}
50+
}
51+
52+
/// This error of another class.
53+
pub fn with_class(mut self, class: ErrorClass) -> Self {
54+
self.class = class;
55+
self
56+
}
57+
58+
/// This error with help text.
59+
pub fn with_help(mut self, help: impl Into<String>) -> Self {
60+
self.help = Some(help.into());
61+
self
62+
}
63+
64+
/// This error with a diagnostic code.
65+
pub fn with_code(mut self, code: &'static str) -> Self {
66+
self.code = Some(code);
67+
self
68+
}
69+
}
Lines changed: 71 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,100 @@
11
//! Audit lists and instances, as `parse_audit_instances` reads them with `null_as_empty`.
22
3+
use crate::header_metadata::_helpers::identity::check_identity;
34
use crate::header_metadata::_helpers::text::{
4-
entry, is_snake_case, non_blank_text, optional_bool, optional_count, optional_text,
5+
Site, entry, non_blank_text, optional_bool, optional_count, optional_text,
56
};
67
use crate::header_metadata::constants::{AUDIT_OPTION_KEYS, AUDIT_SEVERITIES};
7-
use crate::header_metadata::models::{HeaderMetadataDeferral, ParsedAudit};
8+
use crate::header_metadata::models::{HeaderMetadataStop, ParsedAudit};
89
use crate::types::{AuthoredNode, NodeKind};
910

1011
/// Parse an authored audit list; Python's `None` is an empty list.
1112
pub(crate) fn audit_list<N: AuthoredNode>(
1213
node: &N,
13-
) -> Result<Vec<ParsedAudit<N>>, HeaderMetadataDeferral> {
14+
site: Site<'_>,
15+
) -> Result<Vec<ParsedAudit<N>>, HeaderMetadataStop> {
1416
match node.kind() {
1517
NodeKind::Null => Ok(Vec::new()),
16-
NodeKind::List => node.items().iter().map(audit_instance).collect(),
17-
_ => Err(HeaderMetadataDeferral::Invalid),
18+
NodeKind::List => node
19+
.items()
20+
.iter()
21+
.map(|item| audit_instance(item, site))
22+
.collect(),
23+
_ => Err(site.error("audits must be a list")),
1824
}
1925
}
2026

21-
fn audit_instance<N: AuthoredNode>(node: &N) -> Result<ParsedAudit<N>, HeaderMetadataDeferral> {
22-
match node.kind() {
23-
NodeKind::Str => {
24-
identity(node)?;
25-
Ok(bare_audit(node.clone()))
26-
}
27-
NodeKind::Map => {
28-
let entries = node.entries();
29-
let [(definition_name, arguments)] = entries.as_slice() else {
30-
return Err(HeaderMetadataDeferral::Invalid);
31-
};
32-
identity(definition_name)?;
33-
match arguments.kind() {
34-
NodeKind::Null => Ok(bare_audit(definition_name.clone())),
35-
NodeKind::Map => configured_audit(definition_name, &arguments.entries()),
36-
_ => Err(HeaderMetadataDeferral::Invalid),
37-
}
38-
}
39-
_ => Err(HeaderMetadataDeferral::Invalid),
27+
fn audit_instance<N: AuthoredNode>(
28+
node: &N,
29+
site: Site<'_>,
30+
) -> Result<ParsedAudit<N>, HeaderMetadataStop> {
31+
let definition_kind = format!("{} audit definition", site.label);
32+
if node.kind() == NodeKind::Str {
33+
let Some(text) = non_blank_text(node)? else {
34+
return Err(site.error("audits must not contain empty names"));
35+
};
36+
check_identity(&text, &definition_kind, site.path)?;
37+
return Ok(bare_audit(node.clone()));
38+
}
39+
let entries = node.entries();
40+
let [(definition_name, arguments)] = entries.as_slice() else {
41+
return Err(site.error("audits must be strings or single-key mappings"));
42+
};
43+
if node.kind() != NodeKind::Map {
44+
return Err(site.error("audits must be strings or single-key mappings"));
45+
}
46+
let Some(definition) = non_blank_text(definition_name)? else {
47+
return Err(site.error("audit names must be non-empty strings"));
48+
};
49+
check_identity(&definition, &definition_kind, site.path)?;
50+
match arguments.kind() {
51+
NodeKind::Null => Ok(bare_audit(definition_name.clone())),
52+
NodeKind::Map => configured_audit(definition_name, &definition, &arguments.entries(), site),
53+
_ => Err(site.error(&format!("audit '{definition}' arguments must be a mapping"))),
4054
}
4155
}
4256

4357
fn configured_audit<N: AuthoredNode>(
4458
definition_name: &N,
59+
definition: &str,
4560
options: &[(N, N)],
46-
) -> Result<ParsedAudit<N>, HeaderMetadataDeferral> {
47-
let name = optional_text(entry(options, "name"))?;
61+
site: Site<'_>,
62+
) -> Result<ParsedAudit<N>, HeaderMetadataStop> {
63+
let option_label = format!("{} audit '{definition}'", site.label);
64+
let option_site = Site {
65+
path: site.path,
66+
label: &option_label,
67+
};
68+
let name = optional_text(entry(options, "name"), option_site, "name")?;
4869
if let Some(name) = &name {
49-
identity(name)?;
70+
let text = name.text().ok_or(HeaderMetadataStop::Unsupported)?;
71+
check_identity(&text, &format!("{} audit instance", site.label), site.path)?;
5072
}
51-
let description = optional_text(entry(options, "description"))?;
52-
let severity = optional_text(entry(options, "severity"))?;
53-
if let Some(severity) = &severity {
54-
let text = severity.text().ok_or(HeaderMetadataDeferral::Unsupported)?;
55-
if !AUDIT_SEVERITIES.contains(&text.as_str()) {
56-
return Err(HeaderMetadataDeferral::Invalid);
57-
}
73+
let description = optional_text(entry(options, "description"), option_site, "description")?;
74+
let severity = optional_text(entry(options, "severity"), option_site, "severity")?;
75+
if let Some(severity) = &severity
76+
&& !AUDIT_SEVERITIES.iter().any(|value| severity.is_text(value))
77+
{
78+
return Err(option_site.error(&format!(
79+
"'severity' must be one of: {}",
80+
AUDIT_SEVERITIES.join(", ")
81+
)));
5882
}
59-
let run_scope = optional_text(entry(options, "run_scope"))?;
60-
let always_run = optional_bool(entry(options, "always_run"))?;
83+
let run_scope = optional_text(entry(options, "run_scope"), option_site, "run_scope")?;
84+
let always_run = optional_bool(entry(options, "always_run"), option_site, "always_run")?;
6185
if entry(options, "thresholds").is_some_and(|value| value.kind() != NodeKind::Null) {
62-
return Err(HeaderMetadataDeferral::Unsupported);
86+
return Err(HeaderMetadataStop::Unsupported);
6387
}
64-
let minimum_samples = optional_count(entry(options, "minimum_samples"))?;
65-
let evidence_limit = optional_count(entry(options, "evidence_limit"))?;
88+
let minimum_samples = optional_count(
89+
entry(options, "minimum_samples"),
90+
option_site,
91+
"minimum_samples",
92+
)?;
93+
let evidence_limit = optional_count(
94+
entry(options, "evidence_limit"),
95+
option_site,
96+
"evidence_limit",
97+
)?;
6698
let arguments: Vec<(N, N)> = options
6799
.iter()
68100
.filter(|(key, _)| !is_option_key(key))
@@ -95,14 +127,6 @@ fn bare_audit<N: AuthoredNode>(definition_name: N) -> ParsedAudit<N> {
95127
}
96128
}
97129

98-
fn identity<N: AuthoredNode>(node: &N) -> Result<(), HeaderMetadataDeferral> {
99-
if is_snake_case(&non_blank_text(node)?) {
100-
Ok(())
101-
} else {
102-
Err(HeaderMetadataDeferral::Invalid)
103-
}
104-
}
105-
106130
fn is_option_key<N: AuthoredNode>(key: &N) -> bool {
107131
AUDIT_OPTION_KEYS.iter().any(|name| key.is_text(name))
108132
}

0 commit comments

Comments
 (0)