Migrating to Elastic.Clients.Elasticsearch
This guide covers breaking changes when upgrading Foundatio.Parsers.ElasticQueries from NEST (7.x) to Elastic.Clients.Elasticsearch (8.x). This is a major version bump to Foundatio.Parsers 8.0.
Note: Foundatio.Parsers 8.0 uses the
Elastic.Clients.Elasticsearch8.19.x package and is validated against Elasticsearch 9.x servers. The new .NET client package (8.x) is the official successor to NEST and supports both Elasticsearch 8.x and 9.x clusters.
Package Changes
Before:
<PackageReference Include="NEST" Version="7.17.5" />After:
<PackageReference Include="Foundatio.Parsers.ElasticQueries" Version="8.x" />
<!-- Transitively brings in Elastic.Clients.Elasticsearch -->Namespace Changes
Remove old NEST namespaces and add new ones:
// Remove:
using Nest;
// Add (as needed):
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.QueryDsl;
using Elastic.Clients.Elasticsearch.Mapping;
using Elastic.Clients.Elasticsearch.Aggregations;
using Elastic.Clients.Elasticsearch.IndexManagement;Client Type Changes
| Before (NEST) | After (new client) |
|---|---|
IElasticClient | ElasticsearchClient |
ElasticClient | ElasticsearchClient |
ConnectionSettings | ElasticsearchClientSettings |
Before:
var client = new ElasticClient(new ConnectionSettings(new Uri("http://localhost:9200")));After:
var client = new ElasticsearchClient(new ElasticsearchClientSettings(new Uri("http://localhost:9200")));ElasticQueryParser API Changes
BuildQueryAsync
Returns Query instead of QueryContainer:
Before:
QueryContainer query = await parser.BuildQueryAsync("status:active");After:
Query query = await parser.BuildQueryAsync("status:active");BuildAggregationsAsync
Returns AggregationMap instead of AggregationContainer. Use .ToDictionary() to get the Elasticsearch-native dictionary or the Aggregations() extension method on the search descriptor:
Before:
AggregationContainer aggs = await parser.BuildAggregationsAsync("terms:status");
var response = await client.SearchAsync<MyDoc>(s => s
.Aggregations(aggs));After:
using Foundatio.Parsers.ElasticQueries.Extensions;
AggregationMap aggs = await parser.BuildAggregationsAsync("terms:status");
var response = await client.SearchAsync<MyDoc>(s => s
.Aggregations(aggs));BuildSortAsync
Returns ICollection<SortOptions> instead of IEnumerable<IFieldSort>:
Before:
IEnumerable<IFieldSort> sorts = await parser.BuildSortAsync("created:-1");
var response = await client.SearchAsync<MyDoc>(s => s
.Sort(sorts));After:
ICollection<SortOptions> sorts = await parser.BuildSortAsync("created:-1");
var response = await client.SearchAsync<MyDoc>(s => s
.Sort(sorts));TIP
The deleted SearchDescriptorExtensions.Sort() extension is no longer needed. The new client's SearchRequestDescriptor<T>.Sort() accepts ICollection<SortOptions> natively.
UseMappings
The mapping builder parameter changed from Func (return-based) to Action (mutation-based), and the client type changed:
Before:
var parser = new ElasticQueryParser(c => c
.UseMappings<MyDoc>(
m => m.Properties(p => p
.Text(t => t.Name(n => n.Title))
.Keyword(k => k.Name(n => n.Status))),
client));After:
var parser = new ElasticQueryParser(c => c
.UseMappings<MyDoc>(
m => m.Properties(p => p
.Text(n => n.Title)
.Keyword(n => n.Status)),
client));Other UseMappings overload changes:
| Before | After |
|---|---|
UseMappings<T>(Func<..., ...> builder, IElasticClient) | UseMappings<T>(Action<...> builder, ElasticsearchClient) |
UseMappings<T>(Func<..., ...> builder, Inferrer, Func<ITypeMapping>) | UseMappings<T>(Action<...> builder, Inferrer, Func<TypeMapping>) |
UseMappings<T>(IElasticClient) | UseMappings<T>(ElasticsearchClient) |
UseMappings(IElasticClient, string) | UseMappings(ElasticsearchClient, string) |
UseMappings(Func<ITypeMapping>, Inferrer) | UseMappings(Func<TypeMapping>, Inferrer) |
Query Type Changes
All query types changed from object-initializer style to constructor-based:
| Before (NEST) | After (new client) |
|---|---|
new MatchQuery { Field = "f", Query = "v" } | new MatchQuery("f", "v") |
new MatchPhraseQuery { Field = "f", Query = "v" } | new MatchPhraseQuery("f", "v") |
new TermQuery { Field = "f", Value = "v" } | new TermQuery("f", "v") |
new PrefixQuery { Field = "f", Value = "v" } | new PrefixQuery("f", "v") |
new ExistsQuery { Field = "f" } | new ExistsQuery("f") |
new NestedQuery { Path = "p", Query = q } | new NestedQuery("p", q) |
new GeoDistanceQuery { Field = "f", Location = loc, Distance = d } | new GeoDistanceQuery(distance, field, location) |
new DateRangeQuery { Field = "f", GreaterThanOrEqualTo = v } | new DateRangeQuery("f") { Gte = v } |
new DateRangeQuery { Field = "f", LessThan = v } | new DateRangeQuery("f") { Lt = v } |
new TermRangeQuery { Field = "f", GreaterThan = v } | new TermRangeQuery("f") { Gt = v } |
new TermRangeQuery { Field = "f", LessThanOrEqualTo = v } | new TermRangeQuery("f") { Lte = v } |
new QueryStringQuery { Fields = f, Query = q } | new QueryStringQuery(q) { Fields = f } |
new MultiMatchQuery { Fields = f, Query = q } | new MultiMatchQuery(q) { Fields = f } |
QueryBase | Query |
QueryContainer | Query |
Bool Query Operators
The & and | operators still work on Query, but note that | (OR) no longer implicitly sets minimum_should_match: 1. The parser handles this automatically, but if you build bool queries manually in custom visitors, you must set it explicitly:
var boolQuery = new BoolQuery
{
Should = new List<Query> { queryA, queryB },
MinimumShouldMatch = 1
};Aggregation Changes
AggregationBase and AggregationContainer are replaced by AggregationMap, a custom intermediate type that bridges the gap between the parser's tree-building phase and the new client's discriminated-union Aggregation type.
Before:
AggregationContainer aggs = await parser.BuildAggregationsAsync("terms:status");After:
AggregationMap aggs = await parser.BuildAggregationsAsync("terms:status");
// Convert to ES client dictionary when needed:
IDictionary<string, Aggregation> dict = aggs.ToDictionary();Sort Changes
| Before (NEST) | After (new client) |
|---|---|
IFieldSort | SortOptions |
ISort | SortOptions |
SortOrder.Ascending | SortOrder.Asc |
SortOrder.Descending | SortOrder.Desc |
The SearchDescriptorExtensions.Sort() extension method has been removed. Use the native SearchRequestDescriptor<T>.Sort() method directly.
Property Mapping Changes
The new client uses a simpler expression-based syntax:
| Before (NEST) | After (new client) |
|---|---|
.Keyword(f => f.Name(e => e.Id)) | .Keyword(e => e.Id) |
.Text(f => f.Name(e => e.Name)) | .Text(e => e.Name) |
.Number(f => f.Name(e => e.Age).Type(NumberType.Integer)) | .IntegerNumber(e => e.Age) |
.Date(f => f.Name(e => e.Created)) | .Date(e => e.Created) |
.Boolean(f => f.Name(e => e.Active)) | .Boolean(e => e.Active) |
.Object<T>(f => f.Name(e => e.Address).Properties(...)) | .Object(e => e.Address, o => o.Properties(...)) |
.Nested<T>(f => f.Name(e => e.Items).Properties(...)) | .Nested(e => e.Items, n => n.Properties(...)) |
.Dynamic(false) | .Dynamic(DynamicMapping.False) |
m.AutoMap() | (removed — define all properties explicitly) |
Custom Visitor Migration
If you have custom visitors that set queries, aggregations, or sorts on nodes, update the types:
Before:
public class MyVisitor : ChainableQueryVisitor
{
public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
{
QueryBase query = new TermQuery { Field = "status", Value = "active" };
node.SetQuery(query);
}
}After:
public class MyVisitor : ChainableQueryVisitor
{
public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context)
{
Query query = new TermQuery("status", "active");
node.SetQuery(query);
}
}Key type changes in the visitor API:
| Method / Property | Before | After |
|---|---|---|
SetQuery() | QueryBase | Query |
GetQueryAsync() | Task<QueryBase> | Task<Query> |
SetAggregation() | AggregationBase | AggregationMap |
GetAggregationAsync() | Task<AggregationBase> | Task<AggregationMap> |
SetSort() | IFieldSort | SortOptions |
GetSort() | IFieldSort | SortOptions |
IElasticQueryVisitorContext Changes
A new property was added to the IElasticQueryVisitorContext interface:
Func<string, Task<string>> GeoLocationResolver { get; set; }This enables per-request geo location resolution (e.g., converting zip codes to lat/lon). If you implement IElasticQueryVisitorContext directly (rather than inheriting from ElasticQueryVisitorContext), you must add this property.
Response Validation
| Before (NEST) | After (new client) |
|---|---|
response.IsValid | response.IsValidResponse |
Behavioral Changes
These are not breaking API changes but produce different (improved) Elasticsearch JSON:
Filter-mode queries are flatter
Non-scoring queries now produce flat bool { filter: [a, b, c] } instead of nested bool { filter: [bool { must: [a, b, c] }] }. Functionally equivalent but cleaner.
Explicit minimum_should_match on OR queries
NEST's | operator implicitly set minimum_should_match: 1. The new Elastic.Clients.Elasticsearch client does not, so the parser now explicitly sets it on root-level OR queries and parenthesized OR groups inside AND context.
Nested query negation
Negated nested terms (e.g., NOT nested.field:value) now negate the entire NestedQuery rather than the inner query. This is a correctness fix — the new behavior correctly excludes documents that have any matching nested object.
Integer fields map to long
The new client's auto-mapping maps C# int properties to long instead of integer. This means @field_type metadata on aggregations will report "long" instead of "integer" for int-typed fields.
Migration Checklist
- [ ] Replace
using Nest;withusing Elastic.Clients.Elasticsearch;(and sub-namespaces) - [ ] Replace
IElasticClient/ElasticClientwithElasticsearchClient - [ ] Replace
ConnectionSettingswithElasticsearchClientSettings - [ ] Update
BuildQueryAsyncreturn type fromQueryContainertoQuery - [ ] Update
BuildAggregationsAsyncreturn type fromAggregationContainertoAggregationMap - [ ] Update
BuildSortAsyncreturn type fromIEnumerable<IFieldSort>toICollection<SortOptions> - [ ] Update
UseMappingscalls (changeFunc<..., ...>toAction<...>, change client type) - [ ] Update property mapping syntax (remove
.Name(e => e.Prop)wrapper) - [ ] Replace
AutoMap()calls with explicit property mappings - [ ] Replace
.Dynamic(false)with.Dynamic(DynamicMapping.False) - [ ] Replace
response.IsValidwithresponse.IsValidResponse - [ ] Update custom visitors to use
Queryinstead ofQueryBase/QueryContainer - [ ] Update custom visitors to use
AggregationMapinstead ofAggregationBase - [ ] Update custom visitors to use
SortOptionsinstead ofIFieldSort - [ ] Add
GeoLocationResolverproperty if directly implementingIElasticQueryVisitorContext - [ ] Remove
SearchDescriptorExtensions.Sort()usage (use native.Sort()) - [ ] Remove NEST package reference
- [ ] Update Elasticsearch/Kibana Docker images to 9.x