Skip to main content

haste_fhir_search/pg_search/
schema.rs

1//! Per-resource-type table schema generation for the hybrid PG search backend.
2//!
3//! The PG backend mirrors the Elasticsearch split: HL7 base (system-level)
4//! search parameters get dedicated, type-specific columns on a table named
5//! after the resource type (`search_patient`, `search_observation`, ...),
6//! while project-level (tenant custom) parameters share the EAV
7//! `search_dynamic_*` tables keyed by `param_url`.
8//!
9//! Every value column is an array, since a single resource can produce many
10//! values for one parameter. Multi-part types (token, date, reference,
11//! quantity) use *parallel* arrays: index `i` of each column belongs to the
12//! same logical value, which lets queries recombine them with
13//! `unnest(a, b) WITH ORDINALITY`.
14
15use std::collections::{HashMap, HashSet};
16
17use haste_fhir_model::r4::generated::terminology::{BoundCode, SearchParamType};
18
19use crate::{ParameterLevel, ResolvedParameter};
20
21/// The PostgreSQL type of a generated value column.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ColumnType {
24    TextArray,
25    BigIntArray,
26    DoubleArray,
27}
28
29impl ColumnType {
30    /// The SQL type name used in `CREATE TABLE` / `ADD COLUMN`.
31    #[must_use]
32    pub const fn sql_type(self) -> &'static str {
33        match self {
34            ColumnType::TextArray => "TEXT[]",
35            ColumnType::BigIntArray => "BIGINT[]",
36            ColumnType::DoubleArray => "DOUBLE PRECISION[]",
37        }
38    }
39}
40
41/// A single generated column on a per-resource-type table.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ColumnDef {
44    pub name: String,
45    pub column_type: ColumnType,
46    /// Whether a GIN index should be created for this column.
47    pub indexed: bool,
48}
49
50/// The set of columns backing one search parameter, grouped by the role each
51/// column plays. Query builders match on this to know which columns to
52/// `unnest` together.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ParamColumns {
55    String {
56        value: String,
57    },
58    Token {
59        system: String,
60        code: String,
61    },
62    Date {
63        start: String,
64        end: String,
65    },
66    Number {
67        value: String,
68    },
69    Uri {
70        value: String,
71    },
72    Reference {
73        target_type: String,
74        target_id: String,
75    },
76    Quantity {
77        start: String,
78        end: String,
79        system: String,
80        code: String,
81    },
82}
83
84impl ParamColumns {
85    /// Every column name this parameter occupies, in insert order.
86    #[must_use]
87    pub fn column_names(&self) -> Vec<&str> {
88        match self {
89            ParamColumns::String { value }
90            | ParamColumns::Number { value }
91            | ParamColumns::Uri { value } => vec![value.as_str()],
92            ParamColumns::Token { system, code } => vec![system.as_str(), code.as_str()],
93            ParamColumns::Date { start, end } => vec![start.as_str(), end.as_str()],
94            ParamColumns::Reference {
95                target_type,
96                target_id,
97            } => vec![target_type.as_str(), target_id.as_str()],
98            ParamColumns::Quantity {
99                start,
100                end,
101                system,
102                code,
103            } => vec![start.as_str(), end.as_str(), system.as_str(), code.as_str()],
104        }
105    }
106}
107
108/// The generated schema for one FHIR resource type's search table.
109#[derive(Debug, Clone, Default)]
110pub struct ResourceTypeSchema {
111    /// Resource type name, e.g. `"Patient"`.
112    pub resource_type: String,
113    /// Table name, e.g. `"search_patient"`.
114    pub table_name: String,
115    /// Search parameter `code` → the columns backing it.
116    pub parameters: HashMap<String, ParamColumns>,
117    /// All value columns, in a stable (sorted) order.
118    pub columns: Vec<ColumnDef>,
119}
120
121impl ResourceTypeSchema {
122    /// Looks up the columns backing a search parameter `code`.
123    #[must_use]
124    pub fn columns_for(&self, code: &str) -> Option<&ParamColumns> {
125        self.parameters.get(code)
126    }
127
128    /// The position of a column within `columns`.
129    ///
130    /// Batched inserts bind one fixed column list per statement, so a
131    /// resource's values are collected into a slot per position rather than
132    /// into a per-resource list of names. `columns` is sorted by name, which
133    /// is what makes the lookup a binary search.
134    #[must_use]
135    pub fn column_index(&self, name: &str) -> Option<usize> {
136        self.columns
137            .binary_search_by(|column| column.name.as_str().cmp(name))
138            .ok()
139    }
140}
141
142/// All generated per-resource-type schemas, keyed by resource type name.
143#[derive(Debug, Clone, Default)]
144pub struct SchemaRegistry {
145    schemas: HashMap<String, ResourceTypeSchema>,
146}
147
148impl SchemaRegistry {
149    #[must_use]
150    pub fn get(&self, resource_type: &str) -> Option<&ResourceTypeSchema> {
151        self.schemas.get(resource_type)
152    }
153
154    /// Iterates every generated schema.
155    pub fn iter(&self) -> impl Iterator<Item = &ResourceTypeSchema> {
156        self.schemas.values()
157    }
158
159    #[must_use]
160    pub fn len(&self) -> usize {
161        self.schemas.len()
162    }
163
164    #[must_use]
165    pub fn is_empty(&self) -> bool {
166        self.schemas.is_empty()
167    }
168}
169
170/// Resource types whose parameters apply to *every* resource table rather than
171/// getting a table of their own (`_lastUpdated`, `_tag`, `_profile`, ...).
172const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
173
174/// `_id` is already the `resource_id` primary key column, so it never needs a
175/// generated column of its own.
176const SKIPPED_CODES: [&str; 1] = ["_id"];
177
178/// The fixed columns every per-resource-type table carries. A search parameter
179/// whose generated name lands on one of these (`ImplementationGuide`'s
180/// `resource` reference becomes `resource_id`, for instance) can't have a
181/// column of its own and falls back to the dynamic tables.
182const RESERVED_COLUMNS: [&str; 5] = [
183    "tenant",
184    "project",
185    "resource_id",
186    "version_id",
187    "resource_type",
188];
189
190/// Converts a `SearchParameter` `code` into a safe PostgreSQL column base name:
191/// lowercased, with every character outside `[a-z0-9_]` folded to `_`.
192///
193/// FHIR codes are already restricted to a conservative character set, but
194/// `_lastUpdated` (leading underscore, camelCase) and hyphenated codes like
195/// `address-city` both need normalizing.
196#[must_use]
197pub fn code_to_column_base(code: &str) -> String {
198    let mut out = String::with_capacity(code.len());
199    for ch in code.chars() {
200        if ch.is_ascii_alphanumeric() {
201            out.push(ch.to_ascii_lowercase());
202        } else {
203            out.push('_');
204        }
205    }
206    out
207}
208
209/// Converts a FHIR resource type name into its search table name.
210#[must_use]
211pub fn resource_type_to_table(resource_type: &str) -> String {
212    format!("search_{}", resource_type.to_ascii_lowercase())
213}
214
215/// Builds the columns for one parameter, or `None` if the type has no PG
216/// representation (composite, special, ...).
217fn columns_for_type(base: &str, param_type: &BoundCode<SearchParamType>) -> Option<ParamColumns> {
218    if param_type == &SearchParamType::string() {
219        Some(ParamColumns::String {
220            value: base.to_string(),
221        })
222    } else if param_type == &SearchParamType::token() {
223        Some(ParamColumns::Token {
224            system: format!("{base}_system"),
225            code: format!("{base}_code"),
226        })
227    } else if param_type == &SearchParamType::date() {
228        Some(ParamColumns::Date {
229            start: format!("{base}_start"),
230            end: format!("{base}_end"),
231        })
232    } else if param_type == &SearchParamType::number() {
233        Some(ParamColumns::Number {
234            value: base.to_string(),
235        })
236    } else if param_type == &SearchParamType::uri() {
237        Some(ParamColumns::Uri {
238            value: base.to_string(),
239        })
240    } else if param_type == &SearchParamType::reference() {
241        Some(ParamColumns::Reference {
242            target_type: format!("{base}_type"),
243            target_id: format!("{base}_id"),
244        })
245    } else if param_type == &SearchParamType::quantity() {
246        Some(ParamColumns::Quantity {
247            start: format!("{base}_start"),
248            end: format!("{base}_end"),
249            system: format!("{base}_system"),
250            code: format!("{base}_code"),
251        })
252    } else {
253        None
254    }
255}
256
257/// The column definitions (name + SQL type + whether to index) for a
258/// `ParamColumns`.
259fn column_defs(columns: &ParamColumns) -> Vec<ColumnDef> {
260    match columns {
261        ParamColumns::String { value } | ParamColumns::Uri { value } => vec![ColumnDef {
262            name: value.clone(),
263            column_type: ColumnType::TextArray,
264            indexed: true,
265        }],
266        ParamColumns::Number { value } => vec![ColumnDef {
267            name: value.clone(),
268            column_type: ColumnType::DoubleArray,
269            indexed: true,
270        }],
271        ParamColumns::Token { system, code } => vec![
272            ColumnDef {
273                name: system.clone(),
274                column_type: ColumnType::TextArray,
275                // The code is the selective half of a token; indexing the
276                // system as well would mostly index a handful of repeated
277                // canonical URLs.
278                indexed: false,
279            },
280            ColumnDef {
281                name: code.clone(),
282                column_type: ColumnType::TextArray,
283                indexed: true,
284            },
285        ],
286        ParamColumns::Date { start, end } => vec![
287            ColumnDef {
288                name: start.clone(),
289                column_type: ColumnType::BigIntArray,
290                indexed: true,
291            },
292            ColumnDef {
293                name: end.clone(),
294                column_type: ColumnType::BigIntArray,
295                indexed: true,
296            },
297        ],
298        ParamColumns::Reference {
299            target_type,
300            target_id,
301        } => vec![
302            ColumnDef {
303                name: target_type.clone(),
304                column_type: ColumnType::TextArray,
305                indexed: false,
306            },
307            ColumnDef {
308                name: target_id.clone(),
309                column_type: ColumnType::TextArray,
310                indexed: true,
311            },
312        ],
313        ParamColumns::Quantity {
314            start,
315            end,
316            system,
317            code,
318        } => vec![
319            ColumnDef {
320                name: start.clone(),
321                column_type: ColumnType::DoubleArray,
322                indexed: true,
323            },
324            ColumnDef {
325                name: end.clone(),
326                column_type: ColumnType::DoubleArray,
327                indexed: true,
328            },
329            ColumnDef {
330                name: system.clone(),
331                column_type: ColumnType::TextArray,
332                indexed: false,
333            },
334            ColumnDef {
335                name: code.clone(),
336                column_type: ColumnType::TextArray,
337                indexed: false,
338            },
339        ],
340    }
341}
342
343/// Derives per-resource-type table schemas from the system-level search
344/// parameters.
345///
346/// Parameters based on `Resource`/`DomainResource` apply to every resource
347/// type and are therefore replicated onto each generated table. Project-level
348/// parameters are ignored here — they live in the `search_dynamic_*` tables.
349#[must_use]
350pub fn generate_schemas(parameters: &[ResolvedParameter]) -> SchemaRegistry {
351    // (code, ParamColumns) pairs that belong on every table.
352    let mut universal: Vec<(String, ParamColumns)> = Vec::new();
353    // resource type → (code, ParamColumns) pairs specific to it.
354    let mut per_type: HashMap<String, Vec<(String, ParamColumns)>> = HashMap::new();
355    // Every resource type that has at least one parameter, universal-only
356    // types included.
357    let mut resource_types: HashSet<String> = HashSet::new();
358
359    for parameter in parameters {
360        if !matches!(parameter.level, ParameterLevel::System) {
361            continue;
362        }
363
364        let search_parameter = &parameter.search_parameter;
365
366        let Some(code) = search_parameter.code.value.as_deref() else {
367            continue;
368        };
369
370        if SKIPPED_CODES.contains(&code) {
371            continue;
372        }
373
374        // A parameter with no FHIRPath expression is never indexed, so it
375        // would only ever produce an always-NULL column.
376        if search_parameter
377            .expression
378            .as_ref()
379            .and_then(|e| e.value.as_deref())
380            .is_none()
381        {
382            continue;
383        }
384
385        let column_base = code_to_column_base(code);
386        let Some(columns) = columns_for_type(&column_base, &search_parameter.type_) else {
387            continue;
388        };
389
390        for base in &search_parameter.base {
391            let Some(base) = base.as_str() else {
392                continue;
393            };
394
395            if UNIVERSAL_BASES.contains(&base) {
396                if !universal.iter().any(|(existing, _)| existing == code) {
397                    universal.push((code.to_string(), columns.clone()));
398                }
399            } else {
400                resource_types.insert(base.to_string());
401                let entries = per_type.entry(base.to_string()).or_default();
402                if !entries.iter().any(|(existing, _)| existing == code) {
403                    entries.push((code.to_string(), columns.clone()));
404                }
405            }
406        }
407    }
408
409    let mut schemas = HashMap::with_capacity(resource_types.len());
410
411    for resource_type in resource_types {
412        let mut schema = ResourceTypeSchema {
413            table_name: resource_type_to_table(&resource_type),
414            resource_type: resource_type.clone(),
415            parameters: HashMap::new(),
416            columns: Vec::new(),
417        };
418
419        // Column names already claimed on this table, seeded with the fixed
420        // columns. Two parameters on the same resource type can also normalize
421        // to colliding names (a `date` date parameter wanting `date_start`
422        // alongside a hypothetical `date-start` string parameter). First
423        // writer wins; the loser falls back to the dynamic EAV tables, which
424        // have no such constraint.
425        let mut claimed: HashSet<String> =
426            RESERVED_COLUMNS.iter().map(|c| (*c).to_string()).collect();
427
428        // Resource-level parameters first so they are stable across tables.
429        for (code, columns) in universal.iter().chain(
430            per_type
431                .get(&resource_type)
432                .map(Vec::as_slice)
433                .unwrap_or_default(),
434        ) {
435            let names = columns.column_names();
436            if names.iter().any(|name| claimed.contains(*name)) {
437                tracing::warn!(
438                    "PG search: skipping search parameter '{code}' on '{resource_type}' — \
439                     column name collision; it will resolve through the dynamic tables.",
440                );
441                continue;
442            }
443
444            for name in names {
445                claimed.insert(name.to_string());
446            }
447
448            schema.columns.extend(column_defs(columns));
449            schema.parameters.insert(code.clone(), columns.clone());
450        }
451
452        schema.columns.sort_by(|a, b| a.name.cmp(&b.name));
453        schemas.insert(resource_type, schema);
454    }
455
456    SchemaRegistry { schemas }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::SearchParameterResolve;
463    use crate::memory::R4_SEARCH_PARAMETERS_INDEX;
464    use haste_jwt::{ProjectId, TenantId};
465
466    async fn patient_schema() -> ResourceTypeSchema {
467        let parameters = R4_SEARCH_PARAMETERS_INDEX
468            .all(&TenantId::System, &ProjectId::System)
469            .await
470            .expect("system parameters resolve");
471
472        generate_schemas(&parameters)
473            .get("Patient")
474            .expect("Patient schema generated")
475            .clone()
476    }
477
478    fn has_column(schema: &ResourceTypeSchema, name: &str, column_type: ColumnType) -> bool {
479        schema
480            .columns
481            .iter()
482            .any(|c| c.name == name && c.column_type == column_type)
483    }
484
485    #[test]
486    fn code_to_column_base_normalizes() {
487        assert_eq!(code_to_column_base("address-city"), "address_city");
488        assert_eq!(code_to_column_base("_lastUpdated"), "_lastupdated");
489        assert_eq!(code_to_column_base("name"), "name");
490    }
491
492    #[test]
493    fn resource_type_to_table_lowercases() {
494        assert_eq!(resource_type_to_table("Patient"), "search_patient");
495        assert_eq!(
496            resource_type_to_table("MedicationRequest"),
497            "search_medicationrequest"
498        );
499    }
500
501    #[tokio::test]
502    async fn patient_has_expected_string_columns() {
503        let schema = patient_schema().await;
504
505        assert_eq!(schema.table_name, "search_patient");
506        for column in ["name", "family", "given", "address", "address_city"] {
507            assert!(
508                has_column(&schema, column, ColumnType::TextArray),
509                "expected TEXT[] column '{column}' on search_patient",
510            );
511        }
512    }
513
514    #[tokio::test]
515    async fn patient_has_expected_token_and_date_columns() {
516        let schema = patient_schema().await;
517
518        for column in ["identifier_system", "identifier_code", "gender_code"] {
519            assert!(
520                has_column(&schema, column, ColumnType::TextArray),
521                "expected TEXT[] column '{column}' on search_patient",
522            );
523        }
524
525        for column in ["birthdate_start", "birthdate_end"] {
526            assert!(
527                has_column(&schema, column, ColumnType::BigIntArray),
528                "expected BIGINT[] column '{column}' on search_patient",
529            );
530        }
531    }
532
533    #[tokio::test]
534    async fn patient_has_reference_and_resource_level_columns() {
535        let schema = patient_schema().await;
536
537        for column in ["general_practitioner_type", "general_practitioner_id"] {
538            assert!(
539                has_column(&schema, column, ColumnType::TextArray),
540                "expected TEXT[] column '{column}' on search_patient",
541            );
542        }
543
544        // Resource-level parameters are replicated onto every table.
545        for column in ["_lastupdated_start", "_lastupdated_end"] {
546            assert!(
547                has_column(&schema, column, ColumnType::BigIntArray),
548                "expected BIGINT[] column '{column}' on search_patient",
549            );
550        }
551        for column in ["_tag_code", "_profile", "_security_code", "_source"] {
552            assert!(
553                has_column(&schema, column, ColumnType::TextArray),
554                "expected TEXT[] column '{column}' on search_patient",
555            );
556        }
557    }
558
559    #[tokio::test]
560    async fn id_is_skipped_and_lookups_resolve_by_code() {
561        let schema = patient_schema().await;
562
563        assert!(schema.columns_for("_id").is_none());
564
565        assert_eq!(
566            schema.columns_for("birthdate"),
567            Some(&ParamColumns::Date {
568                start: "birthdate_start".to_string(),
569                end: "birthdate_end".to_string(),
570            })
571        );
572        assert_eq!(
573            schema.columns_for("identifier"),
574            Some(&ParamColumns::Token {
575                system: "identifier_system".to_string(),
576                code: "identifier_code".to_string(),
577            })
578        );
579    }
580
581    #[tokio::test]
582    async fn registry_covers_many_resource_types() {
583        let parameters = R4_SEARCH_PARAMETERS_INDEX
584            .all(&TenantId::System, &ProjectId::System)
585            .await
586            .expect("system parameters resolve");
587        let registry = generate_schemas(&parameters);
588
589        assert!(registry.get("Observation").is_some());
590        assert!(registry.get("Encounter").is_some());
591        // Universal bases never get a table of their own.
592        assert!(registry.get("Resource").is_none());
593        assert!(registry.get("DomainResource").is_none());
594    }
595
596    #[tokio::test]
597    async fn reserved_columns_are_never_generated() {
598        let parameters = R4_SEARCH_PARAMETERS_INDEX
599            .all(&TenantId::System, &ProjectId::System)
600            .await
601            .expect("system parameters resolve");
602        let registry = generate_schemas(&parameters);
603
604        for schema in registry.iter() {
605            for column in &schema.columns {
606                assert!(
607                    !RESERVED_COLUMNS.contains(&column.name.as_str()),
608                    "generated column '{}' on '{}' collides with a fixed column",
609                    column.name,
610                    schema.table_name,
611                );
612            }
613        }
614
615        // ImplementationGuide's `resource` reference is the concrete case:
616        // it would generate `resource_id`, so it must route to the dynamic
617        // tables instead of claiming a column.
618        let ig = registry
619            .get("ImplementationGuide")
620            .expect("ImplementationGuide schema generated");
621        assert!(ig.columns_for("resource").is_none());
622    }
623
624    #[tokio::test]
625    async fn column_names_are_unique_per_table() {
626        let parameters = R4_SEARCH_PARAMETERS_INDEX
627            .all(&TenantId::System, &ProjectId::System)
628            .await
629            .expect("system parameters resolve");
630        let registry = generate_schemas(&parameters);
631
632        for schema in registry.iter() {
633            let mut seen = HashSet::new();
634            for column in &schema.columns {
635                assert!(
636                    seen.insert(column.name.clone()),
637                    "duplicate column '{}' on table '{}'",
638                    column.name,
639                    schema.table_name,
640                );
641            }
642        }
643    }
644}