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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sqlbuild-analysis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlbuild-core.workspace = true
sqlbuild-model-config.workspace = true
sqlbuild-scopes.workspace = true
sqlbuild-sqltext.workspace = true

Expand Down
51 changes: 51 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/project/_helpers/deps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Python's dependency keys for compiled resources (`compile/_helpers/deps/dependencies.py`).

use std::collections::HashSet;

use crate::assembly::project::constants::{
ATTACHED_AUDIT_KINDS, AUDIT_TARGET_DEFERRAL, DBT_REF_KIND, KIND_RESOURCES, MODEL_RESOURCE,
};
use crate::assembly::project::models::{AuditFacts, Reference};
use crate::assembly::project::types::Fact;
use crate::assembly::project::types::ObjectKey;

/// `model_build_deps` and `function_build_deps`: each reference's key, first occurrence kept.
pub(crate) fn reference_deps(references: &[Reference]) -> Vec<ObjectKey> {
deduped(references.iter().map(reference_dep).collect())
}

/// `audit_scope_deps`: the references, then the attached resource, first occurrence kept.
pub(crate) fn audit_deps(audit: &AuditFacts) -> Fact<Vec<ObjectKey>> {
let mut keys: Vec<ObjectKey> = reference_deps(&audit.references);
if let Some((kind, name)) = &audit.attached {
if !ATTACHED_AUDIT_KINDS.contains(&kind.as_str()) {
return Err(AUDIT_TARGET_DEFERRAL.to_owned());
}
keys.push((kind.clone(), name.clone()));
}
Ok(deduped(keys))
}

/// `_reference_dep`.
fn reference_dep(reference: &Reference) -> ObjectKey {
let kind: &str = reference.kind.as_str();
if KIND_RESOURCES.contains(&kind) {
return (kind.to_owned(), reference.name.clone());
}
if kind == DBT_REF_KIND {
let name: String = match &reference.package {
Some(package) => format!("{package}.{}", reference.name),
None => reference.name.clone(),
};
return (DBT_REF_KIND.to_owned(), name);
}
(MODEL_RESOURCE.to_owned(), reference.name.clone())
}

/// `_dedupe_object_keys`.
fn deduped(keys: Vec<ObjectKey>) -> Vec<ObjectKey> {
let mut seen: HashSet<ObjectKey> = HashSet::new();
keys.into_iter()
.filter(|key| seen.insert(key.clone()))
.collect()
}
4 changes: 4 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/project/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub(crate) mod deps;
pub(crate) mod syntax;
pub(crate) mod targets;
pub(crate) mod templates;
89 changes: 89 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/project/_helpers/syntax.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Python's `validate_sql_syntax` and `validate_hook_sql_syntax` outcome, without the message.

use std::collections::HashMap;

use polyglot_sql::{
ComplexityGuardOptions, Dialect, DialectType, ValidationOptions, validate_with_dialect,
};
use serde_json::json;

use crate::assembly::project::constants::{
DIALECT_DEFERRAL, MAX_FUNCTION_CALL_DEPTH, NORMALIZATION_DEFERRAL, PLACEHOLDER_DEFERRAL,
PLACEHOLDER_PREFIX, UNSUPPORTED_DIALECT_DEFERRAL,
};
use crate::assembly::project::models::SyntaxCheck;
use crate::assembly::project::types::Fact;
use crate::lineage::main::parser_dialect::parser_dialect;
use crate::semantic_validation::main::normalize::normalize_analysis_sql;
use crate::semantic_validation::models::NormalizationInput;

/// Whether Polyglot's `validate` accepts the cleaned SQL; Python raises where it does not.
pub(crate) fn syntax_valid(check: &SyntaxCheck, dialect: &str) -> Fact<bool> {
let cleaned: String = normalize_analysis_sql(NormalizationInput {
sql: check.sql.clone(),
dialect: dialect.to_owned(),
stubs: HashMap::new(),
placeholders: HashMap::new(),
})
.map_err(|_| NORMALIZATION_DEFERRAL.to_owned())?;
let cleaned: String = if check.placeholders.is_empty() {
cleaned
} else {
placeholder_defaults(&cleaned, &check.placeholders)?
};
let dialect: DialectType =
parser_dialect(Some(dialect)).ok_or_else(|| UNSUPPORTED_DIALECT_DEFERRAL.to_owned())?;
let complexity_guard: ComplexityGuardOptions =
serde_json::from_value(json!({"maxFunctionCallDepth": MAX_FUNCTION_CALL_DEPTH}))
.map_err(|_| DIALECT_DEFERRAL.to_owned())?;
let options = ValidationOptions {
complexity_guard: Some(complexity_guard),
strict_syntax: false,
semantic: false,
};
Ok(validate_with_dialect(&cleaned, &Dialect::get(dialect), &options).valid)
}

