From 3997ceef85155601a59f354e997c68ab1150374b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:31:48 +0100 Subject: [PATCH 1/4] feat: make TEST headers, test modes and trailing SELECT 1 optional in SQL tests A single-test file under tests/unit may omit its TEST header, a TEST block infers its mode from __macro_actual__, __udf_actual__ or __table_fn_actual__, and tests and scenarios may end after their last CTE. Explicit headers, modes and the ceremonial SELECT 1 keep working, and an explicit mode that contradicts the CTEs is still rejected. Compile, Rules and the formatter complete the omitted SELECT 1 internally so each body stays one statement. --- README.md | 3 - .../compiler/_helpers/sql_tests/extraction.rs | 12 +- src/sqlbuild/.agents/skills/sqlbuild/SKILL.md | 5 +- .../concepts/declaration-scopes/visibility.md | 3 - .../references/docs/concepts/macros.md | 3 - .../references/docs/concepts/scenarios.md | 3 +- .../references/docs/concepts/testing.md | 84 +++++----- .../skills/sqlbuild/references/testing.md | 13 +- .../compile/_helpers/attachment/sql_tests.py | 11 +- .../compile/_helpers/sql_tests/core.py | 71 +++++++-- src/sqlbuild/compiler/compile/constants.py | 1 + .../compiler/discovery/_helpers/sql/tests.py | 130 ++++++++++++++- .../main/omitted_ceremonial_select.py | 14 ++ src/sqlbuild/lint/_helpers/expansion.py | 2 +- src/sqlbuild/lint/_helpers/headers.py | 2 + src/sqlbuild/lint/_helpers/native_format.py | 60 ++++++- src/sqlbuild/lint/_helpers/native_sql.py | 63 +++++++- src/sqlbuild/lint/models.py | 1 + .../cli/commands/main/test/_test_types.py | 10 ++ .../cli/commands/main/test/helpers.py | 45 ++++++ .../main/test/test_optional_test_ceremony.py | 149 ++++++++++++++++++ .../compiler/compile/_helpers/_test_types.py | 7 + .../compile/_helpers/test_scenarios.py | 22 ++- .../compiler/compile/_helpers/test_tests.py | 107 ++++++++++++- .../sqlbuild/compiler/compile/test_main.py | 2 +- .../discovery/_helpers/_test_types.py | 8 + .../discovery/_helpers/test_sql_tests.py | 132 +++++++++++++++- tests/unit/src/sqlbuild/lint/_test_types.py | 10 ++ tests/unit/src/sqlbuild/lint/test_run_lint.py | 62 ++++++++ .../tests/scenarios/daily_revenue_minimal.sql | 2 - .../tests/unit/test_fact_orders.sql | 3 - .../tests/unit/test_stg_orders.sql | 3 - .../declaration-scopes/visibility.mdx | 3 - .../src/content/docs/docs/concepts/macros.mdx | 3 - .../content/docs/docs/concepts/scenarios.mdx | 3 +- .../content/docs/docs/concepts/testing.mdx | 84 +++++----- 36 files changed, 957 insertions(+), 179 deletions(-) create mode 100644 src/sqlbuild/compiler/discovery/main/omitted_ceremonial_select.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/test/test_optional_test_ceremony.py diff --git a/README.md b/README.md index c36bc52140..289ae98aaf 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,6 @@ A test mocks the sources and asserts on the model, resolving every model in betw SQL: ```sql -TEST(); - WITH __source__raw__orders AS ( SELECT @@ -199,7 +197,6 @@ __source__raw__payments AS ( __expected__fact_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 1500 AS total_cents, 15.00 AS total_dollars ) -SELECT 1 ``` ## Warehouses diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs index ba6c8cfbd3..78c5e19658 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs @@ -107,7 +107,7 @@ pub(crate) fn extract_batch_json(request_json: &str) -> Result { fn extract_ctes(sql: &str, file: &str) -> Result, String> { let mut index = skip_ignorable(sql, 0)?; index = consume_keyword(sql, index, "WITH").ok_or_else(|| { - format!("SQL test '{file}' must declare mock CTEs and one __expected__ CTE before `SELECT 1`") + format!("SQL test '{file}' must declare mock CTEs and one __expected__ CTE in a top-level WITH clause") })?; index = skip_ignorable(sql, index)?; if let Some(end) = consume_keyword(sql, index, "RECURSIVE") { @@ -140,9 +140,9 @@ fn extract_ctes(sql: &str, file: &str) -> Result, String> { break; } } - if !ceremonial_select_matches(sql, index)? { + if !statement_ends(sql, index)? && !ceremonial_select_matches(sql, index)? { return Err(format!( - "SQL test '{file}' must end with a ceremonial top-level `SELECT 1` after its CTEs" + "SQL test '{file}' must end after its CTEs; only an optional ceremonial top-level `SELECT 1` may follow them" )); } Ok(ctes) @@ -1045,7 +1045,11 @@ fn ceremonial_select_matches(sql: &str, start: usize) -> Result { if byte_at(sql, index) != Some(b'1') { return Ok(false); } - index = skip_ignorable(sql, index + 1)?; + statement_ends(sql, index + 1) +} + +fn statement_ends(sql: &str, start: usize) -> Result { + let mut index = skip_ignorable(sql, start)?; if byte_at(sql, index) == Some(b';') { index = skip_ignorable(sql, index + 1)?; } diff --git a/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md b/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md index fcbb1c481b..b36ae29cbc 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md @@ -125,10 +125,11 @@ do not hand-write that filter. Unlisted inputs are read in full. Use `__cursor_s window into batches, each with its own audits. Always `sqb plan` an incremental change before building. Read [references/incremental-models.md](references/incremental-models.md). -**Tests.** SQL unit tests live under `tests/unit/`. A `TEST();` file mocks inputs with +**Tests.** SQL unit tests live under `tests/unit/`. A test file mocks inputs with `__source__`, `__ref__`, `__seed__` CTEs, states expected output with `__expected__` (only the listed columns are compared, matched by name; unlisted columns are -ignored), adds zero-row checks with `__assert__`, and ends with `SELECT 1`. +ignored), and adds zero-row checks with `__assert__`. A single-test file needs no `TEST();` +header and no closing `SELECT 1`. One test can span many models: mock the sources, assert on the final model, and every intermediate model runs from its real SQL. Never write a test that only proves empty inputs produce no rows; it cannot fail, Rules reject it, and it does not count toward `min_tests_per_model`. Mock real rows diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/declaration-scopes/visibility.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/declaration-scopes/visibility.md index a8cadb1a59..04b5c46328 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/declaration-scopes/visibility.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/declaration-scopes/visibility.md @@ -110,8 +110,6 @@ A test first sees declarations available from its own folder tree. It may also u macros, enums, and constants available to a model for which it defines expected output. ```sql -TEST(); - WITH __expected__orders AS ( SELECT @@ -119,7 +117,6 @@ __expected__orders AS ( @normalize_order_status("'completed'") AS normalized_status, @enum("order_status").COMPLETED AS status ) -SELECT 1 ``` Because the test defines `__expected__orders`, the test may use file-based macros, enums, and diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/macros.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/macros.md index 8457efecc1..7e741b05d9 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/macros.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/macros.md @@ -177,8 +177,6 @@ def mock_orders(count: int = 1) -> str: ``` ```sql -TEST(); - WITH __source__raw__orders AS ( @mock_orders(3) @@ -190,7 +188,6 @@ __expected__stg_orders AS ( UNION ALL SELECT 3 AS order_id, 300 AS customer_id, 'completed' AS status ) -SELECT 1 ``` ## Use macros in hooks diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md index cd6e10a5db..27646b1115 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/scenarios.md @@ -90,8 +90,6 @@ __assert__all_orders_have_payments AS ( FROM __ref("fact_orders") WHERE payment_amount_cents IS NULL ) - -SELECT 1 ``` ### CTE conventions @@ -111,6 +109,7 @@ the complete fixture body. SQLBuild expands it to the relation's declared, typed false filter, as in [unit tests](testing.md). Every scenario must have at least one fixture CTE and at least one `__expected__` or `__assert__` CTE. +The file ends after its last CTE; a trailing ceremonial `SELECT 1` is accepted but not required. Scenario SQL uses macros, enums, and constants available from the scenario file's directory under `tests/scenarios/`. Public enums and constants available to a model are also available when the diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md index 584a200d7c..fd75c2ba54 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md @@ -37,8 +37,6 @@ A test file defines mock inputs and expected outputs using CTEs. SQLBuild substi ```sql -- tests/unit/test_stg_orders.sql -TEST(); - WITH __source__raw__orders AS ( SELECT @@ -58,7 +56,6 @@ __expected__stg_orders AS ( '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status ) -SELECT 1 ``` The test: @@ -84,7 +81,28 @@ that read them. Statically provable collection-versus-scalar type conflicts iden column and suggest explicit `CAST`, `ARRAY_CONSTRUCT`, or `PARSE_JSON` expressions. SQLBuild never invents missing values. -The trailing `SELECT 1` is required as a ceremonial closing statement. +A test file needs no header and no closing statement: a file that holds one test can be just its +CTEs. Add a `TEST (...)` header to name the test, set a cursor window, declare parameters, or turn +off SQL analysis, and on every block of a file that holds several tests. The `TEST();` header and +the trailing `SELECT 1` that earlier releases required are still accepted, so this file is +equivalent to the first example: + +```sql +TEST(); + +WITH +__source__raw__orders AS ( + SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, + '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status +), +__expected__stg_orders AS ( + SELECT 1 AS order_id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, + '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status +) +SELECT 1 +``` + +Nothing other than `SELECT 1` may follow the last CTE. ## CTE conventions @@ -121,8 +139,6 @@ expected rows can therefore live in one helper that both an `__expected__` CTE a read: ```sql -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 3 AS quantity UNION ALL SELECT 2 AS id, 5 AS quantity @@ -138,7 +154,6 @@ __assert__no_unexpected_order_ids AS ( EXCEPT SELECT order_id FROM expected_orders ) -SELECT 1 ``` SQLBuild emits each helper and mock a query needs once, in dependency order, including mocks that @@ -151,8 +166,6 @@ Tests can span multiple models in a single file. Mock your sources, define an ex ```sql -- Mock two sources, assert on the final mart. -- stg_orders and stg_payments resolve automatically from their real SQL. -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, @@ -166,7 +179,6 @@ __expected__fact_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 1500 AS payment_amount_cents, 'credit_card' AS payment_method ) -SELECT 1 ``` SQLBuild topologically sorts the expected models, resolves each intermediate model's real SQL with mocks substituted, and chains the outputs forward. Every model between the mocked sources and the expected model is computed automatically. @@ -187,8 +199,6 @@ dependency errors. You can mock models directly with `__ref__` and seeds with `__seed__`, not just sources. This skips the model's real SQL (or the seed's real CSV data) and provides controlled data instead: ```sql -TEST(); - WITH __ref__stg_orders AS ( SELECT @@ -231,7 +241,6 @@ __expected__fact_orders AS ( 'success' AS payment_status, 2850 AS payment_amount_cents ) -SELECT 1 ``` You can mix `__source__`, `__ref__`, and `__seed__` mocks in the same test. As long as every leaf dependency is satisfied (either by a source mock, a ref mock, a seed mock, or by being in the expected chain), the test resolves. @@ -260,8 +269,6 @@ expressions, with the fixture relation. The mocked function is not deployed as a ```sql -- models/customer_order_totals.sql reads: -- __table_fn("customer_orders")(42) -TEST(); - WITH fixture_orders AS ( SELECT 101 AS order_id, 42 AS customer_id, 2500 AS amount_cents @@ -272,7 +279,6 @@ __table_fn__customer_orders AS ( __expected__customer_order_totals AS ( SELECT 42 AS customer_id, 2500 AS total_cents ) -SELECT 1 ``` The fixture name must match a declared table function. Helper CTEs are available inside the fixture, @@ -288,8 +294,6 @@ compares the real function. A single test can assert on multiple models. SQLBuild resolves and compares each one independently: ```sql -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, @@ -310,7 +314,6 @@ __expected__fact_orders AS ( __expected__dim_customers AS ( SELECT 100 AS customer_id, 1 AS total_orders, 1500 AS lifetime_spend_cents ) -SELECT 1 ``` If the expected models form a chain (e.g. `stg_orders` feeds into `fact_orders` which feeds into `dim_customers`), SQLBuild resolves them in dependency order, using the output of earlier steps as input to later ones. @@ -326,8 +329,6 @@ Only explicit expected CTEs create grants. A matching test filename, `__ref__` m Because unit tests are written in SQL, they support macro calls. This lets you write reusable mock generators instead of copy-pasting mock data across test files: ```sql -TEST(); - WITH __source__raw__orders AS ( @mock_orders() @@ -339,7 +340,6 @@ __expected__fact_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 1500 AS total_cents, 'credit_card' AS payment_method ) -SELECT 1 ``` The `@mock_orders()` call expands at compile time to whatever SQL the Python macro function returns. @@ -360,8 +360,6 @@ with `D013`. When a model uses macros that you want to control in tests (e.g. target-specific logic, dynamic SQL generation), you can override their output with `__macro__` CTEs: ```sql -TEST(); - WITH __macro__country_filter AS ( SELECT 'country_code = ''US''' @@ -372,7 +370,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'completed' AS status ) -SELECT 1 ``` When a `__macro__` mock is defined, every call to `@country_filter(...)` in any model SQL resolved by the test is replaced with the mock value (`country_code = 'US'`). The macro's actual Python function is not called, and the arguments are ignored. @@ -389,8 +386,6 @@ This is useful for: Unit tests can include `__assert__` CTEs for property-based checks. An assertion passes if the query returns zero rows - any returned rows are failing examples. ```sql -TEST(); - WITH __ref__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 3 AS quantity, @@ -399,7 +394,6 @@ __ref__stg_orders AS ( __assert__order_ids_are_not_null AS ( SELECT * FROM __ref("stg_orders") WHERE order_id IS NULL ) -SELECT 1 ``` Assertions can be mixed with `__expected__` CTEs in the same test, or used on their own. They are useful when the natural check is "no rows should violate this rule" rather than "the output should exactly equal these rows" - for example, duplicate checks, negative-value constraints, or conditional business rules. @@ -448,7 +442,6 @@ __source__raw__orders AS ( __expected__order_summary AS ( SELECT 0 AS order_count, 0 AS total_amount ) -SELECT 1 ``` Assertions that filter for invalid rows, compare against expected rows, aggregate, or limit their @@ -457,7 +450,11 @@ project file; see [Rule options](rules/configuration-and-selection.md#rule-optio ## Test modes -By default, `TEST()` runs in model mode - mocking sources/refs and comparing model outputs. Three additional modes let you test reusable logic directly without needing a model chain. +Model tests mock sources and refs and compare model outputs. Three more modes test reusable logic +directly, without a model chain. SQLBuild infers the mode from the test's CTEs: a test that defines +`__macro_actual__` is a macro test, `__udf_actual__` a UDF test, `__table_fn_actual__` a table +function test, and any other test a model test. `TEST (mode ...)` states the mode explicitly; an +explicit mode that contradicts the CTEs is a compile error. ### Macro tests @@ -477,7 +474,6 @@ __macro_actual__ AS ( __macro_expected__ AS ( SELECT 2850 AS line_total_cents ) -SELECT 1 ``` Macros are compile-time code, so macro tests expand the macro at compile time and compare the results. During `sqb build`, macro tests run before any model that uses the tested macro. @@ -487,7 +483,7 @@ Macros are compile-time code, so macro tests expand the macro at compile time an Test scalar UDFs by calling them in `__udf_actual__` and comparing against `__udf_expected__`: ```sql -TEST (mode udf, name "detects_completed_orders"); +TEST (name "detects_completed_orders"); WITH input_values AS ( @@ -506,7 +502,6 @@ __udf_expected__ AS ( UNION ALL SELECT 'pending' AS order_status, FALSE AS is_completed_order ) -SELECT 1 ``` UDFs are warehouse objects, so the function is created before the test runs. During `sqb build`, UDF tests run after the function is created but before any model that uses it. @@ -516,7 +511,7 @@ UDFs are warehouse objects, so the function is created before the test runs. Dur Test table functions by calling them in `__table_fn_actual__` and comparing against `__table_fn_expected__`: ```sql -TEST (mode table_fn, name "returns_customer_orders"); +TEST (name "returns_customer_orders"); WITH __table_fn_actual__ AS ( @@ -528,7 +523,6 @@ __table_fn_expected__ AS ( UNION ALL SELECT 2 AS order_id, 'completed' AS order_status, TRUE AS is_completed_order ) -SELECT 1 ``` Table function tests run after the function is created. Since table functions are terminal (models cannot depend on them), these tests validate the function independently. @@ -539,20 +533,21 @@ Each mode has strict CTE validation: | Mode | Actual CTE | Expected CTE | Allowed | |------|-----------|--------------|---------| -| `model` (default) | existing model-chain syntax | `__expected__` | `__source__`, `__ref__`, `__seed__`, `__assert__`, `__macro__` | +| `model` (no actual CTE) | existing model-chain syntax | `__expected__` | `__source__`, `__ref__`, `__seed__`, `__assert__`, `__macro__` | | `macro` | `__macro_actual__` | `__macro_expected__` | Helper CTEs, `@macro()` calls in actual | | `udf` | `__udf_actual__` | `__udf_expected__` | Helper CTEs, `__udf()` calls in actual | | `table_fn` | `__table_fn_actual__` | `__table_fn_expected__` | Helper CTEs, `__table_fn()` calls in actual | -CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a model test will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. +CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a test declared `TEST (mode model)` will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. ## Multiple tests per file A single test file can contain multiple `TEST()` blocks. A test's name is its `name`, or the file -stem when `name` is omitted. Test names are globally unique: a test cannot share a name with another -test, a scenario, a model, a source, a seed, a function, or a Python node. Name the file or the block -after the behavior it checks, such as `orders_status_rules`, rather than after the model. The cases -of one parameterized test share its name. Each block in a file must have its own `name`: +stem when `name` or the whole header is omitted. Test names are globally unique: a test cannot share +a name with another test, a scenario, a model, a source, a seed, a function, or a Python node. Name +the file or the block after the behavior it checks, such as `orders_status_rules`, rather than after +the model. The cases of one parameterized test share its name. Every block in a multi-test file needs +its own `TEST (...)` header with a unique `name`: ```sql TEST (name "completed_orders_only"); @@ -564,7 +559,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'completed' AS status ) -SELECT 1 TEST (name "cancelled_orders_excluded"); @@ -575,10 +569,10 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'cancelled' AS status ) -SELECT 1 ``` -A file with a single test can omit the `name` field. Files with multiple tests require names on every block. +A file with a single test can omit the `name` field, or the whole header. Files with multiple tests +require a header with a name on every block. ## Repeating test logic @@ -615,7 +609,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS id, @param("expected_status") AS status ) -SELECT 1 ``` The cases report independently as `order status: maps source states [completed]`, `[cancelled]`, @@ -683,7 +676,6 @@ WITH cases(case_name, source_status, expected_status) AS ( __expected__status_mapping AS ( SELECT case_name, expected_status FROM cases ) -SELECT 1 ``` This produces one aggregate test result. Use native cases when each row must pass or fail diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md b/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md index f853cc1578..20291ec9dc 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md @@ -21,8 +21,10 @@ ## Writing a unit test -Files live under `tests/unit/` (any subfolder). Each file is a `TEST();` header, CTEs, and a -required closing `SELECT 1`. +Files live under `tests/unit/` (any subfolder). A file with one test is just its CTEs; add a +`TEST (...)` header only to set a name, cursor window, parameters or `sql_analysis false`, and on +every block of a file with several tests. The older `TEST();` header and trailing `SELECT 1` are +still accepted but not needed. A test's name is its `name` or the file stem. Test names are globally unique across tests, scenarios, models, sources, seeds, functions and Python nodes, so never name a test file after its model (`test_stg_orders.sql`, not `stg_orders.sql`). Run one test with `sqb test ` or @@ -30,8 +32,6 @@ model (`test_stg_orders.sql`, not `stg_orders.sql`). Run one test with `sqb test ```sql -- tests/unit/test_stg_orders.sql -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 100 AS customer_id, 'completed' AS status, @@ -44,7 +44,6 @@ __expected__stg_orders AS ( __assert__no_null_ids AS ( SELECT * FROM __ref("stg_orders") WHERE order_id IS NULL ) -SELECT 1 ``` | CTE prefix | Meaning | @@ -67,7 +66,8 @@ Helper CTEs can read other helpers and mocks by CTE name (for example `__ref__st shared expected rows can live in one helper that both an `__expected__` CTE and an assertion use. Macros work inside tests, so reusable mock generators such as `@mock_orders(count=5)` are normal. -Tests can also target a macro, UDF or table function directly; see +Tests can also target a macro, UDF or table function directly: defining `__macro_actual__`, +`__udf_actual__` or `__table_fn_actual__` selects that mode without a `TEST (mode ...)` header; see [docs/concepts/testing.md](docs/concepts/testing.md) for those modes. ## Empty-input tests @@ -92,7 +92,6 @@ __source__raw__orders AS ( __expected__order_summary AS ( SELECT 0 AS order_count, 0 AS total_amount ) -SELECT 1 ``` Reviewed exceptions go in `sqlbuild_project.toml` under `[rules.rule_options.SQBRTEST203]` as diff --git a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py index f7dc043b2d..8b29ceb4dd 100644 --- a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py +++ b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py @@ -29,6 +29,7 @@ ) from sqlbuild.compiler.compile._helpers.scenarios.core import extract_sql_scenario_ctes from sqlbuild.compiler.compile._helpers.sql_tests.core import ( + complete_omitted_ceremonial_select, extract_assertion_target_model_names, extract_sql_test_ctes, ) @@ -260,7 +261,10 @@ def build_test_inputs( value_renderer=scoped_declarations.value_renderer, collection_rendering=scoped_declarations.collection_rendering, ) - expanded_sql_body: str = expansion.sql + expanded_sql_body: str = complete_omitted_ceremonial_select( + sql=expansion.sql, + syntax=sql_lexical_syntax, + ) reject_cursor_intrinsics( sql=expanded_sql_body, context=f"SQL test '{test_block.name or test_file.file_path.stem}'", @@ -611,7 +615,10 @@ def build_scenario_inputs( value_renderer=scoped_declarations.value_renderer, collection_rendering=scoped_declarations.collection_rendering, ) - expanded_sql_body: str = expansion.sql + expanded_sql_body: str = complete_omitted_ceremonial_select( + sql=expansion.sql, + syntax=sql_lexical_syntax, + ) reject_cursor_intrinsics( sql=expanded_sql_body, context=f"SQL scenario '{scenario_file.file_path.stem}'", diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py index bd3e1c9e91..22eb45c5ac 100644 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py @@ -24,6 +24,7 @@ MACRO_ACTUAL_TEST_CTE_NAME, MACRO_EXPECTED_TEST_CTE_NAME, MACRO_TEST_CTE_PREFIX, + OMITTED_CEREMONIAL_SELECT_SQL, REF_TEST_CTE_PREFIX, RESERVED_SQL_TEST_CTE_NAMES, SEED_TEST_CTE_PREFIX, @@ -52,6 +53,9 @@ CompileSqlTestCtes, ) from sqlbuild.compiler.compile.types import SqlTestMode +from sqlbuild.compiler.discovery.main.omitted_ceremonial_select import ( + omitted_ceremonial_select_offset, +) from sqlbuild.compiler.references.types import SqlReferenceKind from sqlbuild.compiler.sql_analysis.main._find_matching_paren import find_matching_paren from sqlbuild.compiler.sql_analysis.main._is_identifier_character import ( @@ -229,7 +233,46 @@ def extract_top_level_ctes_with_scanner[CteT]( cte_type: Callable[..., CteT], syntax: SqlLexicalSyntax, ) -> tuple[CteT, ...]: - """Scan top-level `WITH` CTEs followed by the ceremonial `SELECT 1` of a test file.""" + """Scan top-level `WITH` CTEs, optionally followed by the ceremonial `SELECT 1`.""" + + ctes: tuple[tuple[str, str], ...] + index: int + ctes, index = _scan_top_level_ctes( + sql=sql, + file_label=file_label, + context_label=context_label, + with_requirement=with_requirement, + syntax=syntax, + ) + _validate_ceremonial_select( + sql=sql, + start=index, + file_label=file_label, + context_label=context_label, + syntax=syntax, + ) + return tuple(cte_type(name=name, sql_body=body) for name, body in ctes) + + +def complete_omitted_ceremonial_select(*, sql: str, syntax: SqlLexicalSyntax) -> str: + """Return test or scenario SQL as one statement, adding an omitted trailing `SELECT 1`.""" + + offset: int | None = omitted_ceremonial_select_offset(sql=sql, syntax=syntax) + if offset is None: + return sql + return f"{sql[:offset]}{OMITTED_CEREMONIAL_SELECT_SQL}{sql[offset:]}" + + +@lru_cache(maxsize=4096) +def _scan_top_level_ctes( + *, + sql: str, + file_label: str, + context_label: str, + with_requirement: str, + syntax: SqlLexicalSyntax, +) -> tuple[tuple[tuple[str, str], ...], int]: + """Return top-level CTEs and the first code position after them.""" with_end: int | None = _try_consume_keyword( sql=sql, @@ -238,7 +281,8 @@ def extract_top_level_ctes_with_scanner[CteT]( ) if with_end is None: raise CompileInputError( - f"{context_label} '{file_label}' must declare {with_requirement} before `SELECT 1`" + f"{context_label} '{file_label}' must declare {with_requirement} in a top-level " + "WITH clause" ) index: int = _skip_ignorable( sql=sql, start=with_end, context_label=context_label, syntax=syntax @@ -249,7 +293,7 @@ def extract_top_level_ctes_with_scanner[CteT]( sql=sql, start=recursive_end, context_label=context_label, syntax=syntax ) - ctes: list[CteT] = [] + ctes: list[tuple[str, str]] = [] seen_cte_names: set[str] = set() while True: cte_name, index = _read_identifier( @@ -288,7 +332,7 @@ def extract_top_level_ctes_with_scanner[CteT]( cte_body_end: int = find_matching_paren( sql=sql, open_paren_index=index, context=context_label, syntax=syntax ) - ctes.append(cte_type(name=cte_name, sql_body=sql[cte_body_start:cte_body_end].strip())) + ctes.append((cte_name, sql[cte_body_start:cte_body_end].strip())) index = _skip_ignorable( sql=sql, start=cte_body_end + 1, context_label=context_label, syntax=syntax ) @@ -297,16 +341,7 @@ def extract_top_level_ctes_with_scanner[CteT]( sql=sql, start=index + 1, context_label=context_label, syntax=syntax ) continue - break - - _validate_ceremonial_select( - sql=sql, - start=index, - file_label=file_label, - context_label=context_label, - syntax=syntax, - ) - return tuple(ctes) + return tuple(ctes), index def _classify_sql_test_ctes( @@ -902,13 +937,15 @@ def _validate_ceremonial_select( syntax: SqlLexicalSyntax, context_label: str = _CONTEXT, ) -> None: - if _is_ceremonial_select_statement( + if _is_statement_end( + sql=sql, start=start, context_label=context_label, syntax=syntax + ) or _is_ceremonial_select_statement( sql=sql, start=start, context_label=context_label, syntax=syntax ): return raise CompileInputError( - f"{context_label} '{file_label}' must end with a ceremonial top-level `SELECT 1` " - "after its CTEs" + f"{context_label} '{file_label}' must end after its CTEs; only an optional " + "ceremonial top-level `SELECT 1` may follow them" ) diff --git a/src/sqlbuild/compiler/compile/constants.py b/src/sqlbuild/compiler/compile/constants.py index 01ab10b9f3..0c61e28222 100644 --- a/src/sqlbuild/compiler/compile/constants.py +++ b/src/sqlbuild/compiler/compile/constants.py @@ -92,6 +92,7 @@ SQL_IDENTIFIER_EXTRA_TOKEN: str = "_" SQL_WITH_KEYWORD: str = "WITH" SQL_CEREMONIAL_SELECT_VALUE: str = "1" +OMITTED_CEREMONIAL_SELECT_SQL: str = f"\nSELECT {SQL_CEREMONIAL_SELECT_VALUE}" UNKNOWN_SQL_TYPE_NAME: str = "UNKNOWN" DECIMAL_SQL_TYPE_NAME: str = "DECIMAL" RESOLVED_SOURCE_CONFIDENCE: str = "resolved" diff --git a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py index 3cd3e72c19..060e34adc6 100644 --- a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py +++ b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py @@ -8,7 +8,14 @@ from pathlib import Path from typing import cast -from sqlbuild.compiler.compile.constants import DEFAULT_SQL_TEST_MODE +from sqlbuild.compiler.compile.constants import ( + DEFAULT_SQL_TEST_MODE, + MACRO_ACTUAL_TEST_CTE_NAME, + SQL_WITH_KEYWORD, + TABLE_FN_ACTUAL_TEST_CTE_NAME, + UDF_ACTUAL_TEST_CTE_NAME, +) +from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.discovery._helpers.sql.header_keys import reject_unsupported_header_keys from sqlbuild.compiler.discovery._helpers.sql.model_files import ( @@ -25,6 +32,11 @@ DiscoveredSqlTestCase, SqlTestParameterDeclaration, ) +from sqlbuild.compiler.sql_analysis.main._is_identifier_character import ( + is_identifier_character, +) +from sqlbuild.compiler.sql_analysis.main._skip_dialect_non_code import dialect_non_code_end +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from sqlbuild.sql_values.exceptions import SqlValueValidationError from sqlbuild.sql_values.main.normalize import normalize_sql_value from sqlbuild.sql_values.models import SqlValue @@ -38,6 +50,29 @@ r"^\s*TEST\s*\(" + STATEMENT_HEADER_BODY_PATTERN + r"\)\s*;\s*", re.DOTALL | re.MULTILINE, ) +_TEST_HEADER_START_PATTERN: re.Pattern[str] = re.compile(r"\s*TEST\s*\(") +_MODE_BY_ACTUAL_CTE_NAME: dict[str, SqlTestMode] = { + MACRO_ACTUAL_TEST_CTE_NAME: SqlTestMode.MACRO, + UDF_ACTUAL_TEST_CTE_NAME: SqlTestMode.UDF, + TABLE_FN_ACTUAL_TEST_CTE_NAME: SqlTestMode.TABLE_FN, +} +_ACTUAL_CTE_NAME_MARKER: str = "_actual__" +_TOP_LEVEL_ACTUAL_CTE_TOKEN_PATTERN: re.Pattern[str] = re.compile( + r"--[^\n]*" + r"|/\*[\s\S]*?(?:\*/|\Z)" + r"|'(?:''|[^'])*(?:'|\Z)" + r'|"(?:""|[^"])*(?:"|\Z)' + r"|`[^`]*(?:`|\Z)" + r"|(?P\()" + r"|(?P\))" + r"|(?" + + "|".join(re.escape(name) for name in _MODE_BY_ACTUAL_CTE_NAME) + + r")(?![\w$])(?=\s*(?:\([^()]*\)\s*)?(?i:AS)(?![\w$]))" +) +_SQL_TEST_CONTEXT: str = "SQL test" +_OPEN_PAREN: str = "(" +_CLOSE_PAREN: str = ")" +_STATEMENT_TERMINATOR: str = ";" _TEST_NAME_HEADER_KEY: str = "name" _TEST_MODE_HEADER_KEY: str = "mode" _TEST_PARAMETERS_HEADER_KEY: str = "parameters" @@ -83,10 +118,7 @@ def parse_sql_test_file(*, contents: str, file_path: Path) -> tuple[DiscoveredSq file_path=file_path, contents=contents ) if not raw_test_blocks: - raise SqlTestParseError( - f"SQL test '{file_path}' must start with a TEST() header as the first " - "non-whitespace content" - ) + return (_parse_headerless_sql_test_file(file_path=file_path, contents=contents),) discovered_blocks: list[DiscoveredSqlTestBlock] = [] test_index: int @@ -106,6 +138,84 @@ def parse_sql_test_file(*, contents: str, file_path: Path) -> tuple[DiscoveredSq return tuple(discovered_blocks) +def omitted_ceremonial_select_offset_impl(*, sql: str, syntax: SqlLexicalSyntax) -> int | None: + """Return the offset after a body's last top-level `)` when only a terminator follows it.""" + + first_code: int | None = None + last_code: int | None = None + last_code_depth: int = 0 + depth: int = 0 + index: int = 0 + try: + while index < len(sql): + non_code_end: int | None = dialect_non_code_end( + sql=sql, start=index, syntax=syntax, context=_SQL_TEST_CONTEXT + ) + if non_code_end is not None: + index = non_code_end + continue + character: str = sql[index] + if not character.isspace() and character != _STATEMENT_TERMINATOR: + first_code = index if first_code is None else first_code + depth += {_OPEN_PAREN: 1, _CLOSE_PAREN: -1}.get(character, 0) + last_code = index + last_code_depth = depth + index += 1 + except CompileInputError: + return None + if first_code is None or last_code is None: + return None + keyword_end: int = first_code + len(SQL_WITH_KEYWORD) + if sql[first_code:keyword_end].upper() != SQL_WITH_KEYWORD or ( + keyword_end < len(sql) and is_identifier_character(sql[keyword_end]) + ): + return None + if sql[last_code] != _CLOSE_PAREN or last_code_depth != 0: + return None + return last_code + 1 + + +def _parse_headerless_sql_test_file(*, file_path: Path, contents: str) -> DiscoveredSqlTestBlock: + if _TEST_HEADER_START_PATTERN.match(contents) is not None: + raise SqlTestParseError( + f"SQL test '{file_path}' starts with a TEST header that is not a complete " + "TEST(...); statement; close the header with `);` or omit it" + ) + sql_body: str = cleandoc(contents) + if not sql_body.strip(): + raise SqlTestParseError(f"SQL test '{file_path}' must define SQL") + return DiscoveredSqlTestBlock( + test_index=1, + header_values={}, + sql_body=sql_body, + mode=_infer_sql_test_mode(sql_body=sql_body, file_path=file_path), + ) + + +def _infer_sql_test_mode(*, sql_body: str, file_path: Path) -> SqlTestMode: + """Infer a TEST block's mode from the direct-logic actual CTE it defines, if any.""" + + if _ACTUAL_CTE_NAME_MARKER not in sql_body: + return DEFAULT_SQL_TEST_MODE + depth: int = 0 + inferred_modes: dict[SqlTestMode, None] = {} + match: re.Match[str] + for match in _TOP_LEVEL_ACTUAL_CTE_TOKEN_PATTERN.finditer(sql_body): + if match.group("open") is not None: + depth += 1 + elif match.group("close") is not None: + depth = max(depth - 1, 0) + elif match.group("name") is not None and depth == 0: + inferred_modes[_MODE_BY_ACTUAL_CTE_NAME[match.group("name")]] = None + if len(inferred_modes) > 1: + names: str = ", ".join(mode.value for mode in inferred_modes) + raise SqlTestParseError( + f"SQL test '{file_path}' defines actual CTEs for several test modes ({names}); " + "each TEST block tests one kind of logic" + ) + return next(iter(inferred_modes), DEFAULT_SQL_TEST_MODE) + + def _split_sql_test_blocks(*, file_path: Path, contents: str) -> tuple[tuple[str, int], ...]: matches: tuple[re.Match[str], ...] = tuple(_TEST_HEADER_ONLY_PATTERN.finditer(contents)) if not matches: @@ -113,7 +223,7 @@ def _split_sql_test_blocks(*, file_path: Path, contents: str) -> tuple[tuple[str if contents[: matches[0].start()].strip(): raise SqlTestParseError( f"SQL test '{file_path}' must start with a TEST() header as the first " - "non-whitespace content" + "non-whitespace content; only a file with a single test may omit its header" ) raw_blocks: list[tuple[str, int]] = [] @@ -154,8 +264,12 @@ def _parse_single_sql_test_block( name_value: object | None = header_values.get("name") test_name: str | None = cast(str | None, name_value) - mode_value: object = header_values.get("mode", DEFAULT_SQL_TEST_MODE.value) - test_mode: SqlTestMode = SqlTestMode(str(mode_value)) + mode_value: object | None = header_values.get(_TEST_MODE_HEADER_KEY) + test_mode: SqlTestMode = ( + _infer_sql_test_mode(sql_body=sql_body, file_path=file_path) + if mode_value is None + else SqlTestMode(str(mode_value)) + ) parameters, cases = _parse_test_parameters_and_cases( header_values=header_values, file_path=file_path, diff --git a/src/sqlbuild/compiler/discovery/main/omitted_ceremonial_select.py b/src/sqlbuild/compiler/discovery/main/omitted_ceremonial_select.py new file mode 100644 index 0000000000..4ec00c7fed --- /dev/null +++ b/src/sqlbuild/compiler/discovery/main/omitted_ceremonial_select.py @@ -0,0 +1,14 @@ +"""Locate where a SQL test or scenario body omits its trailing ceremonial `SELECT 1`.""" + +from __future__ import annotations + +from sqlbuild.compiler.discovery._helpers.sql.tests import ( + omitted_ceremonial_select_offset_impl, +) +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax + + +def omitted_ceremonial_select_offset(*, sql: str, syntax: SqlLexicalSyntax) -> int | None: + """Return the offset after a body's last top-level CTE when it ends without `SELECT 1`.""" + + return omitted_ceremonial_select_offset_impl(sql=sql, syntax=syntax) diff --git a/src/sqlbuild/lint/_helpers/expansion.py b/src/sqlbuild/lint/_helpers/expansion.py index d0235ec799..ca7026ac9d 100644 --- a/src/sqlbuild/lint/_helpers/expansion.py +++ b/src/sqlbuild/lint/_helpers/expansion.py @@ -361,7 +361,7 @@ def expand_file_bodies( external_identifiers: tuple[str, ...] = external_identifiers_for_headers( contents=contents, headers=headers ) - allows_ceremonial_select: bool = any( + allows_ceremonial_select: bool = role.in_sql_test_directory or any( header.kind in {HEADER_KIND_TEST, HEADER_KIND_SCENARIO} for header in headers ) allows_empty_fixture_star: bool = allows_ceremonial_select diff --git a/src/sqlbuild/lint/_helpers/headers.py b/src/sqlbuild/lint/_helpers/headers.py index 9dc2ccb033..47d1d323fd 100644 --- a/src/sqlbuild/lint/_helpers/headers.py +++ b/src/sqlbuild/lint/_helpers/headers.py @@ -7,6 +7,7 @@ from pathlib import Path, PurePath from sqlbuild.compiler.compile.constants import HOOK_DIRECTORY_NAME +from sqlbuild.compiler.discovery.constants import SQL_TESTS_OWNERSHIP_ROOT from sqlbuild.compiler.discovery.main.named_declaration_role_kind import ( named_declaration_role_kind, ) @@ -44,6 +45,7 @@ def lint_file_role( declaration_kind=named_declaration_role_kind(relative_path=relative_path), in_project=True, in_hook_directory=relative_path.is_relative_to(HOOK_DIRECTORY_NAME), + in_sql_test_directory=relative_path.is_relative_to(SQL_TESTS_OWNERSHIP_ROOT), ) diff --git a/src/sqlbuild/lint/_helpers/native_format.py b/src/sqlbuild/lint/_helpers/native_format.py index 9d22a2d0a8..3d4b6edba5 100644 --- a/src/sqlbuild/lint/_helpers/native_format.py +++ b/src/sqlbuild/lint/_helpers/native_format.py @@ -3,25 +3,42 @@ from __future__ import annotations import json +import re from dataclasses import dataclass from pathlib import Path from typing import Any import sqlbuild._native as _native +from sqlbuild.compiler.compile.constants import OMITTED_CEREMONIAL_SELECT_SQL +from sqlbuild.compiler.discovery.main.omitted_ceremonial_select import ( + omitted_ceremonial_select_offset, +) from sqlbuild.compiler.sql_analysis.constants import POLYGLOT_MAX_FUNCTION_CALL_DEPTH -from sqlbuild.lint._helpers.headers import lint_body_ranges, scan_headers +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax +from sqlbuild.lint._helpers.headers import lint_body_ranges, lint_file_role, scan_headers from sqlbuild.lint._helpers.sqlbuild_tokens import neutralize_interpolation, restore_interpolation from sqlbuild.lint.constants import ( CARRIAGE_RETURN_LINE_FEED, + HEADER_KIND_SCENARIO, + HEADER_KIND_TEST, LINE_FEED, LINT_ENGINE_NATIVE, RULE_FORMAT_UNSAFE, VIOLATION_SEVERITY_FAULT, ) from sqlbuild.lint.exceptions import InterpolationRestorationError, NativeLintError -from sqlbuild.lint.models import HeaderSpan, InterpolationSite, LintConfig, LintViolation +from sqlbuild.lint.models import ( + HeaderSpan, + InterpolationSite, + LintConfig, + LintFileRole, + LintViolation, +) _NATIVE_FORMAT_API_VERSION: int = 1 +_FIXTURE_HEADER_KINDS: frozenset[str] = frozenset({HEADER_KIND_TEST, HEADER_KIND_SCENARIO}) +_GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() +_OMITTED_SELECT_SUFFIX_PATTERN: re.Pattern[str] = re.compile(r"\s*\bSELECT\s+1\s*\Z", re.IGNORECASE) @dataclass(frozen=True) @@ -33,6 +50,7 @@ class _PreparedBody: trailing: str cache_key: tuple[str, str, str] interpolation_sites: tuple[InterpolationSite, ...] + omits_select: bool = False def with_newline_style(*, contents: str, newline: str) -> str: @@ -57,20 +75,41 @@ def format_native_sql_bodies( requests_by_key: dict[tuple[str, str, str], dict[str, object]] = {} for file_path, contents in sorted(files.items()): headers: tuple[HeaderSpan, ...] = scan_headers(contents=contents) + role: LintFileRole = lint_file_role(file_path=file_path, project_dir=project_dir) body_ranges: tuple[tuple[int, int], ...] = lint_body_ranges( contents=contents, headers=headers, file_path=file_path, project_dir=project_dir, + role=role, + ) + allows_omitted_select: bool = role.in_sql_test_directory or any( + header.kind in _FIXTURE_HEADER_KINDS for header in headers ) prepared_bodies: list[_PreparedBody] = [] for body_start, body_end in body_ranges: body: str = contents[body_start:body_end] trailing: str = body[len(body.rstrip()) :] core: str = body[: len(body) - len(trailing)] if trailing else body - neutralized, sites = neutralize_interpolation( + omitted_select_offset: int | None = ( + omitted_ceremonial_select_offset( + sql=core, + syntax=_GENERIC_SQL_SYNTAX, + ) + if allows_omitted_select + else None + ) + if omitted_select_offset is not None: + trailing = core[omitted_select_offset:] + trailing + core = core[:omitted_select_offset] + neutralized_core, sites = neutralize_interpolation( body=core, dialect=config.dialect, for_formatting=True ) + neutralized: str = ( + neutralized_core + if omitted_select_offset is None + else f"{neutralized_core}{OMITTED_CEREMONIAL_SELECT_SQL}" + ) token_widths: dict[str, int] = _sentinel_widths(sites=sites) cache_key: tuple[str, str, str] = ( neutralized, @@ -95,6 +134,7 @@ def format_native_sql_bodies( trailing=trailing, cache_key=cache_key, interpolation_sites=sites, + omits_select=omitted_select_offset is not None, ) ) prepared_by_path[file_path] = tuple(prepared_bodies) @@ -127,6 +167,20 @@ def format_native_sql_bodies( ) ) continue + if prepared.omits_select: + suffix: re.Match[str] | None = _OMITTED_SELECT_SUFFIX_PATTERN.search(raw_sql) + if suffix is None: + faults.append( + _format_fault( + file_path=file_path, + contents=contents, + start=prepared.start, + reason="native formatter moved the omitted ceremonial SELECT 1", + ) + ) + continue + raw_sql = raw_sql[: suffix.start()] + changed = True if not changed: continue try: diff --git a/src/sqlbuild/lint/_helpers/native_sql.py b/src/sqlbuild/lint/_helpers/native_sql.py index 6867c5aa6e..9b3f5fad82 100644 --- a/src/sqlbuild/lint/_helpers/native_sql.py +++ b/src/sqlbuild/lint/_helpers/native_sql.py @@ -17,6 +17,7 @@ MACRO_ACTUAL_TEST_CTE_NAME, MACRO_EXPECTED_TEST_CTE_NAME, MACRO_TEST_CTE_PREFIX, + OMITTED_CEREMONIAL_SELECT_SQL, REF_TEST_CTE_PREFIX, SEED_TEST_CTE_PREFIX, SOURCE_TEST_CTE_PREFIX, @@ -27,6 +28,10 @@ ) from sqlbuild.compiler.compile.main.map_expanded_offset import map_expanded_offset from sqlbuild.compiler.compile.models import MappedOffset +from sqlbuild.compiler.discovery.main.omitted_ceremonial_select import ( + omitted_ceremonial_select_offset, +) +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from sqlbuild.lint._helpers.native import source_line_starts, source_position from sqlbuild.lint._helpers.sqlbuild_tokens import interpolation_text_at from sqlbuild.lint.classes.stop_context import LintStopContext @@ -41,6 +46,7 @@ from sqlbuild.lint.models import LintBody, LintConfig, LintEdit, LintViolation _NATIVE_LINT_API_VERSION: int = 1 +_GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() _UNUSED_CTE_CODE: str = "SQBRSQL005" _LONG_LITERAL_CODE: str = "SQBRSQL044" _PARSE_ERROR_POSITION_PATTERN: re.Pattern[str] = re.compile( @@ -90,13 +96,22 @@ def run_native_sql_lint( violations_by_file: dict[Path, list[LintViolation]] = {} line_starts_by_path: dict[Path, tuple[int, ...]] = {} requests: dict[_NativeCacheKey, dict[str, object]] = {} + omitted_select_offsets: dict[str, int | None] = {} for body in bodies: cache_key: _NativeCacheKey = _native_cache_key(body=body, config=config) if cache_key in requests: continue + omitted_select_offset: int | None = _omitted_select_offset( + body=body, offsets=omitted_select_offsets + ) payload: dict[str, object] = { "version": _NATIVE_LINT_API_VERSION, - "sql": body.lint_text, + "sql": ( + body.lint_text + if omitted_select_offset is None + else f"{body.lint_text[:omitted_select_offset]}{OMITTED_CEREMONIAL_SELECT_SQL}" + f"{body.lint_text[omitted_select_offset:]}" + ), "dialect": config.dialect, } if config.enabled_native_rules is not None: @@ -142,6 +157,13 @@ def run_native_sql_lint( raw_diagnostics: object = response.get("diagnostics") if not isinstance(raw_diagnostics, list): raise NativeLintError("native lint response is missing a diagnostics list") + body_omitted_select_offset: int | None = _omitted_select_offset( + body=body, offsets=omitted_select_offsets + ) + if body_omitted_select_offset is not None: + raw_diagnostics = _authored_prefix_diagnostics( + raw_diagnostics=raw_diagnostics, offset=body_omitted_select_offset + ) for raw_diagnostic in raw_diagnostics: try: violation: LintViolation | None = _authored_violation( @@ -160,6 +182,45 @@ def run_native_sql_lint( return {path: tuple(entries) for path, entries in violations_by_file.items()} +def _omitted_select_offset(*, body: LintBody, offsets: dict[str, int | None]) -> int | None: + """Where a fixture body omits its ceremonial `SELECT 1`, so the parser sees one statement.""" + + if not body.allows_ceremonial_select: + return None + if body.lint_text not in offsets: + offsets[body.lint_text] = omitted_ceremonial_select_offset( + sql=body.lint_text, + syntax=_GENERIC_SQL_SYNTAX, + ) + return offsets[body.lint_text] + + +def _authored_prefix_diagnostics(*, raw_diagnostics: list[object], offset: int) -> list[object]: + """Keep diagnostics on authored SQL before the added `SELECT 1`, clamped to end there.""" + + kept: list[object] = [] + for raw_diagnostic in raw_diagnostics: + if not isinstance(raw_diagnostic, dict): + kept.append(raw_diagnostic) + continue + diagnostic: dict[str, object] = dict(cast("dict[str, object]", raw_diagnostic)) + start: object = diagnostic.get("start") + end: object = diagnostic.get("end") + if isinstance(start, int) and start >= offset: + continue + if isinstance(end, int) and end > offset: + diagnostic["end"] = offset + fix: object = diagnostic.get("fix") + fix_end: object = ( + cast("dict[str, object]", fix).get("end") if isinstance(fix, dict) else None + ) + if isinstance(fix_end, int) and fix_end > offset: + diagnostic["fix"] = None + diagnostic["fix_unavailable_reason"] = "fix overlaps generated SQL" + kept.append(diagnostic) + return kept + + def check_lint_not_stopped() -> None: """Abandon a speculative lint run on this thread once its owner no longer needs it.""" diff --git a/src/sqlbuild/lint/models.py b/src/sqlbuild/lint/models.py index 30539080cd..e9f32d7945 100644 --- a/src/sqlbuild/lint/models.py +++ b/src/sqlbuild/lint/models.py @@ -40,6 +40,7 @@ class LintFileRole: declaration_kind: DeclarationKind | None = None in_project: bool = False in_hook_directory: bool = False + in_sql_test_directory: bool = False @dataclass(frozen=True) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py index 05f2214dd3..a23a1a051e 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py @@ -224,6 +224,16 @@ class AuthoredSqlParityTestCase: expected_fragments: tuple[str, ...] +@dataclass(frozen=True) +class OptionalTestCeremonyE2ETestCase: + """SQL test files written with and without their optional header, mode, and SELECT 1.""" + + description: str + test_files: dict[str, str] + expected_exit_code: int + expected_output_fragments: tuple[str, ...] + + @dataclass(frozen=True) class CommentedFixtureE2ETestCase: """SQL tests whose fixture CTEs carry comments around set-operation branches.""" diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py index e96ac85bae..a79a641f32 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py @@ -4,10 +4,40 @@ import json import re +from pathlib import Path from typing import Any import duckdb +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( + execute_duckdb, + prepare_inline_project, +) + +_OPTIONAL_CEREMONY_PROJECT_FILES: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "optional_ceremony"\nadapter = "duckdb"\n\n' + '[connection]\ndatabase = "optional_ceremony.duckdb"\n\n' + '[defaults]\nmaterialized = "table"\n' + ), + "macros/status.py": ( + 'def normalize_status(value: str) -> str:\n return f"LOWER(TRIM({value}))"\n' + ), + "sources/raw_orders.yml": ( + "sources:\n" + " - name: raw_orders\n description: Test source raw_orders.\n" + " schema: main\n" + " table: raw_orders\n" + " columns:\n" + " - name: order_id\n type: INTEGER\n" + " - name: status\n type: VARCHAR\n" + ), + "models/orders.sql": ( + "MODEL (description 'Test model orders.');\n\n" + 'SELECT order_id, @normalize_status("status") AS status FROM __source("raw_orders")\n' + ), +} + def build_dynamic_pivot_test_project_files() -> dict[str, str]: """Build a Snowflake dynamic-pivot project for offline test-plan inspection.""" @@ -1680,3 +1710,18 @@ def build_lookup_project_files(*, sql_analysis_enabled: bool) -> dict[str, str]: "SELECT 1\n" ), } + + +def prepare_optional_ceremony_project(*, tmp_path: Path, test_files: dict[str, str]) -> Path: + """Write the optional-ceremony DuckDB project with the given SQL test files.""" + + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="optional_ceremony", + repo_files={**_OPTIONAL_CEREMONY_PROJECT_FILES, **test_files}, + ) + execute_duckdb( + db_path=project_dir / "optional_ceremony.duckdb", + sql="CREATE TABLE raw_orders AS SELECT 1 AS order_id, 'paid' AS status", + ) + return project_dir diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_optional_test_ceremony.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_optional_test_ceremony.py new file mode 100644 index 0000000000..9ee7760fbd --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_optional_test_ceremony.py @@ -0,0 +1,149 @@ +"""E2E coverage for SQL tests without a TEST header, mode, or trailing SELECT 1.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import ( + OptionalTestCeremonyE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import ( + prepare_optional_ceremony_project, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import run_sqb + +_HEADERLESS_MODEL_TEST: str = ( + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS order_id, ' PAID ' AS status\n" + "),\n" + "__expected__orders AS (\n" + " SELECT 1 AS order_id, 'paid' AS status\n" + ")\n" +) +_HEADERLESS_MACRO_TEST: str = ( + "WITH\n" + "input_values AS (\n" + " SELECT ' PAID ' AS raw_status\n" + "),\n" + "__macro_actual__ AS (\n" + ' SELECT @normalize_status("raw_status") AS status\n' + " FROM input_values\n" + "),\n" + "__macro_expected__ AS (\n" + " SELECT 'paid' AS status\n" + ")\n" +) +_EXPLICIT_MACRO_TEST: str = ( + 'TEST (mode macro, name "normalizes_status_explicitly");\n\n' + + _HEADERLESS_MACRO_TEST + + "SELECT 1\n" +) +_EXPLICIT_MODEL_TEST: str = "TEST();\n\n" + _HEADERLESS_MODEL_TEST + "SELECT 1\n" +_NAMED_BLOCKS_TEST: str = ( + 'TEST (name "orders_keep_paid");\n\n' + + _HEADERLESS_MODEL_TEST + + '\nTEST (name "orders_keep_void");\n\n' + + _HEADERLESS_MODEL_TEST.replace("' PAID '", "'void'").replace("'paid'", "'void'") +) + + +@pytest.mark.parametrize( + "test_case", + [ + OptionalTestCeremonyE2ETestCase( + description="headerless and explicit test forms run, lint, and stay formatted", + test_files={ + "tests/unit/test_orders.sql": _HEADERLESS_MODEL_TEST, + "tests/unit/test_normalize_status.sql": _HEADERLESS_MACRO_TEST, + "tests/unit/test_orders_explicit.sql": _EXPLICIT_MODEL_TEST, + "tests/unit/test_normalize_status_explicit.sql": _EXPLICIT_MACRO_TEST, + "tests/unit/test_orders_named_blocks.sql": _NAMED_BLOCKS_TEST, + }, + expected_exit_code=0, + expected_output_fragments=("PASS=6", "FAIL=0"), + ), + ], + ids=lambda case: case.description, +) +def test_given_optional_test_ceremony_when_testing_then_short_and_explicit_forms_agree( + test_case: OptionalTestCeremonyE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_optional_ceremony_project( + tmp_path=tmp_path, test_files=test_case.test_files + ) + + tested: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), project_dir=project_dir + ) + linted: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "rules", "run", "SQBRSQL"), project_dir=project_dir + ) + formatted: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "format"), project_dir=project_dir + ) + checked: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "format", "--check"), project_dir=project_dir + ) + + output: str = tested.stdout + tested.stderr + assert tested.returncode == test_case.expected_exit_code, output + for fragment in test_case.expected_output_fragments: + assert fragment in output, output + assert "tests/unit/" not in linted.stdout + linted.stderr, linted.stdout + linted.stderr + assert formatted.returncode == 0, formatted.stdout + formatted.stderr + assert checked.returncode == 0, checked.stdout + checked.stderr + written: dict[str, str] = { + path: (project_dir / path).read_text(encoding="utf-8") for path in test_case.test_files + } + assert {path: contents.count("TEST") for path, contents in written.items()} == { + path: contents.count("TEST") for path, contents in test_case.test_files.items() + } + assert {path: contents.count("SELECT 1\n") for path, contents in written.items()} == { + path: contents.count("SELECT 1\n") for path, contents in test_case.test_files.items() + } + + +@pytest.mark.parametrize( + "test_case", + [ + OptionalTestCeremonyE2ETestCase( + description="an explicit mode that contradicts the ctes is rejected", + test_files={ + "tests/unit/test_normalize_status.sql": ( + "TEST (mode model);\n\n" + _HEADERLESS_MACRO_TEST + ), + }, + expected_exit_code=1, + expected_output_fragments=( + "P001", + "is mode 'model' but defines macro-test CTE '__macro_actual__'", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_contradicting_explicit_mode_when_testing_then_compile_rejects_it( + test_case: OptionalTestCeremonyE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_optional_ceremony_project( + tmp_path=tmp_path, test_files=test_case.test_files + ) + + tested: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), project_dir=project_dir + ) + + output: str = tested.stdout + tested.stderr + assert tested.returncode == test_case.expected_exit_code, output + for fragment in test_case.expected_output_fragments: + assert fragment in output, output + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py index b45d310063..5bfd49aedc 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py @@ -895,6 +895,13 @@ class CteScannerMessageTestCase: expected_message: str +@dataclass(frozen=True) +class CompleteCeremonialSelectTestCase: + description: str + sql: str + expected_sql: str + + @dataclass(frozen=True) class ExpectedProjectionScanTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_scenarios.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_scenarios.py index f24b85846a..a61c8f3a6d 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_scenarios.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_scenarios.py @@ -62,6 +62,20 @@ expected_expected_model_names=("fact_orders",), expected_assertion_names=(), ), + ExtractSqlScenarioCtesTestCase( + description="extracts fixtures when the trailing select is omitted", + sql=""" + WITH + __ref__stg_orders AS (SELECT 1 AS order_id), + __expected__daily_revenue AS (SELECT 1 AS order_id) + """.strip(), + expected_authored_cte_names=("__ref__stg_orders",), + expected_source_fixture_names=(), + expected_ref_fixture_names=("stg_orders",), + expected_dbt_ref_fixture_names=(), + expected_expected_model_names=("daily_revenue",), + expected_assertion_names=(), + ), ExtractSqlScenarioCtesTestCase( description="extracts source ref seed fixtures expectations and assertions", sql=""" @@ -184,7 +198,7 @@ def test_given_sql_scenario_cte_variants_when_extracting_then_it_returns_expecte scenario_result AS (SELECT 1) SELECT * FROM scenario_result """.strip(), - expected_error_fragment="must end with a ceremonial top-level `SELECT 1`", + expected_error_fragment="must end after its CTEs", ), ExtractSqlScenarioCtesErrorTestCase( description="raises when assertion depends directly on expected result", @@ -468,7 +482,7 @@ def test_given_invalid_scenario_source_refs_when_building_inputs_then_it_raises_ sql="SELECT * FROM orders", expected_message=( "SQL scenario 'tests/scenarios/orders.sql' must declare fixture CTEs and at least " - "one __expected__ or __assert__ CTE before `SELECT 1`" + "one __expected__ or __assert__ CTE in a top-level WITH clause" ), ), CteScannerMessageTestCase( @@ -494,8 +508,8 @@ def test_given_invalid_scenario_source_refs_when_building_inputs_then_it_raises_ "result AS (SELECT 1) SELECT * FROM result" ), expected_message=( - "SQL scenario 'tests/scenarios/orders.sql' must end with a ceremonial top-level " - "`SELECT 1` after its CTEs" + "SQL scenario 'tests/scenarios/orders.sql' must end after its CTEs; only an optional " + "ceremonial top-level `SELECT 1` may follow them" ), ), CteScannerMessageTestCase( diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py index 8071264867..a134894e1d 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py @@ -4,6 +4,7 @@ from sqlbuild.compiler.compile._helpers.sql_tests.core import ( CompileSqlTestCtes, + complete_omitted_ceremonial_select, extract_sql_test_ctes, extract_sql_test_expected_model_names, ) @@ -15,6 +16,7 @@ from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( + CompleteCeremonialSelectTestCase, CteScannerMessageTestCase, ExtractSqlTestCtesErrorTestCase, ExtractSqlTestCtesTestCase, @@ -41,6 +43,31 @@ expected_expected_model_names=("fact_orders",), expected_mock_dbt_ref_names=("orders", "stripe__payments"), ), + ExtractSqlTestCtesTestCase( + description="extracts model ctes when the trailing select is omitted", + sql=""" + WITH + __source__raw_orders AS (SELECT 1 AS order_id), + __expected__orders AS (SELECT 1 AS order_id) + """.strip(), + expected_authored_cte_names=("__source__raw_orders",), + expected_mock_model_names=(), + expected_mock_source_names=("raw_orders",), + expected_expected_model_names=("orders",), + ), + ExtractSqlTestCtesTestCase( + description="extracts model ctes ending with a semicolon and comment", + sql=""" + WITH + __source__raw_orders AS (SELECT 1 AS order_id), + __expected__orders AS (SELECT 1 AS order_id); + -- end of test + """.strip(), + expected_authored_cte_names=("__source__raw_orders",), + expected_mock_model_names=(), + expected_mock_source_names=("raw_orders",), + expected_expected_model_names=("orders",), + ), ExtractSqlTestCtesTestCase( description="extracts table function fixtures from model tests", sql=""" @@ -419,6 +446,20 @@ def test_given_model_sql_test_cte_variants_when_extracting_then_it_returns_expec expected_macro_actual_cte_name="__table_fn_actual__", expected_macro_expected_cte_name="__table_fn_expected__", ), + ExtractSqlTestCtesTestCase( + description="extracts macro ctes when the trailing select is omitted", + sql=""" + WITH __macro_actual__ AS (SELECT @double(2) AS value), + __macro_expected__ AS (SELECT 4 AS value) + """.strip(), + mode=SqlTestMode.MACRO, + expected_authored_cte_names=(), + expected_mock_model_names=(), + expected_mock_source_names=(), + expected_expected_model_names=(), + expected_macro_actual_cte_name="__macro_actual__", + expected_macro_expected_cte_name="__macro_expected__", + ), ], ids=lambda case: case.description, ) @@ -453,7 +494,7 @@ def test_given_direct_logic_sql_test_cte_variants_when_extracting_then_it_return test_result AS (SELECT 1) SELECT * FROM test_result """.strip(), - expected_error_fragment="must end with a ceremonial top-level `SELECT 1`", + expected_error_fragment="must end after its CTEs", ), ExtractSqlTestCtesErrorTestCase( description="raises when assertion depends directly on expected result", @@ -614,7 +655,7 @@ def test_given_direct_logic_sql_test_cte_variants_when_extracting_then_it_return WITH __source__raw_orders AS (SELECT 1), __expected__orders AS (SELECT 1 AS order_id) SELECT 1 FROM __expected__orders """.strip(), - expected_error_fragment="must end with a ceremonial top-level `SELECT 1`", + expected_error_fragment="must end after its CTEs", ), ExtractSqlTestCtesErrorTestCase( description="raises when final result cte is not ceremonial select one", @@ -625,7 +666,7 @@ def test_given_direct_logic_sql_test_cte_variants_when_extracting_then_it_return test_result AS (SELECT 2) SELECT * FROM test_result """.strip(), - expected_error_fragment="must end with a ceremonial top-level `SELECT 1`", + expected_error_fragment="must end after its CTEs", ), ExtractSqlTestCtesErrorTestCase( description="raises when fallback terminal read follows a set operation", @@ -852,7 +893,7 @@ def test_given_invalid_sql_test_cte_variants_when_extracting_then_it_raises_clea sql="SELECT * FROM orders", expected_message=( "SQL test 'tests/unit/orders.sql' must declare mock CTEs and one " - "__expected__ CTE before `SELECT 1`" + "__expected__ CTE in a top-level WITH clause" ), ), CteScannerMessageTestCase( @@ -868,8 +909,8 @@ def test_given_invalid_sql_test_cte_variants_when_extracting_then_it_raises_clea "result AS (SELECT 1) SELECT * FROM result" ), expected_message=( - "SQL test 'tests/unit/orders.sql' must end with a ceremonial top-level " - "`SELECT 1` after its CTEs" + "SQL test 'tests/unit/orders.sql' must end after its CTEs; only an optional " + "ceremonial top-level `SELECT 1` may follow them" ), ), ), @@ -916,3 +957,57 @@ def test_given_malformed_sql_test_ctes_when_scanning_expected_models_then_names_ ) assert str(error_info.value) == test_case.expected_message + + +@pytest.mark.parametrize( + "test_case", + ( + CompleteCeremonialSelectTestCase( + description="adds the select after the last cte", + sql="WITH __source__orders AS (SELECT 1 AS id),\n__expected__orders AS (SELECT 1 AS id)", + expected_sql=( + "WITH __source__orders AS (SELECT 1 AS id),\n" + "__expected__orders AS (SELECT 1 AS id)\nSELECT 1" + ), + ), + CompleteCeremonialSelectTestCase( + description="adds the select before a trailing semicolon and comment", + sql="WITH __source__orders AS (SELECT 1 AS id);\n-- done", + expected_sql="WITH __source__orders AS (SELECT 1 AS id)\nSELECT 1;\n-- done", + ), + CompleteCeremonialSelectTestCase( + description="ignores parentheses in a trailing comment and string", + sql="WITH __source__orders AS (SELECT ')' AS id) -- closes (\n", + expected_sql="WITH __source__orders AS (SELECT ')' AS id)\nSELECT 1 -- closes (\n", + ), + CompleteCeremonialSelectTestCase( + description="keeps an authored ceremonial select unchanged", + sql="WITH __source__orders AS (SELECT 1 AS id)\nSELECT 1", + expected_sql="WITH __source__orders AS (SELECT 1 AS id)\nSELECT 1", + ), + CompleteCeremonialSelectTestCase( + description="keeps another final statement for the scanner to reject", + sql="WITH __source__orders AS (SELECT 1 AS id)\nSELECT * FROM __source__orders", + expected_sql="WITH __source__orders AS (SELECT 1 AS id)\nSELECT * FROM __source__orders", + ), + CompleteCeremonialSelectTestCase( + description="keeps sql without a with clause unchanged", + sql="SELECT 1", + expected_sql="SELECT 1", + ), + CompleteCeremonialSelectTestCase( + description="keeps an unclosed cte unchanged", + sql="WITH __source__orders AS (SELECT 1 AS id", + expected_sql="WITH __source__orders AS (SELECT 1 AS id", + ), + ), + ids=lambda case: case.description, +) +def test_given_test_sql_when_completing_ceremonial_select_then_only_omitted_select_is_added( + test_case: CompleteCeremonialSelectTestCase, +) -> None: + completed: str = complete_omitted_ceremonial_select( + sql=test_case.sql, syntax=_GENERIC_SQL_SYNTAX + ) + + assert completed == test_case.expected_sql diff --git a/tests/unit/src/sqlbuild/compiler/compile/test_main.py b/tests/unit/src/sqlbuild/compiler/compile/test_main.py index e7a8b5001f..b78a2202ab 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/test_main.py +++ b/tests/unit/src/sqlbuild/compiler/compile/test_main.py @@ -3700,7 +3700,7 @@ def test_given_project_and_local_environment_when_resolving_then_local_values_ov }, selected_target=None, run_id=None, - expected_error_fragment="must end with a ceremonial top-level `SELECT 1`", + expected_error_fragment="must end after its CTEs", ), BuildCompileInputsErrorTestCase( description="raises when a compiled test body references an unknown source mock", diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py index d23f59b5f9..53b4620539 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py @@ -586,6 +586,14 @@ class ParseSqlTestFileErrorTestCase: expected_error_fragment: str +@dataclass(frozen=True) +class ParseSqlTestModeTestCase: + description: str + contents: str + expected_modes: tuple[str, ...] + expected_header_values: tuple[dict[str, object], ...] + + @dataclass(frozen=True) class ParseSqlTestCursorWindowTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py index 04869fef72..ba278a7ae5 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py @@ -26,6 +26,7 @@ ParseSqlTestCursorWindowTestCase, ParseSqlTestFileErrorTestCase, ParseSqlTestFileTestCase, + ParseSqlTestModeTestCase, ) from tests.unit.src.sqlbuild.compiler.discovery._helpers.helpers import ( discovered_test_case_values, @@ -69,7 +70,9 @@ def test_given_batch_preparation_failure_when_discovering_tolerantly_then_report ) -> None: tests_dir: Path = tmp_path / "tests" / "unit" tests_dir.mkdir(parents=True) - (tests_dir / "a_invalid.sql").write_text("SELECT 1", encoding="utf-8") + (tests_dir / "a_invalid.sql").write_text( + 'TEST (name "unterminated")\nSELECT 1', encoding="utf-8" + ) (tests_dir / "b_valid.sql").write_text('TEST (name "valid_order");\nSELECT 1', encoding="utf-8") preparation: Mock = Mock(side_effect=ValueError("invalid batch projection")) monkeypatch.setattr(test_file_discovery, "prepare_sql_test_file_headers", preparation) @@ -110,6 +113,23 @@ def test_given_batch_preparation_failure_when_discovering_tolerantly_then_report expected_test_indexes=(1,), expected_header_values=({},), ), + ParseSqlTestFileTestCase( + description="treats a headerless file as one unnamed test block", + contents=""" + -- Orders keep their identifiers. + WITH + __source__orders AS ( + SELECT 1 AS order_id + ) + """, + expected_names=(None,), + expected_sql_bodies=( + "-- Orders keep their identifiers.\nWITH\n__source__orders AS (\n" + " SELECT 1 AS order_id\n)", + ), + expected_test_indexes=(1,), + expected_header_values=({},), + ), ParseSqlTestFileTestCase( description="discovers multiple named test blocks from one file", contents=""" @@ -268,9 +288,26 @@ def test_given_sql_test_file_variants_when_parsing_then_it_returns_expected_raw_ "test_case", [ ParseSqlTestFileErrorTestCase( - description="raises when the file does not start with a test header", - contents="SELECT 1\n", - expected_error_fragment="must start with a TEST", + description="raises when a headerless body precedes another test block", + contents='SELECT 1;\n\nTEST (name "second");\n\nSELECT 1\n', + expected_error_fragment="only a file with a single test may omit its header", + ), + ParseSqlTestFileErrorTestCase( + description="raises when a test header is not terminated", + contents='TEST (name "orders")\n\nWITH __source__orders AS (SELECT 1 AS id)\n', + expected_error_fragment=r"not a complete TEST\(\.\.\.\); statement", + ), + ParseSqlTestFileErrorTestCase( + description="raises when a headerless file is empty", + contents="\n \n", + expected_error_fragment="must define SQL", + ), + ParseSqlTestFileErrorTestCase( + description="raises when one block defines actual CTEs for several modes", + contents=( + "WITH __macro_actual__ AS (SELECT 1 AS a),\n__udf_actual__ AS (SELECT 1 AS a)\n" + ), + expected_error_fragment=r"actual CTEs for several test modes \(macro, udf\)", ), ParseSqlTestFileErrorTestCase( description="raises when leading comments appear before the first test header", @@ -442,6 +479,93 @@ def test_given_invalid_sql_test_file_contents_when_parsing_then_it_raises_clear_ parse_sql_test_file(contents=test_case.contents, file_path=Path("tests/unit/orders.sql")) +@pytest.mark.parametrize( + "test_case", + [ + ParseSqlTestModeTestCase( + description="defaults to model mode without direct-logic actual CTEs", + contents="WITH __source__orders AS (SELECT 1 AS id),\n__expected__orders AS (SELECT 1 AS id)", + expected_modes=("model",), + expected_header_values=({},), + ), + ParseSqlTestModeTestCase( + description="infers macro mode in a headerless file", + contents=( + "WITH __macro_actual__ AS (SELECT @double(1) AS value),\n" + "__macro_expected__ AS (SELECT 2 AS value)" + ), + expected_modes=("macro",), + expected_header_values=({},), + ), + ParseSqlTestModeTestCase( + description="infers udf mode under a header without a mode", + contents=( + 'TEST (name "detects_completed_orders");\n' + "WITH __udf_actual__ AS (SELECT 1 AS value),\n" + "__udf_expected__ AS (SELECT 1 AS value)\n" + "SELECT 1" + ), + expected_modes=("udf",), + expected_header_values=({"name": "detects_completed_orders"},), + ), + ParseSqlTestModeTestCase( + description="infers table function mode from a lower-case as keyword", + contents=( + "WITH __table_fn_actual__ as (SELECT 1 AS value),\n" + "__table_fn_expected__ as (SELECT 1 AS value)" + ), + expected_modes=("table_fn",), + expected_header_values=({},), + ), + ParseSqlTestModeTestCase( + description="infers each named block independently", + contents=( + 'TEST (name "macro_case");\n' + "WITH __macro_actual__ AS (SELECT 1 AS value),\n" + "__macro_expected__ AS (SELECT 1 AS value);\n\n" + 'TEST (name "model_case");\n' + "WITH __source__orders AS (SELECT 1 AS id),\n" + "__expected__orders AS (SELECT 1 AS id)" + ), + expected_modes=("macro", "model"), + expected_header_values=({"name": "macro_case"}, {"name": "model_case"}), + ), + ParseSqlTestModeTestCase( + description="ignores actual CTE names in comments, strings and nested queries", + contents=( + "-- __macro_actual__ AS (\n" + "WITH __source__orders AS (\n" + " WITH __udf_actual__ AS (SELECT 1 AS id) SELECT id FROM __udf_actual__\n" + "),\n" + "__expected__orders AS (SELECT '__table_fn_actual__ AS (' AS id)" + ), + expected_modes=("model",), + expected_header_values=({},), + ), + ParseSqlTestModeTestCase( + description="keeps an explicit mode even when it contradicts the CTEs", + contents=( + "TEST (mode model);\n" + "WITH __macro_actual__ AS (SELECT 1 AS value),\n" + "__macro_expected__ AS (SELECT 1 AS value)" + ), + expected_modes=("model",), + expected_header_values=({"mode": "model"},), + ), + ], + ids=lambda case: case.description, +) +def test_given_test_block_ctes_when_parsing_then_mode_is_explicit_or_inferred( + test_case: ParseSqlTestModeTestCase, +) -> None: + blocks: tuple[DiscoveredSqlTestBlock, ...] = parse_sql_test_file( + contents=test_case.contents, file_path=Path("tests/unit/orders.sql") + ) + + assert tuple(block.mode.value for block in blocks) == test_case.expected_modes + assert tuple(block.header_values for block in blocks) == test_case.expected_header_values + + @pytest.mark.parametrize( "test_case", [ diff --git a/tests/unit/src/sqlbuild/lint/_test_types.py b/tests/unit/src/sqlbuild/lint/_test_types.py index bfc918855c..5f4c66122b 100644 --- a/tests/unit/src/sqlbuild/lint/_test_types.py +++ b/tests/unit/src/sqlbuild/lint/_test_types.py @@ -47,6 +47,16 @@ class FormatDescriptionResolutionTestCase: expected_description_faults: tuple[tuple[str, str], ...] +@dataclass(frozen=True) +class FixtureCeremonyFormatTestCase: + """A test or scenario file formatted with or without its optional TEST header and SELECT 1.""" + + description: str + relative_path: str + contents: str + expected_contents: str + + @dataclass(frozen=True) class FormatNewlineTestCase: """Test case for preserving newline conventions while formatting.""" diff --git a/tests/unit/src/sqlbuild/lint/test_run_lint.py b/tests/unit/src/sqlbuild/lint/test_run_lint.py index f85f003ec9..e714c105cb 100644 --- a/tests/unit/src/sqlbuild/lint/test_run_lint.py +++ b/tests/unit/src/sqlbuild/lint/test_run_lint.py @@ -12,6 +12,7 @@ from sqlbuild.lint.main.run_lint import run_lint from sqlbuild.lint.models import LintConfig, LintRunResult, LintViolation from tests.unit.src.sqlbuild.lint._test_types import ( + FixtureCeremonyFormatTestCase, FormatDescriptionResolutionTestCase, FormatNewlineTestCase, FormatProjectTestCase, @@ -22,6 +23,13 @@ CLEAN_MODEL: str = 'MODEL (\n materialized table,\n description "ok"\n);\nSELECT 1 AS x FROM t\n' NO_DESCRIPTION_MODEL: str = "MODEL (\n materialized table\n);\nSELECT 1 AS x FROM t\n" PROJECT_TOML: str = 'name = "demo"\nadapter = "duckdb"\n' +_FIXTURE_CTES: str = ( + "with __source__orders as (select 1 as id),\n__expected__orders as (select 1 as id)" +) +_FORMATTED_FIXTURE_CTES: str = ( + "WITH __source__orders AS (\n SELECT 1 AS id\n),\n\n" + "__expected__orders AS (\n SELECT 1 AS id\n)" +) @pytest.mark.parametrize( @@ -333,6 +341,60 @@ def test_given_commented_body_when_formatting_then_comment_is_preserved_in_canon ) +@pytest.mark.parametrize( + "test_case", + [ + FixtureCeremonyFormatTestCase( + description="headerless test without a trailing select stays without one", + relative_path="tests/unit/test_orders.sql", + contents=f"{_FIXTURE_CTES}\n", + expected_contents=f"{_FORMATTED_FIXTURE_CTES}\n", + ), + FixtureCeremonyFormatTestCase( + description="semicolon and trailing comment after the last cte are kept", + relative_path="tests/unit/test_orders.sql", + contents=f"{_FIXTURE_CTES}; -- done\n", + expected_contents=f"{_FORMATTED_FIXTURE_CTES}; -- done\n", + ), + FixtureCeremonyFormatTestCase( + description="explicit header and trailing select are kept", + relative_path="tests/unit/test_orders.sql", + contents=f"TEST();\n{_FIXTURE_CTES}\nselect 1\n", + expected_contents=f"TEST();\n{_FORMATTED_FIXTURE_CTES}\n\nSELECT 1\n", + ), + FixtureCeremonyFormatTestCase( + description="scenario without a trailing select stays without one", + relative_path="tests/scenarios/orders.sql", + contents=( + 'SCENARIO (description "Orders");\n' + + _FIXTURE_CTES.replace("__source__", "__ref__") + + "\n" + ), + expected_contents=( + 'SCENARIO (description "Orders");\n' + + _FORMATTED_FIXTURE_CTES.replace("__source__", "__ref__") + + "\n" + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_fixture_ceremony_variants_when_formatting_and_linting_then_forms_are_kept( + test_case: FixtureCeremonyFormatTestCase, tmp_path: Path +) -> None: + _ = (tmp_path / "sqlbuild_project.toml").write_text(PROJECT_TOML, encoding="utf-8") + target: Path = tmp_path / test_case.relative_path + target.parent.mkdir(parents=True) + _ = target.write_text(test_case.contents, encoding="utf-8") + + formatted: LintRunResult = run_format(project_dir=tmp_path, config=LintConfig(dialect="duckdb")) + linted: LintRunResult = run_lint(project_dir=tmp_path, config=LintConfig(dialect="duckdb")) + + assert formatted.violations == () + assert target.read_text(encoding="utf-8") == test_case.expected_contents + assert linted.violations == () + + @pytest.mark.parametrize( "test_case", [LintBehaviorTestCase(description="reasoned matching local suppression", expected_value=())], diff --git a/website/examples/waffle-shop/tests/scenarios/daily_revenue_minimal.sql b/website/examples/waffle-shop/tests/scenarios/daily_revenue_minimal.sql index e9bdee45b9..4207bd65b7 100644 --- a/website/examples/waffle-shop/tests/scenarios/daily_revenue_minimal.sql +++ b/website/examples/waffle-shop/tests/scenarios/daily_revenue_minimal.sql @@ -55,5 +55,3 @@ __assert__no_negative_revenue AS ( FROM __ref("daily_revenue") WHERE total_revenue_cents < 0 ) - -SELECT 1 diff --git a/website/examples/waffle-shop/tests/unit/test_fact_orders.sql b/website/examples/waffle-shop/tests/unit/test_fact_orders.sql index 864b91fa4e..ff2bfd0ddd 100644 --- a/website/examples/waffle-shop/tests/unit/test_fact_orders.sql +++ b/website/examples/waffle-shop/tests/unit/test_fact_orders.sql @@ -1,5 +1,3 @@ -TEST(); - WITH __ref__stg_orders AS ( SELECT @@ -47,4 +45,3 @@ __assert__line_totals_are_non_negative AS ( FROM __ref("fact_orders") WHERE line_total_cents < 0 ) -SELECT 1 diff --git a/website/examples/waffle-shop/tests/unit/test_stg_orders.sql b/website/examples/waffle-shop/tests/unit/test_stg_orders.sql index 8e6dd20e97..127563fcb1 100644 --- a/website/examples/waffle-shop/tests/unit/test_stg_orders.sql +++ b/website/examples/waffle-shop/tests/unit/test_stg_orders.sql @@ -1,5 +1,3 @@ -TEST(); - WITH __source__raw__orders AS ( SELECT @@ -24,4 +22,3 @@ __assert__order_ids_are_not_null AS ( FROM __ref("stg_orders") WHERE order_id IS NULL ) -SELECT 1 diff --git a/website/src/content/docs/docs/concepts/declaration-scopes/visibility.mdx b/website/src/content/docs/docs/concepts/declaration-scopes/visibility.mdx index 12eff374db..62cf7a140c 100644 --- a/website/src/content/docs/docs/concepts/declaration-scopes/visibility.mdx +++ b/website/src/content/docs/docs/concepts/declaration-scopes/visibility.mdx @@ -100,8 +100,6 @@ A test first sees declarations available from its own folder tree. It may also u macros, enums, and constants available to a model for which it defines expected output. ```sql -TEST(); - WITH __expected__orders AS ( SELECT @@ -109,7 +107,6 @@ __expected__orders AS ( @normalize_order_status("'completed'") AS normalized_status, @enum("order_status").COMPLETED AS status ) -SELECT 1 ``` Because the test defines `__expected__orders`, the test may use file-based macros, enums, and diff --git a/website/src/content/docs/docs/concepts/macros.mdx b/website/src/content/docs/docs/concepts/macros.mdx index 48fc2d971f..37f8683e46 100644 --- a/website/src/content/docs/docs/concepts/macros.mdx +++ b/website/src/content/docs/docs/concepts/macros.mdx @@ -168,8 +168,6 @@ def mock_orders(count: int = 1) -> str: ``` ```sql -TEST(); - WITH __source__raw__orders AS ( @mock_orders(3) @@ -181,7 +179,6 @@ __expected__stg_orders AS ( UNION ALL SELECT 3 AS order_id, 300 AS customer_id, 'completed' AS status ) -SELECT 1 ``` ## Use macros in hooks diff --git a/website/src/content/docs/docs/concepts/scenarios.mdx b/website/src/content/docs/docs/concepts/scenarios.mdx index 5f59d0e742..7c97a6756d 100644 --- a/website/src/content/docs/docs/concepts/scenarios.mdx +++ b/website/src/content/docs/docs/concepts/scenarios.mdx @@ -78,8 +78,6 @@ __assert__all_orders_have_payments AS ( FROM __ref("fact_orders") WHERE payment_amount_cents IS NULL ) - -SELECT 1 ``` ### CTE conventions @@ -99,6 +97,7 @@ the complete fixture body. SQLBuild expands it to the relation's declared, typed false filter, as in [unit tests](/docs/concepts/testing). Every scenario must have at least one fixture CTE and at least one `__expected__` or `__assert__` CTE. +The file ends after its last CTE; a trailing ceremonial `SELECT 1` is accepted but not required. Scenario SQL uses macros, enums, and constants available from the scenario file's directory under `tests/scenarios/`. Public enums and constants available to a model are also available when the diff --git a/website/src/content/docs/docs/concepts/testing.mdx b/website/src/content/docs/docs/concepts/testing.mdx index a5eb68167a..94764a95d5 100644 --- a/website/src/content/docs/docs/concepts/testing.mdx +++ b/website/src/content/docs/docs/concepts/testing.mdx @@ -13,8 +13,6 @@ A test file defines mock inputs and expected outputs using CTEs. SQLBuild substi ```sql -- tests/unit/test_stg_orders.sql -TEST(); - WITH __source__raw__orders AS ( SELECT @@ -34,7 +32,6 @@ __expected__stg_orders AS ( '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status ) -SELECT 1 ``` The test: @@ -60,7 +57,28 @@ that read them. Statically provable collection-versus-scalar type conflicts iden column and suggest explicit `CAST`, `ARRAY_CONSTRUCT`, or `PARSE_JSON` expressions. SQLBuild never invents missing values. -The trailing `SELECT 1` is required as a ceremonial closing statement. +A test file needs no header and no closing statement: a file that holds one test can be just its +CTEs. Add a `TEST (...)` header to name the test, set a cursor window, declare parameters, or turn +off SQL analysis, and on every block of a file that holds several tests. The `TEST();` header and +the trailing `SELECT 1` that earlier releases required are still accepted, so this file is +equivalent to the first example: + +```sql +TEST(); + +WITH +__source__raw__orders AS ( + SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, + '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status +), +__expected__stg_orders AS ( + SELECT 1 AS order_id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, + '2026-04-01 10:00:00' AS ordered_at, 'completed' AS status +) +SELECT 1 +``` + +Nothing other than `SELECT 1` may follow the last CTE. ## CTE conventions @@ -97,8 +115,6 @@ expected rows can therefore live in one helper that both an `__expected__` CTE a read: ```sql -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 3 AS quantity UNION ALL SELECT 2 AS id, 5 AS quantity @@ -114,7 +130,6 @@ __assert__no_unexpected_order_ids AS ( EXCEPT SELECT order_id FROM expected_orders ) -SELECT 1 ``` SQLBuild emits each helper and mock a query needs once, in dependency order, including mocks that @@ -127,8 +142,6 @@ Tests can span multiple models in a single file. Mock your sources, define an ex ```sql -- Mock two sources, assert on the final mart. -- stg_orders and stg_payments resolve automatically from their real SQL. -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, @@ -142,7 +155,6 @@ __expected__fact_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 1500 AS payment_amount_cents, 'credit_card' AS payment_method ) -SELECT 1 ``` SQLBuild topologically sorts the expected models, resolves each intermediate model's real SQL with mocks substituted, and chains the outputs forward. Every model between the mocked sources and the expected model is computed automatically. @@ -163,8 +175,6 @@ dependency errors. You can mock models directly with `__ref__` and seeds with `__seed__`, not just sources. This skips the model's real SQL (or the seed's real CSV data) and provides controlled data instead: ```sql -TEST(); - WITH __ref__stg_orders AS ( SELECT @@ -207,7 +217,6 @@ __expected__fact_orders AS ( 'success' AS payment_status, 2850 AS payment_amount_cents ) -SELECT 1 ``` You can mix `__source__`, `__ref__`, and `__seed__` mocks in the same test. As long as every leaf dependency is satisfied (either by a source mock, a ref mock, a seed mock, or by being in the expected chain), the test resolves. @@ -236,8 +245,6 @@ expressions, with the fixture relation. The mocked function is not deployed as a ```sql -- models/customer_order_totals.sql reads: -- __table_fn("customer_orders")(42) -TEST(); - WITH fixture_orders AS ( SELECT 101 AS order_id, 42 AS customer_id, 2500 AS amount_cents @@ -248,7 +255,6 @@ __table_fn__customer_orders AS ( __expected__customer_order_totals AS ( SELECT 42 AS customer_id, 2500 AS total_cents ) -SELECT 1 ``` The fixture name must match a declared table function. Helper CTEs are available inside the fixture, @@ -264,8 +270,6 @@ compares the real function. A single test can assert on multiple models. SQLBuild resolves and compares each one independently: ```sql -TEST(); - WITH __source__raw__orders AS ( SELECT 1 AS id, 100 AS customer_id, 2 AS waffle_type_id, 3 AS quantity, @@ -286,7 +290,6 @@ __expected__fact_orders AS ( __expected__dim_customers AS ( SELECT 100 AS customer_id, 1 AS total_orders, 1500 AS lifetime_spend_cents ) -SELECT 1 ``` If the expected models form a chain (e.g. `stg_orders` feeds into `fact_orders` which feeds into `dim_customers`), SQLBuild resolves them in dependency order, using the output of earlier steps as input to later ones. @@ -302,8 +305,6 @@ Only explicit expected CTEs create grants. A matching test filename, `__ref__` m Because unit tests are written in SQL, they support macro calls. This lets you write reusable mock generators instead of copy-pasting mock data across test files: ```sql -TEST(); - WITH __source__raw__orders AS ( @mock_orders() @@ -315,7 +316,6 @@ __expected__fact_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 1500 AS total_cents, 'credit_card' AS payment_method ) -SELECT 1 ``` The `@mock_orders()` call expands at compile time to whatever SQL the Python macro function returns. @@ -336,8 +336,6 @@ with `D013`. When a model uses macros that you want to control in tests (e.g. target-specific logic, dynamic SQL generation), you can override their output with `__macro__` CTEs: ```sql -TEST(); - WITH __macro__country_filter AS ( SELECT 'country_code = ''US''' @@ -348,7 +346,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'completed' AS status ) -SELECT 1 ``` When a `__macro__` mock is defined, every call to `@country_filter(...)` in any model SQL resolved by the test is replaced with the mock value (`country_code = 'US'`). The macro's actual Python function is not called, and the arguments are ignored. @@ -365,8 +362,6 @@ This is useful for: Unit tests can include `__assert__` CTEs for property-based checks. An assertion passes if the query returns zero rows - any returned rows are failing examples. ```sql -TEST(); - WITH __ref__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 3 AS quantity, @@ -375,7 +370,6 @@ __ref__stg_orders AS ( __assert__order_ids_are_not_null AS ( SELECT * FROM __ref("stg_orders") WHERE order_id IS NULL ) -SELECT 1 ``` Assertions can be mixed with `__expected__` CTEs in the same test, or used on their own. They are useful when the natural check is "no rows should violate this rule" rather than "the output should exactly equal these rows" - for example, duplicate checks, negative-value constraints, or conditional business rules. @@ -424,7 +418,6 @@ __source__raw__orders AS ( __expected__order_summary AS ( SELECT 0 AS order_count, 0 AS total_amount ) -SELECT 1 ``` Assertions that filter for invalid rows, compare against expected rows, aggregate, or limit their @@ -433,7 +426,11 @@ project file; see [Rule options](/docs/concepts/rules/configuration-and-selectio ## Test modes -By default, `TEST()` runs in model mode - mocking sources/refs and comparing model outputs. Three additional modes let you test reusable logic directly without needing a model chain. +Model tests mock sources and refs and compare model outputs. Three more modes test reusable logic +directly, without a model chain. SQLBuild infers the mode from the test's CTEs: a test that defines +`__macro_actual__` is a macro test, `__udf_actual__` a UDF test, `__table_fn_actual__` a table +function test, and any other test a model test. `TEST (mode ...)` states the mode explicitly; an +explicit mode that contradicts the CTEs is a compile error. ### Macro tests @@ -453,7 +450,6 @@ __macro_actual__ AS ( __macro_expected__ AS ( SELECT 2850 AS line_total_cents ) -SELECT 1 ``` Macros are compile-time code, so macro tests expand the macro at compile time and compare the results. During `sqb build`, macro tests run before any model that uses the tested macro. @@ -463,7 +459,7 @@ Macros are compile-time code, so macro tests expand the macro at compile time an Test scalar UDFs by calling them in `__udf_actual__` and comparing against `__udf_expected__`: ```sql -TEST (mode udf, name "detects_completed_orders"); +TEST (name "detects_completed_orders"); WITH input_values AS ( @@ -482,7 +478,6 @@ __udf_expected__ AS ( UNION ALL SELECT 'pending' AS order_status, FALSE AS is_completed_order ) -SELECT 1 ``` UDFs are warehouse objects, so the function is created before the test runs. During `sqb build`, UDF tests run after the function is created but before any model that uses it. @@ -492,7 +487,7 @@ UDFs are warehouse objects, so the function is created before the test runs. Dur Test table functions by calling them in `__table_fn_actual__` and comparing against `__table_fn_expected__`: ```sql -TEST (mode table_fn, name "returns_customer_orders"); +TEST (name "returns_customer_orders"); WITH __table_fn_actual__ AS ( @@ -504,7 +499,6 @@ __table_fn_expected__ AS ( UNION ALL SELECT 2 AS order_id, 'completed' AS order_status, TRUE AS is_completed_order ) -SELECT 1 ``` Table function tests run after the function is created. Since table functions are terminal (models cannot depend on them), these tests validate the function independently. @@ -515,20 +509,21 @@ Each mode has strict CTE validation: | Mode | Actual CTE | Expected CTE | Allowed | |------|-----------|--------------|---------| -| `model` (default) | existing model-chain syntax | `__expected__` | `__source__`, `__ref__`, `__seed__`, `__assert__`, `__macro__` | +| `model` (no actual CTE) | existing model-chain syntax | `__expected__` | `__source__`, `__ref__`, `__seed__`, `__assert__`, `__macro__` | | `macro` | `__macro_actual__` | `__macro_expected__` | Helper CTEs, `@macro()` calls in actual | | `udf` | `__udf_actual__` | `__udf_expected__` | Helper CTEs, `__udf()` calls in actual | | `table_fn` | `__table_fn_actual__` | `__table_fn_expected__` | Helper CTEs, `__table_fn()` calls in actual | -CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a model test will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. +CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a test declared `TEST (mode model)` will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. ## Multiple tests per file A single test file can contain multiple `TEST()` blocks. A test's name is its `name`, or the file -stem when `name` is omitted. Test names are globally unique: a test cannot share a name with another -test, a scenario, a model, a source, a seed, a function, or a Python node. Name the file or the block -after the behavior it checks, such as `orders_status_rules`, rather than after the model. The cases -of one parameterized test share its name. Each block in a file must have its own `name`: +stem when `name` or the whole header is omitted. Test names are globally unique: a test cannot share +a name with another test, a scenario, a model, a source, a seed, a function, or a Python node. Name +the file or the block after the behavior it checks, such as `orders_status_rules`, rather than after +the model. The cases of one parameterized test share its name. Every block in a multi-test file needs +its own `TEST (...)` header with a unique `name`: ```sql TEST (name "completed_orders_only"); @@ -540,7 +535,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'completed' AS status ) -SELECT 1 TEST (name "cancelled_orders_excluded"); @@ -551,10 +545,10 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS order_id, 100 AS customer_id, 'cancelled' AS status ) -SELECT 1 ``` -A file with a single test can omit the `name` field. Files with multiple tests require names on every block. +A file with a single test can omit the `name` field, or the whole header. Files with multiple tests +require a header with a name on every block. ## Repeating test logic @@ -591,7 +585,6 @@ __source__raw__orders AS ( __expected__stg_orders AS ( SELECT 1 AS id, @param("expected_status") AS status ) -SELECT 1 ``` The cases report independently as `order status: maps source states [completed]`, `[cancelled]`, @@ -659,7 +652,6 @@ WITH cases(case_name, source_status, expected_status) AS ( __expected__status_mapping AS ( SELECT case_name, expected_status FROM cases ) -SELECT 1 ``` This produces one aggregate test result. Use native cases when each row must pass or fail From c7c3164be8caeb3d83e4940e9cc5bb1759ad1506 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:17:52 +0100 Subject: [PATCH 2/4] fix: keep omitted SELECT 1 detection off the per-character scan path Bodies that already end with a ceremonial SELECT 1 now return after a constant-time tail check, and bodies that omit it are scanned between quote, comment and parenthesis boundaries instead of character by character. The duplicate CTE-scan cache is removed. A megabyte-body guard bounds both paths. --- .../compile/_helpers/sql_tests/core.py | 1 - .../compiler/discovery/_helpers/sql/tests.py | 77 +++++++++++++++---- .../discovery/_helpers/_test_types.py | 9 +++ .../discovery/_helpers/test_sql_tests.py | 52 +++++++++++++ 4 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py index 22eb45c5ac..df50e613eb 100644 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py @@ -263,7 +263,6 @@ def complete_omitted_ceremonial_select(*, sql: str, syntax: SqlLexicalSyntax) -> return f"{sql[:offset]}{OMITTED_CEREMONIAL_SELECT_SQL}{sql[offset:]}" -@lru_cache(maxsize=4096) def _scan_top_level_ctes( *, sql: str, diff --git a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py index 060e34adc6..bd97f583cc 100644 --- a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py +++ b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py @@ -4,6 +4,7 @@ import re from datetime import date, datetime +from functools import lru_cache from inspect import cleandoc from pathlib import Path from typing import cast @@ -32,6 +33,7 @@ DiscoveredSqlTestCase, SqlTestParameterDeclaration, ) +from sqlbuild.compiler.sql_analysis.constants import SQL_TEXT_START_CHARACTERS from sqlbuild.compiler.sql_analysis.main._is_identifier_character import ( is_identifier_character, ) @@ -72,7 +74,10 @@ _SQL_TEST_CONTEXT: str = "SQL test" _OPEN_PAREN: str = "(" _CLOSE_PAREN: str = ")" -_STATEMENT_TERMINATOR: str = ";" +_TRAILING_TERMINATOR_CHARACTERS: str = " \t\r\n\f\v;" +_LINE_FEED: str = "\n" +_BLOCK_COMMENT_OPEN: str = "/*" +_BLOCK_COMMENT_CLOSE: str = "*/" _TEST_NAME_HEADER_KEY: str = "name" _TEST_MODE_HEADER_KEY: str = "mode" _TEST_PARAMETERS_HEADER_KEY: str = "parameters" @@ -141,40 +146,80 @@ def parse_sql_test_file(*, contents: str, file_path: Path) -> tuple[DiscoveredSq def omitted_ceremonial_select_offset_impl(*, sql: str, syntax: SqlLexicalSyntax) -> int | None: """Return the offset after a body's last top-level `)` when only a terminator follows it.""" + tail: str = sql.rstrip(_TRAILING_TERMINATOR_CHARACTERS) + if not tail.endswith(_CLOSE_PAREN) and not _may_end_with_comment(tail=tail, syntax=syntax): + return None first_code: int | None = None last_code: int | None = None - last_code_depth: int = 0 + last_close_paren: int | None = None depth: int = 0 + segment_start: int = 0 index: int = 0 + pattern: re.Pattern[str] = _code_boundary_pattern(syntax) try: - while index < len(sql): + while (match := pattern.search(sql, index)) is not None: + boundary: int = match.start() non_code_end: int | None = dialect_non_code_end( - sql=sql, start=index, syntax=syntax, context=_SQL_TEST_CONTEXT + sql=sql, start=boundary, syntax=syntax, context=_SQL_TEST_CONTEXT ) - if non_code_end is not None: - index = non_code_end + if non_code_end is None: + if sql[boundary] == _OPEN_PAREN: + depth += 1 + elif sql[boundary] == _CLOSE_PAREN: + depth -= 1 + last_close_paren = boundary if depth == 0 else None + index = boundary + 1 continue - character: str = sql[index] - if not character.isspace() and character != _STATEMENT_TERMINATOR: - first_code = index if first_code is None else first_code - depth += {_OPEN_PAREN: 1, _CLOSE_PAREN: -1}.get(character, 0) - last_code = index - last_code_depth = depth - index += 1 + first_code, last_code = _code_segment_bounds( + sql=sql, start=segment_start, end=boundary, first=first_code, last=last_code + ) + segment_start = index = non_code_end except CompileInputError: return None - if first_code is None or last_code is None: + first_code, last_code = _code_segment_bounds( + sql=sql, start=segment_start, end=len(sql), first=first_code, last=last_code + ) + if first_code is None or last_code is None or last_code != last_close_paren: return None keyword_end: int = first_code + len(SQL_WITH_KEYWORD) if sql[first_code:keyword_end].upper() != SQL_WITH_KEYWORD or ( keyword_end < len(sql) and is_identifier_character(sql[keyword_end]) ): return None - if sql[last_code] != _CLOSE_PAREN or last_code_depth != 0: - return None return last_code + 1 +def _may_end_with_comment(*, tail: str, syntax: SqlLexicalSyntax) -> bool: + last_line: str = tail.rsplit(_LINE_FEED, 1)[-1] + return tail.endswith(_BLOCK_COMMENT_CLOSE) or any( + prefix in last_line for prefix in syntax.line_comment_prefixes + ) + + +@lru_cache(maxsize=16) +def _code_boundary_pattern(syntax: SqlLexicalSyntax) -> re.Pattern[str]: + starts: tuple[str, ...] = ( + _OPEN_PAREN, + _CLOSE_PAREN, + _BLOCK_COMMENT_OPEN, + *SQL_TEXT_START_CHARACTERS, + *syntax.line_comment_prefixes, + ) + ordered: list[str] = sorted(starts, key=lambda start: len(start), reverse=True) + return re.compile("|".join(re.escape(start) for start in ordered)) + + +def _code_segment_bounds( + *, sql: str, start: int, end: int, first: int | None, last: int | None +) -> tuple[int | None, int | None]: + segment: str = sql[start:end] + stripped: str = segment.rstrip(_TRAILING_TERMINATOR_CHARACTERS) + if not stripped.strip(_TRAILING_TERMINATOR_CHARACTERS): + return first, last + leading: int = len(segment) - len(segment.lstrip(_TRAILING_TERMINATOR_CHARACTERS)) + return (start + leading if first is None else first), start + len(stripped) - 1 + + def _parse_headerless_sql_test_file(*, file_path: Path, contents: str) -> DiscoveredSqlTestBlock: if _TEST_HEADER_START_PATTERN.match(contents) is not None: raise SqlTestParseError( diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py index 53b4620539..249a562b0a 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/_test_types.py @@ -594,6 +594,15 @@ class ParseSqlTestModeTestCase: expected_header_values: tuple[dict[str, object], ...] +@dataclass(frozen=True) +class OmittedSelectPerformanceTestCase: + description: str + trailing_sql: str + fixture_rows: int + expected_offset_found: bool + expected_max_seconds: float + + @dataclass(frozen=True) class ParseSqlTestCursorWindowTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py index ba278a7ae5..11481b30cb 100644 --- a/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py +++ b/tests/unit/src/sqlbuild/compiler/discovery/_helpers/test_sql_tests.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from decimal import Decimal from pathlib import Path from unittest.mock import Mock @@ -14,6 +15,9 @@ prepare_sql_test_file_headers, ) from sqlbuild.compiler.discovery.exceptions import SqlTestParseError +from sqlbuild.compiler.discovery.main.omitted_ceremonial_select import ( + omitted_ceremonial_select_offset, +) from sqlbuild.compiler.discovery.models import ( DiscoveredSqlTestBlock, DiscoveredSqlTestCase, @@ -21,8 +25,10 @@ DiscoveryFileFault, SqlTestParameterDeclaration, ) +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from tests.unit.src.sqlbuild.compiler.discovery._helpers._test_types import ( ExpectedCountTestCase, + OmittedSelectPerformanceTestCase, ParseSqlTestCursorWindowTestCase, ParseSqlTestFileErrorTestCase, ParseSqlTestFileTestCase, @@ -34,6 +40,52 @@ discovered_test_parameters, ) +_FIXTURE_ROW: str = ( + " SELECT 1 AS order_id, 'it''s (open)' AS note, \"Qty\" AS qty -- row (comment)\n UNION ALL\n" +) + + +@pytest.mark.parametrize( + "test_case", + ( + OmittedSelectPerformanceTestCase( + description="authored select one takes the constant-time path", + trailing_sql="SELECT 1\n", + fixture_rows=12_000, + expected_offset_found=False, + expected_max_seconds=0.05, + ), + OmittedSelectPerformanceTestCase( + description="omitted select one scans a megabyte body within budget", + trailing_sql="", + fixture_rows=12_000, + expected_offset_found=True, + expected_max_seconds=1.0, + ), + ), + ids=lambda case: case.description, +) +def test_given_large_fixture_body_when_locating_omitted_select_then_completes_within_budget( + test_case: OmittedSelectPerformanceTestCase, +) -> None: + sql: str = ( + "WITH __source__orders AS (\n" + + _FIXTURE_ROW * test_case.fixture_rows + + " SELECT 2 AS order_id, 'x' AS note, 3 AS qty\n),\n" + + "__expected__orders AS (SELECT 1 AS order_id)\n" + + test_case.trailing_sql + ) + timings: list[float] = [] + offset: int | None = None + + for _ in range(3): + started: float = time.perf_counter() + offset = omitted_ceremonial_select_offset(sql=sql, syntax=SqlLexicalSyntax()) + timings.append(time.perf_counter() - started) + + assert (offset is not None) == test_case.expected_offset_found + assert min(timings) < test_case.expected_max_seconds + @pytest.mark.parametrize( "test_case", From d62195bd3c62ce3049ce4de4939326f63375dfa9 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:48:12 +0100 Subject: [PATCH 3/4] test: cover the optional trailing SELECT 1 in native SQL test extraction --- crates/sqlbuild-analysis/src/compiler/tests/helpers.rs | 10 ++++++++++ .../src/compiler/tests/test_sql_test_extraction.rs | 7 ++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs b/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs index 1fa58bdcb9..ace252923f 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs @@ -24,6 +24,16 @@ pub(crate) fn mixed_expanded_tests_preserve_order_and_payloads() -> bool { true } +pub(crate) fn trailing_ceremonial_select_is_optional() -> bool { + let response = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id); -- done","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("omitted select succeeds"); + let payload: serde_json::Value = serde_json::from_str(&response).expect("valid JSON"); + assert_eq!(payload[0]["expectedModels"][0], "orders"); + + let error = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id) SELECT * FROM __expected__orders","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("other final statement is rejected"); + assert!(error.contains("must end after its CTEs")); + true +} + pub(crate) fn dependent_assertion_returns_authoritative_error() -> bool { let error = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id), __assert__same AS (SELECT id FROM __expected__orders) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("dependency is rejected"); assert!(error.contains("'__assert__same' must not depend on '__expected__orders'")); diff --git a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs index c96e10fa00..825e9bdf5a 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs @@ -7,7 +7,7 @@ use crate::compiler::tests::helpers::{ expected_projection_errors_name_the_expected_cte, mixed_expanded_tests_preserve_order_and_payloads, quoted_ctes_and_implicit_alias_preserve_payload, - set_operation_expected_ctes_validate_every_branch, + set_operation_expected_ctes_validate_every_branch, trailing_ceremonial_select_is_optional, }; use crate::compiler::tests::test_types::{SqlTestExtractionTestCase, TopLevelScanTestCase}; @@ -39,6 +39,11 @@ fn given_sql_test_cases_when_extracting_native_payloads_then_expected_behavior_h run: expected_projection_errors_name_the_expected_cte, expected_success: true, }, + SqlTestExtractionTestCase { + description: "trailing ceremonial SELECT 1 is optional", + run: trailing_ceremonial_select_is_optional, + expected_success: true, + }, SqlTestExtractionTestCase { description: "set-operation expected CTEs validate every branch", run: set_operation_expected_ctes_validate_every_branch, From 2dea3bd1e1a6e79b1a783effdad3141fc63b3a62 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:00:06 +0100 Subject: [PATCH 4/4] test: keep the cache-invalidation structure error invalid under optional SELECT 1 --- .../commands/main/compile/test_compile_cache_invalidation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_cache_invalidation.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_cache_invalidation.py index ddf3c7d886..38d5db9526 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_cache_invalidation.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_cache_invalidation.py @@ -115,7 +115,7 @@ CompileCacheInvalidationTestCase( description="unit_test_structure_error", edit=lambda root: replace_project_text( - root, "tests/unit/test_channel_orders.sql", "SELECT 1\n", "" + root, "tests/unit/test_channel_orders.sql", "SELECT 1\n", "SELECT 2\n" ), expected_failure=True, ),