Skip to content

Commit

Permalink
validate real data contracts for identifying breaking changes
Browse files Browse the repository at this point in the history
  • Loading branch information
torbenkeller committed Feb 23, 2024
1 parent eca80a9 commit 6a13ba7
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 28 deletions.
104 changes: 104 additions & 0 deletions datacontract/breaking/breaking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from datacontract.model.breaking_result import BreakingResults, BreakingResult, Location
from datacontract.model.data_contract_specification import Field, Model


def models_breaking_changes(old_models: dict[str, Model],
new_models: dict[str, Model],
new_path: str
) -> BreakingResults:
composition = ["models"]
results = list[BreakingResult]()

for model_name, old_model in old_models.items():
if model_name not in new_models.keys():
results.append(
BreakingResult(
description=f"removed the model `{model_name}`",
check_name="model-removed",
severity="error",
location=Location(
path=new_path,
composition=composition
)))
continue

results.extend(
fields_breaking_changes(
old_fields=old_model.fields,
new_fields=new_models[model_name].fields,
new_path=new_path,
composition=composition + [model_name]
))

return BreakingResults(breaking_results=results)


def fields_breaking_changes(old_fields: dict[str, Field],
new_fields: dict[str, Field],
new_path: str,
composition: list[str]
) -> list[BreakingResult]:
results = list[BreakingResult]()

for field_name, old_field in old_fields.items():
if field_name not in new_fields.keys():
results.append(
BreakingResult(
description=f"removed the field `{field_name}`",
check_name="field-removed",
severity="error",
location=Location(
path=new_path,
composition=composition
)))
continue

results.extend(
field_breaking_changes(
old_field=old_field,
new_field=new_fields[field_name],
composition=composition + [field_name],
new_path=new_path,
))
return results


def field_breaking_changes(
old_field: Field,
new_field: Field,
composition: list[str],
new_path: str,
) -> list[BreakingResult]:
results = list[BreakingResult]()

if old_field.type is not None and old_field.type != new_field.type:
results.append(BreakingResult(
description=f"changed the type from `{old_field.type}` to `{new_field.type}`",
check_name="field-type-changed",
severity="error",
location=Location(
path=new_path,
composition=composition
)))

if old_field.format is not None and old_field.format != new_field.format:
results.append(BreakingResult(
description=f"changed the format from `{old_field.format}` to `{new_field.format}`",
check_name="field-format-changed",
severity="error",
location=Location(
path=new_path,
composition=composition
)))

if old_field.description is not None and old_field.description != new_field.description:
results.append(BreakingResult(
description="changed the description",
check_name="field-description-changed",
severity="warning",
location=Location(
path=new_path,
composition=composition
)))

return results
10 changes: 3 additions & 7 deletions datacontract/data_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import yaml

from datacontract.breaking.breaking import models_breaking_changes
from datacontract.engines.datacontract.check_that_datacontract_contains_valid_servers_configuration import \
check_that_datacontract_contains_valid_server_configuration
from datacontract.engines.fastjsonschema.check_jsonschema import \
Expand Down Expand Up @@ -142,15 +143,10 @@ def test(self) -> Run:

return run

def breaking(self, other):
def breaking(self, other: 'DataContract') -> BreakingResults:
old = self.get_data_contract_specification()
new = other.get_data_contract_specification()
breaking_results = BreakingResults(breaking_results=list())
breaking_results.breaking_results.append(BreakingResult(description='removed the field updated_at', severity='error', check_name='field-removed',
location=Location(
path='./examples/breaking/datacontract-v2.yaml',
model='my_table')))
return breaking_results
return models_breaking_changes(old_models=old.models, new_models=new.models, new_path=other._data_contract_file)