/// `substitute_placeholder_defaults`: `@@@name` becomes its default; unknown names stay.
fn placeholder_defaults(sql: &str, placeholders: &[(String, String)]) -> Fact<String> {
let mut result: String = String::with_capacity(sql.len());
let mut rest: &str = sql;
while let Some(start) = rest.find(PLACEHOLDER_PREFIX) {
result.push_str(&rest[..start]);
let after: &str = &rest[start + PLACEHOLDER_PREFIX.len()..];
let length: usize = after
.find(|character: char| !is_word(character))
.unwrap_or(after.len());
if after[length..]
.chars()
.next()
.is_some_and(|next| !next.is_ascii())
{
return Err(PLACEHOLDER_DEFERRAL.to_owned());
}
if length == 0 {
result.push_str(&rest[start..=start]);
rest = &rest[start + 1..];
continue;
}
let name: &str = &after[..length];
match placeholders
.iter()
.find(|(placeholder, _)| placeholder == name)
{
Some((_, default)) => result.push_str(default),
None => {
result.push_str(PLACEHOLDER_PREFIX);
result.push_str(name);
}
}
rest = &after[length..];
}
result.push_str(rest);
Ok(result)
}

/// An ASCII character Python's `\w` matches.
fn is_word(character: char) -> bool {
character.is_ascii_alphanumeric() || character == '_'
}
176 changes: 176 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/project/_helpers/targets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! Python's managed source and seed targets (`assembly/project.py`, `assembly/targets.py`).

use crate::assembly::project::_helpers::templates::{Context, TemplateInputs, expanded_text};
use crate::assembly::project::constants::{
DESTINATION_DATABASE_CONTEXT, DESTINATION_QUALIFIED_CONTEXT, DESTINATION_SCHEMA_CONTEXT,
DESTINATION_TABLE_CONTEXT, MODEL_ALIAS_CONTEXT, MODEL_DATABASE_CONTEXT, MODEL_NAME_CONTEXT,
MODEL_SCHEMA_CONTEXT, PRESERVE_TARGET_VALUE, PRESERVED_NAMESPACE_DEFERRAL,
};
use crate::assembly::project::models::{
Namespace, SeedDefaults, SeedFacts, SourceFacts, TargetNamespace,
};
use crate::assembly::project::types::Fact;

/// A managed source's `(database, schema)`.
pub(crate) type SourceNamespace = (Option<String>, Option<String>);

/// `_build_source_relation_entry`: None where the entry stays as authored.
pub(crate) fn managed_source(
source: &SourceFacts,
target: Option<&TargetNamespace>,
inputs: &TemplateInputs<'_>,
) -> Fact<Option<SourceNamespace>> {
let Some(target) = target.filter(|_| source.managed) else {
return Ok(None);
};
let loader_schema: Option<&String> =
python_or(target.loader_schema.as_ref(), target.schema.as_ref());
let loader_target = TargetNamespace {
database: target.database.clone(),
schema: loader_schema.cloned(),
loader_schema: target.loader_schema.clone(),
};
preserved_namespace(
Some(&loader_target),
source.database.as_ref(),
source.schema.as_ref(),
)?;
let database: Option<String> = match &source.database {
Some(database) => Some(database.clone()),
None => optional_text(target.database.as_deref(), inputs)?,
};
let schema: Option<String> = match &source.schema {
Some(schema) => Some(schema.clone()),
None => optional_text(loader_schema.map(String::as_str), inputs)?,
};
Ok(Some((database, schema)))
}

