Skip to main content

kernel/config/
field.rs

1use std::collections::BTreeMap;
2use std::hash::{Hash, Hasher};
3
4use serde::{Deserialize, Serialize};
5
6use crate::common;
7
8use super::{
9    Aggregate, AggregateKey, ColumnPair, Filter, FlussoType, Join, JoinKind, Through, Transform,
10    ViaHop,
11};
12
13/// One field of a document: a name, optional OpenSearch mapping `options` passed
14/// through to the index, and a [`source`](Self::source) saying where its value
15/// comes from. A leaf field's *type* is declared on its source (a
16/// [`Column`]'s [`ty`](Column::ty), an [`Aggregate`]'s
17/// [`value_type`](Aggregate::value_type)) so the document shape is known without
18/// a database.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Field {
21    pub field: common::FieldName,
22    /// Extra OpenSearch mapping properties merged beside the derived `type`
23    /// (e.g. `analyzer`, `scaling_factor`). Empty for most fields.
24    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
25    pub options: BTreeMap<String, common::GenericValue>,
26    /// Whether the field reaches the emitted document. `false` makes it
27    /// input-only: fetched so a [`Script`] can read it, then stripped, and left
28    /// out of the mapping.
29    #[serde(default = "enabled", skip_serializing_if = "is_enabled")]
30    pub emit: bool,
31    pub source: FieldSource,
32}
33
34/// Hashes `emit` only when it is off, so a schema with no input-only field
35/// keeps the content hash it had before `emit` existed.
36impl Hash for Field {
37    fn hash<H: Hasher>(&self, state: &mut H) {
38        let Self {
39            field,
40            options,
41            emit,
42            source,
43        } = self;
44        field.hash(state);
45        options.hash(state);
46        source.hash(state);
47        if !emit {
48            "emit".hash(state);
49        }
50    }
51}
52
53/// Where a field's value comes from. The shapes are mutually exclusive — a field
54/// is exactly one of them — which is why this is an enum rather than a bag of
55/// optional `column` / `relation` / `fields` that can contradict each other.
56#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum FieldSource {
59    /// A column of the current row, optionally transformed, with an optional
60    /// fallback when the column is null.
61    Column(Column),
62    /// A sub-object assembled from sibling fields of the *same* row (it adds a
63    /// nesting level in the document without reading a related table).
64    Group(Vec<Field>),
65    /// A geographic point assembled from two same-row columns
66    /// ([`lat`](Geo::lat)/[`lon`](Geo::lon)) into an OpenSearch `geo_point`.
67    Geo(Geo),
68    /// Data drawn from a related table — folded in as nested documents
69    /// ([`Join`](Relation::Join)) or reduced to a single value
70    /// ([`Aggregate`](Relation::Aggregate)).
71    Relation(Relation),
72    /// A constant value with no database source — `None` renders as null.
73    Constant(common::GenericValue),
74    /// A value computed by a script from the built document.
75    Script(Script),
76}
77
78/// A computed field: a script run over the built document, returning a value of
79/// the declared leaf type (or an array of it).
80#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
81pub struct Script {
82    /// The script source (Rhai).
83    pub source: String,
84    /// The declared leaf type of the result.
85    pub ty: FlussoType,
86    /// Whether the script may return null (`()`).
87    pub nullable: bool,
88    /// Whether the script returns an array of [`ty`](Self::ty).
89    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
90    pub array: bool,
91    /// For an [`Enum`](FlussoType::Enum) result, its variants in rank order.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub enum_order: Vec<String>,
94    /// Whether a sink may add its automatic subfields (as on a [`Column`]).
95    #[serde(default = "enabled", skip_serializing_if = "is_enabled")]
96    pub subfields: bool,
97}
98
99/// A column-backed field: the column to read, its declared type and nullability,
100/// the transforms to apply, and a default to coalesce nulls to.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct Column {
103    pub column: common::ColumnName,
104    /// The declared type — the OpenSearch mapping derives from it, and a live
105    /// database (when reachable) is checked against it.
106    pub ty: FlussoType,
107    /// Whether the column admits null. The resolver still forces non-null for a
108    /// primary key or a column with a `default`.
109    pub nullable: bool,
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub transforms: Vec<Transform>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub default: Option<common::GenericValue>,
114    /// For an [`Enum`](FlussoType::Enum) column, its variants in rank order;
115    /// empty for a bare enum or any non-enum column. When non-empty the field
116    /// sorts by this order instead of alphabetically — the OpenSearch sink
117    /// prebakes the rank into a `.sort` subfield. Belongs on the column, not the
118    /// [`FlussoType`], because it is a property of this specific field.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub enum_order: Vec<String>,
121    /// Whether a sink may add its automatic subfields to this string field
122    /// (the OpenSearch `.keyword`/`.keyword_lowercase`/`.text`). `false` maps
123    /// the bare field whatever the sink's own switch says. Only a `text`,
124    /// `keyword` or `uuid` column may turn it off.
125    #[serde(default = "enabled", skip_serializing_if = "is_enabled")]
126    pub subfields: bool,
127    /// Whether a sink indexes this field's content. `false` keeps the value in
128    /// the stored document but maps it as one disabled object, so nothing
129    /// under it is searchable. Only a `json` column may turn it off.
130    #[serde(default = "enabled", skip_serializing_if = "is_enabled")]
131    pub indexed: bool,
132}
133
134/// The serde default of an opt-out flag: on.
135pub(crate) fn enabled() -> bool {
136    true
137}
138
139/// Whether an opt-out flag is still on, so it is left out of the serialized form.
140#[allow(clippy::trivially_copy_pass_by_ref)]
141pub(crate) fn is_enabled(flag: &bool) -> bool {
142    *flag
143}
144
145/// Bumped when the index a `map` column produces changes shape, so every
146/// schema with a map gets a new content hash (a new physical index, rebuilt
147/// once) while no other schema moves. `1`: keys mapped as their `values` type.
148/// `2`: a keyword key also gets an exact `.keyword` subfield. Both land in
149/// 0.17.0, so a 0.16 map schema still moves once; `2` moves a main-tip index
150/// built under `1`, which lacks the subfield.
151const MAP_KEY_MAPPING_REVISION: u8 = 2;
152
153/// Hashes the fields in declaration order (exactly what the derive did), plus
154/// `MAP_KEY_MAPPING_REVISION` for a `map` column only. `subfields` and `indexed`
155/// are hashed only when turned off, so a schema that never opts out keeps the
156/// content hash (and so the physical index) it had before they existed.
157impl Hash for Column {
158    fn hash<H: Hasher>(&self, state: &mut H) {
159        let Self {
160            column,
161            ty,
162            nullable,
163            transforms,
164            default,
165            enum_order,
166            subfields,
167            indexed,
168        } = self;
169        column.hash(state);
170        ty.hash(state);
171        nullable.hash(state);
172        transforms.hash(state);
173        default.hash(state);
174        enum_order.hash(state);
175        if matches!(ty, FlussoType::Map { .. }) {
176            MAP_KEY_MAPPING_REVISION.hash(state);
177        }
178        if !subfields {
179            "subfields".hash(state);
180        }
181        if !indexed {
182            "indexed".hash(state);
183        }
184    }
185}
186
187/// A geographic point built from two same-row columns. Resolves to an
188/// OpenSearch `geo_point`; the document carries `{ "lat": …, "lon": … }`, or
189/// SQL `NULL` when either column is null (so a nullable point is absent rather
190/// than `{lat: null, lon: null}`, which OpenSearch would reject).
191#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
192pub struct Geo {
193    /// The latitude column (degrees).
194    pub lat: common::ColumnName,
195    /// The longitude column (degrees).
196    pub lon: common::ColumnName,
197    /// Whether the point may be absent — true unless the field is `required`.
198    pub nullable: bool,
199}
200
201/// How a field draws on a related table: either folding its rows in as nested
202/// documents ([`Join`](Self::Join)) or reducing them to a single value
203/// ([`Aggregate`](Self::Aggregate)).
204#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum Relation {
207    /// Fold the related rows in as nested documents, projecting `fields` from
208    /// each one.
209    Join(Join),
210    /// Reduce the related rows to a single scalar.
211    Aggregate(Aggregate),
212}
213
214impl Field {
215    /// The fields nested directly under this one: a [`Group`](FieldSource::Group)'s
216    /// members or a [`Join`](Relation::Join)'s projection. Columns, aggregates,
217    /// constants, and scripts have none.
218    pub fn children(&self) -> &[Field] {
219        match &self.source {
220            FieldSource::Group(fields) => fields,
221            FieldSource::Relation(Relation::Join(join)) => &join.fields,
222            FieldSource::Column(_)
223            | FieldSource::Geo(_)
224            | FieldSource::Relation(Relation::Aggregate(_))
225            | FieldSource::Constant(_)
226            | FieldSource::Script(_) => &[],
227        }
228    }
229
230    pub fn relation(&self) -> Option<&Relation> {
231        match &self.source {
232            FieldSource::Relation(relation) => Some(relation),
233            _ => None,
234        }
235    }
236
237    /// The column this field reads, if it is a plain column field.
238    pub fn column(&self) -> Option<&common::ColumnName> {
239        match &self.source {
240            FieldSource::Column(column) => Some(&column.column),
241            _ => None,
242        }
243    }
244}
245
246/// A relation's key, viewed uniformly across joins and aggregates — the three
247/// physical shapes a "these tables connect" fact can take. Traversal code
248/// (document SQL, reverse resolution) matches on this instead of caring whether
249/// the relation is a join or an aggregate.
250#[derive(Debug, Clone, Copy)]
251pub enum RelationKey<'a> {
252    /// The **parent** row holds the key: `parent.column → target.primary_key`
253    /// (a `belongs_to`).
254    Local(&'a common::ColumnName),
255    /// The **related** rows hold the key: `target.foreign_key → parent.pk`
256    /// (a `has_one`/`has_many`, or a direct-keyed aggregate).
257    Direct(&'a common::ColumnName),
258    /// Both sides connect through a junction table.
259    Through(&'a Through),
260}
261
262impl Relation {
263    pub fn table(&self) -> &common::TableName {
264        match self {
265            Relation::Join(join) => &join.table,
266            Relation::Aggregate(aggregate) => &aggregate.table,
267        }
268    }
269
270    /// The key tying the related rows and the parent row together.
271    pub fn key(&self) -> RelationKey<'_> {
272        match self {
273            Relation::Join(join) => match &join.kind {
274                JoinKind::BelongsTo { column } => RelationKey::Local(column),
275                JoinKind::HasOne { foreign_key } | JoinKind::HasMany { foreign_key } => {
276                    RelationKey::Direct(foreign_key)
277                }
278                JoinKind::ManyToMany { through } => RelationKey::Through(through),
279            },
280            Relation::Aggregate(aggregate) => match &aggregate.key {
281                AggregateKey::Direct(foreign_key) => RelationKey::Direct(foreign_key),
282                AggregateKey::Through(through) => RelationKey::Through(through),
283            },
284        }
285    }
286
287    /// Filters narrowing the related rows, if any.
288    pub fn filters(&self) -> Option<&[Filter]> {
289        match self {
290            Relation::Join(join) => join.filters.as_deref(),
291            Relation::Aggregate(aggregate) => aggregate.filters.as_deref(),
292        }
293    }
294
295    /// The tables between the parent and [`table`](Self::table), in order from
296    /// the parent: an aggregate's `via`. Empty for a join and a one-hop
297    /// aggregate. When non-empty, the [`key`](Self::key) ties the related rows
298    /// to the last hop rather than to the parent.
299    pub fn via(&self) -> &[ViaHop] {
300        match self {
301            Relation::Join(_) => &[],
302            Relation::Aggregate(aggregate) => &aggregate.via,
303        }
304    }
305
306    /// The relation's own extra column equalities: `local` on the table before
307    /// [`table`](Self::table) (the parent, or the last `via` hop), `related` on
308    /// [`table`](Self::table).
309    pub fn on(&self) -> &[ColumnPair] {
310        match self {
311            Relation::Join(join) => &join.on,
312            Relation::Aggregate(aggregate) => &aggregate.on,
313        }
314    }
315
316    /// The pairs whose `local` column sits on the **parent** row: the first
317    /// `via` hop's, or the relation's own when it has no `via`.
318    pub fn parent_on(&self) -> &[ColumnPair] {
319        match self.via().first() {
320            Some(hop) => &hop.on,
321            None => self.on(),
322        }
323    }
324}
325
326/// OpenSearch mapping. `mapping_type` is required; all other properties are passed through as-is.
327#[derive(Debug, Clone, Hash)]
328pub struct Mapping {
329    pub mapping_type: MappingType,
330    pub extra: BTreeMap<String, common::GenericValue>,
331    /// For a `map` field (a dynamic-key object), the mapping every runtime key
332    /// gets — the same mapping a plain field of the map's `values` type
333    /// resolves to; `None` for every other field. A sink maps each key with it
334    /// (the OpenSearch sink as a `dynamic_templates` entry scoped to the map's
335    /// path); a consumer turning the mapping into typed bindings uses it to tell
336    /// a `map` from a plain `object` and offer a value-kind-typed handle.
337    /// Serialized as `values` beside the map's own `type`.
338    pub map_values: Option<Box<Mapping>>,
339    /// Whether this numeric field came from a [`FlussoType::Decimal`] — a PG
340    /// `numeric`/`decimal`. It maps to OpenSearch `double` like a true `double`,
341    /// so [`mapping_type`](Self::mapping_type) alone can't tell them apart.
342    /// Internal metadata only — **not** serialized into the index body. It lets a
343    /// consumer turning the mapping into typed bindings offer a `Decimal`-kind
344    /// handle (exact) instead of an `f64`-kind one.
345    ///
346    /// [`FlussoType::Decimal`]: super::FlussoType::Decimal
347    pub decimal: bool,
348    /// For an [`Enum`](super::FlussoType::Enum) field with a declared order, its
349    /// variants in rank order; `None` for a bare enum or any other field. The
350    /// OpenSearch sink turns it into a `.sort` subfield (a per-field normalizer
351    /// remapping each variant to a zero-padded rank) so the field sorts by
352    /// declared order rather than alphabetically; a consumer building typed
353    /// bindings uses it to offer an order-aware sort handle. Internal metadata —
354    /// the enum itself is still serialized as a plain `keyword`.
355    pub enum_order: Option<Vec<String>>,
356    /// Whether a sink may add its automatic subfields to this string field —
357    /// `false` when the schema field says `subfields: false`. A consumer
358    /// building typed bindings reads it to drop the subfield accessors.
359    /// Serialized as `subfields: false` only when off.
360    pub subfields: bool,
361    /// Whether a sink indexes this field — `false` when the schema field says
362    /// `indexed: false`. The [`mapping_type`](Self::mapping_type) and the
363    /// resolved children still describe the *stored* value's shape; the
364    /// OpenSearch sink maps the field as one `object` with `enabled: false`
365    /// whatever they say, and a consumer building typed bindings offers no
366    /// query handle for it. Serialized as `indexed: false` only when off.
367    pub indexed: bool,
368}
369
370impl Mapping {
371    /// The mapping a field of the declared type `ty` gets with no options of its
372    /// own: its OpenSearch type, the type's [default options](FlussoType::default_options)
373    /// and its `decimal` flag. What every key of a `map` of `ty` is mapped as.
374    pub fn of_type(ty: &FlussoType) -> Self {
375        Self {
376            mapping_type: ty.opensearch(),
377            extra: ty.default_options(),
378            map_values: None,
379            decimal: matches!(ty, FlussoType::Decimal),
380            enum_order: None,
381            subfields: true,
382            indexed: true,
383        }
384    }
385}
386
387/// Serializes the way OpenSearch expects a field mapping — `{ "type": …, …extra }`
388/// — rather than the struct's named fields. The `extra` settings sit beside
389/// `type`, exactly as they would in the index body. A `map` adds `values`: the
390/// mapping each of its keys gets. A field that opted out adds `subfields: false`
391/// or `indexed: false`.
392impl Serialize for Mapping {
393    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
394        use serde::ser::SerializeMap;
395        let len = 1
396            + self.extra.len()
397            + usize::from(self.map_values.is_some())
398            + usize::from(!self.subfields)
399            + usize::from(!self.indexed);
400        let mut map = serializer.serialize_map(Some(len))?;
401        map.serialize_entry("type", &self.mapping_type)?;
402        for (key, value) in &self.extra {
403            map.serialize_entry(key, value)?;
404        }
405        if let Some(values) = &self.map_values {
406            map.serialize_entry("values", values)?;
407        }
408        if !self.subfields {
409            map.serialize_entry("subfields", &false)?;
410        }
411        if !self.indexed {
412            map.serialize_entry("indexed", &false)?;
413        }
414        map.end()
415    }
416}
417
418#[derive(Debug, Clone, Hash, PartialEq, Eq)]
419pub enum MappingType {
420    Text,
421    Keyword,
422    Boolean,
423    Byte,
424    Short,
425    Integer,
426    Long,
427    Float,
428    Double,
429    HalfFloat,
430    ScaledFloat,
431    Date,
432    Object,
433    Nested,
434    /// Any mapping type not covered above.
435    Other(String),
436}
437
438impl MappingType {
439    /// The OpenSearch type name (`keyword`, `half_float`, …). An [`Other`] type
440    /// is its own verbatim name.
441    ///
442    /// [`Other`]: MappingType::Other
443    pub fn name(&self) -> &str {
444        match self {
445            MappingType::Text => "text",
446            MappingType::Keyword => "keyword",
447            MappingType::Boolean => "boolean",
448            MappingType::Byte => "byte",
449            MappingType::Short => "short",
450            MappingType::Integer => "integer",
451            MappingType::Long => "long",
452            MappingType::Float => "float",
453            MappingType::Double => "double",
454            MappingType::HalfFloat => "half_float",
455            MappingType::ScaledFloat => "scaled_float",
456            MappingType::Date => "date",
457            MappingType::Object => "object",
458            MappingType::Nested => "nested",
459            MappingType::Other(name) => name,
460        }
461    }
462
463    /// The mapping type for an OpenSearch type name — the inverse of
464    /// [`name`](Self::name). An unrecognized name becomes [`Other`].
465    ///
466    /// [`Other`]: MappingType::Other
467    pub fn from_name(name: &str) -> MappingType {
468        match name {
469            "text" => MappingType::Text,
470            "keyword" => MappingType::Keyword,
471            "boolean" => MappingType::Boolean,
472            "byte" => MappingType::Byte,
473            "short" => MappingType::Short,
474            "integer" => MappingType::Integer,
475            "long" => MappingType::Long,
476            "float" => MappingType::Float,
477            "double" => MappingType::Double,
478            "half_float" => MappingType::HalfFloat,
479            "scaled_float" => MappingType::ScaledFloat,
480            "date" => MappingType::Date,
481            "object" => MappingType::Object,
482            "nested" => MappingType::Nested,
483            other => MappingType::Other(other.to_owned()),
484        }
485    }
486}
487
488/// Serializes as the bare type name (`"keyword"`).
489/// Used instead of serde with inner at other because this code will not fail
490/// So having the name function will keep a single point of failure.
491impl Serialize for MappingType {
492    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
493        serializer.serialize_str(self.name())
494    }
495}