diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepository.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepository.java index 9dc7b89..f46657c 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepository.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepository.java @@ -5,11 +5,15 @@ import edu.harvard.dbmi.avillach.dictionary.filter.QueryParamPair; import edu.harvard.dbmi.avillach.dictionary.util.MapExtractor; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.List; import java.util.Map; import java.util.Optional; @@ -23,17 +27,19 @@ public class ConceptRepository { private final ConceptFilterQueryGenerator filterGen; private final ConceptMetaExtractor conceptMetaExtractor; + private final ConceptResultSetExtractor conceptResultSetExtractor; @Autowired public ConceptRepository( NamedParameterJdbcTemplate template, ConceptRowMapper mapper, ConceptFilterQueryGenerator filterGen, - ConceptMetaExtractor conceptMetaExtractor + ConceptMetaExtractor conceptMetaExtractor, ConceptResultSetExtractor conceptResultSetExtractor ) { this.template = template; this.mapper = mapper; this.filterGen = filterGen; this.conceptMetaExtractor = conceptMetaExtractor; + this.conceptResultSetExtractor = conceptResultSetExtractor; } @@ -133,4 +139,87 @@ public Map> getConceptMetaForConcepts(List return template.query(sql, params, conceptMetaExtractor); } + + public Optional getConceptTree(String dataset, String conceptPath, int depth) { + String sql = """ + WITH core_query AS ( + WITH RECURSIVE nodes AS ( + SELECT + concept_node_id, parent_id, 0 AS depth + FROM + concept_node + LEFT JOIN dataset ON concept_node.dataset_id = dataset.dataset_id + WHERE + concept_node.CONCEPT_PATH = :path + AND dataset.REF = :dataset + UNION + SELECT + child_nodes.concept_node_id, child_nodes.parent_id, parent_node.depth+ 1 + FROM + concept_node child_nodes + INNER JOIN nodes parent_node ON child_nodes.parent_id = parent_node.concept_node_id + LEFT JOIN dataset ON child_nodes.dataset_id = dataset.dataset_id + ) + SELECT + depth, child_nodes.concept_node_id + FROM + nodes parent_node + INNER JOIN concept_node child_nodes ON child_nodes.parent_id = parent_node.concept_node_id + WHERE + depth < :depth + UNION + SELECT + 0 as depth, concept_node.concept_node_id + FROM + concept_node + LEFT JOIN dataset ON concept_node.dataset_id = dataset.dataset_id + WHERE + concept_node.CONCEPT_PATH = :path + AND dataset.REF = :dataset + UNION + SELECT + -1 as depth, concept_node.concept_node_id + FROM + concept_node + WHERE + concept_node.concept_node_id = ( + SELECT + parent_id + FROM + concept_node + LEFT JOIN dataset ON concept_node.dataset_id = dataset.dataset_id + WHERE + concept_node.CONCEPT_PATH = :path + AND dataset.REF = :dataset + ) + ORDER BY depth ASC + ) + SELECT + concept_node.*, + ds.REF AS dataset, + continuous_min.VALUE AS min, continuous_max.VALUE AS max, + categorical_values.VALUE AS values, + meta_description.VALUE AS description, + core_query.depth AS depth + FROM + concept_node + INNER JOIN core_query ON concept_node.concept_node_id = core_query.concept_node_id + LEFT JOIN dataset AS ds ON concept_node.dataset_id = ds.dataset_id + LEFT JOIN concept_node_meta AS meta_description ON concept_node.concept_node_id = meta_description.concept_node_id AND meta_description.KEY = 'description' + LEFT JOIN concept_node_meta AS continuous_min ON concept_node.concept_node_id = continuous_min.concept_node_id AND continuous_min.KEY = 'min' + LEFT JOIN concept_node_meta AS continuous_max ON concept_node.concept_node_id = continuous_max.concept_node_id AND continuous_max.KEY = 'max' + LEFT JOIN concept_node_meta AS categorical_values ON concept_node.concept_node_id = categorical_values.concept_node_id AND categorical_values.KEY = 'values' + """; + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("path", conceptPath) + .addValue("dataset", dataset) + .addValue("depth", depth); + + if (depth < 0) { + return Optional.empty(); + } + + return Optional.ofNullable(template.query(sql, params, conceptResultSetExtractor)); + + } } diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetExtractor.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetExtractor.java new file mode 100644 index 0000000..370c3e9 --- /dev/null +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetExtractor.java @@ -0,0 +1,66 @@ +package edu.harvard.dbmi.avillach.dictionary.concept; + +import edu.harvard.dbmi.avillach.dictionary.concept.model.Concept; +import edu.harvard.dbmi.avillach.dictionary.concept.model.ConceptType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.secretsmanager.endpoints.internal.Value; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +@Component +public class ConceptResultSetExtractor implements ResultSetExtractor { + @Autowired + private ConceptResultSetUtil conceptResultSetUtil; + + private record Wrapper(Concept c, int id) { + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + Wrapper wrapper = (Wrapper) object; + return id == wrapper.id; + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + }; + + @Override + public Concept extractData(ResultSet rs) throws SQLException, DataAccessException { + Map> conceptsByParentId = new HashMap<>(); + Wrapper root = null; + while (rs.next()) { + Concept c = switch (ConceptType.toConcept(rs.getString("concept_type"))) { + case Categorical -> conceptResultSetUtil.mapCategorical(rs); + case Continuous -> conceptResultSetUtil.mapContinuous(rs); + }; + Wrapper wrapper = new Wrapper(c, rs.getInt("concept_node_id")); + if (root == null) { root = wrapper; } + + int parentId = rs.getInt("parent_id"); + // weirdness: null value for int is 0, so to check for missing parent value, you need the wasNull check + if (!rs.wasNull()) { + List concepts = conceptsByParentId.getOrDefault(parentId, new ArrayList<>()); + concepts.add(wrapper); + conceptsByParentId.put(parentId, concepts); + } + } + + + return root == null ? null : seedChildren(root, conceptsByParentId); + } + + private Concept seedChildren(Wrapper root, Map> conceptsByParentId) { + List children = conceptsByParentId.getOrDefault(root.id, List.of()).stream() + .map(wrapper -> seedChildren(wrapper, conceptsByParentId)) + .toList(); + return root.c.withChildren(children); + } +} diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetUtil.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetUtil.java new file mode 100644 index 0000000..bd543b4 --- /dev/null +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptResultSetUtil.java @@ -0,0 +1,52 @@ +package edu.harvard.dbmi.avillach.dictionary.concept; + +import edu.harvard.dbmi.avillach.dictionary.concept.model.CategoricalConcept; +import edu.harvard.dbmi.avillach.dictionary.concept.model.ContinuousConcept; +import org.json.JSONArray; +import org.json.JSONException; +import org.springframework.stereotype.Component; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +@Component +public class ConceptResultSetUtil { + + public CategoricalConcept mapCategorical(ResultSet rs) throws SQLException { + return new CategoricalConcept( + rs.getString("concept_path"), rs.getString("name"), + rs.getString("display"), rs.getString("dataset"), rs.getString("description"), + rs.getString("values") == null ? List.of() : List.of(rs.getString("values").split(",")), + null, + null + ); + } + + public ContinuousConcept mapContinuous(ResultSet rs) throws SQLException { + return new ContinuousConcept( + rs.getString("concept_path"), rs.getString("name"), + rs.getString("display"), rs.getString("dataset"), rs.getString("description"), + parseMin(rs.getString("values")), parseMax(rs.getString("values")), + null + ); + } + + public Integer parseMin(String valuesArr) { + try { + JSONArray arr = new JSONArray(valuesArr); + return arr.length() == 2 ? arr.getInt(0) : 0; + } catch (JSONException ex) { + return 0; + } + } + + public Integer parseMax(String valuesArr) { + try { + JSONArray arr = new JSONArray(valuesArr); + return arr.length() == 2 ? arr.getInt(1) : 0; + } catch (JSONException ex) { + return 0; + } + } +} diff --git a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRowMapper.java b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRowMapper.java index fc632d0..17e984c 100644 --- a/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRowMapper.java +++ b/src/main/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRowMapper.java @@ -3,6 +3,7 @@ import edu.harvard.dbmi.avillach.dictionary.concept.model.*; import org.json.JSONArray; import org.json.JSONException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -15,48 +16,14 @@ @Component public class ConceptRowMapper implements RowMapper { + @Autowired + ConceptResultSetUtil conceptResultSetUtil; + @Override public Concept mapRow(ResultSet rs, int rowNum) throws SQLException { return switch (ConceptType.toConcept(rs.getString("concept_type"))) { - case Categorical -> mapCategorical(rs); - case Continuous -> mapContinuous(rs); + case Categorical -> conceptResultSetUtil.mapCategorical(rs); + case Continuous -> conceptResultSetUtil.mapContinuous(rs); }; } - - private CategoricalConcept mapCategorical(ResultSet rs) throws SQLException { - return new CategoricalConcept( - rs.getString("concept_path"), rs.getString("name"), - rs.getString("display"), rs.getString("dataset"), rs.getString("description"), - rs.getString("values") == null ? List.of() : List.of(rs.getString("values").split(",")), - null, - null - ); - } - - private ContinuousConcept mapContinuous(ResultSet rs) throws SQLException { - return new ContinuousConcept( - rs.getString("concept_path"), rs.getString("name"), - rs.getString("display"), rs.getString("dataset"), rs.getString("description"), - parseMin(rs.getString("values")), parseMax(rs.getString("values")), - null - ); - } - - private Integer parseMin(String valuesArr) { - try { - JSONArray arr = new JSONArray(valuesArr); - return arr.length() == 2 ? arr.getInt(0) : 0; - } catch (JSONException ex) { - return 0; - } - } - - private Integer parseMax(String valuesArr) { - try { - JSONArray arr = new JSONArray(valuesArr); - return arr.length() == 2 ? arr.getInt(1) : 0; - } catch (JSONException ex) { - return 0; - } - } } 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 873a56b..9e7dca0 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 @@ -57,6 +57,6 @@ public Optional conceptDetail(String dataset, String conceptPath) { } public Optional conceptTree(String dataset, String conceptPath, int depth) { - return Optional.empty(); + return conceptRepository.getConceptTree(dataset, conceptPath, 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 915c2eb..ef3b60c 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 @@ -24,6 +24,14 @@ public CategoricalConcept(CategoricalConcept core, Map meta) { this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.values, core.children, meta); } + public CategoricalConcept(CategoricalConcept core, List children) { + this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.values, children, core.meta); + } + + public CategoricalConcept(String conceptPath, String dataset) { + this(conceptPath, "", "", dataset, "", List.of(), List.of(), null); + } + @JsonProperty("type") @Override @@ -31,6 +39,11 @@ public ConceptType type() { return ConceptType.Categorical; } + @Override + public CategoricalConcept withChildren(List children) { + return new CategoricalConcept(this, children); + } + @Override public boolean equals(Object object) { return conceptEquals(object); @@ -40,4 +53,5 @@ public boolean equals(Object object) { public int hashCode() { return Objects.hash(conceptPath, dataset); } + } 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 270dcf1..17d70dd 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 @@ -3,7 +3,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.annotation.Nullable; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -49,6 +51,11 @@ public sealed interface Concept Map meta(); + @Nullable + List children(); + + Concept withChildren(List children); + 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 5cdbee2..d904632 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 @@ -1,5 +1,8 @@ package edu.harvard.dbmi.avillach.dictionary.concept.model; +import jakarta.annotation.Nullable; + +import java.util.List; import java.util.Map; import java.util.Objects; @@ -24,6 +27,16 @@ public Map meta() { return Map.of(); } + @Override + public List children() { + return List.of(); + } + + @Override + public ConceptShell withChildren(List children) { + 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 2314310..48a9f2f 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 @@ -3,6 +3,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -10,11 +12,28 @@ public record ContinuousConcept( String conceptPath, String name, String display, String dataset, String description, @Nullable Integer min, @Nullable Integer max, - Map meta + Map meta, + @Nullable + List children ) implements Concept { public ContinuousConcept(ContinuousConcept core, Map meta) { - this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.min, core.max, meta); + this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.min, core.max, meta, core.children); + } + + public ContinuousConcept(ContinuousConcept core, List children) { + this(core.conceptPath, core.name, core.display, core.dataset, core.description, core.min, core.max, core.meta, children); + } + + public ContinuousConcept(String conceptPath, String dataset) { + this(conceptPath, "", "", dataset, "", null, null, null, List.of()); + } + + public ContinuousConcept( + String conceptPath, String name, String display, String dataset, String description, + @Nullable Integer min, @Nullable Integer max, Map meta + ) { + this(conceptPath, name, display, dataset, description, min, max, meta, null); } @JsonProperty("type") @@ -23,6 +42,11 @@ public ConceptType type() { return ConceptType.Continuous; } + @Override + public ContinuousConcept withChildren(List children) { + return new ContinuousConcept(this, children); + } + @Override public boolean equals(Object object) { return conceptEquals(object); diff --git a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepositoryTest.java b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepositoryTest.java index 4f63d4f..98d4995 100644 --- a/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepositoryTest.java +++ b/src/test/java/edu/harvard/dbmi/avillach/dictionary/concept/ConceptRepositoryTest.java @@ -178,4 +178,58 @@ void shouldGetMetaForMultipleConcepts() { ); Assertions.assertEquals(expected, actual); } + + @Test + void shouldGetTree() { + Concept d0 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\", "1"); + Concept d1 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\", "1"); + Concept d2 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\", "1"); + Concept d3 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\", "1"); + Concept d4A = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\J45.5 Severe persistent asthma\\", "1"); + Concept d4B = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\J45.9 Other and unspecified );asthma\\", "1"); + d3 = d3.withChildren(List.of(d4A, d4B)); + d2.withChildren(List.of(d3)); + d1.withChildren(List.of(d2)); + d0.withChildren(List.of(d1)); + + Optional actual = subject.getConceptTree("1", "\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\", 3); + Optional expected = Optional.of(d0); + + Assertions.assertEquals(expected, actual); + } + + @Test + void shouldGetTreeForDepthThatExceedsOntology() { + Concept d0 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\", "1"); + Concept d1 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\", "1"); + Concept d2 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\", "1"); + Concept d3 = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\", "1"); + Concept d4A = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\J45.5 Severe persistent asthma\\", "1"); + Concept d4B = new CategoricalConcept("\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\J40-J47 Chronic lower respiratory diseases (J40-J47)\\J45 Asthma\\J45.9 Other and unspecified );asthma\\", "1"); + d3 = d3.withChildren(List.of(d4A, d4B)); + d2.withChildren(List.of(d3)); + d1.withChildren(List.of(d2)); + d0.withChildren(List.of(d1)); + + Optional actual = subject.getConceptTree("1", "\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\", 30); + Optional expected = Optional.of(d0); + + Assertions.assertEquals(expected, actual); + } + + @Test + void shouldReturnEmptyTreeForDNE() { + Optional actual = subject.getConceptTree("1", "\\ACT Top Secret ICD-69\\", 30); + Optional expected = Optional.empty(); + + Assertions.assertEquals(expected, actual); + } + + @Test + void shouldReturnEmptyForNegativeDepth() { + Optional actual = subject.getConceptTree("1", "\\ACT Diagnosis ICD-10\\J00-J99 Diseases of the respiratory system (J00-J99)\\", -1); + Optional expected = Optional.empty(); + + Assertions.assertEquals(expected, actual); + } } \ No newline at end of file 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 ba813ad..9181493 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\":{},\"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,\"type\":\"Continuous\"},{\"conceptPath\":\"/foo//bar\",\"name\":\"bar\",\"display\":\"Bar\",\"dataset\":\"study_a\",\"description\":null,\"values\":[\"a\",\"b\"],\"children\":null,\"meta\":{},\"type\":\"Categorical\"}]"; Assertions.assertEquals(expected, actual); }