/// `build_seed_relation_target`.
pub(crate) fn seed_target(
seed: &SeedFacts,
defaults: &SeedDefaults,
target: Option<&TargetNamespace>,
inputs: &TemplateInputs<'_>,
) -> Fact<Namespace> {
let mut logical_database: Option<String> = environment_text(
defaults
.seed_database
.as_ref()
.or(defaults.database.as_ref()),
inputs,
)?;
let mut logical_schema: Option<String> = environment_text(
defaults.seed_schema.as_ref().or(defaults.schema.as_ref()),
inputs,
)?;
if let Some(raw) = &seed.database {
let context = seed_context(&seed.name, logical_database.clone(), logical_schema.clone());
logical_database = seed_text(raw, inputs, &context)?;
}
if let Some(raw) = &seed.schema {
let context = seed_context(&seed.name, logical_database.clone(), logical_schema.clone());
logical_schema = seed_text(raw, inputs, &context)?;
}
preserved_namespace(target, logical_database.as_ref(), logical_schema.as_ref())?;
let (database, schema) = match target {
None => (logical_database.clone(), logical_schema.clone()),
Some(target) => (
target_override(target.database.as_ref(), logical_database.clone(), inputs)?,
target_override(target.schema.as_ref(), logical_schema.clone(), inputs)?,
),
};
Ok(Namespace {
database,
schema,
logical_database,
logical_schema,
})
}

/// `validate_preserved_logical_namespace`; Python raises where Err.
fn preserved_namespace(
target: Option<&TargetNamespace>,
logical_database: Option<&String>,
logical_schema: Option<&String>,
) -> Fact<()> {
let Some(target) = target else {
return Ok(());
};
let preserved =
|value: Option<&String>| value.is_some_and(|value| value == PRESERVE_TARGET_VALUE);
if (preserved(target.database.as_ref()) && logical_database.is_none())
|| (preserved(target.schema.as_ref()) && logical_schema.is_none())
{
return Err(PRESERVED_NAMESPACE_DEFERRAL.to_owned());
}
Ok(())
}

/// `_expand_target_value`.
fn optional_text(value: Option<&str>, inputs: &TemplateInputs<'_>) -> Fact<Option<String>> {
value
.map(|value| expanded_text(value, inputs, None))
.transpose()
}

/// `_expand_seed_default_value` and `_expand_seed_environment_value`.
fn environment_text(raw: Option<&String>, inputs: &TemplateInputs<'_>) -> Fact<Option<String>> {
match raw {
None => Ok(None),
Some(raw) if raw == PRESERVE_TARGET_VALUE => Ok(None),
Some(raw) => expanded_text(raw, inputs, None).map(Some),
}
}

/// `_expand_seed_target_value`.
fn seed_text(
raw: &str,
inputs: &TemplateInputs<'_>,
context: &Context<'_>,
) -> Fact<Option<String>> {
if raw == PRESERVE_TARGET_VALUE {
return Ok(None);
}
expanded_text(raw, inputs, Some(context)).map(Some)
}

/// `_apply_seed_target_overrides` for one dimension.
fn target_override(
raw: Option<&String>,
logical: Option<String>,
inputs: &TemplateInputs<'_>,
) -> Fact<Option<String>> {
match raw {
Some(raw) if raw != PRESERVE_TARGET_VALUE => environment_text(Some(raw), inputs),
_ => Ok(logical),
}
}

/// The context values a seed's own database and schema templates read.
fn seed_context(
name: &str,
database: Option<String>,
schema: Option<String>,
) -> Vec<(&'static str, Option<String>)> {
let qualified: Option<String> = match (&database, &schema) {
(Some(database), Some(schema)) => Some(format!("{database}.{schema}.{name}")),
(None, Some(schema)) => Some(format!("{schema}.{name}")),
_ => None,
};
vec![
(MODEL_NAME_CONTEXT, Some(name.to_owned())),
(MODEL_DATABASE_CONTEXT, database.clone()),
(MODEL_SCHEMA_CONTEXT, schema.clone()),
(MODEL_ALIAS_CONTEXT, Some(name.to_owned())),
(DESTINATION_DATABASE_CONTEXT, database),
(DESTINATION_SCHEMA_CONTEXT, schema),
(DESTINATION_TABLE_CONTEXT, Some(name.to_owned())),
(DESTINATION_QUALIFIED_CONTEXT, qualified),
]
}

/// Python's `left or right` over optional text: an empty string falls through.
fn python_or<'a>(left: Option<&'a String>, right: Option<&'a String>) -> Option<&'a String> {
left.filter(|value| !value.is_empty()).or(right)
}
Loading
Loading