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 System.Linq;
using Foundatio.Parsers.ElasticQueries.Extensions;
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.QueryDsl;

/// <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)
            {
                node.SetQuery(query);
            }
            
            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<Query?> ResolveFilter(string filterName)
    {
        switch (filterName?.ToLowerInvariant())
        {
            case "premium":
                var premiumIds = await _userService.GetPremiumUserIdsAsync();
                return new TermsQuery
                {
                    Field = "user_id",
                    Terms = new TermsQueryField(premiumIds.Select(id => FieldValue.String(id)).ToList())
                };
            
            case "active":
                return new TermQuery("status", "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
using System;
using System.Threading.Tasks;
using Foundatio.Parsers.LuceneQueries.Extensions;
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;

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

    private static bool IsDateField(string? field)
    {
        return field is "created" or "updated" ||
               field?.EndsWith("_date", StringComparison.Ordinal) is true ||
               field?.EndsWith("_at", StringComparison.Ordinal) is true;
    }

    private static bool IsRelativeDate(string term)
    {
        return term is "today" or "yesterday" or "this_week" or "last_week";
    }

    private static (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 query structure without recording raw terms:

csharp
using System.Threading.Tasks;
using Foundatio.Parsers.LuceneQueries.Nodes;
using Foundatio.Parsers.LuceneQueries.Visitors;
using Microsoft.Extensions.Logging;

public class QueryLoggingVisitor : ChainableQueryVisitor
{
    private readonly ILogger _logger;

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

    public override async Task<IQueryNode?> AcceptAsync(IQueryNode node, IQueryVisitorContext context)
    {
        var result = await base.AcceptAsync(node, context);
        _logger.LogInformation("Query parsed. Term count: {TermCount}", CountTerms(result ?? node));
        return result;
    }

    private static int CountTerms(IQueryNode node)
    {
        int count = node is TermNode ? 1 : 0;
        foreach (var child in node.Children)
            count += CountTerms(child);
        return count;
    }
}

Visitor Priority ​

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

csharp
using System.Collections.Generic;
using Foundatio.Parsers.ElasticQueries;

var includes = new Dictionary<string, string> { ["active"] = "statusAlias:active" };
var fieldAliases = new Dictionary<string, string> { ["statusAlias"] = "status" };
var parser = new ElasticQueryParser(c => c
    .UseIncludes(includes) // Expands includes at priority 0
    .UseFieldMap(fieldAliases) // Resolves fields at priority 10
    .AddVisitor(new CustomFilterVisitor(), priority: 15));

Expand includes before resolving fields so aliases inside included queries are resolved too. Priority 15 runs the custom visitor after field resolution (10) and before mapping validation (20) and query validation (30). AddVisitor registers it in the query, aggregation, and sort chains; use AddQueryVisitor only when it should process queries alone.

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.

Required clauses and implicit groups ​

CombineQueriesVisitor first identifies the original clauses, then calls base.VisitAsync to build child queries, and finally combines those results. Identifying clauses first distinguishes custom queries supplied by earlier visitors from queries generated during recursive traversal.

The parser represents a OR b OR c using binary GroupNode objects even though the user wrote no parentheses. An implicit group is such a node with no parentheses, field scope, prefix, negation, boost or proximity modifier. Implicit OR groups can be flattened into one clause list. An implicit AND subtree stays intact; if it contains a required condition, the subtree is required in the enclosing OR group. Explicit groups such as (a OR b) and custom queries remain separate conditions.

RequiredQueryBuilder handles OR groups containing +: required clauses become must (scoring) or filter (non-scoring), optional clauses become should, and excluded clauses become must_not. Once a required clause exists, optional clauses need not match. A group's + applies to its place in its parent, not to the group's internal operator: +(a OR b) still means either a or b.

The new clause lists and nested-path dictionaries belong to each invocation; they are not shared visitor state. Give overlapping requests separate visitor contexts and ASTs, configure the visitor chain before using it concurrently, and ensure custom visitors and resolver callbacks also support concurrent calls. The helper does not make shared mutable contexts or concurrent configuration changes safe.

Next Steps ​

Released under the Apache 2.0 License.