diff --git a/CHANGELOG.md b/CHANGELOG.md index f50855592..b86bd3fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The bundled Data Contract Editor (`datacontract edit`) is updated to 0.1.16 +- `datacontract lint` and `datacontract test`: reject duplicate keys in a data contract instead of silently keeping the last value (#1741 @kamathramu87) ### Fixed -- `datacontract export sql`: apostrophes and backslashes in descriptions no longer break ClickHouse and Snowflake `COMMENT` clauses (#1729) +- `datacontract export sql`: apostrophes and backslashes in descriptions no longer break ClickHouse and Snowflake `COMMENT` clauses (#1729,#1731 @banshee @ezno12) ## [1.2.7] - 2026-10-16 diff --git a/datacontract/lint/resolve.py b/datacontract/lint/resolve.py index 884f18628..121df3795 100644 --- a/datacontract/lint/resolve.py +++ b/datacontract/lint/resolve.py @@ -54,7 +54,26 @@ class _LaxOpenDataContractStandard(OpenDataContractStandard): class _SafeLoaderNoTimestamp(yaml.SafeLoader): """SafeLoader that keeps dates/timestamps as strings instead of converting to datetime objects.""" - pass + def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]: + # Skip merge keys: overriding a value inherited from an anchor is valid YAML. + first_marks = {} + for key_node, _ in node.value: + if key_node.tag == "tag:yaml.org,2002:merge": + continue + key = self.construct_object(key_node, deep=deep) + try: + first_mark = first_marks.get(key) + except TypeError: + return super().construct_mapping(node, deep=deep) + if first_mark is not None: + raise yaml.constructor.ConstructorError( + f"found key {key!r}", + first_mark, + f"found duplicate key {key!r}", + key_node.start_mark, + ) + first_marks[key] = key_node.start_mark + return super().construct_mapping(node, deep=deep) # Remove the timestamp implicit resolver so dates like 2022-01-15 stay as strings diff --git a/tests/test_resolve.py b/tests/test_resolve.py index ab75d134d..a81ebe659 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1,6 +1,10 @@ import tempfile -from datacontract.lint.resolve import resolve_data_contract +import pytest + +from datacontract.data_contract import DataContract +from datacontract.lint.resolve import _to_yaml, resolve_data_contract +from datacontract.model.exceptions import DataContractException from datacontract.model.server import get_server_type # logging.basicConfig(level=logging.INFO, force=True) @@ -313,3 +317,47 @@ def test_resolve_dcs_status_preserved_when_set(): """, ) assert odcs.status == "active" + + +@pytest.mark.parametrize( + "document", + [ + "status: active\nstatus: draft\n", + "schema:\n- name: orders\n properties:\n - name: id\n required: true\n required: false\n", + "schema:\n- name: orders\n quality: {type: sql, query: SELECT 1, query: SELECT 2}\n", + ], +) +def test_duplicate_mapping_keys_are_rejected(document: str) -> None: + with pytest.raises(DataContractException, match="duplicate key"): + _to_yaml(document) + + +def test_duplicate_key_error_identifies_source_and_line() -> None: + with pytest.raises(DataContractException) as exc: + _to_yaml("apiVersion: v3.2.0\nversion: 1.0.0\nversion: 2.0.0\n", "contracts/orders.yaml") + assert "contracts/orders.yaml" in str(exc.value) + assert "line 2" in str(exc.value) + assert "line 3" in str(exc.value) + assert "line 1" not in str(exc.value) + + +def test_yaml_merge_allows_explicit_override() -> None: + parsed = _to_yaml("defaults: &defaults {required: false}\ncolumn: {<<: *defaults, required: true}\n") + assert parsed["column"]["required"] is True + + +def test_yaml_merge_does_not_hide_duplicate_explicit_keys() -> None: + with pytest.raises(DataContractException, match="duplicate key"): + _to_yaml("defaults: &defaults {required: false}\ncolumn: {<<: *defaults, required: true, required: false}\n") + + +def test_date_strings_and_distinct_mapping_keys_are_unchanged() -> None: + parsed = _to_yaml("date: 2026-10-13\nfirst: {name: id}\nsecond: {name: id}\n") + assert parsed["date"] == "2026-10-13" + assert parsed["first"] == parsed["second"] == {"name": "id"} + + +def test_lint_reports_duplicate_keys_as_a_failed_check() -> None: + run = DataContract(data_contract_str="apiVersion: v3.0.2\napiVersion: v3.2.0\n").lint() + assert not run.has_passed() + assert any("duplicate key" in (check.reason or "") for check in run.checks)