Skip to main content

haste_fhir_search/elastic_search/
mod.rs

1use crate::{
2    IndexFailure, IndexOutcome, IndexResource, ParameterLevel, ResolvedParameter, SearchEngine,
3    SearchOptions, SearchParameterResolve, SearchReturn,
4    indexing_conversion::{self, DynamicParameterEntry, InsertableIndex},
5};
6use bytes::{Bytes, BytesMut};
7use elasticsearch::{
8    BulkOperation, BulkParts, Elasticsearch,
9    auth::Credentials,
10    cert::CertificateValidation,
11    http::{
12        Url,
13        request::Body,
14        transport::{BuildError, SingleNodeConnectionPool, TransportBuilder},
15    },
16    params::Refresh,
17};
18use haste_fhir_client::request::SearchRequest;
19use haste_fhir_model::r4::generated::{
20    resources::{Resource, ResourceType},
21    terminology::{BoundCode, IssueType, SearchParamType},
22};
23use haste_fhir_operation_error::{OperationOutcomeError, derive::OperationOutcomeError};
24use haste_fhirpath::FPEngine;
25use haste_jwt::{ProjectId, ResourceId, TenantId, VersionId};
26use haste_repository::types::{FHIRMethod, SupportedFHIRVersions};
27use serde::Deserialize;
28use std::{collections::HashMap, sync::Arc};
29
30mod migration;
31mod search;
32pub mod search_parameter_resolver;
33
34#[derive(Deserialize, Debug)]
35struct SearchEntryPrivate {
36    pub id: Vec<ResourceId>,
37    pub resource_type: Vec<ResourceType>,
38    pub version_id: Vec<VersionId>,
39    pub project: Vec<ProjectId>,
40}
41
42static DYNAMIC_PARAMETER_INDEX_FIELD: &str = "dynamic_parameters";
43
44/// Elasticsearch treats a literal `.` in a mapped field name as an object
45/// path separator: e.g. mapping the field
46/// `http://hl7.org/fhir/SearchParameter/Patient-name` silently produces
47/// nested objects (`http://hl7` -> `org/fhir/SearchParameter/Patient-name`),
48/// even though `/` is left alone. Search parameter canonical URLs almost
49/// always contain a dotted domain, so every active (top-level) search
50/// parameter needs its dots stripped to stay a genuinely flat field
51/// consistently in the index mapping, in the documents written to it, and in
52/// queries built against it.
53fn flatten_parameter_field_name(url: &str) -> String {
54    url.replace('.', "_")
55}
56
57#[derive(OperationOutcomeError, Debug)]
58pub enum SearchError {
59    #[fatal(
60        code = "exception",
61        diagnostic = "Failed to evaluate fhirpath expression."
62    )]
63    FHIRPathError(#[from] haste_fhirpath::FHIRPathError),
64    #[fatal(
65        code = "exception",
66        diagnostic = "Search does not support the fhir method: '{arg0:?}'"
67    )]
68    UnsupportedFHIRMethod(FHIRMethod),
69    #[fatal(
70        code = "exception",
71        diagnostic = "Failed to index resources server responded with status code: '{arg0}'"
72    )]
73    Fatal(u16),
74    #[fatal(
75        code = "exception",
76        diagnostic = "Elasticsearch server failed to index: '{arg0}'"
77    )]
78    ElasticsearchError(#[from] elasticsearch::Error),
79    #[fatal(
80        code = "exception",
81        diagnostic = "Elasticsearch server responded with an error: '{arg0}'"
82    )]
83    ElasticSearchResponseError(u16),
84    #[fatal(code = "transient", diagnostic = "Elasticsearch is not reachable.")]
85    NotConnected,
86}
87
88#[derive(OperationOutcomeError, Debug)]
89pub enum SearchConfigError {
90    #[fatal(code = "exception", diagnostic = "Failed to parse URL: '{arg0}'.")]
91    UrlParseError(String),
92    #[fatal(
93        code = "exception",
94        diagnostic = "Elasticsearch client creation failed."
95    )]
96    ElasticSearchConfigError(#[from] BuildError),
97    #[fatal(
98        code = "exception",
99        diagnostic = "Unsupported FHIR version for index: '{arg0}'"
100    )]
101    UnsupportedIndex(SupportedFHIRVersions),
102}
103
104#[derive(Clone)]
105pub struct ElasticSearchEngine<SearchParameterResolver: SearchParameterResolve + 'static> {
106    parameter_resolver: Arc<SearchParameterResolver>,
107    fp_engine: Arc<FPEngine>,
108    client: Arc<Elasticsearch>,
109    /// Whether `migrate` is allowed to rebuild the index (briefly making it
110    /// unavailable) to drop columns for search parameters that no longer
111    /// exist. When `false`, `migrate` only logs which parameters would be
112    /// dropped.
113    prune_removed_search_parameters: bool,
114}
115
116/// Creates an Elasticsearch client using the provided URL and credentials.
117///
118/// # Errors
119///
120/// Returns [`SearchConfigError::UrlParseError`] if url is not a valid URL.
121///
122/// Returns a [`SearchConfigError`] if the Elasticsearch transport cannot be
123/// constructed.
124pub fn create_es_client(
125    url: &str,
126    username: String,
127    password: String,
128) -> Result<Arc<Elasticsearch>, SearchConfigError> {
129    let url = Url::parse(url).map_err(|_e| SearchConfigError::UrlParseError(url.to_string()))?;
130    let conn_pool = SingleNodeConnectionPool::new(url);
131    let transport = TransportBuilder::new(conn_pool)
132        .cert_validation(CertificateValidation::None)
133        .auth(Credentials::Basic(username, password))
134        .build()?;
135
136    let elasticsearch_client = Elasticsearch::new(transport);
137
138    Ok(Arc::new(elasticsearch_client))
139}
140
141type Tasks = tokio::task::JoinHandle<(IndexResource, Result<Bytes, OperationOutcomeError>)>;
142
143/// Target size, in bytes, for a single Elasticsearch `_bulk` request body.
144/// Kept mid-range of the recommended 5-15MB window.
145const TARGET_BULK_BATCH_BYTES: usize = 10 * 1024 * 1024;
146
147/// Serializes a bulk operation into the exact NDJSON bytes (`{"index":{...}}\n{...}\n`,
148/// or the `delete` equivalent with no second line) that would be sent to
149/// Elasticsearch. Doing this once, here, rather than letting the client
150/// serialize it later, means: (1) a document's exact size is known for
151/// byte-based batching instead of estimated, and (2) it's only JSON-encoded
152/// once -- the resulting `Bytes` is what actually gets sent, since `Bytes`
153/// implements [`Body`] as a raw passthrough (see `send_bulk_operations`).
154fn serialize_bulk_operation(
155    op: &BulkOperation<HashMap<String, InsertableIndex>>,
156) -> Result<Bytes, OperationOutcomeError> {
157    let mut buf = BytesMut::new();
158    op.write(&mut buf).map_err(SearchError::from)?;
159    Ok(buf.freeze())
160}
161
162struct CollectedOperations {
163    /// Each resource's operation, pre-serialized to the exact bytes that will
164    /// be sent to Elasticsearch. `Bytes::len()` is this batch's byte-size
165    /// accounting; the same bytes are the `_bulk` request body, so a
166    /// document is JSON-encoded exactly once.
167    bulk_lines: Vec<Bytes>,
168    /// Parallel to `bulk_lines` (same order) - the resource each line was
169    /// built from, kept so a failed Elasticsearch bulk item can be attributed
170    /// back to the resource that produced it.
171    sent_resources: Vec<IndexResource>,
172    failed: Vec<IndexFailure>,
173}
174
175/// Groups indices `0..sizes.len()` into batches whose cumulative `sizes` stay
176/// within `target_bytes`, returning each batch's length
177fn batch_lengths_by_size(sizes: &[usize], target_bytes: usize) -> Vec<usize> {
178    let mut batch_lengths = Vec::new();
179    let mut current_len = 0usize;
180    let mut current_size = 0usize;
181
182    for &size in sizes {
183        if current_len > 0 && current_size + size > target_bytes {
184            batch_lengths.push(current_len);
185            current_len = 0;
186            current_size = 0;
187        }
188
189        current_len += 1;
190        current_size += size;
191    }
192
193    if current_len > 0 {
194        batch_lengths.push(current_len);
195    }
196
197    batch_lengths
198}
199
200/// Splits collected bulk operation lines (and their matching resources) into
201/// batches sized by `batch_lengths_by_size`, so a `_bulk` request's actual
202/// document count varies with how large those documents are.
203fn batch_by_byte_size(
204    bulk_lines: Vec<Bytes>,
205    sent_resources: Vec<IndexResource>,
206    target_bytes: usize,
207) -> Vec<(Vec<Bytes>, Vec<IndexResource>)> {
208    let sizes: Vec<usize> = bulk_lines.iter().map(Bytes::len).collect();
209
210    let mut lines_iter = bulk_lines.into_iter();
211    let mut resources_iter = sent_resources.into_iter();
212
213    batch_lengths_by_size(&sizes, target_bytes)
214        .into_iter()
215        .map(|len| {
216            (
217                (&mut lines_iter).take(len).collect(),
218                (&mut resources_iter).take(len).collect(),
219            )
220        })
221        .collect()
222}
223
224/// See <https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk>
225#[derive(Deserialize, Debug)]
226struct BulkResponse {
227    /// The length of time, in milliseconds, it took to process the bulk request.
228    #[allow(dead_code)]
229    took: u64,
230    /// The result of each operation in the bulk request, in the order they were submitted.
231    items: Vec<serde_json::Value>,
232    /// If true, one or more of the operations in the bulk request did not complete successfully.
233    #[allow(dead_code)]
234    errors: bool,
235}
236
237/// Processes the Elasticsearch bulk response, attributing each per-item result
238/// back to the resource that produced it. Elasticsearch indexes bulk items
239/// independently, so one bad document does not prevent the rest of the batch
240/// from succeeding - only a request-level failure (unreachable cluster, malformed response) surfaces as `Err` here.
241fn process_bulk_response(
242    bulk_response: &BulkResponse,
243    sent_resources: Vec<IndexResource>,
244) -> Result<IndexOutcome, OperationOutcomeError> {
245    if bulk_response.items.len() != sent_resources.len() {
246        return Err(OperationOutcomeError::fatal(
247            IssueType::exception(),
248            format!(
249                "Elasticsearch bulk response item count '{}' did not match request count '{}'.",
250                bulk_response.items.len(),
251                sent_resources.len()
252            ),
253        ));
254    }
255
256    let mut succeeded = 0;
257    let mut failed = Vec::new();
258
259    // Per documentation: The result of each operation in the bulk request, in the order they were submitted.
260    // See https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk.
261
262    for (item, resource) in bulk_response.items.iter().zip(sent_resources) {
263        // Each item is a single-key object, e.g. `{"index": {...}}` or `{"delete": {...}}`.
264        if let Some(op_result) = item.as_object().and_then(|o| o.values().next()) {
265            match op_result["status"].as_u64() {
266                Some(status) if (200..300).contains(&status) => succeeded += 1,
267                status => {
268                    let reason = op_result["error"]["reason"]
269                        .as_str()
270                        .unwrap_or("unknown error");
271                    failed.push(IndexFailure {
272                        error: OperationOutcomeError::fatal(
273                            IssueType::exception(),
274                            format!("Elasticsearch indexing failed (status {status:?}): {reason}"),
275                        ),
276                        resource,
277                    });
278                }
279            }
280        } else {
281            failed.push(IndexFailure {
282                error: OperationOutcomeError::fatal(
283                    IssueType::exception(),
284                    format!("Unexpected Elasticsearch bulk item shape: '{item}'"),
285                ),
286                resource,
287            });
288        }
289    }
290
291    if !failed.is_empty() {
292        tracing::error!(
293            "Elasticsearch bulk index reported {} failed item(s) out of {}.",
294            failed.len(),
295            bulk_response.items.len()
296        );
297    }
298
299    Ok(IndexOutcome { succeeded, failed })
300}
301
302impl<SearchParameterResolver: SearchParameterResolve + 'static>
303    ElasticSearchEngine<SearchParameterResolver>
304{
305    pub fn new(
306        parameter_resolver: Arc<SearchParameterResolver>,
307        fp_engine: Arc<FPEngine>,
308        es_client: Arc<Elasticsearch>,
309        prune_removed_search_parameters: bool,
310    ) -> Self {
311        ElasticSearchEngine {
312            parameter_resolver,
313            fp_engine,
314            client: es_client,
315            prune_removed_search_parameters,
316        }
317    }
318
319    /// Checks whether the Elasticsearch client is connected.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`SearchError::NotConnected`] if Elasticsearch responds with a
324    /// non-success status code.
325    ///
326    /// Returns a [`SearchError`] if the ping request fails.
327    pub async fn is_connected(&self) -> Result<(), SearchError> {
328        let res = self.client.ping().send().await.map_err(SearchError::from)?;
329
330        if res.status_code().is_success() {
331            Ok(())
332        } else {
333            Err(SearchError::NotConnected)
334        }
335    }
336
337    /// Sends one Elasticsearch `_bulk` request and attributes each per-item
338    /// result back to the resource that produced it. Elasticsearch indexes
339    /// bulk items independently, so one bad document does not prevent the
340    /// rest of the batch from succeeding - only a request-level failure
341    /// (unreachable cluster, malformed response) surfaces as `Err` here.
342    ///
343    /// `searchable` refreshes the index before answering, so the documents are
344    /// visible to the next search.
345    async fn send_bulk_operations(
346        &self,
347        search_index_name: &'static str,
348        bulk_lines: Vec<Bytes>,
349        sent_resources: Vec<IndexResource>,
350        searchable: bool,
351    ) -> Result<IndexOutcome, OperationOutcomeError> {
352        if bulk_lines.is_empty() {
353            return Ok(IndexOutcome {
354                succeeded: 0,
355                failed: Vec::new(),
356            });
357        }
358
359        let request = self
360            .client
361            .bulk(BulkParts::Index(search_index_name))
362            .body(bulk_lines);
363        let request = if searchable {
364            request.refresh(Refresh::True)
365        } else {
366            request
367        };
368        let res = request.send().await.map_err(SearchError::from)?;
369
370        let response_body = res.json::<BulkResponse>().await.map_err(|_e| {
371            OperationOutcomeError::fatal(
372                IssueType::exception(),
373                "Failed to parse response body.".to_string(),
374            )
375        })?;
376
377        process_bulk_response(&response_body, sent_resources)
378    }
379
380    /// Indexes `resources` with `_bulk` requests of about
381    /// `TARGET_BULK_BATCH_BYTES` each. `searchable` as in
382    /// [`Self::send_bulk_operations`].
383    async fn index_part(
384        &self,
385        resources: Vec<IndexResource>,
386        searchable: bool,
387    ) -> Result<IndexOutcome, OperationOutcomeError> {
388        let resources_total = resources.len();
389        let search_index_name = get_index_name();
390
391        tracing::trace!(
392            "Indexing {} resources into index: '{}'",
393            resources_total,
394            search_index_name
395        );
396
397        let tasks = self.spawn_index_tasks(resources, search_index_name);
398        let CollectedOperations {
399            bulk_lines,
400            sent_resources,
401            mut failed,
402        } = self.collect_bulk_operations(tasks).await?;
403
404        let built_count = bulk_lines.len();
405        let batches = batch_by_byte_size(bulk_lines, sent_resources, TARGET_BULK_BATCH_BYTES);
406
407        tracing::trace!(
408            "Bulk indexing {} resources into index '{}' across {} byte-sized batch(es)",
409            built_count,
410            search_index_name,
411            batches.len()
412        );
413
414        let mut outcome = IndexOutcome {
415            succeeded: 0,
416            failed: Vec::new(),
417        };
418
419        for (batch_lines, batch_resources) in batches {
420            let batch_outcome = self
421                .send_bulk_operations(search_index_name, batch_lines, batch_resources, searchable)
422                .await?;
423            outcome.succeeded += batch_outcome.succeeded;
424            outcome.failed.extend(batch_outcome.failed);
425        }
426
427        outcome.failed.append(&mut failed);
428
429        Ok(outcome)
430    }
431
432    fn spawn_index_tasks(
433        &self,
434        resources: Vec<IndexResource>,
435        search_index_name: &'static str,
436    ) -> Vec<Tasks> {
437        resources
438            .into_iter()
439            .map(|r| {
440                let engine = self.fp_engine.clone();
441                let parameter_resolver = self.parameter_resolver.clone();
442
443                tokio::spawn(async move {
444                    Self::build_bulk_operation(engine, parameter_resolver, r, search_index_name)
445                        .await
446                })
447            })
448            .collect()
449    }
450
451    /// Awaits every bulk-op-building task, separating resources that built
452    /// successfully from those that failed. A single resource's failure
453    /// (bad `FHIRPath` expression, unsupported method, etc.) is captured with
454    /// its identity rather than aborting the rest of the batch. A `JoinError`
455    /// (the task itself panicked) has no resource to attribute the failure
456    /// to, so it aborts the whole call.
457    async fn collect_bulk_operations(
458        &self,
459        tasks: Vec<Tasks>,
460    ) -> Result<CollectedOperations, OperationOutcomeError> {
461        tracing::trace!("Awaiting {} indexing tasks.", tasks.len());
462
463        let mut bulk_lines = Vec::with_capacity(tasks.len());
464        let mut sent_resources = Vec::with_capacity(tasks.len());
465        let mut failed = Vec::new();
466
467        for task in tasks {
468            let (resource, result) = task
469                .await
470                .map_err(|e| OperationOutcomeError::fatal(IssueType::exception(), e.to_string()))?;
471
472            match result {
473                Ok(bytes) => {
474                    sent_resources.push(resource);
475                    bulk_lines.push(bytes);
476                }
477                Err(error) => failed.push(IndexFailure { resource, error }),
478            }
479        }
480
481        Ok(CollectedOperations {
482            bulk_lines,
483            sent_resources,
484            failed,
485        })
486    }
487
488    async fn build_bulk_operation<ParameterResolver: SearchParameterResolve>(
489        engine: Arc<FPEngine>,
490        parameter_resolver: Arc<ParameterResolver>,
491        resource: IndexResource,
492        search_index_name: &'static str,
493    ) -> (IndexResource, Result<Bytes, OperationOutcomeError>) {
494        let result = match &resource.fhir_method {
495            FHIRMethod::Create | FHIRMethod::Update => {
496                Self::build_index_operation(
497                    engine,
498                    parameter_resolver,
499                    &resource,
500                    search_index_name,
501                )
502                .await
503            }
504
505            FHIRMethod::Delete => {
506                let index_id = unique_index_id(
507                    &resource.tenant,
508                    &resource.project,
509                    &resource.resource_type,
510                    &resource.id,
511                );
512                let op: BulkOperation<HashMap<String, InsertableIndex>> =
513                    BulkOperation::delete(index_id)
514                        .index(search_index_name)
515                        .into();
516
517                serialize_bulk_operation(&op)
518            }
519
520            method @ FHIRMethod::Read => Err(OperationOutcomeError::from(
521                SearchError::UnsupportedFHIRMethod((*method).clone()),
522            )),
523        };
524
525        (resource, result)
526    }
527
528    async fn build_index_operation<ParameterResolver: SearchParameterResolve>(
529        engine: Arc<FPEngine>,
530        parameter_resolver: Arc<ParameterResolver>,
531        resource: &IndexResource,
532        search_index_name: &'static str,
533    ) -> Result<Bytes, OperationOutcomeError> {
534        // Id is not sufficient because different Resourcetypes may have the same id.
535        // Additionally should be namespaced by tenant and project to avoid conflicts across tenants and projects.
536        let index_id = unique_index_id(
537            &resource.tenant,
538            &resource.project,
539            &resource.resource_type,
540            &resource.id,
541        );
542
543        let params = parameter_resolver
544            .by_resource_type(&resource.tenant, &resource.project, &resource.resource_type)
545            .await?;
546
547        let mut elastic_index =
548            resource_to_elastic_index(engine, &params, &resource.resource).await?;
549
550        Self::add_index_metadata(&mut elastic_index, resource);
551
552        let op = BulkOperation::index(elastic_index)
553            .id(index_id)
554            .index(search_index_name)
555            .into();
556
557        serialize_bulk_operation(&op)
558    }
559
560    fn add_index_metadata(
561        elastic_index: &mut HashMap<String, InsertableIndex>,
562        resource: &IndexResource,
563    ) {
564        elastic_index.insert(
565            "resource_type".to_string(),
566            InsertableIndex::Meta(resource.resource_type.as_ref().to_string()),
567        );
568
569        elastic_index.insert(
570            "id".to_string(),
571            InsertableIndex::Meta(resource.id.as_ref().to_string()),
572        );
573
574        elastic_index.insert(
575            "version_id".to_string(),
576            InsertableIndex::Meta(resource.version_id.as_ref().to_string()),
577        );
578
579        elastic_index.insert(
580            "project".to_string(),
581            InsertableIndex::Meta(resource.project.as_ref().to_string()),
582        );
583
584        elastic_index.insert(
585            "tenant".to_string(),
586            InsertableIndex::Meta(resource.tenant.as_ref().to_string()),
587        );
588    }
589}
590
591/// Whether `type_` gets a dedicated field in [`migration::create_elasticsearch_searchparameter_mappings`]
592/// (used for every `System`-level parameter's mapping). `composite`/`special`/anything
593/// else has no mapping entry there, so a `System`-level parameter of one of
594/// those types must never be written -- under `dynamic: "strict"`, writing an
595/// unmapped field name fails the whole bulk item.
596pub(crate) fn is_mapped_search_parameter_type(type_: &BoundCode<SearchParamType>) -> bool {
597    type_ == &SearchParamType::number()
598        || type_ == &SearchParamType::string()
599        || type_ == &SearchParamType::uri()
600        || type_ == &SearchParamType::token()
601        || type_ == &SearchParamType::date()
602        || type_ == &SearchParamType::reference()
603        || type_ == &SearchParamType::quantity()
604}
605
606async fn resource_to_elastic_index(
607    fp_engine: Arc<FPEngine>,
608    parameters: &[ResolvedParameter],
609    resource: &Resource,
610) -> Result<HashMap<String, InsertableIndex>, OperationOutcomeError> {
611    let mut map = HashMap::new();
612    let mut dynamic_parameters = Vec::new();
613    for param in parameters {
614        if let Some(expression) = param
615            .search_parameter
616            .expression
617            .as_ref()
618            .and_then(|e| e.value.as_ref())
619            && let Some(url) = param.search_parameter.url.value.as_ref()
620        {
621            // A `System`-level parameter of an unmapped type (composite,
622            // special, ...) has nowhere to be written -- skip it rather than
623            // evaluating FHIRPath for a value that would just get dropped
624            // (or, under strict mapping, reject the whole document).
625            if matches!(param.level, ParameterLevel::System)
626                && !is_mapped_search_parameter_type(&param.search_parameter.type_)
627            {
628                continue;
629            }
630
631            let result = fp_engine
632                .evaluate(expression, vec![resource])
633                .await
634                .map_err(SearchError::from);
635
636            if let Err(err) = result {
637                tracing::error!(
638                    "Failed to evaluate FHIRPath expression: '{}' for resource.",
639                    expression,
640                );
641
642                return Err(err.into());
643            }
644
645            let result_vec = indexing_conversion::to_insertable_index(
646                param,
647                &result?.iter().collect::<Vec<_>>(),
648            )?;
649
650            match param.level {
651                ParameterLevel::System => {
652                    map.insert(flatten_parameter_field_name(url), result_vec);
653                }
654                // Project (user-submitted) parameters are indexed under a single
655                // nested field with the URL stored as a plain value, since an
656                // arbitrary user-supplied URL can't safely become an
657                // Elasticsearch field name.
658                ParameterLevel::Project => {
659                    let type_ = param.search_parameter.type_.as_str().unwrap_or("string");
660                    if let Some(entry) =
661                        DynamicParameterEntry::from_leaf(url.clone(), type_, result_vec)
662                    {
663                        dynamic_parameters.push(entry);
664                    }
665                }
666            }
667        }
668    }
669
670    // Various project level parameters. These are indexed under a single field in elasticsearch as a nested type with url and indexed value..
671    map.insert(
672        DYNAMIC_PARAMETER_INDEX_FIELD.to_string(),
673        InsertableIndex::DynamicParameters(dynamic_parameters),
674    );
675
676    Ok(map)
677}
678
679#[allow(dead_code)]
680static R4_FHIR_INDEX_V1: &str = "r4_search_index";
681static R4_FHIR_INDEX_V2: &str = "r4_search_index_v2";
682
683#[must_use]
684pub const fn get_index_name() -> &'static str {
685    R4_FHIR_INDEX_V2
686}
687
688#[derive(serde::Deserialize, Debug)]
689struct ElasticSearchHitResult {
690    _index: String,
691    _id: String,
692    _score: Option<f64>,
693    fields: SearchEntryPrivate,
694}
695
696#[derive(serde::Deserialize, Debug)]
697struct ElasticSearchHitTotalMeta {
698    value: i64,
699    // relation: String,
700}
701
702#[derive(serde::Deserialize, Debug)]
703struct ElasticSearchHit {
704    total: Option<ElasticSearchHitTotalMeta>,
705    hits: Vec<ElasticSearchHitResult>,
706}
707
708#[derive(serde::Deserialize, Debug)]
709struct ElasticSearchResponse {
710    hits: ElasticSearchHit,
711}
712
713fn unique_index_id(
714    tenant: &TenantId,
715    project: &ProjectId,
716    resource_type: &ResourceType,
717    id: &ResourceId,
718) -> String {
719    let unique_index_id = format!(
720        "{}/{}/{}/{}",
721        tenant.as_ref(),
722        project.as_ref(),
723        resource_type.as_ref(),
724        id.as_ref()
725    );
726
727    unique_index_id
728}
729
730impl<SearchParameterResolver: SearchParameterResolve> SearchEngine
731    for ElasticSearchEngine<SearchParameterResolver>
732{
733    async fn search(
734        &self,
735        _fhir_version: &SupportedFHIRVersions,
736        tenant: &TenantId,
737        project: &ProjectId,
738        search_request: &SearchRequest,
739        options: Option<SearchOptions>,
740    ) -> Result<SearchReturn, haste_fhir_operation_error::OperationOutcomeError> {
741        search::execute_search(
742            self.client.clone(),
743            self.parameter_resolver.clone(),
744            tenant,
745            project,
746            search_request,
747            options.as_ref(),
748        )
749        .await
750    }
751
752    async fn index(
753        &self,
754        _fhir_version: SupportedFHIRVersions,
755        resources: Vec<IndexResource>,
756    ) -> Result<IndexOutcome, OperationOutcomeError> {
757        crate::project_parameters::index_search_parameters_first(
758            self.parameter_resolver.as_ref(),
759            resources,
760            |resources, searchable| self.index_part(resources, searchable),
761        )
762        .await
763    }
764
765    async fn migrate(
766        &self,
767        _fhir_version: &SupportedFHIRVersions,
768    ) -> Result<(), haste_fhir_operation_error::OperationOutcomeError> {
769        migration::create_mapping(
770            self.parameter_resolver.clone(),
771            &self.client,
772            get_index_name(),
773            self.prune_removed_search_parameters,
774        )
775        .await?;
776        Ok(())
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn flatten_parameter_field_name_strips_dots_only() {
786        assert_eq!(
787            flatten_parameter_field_name("http://hl7.org/fhir/SearchParameter/Patient-name"),
788            "http://hl7_org/fhir/SearchParameter/Patient-name"
789        );
790    }
791
792    #[test]
793    fn flatten_parameter_field_name_handles_multiple_dots() {
794        assert_eq!(
795            flatten_parameter_field_name("https://sub.acme.io/v1.2/x"),
796            "https://sub_acme_io/v1_2/x"
797        );
798    }
799
800    #[test]
801    fn batch_lengths_by_size_empty() {
802        assert_eq!(batch_lengths_by_size(&[], 100), Vec::<usize>::new());
803    }
804
805    #[test]
806    fn batch_lengths_by_size_all_fit_in_one_batch() {
807        assert_eq!(batch_lengths_by_size(&[10, 20, 30], 100), vec![3]);
808    }
809
810    #[test]
811    fn batch_lengths_by_size_splits_when_target_exceeded() {
812        // 40 + 40 = 80 fits; +40 would be 120 > 100, so it starts a new batch.
813        assert_eq!(batch_lengths_by_size(&[40, 40, 40], 100), vec![2, 1]);
814    }
815
816    #[test]
817    fn batch_lengths_by_size_oversized_single_item_gets_its_own_batch() {
818        // A single item larger than the target must still be sent, alone,
819        // rather than blocking forever waiting to "fit".
820        assert_eq!(batch_lengths_by_size(&[5, 500, 5], 100), vec![1, 1, 1]);
821    }
822
823    #[test]
824    fn batch_lengths_by_size_exact_fit_does_not_split_early() {
825        assert_eq!(batch_lengths_by_size(&[50, 50], 100), vec![2]);
826    }
827
828    #[test]
829    fn serialize_bulk_operation_index_produces_header_and_source_lines() {
830        let mut doc = HashMap::new();
831        doc.insert(
832            "resource_type".to_string(),
833            InsertableIndex::Meta("Patient".to_string()),
834        );
835
836        let op: BulkOperation<HashMap<String, InsertableIndex>> = BulkOperation::index(doc)
837            .id("t/p/Patient/1")
838            .index("r4_search_index_v2")
839            .into();
840
841        let bytes = serialize_bulk_operation(&op).unwrap();
842        let text = std::str::from_utf8(&bytes).unwrap();
843        let lines: Vec<&str> = text.lines().collect();
844
845        // Header line + source line, each newline-terminated -- this is what
846        // `batch_by_byte_size`/`send_bulk_operations` measure and send
847        // directly, with no re-serialization.
848        assert_eq!(lines.len(), 2);
849        assert!(lines[0].contains(r#""index""#));
850        assert!(lines[0].contains(r#""_id":"t/p/Patient/1""#));
851        assert!(text.ends_with('\n'));
852    }
853
854    #[test]
855    fn serialize_bulk_operation_delete_produces_only_a_header_line() {
856        let op: BulkOperation<HashMap<String, InsertableIndex>> =
857            BulkOperation::delete("t/p/Patient/1")
858                .index("r4_search_index_v2")
859                .into();
860
861        let bytes = serialize_bulk_operation(&op).unwrap();
862        let text = std::str::from_utf8(&bytes).unwrap();
863        let lines: Vec<&str> = text.lines().collect();
864
865        // No document body for a delete -- just the action line.
866        assert_eq!(lines.len(), 1);
867        assert!(lines[0].contains(r#""delete""#));
868        assert!(text.ends_with('\n'));
869    }
870}