tellaro-query-language 1.4.3

A flexible, human-friendly query language for searching and filtering structured data
Documentation
//! Field accessor for nested field access in JSON-like data structures.
//!
//! Supports dot-notation field paths like `user.profile.name`.

use crate::error::{Result, TqlError};
use serde_json::Value as JsonValue;

/// Access a field in a JSON value using dot notation
///
/// # Arguments
///
/// * `record` - The JSON record to access
/// * `field_path` - The field path (e.g., "user.profile.name")
///
/// # Returns
///
/// The field value if found, or None if the path doesn't exist
///
/// # Examples
///
/// ```ignore
/// use serde_json::json;
/// use tql::field_accessor::get_field;
///
/// let record = json!({
///     "user": {
///         "profile": {
///             "name": "John"
///         }
///     }
/// });
///
/// let value = get_field(&record, "user.profile.name").unwrap();
/// assert_eq!(value, Some(&json!("John")));
/// ```
pub fn get_field<'a>(record: &'a JsonValue, field_path: &str) -> Result<Option<&'a JsonValue>> {
    // Split the field path by dots
    let parts: Vec<&str> = field_path.split('.').collect();

    // Start with the root record
    let mut current = record;

    // Navigate through each path segment
    for part in parts {
        match current {
            JsonValue::Object(map) => {
                match map.get(part) {
                    Some(value) => current = value,
                    None => return Ok(None), // Field doesn't exist
                }
            }
            JsonValue::Array(arr) => {
                // If current is an array, try to parse part as index
                if let Ok(index) = part.parse::<usize>() {
                    match arr.get(index) {
                        Some(value) => current = value,
                        None => return Ok(None), // Index out of bounds
                    }
                } else {
                    // Not a valid index
                    return Ok(None);
                }
            }
            _ => {
                // Can't navigate further into non-object/non-array types
                return Ok(None);
            }
        }
    }

    Ok(Some(current))
}

/// Check if a field exists in a record
///
/// # Arguments
///
/// * `record` - The JSON record to check
/// * `field_path` - The field path (e.g., "user.profile.name")
///
/// # Returns
///
/// true if the field exists, false otherwise
pub fn field_exists(record: &JsonValue, field_path: &str) -> Result<bool> {
    Ok(get_field(record, field_path)?.is_some())
}

/// Get a field value as a specific type
///
/// # Arguments
///
/// * `record` - The JSON record to access
/// * `field_path` - The field path
///
/// # Returns
///
/// The field value converted to the requested type, or an error if conversion fails
pub fn get_field_as_string(record: &JsonValue, field_path: &str) -> Result<Option<String>> {
    match get_field(record, field_path)? {
        Some(JsonValue::String(s)) => Ok(Some(s.clone())),
        Some(JsonValue::Number(n)) => Ok(Some(n.to_string())),
        Some(JsonValue::Bool(b)) => Ok(Some(b.to_string())),
        Some(JsonValue::Null) => Ok(Some("null".to_string())),
        Some(_) => Ok(None), // Arrays and objects can't be converted to string directly
        None => Ok(None),
    }
}

/// Get a field value as an integer
pub fn get_field_as_i64(record: &JsonValue, field_path: &str) -> Result<Option<i64>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Number(n)) => Ok(n.as_i64()),
        Some(JsonValue::String(s)) => Ok(s.parse::<i64>().ok()),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as a float
pub fn get_field_as_f64(record: &JsonValue, field_path: &str) -> Result<Option<f64>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Number(n)) => Ok(n.as_f64()),
        Some(JsonValue::String(s)) => Ok(s.parse::<f64>().ok()),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as a boolean
