diff --git a/crates/sqlquack-duckdb/src/blocks/constants.rs b/crates/sqlquack-duckdb/src/blocks/constants.rs index 7d3638b4b..f44d19b60 100644 --- a/crates/sqlquack-duckdb/src/blocks/constants.rs +++ b/crates/sqlquack-duckdb/src/blocks/constants.rs @@ -100,6 +100,8 @@ pub const EPOCH_TEXT_PATTERN: &str = "[0-9]{1,18}"; /// Epoch magnitudes below which integer text counts seconds, milliseconds and microseconds. pub const EPOCH_SCALE_LIMITS: [i64; 3] = [31_536_000_000, 31_536_000_000_000, 31_536_000_000_000_000]; +/// What separates the catalog, schema and table parts of a relation name. +pub const NAME_SEPARATOR: &str = "."; /// Quoted identifiers: `duckdb.quoted_identifier`. pub const QUOTED_IDENTIFIER: &str = "duckdb.quoted_identifier"; /// String literals: `duckdb.string_literal`. diff --git a/crates/sqlquack-duckdb/src/checking/_helpers/engine.rs b/crates/sqlquack-duckdb/src/checking/_helpers/engine.rs index 02252259a..118a1ea64 100644 --- a/crates/sqlquack-duckdb/src/checking/_helpers/engine.rs +++ b/crates/sqlquack-duckdb/src/checking/_helpers/engine.rs @@ -8,6 +8,7 @@ use duckdb::Connection; use sqlquack_core::provenance::models::ProvenanceLog; use crate::blocks::main::quoted_identifier::quoted_identifier; +use crate::checking::main::quoted_relation::quoted_relation; use crate::checking::constants::{DISABLE_OPTIMIZER, MACRO_PREFIX}; use crate::checking::errors::{CheckStage, PrintedSqlFailure}; @@ -21,7 +22,7 @@ pub(crate) struct Engine { } impl Engine { - /// Creates table `name` with `statement`, replacing a table created with other columns. + /// Creates table `name` with `statement`, dropping only a table it made earlier under that name. pub(crate) fn define_table( &mut self, name: &str, @@ -30,10 +31,15 @@ impl Engine { if self.tables.get(name).map(String::as_str) == Some(statement) { return Ok(()); } - let drop = format!( - "DROP TABLE IF EXISTS {}", - quoted_identifier(name, &ProvenanceLog::default()) - ); + let log = ProvenanceLog::default(); + let drop = match ( + statement.starts_with(MACRO_PREFIX), + self.tables.contains_key(name), + ) { + (true, _) => format!("DROP TABLE IF EXISTS {}", quoted_identifier(name, &log)), + (false, true) => format!("DROP TABLE IF EXISTS {}", quoted_relation(name, &log)), + (false, false) => String::new(), + }; self.connection .execute_batch(&format!("{drop}; {statement}")) .map_err(|error| PrintedSqlFailure { @@ -55,12 +61,13 @@ impl Engine { .map(|(name, statement)| (name.clone(), statement.clone())) .collect(); for (name, statement) in stale { - let quoted = quoted_identifier(&name, &ProvenanceLog::default()); + let log = ProvenanceLog::default(); let drop = match statement.starts_with(MACRO_PREFIX) { true => { + let quoted = quoted_identifier(&name, &log); format!("DROP MACRO TABLE IF EXISTS {quoted}; DROP MACRO IF EXISTS {quoted}") } - false => format!("DROP TABLE IF EXISTS {quoted}"), + false => format!("DROP TABLE IF EXISTS {}", quoted_relation(&name, &log)), }; self.connection .execute_batch(&drop) diff --git a/crates/sqlquack-duckdb/src/checking/_helpers/lexing.rs b/crates/sqlquack-duckdb/src/checking/_helpers/lexing.rs index e21d441df..f13872b82 100644 --- a/crates/sqlquack-duckdb/src/checking/_helpers/lexing.rs +++ b/crates/sqlquack-duckdb/src/checking/_helpers/lexing.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; pub(crate) struct Lexed { /// Statements separated by semicolons outside strings, quoted names and comments. pub(crate) statements: usize, - /// Every quoted identifier and bare word, any of which may name a relation or function. + /// Every quoted identifier, bare word and dotted chain of them, any of which may name a relation. pub(crate) names: BTreeSet, } @@ -15,17 +15,20 @@ pub(crate) fn lexed(sql: &str) -> Lexed { let characters: Vec = sql.chars().collect(); let mut names: BTreeSet = BTreeSet::new(); let (mut statements, mut pending) = (0_usize, false); + let (mut chain, mut continuing) = (String::new(), false); let mut index = 0; while let Some(&character) = characters.get(index) { let next = characters.get(index + 1).copied(); match (character, next) { ('\'', _) => { + chain.clear(); index = quoted_end(&characters, index, '\'').0; pending = true; } ('"', _) => { let (end, name) = quoted_end(&characters, index, '"'); - names.insert(name); + chain = extended(&chain, std::mem::take(&mut continuing), &name); + names.extend([chain.clone(), name]); index = end; pending = true; } @@ -59,11 +62,20 @@ pub(crate) fn lexed(sql: &str) -> Lexed { { index += 1; } - names.insert(characters[start..index].iter().collect()); + let name: String = characters[start..index].iter().collect(); + chain = extended(&chain, std::mem::take(&mut continuing), &name); + names.extend([chain.clone(), name]); pending = true; } + ('.', _) if !chain.is_empty() && !continuing => { + continuing = true; + pending = true; + index += 1; + } (character, _) => { pending |= !character.is_whitespace(); + chain.clear(); + continuing = false; index += 1; } } @@ -74,6 +86,14 @@ pub(crate) fn lexed(sql: &str) -> Lexed { } } +/// The dotted chain `name` ends: `chain.name` when a dot joined it to `chain`, else `name` alone. +fn extended(chain: &str, continuing: bool, name: &str) -> String { + match continuing && !chain.is_empty() { + true => format!("{chain}.{name}"), + false => name.to_owned(), + } +} + /// The index past the quoted text opening at `start`, and its text with doubled quotes undone. fn quoted_end(characters: &[char], start: usize, quote: char) -> (usize, String) { let mut text = String::new(); diff --git a/crates/sqlquack-duckdb/src/checking/_helpers/tables.rs b/crates/sqlquack-duckdb/src/checking/_helpers/tables.rs index d0cd2a9da..1ca12d2ad 100644 --- a/crates/sqlquack-duckdb/src/checking/_helpers/tables.rs +++ b/crates/sqlquack-duckdb/src/checking/_helpers/tables.rs @@ -3,10 +3,12 @@ use sqlquack_core::provenance::models::ProvenanceLog; use sqlquack_snowflake::typing::models::SnowflakeType; +use crate::blocks::constants::NAME_SEPARATOR; use crate::blocks::main::column_type::column_type; use crate::blocks::main::quoted_identifier::quoted_identifier; use crate::checking::constants::MACRO_PREFIX; use crate::checking::constants::SEMI_STRUCTURED_HOLDING_TYPE; +use crate::checking::main::quoted_relation::quoted_relation; use crate::checking::models::{FunctionStub, StubReturns}; use crate::checking::types::{FunctionLookup, RelationLookup}; @@ -24,7 +26,7 @@ pub(crate) fn table_definitions( definitions } -/// `CREATE TABLE` for the columns with a DuckDB type, semi-structured ones held as `JSON`. +/// `CREATE TABLE` for the typed columns, semi-structured as `JSON`, in its name's catalog and schema. fn create_table(name: &str, columns: &[(String, SnowflakeType)]) -> String { let log = ProvenanceLog::default(); let definitions: Vec = columns @@ -38,9 +40,21 @@ fn create_table(name: &str, columns: &[(String, SnowflakeType)]) -> String { Some(format!("{} {duckdb}", quoted_identifier(column, &log))) }) .collect(); + let parts: Vec<&str> = name.split(NAME_SEPARATOR).collect(); + let mut prelude = String::new(); + if let [catalog, _, _] = parts.as_slice() { + let catalog = quoted_identifier(catalog, &log); + prelude.push_str(&format!("ATTACH IF NOT EXISTS ':memory:' AS {catalog}; ")); + } + if let [qualifiers @ .., _] = parts.as_slice() + && !qualifiers.is_empty() + { + let schema = quoted_relation(&qualifiers.join(NAME_SEPARATOR), &log); + prelude.push_str(&format!("CREATE SCHEMA IF NOT EXISTS {schema}; ")); + } format!( - "CREATE TABLE {} ({})", - quoted_identifier(name, &log), + "{prelude}CREATE TABLE {} ({})", + quoted_relation(name, &log), definitions.join(", ") ) } diff --git a/crates/sqlquack-duckdb/src/checking/main/mod.rs b/crates/sqlquack-duckdb/src/checking/main/mod.rs index 524668cd3..46418bce6 100644 --- a/crates/sqlquack-duckdb/src/checking/main/mod.rs +++ b/crates/sqlquack-duckdb/src/checking/main/mod.rs @@ -2,3 +2,4 @@ pub mod bound_outside_lambdas; pub mod check_printed; +pub mod quoted_relation; diff --git a/crates/sqlquack-duckdb/src/checking/main/quoted_relation.rs b/crates/sqlquack-duckdb/src/checking/main/quoted_relation.rs new file mode 100644 index 000000000..08985f652 --- /dev/null +++ b/crates/sqlquack-duckdb/src/checking/main/quoted_relation.rs @@ -0,0 +1,14 @@ +//! `quoted_relation`: a relation name as the check creates it and converted SQL reads it. + +use sqlquack_core::provenance::models::ProvenanceLog; + +use crate::blocks::constants::NAME_SEPARATOR; +use crate::blocks::main::quoted_identifier::quoted_identifier; + +/// A relation named `TABLE`, `SCHEMA.TABLE` or `CATALOG.SCHEMA.TABLE`, each part double-quoted. +pub fn quoted_relation(name: &str, log: &ProvenanceLog) -> String { + name.split(NAME_SEPARATOR) + .map(|part| quoted_identifier(part, log)) + .collect::>() + .join(NAME_SEPARATOR) +} diff --git a/crates/sqlquack-policy/src/policy/constants.rs b/crates/sqlquack-policy/src/policy/constants.rs index a02a76364..20b0d89bf 100644 --- a/crates/sqlquack-policy/src/policy/constants.rs +++ b/crates/sqlquack-policy/src/policy/constants.rs @@ -12,6 +12,12 @@ pub const TIMEZONE_PARAMETER: &str = "TIMEZONE"; /// The Snowflake session parameter making quoted identifiers resolve to upper case too. pub const QUOTED_IDENTIFIERS_IGNORE_CASE: &str = "QUOTED_IDENTIFIERS_IGNORE_CASE"; +/// The session's current database, which completes a relation name written without one. +pub const DATABASE_PARAMETER: &str = "DATABASE"; + +/// The session's current schema, which completes a relation name written without one. +pub const SCHEMA_PARAMETER: &str = "SCHEMA"; + /// Session parameters that cannot change a result, accepted and ignored, each with why. pub const RESULT_NEUTRAL_SESSION_PARAMETERS: &[(&str, &str)] = &[ ("QUERY_TAG", "a label recorded in query history"), diff --git a/crates/sqlquack-rules/src/catalogue/constants.rs b/crates/sqlquack-rules/src/catalogue/constants.rs index c6bab09bc..33d1e44a1 100644 --- a/crates/sqlquack-rules/src/catalogue/constants.rs +++ b/crates/sqlquack-rules/src/catalogue/constants.rs @@ -85,7 +85,7 @@ pub const RULES: &[RuleSpec] = &[ RuleSpec { id: FROM_RELATION, constructs: &["node:table"], - conditions: "The relation is a CTE or has typed columns in the schema.", + conditions: "The relation is a CTE or has typed columns in the schema. A schema relation may be declared as TABLE, SCHEMA.TABLE or DATABASE.SCHEMA.TABLE, and a name reads the declared relation whose parts agree with the parts it writes, the longest agreement winning; it is written as the declared name. A name matching several declared relations equally, a qualified name a CTE of its declared name would capture, and two differently written names reading one declared relation are refused.", failures: &[], }, RuleSpec { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs b/crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs index d80e69818..159e51984 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/queries/sources.rs @@ -9,6 +9,7 @@ use sqlquack_core::refusals::main::outside_conditions::outside_conditions; use sqlquack_core::refusals::main::unresolved::unresolved; use sqlquack_core::refusals::models::{Refusal, RefusalCategory}; use sqlquack_duckdb::blocks::main::quoted_identifier::quoted_identifier; +use sqlquack_duckdb::checking::main::quoted_relation::quoted_relation; use sqlquack_snowflake::typing::models::SnowflakeType; use crate::conversion::_helpers::expressions::project_functions::{exact_arguments, held_type}; @@ -35,6 +36,8 @@ use crate::conversion::constants::{ }; use crate::conversion::constants::{FULL_JOIN_KEYWORD, INNER_JOIN_KEYWORD}; use sqlquack_semantics::lineage::main::stored_columns::stored_columns; +use sqlquack_semantics::names::constants::RELATION_KEY_SEPARATOR; +use sqlquack_semantics::names::main::relation_key::relation_key; use sqlquack_semantics::scopes::models::ScopeRelation; use sqlquack_semantics::types::models::{ColumnSpec, FunctionReturns}; @@ -549,15 +552,48 @@ fn table_source( let log = context.log; let _rule = log.rule(FROM_RELATION); let table = payload_object(payload, &construct)?; - require_understood(table, &["name", "alias"], &construct)?; + require_understood(table, &["name", "alias", "catalog", "schema"], &construct)?; let name = identifier_name(field(table, "name", &construct)?) .ok_or_else(|| missing(construct.clone(), "a table has no name".to_owned()))?; - let columns = context.relation(&name)?; + let mut parts: Vec = ["catalog", "schema"] + .iter() + .filter_map(|part| table.get(*part).and_then(identifier_name)) + .collect(); + parts.push(name.clone()); + let written = parts.join(RELATION_KEY_SEPARATOR); + let is_cte = parts.len() == 1 && context.ctes.iter().any(|(cte, _)| cte == &name); + let options = context.options; + if let (1, Some(schema)) = (parts.len(), &options.current_schema) { + parts.insert(0, schema.clone()); + } + if let (2, Some(database)) = (parts.len(), &options.current_database) { + parts.insert(0, database.clone()); + } + let completed = parts.join(RELATION_KEY_SEPARATOR); + let key = match is_cte { + true => name.clone(), + false => relation_key(&parts, context.schema)?.unwrap_or_else(|| written.clone()), + }; + if !is_cte && context.ctes.iter().any(|(cte, _)| cte == &key) { + return Err(unresolved(format!( + "`{written}` reads the schema relation `{key}`, which a CTE of the same name hides" + ))); + } + let columns = context.relation(&key)?; let alias = table .get("alias") .and_then(identifier_name) .unwrap_or_else(|| name.clone()); - let is_cte = context.ctes.iter().any(|(cte, _)| cte == &name); + if !is_cte && let Some(earlier) = context.qualifiers.read(&key, &completed) { + return Err(unresolved(format!( + "`{earlier}` and `{completed}` both read the schema relation `{key}`; declare each \ + under its qualified name, or set the session's DATABASE and SCHEMA, to tell them apart" + ))); + } + let relation_name = match is_cte { + true => quoted_identifier(&key, log), + false => quoted_relation(&key, log), + }; let mut guarded: Vec = Vec::new(); for column in columns .iter() @@ -567,11 +603,10 @@ fn table_source( guarded.push(format!("{} AS {quoted}", guarded_column("ed, context)?)); } let relation = match guarded.is_empty() { - true => quoted_identifier(&name, log), + true => relation_name, false => format!( - "(SELECT * REPLACE ({}) FROM {})", - guarded.join(", "), - quoted_identifier(&name, log) + "(SELECT * REPLACE ({}) FROM {relation_name})", + guarded.join(", ") ), }; let columns = match is_cte { diff --git a/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs b/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs index 84fca326e..779081af3 100644 --- a/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs +++ b/crates/sqlquack-rules/src/conversion/_helpers/support/scope.rs @@ -170,6 +170,8 @@ pub(crate) struct Context<'a> { pub(crate) order: &'a OrderTrack, /// The subquery values emitted so far, which DuckDB cannot bind inside a lambda body. pub(crate) subqueries: &'a SubqueryTrack, + /// The name each schema relation was written as so far. + pub(crate) qualifiers: &'a QualifierTrack, /// Whether query results may keep unordered arrays: CTE and FROM subquery bodies. pub(crate) free_projections: bool, /// Whether this converts a case's input fixture, whose literals stand for source data. @@ -204,6 +206,24 @@ impl OrderTrack { pub(crate) struct StatementTracks { pub(crate) order: OrderTrack, pub(crate) subqueries: SubqueryTrack, + pub(crate) qualifiers: QualifierTrack, +} + +/// The name each schema relation was written as, so two written relations never read one. +#[derive(Debug, Default)] +pub(crate) struct QualifierTrack { + read: std::cell::RefCell>, +} + +impl QualifierTrack { + /// Records the relation `key` read as `written`; the earlier name when it was written otherwise. + pub(crate) fn read(&self, key: &str, written: &str) -> Option { + let mut read = self.read.borrow_mut(); + let earlier = read + .entry(key.to_owned()) + .or_insert_with(|| written.to_owned()); + (earlier != written).then(|| earlier.clone()) + } } /// A subquery value emitted in a statement's conversion. diff --git a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs index 66207cfb8..40f650a3a 100644 --- a/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs +++ b/crates/sqlquack-rules/src/conversion/main/collect_refusals.rs @@ -86,6 +86,7 @@ fn collected_conversion( typed: &typed, order: &tracks.order, subqueries: &tracks.subqueries, + qualifiers: &tracks.qualifiers, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs index 82c313b33..f09e84c65 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_function_body.rs @@ -42,6 +42,7 @@ pub fn convert_function_body( typed: &typed, order: &tracks.order, subqueries: &tracks.subqueries, + qualifiers: &tracks.qualifiers, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs index d6d8d2737..88809c501 100644 --- a/crates/sqlquack-rules/src/conversion/main/convert_statement.rs +++ b/crates/sqlquack-rules/src/conversion/main/convert_statement.rs @@ -47,6 +47,7 @@ pub fn convert_statement( typed: &typed, order: &tracks.order, subqueries: &tracks.subqueries, + qualifiers: &tracks.qualifiers, free_projections: false, fixture_body: false, }; diff --git a/crates/sqlquack-rules/src/conversion/models.rs b/crates/sqlquack-rules/src/conversion/models.rs index 29c6e9c9e..48a158a96 100644 --- a/crates/sqlquack-rules/src/conversion/models.rs +++ b/crates/sqlquack-rules/src/conversion/models.rs @@ -38,6 +38,10 @@ pub struct ConversionOptions { pub quoted_exclusions: bool, /// Whether quoted identifiers resolve to upper case, as `QUOTED_IDENTIFIERS_IGNORE_CASE = true`. pub quoted_identifiers_ignore_case: bool, + /// The session's current database, resolved, which completes a relation name without one. + pub current_database: Option, + /// The session's current schema, resolved, which completes a relation name without one. + pub current_schema: Option, /// User overrides by the name of the function each replaces, upper case. pub overrides: BTreeMap, /// Override names to the SHA-256 of the body recorded cases verified, which strict mode allows. diff --git a/crates/sqlquack-rules/src/session/_helpers/session_profiles.rs b/crates/sqlquack-rules/src/session/_helpers/session_profiles.rs index a2c91e365..26deaa6b5 100644 --- a/crates/sqlquack-rules/src/session/_helpers/session_profiles.rs +++ b/crates/sqlquack-rules/src/session/_helpers/session_profiles.rs @@ -2,7 +2,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use sqlquack_policy::policy::constants::RESULT_NEUTRAL_SESSION_PARAMETERS; +use sqlquack_policy::policy::constants::{ + DATABASE_PARAMETER, RESULT_NEUTRAL_SESSION_PARAMETERS, SCHEMA_PARAMETER, +}; use sqlquack_policy::policy::models::{Policy, SessionValue}; /// The first profile matching `given`, unset ones as `default` pins, `ignored` never compared. @@ -20,6 +22,7 @@ pub(crate) fn matching_profile( .map(|(name, _)| *name) .collect(); let mut known: BTreeSet<&str> = neutral.clone(); + known.extend([DATABASE_PARAMETER, SCHEMA_PARAMETER]); for profile in policy.session_profiles.values() { known.extend( profile diff --git a/crates/sqlquack-rules/src/session/main/session_conversion_options.rs b/crates/sqlquack-rules/src/session/main/session_conversion_options.rs index b027c7614..6dd209056 100644 --- a/crates/sqlquack-rules/src/session/main/session_conversion_options.rs +++ b/crates/sqlquack-rules/src/session/main/session_conversion_options.rs @@ -5,8 +5,8 @@ use std::collections::BTreeMap; use crate::conversion::models::ConversionOptions; use sqlquack_policy::policy::constants::SESSION_TIMEZONE_UTC; use sqlquack_policy::policy::constants::{ - DEFAULT_SESSION_PROFILE, QUOTED_IDENTIFIERS_IGNORE_CASE, RESULT_NEUTRAL_SESSION_PARAMETERS, - SESSION_TIME_ZONE, TIMEZONE_PARAMETER, + DATABASE_PARAMETER, DEFAULT_SESSION_PROFILE, QUOTED_IDENTIFIERS_IGNORE_CASE, + RESULT_NEUTRAL_SESSION_PARAMETERS, SCHEMA_PARAMETER, SESSION_TIME_ZONE, TIMEZONE_PARAMETER, }; use sqlquack_policy::policy::models::{Policy, SessionValue}; @@ -28,10 +28,13 @@ pub fn session_conversion_options( .filter(|name| !builtin_accepts(name)) .collect(); return match unknown.is_empty() { - true => Ok((ConversionOptions::default(), BUILTIN_PROFILE.to_owned())), + true => Ok(( + with_namespace(ConversionOptions::default(), parameters), + BUILTIN_PROFILE.to_owned(), + )), false => Err(format!( - "the built-in default records no session profiles, so it takes only TIMEZONE and \ - the result-neutral parameters, not {}; pass --policy with a policy file whose \ + "the built-in default records no session profiles, so it takes only TIMEZONE, \ + DATABASE, SCHEMA and the result-neutral parameters, not {}; pass --policy with a policy file whose \ session profiles cover them, such as the repository's sqlquack.toml", unknown.join(", ") )), @@ -58,12 +61,32 @@ pub fn session_conversion_options( if zone.is_some_and(|zone| zone != SESSION_TIME_ZONE) { options.divergences.remove(SESSION_TIMEZONE_UTC); } - Ok((options, profile)) + Ok((with_namespace(options, parameters), profile)) +} + +/// `options` with the session's DATABASE and SCHEMA, upper case unless written in double quotes. +fn with_namespace( + options: ConversionOptions, + parameters: &BTreeMap, +) -> ConversionOptions { + let resolved = |name: &str| { + parameters.get(name).map(|value| { + match value.len() > 1 && value.starts_with('"') && value.ends_with('"') { + true => value[1..value.len() - 1].replace("\"\"", "\""), + false => value.to_ascii_uppercase(), + } + }) + }; + ConversionOptions { + current_database: resolved(DATABASE_PARAMETER), + current_schema: resolved(SCHEMA_PARAMETER), + ..options + } } /// Whether the built-in default, which records no session profiles, takes parameter `name`. fn builtin_accepts(name: &str) -> bool { - name == TIMEZONE_PARAMETER + [TIMEZONE_PARAMETER, DATABASE_PARAMETER, SCHEMA_PARAMETER].contains(&name) || RESULT_NEUTRAL_SESSION_PARAMETERS .iter() .any(|(neutral, _)| *neutral == name) diff --git a/crates/sqlquack-semantics/src/names/constants.rs b/crates/sqlquack-semantics/src/names/constants.rs index f12a18a05..691adf504 100644 --- a/crates/sqlquack-semantics/src/names/constants.rs +++ b/crates/sqlquack-semantics/src/names/constants.rs @@ -4,3 +4,8 @@ pub const UNKNOWN_RELATION_PREFIX: &str = "relation `"; /// How it ends, after the name. pub const UNKNOWN_RELATION_SUFFIX: &str = "` has no known columns"; +/// What joins a schema key's parts: `STAGING.ORDERS` is the relation ORDERS in the schema STAGING. +pub const RELATION_KEY_SEPARATOR: &str = "."; +/// How a refusal for a name matching several schema relations ends, after the relations. +pub const AMBIGUOUS_RELATION_SUFFIX: &str = + " in the schema; qualify it, or set the session's DATABASE and SCHEMA, to pick one"; diff --git a/crates/sqlquack-semantics/src/names/main/mod.rs b/crates/sqlquack-semantics/src/names/main/mod.rs index 21aa99335..cc16c96ed 100644 --- a/crates/sqlquack-semantics/src/names/main/mod.rs +++ b/crates/sqlquack-semantics/src/names/main/mod.rs @@ -1 +1,2 @@ pub mod relation_columns; +pub mod relation_key; diff --git a/crates/sqlquack-semantics/src/names/main/relation_key.rs b/crates/sqlquack-semantics/src/names/main/relation_key.rs new file mode 100644 index 000000000..2ae75e593 --- /dev/null +++ b/crates/sqlquack-semantics/src/names/main/relation_key.rs @@ -0,0 +1,47 @@ +//! `relation_key`: the schema relation a written, possibly qualified, relation name reads. + +use sqlquack_core::refusals::main::unresolved::unresolved; +use sqlquack_core::refusals::models::Refusal; + +use crate::names::constants::{AMBIGUOUS_RELATION_SUFFIX, RELATION_KEY_SEPARATOR}; +use crate::types::models::Schema; + +/// The declared key agreeing with the most of `parts`, read from the name backwards; a tie refuses. +pub fn relation_key(parts: &[String], schema: &Schema) -> Result, Refusal> { + let mut best: Vec<&String> = Vec::new(); + let mut best_score = (0, false); + for key in schema.relations.keys() { + let key_parts: Vec<&str> = key.split(RELATION_KEY_SEPARATOR).collect(); + let agrees = key_parts + .iter() + .rev() + .zip(parts.iter().rev()) + .all(|(key_part, written)| key_part == written); + if !agrees { + continue; + } + let score = ( + key_parts.len().min(parts.len()), + key_parts.len() == parts.len(), + ); + if score > best_score { + best_score = score; + best.clear(); + } + if score == best_score { + best.push(key); + } + } + match best.as_slice() { + [] => Ok(None), + [key] => Ok(Some((*key).clone())), + keys => Err(unresolved(format!( + "relation `{}` matches `{}`{AMBIGUOUS_RELATION_SUFFIX}", + parts.join(RELATION_KEY_SEPARATOR), + keys.iter() + .map(|key| key.as_str()) + .collect::>() + .join("`, `"), + ))), + } +} diff --git a/crates/sqlquack/src/census/_helpers/blockers.rs b/crates/sqlquack/src/census/_helpers/blockers.rs index 48a65764f..fd770de23 100644 --- a/crates/sqlquack/src/census/_helpers/blockers.rs +++ b/crates/sqlquack/src/census/_helpers/blockers.rs @@ -8,7 +8,7 @@ use sqlquack_core::refusals::models::RefusalCategory; use sqlquack_semantics::types::models::Schema; use crate::census::_helpers::combinations::combinations; -use crate::census::_helpers::corpus::model_relation; +use crate::census::_helpers::corpus::{model_relation, referenced_model}; use crate::census::_helpers::inspect::FileCensus; use crate::census::constants::{ CYCLE_ROOT, MODEL_ROOT_PREFIX, RELATION_ROOT_PREFIX, UNKNOWN_RELATION_ROOT, @@ -163,7 +163,8 @@ impl Graph { .collect(), }; for (index, census) in censuses.iter().enumerate() { - let model_of = |relation: &String| by_relation.get(&folded(relation)).copied(); + let model_of = + |relation: &String| by_relation.get(&referenced_model(relation)).copied(); let upstream: BTreeSet = census .references .iter() @@ -294,7 +295,7 @@ fn model_reads(files: &[PathBuf], censuses: &[FileCensus], schema: &Schema) -> M let models_of = |index: usize, relations: &BTreeSet| { let mut models: BTreeSet = BTreeSet::new(); for relation in relations { - if let Some(model) = by_relation.get(&folded(relation)) + if let Some(model) = by_relation.get(&referenced_model(relation)) && *model != index { models.insert(*model); diff --git a/crates/sqlquack/src/census/_helpers/completion.rs b/crates/sqlquack/src/census/_helpers/completion.rs index 0a14fa9fc..1205ace44 100644 --- a/crates/sqlquack/src/census/_helpers/completion.rs +++ b/crates/sqlquack/src/census/_helpers/completion.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use sqlquack_semantics::types::models::{ColumnSpec, Schema}; -use crate::census::_helpers::corpus::model_relation; +use crate::census::_helpers::corpus::{model_plain_name, model_relation, referenced_model}; use crate::census::_helpers::inspect::FileCensus; use crate::census::errors::CensusError; @@ -56,10 +56,14 @@ pub(crate) fn complete_in_waves( for (index, census, columns) in complete(&wave, &schema, &censuses)? { censuses[index] = census; pending.remove(&index); - if let (true, Some(columns), Some(relation)) = - (chain_models, columns, model_relation(&files[index])) - { - schema.relations.entry(relation).or_insert(columns); + if let (true, Some(columns)) = (chain_models, columns) { + let file = &files[index]; + for relation in model_relation(file) + .into_iter() + .chain(model_plain_name(file)) + { + schema.relations.entry(relation).or_insert(columns.clone()); + } } } } @@ -74,7 +78,7 @@ fn refused_upstream( ) -> BTreeSet { let mut upstream: BTreeSet = BTreeSet::new(); for relation in census.references.iter().chain(&census.unknown_relations) { - if let Some(model) = by_relation.get(&relation.to_ascii_uppercase()) + if let Some(model) = by_relation.get(&referenced_model(relation)) && *model != index && refused.contains(model) { diff --git a/crates/sqlquack/src/census/_helpers/corpus.rs b/crates/sqlquack/src/census/_helpers/corpus.rs index f61f49d92..dafdd1b3f 100644 --- a/crates/sqlquack/src/census/_helpers/corpus.rs +++ b/crates/sqlquack/src/census/_helpers/corpus.rs @@ -8,6 +8,7 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use sqlquack_rules::catalogue::main::builtin_catalogue::builtin_catalogue; use std::collections::BTreeSet; +use sqlquack_semantics::names::constants::RELATION_KEY_SEPARATOR; use sqlquack_semantics::types::models::{ColumnSpec, Schema}; use crate::census::_helpers::completion::{Completed, complete_in_waves}; @@ -66,12 +67,19 @@ pub(crate) fn inspect_files( for _ in 0..usize::from(options.chain_models) * files.len() { let mut added = 0usize; for (path, census) in files.iter().zip(&censuses) { - if let (Some(columns), Some(relation)) = (&census.outputs, model_relation(path)) - && let std::collections::btree_map::Entry::Vacant(entry) = - schema.relations.entry(relation) + let Some(columns) = &census.outputs else { + continue; + }; + for relation in model_relation(path) + .into_iter() + .chain(model_plain_name(path)) { - entry.insert(columns.clone()); - added += 1; + if let std::collections::btree_map::Entry::Vacant(entry) = + schema.relations.entry(relation) + { + entry.insert(columns.clone()); + added += 1; + } } } if added == 0 { @@ -116,6 +124,19 @@ pub(crate) fn model_relation(path: &Path) -> Option { Some(relation_name(stem.as_bytes()).to_ascii_uppercase()) } +/// The relation dbt's compiled SQL reads a model file by, qualifiers aside: its file name, upper case. +pub(crate) fn model_plain_name(path: &Path) -> Option { + Some(path.file_stem()?.to_str()?.to_ascii_uppercase()) +} + +/// The model relation a reference reads, upper case: a qualified name by its last part, as dbt writes. +pub(crate) fn referenced_model(relation: &str) -> String { + match relation.rsplit_once(RELATION_KEY_SEPARATOR) { + Some((_, name)) => relation_name(name.as_bytes()).to_ascii_uppercase(), + None => relation.to_ascii_uppercase(), + } +} + /// Whether a relation's schema columns declare an offset for any column. fn declares_offset(columns: Option<&Vec>) -> bool { columns.is_some_and(|columns| { diff --git a/crates/sqlquack/tests/census/helpers.rs b/crates/sqlquack/tests/census/helpers.rs index 75e708cd2..fdd8e08b2 100644 --- a/crates/sqlquack/tests/census/helpers.rs +++ b/crates/sqlquack/tests/census/helpers.rs @@ -302,3 +302,36 @@ pub(crate) fn converted_files(set_variable: bool) -> Vec { files.sort_by(|left, right| left.model.cmp(&right.model)); files } + +const COMPILED_STAGING_SQL: &str = "with source as (\n\n select * from analytics.raw.orders\n\n),\n\n\ + renamed as (\n\n select id as order_id, status from source\n\n)\n\n\ + select * from renamed\n"; +const COMPILED_MART_SQL: &str = "select status, count(order_id) as orders\n\ + from \"ANALYTICS\".\"DBT_PROD\".\"STG_ORDERS\"\ngroup by status\n"; +const RAW_ORDERS_SCHEMA: &str = "{\"relations\": {\"ANALYTICS.RAW.ORDERS\": [[\"ID\", \"NUMBER(38,0)\"], [\"STATUS\", \"VARCHAR\"]]}}"; + +/// The unit-testable and strict counts of a census of dbt-compiled models, with `flags`. +pub(crate) fn compiled_dbt_counts(flags: &[&str]) -> (Option, Option) { + let directory = tempfile::tempdir().expect("temporary directory"); + let models = directory.path().join("target/compiled/shop/models"); + std::fs::create_dir_all(models.join("staging")).expect("staging directory created"); + std::fs::create_dir_all(models.join("marts")).expect("marts directory created"); + std::fs::write(models.join("staging/stg_orders.sql"), COMPILED_STAGING_SQL) + .expect("fixture written"); + std::fs::write(models.join("marts/order_counts.sql"), COMPILED_MART_SQL) + .expect("fixture written"); + let schema = directory.path().join("schema.json"); + std::fs::write(&schema, RAW_ORDERS_SCHEMA).expect("schema written"); + let output = Command::new(env!("CARGO_BIN_EXE_sqlquack")) + .args(["census", "--builtin-policy", "--json", "--schema"]) + .arg(&schema) + .args(flags) + .arg(directory.path().join("target/compiled")) + .output() + .expect("sqlquack runs"); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_default(); + ( + report["refusals"]["convertible"].as_u64(), + report["refusals"]["convertible_strict"].as_u64(), + ) +} diff --git a/crates/sqlquack/tests/census/test_census_binary.rs b/crates/sqlquack/tests/census/test_census_binary.rs index f217301f6..11dbd8191 100644 --- a/crates/sqlquack/tests/census/test_census_binary.rs +++ b/crates/sqlquack/tests/census/test_census_binary.rs @@ -1,11 +1,13 @@ use crate::helpers::{ - census_warnings, converted_files, cursor_model_convertible, run_on_declared_orders, - run_on_events, run_on_placed_orders, run_on_refused_upstream, run_on_shop, + census_warnings, compiled_dbt_counts, converted_files, cursor_model_convertible, + run_on_declared_orders, run_on_events, run_on_placed_orders, run_on_refused_upstream, + run_on_shop, }; use crate::test_types::{ - BinaryOutcome, BinaryTestCase, ConvertedDirTestCase, ConvertedFile, CursorTestCase, - DeclaredOutcome, DeclaredTestCase, SessionTimeZoneTestCase, SourceVariantOutcome, - SourceVariantTestCase, UpstreamOutcome, UpstreamTestCase, WarningsTestCase, + BinaryOutcome, BinaryTestCase, CompiledDbtTestCase, ConvertedDirTestCase, ConvertedFile, + CursorTestCase, DeclaredOutcome, DeclaredTestCase, SessionTimeZoneTestCase, + SourceVariantOutcome, SourceVariantTestCase, UpstreamOutcome, UpstreamTestCase, + WarningsTestCase, }; #[test] @@ -436,3 +438,28 @@ fn given_converted_dir_set_when_running_census_then_each_converted_model_sql_is_ ); } } + +#[test] +fn given_compiled_dbt_models_with_qualified_names_when_running_census_then_they_convert() { + let test_cases = [ + CompiledDbtTestCase { + description: "the staging model reads its declared source by database.schema.table; \ + the mart's model relation is unknown without chaining", + flags: &[], + expected_counts: (Some(1), Some(1)), + }, + CompiledDbtTestCase { + description: "chained, the mart reads the staging model by its qualified name", + flags: &["--chain-models"], + expected_counts: (Some(2), Some(2)), + }, + ]; + for test_case in test_cases { + assert_eq!( + compiled_dbt_counts(test_case.flags), + test_case.expected_counts, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack/tests/census/test_types.rs b/crates/sqlquack/tests/census/test_types.rs index 4b24d44ca..10bdda2d9 100644 --- a/crates/sqlquack/tests/census/test_types.rs +++ b/crates/sqlquack/tests/census/test_types.rs @@ -91,3 +91,9 @@ pub(crate) struct ConvertedFile { pub(crate) model: String, pub(crate) holds_select: bool, } + +pub(crate) struct CompiledDbtTestCase { + pub(crate) description: &'static str, + pub(crate) flags: &'static [&'static str], + pub(crate) expected_counts: (Option, Option), +} diff --git a/crates/sqlquack/tests/cli/fixtures/qualified_relations.json b/crates/sqlquack/tests/cli/fixtures/qualified_relations.json new file mode 100644 index 000000000..58f6d7002 --- /dev/null +++ b/crates/sqlquack/tests/cli/fixtures/qualified_relations.json @@ -0,0 +1,6 @@ +{ + "relations": { + "ANALYTICS.STAGING.STG_ORDERS": [["ORDER_ID", "NUMBER(38,0)"]], + "ANALYTICS.MARTS.STG_ORDERS": [["ORDER_ID", "NUMBER(38,0)"]] + } +} diff --git a/crates/sqlquack/tests/cli/test_convert.rs b/crates/sqlquack/tests/cli/test_convert.rs index 304766dd3..473313d54 100644 --- a/crates/sqlquack/tests/cli/test_convert.rs +++ b/crates/sqlquack/tests/cli/test_convert.rs @@ -19,6 +19,7 @@ const QUOTED_ALIAS: &str = "WITH totals AS (SELECT 1 AS \"orderTotal\") SELECT ordertotal FROM totals"; const PLACED_DATE: &str = "SELECT TO_DATE(placed_at) AS placed_on FROM orders"; const DECLARED_SCHEMA: &str = "tests/cli/fixtures/declared_offsets.json"; +const QUALIFIED_SCHEMA: &str = "tests/cli/fixtures/qualified_relations.json"; const NAIVE_TO_ZONED: &str = "SELECT '2024-03-10 22:30:00'::TIMESTAMP_NTZ(6)::TIMESTAMP_TZ AS placed_at"; @@ -519,6 +520,121 @@ fn given_a_bracket_expression_with_a_lazy_spelling_when_converting_then_the_clas } } +#[test] +fn given_qualified_table_names_when_converting_then_they_read_the_declared_relation() { + let staged = ( + "{\"ANALYTICS.STAGING.STG_ORDERS\": [[\"STATUS\", \"VARCHAR\"]], \ + \"ANALYTICS.MARTS.STG_ORDERS\": [[\"STATUS\", \"VARCHAR\"]], \ + \"PAYMENTS\": [[\"STATUS\", \"VARCHAR\"]]}", + "ATTACH ':memory:' AS analytics; CREATE SCHEMA analytics.staging; \ + CREATE SCHEMA analytics.marts; \ + CREATE TABLE analytics.staging.stg_orders (status VARCHAR); \ + CREATE TABLE analytics.marts.stg_orders (status VARCHAR); \ + CREATE TABLE payments (status VARCHAR); \ + INSERT INTO analytics.staging.stg_orders VALUES ('staged'); \ + INSERT INTO analytics.marts.stg_orders VALUES ('marted'); \ + INSERT INTO payments VALUES ('paid')", + ); + let test_cases = [ + ValueTestCase { + description: "compiled dbt's database.schema.table reads that table in the attached database", + sql: "with source as (select * from analytics.staging.stg_orders) select status from source", + expected_value: "staged", + }, + ValueTestCase { + description: "unquoted parts fold to upper case, so a mixed-case spelling is the same table", + sql: "SELECT o.status FROM Analytics.Marts.Stg_Orders AS o", + expected_value: "marted", + }, + ValueTestCase { + description: "a quoted upper-case name is the declared one", + sql: "SELECT status FROM \"ANALYTICS\".\"MARTS\".\"STG_ORDERS\"", + expected_value: "marted", + }, + ValueTestCase { + description: "schema.table matches the one database that declares it", + sql: "SELECT stg_orders.status FROM staging.stg_orders", + expected_value: "staged", + }, + ValueTestCase { + description: "a qualified name reads a relation declared without qualifiers", + sql: "SELECT status FROM analytics.raw.payments", + expected_value: "paid", + }, + ]; + for test_case in test_cases { + let value = converted_value_over(test_case.sql, staged); + assert_eq!(value, test_case.expected_value, "{}", test_case.description); + } + let refusals = [ + ConvertTestCase { + description: "a name matching tables in two schemas, with no session schema, names both", + sql: "SELECT order_id FROM stg_orders", + flags: &["--schema", QUALIFIED_SCHEMA], + mention: "relation `STG_ORDERS` matches `ANALYTICS.MARTS.STG_ORDERS`, \ + `ANALYTICS.STAGING.STG_ORDERS` in the schema", + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a quoted lower-case part is a different table from the declared one", + sql: "SELECT order_id FROM analytics.\"staging\".stg_orders", + flags: &["--schema", QUALIFIED_SCHEMA], + mention: "relation `ANALYTICS.staging.STG_ORDERS` matches `ANALYTICS.STAGING.STG_ORDERS` only without regard to case", + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a database the schema does not declare refuses as unknown", + sql: "SELECT order_id FROM other.staging.stg_orders", + flags: &["--schema", QUALIFIED_SCHEMA], + mention: "relation `OTHER.STAGING.STG_ORDERS` has no known columns", + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "two qualifiers of one undeclared-qualifier relation refuse instead of reading it twice", + sql: "SELECT a.order_id FROM sales.orders a JOIN archive.orders b USING (order_id)", + flags: &["--schema", DECLARED_SCHEMA], + mention: "`SALES.ORDERS` and `ARCHIVE.ORDERS` both read the schema relation `ORDERS`", + expected_outcome: convert_outcome(1, false), + }, + ConvertTestCase { + description: "a qualified name a CTE of the same name would capture refuses", + sql: "WITH orders AS (SELECT 1 AS order_id) SELECT order_id FROM sales.orders", + flags: &["--schema", DECLARED_SCHEMA], + mention: "which a CTE of the same name hides", + expected_outcome: convert_outcome(1, false), + }, + ]; + for test_case in refusals { + assert_eq!( + run_convert(test_case.sql, test_case.flags, test_case.mention), + test_case.expected_outcome, + "{}", + test_case.description + ); + } + let sessions = [ + ( + &["SCHEMA=marts"][..], + "\"ANALYTICS\".\"MARTS\".\"STG_ORDERS\"", + ), + ( + &["DATABASE=analytics", "SCHEMA=staging"][..], + "\"ANALYTICS\".\"STAGING\".\"STG_ORDERS\"", + ), + ]; + for (parameters, expected_relation) in sessions { + let mut flags = vec!["--builtin-policy", "--schema", QUALIFIED_SCHEMA]; + for parameter in parameters { + flags.extend(["--session", parameter]); + } + let (stdout, _) = convert_streams("SELECT order_id FROM stg_orders", &flags); + assert!( + stdout.contains(&format!("FROM {expected_relation}")), + "the session's {parameters:?} settles which table an unqualified name reads: {stdout}" + ); + } +} + #[test] fn given_a_time_with_nanoseconds_when_formatting_then_digits_beyond_the_format_are_truncated() { let test_cases = [ diff --git a/website/src/content/docs/docs/cli/census.mdx b/website/src/content/docs/docs/cli/census.mdx index 60e06d347..57ce2ecdb 100644 --- a/website/src/content/docs/docs/cli/census.mdx +++ b/website/src/content/docs/docs/cli/census.mdx @@ -20,8 +20,10 @@ sqlquack census target/compiled --schema schema.json --chain-models --json ``` Each `.sql` file is one query, or one model compiled by -[SQLBuild](https://github.com/sqlstack-io/sqlbuild); SQLBuild's reference calls are recognised. -A directory is searched for `.sql` files. +[SQLBuild](https://github.com/sqlstack-io/sqlbuild) or dbt; SQLBuild's reference calls are +recognised. A directory is searched for `.sql` files. Compiled models name relations as +`database.schema.table`; see [Schema files](/docs/schema/#naming-relations-and-columns) for how +they resolve. ```console $ sqlquack census target/compiled/my_project/models --schema schema.json @@ -47,8 +49,8 @@ file, model and relation names to the slowest files and the blockers. | `strict` | It converts and so does every model upstream of it. | | `with no named assumption` | The same counts, leaving out files that rely on an assumption such as `session_timezone_utc`. | -With `--chain-models`, each converted file types the relation its file name references, and the -files reading it convert again. A file reading a refused model is labelled +With `--chain-models`, each converted file types the relation its file name references, read +by any qualifier as dbt writes it, and the files reading it convert again. A file reading a refused model is labelled `upstream_refused:`, and one reading a model the scan holds no file for, `upstream_missing:`. diff --git a/website/src/content/docs/docs/policy.mdx b/website/src/content/docs/docs/policy.mdx index f6b118f40..3bace9e65 100644 --- a/website/src/content/docs/docs/policy.mdx +++ b/website/src/content/docs/docs/policy.mdx @@ -73,5 +73,7 @@ Converted SQL is proven only where both sessions run in UTC: Snowflake's `TIMEZO - These are accepted and ignored, because they cannot change a result: `QUERY_TAG`, `USE_CACHED_RESULT`, `STATEMENT_TIMEOUT_IN_SECONDS`, `STATEMENT_QUEUED_TIMEOUT_IN_SECONDS`, `ABORT_DETACHED_QUERY` and `CLIENT_SESSION_KEEP_ALIVE`. +- `DATABASE` and `SCHEMA` set the current database and schema, which complete relation names + written without them. See [Schema files](/docs/schema/#naming-relations-and-columns). - `TIMEZONE = UTC`, or no `TIMEZONE`, leaves time-zone-dependent statements to the `session_timezone_utc` assumption. Any other `TIMEZONE` refuses them, even with `--utc-session`. diff --git a/website/src/content/docs/docs/schema.mdx b/website/src/content/docs/docs/schema.mdx index 271823e2e..159411c19 100644 --- a/website/src/content/docs/docs/schema.mdx +++ b/website/src/content/docs/docs/schema.mdx @@ -23,8 +23,35 @@ A name the schema does not declare is refused with [`SQ0004`](/docs/diagnostics/ ## Naming relations and columns Names resolve as Snowflake resolves them. An unquoted name is upper-cased, so `FROM orders` reads -the relation `ORDERS`. A quoted name keeps its case. When a name matches a declared one only -ignoring case, the diagnostic says so and suggests the spelling to declare. +the relation `ORDERS`. A quoted name keeps its case, so `"orders"` is a different relation from +`ORDERS`. When a name matches a declared one only ignoring case, the diagnostic says so and +suggests the spelling to declare. + +Declare a relation as `ORDERS`, `SALES.ORDERS` or `ANALYTICS.SALES.ORDERS`. A qualified name in the +SQL, such as `analytics.sales.orders` in dbt's compiled models, reads the declared relation whose +parts agree with it, and the converted SQL names that relation as declared: + +| Declared | SQL | Reads | +|----------|-----|-------| +| `ORDERS` | `orders`, `sales.orders`, `analytics.sales.orders` | `ORDERS` | +| `ANALYTICS.SALES.ORDERS` | `orders`, `sales.orders`, `Analytics.Sales.Orders` | `ANALYTICS.SALES.ORDERS` | +| `ANALYTICS.SALES.ORDERS` and `ANALYTICS.ARCHIVE.ORDERS` | `sales.orders` | `ANALYTICS.SALES.ORDERS` | +| `ANALYTICS.SALES.ORDERS` and `ANALYTICS.ARCHIVE.ORDERS` | `orders` | refused: it matches both | + +The session's `DATABASE` and `SCHEMA` complete a name written without them, as in Snowflake: +with `--session SCHEMA=archive`, `orders` reads `ANALYTICS.ARCHIVE.ORDERS`. See +[Sessions](/docs/policy/#sessions). + +These are refused with [`SQ0004`](/docs/diagnostics/reference/#sq0004), naming the relations +involved: + +- A name matching several declared relations that no session default settles. +- A qualified name whose declared relation a CTE of the same name hides. +- Two differently qualified names in one statement that read the same declared relation. Declare + each under its qualified name. + +The converted SQL reads a qualified relation from the database you attach to DuckDB under that +name, such as `ATTACH 'analytics.duckdb' AS analytics`. ## Columns