Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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,199 @@
/*
* 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 = "^";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A bare ^ will not survive the round trip. The addresshierarchy module hands this delimiter straight to String.split, so it is a Java regex, and ^ there is the zero-width start anchor rather than a literal caret. I ran it on Java 17: "Cambodia^00".split("^") returns a single element, ["Cambodia^00"], so AddressHierarchyImportUtil.splitIntoNameAndUserGeneratedId (2.17.0) never separates the id out.

If merged as-is, re-importing an export taken from any database that populates user_generated_id renames every entry it touches: what should come back as Cambodia with id 00 becomes an entry literally named Cambodia^00, with user_generated_id left null. Nothing throws on either side, so neither the export nor the Iniz load reports anything wrong.

It is the module's own default, which is presumably where it came from, but the sibling default is broken the same way ("a|b".split("|") gives ["a", "|", "b"]), so those two defaults look like they have simply never been exercised. The configs in the wild all avoid the bare caret: openmrs/openmrs-content-referenceapplication-demo, mekomsolutions/openmrs-config-haiti and mekomsolutions/ozone-distro-cambodia all pair <entryDelimiter>,</entryDelimiter> with <identifierDelimiter>%</identifierDelimiter> (the demo's own addresshierarchy.csv next to that file reads Cambodia%00,Banteay Meanchey%01,Mongkol Borei%0101,...), and PIH/openmrs-config-zl escapes rather than swaps, \^ and \|. I would go with %, since it matches the reference application demo and sits naturally alongside the comma you already chose for entries.

Suggested change
public static final String IDENTIFIER_DELIMITER = "^";
public static final String IDENTIFIER_DELIMITER = "%";

buildEntriesCsv_appendsUserGeneratedIdWithIdentifierDelimiter pins the current character, so it needs the same edit. Asserting that a produced cell splits back into two parts on IDENTIFIER_DELIMITER would keep a future change to it honest.

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.


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 ? Collections.emptyMap() : template.getSizeMappings();
Map<String, String> elementDefaults = template == null ? Collections.emptyMap() : template.getElementDefaults();

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.

What happens if getSizeMappings or getElementDefaults returns null?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getElementDefaults() returns null on a stock install, so line 124 is the one that goes first. openmrs-core's DEFAULT_ADDRESS_TEMPLATE (OpenmrsConstants, 2.8.0) seeds layout.address.format with <nameMappings>, <sizeMappings> and <lineByLineFormat> and no <elementDefaults> element at all, and LayoutTemplate never initializes the field, so elementDefaults.get(token) throws on the very first level.

That is not an exotic state: it is every database whose address template GP has not been rewritten by an addressConfiguration.xml load, which includes any site that set its levels up through the addresshierarchy admin pages or via importAddressHierarchyFile. Those sites have levels and entries, so the domain is selected, and the export job then dies with an NPE having written neither file.

Extending the guard you already have for a null template to the two maps individually (template.getSizeMappings() == null ? Collections.emptyMap() : template.getSizeMappings(), same for element defaults) covers it, and a test passing a non-null AddressTemplate with unset maps would pin it, which the current HashMap-based test cannot.

@Bawanthathilan Bawanthathilan Aug 13, 2026

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.

What happens if getSizeMappings or getElementDefaults returns null?

the guard only checked template == null, but getSizeMappings() . getElementDefaults() can themselves return null. ill update this PR @wikumChamith


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,107 @@
/*
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These check that the generated string looks right, not that the addresshierarchy module can read it back, which is the property that actually matters for an exporter whose only job is producing input for that module's loader. AddressConfigurationLoader.readFromString is the exact inverse of the writeToString you call on line 139 and it needs no OpenMRS context (it just builds a plain XStream), so the stronger check is cheap:

AddressConfiguration parsed = AddressConfigurationLoader.readFromString(xml);
AddressComponent first = parsed.getAddressComponents().get(0);
assertEquals(AddressField.COUNTRY, first.getField());
assertEquals(40, first.getSizeMapping());
assertEquals("addresshierarchy.csv", parsed.getAddressHierarchyFile().getFilename());

Your call whether it is worth the churn. The reason I would take it is that it keeps holding when an addresshierarchy bump renames a field or changes an XStream alias, where ten contains checks would still pass on nine of them and the tenth failure would not tell you the file had stopped being loadable.

}

@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);
}
}
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>