diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorService.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorService.java new file mode 100644 index 0000000..9678f1d --- /dev/null +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorService.java @@ -0,0 +1,65 @@ +package edu.harvard.dbmi.avillach.dictionary; + +import edu.harvard.dbmi.avillach.dictionary.concept.ConceptService; +import edu.harvard.dbmi.avillach.dictionary.concept.model.Concept; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ConceptDecoratorService { + + private static final Logger LOG = LoggerFactory.getLogger(ConceptDecoratorService.class); + private final boolean enabled; + private final ConceptService conceptService; + + private static final int COMPLIANT = 4, NON_COMPLIANT_TABLED = 3, NON_COMPLIANT_UNTABLED = 2; + + @Autowired + public ConceptDecoratorService( + @Value("${dashboard.enable.extra_details}") boolean enabled, + @Lazy ConceptService conceptService // circular dep + ) { + this.enabled = enabled; + this.conceptService = conceptService; + } + + + public Concept populateParentConcepts(Concept concept) { + if (!enabled) { + return concept; + } + + // In some environments, certain parent concepts have critical details that we need to add to the detailed response + List conceptNodes = List.of(concept.conceptPath() + .split("\\\\")); // you have to double escape the slash. Once for strings, and once for regex + + return switch (conceptNodes.size()) { + case COMPLIANT, NON_COMPLIANT_TABLED -> populateTabledConcept(concept, conceptNodes); + case NON_COMPLIANT_UNTABLED -> populateNonCompliantTabledConcept(concept, conceptNodes); + default -> { + LOG.warn("Ignoring decoration request for weird concept path {}", concept.conceptPath()); + yield concept; + } + }; + } + + private Concept populateTabledConcept(Concept concept, List conceptNodes) { + String studyPath = String.join("\\", conceptNodes.subList(0, 1)) + "\\"; + String tablePath = String.join("\\", conceptNodes.subList(0, 2)) + "\\"; + Concept study = conceptService.conceptDetail(concept.dataset(), studyPath).orElse(null); + Concept table = conceptService.conceptDetail(concept.dataset(), tablePath).orElse(null); + return concept.withStudy(study).withTable(table); + } + + private Concept populateNonCompliantTabledConcept(Concept concept, List conceptNodes) { + String studyPath = String.join("\\", conceptNodes.subList(0, 1)); + Concept study = conceptService.conceptDetail(concept.dataset(), studyPath).orElse(null); + return concept.withStudy(study); + } +} diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptService.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptService.java index 9e7dca0..451812a 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptService.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptService.java @@ -1,5 +1,6 @@ package edu.harvard.dbmi.avillach.dictionary.concept; +import edu.harvard.dbmi.avillach.dictionary.ConceptDecoratorService; import edu.harvard.dbmi.avillach.dictionary.concept.model.CategoricalConcept; import edu.harvard.dbmi.avillach.dictionary.concept.model.Concept; import edu.harvard.dbmi.avillach.dictionary.concept.model.ConceptShell; @@ -20,9 +21,12 @@ public class ConceptService { private final ConceptRepository conceptRepository; + private final ConceptDecoratorService conceptDecoratorService; + @Autowired - public ConceptService(ConceptRepository conceptRepository) { + public ConceptService(ConceptRepository conceptRepository, ConceptDecoratorService conceptDecoratorService) { this.conceptRepository = conceptRepository; + this.conceptDecoratorService = conceptDecoratorService; } public List listConcepts(Filter filter, Pageable page) { @@ -53,7 +57,7 @@ public Optional conceptDetail(String dataset, String conceptPath) { case ConceptShell ignored -> throw new RuntimeException("Concept shell escaped to API"); }; } - ); + ).map(conceptDecoratorService::populateParentConcepts); } public Optional conceptTree(String dataset, String conceptPath, int depth) { diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/CategoricalConcept.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/CategoricalConcept.java index ef3b60c..1b9283c 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/CategoricalConcept.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/CategoricalConcept.java @@ -16,10 +16,23 @@ public record CategoricalConcept( List children, @Nullable - Map meta + Map meta, + + @Nullable + Concept table, + + @Nullable + Concept study ) implements Concept { + public CategoricalConcept( + String conceptPath, String name, String display, String dataset, String description, List values, + @Nullable List children, @Nullable Map meta + ) { + this(conceptPath, name, display, dataset, description, values, children, meta, null, null); + } + public CategoricalConcept(CategoricalConcept core, Map meta) { this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.values, core.children, meta); } @@ -44,6 +57,20 @@ public CategoricalConcept withChildren(List children) { return new CategoricalConcept(this, children); } + @Override + public Concept withTable(Concept table) { + return new CategoricalConcept( + conceptPath, name, display, dataset, description, values, children, meta, table, study + ); + } + + @Override + public Concept withStudy(Concept study) { + return new CategoricalConcept( + conceptPath, name, display, dataset, description, values, children, meta, table, study + ); + } + @Override public boolean equals(Object object) { return conceptEquals(object); diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/Concept.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/Concept.java index 17d70dd..ac9998b 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/Concept.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/Concept.java @@ -49,6 +49,10 @@ public sealed interface Concept */ ConceptType type(); + Concept table(); + + Concept study(); + Map meta(); @Nullable @@ -56,6 +60,10 @@ public sealed interface Concept Concept withChildren(List children); + Concept withTable(Concept table); + + Concept withStudy(Concept study); + default boolean conceptEquals(Object object) { if (this == object) return true; if (!(object instanceof Concept)) return false; diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptShell.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptShell.java index d904632..41b909e 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptShell.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptShell.java @@ -22,6 +22,16 @@ public ConceptType type() { return ConceptType.Continuous; } + @Override + public Concept table() { + return null; + } + + @Override + public Concept study() { + return null; + } + @Override public Map meta() { return Map.of(); @@ -37,6 +47,16 @@ public ConceptShell withChildren(List children) { return this; } + @Override + public Concept withTable(Concept table) { + return this; + } + + @Override + public Concept withStudy(Concept study) { + return this; + } + @Override public boolean equals(Object object) { return conceptEquals(object); diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ContinuousConcept.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ContinuousConcept.java index 48a9f2f..0e84545 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ContinuousConcept.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ContinuousConcept.java @@ -14,9 +14,22 @@ public record ContinuousConcept( @Nullable Integer min, @Nullable Integer max, Map meta, @Nullable - List children + List children, + + @Nullable + Concept table, + + @Nullable + Concept study ) implements Concept { + public ContinuousConcept( + String conceptPath, String name, String display, String dataset, String description, + @Nullable Integer min, @Nullable Integer max, Map meta, @Nullable List children + ) { + this(conceptPath, name, display, dataset, description, min, max, meta, children, null, null); + } + public ContinuousConcept(ContinuousConcept core, Map meta) { this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.min, core.max, meta, core.children); } @@ -47,6 +60,20 @@ public ContinuousConcept withChildren(List children) { return new ContinuousConcept(this, children); } + @Override + public Concept withTable(Concept table) { + return new ContinuousConcept( + conceptPath, name, display, dataset, description, min, max, meta, children, table, study + ); + } + + @Override + public Concept withStudy(Concept study) { + return new ContinuousConcept( + conceptPath, name, display, dataset, description, min, max, meta, children, table, study + ); + } + @Override public boolean equals(Object object) { return conceptEquals(object); diff --git a/src/main/resources/application-bdc.properties b/src/main/resources/application-bdc.properties index 3660041..f3d1d65 100644 --- a/src/main/resources/application-bdc.properties +++ b/src/main/resources/application-bdc.properties @@ -4,3 +4,5 @@ spring.datasource.driver-class-name=com.amazonaws.secretsmanager.sql.AWSSecretsM spring.datasource.url=jdbc-secretsmanager:postgresql://${DATASOURCE_URL}/picsure?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&autoReconnectForPools=true¤tSchema=dict spring.datasource.username=${DATASOURCE_USERNAME} server.port=80 + +dashboard.enable.extra_details=true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 656fc1f..127664a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,4 +7,5 @@ server.port=80 dashboard.columns={abbreviation:'Abbreviation',name:'Name',clinvars:'Clinical Variables'} dashboard.column-order=abbreviation,name,clinvars -dashboard.nonmeta-columns=abbreviation,name \ No newline at end of file +dashboard.nonmeta-columns=abbreviation,name +dashboard.enable.extra_details=true \ No newline at end of file diff --git a/src/test/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorServiceTest.java b/src/test/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorServiceTest.java new file mode 100644 index 0000000..8778ac8 --- /dev/null +++ b/src/test/java/edu/harvard/dbmi/avillach/dictionary/ConceptDecoratorServiceTest.java @@ -0,0 +1,72 @@ +package edu.harvard.dbmi.avillach.dictionary; + +import edu.harvard.dbmi.avillach.dictionary.concept.ConceptService; +import edu.harvard.dbmi.avillach.dictionary.concept.model.CategoricalConcept; +import edu.harvard.dbmi.avillach.dictionary.concept.model.Concept; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.Optional; + + +@SpringBootTest +class ConceptDecoratorServiceTest { + + @MockBean + ConceptService conceptService; + + @Autowired + ConceptDecoratorService subject; + + @Test + void shouldPopulateCompliantStudy() { + CategoricalConcept concept = new CategoricalConcept("\\study\\table\\idk\\concept\\", "dataset"); + CategoricalConcept table = new CategoricalConcept("\\study\\table\\", "dataset"); + CategoricalConcept study = new CategoricalConcept("\\study\\", "dataset"); + + Mockito.when(conceptService.conceptDetail("dataset", table.dataset())) + .thenReturn(Optional.of(table)); + Mockito.when(conceptService.conceptDetail("dataset", study.dataset())) + .thenReturn(Optional.of(study)); + + Concept actual = subject.populateParentConcepts(concept); + Concept expected = concept.withStudy(study).withTable(table); + + Assertions.assertEquals(expected, actual); + } + + @Test + void shouldPopulateNonCompliantTabledStudy() { + CategoricalConcept concept = new CategoricalConcept("\\study\\table\\concept\\", "dataset"); + CategoricalConcept table = new CategoricalConcept("\\study\\table\\", "dataset"); + CategoricalConcept study = new CategoricalConcept("\\study\\", "dataset"); + + Mockito.when(conceptService.conceptDetail("dataset", table.dataset())) + .thenReturn(Optional.of(table)); + Mockito.when(conceptService.conceptDetail("dataset", study.dataset())) + .thenReturn(Optional.of(study)); + + Concept actual = subject.populateParentConcepts(concept); + Concept expected = concept.withStudy(study).withTable(table); + + Assertions.assertEquals(expected, actual); + } + + @Test + void shouldPopulateNonCompliantUnTabledStudy() { + CategoricalConcept concept = new CategoricalConcept("\\study\\concept\\", "dataset"); + CategoricalConcept study = new CategoricalConcept("\\study\\", "dataset"); + + Mockito.when(conceptService.conceptDetail("dataset", study.dataset())) + .thenReturn(Optional.of(study)); + + Concept actual = subject.populateParentConcepts(concept); + Concept expected = concept.withStudy(study); + + Assertions.assertEquals(expected, actual); + } +} \ No newline at end of file diff --git a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptServiceTest.java b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptServiceTest.java index 1757774..7299a6a 100644 --- a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptServiceTest.java +++ b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptServiceTest.java @@ -1,5 +1,6 @@ package edu.harvard.dbmi.avillach.dictionary.concept; +import edu.harvard.dbmi.avillach.dictionary.ConceptDecoratorService; import edu.harvard.dbmi.avillach.dictionary.concept.model.CategoricalConcept; import edu.harvard.dbmi.avillach.dictionary.concept.model.Concept; import edu.harvard.dbmi.avillach.dictionary.concept.model.ConceptShell; @@ -24,6 +25,9 @@ class ConceptServiceTest { @MockBean ConceptRepository repository; + @MockBean + ConceptDecoratorService decoratorService; + @Autowired ConceptService subject; @@ -59,6 +63,8 @@ void shouldShowDetailForContinuous() { Map meta = Map.of("MIN", "0", "MAX", "1", "stigmatizing", "true"); Mockito.when(repository.getConcept("dataset", "path")) .thenReturn(Optional.of(concept)); + Mockito.when(decoratorService.populateParentConcepts(Mockito.any())) + .thenAnswer(i -> i.getArguments()[0]); Mockito.when(repository.getConceptMeta("dataset", "path")) .thenReturn(meta); @@ -74,6 +80,8 @@ void shouldShowDetailForCategorical() { Map meta = Map.of("VALUES", "a", "stigmatizing", "true"); Mockito.when(repository.getConcept("dataset", "path")) .thenReturn(Optional.of(concept)); + Mockito.when(decoratorService.populateParentConcepts(Mockito.any())) + .thenAnswer(i -> i.getArguments()[0]); Mockito.when(repository.getConceptMeta("dataset", "path")) .thenReturn(meta); diff --git a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptTest.java b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptTest.java index 9181493..fec3731 100644 --- a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptTest.java +++ b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/model/ConceptTest.java @@ -75,7 +75,7 @@ void shouldIncludeTypeInList() throws JsonProcessingException { ); String actual = new ObjectMapper().writeValueAsString(concepts); - String expected = "[{\"conceptPath\":\"/foo//baz\",\"name\":\"baz\",\"display\":\"Baz\",\"dataset\":\"study_a\",\"description\":null,\"min\":0,\"max\":1,\"meta\":{},\"children\":null,\"type\":\"Continuous\"},{\"conceptPath\":\"/foo//bar\",\"name\":\"bar\",\"display\":\"Bar\",\"dataset\":\"study_a\",\"description\":null,\"values\":[\"a\",\"b\"],\"children\":null,\"meta\":{},\"type\":\"Categorical\"}]"; + String expected = "[{\"conceptPath\":\"/foo//baz\",\"name\":\"baz\",\"display\":\"Baz\",\"dataset\":\"study_a\",\"description\":null,\"min\":0,\"max\":1,\"meta\":{},\"children\":null,\"table\":null,\"study\":null,\"type\":\"Continuous\"},{\"conceptPath\":\"/foo//bar\",\"name\":\"bar\",\"display\":\"Bar\",\"dataset\":\"study_a\",\"description\":null,\"values\":[\"a\",\"b\"],\"children\":null,\"meta\":{},\"table\":null,\"study\":null,\"type\":\"Categorical\"}]"; Assertions.assertEquals(expected, actual); } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index f5db8d5..bda1b5f 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -7,3 +7,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver dashboard.columns={abbreviation:'Abbreviation',melast:'This one goes last',name:'Name',clinvars:'Clinical Variables',participants:'Participants'} dashboard.column-order=abbreviation,name,clinvars dashboard.nonmeta-columns=abbreviation,name + +dashboard.enable.extra_details=true \ No newline at end of file