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    // A group stays on the same row (the root key still applies); a join crosses
48    // into a related table and brings its own primary key, so that table's key
49    // forces its projected key column non-null — exactly as the root key does.
50    // Columns/aggregates/constants have no children.
51    let (child_fields, child_pk): (&[Field], Option<&ColumnName>) = match &field.source {
52        FieldSource::Relation(Relation::Join(join)) => (&join.fields, Some(&join.primary_key)),
53        FieldSource::Group(fields) => (fields, primary_key),
54        _ => (&[], primary_key),
55    };
56    let children = resolve_fields(child_fields, child_pk);
57
58    let (mapping_type, nullable) = type_and_nullability(field, primary_key);
59    let mapping = Mapping {
60        mapping_type,
61        extra: field.options.clone(),
62    };
63
64    ResolvedField {
65        name: field.field.clone(),
66        mapping,
67        nullable,
68        children,
69    }
70}
71
72/// The OpenSearch type and nullability of one field, from the declared schema.
73fn type_and_nullability(field: &Field, primary_key: Option<&ColumnName>) -> (MappingType, bool) {
74    match &field.source {
75        // A column's declared type and nullability — except a primary key is
76        // never null (it backs the id) and a `default` coalesces null away.
77        FieldSource::Column(Column {
78            column,
79            ty,
80            nullable,
81            default,
82            ..
83        }) => {
84            let forced_non_null = primary_key == Some(column) || default.is_some();
85            (ty.opensearch(), *nullable && !forced_non_null)
86        }
87        // A group is always assembled — an object, never null.
88        FieldSource::Group(_) => (MappingType::Object, false),
89        // A geo point resolves to `geo_point`; its nullability is declared (a
90        // `required` point is non-null, otherwise it may be absent).
91        FieldSource::Geo(geo) => (MappingType::Other("geo_point".to_owned()), geo.nullable),
92        // A constant is null exactly when the value is null.
93        FieldSource::Constant(value) => (
94            constant_mapping_type(value),
95            matches!(value, GenericValue::Null),
96        ),
97        // A join's verb decides its shape and nullability: a to-one join
98        // (`belongs_to`/`has_one`) is an object that may be absent; a to-many
99        // join is a nested array, never null.
100        FieldSource::Relation(Relation::Join(join)) => {
101            if join.kind.is_to_many() {
102                (MappingType::Nested, false)
103            } else {
104                (MappingType::Object, true)
105            }
106        }
107        // An aggregate's type follows its op; only `count` is guaranteed
108        // non-null. `sum`/`min`/`max` carry a declared `value_type`.
109        FieldSource::Relation(Relation::Aggregate(aggregate)) => aggregate_type(aggregate),
110    }
111}
112
113fn aggregate_type(aggregate: &Aggregate) -> (MappingType, bool) {
114    match &aggregate.op {
115        AggregateOp::Count => (MappingType::Long, false),
116        AggregateOp::Avg(_) => (MappingType::Double, true),
117        AggregateOp::Sum(_) | AggregateOp::Min(_) | AggregateOp::Max(_) => {
118            let mapping_type = aggregate
119                .value_type
120                .as_ref()
121                .map(|ty| ty.opensearch())
122                // Conversion requires a `value_type` for these ops; `double` is
123                // a defensive fallback that should never be reached.
124                .unwrap_or(MappingType::Double);
125            (mapping_type, true)
126        }
127    }
128}
129
130/// The mapping type a constant value's shape implies.
131fn constant_mapping_type(value: &GenericValue) -> MappingType {
132    match value {
133        GenericValue::Bool(_) => MappingType::Boolean,
134        GenericValue::Int(_) => MappingType::Long,
135        GenericValue::Decimal(_) => MappingType::Double,
136        GenericValue::Array(items) => items
137            .first()
138            .map(constant_mapping_type)
139            .unwrap_or(MappingType::Keyword),
140        GenericValue::Map(_) => MappingType::Object,
141        GenericValue::String(_) | GenericValue::Null => MappingType::Keyword,
142    }
143}