chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,6 @@
# Suppressing errors for Test projects under dotnet folder
[*.cs]
dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task
dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave
dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member
dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Microsoft.SemanticKernel.Connectors.Redis.UnitTests</AssemblyName>
<RootNamespace>Microsoft.SemanticKernel.Connectors.Redis.UnitTests</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA2007,CA1806,CA1869,CA1861,IDE0300,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0050</NoWarn>
<NoWarn>$(NoWarn);MEVD9001</NoWarn> <!-- Experimental MEVD connector-facing APIs -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Moq"/>
<PackageReference Include="xunit"/>
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Numerics.Tensors" />
</ItemGroup>
<ItemGroup>
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/test/AssertExtensions.cs"
Link="%(RecursiveDir)%(Filename)%(Extension)"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\VectorData\Redis\Redis.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using NRedisStack.Search;
using Xunit;
using static NRedisStack.Search.Schema;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisCollectionCreateMapping"/> class.
/// </summary>
public class RedisCollectionCreateMappingTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public void MapToSchemaCreatesSchema(bool useDollarPrefix)
{
// Arrange.
PropertyModel[] properties =
[
new KeyPropertyModel("Key", typeof(string)),
new DataPropertyModel("FilterableString", typeof(string)) { IsIndexed = true },
new DataPropertyModel("FullTextSearchableString", typeof(string)) { IsFullTextIndexed = true },
new DataPropertyModel("FilterableStringEnumerable", typeof(string[])) { IsIndexed = true },
new DataPropertyModel("FullTextSearchableStringEnumerable", typeof(string[])) { IsFullTextIndexed = true },
new DataPropertyModel("FilterableInt", typeof(int)) { IsIndexed = true },
new DataPropertyModel("FilterableNullableInt", typeof(int)) { IsIndexed = true },
new DataPropertyModel("NonFilterableString", typeof(string)),
new VectorPropertyModel("VectorDefaultIndexingOptions", typeof(ReadOnlyMemory<float>)) { Dimensions = 10, EmbeddingType = typeof(ReadOnlyMemory<float>) },
new VectorPropertyModel("VectorSpecificIndexingOptions", typeof(ReadOnlyMemory<float>))
{
Dimensions = 20,
IndexKind = IndexKind.Flat,
DistanceFunction = DistanceFunction.EuclideanSquaredDistance,
StorageName = "vector_specific_indexing_options",
EmbeddingType = typeof(ReadOnlyMemory<float>)
}
];
// Act.
var schema = RedisCollectionCreateMapping.MapToSchema(properties, useDollarPrefix);
// Assert.
Assert.NotNull(schema);
Assert.Equal(8, schema.Fields.Count);
Assert.IsType<TagField>(schema.Fields[0]);
Assert.IsType<TextField>(schema.Fields[1]);
Assert.IsType<TagField>(schema.Fields[2]);
Assert.IsType<TextField>(schema.Fields[3]);
Assert.IsType<NumericField>(schema.Fields[4]);
Assert.IsType<NumericField>(schema.Fields[5]);
Assert.IsType<VectorField>(schema.Fields[6]);
Assert.IsType<VectorField>(schema.Fields[7]);
if (useDollarPrefix)
{
VerifyFieldName(schema.Fields[0].FieldName, ["$.FilterableString", "AS", "FilterableString"]);
VerifyFieldName(schema.Fields[1].FieldName, ["$.FullTextSearchableString", "AS", "FullTextSearchableString"]);
VerifyFieldName(schema.Fields[2].FieldName, ["$.FilterableStringEnumerable.*", "AS", "FilterableStringEnumerable"]);
VerifyFieldName(schema.Fields[3].FieldName, ["$.FullTextSearchableStringEnumerable", "AS", "FullTextSearchableStringEnumerable"]);
VerifyFieldName(schema.Fields[4].FieldName, ["$.FilterableInt", "AS", "FilterableInt"]);
VerifyFieldName(schema.Fields[5].FieldName, ["$.FilterableNullableInt", "AS", "FilterableNullableInt"]);
VerifyFieldName(schema.Fields[6].FieldName, ["$.VectorDefaultIndexingOptions", "AS", "VectorDefaultIndexingOptions"]);
VerifyFieldName(schema.Fields[7].FieldName, ["$.vector_specific_indexing_options", "AS", "vector_specific_indexing_options"]);
}
else
{
VerifyFieldName(schema.Fields[0].FieldName, ["FilterableString", "AS", "FilterableString"]);
VerifyFieldName(schema.Fields[1].FieldName, ["FullTextSearchableString", "AS", "FullTextSearchableString"]);
VerifyFieldName(schema.Fields[2].FieldName, ["FilterableStringEnumerable.*", "AS", "FilterableStringEnumerable"]);
VerifyFieldName(schema.Fields[3].FieldName, ["FullTextSearchableStringEnumerable", "AS", "FullTextSearchableStringEnumerable"]);
VerifyFieldName(schema.Fields[4].FieldName, ["FilterableInt", "AS", "FilterableInt"]);
VerifyFieldName(schema.Fields[5].FieldName, ["FilterableNullableInt", "AS", "FilterableNullableInt"]);
VerifyFieldName(schema.Fields[6].FieldName, ["VectorDefaultIndexingOptions", "AS", "VectorDefaultIndexingOptions"]);
VerifyFieldName(schema.Fields[7].FieldName, ["vector_specific_indexing_options", "AS", "vector_specific_indexing_options"]);
}
Assert.Equal("10", ((VectorField)schema.Fields[6]).Attributes!["DIM"]);
Assert.Equal("FLOAT32", ((VectorField)schema.Fields[6]).Attributes!["TYPE"]);
Assert.Equal("COSINE", ((VectorField)schema.Fields[6]).Attributes!["DISTANCE_METRIC"]);
Assert.Equal("20", ((VectorField)schema.Fields[7]).Attributes!["DIM"]);
Assert.Equal("FLOAT32", ((VectorField)schema.Fields[7]).Attributes!["TYPE"]);
Assert.Equal("L2", ((VectorField)schema.Fields[7]).Attributes!["DISTANCE_METRIC"]);
}
[Fact]
public void GetSDKIndexKindThrowsOnUnsupportedIndexKind()
{
// Arrange.
var vectorProperty = new VectorPropertyModel("VectorProperty", typeof(ReadOnlyMemory<float>)) { IndexKind = "Unsupported" };
// Act and assert.
Assert.Throws<InvalidOperationException>(() => RedisCollectionCreateMapping.GetSDKIndexKind(vectorProperty));
}
[Fact]
public void GetSDKDistanceAlgorithmThrowsOnUnsupportedDistanceFunction()
{
// Arrange.
var vectorProperty = new VectorPropertyModel("VectorProperty", typeof(ReadOnlyMemory<float>)) { DistanceFunction = "Unsupported" };
// Act and assert.
Assert.Throws<NotSupportedException>(() => RedisCollectionCreateMapping.GetSDKDistanceAlgorithm(vectorProperty));
}
private static void VerifyFieldName(FieldName fieldName, List<object> expected)
{
var args = new List<object>();
fieldName.AddCommandArguments(args);
Assert.Equal(expected, args);
}
}
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisCollectionSearchMapping"/> class.
/// </summary>
public class RedisCollectionSearchMappingTests
{
[Fact]
public void ValidateVectorAndConvertToBytesConvertsFloatVector()
{
// Arrange.
var floatVector = new ReadOnlyMemory<float>(new float[] { 1.0f, 2.0f, 3.0f });
// Act.
var byteArray = RedisCollectionSearchMapping.ValidateVectorAndConvertToBytes(floatVector, "Test");
// Assert.
Assert.NotNull(byteArray);
Assert.Equal(12, byteArray.Length);
Assert.Equal(new byte[12] { 0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 64, 64 }, byteArray);
}
[Fact]
public void ValidateVectorAndConvertToBytesConvertsDoubleVector()
{
// Arrange.
var doubleVector = new ReadOnlyMemory<double>(new double[] { 1.0, 2.0, 3.0 });
// Act.
var byteArray = RedisCollectionSearchMapping.ValidateVectorAndConvertToBytes(doubleVector, "Test");
// Assert.
Assert.NotNull(byteArray);
Assert.Equal(24, byteArray.Length);
Assert.Equal(new byte[24] { 0, 0, 0, 0, 0, 0, 240, 63, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 8, 64 }, byteArray);
}
[Fact]
public void ValidateVectorAndConvertToBytesThrowsForUnsupportedType()
{
// Arrange.
var unsupportedVector = new ReadOnlyMemory<int>(new int[] { 1, 2, 3 });
// Act & Assert.
var exception = Assert.Throws<NotSupportedException>(() =>
{
var byteArray = RedisCollectionSearchMapping.ValidateVectorAndConvertToBytes(unsupportedVector, "Test");
});
Assert.Equal("The provided vector type System.ReadOnlyMemory`1[[System.Int32, System.Private.CoreLib, Version=10.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]] is not supported by the Redis Test connector.", exception.Message);
}
[Fact]
public void BuildQueryBuildsRedisQueryWithDefaults()
{
// Arrange.
var floatVector = new ReadOnlyMemory<float>(new float[] { 1.0f, 2.0f, 3.0f });
var byteArray = MemoryMarshal.AsBytes(floatVector.Span).ToArray();
var model = BuildModel(
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory<float>), 10)
]);
// Act.
var query = RedisCollectionSearchMapping.BuildQuery(byteArray, top: 3, new VectorSearchOptions<DummyType>(), model, model.VectorProperty, null);
// Assert.
Assert.NotNull(query);
Assert.Equal("*=>[KNN 3 @Vector $embedding AS vector_score]", query.QueryString);
Assert.Equal("vector_score", query.SortBy);
Assert.True(query.WithScores);
Assert.Equal(2, query.dialect);
}
[Fact]
public void BuildQueryBuildsRedisQueryWithCustomVectorName()
{
// Arrange.
var floatVector = new ReadOnlyMemory<float>(new float[] { 1.0f, 2.0f, 3.0f });
var byteArray = MemoryMarshal.AsBytes(floatVector.Span).ToArray();
var vectorSearchOptions = new VectorSearchOptions<DummyType> { Skip = 3 };
var model = BuildModel(
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory<float>), 10) { StorageName = "storage_Vector" }
]);
var selectFields = new string[] { "storage_Field1", "storage_Field2" };
// Act.
var query = RedisCollectionSearchMapping.BuildQuery(byteArray, top: 5, vectorSearchOptions, model, model.VectorProperty, selectFields);
// Assert.
Assert.NotNull(query);
Assert.Equal("*=>[KNN 8 @storage_Vector $embedding AS vector_score]", query.QueryString);
}
[Fact]
public void ResolveDistanceFunctionReturnsCosineSimilarityIfNoDistanceFunctionSpecified()
{
var property = new VectorPropertyModel("Prop", typeof(ReadOnlyMemory<float>));
// Act.
var resolvedDistanceFunction = RedisCollectionSearchMapping.ResolveDistanceFunction(property);
// Assert.
Assert.Equal(DistanceFunction.CosineSimilarity, resolvedDistanceFunction);
}
[Fact]
public void ResolveDistanceFunctionReturnsDistanceFunctionFromProvidedProperty()
{
var property = new VectorPropertyModel("Prop", typeof(ReadOnlyMemory<float>)) { DistanceFunction = DistanceFunction.DotProductSimilarity };
// Act.
var resolvedDistanceFunction = RedisCollectionSearchMapping.ResolveDistanceFunction(property);
// Assert.
Assert.Equal(DistanceFunction.DotProductSimilarity, resolvedDistanceFunction);
}
[Fact]
public void GetOutputScoreFromRedisScoreConvertsCosineDistanceToSimilarity()
{
// Act & Assert.
Assert.Equal(-1, RedisCollectionSearchMapping.GetOutputScoreFromRedisScore(2, DistanceFunction.CosineSimilarity));
Assert.Equal(0, RedisCollectionSearchMapping.GetOutputScoreFromRedisScore(1, DistanceFunction.CosineSimilarity));
Assert.Equal(1, RedisCollectionSearchMapping.GetOutputScoreFromRedisScore(0, DistanceFunction.CosineSimilarity));
}
[Theory]
[InlineData(DistanceFunction.CosineDistance, 2)]
[InlineData(DistanceFunction.DotProductSimilarity, 2)]
[InlineData(DistanceFunction.EuclideanSquaredDistance, 2)]
public void GetOutputScoreFromRedisScoreLeavesNonConsineSimilarityUntouched(string distanceFunction, float score)
{
// Act & Assert.
Assert.Equal(score, RedisCollectionSearchMapping.GetOutputScoreFromRedisScore(score, distanceFunction));
}
#pragma warning disable CA1812 // An internal class that is apparently never instantiated. If so, remove the code from the assembly.
private sealed class DummyType;
#pragma warning restore CA1812
private static CollectionModel BuildModel(List<VectorStoreProperty> properties)
=> new RedisModelBuilder(RedisHashSetCollection<string, DummyType>.ModelBuildingOptions)
.BuildDynamic(new() { Properties = properties }, defaultEmbeddingGenerator: null);
}
@@ -0,0 +1,194 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
#pragma warning disable MEVD9001 // Experimental
public sealed class RedisFilterTranslatorTests
{
[Fact]
public void Contains_with_simple_string()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo"));
Assert.Equal("""@Tags:{"foo"}""", result);
}
[Fact]
public void Contains_with_curly_brace_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo}bar"));
Assert.Equal("""@Tags:{"foo}bar"}""", result);
}
[Fact]
public void Contains_with_pipe_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo|bar"));
Assert.Equal("""@Tags:{"foo|bar"}""", result);
}
[Fact]
public void Contains_with_double_quote_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo\"bar"));
Assert.Equal("""@Tags:{"foo\"bar"}""", result);
}
[Fact]
public void Contains_with_asterisk_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo*bar"));
Assert.Equal("""@Tags:{"foo*bar"}""", result);
}
[Fact]
public void Contains_with_at_sign_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo@bar"));
Assert.Equal("""@Tags:{"foo@bar"}""", result);
}
[Fact]
public void Contains_with_injection_attempt()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("evil} | @secret:{*"));
Assert.Equal("""@Tags:{"evil} | @secret:{*"}""", result);
}
[Fact]
public void Any_with_simple_strings()
{
var values = new[] { "a", "b" };
var result = Translate<TestRecord>(r => r.Tags.Any(t => values.Contains(t)));
Assert.Equal("""@Tags:{"a" | "b"}""", result);
}
[Fact]
public void Any_with_metacharacters_in_values()
{
var values = new[] { "a|b", "c}d" };
var result = Translate<TestRecord>(r => r.Tags.Any(t => values.Contains(t)));
Assert.Equal("""@Tags:{"a|b" | "c}d"}""", result);
}
[Fact]
public void Any_with_double_quotes_in_values()
{
var values = new[] { "a\"b", "c\"d" };
var result = Translate<TestRecord>(r => r.Tags.Any(t => values.Contains(t)));
Assert.Equal("""@Tags:{"a\"b" | "c\"d"}""", result);
}
[Fact]
public void Any_with_injection_attempt()
{
var values = new[] { "safe", "x} | @admin:{true" };
var result = Translate<TestRecord>(r => r.Tags.Any(t => values.Contains(t)));
Assert.Equal("""@Tags:{"safe" | "x} | @admin:{true"}""", result);
}
[Fact]
public void Equal_with_simple_string()
{
var result = Translate<TestRecord>(r => r.Name == "foo");
Assert.Equal("""@Name:{"foo"}""", result);
}
[Fact]
public void Equal_with_double_quote_in_value()
{
var result = Translate<TestRecord>(r => r.Name == "foo\"bar");
Assert.Equal("""@Name:{"foo\"bar"}""", result);
}
[Fact]
public void Equal_with_backslash_in_value()
{
var result = Translate<TestRecord>(r => r.Name == "foo\\bar");
Assert.Equal("""@Name:{"foo\\bar"}""", result);
}
[Fact]
public void Equal_with_single_backslash()
{
var result = Translate<TestRecord>(r => r.Name == "\\");
Assert.Equal("""@Name:{"\\"}""", result);
}
[Fact]
public void Equal_with_backslash_quote_injection_attempt()
{
// Input: \" which should NOT break out of the quoted string
var result = Translate<TestRecord>(r => r.Name == "\\\"");
Assert.Equal("""@Name:{"\\\""}""", result);
}
[Fact]
public void Equal_with_backslash_quote_wildcard_injection()
{
// The specific attack payload: \" | * | \"
var result = Translate<TestRecord>(r => r.Name == "\\\" | * | \\\"");
Assert.Equal("""@Name:{"\\\" | * | \\\""}""", result);
}
[Fact]
public void Contains_with_backslash_in_value()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("foo\\bar"));
Assert.Equal("""@Tags:{"foo\\bar"}""", result);
}
[Fact]
public void Contains_with_backslash_quote_injection_attempt()
{
var result = Translate<TestRecord>(r => r.Tags.Contains("\\\""));
Assert.Equal("""@Tags:{"\\\""}""", result);
}
[Fact]
public void Any_with_backslash_in_values()
{
var values = new[] { "a\\b", "c\\d" };
var result = Translate<TestRecord>(r => r.Tags.Any(t => values.Contains(t)));
Assert.Equal("""@Tags:{"a\\b" | "c\\d"}""", result);
}
private static string Translate<TRecord>(Expression<Func<TRecord, bool>> filter)
{
var model = BuildModel();
return new RedisFilterTranslator().Translate(filter, model);
}
private static CollectionModel BuildModel()
{
var definition = new VectorStoreCollectionDefinition
{
Properties =
[
new VectorStoreKeyProperty("Id", typeof(string)),
new VectorStoreDataProperty("Name", typeof(string)),
new VectorStoreDataProperty("Tags", typeof(string[])),
new VectorStoreVectorProperty("Embedding", typeof(ReadOnlyMemory<float>), 10)
]
};
return new RedisJsonModelBuilder(RedisJsonCollection<string, object>.ModelBuildingOptions).BuildDynamic(definition, defaultEmbeddingGenerator: null);
}
#pragma warning disable CA1812
private sealed record TestRecord
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string[] Tags { get; set; } = [];
public ReadOnlyMemory<float> Embedding { get; set; }
}
#pragma warning restore CA1812
}
@@ -0,0 +1,570 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.VectorData;
using Moq;
using NRedisStack;
using StackExchange.Redis;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisHashSetCollection{TKey, TRecord}"/> class.
/// </summary>
public class RedisHashSetCollectionTests
{
private const string TestCollectionName = "testcollection";
private const string TestRecordKey1 = "testid1";
private const string TestRecordKey2 = "testid2";
private readonly Mock<IDatabase> _redisDatabaseMock;
public RedisHashSetCollectionTests()
{
this._redisDatabaseMock = new Mock<IDatabase>(MockBehavior.Strict);
this._redisDatabaseMock.Setup(l => l.Database).Returns(0);
var batchMock = new Mock<IBatch>();
this._redisDatabaseMock.Setup(x => x.CreateBatch(It.IsAny<object>())).Returns(batchMock.Object);
}
[Theory]
[InlineData(TestCollectionName, true)]
[InlineData("nonexistentcollection", false)]
public async Task CollectionExistsReturnsCollectionStateAsync(string collectionName, bool expectedExists)
{
// Arrange
if (expectedExists)
{
SetupExecuteMock(this._redisDatabaseMock, ["index_name", collectionName]);
}
else
{
SetupExecuteMock(this._redisDatabaseMock, new RedisServerException("Unknown index name"));
}
using var sut = new RedisHashSetCollection<string, SinglePropsModel>(
this._redisDatabaseMock.Object,
collectionName);
// Act
var actual = await sut.CollectionExistsAsync();
// Assert
var expectedArgs = new object[] { collectionName };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.INFO",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
Assert.Equal(expectedExists, actual);
}
[Fact]
public async Task CanEnsureCollectionExistsAsync()
{
// Arrange.
SetupExecuteMock(this._redisDatabaseMock, "FT.INFO", new RedisServerException("Unknown index name"));
SetupExecuteMock(this._redisDatabaseMock, "FT.CREATE", string.Empty);
using var sut = new RedisHashSetCollection<string, SinglePropsModel>(this._redisDatabaseMock.Object, TestCollectionName);
// Act.
await sut.EnsureCollectionExistsAsync();
// Assert.
var expectedArgs = new object[] {
"testcollection",
"PREFIX",
1,
"testcollection:",
"SCHEMA",
"OriginalNameData",
"AS",
"OriginalNameData",
"TAG",
"data_storage_name",
"AS",
"data_storage_name",
"TAG",
"vector_storage_name",
"AS",
"vector_storage_name",
"VECTOR",
"HNSW",
6,
"TYPE",
"FLOAT32",
"DIM",
"4",
"DISTANCE_METRIC",
"COSINE" };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.CREATE",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Fact]
public async Task CanDeleteCollectionAsync()
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, string.Empty);
using var sut = this.CreateRecordCollection(false);
// Act
await sut.EnsureCollectionDeletedAsync();
// Assert
var expectedArgs = new object[] { TestCollectionName };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.DROPINDEX",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanGetRecordWithVectorsAsync(bool useDefinition)
{
// Arrange
var hashEntries = new HashEntry[]
{
new("OriginalNameData", "data 1"),
new("data_storage_name", "data 1"),
new("vector_storage_name", MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray())
};
this._redisDatabaseMock.Setup(x => x.HashGetAllAsync(It.IsAny<RedisKey>(), CommandFlags.None)).ReturnsAsync(hashEntries);
using var sut = this.CreateRecordCollection(useDefinition);
// Act
var actual = await sut.GetAsync(
TestRecordKey1,
new() { IncludeVectors = true });
// Assert
this._redisDatabaseMock.Verify(x => x.HashGetAllAsync(TestRecordKey1, CommandFlags.None), Times.Once);
Assert.NotNull(actual);
Assert.Equal(TestRecordKey1, actual.Key);
Assert.Equal("data 1", actual.OriginalNameData);
Assert.Equal("data 1", actual.Data);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector!.Value.ToArray());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanGetRecordWithoutVectorsAsync(bool useDefinition)
{
// Arrange
var redisValues = new RedisValue[] { new("data 1"), new("data 1") };
this._redisDatabaseMock.Setup(x => x.HashGetAsync(It.IsAny<RedisKey>(), It.IsAny<RedisValue[]>(), CommandFlags.None)).ReturnsAsync(redisValues);
using var sut = this.CreateRecordCollection(useDefinition);
// Act
var actual = await sut.GetAsync(
TestRecordKey1,
new() { IncludeVectors = false });
// Assert
var fieldNames = new RedisValue[] { "OriginalNameData", "data_storage_name" };
this._redisDatabaseMock.Verify(x => x.HashGetAsync(TestRecordKey1, fieldNames, CommandFlags.None), Times.Once);
Assert.NotNull(actual);
Assert.Equal(TestRecordKey1, actual.Key);
Assert.Equal("data 1", actual.OriginalNameData);
Assert.Equal("data 1", actual.Data);
Assert.False(actual.Vector.HasValue);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanGetManyRecordsWithVectorsAsync(bool useDefinition)
{
// Arrange
var hashEntries1 = new HashEntry[]
{
new("OriginalNameData", "data 1"),
new("data_storage_name", "data 1"),
new("vector_storage_name", MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray())
};
var hashEntries2 = new HashEntry[]
{
new("OriginalNameData", "data 2"),
new("data_storage_name", "data 2"),
new("vector_storage_name", MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 5, 6, 7, 8 })).ToArray())
};
this._redisDatabaseMock.Setup(x => x.HashGetAllAsync(It.IsAny<RedisKey>(), CommandFlags.None)).Returns((RedisKey key, CommandFlags flags) =>
{
return key switch
{
RedisKey k when k == TestRecordKey1 => Task.FromResult(hashEntries1),
RedisKey k when k == TestRecordKey2 => Task.FromResult(hashEntries2),
_ => throw new ArgumentException("Unexpected key."),
};
});
using var sut = this.CreateRecordCollection(useDefinition);
// Act
var actual = await sut.GetAsync(
[TestRecordKey1, TestRecordKey2],
new() { IncludeVectors = true }).ToListAsync();
// Assert
this._redisDatabaseMock.Verify(x => x.HashGetAllAsync(TestRecordKey1, CommandFlags.None), Times.Once);
this._redisDatabaseMock.Verify(x => x.HashGetAllAsync(TestRecordKey2, CommandFlags.None), Times.Once);
Assert.NotNull(actual);
Assert.Equal(2, actual.Count);
Assert.Equal(TestRecordKey1, actual[0].Key);
Assert.Equal("data 1", actual[0].OriginalNameData);
Assert.Equal("data 1", actual[0].Data);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual[0].Vector!.Value.ToArray());
Assert.Equal(TestRecordKey2, actual[1].Key);
Assert.Equal("data 2", actual[1].OriginalNameData);
Assert.Equal("data 2", actual[1].Data);
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual[1].Vector!.Value.ToArray());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanDeleteRecordAsync(bool useDefinition)
{
// Arrange
this._redisDatabaseMock.Setup(x => x.KeyDeleteAsync(It.IsAny<RedisKey>(), CommandFlags.None)).ReturnsAsync(true);
using var sut = this.CreateRecordCollection(useDefinition);
// Act
await sut.DeleteAsync(TestRecordKey1);
// Assert
this._redisDatabaseMock.Verify(x => x.KeyDeleteAsync(TestRecordKey1, CommandFlags.None), Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanDeleteManyRecordsWithVectorsAsync(bool useDefinition)
{
// Arrange
this._redisDatabaseMock.Setup(x => x.KeyDeleteAsync(It.IsAny<RedisKey>(), CommandFlags.None)).ReturnsAsync(true);
using var sut = this.CreateRecordCollection(useDefinition);
// Act
await sut.DeleteAsync([TestRecordKey1, TestRecordKey2]);
// Assert
this._redisDatabaseMock.Verify(x => x.KeyDeleteAsync(TestRecordKey1, CommandFlags.None), Times.Once);
this._redisDatabaseMock.Verify(x => x.KeyDeleteAsync(TestRecordKey2, CommandFlags.None), Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanUpsertRecordAsync(bool useDefinition)
{
// Arrange
this._redisDatabaseMock.Setup(x => x.HashSetAsync(It.IsAny<RedisKey>(), It.IsAny<HashEntry[]>(), CommandFlags.None)).Returns(Task.CompletedTask);
using var sut = this.CreateRecordCollection(useDefinition);
var model = CreateModel(TestRecordKey1, true);
// Act
await sut.UpsertAsync(model);
// Assert
this._redisDatabaseMock.Verify(
x => x.HashSetAsync(
TestRecordKey1,
It.Is<HashEntry[]>(x => x.Length == 3 && x[0].Name == "OriginalNameData" && x[1].Name == "data_storage_name" && x[2].Name == "vector_storage_name"),
CommandFlags.None),
Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanUpsertManyRecordsAsync(bool useDefinition)
{
// Arrange
this._redisDatabaseMock.Setup(x => x.HashSetAsync(It.IsAny<RedisKey>(), It.IsAny<HashEntry[]>(), CommandFlags.None)).Returns(Task.CompletedTask);
using var sut = this.CreateRecordCollection(useDefinition);
var model1 = CreateModel(TestRecordKey1, true);
var model2 = CreateModel(TestRecordKey2, true);
// Act
await sut.UpsertAsync([model1, model2]);
// Assert
this._redisDatabaseMock.Verify(
x => x.HashSetAsync(
TestRecordKey1,
It.Is<HashEntry[]>(x => x.Length == 3 && x[0].Name == "OriginalNameData" && x[1].Name == "data_storage_name" && x[2].Name == "vector_storage_name"),
CommandFlags.None),
Times.Once);
this._redisDatabaseMock.Verify(
x => x.HashSetAsync(
TestRecordKey2,
It.Is<HashEntry[]>(x => x.Length == 3 && x[0].Name == "OriginalNameData" && x[1].Name == "data_storage_name" && x[2].Name == "vector_storage_name"),
CommandFlags.None),
Times.Once);
}
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task CanSearchWithVectorAndFilterAsync(bool useDefinition, bool includeVectors)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, new RedisResult[]
{
RedisResult.Create(new RedisValue("1")),
RedisResult.Create(new RedisValue(TestRecordKey1)),
RedisResult.Create(new RedisValue("0.8")),
RedisResult.Create(
[
new RedisValue("OriginalNameData"),
new RedisValue("original data 1"),
new RedisValue("data_storage_name"),
new RedisValue("data 1"),
new RedisValue("vector_storage_name"),
RedisValue.Unbox(MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray()),
new RedisValue("vector_score"),
new RedisValue("0.25"),
]),
});
using var sut = this.CreateRecordCollection(useDefinition);
// Act.
var results = await sut.SearchAsync(
new ReadOnlyMemory<float>(new[] { 1f, 2f, 3f, 4f }),
top: 5,
new()
{
IncludeVectors = includeVectors,
Filter = r => r.Data == "data 1",
Skip = 2
}).ToListAsync();
// Assert.
var expectedArgsPart1 = new object[]
{
"testcollection",
"@data_storage_name:{\"data 1\"}=>[KNN 7 @vector_storage_name $embedding AS vector_score]",
"WITHSCORES",
"SORTBY",
"vector_score",
"LIMIT",
2,
7
};
var returnArgs = includeVectors ? Array.Empty<object>() : new object[]
{
"RETURN",
3,
"OriginalNameData",
"data_storage_name",
"vector_score"
};
var expectedArgsPart2 = new object[]
{
"PARAMS",
2,
"embedding",
MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray(),
"DIALECT",
2
};
var expectedArgs = expectedArgsPart1.Concat(returnArgs).Concat(expectedArgsPart2).ToArray();
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.SEARCH",
It.Is<object[]>(x => x.Where(y => !(y is byte[])).SequenceEqual(expectedArgs.Where(y => !(y is byte[]))))),
Times.Once);
Assert.Single(results);
Assert.Equal(TestRecordKey1, results.First().Record.Key);
Assert.Equal(0.25d, results.First().Score);
Assert.Equal("original data 1", results.First().Record.OriginalNameData);
Assert.Equal("data 1", results.First().Record.Data);
if (includeVectors)
{
Assert.Equal(new float[] { 1, 2, 3, 4 }, results.First().Record.Vector!.Value.ToArray());
}
else
{
Assert.False(results.First().Record.Vector.HasValue);
}
}
/// <summary>
/// Tests that the collection can be created even if the definition and the type do not match.
/// In this case, the expectation is that a custom mapper will be provided to map between the
/// schema as defined by the definition and the different data model.
/// </summary>
[Fact]
public void CanCreateCollectionWithMismatchedDefinitionAndType()
{
// Arrange.
var definition = new VectorStoreCollectionDefinition()
{
Properties =
[
new VectorStoreKeyProperty(nameof(SinglePropsModel.Key), typeof(string)),
new VectorStoreDataProperty(nameof(SinglePropsModel.OriginalNameData), typeof(string)),
new VectorStoreVectorProperty(nameof(SinglePropsModel.Vector), typeof(ReadOnlyMemory<float>?), 4),
]
};
// Act.
using var sut = new RedisHashSetCollection<string, SinglePropsModel>(
this._redisDatabaseMock.Object,
TestCollectionName,
new() { Definition = definition });
}
private RedisHashSetCollection<string, SinglePropsModel> CreateRecordCollection(bool useDefinition)
{
return new RedisHashSetCollection<string, SinglePropsModel>(
this._redisDatabaseMock.Object,
TestCollectionName,
new()
{
PrefixCollectionNameToKeyNames = false,
Definition = useDefinition ? this._singlePropsDefinition : null
});
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, Exception exception)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ThrowsAsync(exception);
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string command, Exception exception)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
command,
It.IsAny<object[]>()))
.ThrowsAsync(exception);
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, IEnumerable<string> redisResultStrings)
{
var results = redisResultStrings
.Select(x => RedisResult.Create(new RedisValue(x)))
.ToArray();
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ReturnsAsync(RedisResult.Create(results));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, IEnumerable<RedisResult> redisResultStrings)
{
var results = redisResultStrings
.Select(x => x)
.ToArray();
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ReturnsAsync(RedisResult.Create(results));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string redisResultString)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.Callback((string command, object[] args) =>
{
Console.WriteLine(args);
})
.ReturnsAsync(RedisResult.Create(new RedisValue(redisResultString)));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string command, string redisResultString)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
command,
It.IsAny<object[]>()))
.Callback((string command, object[] args) =>
{
Console.WriteLine(args);
})
.ReturnsAsync(RedisResult.Create(new RedisValue(redisResultString)));
}
private static SinglePropsModel CreateModel(string key, bool withVectors)
{
return new SinglePropsModel
{
Key = key,
OriginalNameData = "data 1",
Data = "data 1",
Vector = withVectors ? new float[] { 1, 2, 3, 4 } : null,
NotAnnotated = null,
};
}
private readonly VectorStoreCollectionDefinition _singlePropsDefinition = new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("OriginalNameData", typeof(string)),
new VectorStoreDataProperty("Data", typeof(string)) { StorageName = "data_storage_name" },
new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory<float>), 10) { StorageName = "vector_storage_name", DistanceFunction = DistanceFunction.CosineDistance }
]
};
public sealed class SinglePropsModel
{
[VectorStoreKey]
public string Key { get; set; } = string.Empty;
[VectorStoreData(IsIndexed = true)]
public string OriginalNameData { get; set; } = string.Empty;
[JsonPropertyName("ignored_data_json_name")]
[VectorStoreData(IsIndexed = true, StorageName = "data_storage_name")]
public string Data { get; set; } = string.Empty;
[JsonPropertyName("ignored_vector_json_name")]
[VectorStoreVector(4, DistanceFunction = DistanceFunction.CosineDistance, StorageName = "vector_storage_name")]
public ReadOnlyMemory<float>? Vector { get; set; }
public string? NotAnnotated { get; set; }
}
}
@@ -0,0 +1,208 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using StackExchange.Redis;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains dynamic mapping tests for the <see cref="RedisHashSetMapper{TConsumerDataModel}"/> class.
/// </summary>
public class RedisHashSetDynamicMappingTests
{
private static readonly CollectionModel s_model = BuildModel(RedisHashSetMappingTestHelpers.s_definition);
private static readonly float[] s_floatVector = new float[] { 1.0f, 2.0f, 3.0f, 4.0f };
private static readonly double[] s_doubleVector = new double[] { 5.0d, 6.0d, 7.0d, 8.0d };
[Fact]
public void MapFromDataToStorageModelMapsAllSupportedTypes()
{
// Arrange.
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(s_model);
var dataModel = new Dictionary<string, object?>
{
["Key"] = "key",
["StringData"] = "data 1",
["IntData"] = 1,
["UIntData"] = 2u,
["LongData"] = 3L,
["ULongData"] = 4ul,
["DoubleData"] = 5.5d,
["FloatData"] = 6.6f,
["NullableIntData"] = 7,
["NullableUIntData"] = 8u,
["NullableLongData"] = 9L,
["NullableULongData"] = 10ul,
["NullableDoubleData"] = 11.1d,
["NullableFloatData"] = 12.2f,
["FloatVector"] = new ReadOnlyMemory<float>(s_floatVector),
["DoubleVector"] = new ReadOnlyMemory<double>(s_doubleVector),
};
// Act.
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
RedisHashSetMappingTestHelpers.VerifyHashSet(storageModel.HashEntries);
}
[Fact]
public void MapFromDataToStorageModelMapsNullValues()
{
// Arrange
CollectionModel model = BuildModel(new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("StringData", typeof(string)) { StorageName = "storage_string_data" },
new VectorStoreDataProperty("NullableIntData", typeof(int?)),
new VectorStoreVectorProperty("FloatVector", typeof(ReadOnlyMemory<float>?), 10),
],
});
var dataModel = new Dictionary<string, object?>
{
["Key"] = "key",
["StringData"] = null,
["NullableIntData"] = null,
["FloatVector"] = null,
};
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(model);
// Act
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
Assert.Equal("storage_string_data", storageModel.HashEntries[0].Name.ToString());
Assert.True(storageModel.HashEntries[0].Value.IsNull);
Assert.Equal("NullableIntData", storageModel.HashEntries[1].Name.ToString());
Assert.True(storageModel.HashEntries[1].Value.IsNull);
}
[Fact]
public void MapFromStorageToDataModelMapsAllSupportedTypes()
{
// Arrange.
var hashSet = RedisHashSetMappingTestHelpers.CreateHashSet();
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(s_model);
// Act.
var dataModel = sut.MapFromStorageToDataModel(("key", hashSet), includeVectors: true);
// Assert.
Assert.Equal("key", dataModel["Key"]);
Assert.Equal("data 1", dataModel["StringData"]);
Assert.Equal(1, dataModel["IntData"]);
Assert.Equal(2u, dataModel["UIntData"]);
Assert.Equal(3L, dataModel["LongData"]);
Assert.Equal(4ul, dataModel["ULongData"]);
Assert.Equal(5.5d, dataModel["DoubleData"]);
Assert.Equal(6.6f, dataModel["FloatData"]);
Assert.Equal(7, dataModel["NullableIntData"]);
Assert.Equal(8u, dataModel["NullableUIntData"]);
Assert.Equal(9L, dataModel["NullableLongData"]);
Assert.Equal(10ul, dataModel["NullableULongData"]);
Assert.Equal(11.1d, dataModel["NullableDoubleData"]);
Assert.Equal(12.2f, dataModel["NullableFloatData"]);
Assert.Equal(new float[] { 1, 2, 3, 4 }, ((ReadOnlyMemory<float>)dataModel["FloatVector"]!).ToArray());
Assert.Equal(new double[] { 5, 6, 7, 8 }, ((ReadOnlyMemory<double>)dataModel["DoubleVector"]!).ToArray());
}
[Fact]
public void MapFromStorageToDataModelMapsNullValues()
{
// Arrange
var model = BuildModel(new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("StringData", typeof(string)) { StorageName = "storage_string_data" },
new VectorStoreDataProperty("NullableIntData", typeof(int?)),
new VectorStoreVectorProperty("FloatVector", typeof(ReadOnlyMemory<float>?), 10),
]
});
var hashSet = new HashEntry[]
{
new("storage_string_data", RedisValue.Null),
new("NullableIntData", RedisValue.Null),
new("FloatVector", RedisValue.Null),
};
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(model);
// Act
var dataModel = sut.MapFromStorageToDataModel(("key", hashSet), includeVectors: true);
// Assert
Assert.Equal("key", dataModel["Key"]);
Assert.Null(dataModel["StringData"]);
Assert.Null(dataModel["NullableIntData"]);
Assert.Null(dataModel["FloatVector"]);
}
[Fact]
public void MapFromDataToStorageModelSkipsMissingProperties()
{
// Arrange.
var model = BuildModel(new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("StringData", typeof(string)) { StorageName = "storage_string_data" },
new VectorStoreDataProperty("NullableIntData", typeof(int?)),
new VectorStoreVectorProperty("FloatVector", typeof(ReadOnlyMemory<float>?), 10),
]
});
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(model);
var dataModel = new Dictionary<string, object?> { ["Key"] = "key" };
// Act.
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
Assert.Equal("storage_string_data", storageModel.HashEntries[0].Name.ToString());
Assert.True(storageModel.HashEntries[0].Value.IsNull);
Assert.Equal("NullableIntData", storageModel.HashEntries[1].Name.ToString());
Assert.True(storageModel.HashEntries[1].Value.IsNull);
}
[Fact]
public void MapFromStorageToDataModelSkipsMissingProperties()
{
// Arrange.
var hashSet = Array.Empty<HashEntry>();
var sut = new RedisHashSetMapper<Dictionary<string, object?>>(s_model);
// Act.
var dataModel = sut.MapFromStorageToDataModel(("key", hashSet), includeVectors: true);
// Assert.
Assert.Single(dataModel);
Assert.Equal("key", dataModel["Key"]);
}
private static CollectionModel BuildModel(VectorStoreCollectionDefinition definition)
=> new RedisModelBuilder(RedisHashSetCollection<object, Dictionary<string, object?>>.ModelBuildingOptions)
.BuildDynamic(definition, defaultEmbeddingGenerator: null);
}
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using Microsoft.SemanticKernel.Connectors.Redis;
using Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
using Xunit;
namespace SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisHashSetMapper{TConsumerDataModel}"/> class.
/// </summary>
public sealed class RedisHashSetMapperTests
{
private static readonly CollectionModel s_model
= new RedisModelBuilder(RedisHashSetCollection<string, AllTypesModel>.ModelBuildingOptions)
.Build(typeof(AllTypesModel), typeof(string), RedisHashSetMappingTestHelpers.s_definition, defaultEmbeddingGenerator: null);
[Fact]
public void MapsAllFieldsFromDataToStorageModel()
{
// Arrange.
var sut = new RedisHashSetMapper<AllTypesModel>(s_model);
// Act.
var actual = sut.MapFromDataToStorageModel(CreateModel("test key"), recordIndex: 0, generatedEmbeddings: null);
// Assert.
Assert.NotNull(actual.HashEntries);
Assert.Equal("test key", actual.Key);
RedisHashSetMappingTestHelpers.VerifyHashSet(actual.HashEntries);
}
[Fact]
public void MapsAllFieldsFromStorageToDataModel()
{
// Arrange.
var sut = new RedisHashSetMapper<AllTypesModel>(s_model);
// Act.
var actual = sut.MapFromStorageToDataModel(("test key", RedisHashSetMappingTestHelpers.CreateHashSet()), includeVectors: true);
// Assert.
Assert.NotNull(actual);
Assert.Equal("test key", actual.Key);
Assert.Equal("data 1", actual.StringData);
Assert.Equal(1, actual.IntData);
Assert.Equal(2u, actual.UIntData);
Assert.Equal(3, actual.LongData);
Assert.Equal(4ul, actual.ULongData);
Assert.Equal(5.5d, actual.DoubleData);
Assert.Equal(6.6f, actual.FloatData);
Assert.Equal(7, actual.NullableIntData);
Assert.Equal(8u, actual.NullableUIntData);
Assert.Equal(9, actual.NullableLongData);
Assert.Equal(10ul, actual.NullableULongData);
Assert.Equal(11.1d, actual.NullableDoubleData);
Assert.Equal(12.2f, actual.NullableFloatData);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.FloatVector!.Value.ToArray());
Assert.Equal(new double[] { 5, 6, 7, 8 }, actual.DoubleVector!.Value.ToArray());
}
private static AllTypesModel CreateModel(string key)
{
return new AllTypesModel
{
Key = key,
StringData = "data 1",
IntData = 1,
UIntData = 2,
LongData = 3,
ULongData = 4,
DoubleData = 5.5d,
FloatData = 6.6f,
NullableIntData = 7,
NullableUIntData = 8,
NullableLongData = 9,
NullableULongData = 10,
NullableDoubleData = 11.1d,
NullableFloatData = 12.2f,
FloatVector = new float[] { 1, 2, 3, 4 },
DoubleVector = new double[] { 5, 6, 7, 8 },
NotAnnotated = "notAnnotated",
};
}
private sealed class AllTypesModel
{
[VectorStoreKey]
public string Key { get; set; } = string.Empty;
[VectorStoreData(StorageName = "storage_string_data")]
public string StringData { get; set; } = string.Empty;
[VectorStoreData]
public int IntData { get; set; }
[VectorStoreData]
public uint UIntData { get; set; }
[VectorStoreData]
public long LongData { get; set; }
[VectorStoreData]
public ulong ULongData { get; set; }
[VectorStoreData]
public double DoubleData { get; set; }
[VectorStoreData]
public float FloatData { get; set; }
[VectorStoreData]
public int? NullableIntData { get; set; }
[VectorStoreData]
public uint? NullableUIntData { get; set; }
[VectorStoreData]
public long? NullableLongData { get; set; }
[VectorStoreData]
public ulong? NullableULongData { get; set; }
[VectorStoreData]
public double? NullableDoubleData { get; set; }
[VectorStoreData]
public float? NullableFloatData { get; set; }
[VectorStoreVector(10)]
public ReadOnlyMemory<float>? FloatVector { get; set; }
[VectorStoreVector(10)]
public ReadOnlyMemory<double>? DoubleVector { get; set; }
public string NotAnnotated { get; set; } = string.Empty;
}
}
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Runtime.InteropServices;
using Microsoft.Extensions.VectorData;
using StackExchange.Redis;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains helper methods and data for testing the mapping of records between storage and data models.
/// These helpers are shared between the different mapping tests.
/// </summary>
internal static class RedisHashSetMappingTestHelpers
{
public static readonly VectorStoreCollectionDefinition s_definition = new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("StringData", typeof(string)) { StorageName = "storage_string_data" },
new VectorStoreDataProperty("IntData", typeof(int)),
new VectorStoreDataProperty("UIntData", typeof(uint)),
new VectorStoreDataProperty("LongData", typeof(long)),
new VectorStoreDataProperty("ULongData", typeof(ulong)),
new VectorStoreDataProperty("DoubleData", typeof(double)),
new VectorStoreDataProperty("FloatData", typeof(float)),
new VectorStoreDataProperty("NullableIntData", typeof(int?)),
new VectorStoreDataProperty("NullableUIntData", typeof(uint?)),
new VectorStoreDataProperty("NullableLongData", typeof(long?)),
new VectorStoreDataProperty("NullableULongData", typeof(ulong?)),
new VectorStoreDataProperty("NullableDoubleData", typeof(double?)),
new VectorStoreDataProperty("NullableFloatData", typeof(float?)),
new VectorStoreVectorProperty("FloatVector", typeof(ReadOnlyMemory<float>), 10),
new VectorStoreVectorProperty("DoubleVector", typeof(ReadOnlyMemory<double>), 10),
]
};
public static HashEntry[] CreateHashSet()
{
var hashSet = new HashEntry[15];
hashSet[0] = new HashEntry("storage_string_data", "data 1");
hashSet[1] = new HashEntry("IntData", 1);
hashSet[2] = new HashEntry("UIntData", 2);
hashSet[3] = new HashEntry("LongData", 3);
hashSet[4] = new HashEntry("ULongData", 4);
hashSet[5] = new HashEntry("DoubleData", 5.5);
hashSet[6] = new HashEntry("FloatData", 6.6);
hashSet[7] = new HashEntry("NullableIntData", 7);
hashSet[8] = new HashEntry("NullableUIntData", 8);
hashSet[9] = new HashEntry("NullableLongData", 9);
hashSet[10] = new HashEntry("NullableULongData", 10);
hashSet[11] = new HashEntry("NullableDoubleData", 11.1);
hashSet[12] = new HashEntry("NullableFloatData", 12.2);
hashSet[13] = new HashEntry("FloatVector", MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray());
hashSet[14] = new HashEntry("DoubleVector", MemoryMarshal.AsBytes(new ReadOnlySpan<double>(new double[] { 5, 6, 7, 8 })).ToArray());
return hashSet;
}
public static void VerifyHashSet(HashEntry[] hashEntries)
{
Assert.Equal("storage_string_data", hashEntries[0].Name.ToString());
Assert.Equal("data 1", hashEntries[0].Value.ToString());
Assert.Equal("IntData", hashEntries[1].Name.ToString());
Assert.Equal(1, (int)hashEntries[1].Value);
Assert.Equal("UIntData", hashEntries[2].Name.ToString());
Assert.Equal(2u, (uint)hashEntries[2].Value);
Assert.Equal("LongData", hashEntries[3].Name.ToString());
Assert.Equal(3, (long)hashEntries[3].Value);
Assert.Equal("ULongData", hashEntries[4].Name.ToString());
Assert.Equal(4ul, (ulong)hashEntries[4].Value);
Assert.Equal("DoubleData", hashEntries[5].Name.ToString());
Assert.Equal(5.5d, (double)hashEntries[5].Value);
Assert.Equal("FloatData", hashEntries[6].Name.ToString());
Assert.Equal(6.6f, (float)hashEntries[6].Value);
Assert.Equal("NullableIntData", hashEntries[7].Name.ToString());
Assert.Equal(7, (int)hashEntries[7].Value);
Assert.Equal("NullableUIntData", hashEntries[8].Name.ToString());
Assert.Equal(8u, (uint)hashEntries[8].Value);
Assert.Equal("NullableLongData", hashEntries[9].Name.ToString());
Assert.Equal(9, (long)hashEntries[9].Value);
Assert.Equal("NullableULongData", hashEntries[10].Name.ToString());
Assert.Equal(10ul, (ulong)hashEntries[10].Value);
Assert.Equal("NullableDoubleData", hashEntries[11].Name.ToString());
Assert.Equal(11.1d, (double)hashEntries[11].Value);
Assert.Equal("NullableFloatData", hashEntries[12].Name.ToString());
Assert.Equal(12.2f, (float)hashEntries[12].Value);
Assert.Equal("FloatVector", hashEntries[13].Name.ToString());
Assert.Equal(new float[] { 1, 2, 3, 4 }, MemoryMarshal.Cast<byte, float>((byte[])hashEntries[13].Value!).ToArray());
Assert.Equal("DoubleVector", hashEntries[14].Name.ToString());
Assert.Equal(new double[] { 5, 6, 7, 8 }, MemoryMarshal.Cast<byte, double>((byte[])hashEntries[14].Value!).ToArray());
}
}
@@ -0,0 +1,565 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.VectorData;
using Moq;
using NRedisStack;
using StackExchange.Redis;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisJsonCollection{TKey, TRecord}"/> class.
/// </summary>
public class RedisJsonCollectionTests
{
private const string TestCollectionName = "testcollection";
private const string TestRecordKey1 = "testid1";
private const string TestRecordKey2 = "testid2";
private readonly Mock<IDatabase> _redisDatabaseMock;
public RedisJsonCollectionTests()
{
this._redisDatabaseMock = new Mock<IDatabase>(MockBehavior.Strict);
this._redisDatabaseMock.Setup(l => l.Database).Returns(0);
var batchMock = new Mock<IBatch>();
this._redisDatabaseMock.Setup(x => x.CreateBatch(It.IsAny<object>())).Returns(batchMock.Object);
}
[Theory]
[InlineData(TestCollectionName, true)]
[InlineData("nonexistentcollection", false)]
public async Task CollectionExistsReturnsCollectionStateAsync(string collectionName, bool expectedExists)
{
// Arrange
if (expectedExists)
{
SetupExecuteMock(this._redisDatabaseMock, ["index_name", collectionName]);
}
else
{
SetupExecuteMock(this._redisDatabaseMock, new RedisServerException("Unknown index name"));
}
using var sut = new RedisJsonCollection<string, MultiPropsModel>(
this._redisDatabaseMock.Object,
collectionName);
// Act
var actual = await sut.CollectionExistsAsync();
// Assert
var expectedArgs = new object[] { collectionName };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.INFO",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
Assert.Equal(expectedExists, actual);
}
[Theory]
[InlineData(true, true, "data2", "vector2")]
[InlineData(true, false, "Data2", "Vector2")]
[InlineData(false, true, "data2", "vector2")]
[InlineData(false, false, "Data2", "Vector2")]
public async Task CanEnsureCollectionExistsAsync(bool useDefinition, bool useCustomJsonSerializerOptions, string expectedData2Name, string expectedVector2Name)
{
// Arrange.
SetupExecuteMock(this._redisDatabaseMock, "FT.INFO", new RedisServerException("Unknown index name"));
SetupExecuteMock(this._redisDatabaseMock, "FT.CREATE", string.Empty);
using var sut = this.CreateRecordCollection(useDefinition, useCustomJsonSerializerOptions);
// Act.
await sut.EnsureCollectionExistsAsync();
// Assert.
var expectedArgs = new object[] {
"testcollection",
"ON",
"JSON",
"PREFIX",
1,
"testcollection:",
"SCHEMA",
"$.data1_json_name",
"AS",
"data1_json_name",
"TAG",
$"$.{expectedData2Name}",
"AS",
expectedData2Name,
"TAG",
"$.vector1_json_name",
"AS",
"vector1_json_name",
"VECTOR",
"HNSW",
6,
"TYPE",
"FLOAT32",
"DIM",
"4",
"DISTANCE_METRIC",
"COSINE",
$"$.{expectedVector2Name}",
"AS",
expectedVector2Name,
"VECTOR",
"HNSW",
6,
"TYPE",
"FLOAT32",
"DIM",
"4",
"DISTANCE_METRIC",
"COSINE" };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.CREATE",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Fact]
public async Task CanDeleteCollectionAsync()
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, string.Empty);
using var sut = this.CreateRecordCollection(false);
// Act
await sut.EnsureCollectionDeletedAsync();
// Assert
var expectedArgs = new object[] { TestCollectionName };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.DROPINDEX",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Theory]
[InlineData(true, true, """{ "data1_json_name": "data 1", "data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "vector2": [1, 2, 3, 4] }""")]
[InlineData(true, false, """{ "data1_json_name": "data 1", "Data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "Vector2": [1, 2, 3, 4] }""")]
[InlineData(false, true, """{ "data1_json_name": "data 1", "data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "vector2": [1, 2, 3, 4] }""")]
[InlineData(false, false, """{ "data1_json_name": "data 1", "Data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "Vector2": [1, 2, 3, 4] }""")]
public async Task CanGetRecordWithVectorsAsync(bool useDefinition, bool useCustomJsonSerializerOptions, string redisResultString)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, redisResultString);
using var sut = this.CreateRecordCollection(useDefinition, useCustomJsonSerializerOptions);
// Act
var actual = await sut.GetAsync(
TestRecordKey1,
new() { IncludeVectors = true });
// Assert
var expectedArgs = new object[] { TestRecordKey1 };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.GET",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
Assert.NotNull(actual);
Assert.Equal(TestRecordKey1, actual.Key);
Assert.Equal("data 1", actual.Data1);
Assert.Equal("data 2", actual.Data2);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector1!.Value.ToArray());
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector2!.Value.ToArray());
}
[Theory]
[InlineData(true, true, """{ "data1_json_name": "data 1", "data2": "data 2" }""", "data2")]
[InlineData(true, false, """{ "data1_json_name": "data 1", "Data2": "data 2" }""", "Data2")]
[InlineData(false, true, """{ "data1_json_name": "data 1", "data2": "data 2" }""", "data2")]
[InlineData(false, false, """{ "data1_json_name": "data 1", "Data2": "data 2" }""", "Data2")]
public async Task CanGetRecordWithoutVectorsAsync(bool useDefinition, bool useCustomJsonSerializerOptions, string redisResultString, string expectedData2Name)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, redisResultString);
using var sut = this.CreateRecordCollection(useDefinition, useCustomJsonSerializerOptions);
// Act
var actual = await sut.GetAsync(
TestRecordKey1,
new() { IncludeVectors = false });
// Assert
var expectedArgs = new object[] { TestRecordKey1, "data1_json_name", expectedData2Name };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.GET",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
Assert.NotNull(actual);
Assert.Equal(TestRecordKey1, actual.Key);
Assert.Equal("data 1", actual.Data1);
Assert.Equal("data 2", actual.Data2);
Assert.False(actual.Vector1.HasValue);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanGetManyRecordsWithVectorsAsync(bool useDefinition)
{
// Arrange
var redisResultString1 = """{ "data1_json_name": "data 1", "Data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "Vector2": [1, 2, 3, 4] }""";
var redisResultString2 = """{ "data1_json_name": "data 1", "Data2": "data 2", "vector1_json_name": [5, 6, 7, 8], "Vector2": [1, 2, 3, 4] }""";
SetupExecuteMock(this._redisDatabaseMock, [redisResultString1, redisResultString2]);
using var sut = this.CreateRecordCollection(useDefinition);
// Act
var actual = await sut.GetAsync(
[TestRecordKey1, TestRecordKey2],
new() { IncludeVectors = true }).ToListAsync();
// Assert
var expectedArgs = new object[] { TestRecordKey1, TestRecordKey2, "$" };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.MGET",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
Assert.NotNull(actual);
Assert.Equal(2, actual.Count);
Assert.Equal(TestRecordKey1, actual[0].Key);
Assert.Equal("data 1", actual[0].Data1);
Assert.Equal("data 2", actual[0].Data2);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual[0].Vector1!.Value.ToArray());
Assert.Equal(TestRecordKey2, actual[1].Key);
Assert.Equal("data 1", actual[1].Data1);
Assert.Equal("data 2", actual[1].Data2);
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual[1].Vector1!.Value.ToArray());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanDeleteRecordAsync(bool useDefinition)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, "200");
using var sut = this.CreateRecordCollection(useDefinition);
// Act
await sut.DeleteAsync(TestRecordKey1);
// Assert
var expectedArgs = new object[] { TestRecordKey1 };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.DEL",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanDeleteManyRecordsWithVectorsAsync(bool useDefinition)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, "200");
using var sut = this.CreateRecordCollection(useDefinition);
// Act
await sut.DeleteAsync([TestRecordKey1, TestRecordKey2]);
// Assert
var expectedArgs1 = new object[] { TestRecordKey1 };
var expectedArgs2 = new object[] { TestRecordKey2 };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.DEL",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs1))),
Times.Once);
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.DEL",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs2))),
Times.Once);
}
[Theory]
[InlineData(true, true, """{"data1_json_name":"data 1","data2":"data 2","vector1_json_name":[1,2,3,4],"vector2":[1,2,3,4],"notAnnotated":null}""")]
[InlineData(true, false, """{"data1_json_name":"data 1","Data2":"data 2","vector1_json_name":[1,2,3,4],"Vector2":[1,2,3,4],"NotAnnotated":null}""")]
[InlineData(false, true, """{"data1_json_name":"data 1","data2":"data 2","vector1_json_name":[1,2,3,4],"vector2":[1,2,3,4],"notAnnotated":null}""")]
[InlineData(false, false, """{"data1_json_name":"data 1","Data2":"data 2","vector1_json_name":[1,2,3,4],"Vector2":[1,2,3,4],"NotAnnotated":null}""")]
public async Task CanUpsertRecordAsync(bool useDefinition, bool useCustomJsonSerializerOptions, string expectedUpsertedJson)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, "OK");
using var sut = this.CreateRecordCollection(useDefinition, useCustomJsonSerializerOptions);
var model = CreateModel(TestRecordKey1, true);
// Act
await sut.UpsertAsync(model);
// Assert
// TODO: Fix issue where NotAnnotated is being included in the JSON.
var expectedArgs = new object[] { TestRecordKey1, "$", expectedUpsertedJson };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.SET",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanUpsertManyRecordsAsync(bool useDefinition)
{
// Arrange
SetupExecuteMock(this._redisDatabaseMock, "OK");
using var sut = this.CreateRecordCollection(useDefinition);
var model1 = CreateModel(TestRecordKey1, true);
var model2 = CreateModel(TestRecordKey2, true);
// Act
await sut.UpsertAsync([model1, model2]);
// Assert
// TODO: Fix issue where NotAnnotated is being included in the JSON.
var expectedArgs = new object[] { TestRecordKey1, "$", """{"data1_json_name":"data 1","Data2":"data 2","vector1_json_name":[1,2,3,4],"Vector2":[1,2,3,4],"NotAnnotated":null}""", TestRecordKey2, "$", """{"data1_json_name":"data 1","Data2":"data 2","vector1_json_name":[1,2,3,4],"Vector2":[1,2,3,4],"NotAnnotated":null}""" };
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"JSON.MSET",
It.Is<object[]>(x => x.SequenceEqual(expectedArgs))),
Times.Once);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CanSearchWithVectorAndFilterAsync(bool useDefinition)
{
// Arrange
var jsonResult = """{ "data1_json_name": "data 1", "Data2": "data 2", "vector1_json_name": [1, 2, 3, 4], "Vector2": [1, 2, 3, 4] }""";
SetupExecuteMock(this._redisDatabaseMock, new RedisResult[]
{
RedisResult.Create(new RedisValue("1")),
RedisResult.Create(new RedisValue(TestRecordKey1)),
RedisResult.Create(new RedisValue("0.8")),
RedisResult.Create(
[
new RedisValue("$"),
new RedisValue(jsonResult),
new RedisValue("vector_score"),
new RedisValue("0.25")
]),
});
using var sut = this.CreateRecordCollection(useDefinition);
// Act.
var results = await sut.SearchAsync(
new ReadOnlyMemory<float>(new[] { 1f, 2f, 3f, 4f }),
top: 5,
new()
{
IncludeVectors = true,
Filter = r => r.Data1 == "data 1",
VectorProperty = r => r.Vector1,
Skip = 2
}).ToListAsync();
// Assert.
var expectedArgs = new object[]
{
"testcollection",
"@data1_json_name:{\"data 1\"}=>[KNN 7 @vector1_json_name $embedding AS vector_score]",
"WITHSCORES",
"SORTBY",
"vector_score",
"LIMIT",
2,
7,
"PARAMS",
2,
"embedding",
MemoryMarshal.AsBytes(new ReadOnlySpan<float>(new float[] { 1, 2, 3, 4 })).ToArray(),
"DIALECT",
2
};
this._redisDatabaseMock
.Verify(
x => x.ExecuteAsync(
"FT.SEARCH",
It.Is<object[]>(x => x.Where(y => !(y is byte[])).SequenceEqual(expectedArgs.Where(y => !(y is byte[]))))),
Times.Once);
Assert.Single(results);
Assert.Equal(TestRecordKey1, results.First().Record.Key);
Assert.Equal(0.25d, results.First().Score);
Assert.Equal("data 1", results.First().Record.Data1);
Assert.Equal("data 2", results.First().Record.Data2);
Assert.Equal(new float[] { 1, 2, 3, 4 }, results.First().Record.Vector1!.Value.ToArray());
Assert.Equal(new float[] { 1, 2, 3, 4 }, results.First().Record.Vector2!.Value.ToArray());
}
private RedisJsonCollection<string, MultiPropsModel> CreateRecordCollection(bool useDefinition, bool useCustomJsonSerializerOptions = false)
{
return new RedisJsonCollection<string, MultiPropsModel>(
this._redisDatabaseMock.Object,
TestCollectionName,
new()
{
PrefixCollectionNameToKeyNames = false,
Definition = useDefinition ? this._multiPropsDefinition : null,
JsonSerializerOptions = useCustomJsonSerializerOptions ? this._customJsonSerializerOptions : null
});
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, Exception exception)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ThrowsAsync(exception);
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string command, Exception exception)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
command,
It.IsAny<object[]>()))
.ThrowsAsync(exception);
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, IEnumerable<string> redisResultStrings)
{
var results = redisResultStrings
.Select(x => RedisResult.Create(new RedisValue(x)))
.ToArray();
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ReturnsAsync(RedisResult.Create(results));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, IEnumerable<RedisResult> redisResultStrings)
{
var results = redisResultStrings
.Select(x => x)
.ToArray();
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ReturnsAsync(RedisResult.Create(results));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string redisResultString)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.Callback((string command, object[] args) =>
{
Console.WriteLine(args);
})
.ReturnsAsync(RedisResult.Create(new RedisValue(redisResultString)));
}
private static void SetupExecuteMock(Mock<IDatabase> redisDatabaseMock, string command, string redisResultString)
{
redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
command,
It.IsAny<object[]>()))
.Callback((string command, object[] args) =>
{
Console.WriteLine(args);
})
.ReturnsAsync(RedisResult.Create(new RedisValue(redisResultString)));
}
private static MultiPropsModel CreateModel(string key, bool withVectors)
{
return new MultiPropsModel
{
Key = key,
Data1 = "data 1",
Data2 = "data 2",
Vector1 = withVectors ? new float[] { 1, 2, 3, 4 } : null,
Vector2 = withVectors ? new float[] { 1, 2, 3, 4 } : null,
NotAnnotated = null,
};
}
private readonly JsonSerializerOptions _customJsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly VectorStoreCollectionDefinition _multiPropsDefinition = new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("Data1", typeof(string)) { IsIndexed = true, StorageName = "ignored_data1_storage_name" },
new VectorStoreDataProperty("Data2", typeof(string)) { IsIndexed = true },
new VectorStoreVectorProperty("Vector1", typeof(ReadOnlyMemory<float>), 4) { DistanceFunction = DistanceFunction.CosineDistance, StorageName = "ignored_vector1_storage_name" },
new VectorStoreVectorProperty("Vector2", typeof(ReadOnlyMemory<float>), 4)
]
};
public sealed class MultiPropsModel
{
[VectorStoreKey]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("data1_json_name")]
[VectorStoreData(IsIndexed = true, StorageName = "ignored_data1_storage_name")]
public string Data1 { get; set; } = string.Empty;
[VectorStoreData(IsIndexed = true)]
public string Data2 { get; set; } = string.Empty;
[JsonPropertyName("vector1_json_name")]
[VectorStoreVector(4, DistanceFunction = DistanceFunction.CosineDistance, StorageName = "ignored_vector1_storage_name")]
public ReadOnlyMemory<float>? Vector1 { get; set; }
[VectorStoreVector(4)]
public ReadOnlyMemory<float>? Vector2 { get; set; }
public string? NotAnnotated { get; set; }
}
}
@@ -0,0 +1,181 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.VectorData;
using Microsoft.Extensions.VectorData.ProviderServices;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisJsonDynamicMapper"/> class.
/// </summary>
public class RedisJsonDynamicMapperTests
{
private static readonly float[] s_floatVector = new float[] { 1.0f, 2.0f, 3.0f, 4.0f };
private static readonly CollectionModel s_model
= new RedisJsonModelBuilder(RedisJsonCollection<object, Dictionary<string, object?>>.ModelBuildingOptions)
.BuildDynamic(
new()
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(string)),
new VectorStoreDataProperty("StringData", typeof(string)),
new VectorStoreDataProperty("IntData", typeof(int)),
new VectorStoreDataProperty("NullableIntData", typeof(int?)),
new VectorStoreDataProperty("ComplexObjectData", typeof(ComplexObject)),
new VectorStoreVectorProperty("FloatVector", typeof(ReadOnlyMemory<float>), 10),
]
},
defaultEmbeddingGenerator: null);
[Fact]
public void MapFromDataToStorageModelMapsAllSupportedTypes()
{
// Arrange.
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
var dataModel = new Dictionary<string, object?>
{
["Key"] = "key",
["StringData"] = "data 1",
["IntData"] = 1,
["NullableIntData"] = 2,
["ComplexObjectData"] = new ComplexObject { Prop1 = "prop 1", Prop2 = "prop 2" },
["FloatVector"] = new ReadOnlyMemory<float>(s_floatVector)
};
// Act.
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
Assert.Equal("data 1", (string)storageModel.Node["StringData"]!);
Assert.Equal(1, (int)storageModel.Node["IntData"]!);
Assert.Equal(2, (int?)storageModel.Node["NullableIntData"]!);
Assert.Equal("prop 1", (string)storageModel.Node["ComplexObjectData"]!.AsObject()["Prop1"]!);
Assert.Equal(new float[] { 1, 2, 3, 4 }, storageModel.Node["FloatVector"]?.AsArray().GetValues<float>().ToArray());
}
[Fact]
public void MapFromDataToStorageModelMapsNullValues()
{
// Arrange.
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
var dataModel = new Dictionary<string, object?>
{
["Key"] = "key",
["StringData"] = null,
["IntData"] = null,
["NullableIntData"] = null,
["ComplexObjectData"] = null,
["FloatVector"] = null,
};
// Act.
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
Assert.Null(storageModel.Node["storage_string_data"]);
Assert.Null(storageModel.Node["IntData"]);
Assert.Null(storageModel.Node["NullableIntData"]);
Assert.Null(storageModel.Node["ComplexObjectData"]);
Assert.Null(storageModel.Node["FloatVector"]);
}
[Fact]
public void MapFromStorageToDataModelMapsAllSupportedTypes()
{
// Arrange.
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
var storageModel = new JsonObject
{
{ "StringData", "data 1" },
{ "IntData", 1 },
{ "NullableIntData", 2 },
{ "ComplexObjectData", new JsonObject(new KeyValuePair<string, JsonNode?>[] { new("Prop1", JsonValue.Create("prop 1")), new("Prop2", JsonValue.Create("prop 2")) }) },
{ "FloatVector", new JsonArray(new float[] { 1, 2, 3, 4 }.Select(x => JsonValue.Create(x)).ToArray()) }
};
// Act.
var dataModel = sut.MapFromStorageToDataModel(("key", storageModel), includeVectors: true);
// Assert.
Assert.Equal("key", dataModel["Key"]);
Assert.Equal("data 1", dataModel["StringData"]);
Assert.Equal(1, dataModel["IntData"]);
Assert.Equal(2, dataModel["NullableIntData"]);
Assert.Equal("prop 1", ((ComplexObject)dataModel["ComplexObjectData"]!).Prop1);
Assert.Equal(new float[] { 1, 2, 3, 4 }, ((ReadOnlyMemory<float>)dataModel["FloatVector"]!).ToArray());
}
[Fact]
public void MapFromStorageToDataModelMapsNullValues()
{
// Arrange.
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
var storageModel = new JsonObject
{
{ "StringData", null },
{ "IntData", null },
{ "NullableIntData", null },
{ "ComplexObjectData", null },
{ "FloatVector", null }
};
// Act.
var dataModel = sut.MapFromStorageToDataModel(("key", storageModel), includeVectors: true);
// Assert.
Assert.Equal("key", dataModel["Key"]);
Assert.Null(dataModel["StringData"]);
Assert.Null(dataModel["IntData"]);
Assert.Null(dataModel["NullableIntData"]);
Assert.Null(dataModel["ComplexObjectData"]);
Assert.Null(dataModel["FloatVector"]);
}
[Fact]
public void MapFromDataToStorageModelSkipsMissingProperties()
{
// Arrange.
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
var dataModel = new Dictionary<string, object?> { ["Key"] = "key" };
// Act.
var storageModel = sut.MapFromDataToStorageModel(dataModel, recordIndex: 0, generatedEmbeddings: null);
// Assert
Assert.Equal("key", storageModel.Key);
Assert.Empty(storageModel.Node.AsObject());
}
[Fact]
public void MapFromStorageToDataModelSkipsMissingProperties()
{
// Arrange.
var storageModel = new JsonObject();
var sut = new RedisJsonDynamicMapper(s_model, JsonSerializerOptions.Default);
// Act.
var dataModel = sut.MapFromStorageToDataModel(("key", storageModel), includeVectors: true);
// Assert.
Assert.Equal("key", dataModel["Key"]);
Assert.Single(dataModel);
}
private sealed class ComplexObject
{
public string Prop1 { get; set; } = string.Empty;
public string Prop2 { get; set; } = string.Empty;
}
}
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.Redis;
using Xunit;
namespace SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisJsonMapper{TConsumerDataModel}"/> class.
/// </summary>
public sealed class RedisJsonMapperTests
{
[Fact]
public void MapsAllFieldsFromDataToStorageModel()
{
// Arrange.
var model = new RedisJsonModelBuilder(RedisJsonCollection<string, MultiPropsModel>.ModelBuildingOptions)
.Build(typeof(MultiPropsModel), typeof(string), definition: null, defaultEmbeddingGenerator: null, JsonSerializerOptions.Default);
var sut = new RedisJsonMapper<MultiPropsModel>(model, JsonSerializerOptions.Default);
// Act.
var actual = sut.MapFromDataToStorageModel(CreateModel("test key"), recordIndex: 0, generatedEmbeddings: null);
// Assert.
Assert.NotNull(actual.Node);
Assert.Equal("test key", actual.Key);
var jsonObject = actual.Node.AsObject();
Assert.Equal("data 1", jsonObject?["Data1"]?.ToString());
Assert.Equal("data 2", jsonObject?["Data2"]?.ToString());
Assert.Equal(new float[] { 1, 2, 3, 4 }, jsonObject?["Vector1"]?.AsArray().GetValues<float>().ToArray());
Assert.Equal(new float[] { 5, 6, 7, 8 }, jsonObject?["Vector2"]?.AsArray().GetValues<float>().ToArray());
}
[Fact]
public void MapsAllFieldsFromDataToStorageModelWithCustomSerializerOptions()
{
// Arrange.
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var model = new RedisJsonModelBuilder(RedisJsonCollection<string, MultiPropsModel>.ModelBuildingOptions)
.Build(typeof(MultiPropsModel), typeof(string), definition: null, defaultEmbeddingGenerator: null, jsonSerializerOptions);
var sut = new RedisJsonMapper<MultiPropsModel>(model, jsonSerializerOptions);
// Act.
var actual = sut.MapFromDataToStorageModel(CreateModel("test key"), recordIndex: 0, generatedEmbeddings: null);
// Assert.
Assert.NotNull(actual.Node);
Assert.Equal("test key", actual.Key);
var jsonObject = actual.Node.AsObject();
Assert.Equal("data 1", jsonObject?["data1"]?.ToString());
Assert.Equal("data 2", jsonObject?["data2"]?.ToString());
Assert.Equal(new float[] { 1, 2, 3, 4 }, jsonObject?["vector1"]?.AsArray().GetValues<float>().ToArray());
Assert.Equal(new float[] { 5, 6, 7, 8 }, jsonObject?["vector2"]?.AsArray().GetValues<float>().ToArray());
}
[Fact]
public void MapsAllFieldsFromStorageToDataModel()
{
// Arrange.
var model = new RedisJsonModelBuilder(RedisJsonCollection<string, MultiPropsModel>.ModelBuildingOptions)
.Build(typeof(MultiPropsModel), typeof(string), definition: null, defaultEmbeddingGenerator: null, JsonSerializerOptions.Default);
var sut = new RedisJsonMapper<MultiPropsModel>(model, JsonSerializerOptions.Default);
// Act.
var jsonObject = new JsonObject
{
{ "Data1", "data 1" },
{ "Data2", "data 2" },
{ "Vector1", new JsonArray(new[] { 1, 2, 3, 4 }.Select(x => JsonValue.Create(x)).ToArray()) },
{ "Vector2", new JsonArray(new[] { 5, 6, 7, 8 }.Select(x => JsonValue.Create(x)).ToArray()) }
};
var actual = sut.MapFromStorageToDataModel(("test key", jsonObject), includeVectors: true);
// Assert.
Assert.NotNull(actual);
Assert.Equal("test key", actual.Key);
Assert.Equal("data 1", actual.Data1);
Assert.Equal("data 2", actual.Data2);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector1!.Value.ToArray());
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vector2!.Value.ToArray());
}
[Fact]
public void MapsAllFieldsFromStorageToDataModelWithCustomSerializerOptions()
{
// Arrange.
var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var model = new RedisJsonModelBuilder(RedisJsonCollection<string, MultiPropsModel>.ModelBuildingOptions)
.Build(typeof(MultiPropsModel), typeof(string), definition: null, defaultEmbeddingGenerator: null, jsonSerializerOptions);
var sut = new RedisJsonMapper<MultiPropsModel>(model, jsonSerializerOptions);
// Act.
var jsonObject = new JsonObject
{
{ "data1", "data 1" },
{ "data2", "data 2" },
{ "vector1", new JsonArray(new[] { 1, 2, 3, 4 }.Select(x => JsonValue.Create(x)).ToArray()) },
{ "vector2", new JsonArray(new[] { 5, 6, 7, 8 }.Select(x => JsonValue.Create(x)).ToArray()) }
};
var actual = sut.MapFromStorageToDataModel(("test key", jsonObject), includeVectors: true);
// Assert.
Assert.NotNull(actual);
Assert.Equal("test key", actual.Key);
Assert.Equal("data 1", actual.Data1);
Assert.Equal("data 2", actual.Data2);
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector1!.Value.ToArray());
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vector2!.Value.ToArray());
}
private static MultiPropsModel CreateModel(string key)
{
return new MultiPropsModel
{
Key = key,
Data1 = "data 1",
Data2 = "data 2",
Vector1 = new float[] { 1, 2, 3, 4 },
Vector2 = new float[] { 5, 6, 7, 8 },
NotAnnotated = "notAnnotated",
};
}
private sealed class MultiPropsModel
{
[VectorStoreKey]
public string Key { get; set; } = string.Empty;
[VectorStoreData]
public string Data1 { get; set; } = string.Empty;
[VectorStoreData]
public string Data2 { get; set; } = string.Empty;
[VectorStoreVector(10)]
public ReadOnlyMemory<float>? Vector1 { get; set; }
[VectorStoreVector(10)]
public ReadOnlyMemory<float>? Vector2 { get; set; }
public string NotAnnotated { get; set; } = string.Empty;
}
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.VectorData;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Microsoft.SemanticKernel.Connectors.Redis.UnitTests;
/// <summary>
/// Contains tests for the <see cref="RedisVectorStore"/> class.
/// </summary>
public class RedisVectorStoreTests
{
private const string TestCollectionName = "testcollection";
private readonly Mock<IDatabase> _redisDatabaseMock;
public RedisVectorStoreTests()
{
this._redisDatabaseMock = new Mock<IDatabase>(MockBehavior.Strict);
this._redisDatabaseMock.Setup(l => l.Database).Returns(0);
var batchMock = new Mock<IBatch>();
this._redisDatabaseMock.Setup(x => x.CreateBatch(It.IsAny<object>())).Returns(batchMock.Object);
}
[Fact]
public void GetCollectionReturnsJsonCollection()
{
// Arrange.
using var sut = new RedisVectorStore(this._redisDatabaseMock.Object);
// Act.
var actual = sut.GetCollection<string, SinglePropsModel<string>>(TestCollectionName);
// Assert.
Assert.NotNull(actual);
Assert.IsType<RedisJsonCollection<string, SinglePropsModel<string>>>(actual);
}
[Fact]
public void GetCollectionReturnsHashSetCollection()
{
// Arrange.
using var sut = new RedisVectorStore(this._redisDatabaseMock.Object, new() { StorageType = RedisStorageType.HashSet });
// Act.
var actual = sut.GetCollection<string, SinglePropsModel<string>>(TestCollectionName);
// Assert.
Assert.NotNull(actual);
Assert.IsType<RedisHashSetCollection<string, SinglePropsModel<string>>>(actual);
}
[Fact]
public void GetCollectionThrowsForInvalidKeyType()
{
// Arrange.
using var sut = new RedisVectorStore(this._redisDatabaseMock.Object);
// Act & Assert.
Assert.Throws<NotSupportedException>(() => sut.GetCollection<int, SinglePropsModel<int>>(TestCollectionName));
}
[Fact]
public async Task ListCollectionNamesCallsSDKAsync()
{
// Arrange.
var redisResultStrings = new string[] { "collection1", "collection2" };
var results = redisResultStrings
.Select(x => RedisResult.Create(new RedisValue(x)))
.ToArray();
this._redisDatabaseMock
.Setup(
x => x.ExecuteAsync(
It.IsAny<string>(),
It.IsAny<object[]>()))
.ReturnsAsync(RedisResult.Create(results));
using var sut = new RedisVectorStore(this._redisDatabaseMock.Object);
// Act.
var collectionNames = sut.ListCollectionNamesAsync();
// Assert.
var collectionNamesList = await collectionNames.ToListAsync();
Assert.Equal(new[] { "collection1", "collection2" }, collectionNamesList);
}
public sealed class SinglePropsModel<TKey>
{
[VectorStoreKey]
public required TKey Key { get; set; }
[VectorStoreData]
public string Data { get; set; } = string.Empty;
[VectorStoreVector(4)]
public ReadOnlyMemory<float>? Vector { get; set; }
public string? NotAnnotated { get; set; }
}
}