diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b6fc7..9edc0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `data` field only, so the `meta.pagination` that would tell you the set was truncated is not part of what you get back. +- `relationship_ids` on `Mappings.get()` / `get_iter()` and their async + counterparts, as a list or a comma-separated string. The server defaults to + `["Maps to"]`, so a composite concept returns only half its decomposition + unless `"Maps to value"` is asked for too: "Allergy to penicillin G" maps to + "Allergy to drug" via `Maps to` and to "penicillin G" via `Maps to value`. + ### Fixed - `include_invalid=False` now reaches the server on `Mappings.get()` / diff --git a/src/omophub/resources/mappings.py b/src/omophub/resources/mappings.py index 5ce84ba..8e53e56 100644 --- a/src/omophub/resources/mappings.py +++ b/src/omophub/resources/mappings.py @@ -24,6 +24,7 @@ def get( concept_id: int, *, target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, include_invalid: bool | None = None, page: int = 1, page_size: int = 100, @@ -40,6 +41,13 @@ def get( Args: concept_id: The concept ID target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + relationship_ids: Relationship types to return, as a list or a + comma-separated string. Defaults server-side to ``["Maps to"]``. + Pass ``["Maps to", "Maps to value"]`` to also get the + Value-as-Concept decomposition of composite concepts -- e.g. + "Allergy to penicillin G" maps to "Allergy to drug" via + ``Maps to`` and to "penicillin G" via ``Maps to value``, and the + default returns only the first of those. include_invalid: Whether to return mappings whose relationship or target concept is deprecated. Omit to take the server default, which for this endpoint is to **include** them; pass ``False`` @@ -59,6 +67,12 @@ def get( params: dict[str, Any] = {"page": page, "page_size": page_size} if target_vocabulary: params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) if include_invalid is not None: params["include_invalid"] = "true" if include_invalid else "false" if vocab_release: @@ -73,6 +87,7 @@ def get_iter( concept_id: int, *, target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, include_invalid: bool | None = None, page_size: int = 100, vocab_release: str | None = None, @@ -86,6 +101,13 @@ def get_iter( Args: concept_id: The concept ID target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + relationship_ids: Relationship types to return, as a list or a + comma-separated string. Defaults server-side to ``["Maps to"]``. + Pass ``["Maps to", "Maps to value"]`` to also get the + Value-as-Concept decomposition of composite concepts -- e.g. + "Allergy to penicillin G" maps to "Allergy to drug" via + ``Maps to`` and to "penicillin G" via ``Maps to value``, and the + default returns only the first of those. include_invalid: Whether to return mappings whose relationship or target concept is deprecated. Omit to take the server default, which for this endpoint is to **include** them; pass ``False`` @@ -104,6 +126,12 @@ def fetch_page( params: dict[str, Any] = {"page": page, "page_size": size} if target_vocabulary: params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) if include_invalid is not None: params["include_invalid"] = "true" if include_invalid else "false" if vocab_release: @@ -192,6 +220,7 @@ async def get( concept_id: int, *, target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, include_invalid: bool | None = None, page: int = 1, page_size: int = 100, @@ -208,6 +237,13 @@ async def get( Args: concept_id: The concept ID target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + relationship_ids: Relationship types to return, as a list or a + comma-separated string. Defaults server-side to ``["Maps to"]``. + Pass ``["Maps to", "Maps to value"]`` to also get the + Value-as-Concept decomposition of composite concepts -- e.g. + "Allergy to penicillin G" maps to "Allergy to drug" via + ``Maps to`` and to "penicillin G" via ``Maps to value``, and the + default returns only the first of those. include_invalid: Whether to return mappings whose relationship or target concept is deprecated. Omit to take the server default, which for this endpoint is to **include** them; pass ``False`` @@ -227,6 +263,12 @@ async def get( params: dict[str, Any] = {"page": page, "page_size": page_size} if target_vocabulary: params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) if include_invalid is not None: params["include_invalid"] = "true" if include_invalid else "false" if vocab_release: @@ -241,6 +283,7 @@ async def get_iter( concept_id: int, *, target_vocabulary: str | None = None, + relationship_ids: str | list[str] | None = None, include_invalid: bool | None = None, page_size: int = 100, vocab_release: str | None = None, @@ -254,6 +297,13 @@ async def get_iter( Args: concept_id: The concept ID target_vocabulary: Filter to a specific target vocabulary (e.g., "ICD10CM") + relationship_ids: Relationship types to return, as a list or a + comma-separated string. Defaults server-side to ``["Maps to"]``. + Pass ``["Maps to", "Maps to value"]`` to also get the + Value-as-Concept decomposition of composite concepts -- e.g. + "Allergy to penicillin G" maps to "Allergy to drug" via + ``Maps to`` and to "penicillin G" via ``Maps to value``, and the + default returns only the first of those. include_invalid: Whether to return mappings whose relationship or target concept is deprecated. Omit to take the server default, which for this endpoint is to **include** them; pass ``False`` @@ -272,6 +322,12 @@ async def fetch_page( params: dict[str, Any] = {"page": page, "page_size": size} if target_vocabulary: params["target_vocabulary"] = target_vocabulary + if relationship_ids: + params["relationship_ids"] = ( + ",".join(relationship_ids) + if isinstance(relationship_ids, list) + else relationship_ids + ) if include_invalid is not None: params["include_invalid"] = "true" if include_invalid else "false" if vocab_release: diff --git a/tests/unit/resources/test_mappings.py b/tests/unit/resources/test_mappings.py index a4cefba..7b19131 100644 --- a/tests/unit/resources/test_mappings.py +++ b/tests/unit/resources/test_mappings.py @@ -59,6 +59,65 @@ def test_get_mappings_with_filters( assert "target_vocabulary=ICD10CM" in url_str assert "include_invalid=true" in url_str + @respx.mock + def test_get_mappings_sends_relationship_ids( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """Value-as-Concept is unreachable without this parameter. + + The server defaults to `Maps to` alone, so a composite concept returns + only half its decomposition unless `Maps to value` is asked for. + """ + route = respx.get(f"{base_url}/concepts/4167462/mappings").mock( + return_value=Response(200, json={"success": True, "data": {"mappings": []}}) + ) + + sync_client.mappings.get(4167462) + assert "relationship_ids" not in str(route.calls[0].request.url) + + sync_client.mappings.get(4167462, relationship_ids=["Maps to", "Maps to value"]) + assert route.calls[1].request.url.params["relationship_ids"] == ( + "Maps to,Maps to value" + ) + + # A bare string is passed through unjoined, matching concepts.relationships(). + sync_client.mappings.get(4167462, relationship_ids="Maps to value") + assert route.calls[2].request.url.params["relationship_ids"] == "Maps to value" + + @respx.mock + def test_get_iter_forwards_relationship_ids( + self, sync_client: OMOPHub, base_url: str + ) -> None: + """The filter has to survive the pagination helper too.""" + route = respx.get(f"{base_url}/concepts/4167462/mappings").mock( + return_value=Response( + 200, + json={ + "success": True, + "data": {"mappings": []}, + "meta": { + "pagination": { + "page": 1, + "page_size": 100, + "total_items": 0, + "total_pages": 0, + "has_next": False, + "has_previous": False, + } + }, + }, + ) + ) + + list( + sync_client.mappings.get_iter( + 4167462, relationship_ids=["Maps to", "Maps to value"] + ) + ) + assert route.calls[0].request.url.params["relationship_ids"] == ( + "Maps to,Maps to value" + ) + @respx.mock def test_get_mappings_include_invalid_is_tri_state( self, sync_client: OMOPHub, base_url: str