diff --git a/pom.xml b/pom.xml index 57d8c0a..7cee15b 100644 --- a/pom.xml +++ b/pom.xml @@ -204,13 +204,16 @@ + ch.qos.logback logback-classic + 1.5.19 ch.qos.logback logback-core + 1.5.19 @@ -308,6 +311,7 @@ org.testcontainers testcontainers + 2.0.3 test @@ -443,6 +447,14 @@ + + + + src/main/resources + true + + + @@ -485,6 +497,19 @@ + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + @ + + false + + + org.apache.maven.plugins diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index aefaaa1..085b15f 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -129,6 +129,7 @@ public class AppProperties { private Boolean mark_resources_for_reindexing_upon_search_parameter_change = true; private Integer reindex_thread_count = null; private Integer expunge_thread_count = null; + private Elasticsearch elasticsearch = null; public List getCustomInterceptorClasses() { return custom_interceptor_classes; @@ -847,6 +848,14 @@ public class AppProperties { this.store_meta_source_information = store_meta_source_information; } + public Elasticsearch getElasticsearch() { + return elasticsearch; + } + + public void setElasticsearch(Elasticsearch elasticsearch) { + this.elasticsearch = elasticsearch; + } + public static class Cors { private Boolean allow_Credentials = true; private List 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