|
| 1 | +"""E2E coverage: a warehouse error in a tested model's output fails the unit test.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import json |
| 6 | +import subprocess |
| 7 | +from pathlib import Path |
| 8 | +from typing import Any |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import ( |
| 13 | + OutputEvaluationErrorE2ETestCase, |
| 14 | +) |
| 15 | +from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import order_totals_test_files |
| 16 | +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( |
| 17 | + execute_duckdb, |
| 18 | + prepare_inline_project, |
| 19 | + run_sqb, |
| 20 | +) |
| 21 | + |
| 22 | +_CONVERSION_ERROR: str = "Numeric value 'cluster::r1' is not recognized" |
| 23 | +# Like a warehouse that prunes unread columns: the stored output computes every column and |
| 24 | +# fails on the fixture, while an inline comparison of the expected columns alone succeeds. |
| 25 | +_PRUNING_WAREHOUSE: str = ( |
| 26 | + "from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n" |
| 27 | + "class PruningDuckDbAdapter(DuckDbAdapter):\n" |
| 28 | + ' adapter_name = "pruning_duckdb"\n' |
| 29 | + " failing_outputs = set()\n\n" |
| 30 | + " def create_sql_test_output(self, *, connection, sql, name):\n" |
| 31 | + " self.failing_outputs.add(sql)\n" |
| 32 | + f' raise RuntimeError("{_CONVERSION_ERROR}")\n\n' |
| 33 | + " def execute(self, *, connection, sql):\n" |
| 34 | + " if sql in self.failing_outputs:\n" |
| 35 | + f' raise RuntimeError("{_CONVERSION_ERROR}")\n' |
| 36 | + " return super().execute(connection=connection, sql=sql)\n" |
| 37 | +) |
| 38 | +# A session that cannot keep test outputs at all, while every query runs. |
| 39 | +_NO_TEMPORARY_TABLES: str = ( |
| 40 | + "from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n" |
| 41 | + "class PruningDuckDbAdapter(DuckDbAdapter):\n" |
| 42 | + ' adapter_name = "pruning_duckdb"\n\n' |
| 43 | + " def create_sql_test_output(self, *, connection, sql, name):\n" |
| 44 | + ' raise PermissionError("temporary tables are disabled on this session")\n' |
| 45 | +) |
| 46 | +# A session that keeps one test output and refuses every later one. |
| 47 | +_ONE_TEMPORARY_TABLE: str = ( |
| 48 | + "from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n" |
| 49 | + "class PruningDuckDbAdapter(DuckDbAdapter):\n" |
| 50 | + ' adapter_name = "pruning_duckdb"\n' |
| 51 | + " stored = []\n\n" |
| 52 | + " def create_sql_test_output(self, *, connection, sql, name):\n" |
| 53 | + " self.stored.append(name)\n" |
| 54 | + " if len(self.stored) > 1:\n" |
| 55 | + ' raise PermissionError("no more temporary tables in this session")\n' |
| 56 | + " return super().create_sql_test_output(connection=connection, sql=sql, name=name)\n" |
| 57 | +) |
| 58 | + |
| 59 | + |
| 60 | +_PROJECT_FILES: dict[str, str] = { |
| 61 | + "sqlbuild_project.toml": ( |
| 62 | + 'name = "orders"\nadapter = "pruning_duckdb"\n\n' |
| 63 | + '[connection]\ndatabase = "orders.duckdb"\n\n' |
| 64 | + '[defaults]\nmaterialized = "table"\n' |
| 65 | + ), |
| 66 | + "sources/raw_orders.yml": ( |
| 67 | + "sources:\n" |
| 68 | + " - name: raw_orders\n description: Test source raw_orders.\n" |
| 69 | + " schema: main\n" |
| 70 | + " table: raw_orders\n" |
| 71 | + " columns:\n" |
| 72 | + " - name: order_id\n type: INTEGER\n" |
| 73 | + " - name: code\n type: VARCHAR\n" |
| 74 | + ), |
| 75 | + "models/orders.sql": ( |
| 76 | + "MODEL (description 'Test model orders.');\n\n" |
| 77 | + 'SELECT order_id, TRY_CAST(code AS INTEGER) AS code_number FROM __source("raw_orders")\n' |
| 78 | + ), |
| 79 | + "tests/unit/test_orders.sql": ( |
| 80 | + 'TEST (name "orders_ids");\n\nWITH\n' |
| 81 | + "__source__raw_orders AS (\n SELECT 1 AS order_id, 'cluster::r1' AS code\n),\n" |
| 82 | + "__expected__orders AS (\n SELECT 1 AS order_id\n)\nSELECT 1\n" |
| 83 | + ), |
| 84 | +} |
| 85 | + |
| 86 | + |
| 87 | +@pytest.mark.parametrize( |
| 88 | + "test_case", |
| 89 | + [ |
| 90 | + OutputEvaluationErrorE2ETestCase( |
| 91 | + description="an output the warehouse cannot compute errors the test", |
| 92 | + adapter_source=_PRUNING_WAREHOUSE, |
| 93 | + extra_files={}, |
| 94 | + expected_exit_code=1, |
| 95 | + expected_fragments=( |
| 96 | + "test 'orders_ids' encountered an execution error while running 'orders'", |
| 97 | + _CONVERSION_ERROR, |
| 98 | + "PASS=0 FAIL=1", |
| 99 | + ), |
| 100 | + expected_json_fragments=('"error"', _CONVERSION_ERROR), |
| 101 | + unexpected_fragments=("PASS=1 FAIL=0",), |
| 102 | + ), |
| 103 | + OutputEvaluationErrorE2ETestCase( |
| 104 | + description="a session that cannot store outputs still compares inline", |
| 105 | + adapter_source=_NO_TEMPORARY_TABLES, |
| 106 | + extra_files={}, |
| 107 | + expected_exit_code=0, |
| 108 | + expected_fragments=("PASS=1 FAIL=0",), |
| 109 | + expected_json_fragments=('"pass"',), |
| 110 | + unexpected_fragments=("encountered an execution error",), |
| 111 | + ), |
| 112 | + OutputEvaluationErrorE2ETestCase( |
| 113 | + description="a computable later output that cannot be stored compares inline", |
| 114 | + adapter_source=_ONE_TEMPORARY_TABLE, |
| 115 | + extra_files=order_totals_test_files("code_number + 1"), |
| 116 | + expected_exit_code=0, |
| 117 | + expected_fragments=("PASS=1 FAIL=0",), |
| 118 | + expected_json_fragments=('"pass"',), |
| 119 | + unexpected_fragments=("encountered an execution error", "does not exist"), |
| 120 | + ), |
| 121 | + OutputEvaluationErrorE2ETestCase( |
| 122 | + description="a later output that fails on its own reports the warehouse error", |
| 123 | + adapter_source=_ONE_TEMPORARY_TABLE, |
| 124 | + extra_files=order_totals_test_files("CAST('cluster::r1' AS INTEGER)"), |
| 125 | + expected_exit_code=1, |
| 126 | + expected_fragments=("Could not convert string 'cluster::r1'", "PASS=0 FAIL=1"), |
| 127 | + expected_json_fragments=('"error"', "Could not convert string"), |
| 128 | + unexpected_fragments=("does not exist",), |
| 129 | + ), |
| 130 | + ], |
| 131 | + ids=lambda case: case.description, |
| 132 | +) |
| 133 | +def test_given_output_store_failure_when_testing_then_only_storage_problems_fall_back( |
| 134 | + test_case: OutputEvaluationErrorE2ETestCase, tmp_path: Path |
| 135 | +) -> None: |
| 136 | + """An inline comparison of some columns must not hide an error computing the others.""" |
| 137 | + |
| 138 | + project_dir: Path = prepare_inline_project( |
| 139 | + tmp_path=tmp_path, |
| 140 | + project_name="orders", |
| 141 | + repo_files={ |
| 142 | + **_PROJECT_FILES, |
| 143 | + "adapters/pruning_duckdb.py": test_case.adapter_source, |
| 144 | + **test_case.extra_files, |
| 145 | + }, |
| 146 | + ) |
| 147 | + config_path: Path = project_dir / "sqlbuild_project.toml" |
| 148 | + _ = config_path.write_text( |
| 149 | + config_path.read_text(encoding="utf-8").replace( |
| 150 | + '"orders.duckdb"', f'"{(project_dir / "orders.duckdb").as_posix()}"' |
| 151 | + ), |
| 152 | + encoding="utf-8", |
| 153 | + ) |
| 154 | + execute_duckdb( |
| 155 | + db_path=project_dir / "orders.duckdb", |
| 156 | + sql="CREATE TABLE raw_orders AS SELECT 1 AS order_id, '1' AS code", |
| 157 | + ) |
| 158 | + |
| 159 | + tested: subprocess.CompletedProcess[str] = run_sqb( |
| 160 | + command=("--no-color", "test"), project_dir=project_dir |
| 161 | + ) |
| 162 | + as_json: subprocess.CompletedProcess[str] = run_sqb( |
| 163 | + command=("--no-color", "test", "--json"), project_dir=project_dir |
| 164 | + ) |
| 165 | + payload: dict[str, Any] = json.loads(as_json.stdout) |
| 166 | + |
| 167 | + output: str = tested.stdout + tested.stderr |
| 168 | + assert tested.returncode == test_case.expected_exit_code, output |
| 169 | + assert as_json.returncode == test_case.expected_exit_code, as_json.stdout |
| 170 | + assert all(fragment in output for fragment in test_case.expected_fragments), output |
| 171 | + assert not any(fragment in output for fragment in test_case.unexpected_fragments), output |
| 172 | + assert all(fragment in json.dumps(payload) for fragment in test_case.expected_json_fragments), ( |
| 173 | + as_json.stdout |
| 174 | + ) |
0 commit comments