From dfa7f4a283c2f89d414ef9050a3c21cab9433df1 Mon Sep 17 00:00:00 2001 From: Anton Vasetenkov Date: Fri, 31 Jan 2025 07:26:31 +1300 Subject: [PATCH] Parsing and serializing CQL annotations in ELM XML and JSON (Kotlin feature branch) (#1493) * XML and JSON serialization for mixed content in ELM * Fix detekt checks --- Src/java/cql-to-elm/build.gradle.kts | 4 - .../CqlPreprocessorElmCommonVisitor.kt | 35 +- .../cqframework/cql/cql2elm/CommentTests.java | 15 +- .../fhir/DataRequirementsProcessor.java | 11 +- .../cql/elm/ElmDeserializeTests.java | 41 +- .../xmlutil/ElmJsonLibraryCommon.kt | 2 + .../xmlutil/ElmXmlLibraryCommon.kt | 49 +- .../serializing/xmlutil/ElmXmlutilTest.java | 623 ------------------ .../serializing/NarrativeJsonSerializer.kt | 88 +++ Src/js/xsd-kotlin-gen/generate.js | 55 +- 10 files changed, 228 insertions(+), 695 deletions(-) delete mode 100644 Src/java/elm-xmlutil/src/test/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlutilTest.java create mode 100644 Src/java/elm/src/main/kotlin/org/cqframework/cql/elm/serializing/NarrativeJsonSerializer.kt diff --git a/Src/java/cql-to-elm/build.gradle.kts b/Src/java/cql-to-elm/build.gradle.kts index fcf433e91..96b48c846 100644 --- a/Src/java/cql-to-elm/build.gradle.kts +++ b/Src/java/cql-to-elm/build.gradle.kts @@ -9,10 +9,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0") - // Temporary until we can get rid of the dependency on wrapping - // the CQL annotations in a JAXBElement for narrative generation - implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.1") - testImplementation(project(":elm-xmlutil")) testImplementation(project(":model-xmlutil")) testImplementation(project(":quick")) diff --git a/Src/java/cql-to-elm/src/main/kotlin/org/cqframework/cql/cql2elm/preprocessor/CqlPreprocessorElmCommonVisitor.kt b/Src/java/cql-to-elm/src/main/kotlin/org/cqframework/cql/cql2elm/preprocessor/CqlPreprocessorElmCommonVisitor.kt index b0672cb01..101956313 100644 --- a/Src/java/cql-to-elm/src/main/kotlin/org/cqframework/cql/cql2elm/preprocessor/CqlPreprocessorElmCommonVisitor.kt +++ b/Src/java/cql-to-elm/src/main/kotlin/org/cqframework/cql/cql2elm/preprocessor/CqlPreprocessorElmCommonVisitor.kt @@ -2,10 +2,7 @@ package org.cqframework.cql.cql2elm.preprocessor -import jakarta.xml.bind.JAXBElement -import java.io.Serializable import java.util.* -import javax.xml.namespace.QName import org.antlr.v4.kotlinruntime.ParserRuleContext import org.antlr.v4.kotlinruntime.TokenStream import org.antlr.v4.kotlinruntime.misc.Interval @@ -763,32 +760,32 @@ abstract class CqlPreprocessorElmCommonVisitor( return Pair.of(header.substring(startFrom).trim { it <= ' ' }, header.length) } - fun wrapNarrative(narrative: Narrative): Serializable { + fun wrapNarrative(narrative: Narrative): Any { @Suppress("ForbiddenComment") /* - TODO: Should be able to collapse narrative if the span doesn't have an attribute - That's what this code is doing, but it doesn't work and I don't have time to debug it - if (narrative.getR() == null) { - StringBuilder content = new StringBuilder(); - boolean onlyStrings = true; - for (Serializable s : narrative.getContent()) { - if (s instanceof String) { - content.append((String)s); + This code collapses the narrative if the span doesn't have an attribute. + It does work but creates a different (simplified) ELM. + + if (narrative.r == null) { + val content = StringBuilder() + var onlyStrings = true + for (s in narrative.content) { + if (s is String) { + content.append(s) } else { - onlyStrings = false; + onlyStrings = false } } if (onlyStrings) { - return content.toString(); + return content.toString() } } + + return narrative */ - return JAXBElement( - QName("urn:hl7-org:cql-annotations:r1", "s"), - Narrative::class.java, - narrative - ) + + return narrative } fun isValidIdentifier(tagName: String): Boolean { diff --git a/Src/java/cql-to-elm/src/test/java/org/cqframework/cql/cql2elm/CommentTests.java b/Src/java/cql-to-elm/src/test/java/org/cqframework/cql/cql2elm/CommentTests.java index 9d8125989..348eefa20 100644 --- a/Src/java/cql-to-elm/src/test/java/org/cqframework/cql/cql2elm/CommentTests.java +++ b/Src/java/cql-to-elm/src/test/java/org/cqframework/cql/cql2elm/CommentTests.java @@ -4,7 +4,6 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; -import jakarta.xml.bind.JAXBElement; import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -42,11 +41,10 @@ void comments() throws IOException { Annotation a = (Annotation) def.getAnnotation().get(0); assertThat(a.getS().getContent(), notNullValue()); assertThat(a.getS().getContent().size(), is(2)); - assertThat(a.getS().getContent().get(0), instanceOf(JAXBElement.class)); - JAXBElement e = (JAXBElement) a.getS().getContent().get(0); + var e = a.getS().getContent().get(0); assertThat(e, notNullValue()); - assertThat(e.getValue(), instanceOf(Narrative.class)); - Narrative n = (Narrative) e.getValue(); + assertThat(e, instanceOf(Narrative.class)); + Narrative n = (Narrative) e; assertThat(n.getContent(), notNullValue()); assertThat(n.getContent().size(), is(4)); assertThat(n.getContent().get(0), instanceOf(String.class)); @@ -63,11 +61,10 @@ void comments() throws IOException { a = (Annotation) def.getAnnotation().get(0); assertThat(a.getS().getContent(), notNullValue()); assertThat(a.getS().getContent().size(), is(2)); - assertThat(a.getS().getContent().get(0), instanceOf(JAXBElement.class)); - e = (JAXBElement) a.getS().getContent().get(0); + e = a.getS().getContent().get(0); assertThat(e, notNullValue()); - assertThat(e.getValue(), instanceOf(Narrative.class)); - n = (Narrative) e.getValue(); + assertThat(e, instanceOf(Narrative.class)); + n = (Narrative) e; assertThat(n.getContent(), notNullValue()); assertThat(n.getContent().size(), is(4)); assertThat(n.getContent().get(0), instanceOf(String.class)); diff --git a/Src/java/elm-fhir/src/main/java/org/cqframework/cql/elm/requirements/fhir/DataRequirementsProcessor.java b/Src/java/elm-fhir/src/main/java/org/cqframework/cql/elm/requirements/fhir/DataRequirementsProcessor.java index fbed43cb1..b3410b129 100644 --- a/Src/java/elm-fhir/src/main/java/org/cqframework/cql/elm/requirements/fhir/DataRequirementsProcessor.java +++ b/Src/java/elm-fhir/src/main/java/org/cqframework/cql/elm/requirements/fhir/DataRequirementsProcessor.java @@ -1,8 +1,6 @@ package org.cqframework.cql.elm.requirements.fhir; import ca.uhn.fhir.context.FhirVersionEnum; -import jakarta.xml.bind.JAXBElement; -import java.io.Serializable; import java.time.ZonedDateTime; import java.util.*; import java.util.List; @@ -450,19 +448,12 @@ private String toNarrativeText(org.hl7.cql_annotations.r1.Annotation a) { } private void addNarrativeText(StringBuilder sb, org.hl7.cql_annotations.r1.Narrative n) { - for (Serializable s : n.getContent()) { + for (var s : n.getContent()) { if (s instanceof org.hl7.cql_annotations.r1.Narrative) { addNarrativeText(sb, (org.hl7.cql_annotations.r1.Narrative) s); } else if (s instanceof String) { sb.append((String) s); } - // TODO: THIS IS WRONG... SHOULDN'T NEED TO KNOW ABOUT JAXB TO ACCOMPLISH THIS - else if (s instanceof JAXBElement) { - JAXBElement j = (JAXBElement) s; - if (j.getValue() instanceof org.hl7.cql_annotations.r1.Narrative) { - addNarrativeText(sb, (org.hl7.cql_annotations.r1.Narrative) j.getValue()); - } - } } } diff --git a/Src/java/elm-test/src/test/java/org/cqframework/cql/elm/ElmDeserializeTests.java b/Src/java/elm-test/src/test/java/org/cqframework/cql/elm/ElmDeserializeTests.java index dda11ac09..4265487d6 100644 --- a/Src/java/elm-test/src/test/java/org/cqframework/cql/elm/ElmDeserializeTests.java +++ b/Src/java/elm-test/src/test/java/org/cqframework/cql/elm/ElmDeserializeTests.java @@ -12,9 +12,10 @@ import org.cqframework.cql.cql2elm.CqlCompilerOptions; import org.cqframework.cql.cql2elm.CqlTranslator; import org.cqframework.cql.cql2elm.LibraryBuilder; +import org.hl7.cql_annotations.r1.Annotation; import org.hl7.cql_annotations.r1.CqlToElmInfo; +import org.hl7.cql_annotations.r1.Narrative; import org.hl7.elm.r1.*; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; class ElmDeserializeTests { @@ -30,7 +31,6 @@ void elmTests() { } @Test - @Disabled("TODO: Re-enable once XmlUtil-based ELM JSON deserialization is implemented for annotations") void jsonANCFHIRDummyLibraryLoad() { try { final Library library = deserializeJsonLibrary("ElmDeserialize/ANCFHIRDummy.json"); @@ -55,8 +55,19 @@ void jsonANCFHIRDummyLibraryLoad() { assertTrue( ((SingletonFrom) library.getStatements().getDef().get(0).getExpression()).getOperand() instanceof Retrieve); - assertNotNull(library.getStatements().getDef().get(1)); - assertTrue(library.getStatements().getDef().get(1).getExpression() instanceof Retrieve); + var observationsStatement = library.getStatements().getDef().get(1); + assertNotNull(observationsStatement); + assertTrue(observationsStatement.getExpression() instanceof Retrieve); + + assertTrue(observationsStatement.getAnnotation().get(0) instanceof Annotation); + var annotation = (Annotation) observationsStatement.getAnnotation().get(0); + assertNotNull(annotation.getS()); + var narrative = annotation.getS(); + assertTrue(narrative.getContent().get(1) instanceof Narrative); + var nestedNarrative = (Narrative) narrative.getContent().get(1); + assertTrue(nestedNarrative.getContent().get(0) instanceof Narrative); + nestedNarrative = (Narrative) nestedNarrative.getContent().get(0); + assertEquals("[", nestedNarrative.getContent().get(0)); verifySigLevels(library, LibraryBuilder.SignatureLevel.All); } catch (IOException e) { @@ -100,7 +111,6 @@ void jsonAdultOutpatientEncountersFHIR4LibraryLoad() { } @Test - @Disabled("Invalid XML value at position: 85:29: Index -1 out of bounds for length 2") void xmlLibraryLoad() { try { final Library library = @@ -130,11 +140,20 @@ void xmlLibraryLoad() { assertTrue( ((SingletonFrom) library.getStatements().getDef().get(0).getExpression()).getOperand() instanceof Retrieve); - assertEquals( - "Qualifying Encounters", - library.getStatements().getDef().get(1).getName()); - assertNotNull(library.getStatements().getDef().get(1)); - assertTrue(library.getStatements().getDef().get(1).getExpression() instanceof Query); + var qualifyingEncountersStatement = library.getStatements().getDef().get(1); + assertEquals("Qualifying Encounters", qualifyingEncountersStatement.getName()); + assertNotNull(qualifyingEncountersStatement); + assertTrue(qualifyingEncountersStatement.getExpression() instanceof Query); + assertTrue(qualifyingEncountersStatement.getAnnotation().get(0) instanceof Annotation); + var annotation = + (Annotation) qualifyingEncountersStatement.getAnnotation().get(0); + assertNotNull(annotation.getS()); + var narrative = annotation.getS(); + assertEquals("\n ", narrative.getContent().get(0)); + assertTrue(narrative.getContent().get(3) instanceof Narrative); + var nestedNarrative = (Narrative) narrative.getContent().get(3); + assertEquals("\n ", nestedNarrative.getContent().get(0)); + assertTrue(nestedNarrative.getContent().get(1) instanceof Narrative); verifySigLevels(library, LibraryBuilder.SignatureLevel.Overloads); } catch (IOException e) { @@ -144,7 +163,6 @@ void xmlLibraryLoad() { } @Test - @Disabled("TODO: Re-enable once XmlUtil-based ELM JSON deserialization is implemented for annotations") void jsonTerminologyLibraryLoad() { try { final Library library = deserializeJsonLibrary("ElmDeserialize/ANCFHIRTerminologyDummy.json"); @@ -200,7 +218,6 @@ private void testElmDeserialization(String directoryName) throws URISyntaxExcept } @Test - @Disabled("Invalid XML value at position: 59:29: Index -1 out of bounds for length 2") void regressionTestJsonSerializer() throws URISyntaxException { // This test validates that the ELM library deserialized from the Json matches the ELM library deserialized from // Xml diff --git a/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmJsonLibraryCommon.kt b/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmJsonLibraryCommon.kt index 273d86f2c..39b84bf4c 100644 --- a/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmJsonLibraryCommon.kt +++ b/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmJsonLibraryCommon.kt @@ -3,11 +3,13 @@ package org.cqframework.cql.elm.serializing.xmlutil import kotlinx.serialization.json.Json import kotlinx.serialization.modules.plus import kotlinx.serialization.modules.serializersModuleOf +import org.cqframework.cql.elm.serializing.NarrativeJsonSerializer import org.hl7.elm_modelinfo.r1.serializing.BigDecimalJsonSerializer val json = Json { serializersModule = serializersModuleOf(BigDecimalJsonSerializer) + + serializersModuleOf(NarrativeJsonSerializer) + org.hl7.elm.r1.serializersModule + org.hl7.cql_annotations.r1.serializersModule explicitNulls = false diff --git a/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlLibraryCommon.kt b/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlLibraryCommon.kt index df2ea67b3..7d3bcd637 100644 --- a/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlLibraryCommon.kt +++ b/Src/java/elm-xmlutil/src/main/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlLibraryCommon.kt @@ -1,20 +1,61 @@ package org.cqframework.cql.elm.serializing.xmlutil +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.serializersModuleOf import nl.adaptivity.xmlutil.QName +import nl.adaptivity.xmlutil.XMLConstants +import nl.adaptivity.xmlutil.serialization.DefaultXmlSerializationPolicy import nl.adaptivity.xmlutil.serialization.XML +import nl.adaptivity.xmlutil.serialization.structure.SafeParentInfo import org.hl7.elm_modelinfo.r1.serializing.BigDecimalXmlSerializer +val builder = + DefaultXmlSerializationPolicy.Builder().apply { + // Use xsi:type for handling polymorphism + typeDiscriminatorName = QName(XMLConstants.XSI_NS_URI, "type", XMLConstants.XSI_PREFIX) + } + +@OptIn(ExperimentalSerializationApi::class) +val customPolicy = + object : DefaultXmlSerializationPolicy(builder) { + override fun isTransparentPolymorphic( + serializerParent: SafeParentInfo, + tagParent: SafeParentInfo + ): Boolean { + // Switch on transparent polymorphic mode for mixed content + if ( + serializerParent.elementSerialDescriptor.serialName == + "kotlinx.serialization.Polymorphic" + ) { + return true + } + return super.isTransparentPolymorphic(serializerParent, tagParent) + } + } + +// Mixed content can include text and Narrative elements +val mixedContentSerializersModule = SerializersModule { + polymorphic(Any::class) { + polymorphic(Any::class, String::class, String.serializer()) + polymorphic( + Any::class, + org.hl7.cql_annotations.r1.Narrative::class, + org.hl7.cql_annotations.r1.Narrative.serializer() + ) + } +} + val xml = XML( serializersModuleOf(BigDecimalXmlSerializer) + + mixedContentSerializersModule + org.hl7.elm.r1.serializersModule + org.hl7.cql_annotations.r1.serializersModule ) { + policy = customPolicy xmlDeclMode = nl.adaptivity.xmlutil.XmlDeclMode.Charset - defaultPolicy { - typeDiscriminatorName = - QName("http://www.w3.org/2001/XMLSchema-instance", "type", "xsi") - } } diff --git a/Src/java/elm-xmlutil/src/test/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlutilTest.java b/Src/java/elm-xmlutil/src/test/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlutilTest.java deleted file mode 100644 index c74978847..000000000 --- a/Src/java/elm-xmlutil/src/test/java/org/cqframework/cql/elm/serializing/xmlutil/ElmXmlutilTest.java +++ /dev/null @@ -1,623 +0,0 @@ -package org.cqframework.cql.elm.serializing.xmlutil; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Test; - -public class ElmXmlutilTest { - - @Test - void deserializeReserializeElmJson() { - var elm = - """ -{"library":{"annotation":[],"identifier":{"id":"AdultOutpatientEncounters_FHIR4","version":"2.0.000"}}}"""; - var lib = new ElmJsonLibraryReader().read(elm); - var libReserialized = new ElmJsonLibraryWriter().writeAsString(lib); - assertEquals(elm, libReserialized); - } - - @Test - void deserializeBigElmJson() { - var lib = new ElmJsonLibraryReader() - .read( - """ -{ - "library": { - "annotation": [ - { - "translatorOptions": "EnableAnnotations", - "type": "CqlToElmInfo", - "signatureLevel" : "Differing" - } - ], - "identifier": { - "id": "AdultOutpatientEncounters_FHIR4", - "version": "2.0.000" - }, - "schemaIdentifier": { - "id": "urn:hl7-org:elm", - "version": "r1" - }, - "usings": { - "def": [ - { - "localIdentifier": "System", - "uri": "urn:hl7-org:elm-types:r1" - }, - { - "localIdentifier": "FHIR", - "uri": "http://hl7.org/fhir", - "version": "4.0.1", - "annotation": [ - { - "type": "Annotation", - "t": [ - { - "name": "update", - "value": "" - } - ] - } - ] - } - ] - }, - "includes": { - "def": [ - { - "localIdentifier": "FHIRHelpers", - "path": "FHIRHelpers", - "version": "4.0.1" - } - ] - }, - "parameters": { - "def": [ - { - "name": "Measurement Period", - "accessLevel": "Public", - "default": { - "lowClosed": true, - "highClosed": false, - "type": "Interval", - "low": { - "type": "DateTime", - "year": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "2019", - "type": "Literal" - }, - "month": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "1", - "type": "Literal" - }, - "day": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "1", - "type": "Literal" - }, - "hour": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "minute": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "second": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "millisecond": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - } - }, - "high": { - "type": "DateTime", - "year": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "2020", - "type": "Literal" - }, - "month": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "1", - "type": "Literal" - }, - "day": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "1", - "type": "Literal" - }, - "hour": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "minute": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "second": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - }, - "millisecond": { - "valueType": "{urn:hl7-org:elm-types:r1}Integer", - "value": "0", - "type": "Literal" - } - } - }, - "parameterTypeSpecifier": { - "type": "IntervalTypeSpecifier", - "pointType": { - "name": "{urn:hl7-org:elm-types:r1}DateTime", - "type": "NamedTypeSpecifier" - } - } - } - ] - }, - "valueSets": { - "def": [ - { - "name": "Office Visit", - "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1001", - "accessLevel": "Public" - }, - { - "name": "Annual Wellness Visit", - "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.526.3.1240", - "accessLevel": "Public" - }, - { - "name": "Preventive Care Services - Established Office Visit, 18 and Up", - "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1025", - "accessLevel": "Public" - }, - { - "name": "Preventive Care Services-Initial Office Visit, 18 and Up", - "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1023", - "accessLevel": "Public" - }, - { - "name": "Home Healthcare Services", - "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1016", - "accessLevel": "Public" - } - ] - }, - "contexts": { - "def": [ - { - "name": "Patient" - } - ] - }, - "statements": { - "def": [ - { - "name": "Patient", - "context": "Patient", - "expression": { - "type": "SingletonFrom", - "operand": { - "dataType": "{http://hl7.org/fhir}Patient", - "templateId": "http://hl7.org/fhir/StructureDefinition/Patient", - "type": "Retrieve" - } - } - }, - { - "name": "Qualifying Encounters", - "context": "Patient", - "accessLevel": "Public", - "expression": { - "type": "Query", - "source": [ - { - "alias": "ValidEncounter", - "expression": { - "type": "Union", - "operand": [ - { - "type": "Union", - "operand": [ - { - "type": "Union", - "operand": [ - { - "dataType": "{http://hl7.org/fhir}Encounter", - "templateId": "http://hl7.org/fhir/StructureDefinition/Encounter", - "codeProperty": "type", - "codeComparator": "in", - "type": "Retrieve", - "codes": { - "name": "Office Visit", - "type": "ValueSetRef" - } - }, - { - "dataType": "{http://hl7.org/fhir}Encounter", - "templateId": "http://hl7.org/fhir/StructureDefinition/Encounter", - "codeProperty": "type", - "codeComparator": "in", - "type": "Retrieve", - "codes": { - "name": "Annual Wellness Visit", - "type": "ValueSetRef" - } - } - ] - }, - { - "type": "Union", - "operand": [ - { - "dataType": "{http://hl7.org/fhir}Encounter", - "templateId": "http://hl7.org/fhir/StructureDefinition/Encounter", - "codeProperty": "type", - "codeComparator": "in", - "type": "Retrieve", - "codes": { - "name": "Preventive Care Services - Established Office Visit, 18 and Up", - "type": "ValueSetRef" - } - }, - { - "dataType": "{http://hl7.org/fhir}Encounter", - "templateId": "http://hl7.org/fhir/StructureDefinition/Encounter", - "codeProperty": "type", - "codeComparator": "in", - "type": "Retrieve", - "codes": { - "name": "Preventive Care Services-Initial Office Visit, 18 and Up", - "type": "ValueSetRef" - } - } - ] - } - ] - }, - { - "dataType": "{http://hl7.org/fhir}Encounter", - "templateId": "http://hl7.org/fhir/StructureDefinition/Encounter", - "codeProperty": "type", - "codeComparator": "in", - "type": "Retrieve", - "codes": { - "name": "Home Healthcare Services", - "type": "ValueSetRef" - } - } - ] - } - } - ], - "relationship": [], - "where": { - "type": "Null" - } - } - } - ] - } - } -}"""); - System.out.println(new ElmJsonLibraryWriter().writeAsString(lib)); - } - - @Test - void deserializeReserializeElmXml() { - var elm = - """ -"""; - var lib = new ElmXmlLibraryReader().read(elm); - var libReserialized = new ElmXmlLibraryWriter().writeAsString(lib); - assertEquals(elm, libReserialized); - } - - @Test - void deserializeBigElmXml() { - var lib = new ElmXmlLibraryReader() - .read( - """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"""); - System.out.println(new ElmXmlLibraryWriter().writeAsString(lib)); - } -} diff --git a/Src/java/elm/src/main/kotlin/org/cqframework/cql/elm/serializing/NarrativeJsonSerializer.kt b/Src/java/elm/src/main/kotlin/org/cqframework/cql/elm/serializing/NarrativeJsonSerializer.kt new file mode 100644 index 000000000..566854ad6 --- /dev/null +++ b/Src/java/elm/src/main/kotlin/org/cqframework/cql/elm/serializing/NarrativeJsonSerializer.kt @@ -0,0 +1,88 @@ +@file:Suppress("WildcardImport") + +package org.cqframework.cql.elm.serializing + +import kotlinx.serialization.* +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import org.hl7.cql_annotations.r1.Narrative + +@Serializable +data class NarrativeJson( + val s: MutableList<@Serializable(NarrativeJsonContentSerializer::class) Any>?, + val r: String? +) + +// When tags appear inside mixed content in JSON, they are wrapped in this structure. +// We only support Narrative tags in mixed content, so this only handles Narratives. +@Serializable +data class NarrativeJsonWrapper( + val name: String, + val declaredType: String, + val scope: String, + @Serializable(NarrativeJsonSerializer::class) val value: Narrative, + val globalScope: Boolean +) + +@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) +object NarrativeJsonContentSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("NarrativeJsonContentSerializer", PolymorphicKind.SEALED) + + override fun serialize(encoder: Encoder, value: Any) { + // Serialize strings as is, but wrap Narratives in a NarrativeJsonWrapper. + when (value) { + is String -> String.serializer().serialize(encoder, value) + is Narrative -> + NarrativeJsonWrapper.serializer() + .serialize( + encoder, + NarrativeJsonWrapper( + "{urn:hl7-org:cql-annotations:r1}s", + "org.hl7.cql_annotations.r1.Narrative", + "javax.xml.bind.JAXBElement\$GlobalScope", + value, + true + ) + ) + } + } + + override fun deserialize(decoder: Decoder): Any { + val jsonDecoder = decoder as JsonDecoder + val jsonElement = jsonDecoder.decodeJsonElement() + + // Deserialize strings as is, otherwise parse as a NarrativeJsonWrapper and extract the + // Narrative. + if (jsonElement is JsonPrimitive && jsonElement.jsonPrimitive.isString) { + return jsonDecoder.json.decodeFromJsonElement(String.serializer(), jsonElement) + } + + val narrativeJsonWrapper = + jsonDecoder.json.decodeFromJsonElement(NarrativeJsonWrapper.serializer(), jsonElement) + return narrativeJsonWrapper.value + } +} + +// A custom serializer for Narrative that reuses the descriptor and serializer from NarrativeJson. +object NarrativeJsonSerializer : KSerializer { + override val descriptor: SerialDescriptor = NarrativeJson.serializer().descriptor + + override fun serialize(encoder: Encoder, value: Narrative) { + encoder.encodeSerializableValue( + NarrativeJson.serializer(), + NarrativeJson(value._content, value.r) + ) + } + + override fun deserialize(decoder: Decoder): Narrative { + val narrative = decoder.decodeSerializableValue(NarrativeJson.serializer()) + return Narrative().apply { + _content = narrative.s + r = narrative.r + } + } +} diff --git a/Src/js/xsd-kotlin-gen/generate.js b/Src/js/xsd-kotlin-gen/generate.js index e0b777400..561b103e5 100644 --- a/Src/js/xsd-kotlin-gen/generate.js +++ b/Src/js/xsd-kotlin-gen/generate.js @@ -83,12 +83,13 @@ function isExtendedByAny(someClass, config) { return false; } -function addPolymorphicAnnotationIfNecessary(className, config) { - if (isExtendedByAny(className, config)) { - return `@kotlinx.serialization.Polymorphic`; +function addContextualAnnotationIfNecessary(type) { + // A custom serializer is needed for Narrative in JSON + if (type === "Narrative") { + return "@kotlinx.serialization.Contextual Narrative"; } - return ''; + return type; } function getType(rawType) { @@ -114,6 +115,10 @@ function getType(rawType) { } function makeLocalName(name) { + // Narrative tags are always serialized as urn:hl7-org:cql-annotations:r1:s + if (name === "Narrative") { + return "s"; + } if ([ "ModelInfo", @@ -128,12 +133,6 @@ if ([ function parse(filePath) { const xml = fs .readFileSync(filePath, "utf8") - .split( - '', - ) - .join( - '', - ) .split("a:CqlToElmBase") .join("org.hl7.cql_annotations.r1.CqlToElmBase"); const result = xml2js(xml, { compact: false }); @@ -441,7 +440,7 @@ function processElements(elements, config, mode) { }, ), ...( - (restrictionSequence && restrictionSequence.elements) || + (element.attributes.mixed !== 'true' && restrictionSequence && restrictionSequence.elements) || [] ).filter((element) => { return ( @@ -487,9 +486,9 @@ function processElements(elements, config, mode) { return ` ${config.packageName === 'org.hl7.elm_modelinfo.r1' ? '' : `@kotlinx.serialization.SerialName(${JSON.stringify(name)})`} @nl.adaptivity.xmlutil.serialization.XmlSerialName(${JSON.stringify(name)}, ${JSON.stringify(config.namespaceUri)}) - private var _${fieldName}: MutableList<${type}>? = null + private var _${fieldName}: MutableList<${addContextualAnnotationIfNecessary(type)}>? = null - var ${fieldName}: MutableList<${type}> + var ${fieldName}: MutableList<${addContextualAnnotationIfNecessary(type)}> get() { if (_${fieldName} == null) { _${fieldName} = ArrayList(); @@ -511,7 +510,7 @@ function processElements(elements, config, mode) { // type === 'nl.adaptivity.xmlutil.SerializableQName' ? '@kotlinx.serialization.Contextual' : '@kotlinx.serialization.Serializable' '' } - var ${makeFieldName(field.attributes.name)}: ${type}? = null + var ${makeFieldName(field.attributes.name)}: ${addContextualAnnotationIfNecessary(type)}? = null `; }; @@ -579,6 +578,28 @@ ${config.packageName === 'org.hl7.elm_modelinfo.r1' ? '' : `@kotlinx.serializati @nl.adaptivity.xmlutil.serialization.XmlSerialName(${JSON.stringify(makeLocalName(element.attributes.name))}, ${ JSON.stringify(config.namespaceUri)}) ${element.attributes.abstract === "true" ? "abstract" : "open"} class ${element.attributes.name} ${extendsClass ? `: ${extendsClass}()` : ""} { +${element.attributes.mixed === 'true' ? ` + + // Using the @XmlValue annotation to have the mixed content (text and tags) stored in a list. + // See also https://github.com/pdvrieze/xmlutil/blob/f9389da/serialization/src/commonTest/kotlin/nl/adaptivity/xml/serialization/MixedValueContainerTest.kt#L72 + + @nl.adaptivity.xmlutil.serialization.XmlValue(true) + var _content: MutableList<@kotlinx.serialization.Polymorphic Any>? = null + + var content: MutableList + get() { + if (_content == null) { + _content = ArrayList(); + } + return _content!! + } + + set(value) { + _content = value + } + +` : ''} + ${fields .map((field) => { return renderField(field, element.attributes.name); @@ -678,6 +699,12 @@ ${getParentAttributes( : "" } + ${element.attributes.mixed === 'true' ? ` + if (this.content != that_.content) { + return false; + } + ` : ''} + ${fields .map((field) => { return `