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/src/assembly/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

pub mod analysis_session;
pub mod project;
pub mod target_checks;
14 changes: 14 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/target_checks/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! Error codes and values of the target namespace checks.

/// A target, resource or loader schema that keeps the logical schema.
pub const PRESERVE_TARGET_VALUE: &str = "preserve";
/// Execution needs an explicit database and schema for every model and seed.
pub const MISSING_TARGET_PARTS_CODE: &str = "S101";
/// Two targets write managed loader data to one namespace.
pub const LOADER_COLLISION_CODE: &str = "S102";
/// A managed write has no explicit physical schema.
pub const MANAGED_WRITE_SCHEMA_CODE: &str = "S103";
/// A named target chooses no schema strategy.
pub const NAMED_TARGET_SCHEMA_CODE: &str = "S104";
pub(crate) const DATABASE_PART: &str = "database";
pub(crate) const SCHEMA_PART: &str = "schema";
16 changes: 16 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/target_checks/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//! The target namespace check failures.

use crate::assembly::target_checks::models::{LoaderCollision, MissingParts};

/// One target namespace check failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetError {
/// A resource of an adapter that needs both parts resolved without some of them.
MissingParts(MissingParts),
/// Two targets on one connection write managed loader data to one namespace.
LoaderCollision(LoaderCollision),
/// A managed write, named by its label, without an explicit physical schema.
ManagedWriteSchema(String),
/// A named target without an explicit schema.
NamedTargetSchema(String),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//! `_optional_explicit_schema`: a schema that names a physical write schema.

use sqlbuild_core::text::main::python_strip::python_strip;

use crate::assembly::target_checks::constants::PRESERVE_TARGET_VALUE;

