diff --git a/.gitignore b/.gitignore index d11d5ddc4..4f59b1de3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ coverage.xml # Django stuff: *.log +# dbt test logs (one subdir per test schema; accumulates indefinitely) +logs/ + # Sphinx documentation docs/_build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aeba5f612..8bfbb66f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +### v1.10.0 + +- Add `query_options` / `query_options_raw` model configs for emitting SQL Server `OPTION` clauses on table, incremental (delete+insert / microbatch), snapshot, and unit_test materializations. See https://github.com/dbt-msft/dbt-sqlserver/issues/613. +- `get_query_options()` is the new extension point for customising the emitted `OPTION` clause. +- **Migration note:** `apply_label()` is preserved as a callable alias (emits LABEL only) in case you use it in your own project but is no longer called by adapter macros. Projects that override `apply_label()` to customise the OPTION clause must override `get_query_options()` instead. + ### v1.9.1 - Removes the dependency on `dbt-fabric`. diff --git a/dbt/include/sqlserver/macros/adapters/catalog.sql b/dbt/include/sqlserver/macros/adapters/catalog.sql index 1011e93c3..8e4b5c161 100644 --- a/dbt/include/sqlserver/macros/adapters/catalog.sql +++ b/dbt/include/sqlserver/macros/adapters/catalog.sql @@ -1,5 +1,5 @@ {% macro sqlserver__get_catalog(information_schemas, schemas) -%} - {% set query_label = apply_label() %} + {% set query_label = get_query_options() %} {%- call statement('catalog', fetch_result=True) -%} {{ get_use_database_sql(information_schemas.database) }} with @@ -126,7 +126,7 @@ {%- endmacro %} {% macro sqlserver__get_catalog_relations(information_schema, relations) -%} - {% set query_label = apply_label() %} + {% set query_label = get_query_options() %} {%- set distinct_databases = relations | map(attribute='database') | unique | list -%} {%- if distinct_databases | length == 1 -%} diff --git a/dbt/include/sqlserver/macros/adapters/columns.sql b/dbt/include/sqlserver/macros/adapters/columns.sql index ba60547cd..a9bc4bfe6 100644 --- a/dbt/include/sqlserver/macros/adapters/columns.sql +++ b/dbt/include/sqlserver/macros/adapters/columns.sql @@ -12,7 +12,7 @@ {% endmacro %} {% macro sqlserver__get_columns_in_query(select_sql) %} - {% set query_label = apply_label() %} + {% set query_label = get_query_options() %} {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%} select TOP 0 * from ( {{ select_sql }} @@ -66,7 +66,7 @@ {% endmacro %} {% macro sqlserver__get_columns_in_relation(relation) -%} - {% set query_label = apply_label() %} + {% set query_label = get_query_options() %} {% call statement('get_columns_in_relation', fetch_result=True) %} {{ get_use_database_sql(relation.database) }} select diff --git a/dbt/include/sqlserver/macros/adapters/metadata.sql b/dbt/include/sqlserver/macros/adapters/metadata.sql index ce463216e..062a0c4f5 100644 --- a/dbt/include/sqlserver/macros/adapters/metadata.sql +++ b/dbt/include/sqlserver/macros/adapters/metadata.sql @@ -1,9 +1,97 @@ +{% macro get_query_options(parse_options=False) %} + {{ log (config.get('query_tag','dbt-sqlserver'))}} + {%- set query_label = config.get('query_tag','dbt-sqlserver') -%} + {%- set query_options = config.get('query_options', {}) -%} + {%- set query_options_raw = config.get('query_options_raw', []) -%} + + {%- set options_list = ["LABEL = '" ~ query_label ~ "'"] -%} + + {%- if parse_options -%} + {%- set valid_options = [ + 'HASH GROUP', 'ORDER GROUP', + 'CONCAT UNION', 'HASH UNION', 'MERGE UNION', + 'LOOP JOIN', 'MERGE JOIN', 'HASH JOIN', + 'DISABLE_OPTIMIZED_PLAN_FORCING', + 'EXPAND VIEWS', + 'FAST', + 'FORCE ORDER', + 'FORCE EXTERNALPUSHDOWN', 'DISABLE EXTERNALPUSHDOWN', + 'FORCE SCALEOUTEXECUTION', 'DISABLE SCALEOUTEXECUTION', + 'IGNORE_NONCLUSTERED_COLUMNSTORE_INDEX', + 'KEEP PLAN', + 'KEEPFIXED PLAN', + 'MAX_GRANT_PERCENT', + 'MIN_GRANT_PERCENT', + 'MAXDOP', + 'MAXRECURSION', + 'NO_PERFORMANCE_SPOOL', + 'OPTIMIZE FOR UNKNOWN', + 'QUERYTRACEON', + 'RECOMPILE', + 'ROBUST PLAN', + ] -%} + {#- SQL Server uses `OPTION (X = N)` for grant-percent hints, not `OPTION (X N)`. -#} + {%- set equals_syntax_options = ['MAX_GRANT_PERCENT', 'MIN_GRANT_PERCENT'] -%} + + {%- for key, value in query_options.items() -%} + {%- if key | upper not in valid_options -%} + {{ exceptions.raise_compiler_error("Invalid query option: '" ~ key ~ "'. Use query_options_raw for non-standard hints. Allowed: " ~ valid_options | join(', ')) }} + {%- endif -%} + + {%- if value is none -%} + {%- do options_list.append(key | upper) -%} + {%- else -%} + {%- if value is not number -%} + {{ exceptions.raise_compiler_error("Query option '" ~ key ~ "' value must be a number, got: '" ~ value ~ "'") }} + {%- endif -%} + {%- set separator = ' = ' if key | upper in equals_syntax_options else ' ' -%} + {#- Render the value verbatim: ints become "1", floats become "12.5". + MAX_GRANT_PERCENT / MIN_GRANT_PERCENT accept decimals 0.0–100.0; integer-only + options will surface a clear SQL Server parse error on invalid decimals. -#} + {%- do options_list.append(key | upper ~ separator ~ value) -%} + {%- endif -%} + {%- endfor -%} + + {#- query_options_raw bypasses the allowlist; users opt in to writing valid SQL Server syntax themselves. + Shape-check only: a plain string would be iterated character-by-character into garbage. -#} + {%- if query_options_raw is string or query_options_raw is mapping -%} + {{ exceptions.raise_compiler_error("query_options_raw must be a list of strings, got: '" ~ query_options_raw ~ "'") }} + {%- endif -%} + {%- for raw in query_options_raw -%} + {%- do options_list.append(raw) -%} + {%- endfor -%} + {%- endif -%} + + OPTION ({{ options_list | join(', ') }}); +{% endmacro %} + +{#- DEPRECATED: backward-compat alias for the pre-1.10 macro. + + Calls to `{{ apply_label() }}` from user macros still resolve and emit + a LABEL-only OPTION clause — but apply_label() is no longer the + extension point. Adapter macros now call get_query_options() instead, + so overriding apply_label() in a project's macros directory will have + no effect on adapter-emitted SQL. + + To customise the OPTION clause emitted by adapter macros (table, + incremental, snapshot, unit_test), override get_query_options instead. -#} {% macro apply_label() %} {{ log (config.get('query_tag','dbt-sqlserver'))}} {%- set query_label = config.get('query_tag','dbt-sqlserver') -%} OPTION (LABEL = '{{query_label}}'); {% endmacro %} +{#- Guard for materializations and incremental strategies that cannot emit OPTION clauses. + Raises a compiler error if the user has configured query_options/query_options_raw. -#} +{% macro raise_if_query_options_set(context_label) %} + {%- if config.get('query_options') or config.get('query_options_raw') -%} + {{ exceptions.raise_compiler_error( + "query_options/query_options_raw is not supported on " ~ context_label + ~ ". Remove the config or switch to a supported materialization (table, incremental delete+insert, snapshot, unit_test)." + ) }} + {%- endif -%} +{% endmacro %} + {% macro default__information_schema_hints() %}{% endmacro %} {% macro sqlserver__information_schema_hints() %}with (nolock){% endmacro %} @@ -27,14 +115,14 @@ {% call statement('list_schemas', fetch_result=True, auto_begin=False) -%} {{ get_use_database_sql(database) }} select name as [schema] - from sys.schemas {{ information_schema_hints() }} {{ apply_label() }} + from sys.schemas {{ information_schema_hints() }} {{ get_query_options() }} {% endcall %} {{ return(load_result('list_schemas').table) }} {% endmacro %} {% macro sqlserver__check_schema_exists(information_schema, schema) -%} {% call statement('check_schema_exists', fetch_result=True, auto_begin=False) -%} - SELECT count(*) as schema_exist FROM sys.schemas WHERE name = '{{ schema }}' {{ apply_label() }} + SELECT count(*) as schema_exist FROM sys.schemas WHERE name = '{{ schema }}' {{ get_query_options() }} {%- endcall %} {{ return(load_result('check_schema_exists').table) }} {% endmacro %} @@ -58,7 +146,7 @@ 'view' as table_type from sys.views as v {{ information_schema_hints() }} where v.schema_id = @schema_id - {{ apply_label() }} + {{ get_query_options() }} {% endcall %} {{ return(load_result('list_relations_without_caching').table) }} {% endmacro %} @@ -82,7 +170,7 @@ 'view' as table_type from sys.views as v {{ information_schema_hints() }} where v.schema_id = @schema_id and v.name = '{{ schema_relation.identifier }}' - {{ apply_label() }} + {{ get_query_options() }} {% endcall %} {{ return(load_result('get_relation_without_caching').table) }} {% endmacro %} @@ -113,7 +201,7 @@ upper(o.name) = upper('{{ relation.identifier }}')){%- if not loop.last %} or {% endif -%} {%- endfor -%} ) - {{ apply_label() }} + {{ get_query_options() }} {%- endcall -%} {{ return(load_result('last_modified')) }} diff --git a/dbt/include/sqlserver/macros/adapters/relation.sql b/dbt/include/sqlserver/macros/adapters/relation.sql index b6c4036d7..6d7a7079c 100644 --- a/dbt/include/sqlserver/macros/adapters/relation.sql +++ b/dbt/include/sqlserver/macros/adapters/relation.sql @@ -22,7 +22,7 @@ and refs.referenced_schema_name = '{{ relation.schema }}' and refs.referenced_entity_name = '{{ relation.identifier }}' and obj.type = 'V' - {{ apply_label() }} + {{ get_query_options() }} {% endcall %} {% set references = load_result('find_references')['data'] %} {% for reference in references -%} diff --git a/dbt/include/sqlserver/macros/materializations/models/incremental/merge.sql b/dbt/include/sqlserver/macros/materializations/models/incremental/merge.sql index 7c325cd45..219493cbc 100644 --- a/dbt/include/sqlserver/macros/materializations/models/incremental/merge.sql +++ b/dbt/include/sqlserver/macros/materializations/models/incremental/merge.sql @@ -1,5 +1,6 @@ {% macro sqlserver__get_merge_sql(target, source, unique_key, dest_columns, incremental_predicates=none) %} - {{ default__get_merge_sql(target, source, unique_key, dest_columns, incremental_predicates) }}; + {{ default__get_merge_sql(target, source, unique_key, dest_columns, incremental_predicates) }} + {{ get_query_options(parse_options=True) }} {% endmacro %} {% macro sqlserver__get_insert_overwrite_merge_sql(target, source, dest_columns, predicates, include_sql_header) %} @@ -8,7 +9,7 @@ {% macro sqlserver__get_delete_insert_merge_sql(target, source, unique_key, dest_columns, incremental_predicates=none) %} - {% set query_label = apply_label() %} + {% set query_label = get_query_options(parse_options=True) %} {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute="name")) -%} {% if unique_key %} @@ -57,6 +58,7 @@ {%- set source = arg_dict["temp_relation"] -%} {%- set dest_columns = arg_dict["dest_columns"] -%} {%- set incremental_predicates = [] if arg_dict.get('incremental_predicates') is none else arg_dict.get('incremental_predicates') -%} + {%- set query_label = get_query_options(parse_options=True) -%} {#-- Add additional incremental_predicates to filter for batch --#} {% if model.config.get("__dbt_internal_microbatch_event_time_start") -%} @@ -74,7 +76,8 @@ {% for predicate in incremental_predicates %} {%- if not loop.first %}and {% endif -%} {{ predicate }} {% endfor %} - ); + ) + {{ query_label }} {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute="name")) -%} insert into {{ target }} ({{ dest_cols_csv }}) @@ -82,4 +85,5 @@ select {{ dest_cols_csv }} from {{ source }} ) + {{ query_label }} {% endmacro %} diff --git a/dbt/include/sqlserver/macros/materializations/models/unit_test/unit_test_create_table_as.sql b/dbt/include/sqlserver/macros/materializations/models/unit_test/unit_test_create_table_as.sql index 2919d4701..b16587813 100644 --- a/dbt/include/sqlserver/macros/materializations/models/unit_test/unit_test_create_table_as.sql +++ b/dbt/include/sqlserver/macros/materializations/models/unit_test/unit_test_create_table_as.sql @@ -13,7 +13,7 @@ {% endmacro %} {% macro sqlserver__unit_test_create_table_as(temporary, relation, sql) -%} - {% set query_label = apply_label() %} + {% set query_label = get_query_options(parse_options=True) %} {% set contract_config = config.get('contract') %} {% set is_nested_cte = check_for_nested_cte(sql) %} diff --git a/dbt/include/sqlserver/macros/materializations/snapshots/snapshot_merge.sql b/dbt/include/sqlserver/macros/materializations/snapshots/snapshot_merge.sql index 789fbea38..3b659f04b 100644 --- a/dbt/include/sqlserver/macros/materializations/snapshots/snapshot_merge.sql +++ b/dbt/include/sqlserver/macros/materializations/snapshots/snapshot_merge.sql @@ -21,10 +21,10 @@ {% else %} and DBT_INTERNAL_DEST.{{ columns.dbt_valid_to }} is null {% endif %} - {{ apply_label() }} + {{ get_query_options(parse_options=True) }} insert into {{ target_table }} ({{ insert_cols_csv }}) select {{target_columns}} from {{ source_table }} as DBT_INTERNAL_SOURCE where DBT_INTERNAL_SOURCE.dbt_change_type = 'insert' - {{ apply_label() }} + {{ get_query_options(parse_options=True) }} {% endmacro %} diff --git a/dbt/include/sqlserver/macros/relations/table/create.sql b/dbt/include/sqlserver/macros/relations/table/create.sql index 79c07984b..9a6e905a8 100644 --- a/dbt/include/sqlserver/macros/relations/table/create.sql +++ b/dbt/include/sqlserver/macros/relations/table/create.sql @@ -1,5 +1,5 @@ {% macro sqlserver__create_table_as(temporary, relation, sql) -%} - {%- set query_label = apply_label() -%} + {%- set query_label = get_query_options(parse_options=True) -%} {%- set tmp_relation = relation.incorporate(path={"identifier": relation.identifier ~ '__dbt_tmp_vw'}, type='view') -%} {%- do adapter.drop_relation(tmp_relation) -%} diff --git a/dbt/include/sqlserver/macros/relations/views/create.sql b/dbt/include/sqlserver/macros/relations/views/create.sql index 874c2e562..f5ec760c5 100644 --- a/dbt/include/sqlserver/macros/relations/views/create.sql +++ b/dbt/include/sqlserver/macros/relations/views/create.sql @@ -1,4 +1,10 @@ {% macro sqlserver__create_view_as(relation, sql) -%} + {#- Only guard against user-configured view materializations; this macro is also + called for intermediate temp views during table/snapshot materializations, + where query_options is intended for the *outer* DML and shouldn't trip a guard here. -#} + {%- if config.get('materialized') == 'view' -%} + {{ raise_if_query_options_set('view materializations (SQL Server does not accept OPTION on CREATE VIEW)') }} + {%- endif -%} {{ get_use_database_sql(relation.database) }} {% set contract_config = config.get('contract') %} diff --git a/tests/functional/adapter/mssql/test_query_options.py b/tests/functional/adapter/mssql/test_query_options.py new file mode 100644 index 000000000..0c0fdce35 --- /dev/null +++ b/tests/functional/adapter/mssql/test_query_options.py @@ -0,0 +1,844 @@ +"""Functional tests for the query_options / query_options_raw model config. + +Coverage: + - Dict-shape query_options on table/incremental/snapshot/unit_test materializations. + - query_options_raw escape hatch (alone, and combined with dict). + - Allowlist validation: unknown keys, non-numeric values, MAX_GRANT_PERCENT `=` syntax. + - Unsupported materialization guards: view + incremental merge/microbatch raise compiler errors. + - apply_label() backward-compat alias (emits LABEL only, ignores query_options). +""" + +import datetime +import os +import re + +import pytest + +from dbt.tests.util import run_dbt + + +def _find_compiled_run_sql(project, filename: str) -> str: + """Locate a model's compiled run-time SQL under target/run and return its contents.""" + target_dir = os.path.join(project.project_root, "target", "run") + for root, _dirs, files in os.walk(target_dir): + if filename in files: + with open(os.path.join(root, filename), "r") as f: + return f.read() + raise AssertionError(f"Could not find compiled {filename} under {target_dir}") + + +# --------------------------------------------------------------------------- +# Table materialization — original recursive / generic / restriction coverage +# --------------------------------------------------------------------------- + +recursive_model_sql = """ +{{ config(materialized='table', query_options={'MAXRECURSION': 200}) }} +WITH cte AS ( + SELECT 1 AS n + UNION ALL + SELECT n + 1 FROM cte WHERE n < 150 +) +SELECT * FROM cte +""" + + +class TestQueryOptionsRecursive: + """MAXRECURSION 200 unlocks recursion past the default 100 limit.""" + + @pytest.fixture(scope="class") + def models(self): + return {"recursive_model.sql": recursive_model_sql} + + def test_max_recursion_option(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + +generic_options_model_sql = """ +{{ config(materialized='table', query_options={'MAXDOP': 1}) }} +select 1 as id +""" + + +class TestQueryOptionsTableEmitsOptions: + """Table materialization renders MAXDOP 1 and LABEL in the compiled SQL.""" + + @pytest.fixture(scope="class") + def models(self): + return {"generic_model.sql": generic_options_model_sql} + + def test_table_option_in_sql(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "generic_model.sql") + assert "MAXDOP 1" in sql + assert "LABEL =" in sql + + +# --------------------------------------------------------------------------- +# View materialization — now raises (was silently ignored) +# --------------------------------------------------------------------------- + +view_with_options_sql = ( + "{{ config(materialized='view', query_options={'MAXDOP': 1}) }} select 1 as id" +) + + +class TestQueryOptionsOnViewRaises: + @pytest.fixture(scope="class") + def models(self): + return {"view_with_options.sql": view_with_options_sql} + + def test_view_with_query_options_errors(self, project): + results = run_dbt(["run"], expect_pass=False) + assert len(results) == 1 + assert results[0].status == "error" + + +# --------------------------------------------------------------------------- +# Allowlist + value-type validation +# --------------------------------------------------------------------------- + +invalid_option_model_sql = """ +{{ config(materialized='table', query_options={'INVALID_OPTION': 1}) }} +select 1 as id +""" + + +class TestQueryOptionsInvalidKey: + @pytest.fixture(scope="class") + def models(self): + return {"invalid_model.sql": invalid_option_model_sql} + + def test_invalid_key_raises_error(self, project): + results = run_dbt(["run"], expect_pass=False) + assert len(results) == 1 + assert results[0].status == "error" + + +non_numeric_value_model_sql = """ +{{ config(materialized='table', query_options={'MAXDOP': 'not-a-number'}) }} +select 1 as id +""" + + +class TestQueryOptionsNonNumericValue: + @pytest.fixture(scope="class") + def models(self): + return {"bad_value_model.sql": non_numeric_value_model_sql} + + def test_non_numeric_value_raises_error(self, project): + results = run_dbt(["run"], expect_pass=False) + assert len(results) == 1 + assert results[0].status == "error" + + +# --------------------------------------------------------------------------- +# `=`-syntax options (MAX_GRANT_PERCENT, MIN_GRANT_PERCENT) +# --------------------------------------------------------------------------- + +max_grant_model_sql = """ +{{ config(materialized='table', query_options={'MAX_GRANT_PERCENT': 50}) }} +select 1 as id +""" + + +class TestQueryOptionsEqualsSyntax: + """MAX_GRANT_PERCENT/MIN_GRANT_PERCENT must render with `= N` not space-N.""" + + @pytest.fixture(scope="class") + def models(self): + return {"max_grant_model.sql": max_grant_model_sql} + + def test_grant_percent_renders_equals_syntax(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "max_grant_model.sql") + assert "MAX_GRANT_PERCENT = 50" in sql + assert "MAX_GRANT_PERCENT 50" not in sql + + +# --------------------------------------------------------------------------- +# query_options_raw escape hatch +# --------------------------------------------------------------------------- + +raw_only_model_sql = """ +{{ config( + materialized='table', + query_options_raw=["USE HINT('DISABLE_OPTIMIZER_ROWGOAL')"] +) }} +select 1 as id +""" + + +class TestQueryOptionsRaw: + @pytest.fixture(scope="class") + def models(self): + return {"raw_model.sql": raw_only_model_sql} + + def test_raw_option_appears_verbatim(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + # Single quotes inside the raw hint get doubled by EXEC('...')'s escape pass, + # so check the unquoted substrings rather than the literal source form. + sql = _find_compiled_run_sql(project, "raw_model.sql") + assert "USE HINT" in sql + assert "DISABLE_OPTIMIZER_ROWGOAL" in sql + assert "LABEL =" in sql + + +mixed_model_sql = """ +{{ config( + materialized='table', + query_options={'MAXDOP': 1}, + query_options_raw=["USE HINT('DISABLE_OPTIMIZER_ROWGOAL')"] +) }} +select 1 as id +""" + + +class TestQueryOptionsDictAndRaw: + @pytest.fixture(scope="class") + def models(self): + return {"mixed_model.sql": mixed_model_sql} + + def test_both_appear(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "mixed_model.sql") + assert "MAXDOP 1" in sql + assert "USE HINT" in sql + assert "DISABLE_OPTIMIZER_ROWGOAL" in sql + assert "LABEL =" in sql + + +# --------------------------------------------------------------------------- +# Incremental delete+insert (opt-in) +# --------------------------------------------------------------------------- + +incremental_seed_csv = """id,name +1,alice +2,bob +3,charlie +""" + +incremental_model_sql = """ +{{ config( + materialized='incremental', + unique_key='id', + incremental_strategy='delete+insert', + query_options={'MAXDOP': 1} +) }} +select id, name from {{ ref('inc_seed') }} +""" + + +class TestQueryOptionsOnIncrementalDeleteInsert: + @pytest.fixture(scope="class") + def seeds(self): + return {"inc_seed.csv": incremental_seed_csv} + + @pytest.fixture(scope="class") + def models(self): + return {"inc_model.sql": incremental_model_sql} + + def test_options_render_on_second_run(self, project): + run_dbt(["seed"]) + run_dbt(["run"]) + + # Second run exercises sqlserver__get_delete_insert_merge_sql + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "inc_model.sql") + assert "MAXDOP 1" in sql + + +# --------------------------------------------------------------------------- +# Incremental merge / microbatch +# --------------------------------------------------------------------------- + +incremental_merge_model_sql = """ +{{ config( + materialized='incremental', + unique_key='id', + incremental_strategy='merge', + query_options={'MAXDOP': 1} +) }} +select id, name from {{ ref('inc_seed') }} +""" + + +class TestQueryOptionsOnIncrementalMerge: + @pytest.fixture(scope="class") + def seeds(self): + return {"inc_seed.csv": incremental_seed_csv} + + @pytest.fixture(scope="class") + def models(self): + return {"inc_merge_model.sql": incremental_merge_model_sql} + + def test_options_render_on_second_run(self, project): + run_dbt(["seed"]) + run_dbt(["run"]) + + # Second run exercises sqlserver__get_merge_sql + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "inc_merge_model.sql") + assert "MAXDOP 1" in sql + + +# --------------------------------------------------------------------------- +# Incremental microbatch (opt-in) +# --------------------------------------------------------------------------- + + +class TestQueryOptionsOnIncrementalMicrobatch: + """Microbatch enumerates every batch between `begin` and "now", so dates must + stay close to the current time or the test will get slower as it ages. + Computed dynamically at fixture time.""" + + @pytest.fixture(scope="class") + def models(self): + today = datetime.datetime.now(datetime.timezone.utc) + d_minus_3 = (today - datetime.timedelta(days=3)).strftime("%Y-%m-%d 00:00:00") + d_minus_2 = (today - datetime.timedelta(days=2)).strftime("%Y-%m-%d 00:00:00") + d_minus_1 = (today - datetime.timedelta(days=1)).strftime("%Y-%m-%d 00:00:00") + + input_sql = f""" +{{{{ config(materialized='table', event_time='event_time') }}}} +select 1 as id, cast('{d_minus_3}' as datetime2) as event_time +union all +select 2 as id, cast('{d_minus_2}' as datetime2) as event_time +union all +select 3 as id, cast('{d_minus_1}' as datetime2) as event_time +""" + + model_sql = f""" +{{{{ config( + materialized='incremental', + incremental_strategy='microbatch', + event_time='event_time', + batch_size='day', + begin='{d_minus_3}', + query_options={{'MAXDOP': 1}} +) }}}} +select * from {{{{ ref('input_model') }}}} +""" + + return { + "input_model.sql": input_sql, + "microbatch_model.sql": model_sql, + } + + def test_options_render_on_microbatch(self, project): + # First run creates input + microbatch model from scratch + run_dbt(["run"]) + + # Second run exercises sqlserver__get_incremental_microbatch_sql + results = run_dbt(["run", "--select", "microbatch_model"]) + assert len(results) == 1 + assert results[0].status == "success" + + # Microbatch writes one compiled file per batch (microbatch_model_YYYY-MM-DD.sql), + # so look for any file with that prefix rather than the model filename verbatim. + target_dir = os.path.join(project.project_root, "target", "run") + for root, _dirs, files in os.walk(target_dir): + for filename in files: + if filename.startswith("microbatch_model_") and filename.endswith(".sql"): + with open(os.path.join(root, filename), "r") as f: + sql = f.read() + assert "MAXDOP 1" in sql, f"MAXDOP 1 missing from {filename}" + return + raise AssertionError("No microbatch batch file found under target/run") + + +# --------------------------------------------------------------------------- +# Snapshot (opt-in) +# --------------------------------------------------------------------------- + +snapshot_seed_csv = """id,name,updated_at +1,alice,2024-01-01 00:00:00 +2,bob,2024-01-01 00:00:00 +""" + +snapshot_block_sql = """ +{% snapshot snap %} +{{ config( + target_schema=schema, + unique_key='id', + strategy='timestamp', + updated_at='updated_at', + query_options={'MAXDOP': 1} +) }} +select * from {{ ref('snap_seed') }} +{% endsnapshot %} +""" + + +class TestQueryOptionsOnSnapshot: + @pytest.fixture(scope="class") + def seeds(self): + return {"snap_seed.csv": snapshot_seed_csv} + + @pytest.fixture(scope="class") + def snapshots(self): + return {"snap.sql": snapshot_block_sql} + + @pytest.fixture(scope="class") + def models(self): + # Need an empty models dict to keep dbt happy + return {} + + def test_options_render_on_second_snapshot_run(self, project): + run_dbt(["seed"]) + run_dbt(["snapshot"]) + + # Second snapshot exercises sqlserver__snapshot_merge_sql + results = run_dbt(["snapshot"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "snap.sql") + assert "MAXDOP 1" in sql + + +# --------------------------------------------------------------------------- +# apply_label() backward-compat alias +# --------------------------------------------------------------------------- + + +class TestApplyLabelBackwardCompat: + """apply_label() must still resolve and emit a label-only OPTION clause. + + The macro is invoked via `dbt run-operation` against a tiny user macro that + asserts the returned string contains LABEL and does NOT contain any + query_options-style hints. + """ + + @pytest.fixture(scope="class") + def macros(self): + return {"verify_apply_label.sql": """ +{% macro verify_apply_label() %} + {%- set result = apply_label() -%} + {{ log("apply_label returned: " ~ result, info=True) }} + {%- if 'LABEL' not in result -%} + {{ exceptions.raise_compiler_error("apply_label() did not emit LABEL") }} + {%- endif -%} + {%- if 'MAXDOP' in result -%} + {{ exceptions.raise_compiler_error("apply_label() must not emit query_options hints") }} + {%- endif -%} +{% endmacro %} +"""} + + def test_apply_label_callable_and_label_only(self, project): + # run-operation will fail (non-zero exit) if apply_label is undefined + # or if either of the verify macro's asserts fires. + run_dbt(["run-operation", "verify_apply_label"]) + + +# --------------------------------------------------------------------------- +# Multi-entry rendering, None-valued options, and custom query_tag +# --------------------------------------------------------------------------- + +multi_key_model_sql = """ +{{ config( + materialized='table', + query_options={'MAXDOP': 1, 'RECOMPILE': none, 'MAXRECURSION': 200} +) }} +WITH cte AS ( + SELECT 1 AS n UNION ALL SELECT n + 1 FROM cte WHERE n < 150 +) +SELECT * FROM cte +""" + + +class TestQueryOptionsMultiKey: + """Multiple dict entries all render and are comma-separated.""" + + @pytest.fixture(scope="class") + def models(self): + return {"multi_key_model.sql": multi_key_model_sql} + + def test_all_keys_present(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "multi_key_model.sql") + assert "MAXDOP 1" in sql + assert "MAXRECURSION 200" in sql + # RECOMPILE appears as a flag (no trailing value) + assert "RECOMPILE" in sql + # Comma separator between options + assert ", MAXDOP" in sql or "MAXDOP" in sql.split("LABEL")[1].split(",")[1] + + +multi_raw_model_sql = """ +{{ config( + materialized='table', + query_options_raw=[ + "USE HINT('DISABLE_OPTIMIZER_ROWGOAL')", + "OPTIMIZE FOR UNKNOWN" + ] +) }} +select 1 as id +""" + + +class TestQueryOptionsMultiRaw: + """Multiple raw entries all render verbatim and are comma-separated.""" + + @pytest.fixture(scope="class") + def models(self): + return {"multi_raw_model.sql": multi_raw_model_sql} + + def test_all_raw_entries_present(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "multi_raw_model.sql") + assert "USE HINT" in sql + assert "DISABLE_OPTIMIZER_ROWGOAL" in sql + assert "OPTIMIZE FOR UNKNOWN" in sql + + +none_valued_model_sql = """ +{{ config( + materialized='table', + query_options={'RECOMPILE': none} +) }} +select 1 as id +""" + + +class TestQueryOptionsNoneValued: + """A None-valued option emits as a bare flag (no trailing number).""" + + @pytest.fixture(scope="class") + def models(self): + return {"none_model.sql": none_valued_model_sql} + + def test_none_value_emits_bare_flag(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "none_model.sql") + # RECOMPILE present, not followed by a number + assert "RECOMPILE" in sql + # The bare-flag form should never produce "RECOMPILE " + assert re.search(r"RECOMPILE\s+\d", sql) is None, "RECOMPILE should be a bare flag" + + +custom_tag_model_sql = """ +{{ config( + materialized='table', + query_tag='my-custom-tag', + query_options={'MAXDOP': 1} +) }} +select 1 as id +""" + + +class TestQueryOptionsCustomQueryTag: + """Custom query_tag config flows into the LABEL portion of the OPTION clause.""" + + @pytest.fixture(scope="class") + def models(self): + return {"custom_tag_model.sql": custom_tag_model_sql} + + def test_custom_tag_in_label(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "custom_tag_model.sql") + # LABEL emitted inside EXEC('...') so single quotes are doubled. + assert "my-custom-tag" in sql + assert "LABEL =" in sql + + +# --------------------------------------------------------------------------- +# Key normalization, allowlist edge cases, and project-level config +# --------------------------------------------------------------------------- + +lowercase_key_model_sql = """ +{{ config( + materialized='table', + query_options={'maxdop': 1} +) }} +select 1 as id +""" + + +class TestQueryOptionsLowercaseKey: + """Lowercase keys are uppercased before allowlist check and SQL emission.""" + + @pytest.fixture(scope="class") + def models(self): + return {"lower_model.sql": lowercase_key_model_sql} + + def test_lowercase_key_uppercased(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "lower_model.sql") + assert "MAXDOP 1" in sql + assert "maxdop" not in sql # source-form lowercase should not survive + + +multi_word_key_model_sql = """ +{{ config( + materialized='table', + query_options={'FORCE ORDER': none} +) }} +select id from (select 1 as id) t +""" + + +class TestQueryOptionsMultiWordKey: + """Space-containing allowlist keys (FORCE ORDER, HASH JOIN, ...) emit verbatim.""" + + @pytest.fixture(scope="class") + def models(self): + return {"multi_word_model.sql": multi_word_key_model_sql} + + def test_multi_word_key_renders(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "multi_word_model.sql") + assert "FORCE ORDER" in sql + + +min_grant_model_sql = """ +{{ config(materialized='table', query_options={'MIN_GRANT_PERCENT': 25}) }} +select 1 as id +""" + + +class TestQueryOptionsMinGrantPercent: + """MIN_GRANT_PERCENT follows the same `= N` rule as MAX_GRANT_PERCENT.""" + + @pytest.fixture(scope="class") + def models(self): + return {"min_grant_model.sql": min_grant_model_sql} + + def test_min_grant_renders_equals_syntax(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "min_grant_model.sql") + assert "MIN_GRANT_PERCENT = 25" in sql + assert "MIN_GRANT_PERCENT 25" not in sql + + +project_level_model_sql = """ +{{ config(materialized='table') }} +select 1 as id +""" + + +class TestQueryOptionsProjectLevel: + """query_options set at project level (under models:) cascades to inheriting models.""" + + @pytest.fixture(scope="class") + def models(self): + return {"project_level_model.sql": project_level_model_sql} + + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "name": "test", + "models": { + "test": { + "+query_options": {"MAXDOP": 1}, + }, + }, + } + + def test_project_level_option_inherited(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "project_level_model.sql") + assert "MAXDOP 1" in sql + + +# --------------------------------------------------------------------------- +# First-run paths for incremental + snapshot (table-create path) +# --------------------------------------------------------------------------- + +incremental_first_run_model_sql = """ +{{ config( + materialized='incremental', + unique_key='id', + incremental_strategy='delete+insert', + query_options={'MAXDOP': 1} +) }} +select id, name from {{ ref('inc_seed') }} +""" + + +class TestQueryOptionsIncrementalFirstRun: + """First run of an incremental model goes through sqlserver__create_table_as. + Asserts options render there too (not just on subsequent DELETE+INSERT runs).""" + + @pytest.fixture(scope="class") + def seeds(self): + return {"inc_seed.csv": incremental_seed_csv} + + @pytest.fixture(scope="class") + def models(self): + return {"first_run_model.sql": incremental_first_run_model_sql} + + def test_first_run_emits_options(self, project): + run_dbt(["seed"]) + + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "first_run_model.sql") + assert "MAXDOP 1" in sql + + +snapshot_first_run_block_sql = """ +{% snapshot snap_first %} +{{ config( + target_schema=schema, + unique_key='id', + strategy='timestamp', + updated_at='updated_at', + query_options={'MAXDOP': 1} +) }} +select * from {{ ref('snap_seed') }} +{% endsnapshot %} +""" + + +class TestQueryOptionsSnapshotFirstRun: + """First snapshot run materializes the snapshot table via the create_table_as path.""" + + @pytest.fixture(scope="class") + def seeds(self): + return {"snap_seed.csv": snapshot_seed_csv} + + @pytest.fixture(scope="class") + def snapshots(self): + return {"snap_first.sql": snapshot_first_run_block_sql} + + @pytest.fixture(scope="class") + def models(self): + return {} + + def test_first_run_emits_options(self, project): + run_dbt(["seed"]) + + results = run_dbt(["snapshot"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "snap_first.sql") + assert "MAXDOP 1" in sql + + +# --------------------------------------------------------------------------- +# Decimal MAX_GRANT_PERCENT renders verbatim (no `| int` truncation) +# --------------------------------------------------------------------------- + +decimal_grant_model_sql = """ +{{ config(materialized='table', query_options={'MAX_GRANT_PERCENT': 12.5}) }} +select 1 as id +""" + + +class TestQueryOptionsDecimalGrantPercent: + """MAX_GRANT_PERCENT accepts decimals 0.0–100.0 per SQL Server spec; the + adapter must render the value verbatim rather than truncating to int.""" + + @pytest.fixture(scope="class") + def models(self): + return {"decimal_grant_model.sql": decimal_grant_model_sql} + + def test_decimal_value_not_truncated(self, project): + results = run_dbt(["run"]) + assert len(results) == 1 + assert results[0].status == "success" + + sql = _find_compiled_run_sql(project, "decimal_grant_model.sql") + assert "MAX_GRANT_PERCENT = 12.5" in sql + # Make sure the value did not get truncated to 12 + assert "MAX_GRANT_PERCENT = 12," not in sql + assert "MAX_GRANT_PERCENT = 12)" not in sql + + +# --------------------------------------------------------------------------- +# query_options_raw shape validation +# --------------------------------------------------------------------------- + +raw_string_model_sql = """ +{{ config( + materialized='table', + query_options_raw="USE HINT('DISABLE_OPTIMIZER_ROWGOAL')" +) }} +select 1 as id +""" + + +class TestQueryOptionsRawStringRaises: + """A plain string passed where a list is expected must raise rather than + silently iterate character-by-character into garbage SQL.""" + + @pytest.fixture(scope="class") + def models(self): + return {"raw_string_model.sql": raw_string_model_sql} + + def test_string_raises_error(self, project): + results = run_dbt(["run"], expect_pass=False) + assert len(results) == 1 + assert results[0].status == "error" + + +# --------------------------------------------------------------------------- +# PARAMETERIZATION is no longer in the allowlist (use query_options_raw) +# --------------------------------------------------------------------------- + +parameterization_model_sql = """ +{{ config(materialized='table', query_options={'PARAMETERIZATION': 'FORCED'}) }} +select 1 as id +""" + + +class TestQueryOptionsParameterizationRejected: + """PARAMETERIZATION requires a SIMPLE/FORCED keyword (not numeric), which + the allowlist path cannot render. It was removed from the allowlist; users + needing it use query_options_raw.""" + + @pytest.fixture(scope="class") + def models(self): + return {"param_model.sql": parameterization_model_sql} + + def test_parameterization_rejected_as_invalid_key(self, project): + results = run_dbt(["run"], expect_pass=False) + assert len(results) == 1 + assert results[0].status == "error"