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;