Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add pre_serialization method to BaseResource #9718

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/unreleased/Features-20240301-091804.yaml
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 16 additions & 1 deletion core/dbt/artifacts/resources/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, fields
from dbt_common.dataclass_schema import dbtClassMixin
from typing import List, Optional
import hashlib
Expand All @@ -15,6 +15,21 @@
original_file_path: 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

Check warning on line 30 in core/dbt/artifacts/resources/base.py

View check run for this annotation

Codecov / codecov/patch

core/dbt/artifacts/resources/base.py#L30

Added line #L30 was not covered by tests
return data


@dataclass
class GraphResource(BaseResource):
Expand Down
166 changes: 166 additions & 0 deletions tests/unit/artifacts/resources/test_serialization.py
Original file line number Diff line number Diff line change
@@ -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())