def get_data_contract_specification(self):
return resolve.resolve_data_contract(self._data_contract_file, self._data_contract_str,
Expand Down
16 changes: 11 additions & 5 deletions datacontract/model/breaking_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

class Location(BaseModel):
path: str
model: str
composition: List[str]


class BreakingResult(BaseModel):
Expand All @@ -15,17 +15,23 @@ class BreakingResult(BaseModel):
location: Location

def __str__(self) -> str:
return f"""{self.severity} \[{self.check_name}] at {self.location.path}
in model {self.location.model}
return f"""{self.severity}\t\[{self.check_name}] at {self.location.path}
in {str.join(" -> ", self.location.composition)}
{self.description}"""


class BreakingResults(BaseModel):
breaking_results: List[BreakingResult]

def __str__(self) -> str:
first_line = f"{len(self.breaking_results)} breaking changes: {len(self.breaking_results)} error, 0 warning\n"
return first_line + str.join("\n", map(lambda x: str(x), self.breaking_results))
changes_amount = len(self.breaking_results)
errors = len(list(filter(lambda x: x.severity == "error", self.breaking_results)))
warnings = len(list(filter(lambda x: x.severity == "warning", self.breaking_results)))

headline = f"{changes_amount} breaking changes: {errors} error, {warnings} warning\n"
content = str.join("\n\n", map(lambda x: str(x), self.breaking_results))

return headline + content

#
# [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ models:
type: table
fields:
id:
type: integer
type: string
required: true
description: my changed description
name:
type: string
required: true
description:
type: string
required: false
created_at:
type: timestamp
required: true
format: YYYY/MM/DD
my_table2:
type: table
fields:
id:
type: integer
required: true
14 changes: 14 additions & 0 deletions tests/examples/breaking/datacontract-models.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
dataContractSpecification: 0.9.2
id: my-data-contract-id
info:
title: My Data Contract
version: 0.0.1
my-custom-required-field: hello

models:
my_table2:
type: table
fields:
id:
type: integer
required: true
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ models:
id:
type: integer
required: true
description: my custom description
name:
type: string
required: true
description:
type: string
required: false
created_at:
type: timestamp
required: true
format: YYYY-MM-DD
updated_at:
type: timestamp
my_table2:
type: table
fields:
id:
type: integer
required: true
68 changes: 59 additions & 9 deletions tests/test_breaking.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,62 @@
logging.basicConfig(level=logging.DEBUG, force=True)


def test_field_removed():
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract-v1.yaml",
"./examples/breaking/datacontract-v2.yaml"])
assert result.exit_code == 1
assert result.stdout == r"""1 breaking changes: 1 error, 0 warning
error [field-removed] at ./examples/breaking/datacontract-v2.yaml
in model my_table
removed the field updated_at
"""
class TestBreakingModels:

def test_model_removed(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-models.yaml"])
assert result.exit_code == 1
assert r"""1 breaking changes: 1 error, 0 warning
error [model-removed] at ./examples/breaking/datacontract-models.yaml
in models
removed the model `my_table`
""" in result.stdout


class TestBreakingFields:

def test_headline(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-fields.yaml"])
assert result.exit_code == 1
assert "4 breaking changes: 3 error, 1 warning" in result.stdout

def test_field_removed(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-fields.yaml"])
assert result.exit_code == 1
assert r"""error [field-removed] at ./examples/breaking/datacontract-fields.yaml
in models -> my_table
removed the field `updated_at`
""" in result.stdout

def test_type_changed(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-fields.yaml"])
assert result.exit_code == 1
assert r"""error [field-type-changed] at ./examples/breaking/datacontract-fields.yaml
in models -> my_table -> id
changed the type from `integer` to `string`
""" in result.stdout

def test_format_changed(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-fields.yaml"])
assert result.exit_code == 1
assert r"""error [field-format-changed] at ./examples/breaking/datacontract-fields.yaml
in models -> my_table -> created_at
changed the format from `YYYY-MM-DD` to `YYYY/MM/DD`
""" in result.stdout

def test_description_changed(self):
result = runner.invoke(app, ["breaking", "./examples/breaking/datacontract.yaml",
"./examples/breaking/datacontract-fields.yaml"])
assert result.exit_code == 1
assert r"""warning [field-description-changed] at
./examples/breaking/datacontract-fields.yaml
in models -> my_table -> id
changed the description
""" in result.stdout


0 comments on commit 6a13ba7

Please sign in to comment.