Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Python's `_assemble_compiled_sql_test` facts: name, targets, scope, case identity and P013s.

use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use sqlbuild_sqltext::sql_scan::models::LexicalSyntax;

use crate::compiler::_helpers::sql_tests::case_identity::case_fingerprint;
use crate::compiler::_helpers::sql_tests::macro_call_names::macro_call_names;
use crate::compiler::_helpers::sql_tests::mock_reads::{
MockReadsRequest, mock_reading_helper_diagnostics,
};
use crate::compiler::models::{
AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyDeferral, SqlTestAssemblyModel,
SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyTest, SqlTestHelperDiagnostic,
};

const MODEL_RESOURCE: &str = "model";
const TABLE_FUNCTION_RESOURCE: &str = "table_fn";
const MACRO_MODE: &str = "macro";
const UDF_MODE: &str = "udf";
const UDF_REFERENCE_KIND: &str = "udf";

/// Each test's facts in request order, or the reason Python assembles it.
pub(crate) fn assemble_batch(batch: &SqlTestAssemblyBatch) -> Vec<SqlTestAssemblyOutcome> {
let tests_macros = batch.tests.iter().any(|test| {
matches!(&test.payload, SqlTestAssemblyPayload::Direct { mode, .. } if mode == MACRO_MODE)
});
let macro_names: Vec<MacroNames> = batch
.models
.iter()
.map(|model| match &model.unscanned_macro_source {
Some(sql) if tests_macros => macro_call_names(sql),
Some(_) => Ok(Vec::new()),
None => Ok(model.macro_deps.clone()),
})
.collect();
let project = Project {
models: &batch.models,
macro_names: &macro_names,
syntax: &batch.lexical_syntax,
};
batch
.tests
.par_iter()
.map(|test| match assemble(test, &project) {
Ok(facts) => SqlTestAssemblyOutcome::Assembled(facts),
Err(deferral) => SqlTestAssemblyOutcome::Deferred(deferral),
})
.collect()
}

/// A model's macro dependencies, or Python's scan of its pre-macro SQL when none were recorded.
type MacroNames = Result<Vec<String>, SqlTestAssemblyDeferral>;

struct Project<'a> {
models: &'a [SqlTestAssemblyModel],
macro_names: &'a [MacroNames],
syntax: &'a LexicalSyntax,
}

fn assemble(
test: &SqlTestAssemblyTest,
project: &Project<'_>,
) -> Result<AssembledSqlTestFacts, SqlTestAssemblyDeferral> {
let models = project.models;
let syntax = project.syntax;
let mut target_model_names: Vec<String> = Vec::new();
let mut tested_resources: Vec<(String, String)> = Vec::new();
let mut diagnostics: Vec<SqlTestHelperDiagnostic> = Vec::new();
let scope_deps: Vec<(&'static str, String)> = match &test.payload {
SqlTestAssemblyPayload::Direct {
mode,
tested_resource_names,
} => {
tested_resources = tested_resource_names
.iter()
.map(|name| (mode.clone(), name.clone()))
.collect();
direct_scope_deps(mode, tested_resource_names, project)?
}
SqlTestAssemblyPayload::Model(payload) => {
if payload.has_macro_mocks {
return Err(SqlTestAssemblyDeferral::MacroMocks);
}
for name in payload
.expected_model_names
.iter()
.chain(&payload.assertion_target_model_names)
.chain(&payload.reference_target_model_names)
{
if !target_model_names.contains(name) {
target_model_names.push(name.clone());
}
}
diagnostics = mock_reading_helper_diagnostics(&MockReadsRequest {
test,
payload,
models,
target_model_names: &target_model_names,
syntax,
})?;
target_model_names
.iter()
.map(|name| (MODEL_RESOURCE, name.clone()))
.collect()
}
};
let case_fingerprint = match &test.case_name {
Some(case_name) => Some(case_fingerprint(
test,
case_name,
&scope_deps,
&tested_resources,
)?),
None => None,
};
Ok(AssembledSqlTestFacts {
name: test_name(test),
scope_deps,
target_model_names,
case_fingerprint,
diagnostic_resource_name: non_empty(&test.block_name)
.unwrap_or(&test.relative_stem)
.to_owned(),
diagnostics,
})
}

