diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bb7cb7f..886a4d6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -13,7 +13,7 @@ env: jobs: test: - if: ${{ (github.event.pull_request.draft == false) && !contains(github.event.pull_request.labels.*.name, 'publish-snapshot') + if: ${{ (github.event.pull_request.draft == false) && !contains(github.event.pull_request.labels.*.name, 'publish-snapshot') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index cdc6a21..95471be 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,35 @@ This example can be applied to any DDI Lifecycle object: `CodeList`, `Sequence`, Useful link: [DDI model documentation](https://ddialliance.github.io/ddimodel-web/DDI-L-3.3/) +## Other features + +### Indexing of a DDI object + +You can index the objects contained within a DDI object. + +```java +DDIIndex ddiIndex = new DDIIndex(); +// Perform indexing +ddiIndex.indexDDIObject(someDDIObject); +// Get an object within the index +AbstractIdentifiableType innerObject = ddiIndex.get("some-inner-object-id"); +// The result can be typed +VariableType variable = ddiIndex.get("some-variable-id", VariableType.class); +// Get the parent object in the hierarchy +VariableSchemeType variableScheme = ddiIndex.getParent("some-variable-id", VariableScheme.class); +``` + +### Utilities + +XMLBeans API is pretty verbose, a utility class of the lib offers some QOE methods: + +```java +VariableType variable = VariableType.Factory.newInstance(); +DDIUtils.setIdValue(variable, "foo-id"); +DDIUtils.getIdValue(variable) // "foo-id" +DDIUtils.ddiToString(variable) // "VariableTypeImpl[id=foo-id]" +``` + ## Requests -If you have a question or bug report, feel free to open an issue. +If you have a question, request or bug report, feel free to open an issue. diff --git a/build.gradle.kts b/build.gradle.kts index 13350d8..a7aeb0b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { allprojects { group = "fr.insee.ddi" - version = "1.0.0" + version = "1.1.0" } tasks.register("printVersion") { diff --git a/model/.gitignore b/model/.gitignore index c0c952b..81bf759 100644 --- a/model/.gitignore +++ b/model/.gitignore @@ -1,4 +1,5 @@ # Generated sources -src/main/java/* +src/main/java/fr/insee/ddi/lifecycle33/* +src/main/java/org/* # Generated resources -src/main/resources/org/apache/xmlbeans/* \ No newline at end of file +src/main/resources/org/apache/xmlbeans/* diff --git a/model/build.gradle.kts b/model/build.gradle.kts index 9241554..99d44ec 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { api("org.apache.xmlbeans:xmlbeans:5.2.0") // This dependency is used internally, and not exposed to consumers on their own compile classpath. implementation("org.apache.logging.log4j:log4j-core:2.21.1") + // + implementation("org.springframework:spring-beans:6.1.11") // Use JUnit Jupiter for testing. testImplementation("org.junit.jupiter:junit-jupiter:5.9.3") diff --git a/model/src/main/java/fr/insee/ddi/exception/DuplicateIdException.java b/model/src/main/java/fr/insee/ddi/exception/DuplicateIdException.java new file mode 100644 index 0000000..8b2fa78 --- /dev/null +++ b/model/src/main/java/fr/insee/ddi/exception/DuplicateIdException.java @@ -0,0 +1,12 @@ +package fr.insee.ddi.exception; + +/** + * Exception to be thrown if duplicate identifiers are detected in a DDI object. + */ +public class DuplicateIdException extends RuntimeException { + + public DuplicateIdException(String message) { + super(message); + } + +} diff --git a/model/src/main/java/fr/insee/ddi/exception/IndexingException.java b/model/src/main/java/fr/insee/ddi/exception/IndexingException.java new file mode 100644 index 0000000..be278ea --- /dev/null +++ b/model/src/main/java/fr/insee/ddi/exception/IndexingException.java @@ -0,0 +1,16 @@ +package fr.insee.ddi.exception; + +/** + * Exception to be thrown if an unexpected exception occurs during DDI indexing. + */ +public class IndexingException extends RuntimeException { + + public IndexingException(String message) { + super(message); + } + + public IndexingException(String message, Exception e) { + super(message, e); + } + +} diff --git a/model/src/main/java/fr/insee/ddi/index/DDIIndex.java b/model/src/main/java/fr/insee/ddi/index/DDIIndex.java new file mode 100644 index 0000000..6f7b6c3 --- /dev/null +++ b/model/src/main/java/fr/insee/ddi/index/DDIIndex.java @@ -0,0 +1,194 @@ +package fr.insee.ddi.index; + +import fr.insee.ddi.exception.DuplicateIdException; +import fr.insee.ddi.exception.IndexingException; +import fr.insee.ddi.lifecycle33.instance.DDIInstanceDocument; +import fr.insee.ddi.lifecycle33.reusable.AbstractIdentifiableType; +import fr.insee.ddi.utils.DDIUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; +import org.springframework.core.convert.TypeDescriptor; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.util.*; + +/** + * Class designed to store all DDI identifiable objects within a DDI object in a flat map. + * Also contains a parents map that make the link between each object and its parent. + */ +public class DDIIndex { + + private static final Logger log = LogManager.getLogger(); + + /** Index of DDI identifiable objects. + * Key: a DDI object identifier + * Value: the DDI object with corresponding identifier. */ + private Map index; + + /** Map containing nesting relationships between objects in index. + * Key: a DDI object identifier + * Value: the identifier of its parent object */ + private Map parentsMap; + + private void setup() { + index = new HashMap<>(); + parentsMap = new HashMap<>(); + } + + /** + * Stores all DDI identifiable objects that are in the given DDI document in the index. + * @param ddiInstanceDocument A DDIInstanceDocument. + * @throws DuplicateIdException if two objects with the same identifier are found. + */ + public void indexDDI(DDIInstanceDocument ddiInstanceDocument) throws DuplicateIdException { + indexDDIObject(ddiInstanceDocument.getDDIInstance()); + } + + /** + * Stores all DDI identifiable objects that are in the given DDI object in the index. + * @param ddiObject A DDI identifiable object. + * @throws DuplicateIdException if two objects with the same identifier are found. + */ + public void indexDDIObject(AbstractIdentifiableType ddiObject) throws DuplicateIdException { + log.info("Indexing DDI object {}...", DDIUtils.ddiToString(ddiObject)); + setup(); + recursiveIndexing(ddiObject); + log.info("Finished indexing of DDI object."); + } + + /** + * In DDI, the most generic object is the AbstractIdentifiableType + * (i.e. each DDI object that has an id is an AbstractIdentifiableType). + * This method recursively path through all AbstractIdentifiableType objects within the given object. + * @param ddiObject A AbstractIdentifiableType object. + */ + private void recursiveIndexing(AbstractIdentifiableType ddiObject) throws IndexingException, DuplicateIdException { + + // Get the DDI object id + String ddiObjectId = !ddiObject.getIDList().isEmpty() ? ddiObject.getIDArray(0).getStringValue() : null; + if (ddiObjectId == null) + throw new IndexingException("DDI object with null identifier encountered while indexing."); + + // Put the object in the map under the id (there should never be duplicate ids in DDI documents) + index.merge(ddiObjectId, ddiObject, (oldDDIObject, newDDIObject) -> { + throw new DuplicateIdException(String.format("Duplicate ID \"%s\" found in given DDI.", ddiObjectId)); + }); + + // Use Spring BeanWrapper to iterate on object property descriptors + BeanWrapper beanWrapper = new BeanWrapperImpl(ddiObject); + Iterator iterator = Arrays.stream(beanWrapper.getPropertyDescriptors()) + .filter(propertyDescriptor -> !propertyDescriptor.getName().equals("class")) + .iterator(); + while (iterator.hasNext()) { + PropertyDescriptor propertyDescriptor = iterator.next(); + + // In DDI classes, everything is in a list + if (List.class.isAssignableFrom(propertyDescriptor.getPropertyType())) { + + // Use Spring TypeDescriptor to determine the content type of the list + TypeDescriptor typeDescriptor = beanWrapper.getPropertyTypeDescriptor(propertyDescriptor.getName()); + assert typeDescriptor != null; + Class listContentType = typeDescriptor.getResolvableType().getGeneric(0).getRawClass(); + + // In some DDI objects, there are some methods (generated by xmlbeans) that does not interest us + assert listContentType != null || propertyDescriptor.getName().equals("listValue") + || propertyDescriptor.getName().equals("limitArrayIndex"); + + // Check that the list content applies for AbstractIdentifiableType + if (listContentType != null && AbstractIdentifiableType.class.isAssignableFrom(listContentType)) { + + // Now that we have what we want, index list content + indexListContent(ddiObject, ddiObjectId, propertyDescriptor); + } + } + } + + } + + private void indexListContent(AbstractIdentifiableType ddiObject, String ddiObjectId, PropertyDescriptor propertyDescriptor) { + try { + // Iteration on each object in the list. + @SuppressWarnings("unchecked") // https://stackoverflow.com/a/4388173/13425151 + Collection ddiCollection = (Collection) propertyDescriptor.getReadMethod().invoke(ddiObject); + for (AbstractIdentifiableType ddiObject2 : ddiCollection) { + // Keep track of link between nested objects + parentsMap.put(ddiObject2.getIDArray(0).getStringValue(), ddiObjectId); + // Recursive call of the function + recursiveIndexing(ddiObject2); + } + } catch (IllegalAccessException | InvocationTargetException e) { + throw new IndexingException(String.format( + "Error when calling read method from property descriptor '%s' in class %s.", + propertyDescriptor.getName(), ddiObject.getClass()), + e); + } + } + + /** Returns the inner index. */ + public Map getIndex() { + return index; + } + + /** + * Returns the DDI object corresponding to the given identifier. + * @param ddiObjectId String identifier value. + * @return The DDI object corresponding to the given identifier. + * @throws NoSuchElementException if there is no object under given identifier. + */ + public AbstractIdentifiableType get(String ddiObjectId) { + AbstractIdentifiableType object = index.get(ddiObjectId); + if (object == null) + throw new NoSuchElementException(String.format("Index has no object with id '%s'.", ddiObjectId)); + return object; + } + + /** + * Returns the DDI object corresponding to the given identifier, cast in the given type. + * @param ddiObjectId String identifier value. + * @param clazz Class into which the result should be cast. + * @return The DDI object corresponding to the given identifier, cast in the given type. + * @param Subtype of DDI AbstractIdentifiableType. + * @throws NoSuchElementException if there is no object under given identifier. + * @throws ClassCastException if the given object cannot be cast to the given type. + */ + public T get(String ddiObjectId, Class clazz) { + AbstractIdentifiableType object = this.get(ddiObjectId); + if (! clazz.isInstance(object)) + throw new ClassCastException(String.format( + "Index object with id '%s' is of type %s that cannot be cast to %s.", + ddiObjectId, ddiObjectId.getClass(), clazz)); + return clazz.cast(object); + } + + /** + * Checks if the given identifier is present in the index. + * @param ddiObjectId String identifier value. + * @return True if the index contains the given identifier. + */ + public boolean containsId(String ddiObjectId) { + return index.containsKey(ddiObjectId); + } + + /** + * Returns the parent object of DDI object with given identifier. + * @param ddiObjectId String identifier value. + * @throws NoSuchElementException if the parent object for given identifier cannot be found. + */ + public AbstractIdentifiableType getParent(String ddiObjectId) { + return this.get(parentsMap.get(ddiObjectId)); + } + + /** + * Returns the parent object of DDI object with given identifier. + * @param ddiObjectId String identifier value. + * @throws NoSuchElementException if the parent object for given identifier cannot be found. + * @throws ClassCastException if the given object cannot be cast to the given type. + */ + public T getParent(String ddiObjectId, Class clazz) { + return this.get(parentsMap.get(ddiObjectId), clazz); + } + +} diff --git a/model/src/main/java/fr/insee/ddi/utils/DDIUtils.java b/model/src/main/java/fr/insee/ddi/utils/DDIUtils.java new file mode 100644 index 0000000..c3d3efe --- /dev/null +++ b/model/src/main/java/fr/insee/ddi/utils/DDIUtils.java @@ -0,0 +1,49 @@ +package fr.insee.ddi.utils; + +import fr.insee.ddi.lifecycle33.reusable.AbstractIdentifiableType; +import fr.insee.ddi.lifecycle33.reusable.IDType; + +/** + * Utility class that provide some methods for DDI objects. + */ +public class DDIUtils { + + private DDIUtils() {} + + /** + * Returns a better representation than the "toString" method for a DDI object. + * @param ddiObject A DDI object. + * @return String representation of the object. + */ + public static String ddiToString(Object ddiObject) { + String className = ddiObject.getClass().getSimpleName(); + if (! (ddiObject instanceof AbstractIdentifiableType ddiIdentifiableObject)) + return className; + if (ddiIdentifiableObject.getIDList().isEmpty()) + return className + "[id=null]"; + return className + "[id=" + ddiIdentifiableObject.getIDArray(0).getStringValue() + "]"; + } + + /** + * Returns the identifier of the given DDI object. + * @param ddiIdentifiableObject A DDI identifiable object. + * @return String value of the object identifier. + */ + public static String getIdValue(AbstractIdentifiableType ddiIdentifiableObject) { + if (ddiIdentifiableObject.getIDList().isEmpty()) + return null; + return ddiIdentifiableObject.getIDArray(0).getStringValue(); + } + + /** + * Sets the identifier value of the given DDI object. + * @param ddiIdentifiableObject A DDI identifiable object. + * @param id String identifier value. + */ + public static void setIdValue(AbstractIdentifiableType ddiIdentifiableObject, String id) { + if (ddiIdentifiableObject.getIDList().isEmpty()) + ddiIdentifiableObject.getIDList().add(IDType.Factory.newInstance()); + ddiIdentifiableObject.getIDArray(0).setStringValue(id); + } + +} diff --git a/model/src/test/java/fr/insee/ddi/index/DDIIndexTest.java b/model/src/test/java/fr/insee/ddi/index/DDIIndexTest.java new file mode 100644 index 0000000..e6f8cb0 --- /dev/null +++ b/model/src/test/java/fr/insee/ddi/index/DDIIndexTest.java @@ -0,0 +1,140 @@ +package fr.insee.ddi.index; + +import fr.insee.ddi.exception.DuplicateIdException; +import fr.insee.ddi.lifecycle33.datacollection.SequenceType; +import fr.insee.ddi.lifecycle33.group.ResourcePackageType; +import fr.insee.ddi.lifecycle33.instance.DDIInstanceDocument; +import fr.insee.ddi.lifecycle33.instance.DDIInstanceType; +import fr.insee.ddi.lifecycle33.logicalproduct.VariableSchemeType; +import fr.insee.ddi.lifecycle33.logicalproduct.VariableType; +import fr.insee.ddi.lifecycle33.reusable.AbstractIdentifiableType; +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; +import java.util.Set; + +import static fr.insee.ddi.utils.DDIUtils.getIdValue; +import static fr.insee.ddi.utils.DDIUtils.setIdValue; +import static org.junit.jupiter.api.Assertions.*; + +public class DDIIndexTest { + + @Test + void indexDDIInstanceDocument() { + // + DDIInstanceDocument ddiInstanceDocument = DDIInstanceDocument.Factory.newInstance(); + DDIInstanceType ddiInstanceType = DDIInstanceType.Factory.newInstance(); + setIdValue(ddiInstanceType, "instance-id"); + ResourcePackageType resourcePackageType = ResourcePackageType.Factory.newInstance(); + setIdValue(resourcePackageType, "resource-package-id"); + VariableSchemeType variableSchemeType = VariableSchemeType.Factory.newInstance(); + setIdValue(variableSchemeType, "variable-scheme-id"); + VariableType variableType1 = VariableType.Factory.newInstance(); + setIdValue(variableType1, "variable-1-id"); + VariableType variableType2 = VariableType.Factory.newInstance(); + setIdValue(variableType2, "variable-2-id"); + // + variableSchemeType.getVariableList().add(variableType1); + variableSchemeType.getVariableList().add(variableType2); + resourcePackageType.getVariableSchemeList().add(variableSchemeType); + ddiInstanceType.getResourcePackageList().add(resourcePackageType); + ddiInstanceDocument.setDDIInstance(ddiInstanceType); + + // + DDIIndex ddiIndex = new DDIIndex(); + ddiIndex.indexDDI(ddiInstanceDocument); + + // + assertTrue(ddiIndex.containsId("instance-id")); + assertTrue(ddiIndex.containsId("resource-package-id")); + assertTrue(ddiIndex.containsId("variable-scheme-id")); + assertTrue(ddiIndex.containsId("variable-1-id")); + assertTrue(ddiIndex.containsId("variable-2-id")); + // + assertThrows(NoSuchElementException.class, () -> ddiIndex.get("foo-id")); + // + compareDDIObjects(variableType1, ddiIndex.get("variable-1-id")); + compareDDIObjects(variableType2, ddiIndex.get("variable-2-id")); + // + compareDDIObjects(ddiInstanceType, ddiIndex.getParent("resource-package-id")); + compareDDIObjects(resourcePackageType, ddiIndex.getParent("variable-scheme-id")); + compareDDIObjects(variableSchemeType, ddiIndex.getParent("variable-1-id")); + compareDDIObjects(variableSchemeType, ddiIndex.getParent("variable-2-id")); + } + + /** Utility for this test class. */ + private static void compareDDIObjects(AbstractIdentifiableType expectedObject, AbstractIdentifiableType actualObject) { + assertEquals(expectedObject.getClass(), actualObject.getClass()); + assertEquals(getIdValue(expectedObject), getIdValue(actualObject)); + } + + @Test + void indexDDIObject() { + // + VariableSchemeType variableSchemeType = VariableSchemeType.Factory.newInstance(); + setIdValue(variableSchemeType, "variable-scheme-id"); + VariableType variableType1 = VariableType.Factory.newInstance(); + setIdValue(variableType1, "variable-1-id"); + VariableType variableType2 = VariableType.Factory.newInstance(); + setIdValue(variableType2, "variable-2-id"); + // + variableSchemeType.getVariableList().add(variableType1); + variableSchemeType.getVariableList().add(variableType2); + + // + DDIIndex ddiIndex = new DDIIndex(); + ddiIndex.indexDDIObject(variableSchemeType); + + // + assertEquals(Set.of("variable-scheme-id", "variable-1-id", "variable-2-id"), ddiIndex.getIndex().keySet()); + } + + @Test + void duplicateIdentifiers() { + // + VariableSchemeType variableSchemeType = VariableSchemeType.Factory.newInstance(); + setIdValue(variableSchemeType, "variable-scheme-id"); + VariableType variableType1 = VariableType.Factory.newInstance(); + setIdValue(variableType1, "variable-1-id"); + VariableType variableType2 = VariableType.Factory.newInstance(); + setIdValue(variableType2, "variable-1-id"); + // + variableSchemeType.getVariableList().add(variableType1); + variableSchemeType.getVariableList().add(variableType2); + + assertThrows(DuplicateIdException.class, () -> new DDIIndex().indexDDIObject(variableSchemeType)); + } + + @Test + void typedGet() { + // + VariableSchemeType variableSchemeType = VariableSchemeType.Factory.newInstance(); + setIdValue(variableSchemeType, "variable-scheme-id"); + VariableType variableType1 = VariableType.Factory.newInstance(); + setIdValue(variableType1, "variable-id"); + variableSchemeType.getVariableList().add(variableType1); + // + DDIIndex ddiIndex = new DDIIndex(); + ddiIndex.indexDDIObject(variableSchemeType); + // + assertInstanceOf(VariableType.class, ddiIndex.get("variable-id", VariableType.class)); + assertThrows(NoSuchElementException.class, () -> ddiIndex.get("foo-id", VariableType.class)); + assertThrows(ClassCastException.class, () -> ddiIndex.get("variable-id", SequenceType.class)); + } + + @Test + void typedGetParent() { + // + VariableSchemeType variableSchemeType = VariableSchemeType.Factory.newInstance(); + setIdValue(variableSchemeType, "variable-scheme-id"); + VariableType variableType1 = VariableType.Factory.newInstance(); + setIdValue(variableType1, "variable-id"); + variableSchemeType.getVariableList().add(variableType1); + // + DDIIndex ddiIndex = new DDIIndex(); + ddiIndex.indexDDIObject(variableSchemeType); + // + assertInstanceOf(VariableSchemeType.class, ddiIndex.getParent("variable-id", VariableSchemeType.class)); + } + +} diff --git a/model/src/test/java/fr/insee/ddi/utils/DDIUtilsTest.java b/model/src/test/java/fr/insee/ddi/utils/DDIUtilsTest.java new file mode 100644 index 0000000..c388585 --- /dev/null +++ b/model/src/test/java/fr/insee/ddi/utils/DDIUtilsTest.java @@ -0,0 +1,50 @@ +package fr.insee.ddi.utils; + +import fr.insee.ddi.lifecycle33.instance.DDIInstanceType; +import fr.insee.ddi.lifecycle33.reusable.IDType; +import fr.insee.ddi.lifecycle33.reusable.ValueType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DDIUtilsTest { + + @Test + void ddiIdentifiableObjectToString() { + // + String fooId = "foo-id"; + DDIInstanceType ddiInstanceType = DDIInstanceType.Factory.newInstance(); + ddiInstanceType.getIDList().add(IDType.Factory.newInstance()); + ddiInstanceType.getIDArray(0).setStringValue(fooId); + // + assertEquals("DDIInstanceTypeImpl[id=foo-id]", DDIUtils.ddiToString(ddiInstanceType)); + } + + @Test + void ddiIdentifiableObjectToString_idNotSet() { + // + DDIInstanceType ddiInstanceType = DDIInstanceType.Factory.newInstance(); + // + assertEquals("DDIInstanceTypeImpl[id=null]", DDIUtils.ddiToString(ddiInstanceType)); + } + + @Test + void nonIdentifiableObjectToString() { + // + ValueType valueType = ValueType.Factory.newInstance(); + // + assertEquals("ValueTypeImpl", DDIUtils.ddiToString(valueType)); + } + + @Test + void getAndSetIdValueTest() { + // + DDIInstanceType ddiInstanceType = DDIInstanceType.Factory.newInstance(); + DDIUtils.setIdValue(ddiInstanceType, "foo-id"); + assertEquals("foo-id", DDIUtils.getIdValue(ddiInstanceType)); + // apply the set id method on an object that already have one + DDIUtils.setIdValue(ddiInstanceType, "bar-id"); + assertEquals("bar-id", DDIUtils.getIdValue(ddiInstanceType)); + } + +}