Skip to content

Custom Visitors ​

This guide covers advanced patterns for creating custom visitors to transform, analyze, and validate queries.

Visitor Basics ​

All visitors extend QueryVisitor and override methods for specific node types:

csharp
using Foundatio.Lucene.Ast;
using Foundatio.Lucene.Visitors;

public class MyVisitor : QueryVisitor
{
    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        // Transform the node
        node.Term = node.Term?.ToLowerInvariant();
        
        // Return the (possibly modified) node
        return node;
    }
}

Visiting Child Nodes ​

Call base.Visit() to visit child nodes:

csharp
protected override QueryNode Visit(FieldQueryNode node, IQueryVisitorContext context)
{
    // Process this node first
    node.Field = node.Field?.ToLowerInvariant();
    
    // Then visit children (the field's value)
    return base.Visit(node, context);
}

WARNING

If you don't call base.Visit(), child nodes won't be visited!

Common Patterns ​

Transformation Visitor ​

Transform nodes in place:

csharp
public class NormalizeTermsVisitor : QueryVisitor
{
    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        // Normalize whitespace and case
        node.Term = node.Term?.Trim().ToLowerInvariant();
        return node;
    }

    protected override QueryNode Visit(PhraseNode node, IQueryVisitorContext context)
    {
        // Normalize phrase
        node.Phrase = node.Phrase?.Trim();
        return node;
    }
}

Collection Visitor ​

Collect information from the tree:

csharp
public class FieldCollectorVisitor : QueryVisitor
{
    public HashSet<string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
    public HashSet<string> Terms { get; } = new(StringComparer.OrdinalIgnoreCase);

    protected override QueryNode Visit(FieldQueryNode node, IQueryVisitorContext context)
    {
        if (node.Field != null)
        {
            Fields.Add(node.Field);
        }
        
        return base.Visit(node, context);
    }

    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        if (node.Term != null)
        {
            Terms.Add(node.Term);
        }
        
        return node;
    }
}

// Usage
var collector = new FieldCollectorVisitor();
collector.Accept(result.Document, new QueryVisitorContext());
Console.WriteLine($"Fields: {string.Join(", ", collector.Fields)}");
Console.WriteLine($"Terms: {string.Join(", ", collector.Terms)}");

Replacement Visitor ​

Replace nodes with different nodes:

csharp
public class StatusExpanderVisitor : QueryVisitor
{
    protected override QueryNode Visit(FieldQueryNode node, IQueryVisitorContext context)
    {
        // Expand status:all to (status:active OR status:pending OR status:review).
        // Re-parsing the expansion is simpler and safer than hand-building AST nodes.
        if (node.Field == "status" && node.Query is TermNode { Term: "all" })
        {
            return LuceneQuery.Parse("(status:active OR status:pending OR status:review)").Document.Query!;
        }

        return base.Visit(node, context);
    }
}

Validation Visitor ​

Collect validation errors:

csharp
public class ValidationVisitor : QueryVisitor
{
    private readonly List<ValidationError> _errors = new();
    public IReadOnlyList<ValidationError> Errors => _errors;
    public bool IsValid => _errors.Count == 0;

    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        if (node.Term?.StartsWith('*') == true)
        {
            _errors.Add(new ValidationError
            {
                Code = "LEADING_WILDCARD",
                Message = "Leading wildcards are not allowed",
                Value = node.Term
            });
        }
        
        return node;
    }

    protected override QueryNode Visit(RangeNode node, IQueryVisitorContext context)
    {
        // Validate range bounds
        if (node.Min != null && node.Max != null)
        {
            if (DateTime.TryParse(node.Min, out var min) && 
                DateTime.TryParse(node.Max, out var max))
            {
                if (min > max)
                {
                    _errors.Add(new ValidationError
                    {
                        Code = "INVALID_RANGE",
                        Message = "Range minimum cannot be greater than maximum"
                    });
                }
            }
        }
        
        return base.Visit(node, context);
    }
}

public class ValidationError
{
    public string Code { get; set; } = "";
    public string Message { get; set; } = "";
    public string? Value { get; set; }
}

Async work: resolve outside the pipeline ​

The visitor pipeline is synchronous by design. When a transformation needs data from an async source (for example loading saved queries for @include), do the async work before running the pipeline and pass the results in. The built-in include expansion takes a pre-resolved dictionary:

csharp
// Resolve saved queries asynchronously up front...
var names = new[] { "active-filter", "recent" };
var includes = await _repository.GetSavedQueriesAsync(names); // your async lookup -> dictionary

