mirror of
https://github.com/dbt-labs/dbt-core
synced 2025-12-22 16:01:27 +00:00
Compare commits
3 Commits
enable-pos
...
er/9615-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77210358e1 | ||
|
|
d53d216d4a | ||
|
|
73c7755f2e |
6
.changes/unreleased/Features-20240301-091804.yaml
Normal file
6
.changes/unreleased/Features-20240301-091804.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Features
|
||||||
|
body: Allow adding new fields and removing optional fields to BaseResource subclasses without creating new versions of artifacts
|
||||||
|
time: 2024-03-01T09:18:04.696662-06:00
|
||||||
|
custom:
|
||||||
|
Author: emmyoop
|
||||||
|
Issue: "9615"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, fields
|
||||||
from dbt_common.dataclass_schema import dbtClassMixin
|
from dbt_common.dataclass_schema import dbtClassMixin
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -15,6 +15,21 @@ class BaseResource(dbtClassMixin):
|
|||||||
original_file_path: str
|
original_file_path: str
|
||||||
unique_id: str
|
unique_id: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __pre_deserialize__(cls, data):
|
||||||
|
data = super().__pre_deserialize__(data)
|
||||||
|
|
||||||
|
# Support deserializing additional fields in BaseResource for forward
|
||||||
|
# compatibility within a major version
|
||||||
|
for f in fields(cls):
|
||||||
|
# missing fields with defaults are acceptable
|
||||||
|
if f.name not in data and f.default:
|
||||||
|
data[f.name] = f.default
|
||||||
|
# missing optional fields are acceptable
|
||||||
|
elif f.name not in data and f.init is False:
|
||||||
|
data[f.name] = None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GraphResource(BaseResource):
|
class GraphResource(BaseResource):
|
||||||
|
|||||||
166
tests/unit/artifacts/resources/test_serialization.py
Normal file
166
tests/unit/artifacts/resources/test_serialization.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import pytest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from mashumaro.exceptions import MissingField
|
||||||
|
|
||||||
|
from dbt.artifacts.resources.base import BaseResource
|
||||||
|
from dbt.artifacts.resources.types import NodeType
|
||||||
|
|
||||||
|
|
||||||
|
# Test that a (mocked) new minor version of a BaseResource (serialized with a value for
|
||||||
|
# a new optional field) can be deserialized successfully. e.g. something like
|
||||||
|
# PreviousBaseResource.from_dict(CurrentBaseResource(...).to_dict())
|
||||||
|
@dataclass
|
||||||
|
class SlimClass(BaseResource):
|
||||||
|
my_str: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OptionalFieldClass(BaseResource):
|
||||||
|
my_str: str
|
||||||
|
optional_field: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequiredFieldClass(BaseResource):
|
||||||
|
my_str: str
|
||||||
|
new_field: str
|
||||||
|
|
||||||
|
|
||||||
|
# Test that a new minor version of a BaseResource serialized with a
|
||||||
|
# field that is now optional, but did not previously exist can be
|
||||||
|
# deserialized successfully.
|
||||||
|
def test_adding_optional_field():
|
||||||
|
current_instance = OptionalFieldClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
optional_field="test", # new optional field
|
||||||
|
)
|
||||||
|
|
||||||
|
current_instance_dict = current_instance.to_dict()
|
||||||
|
expected_current_dict = {
|
||||||
|
"name": "test",
|
||||||
|
"resource_type": "macro",
|
||||||
|
"package_name": "awsome_package",
|
||||||
|
"path": "my_path",
|
||||||
|
"original_file_path": "my_file_path",
|
||||||
|
"unique_id": "abc",
|
||||||
|
"my_str": "test",
|
||||||
|
"optional_field": "test",
|
||||||
|
}
|
||||||
|
assert current_instance_dict == expected_current_dict
|
||||||
|
|
||||||
|
expected_slim_instance = SlimClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
)
|
||||||
|
slim_instance = SlimClass.from_dict(current_instance_dict)
|
||||||
|
assert slim_instance == expected_slim_instance
|
||||||
|
|
||||||
|
|
||||||
|
# Test that a new minor version of a BaseResource serialized without a
|
||||||
|
# field that was previously optional can be deserialized successfully.
|
||||||
|
def test_missing_optional_field():
|
||||||
|
current_instance = SlimClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
# optional_field="test" -> puposely excluded
|
||||||
|
)
|
||||||
|
current_instance_dict = current_instance.to_dict()
|
||||||
|
expected_current_dict = {
|
||||||
|
"name": "test",
|
||||||
|
"resource_type": "macro",
|
||||||
|
"package_name": "awsome_package",
|
||||||
|
"path": "my_path",
|
||||||
|
"original_file_path": "my_file_path",
|
||||||
|
"unique_id": "abc",
|
||||||
|
"my_str": "test",
|
||||||
|
}
|
||||||
|
assert current_instance_dict == expected_current_dict
|
||||||
|
|
||||||
|
expected_optional_field_instance = OptionalFieldClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
optional_field=None,
|
||||||
|
)
|
||||||
|
slim_instance = OptionalFieldClass.from_dict(current_instance_dict)
|
||||||
|
assert slim_instance == expected_optional_field_instance
|
||||||
|
|
||||||
|
|
||||||
|
# Test that a new minor version of a BaseResource serialized with a
|
||||||
|
# new field without a default, but did not previously exist can be
|
||||||
|
# deserialized successfully
|
||||||
|
def test_adding_required_field():
|
||||||
|
current_instance = RequiredFieldClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
new_field="test", # new required field
|
||||||
|
)
|
||||||
|
|
||||||
|
current_instance_dict = current_instance.to_dict()
|
||||||
|
expected_current_dict = {
|
||||||
|
"name": "test",
|
||||||
|
"resource_type": "macro",
|
||||||
|
"package_name": "awsome_package",
|
||||||
|
"path": "my_path",
|
||||||
|
"original_file_path": "my_file_path",
|
||||||
|
"unique_id": "abc",
|
||||||
|
"my_str": "test",
|
||||||
|
"new_field": "test",
|
||||||
|
}
|
||||||
|
assert current_instance_dict == expected_current_dict
|
||||||
|
|
||||||
|
expected_slim_instance = SlimClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
)
|
||||||
|
slim_instance = SlimClass.from_dict(current_instance_dict)
|
||||||
|
assert slim_instance == expected_slim_instance
|
||||||
|
|
||||||
|
|
||||||
|
# Test that a new minor version of a BaseResource serialized without a
|
||||||
|
# field with no default cannot be deserialized successfully. We don't
|
||||||
|
# want to allow removing required fields. Expect error.
|
||||||
|
def test_removing_required_field():
|
||||||
|
current_instance = SlimClass(
|
||||||
|
name="test",
|
||||||
|
resource_type=NodeType.Macro,
|
||||||
|
package_name="awsome_package",
|
||||||
|
path="my_path",
|
||||||
|
original_file_path="my_file_path",
|
||||||
|
unique_id="abc",
|
||||||
|
my_str="test",
|
||||||
|
)
|
||||||
|
expecter_err = 'Field "new_field" of type str is missing in RequiredFieldClass instance'
|
||||||
|
with pytest.raises(MissingField, match=expecter_err):
|
||||||
|
RequiredFieldClass.from_dict(current_instance.to_dict())
|
||||||
Reference in New Issue
Block a user