Skip to content

Commit bf4f9de

Browse files
authored
[MODFQMMGR-1043] Make user address fields queryable (#1142)
1 parent afc5b3b commit bf4f9de

11 files changed

Lines changed: 1189 additions & 57 deletions

File tree

src/main/java/org/folio/fqm/service/EntityTypeService.java

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.folio.querytool.domain.dto.ValueSourceApi;
4545
import org.folio.querytool.domain.dto.ValueWithLabel;
4646
import org.folio.spring.FolioExecutionContext;
47+
import org.folio.spring.i18n.service.TranslationService;
4748
import org.springframework.lang.Nullable;
4849
import org.springframework.stereotype.Service;
4950

@@ -86,6 +87,8 @@ public class EntityTypeService {
8687
"XBC", "XBD", "FIM", "FRF", "XFO", "XFU", "GHC", "DEM", "XAU", "GRD", "GWP", "IEP", "ITL", "LVL", "LTL", "LUF", "MGF", "MTL", "MRO", "MXV",
8788
"MZM", "XPD", "PHP", "XPT", "PTE", "ROL", "RUR", "CSD", "SLE", "SLL", "XAG", "SKK", "SIT", "ESP", "XDR", "XSU", "SDD", "SRG", "STD", "XTS",
8889
"TPE", "TRL", "TMM", "USN", "USS", "XXX", "UYI", "VEB", "VEF", "VED", "CHE", "CHW", "YUM", "ZWN", "ZMK", "ZWD", "ZWR");
90+
private static final String COUNTRIES_FILEPATH = "country_codes.json";
91+
private static final String COUNTRY_TRANSLATION_TEMPLATE = "mod-fqm-manager.countries.%s";
8992

9093
private final EntityTypeRepository entityTypeRepository;
9194
private final EntityTypeFlatteningService entityTypeFlatteningService;
@@ -99,6 +102,7 @@ public class EntityTypeService {
99102
private final FolioExecutionContext folioExecutionContext;
100103
private final ClockService clockService;
101104
private final SimpleHttpClient simpleHttpClient;
105+
private final TranslationService translationService;
102106

103107
/**
104108
* Returns the list of all entity types.
@@ -211,11 +215,14 @@ public ColumnValues getFieldValues(UUID entityTypeId, String fieldName, @Nullabl
211215
// entity types using this MUST declare a dependency on view `_mod_search_languages_availability_indicator`
212216
// to ensure that this source is available
213217
case "languages" -> getLanguages(searchText, tenantsToQuery);
218+
case "countries" -> getCountries();
214219
case "tenant_id" -> getTenantIds(entityType);
215220
case "tenant_name" -> getTenantNames(entityType);
216221
// instructs query builder to provide organization finder plugin, so no values need be returned here
217222
case "organization", "donor_organization" -> ColumnValues.builder().content(List.of()).build();
218-
default -> throw new InvalidEntityTypeDefinitionException("Unhandled source name \"" + field.getSource().getName() + "\" for the FQM value source type in column \"" + fieldName + '"', entityType);
223+
default -> throw new InvalidEntityTypeDefinitionException("Unhandled source name \""
224+
+ field.getSource().getName() + "\" for the FQM value source type in column \""
225+
+ fieldName + '"', entityType);
219226
};
220227
}
221228
}
@@ -309,8 +316,7 @@ private ColumnValues getFieldValuesFromApi(Field field, String searchText, List<
309316
log.error("Failed to get column values from {} tenant due to exception:", tenantId, e);
310317
failureCount++;
311318
lastException = e;
312-
}
313-
catch (FeignException.NotFound e) {
319+
} catch (FeignException.NotFound e) {
314320
log.error("Value source API {} not found in tenant {}", field.getValueSourceApi().getPath(), tenantId);
315321
failureCount++;
316322
lastException = e;
@@ -433,6 +439,40 @@ private ColumnValues getLanguages(String searchText, List<String> tenantsToQuery
433439
return new ColumnValues().content(results);
434440
}
435441

442+
private ColumnValues getCountries() {
443+
ObjectMapper mapper = new ObjectMapper();
444+
try (InputStream input = getClass().getClassLoader().getResourceAsStream(COUNTRIES_FILEPATH)) {
445+
if (input == null) {
446+
log.warn("Country code file {} not found on classpath", COUNTRIES_FILEPATH);
447+
return new ColumnValues().content(List.of());
448+
}
449+
450+
// List of ISO 3166-1 alpha-2 codes
451+
List<String> codes = mapper.readValue(input, new TypeReference<>() {
452+
});
453+
454+
List<ValueWithLabel> values = codes.stream()
455+
.map(code -> {
456+
String translationKey = COUNTRY_TRANSLATION_TEMPLATE.formatted(code);
457+
String label = translationService.format(translationKey);
458+
459+
// Use original code as label if translation is missing
460+
if (label == null || StringUtils.isBlank(label) || label.equals(translationKey)) {
461+
label = code;
462+
}
463+
464+
return new ValueWithLabel().value(code).label(label);
465+
})
466+
.sorted(comparing(ValueWithLabel::getLabel, String.CASE_INSENSITIVE_ORDER))
467+
.toList();
468+
469+
return new ColumnValues().content(values);
470+
} catch (IOException e) {
471+
log.warn("Failed to read countries from {}", COUNTRIES_FILEPATH, e);
472+
return new ColumnValues().content(List.of());
473+
}
474+
}
475+
436476
private static ValueWithLabel toValueWithLabel(Map<String, Object> allValues, String fieldName) {
437477
var valueWithLabel = new ValueWithLabel().label(getFieldValue(allValues, fieldName));
438478
return allValues.containsKey(ID_FIELD_NAME)
@@ -688,7 +728,7 @@ static List<JoinFieldPair> discoverJoinConditions(EntityType customEntityType, E
688728
}
689729
return joinConditions.stream()
690730
.sorted(comparing((JoinFieldPair pair) -> pair.getSourceField().getLabel(), String.CASE_INSENSITIVE_ORDER)
691-
.thenComparing(pair -> pair.getTargetField().getLabel(), String.CASE_INSENSITIVE_ORDER))
731+
.thenComparing(pair -> pair.getTargetField().getLabel(), String.CASE_INSENSITIVE_ORDER))
692732
.toList();
693733
}
694734

src/main/java/org/folio/fqm/service/ResultSetService.java

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package org.folio.fqm.service;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.node.ObjectNode;
36
import lombok.extern.log4j.Log4j2;
7+
import org.apache.commons.collections4.CollectionUtils;
8+
import org.apache.commons.lang3.StringUtils;
49
import org.folio.fqm.client.SettingsClient;
510
import org.folio.fqm.repository.ResultSetRepository;
611
import org.folio.fqm.utils.EntityTypeUtils;
712
import org.folio.querytool.domain.dto.EntityType;
813
import org.folio.spring.FolioExecutionContext;
14+
import org.folio.spring.i18n.service.TranslationService;
915
import org.springframework.beans.factory.annotation.Autowired;
1016
import org.springframework.stereotype.Service;
1117

@@ -21,6 +27,7 @@
2127
import java.util.HashMap;
2228
import java.util.List;
2329
import java.util.Map;
30+
import java.util.Optional;
2431
import java.util.UUID;
2532
import java.util.concurrent.atomic.AtomicInteger;
2633
import java.util.function.Function;
@@ -36,10 +43,15 @@ public class ResultSetService {
3643
.optionalStart().appendOffsetId() // optional Z/timezone at end
3744
.toFormatter().withZone(ZoneOffset.UTC); // force interpretation as UTC
3845
private static final String DATE_TIME_REGEX = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}([+-]\\d{2}:\\d{2}(:\\d{2})?|Z|)$";
46+
private static final String COUNTRY_TRANSLATION_TEMPLATE = "mod-fqm-manager.countries.%s";
47+
private static final String NESTED_FIELD_MARKER = "[*]->";
48+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
49+
3950
private final ResultSetRepository resultSetRepository;
4051
private final EntityTypeFlatteningService entityTypeFlatteningService;
4152
private final SettingsClient settingsClient;
4253
private final FolioExecutionContext executionContext;
54+
private final TranslationService translationService;
4355

4456
public List<Map<String, Object>> getResultSet(UUID entityTypeId,
4557
List<String> fields,
@@ -64,6 +76,7 @@ private List<Map<String, Object>> getSortedContents(UUID entityTypeId, List<List
6476

6577
List<String> dateFields = localize ? EntityTypeUtils.getDateTimeFields(entityType) : List.of();
6678
ZoneId tenantTimezone = localize ? settingsClient.getTenantTimezone() : null;
79+
List<String> countryFields = EntityTypeUtils.getCountryLocalizationFieldPaths(entityType);
6780

6881
return contentIds
6982
.stream()
@@ -79,6 +92,7 @@ private List<Map<String, Object>> getSortedContents(UUID entityTypeId, List<List
7992
}
8093

8194
Map<String, Object> copiedContents = new HashMap<>(contents);
95+
localizeCountries(copiedContents, countryFields);
8296
if (localize) {
8397
localizeContent(copiedContents, dateFields, tenantTimezone);
8498
}
@@ -100,6 +114,99 @@ private void localizeContent(Map<String, Object> contents, List<String> dateFiel
100114
}
101115
}
102116

117+
private void localizeCountries(Map<String, Object> contents, List<String> countryFieldPaths) {
118+
if (CollectionUtils.isEmpty(countryFieldPaths)) {
119+
return;
120+
}
121+
for (String fieldPath : countryFieldPaths) {
122+
localizeCountryField(contents, fieldPath);
123+
}
124+
}
125+
126+
private void localizeCountryField(Map<String, Object> contents, String fieldPath) {
127+
if (StringUtils.isEmpty(fieldPath)) {
128+
return;
129+
}
130+
131+
int markerIndex = fieldPath.indexOf(NESTED_FIELD_MARKER);
132+
if (markerIndex < 0) {
133+
localizeTopLevelCountryField(contents, fieldPath);
134+
return;
135+
}
136+
137+
String rootField = fieldPath.substring(0, markerIndex);
138+
String nestedField = fieldPath.substring(markerIndex + NESTED_FIELD_MARKER.length());
139+
localizeNestedCountryField(contents, rootField, nestedField);
140+
}
141+
142+
private void localizeTopLevelCountryField(Map<String, Object> contents, String fieldName) {
143+
Object value = contents.get(fieldName);
144+
if (!(value instanceof String code) || code.isBlank()) {
145+
return;
146+
}
147+
localizeCountryCode(code).ifPresent(translated -> contents.put(fieldName, translated));
148+
}
149+
150+
private void localizeNestedCountryField(Map<String, Object> contents, String rootField, String nestedField) {
151+
if (rootField.isBlank() || nestedField.isBlank()) {
152+
return;
153+
}
154+
155+
Object root = contents.get(rootField);
156+
if (!(root instanceof String rootJson) || rootJson.isBlank()) {
157+
return;
158+
}
159+
160+
try {
161+
JsonNode node = OBJECT_MAPPER.readTree(rootJson);
162+
if (!node.isArray()) {
163+
return;
164+
}
165+
166+
boolean changed = false;
167+
for (JsonNode elementNode : node) {
168+
changed |= localizeCountryCodeIfPresent(elementNode, nestedField);
169+
}
170+
if (changed) {
171+
contents.put(rootField, OBJECT_MAPPER.writeValueAsString(node));
172+
}
173+
} catch (Exception e) {
174+
log.debug("Unable to localize country field '{}[*]->{}' (unexpected JSON): {}", rootField, nestedField, e.getMessage());
175+
}
176+
}
177+
178+
private boolean localizeCountryCodeIfPresent(JsonNode elementNode, String fieldName) {
179+
if (!elementNode.isObject()) {
180+
return false;
181+
}
182+
183+
ObjectNode objectNode = (ObjectNode) elementNode;
184+
JsonNode valueNode = objectNode.get(fieldName);
185+
if (valueNode == null || !valueNode.isTextual()) {
186+
return false;
187+
}
188+
189+
String code = valueNode.asText();
190+
Optional<String> localized = localizeCountryCode(code);
191+
if (localized.isEmpty()) {
192+
return false;
193+
}
194+
195+
objectNode.put(fieldName, localized.get());
196+
return true;
197+
}
198+
199+
private Optional<String> localizeCountryCode(String code) {
200+
String translationKey = COUNTRY_TRANSLATION_TEMPLATE.formatted(code);
201+
String localized = translationService.format(translationKey);
202+
203+
// If translation is missing, don't modify the original value
204+
if (localized == null || localized.isBlank() || localized.equals(translationKey)) {
205+
return Optional.empty();
206+
}
207+
return Optional.of(localized);
208+
}
209+
103210
private static String adjustDate(Instant instant, ZoneId tenantTimezone) {
104211
return instant.atZone(tenantTimezone).toLocalDate().toString();
105212
}

src/main/java/org/folio/fqm/utils/EntityTypeUtils.java

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import com.fasterxml.jackson.core.JsonProcessingException;
77
import com.fasterxml.jackson.databind.ObjectMapper;
8+
89
import java.io.UncheckedIOException;
910
import java.util.ArrayList;
1011
import java.util.Comparator;
@@ -14,9 +15,11 @@
1415
import java.util.SortedMap;
1516
import java.util.TreeMap;
1617
import java.util.function.BiConsumer;
18+
1719
import lombok.experimental.UtilityClass;
1820
import lombok.extern.log4j.Log4j2;
1921
import org.apache.commons.codec.digest.DigestUtils;
22+
import org.apache.commons.lang3.StringUtils;
2023
import org.apache.commons.lang3.tuple.Pair;
2124
import org.folio.fqm.domain.Query;
2225
import org.folio.fqm.exception.InvalidEntityTypeDefinitionException;
@@ -33,6 +36,7 @@
3336
import org.folio.querytool.domain.dto.Join;
3437
import org.folio.querytool.domain.dto.NestedObjectProperty;
3538
import org.folio.querytool.domain.dto.ObjectType;
39+
import org.folio.querytool.domain.dto.SourceColumn;
3640
import org.jooq.SortField;
3741
import org.jooq.impl.DSL;
3842

@@ -45,6 +49,8 @@ public class EntityTypeUtils {
4549

4650
public static final org.jooq.Field<String[]> RESULT_ID_FIELD = field("result_id", String[].class);
4751

52+
private static final String COUNTRIES_SOURCE = "countries";
53+
4854
/**
4955
* Returns a list of strings corresponding to the names of the id columns of an entity type.
5056
*
@@ -149,7 +155,7 @@ public static Optional<Join> findJoinBetween(EntityTypeColumn source, EntityType
149155
.stream()
150156
.filter(j ->
151157
j.getTargetId().equals(target.getOriginalEntityTypeId()) &&
152-
j.getTargetField().equals(splitFieldIntoAliasAndField(target.getName()).getRight())
158+
j.getTargetField().equals(splitFieldIntoAliasAndField(target.getName()).getRight())
153159
)
154160
.findFirst();
155161
}
@@ -189,8 +195,10 @@ private static List<EntityTypeColumn> getIdColumns(EntityType entityType) {
189195
* No guarantees are made about the order in which fields are visited, however, this will include
190196
* every field, no matter how deeply nested.
191197
*
192-
* @example
193-
* An entity type with columns `obj` and `arrobj`, where `obj` is an object with two properties
198+
* @param entityType the entity to traverse
199+
* @param consumer the consumer to run on each field. The first parameter is the
200+
* {@link Field field} itself and the second parameter is the path to that field
201+
* @example An entity type with columns `obj` and `arrobj`, where `obj` is an object with two properties
194202
* and `arrobj` is an array of objects with three properties, the following calls will be made:
195203
* <pre>
196204
* - consumer.accept(obj, "") // base column
@@ -201,10 +209,6 @@ private static List<EntityTypeColumn> getIdColumns(EntityType entityType) {
201209
* - consumer.accept(arrChild2, "arrobj[*]->") // second property
202210
* - consumer.accept(arrChild3, "arrobj[*]->") // third property
203211
* </pre>
204-
*
205-
* @param entityType the entity to traverse
206-
* @param consumer the consumer to run on each field. The first parameter is the
207-
* {@link Field field} itself and the second parameter is the path to that field
208212
*/
209213
public static void runOnEveryField(EntityType entityType, BiConsumer<Field, String> consumer) {
210214
if (entityType.getColumns() == null) {
@@ -245,13 +249,13 @@ private static void runOnEveryField(String parentPath, Field field, BiConsumer<F
245249
* <strong>NOT</strong> consider the entire entity type definition, only some properties used in
246250
* querying. Notable exclusions include owner information, any localized fields, and the ordering
247251
* of sources and columns.
248-
*
252+
* <p>
249253
* This DOES take into effect values which affect queries and results, including but not limited
250254
* to:
251255
* - Sources (alias, type, target)
252256
* - Columns (name, data type, ID state, getters)
253257
* - Cross-tenant status
254-
*
258+
* <p>
255259
* No assumptions should be made about the specific algorithm used to compute the hash, the hash
256260
* length, and the result should never be used to check if two entity types are equivalent. It
257261
* is only intended to detect changes which may affect query results. Additionally, no guarantees
@@ -344,4 +348,25 @@ public static void verifyEntityTypeHasNotChangedDuringQueryLifetime(Query query,
344348
);
345349
}
346350
}
351+
352+
public static List<String> getCountryLocalizationFieldPaths(EntityType entityType) {
353+
List<String> paths = new ArrayList<>();
354+
EntityTypeUtils.runOnEveryField(entityType, (field, parentPath) -> {
355+
if (field.getSource() == null
356+
|| field.getSource().getType() != SourceColumn.TypeEnum.FQM
357+
|| !COUNTRIES_SOURCE.equals(field.getSource().getName())) {
358+
return;
359+
}
360+
361+
// Use the underlying JSON property name for nested fields
362+
String leaf = (field instanceof NestedObjectProperty prop
363+
&& !StringUtils.isEmpty(prop.getProperty()))
364+
? prop.getProperty()
365+
: field.getName();
366+
367+
paths.add(parentPath + leaf);
368+
});
369+
370+
return paths;
371+
}
347372
}

0 commit comments

Comments
 (0)