// ...then expand synchronously.
result.Document.ExpandIncludes(includes);

Using Visitor Context ​

The IQueryVisitorContext allows passing state:

csharp
public class ContextAwareVisitor : QueryVisitor
{
    protected override QueryNode Visit(FieldQueryNode node, IQueryVisitorContext context)
    {
        // Get user from context
        var user = context.GetValue<User>("CurrentUser");
        
        // Check if user can access this field
        var allowedFields = context.GetValue<HashSet<string>>("AllowedFields");
        if (allowedFields != null && !allowedFields.Contains(node.Field ?? ""))
        {
            throw new UnauthorizedAccessException($"Field '{node.Field}' is not accessible");
        }
        
        return base.Visit(node, context);
    }
}

// Usage
var context = new QueryVisitorContext();
context.SetValue("CurrentUser", currentUser);
context.SetValue("AllowedFields", new HashSet<string> { "title", "author", "date" });

visitor.Accept(document, context);

Composing Visitors ​

Sequential Composition ​

Run visitors in sequence:

csharp
var visitors = new List<QueryVisitor>
{
    new NormalizeTermsVisitor(),
    new FieldResolverQueryVisitor(fieldMap),
    new DateMathEvaluatorVisitor(),
    new ValidationVisitor()
};

var context = new QueryVisitorContext();
QueryNode current = document;

foreach (var visitor in visitors)
{
    current = visitor.Accept(current, context);
}

Chained Visitor ​

Use ChainedQueryVisitor with priorities:

csharp
var chain = new ChainedQueryVisitor()
    .AddVisitor(new NormalizeTermsVisitor(), priority: 10)
    .AddVisitor(new FieldResolverQueryVisitor(fieldMap), priority: 20)
    .AddVisitor(new DateMathEvaluatorVisitor(), priority: 30)
    .AddVisitor(new ValidationVisitor(), priority: 100);

chain.Accept(document, context);

Best Practices ​

1. Keep Visitors Focused ​

csharp
// Good: Single responsibility
public class LowercaseFieldsVisitor : QueryVisitor { }
public class ValidateFieldsVisitor : QueryVisitor { }
public class ExpandAliasesVisitor : QueryVisitor { }

// Bad: Too many responsibilities
public class DoEverythingVisitor : QueryVisitor { }

2. Make Visitors Stateless When Possible ​

csharp
// Good: Stateless, reusable
public class LowercaseVisitor : QueryVisitor
{
    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        node.Term = node.Term?.ToLowerInvariant();
        return node;
    }
}

// If state is needed, use context
public class StatefulVisitor : QueryVisitor
{
    protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
    {
        var count = context.GetValue<int>("TermCount");
        context.SetValue("TermCount", count + 1);
        return node;
    }
}

3. Handle Null Values ​

csharp
protected override QueryNode Visit(TermNode node, IQueryVisitorContext context)
{
    // Always check for null
    if (node.Term != null)
    {
        node.Term = node.Term.ToLowerInvariant();
    }
    
    return node;
}

4. Document Your Visitors ​

csharp
/// <summary>
/// Expands status aliases to their full form.
/// </summary>
/// <remarks>
/// Transformations:
/// - status:all -> (status:active OR status:pending OR status:review)
/// - status:closed -> (status:completed OR status:cancelled)
/// </remarks>
public class StatusExpanderVisitor : QueryVisitor
{
    // ...
}

Testing Visitors ​

csharp
[Fact]
public async Task LowercaseVisitor_LowercasesTerms()
{
    // Arrange
    var result = LuceneQuery.Parse("Title:HELLO");
    var visitor = new LowercaseTermsVisitor();
    
    // Act
    visitor.Accept(result.Document, new QueryVisitorContext());
    var output = QueryStringBuilder.ToQueryString(result.Document);
    
    // Assert
    Assert.Equal("title:hello", output);
}

[Fact]
public async Task ValidationVisitor_RejectsLeadingWildcards()
{
    // Arrange
    var result = LuceneQuery.Parse("*invalid");
    var visitor = new ValidationVisitor();
    
    // Act
    visitor.Accept(result.Document, new QueryVisitorContext());
    
    // Assert
    Assert.False(visitor.IsValid);
    Assert.Contains(visitor.Errors, e => e.Code == "LEADING_WILDCARD");
}

Next Steps ​

Released under the Apache 2.0 License.