Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ pub(crate) fn extract_batch_json(request_json: &str) -> Result<String, String> {
fn extract_ctes(sql: &str, file: &str) -> Result<Vec<Cte>, 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__<model> CTE before `SELECT 1`")
format!("SQL test '{file}' must declare mock CTEs and one __expected__<model> CTE in a top-level WITH clause")
})?;
index = skip_ignorable(sql, index)?;
if let Some(end) = consume_keyword(sql, index, "RECURSIVE") {
Expand Down Expand Up @@ -140,9 +140,9 @@ fn extract_ctes(sql: &str, file: &str) -> Result<Vec<Cte>, 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)
Expand Down Expand Up @@ -1045,7 +1045,11 @@ fn ceremonial_select_matches(sql: &str, start: usize) -> Result<bool, String> {
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<bool, String> {
let mut index = skip_ignorable(sql, start)?;
if byte_at(sql, index) == Some(b';') {
index = skip_ignorable(sql, index + 1)?;
}
Expand Down
10 changes: 10 additions & 0 deletions crates/sqlbuild-analysis/src/compiler/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/sqlbuild/.agents/skills/sqlbuild/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__<name>`, `__ref__<name>`, `__seed__<name>` CTEs, states expected output with
`__expected__<model>` (only the listed columns are compared, matched by name; unlisted columns are
ignored), adds zero-row checks with `__assert__<name>`, and ends with `SELECT 1`.
ignored), and adds zero-row checks with `__assert__<name>`. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,13 @@ 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
1 AS order_id,
@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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,6 @@ def mock_orders(count: int = 1) -> str:
```

```sql
TEST();

WITH
__source__raw__orders AS (
@mock_orders(3)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ __assert__all_orders_have_payments AS (
FROM __ref("fact_orders")
WHERE payment_amount_cents IS NULL
)

SELECT 1
```

### CTE conventions
Expand All @@ -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
Expand Down
Loading
Loading