Skip to content

Query Syntax ​

The query syntax is inspired by Lucene classic QueryParser and Elasticsearch query_string, but it is not a drop-in implementation of either. Examples using LuceneQueryParser.Parse demonstrate AST parsing, not backend execution. See Syntax Compatibility for extensions, query-generation limitations, and migration guidance.

Basic Queries ​

The parsing snippets below use Foundatio.Parsers.LuceneQueries and reuse a LuceneQueryParser named parser. The geo configuration, nested-query, and complete examples create their own parsers.

Term Queries ​

Match documents where a field contains a specific value:

SyntaxDescriptionExample
field:valueField term; matching depends on mapping and analyzerstatus:active
field:"quoted value"Quoted value; a phrase on analyzed text fieldsname:"John Smith"
valueSearch default fieldserror
csharp
using Foundatio.Parsers.LuceneQueries;

var parser = new LuceneQueryParser();

// Simple term
var result = parser.Parse("status:active");

// Quoted phrase
result = parser.Parse("name:\"John Smith\"");

// Default field search (when configured)
result = parser.Parse("error");

Existence Queries ​

With the Elasticsearch query builder, check whether a field has an indexed value:

SyntaxDescription
_exists_:fieldField has an indexed value
_missing_:fieldField has no indexed value; implemented as negated exists

This is not simply a check for a non-null property in _source: mappings can affect which values are indexed. _missing_ is a Foundatio extension, while _exists_ is supported by Elasticsearch but is not existence syntax in bare Lucene classic.

Empty strings count as indexed values. Values excluded by ignore_above do not. Explicit nulls can count when the field mapping supplies null_value; missing fields and empty arrays still have no indexed value.

csharp
// Find documents with an indexed title
var result = parser.Parse("_exists_:title");

// Find documents without an indexed description
result = parser.Parse("_missing_:description");

Wildcard Queries ​

Unescaped * matches any number of characters; ? matches one. On keyword fields, a single trailing * uses a prefix query and other patterns use a wildcard query. On analyzed fields, the default Elasticsearch builder uses an escaped term-only query_string with analyze_wildcard: true.

text
name:john*
name:jo?n
name:jo*n
name:john\*
name:"john*"

The last two forms preserve a literal star; text analysis can still remove punctuation. name:* checks for an indexed value. Fieldless expressions use configured default fields or Elasticsearch's default fields when none are configured.

QueryValidationOptions.AllowLeadingWildcards defaults to true; set it to false to reject leading wildcard operators. Quoted and escaped literals remain allowed. See Wildcard Compatibility and cost controls.

Regex Queries ​

The parser recognizes regular expressions enclosed in forward slashes:

field:/regex/
csharp
// Parse a regex expression into the AST; this does not execute it
var result = parser.Parse("email:/.*@example\\.com/");

The default Elasticsearch query builder emits regexp queries for compatible mapped fields and preserves raw regex escapes. It rejects regex on known numeric, date, and boolean mappings before search. With no configured default fields it uses a term-only query_string regex. Patterns use Elasticsearch/Lucene automaton syntax and match indexed terms, not .NET regex semantics or arbitrary source text. Analyzer and mapping choices matter; see term modifier boundaries.

Range Queries ​

Range queries express bounds; field mappings determine how the backend interprets the values.

Bracket Syntax ​

SyntaxDescription
[min TO max]Inclusive on both ends
{min TO max}Exclusive on both ends
[min TO max}Inclusive min, exclusive max
{min TO max]Exclusive min, inclusive max

Examples:

csharp
// Inclusive range: 1 <= value <= 5
var result = parser.Parse("field:[1 TO 5]");

// Exclusive range: 1 < value < 5
result = parser.Parse("field:{1 TO 5}");

// Mixed: 1 <= value < 5
result = parser.Parse("field:[1 TO 5}");

Shorthand Syntax ​

Inside brackets or braces, use .. instead of TO. The brackets still determine inclusivity:

csharp
// Equivalent to field:[1 TO 5]
var result = parser.Parse("field:[1 .. 5]");

// Equivalent to field:{1 TO 5}
result = parser.Parse("field:{1 .. 5}");

Brackets are required

Bare field:1..5 is an ordinary term in Foundatio, not a range. Only the bracketed .. delimiter is a Foundatio extension. Use field:[1 TO 5] for an inclusive range that also uses Lucene classic and Elasticsearch query_string syntax. See Syntax Compatibility.

