From 5ce1beeaa055e010d2a86b4730fda2b071c1b721 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Thu, 1 Feb 2024 11:11:16 -0800 Subject: [PATCH] Add `Mergeable` to contract utils (#59) * Add `Mergeable` to contract utils At this moment in time, `Mergeable` is defined in core [here](https://github.com/dbt-labs/dbt-core/blob/1a5d6922dddf9ffe018d8860bb2f2141594ddbbe/core/dbt/contracts/util.py#L27). We're curren't in the process of moving data artifacts of nodes defined in dbt-core's `nodes.py` into dbt-artifacts. Some of these data artifacts depend on `Mergeable`. We don't want artifacts to depend on core, thus `Mergeable` has to be moved _somewhere_ upstream. Given `Mergeable`'s similarity to `Replaceable` it made the most sense to move next to `Replaceable` here in dbt-common. * Add changie doc for add mergeable * Add unit test for newly added `Mergeable` class --- .../unreleased/Features-20240201-101851.yaml | 6 +++++ dbt_common/contracts/util.py | 16 ++++++++++++ tests/unit/test_contracts_util.py | 26 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 .changes/unreleased/Features-20240201-101851.yaml create mode 100644 tests/unit/test_contracts_util.py diff --git a/.changes/unreleased/Features-20240201-101851.yaml b/.changes/unreleased/Features-20240201-101851.yaml new file mode 100644 index 00000000..fdd88f5c --- /dev/null +++ b/.changes/unreleased/Features-20240201-101851.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Add dataclass utility `Mergeable` +time: 2024-02-01T10:18:51.474231-08:00 +custom: + Author: QMalcolm + Issue: "58" diff --git a/dbt_common/contracts/util.py b/dbt_common/contracts/util.py index 1467e4d8..7ec02463 100644 --- a/dbt_common/contracts/util.py +++ b/dbt_common/contracts/util.py @@ -5,3 +5,19 @@ class Replaceable: def replace(self, **kwargs): return dataclasses.replace(self, **kwargs) + + +class Mergeable(Replaceable): + def merged(self, *args): + """Perform a shallow merge, where the last non-None write wins. This is + intended to merge dataclasses that are a collection of optional values. + """ + replacements = {} + cls = type(self) + for arg in args: + for field in dataclasses.fields(cls): + value = getattr(arg, field.name) + if value is not None: + replacements[field.name] = value + + return self.replace(**replacements) diff --git a/tests/unit/test_contracts_util.py b/tests/unit/test_contracts_util.py new file mode 100644 index 00000000..2a620370 --- /dev/null +++ b/tests/unit/test_contracts_util.py @@ -0,0 +1,26 @@ +import unittest + +from dataclasses import dataclass +from dbt_common.contracts.util import Mergeable +from typing import List, Optional + + +@dataclass +class ExampleMergableClass(Mergeable): + attr_a: str + attr_b: Optional[int] + attr_c: Optional[List[str]] + + +class TestMergableClass(unittest.TestCase): + def test_mergeability(self): + mergeable1 = ExampleMergableClass( + attr_a="loses", attr_b=None, attr_c=["I'll", "still", "exist"] + ) + mergeable2 = ExampleMergableClass(attr_a="Wins", attr_b=1, attr_c=None) + merge_result: ExampleMergableClass = mergeable1.merged(mergeable2) + assert ( + merge_result.attr_a == mergeable2.attr_a + ) # mergeable2's attr_a is the "last" non None value + assert merge_result.attr_b == mergeable2.attr_b # mergeable1's attrb_b value was None + assert merge_result.attr_c == mergeable1.attr_c # mergeable2's attr_c value was None