Skip to content

Commit 65e0e5a

Browse files
committed
Refactor data models and mapper for improved role handling and validation; update tests accordingly
1 parent 7ab1c10 commit 65e0e5a

4 files changed

Lines changed: 47 additions & 36 deletions

File tree

middleware/sql_to_arc/src/middleware/sql_to_arc/mapper.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Mapper module to convert database rows to ARCTRL objects."""
22

3-
import json
43
from datetime import datetime
54
from typing import Any
65

@@ -116,17 +115,12 @@ def map_contact(row: ContactRow) -> Person:
116115
"""Map a database row to a Person object."""
117116
# Person(lastName, firstName, midInitials, email, phone, fax, address, affiliation, roles)
118117

119-
# Parse roles JSON
120-
roles_json = row.roles
118+
# row.roles is now already a list (it was Json[JsonList] and validated/parsed by Pydantic)
121119
roles = []
122-
if roles_json:
123-
try:
124-
roles_list = json.loads(roles_json)
125-
if isinstance(roles_list, list):
126-
for r in roles_list:
127-
roles.append(_make_oa(r.get("term"), r.get("uri"), r.get("version")))
128-
except json.JSONDecodeError:
129-
pass # Logger?
120+
if row.roles:
121+
for r in row.roles:
122+
if isinstance(r, dict):
123+
roles.append(_make_oa(r.get("term"), r.get("uri"), r.get("version")))
130124

131125
return Person(
132126
last_name=row.last_name,

middleware/sql_to_arc/src/middleware/sql_to_arc/models.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
from types import NoneType
88
from typing import Any, get_args, get_origin
99

10-
from pydantic import BaseModel, ConfigDict, Field, model_validator
10+
from pydantic import BaseModel, ConfigDict, Field, Json, model_validator
1111
from pydantic_core import PydanticUndefined
1212

1313
logger = logging.getLogger(__name__)
1414

15+
# JSON types representing the expected structure after parsing
16+
type JsonList = list[Any]
17+
18+
# Cache for schema-related warnings to avoid per-row logging
19+
_SCHEMA_WARNING_CACHE: set[tuple[str, str, str]] = set()
20+
1521

1622
def spec_field(
1723
*,
@@ -190,11 +196,14 @@ def _report_validation_issues(
190196
)
191197

192198
if missing_optional:
193-
logger.warning(
194-
'Table "%s" is missing optional columns: %s. Using default values.',
195-
cls.__name__,
196-
", ".join(missing_optional),
197-
)
199+
cache_key = (cls.__name__, "missing_optional", ",".join(sorted(missing_optional)))
200+
if cache_key not in _SCHEMA_WARNING_CACHE:
201+
logger.warning(
202+
'Table "%s" is missing optional columns: %s. Using default values.',
203+
cls.__name__,
204+
", ".join(missing_optional),
205+
)
206+
_SCHEMA_WARNING_CACHE.add(cache_key)
198207

199208
@model_validator(mode="before")
200209
@classmethod
@@ -208,11 +217,15 @@ def validate_row(cls, data: Any) -> Any:
208217
# 1. Check for extra columns
209218
extra_columns = sorted(set(row_mapping.keys()) - set(cls.model_fields.keys()))
210219
if extra_columns:
211-
logger.warning(
212-
'Table "%s": Input contains extra columns not defined in model: %s. Accepting due to extra="allow".',
213-
cls.__name__,
214-
", ".join(extra_columns),
215-
)
220+
cache_key = (cls.__name__, "extra_columns", ",".join(extra_columns))
221+
if cache_key not in _SCHEMA_WARNING_CACHE:
222+
logger.warning(
223+
'Table "%s": Input contains extra columns not defined in model: %s. '
224+
'Accepting due to extra="allow".',
225+
cls.__name__,
226+
", ".join(extra_columns),
227+
)
228+
_SCHEMA_WARNING_CACHE.add(cache_key)
216229

217230
# 2. Process field values (NULLs, Coercion, Overrides)
218231
coerced_fields: list[str] = []
@@ -274,7 +287,7 @@ class AssayRow(BaseRow):
274287

275288
identifier: str = spec_field()
276289
investigation_ref: str = spec_field()
277-
study_ref: str | None = spec_field(default=None)
290+
study_ref: Json[JsonList] | None = spec_field(default=None)
278291
title: str | None = spec_field(default=None)
279292
description_text: str | None = spec_field(default=None)
280293
measurement_type_term: str | None = spec_field(default=None)
@@ -314,5 +327,5 @@ class ContactRow(BaseRow):
314327
fax: str | None = spec_field(default=None)
315328
postal_address: str | None = spec_field(default=None)
316329
affiliation: str | None = spec_field(default=None)
317-
roles: str | None = spec_field(default=None) # JSON string
330+
roles: Json[JsonList] | None = spec_field(default=None)
318331
target_ref: str | None = spec_field(default=None)

middleware/sql_to_arc/tests/unit/test_database_validation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from middleware.sql_to_arc.database import Database
88
from middleware.sql_to_arc.models import (
9+
_SCHEMA_WARNING_CACHE,
910
BaseRow,
1011
InvestigationRow,
1112
spec_field,
@@ -35,6 +36,9 @@ async def test_validate_row_missing_required_aborts(caplog: pytest.LogCaptureFix
3536
@pytest.mark.asyncio
3637
async def test_validate_row_missing_optional_warns(caplog: pytest.LogCaptureFixture) -> None:
3738
"""Test that missing optional columns cause a warning but proceed."""
39+
# Ensure cache is clear for this test
40+
_SCHEMA_WARNING_CACHE.clear()
41+
3842
# submission_date is optional
3943
row = {"identifier": "1", "title": "Test", "description_text": "Present"}
4044

middleware/sql_to_arc/tests/unit/test_mapper.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import datetime
44

5+
import pytest
56
from arctrl import ( # type: ignore[import-untyped, import-not-found]
67
ArcAssay,
78
ArcInvestigation,
89
ArcStudy,
910
Person,
1011
Publication,
1112
)
13+
from pydantic import ValidationError
1214

1315
from middleware.sql_to_arc.mapper import (
1416
map_annotation,
@@ -105,7 +107,7 @@ def test_map_assay() -> None:
105107
"""Test mapping of assay data."""
106108
row = AssayRow(
107109
identifier="1",
108-
study_ref="sty1",
110+
study_ref=["sty1"],
109111
investigation_ref="inv1",
110112
measurement_type_term="Proteomics",
111113
measurement_type_uri="http://example.org/prot",
@@ -130,7 +132,7 @@ def test_map_assay_with_platform() -> None:
130132
"""Test mapping of assay data including technology platform."""
131133
row = AssayRow(
132134
identifier="2",
133-
study_ref="sty1",
135+
study_ref=["sty1"],
134136
investigation_ref="inv1",
135137
technology_platform="Orbitrap",
136138
)
@@ -171,7 +173,7 @@ def test_map_contact() -> None:
171173
last_name="Doe",
172174
first_name="John",
173175
email="john@example.com",
174-
roles='[{"term": "Principal Investigator", "uri": "http://roles", "version": "1.0"}]',
176+
roles=[{"term": "Principal Investigator", "uri": "http://roles", "version": "1.0"}],
175177
)
176178

177179
person = map_contact(row)
@@ -188,15 +190,13 @@ def test_map_contact() -> None:
188190

189191
def test_map_contact_invalid_roles() -> None:
190192
"""Test mapping of contact data with invalid roles JSON."""
191-
row = ContactRow(
192-
investigation_ref="inv1",
193-
target_type="investigation",
194-
last_name="Smith",
195-
roles="invalid json string",
196-
)
197-
person = map_contact(row)
198-
assert person.LastName == "Smith"
199-
assert person.Roles == []
193+
with pytest.raises(ValidationError):
194+
ContactRow(
195+
investigation_ref="inv1",
196+
target_type="investigation",
197+
last_name="Smith",
198+
roles=None,
199+
)
200200

201201

202202
def test_map_annotation() -> None:

0 commit comments

Comments
 (0)