Skip to main content

octofhir_sof/
column.rs

1//! Column type definitions for SQL on FHIR views.
2//!
3//! This module defines the types used to represent column information
4//! in the generated SQL and result sets.
5
6use serde::{Deserialize, Serialize};
7
8/// Information about a column in a view result.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ColumnInfo {
11    /// The column name.
12    pub name: String,
13
14    /// The column's data type.
15    pub col_type: ColumnType,
16
17    /// Whether this column can contain null values.
18    pub nullable: bool,
19
20    /// Human-readable description of the column.
21    pub description: Option<String>,
22}
23
24impl ColumnInfo {
25    /// Create a new column info with default settings.
26    pub fn new(name: impl Into<String>, col_type: ColumnType) -> Self {
27        Self {
28            name: name.into(),
29            col_type,
30            nullable: true,
31            description: None,
32        }
33    }
34
35    /// Set whether this column is nullable.
36    pub fn with_nullable(mut self, nullable: bool) -> Self {
37        self.nullable = nullable;
38        self
39    }
40
41    /// Set the column description.
42    pub fn with_description(mut self, description: impl Into<String>) -> Self {
43        self.description = Some(description.into());
44        self
45    }
46}
47
48/// Data types supported by SQL on FHIR columns.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum ColumnType {
52    /// String/text values.
53    #[default]
54    String,
55
56    /// Integer values (FHIR `integer`, `positiveInt`, `unsignedInt`).
57    Integer,
58
59    /// 64-bit integer values (FHIR `integer64`).
60    Integer64,
61
62    /// Decimal/floating-point values.
63    Decimal,
64
65    /// Boolean values.
66    Boolean,
67
68    /// Date values (YYYY-MM-DD).
69    Date,
70
71    /// DateTime values (ISO 8601).
72    DateTime,
73
74    /// Instant values (precise timestamp).
75    Instant,
76
77    /// Time values (HH:MM:SS).
78    Time,
79
80    /// Base64 encoded binary data.
81    Base64Binary,
82
83    /// JSON/complex object (when collection=true or complex type).
84    Json,
85}
86
87impl ColumnType {
88    /// Parse a column type from a string.
89    ///
90    /// Returns `String` as the default type for unknown values.
91    pub fn from_fhir_type(type_str: &str) -> Self {
92        match type_str.to_lowercase().as_str() {
93            "string" | "code" | "uri" | "url" | "canonical" | "id" | "oid" | "uuid"
94            | "markdown" => Self::String,
95            "integer" | "positiveint" | "unsignedint" => Self::Integer,
96            "integer64" => Self::Integer64,
97            "decimal" => Self::Decimal,
98            "boolean" => Self::Boolean,
99            "date" => Self::Date,
100            "datetime" => Self::DateTime,
101            "instant" => Self::Instant,
102            "time" => Self::Time,
103            "base64binary" => Self::Base64Binary,
104            _ => Self::String, // Default to string for unknown types
105        }
106    }
107
108    /// Parse a column type from an ANSI SQL type name, as supplied by an
109    /// `ansi/type` column tag overriding the inferred type. Unknown names fall
110    /// back to `String`.
111    pub fn from_ansi_type(type_str: &str) -> Self {
112        let t = type_str.trim().to_lowercase();
113        match t.as_str() {
114            "int" | "integer" | "smallint" => Self::Integer,
115            "bigint" => Self::Integer64,
116            "decimal" | "numeric" | "real" | "double precision" | "float" => Self::Decimal,
117            "boolean" | "bool" => Self::Boolean,
118            "timestamp with time zone" | "timestamptz" => Self::Instant,
119            "binary" | "varbinary" | "bytea" => Self::Base64Binary,
120            "json" | "jsonb" => Self::Json,
121            _ => Self::String,
122        }
123    }
124
125    /// Get the PostgreSQL type name for this column type. Used for the runtime
126    /// value cast/decode; this is a Postgres dialect, not the ANSI default.
127    pub fn sql_type(&self) -> &'static str {
128        match self {
129            Self::String => "TEXT",
130            Self::Integer => "INTEGER",
131            Self::Integer64 => "BIGINT",
132            Self::Decimal => "NUMERIC",
133            Self::Boolean => "BOOLEAN",
134            Self::Date => "DATE",
135            Self::DateTime | Self::Instant => "TIMESTAMPTZ",
136            Self::Time => "TIME",
137            Self::Base64Binary => "BYTEA",
138            Self::Json => "JSONB",
139        }
140    }
141
142    /// The default ANSI SQL (ISO/IEC 9075) type per the SQL-on-FHIR v2 spec's
143    /// type-mapping table. Temporal and decimal types map to `CHARACTER VARYING`
144    /// to preserve the FHIR string representation; `instant` keeps timezone.
145    /// <https://build.fhir.org/ig/FHIR/sql-on-fhir-v2/StructureDefinition-ViewDefinition-notes.html>
146    pub fn ansi_type(&self) -> &'static str {
147        match self {
148            // string, code, uri, url, canonical, id, oid, uuid, markdown,
149            // date, dateTime, decimal, time
150            Self::String | Self::Decimal | Self::Date | Self::DateTime | Self::Time => {
151                "CHARACTER VARYING"
152            }
153            Self::Integer => "INT",
154            Self::Integer64 => "BIGINT",
155            Self::Boolean => "BOOLEAN",
156            Self::Instant => "TIMESTAMP WITH TIME ZONE",
157            Self::Base64Binary => "BINARY",
158            // JSON has no ANSI equivalent; collection columns are emitted as JSON.
159            Self::Json => "JSON",
160        }
161    }
162
163    /// Get the default value representation for null values.
164    pub fn null_representation(&self) -> &'static str {
165        "NULL"
166    }
167}
168
169impl std::fmt::Display for ColumnType {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            Self::String => write!(f, "string"),
173            Self::Integer => write!(f, "integer"),
174            Self::Integer64 => write!(f, "integer64"),
175            Self::Decimal => write!(f, "decimal"),
176            Self::Boolean => write!(f, "boolean"),
177            Self::Date => write!(f, "date"),
178            Self::DateTime => write!(f, "dateTime"),
179            Self::Instant => write!(f, "instant"),
180            Self::Time => write!(f, "time"),
181            Self::Base64Binary => write!(f, "base64Binary"),
182            Self::Json => write!(f, "json"),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_column_type_from_fhir_type() {
193        assert_eq!(ColumnType::from_fhir_type("string"), ColumnType::String);
194        assert_eq!(ColumnType::from_fhir_type("code"), ColumnType::String);
195        assert_eq!(ColumnType::from_fhir_type("integer"), ColumnType::Integer);
196        assert_eq!(
197            ColumnType::from_fhir_type("positiveInt"),
198            ColumnType::Integer
199        );
200        assert_eq!(ColumnType::from_fhir_type("decimal"), ColumnType::Decimal);
201        assert_eq!(ColumnType::from_fhir_type("boolean"), ColumnType::Boolean);
202        assert_eq!(ColumnType::from_fhir_type("date"), ColumnType::Date);
203        assert_eq!(ColumnType::from_fhir_type("dateTime"), ColumnType::DateTime);
204        assert_eq!(ColumnType::from_fhir_type("instant"), ColumnType::Instant);
205        assert_eq!(ColumnType::from_fhir_type("time"), ColumnType::Time);
206        assert_eq!(
207            ColumnType::from_fhir_type("base64Binary"),
208            ColumnType::Base64Binary
209        );
210        // Unknown types default to string
211        assert_eq!(
212            ColumnType::from_fhir_type("UnknownType"),
213            ColumnType::String
214        );
215    }
216
217    #[test]
218    fn test_column_type_sql_type() {
219        assert_eq!(ColumnType::String.sql_type(), "TEXT");
220        assert_eq!(ColumnType::Integer.sql_type(), "INTEGER");
221        assert_eq!(ColumnType::Integer64.sql_type(), "BIGINT");
222        assert_eq!(ColumnType::Decimal.sql_type(), "NUMERIC");
223        assert_eq!(ColumnType::Boolean.sql_type(), "BOOLEAN");
224        assert_eq!(ColumnType::Date.sql_type(), "DATE");
225        assert_eq!(ColumnType::DateTime.sql_type(), "TIMESTAMPTZ");
226        assert_eq!(ColumnType::Json.sql_type(), "JSONB");
227    }
228
229    #[test]
230    fn test_column_type_ansi_type() {
231        // Spec default ANSI mapping: temporal/decimal stay CHARACTER VARYING.
232        assert_eq!(ColumnType::String.ansi_type(), "CHARACTER VARYING");
233        assert_eq!(ColumnType::Decimal.ansi_type(), "CHARACTER VARYING");
234        assert_eq!(ColumnType::Date.ansi_type(), "CHARACTER VARYING");
235        assert_eq!(ColumnType::DateTime.ansi_type(), "CHARACTER VARYING");
236        assert_eq!(ColumnType::Time.ansi_type(), "CHARACTER VARYING");
237        assert_eq!(ColumnType::Integer.ansi_type(), "INT");
238        assert_eq!(ColumnType::Integer64.ansi_type(), "BIGINT");
239        assert_eq!(ColumnType::Boolean.ansi_type(), "BOOLEAN");
240        assert_eq!(ColumnType::Instant.ansi_type(), "TIMESTAMP WITH TIME ZONE");
241        assert_eq!(ColumnType::Base64Binary.ansi_type(), "BINARY");
242    }
243
244    #[test]
245    fn test_integer64_maps_to_bigint() {
246        assert_eq!(
247            ColumnType::from_fhir_type("integer64"),
248            ColumnType::Integer64
249        );
250        assert_eq!(ColumnType::from_fhir_type("integer"), ColumnType::Integer);
251        assert_eq!(ColumnType::from_ansi_type("BIGINT"), ColumnType::Integer64);
252        assert_eq!(ColumnType::from_ansi_type("INTEGER"), ColumnType::Integer);
253    }
254
255    #[test]
256    fn test_column_info_builder() {
257        let col = ColumnInfo::new("test_col", ColumnType::String)
258            .with_nullable(false)
259            .with_description("A test column");
260
261        assert_eq!(col.name, "test_col");
262        assert_eq!(col.col_type, ColumnType::String);
263        assert!(!col.nullable);
264        assert_eq!(col.description, Some("A test column".to_string()));
265    }
266}