Configuration-Based Reporting with OpenMRS Initializer

Configuration-Based Reporting with OpenMRS Initializer

This guide explains how to create reports using configuration files rather than Java code, leveraging the OpenMRS Initializer module's reportdescriptors domain. This approach allows you to define reports using YAML configuration files and SQL queries, making reports easier to maintain and deploy without code compilation.

What is Configuration-Based Reporting?

Configuration-based reporting uses YAML files to define report metadata and SQL files (or programmatic DataSetManagers) for data extraction. Reports are automatically loaded by the OpenMRS Initializer module at startup.

Key Benefits

No Code Compilation: Define reports in configuration files, no Java coding required

Version Control Friendly: Text-based YAML and SQL files work well with Git

Easy Deployment: Deploy reports by copying configuration files

Rapid Development: Modify SQL and reload without rebuilding module

Configuration Management: Manage reports alongside other OpenMRS configuration

Multi-Site Support: Use conditional loading based on components, countries, or sites

When to Use Configuration-Based Reports

Use configuration-based reports when:

  • Reports are primarily SQL-based data extraction

  • You want to manage reports as configuration (not code)

  • You need to deploy reports to multiple sites with variations

  • Your team prefers working with configuration files over Java

  • Reports are site-specific customizations

Use programmatic reports when:

  • Complex Java logic is required (beyond SQL)

  • You're building a reusable module with bundled reports

  • You need tight integration with module code

  • Reports require custom data definitions or converters

Prerequisites

Global Properties

You need set the global property reporting.loadReportsFromConfigurationAtStartup to true in your OpenMRS instance. This tells the Reporting module to load reports from the Initializer configuration.

Required Modules

Your OpenMRS instance must have these modules installed:

<!-- OpenMRS Reporting Module --> <dependency> <groupId>org.openmrs.module</groupId> <artifactId>reporting</artifactId> <version>1.28.0</version> </dependency> <!-- OpenMRS Initializer Module --> <dependency> <groupId>org.openmrs.module</groupId> <artifactId>initializer</artifactId> <version>2.9.0</version> </dependency>

Configuration Directory Structure

OpenMRS Initializer expects reports in this directory structure:

configuration/ └── reports/ └── reportdescriptors/ └── dataexports/ # Or any category name ├── myreport.yml # Report descriptor files ├── anotherreport.yml └── sql/ ├── myreport.sql # SQL query files └── anotherreport.sql

Standard Locations:

  • Server: /openmrs/data/configuration/reports/reportdescriptors/

  • Development: {appdata}/configuration/reports/reportdescriptors/

YAML Report Descriptor Anatomy

Basic Structure

Every report is defined in a YAML file with these components:

# Unique identifier for the report key: "myreportkey" # UUID for the report definition uuid: "12345678-1234-1234-1234-123456789abc" # Display name (can be message code for i18n) name: "My Report Name" # Description (can be message code for i18n) description: "Detailed description of what this report does" # Parameters users can provide when running report parameters: - key: "startDate" type: "java.util.Date" label: "Start Date" - key: "endDate" type: "java.util.Date" label: "End Date" # Datasets - where the data comes from datasets: - key: "dataset1" type: "sql" # SQL-based dataset config: "sql/myreport.sql" # Path to SQL file # Output format configuration designs: - type: "csv" properties: "characterEncoding": "UTF-8" "dateFormat": "yyyy-MM-dd HH:mm:ss" # Display and access control configuration config: category: "dataExport" order: 100 privilege: "Get Patients"

Complete Example:

Patient Registration Report

File: configuration/reports/reportdescriptors/dataexports/patient_registrations.yml

key: "patient.registrations.dataexport" uuid: "a1b2c3d4-e5f6-7890-1234-567890abcdef" name: "Patient Registrations" description: "Lists all patients registered within a date range with demographics" parameters: - key: "startDate" type: "java.util.Date" label: "Start Date" - key: "endDate" type: "java.util.Date" label: "End Date" - key: "location" type: "org.openmrs.Location" label: "Registration Location" datasets: - key: "registrations" type: "sql" config: "sql/patient_registrations.sql" designs: - type: "csv" properties: "filenameBase": "patient_registrations.{{ formatDate request.reportDefinition.parameterMappings.startDate \"yyyyMMdd\" }}.{{ formatDate request.reportDefinition.parameterMappings.endDate \"yyyyMMdd\" }}" "characterEncoding": "UTF-8" "dateFormat": "yyyy-MM-dd HH:mm:ss" config: category: "dataExport" order: 10 privilege: "Get Patients"

