pg_liquid 0.2.0

A PostgreSQL extension for Liquid template processing.
//! pg_liquid - PostgreSQL extension for Liquid template processing
//!
//! This extension provides two main functions for working with Liquid templates:
//! - `check_valid_syntax`: Validates Liquid template syntax
//! - `render`: Renders Liquid templates with JSON data
//!
//! Built with Rust and pgrx for optimal performance and PostgreSQL integration.

mod variable_extractor;

use pgrx::prelude::*;
use serde_json::Value as JsonValue;

pgrx::pg_module_magic!();

/// Validates whether the provided Liquid template code has valid syntax.
///
/// # Arguments
///
/// * `liquid_code` - The Liquid template code to validate
///
/// # Returns
///
/// * `bool` - `true` if the syntax is valid, `false` otherwise
///
/// # Example
///
/// ```sql
/// SELECT pg_liquid.check_valid_syntax('Hello {{ name }}!');
/// -- Returns: true
///
/// SELECT pg_liquid.check_valid_syntax('Hello {{ name }!');
/// -- Returns: false
/// ```
#[pg_extern]
pub fn check_valid_syntax(liquid_code: &str) -> bool {
    // Create a liquid parser with standard library
    let parser = match liquid::ParserBuilder::with_stdlib().build() {
        Ok(parser) => parser,
        Err(_) => return false,
    };
    
    // Try to parse the liquid template
    match parser.parse(liquid_code) {
        Ok(_) => true,
        Err(_) => false,
    }
}

/// Renders a Liquid template with the provided JSON data.
///
/// # Arguments
///
/// * `liquid_code` - The Liquid template code to render
/// * `data` - PostgreSQL JSONB object containing the data to use in template rendering
///
/// # Returns
///
/// * `String` - The rendered template output
///
/// # Example
///
/// ```sql
/// SELECT pg_liquid.render(
///   'Hello {{ user.name }}! You have {{ user.credits }} credits.',
///   '{
///     "user": {
///       "name": "Alice", 
///       "credits": 100
///     }
///   }'::jsonb
/// );
/// -- Returns: "Hello Alice! You have 100 credits."
/// ```
#[pg_extern]
pub fn render(liquid_code: &str, data: pgrx::JsonB) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
    // Create a liquid parser with standard library
    let parser = liquid::ParserBuilder::with_stdlib().build()
        .map_err(|e| format!("Failed to create liquid parser: {}", e))?;
    
    // Parse the liquid template
    let template = parser.parse(liquid_code)
        .map_err(|e| format!("Failed to parse liquid template: {}", e))?;
    
    // Convert JSONB to liquid Object
    let json_string = data.0.to_string();
    let json_value: JsonValue = serde_json::from_str(&json_string)
        .map_err(|e| format!("Failed to parse JSON data: {}", e))?;
    
    let liquid_data: liquid::Object = serde_json::from_value(json_value)
        .map_err(|e| format!("Failed to convert JSON to liquid Object: {}", e))?;
    
    // Render the template with the data
    let output = template.render(&liquid_data)
        .map_err(|e| format!("Failed to render template: {}", e))?;
    
    Ok(output)
}

/// Extracts all unique variable names from a Liquid template.
///
/// This function performs a static analysis of the template code to find all
/// variable identifiers by parsing the template using the official Liquid grammar.
/// It correctly handles `raw` and `comment` blocks, ignoring any Liquid code within them.
///
/// # Arguments
///
/// * `liquid_code` - The Liquid template code to analyze.
///
/// # Returns
///
/// * An iterator over the unique variable names found in the template, sorted alphabetically.
///
/// # Example
///
/// ```sql
/// SELECT * FROM liquid.get_variables('Hello {{ user.name }}! {% for p in products %}{{p.name}}{% endfor %}');
/// -- Returns:
/// -- get_variables
/// -- ---------------
/// -- p
/// -- products
/// -- user
/// ```
#[pg_extern]
fn get_variables(liquid_code: &str) -> Vec<String> {
    variable_extractor::extract(liquid_code)
}

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

    #[test]
    fn test_check_valid_syntax_positive() {
        // Test valid liquid syntax
        assert_eq!(true, check_valid_syntax("Hello {{ name }}!"));
        assert_eq!(true, check_valid_syntax("{% if user %}Welcome{% endif %}"));
        assert_eq!(true, check_valid_syntax("{{ product.price }}"));  // Remove unknown filter
        assert_eq!(true, check_valid_syntax("Plain text without liquid"));
    }

    #[test]
    fn test_check_valid_syntax_negative() {
        // Test invalid liquid syntax
        assert_eq!(false, check_valid_syntax("Hello {{ name }!"));  // Missing closing brace
        assert_eq!(false, check_valid_syntax("{% if user %}Welcome"));  // Missing endif
        assert_eq!(false, check_valid_syntax("{{ | filter }}"));  // Invalid variable syntax
        assert_eq!(false, check_valid_syntax("{% unknown_tag %}"));  // Unknown tag
    }

    #[test]
    fn test_render_positive() {
        // Test successful rendering
        let data = pgrx::JsonB(serde_json::json!({"name": "Alice", "age": 30}));
        let result = render("Hello {{ name }}!", data).unwrap();
        assert_eq!("Hello Alice!", result);

        // Test with filters
        let data = pgrx::JsonB(serde_json::json!({"price": 19.99}));
        let result = render("Price: ${{ price }}", data).unwrap();
        assert_eq!("Price: $19.99", result);

        // Test with nested objects
        let data = pgrx::JsonB(serde_json::json!({"user": {"name": "Bob", "credits": 100}}));
        let result = render("Welcome {{ user.name }}! Credits: {{ user.credits }}", data).unwrap();
        assert_eq!("Welcome Bob! Credits: 100", result);
    }

    #[test]
    fn test_render_negative() {
        // Test with invalid template syntax - should return error
        let data = pgrx::JsonB(serde_json::json!({"name": "Alice"}));
        let result = render("Hello {{ name }!", data);
        assert!(result.is_err());

        // Test with missing variable - liquid throws an error for unknown variables
        let data = pgrx::JsonB(serde_json::json!({}));
        let result = render("Hello {{ missing_var }}!", data);
        assert!(result.is_err());  // Liquid throws error for missing variables

        // Test with invalid JSON structure - should handle gracefully
        let data = pgrx::JsonB(serde_json::json!(null));
        let result = render("{{ value }}", data);
        // This should return error since null can't be converted to liquid Object
        assert!(result.is_err());
    }

    #[test]
    fn test_get_variables_functionality() {
        // Test simple variable extraction
        let vars = get_variables("Hello {{ user.name }}!");
        assert_eq!(vars, vec!["user"]);

        // Test complex template
        let template = r#"
            <h1>{{ page.title }}</h1>
            <p>Welcome {{ user.name }}!</p>
            {% for item in products %}
                <h3>{{ item.name }}</h3>
            {% endfor %}
            {% assign total = cart.total %}
        "#;
        let vars = get_variables(template);
        assert_eq!(vars, vec!["cart", "item", "page", "products", "total", "user"]);

        // Test no variables
        let vars = get_variables("Just plain text.");
        assert!(vars.is_empty());
    }
}

/// This module is required by `cargo pgrx test` invocations.
/// It must be visible at the root of your extension crate.
#[cfg(test)]
pub mod pg_test {
    pub fn setup(_options: Vec<&str>) {
        // perform one-off initialization when the pg_test framework starts
    }

    pub fn postgresql_conf_options() -> Vec<&'static str> {
        // return any postgresql.conf settings that are required for your tests
        vec![]
    }
}