diff --git a/RediSearchClient.SampleData/Dimensions.cs b/RediSearchClient.SampleData/Dimensions.cs new file mode 100644 index 0000000..d8ad7ce --- /dev/null +++ b/RediSearchClient.SampleData/Dimensions.cs @@ -0,0 +1,11 @@ +namespace RediSearchClient.SampleData.TestingTypes +{ + public class Dimensions + { + public float Length { get; set; } + + public float Width { get; set; } + + public float Height { get; set; } + } +} \ No newline at end of file diff --git a/RediSearchClient.SampleData/Product.cs b/RediSearchClient.SampleData/Product.cs new file mode 100644 index 0000000..fe628b2 --- /dev/null +++ b/RediSearchClient.SampleData/Product.cs @@ -0,0 +1,31 @@ +using RediSearchClient.Attributes; +using System; +using System.Collections.Generic; + +namespace RediSearchClient.SampleData.TestingTypes +{ + [DefaultNoIndex] + public class Product + { + [Tag] + [Index] + [Alias("id")] + public string Identifier { get; set; } + + [Sortable] + public decimal Price { get; set; } + + public string Meta { get; set; } + + [SchemaIgnore] + public int Count { get; set; } + + public Dimensions Dimensions { get; set; } + + public ICollection Sellers { get; set; } + + public Product RelatedProduct { get; set; } //Will be ignored to avoid recursion + + public DateTime DateAdded { get; set; } + } +} \ No newline at end of file diff --git a/RediSearchClient.SampleData/Program.cs b/RediSearchClient.SampleData/Program.cs index 77a25c3..8b9ce97 100644 --- a/RediSearchClient.SampleData/Program.cs +++ b/RediSearchClient.SampleData/Program.cs @@ -5,6 +5,10 @@ using System; using System.Linq; using NReJSON; +using RediSearchClient.SampleData.TestingTypes; +using System.Collections.Generic; +using RediSearchClient.Query; +using System.Text.Json; using var muxr = ConnectionMultiplexer.Connect("localhost"); var db = muxr.GetDatabase(); @@ -178,4 +182,51 @@ , x => x.Text("$.Prizes[*].affiliations[*].country", "InstitutionCountry") ) .Build() -); \ No newline at end of file +); + +var indexName = "product-data-index"; + +if (db.ListIndexes().Any(x => x == indexName)) +{ + db.DropIndex(indexName); +} + +await db.CreateIndexAsync("product-doc:", indexName); + +var product = new Product +{ + Identifier = "testprod1", + Meta = "test meta", + Price = 10.3m, + Dimensions = new Dimensions + { + Width = 10, + Length = 11, + Height = 12 + }, + Sellers = new List + { + new Seller + { + Name = "seller1", + Locations = new List {"location1", "location2"} + }, + new Seller + { + Name = "seller2", + Locations = new List {"location3", "location4"} + } + }, + DateAdded = DateTime.Now +}; + +await db.RediSearchJsonSetAsync(product, $"product-doc:{product.Identifier}"); + +var query = RediSearchQuery + .On(indexName) + .UsingQuery("@id:{ testprod1 }") + .Return("id", "Price", "$.Dimensions", "$.Sellers[*].Name", "$.Sellers[*].Locations", "DateAdded") + .Dialect(3) + .Build(); + +var queryResult = await db.SearchAsync(query); \ No newline at end of file diff --git a/RediSearchClient.SampleData/Seller.cs b/RediSearchClient.SampleData/Seller.cs new file mode 100644 index 0000000..d06993e --- /dev/null +++ b/RediSearchClient.SampleData/Seller.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace RediSearchClient.SampleData.TestingTypes +{ + public class Seller + { + public string Name { get; set; } + + public IEnumerable SoldProducts { get; set; } //Will be ignored to avoid recursion + + public IEnumerable Locations { get; set; } + } +} \ No newline at end of file diff --git a/RediSearchClient/Attributes/AliasAttribute.cs b/RediSearchClient/Attributes/AliasAttribute.cs new file mode 100644 index 0000000..0c3588b --- /dev/null +++ b/RediSearchClient/Attributes/AliasAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class AliasAttribute : Attribute + { + public string Name { get; private set; } + + public AliasAttribute(string name) + { + Name = name; + } + } +} diff --git a/RediSearchClient/Attributes/DefaultNoIndexAttribute.cs b/RediSearchClient/Attributes/DefaultNoIndexAttribute.cs new file mode 100644 index 0000000..efd6af2 --- /dev/null +++ b/RediSearchClient/Attributes/DefaultNoIndexAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace RediSearchClient.Attributes +{ + /// + /// All properties are indexable by default. This attribute will specify that properties on a type that is used to create a RediSearch JSON Schema are non-indexable by default, use [Index] on target properties to override this behavior + /// + public sealed class DefaultNoIndexAttribute : Attribute + { + + } +} diff --git a/RediSearchClient/Attributes/IndexAttribute.cs b/RediSearchClient/Attributes/IndexAttribute.cs new file mode 100644 index 0000000..709c8cd --- /dev/null +++ b/RediSearchClient/Attributes/IndexAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace RediSearchClient.Attributes +{ + /// + /// Use to specify if a property is indexable, defaults to true + /// + public sealed class IndexAttribute : Attribute + { + public bool Value { get; private set; } + + public IndexAttribute(bool value) + { + Value = value; + } + + public IndexAttribute() + { + Value = true; + } + } +} diff --git a/RediSearchClient/Attributes/NonStemmableAttribute.cs b/RediSearchClient/Attributes/NonStemmableAttribute.cs new file mode 100644 index 0000000..5b1aac2 --- /dev/null +++ b/RediSearchClient/Attributes/NonStemmableAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class NonStemmableAttribute : Attribute + { + public NonStemmableAttribute() + { + } + } +} diff --git a/RediSearchClient/Attributes/PhoneticAttribute.cs b/RediSearchClient/Attributes/PhoneticAttribute.cs new file mode 100644 index 0000000..b6a2f67 --- /dev/null +++ b/RediSearchClient/Attributes/PhoneticAttribute.cs @@ -0,0 +1,15 @@ +using RediSearchClient.Indexes; +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class PhoneticAttribute : Attribute + { + public Language Language { get; private set; } + + public PhoneticAttribute(Language language) + { + Language = language; + } + } +} diff --git a/RediSearchClient/Attributes/SchemaIgnoreAttribute.cs b/RediSearchClient/Attributes/SchemaIgnoreAttribute.cs new file mode 100644 index 0000000..e7f90cb --- /dev/null +++ b/RediSearchClient/Attributes/SchemaIgnoreAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class SchemaIgnoreAttribute : Attribute + { + } +} diff --git a/RediSearchClient/Attributes/SortableAttribute.cs b/RediSearchClient/Attributes/SortableAttribute.cs new file mode 100644 index 0000000..637118e --- /dev/null +++ b/RediSearchClient/Attributes/SortableAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class SortableAttribute : Attribute + { + public SortableAttribute() + { + } + } +} diff --git a/RediSearchClient/Attributes/TagAttribute.cs b/RediSearchClient/Attributes/TagAttribute.cs new file mode 100644 index 0000000..e98001b --- /dev/null +++ b/RediSearchClient/Attributes/TagAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class TagAttribute : Attribute + { + public string Separator { get; private set; } = ","; + + public TagAttribute(string separator) + { + Separator = separator; + } + + public TagAttribute() + { + + } + } +} diff --git a/RediSearchClient/Attributes/WeightAttribute.cs b/RediSearchClient/Attributes/WeightAttribute.cs new file mode 100644 index 0000000..643df8a --- /dev/null +++ b/RediSearchClient/Attributes/WeightAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace RediSearchClient.Attributes +{ + public sealed class WeightAttribute : Attribute + { + public int Weight { get; private set; } + + public WeightAttribute(int weight) + { + Weight = weight; + } + } +} diff --git a/RediSearchClient/Converters/DateTimeToNumericConverter.cs b/RediSearchClient/Converters/DateTimeToNumericConverter.cs new file mode 100644 index 0000000..b766b87 --- /dev/null +++ b/RediSearchClient/Converters/DateTimeToNumericConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RediSearchClient.Converters +{ + /// + /// RediSearch does not natively support DateTime types, therefore we need to convert DateTimes to numerics in this case + /// + internal sealed class DateTimeToNumericConverter : JsonConverter + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteNumberValue((value - DateTime.MinValue).TotalSeconds); + } + } +} diff --git a/RediSearchClient/DatabaseExtensionsAsync.cs b/RediSearchClient/DatabaseExtensionsAsync.cs index 4365dd7..eac0448 100644 --- a/RediSearchClient/DatabaseExtensionsAsync.cs +++ b/RediSearchClient/DatabaseExtensionsAsync.cs @@ -7,6 +7,9 @@ using System.Linq; using System.Threading.Tasks; using RediSearchClient.Exceptions; +using System.Reflection; +using RediSearchClient.Attributes; +using System.Text.Json; namespace RediSearchClient { @@ -33,6 +36,165 @@ public static Task CreateIndexAsync(this IDatabase db, string indexName, RediSea return db.ExecuteAsync(RediSearchCommand.CREATE, commandParameters); } + /// + /// Used to map a given type to a JSON RediSearch index. + /// + /// The type to be mapped to the RediSearch index + /// + /// The prefix of the keys that will be included in the index + /// The index name, defaults to the type's name + public static async Task CreateIndexAsync(this IDatabase db, string indexableKeysPrefix, string indexName = null) + { + var allProperties = new List(); + + var entityType = typeof(TEntity); + + var defaultNoIndex = entityType.GetCustomAttributes(false).Any(x => x is DefaultNoIndexAttribute); + + GetProperties(entityType, "$", allProperties, new List { entityType.Name }, false); + + var definition = RediSearchIndex + .OnJson() + .ForKeysWithPrefix(indexableKeysPrefix) + .WithSchema(allProperties.ToArray()) + .Build(); + + await CreateIndexAsync(db, indexName ?? entityType.Name.ToLower(), definition); + + void GetProperties(Type type, string prefix, ICollection result, IEnumerable typesProcessedInHierarchy, bool forceTag) + { + foreach (var property in type.GetProperties()) + { + if (property.PropertyType.IsGenericType) + { + if (property.PropertyType.IsCollectionType()) + { + ProcessCollection(result, prefix, property, property.PropertyType.GetGenericArguments()[0], typesProcessedInHierarchy); + } + + continue; + } + + if (property.PropertyType.IsClass && !property.PropertyType.IsBuiltInType()) + { + if (!typesProcessedInHierarchy.Contains(property.PropertyType.Name)) + { + GetProperties(property.PropertyType, $"{prefix}.{property.Name}", result, + CloneProcessedHierarchy(typesProcessedInHierarchy, property.PropertyType.Name), forceTag); + } + + continue; + } + + MapToRedisType(result, property.PropertyType, property.Name, property.GetCustomAttributes(false), prefix, forceTag); + } + } + + void MapToRedisType(ICollection result, Type propertyType, string propertyName, object[] customAttributes, string prefix, bool forceTag) + { + bool sortable = false; + bool noStem = false; + bool noIndex = defaultNoIndex; + string alias = $"{prefix}.{propertyName}".Substring(2); //Ignoring the $. + string separator = default; + int weight = default; + var language = Language.None; + + foreach (var customAttribute in customAttributes) + { + if (customAttribute is SchemaIgnoreAttribute) + { + return; + } + + if (customAttribute is TagAttribute tagAttribute) + { + forceTag = true; + separator = tagAttribute.Separator; + } + + if (customAttribute is AliasAttribute aliasAttribute) + { + alias = aliasAttribute.Name; + } + + if (customAttribute is PhoneticAttribute phoneticAttribute) + { + language = phoneticAttribute.Language; + } + + if (customAttribute is WeightAttribute weightAttribute) + { + weight = weightAttribute.Weight; + } + + if (customAttribute is NonStemmableAttribute) + { + noStem = true; + } + + if (customAttribute is IndexAttribute indexAttribute) + { + noIndex = !indexAttribute.Value; + } + + if (customAttribute is SortableAttribute) + { + sortable = true; + } + } + + if (forceTag || propertyType.IsBooleanType()) + { + result.Add(new TagJsonSchemaField($"{prefix}.{propertyName}", alias, separator, sortable, noIndex)); + } + else if (IsRedisTextType(propertyType)) + { + result.Add(new TextJsonSchemaField($"{prefix}.{propertyName}", alias, sortable, noStem, noIndex, language, weight)); + } + else if (IsRedisNumericType(propertyType)) + { + result.Add(new NumericJsonSchemaField($"{prefix}.{propertyName}", alias, sortable, noIndex)); + } + } + + void ProcessCollection(ICollection result, string prefix, PropertyInfo property, Type embeddedType, IEnumerable typesProcessedInHierarchy) + { + if (!embeddedType.IsBuiltInType()) + { + if (typesProcessedInHierarchy.Contains(embeddedType.Name)) + { + return; + } + + GetProperties(embeddedType, $"{prefix}.{property.Name}[*]", result, + CloneProcessedHierarchy(typesProcessedInHierarchy, embeddedType.Name), true); + } + else + { + MapToRedisType(result, embeddedType, $"{property.Name}[*]", property.GetCustomAttributes(false), prefix, true); + } + } + + bool IsRedisNumericType(Type t) => t.IsNumericType() || t.IsDateTimeType(); + + bool IsRedisTextType(Type t) => ((t.IsPrimitive || t.IsNullableCharType()) && !t.IsNumericType() && !t.IsBooleanType()) || t.IsStringType() || t.IsObjectType(); + + IEnumerable CloneProcessedHierarchy(IEnumerable hierarchy, string newMember) => new List(hierarchy) { newMember }; + } + + /// + /// Use to set a JSON value in redis from a given generic value + /// + /// + /// + /// The value to be serialized and saved as JSON in Redis + /// The redis key, ensure that it starts with the predefined index prefix + public async static Task RediSearchJsonSetAsync(this IDatabase db, T value, string key) + { + await db.ExecuteAsync("JSON.SET", key, "$", JsonSerializer.Serialize(value, RediSearchJsonSerializerOptionsFactory.GetOptions())); + } + /// /// `FT.SEARCH` /// diff --git a/RediSearchClient/Indexes/BaseRediSearchIndexBuilder.cs b/RediSearchClient/Indexes/BaseRediSearchIndexBuilder.cs index efb4976..e3abe59 100644 --- a/RediSearchClient/Indexes/BaseRediSearchIndexBuilder.cs +++ b/RediSearchClient/Indexes/BaseRediSearchIndexBuilder.cs @@ -234,6 +234,21 @@ public BaseRediSearchIndexBuilder WithSchema( return this; } + /// + /// Allows for defining the schema of the search index. + /// + /// + /// + public BaseRediSearchIndexBuilder WithSchema(IRediSearchSchemaField[] fields) + { + _fields = fields.Select(x => { + IRediSearchSchemaField func(TFieldBuilder y) => x; + return (Func)func; + }).ToArray(); + + return this; + } + private static readonly TFieldBuilder _fieldBuilder = new TFieldBuilder(); /// diff --git a/RediSearchClient/Query/RediSearchQueryBuilder.cs b/RediSearchClient/Query/RediSearchQueryBuilder.cs index 9a11ddf..53f4480 100644 --- a/RediSearchClient/Query/RediSearchQueryBuilder.cs +++ b/RediSearchClient/Query/RediSearchQueryBuilder.cs @@ -337,6 +337,20 @@ public RediSearchQueryBuilder WithPayloads() return this; } + private int? _dialect; + + /// + /// Builder method for specifying "DIALECT". This allows targetting a specific RediSearch dialect. If it is not specified, the DEFAULT_DIALECT is used, + /// which can be set using FT.CONFIG SET or by passing it as an argument to the redisearch module when it is loaded. + /// + /// + public RediSearchQueryBuilder Dialect(int dialect) + { + _dialect = dialect; + + return this; + } + private static readonly RediSearchNumericFilterBuilder _numericFilterBuilder = new RediSearchNumericFilterBuilder(); private readonly SummarizeBuilder _summarizeBuilder = new SummarizeBuilder(); private readonly HighlightBuilder _highlightBuilder = new HighlightBuilder(); @@ -378,6 +392,9 @@ public RediSearchQueryDefinition Build() // [RETURN {num} {key} ... ] argumentLength += _returnFields != null ? 2 + _returnFields.Length : 0; + // [DIALECT dialect] + argumentLength += _dialect.HasValue ? 2 : 0; + // [SUMMARIZE [FIELDS {num} {field} ... ] [FRAGS {num}] [LEN {fragsize}] [SEPARATOR {separator}]] if (_summarizeBuilderAction != null) { @@ -596,6 +613,13 @@ public RediSearchQueryDefinition Build() result[++currentArgumentIndex] = _limit.limit.ToString(); } + // [DIALECT] + if (_dialect.HasValue) + { + result[++currentArgumentIndex] = "DIALECT"; + result[++currentArgumentIndex] = _dialect.Value; + } + return new RediSearchQueryDefinition(result); } } diff --git a/RediSearchClient/RediSearchClient.csproj b/RediSearchClient/RediSearchClient.csproj index 4eaeea9..a302b81 100644 --- a/RediSearchClient/RediSearchClient.csproj +++ b/RediSearchClient/RediSearchClient.csproj @@ -22,6 +22,7 @@ + diff --git a/RediSearchClient/RediSearchJsonSerializerOptionsFactory.cs b/RediSearchClient/RediSearchJsonSerializerOptionsFactory.cs new file mode 100644 index 0000000..529e450 --- /dev/null +++ b/RediSearchClient/RediSearchJsonSerializerOptionsFactory.cs @@ -0,0 +1,21 @@ +using RediSearchClient.Converters; +using System.Text.Json; + +namespace RediSearchClient +{ + internal sealed class RediSearchJsonSerializerOptionsFactory + { + private static JsonSerializerOptions _jsonSerializerOptions; + + internal static JsonSerializerOptions GetOptions() + { + if(_jsonSerializerOptions is null) + { + _jsonSerializerOptions = new JsonSerializerOptions(); + _jsonSerializerOptions.Converters.Add(new DateTimeToNumericConverter()); + } + + return _jsonSerializerOptions; + } + } +} diff --git a/RediSearchClient/TypeExtensions.cs b/RediSearchClient/TypeExtensions.cs new file mode 100644 index 0000000..6d539f8 --- /dev/null +++ b/RediSearchClient/TypeExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace RediSearchClient +{ + internal static class TypeExtensions + { + private static readonly IEnumerable NumericTypes = new List + { + typeof(byte), + typeof(byte?), + typeof(sbyte), + typeof(sbyte?), + typeof(short), + typeof(short?), + typeof(ushort), + typeof(ushort?), + typeof(int), + typeof(int?), + typeof(uint), + typeof(uint?), + typeof(long), + typeof(long?), + typeof(ulong), + typeof(ulong?), + typeof(float), + typeof(float?), + typeof(double), + typeof(double?), + typeof(decimal), + typeof(decimal?) + }; + + internal static bool IsNullableBooleanType(this Type t) => t.FullName == typeof(bool?).FullName; + + internal static bool IsNullableCharType(this Type t) => t.FullName == typeof(char?).FullName; + + internal static bool IsStringType(this Type t) => t.FullName == typeof(string).FullName; + + internal static bool IsObjectType(this Type t) => t.FullName == typeof(object).FullName; + + internal static bool IsNumericType(this Type t) => NumericTypes.Select(x => x.FullName).Contains(t.FullName); + + internal static bool IsBooleanType(this Type t) => t.FullName == typeof(bool).FullName || t.IsNullableBooleanType(); + + internal static bool IsDateTimeType(this Type t) => t.FullName == typeof(DateTime).FullName || t.FullName == typeof(DateTime?).FullName; + + internal static bool IsBuiltInType(this Type t) => t.IsPrimitive || t.IsNullableBooleanType() || t.IsNullableCharType() || t.IsNumericType() || t.IsStringType() || t.IsDateTimeType() || t.IsObjectType(); + + internal static bool IsCollectionType(this Type t) => t.GetInterfaces().Any(x => x.Name == typeof(IEnumerable<>).Name || x.Name == typeof(IEnumerable).Name); + } +}