SQL File: configuration/reports/reportdescriptors/dataexports/sql/patient_registrations.sql

-- Patient Registrations Report -- Lists patients registered within a date range SELECT p.patient_id AS 'Patient ID', pi.identifier AS 'Patient Identifier', pn.given_name AS 'First Name', pn.family_name AS 'Last Name', per.gender AS 'Gender', per.birthdate AS 'Date of Birth', TIMESTAMPDIFF(YEAR, per.birthdate, CURDATE()) AS 'Age', DATE(p.date_created) AS 'Registration Date', loc.name AS 'Registration Location', u.username AS 'Registered By' FROM patient p INNER JOIN person per ON p.patient_id = per.person_id INNER JOIN patient_identifier pi ON p.patient_id = pi.patient_id AND pi.preferred = 1 INNER JOIN person_name pn ON p.patient_id = pn.person_id AND pn.preferred = 1 LEFT JOIN location loc ON p.location_id = loc.location_id LEFT JOIN users u ON p.creator = u.user_id WHERE DATE(p.date_created) BETWEEN @startDate AND @endDate AND (@location IS NULL OR p.location_id = @location) AND p.voided = 0 ORDER BY p.date_created DESC;

Parameters

Parameter Types

Common parameter types:

parameters: # Date parameter - key: "startDate" type: "java.util.Date" label: "Start Date" # Location parameter - key: "location" type: "org.openmrs.Location" label: "Facility" # String parameter - key: "searchTerm" type: "java.lang.String" label: "Search Term" # Integer parameter - key: "maxResults" type: "java.lang.Integer" label: "Maximum Results" # Concept parameter - key: "diagnosis" type: "org.openmrs.Concept" label: "Diagnosis"

Using Parameters in SQL

Parameters are referenced in SQL using @parameterName syntax:

WHERE DATE(e.encounter_datetime) BETWEEN @startDate AND @endDate AND e.location_id = @location AND o.concept_id = @diagnosis

Optional Parameters

Handle optional parameters with NULL checks:

WHERE DATE(e.encounter_datetime) BETWEEN @startDate AND @endDate AND (@location IS NULL OR e.location_id = @location) AND (@diagnosis IS NULL OR o.concept_id = @diagnosis)

Datasets

SQL-Based Datasets

Most common dataset type - executes SQL query against database:

datasets: - key: "mydata" type: "sql" config: "sql/myquery.sql"

The config path is relative to the reportdescriptors directory.

Programmatic DataSetManagers

Reference Java-based dataset managers:

datasets: - key: "complexdata" type: "registrationDataSetManager" # Spring bean name

This requires a Java class implementing DataSetManager, registered as a Spring bean.

Multiple Datasets

A single report can have multiple datasets:

datasets: - {key: "demographics", type: "sql", config: "sql/demographics.sql"} - {key: "vitals", type: "sql", config: "sql/vitals.sql"} - {key: "diagnoses", type: "sql", config: "sql/diagnoses.sql"} - {key: "medications", type: "sql", config: "sql/medications.sql"}

Each dataset becomes a separate sheet/tab in Excel output or separate section in CSV.

Example:

openmrs-config-pihemr/configuration/reports/reportdescriptors/dataexports /visitNoteData.yml

Report Designs

CSV Design

Basic CSV configuration:

designs: - type: "csv" properties: "characterEncoding": "UTF-8" "dateFormat": "yyyy-MM-dd HH:mm:ss"

CSV with Custom Filename

Use template expressions for dynamic filenames:

designs: - type: "csv" properties: "filenameBase": "myreport.{{ formatDate request.reportDefinition.parameterMappings.startDate \"yyyyMMdd\" }}.{{ formatDate request.reportDefinition.parameterMappings.endDate \"yyyyMMdd\" }}" "characterEncoding": "UTF-8"

This generates filenames like: myreport.20240101.20240131.csv

Excel Design

designs: - type: "excel" properties: "filenameBase": "myreport"

Character Encoding and Special Characters

Handle special characters with blacklist regex:

designs: - type: "csv" properties: "characterEncoding": "ISO-8859-1" "blacklistRegex": "[^\\p{InBasicLatin}\\p{L}]" # Remove non-Latin characters "dateFormat": "dd-MMM-yyyy HH:mm:ss"

Configuration Options

Display Category

Group reports by category:

config: category: "dataExport" # Common categories: dataExport, indicator, monitoring

Display Order

Control sort order in UI (lower numbers appear first):

