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:
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:
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:
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:
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:
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:
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:
// 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:
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:
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:
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
// 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
// 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
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
/// <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
[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
- Visitors - Built-in visitors
- Validation - Query validation
- Configuration - Parser configuration