Skip to content

Commit 1ba7f19

Browse files
authored
Reintroduce suggestion feature (#7894)
1 parent 35b0426 commit 1ba7f19

File tree

9 files changed

+309
-2
lines changed

9 files changed

+309
-2
lines changed

src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public partial class SearchRequest
1111
{
1212
internal override void BeforeRequest()
1313
{
14-
if (Aggregations is not null)
14+
if (Aggregations is not null || Suggest is not null)
1515
{
1616
TypedKeys = true;
1717
}
@@ -54,7 +54,8 @@ public SearchRequestDescriptor<TDocument> Pit(string id, Action<Core.Search.Poin
5454

5555
internal override void BeforeRequest()
5656
{
57-
if (AggregationsValue is not null || AggregationsDescriptor is not null || AggregationsDescriptorAction is not null)
57+
if (AggregationsValue is not null || AggregationsDescriptor is not null || AggregationsDescriptorAction is not null ||
58+
SuggestValue is not null || SuggestDescriptor is not null || SuggestDescriptorAction is not null)
5859
{
5960
TypedKeys(true);
6061
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Reflection;
7+
using System.Text.Json.Serialization;
8+
9+
namespace Elastic.Clients.Elasticsearch.Serialization;
10+
11+
/// <summary>
12+
/// A custom <see cref="JsonConverterAttribute"/> used to dynamically create <see cref="JsonConverter"/>
13+
/// instances for generic classes and properties whose type arguments are unknown at compile time.
14+
/// </summary>
15+
internal class GenericConverterAttribute :
16+
JsonConverterAttribute
17+
{
18+
private readonly int _parameterCount;
19+
20+
/// <summary>
21+
/// The constructor.
22+
/// </summary>
23+
/// <param name="genericConverterType">The open generic type of the JSON converter class.</param>
24+
/// <param name="unwrap">
25+
/// Set <c>true</c> to unwrap the generic type arguments of the source/target type before using them to create
26+
/// the converter instance.
27+
/// <para>
28+
/// This is especially useful, if the base converter is e.g. defined as <c>MyBaseConverter{SomeType{T}}</c>
29+
/// but the annotated property already has the concrete type <c>SomeType{T}</c>. Unwrapping the generic
30+
/// arguments will make sure to not incorrectly instantiate a converter class of type
31+
/// <c>MyBaseConverter{SomeType{SomeType{T}}}</c>.
32+
/// </para>
33+
/// </param>
34+
/// <exception cref="ArgumentException">If <paramref name="genericConverterType"/> is not a compatible generic type definition.</exception>
35+
public GenericConverterAttribute(Type genericConverterType, bool unwrap = false)
36+
{
37+
if (!genericConverterType.IsGenericTypeDefinition)
38+
{
39+
throw new ArgumentException(
40+
$"The generic JSON converter type '{genericConverterType.Name}' is not a generic type definition.",
41+
nameof(genericConverterType));
42+
}
43+
44+
GenericConverterType = genericConverterType;
45+
Unwrap = unwrap;
46+
47+
_parameterCount = GenericConverterType.GetTypeInfo().GenericTypeParameters.Length;
48+
49+
if (!unwrap && (_parameterCount != 1))
50+
{
51+
throw new ArgumentException(
52+
$"The generic JSON converter type '{genericConverterType.Name}' must accept exactly 1 generic type " +
53+
$"argument",
54+
nameof(genericConverterType));
55+
}
56+
}
57+
58+
public Type GenericConverterType { get; }
59+
60+
public bool Unwrap { get; }
61+
62+
/// <inheritdoc cref="JsonConverterAttribute.CreateConverter"/>
63+
public override JsonConverter? CreateConverter(Type typeToConvert)
64+
{
65+
if (!Unwrap)
66+
return (JsonConverter)Activator.CreateInstance(GenericConverterType.MakeGenericType(typeToConvert));
67+
68+
var arguments = typeToConvert.GetGenericArguments();
69+
if (arguments.Length != _parameterCount)
70+
{
71+
throw new ArgumentException(
72+
$"The generic JSON converter type '{GenericConverterType.Name}' is not compatible with the target " +
73+
$"type '{typeToConvert.Name}'.",
74+
nameof(typeToConvert));
75+
}
76+
77+
return (JsonConverter)Activator.CreateInstance(GenericConverterType.MakeGenericType(arguments));
78+
}
79+
}

src/Elastic.Clients.Elasticsearch/Serialization/SingleOrManyCollectionConverter.cs

+2
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ internal class SingleOrManyCollectionConverter<TItem> : JsonConverter<ICollectio
1616

1717
public override void Write(Utf8JsonWriter writer, ICollection<TItem> value, JsonSerializerOptions options) =>
1818
SingleOrManySerializationHelper.Serialize<TItem>(value, writer, options);
19+
20+
public override bool CanConvert(Type typeToConvert) => true;
1921
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
10+
11+
using Elastic.Clients.Elasticsearch.Serialization;
12+
13+
namespace Elastic.Clients.Elasticsearch.Core.Search;
14+
15+
[GenericConverter(typeof(SuggestDictionaryConverter<>), unwrap:true)]
16+
public sealed partial class SuggestDictionary<TDocument> :
17+
IsAReadOnlyDictionary<string, IReadOnlyCollection<ISuggest>>
18+
{
19+
internal SuggestDictionary(IReadOnlyDictionary<string, IReadOnlyCollection<ISuggest>> backingDictionary) :
20+
base(backingDictionary)
21+
{
22+
}
23+
24+
public IReadOnlyCollection<TermSuggest>? GetTerm(string key) => TryGet<TermSuggest>(key);
25+
26+
public IReadOnlyCollection<PhraseSuggest>? GetPhrase(string key) => TryGet<PhraseSuggest>(key);
27+
28+
public IReadOnlyCollection<CompletionSuggest<TDocument>>? GetCompletion(string key) => TryGet<CompletionSuggest<TDocument>>(key);
29+
30+
private IReadOnlyCollection<TSuggest>? TryGet<TSuggest>(string key) where TSuggest : class, ISuggest =>
31+
BackingDictionary.TryGetValue(key, out var items) ? items.Cast<TSuggest>().ToArray() : null;
32+
}
33+
34+
internal sealed class SuggestDictionaryConverter<TDocument> :
35+
JsonConverter<SuggestDictionary<TDocument>>
36+
{
37+
public override SuggestDictionary<TDocument>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
38+
{
39+
var dictionary = new Dictionary<string, IReadOnlyCollection<ISuggest>>();
40+
41+
if (reader.TokenType != JsonTokenType.StartObject)
42+
return new SuggestDictionary<TDocument>(dictionary);
43+
44+
while (reader.Read())
45+
{
46+
if (reader.TokenType == JsonTokenType.EndObject)
47+
break;
48+
49+
// TODO: Future optimization, get raw bytes span and parse based on those
50+
var name = reader.GetString() ?? throw new JsonException("Key must not be 'null'.");
51+
52+
reader.Read();
53+
ReadVariant(ref reader, options, dictionary, name);
54+
}
55+
56+
return new SuggestDictionary<TDocument>(dictionary);
57+
}
58+
59+
public static void ReadVariant(ref Utf8JsonReader reader, JsonSerializerOptions options, Dictionary<string, IReadOnlyCollection<ISuggest>> dictionary, string name)
60+
{
61+
var nameParts = name.Split('#');
62+
63+
if (nameParts.Length != 2)
64+
throw new JsonException($"Unable to parse typed-key from suggestion name '{name}'");
65+
66+
var variantName = nameParts[0];
67+
switch (variantName)
68+
{
69+
case "term":
70+
{
71+
var suggest = JsonSerializer.Deserialize<TermSuggest[]>(ref reader, options);
72+
dictionary.Add(nameParts[1], suggest);
73+
break;
74+
}
75+
76+
case "phrase":
77+
{
78+
var suggest = JsonSerializer.Deserialize<PhraseSuggest[]>(ref reader, options);
79+
dictionary.Add(nameParts[1], suggest);
80+
break;
81+
}
82+
83+
case "completion":
84+
{
85+
var suggest = JsonSerializer.Deserialize<CompletionSuggest<TDocument>[]>(ref reader, options);
86+
dictionary.Add(nameParts[1], suggest);
87+
break;
88+
}
89+
90+
default:
91+
throw new Exception($"The suggest variant '{variantName}' in this response is currently not supported.");
92+
}
93+
}
94+
95+
public override void Write(Utf8JsonWriter writer, SuggestDictionary<TDocument> value, JsonSerializerOptions options) => throw new NotImplementedException();
96+
}
97+
98+
public interface ISuggest
99+
{
100+
}
101+
102+
public sealed partial class TermSuggest :
103+
ISuggest
104+
{
105+
[JsonInclude, JsonPropertyName("length")]
106+
public int Length { get; init; }
107+
108+
[JsonInclude, JsonPropertyName("offset")]
109+
public int Offset { get; init; }
110+
111+
[JsonInclude, JsonPropertyName("options"), SingleOrManyCollectionConverter(typeof(TermSuggestOption))]
112+
public IReadOnlyCollection<TermSuggestOption> Options { get; init; }
113+
114+
[JsonInclude, JsonPropertyName("text")]
115+
public string Text { get; init; }
116+
}
117+
118+
public sealed partial class TermSuggestOption
119+
{
120+
[JsonInclude, JsonPropertyName("collate_match")]
121+
public bool? CollateMatch { get; init; }
122+
123+
[JsonInclude, JsonPropertyName("freq")]
124+
public long Freq { get; init; }
125+
126+
[JsonInclude, JsonPropertyName("highlighted")]
127+
public string? Highlighted { get; init; }
128+
129+
[JsonInclude, JsonPropertyName("score")]
130+
public double Score { get; init; }
131+
132+
[JsonInclude, JsonPropertyName("text")]
133+
public string Text { get; init; }
134+
}
135+
136+
public sealed partial class PhraseSuggest :
137+
ISuggest
138+
{
139+
[JsonInclude, JsonPropertyName("length")]
140+
public int Length { get; init; }
141+
142+
[JsonInclude, JsonPropertyName("offset")]
143+
public int Offset { get; init; }
144+
145+
[JsonInclude, JsonPropertyName("options"), SingleOrManyCollectionConverter(typeof(PhraseSuggestOption))]
146+
public IReadOnlyCollection<PhraseSuggestOption> Options { get; init; }
147+
148+
[JsonInclude, JsonPropertyName("text")]
149+
public string Text { get; init; }
150+
}
151+
152+
public sealed partial class PhraseSuggestOption
153+
{
154+
[JsonInclude, JsonPropertyName("collate_match")]
155+
public bool? CollateMatch { get; init; }
156+
157+
[JsonInclude, JsonPropertyName("highlighted")]
158+
public string? Highlighted { get; init; }
159+
160+
[JsonInclude, JsonPropertyName("score")]
161+
public double Score { get; init; }
162+
163+
[JsonInclude, JsonPropertyName("text")]
164+
public string Text { get; init; }
165+
}
166+
167+
public sealed partial class CompletionSuggest<TDocument> :
168+
ISuggest
169+
{
170+
[JsonInclude, JsonPropertyName("length")]
171+
public int Length { get; init; }
172+
173+
[JsonInclude, JsonPropertyName("offset")]
174+
public int Offset { get; init; }
175+
176+
[JsonInclude, JsonPropertyName("options"), GenericConverter(typeof(SingleOrManyCollectionConverter<>), unwrap:true)]
177+
public IReadOnlyCollection<CompletionSuggestOption<TDocument>> Options { get; init; }
178+
179+
[JsonInclude, JsonPropertyName("text")]
180+
public string Text { get; init; }
181+
}
182+
183+
public sealed partial class CompletionSuggestOption<TDocument>
184+
{
185+
[JsonInclude, JsonPropertyName("_id")]
186+
public string? Id { get; init; }
187+
188+
[JsonInclude, JsonPropertyName("_index")]
189+
public string? Index { get; init; }
190+
191+
[JsonInclude, JsonPropertyName("_routing")]
192+
public string? Routing { get; init; }
193+
194+
[JsonInclude, JsonPropertyName("_score")]
195+
public double? Score0 { get; init; }
196+
197+
[JsonInclude, JsonPropertyName("_source")]
198+
[SourceConverter]
199+
public TDocument? Source { get; init; }
200+
201+
[JsonInclude, JsonPropertyName("collate_match")]
202+
public bool? CollateMatch { get; init; }
203+
204+
[JsonInclude, JsonPropertyName("contexts")]
205+
public IReadOnlyDictionary<string, IReadOnlyCollection<Context>>? Contexts { get; init; }
206+
207+
[JsonInclude, JsonPropertyName("fields")]
208+
public IReadOnlyDictionary<string, object>? Fields { get; init; }
209+
210+
[JsonInclude, JsonPropertyName("score")]
211+
public double? Score { get; init; }
212+
213+
[JsonInclude, JsonPropertyName("text")]
214+
public string Text { get; init; }
215+
}

src/Elastic.Clients.Elasticsearch/_Generated/Api/ScrollResponse.g.cs

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public sealed partial class ScrollResponse<TDocument> : ElasticsearchResponse
4747
public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; }
4848
[JsonInclude, JsonPropertyName("_shards")]
4949
public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; }
50+
[JsonInclude, JsonPropertyName("suggest")]
51+
public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary<TDocument>? Suggest { get; init; }
5052
[JsonInclude, JsonPropertyName("terminated_early")]
5153
public bool? TerminatedEarly { get; init; }
5254
[JsonInclude, JsonPropertyName("timed_out")]

src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchResponse.g.cs

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public sealed partial class SearchResponse<TDocument> : ElasticsearchResponse
4747
public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; }
4848
[JsonInclude, JsonPropertyName("_shards")]
4949
public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; }
50+
[JsonInclude, JsonPropertyName("suggest")]
51+
public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary<TDocument>? Suggest { get; init; }
5052
[JsonInclude, JsonPropertyName("terminated_early")]
5153
public bool? TerminatedEarly { get; init; }
5254
[JsonInclude, JsonPropertyName("timed_out")]

src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchTemplateResponse.g.cs

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public sealed partial class SearchTemplateResponse<TDocument> : ElasticsearchRes
4747
public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; }
4848
[JsonInclude, JsonPropertyName("_shards")]
4949
public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; }
50+
[JsonInclude, JsonPropertyName("suggest")]
51+
public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary<TDocument>? Suggest { get; init; }
5052
[JsonInclude, JsonPropertyName("terminated_early")]
5153
public bool? TerminatedEarly { get; init; }
5254
[JsonInclude, JsonPropertyName("timed_out")]

