schema_core/config/field.rs
1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::common;
6
7use super::{Aggregate, AggregateKey, Filter, FlussoType, Join, JoinKind, Through, Transform};
8
9/// One field of a document: a name, optional OpenSearch mapping `options` passed
10/// through to the index, and a [`source`](Self::source) saying where its value
11/// comes from. A leaf field's *type* is declared on its source (a
12/// [`Column`]'s [`ty`](Column::ty), an [`Aggregate`]'s
13/// [`value_type`](Aggregate::value_type)) so the document shape is known without
14/// a database.
15#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
16pub struct Field {
17 pub field: common::FieldName,
18 /// Extra OpenSearch mapping properties merged beside the derived `type`
19 /// (e.g. `analyzer`, `scaling_factor`). Empty for most fields.
20 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
21 pub options: BTreeMap<String, common::GenericValue>,
22 pub source: FieldSource,
23}
24
25/// Where a field's value comes from. The shapes are mutually exclusive — a field
26/// is exactly one of them — which is why this is an enum rather than a bag of
27/// optional `column` / `relation` / `fields` that can contradict each other.
28#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum FieldSource {
31 /// A column of the current row, optionally transformed, with an optional
32 /// fallback when the column is null.
33 Column(Column),
34 /// A sub-object assembled from sibling fields of the *same* row (it adds a
35 /// nesting level in the document without reading a related table).
36 Group(Vec<Field>),
37 /// A geographic point assembled from two same-row columns
38 /// ([`lat`](Geo::lat)/[`lon`](Geo::lon)) into an OpenSearch `geo_point`.
39 Geo(Geo),
40 /// Data drawn from a related table — folded in as nested documents
41 /// ([`Join`](Relation::Join)) or reduced to a single value
42 /// ([`Aggregate`](Relation::Aggregate)).
43 Relation(Relation),
44 /// A constant value with no database source — `None` renders as null.
45 Constant(common::GenericValue),
46}
47
48/// A column-backed field: the column to read, its declared type and nullability,
49/// the transforms to apply, and a default to coalesce nulls to.
50#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
51pub struct Column {
52 pub column: common::ColumnName,
53 /// The declared type — the OpenSearch mapping derives from it, and a live
54 /// database (when reachable) is checked against it.
55 pub ty: FlussoType,
56 /// Whether the column admits null. The resolver still forces non-null for a
57 /// primary key or a column with a `default`.
58 pub nullable: bool,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub transforms: Vec<Transform>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub default: Option<common::GenericValue>,
63 /// For an [`Enum`](FlussoType::Enum) column, its variants in rank order;
64 /// empty for a bare enum or any non-enum column. When non-empty the field
65 /// sorts by this order instead of alphabetically — the OpenSearch sink
66 /// prebakes the rank into a `.sort` subfield. Belongs on the column, not the
67 /// [`FlussoType`], because it is a property of this specific field.
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub enum_order: Vec<String>,
70}
71
72/// A geographic point built from two same-row columns. Resolves to an
73/// OpenSearch `geo_point`; the document carries `{ "lat": …, "lon": … }`, or
74/// SQL `NULL` when either column is null (so a nullable point is absent rather
75/// than `{lat: null, lon: null}`, which OpenSearch would reject).
76#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
77pub struct Geo {
78 /// The latitude column (degrees).
79 pub lat: common::ColumnName,
80 /// The longitude column (degrees).
81 pub lon: common::ColumnName,
82 /// Whether the point may be absent — true unless the field is `required`.
83 pub nullable: bool,
84}
85
86/// How a field draws on a related table: either folding its rows in as nested
87/// documents ([`Join`](Self::Join)) or reducing them to a single value
88/// ([`Aggregate`](Self::Aggregate)).
89#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Relation {
92 /// Fold the related rows in as nested documents, projecting `fields` from
93 /// each one.
94 Join(Join),
95 /// Reduce the related rows to a single scalar.
96 Aggregate(Aggregate),
97}
98
99impl Field {
100 /// The fields nested directly under this one: a [`Group`](FieldSource::Group)'s
101 /// members or a [`Join`](Relation::Join)'s projection. Columns, aggregates,
102 /// and constants have none.
103 pub fn children(&self) -> &[Field] {
104 match &self.source {
105 FieldSource::Group(fields) => fields,
106 FieldSource::Relation(Relation::Join(join)) => &join.fields,
107 FieldSource::Column(_)
108 | FieldSource::Geo(_)
109 | FieldSource::Relation(Relation::Aggregate(_))
110 | FieldSource::Constant(_) => &[],
111 }
112 }
113
114 pub fn relation(&self) -> Option<&Relation> {
115 match &self.source {
116 FieldSource::Relation(relation) => Some(relation),
117 _ => None,
118 }
119 }
120
121 /// The column this field reads, if it is a plain column field.
122 pub fn column(&self) -> Option<&common::ColumnName> {
123 match &self.source {
124 FieldSource::Column(column) => Some(&column.column),
125 _ => None,
126 }
127 }
128}
129
130/// A relation's key, viewed uniformly across joins and aggregates — the three
131/// physical shapes a "these tables connect" fact can take. Traversal code
132/// (document SQL, reverse resolution) matches on this instead of caring whether
133/// the relation is a join or an aggregate.
134#[derive(Debug, Clone, Copy)]
135pub enum RelationKey<'a> {
136 /// The **parent** row holds the key: `parent.column → target.primary_key`
137 /// (a `belongs_to`).
138 Local(&'a common::ColumnName),
139 /// The **related** rows hold the key: `target.foreign_key → parent.pk`
140 /// (a `has_one`/`has_many`, or a direct-keyed aggregate).
141 Direct(&'a common::ColumnName),
142 /// Both sides connect through a junction table.
143 Through(&'a Through),
144}
145
146impl Relation {
147 pub fn table(&self) -> &common::TableName {
148 match self {
149 Relation::Join(join) => &join.table,
150 Relation::Aggregate(aggregate) => &aggregate.table,
151 }
152 }
153
154 /// The key tying the related rows and the parent row together.
155 pub fn key(&self) -> RelationKey<'_> {
156 match self {
157 Relation::Join(join) => match &join.kind {
158 JoinKind::BelongsTo { column } => RelationKey::Local(column),
159 JoinKind::HasOne { foreign_key } | JoinKind::HasMany { foreign_key } => {
160 RelationKey::Direct(foreign_key)
161 }
162 JoinKind::ManyToMany { through } => RelationKey::Through(through),
163 },
164 Relation::Aggregate(aggregate) => match &aggregate.key {
165 AggregateKey::Direct(foreign_key) => RelationKey::Direct(foreign_key),
166 AggregateKey::Through(through) => RelationKey::Through(through),
167 },
168 }
169 }
170
171 /// Filters narrowing the related rows, if any.
172 pub fn filters(&self) -> Option<&[Filter]> {
173 match self {
174 Relation::Join(join) => join.filters.as_deref(),
175 Relation::Aggregate(aggregate) => aggregate.filters.as_deref(),
176 }
177 }
178}
179
180/// OpenSearch mapping. `mapping_type` is required; all other properties are passed through as-is.
181#[derive(Debug, Clone, Hash)]
182pub struct Mapping {
183 pub mapping_type: MappingType,
184 pub extra: BTreeMap<String, common::GenericValue>,
185 /// For a `map` field (a dynamic-key object), the mapping type of every
186 /// value; `None` for every other field. Internal metadata only — it is
187 /// **not** serialized into the index body (a map carries just
188 /// `{"type":"object","dynamic":true}`, the latter via `extra`). It exists so
189 /// a consumer turning the mapping into typed bindings can tell a `map` from a
190 /// plain `object` and offer a value-kind-typed handle.
191 pub map_values: Option<MappingType>,
192 /// Whether this numeric field came from a [`FlussoType::Decimal`] — a PG
193 /// `numeric`/`decimal`. It maps to OpenSearch `double` like a true `double`,
194 /// so [`mapping_type`](Self::mapping_type) alone can't tell them apart.
195 /// Internal metadata only — **not** serialized into the index body. It lets a
196 /// consumer turning the mapping into typed bindings offer a `Decimal`-kind
197 /// handle (exact) instead of an `f64`-kind one.
198 ///
199 /// [`FlussoType::Decimal`]: super::FlussoType::Decimal
200 pub decimal: bool,
201 /// For an [`Enum`](super::FlussoType::Enum) field with a declared order, its
202 /// variants in rank order; `None` for a bare enum or any other field. The
203 /// OpenSearch sink turns it into a `.sort` subfield (a per-field normalizer
204 /// remapping each variant to a zero-padded rank) so the field sorts by
205 /// declared order rather than alphabetically; a consumer building typed
206 /// bindings uses it to offer an order-aware sort handle. Internal metadata —
207 /// the enum itself is still serialized as a plain `keyword`.
208 pub enum_order: Option<Vec<String>>,
209}
210
211/// Serializes the way OpenSearch expects a field mapping — `{ "type": …, …extra }`
212/// — rather than the struct's two named fields. The `extra` settings sit beside
213/// `type`, exactly as they would in the index body.
214impl Serialize for Mapping {
215 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
216 use serde::ser::SerializeMap;
217 let mut map = serializer.serialize_map(Some(1 + self.extra.len()))?;
218 map.serialize_entry("type", &self.mapping_type)?;
219 for (key, value) in &self.extra {
220 map.serialize_entry(key, value)?;
221 }
222 map.end()
223 }
224}
225
226#[derive(Debug, Clone, Hash, PartialEq, Eq)]
227pub enum MappingType {
228 Text,
229 Keyword,
230 Boolean,
231 Byte,
232 Short,
233 Integer,
234 Long,
235 Float,
236 Double,
237 HalfFloat,
238 ScaledFloat,
239 Date,
240 Object,
241 Nested,
242 /// Any mapping type not covered above.
243 Other(String),
244}
245
246impl MappingType {
247 /// The OpenSearch type name (`keyword`, `half_float`, …). An [`Other`] type
248 /// is its own verbatim name.
249 ///
250 /// [`Other`]: MappingType::Other
251 pub fn name(&self) -> &str {
252 match self {
253 MappingType::Text => "text",
254 MappingType::Keyword => "keyword",
255 MappingType::Boolean => "boolean",
256 MappingType::Byte => "byte",
257 MappingType::Short => "short",
258 MappingType::Integer => "integer",
259 MappingType::Long => "long",
260 MappingType::Float => "float",
261 MappingType::Double => "double",
262 MappingType::HalfFloat => "half_float",
263 MappingType::ScaledFloat => "scaled_float",
264 MappingType::Date => "date",
265 MappingType::Object => "object",
266 MappingType::Nested => "nested",
267 MappingType::Other(name) => name,
268 }
269 }
270
271 /// The mapping type for an OpenSearch type name — the inverse of
272 /// [`name`](Self::name). An unrecognized name becomes [`Other`].
273 ///
274 /// [`Other`]: MappingType::Other
275 pub fn from_name(name: &str) -> MappingType {
276 match name {
277 "text" => MappingType::Text,
278 "keyword" => MappingType::Keyword,
279 "boolean" => MappingType::Boolean,
280 "byte" => MappingType::Byte,
281 "short" => MappingType::Short,
282 "integer" => MappingType::Integer,
283 "long" => MappingType::Long,
284 "float" => MappingType::Float,
285 "double" => MappingType::Double,
286 "half_float" => MappingType::HalfFloat,
287 "scaled_float" => MappingType::ScaledFloat,
288 "date" => MappingType::Date,
289 "object" => MappingType::Object,
290 "nested" => MappingType::Nested,
291 other => MappingType::Other(other.to_owned()),
292 }
293 }
294}
295
296/// Serializes as the bare type name (`"keyword"`).
297/// Used instead of serde with inner at other because this code will not fail
298/// So having the name function will keep a single point of failure.
299impl Serialize for MappingType {
300 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
301 serializer.serialize_str(self.name())
302 }
303}