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    /// 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}