config: order: 10 # Lower numbers appear first

Privilege-Based Access Control

Require specific privileges to run report:

config: privilege: "Get Patients" # OpenMRS privilege required

Or use custom privileges:

config: privilege: "Task: clinical.reports"

Component-Based Filtering

Show report only when specific components are enabled:

config: components: - "registration" # Your custom component names - "clinicalEncounters" - "allDataExports" # Show for all data exports

Country/Site-Based Filtering

Show report only in specific countries or sites:

config: countries: - "HAITI" - "LIBERIA" - "OTHER"

Or use custom site identifiers based on your implementation.

SQL Query Patterns

Basic Query Structure

-- Report Name and Description -- Purpose: What this report does SELECT p.patient_id AS 'Column 1', pi.identifier AS 'Column 2', pn.given_name AS 'Column 3' FROM patient p INNER JOIN patient_identifier pi ON p.patient_id = pi.patient_id WHERE DATE(p.date_created) BETWEEN @startDate AND @endDate AND p.voided = 0 ORDER BY p.date_created DESC;

Temporary Table Pattern

For complex reports, use temporary tables:

-- Create temporary table DROP TEMPORARY TABLE IF EXISTS temp_report_data; CREATE TEMPORARY TABLE temp_report_data ( patient_id INT, identifier VARCHAR(50), name VARCHAR(100), gender CHAR(1), birthdate DATE ); -- Insert base data INSERT INTO temp_report_data SELECT p.patient_id, pi.identifier, CONCAT(pn.given_name, ' ', pn.family_name), per.gender, per.birthdate FROM patient p INNER JOIN person per ON p.patient_id = per.person_id INNER JOIN patient_identifier pi ON p.patient_id = pi.patient_id AND pi.preferred = 1 INNER JOIN person_name pn ON p.patient_id = pn.person_id AND pn.preferred = 1 WHERE DATE(p.date_created) BETWEEN @startDate AND @endDate AND p.voided = 0; -- Enrich with additional data UPDATE temp_report_data t SET name = UPPER(name) WHERE name IS NOT NULL; -- Return results SELECT * FROM temp_report_data ORDER BY patient_id;

Handling NULL Values

SELECT p.patient_id, COALESCE(pn.given_name, 'Unknown') AS first_name, IFNULL(per.birthdate, '1900-01-01') AS birthdate, COALESCE(pa.address1, 'Not Provided') AS address FROM patient p LEFT JOIN person_name pn ON p.patient_id = pn.person_id LEFT JOIN person per ON p.patient_id = per.person_id LEFT JOIN person_address pa ON p.patient_id = pa.person_id

Age Calculations

SELECT p.patient_id, per.birthdate, TIMESTAMPDIFF(YEAR, per.birthdate, CURDATE()) AS age_years, TIMESTAMPDIFF(MONTH, per.birthdate, CURDATE()) AS age_months, CASE WHEN TIMESTAMPDIFF(YEAR, per.birthdate, CURDATE()) < 5 THEN '<5' WHEN TIMESTAMPDIFF(YEAR, per.birthdate, CURDATE()) BETWEEN 5 AND 14 THEN '5-14' WHEN TIMESTAMPDIFF(YEAR, per.birthdate, CURDATE()) BETWEEN 15 AND 49 THEN '15-49' ELSE '50+' END AS age_group FROM patient p INNER JOIN person per ON p.patient_id = per.person_id

Pivot Data (Observations)

SELECT p.patient_id, MAX(CASE WHEN cn.name = 'WEIGHT (KG)' THEN o.value_numeric END) AS weight, MAX(CASE WHEN cn.name = 'HEIGHT (CM)' THEN o.value_numeric END) AS height, MAX(CASE WHEN cn.name = 'TEMPERATURE (C)' THEN o.value_numeric END) AS temperature FROM patient p INNER JOIN encounter e ON p.patient_id = e.patient_id LEFT JOIN obs o ON e.encounter_id = o.encounter_id LEFT JOIN concept_name cn ON o.concept_id = cn.concept_id WHERE e.encounter_datetime BETWEEN @startDate AND @endDate AND e.voided = 0 AND o.voided = 0 GROUP BY p.patient_id;

Step-by-Step Tutorial: Creating Your First Report

Step 1: Create Directory Structure

mkdir -p configuration/reports/reportdescriptors/dataexports/sql

Step 2: Create YAML Descriptor

Create configuration/reports/reportdescriptors/dataexports/my_first_report.yml:

key: "my.first.report" uuid: "11111111-2222-3333-4444-555555555555" # Generate unique UUID name: "My First Report" description: "A simple patient list report" parameters: - key: "startDate" type: "java.util.Date" label: "Start Date" - key: "endDate" type: "java.util.Date" label: "End Date" datasets: - key: "patients" type: "sql" config: "sql/my_first_report.sql" designs: - type: "csv" properties: "characterEncoding": "UTF-8" "dateFormat": "yyyy-MM-dd" config: category: "dataExport" order: 100 privilege: "Get Patients"

Step 3: Create SQL Query

Create configuration/reports/reportdescriptors/dataexports/sql/my_first_report.sql:

-- My First Report -- Lists patients registered in date range SELECT p.patient_id AS 'Patient ID', pi.identifier AS 'Identifier', pn.given_name AS 'First Name', pn.family_name AS 'Last Name', per.gender AS 'Gender', per.birthdate AS 'Birth Date' FROM patient p INNER JOIN person per ON p.patient_id = per.person_id INNER JOIN patient_identifier pi ON p.patient_id = pi.patient_id AND pi.preferred = 1 INNER JOIN person_name pn ON p.patient_id = pn.person_id AND pn.preferred = 1 WHERE DATE(p.date_created) BETWEEN @startDate AND @endDate AND p.voided = 0 ORDER BY p.date_created DESC;

Step 4: Deploy and Test

  1. Copy files to OpenMRS configuration directory

  2. Restart OpenMRS (or reload Initializer configuration if supported)

  3. Navigate to Reports → Manage Reports

  4. Find your report in the list

  5. Run report with date range parameters

  6. Download CSV output

Step 5: Verify and Iterate

  • Check CSV output for correctness

  • Modify SQL as needed

  • Reload configuration (restart or reload)

  • Test again

Advanced Patterns

Multi-Dataset Report Example

YAML:

key: "comprehensive.patient.data" uuid: "aaaabbbb-cccc-dddd-eeee-ffffffffffff" name: "Comprehensive Patient Data" description: "Patient demographics, encounters, diagnoses, and medications" parameters: - {key: "startDate", type: "java.util.Date", label: "Start Date"} - {key: "endDate", type: "java.util.Date", label: "End Date"} datasets: - {key: "demographics", type: "sql", config: "sql/patient_demographics.sql"} - {key: "encounters", type: "sql", config: "sql/patient_encounters.sql"} - {key: "diagnoses", type: "sql", config: "sql/patient_diagnoses.sql"} - {key: "medications", type: "sql", config: "sql/patient_medications.sql"} designs: - type: "csv" properties: "filenameBase": "comprehensive_data" "characterEncoding": "UTF-8" config: category: "dataExport" order: 50 privilege: "Get Patients"

Each dataset query should use the same parameters and preferably return a common key (like patient_id) for joining in analysis tools.

Conditional Display Example

config: category: "dataExport" order: 20 components: - "hiv" # Only show if HIV component enabled - "allDataExports" countries: - "KENYA" # Only show in Kenya sites - "UGANDA" privilege: "Task: hiv.reports" # Require custom privilege

Comparison: Configuration-Based vs Programmatic

Aspect

Configuration-Based

Programmatic

Aspect

Configuration-Based

Programmatic

Files

YAML + SQL

Java classes

Deployment

Copy config files

Build and deploy module

Modification

Edit files, reload

Code, compile, redeploy

Version Control

Text files in Git

Java code in Git

Complexity

Simple to moderate

Simple to very complex

SQL Support

Excellent

Excellent

Custom Logic

Limited (SQL only)

Unlimited (Java)

Learning Curve

Lower (YAML, SQL)

Higher (Java, APIs)

Reusability

Config per installation

Module distributed to many sites

Best For

Site-specific reports

Module-bundled reports

When to Use Each Approach

Use Configuration-Based for:

  • Data extraction reports

  • Site-specific customizations

  • Rapid development and deployment

  • SQL-based reporting

  • Multi-site deployments with variations

Use Programmatic for:

  • Complex data transformations

  • Reusable module components

  • Custom cohort definitions

  • Integration with module logic

  • Shared across many implementations

Best Practices

Organization

  1. Use clear naming: patient_registrations.yml, not report1.yml

  2. Group by category: Use subdirectories for different report types

  3. Match SQL filenames: myreport.ymlsql/myreport.sql

  4. Comment SQL queries: Explain purpose and logic

