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}