Compare commits

...

30 Commits

Author SHA1 Message Date
Doug Beatty
0baf090c3e Add store_failures_as config for generic tests 2023-09-30 16:17:13 -06:00
Mike Alfare
c21459573e Merge branch 'main' into feature/materialized-tests/adap-850 2023-09-28 23:28:15 -04:00
Mike Alfare
96d18a985f update expected test config dicts to include the new default value for store_failures_as 2023-09-28 22:17:22 -04:00
Mike Alfare
2f1aa91f9d remove duplicated default for store_failures_as 2023-09-28 21:36:57 -04:00
Mike Alfare
cf8656561f update default for store_test_failures_as; rename postgres test to reflect store failures name 2023-09-28 21:35:26 -04:00
Mike Alfare
eb1f3b4727 update local variable to be more intuitive 2023-09-28 20:56:57 -04:00
Mike Alfare
08337746a3 revert test debugging artifacts 2023-09-28 20:53:34 -04:00
Mike Alfare
4b81e22e7a revert unexpected formatting changes from black 2023-09-28 20:51:52 -04:00
Mike Alfare
3ddc7d4e7b updated changelog entry to reflect correct parameters 2023-09-28 20:49:55 -04:00
Mike Alfare
9971be7acf updated changelog entry to reflect correct parameters 2023-09-28 20:49:07 -04:00
Mike Alfare
5aa2031838 rename strategy parameter to store_failures_as; allow store_failures to drive whether failures are stored, defaulted as a table; allow override to view 2023-09-28 20:48:01 -04:00
Mike Alfare
beb0ed43a6 add audit schema suffix as class attribute 2023-09-21 21:42:49 -04:00
Mike Alfare
61d514b0d7 shorten inserted record values to avoid data type issues in the model table 2023-09-21 17:29:53 -04:00
Mike Alfare
069a9b2623 shorten inserted record values to avoid data type issues in the model table 2023-09-21 17:29:29 -04:00
Mike Alfare
49b797e3a4 separate setup and teardown methods, move postgres piece back into dbt-postgres 2023-09-21 17:19:36 -04:00
Mike Alfare
3fa08583c7 move configuration into base test class 2023-09-21 17:06:44 -04:00
Mike Alfare
661c5f6a2e use built-in utility to test relation types, reduce configuration to just row counts 2023-09-21 16:44:41 -04:00
Mike Alfare
40b646b7de break up tests into reusable tests and adapter specific configuration, update test to check for relation type and confirm views update 2023-09-16 15:28:45 -04:00
Mike Alfare
9a0656f08c updated test expected values for new config option 2023-09-16 00:05:28 -04:00
Mike Alfare
f76fc74eec removed unnecessary formatting changes 2023-09-15 23:36:53 -04:00
Mike Alfare
ccf199ccc9 removed unnecessary formatting changes 2023-09-15 23:35:29 -04:00
Mike Alfare
0313904bc3 removed unnecessary formatting changes 2023-09-15 23:22:15 -04:00
Mike Alfare
a149d66910 removed unnecessary formatting changes 2023-09-15 23:16:37 -04:00
Mike Alfare
7b4be33a1b removed unnecessary formatting changes 2023-09-15 23:15:59 -04:00
Mike Alfare
1a3bd81edc removed unnecessary formatting changes 2023-09-15 23:14:27 -04:00
Mike Alfare
8e366bbc72 removed unnecessary formatting changes 2023-09-15 23:13:02 -04:00
Mike Alfare
f5e0797149 removed unnecessary formatting changes 2023-09-15 23:09:34 -04:00
Mike Alfare
e999ed2551 create test results as views 2023-09-15 23:04:55 -04:00
Mike Alfare
d4dfec8b53 changie 2023-09-15 17:44:39 -04:00
Mike Alfare
718b8921a4 add strategy parameter to TestConfig, default to ephemeral, catch strategy parameter in test materialization 2023-09-15 17:43:03 -04:00
12 changed files with 242 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
kind: Features
body: Support storing test failures as views
time: 2023-09-15T17:44:28.833877-04:00
custom:
Author: mikealfare
Issue: "6914"

View File

@@ -216,7 +216,6 @@ T = TypeVar("T", bound="BaseConfig")
@dataclass
class BaseConfig(AdditionalPropertiesAllowed, Replaceable):
# enable syntax like: config['key']
def __getitem__(self, key):
return self.get(key)
@@ -555,6 +554,7 @@ class TestConfig(NodeAndTestConfig):
# Annotated is used by mashumaro for jsonschema generation
severity: Annotated[Severity, Pattern(SEVERITY_PATTERN)] = Severity("ERROR")
store_failures: Optional[bool] = None
store_failures_as: Optional[str] = "table"
where: Optional[str] = None
limit: Optional[int] = None
fail_calc: str = "count(*)"
@@ -572,6 +572,7 @@ class TestConfig(NodeAndTestConfig):
"warn_if",
"error_if",
"store_failures",
"store_failures_as",
]
seen = set()

View File

@@ -6,15 +6,17 @@
{% set identifier = model['alias'] %}
{% set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) %}
{% set store_failures_as = config.get('store_failures_as') %}
{% set target_relation = api.Relation.create(
identifier=identifier, schema=schema, database=database, type='table') -%} %}
identifier=identifier, schema=schema, database=database, type=store_failures_as) -%} %}
{% if old_relation %}
{% do adapter.drop_relation(old_relation) %}
{% endif %}
{% call statement(auto_begin=True) %}
{{ create_table_as(False, target_relation, sql) }}
{{ get_create_sql(target_relation, sql) }}
{% endcall %}
{% do relations.append(target_relation) %}

View File

@@ -6,7 +6,13 @@
{%- macro default__get_create_sql(relation, sql) -%}
{%- if relation.is_materialized_view -%}
{%- if relation.is_view -%}
{{ get_create_view_as_sql(relation, sql) }}
{%- elif relation.is_table -%}
{{ get_create_table_as_sql(False, relation, sql) }}
{%- elif relation.is_materialized_view -%}
{{ get_create_materialized_view_as_sql(relation, sql) }}
{%- else -%}

View File

@@ -101,6 +101,7 @@ class TestBuilder(Generic[Testable]):
"error_if",
"fail_calc",
"store_failures",
"store_failures_as",
"meta",
"database",
"schema",
@@ -242,6 +243,10 @@ class TestBuilder(Generic[Testable]):
def store_failures(self) -> Optional[bool]:
return self.config.get("store_failures")
@property
def store_failures_as(self) -> Optional[bool]:
return self.config.get("store_failures_as")
@property
def where(self) -> Optional[str]:
return self.config.get("where")
@@ -294,6 +299,8 @@ class TestBuilder(Generic[Testable]):
config["fail_calc"] = self.fail_calc
if self.store_failures is not None:
config["store_failures"] = self.store_failures
if self.store_failures_as is not None:
config["store_failures_as"] = self.store_failures_as
if self.meta is not None:
config["meta"] = self.meta
if self.database is not None:

View File

@@ -0,0 +1,44 @@
SEED__CHIPMUNKS = """
name,shirt
alvin,red
simon,blue
theodore,green
""".strip()
MODEL__CHIPMUNKS = """
{{ config(materialized='table') }}
select *
from {{ ref('chipmunks_stage') }}
"""
TEST__FAIL_AS_VIEW = """
{{ config(store_failures_as="view") }}
select *
from {{ ref('chipmunks') }}
where shirt = 'green'
"""
TEST__PASS_AS_VIEW = """
{{ config(store_failures_as="view") }}
select *
from {{ ref('chipmunks') }}
where shirt = 'grape'
"""
TEST__FAIL_AS_TABLE = """
{{ config(store_failures_as="table") }}
select *
from {{ ref('chipmunks') }}
where shirt = 'green'
"""
TEST__PASS_AS_TABLE = """
{{ config(store_failures_as="table") }}
select *
from {{ ref('chipmunks') }}
where shirt = 'purple'
"""

View File

@@ -0,0 +1,154 @@
from collections import namedtuple
from typing import Dict
import pytest
from dbt.contracts.results import TestStatus
from dbt.tests.util import run_dbt, check_relation_types
from dbt.tests.adapter.store_test_failures_tests._files import (
SEED__CHIPMUNKS,
MODEL__CHIPMUNKS,
TEST__FAIL_AS_VIEW,
TEST__PASS_AS_VIEW,
TEST__FAIL_AS_TABLE,
TEST__PASS_AS_TABLE,
)
class StoreTestFailures:
seed_table: str = "chipmunks_stage"
model_table: str = "chipmunks"
audit_schema_suffix: str = "dbt_test__audit"
audit_schema: str
@pytest.fixture(scope="class", autouse=True)
def setup_class(self, project):
# the seed doesn't get touched, load it once
run_dbt(["seed"])
yield
@pytest.fixture(scope="function", autouse=True)
def setup_method(self, project, setup_class):
# make sure the model is always right
run_dbt(["run"])
# the name of the audit schema doesn't change in a class, but this doesn't run at the class level
self.audit_schema = f"{project.test_schema}_{self.audit_schema_suffix}"
yield
@pytest.fixture(scope="function", autouse=True)
def teardown_method(self, project):
yield
# clear out the audit schema after each test case
with project.adapter.connection_named("__test"):
audit_schema = project.adapter.Relation.create(
database=project.database, schema=self.audit_schema
)
project.adapter.drop_schema(audit_schema)
@pytest.fixture(scope="class")
def seeds(self):
return {f"{self.seed_table}.csv": SEED__CHIPMUNKS}
@pytest.fixture(scope="class")
def models(self):
return {f"{self.model_table}.sql": MODEL__CHIPMUNKS}
@pytest.fixture(scope="class")
def tests(self):
return {
"fail_as_view.sql": TEST__FAIL_AS_VIEW,
"pass_as_view.sql": TEST__PASS_AS_VIEW,
"fail_as_table.sql": TEST__FAIL_AS_TABLE,
"pass_as_table.sql": TEST__PASS_AS_TABLE,
}
def row_count(self, project, relation_name: str) -> int:
"""
Return the row count for the relation.
Args:
project: the project fixture
relation_name: the name of the relation
Returns:
the row count as an integer
"""
sql = f"select count(*) from {self.audit_schema}.{relation_name}"
return project.run_sql(sql, fetch="one")[0]
def insert_record(self, project, record: Dict[str, str]):
field_names, field_values = [], []
for field_name, field_value in record.items():
field_names.append(field_name)
field_values.append(f"'{field_value}'")
field_name_clause = ", ".join(field_names)
field_value_clause = ", ".join(field_values)
sql = f"""
insert into {project.test_schema}.{self.model_table} ({field_name_clause})
values ({field_value_clause})
"""
project.run_sql(sql)
def delete_record(self, project, record: Dict[str, str]):
where_clause = " and ".join(
[f"{field_name} = '{field_value}'" for field_name, field_value in record.items()]
)
sql = f"""
delete from {project.test_schema}.{self.model_table}
where {where_clause}
"""
project.run_sql(sql)
def test_tests_run_successfully_and_are_stored_as_expected(self, project):
# set up the expected results
TestResult = namedtuple("TestResult", ["name", "status", "type", "row_count"])
expected_results = {
TestResult("pass_as_view", TestStatus.Pass, "view", 0),
TestResult("fail_as_view", TestStatus.Fail, "view", 1),
TestResult("pass_as_table", TestStatus.Pass, "table", 0),
TestResult("fail_as_table", TestStatus.Fail, "table", 1),
}
# run the tests once
results = run_dbt(["test", "--store-failures"], expect_pass=False)
# show that the statuses are what we expect
actual = {(result.node.name, result.status) for result in results}
expected = {(result.name, result.status) for result in expected_results}
assert actual == expected
# show that the results are persisted in the correct database objects
check_relation_types(
project.adapter, {result.name: result.type for result in expected_results}
)
# show that only the failed records show up
actual = {
(result.name, self.row_count(project, result.name)) for result in expected_results
}
expected = {(result.name, result.row_count) for result in expected_results}
assert actual == expected
# insert a new record in the model that fails the "pass" tests
# show that the view updates, but not the table
self.insert_record(project, {"name": "dave", "shirt": "grape"})
expected_results.remove(TestResult("pass_as_view", TestStatus.Pass, "view", 0))
expected_results.add(TestResult("pass_as_view", TestStatus.Pass, "view", 1))
# delete the original record from the model that failed the "fail" tests
# show that the view updates, but not the table
self.delete_record(project, {"name": "theodore", "shirt": "green"})
expected_results.remove(TestResult("fail_as_view", TestStatus.Fail, "view", 1))
expected_results.add(TestResult("fail_as_view", TestStatus.Fail, "view", 0))
# show that the views update without needing to run dbt, but the tables do not update
actual = {
(result.name, self.row_count(project, result.name)) for result in expected_results
}
expected = {(result.name, result.row_count) for result in expected_results}
assert actual == expected

View File

@@ -132,6 +132,7 @@ def get_rendered_tst_config(**updates):
"tags": [],
"severity": "ERROR",
"store_failures": None,
"store_failures_as": "table",
"warn_if": "!= 0",
"error_if": "!= 0",
"fail_calc": "count(*)",

View File

@@ -493,6 +493,7 @@ class TestList:
"materialized": "test",
"severity": "ERROR",
"store_failures": None,
"store_failures_as": "table",
"warn_if": "!= 0",
"error_if": "!= 0",
"fail_calc": "count(*)",
@@ -520,6 +521,7 @@ class TestList:
"materialized": "test",
"severity": "ERROR",
"store_failures": None,
"store_failures_as": "table",
"warn_if": "!= 0",
"error_if": "!= 0",
"fail_calc": "count(*)",
@@ -550,6 +552,7 @@ class TestList:
"materialized": "test",
"severity": "ERROR",
"store_failures": None,
"store_failures_as": "table",
"warn_if": "!= 0",
"error_if": "!= 0",
"fail_calc": "count(*)",

View File

@@ -0,0 +1,11 @@
import pytest
from dbt.tests.adapter.store_test_failures_tests.basic import StoreTestFailures
class TestStoreTestFailures(StoreTestFailures):
@pytest.fixture(scope="function", autouse=True)
def setup_audit_schema(self, project, setup_method):
# postgres only supports schema names of 63 characters
# a schema with a longer name still gets created, but the name gets truncated
self.audit_schema = self.audit_schema[:63]

View File

@@ -544,6 +544,7 @@ def basic_compiled_schema_test_dict():
"config": {
"enabled": True,
"materialized": "test",
"store_failures_as": "table",
"tags": [],
"severity": "warn",
"schema": "dbt_test__audit",

View File

@@ -1038,6 +1038,7 @@ def basic_parsed_schema_test_dict():
"config": {
"enabled": True,
"materialized": "test",
"store_failures_as": "table",
"tags": [],
"severity": "ERROR",
"warn_if": "!= 0",
@@ -1117,6 +1118,7 @@ def complex_parsed_schema_test_dict():
"config": {
"enabled": True,
"materialized": "table",
"store_failures_as": "table",
"tags": [],
"severity": "WARN",
"warn_if": "!= 0",