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}
179
180/// Serializes the way OpenSearch expects a field mapping — `{ "type": …, …extra }`
181/// — rather than the struct's two named fields. The `extra` settings sit beside
182/// `type`, exactly as they would in the index body.
183impl Serialize for Mapping {
184 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
185 use serde::ser::SerializeMap;
186 let mut map = serializer.serialize_map(Some(1 + self.extra.len()))?;
187 map.serialize_entry("type", &self.mapping_type)?;
188 for (key, value) in &self.extra {
189 map.serialize_entry(key, value)?;
190 }
191 map.end()
192 }
193}
194
195#[derive(Debug, Clone, Hash, PartialEq, Eq)]
196pub enum MappingType {
197 Text,
198 Keyword,
199 Boolean,
200 Byte,
201 Short,
202 Integer,
203 Long,
204 Float,
205 Double,
206 HalfFloat,
207 ScaledFloat,
208 Date,
209 Object,
210 Nested,
211 /// Any mapping type not covered above.
212 Other(String),
213}
214
215impl MappingType {
216 /// The OpenSearch type name (`keyword`, `half_float`, …). An [`Other`] type
217 /// is its own verbatim name.
218 ///
219 /// [`Other`]: MappingType::Other
220 pub fn name(&self) -> &str {
221 match self {
222 MappingType::Text => "text",
223 MappingType::Keyword => "keyword",
224 MappingType::Boolean => "boolean",
225 MappingType::Byte => "byte",
226 MappingType::Short => "short",
227 MappingType::Integer => "integer",
228 MappingType::Long => "long",
229 MappingType::Float => "float",
230 MappingType::Double => "double",
231 MappingType::HalfFloat => "half_float",
232 MappingType::ScaledFloat => "scaled_float",
233 MappingType::Date => "date",
234 MappingType::Object => "object",
235 MappingType::Nested => "nested",
236 MappingType::Other(name) => name,
237 }
238 }
239
240 /// The mapping type for an OpenSearch type name — the inverse of
241 /// [`name`](Self::name). An unrecognized name becomes [`Other`].
242 ///
243 /// [`Other`]: MappingType::Other
244 pub fn from_name(name: &str) -> MappingType {
245 match name {
246 "text" => MappingType::Text,
247 "keyword" => MappingType::Keyword,
248 "boolean" => MappingType::Boolean,
249 "byte" => MappingType::Byte,
250 "short" => MappingType::Short,
251 "integer" => MappingType::Integer,
252 "long" => MappingType::Long,
253 "float" => MappingType::Float,
254 "double" => MappingType::Double,
255 "half_float" => MappingType::HalfFloat,
256 "scaled_float" => MappingType::ScaledFloat,
257 "date" => MappingType::Date,
258 "object" => MappingType::Object,
259 "nested" => MappingType::Nested,
260 other => MappingType::Other(other.to_owned()),
261 }
262 }
263}
264
265/// Serializes as the bare type name (`"keyword"`).
266/// Used instead of serde with inner at other because this code will not fail
267/// So having the name function will keep a single point of failure.
268impl Serialize for MappingType {
269 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
270 serializer.serialize_str(self.name())
271 }
272}