use super::error::{OpenSearchError, Result};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldType {
Keyword,
Text,
Long,
Double,
Boolean,
Date,
Ip,
Wildcard,
Object,
Nested,
}
#[derive(Debug, Clone)]
pub struct FieldMapping {
pub field_type: FieldType,
pub subfields: HashMap<String, FieldType>,
}
impl FieldType {
pub fn as_str(&self) -> &'static str {
match self {
FieldType::Keyword => "keyword",
FieldType::Text => "text",
FieldType::Long => "long",
FieldType::Double => "double",
FieldType::Boolean => "boolean",
FieldType::Date => "date",
FieldType::Ip => "ip",
FieldType::Wildcard => "wildcard",
FieldType::Object => "object",
FieldType::Nested => "nested",
}
}
pub fn is_orderable(&self) -> bool {
matches!(self, FieldType::Long | FieldType::Double | FieldType::Date)
}
pub fn is_unanalyzed_string(&self) -> bool {
matches!(self, FieldType::Keyword | FieldType::Wildcard)
}
}
#[derive(Debug, Clone)]
pub struct FieldMappings {
mappings: HashMap<String, FieldMapping>,
}
const WHOLE_VALUE_OPERATORS: &[&str] = &[
"contains",
"contains_cs",
"not_contains",
"not_contains_cs",
"startswith",
"startswith_cs",
"not_startswith",
"not_startswith_cs",
"endswith",
"endswith_cs",
"not_endswith",
"not_endswith_cs",
"matches",
"not_matches",
"regexp",
"not_regexp",
"regex",
"not_regex",
"eq_ci",
];
const CASE_SENSITIVE_WHOLE_VALUE_OPERATORS: &[&str] = &[
"contains_cs",
"not_contains_cs",
"startswith_cs",
"not_startswith_cs",
"endswith_cs",
"not_endswith_cs",
];
const CASE_SENSITIVE_MEMBERSHIP_OPERATORS: &[&str] = &["in_cs", "not_in_cs"];
impl FieldMappings {
pub fn new() -> Self {
Self {
mappings: HashMap::new(),
}
}
pub fn from_opensearch_response(response: JsonValue) -> Result<Self> {
let mut mappings = HashMap::new();
let properties = if let Some(index_obj) = response.as_object() {
if let Some((_index_name, index_data)) = index_obj.iter().next() {
if let Some(mappings_obj) = index_data.get("mappings") {
mappings_obj.get("properties")
} else {
None
}
} else {
None
}
} else {
None
};
if let Some(properties) = properties {
if let Some(props_obj) = properties.as_object() {
for (field_name, field_def) in props_obj {
if let Some(mapping) = Self::parse_field_definition(field_def) {
mappings.insert(field_name.clone(), mapping);
}
}
}
}
Ok(Self { mappings })
}
pub fn from_properties(properties: HashMap<String, JsonValue>) -> Self {
let mut mappings = HashMap::new();
for (field_name, field_def) in properties {
if let Some(mapping) = Self::parse_field_definition(&field_def) {
mappings.insert(field_name, mapping);
}
}
Self { mappings }
}
fn parse_field_definition(field_def: &JsonValue) -> Option<FieldMapping> {
let field_type_str = field_def.get("type")?.as_str()?;
let field_type = Self::parse_field_type(field_type_str)?;
let mut subfields = HashMap::new();
if let Some(fields) = field_def.get("fields") {
if let Some(fields_obj) = fields.as_object() {
for (subfield_name, subfield_def) in fields_obj {
if let Some(subfield_type_str) =
subfield_def.get("type").and_then(|v| v.as_str())
{
if let Some(subfield_type) = Self::parse_field_type(subfield_type_str) {
subfields.insert(subfield_name.clone(), subfield_type);
}
}
}
}
}
Some(FieldMapping {
field_type,
subfields,
})
}
fn parse_field_type(type_str: &str) -> Option<FieldType> {
match type_str {
"keyword" => Some(FieldType::Keyword),
"text" => Some(FieldType::Text),
"long" | "integer" | "short" | "byte" => Some(FieldType::Long),
"double" | "float" | "half_float" | "scaled_float" => Some(FieldType::Double),
"boolean" => Some(FieldType::Boolean),
"date" => Some(FieldType::Date),
"ip" => Some(FieldType::Ip),
"wildcard" => Some(FieldType::Wildcard),
"object" => Some(FieldType::Object),
"nested" => Some(FieldType::Nested),
_ => None, }
}
fn is_keyword_operator(operator: &str) -> bool {
matches!(
operator,
"eq" | "="
| "ne"
| "!="
| "in"
| "not_in"
| "exists"
| "not_exists"
| "any"
| "all"
| "not_any"
| "not_all"
| "in_cs"
| "not_in_cs"
| "none"
| "not_none"
)
}
fn is_range_operator(operator: &str) -> bool {
matches!(
operator,
">" | ">=" | "<" | "<=" | "gt" | "gte" | "lt" | "lte" | "between" | "not_between"
)
}
fn subfield_of<F>(&self, field: &str, pred: F) -> Option<String>
where
F: Fn(&FieldType) -> bool,
{
let mapping = self.mappings.get(field)?;
let mut names: Vec<&String> = mapping
.subfields
.iter()
.filter(|(_, ty)| pred(ty))
.map(|(name, _)| name)
.collect();
names.sort();
names.first().map(|n| format!("{}.{}", field, n))
}
fn keyword_form(&self, field: &str) -> Option<String> {
let mapping = self.mappings.get(field)?;
if mapping.field_type == FieldType::Keyword {
return Some(field.to_string());
}
self.subfield_of(field, |t| *t == FieldType::Keyword)
}
fn unanalyzed_form(&self, field: &str) -> Option<String> {
if let Some(kw) = self.keyword_form(field) {
return Some(kw);
}
let mapping = self.mappings.get(field)?;
if mapping.field_type == FieldType::Wildcard {
return Some(field.to_string());
}
self.subfield_of(field, |t| *t == FieldType::Wildcard)
}
fn available_types(&self, field: &str) -> String {
let Some(mapping) = self.mappings.get(field) else {
return "unknown".to_string();
};
let mut parts = vec![format!("{}({})", field, mapping.field_type.as_str())];
let mut subs: Vec<_> = mapping.subfields.iter().collect();
subs.sort_by_key(|(n, _)| (*n).clone());
for (name, ty) in subs {
parts.push(format!("{}.{}({})", field, name, ty.as_str()));
}
parts.join(", ")
}
pub fn get_query_field(&self, field: &str, operator: &str) -> Result<String> {
let Some(mapping) = self.mappings.get(field) else {
return Ok(field.to_string());
};
if matches!(operator, "exists" | "not_exists" | "is" | "is_not") {
return Ok(field.to_string());
}
if Self::is_keyword_operator(operator) {
if let Some(unanalyzed) = self.unanalyzed_form(field) {
return Ok(unanalyzed);
}
if CASE_SENSITIVE_MEMBERSHIP_OPERATORS.contains(&operator)
&& mapping.field_type == FieldType::Text
{
return Err(OpenSearchError::TypeError {
field: field.to_string(),
field_type: mapping.field_type.as_str().to_string(),
operator: operator.to_string(),
suggestion: format!(
" '{field}' is analyzed and has no case-preserving form, \
so the case this operator asks about is not in the index \
-- the analyzer lowercased it. Add a `.keyword` subfield \
to '{field}', or use the case-insensitive '{ci}'.",
field = field,
ci = operator.trim_end_matches("_cs"),
),
});
}
return Ok(field.to_string());
}
if WHOLE_VALUE_OPERATORS.contains(&operator) {
if let Some(unanalyzed) = self.unanalyzed_form(field) {
return Ok(unanalyzed);
}
if CASE_SENSITIVE_WHOLE_VALUE_OPERATORS.contains(&operator) {
return Err(OpenSearchError::TypeError {
field: field.to_string(),
field_type: mapping.field_type.as_str().to_string(),
operator: operator.to_string(),
suggestion: format!(
" '{field}' is analyzed and has no case-preserving form, \
so the case this operator asks about is not in the index \
-- the analyzer lowercased it. Add a `.keyword` subfield \
to '{field}', or use the case-insensitive '{ci}'.",
field = field,
ci = operator.trim_end_matches("_cs"),
),
});
}
if mapping.field_type == FieldType::Text {
return Ok(field.to_string());
}
return Err(OpenSearchError::UnsupportedOperation {
operator: operator.to_string(),
available_types: self.available_types(field),
});
}
if Self::is_range_operator(operator) {
if mapping.field_type.is_orderable() {
return Ok(field.to_string());
}
if let Some(sub) = self.subfield_of(field, FieldType::is_orderable) {
return Ok(sub);
}
if let Some(keyword) = self.keyword_form(field) {
return Ok(keyword);
}
if mapping.field_type == FieldType::Text {
return Err(OpenSearchError::TypeError {
field: field.to_string(),
field_type: mapping.field_type.as_str().to_string(),
operator: operator.to_string(),
suggestion:
" Range operators need a numeric, date, or keyword field; this field is analyzed text."
.to_string(),
});
}
return Err(OpenSearchError::UnsupportedOperation {
operator: operator.to_string(),
available_types: self.available_types(field),
});
}
if matches!(operator, "cidr" | "not_cidr") {
if mapping.field_type == FieldType::Ip {
return Ok(field.to_string());
}
if let Some(sub) = self.subfield_of(field, |t| *t == FieldType::Ip) {
return Ok(sub);
}
if operator == "cidr" {
return Err(OpenSearchError::TypeError {
field: field.to_string(),
field_type: mapping.field_type.as_str().to_string(),
operator: operator.to_string(),
suggestion: " CIDR matching needs an ip field or an ip subfield; a keyword holding an address is compared literally and can never match a prefix.".to_string(),
});
}
return Err(OpenSearchError::UnsupportedOperation {
operator: operator.to_string(),
available_types: self.available_types(field),
});
}
Ok(field.to_string())
}
pub fn should_use_term_query(&self, field: &str) -> bool {
if let Some(mapping) = self.mappings.get(field) {
matches!(
mapping.field_type,
FieldType::Keyword
| FieldType::Long
| FieldType::Double
| FieldType::Boolean
| FieldType::Date
| FieldType::Ip
| FieldType::Wildcard
)
} else {
true
}
}
pub fn get_field_type(&self, field: &str) -> Option<&FieldType> {
self.mappings.get(field).map(|m| &m.field_type)
}
pub fn resolved_field_type(&self, path: &str) -> Option<&FieldType> {
if let Some(mapping) = self.mappings.get(path) {
return Some(&mapping.field_type);
}
let (base, sub) = path.rsplit_once('.')?;
self.mappings.get(base)?.subfields.get(sub)
}
pub fn add_mapping(&mut self, field: String, mapping: FieldMapping) {
self.mappings.insert(field, mapping);
}
pub fn len(&self) -> usize {
self.mappings.len()
}
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
}
impl Default for FieldMappings {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_mappings() {
let mappings = FieldMappings::new();
assert_eq!(mappings.get_query_field("test", "eq").unwrap(), "test");
assert!(mappings.should_use_term_query("test"));
}
#[test]
fn test_text_field_with_keyword() {
let mut mappings = FieldMappings::new();
let mut subfields = HashMap::new();
subfields.insert("keyword".to_string(), FieldType::Keyword);
mappings.add_mapping(
"message".to_string(),
FieldMapping {
field_type: FieldType::Text,
subfields,
},
);
assert_eq!(
mappings.get_query_field("message", "eq").unwrap(),
"message.keyword"
);
assert_eq!(
mappings.get_query_field("message", "contains").unwrap(),
"message.keyword"
);
assert_eq!(
mappings.get_query_field("message", "matches").unwrap(),
"message.keyword"
);
}
#[test]
fn test_keyword_field() {
let mut mappings = FieldMappings::new();
mappings.add_mapping(
"status".to_string(),
FieldMapping {
field_type: FieldType::Keyword,
subfields: HashMap::new(),
},
);
assert_eq!(mappings.get_query_field("status", "eq").unwrap(), "status");
assert!(mappings.should_use_term_query("status"));
}
}