chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Microsoft.SemanticKernel.Connectors.Qdrant.UnitTests</AssemblyName>
|
||||
<RootNamespace>Microsoft.SemanticKernel.Connectors.Qdrant.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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/test/AssertExtensions.cs"
|
||||
Link="%(RecursiveDir)%(Filename)%(Extension)"/>
|
||||
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/test/HttpMessageHandlerStub.cs"
|
||||
Link="%(RecursiveDir)%(Filename)%(Extension)"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\VectorData\Qdrant\Qdrant.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.Extensions.VectorData.ProviderServices;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Connectors.Qdrant.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="QdrantCollectionCreateMapping"/> class.
|
||||
/// </summary>
|
||||
public class QdrantCollectionCreateMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapSingleVectorCreatesVectorParams()
|
||||
{
|
||||
// Arrange.
|
||||
var vectorProperty = new VectorPropertyModel("testvector", typeof(ReadOnlyMemory<float>)) { Dimensions = 4, DistanceFunction = DistanceFunction.DotProductSimilarity };
|
||||
|
||||
// Act.
|
||||
var actual = QdrantCollectionCreateMapping.MapSingleVector(vectorProperty);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(Distance.Dot, actual.Distance);
|
||||
Assert.Equal(4ul, actual.Size);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSingleVectorDefaultsToCosine()
|
||||
{
|
||||
// Arrange.
|
||||
var vectorProperty = new VectorPropertyModel("testvector", typeof(ReadOnlyMemory<float>)) { Dimensions = 4 };
|
||||
|
||||
// Act.
|
||||
var actual = QdrantCollectionCreateMapping.MapSingleVector(vectorProperty);
|
||||
|
||||
// Assert.
|
||||
Assert.Equal(Distance.Cosine, actual.Distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSingleVectorThrowsForUnsupportedDistanceFunction()
|
||||
{
|
||||
// Arrange.
|
||||
var vectorProperty = new VectorPropertyModel("testvector", typeof(ReadOnlyMemory<float>)) { Dimensions = 4, DistanceFunction = DistanceFunction.CosineDistance };
|
||||
|
||||
// Act and assert.
|
||||
Assert.Throws<NotSupportedException>(() => QdrantCollectionCreateMapping.MapSingleVector(vectorProperty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapNamedVectorsCreatesVectorParamsMap()
|
||||
{
|
||||
// Arrange.
|
||||
var vectorProperties = new VectorPropertyModel[]
|
||||
{
|
||||
new("testvector1", typeof(ReadOnlyMemory<float>))
|
||||
{
|
||||
Dimensions = 10,
|
||||
DistanceFunction = DistanceFunction.EuclideanDistance,
|
||||
StorageName = "storage_testvector1"
|
||||
},
|
||||
new("testvector2", typeof(ReadOnlyMemory<float>))
|
||||
{
|
||||
Dimensions = 20,
|
||||
StorageName = "storage_testvector2"
|
||||
}
|
||||
};
|
||||
|
||||
// Act.
|
||||
var actual = QdrantCollectionCreateMapping.MapNamedVectors(vectorProperties);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(2, actual.Map.Count);
|
||||
Assert.Equal(10ul, actual.Map["storage_testvector1"].Size);
|
||||
Assert.Equal(Distance.Euclid, actual.Map["storage_testvector1"].Distance);
|
||||
Assert.Equal(20ul, actual.Map["storage_testvector2"].Size);
|
||||
Assert.Equal(Distance.Cosine, actual.Map["storage_testvector2"].Distance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Connectors.Qdrant.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="QdrantCollectionSearchMapping"/> class.
|
||||
/// </summary>
|
||||
public class QdrantCollectionSearchMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapScoredPointToVectorSearchResultMapsResults()
|
||||
{
|
||||
var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3] }");
|
||||
|
||||
// Arrange.
|
||||
var scoredPoint = new ScoredPoint
|
||||
{
|
||||
Id = 1,
|
||||
Payload = { ["storage_DataField"] = "data 1" },
|
||||
Vectors = new VectorsOutput() { Vector = responseVector },
|
||||
Score = 0.5f
|
||||
};
|
||||
|
||||
var model = new QdrantModelBuilder(hasNamedVectors: false)
|
||||
.Build(
|
||||
typeof(DataModel),
|
||||
typeof(ulong),
|
||||
new()
|
||||
{
|
||||
Properties =
|
||||
[
|
||||
new VectorStoreKeyProperty("Id", typeof(ulong)),
|
||||
new VectorStoreDataProperty("DataField", typeof(string)) { StorageName = "storage_DataField" },
|
||||
new VectorStoreVectorProperty("Embedding", typeof(ReadOnlyMemory<float>), 10),
|
||||
]
|
||||
},
|
||||
defaultEmbeddingGenerator: null);
|
||||
|
||||
var mapper = new QdrantMapper<DataModel>(model, hasNamedVectors: false);
|
||||
|
||||
// Act.
|
||||
var actual = QdrantCollectionSearchMapping.MapScoredPointToVectorSearchResult<DataModel>(scoredPoint, mapper, true, "Qdrant", "myvectorstore", "mycollection", "query");
|
||||
|
||||
// Assert.
|
||||
Assert.Equal(1ul, actual.Record.Id);
|
||||
Assert.Equal("data 1", actual.Record.DataField);
|
||||
Assert.Equal(new float[] { 1, 2, 3 }, actual.Record.Embedding.ToArray());
|
||||
Assert.Equal(0.5f, actual.Score);
|
||||
}
|
||||
|
||||
public class DataModel
|
||||
{
|
||||
public ulong Id { get; init; }
|
||||
|
||||
public string? DataField { get; init; }
|
||||
|
||||
public ReadOnlyMemory<float> Embedding { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Moq;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Connectors.Qdrant.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="QdrantCollection{TKey, TRecord}"/> class.
|
||||
/// </summary>
|
||||
public class QdrantCollectionTests
|
||||
{
|
||||
private const string TestCollectionName = "testcollection";
|
||||
private const ulong UlongTestRecordKey1 = 1;
|
||||
private const ulong UlongTestRecordKey2 = 2;
|
||||
private static readonly Guid s_guidTestRecordKey1 = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly Guid s_guidTestRecordKey2 = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
private readonly Mock<MockableQdrantClient> _qdrantClientMock;
|
||||
|
||||
private readonly CancellationToken _testCancellationToken = new(false);
|
||||
|
||||
public QdrantCollectionTests()
|
||||
{
|
||||
this._qdrantClientMock = new Mock<MockableQdrantClient>(MockBehavior.Strict);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TestCollectionName, true)]
|
||||
[InlineData("nonexistentcollection", false)]
|
||||
public async Task CollectionExistsReturnsCollectionStateAsync(string collectionName, bool expectedExists)
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = new QdrantCollection<ulong, SinglePropsModel<ulong>>(() => this._qdrantClientMock.Object, collectionName);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.CollectionExistsAsync(
|
||||
It.IsAny<string>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(expectedExists);
|
||||
|
||||
// Act.
|
||||
var actual = await sut.CollectionExistsAsync(this._testCancellationToken);
|
||||
|
||||
// Assert.
|
||||
Assert.Equal(expectedExists, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanCreateCollectionAsync()
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = new QdrantCollection<ulong, SinglePropsModel<ulong>>(() => this._qdrantClientMock.Object, TestCollectionName);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.CollectionExistsAsync(
|
||||
It.IsAny<string>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.CreateCollectionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<VectorParams>(),
|
||||
this._testCancellationToken))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.CreatePayloadIndexAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<PayloadSchemaType>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
|
||||
// Act.
|
||||
await sut.EnsureCollectionExistsAsync(this._testCancellationToken);
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.CreateCollectionAsync(
|
||||
TestCollectionName,
|
||||
It.Is<VectorParams>(x => x.Size == 4),
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.CreatePayloadIndexAsync(
|
||||
TestCollectionName,
|
||||
"OriginalNameData",
|
||||
PayloadSchemaType.Keyword,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.CreatePayloadIndexAsync(
|
||||
TestCollectionName,
|
||||
"OriginalNameData",
|
||||
PayloadSchemaType.Text,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.CreatePayloadIndexAsync(
|
||||
TestCollectionName,
|
||||
"data_storage_name",
|
||||
PayloadSchemaType.Keyword,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanDeleteCollectionAsync()
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = new QdrantCollection<ulong, SinglePropsModel<ulong>>(() => this._qdrantClientMock.Object, TestCollectionName);
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.DeleteCollectionAsync(
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
this._testCancellationToken))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act.
|
||||
await sut.EnsureCollectionDeletedAsync(this._testCancellationToken);
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.DeleteCollectionAsync(
|
||||
TestCollectionName,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestOptions))]
|
||||
public async Task CanGetRecordWithVectorsAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey testRecordKey)
|
||||
where TKey : notnull
|
||||
{
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
|
||||
// Arrange.
|
||||
var retrievedPoint = CreateRetrievedPoint(hasNamedVectors, testRecordKey);
|
||||
this.SetupRetrieveMock([retrievedPoint]);
|
||||
|
||||
// Act.
|
||||
var actual = await sut.GetAsync(
|
||||
testRecordKey,
|
||||
new() { IncludeVectors = true },
|
||||
this._testCancellationToken);
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.RetrieveAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<PointId>>(x => x.Count == 1 && (testRecordKey!.GetType() == typeof(ulong) && x[0].Num == (testRecordKey as ulong?) || testRecordKey!.GetType() == typeof(Guid) && x[0].Uuid == (testRecordKey as Guid?).ToString())),
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(testRecordKey, 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]
|
||||
[MemberData(nameof(TestOptions))]
|
||||
public async Task CanGetRecordWithoutVectorsAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey testRecordKey)
|
||||
where TKey : notnull
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
var retrievedPoint = CreateRetrievedPoint(hasNamedVectors, testRecordKey);
|
||||
this.SetupRetrieveMock([retrievedPoint]);
|
||||
|
||||
// Act.
|
||||
var actual = await sut.GetAsync(
|
||||
testRecordKey,
|
||||
new() { IncludeVectors = false },
|
||||
this._testCancellationToken);
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.RetrieveAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<PointId>>(x => x.Count == 1 && (testRecordKey!.GetType() == typeof(ulong) && x[0].Num == (testRecordKey as ulong?) || testRecordKey!.GetType() == typeof(Guid) && x[0].Uuid == (testRecordKey as Guid?).ToString())),
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(testRecordKey, actual.Key);
|
||||
Assert.Equal("data 1", actual.OriginalNameData);
|
||||
Assert.Equal("data 1", actual.Data);
|
||||
Assert.Null(actual.Vector);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MultiRecordTestOptions))]
|
||||
public async Task CanGetManyRecordsWithVectorsAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey[] testRecordKeys)
|
||||
where TKey : notnull
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
var retrievedPoint1 = CreateRetrievedPoint(hasNamedVectors, UlongTestRecordKey1);
|
||||
var retrievedPoint2 = CreateRetrievedPoint(hasNamedVectors, UlongTestRecordKey2);
|
||||
this.SetupRetrieveMock(testRecordKeys.Select(x => CreateRetrievedPoint(hasNamedVectors, x)).ToList());
|
||||
|
||||
// Act.
|
||||
var actual = await sut.GetAsync(
|
||||
testRecordKeys,
|
||||
new() { IncludeVectors = true },
|
||||
this._testCancellationToken).ToListAsync();
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.RetrieveAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<PointId>>(x =>
|
||||
x.Count == 2 &&
|
||||
(testRecordKeys[0]!.GetType() == typeof(ulong) && x[0].Num == (testRecordKeys[0] as ulong?) || testRecordKeys[0]!.GetType() == typeof(Guid) && x[0].Uuid == (testRecordKeys[0] as Guid?).ToString()) &&
|
||||
(testRecordKeys[1]!.GetType() == typeof(ulong) && x[1].Num == (testRecordKeys[1] as ulong?) || testRecordKeys[1]!.GetType() == typeof(Guid) && x[1].Uuid == (testRecordKeys[1] as Guid?).ToString())),
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(2, actual.Count);
|
||||
Assert.Equal(testRecordKeys[0], actual[0].Key);
|
||||
Assert.Equal(testRecordKeys[1], actual[1].Key);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task CanDeleteUlongRecordAsync(bool useDefinition, bool hasNamedVectors)
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<ulong>(useDefinition, hasNamedVectors);
|
||||
this.SetupDeleteMocks();
|
||||
|
||||
// Act
|
||||
await sut.DeleteAsync(
|
||||
UlongTestRecordKey1,
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.DeleteAsync(
|
||||
TestCollectionName,
|
||||
It.Is<ulong>(x => x == UlongTestRecordKey1),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task CanDeleteGuidRecordAsync(bool useDefinition, bool hasNamedVectors)
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<Guid>(useDefinition, hasNamedVectors);
|
||||
this.SetupDeleteMocks();
|
||||
|
||||
// Act
|
||||
await sut.DeleteAsync(
|
||||
s_guidTestRecordKey1,
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.DeleteAsync(
|
||||
TestCollectionName,
|
||||
It.Is<Guid>(x => x == s_guidTestRecordKey1),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task CanDeleteManyUlongRecordsAsync(bool useDefinition, bool hasNamedVectors)
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<ulong>(useDefinition, hasNamedVectors);
|
||||
this.SetupDeleteMocks();
|
||||
|
||||
// Act
|
||||
await sut.DeleteAsync(
|
||||
[UlongTestRecordKey1, UlongTestRecordKey2],
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.DeleteAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<ulong>>(x => x.Count == 2 && x.Contains(UlongTestRecordKey1) && x.Contains(UlongTestRecordKey2)),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task CanDeleteManyGuidRecordsAsync(bool useDefinition, bool hasNamedVectors)
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<Guid>(useDefinition, hasNamedVectors);
|
||||
this.SetupDeleteMocks();
|
||||
|
||||
// Act
|
||||
await sut.DeleteAsync(
|
||||
[s_guidTestRecordKey1, s_guidTestRecordKey2],
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.DeleteAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<Guid>>(x => x.Count == 2 && x.Contains(s_guidTestRecordKey1) && x.Contains(s_guidTestRecordKey2)),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestOptions))]
|
||||
public async Task CanUpsertRecordAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey testRecordKey)
|
||||
where TKey : notnull
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
this.SetupUpsertMock();
|
||||
|
||||
// Act
|
||||
await sut.UpsertAsync(
|
||||
CreateModel(testRecordKey, true),
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.UpsertAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<PointStruct>>(x => x.Count == 1 && (testRecordKey!.GetType() == typeof(ulong) && x[0].Id.Num == (testRecordKey as ulong?) || testRecordKey!.GetType() == typeof(Guid) && x[0].Id.Uuid == (testRecordKey as Guid?).ToString())),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MultiRecordTestOptions))]
|
||||
public async Task CanUpsertManyRecordsAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey[] testRecordKeys)
|
||||
where TKey : notnull
|
||||
{
|
||||
// Arrange
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
this.SetupUpsertMock();
|
||||
|
||||
var models = testRecordKeys.Select(x => CreateModel(x, true));
|
||||
|
||||
// Act
|
||||
await sut.UpsertAsync(
|
||||
models,
|
||||
cancellationToken: this._testCancellationToken);
|
||||
|
||||
// Assert
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.UpsertAsync(
|
||||
TestCollectionName,
|
||||
It.Is<IReadOnlyList<PointStruct>>(x =>
|
||||
x.Count == 2 &&
|
||||
(testRecordKeys[0]!.GetType() == typeof(ulong) && x[0].Id.Num == (testRecordKeys[0] as ulong?) || testRecordKeys[0]!.GetType() == typeof(Guid) && x[0].Id.Uuid == (testRecordKeys[0] as Guid?).ToString()) &&
|
||||
(testRecordKeys[1]!.GetType() == typeof(ulong) && x[1].Id.Num == (testRecordKeys[1] as ulong?) || testRecordKeys[1]!.GetType() == typeof(Guid) && x[1].Id.Uuid == (testRecordKeys[1] as Guid?).ToString())),
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <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<ulong>.Key), typeof(ulong)),
|
||||
new VectorStoreDataProperty(nameof(SinglePropsModel<ulong>.OriginalNameData), typeof(string)),
|
||||
new VectorStoreVectorProperty(nameof(SinglePropsModel<ulong>.Vector), typeof(ReadOnlyMemory<float>?), 4),
|
||||
]
|
||||
};
|
||||
|
||||
// Act.
|
||||
using var sut = new QdrantCollection<ulong, SinglePropsModel<ulong>>(
|
||||
() => this._qdrantClientMock.Object,
|
||||
TestCollectionName,
|
||||
new() { Definition = definition });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TestOptions))]
|
||||
public async Task CanSearchWithVectorAndFilterAsync<TKey>(bool useDefinition, bool hasNamedVectors, TKey testRecordKey)
|
||||
where TKey : notnull
|
||||
{
|
||||
using var sut = this.CreateRecordCollection<TKey>(useDefinition, hasNamedVectors);
|
||||
|
||||
// Arrange.
|
||||
var scoredPoint = CreateScoredPoint(hasNamedVectors, testRecordKey);
|
||||
this.SetupQueryMock([scoredPoint]);
|
||||
|
||||
// Act.
|
||||
var results = await sut.SearchAsync(
|
||||
new ReadOnlyMemory<float>(new[] { 1f, 2f, 3f, 4f }),
|
||||
top: 5,
|
||||
new() { IncludeVectors = true, Filter = r => r.Data == "data 1", Skip = 2 },
|
||||
this._testCancellationToken).ToListAsync();
|
||||
|
||||
// Assert.
|
||||
this._qdrantClientMock
|
||||
.Verify(
|
||||
x => x.QueryAsync(
|
||||
TestCollectionName,
|
||||
It.Is<Query?>(x => x!.Nearest.Dense.Data.ToArray().SequenceEqual(new[] { 1f, 2f, 3f, 4f })),
|
||||
null,
|
||||
hasNamedVectors ? "vector_storage_name" : null,
|
||||
It.Is<Filter?>(x => x!.Must.Count == 1 && x.Must.First().Field.Key == "data_storage_name" && x.Must.First().Field.Match.Keyword == "data 1"),
|
||||
null,
|
||||
null,
|
||||
5,
|
||||
2,
|
||||
null,
|
||||
It.Is<WithVectorsSelector?>(x => x!.Enable == true),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
this._testCancellationToken),
|
||||
Times.Once);
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal(testRecordKey, results.First().Record.Key);
|
||||
Assert.Equal("data 1", results.First().Record.OriginalNameData);
|
||||
Assert.Equal("data 1", results.First().Record.Data);
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, results.First().Record.Vector!.Value.ToArray());
|
||||
Assert.Equal(0.5f, results.First().Score);
|
||||
}
|
||||
|
||||
private void SetupRetrieveMock(List<RetrievedPoint> retrievedPoints)
|
||||
{
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.RetrieveAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<PointId>>(),
|
||||
It.IsAny<bool>(), // With Payload
|
||||
It.IsAny<bool>(), // With Vectors
|
||||
It.IsAny<ReadConsistency>(),
|
||||
It.IsAny<ShardKeySelector>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(retrievedPoints);
|
||||
}
|
||||
|
||||
private void SetupQueryMock(List<ScoredPoint> scoredPoints)
|
||||
{
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.QueryAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Query?>(),
|
||||
null,
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<Filter?>(),
|
||||
null,
|
||||
null,
|
||||
It.IsAny<ulong>(),
|
||||
It.IsAny<ulong>(),
|
||||
null,
|
||||
It.IsAny<WithVectorsSelector?>(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(scoredPoints);
|
||||
}
|
||||
|
||||
private void SetupDeleteMocks()
|
||||
{
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.DeleteAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ulong>(),
|
||||
It.IsAny<bool>(), // wait
|
||||
It.IsAny<WriteOrderingType?>(),
|
||||
It.IsAny<ShardKeySelector?>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.DeleteAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Guid>(),
|
||||
It.IsAny<bool>(), // wait
|
||||
It.IsAny<WriteOrderingType?>(),
|
||||
It.IsAny<ShardKeySelector?>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.DeleteAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<ulong>>(),
|
||||
It.IsAny<bool>(), // wait
|
||||
It.IsAny<WriteOrderingType?>(),
|
||||
It.IsAny<ShardKeySelector?>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.DeleteAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<Guid>>(),
|
||||
It.IsAny<bool>(), // wait
|
||||
It.IsAny<WriteOrderingType?>(),
|
||||
It.IsAny<ShardKeySelector?>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
}
|
||||
|
||||
private void SetupUpsertMock()
|
||||
{
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.UpsertAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<PointStruct>>(),
|
||||
It.IsAny<bool>(), // wait
|
||||
It.IsAny<WriteOrderingType?>(),
|
||||
It.IsAny<ShardKeySelector?>(),
|
||||
this._testCancellationToken))
|
||||
.ReturnsAsync(new UpdateResult());
|
||||
}
|
||||
|
||||
private static RetrievedPoint CreateRetrievedPoint<TKey>(bool hasNamedVectors, TKey recordKey)
|
||||
{
|
||||
var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }");
|
||||
|
||||
RetrievedPoint point;
|
||||
if (hasNamedVectors)
|
||||
{
|
||||
var namedVectors = new NamedVectorsOutput();
|
||||
namedVectors.Vectors.Add("vector_storage_name", responseVector);
|
||||
point = new RetrievedPoint()
|
||||
{
|
||||
Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" },
|
||||
Vectors = new VectorsOutput { Vectors = namedVectors }
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
point = new RetrievedPoint()
|
||||
{
|
||||
Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" },
|
||||
Vectors = new VectorsOutput() { Vector = responseVector }
|
||||
};
|
||||
}
|
||||
|
||||
if (recordKey is ulong ulongKey)
|
||||
{
|
||||
point.Id = ulongKey;
|
||||
}
|
||||
|
||||
if (recordKey is Guid guidKey)
|
||||
{
|
||||
point.Id = guidKey;
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
private static ScoredPoint CreateScoredPoint<TKey>(bool hasNamedVectors, TKey recordKey)
|
||||
{
|
||||
var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }");
|
||||
|
||||
ScoredPoint point;
|
||||
if (hasNamedVectors)
|
||||
{
|
||||
var namedVectors = new NamedVectorsOutput();
|
||||
namedVectors.Vectors.Add("vector_storage_name", responseVector);
|
||||
point = new ScoredPoint()
|
||||
{
|
||||
Score = 0.5f,
|
||||
Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" },
|
||||
Vectors = new VectorsOutput { Vectors = namedVectors }
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
point = new ScoredPoint()
|
||||
{
|
||||
Score = 0.5f,
|
||||
Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" },
|
||||
Vectors = new VectorsOutput() { Vector = responseVector }
|
||||
};
|
||||
}
|
||||
|
||||
if (recordKey is ulong ulongKey)
|
||||
{
|
||||
point.Id = ulongKey;
|
||||
}
|
||||
|
||||
if (recordKey is Guid guidKey)
|
||||
{
|
||||
point.Id = guidKey;
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
private VectorStoreCollection<T, SinglePropsModel<T>> CreateRecordCollection<T>(bool useDefinition, bool hasNamedVectors)
|
||||
where T : notnull
|
||||
{
|
||||
var store = new QdrantCollection<T, SinglePropsModel<T>>(
|
||||
() => this._qdrantClientMock.Object,
|
||||
TestCollectionName,
|
||||
new()
|
||||
{
|
||||
Definition = useDefinition ? CreateSinglePropsDefinition(typeof(T)) : null,
|
||||
HasNamedVectors = hasNamedVectors
|
||||
}) as VectorStoreCollection<T, SinglePropsModel<T>>;
|
||||
return store!;
|
||||
}
|
||||
|
||||
private static SinglePropsModel<T> CreateModel<T>(T key, bool withVectors)
|
||||
{
|
||||
return new SinglePropsModel<T>
|
||||
{
|
||||
Key = key,
|
||||
OriginalNameData = "data 1",
|
||||
Data = "data 1",
|
||||
Vector = withVectors ? new float[] { 1, 2, 3, 4 } : null,
|
||||
NotAnnotated = null,
|
||||
};
|
||||
}
|
||||
|
||||
private static VectorStoreCollectionDefinition CreateSinglePropsDefinition(Type keyType)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Properties =
|
||||
[
|
||||
new VectorStoreKeyProperty("Key", keyType),
|
||||
new VectorStoreDataProperty("OriginalNameData", typeof(string)) { IsIndexed = true, IsFullTextIndexed = true },
|
||||
new VectorStoreDataProperty("Data", typeof(string)) { IsIndexed = true, StorageName = "data_storage_name" },
|
||||
new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory<float>), 4) { StorageName = "vector_storage_name" }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class SinglePropsModel<T>
|
||||
{
|
||||
[VectorStoreKey]
|
||||
public required T Key { get; set; }
|
||||
|
||||
[VectorStoreData(IsIndexed = true, IsFullTextIndexed = 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, StorageName = "vector_storage_name")]
|
||||
public ReadOnlyMemory<float>? Vector { get; set; }
|
||||
|
||||
public string? NotAnnotated { get; set; }
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> TestOptions
|
||||
=> GenerateAllCombinations(new object[][] {
|
||||
new object[] { true, false },
|
||||
new object[] { true, false },
|
||||
new object[] { UlongTestRecordKey1, s_guidTestRecordKey1 }
|
||||
});
|
||||
|
||||
public static IEnumerable<object[]> MultiRecordTestOptions
|
||||
=> GenerateAllCombinations(new object[][] {
|
||||
new object[] { true, false },
|
||||
new object[] { true, false },
|
||||
new object[] { new ulong[] { UlongTestRecordKey1, UlongTestRecordKey2 }, new Guid[] { s_guidTestRecordKey1, s_guidTestRecordKey2 } }
|
||||
});
|
||||
|
||||
private static object[][] GenerateAllCombinations(object[][] input)
|
||||
{
|
||||
var counterArray = Enumerable.Range(0, input.Length).Select(x => 0).ToArray();
|
||||
|
||||
// Add each item from the first option set as a separate row.
|
||||
object[][] currentCombinations = input[0].Select(x => new object[1] { x }).ToArray();
|
||||
|
||||
// Loop through each additional option set.
|
||||
for (int currentOptionSetIndex = 1; currentOptionSetIndex < input.Length; currentOptionSetIndex++)
|
||||
{
|
||||
var iterationCombinations = new List<object[]>();
|
||||
var currentOptionSet = input[currentOptionSetIndex];
|
||||
|
||||
// Loop through each row we have already.
|
||||
foreach (var currentCombination in currentCombinations)
|
||||
{
|
||||
// Add each of the values from the new options set to the current row to generate a new row.
|
||||
for (var currentColumnRow = 0; currentColumnRow < currentOptionSet.Length; currentColumnRow++)
|
||||
{
|
||||
iterationCombinations.Add(currentCombination.Append(currentOptionSet[currentColumnRow]).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
currentCombinations = iterationCombinations.ToArray();
|
||||
}
|
||||
|
||||
return currentCombinations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.Qdrant;
|
||||
using Qdrant.Client.Grpc;
|
||||
using Xunit;
|
||||
|
||||
namespace SemanticKernel.Connectors.Qdrant.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="QdrantMapper{TConsumerDataModel}"/> class.
|
||||
/// </summary>
|
||||
public class QdrantMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void MapsSinglePropsFromDataToStorageModelWithUlong(bool hasNamedVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateSinglePropsVectorStoreRecordDefinition(typeof(ulong));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors)
|
||||
.Build(typeof(SinglePropsModel<ulong>), typeof(ulong), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<SinglePropsModel<ulong>>(model, hasNamedVectors);
|
||||
|
||||
// Act.
|
||||
var actual = sut.MapFromDataToStorageModel(CreateSinglePropsModel<ulong>(5ul), recordIndex: 0, generatedEmbeddings: null);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(5ul, actual.Id.Num);
|
||||
Assert.Single(actual.Payload);
|
||||
Assert.Equal("data value", actual.Payload["data"].StringValue);
|
||||
|
||||
if (hasNamedVectors)
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vectors_.Vectors["vector"].Data.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vector.Data.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void MapsSinglePropsFromDataToStorageModelWithGuid(bool hasNamedVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateSinglePropsVectorStoreRecordDefinition(typeof(Guid));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors)
|
||||
.Build(typeof(SinglePropsModel<Guid>), typeof(Guid), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<SinglePropsModel<Guid>>(model, hasNamedVectors);
|
||||
|
||||
// Act.
|
||||
var actual = sut.MapFromDataToStorageModel(CreateSinglePropsModel<Guid>(Guid.Parse("11111111-1111-1111-1111-111111111111")), recordIndex: 0, generatedEmbeddings: null);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse(actual.Id.Uuid));
|
||||
Assert.Single(actual.Payload);
|
||||
Assert.Equal("data value", actual.Payload["data"].StringValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public void MapsSinglePropsFromStorageToDataModelWithUlong(bool hasNamedVectors, bool includeVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateSinglePropsVectorStoreRecordDefinition(typeof(ulong));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors)
|
||||
.Build(typeof(SinglePropsModel<ulong>), typeof(ulong), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<SinglePropsModel<ulong>>(model, hasNamedVectors);
|
||||
|
||||
// Act.
|
||||
var point = CreateSinglePropsPointStruct(5, hasNamedVectors);
|
||||
var actual = sut.MapFromStorageToDataModel(point.Id, point.Payload, point.Vectors, includeVectors);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(5ul, actual.Key);
|
||||
Assert.Equal("data value", actual.Data);
|
||||
|
||||
if (includeVectors)
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector!.Value.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(actual.Vector);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public void MapsSinglePropsFromStorageToDataModelWithGuid(bool hasNamedVectors, bool includeVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateSinglePropsVectorStoreRecordDefinition(typeof(Guid));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors)
|
||||
.Build(typeof(SinglePropsModel<Guid>), typeof(Guid), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<SinglePropsModel<Guid>>(model, hasNamedVectors);
|
||||
|
||||
// Act.
|
||||
var point = CreateSinglePropsPointStruct(Guid.Parse("11111111-1111-1111-1111-111111111111"), hasNamedVectors);
|
||||
var actual = sut.MapFromStorageToDataModel(point.Id, point.Payload, point.Vectors, includeVectors);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), actual.Key);
|
||||
Assert.Equal("data value", actual.Data);
|
||||
|
||||
if (includeVectors)
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector!.Value.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(actual.Vector);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsMultiPropsFromDataToStorageModelWithUlong()
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateMultiPropsVectorStoreRecordDefinition(typeof(ulong));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors: true)
|
||||
.Build(typeof(MultiPropsModel<ulong>), typeof(ulong), definition, defaultEmbeddingGenerator: null);
|
||||
|
||||
var sut = new QdrantMapper<MultiPropsModel<ulong>>(model, hasNamedVectors: true);
|
||||
|
||||
// Act.
|
||||
var actual = sut.MapFromDataToStorageModel(CreateMultiPropsModel<ulong>(5ul), recordIndex: 0, generatedEmbeddings: null);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(5ul, actual.Id.Num);
|
||||
Assert.Equal(8, actual.Payload.Count);
|
||||
Assert.Equal("data 1", actual.Payload["dataString"].StringValue);
|
||||
Assert.Equal(5, actual.Payload["dataInt"].IntegerValue);
|
||||
Assert.Equal(5, actual.Payload["dataLong"].IntegerValue);
|
||||
Assert.Equal(5.5f, actual.Payload["dataFloat"].DoubleValue);
|
||||
Assert.Equal(5.5d, actual.Payload["dataDouble"].DoubleValue);
|
||||
Assert.True(actual.Payload["dataBool"].BoolValue);
|
||||
Assert.Equal("2025-02-10T05:10:15.0000000+01:00", actual.Payload["dataDateTimeOffset"].StringValue);
|
||||
Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.Payload["dataArrayInt"].ListValue.Values.Select(x => (int)x.IntegerValue).ToArray());
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vectors_.Vectors["vector1"].Data.ToArray());
|
||||
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vectors.Vectors_.Vectors["vector2"].Data.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsMultiPropsFromDataToStorageModelWithGuid()
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateMultiPropsVectorStoreRecordDefinition(typeof(Guid));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors: true)
|
||||
.Build(typeof(MultiPropsModel<Guid>), typeof(Guid), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<MultiPropsModel<Guid>>(model, hasNamedVectors: true);
|
||||
|
||||
// Act.
|
||||
var actual = sut.MapFromDataToStorageModel(CreateMultiPropsModel<Guid>(Guid.Parse("11111111-1111-1111-1111-111111111111")), recordIndex: 0, generatedEmbeddings: null);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse(actual.Id.Uuid));
|
||||
Assert.Equal(8, actual.Payload.Count);
|
||||
Assert.Equal("data 1", actual.Payload["dataString"].StringValue);
|
||||
Assert.Equal(5, actual.Payload["dataInt"].IntegerValue);
|
||||
Assert.Equal(5, actual.Payload["dataLong"].IntegerValue);
|
||||
Assert.Equal(5.5f, actual.Payload["dataFloat"].DoubleValue);
|
||||
Assert.Equal(5.5d, actual.Payload["dataDouble"].DoubleValue);
|
||||
Assert.True(actual.Payload["dataBool"].BoolValue);
|
||||
Assert.Equal("2025-02-10T05:10:15.0000000+01:00", actual.Payload["dataDateTimeOffset"].StringValue);
|
||||
Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.Payload["dataArrayInt"].ListValue.Values.Select(x => (int)x.IntegerValue).ToArray());
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vectors_.Vectors["vector1"].Data.ToArray());
|
||||
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vectors.Vectors_.Vectors["vector2"].Data.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void MapsMultiPropsFromStorageToDataModelWithUlong(bool includeVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateMultiPropsVectorStoreRecordDefinition(typeof(ulong));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors: true)
|
||||
.Build(typeof(MultiPropsModel<ulong>), typeof(ulong), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<MultiPropsModel<ulong>>(model, hasNamedVectors: true);
|
||||
|
||||
// Act.
|
||||
var point = CreateMultiPropsPointStruct(5);
|
||||
var actual = sut.MapFromStorageToDataModel(point.Id, point.Payload, point.Vectors, includeVectors);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(5ul, actual.Key);
|
||||
Assert.Equal("data 1", actual.DataString);
|
||||
Assert.Equal(5, actual.DataInt);
|
||||
Assert.Equal(5L, actual.DataLong);
|
||||
Assert.Equal(5.5f, actual.DataFloat);
|
||||
Assert.Equal(5.5d, actual.DataDouble);
|
||||
Assert.True(actual.DataBool);
|
||||
Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), actual.DataDateTimeOffset);
|
||||
Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.DataArrayInt);
|
||||
|
||||
if (includeVectors)
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector1!.Value.ToArray());
|
||||
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vector2!.Value.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(actual.Vector1);
|
||||
Assert.Null(actual.Vector2);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void MapsMultiPropsFromStorageToDataModelWithGuid(bool includeVectors)
|
||||
{
|
||||
// Arrange.
|
||||
var definition = CreateMultiPropsVectorStoreRecordDefinition(typeof(Guid));
|
||||
var model = new QdrantModelBuilder(hasNamedVectors: true)
|
||||
.Build(typeof(MultiPropsModel<Guid>), typeof(Guid), definition, defaultEmbeddingGenerator: null);
|
||||
var sut = new QdrantMapper<MultiPropsModel<Guid>>(model, hasNamedVectors: true);
|
||||
|
||||
// Act.
|
||||
var point = CreateMultiPropsPointStruct(Guid.Parse("11111111-1111-1111-1111-111111111111"));
|
||||
var actual = sut.MapFromStorageToDataModel(point.Id, point.Payload, point.Vectors, includeVectors);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), actual.Key);
|
||||
Assert.Equal("data 1", actual.DataString);
|
||||
Assert.Equal(5, actual.DataInt);
|
||||
Assert.Equal(5L, actual.DataLong);
|
||||
Assert.Equal(5.5f, actual.DataFloat);
|
||||
Assert.Equal(5.5d, actual.DataDouble);
|
||||
Assert.True(actual.DataBool);
|
||||
Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), actual.DataDateTimeOffset);
|
||||
Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.DataArrayInt);
|
||||
|
||||
if (includeVectors)
|
||||
{
|
||||
Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vector1!.Value.ToArray());
|
||||
Assert.Equal(new float[] { 5, 6, 7, 8 }, actual.Vector2!.Value.ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(actual.Vector1);
|
||||
Assert.Null(actual.Vector2);
|
||||
}
|
||||
}
|
||||
|
||||
private static SinglePropsModel<TKey> CreateSinglePropsModel<TKey>(TKey key)
|
||||
{
|
||||
return new SinglePropsModel<TKey>
|
||||
{
|
||||
Key = key,
|
||||
Data = "data value",
|
||||
Vector = new float[] { 1, 2, 3, 4 },
|
||||
NotAnnotated = "notAnnotated",
|
||||
};
|
||||
}
|
||||
|
||||
private static MultiPropsModel<TKey> CreateMultiPropsModel<TKey>(TKey key)
|
||||
{
|
||||
return new MultiPropsModel<TKey>
|
||||
{
|
||||
Key = key,
|
||||
DataString = "data 1",
|
||||
DataInt = 5,
|
||||
DataLong = 5L,
|
||||
DataFloat = 5.5f,
|
||||
DataDouble = 5.5d,
|
||||
DataBool = true,
|
||||
DataDateTimeOffset = new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)),
|
||||
DataArrayInt = [1, 2, 3, 4],
|
||||
Vector1 = new float[] { 1, 2, 3, 4 },
|
||||
Vector2 = new float[] { 5, 6, 7, 8 },
|
||||
NotAnnotated = "notAnnotated",
|
||||
};
|
||||
}
|
||||
|
||||
private static RetrievedPoint CreateSinglePropsPointStruct(ulong id, bool hasNamedVectors)
|
||||
{
|
||||
var pointStruct = new RetrievedPoint();
|
||||
pointStruct.Id = new PointId() { Num = id };
|
||||
AddDataToSinglePropsPointStruct(pointStruct, hasNamedVectors);
|
||||
return pointStruct;
|
||||
}
|
||||
|
||||
private static RetrievedPoint CreateSinglePropsPointStruct(Guid id, bool hasNamedVectors)
|
||||
{
|
||||
var pointStruct = new RetrievedPoint();
|
||||
pointStruct.Id = new PointId() { Uuid = id.ToString() };
|
||||
AddDataToSinglePropsPointStruct(pointStruct, hasNamedVectors);
|
||||
return pointStruct;
|
||||
}
|
||||
|
||||
private static void AddDataToSinglePropsPointStruct(RetrievedPoint pointStruct, bool hasNamedVectors)
|
||||
{
|
||||
var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }");
|
||||
|
||||
pointStruct.Payload.Add("data", "data value");
|
||||
|
||||
if (hasNamedVectors)
|
||||
{
|
||||
var namedVectors = new NamedVectorsOutput();
|
||||
namedVectors.Vectors.Add("vector", responseVector);
|
||||
pointStruct.Vectors = new VectorsOutput() { Vectors = namedVectors };
|
||||
}
|
||||
else
|
||||
{
|
||||
pointStruct.Vectors = new VectorsOutput() { Vector = responseVector };
|
||||
}
|
||||
}
|
||||
|
||||
private static RetrievedPoint CreateMultiPropsPointStruct(ulong id)
|
||||
{
|
||||
var pointStruct = new RetrievedPoint();
|
||||
pointStruct.Id = new PointId() { Num = id };
|
||||
AddDataToMultiPropsPointStruct(pointStruct);
|
||||
return pointStruct;
|
||||
}
|
||||
|
||||
private static RetrievedPoint CreateMultiPropsPointStruct(Guid id)
|
||||
{
|
||||
var pointStruct = new RetrievedPoint();
|
||||
pointStruct.Id = new PointId() { Uuid = id.ToString() };
|
||||
AddDataToMultiPropsPointStruct(pointStruct);
|
||||
return pointStruct;
|
||||
}
|
||||
|
||||
private static void AddDataToMultiPropsPointStruct(RetrievedPoint pointStruct)
|
||||
{
|
||||
pointStruct.Payload.Add("dataString", "data 1");
|
||||
pointStruct.Payload.Add("dataInt", 5);
|
||||
pointStruct.Payload.Add("dataLong", 5L);
|
||||
pointStruct.Payload.Add("dataFloat", 5.5f);
|
||||
pointStruct.Payload.Add("dataDouble", 5.5d);
|
||||
pointStruct.Payload.Add("dataBool", true);
|
||||
pointStruct.Payload.Add("dataDateTimeOffset", "2025-02-10T05:10:15.0000000+01:00");
|
||||
|
||||
var dataIntArray = new ListValue();
|
||||
dataIntArray.Values.Add(1);
|
||||
dataIntArray.Values.Add(2);
|
||||
dataIntArray.Values.Add(3);
|
||||
dataIntArray.Values.Add(4);
|
||||
pointStruct.Payload.Add("dataArrayInt", new Value { ListValue = dataIntArray });
|
||||
|
||||
var responseVector1 = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }");
|
||||
var responseVector2 = VectorOutput.Parser.ParseJson("{ \"data\": [5, 6, 7, 8] }");
|
||||
|
||||
var namedVectors = new NamedVectorsOutput();
|
||||
namedVectors.Vectors.Add("vector1", responseVector1);
|
||||
namedVectors.Vectors.Add("vector2", responseVector2);
|
||||
pointStruct.Vectors = new VectorsOutput() { Vectors = namedVectors };
|
||||
}
|
||||
|
||||
private static VectorStoreCollectionDefinition CreateSinglePropsVectorStoreRecordDefinition(Type keyType) => new()
|
||||
{
|
||||
Properties =
|
||||
[
|
||||
new VectorStoreKeyProperty("Key", keyType) { StorageName = "key" },
|
||||
new VectorStoreDataProperty("Data", typeof(string)) { StorageName = "data" },
|
||||
new VectorStoreVectorProperty("Vector", typeof(ReadOnlyMemory<float>), 10) { StorageName = "vector" },
|
||||
],
|
||||
};
|
||||
|
||||
private sealed class SinglePropsModel<TKey>
|
||||
{
|
||||
[VectorStoreKey(StorageName = "key")]
|
||||
public TKey? Key { get; set; } = default;
|
||||
|
||||
[VectorStoreData(StorageName = "data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
|
||||
[VectorStoreVector(10, StorageName = "vector")]
|
||||
public ReadOnlyMemory<float>? Vector { get; set; }
|
||||
|
||||
public string NotAnnotated { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static VectorStoreCollectionDefinition CreateMultiPropsVectorStoreRecordDefinition(Type keyType) => new()
|
||||
{
|
||||
Properties =
|
||||
[
|
||||
new VectorStoreKeyProperty("Key", keyType) { StorageName = "key" },
|
||||
new VectorStoreDataProperty("DataString", typeof(string)) { StorageName = "dataString" },
|
||||
new VectorStoreDataProperty("DataInt", typeof(int)) { StorageName = "dataInt" },
|
||||
new VectorStoreDataProperty("DataLong", typeof(long)) { StorageName = "dataLong" },
|
||||
new VectorStoreDataProperty("DataFloat", typeof(float)) { StorageName = "dataFloat" },
|
||||
new VectorStoreDataProperty("DataDouble", typeof(double)) { StorageName = "dataDouble" },
|
||||
new VectorStoreDataProperty("DataBool", typeof(bool)) { StorageName = "dataBool" },
|
||||
new VectorStoreDataProperty("DataDateTimeOffset", typeof(DateTimeOffset)) { StorageName = "dataDateTimeOffset" },
|
||||
new VectorStoreDataProperty("DataArrayInt", typeof(List<int>)) { StorageName = "dataArrayInt" },
|
||||
new VectorStoreVectorProperty("Vector1", typeof(ReadOnlyMemory<float>), 10) { StorageName = "vector1" },
|
||||
new VectorStoreVectorProperty("Vector2", typeof(ReadOnlyMemory<float>), 10) { StorageName = "vector2" },
|
||||
],
|
||||
};
|
||||
|
||||
private sealed class MultiPropsModel<TKey>
|
||||
{
|
||||
[VectorStoreKey(StorageName = "key")]
|
||||
public TKey? Key { get; set; } = default;
|
||||
|
||||
[VectorStoreData(StorageName = "dataString")]
|
||||
public string DataString { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("data_int_json")]
|
||||
[VectorStoreData(StorageName = "dataInt")]
|
||||
public int DataInt { get; set; } = 0;
|
||||
|
||||
[VectorStoreData(StorageName = "dataLong")]
|
||||
public long DataLong { get; set; } = 0;
|
||||
|
||||
[VectorStoreData(StorageName = "dataFloat")]
|
||||
public float DataFloat { get; set; } = 0;
|
||||
|
||||
[VectorStoreData(StorageName = "dataDouble")]
|
||||
public double DataDouble { get; set; } = 0;
|
||||
|
||||
[VectorStoreData(StorageName = "dataBool")]
|
||||
public bool DataBool { get; set; } = false;
|
||||
|
||||
[VectorStoreData(StorageName = "dataDateTimeOffset")]
|
||||
public DateTimeOffset DataDateTimeOffset { get; set; }
|
||||
|
||||
[VectorStoreData(StorageName = "dataArrayInt")]
|
||||
public List<int>? DataArrayInt { get; set; }
|
||||
|
||||
[VectorStoreVector(10, StorageName = "vector1")]
|
||||
public ReadOnlyMemory<float>? Vector1 { get; set; }
|
||||
|
||||
[VectorStoreVector(10, StorageName = "vector2")]
|
||||
public ReadOnlyMemory<float>? Vector2 { get; set; }
|
||||
|
||||
public string NotAnnotated { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.SemanticKernel.Connectors.Qdrant.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="QdrantVectorStore"/> class.
|
||||
/// </summary>
|
||||
public class QdrantVectorStoreTests
|
||||
{
|
||||
private const string TestCollectionName = "testcollection";
|
||||
|
||||
private readonly Mock<MockableQdrantClient> _qdrantClientMock;
|
||||
|
||||
private readonly CancellationToken _testCancellationToken = new(false);
|
||||
|
||||
public QdrantVectorStoreTests()
|
||||
{
|
||||
this._qdrantClientMock = new Mock<MockableQdrantClient>(MockBehavior.Strict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCollectionReturnsCollection()
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = new QdrantVectorStore(this._qdrantClientMock.Object);
|
||||
|
||||
// Act.
|
||||
var actual = sut.GetCollection<ulong, SinglePropsModel<ulong>>(TestCollectionName);
|
||||
|
||||
// Assert.
|
||||
Assert.NotNull(actual);
|
||||
Assert.IsType<QdrantCollection<ulong, SinglePropsModel<ulong>>>(actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCollectionThrowsForInvalidKeyType()
|
||||
{
|
||||
// Arrange.
|
||||
using var sut = new QdrantVectorStore(this._qdrantClientMock.Object);
|
||||
|
||||
// Act & Assert.
|
||||
Assert.Throws<NotSupportedException>(() => sut.GetCollection<string, SinglePropsModel<string>>(TestCollectionName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCollectionNamesCallsSDKAsync()
|
||||
{
|
||||
// Arrange.
|
||||
this._qdrantClientMock
|
||||
.Setup(x => x.ListCollectionsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { "collection1", "collection2" });
|
||||
using var sut = new QdrantVectorStore(this._qdrantClientMock.Object);
|
||||
|
||||
// Act.
|
||||
var collectionNames = sut.ListCollectionNamesAsync(this._testCancellationToken);
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user