From eb818f5874bd2eadbdc216a4b349e865c2fee96a Mon Sep 17 00:00:00 2001 From: JasonWang-0401 Date: Sun, 7 Jun 2026 02:31:37 -0700 Subject: [PATCH] Add nickname-aware patient name search Adds an opt-in nickname/alias layer to patient name search so that searching a common English given-name nickname matches a patient registered under the formal name and vice versa (e.g. "Bob" finds "Robert"). Soundex cannot connect these because a nickname is a social alias, not a phonetic variant. The change mirrors the existing analyzer pattern instead of adding a subsystem: - nicknames.txt: curated Solr-format synonym dictionary (given/middle names). - SearchAnalysis.NICKNAME_ANALYZER: new analyzer name constant. - LuceneConfig: registers nicknameAnalyzer (Whitespace + Classic + LowerCase + ASCIIFolding + SynonymGraphFilter over nicknames.txt + FlattenGraphFilter). - PersonName: two new index-time @FullTextField fields, givenNameNickname and middleNameNickname, queried with the plain EXACT analyzer. - OpenmrsConstants: new default-off boolean GlobalProperty patientSearch.matchMode.nickname, seeded as a core global property. - PersonQuery.getPersonNameQuery: adds the nickname fields to the query only when the toggle is enabled, composing with the existing match modes. Default-off and additive, so existing deployments are unaffected until an administrator enables it and rebuilds the search index. Adds two PatientDAOTest cases covering the enabled and disabled paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/src/main/java/org/openmrs/PersonName.java | 2 + .../openmrs/api/db/hibernate/PersonQuery.java | 6 +++ .../db/hibernate/search/SearchAnalysis.java | 2 + .../hibernate/search/lucene/LuceneConfig.java | 12 +++++ .../org/openmrs/util/OpenmrsConstants.java | 6 +++ .../api/db/hibernate/search/nicknames.txt | 40 +++++++++++++++++ .../org/openmrs/api/db/PatientDAOTest.java | 44 +++++++++++++++++++ 7 files changed, 112 insertions(+) create mode 100644 api/src/main/resources/org/openmrs/api/db/hibernate/search/nicknames.txt diff --git a/api/src/main/java/org/openmrs/PersonName.java b/api/src/main/java/org/openmrs/PersonName.java index 6645d5843966..cb44e89ec859 100644 --- a/api/src/main/java/org/openmrs/PersonName.java +++ b/api/src/main/java/org/openmrs/PersonName.java @@ -69,6 +69,7 @@ public class PersonName extends BaseChangeableOpenmrsData implements java.io.Ser @FullTextField(name = "givenNameStart", analyzer = SearchAnalysis.START_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) @FullTextField(name = "givenNameAnywhere", analyzer = SearchAnalysis.ANYWHERE_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) @FullTextField(name = "givenNameSoundex", analyzer = SearchAnalysis.SOUNDEX_ANALYZER) + @FullTextField(name = "givenNameNickname", analyzer = SearchAnalysis.NICKNAME_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) private String givenName; private String prefix; @@ -77,6 +78,7 @@ public class PersonName extends BaseChangeableOpenmrsData implements java.io.Ser @FullTextField(name = "middleNameStart", analyzer = SearchAnalysis.START_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) @FullTextField(name = "middleNameAnywhere", analyzer = SearchAnalysis.ANYWHERE_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) @FullTextField(name = "middleNameSoundex", analyzer = SearchAnalysis.SOUNDEX_ANALYZER) + @FullTextField(name = "middleNameNickname", analyzer = SearchAnalysis.NICKNAME_ANALYZER, searchAnalyzer = SearchAnalysis.EXACT_ANALYZER) private String middleName; private String familyNamePrefix; diff --git a/api/src/main/java/org/openmrs/api/db/hibernate/PersonQuery.java b/api/src/main/java/org/openmrs/api/db/hibernate/PersonQuery.java index 8ab9b25b2f73..5a89676a9e6e 100644 --- a/api/src/main/java/org/openmrs/api/db/hibernate/PersonQuery.java +++ b/api/src/main/java/org/openmrs/api/db/hibernate/PersonQuery.java @@ -173,6 +173,12 @@ private SearchPredicate getPersonNameQuery(SearchPredicateFactory predicateFacto Arrays.asList("givenNameAnywhere", "middleNameAnywhere", "familyNameAnywhere", "familyName2Anywhere")); } + boolean nickname = Boolean.parseBoolean(Context.getAdministrationService() + .getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_NICKNAME, "false")); + if (nickname) { + fields.addAll(Arrays.asList("givenNameNickname", "middleNameNickname")); + } + return newPersonNameSearchQuery(predicateFactory, fields, query, orQueryParser, includeVoided, patientsOnly, dead, null, null); } diff --git a/api/src/main/java/org/openmrs/api/db/hibernate/search/SearchAnalysis.java b/api/src/main/java/org/openmrs/api/db/hibernate/search/SearchAnalysis.java index 513dbbc7116d..c48e76965fa3 100644 --- a/api/src/main/java/org/openmrs/api/db/hibernate/search/SearchAnalysis.java +++ b/api/src/main/java/org/openmrs/api/db/hibernate/search/SearchAnalysis.java @@ -39,4 +39,6 @@ private SearchAnalysis() { public static final String SOUNDEX_ANALYZER = "soundexAnalyzer"; public static final String NAME_ANALYZER = "nameAnalyzer"; + + public static final String NICKNAME_ANALYZER = "nicknameAnalyzer"; } diff --git a/api/src/main/java/org/openmrs/api/db/hibernate/search/lucene/LuceneConfig.java b/api/src/main/java/org/openmrs/api/db/hibernate/search/lucene/LuceneConfig.java index 9e01421135f3..3a1a18697ab1 100644 --- a/api/src/main/java/org/openmrs/api/db/hibernate/search/lucene/LuceneConfig.java +++ b/api/src/main/java/org/openmrs/api/db/hibernate/search/lucene/LuceneConfig.java @@ -10,6 +10,7 @@ package org.openmrs.api.db.hibernate.search.lucene; import org.apache.lucene.analysis.classic.ClassicFilterFactory; +import org.apache.lucene.analysis.core.FlattenGraphFilterFactory; import org.apache.lucene.analysis.core.KeywordTokenizerFactory; import org.apache.lucene.analysis.core.LowerCaseFilterFactory; import org.apache.lucene.analysis.core.WhitespaceTokenizerFactory; @@ -18,6 +19,7 @@ import org.apache.lucene.analysis.ngram.NGramFilterFactory; import org.apache.lucene.analysis.phonetic.PhoneticFilterFactory; import org.apache.lucene.analysis.standard.StandardTokenizerFactory; +import org.apache.lucene.analysis.synonym.SynonymGraphFilterFactory; import org.hibernate.search.backend.lucene.analysis.LuceneAnalysisConfigurationContext; import org.hibernate.search.backend.lucene.analysis.LuceneAnalysisConfigurer; import org.openmrs.api.db.hibernate.search.SearchAnalysis; @@ -61,5 +63,15 @@ public void configure(LuceneAnalysisConfigurationContext context) { context.analyzer(SearchAnalysis.SOUNDEX_ANALYZER).custom().tokenizer(StandardTokenizerFactory.class) .tokenFilter(ClassicFilterFactory.class).tokenFilter(LowerCaseFilterFactory.class) .tokenFilter(PhoneticFilterFactory.class).param("encoder", "Soundex"); + + // Same backbone as EXACT_ANALYZER, plus a SynonymGraphFilter that expands a name into its + // common nicknames at index time using the curated nicknames.txt dictionary. The graph the + // synonym filter produces must be flattened with FlattenGraphFilter before it can be indexed. + // Queries use the plain EXACT_ANALYZER (see PersonName), so expansion happens only at index time. + context.analyzer(SearchAnalysis.NICKNAME_ANALYZER).custom().tokenizer(WhitespaceTokenizerFactory.class) + .tokenFilter(ClassicFilterFactory.class).tokenFilter(LowerCaseFilterFactory.class) + .tokenFilter(ASCIIFoldingFilterFactory.class).tokenFilter(SynonymGraphFilterFactory.class) + .param("synonyms", "org/openmrs/api/db/hibernate/search/nicknames.txt").param("expand", "true") + .param("ignoreCase", "true").tokenFilter(FlattenGraphFilterFactory.class); } } diff --git a/api/src/main/java/org/openmrs/util/OpenmrsConstants.java b/api/src/main/java/org/openmrs/util/OpenmrsConstants.java index 7ba7af14f073..da4ce861ca53 100644 --- a/api/src/main/java/org/openmrs/util/OpenmrsConstants.java +++ b/api/src/main/java/org/openmrs/util/OpenmrsConstants.java @@ -341,6 +341,8 @@ public static final Collection AUTO_ROLES() { public static final String GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_SOUNDEX = "SOUNDEX"; + public static final String GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_NICKNAME = "patientSearch.matchMode.nickname"; + public static final String GLOBAL_PROPERTY_PROVIDER_SEARCH_MATCH_MODE = "providerSearch.matchMode"; public static final String GLOBAL_PROPERTY_DEFAULT_SERIALIZER = "serialization.defaultSerializer"; @@ -881,6 +883,10 @@ public static final List CORE_GLOBAL_PROPERTIES() { props.add(new GlobalProperty(GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_MODE, GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_START, "Specifies how patient names are matched while searching patient. Valid values are 'ANYWHERE' or 'START'. Defaults to start if missing or invalid value is present.")); + props.add(new GlobalProperty(GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_NICKNAME, "false", + "When set to true, patient name search also matches common English nicknames (e.g. searching 'Bob' finds a patient registered as 'Robert' and vice versa). Additive to the patientSearch.matchMode setting. Requires a search index rebuild to take effect. Defaults to false.", + BooleanDatatype.class, null)); + props.add(new GlobalProperty(GP_ENABLE_CONCEPT_MAP_TYPE_MANAGEMENT, "false", "Enables or disables management of concept map types", BooleanDatatype.class, null)); diff --git a/api/src/main/resources/org/openmrs/api/db/hibernate/search/nicknames.txt b/api/src/main/resources/org/openmrs/api/db/hibernate/search/nicknames.txt new file mode 100644 index 000000000000..fe662831f4a6 --- /dev/null +++ b/api/src/main/resources/org/openmrs/api/db/hibernate/search/nicknames.txt @@ -0,0 +1,40 @@ +# Curated English given-name nickname dictionary (Solr synonym format). +# +# Used by the "nicknameAnalyzer" defined in LuceneConfig to expand a person's +# given/middle name into its common nicknames at index time, so that a search +# for a nickname (e.g. "Bob") matches a patient registered under the formal +# name (e.g. "Robert") and vice versa. +# +# Each comma-separated line is an equivalence group: every term is treated as a +# synonym of every other term in that line (SynonymGraphFilter with expand=true). +# Keep entries lowercase; the analyzer lowercases tokens before this filter runs. +# +# Scope (v1): English given/middle names only. Surnames and locale-specific +# dictionaries are out of scope. + +robert, rob, bob, bobby, robbie +william, will, bill, billy, liam +richard, rick, rich, dick, ricky +james, jim, jimmy, jamie +john, johnny, jack +michael, mike, mikey, mick +charles, charlie, chuck, chas +joseph, joe, joey +thomas, tom, tommy +edward, ed, eddie, ted, ned +anthony, tony +daniel, dan, danny +matthew, matt +christopher, chris +nicholas, nick, nicky +margaret, maggie, peggy, meg, marge +elizabeth, liz, beth, betty, eliza, lizzie +katherine, catherine, kate, katie, kathy, cathy +jennifer, jen, jenny +patricia, pat, patty, tricia +deborah, deb, debbie +susan, sue, suzie +barbara, barb, babs +victoria, vicky, tori +samantha, sam, sammy +alexandra, alex, lexi, sandra diff --git a/api/src/test/java/org/openmrs/api/db/PatientDAOTest.java b/api/src/test/java/org/openmrs/api/db/PatientDAOTest.java index 9b9e618a0e4f..ad8fae693c7f 100644 --- a/api/src/test/java/org/openmrs/api/db/PatientDAOTest.java +++ b/api/src/test/java/org/openmrs/api/db/PatientDAOTest.java @@ -111,6 +111,50 @@ public void runBeforeEachTest() { OpenmrsConstants.GLOBAL_PROPERTY_PERSON_ATTRIBUTE_SEARCH_MATCH_EXACT); } + /** + * Verifies the nickname-aware name search: with the nickname GlobalProperty enabled, searching for + * a common English nickname matches a patient registered under the formal given name. + * + * @see PatientDAO#getPatients(String,Integer,Integer) + */ + @Test + public void getPatients_shouldMatchNicknameWhenNicknameSearchIsEnabled() { + // register an existing patient under the formal given name "Robert" + Patient patient = patientService.getPatient(2); + patient.getPersonName().setGivenName("Robert"); + patientService.savePatient(patient); + updateSearchIndex(); + + globalPropertiesTestHelper.setGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_NICKNAME, "true"); + + List results = dao.getPatients("Bob", 0, null); + + assertTrue(results.contains(patient), + "searching the nickname 'Bob' should match the patient registered as 'Robert'"); + } + + /** + * Regression guard: with the nickname GlobalProperty off (the default), name search behaves exactly + * as before and a nickname does not match the formal name. + * + * @see PatientDAO#getPatients(String,Integer,Integer) + */ + @Test + public void getPatients_shouldNotMatchNicknameWhenNicknameSearchIsDisabled() { + Patient patient = patientService.getPatient(2); + patient.getPersonName().setGivenName("Robert"); + patientService.savePatient(patient); + updateSearchIndex(); + + globalPropertiesTestHelper.setGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_NICKNAME, + "false"); + + List results = dao.getPatients("Bob", 0, null); + + assertFalse(results.contains(patient), + "with nickname search disabled, 'Bob' must not match the patient registered as 'Robert'"); + } + /** * @see PatientDAO#getPatients(String,String,List,null) */