Skip to content

Commit 0743565

Browse files
committed
Reintroduce suggestion feature
1 parent 736c4cd commit 0743565

File tree

9 files changed

+301
-2
lines changed

9 files changed

+301
-2
lines changed

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

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

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

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

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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 0 deletions
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)