SQL Optimization

  1. Filter early: Use WHERE clause to limit data

  2. Index usage: Filter on indexed columns (patient_id, encounter_id, etc.)

  3. **Avoid SELECT ***: Specify only needed columns

  4. Test with production data: Verify performance with realistic data volumes

  5. Use EXPLAIN: Check query execution plan for optimization opportunities

Parameter Handling

  1. Always validate: Handle NULL parameters in SQL

  2. Provide labels: Use clear, user-friendly labels

  3. Use standard names: startDate, endDate for consistency

  4. Document ranges: Explain what date range means (registration? encounter?)

Security

  1. Check privileges: Always set appropriate privilege requirements

  2. Filter voided: Always include WHERE x.voided = 0

  3. Protect PHI: Be mindful of sensitive data in reports

  4. Use parameters: Never build SQL with string concatenation

Testing

  1. Test edge cases: Empty date ranges, NULL parameters

  2. Verify counts: Check totals against known data

  3. Check encoding: Test special characters in names/addresses

  4. Performance test: Run with production-size datasets

  5. Multi-site test: If using components/countries, test filtering

Troubleshooting

Report Doesn't Appear

Check:

  1. Files in correct directory: configuration/reports/reportdescriptors/

  2. YAML syntax valid (use online YAML validator)

  3. OpenMRS restarted after adding files

  4. Initializer module loaded successfully (check logs)

  5. Privileges - user has required privilege

SQL Errors

Debug:

  1. Test SQL in MySQL directly

  2. Set parameters: SET @startDate = '2024-01-01';

  3. Check parameter names match (@startDate in SQL, startDate in YAML)

  4. Review OpenMRS logs for SQL error details

Empty Results

Verify:

  1. Date range contains data

  2. Parameters correctly passed

  3. WHERE clauses not too restrictive

  4. Voided records filtered appropriately

  5. JOINs not excluding all records

Encoding Issues

Fix:

designs: - type: "csv" properties: "characterEncoding": "UTF-8" # Try different encodings "blacklistRegex": "[^\\p{InBasicLatin}\\p{L}]" # Remove problematic characters

Example: Real-World Report from PIH

Here's a simplified example based on PIH's implementation:

File: appointments.yml

key: "appointments.dataexport" uuid: "7fd11020-e5d1-11e3-ac10-0800200c9a66" name: "Appointments Data Export" description: "Lists all appointments with patient demographics and appointment details" parameters: - key: "startDate" type: "java.util.Date" label: "Start Date" - key: "endDate" type: "java.util.Date" label: "End Date" datasets: - key: "appointments" type: "sql" config: "sql/appointments.sql" designs: - type: "csv" properties: "characterEncoding": "ISO-8859-1" "dateFormat": "dd-MMM-yyyy HH:mm:ss" config: category: "dataExport" order: 150 components: - "appointmentScheduling" privilege: "Task: archive.reports"

File: sql/appointments.sql (simplified)

-- Appointments Data Export SELECT p.patient_id AS 'Patient ID', pi.identifier AS 'Identifier', CONCAT(pn.given_name, ' ', pn.family_name) AS 'Patient Name', per.gender AS 'Gender', per.birthdate AS 'Birth Date', app.start_date_time AS 'Appointment Date', app.status AS 'Status', app_type.name AS 'Appointment Type', loc.name AS 'Location' FROM patient_appointment app INNER JOIN patient p ON app.patient_id = p.patient_id INNER JOIN person per ON p.patient_id = per.person_id INNER JOIN patient_identifier pi ON p.patient_id = pi.patient_id AND pi.preferred = 1 INNER JOIN person_name pn ON p.patient_id = pn.person_id AND pn.preferred = 1 LEFT JOIN appointment_type app_type ON app.appointment_type_id = app_type.appointment_type_id LEFT JOIN location loc ON app.location_id = loc.location_id WHERE DATE(app.start_date_time) BETWEEN @startDate AND @endDate AND app.voided = 0 ORDER BY app.start_date_time DESC;

Additional Resources

Summary

Configuration-based reporting with OpenMRS Initializer provides a powerful, flexible way to create reports without Java coding. By using YAML for metadata and SQL for data extraction, you can rapidly develop, deploy, and maintain reports across multiple OpenMRS installations.

Key Takeaways:

  • YAML files define report metadata, parameters, and configuration

  • SQL files contain data extraction logic

  • Reports automatically load via OpenMRS Initializer

  • Suitable for SQL-based data extraction reports

  • Complements programmatic reporting for complex scenarios

  • Ideal for site-specific customizations and multi-site deployments