use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use tantivy::schema::{
DateOptions, DateTimePrecision, FAST, Field, INDEXED, STORED, STRING, Schema, TEXT,
};
use crate::error::{IndexError, IndexResult};
pub const SOURCE_FIELD: &str = "_source";
pub const TIMESTAMP_FIELD: &str = "_timestamp";
pub const DYNAMIC_FIELD: &str = "_dynamic";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
Keyword,
Text,
Long,
Double,
Boolean,
Date,
Ip,
}
impl FieldType {
fn parse(s: &str) -> IndexResult<Self> {
match s {
"keyword" => Ok(Self::Keyword),
"long" | "integer" | "short" | "byte" => Ok(Self::Long),
"double" | "float" | "half_float" => Ok(Self::Double),
"text" => Ok(Self::Text),
"boolean" => Ok(Self::Boolean),
"date" => Ok(Self::Date),
"ip" => Ok(Self::Ip),
other => Err(IndexError::InvalidMapping(format!(
"unsupported field type '{other}'"
))),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IndexMapping {
pub properties: BTreeMap<String, FieldType>,
}
impl IndexMapping {
pub fn from_json(mapping: &serde_json::Value) -> IndexResult<Self> {
let mut properties = BTreeMap::new();
let Some(props) = mapping.get("properties") else {
return Ok(Self { properties });
};
let props = props.as_object().ok_or_else(|| {
IndexError::InvalidMapping("'properties' must be an object".to_string())
})?;
for (name, def) in props {
if name.starts_with('_') {
return Err(IndexError::InvalidMapping(format!(
"field name '{name}' is reserved"
)));
}
let ty = def
.get("type")
.and_then(|t| t.as_str())
.ok_or_else(|| {
IndexError::InvalidMapping(format!("field '{name}' is missing 'type'"))
})?;
properties.insert(name.clone(), FieldType::parse(ty)?);
}
Ok(Self { properties })
}
pub fn to_json(&self) -> serde_json::Value {
let props: serde_json::Map<String, serde_json::Value> = self
.properties
.iter()
.map(|(name, ty)| {
(
name.clone(),
serde_json::json!({ "type": serde_json::to_value(ty).unwrap() }),
)
})
.collect();
serde_json::json!({ "properties": props })
}
}
#[derive(Clone)]
pub struct MappedSchema {
pub schema: Schema,
pub source: Field,
pub timestamp: Field,
pub dynamic: Field,
pub fields: BTreeMap<String, (Field, FieldType)>,
pub mapping: IndexMapping,
}
impl MappedSchema {
pub fn build(mapping: IndexMapping) -> Self {
let mut builder = Schema::builder();
let source = builder.add_text_field(SOURCE_FIELD, STORED);
let timestamp = builder.add_date_field(
TIMESTAMP_FIELD,
DateOptions::default()
.set_indexed()
.set_fast()
.set_precision(DateTimePrecision::Milliseconds),
);
let dynamic = builder.add_json_field(DYNAMIC_FIELD, TEXT | FAST);
let mut fields = BTreeMap::new();
for (name, ty) in &mapping.properties {
let field = match ty {
FieldType::Keyword => builder.add_text_field(name, STRING | FAST),
FieldType::Text => builder.add_text_field(name, TEXT),
FieldType::Long => builder.add_i64_field(name, INDEXED | FAST),
FieldType::Double => builder.add_f64_field(name, INDEXED | FAST),
FieldType::Boolean => builder.add_bool_field(name, INDEXED | FAST),
FieldType::Date => builder.add_date_field(
name,
DateOptions::default()
.set_indexed()
.set_fast()
.set_precision(DateTimePrecision::Milliseconds),
),
FieldType::Ip => builder.add_ip_addr_field(name, INDEXED | FAST),
};
fields.insert(name.clone(), (field, *ty));
}
Self {
schema: builder.build(),
source,
timestamp,
dynamic,
fields,
mapping,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_es_mapping_subset() {
let mapping = IndexMapping::from_json(&serde_json::json!({
"properties": {
"service": {"type": "keyword"},
"message": {"type": "text"},
"status": {"type": "integer", "ignored_param": true},
"latency": {"type": "double"},
"ok": {"type": "boolean"},
"ts": {"type": "date"},
"client": {"type": "ip"},
}
}))
.unwrap();
assert_eq!(mapping.properties["service"], FieldType::Keyword);
assert_eq!(mapping.properties["status"], FieldType::Long);
assert_eq!(mapping.properties.len(), 7);
}
#[test]
fn rejects_unknown_type_and_reserved_names() {
assert!(
IndexMapping::from_json(&serde_json::json!({
"properties": {"f": {"type": "geo_shape"}}
}))
.is_err()
);
assert!(
IndexMapping::from_json(&serde_json::json!({
"properties": {"_source": {"type": "keyword"}}
}))
.is_err()
);
}
#[test]
fn empty_mapping_builds_reserved_fields_only() {
let schema = MappedSchema::build(IndexMapping::default());
assert!(schema.schema.get_field(SOURCE_FIELD).is_ok());
assert!(schema.schema.get_field(TIMESTAMP_FIELD).is_ok());
assert!(schema.schema.get_field(DYNAMIC_FIELD).is_ok());
assert!(schema.fields.is_empty());
}
#[test]
fn mapping_roundtrips_to_json() {
let json = serde_json::json!({
"properties": {"service": {"type": "keyword"}, "n": {"type": "long"}}
});
let mapping = IndexMapping::from_json(&json).unwrap();
assert_eq!(mapping.to_json(), json);
}
}