From 869f2569b120bbbb8774fe4aff4bcb2c35bc6850 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:06:10 +0100 Subject: [PATCH 1/2] feat(native): report model config errors natively --- .../config_presence/main/first_macro_path.rs | 51 +++ .../src/config_presence/main/mod.rs | 1 + .../src/config_presence/models.rs | 10 + crates/sqlbuild-model-config/src/errors.rs | 69 +++ .../src/header_metadata/_helpers/audits.rs | 118 +++-- .../src/header_metadata/_helpers/columns.rs | 118 +++-- .../src/header_metadata/_helpers/identity.rs | 84 ++++ .../src/header_metadata/_helpers/mod.rs | 1 + .../src/header_metadata/_helpers/text.rs | 85 ++-- .../src/header_metadata/constants.rs | 10 + .../main/parse_header_metadata.rs | 25 +- .../src/header_metadata/models.rs | 18 +- .../src/header_metadata/tests/helpers.rs | 35 +- .../tests/test_parse_header_metadata.rs | 76 ++-- .../src/header_metadata/tests/test_types.rs | 5 +- crates/sqlbuild-model-config/src/lib.rs | 1 + .../src/model_validation/_helpers/config.rs | 140 ++++-- .../_helpers/cursor_bounds.rs | 248 ++++++++-- .../model_validation/_helpers/incremental.rs | 428 ++++++++++++------ .../_helpers/materialization.rs | 256 +++++++---- .../model_validation/_helpers/microbatch.rs | 403 ++++++++++++----- .../model_validation/_helpers/references.rs | 79 +++- .../src/model_validation/_helpers/snapshot.rs | 280 +++++++++--- .../src/model_validation/_helpers/text.rs | 10 + .../src/model_validation/constants.rs | 18 + .../src/model_validation/main/mod.rs | 2 +- .../main/retention_override.rs | 26 +- .../main/table_type_override.rs | 23 +- ...del_config.rs => validate_model_config.rs} | 22 +- .../src/model_validation/models.rs | 21 +- .../src/model_validation/tests.rs | 4 +- .../src/model_validation/tests/helpers.rs | 51 ++- .../tests/test_storage_overrides.rs | 20 +- .../src/model_validation/tests/test_types.rs | 13 +- ...onfig.rs => test_validate_model_config.rs} | 146 +++--- .../src/model_validation/types.rs | 6 +- .../src/path_defaults/_helpers/matching.rs | 30 +- .../src/path_defaults/constants.rs | 3 + .../path_defaults/main/select_path_default.rs | 11 +- .../src/path_defaults/models.rs | 6 +- .../tests/test_select_path_default.rs | 12 +- .../src/templates/_helpers/evaluation.rs | 33 +- .../src/templates/_helpers/expressions.rs | 33 +- .../src/templates/_helpers/tokens.rs | 65 ++- .../src/templates/errors.rs | 87 ++-- .../src/templates/main/mod.rs | 1 + .../templates/main/template_error_message.rs | 45 ++ .../src/templates/models.rs | 2 +- .../tests/test_expand_template_string.rs | 57 +-- .../_helpers/model_config/config_errors.rs | 40 ++ .../_helpers/model_config/config_templates.rs | 45 +- .../bindings/_helpers/model_config/errors.rs | 13 + .../_helpers/model_config/header_metadata.rs | 86 ++-- .../src/bindings/_helpers/model_config/mod.rs | 2 + .../model_config/model_config_builder.rs | 187 +++++--- .../_helpers/model_config/model_validator.rs | 36 +- .../_helpers/model_config/registration.rs | 4 +- .../_helpers/corpus/config_failure_cases.py | 137 +++++- src/sqlbuild/_native.pyi | 29 +- .../compile/_helpers/attachment/functions.py | 2 - .../_helpers/attachment/model_config.py | 61 ++- .../compile/_helpers/attachment/sources.py | 1 - .../_helpers/render/context_templates.py | 28 +- .../compiler/model_config/constants.py | 6 +- .../main/_expand_native_config_templates.py | 25 +- .../model_config/main/_native_config_error.py | 35 ++ .../main/_parse_native_header_metadata.py | 46 +- src/sqlbuild/compiler/model_config/models.py | 8 +- src/sqlbuild/compiler/model_config/types.py | 11 + .../cli/commands/main/compile/_test_types.py | 11 + .../cli/commands/main/compile/helpers.py | 53 +++ .../main/compile/test_native_config_errors.py | 180 ++++++++ .../native_model_config/_test_types.py | 10 +- .../compile/native_model_config/helpers.py | 129 ++++-- .../test_native_model_config_session.py | 165 ++++--- .../compiler/model_config/_test_types.py | 9 - .../sqlbuild/compiler/model_config/helpers.py | 108 ++--- .../model_config/test_native_model_config.py | 34 -- 78 files changed, 3349 insertions(+), 1440 deletions(-) create mode 100644 crates/sqlbuild-model-config/src/config_presence/main/first_macro_path.rs create mode 100644 crates/sqlbuild-model-config/src/errors.rs create mode 100644 crates/sqlbuild-model-config/src/header_metadata/_helpers/identity.rs rename crates/sqlbuild-model-config/src/model_validation/main/{accept_model_config.rs => validate_model_config.rs} (57%) rename crates/sqlbuild-model-config/src/model_validation/tests/{test_accept_model_config.rs => test_validate_model_config.rs} (71%) create mode 100644 crates/sqlbuild-model-config/src/templates/main/template_error_message.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/model_config/config_errors.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/model_config/errors.rs create mode 100644 src/sqlbuild/compiler/model_config/main/_native_config_error.py create mode 100644 src/sqlbuild/compiler/model_config/types.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_config_errors.py diff --git a/crates/sqlbuild-model-config/src/config_presence/main/first_macro_path.rs b/crates/sqlbuild-model-config/src/config_presence/main/first_macro_path.rs new file mode 100644 index 0000000000..2490eb32ae --- /dev/null +++ b/crates/sqlbuild-model-config/src/config_presence/main/first_macro_path.rs @@ -0,0 +1,51 @@ +//! The first config field, in Python's walk order, whose string holds a macro call. + +use crate::config_presence::_helpers::macro_calls::macro_call; +use crate::config_presence::models::{MacroPath, Presence}; +use crate::types::{AuthoredNode, NodeKind}; + +/// Walk config values as `validate_no_macros_in_config_value` does, skipping the `skipped` keys. +pub fn first_macro_path(values: &N, skipped: &[&str]) -> MacroPath { + let mut entries = values.entries(); + entries.retain(|(key, _)| !skipped.iter().any(|name| key.is_text(name))); + walk_entries(&entries, &[]) +} + +fn walk(node: &N, path: &[String]) -> MacroPath { + match node.kind() { + NodeKind::Str => match node.with_text(macro_call) { + Some(Presence::Present) => MacroPath::Found(path.to_vec()), + Some(Presence::Absent) => MacroPath::Absent, + Some(Presence::Deferred) | None => MacroPath::Deferred, + }, + NodeKind::Map => walk_entries(&node.entries(), path), + NodeKind::List | NodeKind::Tuple => { + for item in node.items() { + let found = walk(&item, path); + if found != MacroPath::Absent { + return found; + } + } + MacroPath::Absent + } + _ => MacroPath::Absent, + } +} + +fn walk_entries(entries: &[(N, N)], path: &[String]) -> MacroPath { + for (key, value) in entries { + if key.kind() != NodeKind::Str { + continue; + } + let Some(name) = key.text() else { + return MacroPath::Deferred; + }; + let mut child: Vec = path.to_vec(); + child.push(name); + let found = walk(value, &child); + if found != MacroPath::Absent { + return found; + } + } + MacroPath::Absent +} diff --git a/crates/sqlbuild-model-config/src/config_presence/main/mod.rs b/crates/sqlbuild-model-config/src/config_presence/main/mod.rs index 36a864d560..03e068523b 100644 --- a/crates/sqlbuild-model-config/src/config_presence/main/mod.rs +++ b/crates/sqlbuild-model-config/src/config_presence/main/mod.rs @@ -1,2 +1,3 @@ pub mod contains_macro_call; pub mod contains_template; +pub mod first_macro_path; diff --git a/crates/sqlbuild-model-config/src/config_presence/models.rs b/crates/sqlbuild-model-config/src/config_presence/models.rs index cecf2b46d0..38b270b275 100644 --- a/crates/sqlbuild-model-config/src/config_presence/models.rs +++ b/crates/sqlbuild-model-config/src/config_presence/models.rs @@ -8,3 +8,13 @@ pub enum Presence { /// A string needs Python's Unicode `\s` class to decide. Deferred, } + +/// Where Python's config walk first finds a macro call. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MacroPath { + /// The mapping keys leading to the first string holding a macro call. + Found(Vec), + Absent, + /// A string before any match needs Python's Unicode rules to decide. + Deferred, +} diff --git a/crates/sqlbuild-model-config/src/errors.rs b/crates/sqlbuild-model-config/src/errors.rs new file mode 100644 index 0000000000..ebd6e72e65 --- /dev/null +++ b/crates/sqlbuild-model-config/src/errors.rs @@ -0,0 +1,69 @@ +//! Errors the native config stages report exactly as the Python stages raise them. + +/// The Python exception class one error raises. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ErrorClass { + /// `CompileInputError`. + CompileInput, + /// `ConfigValueTypeError` for a config key that must hold a string. + ConfigValueType, + /// `ResourceIdentityError` for an identity that is not canonical snake_case. + ResourceIdentity, + /// `DiscoveryConflictError` for equally specific path-default keys. + DiscoveryConflict, +} + +/// One error with the class, message, code and help the Python stage raises. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigError { + pub class: ErrorClass, + /// The message; empty for `ConfigValueTypeError`, which Python words from the value's type. + pub message: String, + /// The diagnostic code when it is not the class default. + pub code: Option<&'static str>, + pub help: Option, + /// The config key a `ConfigValueTypeError` names. + pub key: Option, +} + +impl ConfigError { + /// A `CompileInputError` with this message. + pub fn compile(message: String) -> Self { + Self { + class: ErrorClass::CompileInput, + message, + code: None, + help: None, + key: None, + } + } + + /// The `ConfigValueTypeError` `get_config_str` raises for a non-string `key`. + pub fn config_value_type(key: &str) -> Self { + Self { + class: ErrorClass::ConfigValueType, + message: String::new(), + code: None, + help: None, + key: Some(key.to_owned()), + } + } + + /// This error of another class. + pub fn with_class(mut self, class: ErrorClass) -> Self { + self.class = class; + self + } + + /// This error with help text. + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// This error with a diagnostic code. + pub fn with_code(mut self, code: &'static str) -> Self { + self.code = Some(code); + self + } +} diff --git a/crates/sqlbuild-model-config/src/header_metadata/_helpers/audits.rs b/crates/sqlbuild-model-config/src/header_metadata/_helpers/audits.rs index 3431fa0d4f..5e7915de96 100644 --- a/crates/sqlbuild-model-config/src/header_metadata/_helpers/audits.rs +++ b/crates/sqlbuild-model-config/src/header_metadata/_helpers/audits.rs @@ -1,68 +1,100 @@ //! Audit lists and instances, as `parse_audit_instances` reads them with `null_as_empty`. +use crate::header_metadata::_helpers::identity::check_identity; use crate::header_metadata::_helpers::text::{ - entry, is_snake_case, non_blank_text, optional_bool, optional_count, optional_text, + Site, entry, non_blank_text, optional_bool, optional_count, optional_text, }; use crate::header_metadata::constants::{AUDIT_OPTION_KEYS, AUDIT_SEVERITIES}; -use crate::header_metadata::models::{HeaderMetadataDeferral, ParsedAudit}; +use crate::header_metadata::models::{HeaderMetadataStop, ParsedAudit}; use crate::types::{AuthoredNode, NodeKind}; /// Parse an authored audit list; Python's `None` is an empty list. pub(crate) fn audit_list( node: &N, -) -> Result>, HeaderMetadataDeferral> { + site: Site<'_>, +) -> Result>, HeaderMetadataStop> { match node.kind() { NodeKind::Null => Ok(Vec::new()), - NodeKind::List => node.items().iter().map(audit_instance).collect(), - _ => Err(HeaderMetadataDeferral::Invalid), + NodeKind::List => node + .items() + .iter() + .map(|item| audit_instance(item, site)) + .collect(), + _ => Err(site.error("audits must be a list")), } } -fn audit_instance(node: &N) -> Result, HeaderMetadataDeferral> { - match node.kind() { - NodeKind::Str => { - identity(node)?; - Ok(bare_audit(node.clone())) - } - NodeKind::Map => { - let entries = node.entries(); - let [(definition_name, arguments)] = entries.as_slice() else { - return Err(HeaderMetadataDeferral::Invalid); - }; - identity(definition_name)?; - match arguments.kind() { - NodeKind::Null => Ok(bare_audit(definition_name.clone())), - NodeKind::Map => configured_audit(definition_name, &arguments.entries()), - _ => Err(HeaderMetadataDeferral::Invalid), - } - } - _ => Err(HeaderMetadataDeferral::Invalid), +fn audit_instance( + node: &N, + site: Site<'_>, +) -> Result, HeaderMetadataStop> { + let definition_kind = format!("{} audit definition", site.label); + if node.kind() == NodeKind::Str { + let Some(text) = non_blank_text(node)? else { + return Err(site.error("audits must not contain empty names")); + }; + check_identity(&text, &definition_kind, site.path)?; + return Ok(bare_audit(node.clone())); + } + let entries = node.entries(); + let [(definition_name, arguments)] = entries.as_slice() else { + return Err(site.error("audits must be strings or single-key mappings")); + }; + if node.kind() != NodeKind::Map { + return Err(site.error("audits must be strings or single-key mappings")); + } + let Some(definition) = non_blank_text(definition_name)? else { + return Err(site.error("audit names must be non-empty strings")); + }; + check_identity(&definition, &definition_kind, site.path)?; + match arguments.kind() { + NodeKind::Null => Ok(bare_audit(definition_name.clone())), + NodeKind::Map => configured_audit(definition_name, &definition, &arguments.entries(), site), + _ => Err(site.error(&format!("audit '{definition}' arguments must be a mapping"))), } } fn configured_audit( definition_name: &N, + definition: &str, options: &[(N, N)], -) -> Result, HeaderMetadataDeferral> { - let name = optional_text(entry(options, "name"))?; + site: Site<'_>, +) -> Result, HeaderMetadataStop> { + let option_label = format!("{} audit '{definition}'", site.label); + let option_site = Site { + path: site.path, + label: &option_label, + }; + let name = optional_text(entry(options, "name"), option_site, "name")?; if let Some(name) = &name { - identity(name)?; + let text = name.text().ok_or(HeaderMetadataStop::Unsupported)?; + check_identity(&text, &format!("{} audit instance", site.label), site.path)?; } - let description = optional_text(entry(options, "description"))?; - let severity = optional_text(entry(options, "severity"))?; - if let Some(severity) = &severity { - let text = severity.text().ok_or(HeaderMetadataDeferral::Unsupported)?; - if !AUDIT_SEVERITIES.contains(&text.as_str()) { - return Err(HeaderMetadataDeferral::Invalid); - } + let description = optional_text(entry(options, "description"), option_site, "description")?; + let severity = optional_text(entry(options, "severity"), option_site, "severity")?; + if let Some(severity) = &severity + && !AUDIT_SEVERITIES.iter().any(|value| severity.is_text(value)) + { + return Err(option_site.error(&format!( + "'severity' must be one of: {}", + AUDIT_SEVERITIES.join(", ") + ))); } - let run_scope = optional_text(entry(options, "run_scope"))?; - let always_run = optional_bool(entry(options, "always_run"))?; + let run_scope = optional_text(entry(options, "run_scope"), option_site, "run_scope")?; + let always_run = optional_bool(entry(options, "always_run"), option_site, "always_run")?; if entry(options, "thresholds").is_some_and(|value| value.kind() != NodeKind::Null) { - return Err(HeaderMetadataDeferral::Unsupported); + return Err(HeaderMetadataStop::Unsupported); } - let minimum_samples = optional_count(entry(options, "minimum_samples"))?; - let evidence_limit = optional_count(entry(options, "evidence_limit"))?; + let minimum_samples = optional_count( + entry(options, "minimum_samples"), + option_site, + "minimum_samples", + )?; + let evidence_limit = optional_count( + entry(options, "evidence_limit"), + option_site, + "evidence_limit", + )?; let arguments: Vec<(N, N)> = options .iter() .filter(|(key, _)| !is_option_key(key)) @@ -95,14 +127,6 @@ fn bare_audit(definition_name: N) -> ParsedAudit { } } -fn identity(node: &N) -> Result<(), HeaderMetadataDeferral> { - if is_snake_case(&non_blank_text(node)?) { - Ok(()) - } else { - Err(HeaderMetadataDeferral::Invalid) - } -} - fn is_option_key(key: &N) -> bool { AUDIT_OPTION_KEYS.iter().any(|name| key.is_text(name)) } diff --git a/crates/sqlbuild-model-config/src/header_metadata/_helpers/columns.rs b/crates/sqlbuild-model-config/src/header_metadata/_helpers/columns.rs index 82f8fae2ab..df77dcdcb0 100644 --- a/crates/sqlbuild-model-config/src/header_metadata/_helpers/columns.rs +++ b/crates/sqlbuild-model-config/src/header_metadata/_helpers/columns.rs @@ -2,63 +2,75 @@ use std::collections::HashSet; +use crate::errors::ConfigError; use crate::header_metadata::_helpers::audits::audit_list; -use crate::header_metadata::_helpers::text::{entry, non_blank_text, optional_bool, optional_text}; -use crate::header_metadata::constants::{MODEL_COLUMN_KEYS, NOT_NULL_AUDIT_NAME}; -use crate::header_metadata::models::{HeaderMetadataDeferral, ParsedColumn}; +use crate::header_metadata::_helpers::text::{ + Site, entry, non_blank_text, optional_bool, optional_text, +}; +use crate::header_metadata::constants::{ + MODEL_COLUMN_KEYS, MODEL_LABEL, NOT_NULL_AUDIT_NAME, NULLABLE_NOT_NULL_CODE, +}; +use crate::header_metadata::models::{HeaderMetadataStop, ParsedColumn}; use crate::types::{AuthoredNode, NodeKind}; /// Parse an authored columns mapping; Python's `None` declares no columns. pub(crate) fn column_mapping( node: &N, -) -> Result>, HeaderMetadataDeferral> { + path: &str, +) -> Result>, HeaderMetadataStop> { + let site = Site { + path, + label: MODEL_LABEL, + }; match node.kind() { - NodeKind::Null => Ok(Vec::new()), - NodeKind::Map => { - let columns: Vec> = node - .entries() - .iter() - .map(|(name, metadata)| column(name, metadata)) - .collect::>()?; - if has_duplicate_names(&columns) { - Err(HeaderMetadataDeferral::Invalid) - } else { - Ok(columns) - } - } - _ => Err(HeaderMetadataDeferral::Invalid), + NodeKind::Null => return Ok(Vec::new()), + NodeKind::Map => {} + _ => return Err(site.error("'columns' must be a mapping")), } -} - -/// Return whether two ASCII column names are equal under Python's `str.lower()`. -fn has_duplicate_names(columns: &[ParsedColumn]) -> bool { let mut seen: HashSet = HashSet::new(); - !columns.iter().all(|column| { - column - .name - .text() - .is_some_and(|name| seen.insert(name.to_ascii_lowercase())) - }) + let mut columns: Vec> = Vec::new(); + for (name, metadata) in node.entries() { + let Some(text) = non_blank_text(&name)? else { + return Err(site.error("column names must be non-empty strings")); + }; + if !text.is_ascii() { + return Err(HeaderMetadataStop::Unsupported); + } + if !seen.insert(text.to_ascii_lowercase()) { + return Err(site.error(&format!( + "has duplicate column '{text}' (column names are case-insensitive)" + ))); + } + columns.push(column(&name, &text, &metadata, path)?); + } + Ok(columns) } fn column( name: &N, + text: &str, metadata: &N, -) -> Result, HeaderMetadataDeferral> { - let text = non_blank_text(name)?; - if !text.is_ascii() { - return Err(HeaderMetadataDeferral::Unsupported); - } + path: &str, +) -> Result, HeaderMetadataStop> { + let label = format!("{MODEL_LABEL} column '{text}'"); + let site = Site { + path, + label: &label, + }; if metadata.kind() != NodeKind::Map { - return Err(HeaderMetadataDeferral::Invalid); + return Err(site.error("metadata must be a mapping")); } let entries = metadata.entries(); - if !entries.iter().all(|(key, _)| is_column_key(key)) { - return Err(HeaderMetadataDeferral::Invalid); + let unknown = unknown_keys(&entries)?; + if !unknown.is_empty() { + return Err(site.error(&format!( + "has unknown metadata keys: {}", + unknown.join(", ") + ))); } - let nullable = optional_bool(entry(&entries, "nullable"))?; + let nullable = optional_bool(entry(&entries, "nullable"), site, "nullable")?; let audits = match entry(&entries, "audits") { - Some(value) => audit_list(value)?, + Some(value) => audit_list(value, site)?, None => Vec::new(), }; let declares_nullable = nullable @@ -69,18 +81,36 @@ fn column( .iter() .any(|audit| audit.definition_name.is_text(NOT_NULL_AUDIT_NAME)) { - return Err(HeaderMetadataDeferral::Invalid); + return Err(HeaderMetadataStop::Error( + ConfigError::compile(format!( + "{path} column '{text}' cannot set nullable = true and audit not_null" + )) + .with_code(NULLABLE_NOT_NULL_CODE) + .with_help("remove the not_null audit or set nullable = false"), + )); } Ok(ParsedColumn { name: name.clone(), - column_type: optional_text(entry(&entries, "type"))?, + column_type: optional_text(entry(&entries, "type"), site, "type")?, nullable, - description: optional_text(entry(&entries, "description"))?, + description: optional_text(entry(&entries, "description"), site, "description")?, audits, - migrate_from: optional_text(entry(&entries, "migrate_from"))?, + migrate_from: optional_text(entry(&entries, "migrate_from"), site, "migrate_from")?, }) } -fn is_column_key(key: &N) -> bool { - MODEL_COLUMN_KEYS.iter().any(|name| key.is_text(name)) +/// Return the sorted metadata keys outside `MODEL_COLUMN_KEYS`, deferring keys Python cannot sort. +fn unknown_keys(entries: &[(N, N)]) -> Result, HeaderMetadataStop> { + let mut unknown: Vec = Vec::new(); + for (key, _) in entries { + if MODEL_COLUMN_KEYS.iter().any(|name| key.is_text(name)) { + continue; + } + if key.kind() != NodeKind::Str { + return Err(HeaderMetadataStop::Unsupported); + } + unknown.push(key.text().ok_or(HeaderMetadataStop::Unsupported)?); + } + unknown.sort_unstable(); + Ok(unknown) } diff --git a/crates/sqlbuild-model-config/src/header_metadata/_helpers/identity.rs b/crates/sqlbuild-model-config/src/header_metadata/_helpers/identity.rs new file mode 100644 index 0000000000..c046a733ab --- /dev/null +++ b/crates/sqlbuild-model-config/src/header_metadata/_helpers/identity.rs @@ -0,0 +1,84 @@ +//! `validate_resource_identity` for ASCII names, with its suggested snake_case spelling. + +use crate::errors::{ConfigError, ErrorClass}; +use crate::header_metadata::_helpers::text::ascii_strip; +use crate::header_metadata::constants::{FALLBACK_IDENTITY, IDENTITY_HELP}; +use crate::header_metadata::models::HeaderMetadataStop; + +/// Raise `ResourceIdentityError` unless `name` is canonical snake_case; defer non-ASCII names. +pub(crate) fn check_identity(name: &str, kind: &str, path: &str) -> Result<(), HeaderMetadataStop> { + if is_snake_case(name) { + return Ok(()); + } + if !name.is_ascii() { + return Err(HeaderMetadataStop::Unsupported); + } + let suggestion = suggested_identity(name); + Err(HeaderMetadataStop::Error( + ConfigError::compile(format!( + "Invalid {kind} identity '{name}' in {path}; use snake_case '{suggestion}'" + )) + .with_class(ErrorClass::ResourceIdentity) + .with_help(IDENTITY_HELP), + )) +} + +/// Return whether `name` fully matches `^[a-z](?:[a-z0-9_]*[a-z0-9])?$`. +fn is_snake_case(name: &str) -> bool { + let bytes = name.as_bytes(); + match (bytes.first(), bytes.last()) { + (Some(first), Some(last)) => { + first.is_ascii_lowercase() + && (last.is_ascii_lowercase() || last.is_ascii_digit()) + && bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') + } + _ => false, + } +} + +/// Return `suggest_resource_identity` for an ASCII public identity. +fn suggested_identity(name: &str) -> String { + let characters: Vec = ascii_strip(name).chars().collect(); + let acronyms = split_where(&characters, |index| { + characters[index - 1].is_ascii_uppercase() + && characters[index].is_ascii_uppercase() + && characters + .get(index + 1) + .is_some_and(char::is_ascii_lowercase) + }); + let words = split_where(&acronyms, |index| { + (acronyms[index - 1].is_ascii_lowercase() || acronyms[index - 1].is_ascii_digit()) + && acronyms[index].is_ascii_uppercase() + }); + let mut replaced = String::with_capacity(words.len()); + let mut in_invalid_run = false; + for character in words { + if character.is_ascii_alphanumeric() || character == '_' { + replaced.push(character.to_ascii_lowercase()); + in_invalid_run = false; + } else if !in_invalid_run { + replaced.push('_'); + in_invalid_run = true; + } + } + let corrected = replaced.trim_matches('_'); + if corrected.is_empty() { + FALLBACK_IDENTITY.to_owned() + } else { + corrected.to_owned() + } +} + +/// Insert `_` before every index after the first where `boundary` holds. +fn split_where(characters: &[char], boundary: impl Fn(usize) -> bool) -> Vec { + let mut result: Vec = Vec::with_capacity(characters.len() * 2); + for (index, character) in characters.iter().enumerate() { + if index > 0 && boundary(index) { + result.push('_'); + } + result.push(*character); + } + result +} diff --git a/crates/sqlbuild-model-config/src/header_metadata/_helpers/mod.rs b/crates/sqlbuild-model-config/src/header_metadata/_helpers/mod.rs index 6e200497a7..3e2c0f3b41 100644 --- a/crates/sqlbuild-model-config/src/header_metadata/_helpers/mod.rs +++ b/crates/sqlbuild-model-config/src/header_metadata/_helpers/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod audits; pub(crate) mod columns; +pub(crate) mod identity; pub(crate) mod text; diff --git a/crates/sqlbuild-model-config/src/header_metadata/_helpers/text.rs b/crates/sqlbuild-model-config/src/header_metadata/_helpers/text.rs index b1829e6728..14989b1747 100644 --- a/crates/sqlbuild-model-config/src/header_metadata/_helpers/text.rs +++ b/crates/sqlbuild-model-config/src/header_metadata/_helpers/text.rs @@ -1,71 +1,85 @@ -//! Text checks that agree with Python's `str` methods or defer. +//! Text checks that agree with Python's `str` methods or defer, and the errors they raise. -use crate::header_metadata::models::HeaderMetadataDeferral; +use crate::errors::ConfigError; +use crate::header_metadata::models::HeaderMetadataStop; use crate::types::{AuthoredNode, NodeKind}; -/// Return the text of a string whose `strip()` Python finds non-empty. -pub(crate) fn non_blank_text(node: &N) -> Result { +/// Where one parse reports its errors: the file path and the label the messages name. +#[derive(Clone, Copy)] +pub(crate) struct Site<'a> { + pub(crate) path: &'a str, + pub(crate) label: &'a str, +} + +impl Site<'_> { + /// The `CompileInputError` `