1use std::collections::{HashMap, HashSet};
16
17use haste_fhir_model::r4::generated::terminology::{BoundCode, SearchParamType};
18
19use crate::{ParameterLevel, ResolvedParameter};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ColumnType {
24 TextArray,
25 BigIntArray,
26 DoubleArray,
27}
28
29impl ColumnType {
30 #[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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ColumnDef {
44 pub name: String,
45 pub column_type: ColumnType,
46 pub indexed: bool,
48}
49
50#[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 #[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#[derive(Debug, Clone, Default)]
110pub struct ResourceTypeSchema {
111 pub resource_type: String,
113 pub table_name: String,
115 pub parameters: HashMap<String, ParamColumns>,
117 pub columns: Vec<ColumnDef>,
119}
120
121impl ResourceTypeSchema {
122 #[must_use]
124 pub fn columns_for(&self, code: &str) -> Option<&ParamColumns> {
125 self.parameters.get(code)
126 }
127
128 #[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#[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 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
170const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
173
174const SKIPPED_CODES: [&str; 1] = ["_id"];
177
178const RESERVED_COLUMNS: [&str; 5] = [
183 "tenant",
184 "project",
185 "resource_id",
186 "version_id",
187 "resource_type",
188];
189
190#[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#[must_use]
211pub fn resource_type_to_table(resource_type: &str) -> String {
212 format!("search_{}", resource_type.to_ascii_lowercase())
213}
214
215fn 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
257fn 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 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#[must_use]
350pub fn generate_schemas(parameters: &[ResolvedParameter]) -> SchemaRegistry {
351 let mut universal: Vec<(String, ParamColumns)> = Vec::new();
353 let mut per_type: HashMap<String, Vec<(String, ParamColumns)>> = HashMap::new();
355 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 = ¶meter.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 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 let mut claimed: HashSet<String> =
426 RESERVED_COLUMNS.iter().map(|c| (*c).to_string()).collect();
427
428 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(¶meters)
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 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(¶meters);
588
589 assert!(registry.get("Observation").is_some());
590 assert!(registry.get("Encounter").is_some());
591 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(¶meters);
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 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(¶meters);
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}