Unbounded Ranges ​

Use * for unbounded sides:

csharp
// All values before 2024
var result = parser.Parse("date:{* TO 2024-01-01}");

// All values 10 and above
result = parser.Parse("count:[10 TO *]");

Comparison Operators ​

Use comparison operators for single-sided ranges:

OperatorDescriptionEquivalent
>Greater than{value TO *}
>=Greater than or equal[value TO *]
<Less than{* TO value}
<=Less than or equal{* TO value]
csharp
// Greater than 10
var result = parser.Parse("age:>10");

// Greater than or equal to 10
result = parser.Parse("age:>=10");

// Less than 100
result = parser.Parse("price:<100");

// Less than or equal to 100
result = parser.Parse("price:<=100");

These comparison forms are supported by Elasticsearch query_string, but are not classic Lucene range syntax. Use the bracketed equivalents for a bare Lucene classic consumer.

Date-range Time Zones ​

On a mapped Elasticsearch date or date_nanos field, a caret suffix supplies a range time zone:

csharp
var result = parser.Parse("created:[2024-01-01 TO *]^\"America/Chicago\"");

The default Elasticsearch range builder assigns that value to time_zone, not boost. Do not use ^2 to boost a date range: it is interpreted as a time-zone value instead. The caret time-zone form is a Foundatio extension; for an external query_string request, configure its time_zone option separately. See Date-range Compatibility.

Quote timestamp bounds containing colons. To select a local calendar day, use the next local midnight as an exclusive upper bound:

text
created:["2024-03-10T00:00:00" TO "2024-03-11T00:00:00"}^"America/Chicago"

That day spans 23 hours because daylight saving time begins; the fall transition day spans 25. Do not substitute a fixed 24-hour interval. For date_nanos, keep timestamp strings at their original precision rather than converting them through a lower-precision application type.

Boolean Operators ​

Combine queries with boolean logic:

OperatorDescriptionAlternative
ANDBoth conditions must match&&
OREither condition must match||
NOTNegate the following condition!

For adjacent terms, Foundatio defaults to AND, while Elasticsearch query_string and bare Lucene classic default to OR. Set the default operator explicitly when moving queries between consumers.

Boolean syntax is not a cross-engine equivalence guarantee

Live comparison tests show different document sets for A OR NOT B and unparenthesized A OR B AND C. Use explicit parentheses for mixed positive operators, and translate the intended Boolean structure rather than forwarding negated disjunctions unchanged. Pure-negative queries also return the complement in Foundatio/Elasticsearch but no documents in bare Lucene classic. See Boolean Compatibility.

Examples ​

csharp
// AND - both must match
var result = parser.Parse("status:active AND type:user");

// OR - either must match
result = parser.Parse("status:active OR status:pending");

// NOT - exclude matches
result = parser.Parse("status:active AND NOT deleted:true");

// Complex boolean
result = parser.Parse("((status:active AND type:user) OR type:admin) AND NOT deleted:true");

Prefix Operators ​

The AST records required/excluded clause markers:

PrefixParsed meaning
+Required clause in the default Elasticsearch query builder
-Excluded marker
!Excluded marker
csharp
// Parse a required marker; this is not a query-execution assertion
var result = parser.Parse("+status:active");

// Parse an excluded marker
result = parser.Parse("-deleted:true");

// Parse combined markers
result = parser.Parse("+status:active -deleted:true type:user");

A clause is one search condition, such as status:active, or a parenthesized group of conditions. Required means a returned document must satisfy that condition. For example, with an OR default, +status:active category:premium means “show active records; matching premium can improve their ranking.” It must not return an inactive record just because it is premium.

In the default Elasticsearch query builder, + requires a clause at its enclosing Boolean scope. With an OR default, +text:alpha text:gamma requires alpha; gamma is optional for matching and contributes to scoring when scoring is enabled. With an AND default, both terms must match. A required group keeps its internal operator: +(text:alpha OR text:beta) requires either term, not both. Include expansion preserves the outer required or excluded marker. These execution guarantees apply to Elasticsearch; the AST markers alone do not establish equivalent behavior in other consumers. See the upgrade guidance for the changed result sets.

Parentheses establish the scope of required conditions. For example, category:premium OR status:active AND +region:us requires both active status and the US region. Writing category:premium OR (status:active AND +region:us) instead allows premium records through the separate OR alternative. Use parentheses to make mixed AND/OR expressions explicit; the parser's existing grouping rules are preserved.

