Skip to main content

helios_persistence/backends/postgres/search/
writer.rs

1//! PostgreSQL search index writer implementation.
2
3use chrono::{DateTime, Utc};
4
5use crate::error::{BackendError, StorageResult};
6use crate::search::{converters::IndexValue, extractor::ExtractedValue};
7
8fn internal_error(message: String) -> crate::error::StorageError {
9    crate::error::StorageError::Backend(BackendError::Internal {
10        backend_name: "postgres".to_string(),
11        message,
12        source: None,
13    })
14}
15
16/// PostgreSQL implementation of SearchIndexWriter.
17pub struct PostgresSearchIndexWriter;
18
19impl PostgresSearchIndexWriter {
20    /// Writes a single search index entry to PostgreSQL.
21    ///
22    /// Accepts any type that can be dereferenced to a `tokio_postgres::Client`,
23    /// including `deadpool_postgres::Client` and `&deadpool_postgres::Client`.
24    pub async fn write_entry(
25        client: &deadpool_postgres::Client,
26        tenant_id: &str,
27        resource_type: &str,
28        resource_id: &str,
29        extracted: &ExtractedValue,
30    ) -> StorageResult<()> {
31        match &extracted.value {
32            IndexValue::String(s) => {
33                client
34                    .execute(
35                        "INSERT INTO search_index (
36                            tenant_id, resource_type, resource_id, param_name, param_url,
37                            value_string, composite_group
38                        ) VALUES ($1, $2, $3, $4, $5, $6, $7)",
39                        &[
40                            &tenant_id,
41                            &resource_type,
42                            &resource_id,
43                            &extracted.param_name.as_str(),
44                            &extracted.param_url.as_str(),
45                            &Some(s.as_str()),
46                            &extracted.composite_group.map(|g| g as i32),
47                        ],
48                    )
49                    .await
50                    .map_err(|e| {
51                        internal_error(format!("Failed to insert string search index entry: {}", e))
52                    })?;
53            }
54            IndexValue::Token {
55                system,
56                code,
57                display,
58                identifier_type_system,
59                identifier_type_code,
60            } => {
61                client
62                    .execute(
63                        "INSERT INTO search_index (
64                            tenant_id, resource_type, resource_id, param_name, param_url,
65                            value_token_system, value_token_code, value_token_display,
66                            composite_group, value_identifier_type_system, value_identifier_type_code
67                        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
68                        &[
69                            &tenant_id,
70                            &resource_type,
71                            &resource_id,
72                            &extracted.param_name.as_str(),
73                            &extracted.param_url.as_str(),
74                            &system.as_deref(),
75                            &code.as_str(),
76                            &display.as_deref(),
77                            &extracted.composite_group.map(|g| g as i32),
78                            &identifier_type_system.as_deref(),
79                            &identifier_type_code.as_deref(),
80                        ],
81                    )
82                    .await
83                    .map_err(|e| {
84                        internal_error(format!("Failed to insert token search index entry: {}", e))
85                    })?;
86            }
87            IndexValue::Date { value, precision } => {
88                let precision_str = precision.to_string();
89                let normalized = normalize_date_for_pg(value);
90                let timestamp: DateTime<Utc> = DateTime::parse_from_rfc3339(&normalized)
91                    .map(|dt| dt.with_timezone(&Utc))
92                    .or_else(|_| normalized.parse::<DateTime<Utc>>())
93                    .unwrap_or_else(|_| Utc::now());
94                client
95                    .execute(
96                        "INSERT INTO search_index (
97                            tenant_id, resource_type, resource_id, param_name, param_url,
98                            value_date, value_date_precision, composite_group
99                        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
100                        &[
101                            &tenant_id,
102                            &resource_type,
103                            &resource_id,
104                            &extracted.param_name.as_str(),
105                            &extracted.param_url.as_str(),
106                            &timestamp,
107                            &precision_str.as_str(),
108                            &extracted.composite_group.map(|g| g as i32),
109                        ],
110                    )
111                    .await
112                    .map_err(|e| {
113                        internal_error(format!("Failed to insert date search index entry: {}", e))
114                    })?;
115            }
116            IndexValue::Number(n) => {
117                client
118                    .execute(
119                        "INSERT INTO search_index (
120                            tenant_id, resource_type, resource_id, param_name, param_url,
121                            value_number, composite_group
122                        ) VALUES ($1, $2, $3, $4, $5, $6, $7)",
123                        &[
124                            &tenant_id,
125                            &resource_type,
126                            &resource_id,
127                            &extracted.param_name.as_str(),
128                            &extracted.param_url.as_str(),
129                            n,
130                            &extracted.composite_group.map(|g| g as i32),
131                        ],
132                    )
133                    .await
134                    .map_err(|e| {
135                        internal_error(format!("Failed to insert number search index entry: {}", e))
136                    })?;
137            }
138            IndexValue::Quantity {
139                value,
140                unit,
141                system,
142                code: _,
143            } => {
144                client
145                    .execute(
146                        "INSERT INTO search_index (
147                            tenant_id, resource_type, resource_id, param_name, param_url,
148                            value_quantity_value, value_quantity_unit, value_quantity_system, composite_group
149                        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
150                        &[
151                            &tenant_id,
152                            &resource_type,
153                            &resource_id,
154                            &extracted.param_name.as_str(),
155                            &extracted.param_url.as_str(),
156                            value,
157                            &unit.as_deref(),
158                            &system.as_deref(),
159                            &extracted.composite_group.map(|g| g as i32),
160                        ],
161                    )
162                    .await
163                    .map_err(|e| {
164                        internal_error(format!(
165                            "Failed to insert quantity search index entry: {}",
166                            e
167                        ))
168                    })?;
169            }
170            IndexValue::Reference {
171                reference,
172                resource_type: _,
173                resource_id: _,
174            } => {
175                client
176                    .execute(
177                        "INSERT INTO search_index (
178                            tenant_id, resource_type, resource_id, param_name, param_url,
179                            value_reference, composite_group
180                        ) VALUES ($1, $2, $3, $4, $5, $6, $7)",
181                        &[
182                            &tenant_id,
183                            &resource_type,
184                            &resource_id,
185                            &extracted.param_name.as_str(),
186                            &extracted.param_url.as_str(),
187                            &reference.as_str(),
188                            &extracted.composite_group.map(|g| g as i32),
189                        ],
190                    )
191                    .await
192                    .map_err(|e| {
193                        internal_error(format!(
194                            "Failed to insert reference search index entry: {}",
195                            e
196                        ))
197                    })?;
198            }
199            IndexValue::Uri(uri) => {
200                client
201                    .execute(
202                        "INSERT INTO search_index (
203                            tenant_id, resource_type, resource_id, param_name, param_url,
204                            value_uri, composite_group
205                        ) VALUES ($1, $2, $3, $4, $5, $6, $7)",
206                        &[
207                            &tenant_id,
208                            &resource_type,
209                            &resource_id,
210                            &extracted.param_name.as_str(),
211                            &extracted.param_url.as_str(),
212                            &uri.as_str(),
213                            &extracted.composite_group.map(|g| g as i32),
214                        ],
215                    )
216                    .await
217                    .map_err(|e| {
218                        internal_error(format!("Failed to insert URI search index entry: {}", e))
219                    })?;
220            }
221        }
222
223        Ok(())
224    }
225}
226
227/// Normalize a date string for PostgreSQL TIMESTAMPTZ.
228///
229/// Converts partial dates to full timestamps:
230/// - "2024" -> "2024-01-01T00:00:00+00:00"
231/// - "2024-01" -> "2024-01-01T00:00:00+00:00"
232/// - "2024-01-15" -> "2024-01-15T00:00:00+00:00"
233/// - "2024-01-15T10:30:00" -> "2024-01-15T10:30:00+00:00"
234fn normalize_date_for_pg(value: &str) -> String {
235    if value.contains('T') {
236        // Already has time component - ensure timezone
237        if value.contains('+') || value.contains('Z') || value.ends_with("-00:00") {
238            value.to_string()
239        } else {
240            format!("{}+00:00", value)
241        }
242    } else if value.len() == 10 {
243        // YYYY-MM-DD
244        format!("{}T00:00:00+00:00", value)
245    } else if value.len() == 7 {
246        // YYYY-MM
247        format!("{}-01T00:00:00+00:00", value)
248    } else if value.len() == 4 {
249        // YYYY
250        format!("{}-01-01T00:00:00+00:00", value)
251    } else {
252        // Best effort
253        value.to_string()
254    }
255}