Skip to content

Reintroduce suggestion feature #7894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Elastic.Clients.Elasticsearch/Api/SearchRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -54,7 +54,8 @@ public SearchRequestDescriptor<TDocument> Pit(string id, Action<Core.Search.Poin

internal override void BeforeRequest()
{
if (AggregationsValue is not null || AggregationsDescriptor is not null || AggregationsDescriptorAction is not null)
if (AggregationsValue is not null || AggregationsDescriptor is not null || AggregationsDescriptorAction is not null ||
SuggestValue is not null || SuggestDescriptor is not null || SuggestDescriptorAction is not null)
{
TypedKeys(true);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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.Reflection;
using System.Text.Json.Serialization;

namespace Elastic.Clients.Elasticsearch.Serialization;

/// <summary>
/// A custom <see cref="JsonConverterAttribute"/> used to dynamically create <see cref="JsonConverter"/>
/// instances for generic classes and properties whose type arguments are unknown at compile time.
/// </summary>
internal class GenericConverterAttribute :
JsonConverterAttribute
{
private readonly int _parameterCount;

/// <summary>
/// The constructor.
/// </summary>
/// <param name="genericConverterType">The open generic type of the JSON converter class.</param>
/// <param name="unwrap">
/// Set <c>true</c> to unwrap the generic type arguments of the source/target type before using them to create
/// the converter instance.
/// <para>
/// This is especially useful, if the base converter is e.g. defined as <c>MyBaseConverter{SomeType{T}}</c>
/// but the annotated property already has the concrete type <c>SomeType{T}</c>. Unwrapping the generic
/// arguments will make sure to not incorrectly instantiate a converter class of type
/// <c>MyBaseConverter{SomeType{SomeType{T}}}</c>.
/// </para>
/// </param>
/// <exception cref="ArgumentException">If <paramref name="genericConverterType"/> is not a compatible generic type definition.</exception>
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; }

/// <inheritdoc cref="JsonConverterAttribute.CreateConverter"/>
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ internal class SingleOrManyCollectionConverter<TItem> : JsonConverter<ICollectio

public override void Write(Utf8JsonWriter writer, ICollection<TItem> value, JsonSerializerOptions options) =>
SingleOrManySerializationHelper.Serialize<TItem>(value, writer, options);

public override bool CanConvert(Type typeToConvert) => true;
}
Original file line number Diff line number Diff line change
@@ -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<TDocument> :
IsAReadOnlyDictionary<string, IReadOnlyCollection<ISuggest>>
{
public SuggestDictionary(IReadOnlyDictionary<string, IReadOnlyCollection<ISuggest>> backingDictionary) :
base(backingDictionary)
{
}

public IReadOnlyCollection<TermSuggest>? GetTerm(string key) => TryGet<TermSuggest>(key);

public IReadOnlyCollection<PhraseSuggest>? GetPhrase(string key) => TryGet<PhraseSuggest>(key);

public IReadOnlyCollection<CompletionSuggest<TDocument>>? GetCompletion(string key) => TryGet<CompletionSuggest<TDocument>>(key);

private IReadOnlyCollection<TSuggest>? TryGet<TSuggest>(string key) where TSuggest : class, ISuggest =>
BackingDictionary.TryGetValue(key, out var items) ? items.Cast<TSuggest>().ToArray() : null;
}

internal sealed class SuggestDictionaryConverter<TDocument> :
JsonConverter<SuggestDictionary<TDocument>>
{
public override SuggestDictionary<TDocument>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var dictionary = new Dictionary<string, IReadOnlyCollection<ISuggest>>();

if (reader.TokenType != JsonTokenType.StartObject)
return new SuggestDictionary<TDocument>(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<TDocument>(dictionary);
}

public static void ReadVariant(ref Utf8JsonReader reader, JsonSerializerOptions options, Dictionary<string, IReadOnlyCollection<ISuggest>> 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<TermSuggest[]>(ref reader, options);
dictionary.Add(nameParts[1], suggest);
break;
}

case "phrase":
{
var suggest = JsonSerializer.Deserialize<PhraseSuggest[]>(ref reader, options);
dictionary.Add(nameParts[1], suggest);
break;
}

case "completion":
{
var suggest = JsonSerializer.Deserialize<CompletionSuggest<TDocument>[]>(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<TDocument> 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<TermSuggestOption> 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<PhraseSuggestOption> 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<TDocument> :
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<CompletionSuggestOption<TDocument>> Options { get; init; }

[JsonInclude, JsonPropertyName("text")]
public string Text { get; init; }
}

public sealed partial class CompletionSuggestOption<TDocument>
{
[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<string, IReadOnlyCollection<Context>>? Contexts { get; init; }

[JsonInclude, JsonPropertyName("fields")]
public IReadOnlyDictionary<string, object>? Fields { get; init; }

[JsonInclude, JsonPropertyName("score")]
public double? Score { get; init; }

[JsonInclude, JsonPropertyName("text")]
public string Text { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public sealed partial class ScrollResponse<TDocument> : 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<TDocument>? Suggest { get; init; }
[JsonInclude, JsonPropertyName("terminated_early")]
public bool? TerminatedEarly { get; init; }
[JsonInclude, JsonPropertyName("timed_out")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public sealed partial class SearchResponse<TDocument> : 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<TDocument>? Suggest { get; init; }
[JsonInclude, JsonPropertyName("terminated_early")]
public bool? TerminatedEarly { get; init; }
[JsonInclude, JsonPropertyName("timed_out")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public sealed partial class SearchTemplateResponse<TDocument> : 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<TDocument>? Suggest { get; init; }
[JsonInclude, JsonPropertyName("terminated_early")]
public bool? TerminatedEarly { get; init; }
[JsonInclude, JsonPropertyName("timed_out")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public sealed partial class AsyncSearch<TDocument>
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<TDocument>? Suggest { get; init; }
[JsonInclude, JsonPropertyName("terminated_early")]
public bool? TerminatedEarly { get; init; }
[JsonInclude, JsonPropertyName("timed_out")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public sealed partial class MultiSearchItem<TDocument>
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<TDocument>? Suggest { get; init; }
[JsonInclude, JsonPropertyName("terminated_early")]
public bool? TerminatedEarly { get; init; }
[JsonInclude, JsonPropertyName("timed_out")]
Expand Down