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;