Skip to content

Commit 93a6ad4

Browse files
bjaggclaude
andcommitted
Issue #1028: address review — rollback on failure, generic test data, failure tests
Per cbeach47's review of #1030: - create_attribute: wrap the flush/association/commit in try/except that rolls back on any failure, so a failed association can't leave a half-created attribute (makes the atomicity guarantee explicit). - tests: drop the 'Unicon' name (use 'Test Organization'); derive seed names from the test name (request.node.name) to avoid cross-test collisions; add two failure tests — attribute-creation failure creates no association, and association failure rolls back the attribute (no orphan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c1780ef commit 93a6ad4

2 files changed

Lines changed: 91 additions & 24 deletions

File tree

components/lif/mdr_services/attribute_service.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,22 +108,28 @@ async def create_attribute(session: AsyncSession, data: CreateAttributeDTO):
108108
attribute = Attribute(**data.dict(exclude={"EntityId"}))
109109
session.add(attribute)
110110

111-
if data.EntityId is not None:
112-
await session.flush() # assign attribute.Id without committing
113-
# For an extension model (OrgLIF/PartnerLIF — data.Extension is derived from the model's
114-
# BaseDataModelId above), the association is an extension placement onto this model, matching
115-
# the frontend's tmplCreateEntityAttributeAssociation (ExtendedByDataModelId = the model id).
116-
association = EntityAttributeAssociation(
117-
EntityId=data.EntityId,
118-
AttributeId=attribute.Id,
119-
Contributor=data.Contributor,
120-
ContributorOrganization=data.ContributorOrganization,
121-
Deleted=False,
122-
ExtendedByDataModelId=data.DataModelId if data.Extension else None,
123-
)
124-
session.add(association)
111+
try:
112+
if data.EntityId is not None:
113+
await session.flush() # assign attribute.Id without committing
114+
# For an extension model (OrgLIF/PartnerLIF — data.Extension is derived from the model's
115+
# BaseDataModelId above), the association is an extension placement onto this model, matching
116+
# the frontend's tmplCreateEntityAttributeAssociation (ExtendedByDataModelId = the model id).
117+
association = EntityAttributeAssociation(
118+
EntityId=data.EntityId,
119+
AttributeId=attribute.Id,
120+
Contributor=data.Contributor,
121+
ContributorOrganization=data.ContributorOrganization,
122+
Deleted=False,
123+
ExtendedByDataModelId=data.DataModelId if data.Extension else None,
124+
)
125+
session.add(association)
126+
await session.commit()
127+
except Exception:
128+
# Keep the create atomic: if the association or commit fails, roll back the attribute too,
129+
# so we never persist a half-created (orphaned) attribute (#1028).
130+
await session.rollback()
131+
raise
125132

126-
await session.commit()
127133
await session.refresh(attribute)
128134
attribute_dto = AttributeDTO.from_orm(attribute)
129135
return attribute_dto

test/bases/lif/mdr_restapi/test_attribute_atomic_create.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,30 @@
33
Passing ``EntityId`` to ``create_attribute`` must create the attribute AND its
44
entity association in a single transaction, so a dropped response can never leave
55
an orphaned (persisted-but-unassociated) attribute. Without ``EntityId`` the
6-
behaviour is unchanged (attribute only, no association).
6+
behaviour is unchanged (attribute only). If either step fails, the whole create
7+
rolls back — never a half-created attribute.
78
89
Drives the real service against a live Postgres (``test_db_session`` fixture).
10+
Seed names are derived from the test name (``request.node.name``) since the
11+
fixture DB is shared across tests in this module.
912
"""
1013

14+
import pytest
15+
from fastapi import HTTPException
1116
from sqlmodel import select
1217

13-
from lif.datatypes.mdr_sql_model import DataModel, DataModelType, Entity, EntityAttributeAssociation
18+
from lif.datatypes.mdr_sql_model import Attribute, DataModel, DataModelType, Entity, EntityAttributeAssociation
1419
from lif.mdr_dto.attribute_dto import CreateAttributeDTO
20+
from lif.mdr_services import attribute_service
1521
from lif.mdr_services.attribute_service import create_attribute
1622

1723

1824
async def _seed_model_and_entity(session, tag):
19-
# Unique names per test — the fixture DB is shared across tests in this module.
2025
dm = DataModel(
2126
Name=f"AtomicCreateDM_{tag}",
2227
Type=DataModelType.SourceSchema,
2328
DataModelVersion="1.0",
24-
ContributorOrganization="UniconQA",
29+
ContributorOrganization="Test Organization",
2530
Deleted=False,
2631
)
2732
session.add(dm)
@@ -46,9 +51,14 @@ async def _associations_for(session, attribute_id):
4651
return result.scalars().all()
4752

4853

49-
async def test_create_attribute_with_entity_id_creates_association_atomically(test_db_session):
54+
async def _attributes_named(session, unique_name):
55+
result = await session.execute(select(Attribute).where(Attribute.UniqueName == unique_name))
56+
return result.scalars().all()
57+
58+
59+
async def test_create_attribute_with_entity_id_creates_association_atomically(test_db_session, request):
5060
session = test_db_session
51-
dm, entity = await _seed_model_and_entity(session, "assoc")
61+
dm, entity = await _seed_model_and_entity(session, request.node.name)
5262

5363
created = await create_attribute(
5464
session,
@@ -68,17 +78,68 @@ async def test_create_attribute_with_entity_id_creates_association_atomically(te
6878
assert assocs[0].EntityId == entity.Id
6979

7080

71-
async def test_create_attribute_without_entity_id_makes_no_association(test_db_session):
81+
async def test_create_attribute_without_entity_id_makes_no_association(test_db_session, request):
7282
"""Backward-compatible: no EntityId -> attribute only, no association (unchanged behaviour)."""
7383
session = test_db_session
74-
dm, entity = await _seed_model_and_entity(session, "noassoc")
84+
dm, _entity = await _seed_model_and_entity(session, request.node.name)
7585

7686
created = await create_attribute(
7787
session,
7888
CreateAttributeDTO(
79-
Name="size", UniqueName=f"{entity.UniqueName}.size", DataType="xsd:string", DataModelId=dm.Id
89+
Name="size", UniqueName=f"{_entity.UniqueName}.size", DataType="xsd:string", DataModelId=dm.Id
8090
),
8191
)
8292

8393
assert created.Id is not None
8494
assert await _associations_for(session, created.Id) == []
95+
96+
97+
async def test_failure_creating_attribute_creates_no_association(test_db_session, request):
98+
"""If the attribute step fails (duplicate unique name), the failed attempt creates no association."""
99+
session = test_db_session
100+
dm, entity = await _seed_model_and_entity(session, request.node.name)
101+
dto = CreateAttributeDTO(
102+
Name="dup", UniqueName=f"{entity.UniqueName}.dup", DataType="xsd:string", DataModelId=dm.Id, EntityId=entity.Id
103+
)
104+
105+
first = await create_attribute(session, dto) # succeeds (attribute + association)
106+
107+
# A second create with the same unique name fails the uniqueness check.
108+
with pytest.raises(HTTPException) as exc:
109+
await create_attribute(session, dto)
110+
assert exc.value.status_code == 400
111+
112+
# Only the first attribute's association exists — the failed retry added nothing.
113+
result = await session.execute(
114+
select(EntityAttributeAssociation).where(
115+
EntityAttributeAssociation.EntityId == entity.Id,
116+
EntityAttributeAssociation.Deleted == False, # noqa: E712
117+
)
118+
)
119+
assocs = result.scalars().all()
120+
assert len(assocs) == 1
121+
assert assocs[0].AttributeId == first.Id
122+
123+
124+
async def test_failure_creating_association_rolls_back_the_attribute(test_db_session, request, monkeypatch):
125+
"""If the association step fails, the attribute must roll back too — no orphaned attribute."""
126+
session = test_db_session
127+
dm, entity = await _seed_model_and_entity(session, request.node.name)
128+
129+
class _BoomAssociation:
130+
def __init__(self, *args, **kwargs):
131+
raise RuntimeError("simulated association-creation failure")
132+
133+
monkeypatch.setattr(attribute_service, "EntityAttributeAssociation", _BoomAssociation)
134+
135+
unique_name = f"{entity.UniqueName}.rollback"
136+
with pytest.raises(RuntimeError):
137+
await create_attribute(
138+
session,
139+
CreateAttributeDTO(
140+
Name="rollback", UniqueName=unique_name, DataType="xsd:string", DataModelId=dm.Id, EntityId=entity.Id
141+
),
142+
)
143+
144+
# The attribute must NOT have persisted — it was rolled back with the failed association.
145+
assert await _attributes_named(session, unique_name) == []

0 commit comments

Comments
 (0)