-, !, and NOT all negate a clause, but the parser stores them on different node properties: NOT sets IsNegated while - and ! set Prefix. In query contexts, use the IsExcluded() extension method rather than checking either property directly. See Negation and Prefix Operators.

Write clause operators before the field name, with symbolic prefixes attached: +field:value, -field:value, !field:value, or NOT field:value. Operators immediately after the colon are rejected consistently for terms, groups, and ranges. Operators inside a field-scoped group, such as field:(-value) or field:(NOT value), remain valid and apply to the inner clause.

Breaking syntax correction: operator placement ​

Following the decision in issue #272, previously accepted post-colon terms and groups now produce a parse error. Update saved queries and query generators before upgrading; this correction requires a breaking-change release.

Rejected inputReplacement
field:-value-field:value
field:+value+field:value
field:!value!field:value
field:NOT valueNOT field:value
field:-(a OR b)-field:(a OR b)
field:NOT [1 TO 2]NOT field:[1 TO 2]

For literal values, preserve the value by quoting or escaping it: field:"-value", field:\-value, or field:"NOT value". Do not move a literal sign before the field. Signed range endpoints remain valid, for example field:[-5 TO -1] and field:>=-5. Signed aggregation option values also need quotes, for example @offset:"-6h".

The grammar is shared by queries, sorts, aggregations, and parsed include expressions. Audit stored expressions and custom query generators in each context; the number of affected consumer expressions cannot be inferred from this repository. This does not require grouping every value: -field:value remains valid. Use field:(-value) only when the operator should apply to an inner clause. For numeric equality with a literal negative value, quote it (price:"-5"); moving the sign to -price:5 instead means exclusion, not negative five.

LuceneQueryParser.Parse throws FormatException with a cursor and a message directing callers to put the operator before the field name. Elasticsearch and SQL ParseAsync return null and record that diagnostic in the supplied context. ElasticQueryParser.BuildQueryAsync throws QueryValidationException; SqlQueryParser.ToDynamicLinqAsync throws ValidationException. These are syntax errors, not empty result sets.

WARNING

The table above applies to query expressions only. In sort and aggregation expressions, + and - are ordering operators (ascending and descending) and the boolean negation operators NOT and ! are rejected as validation errors. See Building Sort and Sorting Bucket Aggregations.

Grouping ​

Use parentheses to group clauses and control precedence:

csharp
// Group OR conditions
var result = parser.Parse("(status:active OR status:pending) AND type:user");

// Nested groups
result = parser.Parse("((a:1 OR b:2) AND c:3) OR d:4");

Field Grouping ​

Apply a field to multiple values:

csharp
// Field applies to all terms in group
var result = parser.Parse("status:(active OR pending OR review)");

// A quoted multiword value remains one clause in the group
result = parser.Parse("city:(\"New York\" OR Madison)");

On an analyzed text field, "New York" is a phrase; on a keyword field it is a single literal value. This ordinary field grouping does not perform geographic resolution.

Date Math ​

Date fields support date math expressions for relative dates.

Anchor Date ​

Expressions start with an anchor:

  • now - Current date/time
  • 2024-01-01|| - Specific date followed by ||

Math Operations ​

OperationDescription
+1dAdd 1 day
-1dSubtract 1 day
/dRound down to day

Supported Units ​

UnitDescription
yYears
MMonths
wWeeks
dDays
h or HHours
mMinutes
sSeconds

Examples ​

Assuming current time is 2024-06-15 12:00:00:

ExpressionResult
now2024-06-15 12:00:00
now+1h2024-06-15 13:00:00
now-1d2024-06-14 12:00:00
now-7d2024-06-08 12:00:00
now/d2024-06-15 00:00:00
now-1M/M2024-05-01 00:00:00
csharp
// Last 7 days
var result = parser.Parse("created:[now-7d TO now]");

// Previous calendar month: inclusive start, exclusive end
result = parser.Parse("created:[now-1M/M TO now/M}");

// Future dates
result = parser.Parse("expires:[now TO now+30d]");

Geo Proximity Queries ​

Filter documents by geographic distance from a point.

Syntax ​

geofield:location~distance

Where:

  • location can be a geohash, coordinates, or resolvable location (zip code, city)
  • distance is a number followed by a unit (mi, km, m)

Examples ​

Quote a location containing spaces so it remains one value:

text
location:"New York, NY"~75mi
location:10001~10mi

The first query searches within 75 miles of the point resolved for New York, NY; the second searches within 10 miles of the point resolved for ZIP code 10001. Both need an application-provided lookup. They do not search city or ZIP-code boundaries or geocode the input automatically. Keep postal codes as strings to preserve leading zeros.

csharp
// Parse a quoted city name; the quotes are escaped inside a C# string
var result = parser.Parse("location:\"New York, NY\"~75mi");

// A ZIP code is one value and does not need quotes
result = parser.Parse("location:10001~10mi");

// Coordinates can be supplied directly (latitude, longitude)
result = parser.Parse("location:40.7128,-74.0060~10km");

Configuration ​

To generate an Elasticsearch geo-distance query with Foundatio, map the field as geo_point and enable the geo visitor through UseGeo. Place names and postal codes also need an application-provided resolver. This example supplies an in-memory mapping and two illustrative lookup points:

csharp
using System;
using Elastic.Clients.Elasticsearch.Mapping;
using Foundatio.Parsers.ElasticQueries;

using var mappings = new ElasticMappingResolver(() => new TypeMapping
{
    Properties = new Properties
    {
        { "location", new GeoPointProperty() }
    }
});

var parser = new ElasticQueryParser(c => c
    .UseMappings(mappings)
    .UseGeo(ResolveLocation));

var query = await parser.BuildQueryAsync("location:\"New York, NY\"~75mi");
query = await parser.BuildQueryAsync("location:10001~10mi");

static string ResolveLocation(string location)
{
    if (String.Equals(location, "New York, NY", StringComparison.OrdinalIgnoreCase))
        return "40.7128,-74.0060";

    if (String.Equals(location, "10001", StringComparison.Ordinal))
        return "40.7506,-73.9972";

    throw new ArgumentException("This example only resolves New York, NY and ZIP code 10001.", nameof(location));
}

The resolver receives New York, NY without the surrounding quotes, or the string 10001. In an application, use the actual index mappings and a resolver that handles the inputs you support, including coordinates or geohashes if offered. The example's in-memory mapping informs query generation; it does not create an Elasticsearch index.

Geo Range Queries ​

Filter documents within a geographic bounding box using the top-left and bottom-right coordinates:

text
geofield:[topLeft TO bottomRight]
csharp
// Approximate rectangle around New York City, in latitude,longitude order
var result = parser.Parse("location:[40.92,-74.26 TO 40.49,-73.70]");

The field must be mapped as geo_point and the geo visitor enabled to generate an Elasticsearch bounding-box query. Bounds accept coordinates or geohashes directly; the range visitor does not resolve city names. The rectangle is illustrative, not an exact city boundary.

Nested Document Queries ​

Enable nested-query handling with UseNested(). This example uses a configured ElasticsearchClient named client and an existing my-index index with comments mapped as nested:

csharp
using Foundatio.Parsers.ElasticQueries;

var parser = new ElasticQueryParser(c => c
    .UseMappings(client, "my-index")
    .UseNested());

// Query nested field - automatically wrapped in nested query
var query = await parser.BuildQueryAsync("comments.author:john");

// Grouped nested query
query = await parser.BuildQueryAsync("comments:(comments.author:john comments.text:hello)");

// Negated nested query
query = await parser.BuildQueryAsync("NOT comments:(comments.author:spammer)");

// Exists/missing on nested fields
query = await parser.BuildQueryAsync("_exists_:comments.author");
query = await parser.BuildQueryAsync("_missing_:comments");

Elasticsearch Limitation

Standard Elasticsearch query_string does not support nested documents. With the mappings and nested visitor configured, Foundatio.Parsers can detect nested fields and wrap queries appropriately. Term translation also applies to these nested field queries.

For a full explanation of how the AST is structured and traversed for nested queries, see Nested Queries and Visitor Traversal.

Boosting ​

The parser recognizes relevance-boost syntax:

csharp
// Parse a term boost into the AST
var result = parser.Parse("title:important^2");

// Parse a phrase boost into the AST
result = parser.Parse("title:\"very important\"^3");

The default Elasticsearch query builder applies finite non-negative boosts to terms, phrases, non-date ranges, and groups. Enable scoring with new ElasticQueryVisitorContext { UseScoring = true }; filter context intentionally yields zero scores. Mapped date ranges retain their caret-as-time-zone extension. See Matching and Scoring and Date-range Time Zones.

