Skip to content

Commit 190bb25

Browse files
authored
Issue #1028: create attribute + association atomically (fix orphaned attributes) (#1030)
Fixes #1028. ## Problem Adding an attribute in the MDR UI was **two separate, independently-committed requests** (`POST /attributes/` then `POST /entity_attribute_associations/`). If the first response was lost (network/idle-timeout), the client caught the error and never issued the second — leaving the attribute persisted but **unassociated**: invisible in the entity view (the list joins through `EntityAttributeAssociation`) yet blocking re-creation with a uniqueness error. A teammate hit this twice (`AchievementSubject.id` + `.achievement` on DataModel 3, demo/lif-team). Confirmed root cause + verified the backend happy path via a direct two-endpoint test; the failure is specifically the dropped-response-between-the-two-requests window. ## Fix **Backend — atomic create** (`components/lif/mdr_services/attribute_service.py`, `mdr_dto/attribute_dto.py`): - `CreateAttributeDTO` gains an optional `EntityId`. When set, `create_attribute` flushes the attribute then adds the entity association and **commits once**. A dropped response now leaves either nothing or a fully-associated (visible) attribute — never an orphan. - Sets the association's `ExtendedByDataModelId` for extension models (OrgLIF/PartnerLIF), mirroring the frontend template, so the single call is correct for extension data models too. - Backward compatible: no `EntityId` → attribute only, unchanged (import path etc. unaffected). **Frontend** (`frontends/mdr-frontend`): - `ModelExplorer.handleCreateAttribute` passes `EntityId` and drops the separate `createEntityAttributeAssociation` call — one request instead of two. (The separate-association dialog for *existing* attributes is unchanged.) - `api.ts`: add a **30s request timeout** so a stalled request fails fast/clearly instead of hanging into the ambiguous "Network error" that triggered the bug. ## Tests - Integration tests (live Postgres) in `test/bases/lif/mdr_restapi/test_attribute_atomic_create.py`: `EntityId` set → association created atomically; omitted → no association (backward-compat). Both green. - Backend pre-commit gate green; frontend `tsc` type-check clean. ## Notes - `npm run lint` currently crashes repo-wide on a pre-existing eslint-config typo (`@typescript-eslint/no-explicite-any`) — unrelated to this change; flagging separately. - Recovery for already-orphaned attributes: `GET /attributes/by_data_model_id/{id}?pagination=false` to find, `DELETE /attributes/{id}` to soft-delete (frees the name). Already cleaned up the reported two. ##### Type of Change - [x] Bug fix (non-breaking) - [x] API endpoints - [x] frontends/ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents e3b5ebd + 93a6ad4 commit 190bb25

6 files changed

Lines changed: 198 additions & 14 deletions

File tree

components/lif/mdr_dto/attribute_dto.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ class CreateAttributeDTO(BaseModel):
5353
ExtensionNotes: Optional[str] = None
5454
Example: Optional[str] = None
5555
Common: Optional[bool] = None
56-
# EntityId: int
56+
# When set, the attribute and its association to this entity are created in a single
57+
# transaction (see create_attribute) so a dropped response can't orphan the attribute (#1028).
58+
EntityId: Optional[int] = None
5759

5860

5961
class UpdateAttributeDTO(BaseModel):

components/lif/mdr_services/attribute_service.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
CreateAttributeDTO,
1717
UpdateAttributeDTO,
1818
)
19-
from lif.mdr_services.helper_service import check_datamodel_by_id
19+
from lif.mdr_services.helper_service import check_datamodel_by_id, check_entity_by_id
2020
from lif.mdr_services.value_set_values_service import check_value_set_exists_by_id
2121
from lif.mdr_utils.logger_config import get_logger
2222
from sqlalchemy.ext.asyncio import AsyncSession
@@ -96,9 +96,40 @@ async def create_attribute(session: AsyncSession, data: CreateAttributeDTO):
9696
if data.ValueSetId:
9797
await check_value_set_exists_by_id(session=session, id=data.ValueSetId)
9898

99-
attribute = Attribute(**data.dict())
99+
# When an EntityId is supplied, create the attribute AND its entity association in a single
100+
# transaction. Previously the UI created these via two separate, independently-committed
101+
# requests; if the first response was lost the client never issued the second, leaving the
102+
# attribute persisted but unassociated — invisible in the entity view yet blocking re-create
103+
# on the unique name (#1028). One commit means a dropped response leaves either nothing or a
104+
# fully-associated (visible) attribute, never an orphan.
105+
if data.EntityId is not None:
106+
await check_entity_by_id(session=session, id=data.EntityId)
107+
108+
attribute = Attribute(**data.dict(exclude={"EntityId"}))
100109
session.add(attribute)
101-
await session.commit()
110+
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
132+
102133
await session.refresh(attribute)
103134
attribute_dto = AttributeDTO.from_orm(attribute)
104135
return attribute_dto