fn non_empty(name: &Option<String>) -> Option<&str> {
name.as_deref().filter(|name| !name.is_empty())
}

/// Python's `_resolve_test_name`.
fn test_name(test: &SqlTestAssemblyTest) -> String {
let parent = non_empty(&test.block_name).unwrap_or(&test.file_stem);
match &test.case_name {
Some(case_name) => format!("{parent} [{case_name}]"),
None => parent.to_owned(),
}
}

/// Python's macro, UDF and table-function scope dependencies of a direct-logic test.
fn direct_scope_deps(
mode: &str,
tested_names: &[String],
project: &Project<'_>,
) -> Result<Vec<(&'static str, String)>, SqlTestAssemblyDeferral> {
let mut scope_deps: Vec<(&'static str, String)> = Vec::new();
match mode {
MACRO_MODE => {
for (model, names) in project.models.iter().zip(project.macro_names) {
let names = names.as_ref().map_err(|deferral| *deferral)?;
if names.iter().any(|name| tested_names.contains(name)) {
scope_deps.push((MODEL_RESOURCE, model.name.clone()));
}
}
}
UDF_MODE => {
for model in project.models {
if model.references.iter().any(|reference| {
reference.kind == UDF_REFERENCE_KIND && tested_names.contains(&reference.name)
}) {
scope_deps.push((MODEL_RESOURCE, model.name.clone()));
}
}
}
_ => scope_deps.extend(
tested_names
.iter()
.map(|name| (TABLE_FUNCTION_RESOURCE, name.clone())),
),
}
Ok(scope_deps)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! Python's `build_sql_test_case_fingerprint`: the version identity of one expanded test case.

use sha2::{Digest, Sha256};
use sqlbuild_core::json::main::dumps::dumps;
use sqlbuild_core::json::models::{JsonDialect, JsonInteger, JsonValue, StdlibJsonOptions};

use crate::compiler::models::{
SqlTestAssemblyDeferral, SqlTestAssemblyTest, SqlTestParameterValue,
};

/// Python's default decimal context: precision and exponent limits `normalize()` applies.
const DECIMAL_PRECISION: usize = 28;
const DECIMAL_EMAX: i64 = 999_999;
const DECIMAL_EMIN: i64 = -999_999;

/// The SHA-256 of Python's compact, ASCII, key-sorted JSON of the case's compile inputs.
pub(crate) fn case_fingerprint(
test: &SqlTestAssemblyTest,
case_name: &str,
scope_deps: &[(&'static str, String)],
tested_resources: &[(String, String)],
) -> Result<String, SqlTestAssemblyDeferral> {
let mut sorted_deps: Vec<(&str, &str)> = scope_deps
.iter()
.map(|(kind, name)| (*kind, name.as_str()))
.collect();
sorted_deps.sort_unstable();
let mut sorted_resources: Vec<(&str, &str)> = tested_resources
.iter()
.map(|(kind, name)| (kind.as_str(), name.as_str()))
.collect();
sorted_resources.sort_unstable();
let parameter_values: Vec<JsonValue> = test
.parameter_values
.iter()
.map(|(name, value)| Ok(JsonValue::Array(vec![string(name), identity(value)?])))
.collect::<Result<_, SqlTestAssemblyDeferral>>()?;
let payload = JsonValue::Object(vec![
("source_path".to_owned(), string(&test.relative_path)),
("block_index".to_owned(), integer(test.block_index)),
("case_name".to_owned(), string(case_name)),
(
"parameter_schema".to_owned(),
JsonValue::Array(
test.parameter_schema
.iter()
.map(|(name, kind, nullable)| {
JsonValue::Array(vec![
string(name),
string(kind),
JsonValue::Bool(*nullable),
])
})
.collect(),
),
),
(
"parameter_values".to_owned(),
JsonValue::Array(parameter_values),
),
("expanded_sql".to_owned(), string(&test.sql_body)),
("scope_deps".to_owned(), pairs(&sorted_deps)),
("tested_resources".to_owned(), pairs(&sorted_resources)),
]);
let dialect = JsonDialect::Stdlib(StdlibJsonOptions {
indent: None,
item_separator: ",".to_owned(),
key_separator: ":".to_owned(),
ensure_ascii: true,
sort_keys: true,
allow_nan: true,
});
let encoded =
dumps(&payload, &dialect).map_err(|_| SqlTestAssemblyDeferral::UnencodableValue)?;
Ok(Sha256::digest(encoded.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}

fn string(text: &str) -> JsonValue {
JsonValue::String(text.to_owned())
}

fn integer(value: i64) -> JsonValue {
JsonInteger::parse(&value.to_string()).map_or(JsonValue::Null, JsonValue::Integer)
}

fn pairs(entries: &[(&str, &str)]) -> JsonValue {
JsonValue::Array(
entries
.iter()
.map(|(first, second)| JsonValue::Array(vec![string(first), string(second)]))
.collect(),
)
}

/// Python's `sql_value_identity`: `(kind, payload)` with decimals as normalized tuples.
fn identity(value: &SqlTestParameterValue) -> Result<JsonValue, SqlTestAssemblyDeferral> {
let (kind, payload) = match value {
SqlTestParameterValue::String(text) => ("string", string(text)),
SqlTestParameterValue::Integer(number) => ("integer", integer(*number)),
SqlTestParameterValue::Boolean(flag) => ("boolean", JsonValue::Bool(*flag)),
SqlTestParameterValue::Float(number) => ("float", JsonValue::Float(*number)),
SqlTestParameterValue::Decimal {
negative,
digits,
exponent,
} => ("decimal", normalized_decimal(*negative, digits, *exponent)?),
SqlTestParameterValue::Null => ("null", JsonValue::Null),
SqlTestParameterValue::List(items) => ("list", identities(items)?),
SqlTestParameterValue::Set(items) => ("set", identities(items)?),
SqlTestParameterValue::Object(entries) => (
"object",
JsonValue::Array(
entries
.iter()
.map(|(key, item)| Ok(JsonValue::Array(vec![string(key), identity(item)?])))
.collect::<Result<_, SqlTestAssemblyDeferral>>()?,
),
),
};
Ok(JsonValue::Array(vec![string(kind), payload]))
}

fn identities(items: &[SqlTestParameterValue]) -> Result<JsonValue, SqlTestAssemblyDeferral> {
Ok(JsonValue::Array(
items.iter().map(identity).collect::<Result<_, _>>()?,
))
}

/// `Decimal.normalize().as_tuple()` where the default context neither rounds nor clamps.
fn normalized_decimal(
negative: bool,
digits: &[u8],
exponent: i64,
) -> Result<JsonValue, SqlTestAssemblyDeferral> {
let sign = integer(i64::from(negative));
let first = digits.iter().position(|digit| *digit != 0);
let Some(first) = first else {
return Ok(JsonValue::Array(vec![
sign,
JsonValue::Array(vec![integer(0)]),
integer(0),
]));
};
let significant = &digits[first..];
let adjusted = exponent + i64::try_from(significant.len()).unwrap_or(i64::MAX) - 1;
if significant.len() > DECIMAL_PRECISION || !(DECIMAL_EMIN..=DECIMAL_EMAX).contains(&adjusted) {
return Err(SqlTestAssemblyDeferral::DecimalContext);
}
let mut end = significant.len();
let mut normalized_exponent = exponent;
while significant[end - 1] == 0 && normalized_exponent < DECIMAL_EMAX {
end -= 1;
normalized_exponent += 1;
}
Ok(JsonValue::Array(vec![
sign,
JsonValue::Array(
significant[..end]
.iter()
.map(|digit| integer(i64::from(*digit)))
.collect(),
),
integer(normalized_exponent),
]))
}
Loading
Loading