haste_fhir_search/pg_search/
migration.rs1use std::fmt::Write as _;
2
3use haste_fhir_model::r4::generated::terminology::IssueType;
4use haste_fhir_operation_error::OperationOutcomeError;
5use sqlx::{AssertSqlSafe, Pool, Postgres};
6
7use super::schema::{
8 ColumnDef, IndexKind, ResourceTypeSchema, SHARED_TABLES, SchemaRegistry, SharedTable,
9 resource_table_name, shared_table_name, shared_value_columns, sql_type, string_prefix_expr,
10};
11
12pub async fn run_migration(
20 pool: &Pool<Postgres>,
21 registry: &SchemaRegistry,
22) -> Result<(), OperationOutcomeError> {
23 let mut lock = pool.acquire().await.map_err(|e| {
27 wrap(
28 "Failed to acquire a connection for the PG search migration",
29 &e,
30 )
31 })?;
32
33 sqlx::query("SELECT pg_advisory_lock($1)")
34 .bind(MIGRATION_LOCK_KEY)
35 .execute(&mut *lock)
36 .await
37 .map_err(|e| wrap("Failed to take the PG search migration lock", &e))?;
38
39 let result = run_migration_locked(pool, registry).await;
41
42 let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
44 .bind(MIGRATION_LOCK_KEY)
45 .execute(&mut *lock)
46 .await;
47
48 result
49}
50
51const MIGRATION_LOCK_KEY: i64 = 0x0F47_5EA4_C401;
53
54async fn run_migration_locked(
55 pool: &Pool<Postgres>,
56 registry: &SchemaRegistry,
57) -> Result<(), OperationOutcomeError> {
58 execute_ddl(
59 pool,
60 &base_migration_sql(registry),
61 "Failed to run PG search base migration",
62 )
63 .await?;
64
65 let mut schemas: Vec<_> = registry.schemas.values().collect();
68 schemas.sort_by(|a, b| a.table_name.cmp(&b.table_name));
69
70 for schema in schemas {
71 execute_ddl(
72 pool,
73 &resource_type_table_sql(schema),
74 &format!("Failed to migrate PG search table '{}'", schema.table_name),
75 )
76 .await?;
77 }
78
79 tracing::info!(
80 "PG search index tables created/verified successfully ({} resource type tables).",
81 registry.schemas.len()
82 );
83 Ok(())
84}
85
86async fn execute_ddl(
87 pool: &Pool<Postgres>,
88 sql: &str,
89 context: &str,
90) -> Result<(), OperationOutcomeError> {
91 sqlx::raw_sql(AssertSqlSafe(sql))
92 .execute(pool)
93 .await
94 .map(|_| ())
95 .map_err(|e| wrap(context, &e))
96}
97
98fn base_migration_sql(registry: &SchemaRegistry) -> String {
101 let resource_table = resource_table_name(®istry.version);
102
103 let create_anchor = format!(
104 "-- One row per indexed resource. Other tables reference it by `res_key`,
105-- which is never reused.
106--
107-- `sequence` is the repository write sequence of the indexed version; the
108-- upsert uses it to reject replays of older versions.
109CREATE TABLE IF NOT EXISTS {resource_table} (
110 res_key BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE,
111 tenant TEXT NOT NULL,
112 project TEXT NOT NULL,
113 resource_type TEXT NOT NULL,
114 resource_id TEXT NOT NULL,
115 version_id TEXT NOT NULL,
116 sequence BIGINT NOT NULL,
117 PRIMARY KEY (tenant, project, resource_type, resource_id)
118);
119
120-- `:contains` is an unanchored LIKE: trigrams serve it, and `btree_gin` lets
121-- the scope columns lead that index.
122CREATE EXTENSION IF NOT EXISTS pg_trgm;
123CREATE EXTENSION IF NOT EXISTS btree_gin;
124"
125 );
126
127 let anchor_columns = add_columns_sql(&resource_table, ®istry.anchor.columns);
130 let anchor_indexes = indexes_sql(&resource_table, ®istry.anchor.columns, ANCHOR_SCOPE);
131 let shared_tables: String = SHARED_TABLES
132 .into_iter()
133 .map(|table| shared_table_sql(registry, table))
134 .collect();
135
136 create_anchor + &anchor_columns + &anchor_indexes + &shared_tables
137}
138
139fn shared_table_sql(registry: &SchemaRegistry, table: SharedTable) -> String {
143 let name = shared_table_name(®istry.version, table);
144
145 let value_columns = shared_value_columns(table)
146 .iter()
147 .map(|column| {
148 let null = if column.nullable { "" } else { " NOT NULL" };
149 format!(" {} {}{null}", column.name, column.sql_type)
150 })
151 .collect::<Vec<_>>()
152 .join(",\n");
153
154 let create_table = format!(
155 "CREATE TABLE IF NOT EXISTS {name} (
156 res_key BIGINT NOT NULL,
157 param_identity BIGINT NOT NULL,
158{value_columns}
159);
160
161-- Correlates rows back to their resource, for clauses and for clearing.
162CREATE INDEX IF NOT EXISTS idx_{name}_resource
163 ON {name} (res_key, param_identity);
164"
165 );
166
167 let is_string = matches!(table, SharedTable::String);
170 let value_key = match table {
171 SharedTable::String => format!("{} text_pattern_ops", string_prefix_expr("value")),
172 SharedTable::Uri | SharedTable::Number => "value".into(),
173 SharedTable::Token => "code".into(),
174 SharedTable::Date => "start_ms, end_ms".into(),
175 SharedTable::Quantity => "start_value, end_value".into(),
176 SharedTable::Reference => "target_id".into(),
177 };
178 let value_index_name = if is_string { "prefix" } else { "value" };
182 let stats_name = if is_string {
183 "prefix_stats"
184 } else {
185 "identity_stats"
186 };
187 let legacy = if is_string {
188 format!(
189 "DROP INDEX IF EXISTS idx_{name}_value;
190DROP STATISTICS IF EXISTS {name}_identity_stats;
191"
192 )
193 } else {
194 String::new()
195 };
196 let value_index = format!(
197 "{legacy}CREATE INDEX IF NOT EXISTS idx_{name}_{value_index_name}
198 ON {name} (param_identity, {value_key});
199"
200 );
201
202 let stats_key = match table {
208 SharedTable::String => Some(format!("({})", string_prefix_expr("value"))),
209 SharedTable::Uri => Some("value".into()),
210 SharedTable::Token => Some("code".into()),
211 SharedTable::Reference => Some("target_id".into()),
212 SharedTable::Number | SharedTable::Date | SharedTable::Quantity => None,
213 };
214 let statistics = stats_key.map_or_else(String::new, |stats_key| {
215 format!(
216 "CREATE STATISTICS IF NOT EXISTS {name}_{stats_name} (mcv, ndistinct, dependencies)
217 ON param_identity, {stats_key} FROM {name};
218ALTER STATISTICS {name}_{stats_name} SET STATISTICS 1000;
219"
220 )
221 });
222
223 let contains_index = if matches!(table, SharedTable::String) {
224 format!(
225 "CREATE INDEX IF NOT EXISTS idx_{name}_contains
226 ON {name} USING GIN (param_identity, LOWER(value) gin_trgm_ops);
227"
228 )
229 } else {
230 String::new()
231 };
232
233 create_table + &value_index + &statistics + &contains_index
234}
235
236fn resource_type_table_sql(schema: &ResourceTypeSchema) -> String {
239 let table = &schema.table_name;
240
241 format!(
242 "CREATE TABLE IF NOT EXISTS {table} (
243 res_key BIGINT PRIMARY KEY,
244 scope BIGINT NOT NULL
245);
246"
247 ) + &add_columns_sql(table, &schema.columns)
248 + &indexes_sql(table, &schema.columns, TYPE_TABLE_SCOPE)
249}
250
251fn add_columns_sql(table: &str, columns: &[ColumnDef]) -> String {
252 columns.iter().fold(String::new(), |mut sql, column| {
253 let _ = writeln!(
254 sql,
255 "ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {} {};",
256 quote_ident(&column.name),
257 sql_type(column.column_type)
258 );
259 sql
260 })
261}
262
263fn indexes_sql(table: &str, columns: &[ColumnDef], scope: &[&str]) -> String {
264 columns
265 .iter()
266 .map(|column| index_sql(table, column, scope))
267 .collect()
268}
269
270const TYPE_TABLE_SCOPE: &[&str] = &["scope"];
272
273const ANCHOR_SCOPE: &[&str] = &["tenant", "project", "resource_type"];
276
277fn index_sql(table: &str, column: &ColumnDef, scope: &[&str]) -> String {
279 let name = truncate_identifier(&format!("idx_{table}_{}", column.name));
280 let ident = quote_ident(&column.name);
281 let lead = scope.join(", ");
282
283 match column.index {
284 IndexKind::None => String::new(),
285 IndexKind::BTree => {
286 format!("CREATE INDEX IF NOT EXISTS {name} ON {table} ({lead}, {ident});\n")
287 }
288 IndexKind::BTreeDescending => format!(
289 "CREATE INDEX IF NOT EXISTS {name} ON {table} ({lead}, {ident} DESC NULLS LAST);\n"
290 ),
291 IndexKind::LoweredPrefix => format!(
295 "DROP INDEX IF EXISTS {name};\n\
296 CREATE INDEX IF NOT EXISTS {name}_prefix \
297 ON {table} ({lead}, {} text_pattern_ops);\n\
298 CREATE INDEX IF NOT EXISTS {name}_ct \
299 ON {table} USING GIN ({lead}, LOWER({ident}) gin_trgm_ops);\n",
300 string_prefix_expr(&ident)
301 ),
302 }
303}
304
305const MAX_IDENTIFIER_LEN: usize = 63;
307
308fn truncate_identifier(name: &str) -> String {
309 name.chars().take(MAX_IDENTIFIER_LEN).collect()
310}
311
312fn quote_ident(name: &str) -> String {
313 format!("\"{}\"", name.replace('"', "\"\""))
314}
315
316fn wrap(context: &str, error: &sqlx::Error) -> OperationOutcomeError {
317 OperationOutcomeError::fatal(IssueType::exception(), format!("{context}: {error}"))
318}
319
320#[cfg(test)]
323pub(crate) fn shared_table_sql_for_test(
324 version: haste_repository::types::SupportedFHIRVersions,
325 table: SharedTable,
326) -> String {
327 shared_table_sql(&super::schema::generate_schemas(version, &[]), table)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::pg_search::schema::ColumnType;
334
335 fn column(name: &str, index: IndexKind) -> ColumnDef {
336 ColumnDef {
337 name: name.to_string(),
338 column_type: ColumnType::Text,
339 index,
340 }
341 }
342
343 #[test]
345 fn every_index_leads_with_the_tenant_scope() {
346 for (scope, lead) in [
347 (TYPE_TABLE_SCOPE, "(scope, "),
348 (ANCHOR_SCOPE, "(tenant, project, resource_type, "),
349 ] {
350 for index in [
351 IndexKind::BTree,
352 IndexKind::BTreeDescending,
353 IndexKind::LoweredPrefix,
354 ] {
355 let sql = index_sql("t", &column("code", index), scope);
356 for statement in sql.lines().filter(|line| line.contains("CREATE INDEX")) {
357 assert!(
358 statement.contains(lead),
359 "index does not lead with {lead}: {statement}"
360 );
361 }
362 }
363 }
364 }
365}