Fuzzy Queries ​

The parser recognizes ~ as fuzzy matching syntax (edit distance):

csharp
// Parse fuzzy syntax with the distance omitted
var result = parser.Parse("name:john~");

// Parse fuzzy syntax with a specific edit distance
result = parser.Parse("name:john~2");

The default Elasticsearch query builder accepts the numeric fuzzy distances 0, 1, or 2. These are Elasticsearch's supported numeric edit distances: 0 allows no character edits, 1 allows one, and 2 allows two. Edits include insertions, deletions, substitutions, and, by default, adjacent-character transpositions. Analyzed text uses match with fuzziness; keyword fields use fuzzy.

Bare ~ means 2, following Lucene's classic query parser default. It is a fixed distance, not Elasticsearch's length-dependent AUTO policy. Specify an explicit distance when comparing query consumers.

For length-dependent matching, the Elasticsearch query builder also accepts ~AUTO and custom thresholds. Elasticsearch generally recommends AUTO. The default thresholds (AUTO:3,6) allow zero edits for terms of length 0–2, one for 3–5, and two for 6 or more.

csharp
// Let Elasticsearch select the edit distance from the analyzed term length
var query = await elasticParser.BuildQueryAsync("name:john~AUTO");

// Escape the colon in query syntax; C# verbatim strings preserve the backslash
query = await elasticParser.BuildQueryAsync(@"name:john~AUTO\:2,4");

AUTO is case-insensitive. Custom thresholds are non-negative 32-bit integers with low <= high: term lengths below low allow zero edits, lengths below high allow one, and all longer terms allow two. Equal thresholds are allowed and skip the one-edit band. The colon must be escaped (\:) because an unescaped colon separates a field from its value in this grammar. These policies are an Elasticsearch translation extension; do not forward the suffix to Lucene's classic query parser or assume the SQL translator applies it. There is no global default-fuzziness setting; bare ~ remains distance 2.

Quoted phrases use match_phrase with a non-negative integer slop, for example title:"a b"~5. Slop measures token-position movement rather than character edits; zero requires an exact phrase, and transposing adjacent terms costs two positions. Bare phrase ~ means zero. See Elasticsearch's phrase-slop definition.

Fuzziness cannot be combined with wildcard or regex syntax, and group proximity is unsupported. Invalid modifiers produce query validation errors. See mapping and default-distance boundaries.

Escaping Special Characters ​

In unquoted terms and field names, a backslash can escape a literal space and these characters:

+ - ! ( ) { } [ ] ^ " ~ * ? : \ /

Dots do not need escaping: use field.with.dots:value. Writing field\.with\.dots:value throws FormatException from LuceneQueryParser.Parse because \. is not an allowed escape in an unquoted field name. The same restriction applies to unquoted term values. Backslashes before &, |, =, <, and > are also rejected there; those characters can appear unescaped within a term, although some have operator meanings in other positions.

Quoted values and regex bodies follow different rules. They accept these backslashes and preserve them in the raw AST Term; the UnescapedTerm property removes them:

Query textParsing result
field:a.bTerm value a.b
field:a\.bParse error
field:a&bTerm value a&b
field:a\&bParse error
field:"a\.b"Quoted term; raw value a\.b, unescaped value a.b
field:/a\.b/Regex term; raw value a\.b, unescaped value a.b

These rules come from the Foundatio grammar; they differ from Elasticsearch's reserved-character rules. AST parsing preserves the syntax; backend translation determines execution. The default Elasticsearch query builder now preserves literal wildcards and translates regex, while other backends have separate contracts. See Wildcard Queries and Regex Queries.

csharp
// Escape colon in value (the C# string needs a second backslash)
var result = parser.Parse("url:https\\://example.com");

// Escape parentheses and a space
result = parser.Parse("name:John\\ \\(Jr\\)");

Complete Example ​

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

var parser = new LuceneQueryParser();

// Parse and inspect the AST; backend behavior depends on configuration
string query = @"
    (status:active OR status:pending)
    AND created:[now-30d TO now]
    AND NOT deleted:true
    AND (name:john* OR email:john*)
";

var result = parser.Parse(query);

// Debug the AST
Console.WriteLine(DebugQueryVisitor.Run(result));

// Regenerate (normalized)
string normalized = GenerateQueryVisitor.Run(result);

Next Steps ​

Released under the Apache 2.0 License.