src/Elastic.Clients.Elasticsearch/_Generated/Types/AsyncSearch/AsyncSearch.g.cs

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ public sealed partial class AsyncSearch<TDocument>
6161
public string? PitId { get; init; }
6262
[JsonInclude, JsonPropertyName("profile")]
6363
public Elastic.Clients.Elasticsearch.Core.Search.Profile? Profile { get; init; }
64+
[JsonInclude, JsonPropertyName("suggest")]
65+
public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary<TDocument>? Suggest { get; init; }
6466
[JsonInclude, JsonPropertyName("terminated_early")]
6567
public bool? TerminatedEarly { get; init; }
6668
[JsonInclude, JsonPropertyName("timed_out")]

src/Elastic.Clients.Elasticsearch/_Generated/Types/Core/MSearch/MultiSearchItem.g.cs

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public sealed partial class MultiSearchItem<TDocument>
5151
public Elastic.Clients.Elasticsearch.Core.Search.Profile? Profile { get; init; }
5252
[JsonInclude, JsonPropertyName("status")]
5353
public int? Status { get; init; }
54+
[JsonInclude, JsonPropertyName("suggest")]
55+
public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary<TDocument>? Suggest { get; init; }
5456
[JsonInclude, JsonPropertyName("terminated_early")]
5557
public bool? TerminatedEarly { get; init; }
5658
[JsonInclude, JsonPropertyName("timed_out")]

0 commit comments

Comments
 (0)