Skip to main content

kernel/config/
flusso_type.rs

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