Skip to content

feat: add breaking change detection - #1482

Open
pierre-monnet wants to merge 6 commits into
datacontract:mainfrom
pierre-monnet:breaking_change
Open

feat: add breaking change detection#1482
pierre-monnet wants to merge 6 commits into
datacontract:mainfrom
pierre-monnet:breaking_change

Conversation

@pierre-monnet

@pierre-monnet pierre-monnet commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #1016

  • Tests pass (uv run pytest)
  • Code formatted (uv run ruff check --fix && uv run ruff format)
  • Docs updated (if relevant)
  • CHANGELOG.md entry added

@pierre-monnet
pierre-monnet marked this pull request as ready for review August 4, 2026 14:03
@pierre-monnet

Copy link
Copy Markdown
Contributor Author

@jochenchrist @simonharrer

@jschoedl jschoedl linked an issue Aug 19, 2026 that may be closed by this pull request
Comment thread tests/test_breaking.py
def _entry(path, change_type, old_value=None, new_value=None):
return ChangelogEntry(path=path, type=change_type, old_value=old_value, new_value=new_value)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When looking at the tests, I cannot really see how the command works from a CLI perspective?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some tests. Let me know if more are needed!

@jschoedl jschoedl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thank you for the PR!

Comment thread datacontract/breaking/rules.py Outdated
Comment on lines +171 to +176
def _is_schema_property_path(segments: list[str]) -> bool:
try:
properties_index = segments.index("properties")
except ValueError:
return False
return segments[0] == "schema" and properties_index + 1 < len(segments)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns True if any of the segments is properties, even if it is not the last one. E.g. deleting a description (located at properties.some_property.description) is mis-classified as deleting the property itself, although this is actually no breaking change.

We should check for properties being the segment before the last one

Comment thread docs/docs/testing/breaking-changes.md Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not be placed in the testing directory, because it hides the datacontract breaking functionality between the list of different testing backends.

Instead, I'd vote for a seperate page "Compare contract versions" (or similar name) at the top level, which should include both changelog and breaking. As there is no seperate documentation of changelog yet, you don't need to write it - but at least a link to the auto-generated command reference would be nice.

Comment on lines +22 to +25
def detect(self, changelog: ChangelogResult) -> BreakingChangeResult:
entries = [self._classify(entry) for entry in changelog.entries]
summary = [self._summarize(entry, entries) for entry in changelog.summary]
return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_summarize(entry, entries) loops each of the changelog entries, i.e. we have quadratic runtime complexity. If there are a lot of entries (e.g. a few thousands), this can take a while.

To avoid that, the entries parameter should not get all of the entries, but only the relevant ones, e.g. like this:

Suggested change
def detect(self, changelog: ChangelogResult) -> BreakingChangeResult:
entries = [self._classify(entry) for entry in changelog.entries]
summary = [self._summarize(entry, entries) for entry in changelog.summary]
return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries)
def detect(self, changelog: ChangelogResult) -> BreakingChangeResult:
entries = [self._classify(entry) for entry in changelog.entries]
entries_by_prefix = defaultdict(list)
for entry in entries:
prefix = ""
for segment in entry.path.split("."):
prefix = f"{prefix}.{segment}" if prefix else segment
entries_by_prefix[prefix].append(entry)
summary = [self._summarize(entry, entries_by_prefix.get(entry.path, [])) for entry in changelog.summary]
return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries)

Comment thread datacontract/api.py Outdated
POST a JSON body with `v1` (source/before) and `v2` (target/after) as YAML strings.
""",
)
async def breaking_endpoint(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def breaking_endpoint(
def breaking_endpoint(

This is declared as async, but actually only does blocking CPU and I/O.

Comment thread datacontract/breaking/rules.py Outdated
Comment on lines +115 to +133
class EnumConstraintRule(BreakingChangeRule):
priority = 65
rule_id = "enum-constraint-changed"

def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None:
if not entry.path.endswith(".enum"):
return None
old = _parse_sequence(entry.old_value)
new = _parse_sequence(entry.new_value)
if old is not None and new is not None:
if set(old) - set(new):
level = BreakingChangeLevel.ERROR
elif set(new) - set(old):
level = BreakingChangeLevel.INFO
else:
level = BreakingChangeLevel.INFO
else:
level = BreakingChangeLevel.WARNING
return RuleEvaluation(self.rule_id, level, _change_message("enum constraint", entry))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum is not supported in ODCS. As it was supported in the old DCS format, there might be some locations in code or docs where we didn't clean it up yet, but today, this code is not reachable.

The new way is to specify a quality check with validValues: https://bitol-io.github.io/open-data-contract-standard/v3.1.0/data-quality/#invalid-values

Comment on lines +152 to +159
old = _parse_number(entry.old_value)
new = _parse_number(entry.new_value)
if old is None or new is None:
level = BreakingChangeLevel.WARNING
elif _is_tightening(entry.path, old, new):
level = BreakingChangeLevel.ERROR
else:
level = BreakingChangeLevel.INFO

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_parse_number in its current form does not work for logicalType: date yet

Comment thread datacontract/api.py Outdated

try:
result = DataContract(data_contract_file=v1_path).breaking(DataContract(data_contract_file=v2_path))
return result

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This includes internal information that is not needed by the caller such as the full file paths of the tempdir. For security reasons, it would be best to keep the response minimal as it was done with the ChangelogResponse a few lines before this one.

Comment thread datacontract/api.py
Comment on lines +866 to +874
@app.post(
"/breaking",
tags=["breaking"],
summary="Show compatibility impact between two data contracts.",
description="""
Compare two ODCS data contract YAMLs and classify their backward-compatibility impact.
POST a JSON body with `v1` (source/before) and `v2` (target/after) as YAML strings.
""",
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add operation_id, response_description and responses to document the API endpoint (see the other endpoints for examples)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The priority integer seems a bit odd to me. Why not list all the rules in a list where the first one wins?

pierre-monnet and others added 2 commits August 23, 2026 18:11
- Add BreakingChangesResponse model to provide detailed breaking change information.
- Update breaking endpoint to return structured response for compatibility impact.
- Introduce new rules for detecting breaking changes in schema and properties.
- Enhance tests for breaking changes detection and validation rules.
- Create documentation for comparing contract versions and breaking changes.
- Clean up and reorganize existing documentation for clarity.
@pierre-monnet

Copy link
Copy Markdown
Contributor Author

Hi, thank you for the PR!

Hi, thanks for review, I pushed fixes :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reintroduce breaking breaking, diff, changelog for ODCS

3 participants