Skip to main content

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}
64
65/// A geographic point built from two same-row columns. Resolves to an
66/// OpenSearch `geo_point`; the document carries `{ "lat": …, "lon": … }`, or
67/// SQL `NULL` when either column is null (so a nullable point is absent rather
68/// than `{lat: null, lon: null}`, which OpenSearch would reject).
69#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
70pub struct Geo {
71    /// The latitude column (degrees).
72    pub lat: common::ColumnName,
73    /// The longitude column (degrees).
74    pub lon: common::ColumnName,
75    /// Whether the point may be absent — true unless the field is `required`.
76    pub nullable: bool,
77}
78
79/// How a field draws on a related table: either folding its rows in as nested
80/// documents ([`Join`](Self::Join)) or reducing them to a single value
81/// ([`Aggregate`](Self::Aggregate)).
82#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Relation {
85    /// Fold the related rows in as nested documents, projecting `fields` from
86    /// each one.
87    Join(Join),
88    /// Reduce the related rows to a single scalar.
89    Aggregate(Aggregate),
90}
91
92impl Field {
93    /// The fields nested directly under this one: a [`Group`](FieldSource::Group)'s
94    /// members or a [`Join`](Relation::Join)'s projection. Columns, aggregates,
95    /// and constants have none.
96    pub fn children(&self) -> &[Field] {
97        match &self.source {
98            FieldSource::Group(fields) => fields,
99            FieldSource::Relation(Relation::Join(join)) => &join.fields,
100            FieldSource::Column(_)
101            | FieldSource::Geo(_)
102            | FieldSource::Relation(Relation::Aggregate(_))
103            | FieldSource::Constant(_) => &[],
104        }
105    }
106
107    pub fn relation(&self) -> Option<&Relation> {
108        match &self.source {
109            FieldSource::Relation(relation) => Some(relation),
110            _ => None,
111        }
112    }
113
114    /// The column this field reads, if it is a plain column field.
115    pub fn column(&self) -> Option<&common::ColumnName> {
116        match &self.source {
117            FieldSource::Column(column) => Some(&column.column),
118            _ => None,
119        }
120    }
121}
122
123/// A relation's key, viewed uniformly across joins and aggregates — the three
124/// physical shapes a "these tables connect" fact can take. Traversal code
125/// (document SQL, reverse resolution) matches on this instead of caring whether
126/// the relation is a join or an aggregate.
127#[derive(Debug, Clone, Copy)]
128pub enum RelationKey<'a> {
129    /// The **parent** row holds the key: `parent.column → target.primary_key`
130    /// (a `belongs_to`).
131    Local(&'a common::ColumnName),
132    /// The **related** rows hold the key: `target.foreign_key → parent.pk`
133    /// (a `has_one`/`has_many`, or a direct-keyed aggregate).
134    Direct(&'a common::ColumnName),
135    /// Both sides connect through a junction table.
136    Through(&'a Through),
137}
138
139impl Relation {
140    pub fn table(&self) -> &common::TableName {
141        match self {
142            Relation::Join(join) => &join.table,
143            Relation::Aggregate(aggregate) => &aggregate.table,
144        }
145    }
146
147    /// The key tying the related rows and the parent row together.
148    pub fn key(&self) -> RelationKey<'_> {
149        match self {
150            Relation::Join(join) => match &join.kind {
151                JoinKind::BelongsTo { column } => RelationKey::Local(column),
152                JoinKind::HasOne { foreign_key } | JoinKind::HasMany { foreign_key } => {
153                    RelationKey::Direct(foreign_key)
154                }
155                JoinKind::ManyToMany { through } => RelationKey::Through(through),
156            },
157            Relation::Aggregate(aggregate) => match &aggregate.key {
158                AggregateKey::Direct(foreign_key) => RelationKey::Direct(foreign_key),
159                AggregateKey::Through(through) => RelationKey::Through(through),
160            },
161        }
162    }
163
164    /// Filters narrowing the related rows, if any.
165    pub fn filters(&self) -> Option<&[Filter]> {
166        match self {
167            Relation::Join(join) => join.filters.as_deref(),
168            Relation::Aggregate(aggregate) => aggregate.filters.as_deref(),
169        }
170    }
171}
172
173/// OpenSearch mapping. `mapping_type` is required; all other properties are passed through as-is.
174#[derive(Debug, Clone, Hash)]
175pub struct Mapping {
176    pub mapping_type: MappingType,
177    pub extra: BTreeMap<String, common::GenericValue>,
178    /// For a `map` field (a dynamic-key object), the mapping type of every
179    /// value; `None` for every other field. Internal metadata only — it is
180    /// **not** serialized into the index body (a map carries just
181    /// `{"type":"object","dynamic":true}`, the latter via `extra`). It exists so
182    /// a consumer turning the mapping into typed bindings can tell a `map` from a
183    /// plain `object` and offer a value-kind-typed handle.
184    pub map_values: Option<MappingType>,
185}
186
187/// Serializes the way OpenSearch expects a field mapping — `{ "type": …, …extra }`
188/// — rather than the struct's two named fields. The `extra` settings sit beside
189/// `type`, exactly as they would in the index body.
190impl Serialize for Mapping {
191    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
192        use serde::ser::SerializeMap;
193        let mut map = serializer.serialize_map(Some(1 + self.extra.len()))?;
194        map.serialize_entry("type", &self.mapping_type)?;
195        for (key, value) in &self.extra {
196            map.serialize_entry(key, value)?;
197        }
198        map.end()
199    }
200}
201
202#[derive(Debug, Clone, Hash, PartialEq, Eq)]
203pub enum MappingType {
204    Text,
205    Keyword,
206    Boolean,
207    Byte,
208    Short,
209    Integer,
210    Long,
211    Float,
212    Double,
213    HalfFloat,
214    ScaledFloat,
215    Date,
216    Object,
217    Nested,
218    /// Any mapping type not covered above.
219    Other(String),
220}
221
222impl MappingType {
223    /// The OpenSearch type name (`keyword`, `half_float`, …). An [`Other`] type
224    /// is its own verbatim name.
225    ///
226    /// [`Other`]: MappingType::Other
227    pub fn name(&self) -> &str {
228        match self {
229            MappingType::Text => "text",
230            MappingType::Keyword => "keyword",
231            MappingType::Boolean => "boolean",
232            MappingType::Byte => "byte",
233            MappingType::Short => "short",
234            MappingType::Integer => "integer",
235            MappingType::Long => "long",
236            MappingType::Float => "float",
237            MappingType::Double => "double",
238            MappingType::HalfFloat => "half_float",
239            MappingType::ScaledFloat => "scaled_float",
240            MappingType::Date => "date",
241            MappingType::Object => "object",
242            MappingType::Nested => "nested",
243            MappingType::Other(name) => name,
244        }
245    }
246
247    /// The mapping type for an OpenSearch type name — the inverse of
248    /// [`name`](Self::name). An unrecognized name becomes [`Other`].
249    ///
250    /// [`Other`]: MappingType::Other
251    pub fn from_name(name: &str) -> MappingType {
252        match name {
253            "text" => MappingType::Text,
254            "keyword" => MappingType::Keyword,
255            "boolean" => MappingType::Boolean,
256            "byte" => MappingType::Byte,
257            "short" => MappingType::Short,
258            "integer" => MappingType::Integer,
259            "long" => MappingType::Long,
260            "float" => MappingType::Float,
261            "double" => MappingType::Double,
262            "half_float" => MappingType::HalfFloat,
263            "scaled_float" => MappingType::ScaledFloat,
264            "date" => MappingType::Date,
265            "object" => MappingType::Object,
266            "nested" => MappingType::Nested,
267            other => MappingType::Other(other.to_owned()),
268        }
269    }
270}
271
272/// Serializes as the bare type name (`"keyword"`).
273/// Used instead of serde with inner at other because this code will not fail
274/// So having the name function will keep a single point of failure.
275impl Serialize for MappingType {
276    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
277        serializer.serialize_str(self.name())
278    }
279}