Skip to main content

schema_core/config/
projection.rs

1//! Projecting a self-describing schema into a fully-typed mapping — without a
2//! database.
3//!
4//! Every gap a thin config once left to the source is now stated in the schema:
5//! a column field carries its [`FlussoType`] and nullability,
6//! an aggregate its result type. So the mapping follows from the schema alone.
7//! The structural rules are unchanged from when the source derived them — a
8//! group is an `object`, a to-many join is a `nested` array, a `count` is a
9//! non-null `long`, a primary key is never null — they just no longer need a
10//! round-trip to ask.
11
12use crate::common::{ColumnName, GenericValue, IndexName};
13
14use super::{
15    Aggregate, AggregateOp, Column, ContentHash, Field, FieldSource, FlussoType, IndexMapping,
16    IndexSchema, Mapping, MappingType, Relation, ResolvedField,
17};
18
19impl IndexSchema {
20    /// Project this schema into its fully-typed [`IndexMapping`].
21    pub fn resolve(&self, index: IndexName) -> IndexMapping {
22        resolve_index(index, self)
23    }
24}
25
26fn resolve_index(index: IndexName, schema: &IndexSchema) -> IndexMapping {
27    IndexMapping {
28        index,
29        // Hash the parsed schema, not the file: structural changes (including a
30        // declared type) flip the hash; cosmetic file changes do not.
31        hash: ContentHash::of(schema),
32        fields: resolve_fields(&schema.fields, schema.primary_key.as_ref()),
33    }
34}
35
36/// Resolve a list of fields. `primary_key` is the root table's key while we are
37/// still on the root row (it passes through groups, which stay on the same row);
38/// it is `None` once we cross into a related table via a join.
39fn resolve_fields(fields: &[Field], primary_key: Option<&ColumnName>) -> Vec<ResolvedField> {
40    fields
41        .iter()
42        .map(|field| resolve_field(field, primary_key))
43        .collect()
44}
45
46fn resolve_field(field: &Field, primary_key: Option<&ColumnName>) -> ResolvedField {
47    let (child_fields, child_pk): (&[Field], Option<&ColumnName>) = match &field.source {
48        FieldSource::Relation(Relation::Join(join)) => (&join.fields, Some(&join.primary_key)),
49        FieldSource::Group(fields) => (fields, primary_key),
50        _ => (&[], primary_key),
51    };
52    let children = resolve_fields(child_fields, child_pk);
53
54    let (mapping_type, nullable, array) = type_and_nullability(field, primary_key);
55    let mapping = Mapping {
56        mapping_type,
57        extra: field.options.clone(),
58        map_values: map_value_type(field),
59        decimal: is_decimal(field),
60        enum_order: enum_order(field),
61    };
62
63    ResolvedField {
64        name: field.field.clone(),
65        mapping,
66        nullable,
67        array,
68        children,
69    }
70}
71
72/// The value mapping type of a `map` field — `Some(values.opensearch())` for a
73/// column declared [`FlussoType::Map`], `None` for everything else. This is the
74/// only thing distinguishing a `map`'s `object` mapping from a plain one.
75fn map_value_type(field: &Field) -> Option<MappingType> {
76    match &field.source {
77        FieldSource::Column(Column {
78            ty: FlussoType::Map { values },
79            ..
80        }) => Some(values.opensearch()),
81        _ => None,
82    }
83}
84
85/// The declared variant order of an [`Enum`](FlussoType::Enum) column field, or
86/// `None` for a bare enum (empty order) or any other field. Drives the
87/// order-aware `.sort` subfield the OpenSearch sink emits.
88fn enum_order(field: &Field) -> Option<Vec<String>> {
89    match &field.source {
90        FieldSource::Column(Column {
91            ty: FlussoType::Enum,
92            enum_order,
93            ..
94        }) if !enum_order.is_empty() => Some(enum_order.clone()),
95        _ => None,
96    }
97}
98
99/// Whether this field's leaf type is [`FlussoType::Decimal`] — a PG
100/// `numeric`/`decimal` (or a decimal-typed `sum`/`min`/`max`/`ids` aggregate, or
101/// a decimal constant). It maps to OpenSearch `double` either way; this is what
102/// lets a typed binding offer a `Decimal` handle instead of `f64`.
103fn is_decimal(field: &Field) -> bool {
104    let ty_is_decimal = |ty: &FlussoType| matches!(ty, FlussoType::Decimal);
105    match &field.source {
106        FieldSource::Column(Column { ty, .. }) => ty_is_decimal(ty),
107        FieldSource::Constant(GenericValue::Decimal(_)) => true,
108        FieldSource::Relation(Relation::Aggregate(aggregate)) => match &aggregate.op {
109            AggregateOp::Sum(_) | AggregateOp::Min(_) | AggregateOp::Max(_) => {
110                aggregate.value_type.as_ref().is_some_and(ty_is_decimal)
111            }
112            AggregateOp::Ids { element_type } => ty_is_decimal(element_type),
113            AggregateOp::Count | AggregateOp::Avg(_) => false,
114        },
115        _ => false,
116    }
117}
118
119/// Returns `(mapping_type, nullable, array)`. `array` is true only for an `ids`
120/// aggregate, whose `mapping_type` is then the element type.
121fn type_and_nullability(
122    field: &Field,
123    primary_key: Option<&ColumnName>,
124) -> (MappingType, bool, bool) {
125    match &field.source {
126        FieldSource::Column(Column {
127            column,
128            ty,
129            nullable,
130            default,
131            ..
132        }) => {
133            let forced_non_null = primary_key == Some(column) || default.is_some();
134            (ty.opensearch(), *nullable && !forced_non_null, false)
135        }
136        FieldSource::Group(_) => (MappingType::Object, false, false),
137        FieldSource::Geo(geo) => (
138            MappingType::Other("geo_point".to_owned()),
139            geo.nullable,
140            false,
141        ),
142        FieldSource::Constant(value) => (
143            constant_mapping_type(value),
144            matches!(value, GenericValue::Null),
145            false,
146        ),
147        FieldSource::Relation(Relation::Join(join)) => {
148            let mapping_type = if join.kind.is_to_many() {
149                MappingType::Nested
150            } else {
151                MappingType::Object
152            };
153            (mapping_type, join.nullable, false)
154        }
155        FieldSource::Relation(Relation::Aggregate(aggregate)) => aggregate_type(aggregate),
156    }
157}
158
159fn aggregate_type(aggregate: &Aggregate) -> (MappingType, bool, bool) {
160    match &aggregate.op {
161        AggregateOp::Count => (MappingType::Long, false, false),
162        AggregateOp::Avg(_) => (MappingType::Double, true, false),
163        AggregateOp::Sum(_) | AggregateOp::Min(_) | AggregateOp::Max(_) => {
164            let mapping_type = aggregate
165                .value_type
166                .as_ref()
167                .map(|ty| ty.opensearch())
168                // Conversion requires a `value_type` for these ops; `double` is
169                // a defensive fallback that should never be reached.
170                .unwrap_or(MappingType::Double);
171            (mapping_type, true, false)
172        }
173        AggregateOp::Ids { element_type } => (element_type.opensearch(), false, true),
174    }
175}
176
177#[cfg(test)]
178mod tests;
179
180/// The mapping type a constant value's shape implies.
181fn constant_mapping_type(value: &GenericValue) -> MappingType {
182    match value {
183        GenericValue::Bool(_) => MappingType::Boolean,
184        GenericValue::SmallInt(_) => MappingType::Short,
185        GenericValue::Int(_) => MappingType::Integer,
186        GenericValue::BigInt(_) => MappingType::Long,
187        GenericValue::Float(_) => MappingType::Float,
188        GenericValue::Double(_) | GenericValue::Decimal(_) => MappingType::Double,
189        GenericValue::Date(_)
190        | GenericValue::Time(_)
191        | GenericValue::Timestamp(_)
192        | GenericValue::TimestampTz(_) => MappingType::Date,
193        GenericValue::Bytes(_) => MappingType::Other("binary".to_owned()),
194        GenericValue::Array(items) => items
195            .first()
196            .map(constant_mapping_type)
197            .unwrap_or(MappingType::Keyword),
198        GenericValue::Map(_) => MappingType::Object,
199        GenericValue::String(_) | GenericValue::Uuid(_) | GenericValue::Null => {
200            MappingType::Keyword
201        }
202    }
203}