1use std::collections::{HashMap, HashSet};
21
22use haste_fhir_model::r4::generated::terminology::{BoundCode, SearchParamType};
23use haste_repository::types::SupportedFHIRVersions;
24
25use crate::{ParameterLevel, ResolvedParameter, search_parameter_cardinality};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ColumnType {
30 Text,
31 BigInt,
32 Double,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum IndexKind {
38 None,
40 BTree,
42 BTreeDescending,
45 LoweredPrefix,
49}
50
51pub const STRING_PREFIX_CHARS: usize = 512;
60
61#[must_use]
64pub fn string_prefix_expr(column: &str) -> String {
65 format!("LOWER(LEFT({column}, {STRING_PREFIX_CHARS}))")
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ColumnDef {
71 pub name: String,
72 pub column_type: ColumnType,
73 pub index: IndexKind,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ParamColumns {
80 String {
81 value: String,
82 },
83 Token {
86 system: Option<String>,
87 code: String,
88 },
89 Date {
90 start: String,
91 end: String,
92 },
93 Number {
94 value: String,
95 },
96 Uri {
97 value: String,
98 },
99 Reference {
102 target_type: String,
103 target_id: String,
104 target_uri: String,
105 },
106 Quantity {
107 start: String,
108 end: String,
109 system: String,
110 code: String,
111 },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct SharedColumn {
117 pub name: &'static str,
118 pub sql_type: &'static str,
120 pub nullable: bool,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum SharedTable {
126 String,
127 Token,
128 Date,
129 Number,
130 Quantity,
131 Uri,
132 Reference,
133}
134
135pub const SHARED_TABLES: [SharedTable; 7] = [
137 SharedTable::String,
138 SharedTable::Token,
139 SharedTable::Date,
140 SharedTable::Number,
141 SharedTable::Quantity,
142 SharedTable::Uri,
143 SharedTable::Reference,
144];
145
146#[derive(Debug, Clone, Default)]
148pub struct ResourceTypeSchema {
149 pub resource_type: String,
151 pub table_name: String,
153 pub parameters: HashMap<String, ParamColumns>,
155 pub columns: Vec<ColumnDef>,
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct SchemaRegistry {
162 pub version: SupportedFHIRVersions,
163 pub anchor: ResourceTypeSchema,
167 pub schemas: HashMap<String, ResourceTypeSchema>,
169}
170
171#[must_use]
173pub const fn sql_type(column_type: ColumnType) -> &'static str {
174 match column_type {
175 ColumnType::Text => "TEXT",
176 ColumnType::BigInt => "BIGINT",
177 ColumnType::Double => "DOUBLE PRECISION",
178 }
179}
180
181#[must_use]
183pub fn column_names(columns: &ParamColumns) -> Vec<&str> {
184 match columns {
185 ParamColumns::String { value }
186 | ParamColumns::Number { value }
187 | ParamColumns::Uri { value } => vec![value.as_str()],
188 ParamColumns::Token { system, code } => system
189 .iter()
190 .map(String::as_str)
191 .chain(std::iter::once(code.as_str()))
192 .collect(),
193 ParamColumns::Date { start, end } => vec![start.as_str(), end.as_str()],
194 ParamColumns::Reference {
195 target_type,
196 target_id,
197 target_uri,
198 } => vec![
199 target_type.as_str(),
200 target_id.as_str(),
201 target_uri.as_str(),
202 ],
203 ParamColumns::Quantity {
204 start,
205 end,
206 system,
207 code,
208 } => vec![start.as_str(), end.as_str(), system.as_str(), code.as_str()],
209 }
210}
211
212const fn shared_column(name: &'static str, sql_type: &'static str, nullable: bool) -> SharedColumn {
213 SharedColumn {
214 name,
215 sql_type,
216 nullable,
217 }
218}
219
220#[must_use]
222pub const fn shared_table_kind(table: SharedTable) -> &'static str {
223 match table {
224 SharedTable::String => "string",
225 SharedTable::Token => "token",
226 SharedTable::Date => "date",
227 SharedTable::Number => "number",
228 SharedTable::Quantity => "quantity",
229 SharedTable::Uri => "uri",
230 SharedTable::Reference => "reference",
231 }
232}
233
234#[must_use]
237pub const fn shared_value_columns(table: SharedTable) -> &'static [SharedColumn] {
238 match table {
239 SharedTable::String | SharedTable::Uri => {
240 const COLUMNS: &[SharedColumn] = &[shared_column("value", "text", false)];
241 COLUMNS
242 }
243 SharedTable::Token => {
244 const COLUMNS: &[SharedColumn] = &[
246 shared_column("system", "text", true),
247 shared_column("code", "text", true),
248 ];
249 COLUMNS
250 }
251 SharedTable::Date => {
252 const COLUMNS: &[SharedColumn] = &[
253 shared_column("start_ms", "int8", false),
254 shared_column("end_ms", "int8", false),
255 ];
256 COLUMNS
257 }
258 SharedTable::Number => {
259 const COLUMNS: &[SharedColumn] = &[shared_column("value", "float8", false)];
260 COLUMNS
261 }
262 SharedTable::Quantity => {
263 const COLUMNS: &[SharedColumn] = &[
264 shared_column("start_value", "float8", false),
265 shared_column("end_value", "float8", false),
266 shared_column("start_system", "text", true),
267 shared_column("start_code", "text", true),
268 ];
269 COLUMNS
270 }
271 SharedTable::Reference => {
272 const COLUMNS: &[SharedColumn] = &[
274 shared_column("target_resource_type", "text", true),
275 shared_column("target_id", "text", true),
276 ];
277 COLUMNS
278 }
279 }
280}
281
282#[must_use]
285pub fn shared_table_name(version: &SupportedFHIRVersions, table: SharedTable) -> String {
286 format!("{version}_param_{}_idx", shared_table_kind(table))
287}
288
289#[must_use]
291pub fn shared_table_for(param_type: &BoundCode<SearchParamType>) -> Option<SharedTable> {
292 [
293 (SearchParamType::string(), SharedTable::String),
294 (SearchParamType::token(), SharedTable::Token),
295 (SearchParamType::date(), SharedTable::Date),
296 (SearchParamType::number(), SharedTable::Number),
297 (SearchParamType::quantity(), SharedTable::Quantity),
298 (SearchParamType::uri(), SharedTable::Uri),
299 (SearchParamType::reference(), SharedTable::Reference),
300 ]
301 .into_iter()
302 .find_map(|(candidate, table)| (param_type == &candidate).then_some(table))
303}
304
305#[must_use]
307pub fn resource_table_name(version: &SupportedFHIRVersions) -> String {
308 format!("{version}_resource_idx")
309}
310
311#[must_use]
313pub fn resource_type_to_table(version: &SupportedFHIRVersions, resource_type: &str) -> String {
314 format!("{version}_{}_idx", resource_type.to_ascii_lowercase())
315}
316
317#[must_use]
319pub fn column_index(schema: &ResourceTypeSchema, name: &str) -> Option<usize> {
320 schema
321 .columns
322 .binary_search_by(|column| column.name.as_str().cmp(name))
323 .ok()
324}
325
326const RESOURCE_ID_CODE: &str = "_id";
328
329const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
331
332const RESERVED_COLUMNS: [&str; 8] = [
334 "res_key",
335 "scope",
336 "tenant",
337 "project",
338 "resource_type",
339 "resource_id",
340 "version_id",
341 "sequence",
342];
343
344fn code_to_column_base(code: &str) -> String {
346 code.chars()
347 .map(|c| {
348 if c.is_ascii_alphanumeric() {
349 c.to_ascii_lowercase()
350 } else {
351 '_'
352 }
353 })
354 .collect()
355}
356
357fn columns_for_type(base: &str, param_type: &BoundCode<SearchParamType>) -> Option<ParamColumns> {
360 let part = |suffix: &str| format!("{base}{suffix}");
361
362 Some(match shared_table_for(param_type)? {
363 SharedTable::String => ParamColumns::String {
364 value: base.to_string(),
365 },
366 SharedTable::Uri => ParamColumns::Uri {
367 value: base.to_string(),
368 },
369 SharedTable::Number => ParamColumns::Number {
370 value: base.to_string(),
371 },
372 SharedTable::Token => ParamColumns::Token {
373 system: Some(part("_system")),
374 code: part("_code"),
375 },
376 SharedTable::Date => ParamColumns::Date {
377 start: part("_start"),
378 end: part("_end"),
379 },
380 SharedTable::Reference => ParamColumns::Reference {
381 target_type: part("_type"),
382 target_id: part("_id"),
383 target_uri: part("_uri"),
384 },
385 SharedTable::Quantity => ParamColumns::Quantity {
386 start: part("_start"),
387 end: part("_end"),
388 system: part("_system"),
389 code: part("_code"),
390 },
391 })
392}
393
394fn column_defs(columns: &ParamColumns) -> Vec<ColumnDef> {
397 let def = |name: &String, column_type, index| ColumnDef {
398 name: name.clone(),
399 column_type,
400 index,
401 };
402 let text = |name, index| def(name, ColumnType::Text, index);
403
404 match columns {
405 ParamColumns::String { value } => vec![text(value, IndexKind::LoweredPrefix)],
406 ParamColumns::Uri { value } => vec![text(value, IndexKind::BTree)],
407 ParamColumns::Number { value } => vec![def(value, ColumnType::Double, IndexKind::BTree)],
408 ParamColumns::Token { system, code } => system
409 .iter()
410 .map(|system| text(system, IndexKind::None))
411 .chain(std::iter::once(text(code, IndexKind::BTree)))
412 .collect(),
413 ParamColumns::Date { start, end } => vec![
414 def(start, ColumnType::BigInt, IndexKind::BTree),
415 def(end, ColumnType::BigInt, IndexKind::BTreeDescending),
417 ],
418 ParamColumns::Reference {
419 target_type,
420 target_id,
421 target_uri,
422 } => vec![
423 text(target_type, IndexKind::None),
424 text(target_id, IndexKind::BTree),
425 text(target_uri, IndexKind::None),
426 ],
427 ParamColumns::Quantity {
428 start,
429 end,
430 system,
431 code,
432 } => vec![
433 def(start, ColumnType::Double, IndexKind::BTree),
434 def(end, ColumnType::Double, IndexKind::BTree),
435 text(system, IndexKind::None),
436 text(code, IndexKind::None),
437 ],
438 }
439}
440
441fn singular_columns(parameter: &ResolvedParameter) -> Option<(&str, &str, ParamColumns)> {
445 if !matches!(parameter.level, ParameterLevel::System) {
446 return None;
447 }
448
449 let search_parameter = ¶meter.search_parameter;
450 let code = search_parameter.code.value.as_deref()?;
451 search_parameter
452 .expression
453 .as_ref()
454 .and_then(|e| e.value.as_deref())?;
455 let url = search_parameter.url.value.as_deref()?;
456 let columns = columns_for_type(&code_to_column_base(code), &search_parameter.type_)?;
457
458 Some((code, url, columns))
459}
460
461fn build_table_schema(
464 table_name: String,
465 resource_type: String,
466 entries: &[(String, ParamColumns)],
467) -> ResourceTypeSchema {
468 let reserved: HashSet<&str> = RESERVED_COLUMNS.into_iter().collect();
469
470 let (_, parameters) = entries.iter().fold(
471 (reserved, Vec::with_capacity(entries.len())),
472 |(mut claimed, mut accepted), (code, columns)| {
473 let names = column_names(columns);
474 if names.iter().any(|name| claimed.contains(name)) {
475 tracing::warn!(
476 "PG search: '{code}' on '{table_name}' collides with an existing column; \
477 it resolves through the shared tables instead.",
478 );
479 } else {
480 claimed.extend(names);
481 accepted.push((code, columns));
482 }
483 (claimed, accepted)
484 },
485 );
486
487 let mut columns: Vec<ColumnDef> = parameters
488 .iter()
489 .flat_map(|(_, columns)| column_defs(columns))
490 .collect();
491 columns.sort_by(|a, b| a.name.cmp(&b.name));
492
493 ResourceTypeSchema {
494 table_name,
495 resource_type,
496 parameters: parameters
497 .into_iter()
498 .map(|(code, columns)| (code.clone(), columns.clone()))
499 .collect(),
500 columns,
501 }
502}
503
504#[must_use]
507pub fn generate_schemas(
508 version: SupportedFHIRVersions,
509 parameters: &[ResolvedParameter],
510) -> SchemaRegistry {
511 let placements = parameters
514 .iter()
515 .filter_map(|parameter| Some((parameter, singular_columns(parameter)?)))
516 .flat_map(|(parameter, (code, url, columns))| {
517 parameter
518 .search_parameter
519 .base
520 .iter()
521 .filter_map(BoundCode::as_str)
522 .filter(move |base| search_parameter_cardinality::is_single_valued(url, base))
523 .filter(move |base| !(code == RESOURCE_ID_CODE && UNIVERSAL_BASES.contains(base)))
525 .map(move |base| (base, code, columns.clone()))
526 });
527
528 let (universal, per_type) = placements.fold(
530 (
531 Vec::<(String, ParamColumns)>::new(),
532 HashMap::<String, Vec<(String, ParamColumns)>>::new(),
533 ),
534 |(mut universal, mut per_type), (base, code, columns)| {
535 let entries = if UNIVERSAL_BASES.contains(&base) {
536 &mut universal
537 } else {
538 per_type.entry(base.to_string()).or_default()
539 };
540 if !entries.iter().any(|(existing, _)| existing == code) {
541 entries.push((code.to_string(), columns));
542 }
543 (universal, per_type)
544 },
545 );
546
547 let mut anchor = build_table_schema(
548 resource_table_name(&version),
549 "Resource".to_string(),
550 &universal,
551 );
552 anchor.parameters.insert(
553 RESOURCE_ID_CODE.to_string(),
554 ParamColumns::Token {
555 system: None,
556 code: "resource_id".to_string(),
557 },
558 );
559
560 let schemas = per_type
561 .into_iter()
562 .map(|(resource_type, entries)| {
563 let schema = build_table_schema(
564 resource_type_to_table(&version, &resource_type),
565 resource_type.clone(),
566 &entries,
567 );
568 (resource_type, schema)
569 })
570 .collect();
571
572 SchemaRegistry {
573 version,
574 anchor,
575 schemas,
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::SearchParameterResolve;
583 use crate::memory::R4_SEARCH_PARAMETERS_INDEX;
584 use haste_jwt::{ProjectId, TenantId};
585
586 async fn registry() -> SchemaRegistry {
587 let parameters = R4_SEARCH_PARAMETERS_INDEX
588 .all(&TenantId::System, &ProjectId::System)
589 .await
590 .expect("system parameters resolve");
591
592 generate_schemas(SupportedFHIRVersions::R4, ¶meters)
593 }
594
595 fn schema<'a>(registry: &'a SchemaRegistry, resource_type: &str) -> &'a ResourceTypeSchema {
596 registry
597 .schemas
598 .get(resource_type)
599 .unwrap_or_else(|| panic!("{resource_type} schema"))
600 }
601
602 fn column<'a>(schema: &'a ResourceTypeSchema, name: &str) -> Option<&'a ColumnDef> {
603 schema.columns.iter().find(|c| c.name == name)
604 }
605
606 #[test]
607 fn tables_are_named_by_version_and_purpose() {
608 assert_eq!(
609 resource_type_to_table(&SupportedFHIRVersions::R4, "Patient"),
610 "r4_patient_idx"
611 );
612 assert_eq!(
613 shared_table_name(&SupportedFHIRVersions::R4, SharedTable::Token),
614 "r4_param_token_idx"
615 );
616 assert_eq!(
617 resource_table_name(&SupportedFHIRVersions::R4),
618 "r4_resource_idx"
619 );
620 }
621
622 #[tokio::test]
623 async fn singular_parameters_get_scalar_columns() {
624 let registry = registry().await;
625 let patient = schema(®istry, "Patient");
626
627 let birth_date = column(patient, "birthdate_start").expect("birthdate_start");
628 assert_eq!(birth_date.column_type, ColumnType::BigInt);
629 assert_eq!(birth_date.index, IndexKind::BTree);
630
631 let gender = column(patient, "gender_code").expect("gender_code");
632 assert_eq!(gender.column_type, ColumnType::Text);
633 }
634
635 #[tokio::test]
637 async fn repeating_parameters_get_no_column() {
638 let registry = registry().await;
639 let patient = schema(®istry, "Patient");
640
641 for code in ["name", "family", "given", "identifier", "address"] {
642 assert!(
643 !patient.parameters.contains_key(code),
644 "'{code}' repeats and must not claim a column",
645 );
646 }
647 }
648
649 #[tokio::test]
651 async fn string_columns_index_the_lowered_value() {
652 let registry = registry().await;
653
654 let string_columns: Vec<_> = registry
655 .schemas
656 .values()
657 .flat_map(|schema| {
658 schema
659 .parameters
660 .values()
661 .filter_map(move |columns| match columns {
662 ParamColumns::String { value } => Some((schema, value)),
663 _ => None,
664 })
665 })
666 .collect();
667
668 assert!(!string_columns.is_empty(), "expected some string columns");
669 for (schema, value) in string_columns {
670 assert_eq!(
671 column(schema, value).expect(value).index,
672 IndexKind::LoweredPrefix,
673 "'{value}' on '{}' is searched by prefix",
674 schema.table_name,
675 );
676 }
677 }
678
679 #[tokio::test]
680 async fn only_the_filtered_half_of_a_pair_is_indexed() {
681 let registry = registry().await;
682 let observation = schema(®istry, "Observation");
683
684 let Some(ParamColumns::Reference {
685 target_type,
686 target_id,
687 ..
688 }) = observation.parameters.get("subject")
689 else {
690 panic!("Observation.subject should be a singular reference column");
691 };
692
693 assert_eq!(
694 column(observation, target_id).unwrap().index,
695 IndexKind::BTree
696 );
697 assert_eq!(
698 column(observation, target_type).unwrap().index,
699 IndexKind::None
700 );
701 }
702
703 #[tokio::test]
705 async fn shared_parameters_get_columns_where_their_branch_is_single() {
706 let registry = registry().await;
707 let has = |resource_type: &str, code: &str| {
708 schema(®istry, resource_type)
709 .parameters
710 .contains_key(code)
711 };
712
713 assert!(has("Observation", "patient"));
714 assert!(has("Observation", "encounter"));
715 assert!(has("Condition", "patient"));
716 assert!(has("Encounter", "date"));
717
718 assert!(!has("Observation", "date"));
720 assert!(!has("Observation", "code"));
722 assert!(!has("DocumentReference", "encounter"));
724 }
725
726 #[tokio::test]
727 async fn resource_level_parameters_live_on_the_anchor() {
728 let registry = registry().await;
729
730 assert_eq!(registry.anchor.table_name, "r4_resource_idx");
731 assert!(
732 registry.anchor.parameters.contains_key("_lastUpdated"),
733 "_lastUpdated belongs on the anchor",
734 );
735 assert!(
736 !schema(®istry, "Patient")
737 .parameters
738 .contains_key("_lastUpdated"),
739 "_lastUpdated must not be replicated onto every resource type table",
740 );
741 }
742
743 #[tokio::test]
744 async fn id_is_answered_from_the_resource_key() {
745 let registry = registry().await;
746 let anchor = ®istry.anchor;
747
748 assert_eq!(
749 anchor.parameters.get("_id"),
750 Some(&ParamColumns::Token {
751 system: None,
752 code: "resource_id".to_string()
753 })
754 );
755 assert!(
756 anchor.columns.iter().all(|c| !c.name.starts_with("_id")),
757 "no column is created for _id"
758 );
759 }
760
761 #[tokio::test]
762 async fn anchor_columns_are_indexed() {
763 let registry = registry().await;
764 let anchor = ®istry.anchor;
765
766 let Some(ParamColumns::Date { start, end }) = anchor.parameters.get("_lastUpdated") else {
767 panic!("_lastUpdated should be a date column pair");
768 };
769 assert_eq!(column(anchor, start).unwrap().index, IndexKind::BTree);
770 assert_eq!(
772 column(anchor, end).unwrap().index,
773 IndexKind::BTreeDescending
774 );
775 }
776
777 #[tokio::test]
778 async fn reserved_columns_are_never_generated() {
779 let registry = registry().await;
780
781 for schema in registry.schemas.values() {
782 for column in &schema.columns {
783 assert!(
784 !RESERVED_COLUMNS.contains(&column.name.as_str()),
785 "generated column '{}' on '{}' collides with a fixed column",
786 column.name,
787 schema.table_name,
788 );
789 }
790 }
791 }
792
793 #[tokio::test]
794 async fn column_names_are_unique_per_table() {
795 let registry = registry().await;
796
797 for schema in registry.schemas.values() {
798 let mut seen = HashSet::new();
799 for column in &schema.columns {
800 assert!(
801 seen.insert(column.name.clone()),
802 "duplicate column '{}' on '{}'",
803 column.name,
804 schema.table_name,
805 );
806 }
807 }
808 }
809}