diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs new file mode 100644 index 0000000000..7e7fda68b8 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/columns.rs @@ -0,0 +1,295 @@ +//! Python's `collect_model_column_contract_diagnostics`, in its exact emission order. + +use std::collections::{HashMap, HashSet}; + +use crate::contracts::_helpers::dynamic::dynamic_column_diagnostics; +use crate::contracts::_helpers::type_comparison::{TypeComparison, types_equal}; +use crate::contracts::constants::{ + CONTRACT_ENFORCED, CONTRACT_NONE, DYNAMIC_OUTPUT_NOT_PROVEN_CODE, EXTRA_COLUMN_CODE, + EXTRA_COLUMN_HELP, EXTRA_COLUMN_NAMED_SCHEMA_HELP, MISSING_COLUMN_CODE, + MISSING_DECLARATIONS_CODE, MISSING_DECLARATIONS_HELP, MODEL_COLUMNS_DECLARATION, + NAMED_SCHEMA_DECLARATION, NULLABILITY_HELP, NULLABILITY_MISMATCH_CODE, NULLABLE_OUTPUT_MESSAGE, + TYPE_MISMATCH_CODE, TYPE_MISMATCH_HELP, UNKNOWN_TYPE_CODE, UNKNOWN_TYPE_HELP, + UNPROVEN_TYPE_OUTPUT_MESSAGE, +}; +use crate::contracts::models::{ + ContractDeferral, ContractDiagnostic, ContractLocation, ContractModel, ContractSchema, + ContractSeverity, DeclaredColumn, InferredOutputColumn, RelatedOutput, +}; + +/// Python's `_requires_contract_evaluation`. +pub(crate) fn requires_contract_evaluation(model: &ContractModel, implicit: bool) -> bool { + declared_shape_validation_is_active(model, implicit) + || model + .schema + .as_ref() + .is_some_and(|schema| schema.type_enforcement) +} + +/// Python's `_declared_shape_validation_is_active`. +pub(crate) fn declared_shape_validation_is_active(model: &ContractModel, implicit: bool) -> bool { + match model.contract.as_deref() { + Some(CONTRACT_ENFORCED) => true, + Some(CONTRACT_NONE) => false, + _ => { + implicit + && model + .schema + .as_ref() + .is_some_and(|schema| !schema.columns.is_empty()) + } + } +} + +/// One model's contract diagnostics, or the reason Python must evaluate it. +pub(crate) fn model_contract_diagnostics( + model: &ContractModel, + validate_declared_shape: bool, + dialect: &str, +) -> Result, ContractDeferral> { + let contract_enforced: bool = model.contract.as_deref() == Some(CONTRACT_ENFORCED); + let Some(schema) = model + .schema + .as_ref() + .filter(|schema| !schema.columns.is_empty() || !schema.dynamic_columns.is_empty()) + else { + return Ok(if contract_enforced { + vec![missing_declarations(model)] + } else { + Vec::new() + }); + }; + let mut diagnostics: Vec = dynamic_column_diagnostics(model, dialect)?; + let dynamic_output_unproven: bool = diagnostics + .iter() + .any(|diagnostic| diagnostic.code == DYNAMIC_OUTPUT_NOT_PROVEN_CODE); + let Some(inferred_columns) = model.inferred_columns.as_ref() else { + return Ok(diagnostics); + }; + let inferred_by_name: HashMap<&str, &InferredOutputColumn> = inferred_columns + .iter() + .map(|column| (column.name.as_str(), column)) + .collect(); + if contract_enforced { + diagnostics.extend(extra_columns(model, schema, inferred_columns)); + } + let unchecked: HashSet<&str> = model + .unchecked_output_columns + .iter() + .map(String::as_str) + .collect(); + for (index, declared) in schema.columns.iter().enumerate() { + let Some(inferred) = inferred_by_name.get(declared.name.as_str()) else { + if validate_declared_shape && !model.fast_lineage_has_star { + diagnostics.push(missing_column(model, index, declared, contract_enforced)); + } + continue; + }; + if validate_declared_shape { + diagnostics.extend(nullability(index, declared, inferred)); + } + let Some(declared_type) = declared.declared_type.as_deref() else { + continue; + }; + if !validate_declared_shape && !schema.type_enforcement { + continue; + } + if dynamic_output_unproven && inferred.inferred_type.is_none() { + continue; + } + let checked = TypedColumn { + index, + declared, + declared_type, + inferred, + }; + let check = TypeCheck { + type_enforcement: schema.type_enforcement, + contract_enforced, + unchecked: &unchecked, + dialect, + }; + diagnostics.extend(column_type(&checked, &check)?); + } + Ok(diagnostics) +} + +struct TypeCheck<'a> { + type_enforcement: bool, + contract_enforced: bool, + unchecked: &'a HashSet<&'a str>, + dialect: &'a str, +} + +struct TypedColumn<'a> { + index: usize, + declared: &'a DeclaredColumn, + declared_type: &'a str, + inferred: &'a InferredOutputColumn, +} + +fn missing_declarations(model: &ContractModel) -> ContractDiagnostic { + ContractDiagnostic { + code: MISSING_DECLARATIONS_CODE, + severity: ContractSeverity::Error, + message: format!( + "model '{}' has contract enforced but declares no columns", + model.name + ), + column_name: None, + location: ContractLocation::None, + related_output: None, + help: MISSING_DECLARATIONS_HELP.to_owned(), + } +} + +fn extra_columns( + model: &ContractModel, + schema: &ContractSchema, + inferred_columns: &[InferredOutputColumn], +) -> Vec { + let declared_names: HashSet<&str> = schema + .columns + .iter() + .map(|column| column.name.as_str()) + .collect(); + let help: &str = if schema.named_schema { + EXTRA_COLUMN_NAMED_SCHEMA_HELP + } else { + EXTRA_COLUMN_HELP + }; + inferred_columns + .iter() + .filter(|column| !declared_names.contains(column.name.as_str())) + .map(|column| ContractDiagnostic { + code: EXTRA_COLUMN_CODE, + severity: ContractSeverity::Error, + message: format!( + "column '{}' is not declared in enforced contract for model '{}'", + column.name, model.name + ), + column_name: Some(column.name.clone()), + location: ContractLocation::Output(column.name.clone()), + related_output: None, + help: help.to_owned(), + }) + .collect() +} + +fn nullability( + index: usize, + declared: &DeclaredColumn, + inferred: &InferredOutputColumn, +) -> Option { + if !declared.not_null || !inferred.nullable { + return None; + } + Some(ContractDiagnostic { + code: NULLABILITY_MISMATCH_CODE, + severity: ContractSeverity::Error, + message: format!( + "column '{}' is declared non-null but may be nullable", + declared.name + ), + column_name: Some(declared.name.clone()), + location: ContractLocation::Declared(index), + related_output: Some(RelatedOutput { + column_name: declared.name.clone(), + message: NULLABLE_OUTPUT_MESSAGE.to_owned(), + }), + help: NULLABILITY_HELP.to_owned(), + }) +} + +fn missing_column( + model: &ContractModel, + index: usize, + column: &DeclaredColumn, + contract_enforced: bool, +) -> ContractDiagnostic { + let contract_label: &str = if contract_enforced { + "enforced contract column" + } else { + "declared column" + }; + let declaration_label: &str = if column.declared_in_named_schema { + NAMED_SCHEMA_DECLARATION + } else { + MODEL_COLUMNS_DECLARATION + }; + let name: &str = &column.name; + let help: String = if contract_enforced { + format!( + "add {name} to the SELECT list or correct {declaration_label}; validation is active \ + because this model declares contract enforced" + ) + } else { + format!( + "add {name} to the SELECT list or correct/remove {declaration_label}; \ + {declaration_label} is validated using static SQL analysis because \ + settings.column_contract_mode is \"implicit\" (the default). If this project \ + intentionally uses columns only for metadata and audits, set [settings] \ + column_contract_mode = \"explicit\"; models with contract enforced remain validated" + ) + }; + ContractDiagnostic { + code: MISSING_COLUMN_CODE, + severity: ContractSeverity::Error, + message: format!( + "{contract_label} '{name}' was not found in statically inferred output for model '{}'", + model.name + ), + column_name: Some(name.to_owned()), + location: ContractLocation::Declared(index), + related_output: None, + help, + } +} + +fn column_type( + column: &TypedColumn<'_>, + check: &TypeCheck<'_>, +) -> Result, ContractDeferral> { + let name: &str = &column.declared.name; + let declared_type: &str = column.declared_type; + let Some(inferred_type) = column.inferred.inferred_type.as_deref() else { + if !check.type_enforcement || check.unchecked.contains(column.inferred.name.as_str()) { + return Ok(None); + } + return Ok(Some(ContractDiagnostic { + code: UNKNOWN_TYPE_CODE, + severity: ContractSeverity::Warning, + message: format!( + "column '{name}' type could not be proven against declared {declared_type}" + ), + column_name: Some(name.to_owned()), + location: ContractLocation::Declared(column.index), + related_output: Some(RelatedOutput { + column_name: name.to_owned(), + message: UNPROVEN_TYPE_OUTPUT_MESSAGE.to_owned(), + }), + help: UNKNOWN_TYPE_HELP.to_owned(), + })); + }; + if types_equal(declared_type, inferred_type, check.dialect)? == TypeComparison::Equal { + return Ok(None); + } + Ok(Some(ContractDiagnostic { + code: TYPE_MISMATCH_CODE, + severity: if check.type_enforcement || check.contract_enforced { + ContractSeverity::Error + } else { + ContractSeverity::Warning + }, + message: format!( + "column '{name}' inferred as {inferred_type} but declared type is {declared_type}" + ), + column_name: Some(name.to_owned()), + location: ContractLocation::Declared(column.index), + related_output: Some(RelatedOutput { + column_name: name.to_owned(), + message: format!("inferred {inferred_type}"), + }), + help: TYPE_MISMATCH_HELP.to_owned(), + })) +} diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/dynamic.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/dynamic.rs new file mode 100644 index 0000000000..b42f5df666 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/dynamic.rs @@ -0,0 +1,122 @@ +//! Python's `_dynamic_column_diagnostics`: proof of a dynamic column contract and its families. + +use std::collections::HashMap; + +use crate::contracts::_helpers::type_comparison::{TypeComparison, types_equal}; +use crate::contracts::constants::{ + DYNAMIC_FAMILY_TYPE_MISMATCH_HELP, DYNAMIC_FAMILY_UNKNOWN_TYPE_HELP, + DYNAMIC_OUTPUT_NOT_PROVEN_CODE, DYNAMIC_OUTPUT_NOT_PROVEN_HELP, NO_COMPILER_EVIDENCE, + TYPE_MISMATCH_CODE, UNKNOWN_TYPE_CODE, +}; +use crate::contracts::models::{ + ContractDeferral, ContractDiagnostic, ContractLocation, ContractModel, ContractSeverity, + DynamicContractProof, +}; + +/// The dynamic column contract diagnostics of one model, before its fixed columns. +pub(crate) fn dynamic_column_diagnostics( + model: &ContractModel, + dialect: &str, +) -> Result, ContractDeferral> { + let Some(schema) = model + .schema + .as_ref() + .filter(|schema| !schema.dynamic_columns.is_empty()) + else { + return Ok(Vec::new()); + }; + let Some(proof) = model + .dynamic_proof + .as_ref() + .filter(|proof| proof.output_proven) + else { + return Ok(vec![not_proven(model)]); + }; + let proof_by_name: HashMap> = families_by_casefold(proof)?; + let mut diagnostics: Vec = Vec::new(); + for family in &schema.dynamic_columns { + let key: String = casefold(&family.name)?; + let declared: &str = &family.declared_type; + let Some(inferred) = proof_by_name.get(&key).copied().flatten() else { + diagnostics.push(family_diagnostic( + UNKNOWN_TYPE_CODE, + ContractSeverity::Warning, + format!( + "dynamic column family '{}' type could not be proven against declared {declared}", + family.name + ), + DYNAMIC_FAMILY_UNKNOWN_TYPE_HELP, + )); + continue; + }; + if types_equal(declared, inferred, dialect)? == TypeComparison::Different { + diagnostics.push(family_diagnostic( + TYPE_MISMATCH_CODE, + ContractSeverity::Error, + format!( + "dynamic column family '{}' inferred as {inferred} but declared type is {declared}", + family.name + ), + DYNAMIC_FAMILY_TYPE_MISMATCH_HELP, + )); + } + } + Ok(diagnostics) +} + +fn not_proven(model: &ContractModel) -> ContractDiagnostic { + let reason: &str = model + .dynamic_proof + .as_ref() + .and_then(|proof| proof.failure_reason.as_deref()) + .filter(|reason| !reason.is_empty()) + .unwrap_or(NO_COMPILER_EVIDENCE); + ContractDiagnostic { + code: DYNAMIC_OUTPUT_NOT_PROVEN_CODE, + severity: ContractSeverity::Error, + message: format!( + "model '{}' dynamic column contract is not proven: {reason}", + model.name + ), + column_name: None, + location: ContractLocation::None, + related_output: None, + help: DYNAMIC_OUTPUT_NOT_PROVEN_HELP.to_owned(), + } +} + +fn families_by_casefold( + proof: &DynamicContractProof, +) -> Result>, ContractDeferral> { + let mut by_name: HashMap> = HashMap::new(); + for (name, inferred_type) in &proof.families { + let _ = by_name.insert(casefold(name)?, inferred_type.as_deref()); + } + Ok(by_name) +} + +/// Python's `str.casefold` for ASCII names; other names defer. +fn casefold(name: &str) -> Result { + if name.is_ascii() { + Ok(name.to_ascii_lowercase()) + } else { + Err(ContractDeferral::NonAsciiFamilyName) + } +} + +fn family_diagnostic( + code: &'static str, + severity: ContractSeverity, + message: String, + help: &str, +) -> ContractDiagnostic { + ContractDiagnostic { + code, + severity, + message, + column_name: None, + location: ContractLocation::None, + related_output: None, + help: help.to_owned(), + } +} diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/mod.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/mod.rs new file mode 100644 index 0000000000..32e0493cc5 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod columns; +pub(crate) mod dynamic; +pub(crate) mod promotion; +pub(crate) mod setting_help; +pub(crate) mod type_comparison; diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/promotion.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/promotion.rs new file mode 100644 index 0000000000..a445125ab0 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/promotion.rs @@ -0,0 +1,87 @@ +//! Python's `promotion_conflict_diagnostics_impl`: enforced contracts under immediate promotion. + +use crate::contracts::_helpers::setting_help::{ + StringSetting, join_helps, setting_help, setting_note, snippet_help, +}; +use crate::contracts::constants::{ + CONTRACT_ENFORCED, IMMEDIATE_PROMOTION_MODE, MICROBATCH_INCREMENTAL_MODE, + PROMOTION_CONFLICT_CODE, PROMOTION_REMOVE_CONTRACT_HELP, SETTINGS_SECTION, + STAGED_LIFECYCLE_MATERIALIZATIONS, STAGED_PROMOTION_MODE, TABLE_PROMOTION_MODE_SETTING_KEY, +}; +use crate::contracts::models::{PromotionConflict, PromotionModel, PromotionRequest}; + +/// One conflict per enforced-contract table model when the effective promotion is immediate. +pub(crate) fn promotion_conflicts(request: &PromotionRequest) -> Vec { + let effective: &str = request + .explicit_mode + .as_deref() + .unwrap_or(&request.adapter_default); + if effective != IMMEDIATE_PROMOTION_MODE { + return Vec::new(); + } + let help: String = conflict_help(request); + request + .models + .iter() + .enumerate() + .filter(|(_, model)| uses_staged_table_lifecycle(model)) + .map(|(model_index, model)| PromotionConflict { + model_index, + code: PROMOTION_CONFLICT_CODE, + message: format!( + "model '{}': contract enforced requires staged table promotion; immediate table \ + promotion cannot validate runtime output before target mutation", + model.name + ), + help: help.clone(), + }) + .collect() +} + +fn uses_staged_table_lifecycle(model: &PromotionModel) -> bool { + model.contract.as_deref() == Some(CONTRACT_ENFORCED) + && model + .materialized + .as_deref() + .is_some_and(|materialized| STAGED_LIFECYCLE_MATERIALIZATIONS.contains(&materialized)) + && model.incremental_mode.as_deref() != Some(MICROBATCH_INCREMENTAL_MODE) +} + +fn conflict_help(request: &PromotionRequest) -> String { + let explicit: bool = request.explicit_mode.is_some(); + let file: &str = &request.settings_file; + let current: String = setting_note( + &StringSetting { + file_name: file, + section: SETTINGS_SECTION, + key: TABLE_PROMOTION_MODE_SETTING_KEY, + value: IMMEDIATE_PROMOTION_MODE, + }, + explicit, + ) + "; enforced contracts are validated in a staging table before promotion"; + let staged_fix: String = if explicit && request.adapter_default == STAGED_PROMOTION_MODE { + snippet_help( + "remove that line to use the default staged promotion", + &format!("or set this in {file}"), + &[ + format!("[{SETTINGS_SECTION}]"), + format!("{TABLE_PROMOTION_MODE_SETTING_KEY} = \"{STAGED_PROMOTION_MODE}\""), + ], + ) + } else { + setting_help( + "to validate enforced contracts before promotion", + &StringSetting { + file_name: file, + section: SETTINGS_SECTION, + key: TABLE_PROMOTION_MODE_SETTING_KEY, + value: STAGED_PROMOTION_MODE, + }, + ) + }; + join_helps(&[ + current, + staged_fix, + PROMOTION_REMOVE_CONTRACT_HELP.to_owned(), + ]) +} diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/setting_help.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/setting_help.rs new file mode 100644 index 0000000000..b3e3de3d5e --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/setting_help.rs @@ -0,0 +1,61 @@ +//! Python's setting help texts (`sqlbuild.errors.setting_help`) for string settings. + +use crate::contracts::constants::{ADDITIONAL_HELP_SEPARATOR, SETTING_SNIPPET_INDENT}; + +/// One string setting: the file it belongs to, its TOML section and key, and its value. +pub(crate) struct StringSetting<'a> { + pub(crate) file_name: &'a str, + pub(crate) section: &'a str, + pub(crate) key: &'a str, + pub(crate) value: &'a str, +} + +/// A TOML string literal, as Python's `toml_value` renders a string. +pub(crate) fn toml_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// `, set this in :` followed by the indented section and key. +pub(crate) fn setting_help(purpose: &str, setting: &StringSetting<'_>) -> String { + let StringSetting { + file_name, + section, + key, + value, + } = setting; + format!( + "{purpose}, set this in {file_name}:\n{SETTING_SNIPPET_INDENT}[{section}]\n\ + {SETTING_SNIPPET_INDENT}{key} = {}", + toml_string(value) + ) +} + +/// A setting's current value, set explicitly or by default. +pub(crate) fn setting_note(setting: &StringSetting<'_>, explicit: bool) -> String { + let StringSetting { + file_name, + section, + key, + value, + } = setting; + let rendered: String = toml_string(value); + if explicit { + format!("{file_name} sets [{section}] {key} = {rendered}") + } else { + format!("{file_name} does not set [{section}] {key}, so it defaults to {rendered}") + } +} + +/// `, :` followed by the exact lines to write, one per line. +pub(crate) fn snippet_help(purpose: &str, target: &str, lines: &[String]) -> String { + let snippet: Vec = lines + .iter() + .map(|line| format!("{SETTING_SNIPPET_INDENT}{line}")) + .collect(); + format!("{purpose}, {target}:\n{}", snippet.join("\n")) +} + +/// Several help paragraphs joined with one `= help:` label each. +pub(crate) fn join_helps(helps: &[String]) -> String { + helps.join(ADDITIONAL_HELP_SEPARATOR) +} diff --git a/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs b/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs new file mode 100644 index 0000000000..9089685e7e --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/_helpers/type_comparison.rs @@ -0,0 +1,33 @@ +//! Python's `types_equal` over the native type system, deferring where it cannot answer. + +use crate::contracts::models::ContractDeferral; +use crate::type_system::main::normalize_type::normalize_type; +use crate::type_system::models::{NormalizedType, TypeNormalization}; + +/// Whether two type strings normalize to the same comparison shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TypeComparison { + Equal, + Different, +} + +/// Compare two types as Python's `types_equal`, or defer when either type has no native answer. +pub(crate) fn types_equal( + left: &str, + right: &str, + dialect: &str, +) -> Result { + let left: NormalizedType = normalized(left, dialect)?; + let right: NormalizedType = normalized(right, dialect)?; + Ok(if left == right { + TypeComparison::Equal + } else { + TypeComparison::Different + }) +} + +fn normalized(type_sql: &str, dialect: &str) -> Result { + normalize_type(type_sql, dialect) + .map(|TypeNormalization { normalized, .. }| normalized) + .ok_or(ContractDeferral::TypeNormalization) +} diff --git a/crates/sqlbuild-analysis/src/contracts/constants.rs b/crates/sqlbuild-analysis/src/contracts/constants.rs new file mode 100644 index 0000000000..112d6458f2 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/constants.rs @@ -0,0 +1,44 @@ +//! Contract codes, config values and the fixed help texts Python emits. + +pub(crate) const MISSING_COLUMN_CODE: &str = "K001"; +pub(crate) const TYPE_MISMATCH_CODE: &str = "K002"; +pub(crate) const UNKNOWN_TYPE_CODE: &str = "K003"; +pub(crate) const NULLABILITY_MISMATCH_CODE: &str = "K004"; +pub(crate) const EXTRA_COLUMN_CODE: &str = "K005"; +pub(crate) const MISSING_DECLARATIONS_CODE: &str = "K006"; +pub(crate) const DYNAMIC_OUTPUT_NOT_PROVEN_CODE: &str = "K011"; +pub(crate) const PROMOTION_CONFLICT_CODE: &str = "K011"; + +pub(crate) const CONTRACT_ENFORCED: &str = "enforced"; +pub(crate) const CONTRACT_NONE: &str = "none"; +pub(crate) const STAGED_LIFECYCLE_MATERIALIZATIONS: [&str; 2] = ["table", "incremental"]; +pub(crate) const MICROBATCH_INCREMENTAL_MODE: &str = "microbatch"; +pub(crate) const IMMEDIATE_PROMOTION_MODE: &str = "immediate"; +pub(crate) const STAGED_PROMOTION_MODE: &str = "staged"; +pub(crate) const SETTINGS_SECTION: &str = "settings"; +pub(crate) const TABLE_PROMOTION_MODE_SETTING_KEY: &str = "table_promotion_mode"; + +pub(crate) const NO_COMPILER_EVIDENCE: &str = "no compiler evidence"; +pub(crate) const DYNAMIC_OUTPUT_NOT_PROVEN_HELP: &str = "project one supported runtime-dynamic pivot through a wildcard output boundary and declare every generated family"; +pub(crate) const DYNAMIC_FAMILY_UNKNOWN_TYPE_HELP: &str = "establish an authoritative type on the pivot value column or CAST the type-preserving aggregate input explicitly"; +pub(crate) const DYNAMIC_FAMILY_TYPE_MISMATCH_HELP: &str = + "correct the family type or the pivot aggregate input type"; +pub(crate) const MISSING_DECLARATIONS_HELP: &str = "add MODEL(columns (...)), declare a proven MODEL(dynamic_columns (...)) family, or set contract none for this model"; +pub(crate) const EXTRA_COLUMN_NAMED_SCHEMA_HELP: &str = + "add the column to the named SCHEMA or remove it from the SELECT list"; +pub(crate) const EXTRA_COLUMN_HELP: &str = + "add the column to MODEL(columns) or remove it from the SELECT list"; +pub(crate) const NULLABILITY_HELP: &str = + "use COALESCE, filter nulls explicitly, or remove the non-null contract"; +pub(crate) const NULLABLE_OUTPUT_MESSAGE: &str = "output expression proven nullable"; +pub(crate) const UNPROVEN_TYPE_OUTPUT_MESSAGE: &str = "output expression with unproven type"; +pub(crate) const UNKNOWN_TYPE_HELP: &str = + "add an explicit CAST if this declared type should be checked statically"; +pub(crate) const TYPE_MISMATCH_HELP: &str = + "change the declared type or cast the expression explicitly"; +pub(crate) const NAMED_SCHEMA_DECLARATION: &str = "the named SCHEMA declaration"; +pub(crate) const MODEL_COLUMNS_DECLARATION: &str = "MODEL(columns (...))"; +pub(crate) const PROMOTION_REMOVE_CONTRACT_HELP: &str = "or remove the model's enforced contract: `contract enforced` in its MODEL header, or `contract = \"enforced\"` in the [defaults] or [path_defaults] entry that applies to it"; + +pub(crate) const SETTING_SNIPPET_INDENT: &str = " "; +pub(crate) const ADDITIONAL_HELP_SEPARATOR: &str = "\n = help: "; diff --git a/crates/sqlbuild-analysis/src/contracts/main/evaluate_model_contracts.rs b/crates/sqlbuild-analysis/src/contracts/main/evaluate_model_contracts.rs new file mode 100644 index 0000000000..b70090de85 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/main/evaluate_model_contracts.rs @@ -0,0 +1,29 @@ +//! Python's `evaluate_model_contracts`: every model's column contract diagnostics, in order. + +use crate::contracts::_helpers::columns::{ + declared_shape_validation_is_active, model_contract_diagnostics, requires_contract_evaluation, +}; +use crate::contracts::models::{ContractOutcome, ContractRequest}; + +/// One outcome per requested model; models that need no evaluation have no diagnostics. +#[must_use] +pub fn evaluate_model_contracts(request: &ContractRequest) -> Vec { + let implicit: bool = request.implicit_column_contracts; + request + .models + .iter() + .map(|model| { + if !requires_contract_evaluation(model, implicit) { + return ContractOutcome::Diagnostics(Vec::new()); + } + match model_contract_diagnostics( + model, + declared_shape_validation_is_active(model, implicit), + &request.dialect, + ) { + Ok(diagnostics) => ContractOutcome::Diagnostics(diagnostics), + Err(deferral) => ContractOutcome::Deferred(deferral), + } + }) + .collect() +} diff --git a/crates/sqlbuild-analysis/src/contracts/main/mod.rs b/crates/sqlbuild-analysis/src/contracts/main/mod.rs new file mode 100644 index 0000000000..320ccacdc3 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/main/mod.rs @@ -0,0 +1,2 @@ +pub mod evaluate_model_contracts; +pub mod promotion_conflicts; diff --git a/crates/sqlbuild-analysis/src/contracts/main/promotion_conflicts.rs b/crates/sqlbuild-analysis/src/contracts/main/promotion_conflicts.rs new file mode 100644 index 0000000000..9afb94ff4a --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/main/promotion_conflicts.rs @@ -0,0 +1,10 @@ +//! Python's `promotion_conflict_diagnostics`: K011 for enforced contracts under immediate promotion. + +use crate::contracts::_helpers::promotion::promotion_conflicts as conflicts; +use crate::contracts::models::{PromotionConflict, PromotionRequest}; + +/// One conflict per enforced-contract table model, in model order. +#[must_use] +pub fn promotion_conflicts(request: &PromotionRequest) -> Vec { + conflicts(request) +} diff --git a/crates/sqlbuild-analysis/src/contracts/mod.rs b/crates/sqlbuild-analysis/src/contracts/mod.rs index cf143bb851..4ca56896ce 100644 --- a/crates/sqlbuild-analysis/src/contracts/mod.rs +++ b/crates/sqlbuild-analysis/src/contracts/mod.rs @@ -1 +1,9 @@ //! Model column contracts and promotion conflicts. + +pub(crate) mod _helpers; +pub(crate) mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlbuild-analysis/src/contracts/models.rs b/crates/sqlbuild-analysis/src/contracts/models.rs new file mode 100644 index 0000000000..131649c921 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/models.rs @@ -0,0 +1,203 @@ +//! Plain-data contract requests and the diagnostics native contract validation returns. + +/// The models of one compiled project whose column contracts are evaluated together. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractRequest { + /// The dialect name Python hands type normalization (`str(dialect or "generic")`). + pub dialect: String, + /// Whether `settings.column_contract_mode` is `implicit`. + pub implicit_column_contracts: bool, + pub models: Vec, +} + +/// One compiled model's contract inputs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractModel { + pub name: String, + /// The `contract` config value when it is a string. + pub contract: Option, + /// The model's schema entry, when it has one. + pub schema: Option, + /// Inferred output columns, or None when static analysis inferred none. + pub inferred_columns: Option>, + pub fast_lineage_has_star: bool, + pub dynamic_proof: Option, + pub unchecked_output_columns: Vec, +} + +/// The declared shape of one model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractSchema { + pub columns: Vec, + pub dynamic_columns: Vec, + pub type_enforcement: bool, + /// Whether the columns come from a named SCHEMA. + pub named_schema: bool, +} + +/// One declared column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredColumn { + pub name: String, + pub declared_type: Option, + /// Whether the column declares `nullable false`. + pub not_null: bool, + /// Whether the column is declared in a named SCHEMA file other than the model file. + pub declared_in_named_schema: bool, +} + +/// One declared runtime-generated column family. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredColumnFamily { + pub name: String, + pub declared_type: String, +} + +/// One statically inferred output column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InferredOutputColumn { + pub name: String, + pub inferred_type: Option, + /// Whether the column is proven nullable. + pub nullable: bool, +} + +/// Compiler evidence for a dynamic column contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DynamicContractProof { + pub output_proven: bool, + pub failure_reason: Option, + /// `(family name, inferred type)` in proof order. + pub families: Vec<(String, Option)>, +} + +/// The severity of one contract diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContractSeverity { + Error, + Warning, +} + +impl ContractSeverity { + /// The value of Python's `DiagnosticSeverity` member. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + } + } +} + +/// Where a contract diagnostic points. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractLocation { + None, + /// The declared column at this index of the model's schema columns. + Declared(usize), + /// The output expression of this column in the model's SQL. + Output(String), +} + +impl ContractLocation { + /// `(declared column index, output column name)`; at most one is set. + #[must_use] + pub fn into_parts(self) -> (Option, Option) { + match self { + Self::None => (None, None), + Self::Declared(index) => (Some(index), None), + Self::Output(column) => (None, Some(column)), + } + } +} + +/// A related output location: the column whose output expression it points at, and its message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelatedOutput { + pub column_name: String, + pub message: String, +} + +/// One contract diagnostic, in Python's emission order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractDiagnostic { + pub code: &'static str, + pub severity: ContractSeverity, + pub message: String, + pub column_name: Option, + pub location: ContractLocation, + pub related_output: Option, + pub help: String, +} + +/// Why native contract validation hands one model back to Python. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContractDeferral { + /// Native type normalization cannot reproduce Python for a compared type. + TypeNormalization, + /// A dynamic family name outside ASCII, whose `casefold` native does not reproduce. + NonAsciiFamilyName, +} + +impl ContractDeferral { + /// The deferral kind recorded for the harness. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::TypeNormalization => "type_normalization", + Self::NonAsciiFamilyName => "non_ascii_family_name", + } + } +} + +/// One model's contract outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractOutcome { + Diagnostics(Vec), + Deferred(ContractDeferral), +} + +impl ContractOutcome { + /// `(deferral, diagnostics)`: a deferred model has no diagnostics. + #[must_use] + pub fn into_parts(self) -> (Option, Vec) { + match self { + Self::Diagnostics(diagnostics) => (None, diagnostics), + Self::Deferred(deferral) => (Some(deferral), Vec::new()), + } + } +} + +/// The inputs to table promotion conflict detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromotionRequest { + /// `settings.table_promotion_mode` when the project sets it. + pub explicit_mode: Option, + /// The adapter's default table promotion mode. + pub adapter_default: String, + /// The configuration file the setting belongs to. + pub settings_file: String, + pub models: Vec, +} + +/// One model's lifecycle config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromotionModel { + pub name: String, + /// The `contract` config value when it is a string. + pub contract: Option, + /// The `materialized` config value (default `table`) when it is a string. + pub materialized: Option, + /// The `incremental_mode` config value when it is a string. + pub incremental_mode: Option, +} + +/// One K011 promotion conflict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromotionConflict { + /// The index of the conflicting model in the request. + pub model_index: usize, + pub code: &'static str, + pub message: String, + pub help: String, +} diff --git a/crates/sqlbuild-analysis/src/contracts/tests.rs b/crates/sqlbuild-analysis/src/contracts/tests.rs new file mode 100644 index 0000000000..b4e1f8772a --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/tests.rs @@ -0,0 +1,8 @@ +#[path = "tests/test_evaluate_model_contracts.rs"] +mod evaluate_model_contracts; +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_promotion_conflicts.rs"] +mod promotion_conflicts; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlbuild-analysis/src/contracts/tests/helpers.rs b/crates/sqlbuild-analysis/src/contracts/tests/helpers.rs new file mode 100644 index 0000000000..542f3a7d64 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/tests/helpers.rs @@ -0,0 +1,148 @@ +use crate::contracts::models::{ + ContractDiagnostic, ContractModel, ContractOutcome, ContractSchema, DeclaredColumn, + DeclaredColumnFamily, DynamicContractProof, InferredOutputColumn, PromotionModel, + PromotionRequest, +}; + +pub(crate) fn model(contract: Option<&str>, schema: Option) -> ContractModel { + ContractModel { + name: "orders".to_owned(), + contract: contract.map(str::to_owned), + schema, + inferred_columns: None, + fast_lineage_has_star: false, + dynamic_proof: None, + unchecked_output_columns: Vec::new(), + } +} + +pub(crate) fn schema(columns: Vec, type_enforcement: bool) -> ContractSchema { + ContractSchema { + columns, + dynamic_columns: Vec::new(), + type_enforcement, + named_schema: false, + } +} + +pub(crate) fn declared(name: &str, declared_type: Option<&str>, not_null: bool) -> DeclaredColumn { + DeclaredColumn { + name: name.to_owned(), + declared_type: declared_type.map(str::to_owned), + not_null, + declared_in_named_schema: false, + } +} + +pub(crate) fn inferred( + name: &str, + inferred_type: Option<&str>, + nullable: bool, +) -> InferredOutputColumn { + InferredOutputColumn { + name: name.to_owned(), + inferred_type: inferred_type.map(str::to_owned), + nullable, + } +} + +pub(crate) fn with_inferred( + mut model: ContractModel, + columns: Vec, +) -> ContractModel { + model.inferred_columns = Some(columns); + model +} + +pub(crate) fn dynamic_model( + families: &[(&str, &str)], + proof: Option, +) -> ContractModel { + let mut declared_schema: ContractSchema = schema(Vec::new(), false); + declared_schema.dynamic_columns = families + .iter() + .map(|(name, declared_type)| DeclaredColumnFamily { + name: (*name).to_owned(), + declared_type: (*declared_type).to_owned(), + }) + .collect(); + let mut dynamic: ContractModel = model(Some("enforced"), Some(declared_schema)); + dynamic.dynamic_proof = proof; + dynamic +} + +pub(crate) fn proof( + output_proven: bool, + failure_reason: Option<&str>, + families: &[(&str, Option<&str>)], +) -> DynamicContractProof { + DynamicContractProof { + output_proven, + failure_reason: failure_reason.map(str::to_owned), + families: families + .iter() + .map(|(name, inferred_type)| ((*name).to_owned(), inferred_type.map(str::to_owned))) + .collect(), + } +} + +pub(crate) fn promotion_model( + contract: Option<&str>, + materialized: Option<&str>, + incremental_mode: Option<&str>, +) -> PromotionModel { + PromotionModel { + name: "orders".to_owned(), + contract: contract.map(str::to_owned), + materialized: materialized.map(str::to_owned), + incremental_mode: incremental_mode.map(str::to_owned), + } +} + +pub(crate) fn outcome_lines(outcome: &ContractOutcome) -> Vec { + let (deferral, diagnostics) = outcome.clone().into_parts(); + deferral.map_or_else( + || diagnostics.iter().map(diagnostic_line).collect(), + |deferral| vec![format!("deferred:{}", deferral.as_str())], + ) +} + +fn diagnostic_line(diagnostic: &ContractDiagnostic) -> String { + let (declared, output) = diagnostic.location.clone().into_parts(); + let location: String = declared + .map(|index| format!("declared:{index}")) + .or_else(|| output.map(|column| format!("output:{column}"))) + .unwrap_or_else(|| "-".to_owned()); + let related: String = diagnostic + .related_output + .as_ref() + .map_or(String::new(), |related| { + format!(" [{}: {}]", related.column_name, related.message) + }); + format!( + "{} {} {location} {}{related}", + diagnostic.code, + diagnostic.severity.as_str(), + diagnostic.message + ) +} + +pub(crate) fn promotion_request( + explicit_mode: Option<&str>, + adapter_default: &str, + settings_file: &str, +) -> PromotionRequest { + PromotionRequest { + explicit_mode: explicit_mode.map(str::to_owned), + adapter_default: adapter_default.to_owned(), + settings_file: settings_file.to_owned(), + models: vec![ + promotion_model(Some("enforced"), Some("table"), None), + promotion_model(Some("enforced"), Some("view"), None), + promotion_model(Some("enforced"), Some("incremental"), Some("microbatch")), + promotion_model(None, Some("table"), None), + promotion_model(Some("enforced"), Some("incremental"), Some("merge")), + promotion_model(Some("enforced"), None, None), + ], + } +} diff --git a/crates/sqlbuild-analysis/src/contracts/tests/test_evaluate_model_contracts.rs b/crates/sqlbuild-analysis/src/contracts/tests/test_evaluate_model_contracts.rs new file mode 100644 index 0000000000..fa83f9ded2 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/tests/test_evaluate_model_contracts.rs @@ -0,0 +1,212 @@ +use crate::contracts::main::evaluate_model_contracts::evaluate_model_contracts; +use crate::contracts::models::{ContractOutcome, ContractRequest}; +use crate::contracts::tests::helpers::{ + declared, dynamic_model, inferred, model, outcome_lines, proof, schema, with_inferred, +}; +use crate::contracts::tests::test_types::ContractTestCase; + +#[test] +fn given_model_contracts_when_evaluating_then_diagnostics_follow_python_order() { + let test_cases = [ + ContractTestCase { + description: "enforced contract without declared columns", + implicit: true, + model: model(Some("enforced"), None), + expected_lines: &[ + "K006 error - model 'orders' has contract enforced but declares no columns", + ], + }, + ContractTestCase { + description: "no contract, no schema", + implicit: true, + model: model(None, None), + expected_lines: &[], + }, + ContractTestCase { + description: "explicit mode skips declared columns without type enforcement", + implicit: false, + model: with_inferred( + model( + None, + Some(schema(vec![declared("missing", None, false)], false)), + ), + vec![inferred("order_id", None, false)], + ), + expected_lines: &[], + }, + ContractTestCase { + description: "enforced: extras first, then missing, nullability and type per column", + implicit: true, + model: with_inferred( + model( + Some("enforced"), + Some(schema( + vec![ + declared("order_id", Some("INTEGER"), true), + declared("missing", Some("INTEGER"), false), + declared("amount", Some("VARCHAR"), false), + ], + false, + )), + ), + vec![ + inferred("order_id", Some("INTEGER"), true), + inferred("extra", None, false), + inferred("amount", Some("DOUBLE"), false), + ], + ), + expected_lines: &[ + "K005 error output:extra column 'extra' is not declared in enforced contract for \ + model 'orders'", + "K004 error declared:0 column 'order_id' is declared non-null but may be nullable \ + [order_id: output expression proven nullable]", + "K001 error declared:1 enforced contract column 'missing' was not found in \ + statically inferred output for model 'orders'", + "K002 error declared:2 column 'amount' inferred as DOUBLE but declared type is \ + VARCHAR [amount: inferred DOUBLE]", + ], + }, + ContractTestCase { + description: "implicit mode: a mismatch is a warning and a star hides missing columns", + implicit: true, + model: { + let mut starred = with_inferred( + model( + None, + Some(schema( + vec![ + declared("missing", None, false), + declared("amount", Some("VARCHAR"), false), + ], + false, + )), + ), + vec![inferred("amount", Some("DOUBLE"), false)], + ); + starred.fast_lineage_has_star = true; + starred + }, + expected_lines: &[ + "K002 warning declared:1 column 'amount' inferred as DOUBLE but declared type is \ + VARCHAR [amount: inferred DOUBLE]", + ], + }, + ContractTestCase { + description: "type enforcement warns about unproven types except unchecked outputs", + implicit: false, + model: { + let mut enforced_types = with_inferred( + model( + None, + Some(schema( + vec![ + declared("amount", Some("DOUBLE"), false), + declared("label", Some("VARCHAR"), false), + ], + true, + )), + ), + vec![ + inferred("amount", None, false), + inferred("label", None, false), + ], + ); + enforced_types.unchecked_output_columns = vec!["label".to_owned()]; + enforced_types + }, + expected_lines: &[ + "K003 warning declared:0 column 'amount' type could not be proven against \ + declared DOUBLE [amount: output expression with unproven type]", + ], + }, + ContractTestCase { + description: "equal types under the dialect raise nothing", + implicit: true, + model: with_inferred( + model( + Some("enforced"), + Some(schema(vec![declared("amount", Some("INT"), false)], false)), + ), + vec![inferred("amount", Some("INTEGER"), false)], + ), + expected_lines: &[], + }, + ContractTestCase { + description: "a type native normalization cannot answer defers the model", + implicit: true, + model: with_inferred( + model( + Some("enforced"), + Some(schema(vec![declared("amount", Some("TÉXT"), false)], false)), + ), + vec![inferred("amount", Some("VARCHAR"), false)], + ), + expected_lines: &["deferred:type_normalization"], + }, + ContractTestCase { + description: "an unproven dynamic contract without a reason", + implicit: true, + model: dynamic_model(&[("amount_*", "DOUBLE")], None), + expected_lines: &[ + "K011 error - model 'orders' dynamic column contract is not proven: no compiler \ + evidence", + ], + }, + ContractTestCase { + description: "an unproven dynamic contract with its reason", + implicit: true, + model: dynamic_model( + &[("amount_*", "DOUBLE")], + Some(proof(false, Some("no pivot"), &[])), + ), + expected_lines: &[ + "K011 error - model 'orders' dynamic column contract is not proven: no pivot", + ], + }, + ContractTestCase { + description: "proven families match case-insensitively and compare their types", + implicit: true, + model: dynamic_model( + &[ + ("Amount_*", "DOUBLE"), + ("count_*", "BIGINT"), + ("label_*", "VARCHAR"), + ], + Some(proof( + true, + None, + &[("amount_*", Some("DOUBLE")), ("COUNT_*", Some("VARCHAR"))], + )), + ), + expected_lines: &[ + "K002 error - dynamic column family 'count_*' inferred as VARCHAR but declared \ + type is BIGINT", + "K003 warning - dynamic column family 'label_*' type could not be proven against \ + declared VARCHAR", + ], + }, + ContractTestCase { + description: "a family name outside ASCII defers the model", + implicit: true, + model: dynamic_model( + &[("straße_*", "DOUBLE")], + Some(proof(true, None, &[("strasse_*", Some("DOUBLE"))])), + ), + expected_lines: &["deferred:non_ascii_family_name"], + }, + ]; + for test_case in test_cases { + let request = ContractRequest { + dialect: "duckdb".to_owned(), + implicit_column_contracts: test_case.implicit, + models: vec![test_case.model], + }; + let outcomes: Vec = evaluate_model_contracts(&request); + assert_eq!( + outcomes.iter().flat_map(outcome_lines).collect::>(), + test_case.expected_lines, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/contracts/tests/test_promotion_conflicts.rs b/crates/sqlbuild-analysis/src/contracts/tests/test_promotion_conflicts.rs new file mode 100644 index 0000000000..9ef093c123 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/tests/test_promotion_conflicts.rs @@ -0,0 +1,68 @@ +use crate::contracts::main::promotion_conflicts::promotion_conflicts; +use crate::contracts::models::PromotionConflict; +use crate::contracts::tests::helpers::promotion_request; +use crate::contracts::tests::test_types::PromotionTestCase; + +const ADAPTER_DEFAULT_HELP: &str = "sqlbuild_project.toml does not set [settings] \ + table_promotion_mode, so it defaults to \"immediate\"; enforced contracts are validated in a \ + staging table before promotion\n = help: to validate enforced contracts before promotion, \ + set this in sqlbuild_project.toml:\n [settings]\n \ + table_promotion_mode = \"staged\"\n = help: or remove the model's enforced contract: \ + `contract enforced` in its MODEL header, or `contract = \"enforced\"` in the [defaults] or \ + [path_defaults] entry that applies to it"; +const EXPLICIT_OVER_STAGED_HELP: &str = "sqlbuild_local.toml sets [settings] \ + table_promotion_mode = \"immediate\"; enforced contracts are validated in a staging table \ + before promotion\n = help: remove that line to use the default staged promotion, or set \ + this in sqlbuild_local.toml:\n [settings]\n \ + table_promotion_mode = \"staged\"\n = help: or remove the model's enforced contract: \ + `contract enforced` in its MODEL header, or `contract = \"enforced\"` in the [defaults] or \ + [path_defaults] entry that applies to it"; + +#[test] +fn given_promotion_settings_when_checking_conflicts_then_only_immediate_staged_lifecycles_conflict() +{ + let test_cases = [ + PromotionTestCase { + description: "staged by default", + request: promotion_request(None, "staged", "sqlbuild_project.toml"), + expected_models: &[], + expected_help: None, + }, + PromotionTestCase { + description: "immediate by adapter default", + request: promotion_request(None, "immediate", "sqlbuild_project.toml"), + expected_models: &[0, 4], + expected_help: Some(ADAPTER_DEFAULT_HELP), + }, + PromotionTestCase { + description: "explicit immediate over a staged adapter default", + request: promotion_request(Some("immediate"), "staged", "sqlbuild_local.toml"), + expected_models: &[0, 4], + expected_help: Some(EXPLICIT_OVER_STAGED_HELP), + }, + PromotionTestCase { + description: "explicit staged over an immediate adapter default", + request: promotion_request(Some("staged"), "immediate", "sqlbuild_project.toml"), + expected_models: &[], + expected_help: None, + }, + ]; + for test_case in test_cases { + let conflicts: Vec = promotion_conflicts(&test_case.request); + assert_eq!( + conflicts + .iter() + .map(|conflict| conflict.model_index) + .collect::>(), + test_case.expected_models, + "{}", + test_case.description + ); + assert_eq!( + conflicts.first().map(|conflict| conflict.help.as_str()), + test_case.expected_help, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/contracts/tests/test_types.rs b/crates/sqlbuild-analysis/src/contracts/tests/test_types.rs new file mode 100644 index 0000000000..bfbdb73893 --- /dev/null +++ b/crates/sqlbuild-analysis/src/contracts/tests/test_types.rs @@ -0,0 +1,16 @@ +use crate::contracts::models::{ContractModel, PromotionRequest}; + +pub(crate) struct ContractTestCase { + pub(crate) description: &'static str, + pub(crate) implicit: bool, + pub(crate) model: ContractModel, + /// One `code severity location message` line per diagnostic, or `deferred:`. + pub(crate) expected_lines: &'static [&'static str], +} + +pub(crate) struct PromotionTestCase { + pub(crate) description: &'static str, + pub(crate) request: PromotionRequest, + pub(crate) expected_models: &'static [usize], + pub(crate) expected_help: Option<&'static str>, +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/contracts/mod.rs b/crates/sqlbuild-python/src/bindings/_helpers/contracts/mod.rs index 7444bd0374..abe28656cc 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/contracts/mod.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/contracts/mod.rs @@ -1 +1,2 @@ +pub(crate) mod model_contracts; pub(crate) mod registration; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/contracts/model_contracts.rs b/crates/sqlbuild-python/src/bindings/_helpers/contracts/model_contracts.rs new file mode 100644 index 0000000000..ba39f3fa24 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/contracts/model_contracts.rs @@ -0,0 +1,191 @@ +//! Model column contracts and promotion conflicts for the preview compiler engine. + +use pyo3::prelude::{Bound, PyModule, PyModuleMethods, PyResult, Python}; +use pyo3::{pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::contracts::main::evaluate_model_contracts::evaluate_model_contracts; +use sqlbuild_analysis::contracts::main::promotion_conflicts::promotion_conflicts; +use sqlbuild_analysis::contracts::models::{ + ContractDeferral, ContractDiagnostic, ContractModel, ContractOutcome, ContractRequest, + ContractSchema, ContractSeverity, DeclaredColumn, DeclaredColumnFamily, DynamicContractProof, + InferredOutputColumn, PromotionConflict, PromotionModel, PromotionRequest, +}; + +use crate::bindings::_helpers::boundary::panics::compiler_error; +use crate::bindings::types::CompilerDetach; + +/// `(name, type, not_null, declared_in_named_schema)`. +type DeclaredRow = (String, Option, bool, bool); +/// `(columns, dynamic_columns, type_enforcement, named_schema)`. +type SchemaRow = (Vec, Vec<(String, String)>, bool, bool); +/// `(output_proven, failure_reason, [(family, inferred_type)])`. +type ProofRow = (bool, Option, Vec<(String, Option)>); +/// `(name, contract, schema, inferred_columns, fast_lineage_has_star, proof, unchecked)`. +type ModelRow = ( + String, + Option, + Option, + Option, bool)>>, + bool, + Option, + Vec, +); +/// `(code, is_error, message, column, declared_index, output_column, related, help)`. +type DiagnosticRow = ( + &'static str, + bool, + String, + Option, + Option, + Option, + Option<(String, String)>, + String, +); +/// `(deferral_kind, diagnostics)`. +type OutcomeRow = (Option<&'static str>, Vec); +/// `(name, contract, materialized, incremental_mode)`. +type PromotionModelRow = (String, Option, Option, Option); + +/// Evaluate every model's column contract; deferred models carry their deferral kind. +#[pyfunction] +fn evaluate_native_model_contracts( + py: Python<'_>, + request: (String, bool, Vec), +) -> PyResult> { + let (dialect, implicit_column_contracts, models) = request; + let request = ContractRequest { + dialect, + implicit_column_contracts, + models: models.into_iter().map(contract_model).collect(), + }; + let outcomes: Vec = py + .compiler_detach(|| Ok(evaluate_model_contracts(&request))) + .map_err(compiler_error)?; + Ok(outcomes.into_iter().map(outcome_row).collect()) +} + +/// The K011 conflicts of enforced contracts under immediate table promotion. +#[pyfunction] +fn native_promotion_conflicts( + py: Python<'_>, + request: (Option, String, String, Vec), +) -> PyResult> { + let (explicit_mode, adapter_default, settings_file, models) = request; + let request = PromotionRequest { + explicit_mode, + adapter_default, + settings_file, + models: models + .into_iter() + .map( + |(name, contract, materialized, incremental_mode)| PromotionModel { + name, + contract, + materialized, + incremental_mode, + }, + ) + .collect(), + }; + let conflicts: Vec = py + .compiler_detach(|| Ok(promotion_conflicts(&request))) + .map_err(compiler_error)?; + Ok(conflicts + .into_iter() + .map(|conflict| { + ( + conflict.model_index, + conflict.code, + conflict.message, + conflict.help, + ) + }) + .collect()) +} + +fn contract_model( + (name, contract, schema, inferred, has_star, proof, unchecked): ModelRow, +) -> ContractModel { + ContractModel { + name, + contract, + schema: schema.map(contract_schema), + inferred_columns: inferred.map(inferred_columns), + fast_lineage_has_star: has_star, + dynamic_proof: proof.map(|(output_proven, failure_reason, families)| { + DynamicContractProof { + output_proven, + failure_reason, + families, + } + }), + unchecked_output_columns: unchecked, + } +} + +fn inferred_columns(columns: Vec<(String, Option, bool)>) -> Vec { + columns + .into_iter() + .map(|(name, inferred_type, nullable)| InferredOutputColumn { + name, + inferred_type, + nullable, + }) + .collect() +} + +fn contract_schema( + (columns, dynamic_columns, type_enforcement, named_schema): SchemaRow, +) -> ContractSchema { + ContractSchema { + columns: columns + .into_iter() + .map( + |(name, declared_type, not_null, declared_in_named_schema)| DeclaredColumn { + name, + declared_type, + not_null, + declared_in_named_schema, + }, + ) + .collect(), + dynamic_columns: dynamic_columns + .into_iter() + .map(|(name, declared_type)| DeclaredColumnFamily { + name, + declared_type, + }) + .collect(), + type_enforcement, + named_schema, + } +} + +fn outcome_row(outcome: ContractOutcome) -> OutcomeRow { + let (deferral, diagnostics) = outcome.into_parts(); + ( + deferral.map(ContractDeferral::as_str), + diagnostics.into_iter().map(diagnostic_row).collect(), + ) +} + +fn diagnostic_row(diagnostic: ContractDiagnostic) -> DiagnosticRow { + let (declared_index, output_column) = diagnostic.location.into_parts(); + ( + diagnostic.code, + diagnostic.severity == ContractSeverity::Error, + diagnostic.message, + diagnostic.column_name, + declared_index, + output_column, + diagnostic + .related_output + .map(|related| (related.column_name, related.message)), + diagnostic.help, + ) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(evaluate_native_model_contracts, module)?)?; + module.add_function(wrap_pyfunction!(native_promotion_conflicts, module)?)?; + Ok(()) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/contracts/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/contracts/registration.rs index a821930745..894c76182c 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/contracts/registration.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/contracts/registration.rs @@ -2,6 +2,8 @@ use pyo3::prelude::{Bound, PyModule, PyResult}; -pub(crate) fn register(_module: &Bound<'_, PyModule>) -> PyResult<()> { - Ok(()) +use crate::bindings::_helpers::contracts::model_contracts; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + model_contracts::register(module) } diff --git a/scripts/compiler_differential/_helpers/analysis_corpus/contracts.py b/scripts/compiler_differential/_helpers/analysis_corpus/contracts.py index db6cbc7038..58e7d3d84a 100644 --- a/scripts/compiler_differential/_helpers/analysis_corpus/contracts.py +++ b/scripts/compiler_differential/_helpers/analysis_corpus/contracts.py @@ -1,9 +1,145 @@ """Failure cases owned by the native contracts and promotion conflicts lane.""" +from scripts.compiler_differential._helpers.corpus.case_builder import ( + failure_case, + mart_body_files, + staging_files, +) +from scripts.compiler_differential.constants import ( + FAILURE_BASE_CONFIG, + FAILURE_CONFIG_PATH, + FAILURE_MART_PATH, +) from scripts.compiler_differential.models import FailureCase +_SOURCE_QUERY: str = 'SELECT order_id, customer_id, amount, status\nFROM __source("raw_orders")\n' +_ENFORCED_HEADER: str = 'MODEL (\n description "Staged orders",\n contract enforced,\n' +_TYPED_COLUMNS: str = ( + " columns (\n order_id (type INTEGER),\n customer_id (type INTEGER),\n" + " amount (type DOUBLE),\n status (type VARCHAR),\n ),\n" +) +_NAMED_SCHEMA_PATH: str = "models/staging/_sqlbuild/_schemas/staged_order.sql" +_NAMED_SCHEMA: str = ( + 'SCHEMA (\n name staged_order,\n description "Canonical staged order shape",\n' + " columns (\n order_id (type INTEGER),\n customer_id (type INTEGER),\n" + " amount (type DOUBLE),\n shipped_at (type TIMESTAMP),\n ),\n);\n" +) +_PIVOT_MART: str = ( + 'MODEL (\n description "Order amounts per status",\n materialized table,\n' + " columns (customer_id (type INTEGER)),\n" + " dynamic_columns (\n status_amounts (\n pivot_column status,\n" + " value_column amount,\n aggregate MAX,\n type {family_type}\n )\n ),\n" + ');\n\nPIVOT __ref("stg_orders")\nON status\nUSING MAX(amount)\nGROUP BY customer_id\n' +) +_IMMEDIATE_SETTING: str = '\n[settings]\ntable_promotion_mode = "immediate"\n' + def contracts_failure_cases() -> tuple[FailureCase, ...]: """Return this lane's failure cases; the lane appends here without editing shared lists.""" - return () + return ( + failure_case( + name="contract-order-every-column-family", + expected_code="K005", + expected_message="column 'status' is not declared in enforced contract for model " + "'stg_orders'", + files=staging_files( + f"{_ENFORCED_HEADER} columns (\n order_id (type VARCHAR),\n" + " customer_id (type INTEGER, nullable false),\n" + " shipped_at (type TIMESTAMP),\n amount (type DOUBLE),\n ),\n);\n\n" + "SELECT order_id, CAST(NULL AS INTEGER) AS customer_id, amount, status\n" + 'FROM __source("raw_orders")\n' + ), + ), + failure_case( + name="contract-implicit-missing-column", + expected_code="K001", + expected_message="declared column 'order_count' was not found in statically inferred " + "output for model 'customer_totals'", + files={ + FAILURE_MART_PATH: ( + 'MODEL (\n description "Order totals per customer",\n columns (\n' + " customer_id (type INTEGER),\n total_amount (type DOUBLE),\n" + " order_count (type BIGINT),\n ),\n);\n\n" + "SELECT customer_id, SUM(amount) AS total_amount\n" + 'FROM __ref("stg_orders")\nGROUP BY customer_id\n' + ) + }, + ), + failure_case( + name="contract-errors-across-models", + expected_code="K002", + expected_message="column 'total_amount' inferred as DOUBLE but declared type is BIGINT", + files={ + **staging_files(f"{_ENFORCED_HEADER});\n\n{_SOURCE_QUERY}"), + **mart_body_files( + "SELECT customer_id, CAST(SUM(amount) AS VARCHAR) AS total_amount\n" + 'FROM __ref("stg_orders")\nGROUP BY customer_id\n' + ), + "models/marts/typed_totals.sql": ( + 'MODEL (\n description "Typed totals",\n' + " columns (total_amount (type BIGINT)),\n);\n\n" + "SELECT CAST(1.5 AS DOUBLE) AS total_amount\n" + ), + }, + ), + failure_case( + name="contract-named-schema-columns", + expected_code="K005", + expected_help="add the column to the named SCHEMA or remove it from the SELECT list", + files={ + _NAMED_SCHEMA_PATH: _NAMED_SCHEMA, + **staging_files( + f"{_ENFORCED_HEADER} model_schema staged_order,\n);\n\n{_SOURCE_QUERY}" + ), + }, + ), + failure_case( + name="contract-local-immediate-promotion", + expected_code="K011", + expected_message="contract enforced requires staged table promotion", + files={ + "sqlbuild_local.toml": _IMMEDIATE_SETTING.lstrip("\n"), + **staging_files( + f"{_ENFORCED_HEADER} materialized table,\n{_TYPED_COLUMNS});\n\n" + f"{_SOURCE_QUERY}" + ), + }, + ), + failure_case( + name="contract-immediate-promotion-several-models", + expected_code="K011", + expected_message="model 'customer_totals': contract enforced requires staged table " + "promotion", + files={ + FAILURE_CONFIG_PATH: FAILURE_BASE_CONFIG + + '\n[defaults]\ncontract = "enforced"\nmaterialized = "table"\n' + + _IMMEDIATE_SETTING, + **staging_files( + 'MODEL (\n description "Staged orders",\n materialized view,\n' + f"{_TYPED_COLUMNS});\n\n{_SOURCE_QUERY}" + ), + FAILURE_MART_PATH: ( + 'MODEL (\n description "Order totals per customer",\n columns (\n' + " customer_id (type INTEGER),\n total_amount (type DOUBLE),\n ),\n" + ");\n\nSELECT customer_id, SUM(amount) AS total_amount\n" + 'FROM __ref("stg_orders")\nGROUP BY customer_id\n' + ), + }, + ), + failure_case( + name="contract-dynamic-family-type-mismatch", + expected_code="K002", + expected_message="dynamic column family 'status_amounts'", + files={ + **staging_files( + f"{_ENFORCED_HEADER} materialized table,\n{_TYPED_COLUMNS});\n\n" + "SELECT CAST(order_id AS INTEGER) AS order_id,\n" + " CAST(customer_id AS INTEGER) AS customer_id,\n" + " CAST(amount AS DOUBLE) AS amount, CAST(status AS VARCHAR) AS status\n" + 'FROM __source("raw_orders")\n' + ), + FAILURE_MART_PATH: _PIVOT_MART.format(family_type="VARCHAR"), + }, + ), + ) diff --git a/src/sqlbuild/_native.pyi b/src/sqlbuild/_native.pyi index 1cd39de451..ec665e40a6 100644 --- a/src/sqlbuild/_native.pyi +++ b/src/sqlbuild/_native.pyi @@ -494,6 +494,45 @@ def complete_semantic_checks( ]: ... # Native analysis: contracts. +def evaluate_native_model_contracts( + request: tuple[ + str, + bool, + list[ + tuple[ + str, + str | None, + tuple[list[tuple[str, str | None, bool, bool]], list[tuple[str, str]], bool, bool] + | None, + list[tuple[str, str | None, bool]] | None, + bool, + tuple[bool, str | None, list[tuple[str, str | None]]] | None, + list[str], + ] + ], + ], + /, +) -> list[ + tuple[ + str | None, + list[ + tuple[ + str, + bool, + str, + str | None, + int | None, + str | None, + tuple[str, str] | None, + str, + ] + ], + ] +]: ... +def native_promotion_conflicts( + request: tuple[str | None, str, str, list[tuple[str, str | None, str | None, str | None]]], + /, +) -> list[tuple[int, str, str, str]]: ... # Native analysis: column lineage facts. def build_fast_column_lineage( diff --git a/src/sqlbuild/compiler/contracts/_helpers/evaluation.py b/src/sqlbuild/compiler/contracts/_helpers/evaluation.py new file mode 100644 index 0000000000..794bf59167 --- /dev/null +++ b/src/sqlbuild/compiler/contracts/_helpers/evaluation.py @@ -0,0 +1,42 @@ +"""Which models need contract evaluation, and Python's evaluation of one model.""" + +from __future__ import annotations + +from sqlbuild.adapter.contract.types import TypeDialect +from sqlbuild.compiler.compile.models import CompiledModel, CompilerDiagnostic +from sqlbuild.compiler.contracts._helpers.columns import collect_model_column_contract_diagnostics +from sqlbuild.compiler.planner.types import ContractPolicy +from sqlbuild.spec.contracts.types import ColumnContractMode + + +def requires_contract_evaluation(*, model: CompiledModel, mode: ColumnContractMode) -> bool: + """Whether the model declares a shape to validate or enforces declared types.""" + + if declared_shape_validation_is_active(model=model, mode=mode): + return True + return model.schema_entry is not None and bool(model.schema_entry.type_enforcement) + + +def declared_shape_validation_is_active(*, model: CompiledModel, mode: ColumnContractMode) -> bool: + """Whether the model's declared columns are validated against its inferred output.""" + + contract: object = model.config.values.get("contract") + if contract == ContractPolicy.ENFORCED: + return True + if contract == ContractPolicy.NONE: + return False + return mode == ColumnContractMode.IMPLICIT and bool( + model.schema_entry is not None and model.schema_entry.columns + ) + + +def python_model_contract_diagnostics( + *, model: CompiledModel, mode: ColumnContractMode, dialect: TypeDialect | str | None +) -> tuple[CompilerDiagnostic, ...]: + """Python's contract diagnostics for one model that requires evaluation.""" + + return collect_model_column_contract_diagnostics( + model=model, + validate_declared_shape=declared_shape_validation_is_active(model=model, mode=mode), + dialect=dialect, + ) diff --git a/src/sqlbuild/compiler/contracts/_helpers/native_contracts.py b/src/sqlbuild/compiler/contracts/_helpers/native_contracts.py new file mode 100644 index 0000000000..5ed42e44b8 --- /dev/null +++ b/src/sqlbuild/compiler/contracts/_helpers/native_contracts.py @@ -0,0 +1,172 @@ +"""Model contract diagnostics built by the native engine, materialised as Python builds them.""" + +from __future__ import annotations + +import sqlbuild._native as _native +from sqlbuild.adapter.contract.types import TypeDialect +from sqlbuild.compiler.compile.models import ( + CompiledModel, + CompiledProject, + CompilerDiagnostic, + DynamicColumnContractProof, + RelatedLocation, +) +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, +) +from sqlbuild.compiler.contracts._helpers.columns import ( + _output_column_location, + _output_related_locations, +) +from sqlbuild.compiler.contracts._helpers.evaluation import ( + python_model_contract_diagnostics, + requires_contract_evaluation, +) +from sqlbuild.compiler.contracts._helpers.native_deferrals import record_contract_deferral +from sqlbuild.compiler.contracts.constants import ( + NATIVE_CONTRACTS_DEFERRAL_SITE, + NATIVE_CONTRACTS_UNSUPPORTED_INPUT, +) +from sqlbuild.compiler.contracts.models import ContractValidationResult +from sqlbuild.compiler.lineage.types import InferredNullability +from sqlbuild.spec.contracts.models import SchemaColumn, SchemaModelEntry, SourceLocation +from sqlbuild.spec.contracts.types import ColumnContractMode + +type _DeclaredRow = tuple[str, str | None, bool, bool] +type _SchemaRow = tuple[list[_DeclaredRow], list[tuple[str, str]], bool, bool] +type _ProofRow = tuple[bool, str | None, list[tuple[str, str | None]]] +type _ModelRow = tuple[ + str, + str | None, + _SchemaRow | None, + list[tuple[str, str | None, bool]] | None, + bool, + _ProofRow | None, + list[str], +] +type _DiagnosticRow = tuple[ + str, bool, str, str | None, int | None, str | None, tuple[str, str] | None, str +] + + +def native_model_contracts( + *, project: CompiledProject, dialect: TypeDialect | str | None +) -> ContractValidationResult | None: + """Return `evaluate_model_contracts`'s result, with deferred models evaluated by Python.""" + + if not isinstance(dialect, str | None): + record_contract_deferral( + kind=NATIVE_CONTRACTS_UNSUPPORTED_INPUT, site=NATIVE_CONTRACTS_DEFERRAL_SITE + ) + return None + mode: ColumnContractMode = project.settings.column_contract_mode + outcomes: list[tuple[str | None, list[_DiagnosticRow]]] = ( + _native.evaluate_native_model_contracts( + ( + str(dialect or "generic"), + mode == ColumnContractMode.IMPLICIT, + [_model_row(model) for model in project.models], + ) + ) + ) + diagnostics: list[CompilerDiagnostic] = [] + for model, (deferral, rows) in zip(project.models, outcomes, strict=True): + if deferral is not None: + record_contract_deferral(kind=deferral, site=NATIVE_CONTRACTS_DEFERRAL_SITE) + if requires_contract_evaluation(model=model, mode=mode): + diagnostics.extend( + python_model_contract_diagnostics(model=model, mode=mode, dialect=dialect) + ) + continue + diagnostics.extend(_diagnostic(model=model, row=row) for row in rows) + return ContractValidationResult(diagnostics=tuple(diagnostics)) + + +def _model_row(model: CompiledModel) -> _ModelRow: + contract: object = model.config.values.get("contract") + return ( + model.name, + contract if isinstance(contract, str) else None, + _schema_row(model=model, schema=model.schema_entry), + None + if model.inferred_columns is None + else [ + (column.name, column.type, column.nullability == InferredNullability.NULLABLE) + for column in model.inferred_columns + ], + bool(model.fast_lineage_has_star), + _proof_row(model.dynamic_column_contract), + list(model.unchecked_output_columns), + ) + + +def _schema_row(*, model: CompiledModel, schema: SchemaModelEntry | None) -> _SchemaRow | None: + if schema is None: + return None + named_schema: bool = schema.model_schema is not None + return ( + [ + ( + column.name, + column.type, + column.nullable is False, + named_schema and _declared_outside_model(model=model, column=column), + ) + for column in schema.columns + ], + [(family.name, family.type) for family in schema.dynamic_columns], + bool(schema.type_enforcement), + named_schema, + ) + + +def _declared_outside_model(*, model: CompiledModel, column: SchemaColumn) -> bool: + return column.location is not None and column.location.path != model.relative_path + + +def _proof_row(proof: DynamicColumnContractProof | None) -> _ProofRow | None: + if proof is None: + return None + return ( + bool(proof.output_proven), + proof.failure_reason, + [(family.name, family.inferred_type) for family in proof.families], + ) + + +def _diagnostic(*, model: CompiledModel, row: _DiagnosticRow) -> CompilerDiagnostic: + code, is_error, message, column_name, declared_index, output_column, related, help_text = row + return CompilerDiagnostic( + phase=DiagnosticPhase.CONTRACT, + severity=DiagnosticSeverity.ERROR if is_error else DiagnosticSeverity.WARNING, + code=code, + message=message, + resource_type=CompiledResourceType.MODEL, + resource_name=model.name, + column_name=column_name, + path=model.relative_path, + location=_location(model=model, declared_index=declared_index, output_column=output_column), + related_locations=_related_locations(model=model, related=related), + help=help_text, + ) + + +def _location( + *, model: CompiledModel, declared_index: int | None, output_column: str | None +) -> SourceLocation | None: + if declared_index is not None and model.schema_entry is not None: + return model.schema_entry.columns[declared_index].location + if output_column is not None: + return _output_column_location(model=model, column_name=output_column) + return None + + +def _related_locations( + *, model: CompiledModel, related: tuple[str, str] | None +) -> tuple[RelatedLocation, ...]: + if related is None: + return () + column_name, message = related + return _output_related_locations(model=model, column_name=column_name, message=message) diff --git a/src/sqlbuild/compiler/contracts/_helpers/native_deferrals.py b/src/sqlbuild/compiler/contracts/_helpers/native_deferrals.py new file mode 100644 index 0000000000..18d245c041 --- /dev/null +++ b/src/sqlbuild/compiler/contracts/_helpers/native_deferrals.py @@ -0,0 +1,23 @@ +"""Debug-only record of contract work the native engine hands back to Python.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from sqlbuild.compiler.lineage.constants import ANALYSIS_DEFERRAL_RECORD_PREFIX +from sqlbuild.compiler.sql_analysis.constants import ANALYSIS_RECORD_DIR_ENV_VAR + + +def record_contract_deferral(*, kind: str, site: str) -> None: + """Append one `{"kind", "site"}` line when the analysis record directory is set.""" + + directory: str | None = os.environ.get(ANALYSIS_RECORD_DIR_ENV_VAR) + if not directory: + return + target: Path = Path(directory) + target.mkdir(parents=True, exist_ok=True) + record: str = json.dumps({"kind": kind, "site": site}) + with open(target / f"{ANALYSIS_DEFERRAL_RECORD_PREFIX}{os.getpid()}.jsonl", "a") as handle: + _ = handle.write(record + "\n") diff --git a/src/sqlbuild/compiler/contracts/_helpers/native_promotion.py b/src/sqlbuild/compiler/contracts/_helpers/native_promotion.py new file mode 100644 index 0000000000..a345650b26 --- /dev/null +++ b/src/sqlbuild/compiler/contracts/_helpers/native_promotion.py @@ -0,0 +1,65 @@ +"""K011 promotion conflicts found by the native engine, materialised as Python builds them.""" + +from __future__ import annotations + +import sqlbuild._native as _native +from sqlbuild.compiler.compile.models import CompiledModel, CompiledProject, CompilerDiagnostic +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, +) +from sqlbuild.compiler.contracts._helpers.native_deferrals import record_contract_deferral +from sqlbuild.compiler.contracts.constants import ( + NATIVE_CONTRACTS_UNSUPPORTED_INPUT, + NATIVE_PROMOTION_DEFERRAL_SITE, +) +from sqlbuild.compiler.planner.types import MaterializationType + + +def native_promotion_conflicts( + *, project: CompiledProject, adapter_default: object, settings_file: str +) -> tuple[CompilerDiagnostic, ...] | None: + """Return `promotion_conflict_diagnostics`'s result, or None where Python must build it.""" + + explicit_mode: object = project.settings.table_promotion_mode + if not isinstance(adapter_default, str) or not isinstance(explicit_mode, str | None): + record_contract_deferral( + kind=NATIVE_CONTRACTS_UNSUPPORTED_INPUT, site=NATIVE_PROMOTION_DEFERRAL_SITE + ) + return None + conflicts: list[tuple[int, str, str, str]] = _native.native_promotion_conflicts( + ( + explicit_mode, + str(adapter_default), + settings_file, + [_model_row(model) for model in project.models], + ) + ) + return tuple( + CompilerDiagnostic( + phase=DiagnosticPhase.CONTRACT, + severity=DiagnosticSeverity.ERROR, + code=code, + message=message, + resource_type=CompiledResourceType.MODEL, + resource_name=project.models[index].name, + path=project.models[index].relative_path, + help=help_text, + ) + for index, code, message, help_text in conflicts + ) + + +def _model_row(model: CompiledModel) -> tuple[str, str | None, str | None, str | None]: + values: dict[str, object] = model.config.values + return ( + model.name, + _text(values.get("contract")), + _text(values.get("materialized", MaterializationType.TABLE)), + _text(values.get("incremental_mode")), + ) + + +def _text(value: object) -> str | None: + return str.__str__(value) if isinstance(value, str) else None diff --git a/src/sqlbuild/compiler/contracts/constants.py b/src/sqlbuild/compiler/contracts/constants.py index beb870aca5..d51f5c1015 100644 --- a/src/sqlbuild/compiler/contracts/constants.py +++ b/src/sqlbuild/compiler/contracts/constants.py @@ -1,3 +1,6 @@ """Model contract decision constants.""" NOT_NULL_AUDIT_NAME: str = "not_null" +NATIVE_CONTRACTS_DEFERRAL_SITE: str = "contracts/columns.py" +NATIVE_PROMOTION_DEFERRAL_SITE: str = "contracts/promotion.py" +NATIVE_CONTRACTS_UNSUPPORTED_INPUT: str = "unsupported_input" diff --git a/src/sqlbuild/compiler/contracts/main/_evaluate_native_model_contracts.py b/src/sqlbuild/compiler/contracts/main/_evaluate_native_model_contracts.py index bb823aa8c2..c35b77e9ef 100644 --- a/src/sqlbuild/compiler/contracts/main/_evaluate_native_model_contracts.py +++ b/src/sqlbuild/compiler/contracts/main/_evaluate_native_model_contracts.py @@ -4,6 +4,7 @@ from sqlbuild.adapter.contract.types import TypeDialect from sqlbuild.compiler.compile.models import CompiledProject +from sqlbuild.compiler.contracts._helpers.native_contracts import native_model_contracts from sqlbuild.compiler.contracts.models import ContractValidationResult @@ -12,4 +13,4 @@ def evaluate_native_model_contracts( ) -> ContractValidationResult | None: """Return the contract diagnostics, or None where Python must evaluate the contracts.""" - return None + return native_model_contracts(project=project, dialect=dialect) diff --git a/src/sqlbuild/compiler/contracts/main/_native_promotion_conflicts.py b/src/sqlbuild/compiler/contracts/main/_native_promotion_conflicts.py new file mode 100644 index 0000000000..af639c188d --- /dev/null +++ b/src/sqlbuild/compiler/contracts/main/_native_promotion_conflicts.py @@ -0,0 +1,16 @@ +"""Find promotion conflicts natively for the preview compiler engine.""" + +from __future__ import annotations + +from sqlbuild.compiler.compile.models import CompiledProject, CompilerDiagnostic +from sqlbuild.compiler.contracts._helpers.native_promotion import native_promotion_conflicts + + +def native_promotion_conflict_diagnostics( + *, project: CompiledProject, adapter_default: object, settings_file: str +) -> tuple[CompilerDiagnostic, ...] | None: + """Return the K011 promotion conflicts, or None where Python must find them.""" + + return native_promotion_conflicts( + project=project, adapter_default=adapter_default, settings_file=settings_file + ) diff --git a/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py b/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py index fa6f932517..5e86bbab99 100644 --- a/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py +++ b/src/sqlbuild/compiler/contracts/main/promotion_conflicts.py @@ -5,6 +5,11 @@ from sqlbuild.adapter.contract.types import TablePromotionMode from sqlbuild.compiler.compile.models import CompiledProject, CompilerDiagnostic from sqlbuild.compiler.contracts._helpers.promotion import promotion_conflict_diagnostics_impl +from sqlbuild.compiler.contracts.main._native_promotion_conflicts import ( + native_promotion_conflict_diagnostics, +) +from sqlbuild.compiler.frontier.main.native_stage_enabled import native_stage_enabled +from sqlbuild.compiler.frontier.types import NativeStage def promotion_conflict_diagnostics( @@ -15,6 +20,12 @@ def promotion_conflict_diagnostics( ) -> tuple[CompilerDiagnostic, ...]: """Return one K011 error per enforced-contract table model under immediate promotion.""" + if native_stage_enabled(NativeStage.CONTRACTS): + native: tuple[CompilerDiagnostic, ...] | None = native_promotion_conflict_diagnostics( + project=project, adapter_default=adapter_default, settings_file=settings_file + ) + if native is not None: + return native return promotion_conflict_diagnostics_impl( project=project, adapter_default=adapter_default, settings_file=settings_file ) diff --git a/src/sqlbuild/compiler/contracts/main/validate.py b/src/sqlbuild/compiler/contracts/main/validate.py index 20049a5419..a3ea86bee3 100644 --- a/src/sqlbuild/compiler/contracts/main/validate.py +++ b/src/sqlbuild/compiler/contracts/main/validate.py @@ -3,15 +3,17 @@ from __future__ import annotations from sqlbuild.adapter.contract.types import TypeDialect -from sqlbuild.compiler.compile.models import CompiledModel, CompiledProject, CompilerDiagnostic -from sqlbuild.compiler.contracts._helpers.columns import collect_model_column_contract_diagnostics +from sqlbuild.compiler.compile.models import CompiledProject, CompilerDiagnostic +from sqlbuild.compiler.contracts._helpers.evaluation import ( + python_model_contract_diagnostics, + requires_contract_evaluation, +) from sqlbuild.compiler.contracts.main._evaluate_native_model_contracts import ( evaluate_native_model_contracts, ) from sqlbuild.compiler.contracts.models import ContractValidationResult from sqlbuild.compiler.frontier.main.native_stage_enabled import native_stage_enabled from sqlbuild.compiler.frontier.types import NativeStage -from sqlbuild.compiler.planner.types import ContractPolicy from sqlbuild.spec.contracts.types import ColumnContractMode @@ -28,40 +30,14 @@ def evaluate_model_contracts( ) if native_result is not None: return native_result - if not any( - _requires_contract_evaluation(model=model, mode=project.settings.column_contract_mode) - for model in project.models - ): + mode: ColumnContractMode = project.settings.column_contract_mode + if not any(requires_contract_evaluation(model=model, mode=mode) for model in project.models): return ContractValidationResult(diagnostics=()) diagnostics: list[CompilerDiagnostic] = [] for model in project.models: - if _requires_contract_evaluation(model=model, mode=project.settings.column_contract_mode): + if requires_contract_evaluation(model=model, mode=mode): diagnostics.extend( - collect_model_column_contract_diagnostics( - model=model, - validate_declared_shape=_declared_shape_validation_is_active( - model=model, - mode=project.settings.column_contract_mode, - ), - dialect=dialect, - ) + python_model_contract_diagnostics(model=model, mode=mode, dialect=dialect) ) return ContractValidationResult(diagnostics=tuple(diagnostics)) - - -def _requires_contract_evaluation(*, model: CompiledModel, mode: ColumnContractMode) -> bool: - if _declared_shape_validation_is_active(model=model, mode=mode): - return True - return model.schema_entry is not None and bool(model.schema_entry.type_enforcement) - - -def _declared_shape_validation_is_active(*, model: CompiledModel, mode: ColumnContractMode) -> bool: - contract: object = model.config.values.get("contract") - if contract == ContractPolicy.ENFORCED: - return True - if contract == ContractPolicy.NONE: - return False - return mode == ColumnContractMode.IMPLICIT and bool( - model.schema_entry is not None and model.schema_entry.columns - ) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py index c0587a725b..977aafbf7b 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py @@ -818,7 +818,7 @@ class DefaultEngineParityTestCase: @dataclass(frozen=True) class NativeTypeSystemTestCase: - """Python and preview compiles of one project whose contracts compare type spellings.""" + """Compiles of one project by each engine whose contracts compare types or fail.""" description: str project_files: dict[str, str] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py index 505571605f..0d3a7c4dc8 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -16,6 +16,7 @@ import time import zipfile from bisect import bisect_left +from collections import Counter from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager, redirect_stdout from dataclasses import replace @@ -40,6 +41,7 @@ import sqlbuild.compiler.compile._helpers.macro_bridge.call_store as call_store_module import sqlbuild.compiler.compile._helpers.native_stages.assembly as native_stages import sqlbuild.compiler.compile._helpers.native_stages.sql_tests as native_sql_test_stage +import sqlbuild.compiler.contracts.main.promotion_conflicts as promotion_conflicts import sqlbuild.compiler.contracts.main.validate as contract_validation import sqlbuild.compiler.frontier.main.compiled_code_identity as compiled_code_identity_module import sqlbuild.compiler.lineage.main.columns as column_lineage @@ -91,6 +93,11 @@ MACRO_BRIDGE_PROJECT_FILES, MACRO_CALL_LOG_ENV_VAR, ) +from tests.integration.src.sqlbuild.compiler.contracts.helpers import ( + NativeContractOutcome, + NativeContractRequest, + native_contract_statuses, +) _DBT_SHAPED_SQL_SIZE_PROFILE: tuple[tuple[float, int], ...] = ( (0.50, 1_800), @@ -4881,6 +4888,7 @@ def engine_macro_call_runs( (native_sql_test_stage, "assemble_native_sql_tests"), (diagnostic_recovery, "complete_native_semantic_diagnostics"), (contract_validation, "evaluate_native_model_contracts"), + (promotion_conflicts, "native_promotion_conflict_diagnostics"), (column_lineage, "build_native_column_lineage"), (target_writer, "plan_native_sql_test_artifacts"), ) @@ -4929,32 +4937,55 @@ def engine_in_process_compile( return run, seams +class NativeTypeAnswers(NamedTuple): + """What the native type system answered in one compile: each Python-side normalization, + contract results that compared at least one typed column, and contract handbacks.""" + + normalized: list[bool] + typed_comparisons: int + handbacks: int + + def type_system_engine_compile( *, project_dir: Path, engine: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], -) -> tuple[CompileReuseRun, list[bool]]: - """Compile in this process under `engine`; return the run and whether each native type - normalization answered.""" +) -> tuple[CompileReuseRun, NativeTypeAnswers]: + """Compile in this process under `engine`; return the run and what native types answered.""" - answered: list[bool] = [] + normalized: list[bool] = [] + contract_statuses: Counter[str] = Counter() normalize: Callable[[str, str], object] = native_module.normalize_type + evaluate_contracts: Callable[..., list[NativeContractOutcome]] = ( + native_module.evaluate_native_model_contracts + ) def recorded(type_sql: str, dialect: str) -> object: result: object = normalize(type_sql, dialect) - answered.append(result is not None) + normalized.append(result is not None) return result + def recorded_contracts(request: NativeContractRequest) -> list[NativeContractOutcome]: + outcomes: list[NativeContractOutcome] = evaluate_contracts(request) + contract_statuses.update(native_contract_statuses(request=request, outcomes=outcomes)) + contract_statuses["handbacks"] += sum(deferral is not None for deferral, _ in outcomes) + return outcomes + type_normalization.normalize_type.cache_clear() with monkeypatch.context() as patch: patch.setattr(native_module, "normalize_type", recorded) + patch.setattr(native_module, "evaluate_native_model_contracts", recorded_contracts) for name, value in {COMPILER_ENGINE_ENV_VAR: engine, REUSE_DISABLE_ENV_VAR: "1"}.items(): patch.setenv(name, value) run: CompileReuseRun = in_process_reuse_run(project_dir=project_dir, capsys=capsys) type_normalization.normalize_type.cache_clear() - return run, answered + return run, NativeTypeAnswers( + normalized=normalized, + typed_comparisons=contract_statuses["typed_comparisons"], + handbacks=contract_statuses["handbacks"], + ) def lifecycle_error_type(*, project_dir: Path, engine: str, monkeypatch: pytest.MonkeyPatch) -> str: diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py index b42e71022d..7cd113a127 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py @@ -8,6 +8,7 @@ import pytest from sqlbuild.compiler.compile.models import CompiledProject +from sqlbuild.compiler.contracts.models import ContractValidationResult from sqlbuild.compiler.lineage.models import ProjectColumnLineage from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import ( NativeAnalysisSeamTestCase, @@ -34,7 +35,8 @@ "analyze_native_model_sql": [ANY], "assemble_native_sql_tests": [None], "complete_native_semantic_diagnostics": [ANY], - "evaluate_native_model_contracts": [None], + "evaluate_native_model_contracts": [ANY], + "native_promotion_conflict_diagnostics": [()], "build_native_column_lineage": [ANY], "plan_native_sql_test_artifacts": [None], }, @@ -76,6 +78,7 @@ def test_given_project_when_compiling_with_each_engine_then_native_seams_answer_ assert isinstance(preview_seams["analyze_native_model_sql"][0], dict) assert isinstance(preview_seams["infer_native_expression_source_shapes"][0], tuple) assert isinstance(preview_seams["complete_native_semantic_diagnostics"][0], CompiledProject) + assert isinstance(preview_seams["evaluate_native_model_contracts"][0], ContractValidationResult) assert (native_run.returncode, preview_run.returncode) == (0, 0) assert report_without_engine(native_run) == report_without_engine(python_run) assert report_without_engine(preview_run) == report_without_engine(python_run) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_type_system.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_type_system.py index d58d453159..316e13e13d 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_type_system.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_type_system.py @@ -1,4 +1,4 @@ -"""Contracts compare type spellings through the native type system under the preview engine.""" +"""Every engine reports contract types and failures alike; preview compares types natively.""" from __future__ import annotations @@ -11,6 +11,7 @@ ) from tests.e2e.src.sqlbuild.cli.commands.main.compile.helpers import ( CompileReuseRun, + NativeTypeAnswers, report_without_engine, type_system_engine_compile, ) @@ -44,6 +45,78 @@ SELECT order_id, amount AS total FROM __ref("orders") """ +_NO_NATIVE_ANSWERS: NativeTypeAnswers = NativeTypeAnswers( + normalized=[], typed_comparisons=0, handbacks=0 +) +_IMMEDIATE_PROMOTION_TOML: str = ( + _PROJECT_TOML + '\n[settings]\ntable_promotion_mode = "immediate"\n' +) +_ENFORCED_ORDERS: str = """MODEL ( + description "Orders with an enforced contract", + materialized table, + contract enforced, + columns ( + order_id (type VARCHAR), + customer_id (type INTEGER, nullable false), + shipped_at (type TIMESTAMP), + amount (type DOUBLE), + ), +); + +SELECT CAST(1 AS INTEGER) AS order_id, CAST(NULL AS INTEGER) AS customer_id, + CAST(2.5 AS DOUBLE) AS amount, 'placed' AS status +""" +_IMPLICIT_TOTALS: str = """MODEL ( + description "Totals with declared columns and no enforced contract", + columns ( + total_amount (type DOUBLE), + order_count (type BIGINT), + ), +); + +SELECT CAST(2.5 AS DOUBLE) AS total_amount +""" +_EMPTY_CONTRACT: str = """MODEL ( + description "An enforced contract without columns", + materialized view, + contract enforced, +); + +SELECT 1 AS order_id +""" +_ORDER_AMOUNTS: str = """MODEL ( + description "Typed order amounts per category", + materialized view, + contract enforced, + columns ( + customer_id (type INTEGER), + category (type VARCHAR), + amount (type "DECIMAL(12,2)"), + ), +); + +SELECT CAST(1 AS INTEGER) AS customer_id, CAST('books' AS VARCHAR) AS category, + CAST(10.25 AS DECIMAL(12,2)) AS amount +""" +_CATEGORY_PIVOT: str = """MODEL ( + description "Order amounts pivoted per category", + materialized view, + columns (customer_id (type INTEGER)), + dynamic_columns ( + category_amounts ( + pivot_column category, + value_column amount, + aggregate MAX, + type VARCHAR + ) + ), +); + +PIVOT __ref("contract_order_amounts") +ON category +USING MAX(amount) +GROUP BY customer_id +""" @pytest.mark.parametrize( @@ -59,16 +132,29 @@ expected_exit_code=1, expected_report_text="inferred as numeric(10,2) but declared type is DECIMAL(12,2)", ), + NativeTypeSystemTestCase( + description="every contract family fails alike under immediate promotion", + project_files={ + "sqlbuild_project.toml": _IMMEDIATE_PROMOTION_TOML, + "models/contract_orders.sql": _ENFORCED_ORDERS, + "models/contract_totals.sql": _IMPLICIT_TOTALS, + "models/contract_empty.sql": _EMPTY_CONTRACT, + "models/contract_order_amounts.sql": _ORDER_AMOUNTS, + "models/contract_category_pivot.sql": _CATEGORY_PIVOT, + }, + expected_exit_code=1, + expected_report_text="contract enforced requires staged table promotion", + ), ], ids=lambda case: case.description, ) -def test_given_contract_types_when_compiling_with_preview_then_native_types_match_python( +def test_given_contract_types_when_compiling_with_each_engine_then_reports_match_python( test_case: NativeTypeSystemTestCase, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: - outcomes: dict[str, tuple[CompileReuseRun, list[bool]]] = { + outcomes: dict[str, tuple[CompileReuseRun, NativeTypeAnswers]] = { engine: type_system_engine_compile( project_dir=prepare_inline_project( tmp_path=tmp_path, project_name=engine, repo_files=test_case.project_files @@ -77,19 +163,21 @@ def test_given_contract_types_when_compiling_with_preview_then_native_types_matc monkeypatch=monkeypatch, capsys=capsys, ) - for engine in ("python", "native-preview") + for engine in ("python", "native", "native-preview") } python_run, python_answers = outcomes["python"] + native_run, native_answers = outcomes["native"] preview_run, preview_answers = outcomes["native-preview"] - assert (python_run.returncode, preview_run.returncode) == ( - test_case.expected_exit_code, - test_case.expected_exit_code, + assert (python_run.returncode, native_run.returncode, preview_run.returncode) == ( + (test_case.expected_exit_code,) * 3 ) assert test_case.expected_report_text in python_run.report + assert report_without_engine(native_run) == report_without_engine(python_run) assert report_without_engine(preview_run) == report_without_engine(python_run) - assert preview_run.compiled == python_run.compiled - assert (python_answers, any(preview_answers)) == ([], True) + assert (native_run.compiled, preview_run.compiled) == (python_run.compiled,) * 2 + assert (python_answers, native_answers) == (_NO_NATIVE_ANSWERS, _NO_NATIVE_ANSWERS) + assert (preview_answers.typed_comparisons >= 1, preview_answers.handbacks) == (True, 0) if __name__ == "__main__": diff --git a/tests/integration/src/sqlbuild/compiler/contracts/__init__.py b/tests/integration/src/sqlbuild/compiler/contracts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/src/sqlbuild/compiler/contracts/_test_types.py b/tests/integration/src/sqlbuild/compiler/contracts/_test_types.py new file mode 100644 index 0000000000..3f8546f893 --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/contracts/_test_types.py @@ -0,0 +1,40 @@ +"""Test case types for native contract validation and promotion conflicts.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GeneratedContractParityTestCase: + """Generated projects whose models are perturbed into every contract family per dialect.""" + + description: str + seed: int + variants: int + dialects: tuple[str | None, ...] + expected_minimum_native: int + expected_minimum_typed_comparisons: int + expected_minimum_diagnostics: int + expected_codes: frozenset[str] + + +@dataclass(frozen=True) +class GeneratedPromotionParityTestCase: + """Generated lifecycle configs and promotion settings compared with Python.""" + + description: str + seed: int + variants: int + expected_minimum_conflicts: int + + +@dataclass(frozen=True) +class DeferredContractTestCase: + """A contract input native validation hands back to Python, and the deferral it records.""" + + description: str + declared_type: str + dialect: str | None + expected_kind: str + expected_deferred_models: int diff --git a/tests/integration/src/sqlbuild/compiler/contracts/helpers.py b/tests/integration/src/sqlbuild/compiler/contracts/helpers.py new file mode 100644 index 0000000000..19b79f5805 --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/contracts/helpers.py @@ -0,0 +1,473 @@ +"""Generated contract inputs, and the Python and native contract results they produce.""" + +from __future__ import annotations + +import itertools +import json +import random +from collections import Counter +from collections.abc import Callable, Iterator, Sequence +from dataclasses import replace +from pathlib import Path +from typing import Any, NamedTuple + +import pytest + +import sqlbuild._native as native_module +from sqlbuild.adapter.contract.types import TablePromotionMode +from sqlbuild.adapter.type_system._helpers.type_normalization import normalize_type +from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter +from sqlbuild.compiler.compile.models import ( + CompiledModel, + CompiledProject, + CompilerDiagnostic, + DynamicColumnContractProof, + DynamicColumnFamilyProof, + InferredColumn, +) +from sqlbuild.compiler.contracts.main.promotion_conflicts import promotion_conflict_diagnostics +from sqlbuild.compiler.contracts.main.validate import evaluate_model_contracts +from sqlbuild.compiler.discovery.main.discover import discover_project_inputs +from sqlbuild.compiler.frontier.constants import COMPILER_ENGINE_ENV_VAR +from sqlbuild.compiler.lineage.types import InferredNullability +from sqlbuild.compiler.pipeline.main.graph import build_project_graph +from sqlbuild.compiler.planner.types import ContractPolicy, IncrementalMode, MaterializationType +from sqlbuild.spec.contracts.models import ( + SchemaColumn, + SchemaDynamicColumnFamily, + SchemaModelEntry, + SourceLocation, +) +from sqlbuild.spec.contracts.types import ColumnContractMode + +_PROJECT_TOML: str = ( + 'name = "orders_contracts"\nadapter = "duckdb"\n\n[connection]\ndatabase = "orders.duckdb"\n' +) +_SOURCES: str = """sources: + - name: raw_orders + description: Orders feed. + expression: >- + (SELECT 1 AS order_id, 10 AS customer_id, CAST(5 AS DOUBLE) AS amount, 'placed' AS status) + columns: + - name: order_id + type: INTEGER + - name: customer_id + type: INTEGER + - name: amount + type: DOUBLE + - name: status + type: VARCHAR +""" +_MODELS: dict[str, str] = { + "models/staging/stg_orders.sql": ( + 'MODEL (\n description "Staged orders",\n contract enforced,\n columns (\n' + " order_id (type INTEGER, nullable false),\n amount (type DOUBLE),\n ),\n);\n\n" + 'SELECT order_id, customer_id, amount, status FROM __source("raw_orders")\n' + ), + "models/marts/customer_totals.sql": ( + 'MODEL (\n description "Totals per customer",\n columns (\n' + " customer_id (type BIGINT),\n total_amount (type DECIMAL(18, 2)),\n ),\n);\n\n" + "SELECT customer_id, SUM(amount) AS total_amount, COUNT(*) AS order_count,\n" + " MAX(status) AS last_status\n" + 'FROM __ref("stg_orders")\nGROUP BY customer_id\n' + ), + "models/marts/order_flags.sql": ( + 'MODEL (\n description "Order flags",\n);\n\n' + "SELECT order_id, amount > 10 AS is_large, CAST(NULL AS VARCHAR) AS note,\n" + " status AS Status_Label\n" + 'FROM __ref("stg_orders")\n' + ), + "models/marts/all_orders.sql": ( + 'MODEL (\n description "Every order column",\n);\n\nSELECT * FROM __ref("stg_orders")\n' + ), +} +TYPE_POOL: tuple[str | None, ...] = ( + None, + "INTEGER", + "INT", + "int4", + "BIGINT", + "INT64", + "SMALLINT", + "VARCHAR", + "VARCHAR(10)", + "varchar(255)", + "STRING", + "TEXT", + "CHAR(3)", + "DOUBLE", + "FLOAT", + "FLOAT64", + "REAL", + "NUMERIC", + "NUMERIC(10, 2)", + "DECIMAL(18,2)", + "NUMBER(38, 0)", + "BOOLEAN", + "BOOL", + "DATE", + "TIMESTAMP", + "TIMESTAMP_NTZ", + "TIMESTAMPTZ", + "TIMESTAMP WITH TIME ZONE", + "DATETIME", + "JSON", + "VARIANT", + "STRUCT", + "ARRAY", + "INTEGER[]", + "MAP(VARCHAR, INTEGER)", + "not a type (", + "", +) +_OUTPUT_NAMES: list[str] = [ + "order_id", + "customer_id", + "amount", + "status", + "total_amount", + "order_count", + "is_large", + "note", + "Status_Label", + "missing_id", + "Order_ID", +] +_ENFORCED_MODEL: str = "stg_orders" +_NAMED_SCHEMA_PATH: Path = Path("schemas/orders_schema.sql") +_LIFECYCLE_KEYS: tuple[str, ...] = ("contract", "materialized", "incremental_mode") +_FAMILY_NAMES: tuple[str, ...] = ("amount_*", "Amount_*", "count_*", "label_*") +_REASONS: tuple[str | None, ...] = (None, "", "no supported pivot") +_CONTRACTS: tuple[object, ...] = ( + "enforced", + ContractPolicy.ENFORCED, + "none", + ContractPolicy.NONE, + "ENFORCED", + None, + 1, +) +_MATERIALIZATIONS: tuple[object, ...] = ( + MaterializationType.TABLE, + "table", + MaterializationType.VIEW, + MaterializationType.INCREMENTAL, + "incremental", + MaterializationType.SNAPSHOT, + None, +) +_INCREMENTAL_MODES: tuple[object, ...] = ( + IncrementalMode.MICROBATCH, + "microbatch", + IncrementalMode.FULL, + None, +) +_PROMOTION_MODES: tuple[str | None, ...] = (None, "immediate", "staged", "IMMEDIATE", "") +_SETTINGS_FILES: tuple[str, ...] = ("sqlbuild_project.toml", "sqlbuild_local.toml") + +type ContractView = tuple[CompilerDiagnostic, ...] +type NativeContractRequest = tuple[str, bool, list[Any]] +type NativeContractOutcome = tuple[str | None, list[Any]] +_EMPTY_SCHEMA_ROW: tuple[list[Any], list[Any], bool, bool] = ([], [], False, False) + + +def compiled_contract_project(*, project_dir: Path) -> CompiledProject: + """Write and compile the base project whose models the generators perturb.""" + + files: dict[str, str] = { + "sqlbuild_project.toml": _PROJECT_TOML, + "sources/raw.yml": _SOURCES, + **_MODELS, + } + for relative_path, contents in files.items(): + path: Path = project_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + _ = path.write_text(contents, encoding="utf-8") + return build_project_graph( + discovered_inputs=discover_project_inputs(project_dir=project_dir), + adapter=DuckDbAdapter(), + ).project + + +def perturbed_project(*, project: CompiledProject, rng: random.Random) -> CompiledProject: + """The project with every model's contract inputs replaced by generated ones.""" + + return replace( + project, + settings=replace( + project.settings, + column_contract_mode=rng.choice(tuple(ColumnContractMode)), + table_promotion_mode=rng.choice(_PROMOTION_MODES), + ), + models=tuple(perturbed_model(model=model, rng=rng) for model in project.models), + ) + + +def perturbed_model(*, model: CompiledModel, rng: random.Random) -> CompiledModel: + """One model with generated contract config, declared shape, inference and proof.""" + + values: dict[str, object] = dict(model.config.values) + for key in _LIFECYCLE_KEYS: + _ = values.pop(key, None) + for key, options in zip( + _LIFECYCLE_KEYS, (_CONTRACTS, _MATERIALIZATIONS, _INCREMENTAL_MODES), strict=True + ): + values.update(rng.choice(({}, *({key: option} for option in options)))) + return replace( + model, + config=replace(model.config, values=values), + schema_entry=rng.choice((None, *(_schema_entry(model=model, rng=rng),) * 9)), + inferred_columns=rng.choice((None, *(_inferred_columns(rng=rng),) * 9)), + fast_lineage_has_star=rng.random() < 0.2, + dynamic_column_contract=rng.choice((None, None, *(_dynamic_proof(rng=rng),) * 5)), + unchecked_output_columns=frozenset(rng.sample(_OUTPUT_NAMES, k=rng.randint(0, 2))), + ) + + +def _schema_entry(*, model: CompiledModel, rng: random.Random) -> SchemaModelEntry: + columns: tuple[SchemaColumn, ...] = tuple( + SchemaColumn( + name=name, + type=rng.choice(TYPE_POOL), + nullable=rng.choice((None, False, True)), + location=rng.choice( + ( + None, + SourceLocation(path=model.relative_path, line=3, column=5), + SourceLocation(path=_NAMED_SCHEMA_PATH, line=2, column=3), + ) + ), + ) + for name in rng.sample(_OUTPUT_NAMES, k=rng.randint(0, 4)) + ) + families: tuple[SchemaDynamicColumnFamily, ...] = tuple( + SchemaDynamicColumnFamily( + name=name, + pivot_column="status", + value_column="amount", + aggregate="sum", + type=str(rng.choice(TYPE_POOL[1:])), + ) + for name in rng.sample(_FAMILY_NAMES, k=rng.choice((0, 0, 1, 2))) + ) + return SchemaModelEntry( + name=model.name, + model_schema=rng.choice((None, None, "orders_schema")), + type_enforcement=rng.choice((None, False, True)), + columns=columns, + dynamic_columns=families, + ) + + +def _inferred_columns(*, rng: random.Random) -> tuple[InferredColumn, ...]: + return tuple( + InferredColumn( + name=name, + type=rng.choice(TYPE_POOL), + nullability=rng.choice(tuple(InferredNullability)), + ) + for name in rng.choices(_OUTPUT_NAMES, k=rng.randint(0, 6)) + ) + + +def _dynamic_proof(*, rng: random.Random) -> DynamicColumnContractProof: + return DynamicColumnContractProof( + output_proven=rng.random() < 0.6, + failure_reason=rng.choice(_REASONS), + families=tuple( + DynamicColumnFamilyProof( + name=rng.choice((name, name.upper(), name.title())), + inferred_type=rng.choice(TYPE_POOL), + ) + for name in rng.sample(_FAMILY_NAMES, k=rng.randint(0, 3)) + ), + ) + + +def contract_views( + *, + project: CompiledProject, + dialect: str | None, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[ContractView, ContractView]: + """Python's and the native engine's contract diagnostics for the same project.""" + + return ( + _on_engine( + engine="python", + monkeypatch=monkeypatch, + run=lambda: evaluate_model_contracts(project=project, dialect=dialect).diagnostics, + ), + _on_engine( + engine="native-preview", + monkeypatch=monkeypatch, + run=lambda: evaluate_model_contracts(project=project, dialect=dialect).diagnostics, + ), + ) + + +def promotion_views( + *, + project: CompiledProject, + adapter_default: TablePromotionMode, + settings_file: str, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[ContractView, ContractView]: + """Python's and the native engine's K011 promotion conflicts for the same project.""" + + def run() -> ContractView: + return promotion_conflict_diagnostics( + project=project, adapter_default=adapter_default, settings_file=settings_file + ) + + return ( + _on_engine(engine="python", monkeypatch=monkeypatch, run=run), + _on_engine(engine="native-preview", monkeypatch=monkeypatch, run=run), + ) + + +def promotion_settings(*, rng: random.Random) -> tuple[TablePromotionMode, str]: + """A generated adapter default promotion mode and settings file.""" + + return rng.choice(tuple(TablePromotionMode)), rng.choice(_SETTINGS_FILES) + + +def _on_engine( + *, engine: str, monkeypatch: pytest.MonkeyPatch, run: Callable[[], ContractView] +) -> ContractView: + monkeypatch.setenv(COMPILER_ENGINE_ENV_VAR, engine) + normalize_type.cache_clear() + return run() + + +class NativeContractRecord(NamedTuple): + """Native contract outcomes: `native` (evaluated natively), `typed_comparisons`, handback + kinds and `native_diagnostics` in `statuses`; codes of natively built diagnostics in `codes`.""" + + statuses: Counter[str] + codes: Counter[str] + + +def record_native_outcomes(*, monkeypatch: pytest.MonkeyPatch) -> NativeContractRecord: + """Record native contract outcomes for the rest of the test.""" + + record: NativeContractRecord = NativeContractRecord(statuses=Counter(), codes=Counter()) + evaluate: Callable[..., list[NativeContractOutcome]] = ( + native_module.evaluate_native_model_contracts + ) + + def counted(request: NativeContractRequest) -> list[NativeContractOutcome]: + outcomes: list[NativeContractOutcome] = evaluate(request) + record.statuses.update(native_contract_statuses(request=request, outcomes=outcomes)) + answered: list[NativeContractOutcome] = list(filter(_answered, outcomes)) + rows: Iterator[Sequence[Any]] = itertools.chain.from_iterable( + diagnostics for _, diagnostics in answered + ) + record.codes.update(row[0] for row in rows) + return outcomes + + monkeypatch.setattr(native_module, "evaluate_native_model_contracts", counted) + return record + + +def native_contract_statuses( + *, request: NativeContractRequest, outcomes: list[NativeContractOutcome] +) -> Counter[str]: + """Per-request counts: `native` for models native evaluated, `typed_comparisons` for those + that compared at least one typed column, each handback kind, and `native_diagnostics`.""" + + _, implicit, models = request + pairs: list[tuple[Sequence[Any], NativeContractOutcome]] = list( + zip(models, outcomes, strict=True) + ) + answered: list[tuple[Sequence[Any], NativeContractOutcome]] = list( + filter(lambda pair: _answered(pair[1]), pairs) + ) + evaluated: list[Sequence[Any]] = list( + filter( + lambda model: _requires_evaluation(model=model, implicit=implicit), + (model for model, _ in answered), + ) + ) + statuses: Counter[str] = Counter( + str(deferral) for deferral, _ in filter(lambda outcome: not _answered(outcome), outcomes) + ) + statuses["native"] += len(evaluated) + statuses["typed_comparisons"] += sum( + _compares_typed_column(model=model, implicit=implicit) for model in evaluated + ) + statuses["native_diagnostics"] += sum(len(diagnostics) for _, (_, diagnostics) in answered) + return statuses + + +def _answered(outcome: NativeContractOutcome) -> bool: + return outcome[0] is None + + +def _schema(model: Sequence[Any]) -> Sequence[Any]: + return (_EMPTY_SCHEMA_ROW, model[2])[model[2] is not None] + + +def _shape_validation_active(*, model: Sequence[Any], implicit: bool) -> bool: + contract: str | None = model[1] + return contract == "enforced" or (contract != "none" and implicit and bool(_schema(model)[0])) + + +def _requires_evaluation(*, model: Sequence[Any], implicit: bool) -> bool: + return _shape_validation_active(model=model, implicit=implicit) or bool(_schema(model)[2]) + + +def _compares_typed_column(*, model: Sequence[Any], implicit: bool) -> bool: + inferred_types: dict[str, str | None] = { + name: inferred_type for name, inferred_type, _ in (model[3] or ()) + } + return ( + any( + declared_type is not None and inferred_types.get(name) is not None + for name, declared_type, _, _ in _schema(model)[0] + ) + and model[3] is not None + ) + + +def record_native_promotion_calls(*, monkeypatch: pytest.MonkeyPatch) -> Counter[str]: + """Count native promotion conflict calls and the conflicts they return.""" + + statuses: Counter[str] = Counter() + conflicts: Callable[..., list[tuple[int, str, str, str]]] = ( + native_module.native_promotion_conflicts + ) + + def counted(*arguments: Any) -> list[tuple[int, str, str, str]]: + found: list[tuple[int, str, str, str]] = conflicts(*arguments) + statuses["calls"] += 1 + statuses["conflicts"] += len(found) + return found + + monkeypatch.setattr(native_module, "native_promotion_conflicts", counted) + return statuses + + +def deferral_records(directory: Path) -> list[dict[str, str]]: + """Every deferral record written under `directory`, in file order.""" + + lines: Iterator[str] = itertools.chain.from_iterable( + path.read_text(encoding="utf-8").splitlines() + for path in sorted(directory.glob("analysis-deferrals-*.jsonl")) + ) + return [json.loads(line) for line in lines] + + +def with_declared_type(*, project: CompiledProject, declared_type: str) -> CompiledProject: + """The project with its enforced staging model declaring `declared_type` for every column.""" + + by_name: dict[str, CompiledModel] = {model.name: model for model in project.models} + staging: CompiledModel = by_name[_ENFORCED_MODEL] + assert staging.schema_entry is not None + columns: tuple[SchemaColumn, ...] = tuple( + replace(column, type=declared_type) for column in staging.schema_entry.columns + ) + by_name[_ENFORCED_MODEL] = replace( + staging, schema_entry=replace(staging.schema_entry, columns=columns) + ) + return replace(project, models=tuple(by_name[model.name] for model in project.models)) diff --git a/tests/integration/src/sqlbuild/compiler/contracts/test_native_model_contracts.py b/tests/integration/src/sqlbuild/compiler/contracts/test_native_model_contracts.py new file mode 100644 index 0000000000..bd752dfbb7 --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/contracts/test_native_model_contracts.py @@ -0,0 +1,165 @@ +"""Native contract validation and promotion conflicts equal Python's on generated projects.""" + +from __future__ import annotations + +import random +from collections import Counter +from pathlib import Path + +import pytest + +from sqlbuild.compiler.compile.models import CompiledProject +from sqlbuild.compiler.sql_analysis.constants import ANALYSIS_RECORD_DIR_ENV_VAR +from tests.integration.src.sqlbuild.compiler.contracts._test_types import ( + DeferredContractTestCase, + GeneratedContractParityTestCase, + GeneratedPromotionParityTestCase, +) +from tests.integration.src.sqlbuild.compiler.contracts.helpers import ( + NativeContractRecord, + compiled_contract_project, + contract_views, + deferral_records, + perturbed_project, + promotion_settings, + promotion_views, + record_native_outcomes, + record_native_promotion_calls, + with_declared_type, +) +from tests.integration.src.sqlbuild.compiler.helpers import mismatches + + +@pytest.mark.parametrize( + "test_case", + [ + GeneratedContractParityTestCase( + description="enforced, implicit, typed, nullability and dynamic contracts", + seed=20261009, + variants=60, + dialects=(None, "duckdb", "postgres", "snowflake", "bigquery", "databricks", "tsql"), + expected_minimum_native=800, + expected_minimum_typed_comparisons=300, + expected_minimum_diagnostics=2000, + expected_codes=frozenset({"K001", "K002", "K003", "K004", "K005", "K006", "K011"}), + ) + ], + ids=lambda case: case.description, +) +def test_given_generated_contracts_when_validating_natively_then_diagnostics_match_python( + test_case: GeneratedContractParityTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rng: random.Random = random.Random(test_case.seed) + record: NativeContractRecord = record_native_outcomes(monkeypatch=monkeypatch) + base: CompiledProject = compiled_contract_project(project_dir=tmp_path / "project") + labels: list[object] = [] + python_views: list[object] = [] + native_views: list[object] = [] + for variant in range(test_case.variants): + project: CompiledProject = perturbed_project(project=base, rng=rng) + for dialect in test_case.dialects: + python, native = contract_views( + project=project, dialect=dialect, monkeypatch=monkeypatch + ) + labels.append((variant, dialect)) + python_views.append(python) + native_views.append(native) + + assert mismatches(inputs=labels, expected=python_views, actual=native_views) == [] + assert record.statuses["native"] >= test_case.expected_minimum_native + assert record.statuses["typed_comparisons"] >= test_case.expected_minimum_typed_comparisons + assert record.statuses["native_diagnostics"] >= test_case.expected_minimum_diagnostics + assert set(record.codes) >= test_case.expected_codes + + +@pytest.mark.parametrize( + "test_case", + [ + GeneratedPromotionParityTestCase( + description="lifecycle configs under explicit and adapter-default promotion modes", + seed=20261010, + variants=400, + expected_minimum_conflicts=30, + ) + ], + ids=lambda case: case.description, +) +def test_given_generated_lifecycles_when_finding_promotion_conflicts_natively_then_match_python( + test_case: GeneratedPromotionParityTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rng: random.Random = random.Random(test_case.seed) + calls: Counter[str] = record_native_promotion_calls(monkeypatch=monkeypatch) + base: CompiledProject = compiled_contract_project(project_dir=tmp_path / "project") + labels: list[object] = [] + python_views: list[object] = [] + native_views: list[object] = [] + for variant in range(test_case.variants): + project: CompiledProject = perturbed_project(project=base, rng=rng) + adapter_default, settings_file = promotion_settings(rng=rng) + python, native = promotion_views( + project=project, + adapter_default=adapter_default, + settings_file=settings_file, + monkeypatch=monkeypatch, + ) + labels.append(variant) + python_views.append(python) + native_views.append(native) + + assert mismatches(inputs=labels, expected=python_views, actual=native_views) == [] + assert calls["calls"] == test_case.variants + assert calls["conflicts"] >= test_case.expected_minimum_conflicts + + +@pytest.mark.parametrize( + "test_case", + [ + DeferredContractTestCase( + description="a declared type outside ASCII", + declared_type="TÉXT", + dialect="duckdb", + expected_kind="type_normalization", + expected_deferred_models=1, + ), + DeferredContractTestCase( + description="a dialect outside the native type system", + declared_type="INTEGER", + dialect="mysql", + expected_kind="type_normalization", + expected_deferred_models=2, + ), + ], + ids=lambda case: case.description, +) +def test_given_input_native_cannot_answer_when_validating_then_python_answers_and_it_is_recorded( + test_case: DeferredContractTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + record_dir: Path = tmp_path / "records" + record: NativeContractRecord = record_native_outcomes(monkeypatch=monkeypatch) + project: CompiledProject = with_declared_type( + project=compiled_contract_project(project_dir=tmp_path / "project"), + declared_type=test_case.declared_type, + ) + monkeypatch.setenv(ANALYSIS_RECORD_DIR_ENV_VAR, str(record_dir)) + + python, native = contract_views( + project=project, dialect=test_case.dialect, monkeypatch=monkeypatch + ) + + assert native == python + assert record.statuses[test_case.expected_kind] == test_case.expected_deferred_models + assert ( + deferral_records(record_dir) + == [{"kind": test_case.expected_kind, "site": "contracts/columns.py"}] + * test_case.expected_deferred_models + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"])