allowed_origin = List.of("*");
@@ -1196,4 +1205,17 @@ public class AppProperties {
}
}
}
+
+ public static class Elasticsearch {
+
+ private String index_prefix = "";
+
+ public String getIndex_prefix() {
+ return index_prefix;
+ }
+
+ public void setIndex_prefix(String index_prefix) {
+ this.index_prefix = index_prefix;
+ }
+ }
}
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
index 99fe307..88c5874 100644
--- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
@@ -296,6 +296,12 @@ public class FhirServerConfigCommon {
appProperties.getExpunge_thread_count());
}
+ // Determine index prefix from configuration
+ if (appProperties.getElasticsearch() != null) {
+ String indexPrefix = appProperties.getElasticsearch().getIndex_prefix();
+ jpaStorageSettings.setHSearchIndexPrefix(indexPrefix != null ? indexPrefix : "");
+ }
+
return jpaStorageSettings;
}
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java
index 82d8511..a6299f7 100644
--- a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchBootSvcImpl.java
@@ -6,6 +6,7 @@ import ca.uhn.fhir.jpa.dao.TolerantJsonParser;
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
import ca.uhn.fhir.jpa.search.lastn.IElasticsearchSvc;
import ca.uhn.fhir.jpa.search.lastn.json.ObservationJson;
+import ca.uhn.fhir.jpa.starter.AppProperties;
import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
@@ -33,8 +34,8 @@ import java.util.stream.Collectors;
public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
// Index Constants
- public static final String OBSERVATION_INDEX = "observation_index";
- public static final String OBSERVATION_CODE_INDEX = "code_index";
+ public static final String OBSERVATION_INDEX_BASE_NAME = "observation_index";
+ public static final String OBSERVATION_CODE_INDEX_BASE_NAME = "code_index";
public static final String OBSERVATION_INDEX_SCHEMA_FILE = "ObservationIndexSchema.json";
public static final String OBSERVATION_CODE_INDEX_SCHEMA_FILE = "ObservationCodeIndexSchema.json";
@@ -53,11 +54,26 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
private final FhirContext myContext;
- public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext) {
+ // Prefixed index names
+ private String observationIndexName = OBSERVATION_INDEX_BASE_NAME;
+ private String observationCodeIndexName = OBSERVATION_CODE_INDEX_BASE_NAME;
+
+ public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext, AppProperties appProperties) {
myContext = fhirContext;
myRestHighLevelClient = client;
+ // Determine index prefix from configuration
+ if (appProperties.getElasticsearch() != null) {
+ String indexPrefix = appProperties.getElasticsearch().getIndex_prefix();
+ if (indexPrefix != null
+ && !sanitizeElasticsearchIndexName(indexPrefix).isEmpty()) {
+ // Set prefixed index names
+ this.observationIndexName = indexPrefix + "-" + OBSERVATION_INDEX_BASE_NAME;
+ this.observationCodeIndexName = indexPrefix + "-" + OBSERVATION_CODE_INDEX_BASE_NAME;
+ }
+ }
+
try {
createObservationIndexIfMissing();
createObservationCodeIndexIfMissing();
@@ -66,6 +82,34 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
}
}
+ /**
+ * Sanitizes a string to be a valid Elasticsearch index name.
+ *
+ * Elasticsearch index name requirements:
+ * - Must be lowercase
+ * - Can only contain: lowercase letters, numbers, hyphens (-), and underscores (_)
+ * - Cannot start with: -, _, or +
+ * - Cannot exceed 255 characters
+ *
+ * This method performs the following transformations:
+ * 1. Converts to lowercase
+ * 2. Replaces any invalid characters with underscores
+ * 3. Removes leading -, _, or + characters
+ * 4. Truncates to 255 characters if necessary
+ * 5. Trims any remaining whitespace
+ *
+ * @param name the string to sanitize
+ * @return a valid Elasticsearch index name
+ */
+ private String sanitizeElasticsearchIndexName(String name) {
+ String cleaned = name.toLowerCase().replaceAll("[^a-z0-9\\-_]", "_");
+ cleaned = cleaned.replaceAll("^[\\-_.]+", "");
+ if (cleaned.length() > 255) {
+ cleaned = cleaned.substring(0, 255);
+ }
+ return cleaned.trim();
+ }
+
private String getIndexSchema(String theSchemaFileName) throws IOException {
InputStreamReader input =
new InputStreamReader(ElasticsearchSvcImpl.class.getResourceAsStream(theSchemaFileName));
@@ -80,21 +124,21 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
}
private void createObservationIndexIfMissing() throws IOException {
- if (indexExists(OBSERVATION_INDEX)) {
+ if (indexExists(observationIndexName)) {
return;
}
String observationMapping = getIndexSchema(OBSERVATION_INDEX_SCHEMA_FILE);
- if (!createIndex(OBSERVATION_INDEX, observationMapping)) {
+ if (!createIndex(observationIndexName, observationMapping)) {
throw new RuntimeException(Msg.code(1176) + "Failed to create observation index");
}
}
private void createObservationCodeIndexIfMissing() throws IOException {
- if (indexExists(OBSERVATION_CODE_INDEX)) {
+ if (indexExists(observationCodeIndexName)) {
return;
}
String observationCodeMapping = getIndexSchema(OBSERVATION_CODE_INDEX_SCHEMA_FILE);
- if (!createIndex(OBSERVATION_CODE_INDEX, observationCodeMapping)) {
+ if (!createIndex(observationCodeIndexName, observationCodeMapping)) {
throw new RuntimeException(Msg.code(1177) + "Failed to create observation code index");
}
}
@@ -147,7 +191,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
.map(v -> FieldValue.of(v))
.collect(Collectors.toList());
- return SearchRequest.of(sr -> sr.index(OBSERVATION_INDEX)
+ return SearchRequest.of(sr -> sr.index(observationIndexName)
.query(qb -> qb.bool(bb -> bb.must(bbm -> {
bbm.terms(terms ->
terms.field(OBSERVATION_IDENTIFIER_FIELD_NAME).terms(termsb -> termsb.value(values)));
@@ -160,4 +204,20 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
public void refreshIndex(String theIndexName) throws IOException {
myRestHighLevelClient.indices().refresh(fn -> fn.index(theIndexName));
}
+
+ /**
+ * Get the observation index name (with prefix if configured)
+ * @return the observation index name
+ */
+ public String getObservationIndexName() {
+ return observationIndexName;
+ }
+
+ /**
+ * Get the observation code index name (with prefix if configured)
+ * @return the observation code index name
+ */
+ public String getObservationCodeIndexName() {
+ return observationCodeIndexName;
+ }
}
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchConfig.java
new file mode 100644
index 0000000..dfdd871
--- /dev/null
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/elastic/ElasticsearchConfig.java
@@ -0,0 +1,69 @@
+package ca.uhn.fhir.jpa.starter.elastic;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import co.elastic.clients.transport.rest_client.RestClientTransport;
+import org.apache.http.HttpHost;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestClientBuilder;
+import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Configuration;
+
+import java.net.URI;
+import java.util.List;
+
+/**
+ * Custom Elasticsearch configuration that creates the ElasticsearchClient bean
+ * without the sniffer. This is used when the default Spring Boot autoconfiguration
+ * is excluded.
+ */
+@Configuration
+@Conditional(ElasticConfigCondition.class)
+public class ElasticsearchConfig {
+
+ @Bean
+ public RestClient elasticsearchRestClient(ElasticsearchProperties properties) {
+ List uris = properties.getUris();
+
+ HttpHost[] hosts = uris.stream()
+ .map(URI::create)
+ .map(uri -> new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme()))
+ .toArray(HttpHost[]::new);
+
+ RestClientBuilder builder = RestClient.builder(hosts);
+
+ // Configure authentication if credentials are provided
+ if (properties.getUsername() != null && properties.getPassword() != null) {
+ BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+ credentialsProvider.setCredentials(
+ AuthScope.ANY, new UsernamePasswordCredentials(properties.getUsername(), properties.getPassword()));
+
+ builder.setHttpClientConfigCallback(
+ httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+ }
+
+ // Configure connection and socket timeouts if needed
+ builder.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
+ .setConnectTimeout(
+ properties.getConnectionTimeout() != null
+ ? (int) properties.getConnectionTimeout().toMillis()
+ : 5000)
+ .setSocketTimeout(
+ properties.getSocketTimeout() != null
+ ? (int) properties.getSocketTimeout().toMillis()
+ : 60000));
+
+ return builder.build();
+ }
+
+ @Bean
+ public ElasticsearchClient elasticsearchClient(RestClient restClient) {
+ RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
+ return new ElasticsearchClient(transport);
+ }
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 73e6240..8afc883 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -37,6 +37,12 @@ management:
enabled: true
spring:
+ # -------------------------------------------------------------------------------
+ # Application Name
+ # -------------------------------------------------------------------------------
+ application:
+ name: "@project.artifactId@"
+
# -------------------------------------------------------------------------------
# A. Spring AI — Model Context Protocol (MCP)
# -------------------------------------------------------------------------------
@@ -81,6 +87,7 @@ spring:
main:
allow-bean-definition-overriding: false
allow-circular-references: true
+ banner-mode: off
autoconfigure:
# This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed.
@@ -252,6 +259,12 @@ hapi:
# enable_index_contained_resource: false
# store_resource_in_lucene_index_enabled: true
+ # -------------------------------------------------------------------------------
+ # Elasticsearch Configuration
+ # -------------------------------------------------------------------------------
+ # elasticsearch:
+ # index_prefix: "myprefix" # Prefix for all Elasticsearch indexes (e.g., myprefix_observation_index)
+
# -------------------------------------------------------------------------------
# E. Bulk Operations
# -------------------------------------------------------------------------------
@@ -443,4 +456,4 @@ hapi:
name: Local Tester
server_address: 'http://localhost:8080/fhir'
refuse_to_fetch_third_party_urls: false
- fhir_version: R4
\ No newline at end of file
+ fhir_version: R4
diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java
index 5ec10a6..2b093b0 100644
--- a/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java
+++ b/src/test/java/ca/uhn/fhir/jpa/starter/CdsHooksServletIT.java
@@ -72,7 +72,7 @@ class CdsHooksServletIT implements IServerSupport {
@BeforeEach
void beforeEach() {
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
- ourCtx.getRestfulClientFactory().setSocketTimeout(1200 * 1000);
+ ourCtx.getRestfulClientFactory().setSocketTimeout(1200 * 3000);
ourServerBase = "http://localhost:" + port + "/fhir/";
ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
ourCdsBase = "http://localhost:" + port + "/cds-services";
diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java
index 77d5d6b..838d695 100644
--- a/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java
+++ b/src/test/java/ca/uhn/fhir/jpa/starter/ElasticsearchLastNR4IT.java
@@ -3,6 +3,7 @@ package ca.uhn.fhir.jpa.starter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.jpa.search.lastn.ElasticsearchRestClientFactory;
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl;
import ca.uhn.fhir.jpa.test.config.TestElasticsearchContainerHelper;
@@ -14,6 +15,8 @@ import java.io.IOException;
import java.util.Date;
import java.util.GregorianCalendar;
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch.indices.IndexSettings;
import jakarta.annotation.PreDestroy;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle;
@@ -25,7 +28,6 @@ import org.hl7.fhir.r4.model.Patient;
import org.hl7.fhir.r4.model.StringType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
@@ -35,6 +37,8 @@ import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ActiveProfiles;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
@@ -43,9 +47,8 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@ExtendWith(SpringExtension.class)
@Testcontainers
-@Disabled
@ActiveProfiles("test")
-@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties =
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class, ElasticsearchLastNR4IT.TestConfig.class}, properties =
{
"spring.datasource.url=jdbc:h2:mem:dbr4",
"hapi.fhir.fhir_version=r4",
@@ -81,21 +84,20 @@ class ElasticsearchLastNR4IT {
@BeforeAll
public static void beforeClass() throws IOException {
//Given
- // ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
-// "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
+ ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
+ "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
/* As of 2023-08-10, HAPI FHIR sets SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS to 50000
which is in excess of elastic's default max_result_window. If MAX_SUBSCRIPTION_RESULTS is changed
to a value <= 10000, the following will no longer be necessary. - dotasek
*/
- /* elasticsearchHighLevelRestClient.indices().putTemplate(t->{
+ elasticsearchHighLevelRestClient.indices().putTemplate(t->{
t.name("hapi_fhir_template");
t.indexPatterns("*");
- t.settings(new IndexSettings.Builder().maxResultWindow(50000).build());
+ t.settings(new IndexSettings.Builder().maxNgramDiff(50).maxResultWindow(50000).build());
return t;
});
-*/
}
@PreDestroy
@@ -151,6 +153,15 @@ class ElasticsearchLastNR4IT {
}
+ @Configuration
+ static class TestConfig {
+ @Bean
+ public ElasticsearchClient elasticsearchClient() throws IOException {
+ return ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
+ "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
+ }
+ }
+
static class Initializer
implements ApplicationContextInitializer {
@@ -158,7 +169,7 @@ class ElasticsearchLastNR4IT {
public void initialize(
ConfigurableApplicationContext configurableApplicationContext) {
// Since the port is dynamically generated, replace the URL with one that has the correct port
- TestPropertyValues.of("spring.elasticsearch.uris=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200))
+ TestPropertyValues.of("spring.elasticsearch.uris=http://" + embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200))
.applyTo(configurableApplicationContext.getEnvironment());
TestPropertyValues.of("spring.jpa.properties.hibernate.search.backend.hosts=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200))
.applyTo(configurableApplicationContext.getEnvironment());
diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/PostgresElasticsearchPatientIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/PostgresElasticsearchPatientIT.java
new file mode 100644
index 0000000..6d08b09
--- /dev/null
+++ b/src/test/java/ca/uhn/fhir/jpa/starter/PostgresElasticsearchPatientIT.java
@@ -0,0 +1,108 @@
+package ca.uhn.fhir.jpa.starter;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.rest.client.api.IGenericClient;
+import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
+import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
+import org.hl7.fhir.instance.model.api.IIdType;
+import org.hl7.fhir.r4.model.Bundle;
+import org.hl7.fhir.r4.model.Patient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.Duration;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@Testcontainers
+@ActiveProfiles("test")
+@TestPropertySource(locations = "classpath:test-postgres-elasticsearch.yaml")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class})
+class PostgresElasticsearchPatientIT {
+
+ @Container
+ private static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
+ .withDatabaseName("hapi")
+ .withUsername("fhiruser")
+ .withPassword("fhirpass");
+
+ @Container
+ private static final ElasticsearchContainer ELASTICSEARCH = new ElasticsearchContainer(
+ "docker.elastic.co/elasticsearch/elasticsearch:8.11.0"
+ )
+ .withEnv("xpack.security.enabled", "false")
+ .withEnv("discovery.type", "single-node")
+ .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m");
+
+ @DynamicPropertySource
+ static void registerDatasourceProperties(DynamicPropertyRegistry registry) {
+ // PostgreSQL configuration
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect");
+
+ // Elasticsearch configuration
+ registry.add("spring.jpa.properties.hibernate.search.backend.hosts", ELASTICSEARCH::getHttpHostAddress);
+ registry.add("spring.jpa.properties.hibernate.search.backend.protocol", () -> "http");
+ registry.add("spring.jpa.properties.hibernate.search.backend.username", () -> "");
+ registry.add("spring.jpa.properties.hibernate.search.backend.password", () -> "");
+ }
+
+ @LocalServerPort
+ private int port;
+
+ private IGenericClient ourClient;
+ private FhirContext ourCtx;
+
+ @BeforeEach
+ void beforeEach() {
+ ourCtx = FhirContext.forR4();
+ ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
+ ourCtx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis());
+ String ourServerBase = "http://localhost:" + port + "/fhir/";
+ ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
+ ourClient.registerInterceptor(new LoggingInterceptor(true));
+ }
+
+ @Test
+ void testCreateAndSearchPatientByFamilyName() {
+ String givenName = "Jane";
+ String familyName = "Smith Doe";
+
+ Patient patient = new Patient();
+ patient.addName().setFamily(familyName).addGiven(givenName);
+ IIdType id = ourClient.create().resource(patient).execute().getId().toUnqualifiedVersionless();
+ assertNotNull(id);
+
+ await().atMost(Duration.ofSeconds(30)).until(() -> searchByFamily(familyName).getTotal() == 1);
+
+ Bundle results = searchByFamily(familyName);
+ assertEquals(1, results.getTotal());
+ Patient found = (Patient) results.getEntry().get(0).getResource();
+ assertEquals(familyName, found.getNameFirstRep().getFamily());
+ assertEquals(id, found.getIdElement().toUnqualifiedVersionless());
+ }
+
+ private Bundle searchByFamily(String family) {
+ return ourClient
+ .search()
+ .forResource(Patient.class)
+ .where(Patient.FAMILY.matches().value(family))
+ .returnBundle(Bundle.class)
+ .execute();
+ }
+}
diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/PostgresLucenePatientIT.java b/src/test/java/ca/uhn/fhir/jpa/starter/PostgresLucenePatientIT.java
new file mode 100644
index 0000000..349eb6c
--- /dev/null
+++ b/src/test/java/ca/uhn/fhir/jpa/starter/PostgresLucenePatientIT.java
@@ -0,0 +1,92 @@
+package ca.uhn.fhir.jpa.starter;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.rest.client.api.IGenericClient;
+import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
+import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
+import org.hl7.fhir.instance.model.api.IIdType;
+import org.hl7.fhir.r4.model.Bundle;
+import org.hl7.fhir.r4.model.Patient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.Duration;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@Testcontainers
+@ActiveProfiles("test")
+@TestPropertySource(locations = "classpath:test-postgres-lucene.yaml")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class})
+class PostgresLucenePatientIT {
+
+ @Container
+ private static final PostgreSQLContainer> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
+ .withDatabaseName("hapi")
+ .withUsername("fhiruser")
+ .withPassword("fhirpass");
+
+ @DynamicPropertySource
+ static void registerDatasourceProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect");
+ }
+
+ @LocalServerPort
+ private int port;
+
+ private IGenericClient ourClient;
+ private FhirContext ourCtx;
+
+ @BeforeEach
+ void beforeEach() {
+ ourCtx = FhirContext.forR4();
+ ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
+ ourCtx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis());
+ String ourServerBase = "http://localhost:" + port + "/fhir/";
+ ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
+ ourClient.registerInterceptor(new LoggingInterceptor(true));
+ }
+
+ @Test
+ void testCreateAndSearchPatientByFamilyName() {
+ String givenName = "Jane";
+ String familyName = "Smith Doe";
+
+ Patient patient = new Patient();
+ patient.addName().setFamily(familyName).addGiven(givenName);
+ IIdType id = ourClient.create().resource(patient).execute().getId().toUnqualifiedVersionless();
+ assertNotNull(id);
+
+ await().atMost(Duration.ofSeconds(30)).until(() -> searchByFamily(familyName).getTotal() == 1);
+
+ Bundle results = searchByFamily(familyName);
+ assertEquals(1, results.getTotal());
+ Patient found = (Patient) results.getEntry().get(0).getResource();
+ assertEquals(familyName, found.getNameFirstRep().getFamily());
+ assertEquals(id, found.getIdElement().toUnqualifiedVersionless());
+ }
+
+ private Bundle searchByFamily(String family) {
+ return ourClient
+ .search()
+ .forResource(Patient.class)
+ .where(Patient.FAMILY.matches().value(family))
+ .returnBundle(Bundle.class)
+ .execute();
+ }
+}
diff --git a/src/test/resources/test-postgres-elasticsearch.yaml b/src/test/resources/test-postgres-elasticsearch.yaml
new file mode 100644
index 0000000..82a3681
--- /dev/null
+++ b/src/test/resources/test-postgres-elasticsearch.yaml
@@ -0,0 +1,21 @@
+hapi:
+ fhir:
+ fhir_version: r4
+ cr_enabled: false
+ advanced_lucene_indexing: true
+ store_resource_in_lucene_index_enabled: true
+ search_index_full_text_enabled: true
+
+spring:
+ main:
+ allow-bean-definition-overriding: true
+ jpa:
+ properties:
+ hibernate:
+ search:
+ enabled: true
+ backend:
+ type: elasticsearch
+ analysis:
+ configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer
+ # Connection properties will be set dynamically by the test via @DynamicPropertySource
diff --git a/src/test/resources/test-postgres-lucene.yaml b/src/test/resources/test-postgres-lucene.yaml
new file mode 100644
index 0000000..9179b1a
--- /dev/null
+++ b/src/test/resources/test-postgres-lucene.yaml
@@ -0,0 +1,22 @@
+hapi:
+ fhir:
+ fhir_version: r4
+ cr_enabled: false
+ advanced_lucene_indexing: true
+ store_resource_in_lucene_index_enabled: true
+ search_index_full_text_enabled: true
+
+spring:
+ main:
+ allow-bean-definition-overriding: true
+ jpa:
+ properties:
+ hibernate:
+ search:
+ enabled: true
+ backend:
+ type: lucene
+ analysis:
+ configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer
+ directory:
+ type: local-heap