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 /// The relation this field draws on, if it draws on a related table.
108 pub fn relation(&self) -> Option<&Relation> {
109 match &self.source {
110 FieldSource::Relation(relation) => Some(relation),
111 _ => None,
112 }
113 }
114
115 /// The column this field reads, if it is a plain column field.
116 pub fn column(&self) -> Option<&common::ColumnName> {
117 match &self.source {
118 FieldSource::Column(column) => Some(&column.column),
119 _ => None,
120 }
121 }
122}
123
124/// A relation's key, viewed uniformly across joins and aggregates — the three
125/// physical shapes a "these tables connect" fact can take. Traversal code
126/// (document SQL, reverse resolution) matches on this instead of caring whether
127/// the relation is a join or an aggregate.
128#[derive(Debug, Clone, Copy)]
129pub enum RelationKey<'a> {
130 /// The **parent** row holds the key: `parent.column → target.primary_key`
131 /// (a `belongs_to`).
132 Local(&'a common::ColumnName),
133 /// The **related** rows hold the key: `target.foreign_key → parent.pk`
134 /// (a `has_one`/`has_many`, or a direct-keyed aggregate).
135 Direct(&'a common::ColumnName),
136 /// Both sides connect through a junction table.
137 Through(&'a Through),
138}
139
140impl Relation {
141 /// The related table this relation targets.
142 pub fn table(&self) -> &common::TableName {
143 match self {
144 Relation::Join(join) => &join.table,
145 Relation::Aggregate(aggregate) => &aggregate.table,
146 }
147 }
148
149 /// The key tying the related rows and the parent row together.
150 pub fn key(&self) -> RelationKey<'_> {
151 match self {
152 Relation::Join(join) => match &join.kind {
153 JoinKind::BelongsTo { column } => RelationKey::Local(column),
154 JoinKind::HasOne { foreign_key } | JoinKind::HasMany { foreign_key } => {
155 RelationKey::Direct(foreign_key)
156 }
157 JoinKind::ManyToMany { through } => RelationKey::Through(through),
158 },
159 Relation::Aggregate(aggregate) => match &aggregate.key {
160 AggregateKey::Direct(foreign_key) => RelationKey::Direct(foreign_key),
161 AggregateKey::Through(through) => RelationKey::Through(through),
162 },
163 }
164 }
165
166 /// Filters narrowing the related rows, if any.
167 pub fn filters(&self) -> Option<&[Filter]> {
168 match self {
169 Relation::Join(join) => join.filters.as_deref(),
170 Relation::Aggregate(aggregate) => aggregate.filters.as_deref(),
171 }
172 }
173}
174
175/// OpenSearch mapping. `mapping_type` is required; all other properties are passed through as-is.
176#[derive(Debug, Clone, Hash)]
177pub struct Mapping {
178 pub mapping_type: MappingType,
179 pub extra: BTreeMap<String, common::GenericValue>,
180}
181
182/// Serializes the way OpenSearch expects a field mapping — `{ "type": …, …extra }`
183/// — rather than the struct's two named fields. The `extra` settings sit beside
184/// `type`, exactly as they would in the index body.
185impl Serialize for Mapping {
186 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
187 use serde::ser::SerializeMap;
188 let mut map = serializer.serialize_map(Some(1 + self.extra.len()))?;
189 map.serialize_entry("type", &self.mapping_type)?;
190 for (key, value) in &self.extra {
191 map.serialize_entry(key, value)?;
192 }
193 map.end()
194 }
195}
196
197#[derive(Debug, Clone, Hash, PartialEq, Eq)]
198pub enum MappingType {
199 Text,
200 Keyword,
201 Boolean,
202 Byte,
203 Short,
204 Integer,
205 Long,
206 Float,
207 Double,
208 HalfFloat,
209 ScaledFloat,
210 Date,
211 Object,
212 Nested,
213 /// Any mapping type not covered above.
214 Other(String),
215}
216
217impl MappingType {
218 /// The OpenSearch type name (`keyword`, `half_float`, …). An [`Other`] type
219 /// is its own verbatim name.
220 ///
221 /// [`Other`]: MappingType::Other
222 pub fn name(&self) -> &str {
223 match self {
224 MappingType::Text => "text",
225 MappingType::Keyword => "keyword",
226 MappingType::Boolean => "boolean",
227 MappingType::Byte => "byte",
228 MappingType::Short => "short",
229 MappingType::Integer => "integer",
230 MappingType::Long => "long",
231 MappingType::Float => "float",
232 MappingType::Double => "double",
233 MappingType::HalfFloat => "half_float",
234 MappingType::ScaledFloat => "scaled_float",
235 MappingType::Date => "date",
236 MappingType::Object => "object",
237 MappingType::Nested => "nested",
238 MappingType::Other(name) => name,
239 }
240 }
241
242 /// The mapping type for an OpenSearch type name — the inverse of
243 /// [`name`](Self::name). An unrecognized name becomes [`Other`].
244 ///
245 /// [`Other`]: MappingType::Other
246 pub fn from_name(name: &str) -> MappingType {
247 match name {
248 "text" => MappingType::Text,
249 "keyword" => MappingType::Keyword,
250 "boolean" => MappingType::Boolean,
251 "byte" => MappingType::Byte,
252 "short" => MappingType::Short,
253 "integer" => MappingType::Integer,
254 "long" => MappingType::Long,
255 "float" => MappingType::Float,
256 "double" => MappingType::Double,
257 "half_float" => MappingType::HalfFloat,
258 "scaled_float" => MappingType::ScaledFloat,
259 "date" => MappingType::Date,
260 "object" => MappingType::Object,
261 "nested" => MappingType::Nested,
262 other => MappingType::Other(other.to_owned()),
263 }
264 }
265}
266
267/// Serializes as the bare type name (`"keyword"`).
268/// Used instead of serde with inner at other because this code will not fail
269/// So having the name function will keep a single point of failure.
270impl Serialize for MappingType {
271 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272 serializer.serialize_str(self.name())
273 }
274}