From 9d8307654a06e57512d7b5b14f8b11f112af89ce Mon Sep 17 00:00:00 2001 From: Axell Padilla <68310020+axellpadilla@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:20:32 +0000 Subject: [PATCH] feat(sqlserver): wire SqlServer relations and the adapter backend Two exhaustive matches stopped compiling when Part 1 added the variant -- `create_static_relation` and `backend_of` -- and `relation_impl.rs` has none, so every arm it needs is a silent-correctness one that shows up as wrong SQL rather than a build failure. `backend_of` maps `SqlServer` to `Backend::SQLServer`, the same ADBC backend Fabric rides. No adapter can be constructed without it, and no issue in the series claimed it. In `relation_impl.rs`: - `get_database` joins the group that raises `InvalidConfig` when `database` is unset. The `_` arm returns an empty string, so without this a relation missing a database renders as `.schema.table`. - `get_canonical_fqn` joins `Fabric | Bigquery`, which pass an unquoted path part through verbatim. SQL Server stores the case it was given. `normalize_component` is deliberately left alone: it models how the server resolves an unquoted name, and folding to lower case is the right model for a case-insensitive collation, which is where Fabric already sits. - `quoted` doubles an embedded delimiter for `SqlServer`. The shared default interpolates verbatim, so `x"q` rendered `"x"q"` -- unparseable, and v2 would have rejected names v1 accepts. The override is scoped to this adapter rather than fixed in `dbt-schemas`, where it would change rendering for every adapter that quotes. - `new_sqlserver` mirrors `new_fabric`; its callers arrive with the metadata module. `include_policy` needs nothing: `_ => Policy::trues()` is already right for 3-part naming, and the explicit arms there are all adapters that drop a path part. Five tests in a `sqlserver` module: three-part rendering, the missing database error, delimiter doubling, case preservation through `get_canonical_fqn`, and construction through `RelationStatic`. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/adapter/adapter_factory.rs | 1 + crates/dbt-adapter/src/relation/factory.rs | 4 +- .../dbt-adapter/src/relation/relation_impl.rs | 116 +++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/crates/dbt-adapter/src/adapter/adapter_factory.rs b/crates/dbt-adapter/src/adapter/adapter_factory.rs index 52446d6bcaf..606c8cb1ca7 100644 --- a/crates/dbt-adapter/src/adapter/adapter_factory.rs +++ b/crates/dbt-adapter/src/adapter/adapter_factory.rs @@ -41,6 +41,7 @@ pub fn backend_of(adapter_type: AdapterType) -> Backend { AdapterType::DuckDB => Backend::DuckDB, AdapterType::Alt => Backend::Alt, AdapterType::Fabric => Backend::SQLServer, + AdapterType::SqlServer => Backend::SQLServer, AdapterType::ClickHouse => Backend::ClickHouse, AdapterType::Exasol => Backend::Exasol, AdapterType::Starburst => todo!("Starburst"), diff --git a/crates/dbt-adapter/src/relation/factory.rs b/crates/dbt-adapter/src/relation/factory.rs index 424180f1e55..7f426a9f4d6 100644 --- a/crates/dbt-adapter/src/relation/factory.rs +++ b/crates/dbt-adapter/src/relation/factory.rs @@ -16,8 +16,8 @@ pub fn create_static_relation( ) -> Option { use AdapterType::*; let result = match adapter_type { - Snowflake | Databricks | Spark | Fabric | DuckDB | Alt | Exasol | Postgres | Redshift - | Salesforce | Bigquery | ClickHouse => { + Snowflake | Databricks | Spark | Fabric | SqlServer | DuckDB | Alt | Exasol | Postgres + | Redshift | Salesforce | Bigquery | ClickHouse => { let relation_type = RelationStatic { adapter_type, quoting, diff --git a/crates/dbt-adapter/src/relation/relation_impl.rs b/crates/dbt-adapter/src/relation/relation_impl.rs index 4b99cf27039..d0b01758713 100644 --- a/crates/dbt-adapter/src/relation/relation_impl.rs +++ b/crates/dbt-adapter/src/relation/relation_impl.rs @@ -216,7 +216,7 @@ impl BaseRelationProperties for Relation { fn get_database(&self) -> FsResult { use AdapterType::*; match self.adapter_type { - Databricks | Fabric | Postgres | Redshift | Salesforce | Bigquery => { + Databricks | Fabric | SqlServer | Postgres | Redshift | Salesforce | Bigquery => { self.path.database.clone().ok_or_else(|| { fs_err!( ErrorCode::InvalidConfig, @@ -267,7 +267,7 @@ impl BaseRelationProperties for Relation { db_str } else { match self.adapter_type { - Fabric | Bigquery => db_str, + Fabric | SqlServer | Bigquery => db_str, Salesforce | Snowflake => db_str.to_ascii_uppercase(), _ => db_str.to_ascii_lowercase(), } @@ -277,7 +277,7 @@ impl BaseRelationProperties for Relation { schema_str } else { match self.adapter_type { - Fabric | Bigquery => schema_str, + Fabric | SqlServer | Bigquery => schema_str, Salesforce | Snowflake => schema_str.to_ascii_uppercase(), _ => schema_str.to_ascii_lowercase(), } @@ -287,7 +287,7 @@ impl BaseRelationProperties for Relation { ident_str } else { match self.adapter_type { - Fabric | Bigquery => ident_str, + Fabric | SqlServer | Bigquery => ident_str, Salesforce | Snowflake => ident_str.to_ascii_uppercase(), _ => ident_str.to_ascii_lowercase(), } @@ -490,6 +490,18 @@ impl Relation { .with_quoting(custom_quoting) } + pub fn new_sqlserver( + database: Option, + schema: Option, + identifier: Option, + relation_type: Option, + custom_quoting: ResolvedQuoting, + ) -> Self { + Self::new(AdapterType::SqlServer, database, schema, identifier) + .with_relation_type(relation_type) + .with_quoting(custom_quoting) + } + /// Add a constraint, routing to create_constraints or alter_constraints based on type pub fn add_constraint(&mut self, constraint: databricks::typed_constraint::TypedConstraint) { use dbt_schemas::schemas::common::ConstraintType; @@ -991,10 +1003,22 @@ impl BaseRelation for Relation { match self.adapter_type { Salesforce | Bigquery | ClickHouse => component.to_string(), Snowflake => component.to_uppercase(), + // TODO: SqlServer lands here, and the fold merges names that a + // case-sensitive collation keeps distinct. _ => component.to_lowercase(), } } + fn quoted(&self, s: &str) -> String { + let q = self.quote_character(); + match self.adapter_type { + // A delimited SQL Server identifier escapes an embedded delimiter by + // doubling it: https://learn.microsoft.com/sql/relational-databases/databases/database-identifiers + AdapterType::SqlServer => format!("{q}{}{q}", s.replace(q, &format!("{q}{q}"))), + _ => format!("{q}{s}{q}"), + } + } + fn render_self_as_str(&self) -> String { if self.adapter_type == AdapterType::DuckDB && let Some(external) = &self.external @@ -2027,4 +2051,88 @@ mod tests { ); } } + + mod sqlserver { + use super::*; + + fn relation(database: Option) -> Relation { + Relation::new( + AdapterType::SqlServer, + database, + "my_schema".to_string(), + "my_table".to_string(), + ) + .with_quoting(DEFAULT_RESOLVED_QUOTING) + } + + #[test] + fn test_renders_all_three_parts_quoted() { + let relation = relation(Some("my_db".to_string())); + assert_eq!( + relation.render_self_as_str(), + "\"my_db\".\"my_schema\".\"my_table\"" + ); + } + + #[test] + fn test_missing_database_is_an_error() { + assert!(relation(None).get_database().is_err()); + } + + #[test] + fn test_embedded_delimiter_is_doubled() { + let relation = Relation::new( + AdapterType::SqlServer, + "my_db".to_string(), + "dom\\usr".to_string(), + "x\"q".to_string(), + ) + .with_quoting(DEFAULT_RESOLVED_QUOTING); + + assert_eq!( + relation.render_self_as_str(), + "\"my_db\".\"dom\\usr\".\"x\"\"q\"" + ); + } + + /// An unquoted path part keeps the case it was given, as SQL Server stores it. + #[test] + fn test_canonical_fqn_preserves_case() { + let relation = Relation::new( + AdapterType::SqlServer, + "MyDB".to_string(), + "MySchema".to_string(), + "MyTable".to_string(), + ) + .with_quoting(Policy::falses()); + + let fqn = relation.get_canonical_fqn().unwrap(); + assert_eq!(fqn.to_string(), "MyDB.MySchema.MyTable"); + } + + #[test] + fn test_try_new_via_static_base_relation() { + let relation_type = RelationStatic { + adapter_type: AdapterType::SqlServer, + quoting: DEFAULT_RESOLVED_QUOTING, + }; + let relation = relation_type + .try_new( + Some("my_db".to_string()), + Some("my_schema".to_string()), + Some("my_table".to_string()), + Some(RelationType::Table), + Some(DEFAULT_RESOLVED_QUOTING), + None, + ) + .unwrap(); + + let relation = relation.downcast_object::().unwrap(); + assert_eq!( + relation.inner().render_self_as_str(), + "\"my_db\".\"my_schema\".\"my_table\"" + ); + assert_eq!(relation.relation_type().unwrap(), RelationType::Table); + } + } }