tellaro-query-language 1.3.8

A flexible, human-friendly query language for searching and filtering structured data
Documentation
//! Field mapping system for OpenSearch.
//!
//! This module handles field type detection and intelligent query building
//! based on OpenSearch mappings.

use super::error::Result;
use serde_json::Value as JsonValue;
use std::collections::HashMap;

/// Field types in OpenSearch
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldType {
    /// Keyword field (exact match)
    Keyword,
    /// Text field (full-text search)
    Text,
    /// Long integer
    Long,
    /// Double precision floating point
    Double,
    /// Boolean
    Boolean,
    /// Date/datetime
    Date,
    /// IP address
    Ip,
    /// Object (nested JSON)
    Object,
    /// Nested (array of objects)
    Nested,
}

/// Field mapping information
#[derive(Debug, Clone)]
pub struct FieldMapping {
    /// The field type
    pub field_type: FieldType,
    /// Subfields (e.g., .keyword for text fields)
    pub subfields: HashMap<String, FieldType>,
}

/// Collection of field mappings for an index
#[derive(Debug, Clone)]
pub struct FieldMappings {
    mappings: HashMap<String, FieldMapping>,
}

impl FieldMappings {
    /// Create an empty field mappings collection
    pub fn new() -> Self {
        Self {
            mappings: HashMap::new(),
        }
    }

    /// Create from OpenSearch index mappings response
    ///
    /// # Arguments
    ///
    /// * `response` - The JSON response from OpenSearch mappings API
    ///
    /// # Example
    ///
    /// ```ignore
    /// let response = client.indices().get_mapping().send().await?;
    /// let mappings = FieldMappings::from_opensearch_response(response)?;
    /// ```
    pub fn from_opensearch_response(response: JsonValue) -> Result<Self> {
        let mut mappings = HashMap::new();

        // Parse OpenSearch mappings response
        // Expected format:
        // {
        //   "index_name": {
        //     "mappings": {
        //       "properties": {
        //         "field_name": {
        //           "type": "text",
        //           "fields": {
        //             "keyword": { "type": "keyword" }
        //           }
        //         }
        //       }
        //     }
        //   }
        // }

        // Handle different response formats
        let properties = if let Some(index_obj) = response.as_object() {
            // Get the first index (usually there's only one)
            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 })
    }

    /// Create from pre-extracted properties (e.g., from index template's mappings.properties)
    ///
    /// This is useful when field mappings have already been extracted from an index template
    /// and don't need the full OpenSearch response wrapper.
    ///
    /// # Arguments
    ///
    /// * `properties` - A HashMap of field names to their type definitions
    ///
    /// # Example
    ///
    /// ```ignore
    /// let properties = template.get_tql_field_mappings(Some(&["event.code", "message"]));
    /// let mappings = FieldMappings::from_properties(properties);
    /// ```
    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();

        // Parse subfields if they exist
        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),
            "object" => Some(FieldType::Object),
            "nested" => Some(FieldType::Nested),
            _ => None, // Unknown type
        }
    }

    /// Get the appropriate field name for a query operation
    ///
    /// For example, for "message contains", this might return "message.keyword"
    /// or just "message" depending on the field mapping and operator.
    ///
    /// # Arguments
    ///
    /// * `field` - The field name
    /// * `operator` - The TQL operator being used
    pub fn get_query_field(&self, field: &str, operator: &str) -> String {
        // If we have a mapping for this field
        if let Some(mapping) = self.mappings.get(field) {
            // For operators that require exact/non-analyzed fields on text fields, use .keyword subfield if available
            // This includes: exact match (eq, ne, in), wildcards (contains, startswith, endswith), and regex (matches)
            if matches!(
                operator,
                "eq" | "ne" | "in" | "contains" | "startswith" | "endswith" | "matches"
            ) && mapping.field_type == FieldType::Text
                && mapping.subfields.contains_key("keyword")
            {
                return format!("{}.keyword", field);
            }
        }

        // Default: use the field as-is
        field.to_string()
    }

    /// Determine if a field should use term query vs match query
    ///
    /// # Arguments
    ///
    /// * `field` - The field name
    ///
    /// # Returns
    ///
    /// `true` if term query should be used (exact match), `false` for match query
    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
            )
        } else {
            // Default to term query if we don't know the type
            true
        }
    }

    /// Get the field type for a given field
    pub fn get_field_type(&self, field: &str) -> Option<&FieldType> {
        self.mappings.get(field).map(|m| &m.field_type)
    }

    /// Add a field mapping
    pub fn add_mapping(&mut self, field: String, mapping: FieldMapping) {
        self.mappings.insert(field, mapping);
    }

    /// Get the number of field mappings
    pub fn len(&self) -> usize {
        self.mappings.len()
    }

    /// Check if there are no field mappings
    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"), "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,
            },
        );

        // For eq operator, should use .keyword subfield
        assert_eq!(mappings.get_query_field("message", "eq"), "message.keyword");
        // For contains operator, should use base field
        assert_eq!(mappings.get_query_field("message", "contains"), "message");
    }

    #[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"), "status");
        assert!(mappings.should_use_term_query("status"));
    }
}