/// The stripped schema, unless it is blank or `preserve`.
pub fn explicit_write_schema(value: Option<&str>) -> Option<String> {
let schema: &str = python_strip(value?);
(!schema.is_empty() && schema != PRESERVE_TARGET_VALUE).then(|| schema.to_owned())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! The namespace a loader collision reports: the first shared one by its Python `repr`.

use sqlbuild_core::text::main::python_str_repr::python_str_repr;

/// Python's `repr` of an optional string.
fn optional_repr(value: Option<&String>) -> String {
value.map_or_else(|| "None".to_owned(), |text| python_str_repr(text))
}

/// `sorted(namespaces, key=repr)[0]` of `(database, schema)` pairs, or `None` when empty.
pub fn first_shared_namespace(
namespaces: &[(Option<String>, Option<String>)],
) -> Option<(Option<String>, Option<String>)> {
namespaces
.iter()
.min_by_key(|(database, schema)| {
format!(
"({}, {})",
optional_repr(database.as_ref()),
optional_repr(schema.as_ref())
)
})
.cloned()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! The parts of a relation location execution needs but the location lacks.

use crate::assembly::target_checks::constants::{DATABASE_PART, SCHEMA_PART};

/// `database` and then `schema`, each when it is missing.
pub fn missing_target_parts(database: Option<&str>, schema: Option<&str>) -> Vec<&'static str> {
let mut missing: Vec<&'static str> = Vec::new();
if database.is_none() {
missing.push(DATABASE_PART);
}
if schema.is_none() {
missing.push(SCHEMA_PART);
}
missing
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub mod explicit_write_schema;
pub mod first_shared_namespace;
pub mod missing_target_parts;
pub mod target_error_message;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! The message Python raises for a target namespace check failure.

use sqlbuild_core::text::main::python_str_repr::python_str_repr;

use crate::assembly::target_checks::constants::PRESERVE_TARGET_VALUE;
use crate::assembly::target_checks::errors::TargetError;
use crate::assembly::target_checks::main::missing_target_parts::missing_target_parts;

/// Python's `repr` of an optional string.
fn optional_repr(value: Option<&String>) -> String {
value.map_or_else(|| "None".to_owned(), |text| python_str_repr(text))
}

/// The `PlannerInputError` message of `error`.
pub fn target_error_message(error: &TargetError) -> String {
match error {
TargetError::MissingParts(parts) => format!(
"{} execution requires explicit target {}. {} '{}' resolved to database={} schema={}. \
Set them in the model config, the environment config, or the project defaults.",
parts.adapter_name,
missing_target_parts(parts.database.as_deref(), parts.schema.as_deref()).join(", "),
parts.resource_kind,
parts.resource_name,
optional_repr(parts.database.as_ref()),
optional_repr(parts.schema.as_ref()),
),
TargetError::LoaderCollision(collision) => format!(
"Managed loader write collision: targets '{left}' and '{right}' both resolve to \
database={} schema={}. Set distinct targets.{left}.loader_schema and \
targets.{right}.loader_schema values.",
optional_repr(collision.database.as_ref()),
optional_repr(collision.schema.as_ref()),
left = collision.left_target,
right = collision.right_target,
),
TargetError::ManagedWriteSchema(label) => format!(
"{label} has no explicitly resolved physical write schema. Set schema on the \
resource or its defaults, selected target, loader_schema, or connection."
),
TargetError::NamedTargetSchema(target) => format!(
"Named target '{target}' must explicitly set schema to a nonblank literal or \
'{PRESERVE_TARGET_VALUE}'. Resource schemas, defaults, and connection schemas do \
not select a named target schema strategy."
),
}
}
7 changes: 7 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/target_checks/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
pub mod constants;
pub mod errors;
pub mod main;
pub mod models;

#[cfg(test)]
mod tests;
20 changes: 20 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/target_checks/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! The target namespace check failures, with the values their messages name.

/// A resource whose relation location misses a database or schema.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingParts {
pub adapter_name: String,
pub resource_kind: String,
pub resource_name: String,
pub database: Option<String>,
pub schema: Option<String>,
}

/// Two targets and the first namespace they share.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoaderCollision {
pub left_target: String,
pub right_target: String,
pub database: Option<String>,
pub schema: Option<String>,
}
6 changes: 6 additions & 0 deletions crates/sqlbuild-analysis/src/assembly/target_checks/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#[path = "tests/helpers.rs"]
mod helpers;
#[path = "tests/test_target_checks.rs"]
mod target_checks;
#[path = "tests/test_types.rs"]
mod test_types;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub(crate) fn owned(value: Option<&str>) -> Option<String> {
value.map(str::to_owned)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use crate::assembly::target_checks::errors::TargetError;
use crate::assembly::target_checks::main::explicit_write_schema::explicit_write_schema;
use crate::assembly::target_checks::main::first_shared_namespace::first_shared_namespace;
use crate::assembly::target_checks::main::target_error_message::target_error_message;
use crate::assembly::target_checks::models::{LoaderCollision, MissingParts};
use crate::assembly::target_checks::tests::helpers::owned;
use crate::assembly::target_checks::tests::test_types::{
MessageTestCase, NamespaceTestCase, SchemaTestCase,
};

#[test]
fn given_target_error_when_formatting_then_matches_python_message() {
let test_cases = [
MessageTestCase {
description: "a seed missing both parts",
error: TargetError::MissingParts(MissingParts {
adapter_name: "snowflake".to_owned(),
resource_kind: "seed".to_owned(),
resource_name: "products".to_owned(),
database: None,
schema: None,
}),
expected_message: "snowflake execution requires explicit target database, schema. seed 'products' resolved to database=None schema=None. Set them in the model config, the environment config, or the project defaults.",
},
MessageTestCase {
description: "a model missing its schema, with a quoted database",
error: TargetError::MissingParts(MissingParts {
adapter_name: "bigquery".to_owned(),
resource_kind: "model".to_owned(),
resource_name: "orders".to_owned(),
database: owned(Some("it's")),
schema: None,
}),
expected_message: "bigquery execution requires explicit target schema. model 'orders' resolved to database=\"it's\" schema=None. Set them in the model config, the environment config, or the project defaults.",
},
MessageTestCase {
description: "a loader collision",
error: TargetError::LoaderCollision(LoaderCollision {
left_target: "dev".to_owned(),
right_target: "prod".to_owned(),
database: None,
schema: owned(Some("raw")),
}),
expected_message: "Managed loader write collision: targets 'dev' and 'prod' both resolve to database=None schema='raw'. Set distinct targets.dev.loader_schema and targets.prod.loader_schema values.",
},
MessageTestCase {
description: "a managed write without a schema",
error: TargetError::ManagedWriteSchema("Model 'orders'".to_owned()),
expected_message: "Model 'orders' has no explicitly resolved physical write schema. Set schema on the resource or its defaults, selected target, loader_schema, or connection.",
},
MessageTestCase {
description: "a named target without a schema",
error: TargetError::NamedTargetSchema("ci".to_owned()),
expected_message: "Named target 'ci' must explicitly set schema to a nonblank literal or 'preserve'. Resource schemas, defaults, and connection schemas do not select a named target schema strategy.",
},
];

for test_case in test_cases {
let message = target_error_message(&test_case.error);

assert_eq!(
message, test_case.expected_message,
"{}",
test_case.description
);
}
}

#[test]
fn given_schema_when_reading_explicit_write_schema_then_matches_python() {
let test_cases = [
SchemaTestCase {
description: "a padded schema is stripped",
schema: Some(" analytics\t"),
expected_schema: Some("analytics"),
},
SchemaTestCase {
description: "preserve is not a physical schema",
schema: Some(" preserve "),
expected_schema: None,
},
SchemaTestCase {
description: "a blank schema is none",
schema: Some("\u{3000}"),
expected_schema: None,
},
SchemaTestCase {
description: "no schema is none",
schema: None,
expected_schema: None,
},
];

for test_case in test_cases {
let schema = explicit_write_schema(test_case.schema);

assert_eq!(
schema.as_deref(),
test_case.expected_schema,
"{}",
test_case.description
);
}
}

#[test]
fn given_shared_namespaces_when_choosing_first_then_sorts_by_python_repr() {
let test_cases = [
NamespaceTestCase {
description: "a quoted string sorts before None",
namespaces: &[(None, Some("raw")), (Some("db"), Some("raw"))],
expected_namespace: Some((Some("db"), Some("raw"))),
},
NamespaceTestCase {
description: "no namespace is shared",
namespaces: &[],
expected_namespace: None,
},
];

for test_case in test_cases {
let namespaces: Vec<(Option<String>, Option<String>)> = test_case
.namespaces
.iter()
.map(|(database, schema)| (owned(*database), owned(*schema)))
.collect();

let first = first_shared_namespace(&namespaces);

assert_eq!(
first,
test_case
.expected_namespace
.map(|(database, schema)| (owned(database), owned(schema))),
"{}",
test_case.description
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use crate::assembly::target_checks::errors::TargetError;

pub(crate) struct MessageTestCase {
pub(crate) description: &'static str,
pub(crate) error: TargetError,
pub(crate) expected_message: &'static str,
}

pub(crate) struct SchemaTestCase {
pub(crate) description: &'static str,
pub(crate) schema: Option<&'static str>,
pub(crate) expected_schema: Option<&'static str>,
}

pub(crate) struct NamespaceTestCase {
pub(crate) description: &'static str,
pub(crate) namespaces: &'static [(Option<&'static str>, Option<&'static str>)],
pub(crate) expected_namespace: Option<(Option<&'static str>, Option<&'static str>)>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub(crate) mod tokens;
Loading
Loading