Skip to main content

kernel/config/
flusso_type.rs

1use serde::{Deserialize, Serialize};
2
3use super::MappingType;
4
5/// The declared type of a leaf field — the single vocabulary that bridges a
6/// Postgres column type and an OpenSearch mapping type.
7///
8/// A self-describing schema names one of these per scalar field, so the document
9/// shape (and the index mapping) is known without ever touching the database. A
10/// variant pins both ends: which Postgres types satisfy it (for validation, when
11/// a database is reachable) and which OpenSearch [`MappingType`] it emits. That
12/// is what lets the two disagree on purpose — an `Enum` is stored as text in
13/// Postgres but must be a `keyword` in OpenSearch.
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum FlussoType {
17    /// Analyzed natural-language full text — descriptions, bios, comments. The
18    /// default analyzed type; carries the `flusso_text` analyzer (plain tokenize
19    /// + accent/case fold). PG `text` / `varchar` → OS `text`.
20    Text,
21    /// Identifier-like short text — names, SKUs, codes, statuses. Analyzed with
22    /// the `flusso_code` analyzer, which splits on punctuation/case so `C-01234`
23    /// is found by `c01234` or `01234`. PG `text` / `varchar` → OS `text`.
24    Identifier,
25    /// Exact, aggregatable token. PG `text` / `varchar` → OS `keyword`.
26    Keyword,
27    /// A closed set of string values — stored as text in Postgres (a `varchar`
28    /// or a PG `enum`), indexed exactly as a `keyword`. An optional declared
29    /// variant order (on the field's [`Column`](super::Column), not here) makes
30    /// it sort by that order rather than alphabetically.
31    Enum,
32    /// PG `uuid` → OS `keyword`.
33    Uuid,
34    /// PG `boolean` → OS `boolean`.
35    Boolean,
36    /// PG `smallint` / `int2` → OS `short`.
37    Short,
38    /// PG `integer` / `int4` → OS `integer`.
39    Integer,
40    /// PG `bigint` / `int8` → OS `long`.
41    Long,
42    /// PG `real` / `float4` → OS `float`.
43    Float,
44    /// PG `double precision` / `float8` → OS `double`.
45    Double,
46    /// PG `numeric` / `decimal` / `money` → OS `double` (lossy but searchable;
47    /// declare [`Custom`](Self::Custom) `scaled_float` when exactness matters).
48    Decimal,
49    /// PG `date` → OS `date`.
50    Date,
51    /// PG `timestamp` / `timestamptz` / `time` → OS `date`.
52    Timestamp,
53    /// PG `bytea` → OS `binary`.
54    Binary,
55    /// PG `json` / `jsonb` → OS `object`.
56    Json,
57    /// A dynamic-key object: a `json`/`jsonb` column whose keys are
58    /// runtime-determined but whose values all share one leaf type (e.g.
59    /// translations `{"en": …, "it": …}`). Maps to OS `object` with
60    /// `dynamic: true`, so unmapped keys stay full-text searchable without
61    /// enumerating them in the schema. `values` is the leaf type of every value.
62    Map { values: Box<FlussoType> },
63    /// A geographic point → OS `geo_point`.
64    ///
65    /// The document is assembled server-side as JSON, so the source column must
66    /// already hold a value OpenSearch accepts as a `geo_point` and that carries
67    /// through JSON verbatim: `json`/`jsonb` shaped as `{"lat": …, "lon": …}` or
68    /// `[lon, lat]`, or `text` as `"lat,lon"`. A PostGIS `geometry` or PG-native
69    /// `point` is **not** accepted — it would serialize as WKB / `(x,y)`; expose
70    /// a generated `jsonb`/`text` column in that shape instead.
71    GeoPoint,
72    /// An escape hatch: an explicit OpenSearch type with the Postgres types it
73    /// accepts, for anything the named variants don't cover (`geo_shape`,
74    /// `scaled_float`, …).
75    Custom {
76        postgres: Vec<String>,
77        opensearch: String,
78    },
79}
80
81impl FlussoType {
82    /// The OpenSearch [`MappingType`] this declared type maps to.
83    pub fn opensearch(&self) -> MappingType {
84        match self {
85            FlussoType::Text | FlussoType::Identifier => MappingType::Text,
86            FlussoType::Keyword | FlussoType::Enum | FlussoType::Uuid => MappingType::Keyword,
87            FlussoType::Boolean => MappingType::Boolean,
88            FlussoType::Short => MappingType::Short,
89            FlussoType::Integer => MappingType::Integer,
90            FlussoType::Long => MappingType::Long,
91            FlussoType::Float => MappingType::Float,
92            FlussoType::Double | FlussoType::Decimal => MappingType::Double,
93            FlussoType::Date | FlussoType::Timestamp => MappingType::Date,
94            FlussoType::Binary => MappingType::Other("binary".to_owned()),
95            FlussoType::Json | FlussoType::Map { .. } => MappingType::Object,
96            FlussoType::GeoPoint => MappingType::Other("geo_point".to_owned()),
97            FlussoType::Custom { opensearch, .. } => MappingType::from_name(opensearch),
98        }
99    }
100
101    /// Whether `sql_type` (a Postgres type name as `format_type` reports it, e.g.
102    /// `bigint`, `character varying(255)`, `numeric(10,2)`, `integer[]`) is a
103    /// Postgres type this declared type accepts. Used to validate a declared
104    /// schema against a live database; with no database, it is never consulted.
105    pub fn accepts_pg(&self, sql_type: &str) -> bool {
106        let base = normalize_pg_type(sql_type);
107        match self {
108            FlussoType::Text | FlussoType::Identifier | FlussoType::Keyword | FlussoType::Enum => {
109                matches!(
110                    base.as_str(),
111                    "text"
112                        | "character varying"
113                        | "varchar"
114                        | "character"
115                        | "char"
116                        | "bpchar"
117                        | "citext"
118                        | "name"
119                )
120            }
121            FlussoType::Uuid => base == "uuid",
122            FlussoType::Boolean => matches!(base.as_str(), "boolean" | "bool"),
123            FlussoType::Short => matches!(base.as_str(), "smallint" | "int2" | "smallserial"),
124            FlussoType::Integer => matches!(base.as_str(), "integer" | "int" | "int4" | "serial"),
125            FlussoType::Long => matches!(base.as_str(), "bigint" | "int8" | "bigserial"),
126            FlussoType::Float => matches!(base.as_str(), "real" | "float4"),
127            FlussoType::Double => matches!(base.as_str(), "double precision" | "float8"),
128            FlussoType::Decimal => matches!(base.as_str(), "numeric" | "decimal" | "money"),
129            FlussoType::Date => base == "date",
130            FlussoType::Timestamp => matches!(
131                base.as_str(),
132                "timestamp with time zone"
133                    | "timestamp without time zone"
134                    | "timestamp"
135                    | "timestamptz"
136                    | "time with time zone"
137                    | "time without time zone"
138                    | "time"
139                    | "timetz"
140            ),
141            FlussoType::Binary => base == "bytea",
142            FlussoType::Json | FlussoType::Map { .. } => {
143                matches!(base.as_str(), "json" | "jsonb")
144            }
145            // Geo data must reach OpenSearch as JSON `{lat,lon}` / `[lon,lat]` or
146            // a `"lat,lon"` string — i.e. it lives in a json/jsonb or text-like
147            // column. PostGIS `geometry` / PG `point` are intentionally rejected.
148            FlussoType::GeoPoint => matches!(
149                base.as_str(),
150                "json"
151                    | "jsonb"
152                    | "text"
153                    | "character varying"
154                    | "varchar"
155                    | "character"
156                    | "char"
157                    | "bpchar"
158                    | "citext"
159                    | "name"
160            ),
161            FlussoType::Custom { postgres, .. } => {
162                postgres.iter().any(|t| normalize_pg_type(t) == base)
163            }
164        }
165    }
166}
167
168/// Normalize a `format_type` Postgres type name to its bare base name: drop an
169/// array `[]` suffix (OpenSearch fields are natively multi-valued) and any
170/// `(precision)` / `(length)` modifier, then lowercase. Mirrors the
171/// normalization the Postgres source uses when reading a column's type.
172fn normalize_pg_type(sql_type: &str) -> String {
173    let base = sql_type.trim().trim_end_matches("[]").trim();
174    let base = base.split('(').next().unwrap_or(base).trim();
175    base.to_ascii_lowercase()
176}
177
178#[cfg(test)]
179mod tests;