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";
pub const ID_FIELD: &str = "_id";
pub const SEQ_FIELD: &str = "_seq";
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
#[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,
pub id: Option<Field>,
pub seq: Option<Field>,
pub schema_version: u32,
}
impl MappedSchema {
pub fn build(mapping: IndexMapping) -> Self {
Self::build_versioned(mapping, CURRENT_SCHEMA_VERSION)
}
pub fn build_versioned(mapping: IndexMapping, schema_version: u32) -> 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));
}
let (id, seq) = if schema_version >= 1 {
(
Some(builder.add_text_field(ID_FIELD, STRING | STORED)),
Some(builder.add_i64_field(SEQ_FIELD, INDEXED | FAST)),
)
} else {
(None, None)
};
Self {
schema: builder.build(),
source,
timestamp,
dynamic,
fields,
mapping,
id,
seq,
schema_version,
}
}
}
#[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.schema.get_field(ID_FIELD).is_ok());
assert!(schema.schema.get_field(SEQ_FIELD).is_ok());
assert!(schema.fields.is_empty());
assert_eq!(schema.schema_version, CURRENT_SCHEMA_VERSION);
}
#[test]
fn legacy_schema_keeps_mapped_field_ordinals() {
let mapping = IndexMapping::from_json(&serde_json::json!({
"properties": {"a": {"type": "keyword"}, "b": {"type": "long"}}
}))
.unwrap();
let legacy = MappedSchema::build_versioned(mapping.clone(), 0);
let current = MappedSchema::build(mapping);
assert!(legacy.id.is_none() && legacy.seq.is_none());
assert!(legacy.schema.get_field(ID_FIELD).is_err());
for name in ["a", "b"] {
assert_eq!(legacy.fields[name].0, current.fields[name].0);
}
}
#[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);
}
}