Skip to content

fix: one identifier quoting style, and the unquoted identifiers underneath it (#785, #409) - #795

Open
axellpadilla wants to merge 1 commit into
fix/632-drop-outbound-fk-constraintsfrom
fix/785-consistent-identifier-quoting
Open

fix: one identifier quoting style, and the unquoted identifiers underneath it (#785, #409)#795
axellpadilla wants to merge 1 commit into
fix/632-drop-outbound-fk-constraintsfrom
fix/785-consistent-identifier-quoting

Conversation

@axellpadilla

Copy link
Copy Markdown
Collaborator

Closes #785. Closes #409.

Summary

{{ relation }} renders ANSI "double quoted" identifiers — the adapter overrides neither quote_character nor quote(). A handful of macros hand-formatted [brackets] instead, so single statements mixed both styles. Those now route through adapter.quote() / the relation object.

Making that correct also required quoting identifiers the adapter builds inside string literals, and escaping embedded delimiters. Both are included.

What changed

One quoting style. No macro hand-formats an identifier any more: USE, CREATE SCHEMA, contract column lists, grantees, index/constraint names, alter table … drop constraint, and generated test/unit-test view names.

get_use_database_sql() already existed with 15+ callers, so the seven hand-formatted USE spots now call it, and the helper alone emits adapter.quote() — one place to change rather than nine.

Identifiers inside string literals are quoted. OBJECT_ID('schema.table') returns NULL rather than erroring when the schema contains a . or a ", and every call site read that as "object does not exist". That meant the drop-before-create guards in create_table_as skipped and the CREATE then hit Msg 2714, and the mask introspection in apply_masks found no columns, so configured DDM masks were never applied. sp_rename had the same defect, failing the table rename-swap with No item by the name of …. Eleven sites across create.sql, apply_masks.sql, indexes.sql, relation.sql and columns.sql now pass quoted, qualified names; sp_rename's @newname stays bare, since quoting it would make the quotes part of the name.

Embedded delimiters are escaped. ab"cd renders "ab""cd" through both adapter.quote() and SQLServerRelation.quoted(). This is load-bearing in both directions: a " needs no escaping inside [brackets] but does inside double quotes, so the delimiter change would otherwise reject names the adapter previously accepted; and the string-literal fix renders identifiers through the relation. Neither override delegates to super() — pre-escaping input to a method whose contract is wrap verbatim would double-escape if upstream ever starts escaping too.

#409. The clustered columnstore index name embeds the schema and was emitted bare, so domain\user produced unparseable DDL (Incorrect syntax near '\'). It is quoted at both DDL sites, and still compared as a raw string against sys.indexes.name.

Verification

Checked against a live SQL_Latin1_General_CP1_CS_AS server:

  • USE "db" works only with QUOTED_IDENTIFIER ON — with it off it is a hard Msg 102. It is already on by default on all three backends: sessionproperty('QUOTED_IDENTIFIER') = 1 on pyodbc, mssql-python, and the go-mssqldb-backed adbc. Issue item 1 therefore needs no code change.
  • OBJECT_ID's bare form returns NULL for . and ", but resolves for a backslash or a space.
  • sp_rename accepts both "quoted" and [bracketed] @objname.
  • SQL Server accepts " in delimited identifiers at schema, table, column and index level.

Tests

Full suite green: 784 passed, 48 skipped, 2 xfailed.

Coverage sits in the existing classes rather than new files:

  • TestIndexMacros — the model's schema is …_dom\usr.x"q, covering the backslash (Database error when using slash in schema name #409, CCI name), the dot and the quote (string literals, rendering) in one name, reusing the class's single dbt build.
  • TestColumnPropertyMasks — a dotted-schema model asserting the mask is applied, guarding the mask path specifically.
  • test_constraints.py — expected-DDL assertions updated to the new output.
  • One new unit file, tests/unit/adapters/mssql/test_quote.py: quoting behaviour plus a lint that fails the build if a hand-formatted bracket identifier reappears (4 patterns, positive-controlled against every form removed here).

Non-goals

  • Server-side QUOTENAME() keeps emitting brackets. That is dynamic SQL built inside T-SQL, where brackets are the injection-safe idiom, and it is not SQL the dbt Core v2.0 port copies.
  • The " handling is hardening rather than a security fix — reachable only through YAML-supplied names, never model names.
  • cci_name still strips . and from the generated index name, and the QUOTENAME() blocks still assume the model's own schema. Both are unchanged here.

Stacked on #793 (itself on #792); change the base to master and rebase once those land.

@axellpadilla axellpadilla added this to the v1.12.0 milestone Aug 1, 2026
…785)

Relation rendering already emits ANSI "double quoted" identifiers, since the
adapter overrides neither quote_character nor quote(). A handful of macros
hand-formatted [brackets] instead, so single statements mixed both styles.
Those now route through adapter.quote() / the relation object: USE (via the
existing get_use_database_sql helper), CREATE SCHEMA, contract column lists,
grantees, index and constraint names, and generated test view names.

Two things were required to make that correct, so they come with it.

Identifiers the adapter builds inside string literals were never quoted.
OBJECT_ID('schema.table') returns NULL rather than erroring when the schema
holds a '.' or a '"', and callers read that as "object does not exist": the
drop-before-create guards in create_table_as skipped and the CREATE then hit
Msg 2714, and the mask introspection in apply_masks found no columns, so
configured DDM masks were never applied. sp_rename shared the defect, failing
the rename-swap with "No item by the name of ...". Those sites now pass
quoted, qualified names; sp_rename's @NewName stays bare, since quoting it
would make the quotes part of the name.

Embedded delimiters are escaped, in adapter.quote() and in relation
rendering. A '"' needs no escaping inside [brackets] but does inside double
quotes, so the delimiter change alone would have rejected names the adapter
previously accepted; and the string-literal fix renders identifiers through
the relation. Neither override delegates to super(), which wraps verbatim --
pre-escaping its input would double-escape if upstream ever starts escaping.

Also fixes the clustered columnstore index name, which embeds the schema and
was emitted bare, so a domain-qualified schema produced unparseable DDL
(Incorrect syntax near '\').

A unit test fails the build if a hand-formatted bracket identifier reappears.
Server-side QUOTENAME() keeps its brackets: that is dynamic SQL built inside
T-SQL, where brackets are the injection-safe idiom.

Closes #785
Closes #409
@axellpadilla
axellpadilla force-pushed the fix/785-consistent-identifier-quoting branch from b4f8dbe to 8d16c6e Compare August 1, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant