Skip to content

Commit 0ca6bbb

Browse files
feat: derive model/test owners from dbt group owner when no direct owner is set
Co-Authored-By: Yosef Arbiv <yosef.arbiv@gmail.com>
1 parent 231dbfd commit 0ca6bbb

3 files changed

Lines changed: 212 additions & 1 deletion

File tree

integration_tests/tests/test_dbt_artifacts/test_groups.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
Covers models, tests, seeds, and snapshots group assignment and artifact table correctness.
44
"""
55
import contextlib
6+
import json
67
import uuid
8+
from typing import List, Optional, Union
79

810
import pytest
911
from dbt_project import DbtProject
@@ -26,6 +28,23 @@
2628
}
2729

2830

31+
def _parse_owners(owners_value: Optional[Union[str, List[str]]]) -> List[str]:
32+
"""Parse an owner column value which may be a JSON string or a list."""
33+
if owners_value is None:
34+
return []
35+
if isinstance(owners_value, list):
36+
return owners_value
37+
if isinstance(owners_value, str):
38+
if not owners_value or owners_value == "[]":
39+
return []
40+
try:
41+
parsed = json.loads(owners_value)
42+
return parsed if isinstance(parsed, list) else [parsed]
43+
except json.JSONDecodeError:
44+
return [owners_value]
45+
return []
46+
47+
2948
@contextlib.contextmanager
3049
def _write_group_config(dbt_project: DbtProject, group_config: dict, name: str):
3150
"""Context manager to write a group config YAML file in the dbt project and clean up after."""
@@ -540,3 +559,171 @@ def test_snapshot_group_attribute(dbt_project: DbtProject, tmp_path):
540559
assert_group_name_in_run_results_view(
541560
dbt_project, "snapshot_run_results", snapshot_name, group_name
542561
)
562+
563+
564+
@pytest.mark.skip_for_dbt_fusion
565+
def test_model_owner_derived_from_group(dbt_project: DbtProject, tmp_path):
566+
"""
567+
A model assigned to a group but WITHOUT a direct owner should inherit the
568+
group's owner (email preferred) in the dbt_models artifact table.
569+
"""
570+
unique_id = str(uuid.uuid4()).replace("-", "_")
571+
model_name = f"model_group_owner_{unique_id}"
572+
group_name = f"test_group_{unique_id}"
573+
model_sql = """
574+
select 1 as col
575+
"""
576+
schema_yaml = {
577+
"version": 2,
578+
"models": [
579+
{
580+
"name": model_name,
581+
"config": {"group": group_name},
582+
"description": "A model assigned to a group without a direct owner",
583+
}
584+
],
585+
}
586+
group_config = {
587+
"groups": [
588+
{
589+
"name": group_name,
590+
"owner": {"name": OWNER_NAME, "email": OWNER_EMAIL},
591+
}
592+
]
593+
}
594+
with _write_group_config(
595+
dbt_project, group_config, name=f"groups_model_owner_{unique_id}.yml"
596+
), dbt_project.write_yaml(
597+
schema_yaml, name=f"schema_model_group_owner_{unique_id}.yml"
598+
):
599+
dbt_model_path = dbt_project.models_dir_path / "tmp" / f"{model_name}.sql"
600+
dbt_model_path.parent.mkdir(parents=True, exist_ok=True)
601+
dbt_model_path.write_text(model_sql)
602+
try:
603+
dbt_project.dbt_runner.vars["disable_dbt_artifacts_autoupload"] = False
604+
dbt_project.dbt_runner.vars["disable_run_results"] = False
605+
dbt_project.dbt_runner.run(select=model_name)
606+
607+
models = dbt_project.read_table(
608+
"dbt_models", where=f"name = '{model_name}'", raise_if_empty=True
609+
)
610+
assert len(models) == 1, f"Expected 1 model, got {len(models)}"
611+
owners = _parse_owners(models[0].get("owner"))
612+
assert owners == [
613+
OWNER_EMAIL
614+
], f"Expected owner ['{OWNER_EMAIL}'] derived from group, got {owners}"
615+
finally:
616+
if dbt_model_path.exists():
617+
dbt_model_path.unlink()
618+
619+
620+
def test_test_owner_derived_from_group(dbt_project: DbtProject, tmp_path):
621+
"""
622+
A test on a grouped model WITHOUT a direct owner should inherit the group's
623+
owner as model_owners in the dbt_tests artifact table (this feeds
624+
elementary_test_results.owners and alert routing).
625+
"""
626+
unique_id = str(uuid.uuid4()).replace("-", "_")
627+
model_name = f"model_group_test_owner_{unique_id}"
628+
group_name = f"test_group_{unique_id}"
629+
model_sql = """
630+
select 1 as col
631+
"""
632+
schema_yaml = {
633+
"version": 2,
634+
"models": [
635+
{
636+
"name": model_name,
637+
"config": {"group": group_name},
638+
"description": "A grouped model without a direct owner",
639+
"columns": [{"name": "col", "tests": ["unique"]}],
640+
}
641+
],
642+
}
643+
group_config = {
644+
"groups": [
645+
{
646+
"name": group_name,
647+
"owner": {"name": OWNER_NAME, "email": OWNER_EMAIL},
648+
}
649+
]
650+
}
651+
with _write_group_config(
652+
dbt_project, group_config, name=f"groups_test_owner_{unique_id}.yml"
653+
), dbt_project.write_yaml(
654+
schema_yaml, name=f"schema_test_group_owner_{unique_id}.yml"
655+
):
656+
dbt_model_path = dbt_project.models_dir_path / "tmp" / f"{model_name}.sql"
657+
dbt_model_path.parent.mkdir(parents=True, exist_ok=True)
658+
dbt_model_path.write_text(model_sql)
659+
try:
660+
dbt_project.dbt_runner.vars["disable_dbt_artifacts_autoupload"] = False
661+
dbt_project.dbt_runner.run(select=model_name)
662+
tests = dbt_project.read_table(
663+
"dbt_tests",
664+
where=f"parent_model_unique_id LIKE '%{model_name}'",
665+
raise_if_empty=True,
666+
)
667+
assert len(tests) == 1, f"Expected 1 test, got {len(tests)}"
668+
model_owners = _parse_owners(tests[0].get("model_owners"))
669+
assert model_owners == [
670+
OWNER_EMAIL
671+
], f"Expected model_owners ['{OWNER_EMAIL}'] derived from group, got {model_owners}"
672+
finally:
673+
if dbt_model_path.exists():
674+
dbt_model_path.unlink()
675+
676+
677+
@pytest.mark.skip_for_dbt_fusion
678+
def test_direct_owner_takes_precedence_over_group(dbt_project: DbtProject, tmp_path):
679+
"""
680+
When a model has BOTH a direct owner and a group, the direct owner wins and
681+
the group owner is not added (mirrors dbt's own config precedence).
682+
"""
683+
unique_id = str(uuid.uuid4()).replace("-", "_")
684+
model_name = f"model_direct_owner_{unique_id}"
685+
group_name = f"test_group_{unique_id}"
686+
direct_owner = "Alice"
687+
model_sql = f"""
688+
{{{{ config(meta={{'owner': '{direct_owner}'}}) }}}}
689+
select 1 as col
690+
"""
691+
schema_yaml = {
692+
"version": 2,
693+
"models": [
694+
{
695+
"name": model_name,
696+
"config": {"group": group_name},
697+
"description": "A grouped model with a direct owner",
698+
}
699+
],
700+
}
701+
group_config = {
702+
"groups": [
703+
{
704+
"name": group_name,
705+
"owner": {"name": OWNER_NAME, "email": OWNER_EMAIL},
706+
}
707+
]
708+
}
709+
with _write_group_config(
710+
dbt_project, group_config, name=f"groups_direct_owner_{unique_id}.yml"
711+
), dbt_project.write_yaml(schema_yaml, name=f"schema_direct_owner_{unique_id}.yml"):
712+
dbt_model_path = dbt_project.models_dir_path / "tmp" / f"{model_name}.sql"
713+
dbt_model_path.parent.mkdir(parents=True, exist_ok=True)
714+
dbt_model_path.write_text(model_sql)
715+
try:
716+
dbt_project.dbt_runner.vars["disable_dbt_artifacts_autoupload"] = False
717+
dbt_project.dbt_runner.run(select=model_name)
718+
719+
models = dbt_project.read_table(
720+
"dbt_models", where=f"name = '{model_name}'", raise_if_empty=True
721+
)
722+
assert len(models) == 1, f"Expected 1 model, got {len(models)}"
723+
owners = _parse_owners(models[0].get("owner"))
724+
assert owners == [
725+
direct_owner
726+
], f"Expected direct owner ['{direct_owner}'] to win over group, got {owners}"
727+
finally:
728+
if dbt_model_path.exists():
729+
dbt_model_path.unlink()

macros/edr/dbt_artifacts/upload_dbt_models.sql

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
{% endfor %}
8080
{% elif raw_owner is iterable %} {% do formatted_owner.extend(raw_owner) %}
8181
{% endif %}
82+
{% set group_name = config_dict.get("group") or node_dict.get("group") %}
83+
{% if not formatted_owner and group_name %}
84+
{% set group_owner = elementary.get_group_owner(group_name) %}
85+
{% if group_owner %} {% do formatted_owner.append(group_owner) %} {% endif %}
86+
{% endif %}
8287
{% set config_tags = elementary.safe_get_with_default(config_dict, "tags", []) %}
8388
{% set global_tags = elementary.safe_get_with_default(node_dict, "tags", []) %}
8489
{% set meta_tags = elementary.safe_get_with_default(meta_dict, "tags", []) %}
@@ -112,7 +117,7 @@
112117
"incremental_strategy": config_dict.get("incremental_strategy"),
113118
"bigquery_partition_by": config_dict.get("partition_by"),
114119
"bigquery_cluster_by": config_dict.get("cluster_by"),
115-
"group_name": config_dict.get("group") or node_dict.get("group"),
120+
"group_name": group_name,
116121
"access": config_dict.get("access") or node_dict.get("access"),
117122
} %}
118123
{% do flatten_model_metadata_dict.update(
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% macro get_group_owner(group_name) %}
2+
{#
3+
Resolve a dbt group's owner to a single string, preferring the owner email
4+
and falling back to the owner name. Mirrors how dbt_groups exposes
5+
owner_email / owner_name. Returns none when the group or owner is missing.
6+
#}
7+
{% if not group_name %} {% do return(none) %} {% endif %}
8+
{% for group_node in graph.groups.values() %}
9+
{% if group_node.get("resource_type") == "group" and group_node.get(
10+
"name"
11+
) == group_name %}
12+
{% set owner_dict = elementary.safe_get_with_default(
13+
group_node, "owner", {}
14+
) %}
15+
{% do return(owner_dict.get("email") or owner_dict.get("name")) %}
16+
{% endif %}
17+
{% endfor %}
18+
{% do return(none) %}
19+
{% endmacro %}

0 commit comments

Comments
 (0)