Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ Currently supported domains:
* Person Attribute Types (name, description, searchable, format, foreign uuid, edit privilege)
* Location Tags (name, description)
* Locations (name, description, parent location, tags, address fields) — parent locations and tags
are pulled in via cross-domain closure
are pulled in via cross-domain closure. Tag membership is emitted inline as `Tag|<name>` columns,
which is Initializer's own equivalent of the standalone `locationtagmaps` domain, so that data
needs no separate file
* Drugs (name, description, strength, concept drug, concept dosage form, ingredients, mappings) —
drug/dosage-form/ingredient concepts are pulled in via cross-domain closure
* Order types (name, description, java class name, parent, concept classes) — parent order types and
Expand Down Expand Up @@ -64,12 +66,14 @@ Currently supported domains:
* Metadata sharing (raw zip packages already built and published through the metadatasharing
module's own UI, copied out as-is; not CSV/XML — one file per package) — requires the
metadatasharing module
* Address hierarchy (the `addressConfiguration.xml`, rebuilt from the ordered hierarchy levels and
the live address template, plus a headerless `addresshierarchy.csv` of one root-to-leaf path per
leaf entry; not CSV/XML rows — a whole-config directory) — requires the addresshierarchy module

Domains contributed by other modules (supportable, but depend on the module being present;
not yet covered):

* Identifier generation (idgen, auto-generation options)
* Address hierarchy (address hierarchy entries, location tag maps)
* Forms (Bahmni forms, AMPATH forms, AMPATH form translations, HTML forms)
* Billing / cashier (billable services, payment modes, cash points, cashier item prices)
* Appointment scheduling (specialities, service definitions, service types)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.module.metadataexport.domain.addresshierarchy;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openmrs.OpenmrsObject;
import org.openmrs.annotation.OpenmrsProfile;
import org.openmrs.api.context.Context;
import org.openmrs.layout.address.AddressSupport;
import org.openmrs.layout.address.AddressTemplate;
import org.openmrs.module.addresshierarchy.AddressField;
import org.openmrs.module.addresshierarchy.AddressHierarchyEntry;
import org.openmrs.module.addresshierarchy.AddressHierarchyLevel;
import org.openmrs.module.addresshierarchy.config.AddressComponent;
import org.openmrs.module.addresshierarchy.config.AddressConfiguration;
import org.openmrs.module.addresshierarchy.config.AddressConfigurationLoader;
import org.openmrs.module.addresshierarchy.config.AddressHierarchyFile;
import org.openmrs.module.addresshierarchy.service.AddressHierarchyService;
import org.openmrs.module.initializer.Domain;
import org.openmrs.module.metadataexport.export.DomainExporter;
import org.openmrs.module.metadataexport.export.ExportContext;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Slf4j
@Component
@OpenmrsProfile(modules = { "addresshierarchy:2.17.0" })
public class AddressHierarchyDomainExporter implements DomainExporter<AddressHierarchyEntry> {

public static final String CONFIG_FILE_NAME = "addressConfiguration.xml";

public static final String ENTRIES_FILE_NAME = "addresshierarchy.csv";

public static final String ENTRY_DELIMITER = ",";

public static final String IDENTIFIER_DELIMITER = "%";

private static final int DEFAULT_SIZE_MAPPING = 40;

@Override
public Domain getDomain() {
return Domain.ADDRESS_HIERARCHY;
}

@Override
public boolean handles(OpenmrsObject instance) {
return instance instanceof AddressHierarchyEntry;
}

@Override
public Collection<AddressHierarchyEntry> getAllInstances() {
AddressHierarchyService service = Context.getService(AddressHierarchyService.class);
List<AddressHierarchyEntry> all = new ArrayList<>();
for (AddressHierarchyLevel level : service.getOrderedAddressHierarchyLevels()) {
all.addAll(service.getAddressHierarchyEntriesByLevel(level));
}
return all;
}

@Override
public Collection<? extends OpenmrsObject> getDependencies(AddressHierarchyEntry instance) {
return Collections.emptyList();
}

@Override
public void export(Collection<AddressHierarchyEntry> instances, ExportContext context) throws IOException {
List<AddressHierarchyLevel> levels = Context.getService(AddressHierarchyService.class)
.getOrderedAddressHierarchyLevels();
if (levels.isEmpty()) {
log.warn("Address Hierarchy: no hierarchy levels are configured, nothing to export");
return;
}

File domainDir = new File(new File(context.getOutputDir(), "configuration"), getDomain().getName());
domainDir.mkdirs();

AddressTemplate template = AddressSupport.getInstance().getDefaultLayoutTemplate();
String configXml = buildAddressConfigurationXml(levels, template);
Files.write(new File(domainDir, CONFIG_FILE_NAME).toPath(), configXml.getBytes(StandardCharsets.UTF_8));

String entriesCsv = buildEntriesCsv(instances);
Files.write(new File(domainDir, ENTRIES_FILE_NAME).toPath(), entriesCsv.getBytes(StandardCharsets.UTF_8));
}

/**
* Rebuilds the {@code addressConfiguration.xml} content from the ordered hierarchy levels and the
* live address template, delegating the actual serialization to the addresshierarchy module so the
* output stays in lockstep with what it parses on import.
*/
String buildAddressConfigurationXml(List<AddressHierarchyLevel> levels, AddressTemplate template) {
Map<String, String> sizeMappings = template == null ? null : template.getSizeMappings();
if (sizeMappings == null) {
sizeMappings = Collections.emptyMap();
}
Map<String, String> elementDefaults = template == null ? null : template.getElementDefaults();
if (elementDefaults == null) {
elementDefaults = Collections.emptyMap();
}

AddressConfiguration configuration = new AddressConfiguration();
for (AddressHierarchyLevel level : levels) {
AddressField field = level.getAddressField();
String token = field == null ? null : field.getName();

AddressComponent component = new AddressComponent();
component.setField(field);
component.setNameMapping(level.getName());
component.setSizeMapping(parseSize(sizeMappings.get(token)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't the NPE be on levels without an address field?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and it lands on this line rather than on field.getName(), which is already null-guarded on line 118. token goes into the map as null, and sizeMappings is whatever came out of the GP: the core default declares <sizeMappings class="properties">, and XStream aliases properties to java.util.Properties, which extends Hashtable. Hashtable.get(null) throws NPE instead of returning null (I checked on Java 17). The unit test misses it because it builds a HashMap, which swallows a null key happily.

Levels with no address field are reachable rather than theoretical: AddressHierarchyServiceImpl.addAddressHierarchyLevel() creates one with addressField left unset, and AddressHierarchyImportUtil calls it for every CSV column beyond the configured levels, so any hierarchy grown that way has them.

Worth knowing before picking the fix: a plain null check on the lookup would get you past the export but leave a <field>-less <addressComponent> in the file, and that blows up on the way back in. AddressConfiguration.getAddressTemplate() calls c.getField().getName() unconditionally for every component, and isMatchableLevelConfig looks each level up by field. So skipping a level that has no address field, with a warning, produces something loadable where guarding the map lookup alone does not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't the NPE be on levels without an address field?

already gurded

component.setElementDefault(elementDefaults.get(token));
component.setRequiredInHierarchy(Boolean.TRUE.equals(level.getRequired()));
configuration.addAddressComponent(component);
}

if (template != null && template.getLineByLineFormat() != null) {
configuration.setLineByLineFormat(new ArrayList<>(template.getLineByLineFormat()));
}

AddressHierarchyFile file = new AddressHierarchyFile();
file.setFilename(ENTRIES_FILE_NAME);
file.setEntryDelimiter(ENTRY_DELIMITER);
file.setIdentifierDelimiter(IDENTIFIER_DELIMITER);
configuration.setAddressHierarchyFile(file);

return AddressConfigurationLoader.writeToString(configuration);
}

/**
* Builds the headerless entries CSV from the entries alone (no extra queries): a leaf is any entry
* that is not some other entry's parent, and each leaf's row is its root-to-leaf path walked
* through {@link AddressHierarchyEntry#getParent()}. Rows are sorted for deterministic output.
*/
String buildEntriesCsv(Collection<AddressHierarchyEntry> instances) {
Set<Integer> parentIds = new HashSet<>();
for (AddressHierarchyEntry entry : instances) {
AddressHierarchyEntry parent = entry.getParent();
if (parent != null && parent.getId() != null) {
parentIds.add(parent.getId());
}
}

List<String> rows = new ArrayList<>();
for (AddressHierarchyEntry entry : instances) {
if (entry.getId() != null && parentIds.contains(entry.getId())) {
continue; // not a leaf: it is covered by its descendants' rows
}
rows.add(buildRow(entry));
}
Collections.sort(rows);

StringBuilder csv = new StringBuilder();
for (String row : rows) {
csv.append(row).append('\n');
}
return csv.toString();
}

private static String buildRow(AddressHierarchyEntry leaf) {
Deque<String> cells = new ArrayDeque<>();
for (AddressHierarchyEntry current = leaf; current != null; current = current.getParent()) {
cells.addFirst(buildCell(current));
}
return String.join(ENTRY_DELIMITER, cells);
}

private static String buildCell(AddressHierarchyEntry entry) {
String name = entry.getName() == null ? "" : entry.getName();
if (StringUtils.isNotEmpty(entry.getUserGeneratedId())) {
return name + IDENTIFIER_DELIMITER + entry.getUserGeneratedId();
}
return name;
}

private static int parseSize(String size) {
if (StringUtils.isBlank(size)) {
return DEFAULT_SIZE_MAPPING;
}
try {
return Integer.parseInt(size.trim());
}
catch (NumberFormatException e) {
return DEFAULT_SIZE_MAPPING;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.module.metadataexport.domain.addresshierarchy;

import org.junit.jupiter.api.Test;
import org.openmrs.layout.address.AddressTemplate;
import org.openmrs.module.addresshierarchy.AddressField;
import org.openmrs.module.addresshierarchy.AddressHierarchyEntry;
import org.openmrs.module.addresshierarchy.AddressHierarchyLevel;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class AddressHierarchyDomainExporterTest {

private final AddressHierarchyDomainExporter exporter = new AddressHierarchyDomainExporter();

private static AddressHierarchyEntry entry(int id, String name, AddressHierarchyEntry parent) {
AddressHierarchyEntry entry = new AddressHierarchyEntry();
entry.setId(id);
entry.setName(name);
entry.setParent(parent);
return entry;
}

private static AddressHierarchyLevel level(AddressField field, String name, boolean required) {
AddressHierarchyLevel level = new AddressHierarchyLevel();
level.setAddressField(field);
level.setName(name);
level.setRequired(required);
return level;
}

@Test
void buildEntriesCsv_emitsOneSortedRowPerLeafPath() {
AddressHierarchyEntry country = entry(1, "Cambodia", null);
AddressHierarchyEntry province = entry(2, "Banteay Meanchey", country);
AddressHierarchyEntry district = entry(3, "Mongkol Borei", province);
AddressHierarchyEntry otherProvince = entry(4, "Kampong Cham", country);

String csv = exporter.buildEntriesCsv(Arrays.asList(country, province, district, otherProvince));

// country and the first province are interior nodes (parents), so only the two leaves are rows,
// each a full root-to-leaf path, sorted for deterministic output.
assertEquals("Cambodia,Banteay Meanchey,Mongkol Borei\n" + "Cambodia,Kampong Cham\n", csv);
}

@Test
void buildEntriesCsv_appendsUserGeneratedIdWithIdentifierDelimiter() {
AddressHierarchyEntry country = entry(1, "Cambodia", null);
AddressHierarchyEntry province = entry(2, "Banteay Meanchey", country);
province.setUserGeneratedId("BM");

String csv = exporter.buildEntriesCsv(Arrays.asList(country, province));

assertEquals("Cambodia,Banteay Meanchey%BM\n", csv);
}

@Test
void buildAddressConfigurationXml_reproducesComponentsFileAndFormat() {
Map<String, String> sizeMappings = new HashMap<>();
sizeMappings.put(AddressField.COUNTRY.getName(), "40");
sizeMappings.put(AddressField.STATE_PROVINCE.getName(), "40");
Map<String, String> elementDefaults = new HashMap<>();
elementDefaults.put(AddressField.COUNTRY.getName(), "addresshierarchy.cambodia");

AddressTemplate template = new AddressTemplate("addressTemplate");
template.setSizeMappings(sizeMappings);
template.setElementDefaults(elementDefaults);
template.setLineByLineFormat(Arrays.asList("stateProvince", "country"));

String xml = exporter
.buildAddressConfigurationXml(Arrays.asList(level(AddressField.COUNTRY, "Location.country", true),
level(AddressField.STATE_PROVINCE, "Location.province", true)), template);

assertTrue(xml.contains("<field>COUNTRY</field>"), xml);
assertTrue(xml.contains("<nameMapping>Location.country</nameMapping>"), xml);
assertTrue(xml.contains("<sizeMapping>40</sizeMapping>"), xml);
assertTrue(xml.contains("<elementDefault>addresshierarchy.cambodia</elementDefault>"), xml);
assertTrue(xml.contains("<requiredInHierarchy>true</requiredInHierarchy>"), xml);
assertTrue(xml.contains("<field>STATE_PROVINCE</field>"), xml);
assertTrue(xml.contains("<string>country</string>"), xml);
assertTrue(xml.contains("<filename>addresshierarchy.csv</filename>"), xml);
assertTrue(xml.contains("<entryDelimiter>,</entryDelimiter>"), xml);
assertTrue(xml.contains("<identifierDelimiter>%</identifierDelimiter>"), xml);
}

@Test
void buildAddressConfigurationXml_defaultsSizeWhenTemplateMissing() {
String xml = exporter
.buildAddressConfigurationXml(Arrays.asList(level(AddressField.COUNTRY, "Location.country", false)), null);

assertTrue(xml.contains("<sizeMapping>40</sizeMapping>"), xml);
assertTrue(xml.contains("<requiredInHierarchy>false</requiredInHierarchy>"), xml);
}

@Test
void buildAddressConfigurationXml_toleratesTemplateWithNullMappings() {
// A non-null template whose size/default maps are null must not NPE; it falls back to defaults.
AddressTemplate template = new AddressTemplate("addressTemplate");
template.setSizeMappings(null);
template.setElementDefaults(null);

String xml = exporter.buildAddressConfigurationXml(
Arrays.asList(level(AddressField.COUNTRY, "Location.country", true)), template);

assertTrue(xml.contains("<field>COUNTRY</field>"), xml);
assertTrue(xml.contains("<sizeMapping>40</sizeMapping>"), xml);
}
}
1 change: 1 addition & 0 deletions omod/src/main/resources/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<aware_of_module>org.openmrs.module.emrapi</aware_of_module>
<aware_of_module>org.openmrs.module.metadatamapping</aware_of_module>
<aware_of_module>org.openmrs.module.metadatasharing</aware_of_module>
<aware_of_module>org.openmrs.module.addresshierarchy</aware_of_module>
</aware_of_modules>


Expand Down
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<emrapiVersion>3.4.0</emrapiVersion>
<metadatamappingVersion>1.6.0</metadatamappingVersion>
<metadatasharingVersion>1.2.2</metadatasharingVersion>
<addresshierarchyVersion>2.17.0</addresshierarchyVersion>
</properties>

<build>
Expand Down Expand Up @@ -137,5 +138,12 @@
<version>${metadatasharingVersion}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.openmrs.module</groupId>
<artifactId>addresshierarchy-api</artifactId>
<version>${addresshierarchyVersion}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>