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`](super::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, IndexMapping, IndexSchema,
16    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    };
59
60    ResolvedField {
61        name: field.field.clone(),
62        mapping,
63        nullable,
64        array,
65        children,
66    }
67}
68
69/// Returns `(mapping_type, nullable, array)`. `array` is true only for an `ids`
70/// aggregate, whose `mapping_type` is then the element type.
71fn type_and_nullability(
72    field: &Field,
73    primary_key: Option<&ColumnName>,
74) -> (MappingType, bool, bool) {
75    match &field.source {
76        FieldSource::Column(Column {
77            column,
78            ty,
79            nullable,
80            default,
81            ..
82        }) => {
83            let forced_non_null = primary_key == Some(column) || default.is_some();
84            (ty.opensearch(), *nullable && !forced_non_null, false)
85        }
86        FieldSource::Group(_) => (MappingType::Object, false, false),
87        FieldSource::Geo(geo) => (
88            MappingType::Other("geo_point".to_owned()),
89            geo.nullable,
90            false,
91        ),
92        FieldSource::Constant(value) => (
93            constant_mapping_type(value),
94            matches!(value, GenericValue::Null),
95            false,
96        ),
97        FieldSource::Relation(Relation::Join(join)) => {
98            if join.kind.is_to_many() {
99                (MappingType::Nested, false, false)
100            } else {
101                (MappingType::Object, true, false)
102            }
103        }
104        FieldSource::Relation(Relation::Aggregate(aggregate)) => aggregate_type(aggregate),
105    }
106}
107
108fn aggregate_type(aggregate: &Aggregate) -> (MappingType, bool, bool) {
109    match &aggregate.op {
110        AggregateOp::Count => (MappingType::Long, false, false),
111        AggregateOp::Avg(_) => (MappingType::Double, true, false),
112        AggregateOp::Sum(_) | AggregateOp::Min(_) | AggregateOp::Max(_) => {
113            let mapping_type = aggregate
114                .value_type
115                .as_ref()
116                .map(|ty| ty.opensearch())
117                // Conversion requires a `value_type` for these ops; `double` is
118                // a defensive fallback that should never be reached.
119                .unwrap_or(MappingType::Double);
120            (mapping_type, true, false)
121        }
122        AggregateOp::Ids { element_type } => (element_type.opensearch(), false, true),
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::unwrap_used, clippy::indexing_slicing)]
129
130    use crate::common::{FieldName, IndexName, TableName};
131    use crate::config::{
132        Aggregate, AggregateKey, AggregateOp, DatabaseSchema, Field, FieldSource, FlussoType,
133        IndexSchema, MappingType, Relation,
134    };
135
136    fn ids_schema(element_type: FlussoType) -> IndexSchema {
137        IndexSchema {
138            version: 1,
139            table: TableName::try_new("users").unwrap(),
140            db_schema: DatabaseSchema::default(),
141            primary_key: None,
142            doc_id: None,
143            soft_delete: None,
144            filters: None,
145            fields: vec![Field {
146                field: FieldName::try_new("orderIds").unwrap(),
147                options: Default::default(),
148                source: FieldSource::Relation(Relation::Aggregate(Aggregate {
149                    table: TableName::try_new("orders").unwrap(),
150                    op: AggregateOp::Ids { element_type },
151                    key: AggregateKey::Direct(
152                        crate::common::ColumnName::try_new("user_id").unwrap(),
153                    ),
154                    value_type: None,
155                    filters: None,
156                })),
157            }],
158        }
159    }
160
161    #[test]
162    fn ids_projects_to_a_non_null_element_typed_array() {
163        let mapping = ids_schema(FlussoType::Long).resolve(IndexName::try_new("users").unwrap());
164        let field = &mapping.fields[0];
165        assert_eq!(field.mapping.mapping_type, MappingType::Long);
166        assert!(field.array);
167        assert!(!field.nullable);
168
169        let mapping = ids_schema(FlussoType::Keyword).resolve(IndexName::try_new("users").unwrap());
170        let field = &mapping.fields[0];
171        assert_eq!(field.mapping.mapping_type, MappingType::Keyword);
172        assert!(field.array);
173        assert!(!field.nullable);
174    }
175}
176
177/// The mapping type a constant value's shape implies.
178fn constant_mapping_type(value: &GenericValue) -> MappingType {
179    match value {
180        GenericValue::Bool(_) => MappingType::Boolean,
181        GenericValue::Int(_) => MappingType::Long,
182        GenericValue::Decimal(_) => MappingType::Double,
183        GenericValue::Array(items) => items
184            .first()
185            .map(constant_mapping_type)
186            .unwrap_or(MappingType::Keyword),
187        GenericValue::Map(_) => MappingType::Object,
188        GenericValue::String(_) | GenericValue::Null => MappingType::Keyword,
189    }
190}