pub fn get_field_as_bool(record: &JsonValue, field_path: &str) -> Result<Option<bool>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Bool(b)) => Ok(Some(*b)),
        Some(JsonValue::String(s)) => {
            let lower = s.to_lowercase();
            match lower.as_str() {
                "true" | "yes" | "1" => Ok(Some(true)),
                "false" | "no" | "0" => Ok(Some(false)),
                _ => Ok(None),
            }
        }
        Some(JsonValue::Number(n)) => {
            if let Some(i) = n.as_i64() {
                Ok(Some(i != 0))
            } else {
                Ok(None)
            }
        }
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as an array
pub fn get_field_as_array<'a>(
    record: &'a JsonValue,
    field_path: &str,
) -> Result<Option<&'a Vec<JsonValue>>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Array(arr)) => Ok(Some(arr)),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Set a field value in a record (mutable operation)
///
/// # Arguments
///
/// * `record` - The JSON record to modify
/// * `field_path` - The field path
/// * `value` - The new value to set
///
/// # Returns
///
/// Ok if successful, Error if the path can't be created
pub fn set_field(record: &mut JsonValue, field_path: &str, value: JsonValue) -> Result<()> {
    let parts: Vec<&str> = field_path.split('.').collect();

    if parts.is_empty() {
        return Err(TqlError::FieldError(format!(
            "Empty field path: {}",
            field_path
        )));
    }

    // Navigate to the parent object
    let mut current = record;
    for (i, part) in parts.iter().enumerate() {
        if i == parts.len() - 1 {
            // Last part - set the value
            match current {
                JsonValue::Object(map) => {
                    map.insert(part.to_string(), value);
                    return Ok(());
                }
                _ => {
                    return Err(TqlError::FieldError(format!(
                        "Cannot set field '{}' on non-object",
                        field_path
                    )));
                }
            }
        } else {
            // Navigate deeper, creating objects as needed
            match current {
                JsonValue::Object(map) => {
                    current = map
                        .entry(part.to_string())
                        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
                }
                _ => {
                    return Err(TqlError::FieldError(format!(
                        "Cannot navigate through non-object at '{}' in path '{}'",
                        part, field_path
                    )));
                }
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_get_field_simple() {
        let record = json!({
            "name": "John",
            "age": 30
        });

        let name = get_field(&record, "name").unwrap();
        assert_eq!(name, Some(&json!("John")));

        let age = get_field(&record, "age").unwrap();
        assert_eq!(age, Some(&json!(30)));
    }

    #[test]
    fn test_get_field_nested() {
        let record = json!({
            "user": {
                "profile": {
                    "name": "John",
                    "age": 30
                }
            }
        });

        let name = get_field(&record, "user.profile.name").unwrap();
        assert_eq!(name, Some(&json!("John")));
    }

    #[test]
    fn test_get_field_nonexistent() {
        let record = json!({
            "name": "John"
        });

        let result = get_field(&record, "nonexistent").unwrap();
        assert_eq!(result, None);

        let result = get_field(&record, "user.profile.name").unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn test_get_field_array_index() {
        let record = json!({
            "tags": ["rust", "tql", "parser"]
        });

        let tag = get_field(&record, "tags.1").unwrap();
        assert_eq!(tag, Some(&json!("tql")));
    }

    #[test]
    fn test_field_exists() {
        let record = json!({
            "user": {
                "name": "John"
            }
        });

        assert!(field_exists(&record, "user.name").unwrap());
        assert!(!field_exists(&record, "user.age").unwrap());
    }

    #[test]
    fn test_get_field_as_string() {
        let record = json!({
            "name": "John",
            "age": 30,
            "active": true
        });

        assert_eq!(
            get_field_as_string(&record, "name").unwrap(),
            Some("John".to_string())
        );
        assert_eq!(
            get_field_as_string(&record, "age").unwrap(),
            Some("30".to_string())
        );
        assert_eq!(
            get_field_as_string(&record, "active").unwrap(),
            Some("true".to_string())
        );
    }

    #[test]
    fn test_get_field_as_i64() {
        let record = json!({
            "age": 30,
            "count": "42"
        });

        assert_eq!(get_field_as_i64(&record, "age").unwrap(), Some(30));
        assert_eq!(get_field_as_i64(&record, "count").unwrap(), Some(42));
    }

    #[test]
    fn test_get_field_as_bool() {
        let record = json!({
            "active": true,
            "enabled": "yes",
            "disabled": "no"
        });

        assert_eq!(get_field_as_bool(&record, "active").unwrap(), Some(true));
        assert_eq!(get_field_as_bool(&record, "enabled").unwrap(), Some(true));
        assert_eq!(get_field_as_bool(&record, "disabled").unwrap(), Some(false));
    }

    #[test]
    fn test_set_field() {
        let mut record = json!({});

        set_field(&mut record, "name", json!("John")).unwrap();
        assert_eq!(get_field(&record, "name").unwrap(), Some(&json!("John")));

        set_field(&mut record, "user.profile.age", json!(30)).unwrap();
        assert_eq!(
            get_field(&record, "user.profile.age").unwrap(),
            Some(&json!(30))
        );
    }
}