001/*
002 * #%L
003 * HAPI FHIR JPA Server
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.jpa.dao;
021
022import ca.uhn.fhir.context.support.IValidationSupport;
023import ca.uhn.fhir.context.support.TranslateConceptResults;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.jpa.api.dao.IFhirResourceDaoConceptMap;
026import ca.uhn.fhir.jpa.api.model.TranslationRequest;
027import ca.uhn.fhir.jpa.model.cross.IBasePersistedResource;
028import ca.uhn.fhir.jpa.model.entity.ResourceTable;
029import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
030import ca.uhn.fhir.jpa.term.api.ITermConceptMappingSvc;
031import ca.uhn.fhir.rest.api.server.IBundleProvider;
032import ca.uhn.fhir.rest.api.server.RequestDetails;
033import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
034import ca.uhn.fhir.rest.param.TokenParam;
035import ca.uhn.fhir.rest.param.UriParam;
036import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
037import ca.uhn.fhir.util.OperationOutcomeUtil;
038import ca.uhn.fhir.util.ValidateUtil;
039import ca.uhn.hapi.converters.canonical.VersionCanonicalizer;
040import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
041import org.hl7.fhir.instance.model.api.IBaseResource;
042import org.hl7.fhir.r4.model.ConceptMap;
043import org.hl7.fhir.r4.model.Enumerations;
044import org.slf4j.Logger;
045import org.slf4j.LoggerFactory;
046import org.springframework.beans.factory.annotation.Autowired;
047
048import java.util.Date;
049import java.util.List;
050import java.util.stream.Collectors;
051
052import static org.apache.commons.lang3.StringUtils.isBlank;
053import static org.apache.commons.lang3.StringUtils.isNotBlank;
054
055public class JpaResourceDaoConceptMap<T extends IBaseResource> extends JpaResourceDao<T>
056                implements IFhirResourceDaoConceptMap<T> {
057
058        private static final Logger ourLog = LoggerFactory.getLogger(JpaResourceDaoConceptMap.class);
059
060        @Autowired
061        private ITermConceptMappingSvc myTermConceptMappingSvc;
062
063        @Autowired
064        private IValidationSupport myValidationSupport;
065
066        @Autowired
067        private VersionCanonicalizer myVersionCanonicalizer;
068
069        /**
070         * Operation: <code>ConceptMap/$translate</code>
071         */
072        @Override
073        public TranslateConceptResults translate(
074                        TranslationRequest theTranslationRequest, RequestDetails theRequestDetails) {
075                IValidationSupport.TranslateCodeRequest translateCodeRequest = theTranslationRequest.asTranslateCodeRequest();
076                return myValidationSupport.translateConcept(translateCodeRequest);
077        }
078
079        @Override
080        public ResourceTable updateEntity(
081                        RequestDetails theRequestDetails,
082                        IBaseResource theResource,
083                        IBasePersistedResource theEntity,
084                        Date theDeletedTimestampOrNull,
085                        boolean thePerformIndexing,
086                        boolean theUpdateVersion,
087                        TransactionDetails theTransactionDetails,
088                        boolean theForceUpdate,
089                        boolean theCreateNewHistoryEntry) {
090                ResourceTable retVal = super.updateEntity(
091                                theRequestDetails,
092                                theResource,
093                                theEntity,
094                                theDeletedTimestampOrNull,
095                                thePerformIndexing,
096                                theUpdateVersion,
097                                theTransactionDetails,
098                                theForceUpdate,
099                                theCreateNewHistoryEntry);
100
101                boolean entityWasSaved = !retVal.isUnchangedInCurrentOperation();
102                boolean shouldProcessUpdate = entityWasSaved && thePerformIndexing;
103                if (shouldProcessUpdate) {
104                        if (retVal.getDeleted() == null) {
105                                ConceptMap conceptMap = myVersionCanonicalizer.conceptMapToCanonical(theResource);
106                                myTermConceptMappingSvc.storeTermConceptMapAndChildren(retVal, conceptMap);
107                        } else {
108                                myTermConceptMappingSvc.deleteConceptMapAndChildren(retVal);
109                        }
110                }
111
112                return retVal;
113        }
114
115        /**
116         * Operation: <code>ConceptMap/$hapi.fhir.add-mapping</code>
117         */
118        @SuppressWarnings("unchecked")
119        @Override
120        public IBaseOperationOutcome addMapping(AddMappingRequest theRequest, RequestDetails theRequestDetails) {
121                String sourceDisplay = theRequest.getSourceDisplay();
122                String targetDisplay = theRequest.getTargetDisplay();
123                String equivalence = theRequest.getEquivalence();
124                ValidateUtil.isNotBlankOrThrowInvalidRequest(equivalence, "Equivalence must be provided");
125
126                ConceptMap conceptMapCanonical =
127                                fetchExistingConceptMapAndConvertToCanonical(theRequest, true, theRequestDetails);
128
129                List<ConceptMap.ConceptMapGroupComponent> groups = findOrCreateGroup(theRequest, conceptMapCanonical, true);
130                ConceptMap.ConceptMapGroupComponent group = groups.get(0);
131
132                List<ConceptMap.SourceElementComponent> sourceElements =
133                                findOrCreateSourceElements(theRequest, group, true, sourceDisplay);
134                ConceptMap.SourceElementComponent sourceElement = sourceElements.get(0);
135
136                findOrCreateTargetElement(theRequest, sourceElement, true, targetDisplay, equivalence);
137
138                T conceptMapToStore = (T) myVersionCanonicalizer.conceptMapFromCanonical(conceptMapCanonical);
139                if (conceptMapToStore.getIdElement().hasIdPart()) {
140                        update(conceptMapToStore, theRequestDetails);
141                } else {
142                        create(conceptMapToStore, theRequestDetails);
143                }
144
145                IBaseOperationOutcome operationOutcome = OperationOutcomeUtil.createOperationOutcome(
146                                OperationOutcomeUtil.OO_SEVERITY_WARN,
147                                "Mapping has been added",
148                                OperationOutcomeUtil.OO_ISSUE_CODE_PROCESSING,
149                                myFhirContext,
150                                null);
151                return operationOutcome;
152        }
153
154        /**
155         * Operation: <code>ConceptMap/$hapi.fhir.remove-mapping</code>
156         */
157        @SuppressWarnings("unchecked")
158        @Override
159        public IBaseOperationOutcome removeMapping(RemoveMappingRequest theRequest, RequestDetails theRequestDetails) {
160                ConceptMap conceptMapCanonical =
161                                fetchExistingConceptMapAndConvertToCanonical(theRequest, false, theRequestDetails);
162                if (conceptMapCanonical == null) {
163                        String message = "No ConceptMap found matching the given URL and/or version. No action performed.";
164                        ourLog.warn(message);
165                        IBaseOperationOutcome operationOutcome = OperationOutcomeUtil.createOperationOutcome(
166                                        OperationOutcomeUtil.OO_SEVERITY_WARN,
167                                        message,
168                                        OperationOutcomeUtil.OO_ISSUE_CODE_PROCESSING,
169                                        myFhirContext,
170                                        null);
171                        return operationOutcome;
172                }
173
174                int mappingsRemoved = 0;
175                List<ConceptMap.ConceptMapGroupComponent> groups = findOrCreateGroup(theRequest, conceptMapCanonical, false);
176                for (ConceptMap.ConceptMapGroupComponent group : groups) {
177
178                        List<ConceptMap.SourceElementComponent> sourceElements =
179                                        findOrCreateSourceElements(theRequest, group, false, null);
180                        for (ConceptMap.SourceElementComponent sourceElement : sourceElements) {
181
182                                List<ConceptMap.TargetElementComponent> targetElements =
183                                                findOrCreateTargetElement(theRequest, sourceElement, false, null, null);
184                                for (ConceptMap.TargetElementComponent targetElement : targetElements) {
185                                        mappingsRemoved++;
186                                        sourceElement.getTarget().remove(targetElement);
187                                }
188
189                                if (sourceElement.getTarget().isEmpty()) {
190                                        group.getElement().remove(sourceElement);
191                                }
192                        }
193                }
194
195                T conceptMapToStore = (T) myVersionCanonicalizer.conceptMapFromCanonical(conceptMapCanonical);
196                update(conceptMapToStore, theRequestDetails);
197
198                String message = "Removed " + mappingsRemoved + " ConceptMap mappings";
199                ourLog.info(message);
200                IBaseOperationOutcome operationOutcome = OperationOutcomeUtil.createOperationOutcome(
201                                OperationOutcomeUtil.OO_SEVERITY_WARN,
202                                message,
203                                OperationOutcomeUtil.OO_ISSUE_CODE_PROCESSING,
204                                myFhirContext,
205                                null);
206                return operationOutcome;
207        }
208
209        private static List<ConceptMap.TargetElementComponent> findOrCreateTargetElement(
210                        RemoveMappingRequest theRequest,
211                        ConceptMap.SourceElementComponent sourceElement,
212                        boolean theCreate,
213                        String targetDisplay,
214                        String equivalence) {
215                String targetCode = theRequest.getTargetCode();
216                ValidateUtil.isNotBlankOrThrowInvalidRequest(targetCode, "Target code must be provided");
217
218                List<ConceptMap.TargetElementComponent> retVal = sourceElement.getTarget().stream()
219                                .filter(t -> targetCode.equals(t.getCode()))
220                                .collect(Collectors.toList());
221
222                if (retVal.isEmpty() && theCreate) {
223                        ConceptMap.TargetElementComponent newTarget = sourceElement.addTarget();
224                        newTarget.setCode(targetCode);
225                        newTarget.setDisplay(targetDisplay);
226                        newTarget.setEquivalence(Enumerations.ConceptMapEquivalence.fromCode(equivalence));
227                        retVal.add(newTarget);
228                }
229
230                return retVal;
231        }
232
233        private static List<ConceptMap.SourceElementComponent> findOrCreateSourceElements(
234                        RemoveMappingRequest theRequest,
235                        ConceptMap.ConceptMapGroupComponent group,
236                        boolean theCreate,
237                        String sourceDisplay) {
238                String sourceCode = theRequest.getSourceCode();
239                ValidateUtil.isNotBlankOrThrowInvalidRequest(sourceCode, "Source code must be provided");
240
241                List<ConceptMap.SourceElementComponent> retVal = group.getElement().stream()
242                                .filter(t -> sourceCode.equals(t.getCode()))
243                                .collect(Collectors.toList());
244
245                if (retVal.isEmpty() && theCreate) {
246                        ConceptMap.SourceElementComponent newElement = group.addElement();
247                        newElement.setCode(sourceCode);
248                        newElement.setDisplay(sourceDisplay);
249                        retVal.add(newElement);
250                }
251
252                return retVal;
253        }
254
255        private static List<ConceptMap.ConceptMapGroupComponent> findOrCreateGroup(
256                        RemoveMappingRequest theRequest, ConceptMap conceptMapCanonical, boolean theCreateIfNotFound) {
257                String sourceSystem = theRequest.getSourceSystem();
258                ValidateUtil.isNotBlankOrThrowInvalidRequest(sourceSystem, "Source system must be provided");
259                String sourceSystemVersion = theRequest.getSourceSystemVersion();
260                String targetSystem = theRequest.getTargetSystem();
261                ValidateUtil.isNotBlankOrThrowInvalidRequest(sourceSystem, "Target system must be provided");
262                String targetSystemVersion = theRequest.getTargetSystemVersion();
263
264                List<ConceptMap.ConceptMapGroupComponent> retVal = conceptMapCanonical.getGroup().stream()
265                                .filter(t -> {
266                                        boolean match = sourceSystem.equals(t.getSource());
267                                        match &= targetSystem.equals(t.getTarget());
268                                        match &= isBlank(sourceSystemVersion) || sourceSystemVersion.equals(t.getSourceVersion());
269                                        match &= isBlank(targetSystemVersion) || targetSystemVersion.equals(t.getTargetVersion());
270                                        return match;
271                                })
272                                .collect(Collectors.toList());
273
274                if (retVal.isEmpty() && theCreateIfNotFound) {
275                        ConceptMap.ConceptMapGroupComponent newGroup = conceptMapCanonical.addGroup();
276                        newGroup.setSource(sourceSystem);
277                        newGroup.setSourceVersion(sourceSystemVersion);
278                        newGroup.setTarget(targetSystem);
279                        newGroup.setTargetVersion(targetSystemVersion);
280                        retVal.add(newGroup);
281                }
282
283                return retVal;
284        }
285
286        private ConceptMap fetchExistingConceptMapAndConvertToCanonical(
287                        RemoveMappingRequest theRequest, boolean theCreateIfNotFound, RequestDetails theRequestDetails) {
288                String conceptMapUrl = theRequest.getConceptMapUri();
289                ValidateUtil.isNotBlankOrThrowInvalidRequest(conceptMapUrl, "ConceptMap URI must be provided");
290                String conceptMapVersion = theRequest.getConceptMapVersion();
291
292                SearchParameterMap map = conceptMapUrlToParameterMap(conceptMapUrl, conceptMapVersion);
293                IBundleProvider bundle = search(map, theRequestDetails);
294
295                ConceptMap conceptMapCanonical;
296                if (bundle.sizeOrThrowNpe() > 1) {
297                        throw new InvalidRequestException(Msg.code(1743) + "Multiple ConceptMap resources match URL["
298                                        + conceptMapUrl + "]. Do you need to specify a version?");
299                } else if (bundle.isEmpty()) {
300
301                        if (theCreateIfNotFound) {
302                                ourLog.info("Creating new ConceptMap with URL: {}", conceptMapUrl);
303                                conceptMapCanonical = new ConceptMap();
304                                conceptMapCanonical.setUrl(conceptMapUrl);
305                                conceptMapCanonical.setDate(new Date());
306                                conceptMapCanonical.setDescription("Automatically created by HAPI FHIR");
307                        } else {
308                                conceptMapCanonical = null;
309                        }
310
311                } else {
312
313                        IBaseResource conceptMap = bundle.getResources(0, 1).get(0);
314                        conceptMapCanonical = myVersionCanonicalizer.conceptMapToCanonical(conceptMap);
315                }
316                return conceptMapCanonical;
317        }
318
319        /**
320         * @param theConceptMapUrl The URL. Can include a <code>|version</code> suffix, in which case
321         *                         {@literal theConceptMapVersion} should be null.
322         * @param theConceptMapVersion The version
323         */
324        public static SearchParameterMap conceptMapUrlToParameterMap(String theConceptMapUrl, String theConceptMapVersion) {
325
326                String url;
327                String version;
328
329                int pipeIndex = theConceptMapUrl.indexOf('|');
330                if (pipeIndex > 0) {
331                        url = theConceptMapUrl.substring(0, pipeIndex);
332                        version = theConceptMapUrl.substring(pipeIndex + 1);
333                } else {
334                        url = theConceptMapUrl;
335                        version = theConceptMapVersion;
336                }
337
338                if (isNotBlank(theConceptMapVersion) && isNotBlank(version) && !theConceptMapVersion.equals(version)) {
339                        throw new InvalidRequestException(Msg.code(2818) + "ConceptMap URL includes a version[" + version
340                                        + "] which conflicts with specified version[" + theConceptMapVersion + "]");
341                }
342
343                SearchParameterMap map = new SearchParameterMap();
344                map.setLoadSynchronousUpTo(2);
345                map.add(ConceptMap.SP_URL, new UriParam(url));
346                if (version != null) {
347                        map.add(ConceptMap.SP_VERSION, new TokenParam(version));
348                }
349                return map;
350        }
351}