diff --git a/src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs b/src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs index e271a12bdf8..cb756c5df90 100644 --- a/src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs +++ b/src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs @@ -11,7 +11,7 @@ public partial class SearchRequest { internal override void BeforeRequest() { - if (Aggregations is not null) + if (Aggregations is not null || Suggest is not null) { TypedKeys = true; } @@ -54,7 +54,8 @@ public SearchRequestDescriptor Pit(string id, Action +/// A custom used to dynamically create +/// instances for generic classes and properties whose type arguments are unknown at compile time. +/// +internal class GenericConverterAttribute : + JsonConverterAttribute +{ + private readonly int _parameterCount; + + /// + /// The constructor. + /// + /// The open generic type of the JSON converter class. + /// + /// Set true to unwrap the generic type arguments of the source/target type before using them to create + /// the converter instance. + /// + /// This is especially useful, if the base converter is e.g. defined as MyBaseConverter{SomeType{T}} + /// but the annotated property already has the concrete type SomeType{T}. Unwrapping the generic + /// arguments will make sure to not incorrectly instantiate a converter class of type + /// MyBaseConverter{SomeType{SomeType{T}}}. + /// + /// + /// If is not a compatible generic type definition. + public GenericConverterAttribute(Type genericConverterType, bool unwrap = false) + { + if (!genericConverterType.IsGenericTypeDefinition) + { + throw new ArgumentException( + $"The generic JSON converter type '{genericConverterType.Name}' is not a generic type definition.", + nameof(genericConverterType)); + } + + GenericConverterType = genericConverterType; + Unwrap = unwrap; + + _parameterCount = GenericConverterType.GetTypeInfo().GenericTypeParameters.Length; + + if (!unwrap && (_parameterCount != 1)) + { + throw new ArgumentException( + $"The generic JSON converter type '{genericConverterType.Name}' must accept exactly 1 generic type " + + $"argument", + nameof(genericConverterType)); + } + } + + public Type GenericConverterType { get; } + + public bool Unwrap { get; } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert) + { + if (!Unwrap) + return (JsonConverter)Activator.CreateInstance(GenericConverterType.MakeGenericType(typeToConvert)); + + var arguments = typeToConvert.GetGenericArguments(); + if (arguments.Length != _parameterCount) + { + throw new ArgumentException( + $"The generic JSON converter type '{GenericConverterType.Name}' is not compatible with the target " + + $"type '{typeToConvert.Name}'.", + nameof(typeToConvert)); + } + + return (JsonConverter)Activator.CreateInstance(GenericConverterType.MakeGenericType(arguments)); + } +} diff --git a/src/Elastic.Clients.Elasticsearch/Serialization/SingleOrManyCollectionConverter.cs b/src/Elastic.Clients.Elasticsearch/Serialization/SingleOrManyCollectionConverter.cs index 672db397f45..90beedf2efd 100644 --- a/src/Elastic.Clients.Elasticsearch/Serialization/SingleOrManyCollectionConverter.cs +++ b/src/Elastic.Clients.Elasticsearch/Serialization/SingleOrManyCollectionConverter.cs @@ -16,4 +16,6 @@ internal class SingleOrManyCollectionConverter : JsonConverter value, JsonSerializerOptions options) => SingleOrManySerializationHelper.Serialize(value, writer, options); + + public override bool CanConvert(Type typeToConvert) => true; } diff --git a/src/Elastic.Clients.Elasticsearch/Types/Core/Search/SuggestDictionary.cs b/src/Elastic.Clients.Elasticsearch/Types/Core/Search/SuggestDictionary.cs new file mode 100644 index 00000000000..74be84370f1 --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Types/Core/Search/SuggestDictionary.cs @@ -0,0 +1,215 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Elastic.Clients.Elasticsearch.Serialization; + +namespace Elastic.Clients.Elasticsearch.Core.Search; + +[GenericConverter(typeof(SuggestDictionaryConverter<>), unwrap:true)] +public sealed partial class SuggestDictionary : + IsAReadOnlyDictionary> +{ + internal SuggestDictionary(IReadOnlyDictionary> backingDictionary) : + base(backingDictionary) + { + } + + public IReadOnlyCollection? GetTerm(string key) => TryGet(key); + + public IReadOnlyCollection? GetPhrase(string key) => TryGet(key); + + public IReadOnlyCollection>? GetCompletion(string key) => TryGet>(key); + + private IReadOnlyCollection? TryGet(string key) where TSuggest : class, ISuggest => + BackingDictionary.TryGetValue(key, out var items) ? items.Cast().ToArray() : null; +} + +internal sealed class SuggestDictionaryConverter : + JsonConverter> +{ + public override SuggestDictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var dictionary = new Dictionary>(); + + if (reader.TokenType != JsonTokenType.StartObject) + return new SuggestDictionary(dictionary); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + // TODO: Future optimization, get raw bytes span and parse based on those + var name = reader.GetString() ?? throw new JsonException("Key must not be 'null'."); + + reader.Read(); + ReadVariant(ref reader, options, dictionary, name); + } + + return new SuggestDictionary(dictionary); + } + + public static void ReadVariant(ref Utf8JsonReader reader, JsonSerializerOptions options, Dictionary> dictionary, string name) + { + var nameParts = name.Split('#'); + + if (nameParts.Length != 2) + throw new JsonException($"Unable to parse typed-key from suggestion name '{name}'"); + + var variantName = nameParts[0]; + switch (variantName) + { + case "term": + { + var suggest = JsonSerializer.Deserialize(ref reader, options); + dictionary.Add(nameParts[1], suggest); + break; + } + + case "phrase": + { + var suggest = JsonSerializer.Deserialize(ref reader, options); + dictionary.Add(nameParts[1], suggest); + break; + } + + case "completion": + { + var suggest = JsonSerializer.Deserialize[]>(ref reader, options); + dictionary.Add(nameParts[1], suggest); + break; + } + + default: + throw new Exception($"The suggest variant '{variantName}' in this response is currently not supported."); + } + } + + public override void Write(Utf8JsonWriter writer, SuggestDictionary value, JsonSerializerOptions options) => throw new NotImplementedException(); +} + +public interface ISuggest +{ +} + +public sealed partial class TermSuggest : + ISuggest +{ + [JsonInclude, JsonPropertyName("length")] + public int Length { get; init; } + + [JsonInclude, JsonPropertyName("offset")] + public int Offset { get; init; } + + [JsonInclude, JsonPropertyName("options"), SingleOrManyCollectionConverter(typeof(TermSuggestOption))] + public IReadOnlyCollection Options { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} + +public sealed partial class TermSuggestOption +{ + [JsonInclude, JsonPropertyName("collate_match")] + public bool? CollateMatch { get; init; } + + [JsonInclude, JsonPropertyName("freq")] + public long Freq { get; init; } + + [JsonInclude, JsonPropertyName("highlighted")] + public string? Highlighted { get; init; } + + [JsonInclude, JsonPropertyName("score")] + public double Score { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} + +public sealed partial class PhraseSuggest : + ISuggest +{ + [JsonInclude, JsonPropertyName("length")] + public int Length { get; init; } + + [JsonInclude, JsonPropertyName("offset")] + public int Offset { get; init; } + + [JsonInclude, JsonPropertyName("options"), SingleOrManyCollectionConverter(typeof(PhraseSuggestOption))] + public IReadOnlyCollection Options { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} + +public sealed partial class PhraseSuggestOption +{ + [JsonInclude, JsonPropertyName("collate_match")] + public bool? CollateMatch { get; init; } + + [JsonInclude, JsonPropertyName("highlighted")] + public string? Highlighted { get; init; } + + [JsonInclude, JsonPropertyName("score")] + public double Score { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} + +public sealed partial class CompletionSuggest : + ISuggest +{ + [JsonInclude, JsonPropertyName("length")] + public int Length { get; init; } + + [JsonInclude, JsonPropertyName("offset")] + public int Offset { get; init; } + + [JsonInclude, JsonPropertyName("options"), GenericConverter(typeof(SingleOrManyCollectionConverter<>), unwrap:true)] + public IReadOnlyCollection> Options { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} + +public sealed partial class CompletionSuggestOption +{ + [JsonInclude, JsonPropertyName("_id")] + public string? Id { get; init; } + + [JsonInclude, JsonPropertyName("_index")] + public string? Index { get; init; } + + [JsonInclude, JsonPropertyName("_routing")] + public string? Routing { get; init; } + + [JsonInclude, JsonPropertyName("_score")] + public double? Score0 { get; init; } + + [JsonInclude, JsonPropertyName("_source")] + [SourceConverter] + public TDocument? Source { get; init; } + + [JsonInclude, JsonPropertyName("collate_match")] + public bool? CollateMatch { get; init; } + + [JsonInclude, JsonPropertyName("contexts")] + public IReadOnlyDictionary>? Contexts { get; init; } + + [JsonInclude, JsonPropertyName("fields")] + public IReadOnlyDictionary? Fields { get; init; } + + [JsonInclude, JsonPropertyName("score")] + public double? Score { get; init; } + + [JsonInclude, JsonPropertyName("text")] + public string Text { get; init; } +} diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Api/ScrollResponse.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Api/ScrollResponse.g.cs index 0a123a0fc18..cb30ef729b4 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Api/ScrollResponse.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Api/ScrollResponse.g.cs @@ -47,6 +47,8 @@ public sealed partial class ScrollResponse : ElasticsearchResponse public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; } [JsonInclude, JsonPropertyName("_shards")] public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; } + [JsonInclude, JsonPropertyName("suggest")] + public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary? Suggest { get; init; } [JsonInclude, JsonPropertyName("terminated_early")] public bool? TerminatedEarly { get; init; } [JsonInclude, JsonPropertyName("timed_out")] diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchResponse.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchResponse.g.cs index b3a8f8b3647..a25dd8b35d5 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchResponse.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchResponse.g.cs @@ -47,6 +47,8 @@ public sealed partial class SearchResponse : ElasticsearchResponse public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; } [JsonInclude, JsonPropertyName("_shards")] public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; } + [JsonInclude, JsonPropertyName("suggest")] + public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary? Suggest { get; init; } [JsonInclude, JsonPropertyName("terminated_early")] public bool? TerminatedEarly { get; init; } [JsonInclude, JsonPropertyName("timed_out")] diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchTemplateResponse.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchTemplateResponse.g.cs index a4a15ab1159..b15e534c33f 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchTemplateResponse.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Api/SearchTemplateResponse.g.cs @@ -47,6 +47,8 @@ public sealed partial class SearchTemplateResponse : ElasticsearchRes public Elastic.Clients.Elasticsearch.ScrollId? ScrollId { get; init; } [JsonInclude, JsonPropertyName("_shards")] public Elastic.Clients.Elasticsearch.ShardStatistics Shards { get; init; } + [JsonInclude, JsonPropertyName("suggest")] + public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary? Suggest { get; init; } [JsonInclude, JsonPropertyName("terminated_early")] public bool? TerminatedEarly { get; init; } [JsonInclude, JsonPropertyName("timed_out")] diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Types/AsyncSearch/AsyncSearch.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Types/AsyncSearch/AsyncSearch.g.cs index 1f7bf29aef8..964f6a44071 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Types/AsyncSearch/AsyncSearch.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Types/AsyncSearch/AsyncSearch.g.cs @@ -61,6 +61,8 @@ public sealed partial class AsyncSearch public string? PitId { get; init; } [JsonInclude, JsonPropertyName("profile")] public Elastic.Clients.Elasticsearch.Core.Search.Profile? Profile { get; init; } + [JsonInclude, JsonPropertyName("suggest")] + public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary? Suggest { get; init; } [JsonInclude, JsonPropertyName("terminated_early")] public bool? TerminatedEarly { get; init; } [JsonInclude, JsonPropertyName("timed_out")] diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Core/MSearch/MultiSearchItem.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Core/MSearch/MultiSearchItem.g.cs index 5c4d4beda5d..2e8f80e0748 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Core/MSearch/MultiSearchItem.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Core/MSearch/MultiSearchItem.g.cs @@ -51,6 +51,8 @@ public sealed partial class MultiSearchItem public Elastic.Clients.Elasticsearch.Core.Search.Profile? Profile { get; init; } [JsonInclude, JsonPropertyName("status")] public int? Status { get; init; } + [JsonInclude, JsonPropertyName("suggest")] + public Elastic.Clients.Elasticsearch.Core.Search.SuggestDictionary? Suggest { get; init; } [JsonInclude, JsonPropertyName("terminated_early")] public bool? TerminatedEarly { get; init; } [JsonInclude, JsonPropertyName("timed_out")]