Skip to content

Custom Visitors

Create custom visitors to implement specialized query transformations, validations, or data extraction.

Creating a Basic Visitor

Non-Mutating Visitor

Extend QueryNodeVisitorBase for read-only traversal:

csharp
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;

public class FieldCollectorVisitor : QueryNodeVisitorBase
{
    public HashSet<string> Fields { get; } = new();

    public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
    {
        if (!string.IsNullOrEmpty(node.Field))
            Fields.Add(node.Field);
        
        return Task.CompletedTask;
    }

    public override Task VisitAsync(TermRangeNode node, IQueryVisitorContext context)
    {
        if (!string.IsNullOrEmpty(node.Field))
            Fields.Add(node.Field);
        
        return Task.CompletedTask;
    }

    public override Task VisitAsync(ExistsNode node, IQueryVisitorContext context)
    {
        if (!string.IsNullOrEmpty(node.Field))
            Fields.Add(node.Field);
        
        return Task.CompletedTask;
    }
}

Usage:

csharp
var parser = new LuceneQueryParser();
var result = await parser.ParseAsync("status:active AND created:[2024-01-01 TO 2024-12-31]");

var visitor = new FieldCollectorVisitor();
await result.AcceptAsync(visitor, new QueryVisitorContext());

foreach (var field in visitor.Fields)
{
    Console.WriteLine($"Field: {field}");
}
// Output: status, created

Mutating Visitor

Extend MutatingQueryNodeVisitorBase to modify nodes:

csharp
public class FieldPrefixVisitor : MutatingQueryNodeVisitorBase
{
    private readonly string _prefix;

    public FieldPrefixVisitor(string prefix)
    {
        _prefix = prefix;
    }

    public override IQueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        if (!string.IsNullOrEmpty(node.Field) && !node.Field.StartsWith(_prefix))
            node.Field = _prefix + node.Field;
        return node;
    }

    public override IQueryNode Visit(TermRangeNode node, IQueryVisitorContext context)
    {
        if (!string.IsNullOrEmpty(node.Field) && !node.Field.StartsWith(_prefix))
            node.Field = _prefix + node.Field;
        return node;
    }
}

Usage:

csharp
var parser = new LuceneQueryParser();
var result = await parser.ParseAsync("status:active");

var visitor = new FieldPrefixVisitor("data.");
await result.AcceptAsync(visitor, new QueryVisitorContext());

string query = await GenerateQueryVisitor.RunAsync(result);
// Output: "data.status:active"

Chainable Visitors

For use with ElasticQueryParser or ChainedQueryVisitor, implement IChainableQueryVisitor:

csharp
public class CustomFilterVisitor : ChainableQueryVisitor
{
    public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
    {
        // Process group nodes
        if (node.Field == "@custom")
        {
            // Custom processing logic
            await ProcessCustomFilter(node, context);
        }

        // Continue traversal to child nodes
        await base.VisitAsync(node, context);
    }

    private Task ProcessCustomFilter(GroupNode node, IQueryVisitorContext context)
    {
        // Implementation
        return Task.CompletedTask;
    }
}

Adding to ElasticQueryParser

csharp
var parser = new ElasticQueryParser(c => c
    .AddVisitor(new CustomFilterVisitor(), priority: 100));

var query = await parser.BuildQueryAsync("@custom:(filter)");

Visitor with Result

Return a value from traversal:

csharp
public class QueryComplexityVisitor : QueryNodeVisitorWithResultBase<int>
{
    private int _complexity = 0;

    public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
    {
        _complexity += 1;
        return Task.CompletedTask;
    }

    public override Task VisitAsync(TermRangeNode node, IQueryVisitorContext context)
    {
        _complexity += 2; // Ranges are more complex
        return Task.CompletedTask;
    }

    public override Task VisitAsync(GroupNode node, IQueryVisitorContext context)
    {
        _complexity += 1;
        return base.VisitAsync(node, context);
    }

    public override async Task<int> AcceptAsync(IQueryNode node, IQueryVisitorContext context)
    {
        _complexity = 0;
        await node.AcceptAsync(this, context);
        return _complexity;
    }
}

Usage:

csharp
var parser = new LuceneQueryParser();
var result = await parser.ParseAsync("(a:1 AND b:2) OR c:[1 TO 10]");

var visitor = new QueryComplexityVisitor();
int complexity = await visitor.AcceptAsync(result, new QueryVisitorContext());
Console.WriteLine($"Complexity: {complexity}");

Real-World Example: Custom Filter Resolution

This example shows a visitor that resolves custom filter syntax to Elasticsearch queries:

csharp
using Foundatio.Parsers.ElasticQueries.Extensions;
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;
using Nest;

/// <summary>
/// Resolves @custom:(filter) syntax to actual queries.
/// Example: @custom:(premium) -> terms query for premium user IDs
/// </summary>
public class CustomFilterVisitor : ChainableQueryVisitor
{
    private readonly IUserService _userService;

    public CustomFilterVisitor(IUserService userService)
    {
        _userService = userService;
    }

    public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
    {
        if (node.Field == "@custom" && node.Left != null)
        {
            string filterName = GetFilterName(node);
            var query = await ResolveFilter(filterName);
            
            if (query != null)
            {
                // Set the resolved query on the parent node
                node.Parent?.SetQuery(query);
            }
            
            // Clear the node's children (they've been processed)
            node.Left = null;
            node.Right = null;
        }

        await base.VisitAsync(node, context);
    }

    private string GetFilterName(GroupNode node)
    {
        if (node.Left is TermNode term)
            return term.Term;
        
        return GenerateQueryVisitor.Run(node.Left);
    }