frontends/mdr-frontend/src/components/ModelExplorer/ModelExplorer.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -407,21 +407,20 @@ const ModelTree: React.FC<ModelTreeProps> = ({
407407
const useEAA = parentNode?.type === 'entity';
408408
/** ^ Always want an association, but should also be under an Entity */
409409
const inclusionParams: any = useInclusion ? tmplCreateInclusion(model.Id, 0, 'Attribute') : {};
410-
const eAAParams: any = useEAA ? tmplCreateEntityAttributeAssociation(parentNode.id, model) : {};
411410

412411
const createdIds: any = {};
413412
try {
414-
const newAttribute: any = await createAttribute({ ...params, DataModelId: model.Id });
413+
// When adding under an Entity, pass EntityId so the API creates the attribute AND its
414+
// entity association in a single transaction (#1028). This replaces the old two-request
415+
// flow (create attribute, then create association), which orphaned the attribute if the
416+
// first response was lost — persisted-but-unassociated: invisible + blocking re-create.
417+
const newAttribute: any = await createAttribute({
418+
...params,
419+
DataModelId: model.Id,
420+
...(useEAA ? { EntityId: parentNode.id } : {}),
421+
});
415422
createdIds.Attribute = newAttribute?.Id;
416423
debugLog(">> Created Attribute:", newAttribute?.Id, newAttribute);
417-
if (newAttribute?.Id && useEAA) {
418-
eAAParams.AttributeId = newAttribute?.Id;
419-
eAAParams.Contributor = params.Contributor;
420-
eAAParams.ContributorOrganization = params.ContributorOrganization;
421-
const newEAA: any = await createEntityAttributeAssociation(eAAParams);
422-
createdIds.EntityAttributeAssociation = newEAA?.Id;
423-
debugLog(">> Created Entity Attribute Association:", newEAA?.Id, newEAA);
424-
}
425424
if (newAttribute?.Id && useInclusion) {
426425
inclusionParams.IncludedElementId = newAttribute?.Id;
427426
inclusionParams.Contributor = params.Contributor;

frontends/mdr-frontend/src/services/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const api = axios.create({
1212
// config to set Access-Control-Allow-Credentials: true and an explicit
1313
// origin (not "*"), which the MDR API already does.
1414
withCredentials: true,
15+
// Fail a stalled request instead of hanging indefinitely. Without a timeout, a request
16+
// whose response is lost hangs until the browser/OS gives up, surfacing as an ambiguous
17+
// "Network error" — the trigger for the orphaned-attribute class of bugs (#1028).
18+
timeout: 30000,
1519
});
1620

1721
// Add a response interceptor to handle token refresh

frontends/mdr-frontend/src/services/attributesService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export interface AttributeParams {
2525
ContributorOrganization?: string | null;
2626
Extension?: boolean;
2727
ExtensionNotes?: string | null;
28+
// When set, the API creates the attribute AND its association to this entity in one
29+
// transaction, so a dropped response can't orphan the attribute (#1028).
30+
EntityId?: number;
2831
}
2932

3033
export interface AttributeDTO {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Atomic create-attribute (Issue #1028).
2+
3+
Passing ``EntityId`` to ``create_attribute`` must create the attribute AND its
4+
entity association in a single transaction, so a dropped response can never leave
5+
an orphaned (persisted-but-unassociated) attribute. Without ``EntityId`` the
6+
behaviour is unchanged (attribute only). If either step fails, the whole create
7+
rolls back — never a half-created attribute.
8+
9+
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.
12+
"""
13+
14+
import pytest
15+
from fastapi import HTTPException
16+
from sqlmodel import select
17+
18+
from lif.datatypes.mdr_sql_model import Attribute, DataModel, DataModelType, Entity, EntityAttributeAssociation
19+
from lif.mdr_dto.attribute_dto import CreateAttributeDTO
20+
from lif.mdr_services import attribute_service
21+
from lif.mdr_services.attribute_service import create_attribute
22+
23+
24+
async def _seed_model_and_entity(session, tag):
25+
dm = DataModel(
26+
Name=f"AtomicCreateDM_{tag}",
27+
Type=DataModelType.SourceSchema,
28+
DataModelVersion="1.0",
29+
ContributorOrganization="Test Organization",
30+
Deleted=False,
31+
)
32+
session.add(dm)
33+
await session.commit()
34+
await session.refresh(dm)
35+
36+
name = f"Widget_{tag}"
37+
entity = Entity(Name=name, UniqueName=name, DataModelId=dm.Id, Array="No", Required="No", Deleted=False)
38+
session.add(entity)
39+
await session.commit()
40+
await session.refresh(entity)
41+
return dm, entity
42+
43+
44+
async def _associations_for(session, attribute_id):
45+
result = await session.execute(
46+
select(EntityAttributeAssociation).where(
47+
EntityAttributeAssociation.AttributeId == attribute_id,
48+
EntityAttributeAssociation.Deleted == False, # noqa: E712
49+
)
50+
)
51+
return result.scalars().all()
52+
53+
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):
60+
session = test_db_session
61+
dm, entity = await _seed_model_and_entity(session, request.node.name)
62+
63+
created = await create_attribute(
64+
session,
65+
CreateAttributeDTO(
66+
Name="color",
67+
UniqueName=f"{entity.UniqueName}.color",
68+
DataType="xsd:string",
69+
DataModelId=dm.Id,
70+
EntityId=entity.Id,
71+
),
72+
)
73+
74+
assert created.Id is not None
75+
# The association was created in the same call — the attribute is NOT orphaned.
76+
assocs = await _associations_for(session, created.Id)
77+
assert len(assocs) == 1
78+
assert assocs[0].EntityId == entity.Id
79+
80+
81+
async def test_create_attribute_without_entity_id_makes_no_association(test_db_session, request):
82+
"""Backward-compatible: no EntityId -> attribute only, no association (unchanged behaviour)."""
83+
session = test_db_session
84+
dm, _entity = await _seed_model_and_entity(session, request.node.name)
85+
86+
created = await create_attribute(
87+
session,
88+
CreateAttributeDTO(
89+
Name="size", UniqueName=f"{_entity.UniqueName}.size", DataType="xsd:string", DataModelId=dm.Id
90+
),
91+
)
92+
93+
assert created.Id is not None
94+
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)