Skip to main content

haste_fhir_search/pg_search/
schema.rs

1//! Table and column layout for the PG search backend.
2//!
3//! Where a parameter is stored depends on whether it can repeat:
4//!
5//! - **Singular**: a scalar column on its resource type's table
6//!   (`r4_patient_idx`). Scalars support ordered comparisons, prefix matches,
7//!   sorts and composite indexes; arrays only support overlap.
8//! - **Repeating**: one row per value in the shared table for its type
9//!   (`r4_param_token_idx`, ...), keyed by canonical URL. Same indexes, at the
10//!   cost of a join.
11//!
12//! Every table references its resource by `res_key`, a `BIGINT` allocated by
13//! the anchor (`r4_resource_idx`). Only the anchor stores tenant, project, type
14//! and id, which keeps the other indexes small.
15//!
16//! Cardinality is precomputed in [`crate::search_parameter_cardinality`].
17//! Anything not known to be singular goes to the shared tables, since a scalar
18//! column would silently drop extra values.
19
20use 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/// A generated column's type. Always scalar: repeating values live in rows.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ColumnType {
30    Text,
31    BigInt,
32    Double,
33}
34
35/// How a column is indexed.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum IndexKind {
38    /// Not indexed; filtered on the row its indexed sibling finds.
39    None,
40    /// Plain B-tree for equality and range comparisons.
41    BTree,
42    /// B-tree stored `DESC NULLS LAST`, so a descending sort can stop at the
43    /// page size. (A plain B-tree read backwards is `DESC NULLS FIRST`.)
44    BTreeDescending,
45    /// B-tree over [`string_prefix_expr`] with `text_pattern_ops`, for
46    /// case-insensitive prefix matches, plus a trigram GIN over
47    /// `LOWER(column)` for `:contains`.
48    LoweredPrefix,
49}
50
51/// How many leading characters of a string value its prefix B-tree covers.
52///
53/// A B-tree entry must fit in a third of a page (2704 bytes), and a long
54/// narrative such as a `ValueSet.description` does not. Indexing only the
55/// first characters keeps every value indexable: at most four UTF-8 bytes a
56/// character, this many fit with room for the scope columns. A prefix term is
57/// never this long, so a search loses nothing; the full value is still stored
58/// and the trigram index still covers all of it.
59pub const STRING_PREFIX_CHARS: usize = 512;
60
61/// The indexed expression over a string column. Prefix and exact searches
62/// compare it, so the planner can match them to the index.
63#[must_use]
64pub fn string_prefix_expr(column: &str) -> String {
65    format!("LOWER(LEFT({column}, {STRING_PREFIX_CHARS}))")
66}
67
68/// One generated column.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ColumnDef {
71    pub name: String,
72    pub column_type: ColumnType,
73    pub index: IndexKind,
74}
75
76/// The columns backing one parameter. Multi-part values keep their parts on the
77/// same row, so e.g. a token's system stays paired with its code.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ParamColumns {
80    String {
81        value: String,
82    },
83    /// `system` is `None` only for `_id`, which reads the anchor's
84    /// `resource_id`.
85    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    /// `target_uri` holds a canonical or absolute URL, which has no
100    /// type/id split; it keeps such a value present for `:missing`.
101    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/// A value column of a [`SharedTable`]. Shared by the DDL and the batch insert.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct SharedColumn {
117    pub name: &'static str,
118    /// Valid both in `CREATE TABLE` and as a `$n::<sql_type>[]` cast.
119    pub sql_type: &'static str,
120    pub nullable: bool,
121}
122
123/// The shared table for one value type, holding repeating parameters.
124#[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
135/// Every shared table, in creation order.
136pub 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/// One resource type's table of singular parameters.
147#[derive(Debug, Clone, Default)]
148pub struct ResourceTypeSchema {
149    /// e.g. `"Patient"`.
150    pub resource_type: String,
151    /// e.g. `"r4_patient_idx"`.
152    pub table_name: String,
153    /// Parameter `code` → its columns.
154    pub parameters: HashMap<String, ParamColumns>,
155    /// All value columns, sorted by name.
156    pub columns: Vec<ColumnDef>,
157}
158
159/// Every generated table layout for one FHIR version.
160#[derive(Debug, Clone, Default)]
161pub struct SchemaRegistry {
162    pub version: SupportedFHIRVersions,
163    /// The anchor's columns: parameters on `Resource`/`DomainResource`
164    /// (`_lastUpdated`, `_tag`, ...). Stored once here instead of on every
165    /// type table, so untyped searches can read them too.
166    pub anchor: ResourceTypeSchema,
167    /// Keyed by resource type name.
168    pub schemas: HashMap<String, ResourceTypeSchema>,
169}
170
171/// SQL type for `CREATE TABLE` / `ADD COLUMN`.
172#[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/// Every column a parameter occupies, in insert order.
182#[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/// The value type a shared table holds.
221#[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/// A shared table's value columns, after the `res_key, param_identity` keys.
235/// The DDL and the inserts both read this list, so they cannot drift.
236#[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            // A Coding may have only a system or only a code.
245            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            // A type with no id, or an absolute URL, leaves one half empty.
273            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/// `r4_param_token_idx`. The `param_` segment can't clash with a type table,
283/// since no resource type name contains an underscore.
284#[must_use]
285pub fn shared_table_name(version: &SupportedFHIRVersions, table: SharedTable) -> String {
286    format!("{version}_param_{}_idx", shared_table_kind(table))
287}
288
289/// The shared table for a parameter type; `None` for composite and special.
290#[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/// `r4_resource_idx`: the anchor, one row per indexed resource.
306#[must_use]
307pub fn resource_table_name(version: &SupportedFHIRVersions) -> String {
308    format!("{version}_resource_idx")
309}
310
311/// `r4_patient_idx`: one table per resource type.
312#[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/// Position of `name` in `schema.columns` (sorted, so binary search).
318#[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
326/// `_id` reads the anchor's `resource_id` primary key instead of a column.
327const RESOURCE_ID_CODE: &str = "_id";
328
329/// Bases whose parameters apply to every resource, so live on the anchor.
330const UNIVERSAL_BASES: [&str; 2] = ["Resource", "DomainResource"];
331
332/// Fixed columns; a parameter whose name lands on one uses the shared tables.
333const 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
344/// Folds a `code` to `[a-z0-9_]`, so a column name never needs escaping.
345fn 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
357/// Column names for a singular parameter of `param_type`. Derived from the
358/// same mapping as the shared tables, so both shapes always agree.
359fn 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
394/// Column definitions for a parameter. Of a pair, only the half queries filter
395/// on is indexed (token code, reference id); the other is checked on the row.
396fn 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            // Only descending sorts read the end.
416            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
441/// The `(code, url, columns)` a system-level parameter with an expression and
442/// a column-mappable type would use. Cardinality is checked per base by the
443/// caller.
444fn singular_columns(parameter: &ResolvedParameter) -> Option<(&str, &str, ParamColumns)> {
445    if !matches!(parameter.level, ParameterLevel::System) {
446        return None;
447    }
448
449    let search_parameter = &parameter.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
461/// Lays out one table. On a column name clash (with a fixed column or an
462/// earlier parameter) the later parameter falls back to the shared tables.
463fn 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/// Builds every resource type table from the singular system parameters, plus
505/// the anchor's columns for the resource-level ones.
506#[must_use]
507pub fn generate_schemas(
508    version: SupportedFHIRVersions,
509    parameters: &[ResolvedParameter],
510) -> SchemaRegistry {
511    // (base, code, columns) for every base where the parameter is singular.
512    // Checked per base: a parameter can repeat on one type but not another.
513    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                // `_id` is the anchor's own key, added below.
524                .filter(move |base| !(code == RESOURCE_ID_CODE && UNIVERSAL_BASES.contains(base)))
525                .map(move |base| (base, code, columns.clone()))
526        });
527
528    // First occurrence of each code per table wins.
529    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, &parameters)
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(&registry, "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    /// `Patient.name` is `0..*`, so it uses the shared string table.
636    #[tokio::test]
637    async fn repeating_parameters_get_no_column() {
638        let registry = registry().await;
639        let patient = schema(&registry, "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    /// Case-insensitive prefix search needs an index over the lowered prefix.
650    #[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(&registry, "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    /// Each type takes only its own branch of a union expression.
704    #[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(&registry, 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        // `Observation.effective` may be a Timing (several dates).
719        assert!(!has("Observation", "date"));
720        // A CodeableConcept is one token per coding.
721        assert!(!has("Observation", "code"));
722        // `DocumentReference.context.encounter` repeats.
723        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(&registry, "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 = &registry.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 = &registry.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        // `_sort=-_lastUpdated` reads the end in descending order.
771        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}