    private async Task<QueryContainer> ResolveFilter(string filterName)
    {
        switch (filterName?.ToLowerInvariant())
        {
            case "premium":
                var premiumIds = await _userService.GetPremiumUserIdsAsync();
                return new TermsQuery { Field = "user_id", Terms = premiumIds };
            
            case "active":
                return new TermQuery { Field = "status", Value = "active" };
            
            default:
                return null;
        }
    }
}

Usage:

csharp
var parser = new ElasticQueryParser(c => c
    .AddVisitor(new CustomFilterVisitor(userService), priority: 50));

// Resolves @custom:(premium) to a terms query
var query = await parser.BuildQueryAsync("@custom:(premium) AND category:electronics");

Example: Date Range Expansion

Expand relative date expressions:

csharp
public class DateRangeExpansionVisitor : ChainableMutatingQueryVisitor
{
    public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
    {
        if (IsDateField(node.Field) && IsRelativeDate(node.Term))
        {
            var (start, end) = ExpandRelativeDate(node.Term);
            
            // Replace term node with range node
            var rangeNode = new TermRangeNode
            {
                Field = node.Field,
                Min = start,
                Max = end,
                MinInclusive = true,
                MaxInclusive = true
            };
            
            node.ReplaceSelf(rangeNode);
        }
        
        return Task.CompletedTask;
    }

    private bool IsDateField(string field)
    {
        return field?.EndsWith("_date") == true || 
               field?.EndsWith("_at") == true ||
               field == "created" || 
               field == "updated";
    }

    private bool IsRelativeDate(string term)
    {
        return term == "today" || term == "yesterday" || 
               term == "this_week" || term == "last_week";
    }

    private (string start, string end) ExpandRelativeDate(string term)
    {
        return term switch
        {
            "today" => ("now/d", "now"),
            "yesterday" => ("now-1d/d", "now-1d/d"),
            "this_week" => ("now/w", "now"),
            "last_week" => ("now-1w/w", "now-1w/w"),
            _ => (term, term)
        };
    }
}

Example: Query Logging Visitor

Log all queries for analytics:

csharp
public class QueryLoggingVisitor : ChainableQueryVisitor
{
    private readonly ILogger _logger;
    private readonly List<string> _terms = new();
    private readonly List<string> _fields = new();

    public QueryLoggingVisitor(ILogger logger)
    {
        _logger = logger;
    }

    public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
    {
        _fields.Add(node.Field ?? "_default");
        _terms.Add(node.Term);
        return Task.CompletedTask;
    }

    public override async Task<IQueryNode> AcceptAsync(IQueryNode node, IQueryVisitorContext context)
    {
        _terms.Clear();
        _fields.Clear();
        
        var result = await base.AcceptAsync(node, context);
        
        _logger.LogInformation(
            "Query executed. Fields: {Fields}, Terms: {Terms}",
            string.Join(", ", _fields.Distinct()),
            string.Join(", ", _terms));
        
        return result;
    }
}

Visitor Priority

When using chained visitors, priority determines execution order (lower runs first):

csharp
var parser = new ElasticQueryParser(c => c
    // Field resolution first
    .AddVisitor(new FieldResolverQueryVisitor(resolver), priority: 10)
    
    // Then include expansion
    .AddVisitor(new IncludeVisitor(), priority: 20)
    
    // Then custom processing
    .AddVisitor(new CustomFilterVisitor(), priority: 50)
    
    // Validation last
    .AddVisitor(new ValidationVisitor(), priority: 100));

Best Practices

1. Call Base Implementation

Always call the base implementation to continue traversal:

csharp
public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
{
    // Your logic here
    
    // Continue to child nodes
    await base.VisitAsync(node, context);
}

2. Handle Null Fields

csharp
public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
{
    if (string.IsNullOrEmpty(node.Field))
        return Task.CompletedTask;
    
    // Process node
    return Task.CompletedTask;
}

3. Use Context for State

csharp
public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
{
    // Get state from context
    var userId = context.GetValue<string>("UserId");
    
    // Store results in context
    var fields = context.GetCollection<string>("ReferencedFields");
    fields.Add(node.Field);
    
    return Task.CompletedTask;
}

4. Make Visitors Stateless When Possible

csharp
// Prefer stateless visitors
public class StatelessVisitor : ChainableQueryVisitor
{
    public override Task VisitAsync(TermNode node, IQueryVisitorContext context)
    {
        // Use context for state, not instance fields
        var count = context.GetValue<int>("TermCount");
        context.SetValue("TermCount", count + 1);
        return Task.CompletedTask;
    }
}

5. Understand Traversal Order for Nested Queries

The base VisitAsync(GroupNode) only recurses into children -- it does not process the GroupNode itself. Where you place your logic relative to base.VisitAsync() determines whether your visitor is pre-order or post-order:

csharp
public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
{
    // PRE-ORDER: process this node before children
    ProcessNode(node);
    await base.VisitAsync(node, context);
}

public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
{
    // POST-ORDER: process children before this node
    await base.VisitAsync(node, context);
    ProcessNode(node);
}

For queries with nested field groups like field:(-field:(value) OR other), both occurrences of the field are visited. Each node's Field property is independent -- there is no automatic field inheritance or path composition from parent groups. If your visitor needs to track nesting depth or field ancestry, you must implement that yourself using node.Parent or a depth counter in the visitor context.

See Nested Queries and Visitor Traversal for a complete breakdown of how the AST is structured and traversed for nested queries.

Next Steps

Released under the MIT License.