531 lines
20 KiB
C#
531 lines
20 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.VectorData;
|
|
using Microsoft.SemanticKernel.Connectors.Weaviate;
|
|
using Xunit;
|
|
|
|
namespace SemanticKernel.Connectors.Weaviate.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="WeaviateCollection{TKey, TRecord}"/> class.
|
|
/// </summary>
|
|
public sealed class WeaviateCollectionTests : IDisposable
|
|
{
|
|
private readonly HttpMessageHandlerStub _messageHandlerStub = new();
|
|
private readonly HttpClient _mockHttpClient;
|
|
|
|
public WeaviateCollectionTests()
|
|
{
|
|
this._mockHttpClient = new(this._messageHandlerStub, false) { BaseAddress = new Uri("http://default-endpoint") };
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorForModelWithoutKeyThrowsException()
|
|
{
|
|
// Act & Assert
|
|
var exception = Assert.Throws<NotSupportedException>(() => new WeaviateCollection<Guid, object>(this._mockHttpClient, "Collection"));
|
|
Assert.Contains("No key property found", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorWithoutEndpointThrowsException()
|
|
{
|
|
// Arrange
|
|
using var httpClient = new HttpClient();
|
|
|
|
// Act & Assert
|
|
var exception = Assert.Throws<ArgumentException>(() => new WeaviateCollection<Guid, WeaviateHotel>(httpClient, "Collection"));
|
|
Assert.Contains("Weaviate endpoint should be provided", exception.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorWithDeclarativeModelInitializesCollection()
|
|
{
|
|
// Act & Assert
|
|
using var collection = new WeaviateCollection<Guid, WeaviateHotel>(
|
|
this._mockHttpClient,
|
|
"Collection");
|
|
|
|
Assert.NotNull(collection);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorWithImperativeModelInitializesCollection()
|
|
{
|
|
// Arrange
|
|
var definition = new VectorStoreCollectionDefinition
|
|
{
|
|
Properties = [new VectorStoreKeyProperty("Id", typeof(Guid))]
|
|
};
|
|
|
|
// Act
|
|
using var collection = new WeaviateCollection<Guid, TestModel>(
|
|
this._mockHttpClient,
|
|
"Collection",
|
|
new() { Definition = definition });
|
|
|
|
// Assert
|
|
Assert.NotNull(collection);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CollectionExistsData))]
|
|
public async Task CollectionExistsReturnsValidResultAsync(HttpResponseMessage responseMessage, bool expectedResult)
|
|
{
|
|
// Arrange
|
|
this._messageHandlerStub.ResponseToReturn = responseMessage;
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act
|
|
var actualResult = await sut.CollectionExistsAsync();
|
|
|
|
// Assert
|
|
Assert.Equal(expectedResult, actualResult);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("notStartingWithCapitalLetter")]
|
|
[InlineData("0startingWithDigit")]
|
|
[InlineData("contains spaces")]
|
|
[InlineData("contains-dashes")]
|
|
[InlineData("contains_underscores")]
|
|
[InlineData("contains$specialCharacters")]
|
|
[InlineData("contains!specialCharacters")]
|
|
[InlineData("contains@specialCharacters")]
|
|
[InlineData("contains#specialCharacters")]
|
|
[InlineData("contains%specialCharacters")]
|
|
[InlineData("contains^specialCharacters")]
|
|
[InlineData("contains&specialCharacters")]
|
|
[InlineData("contains*specialCharacters")]
|
|
[InlineData("contains(specialCharacters")]
|
|
[InlineData("contains)specialCharacters")]
|
|
[InlineData("containsNonAsciiĄ")]
|
|
[InlineData("containsNonAsciią")]
|
|
public void CollectionCtorRejectsInvalidNames(string collectionName)
|
|
{
|
|
ArgumentException argumentException = Assert.Throws<ArgumentException>(() => new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, collectionName));
|
|
Assert.Equal("collectionName", argumentException.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnsureCollectionExistsUsesValidCollectionSchemaAsync()
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "Collection";
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName);
|
|
|
|
using var collectionExistsResponse = new HttpResponseMessage(HttpStatusCode.NotFound);
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(collectionExistsResponse);
|
|
|
|
using var createCollectionResponse = new HttpResponseMessage(HttpStatusCode.OK);
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(createCollectionResponse);
|
|
|
|
// Act
|
|
await sut.EnsureCollectionExistsAsync();
|
|
|
|
// Assert
|
|
var schemaRequest = JsonSerializer.Deserialize<WeaviateCreateCollectionSchemaRequest>(this._messageHandlerStub.RequestContent);
|
|
|
|
Assert.NotNull(schemaRequest);
|
|
|
|
Assert.Equal(CollectionName, schemaRequest.CollectionName);
|
|
|
|
Assert.NotNull(schemaRequest.VectorConfigurations);
|
|
Assert.Equal("descriptionEmbedding", schemaRequest.VectorConfigurations.Keys.First());
|
|
|
|
var vectorConfiguration = schemaRequest.VectorConfigurations["descriptionEmbedding"];
|
|
|
|
Assert.Equal("cosine", vectorConfiguration.VectorIndexConfig?.Distance);
|
|
Assert.Equal("hnsw", vectorConfiguration.VectorIndexType);
|
|
|
|
Assert.NotNull(schemaRequest.Properties);
|
|
|
|
this.AssertSchemaProperty(schemaRequest.Properties[0], "hotelName", "text", true, false);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[1], "hotelCode", "int", false, false);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[2], "hotelRating", "number", false, false);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[3], "parking_is_included", "boolean", false, false);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[4], "tags", "text[]", false, false);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[5], "description", "text", false, true);
|
|
this.AssertSchemaProperty(schemaRequest.Properties[6], "timestamp", "date", false, false);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteCollectionSendsValidRequestAsync()
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "Collection";
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName);
|
|
|
|
// Act
|
|
await sut.EnsureCollectionDeletedAsync();
|
|
|
|
// Assert
|
|
Assert.Equal("http://default-endpoint/schema/Collection", this._messageHandlerStub.RequestUri?.AbsoluteUri);
|
|
Assert.Equal(HttpMethod.Delete, this._messageHandlerStub.Method);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteSendsValidRequestAsync()
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "Collection";
|
|
var id = new Guid("55555555-5555-5555-5555-555555555555");
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName);
|
|
|
|
// Act
|
|
await sut.DeleteAsync(id);
|
|
|
|
// Assert
|
|
Assert.Equal("http://default-endpoint/objects/Collection/55555555-5555-5555-5555-555555555555", this._messageHandlerStub.RequestUri?.AbsoluteUri);
|
|
Assert.Equal(HttpMethod.Delete, this._messageHandlerStub.Method);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteBatchUsesValidQueryMatchAsync()
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "Collection";
|
|
List<Guid> ids = [new Guid("11111111-1111-1111-1111-111111111111"), new Guid("22222222-2222-2222-2222-222222222222")];
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName);
|
|
|
|
// Act
|
|
await sut.DeleteAsync(ids);
|
|
|
|
// Assert
|
|
var request = JsonSerializer.Deserialize<WeaviateDeleteObjectBatchRequest>(this._messageHandlerStub.RequestContent);
|
|
|
|
Assert.NotNull(request?.Match);
|
|
|
|
Assert.Equal(CollectionName, request.Match.CollectionName);
|
|
|
|
Assert.NotNull(request.Match.WhereClause);
|
|
|
|
var clause = request.Match.WhereClause;
|
|
|
|
Assert.Equal("ContainsAny", clause.Operator);
|
|
Assert.Equal(["id"], clause.Path);
|
|
Assert.Equal(["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"], clause.Values);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetExistingRecordReturnsValidRecordAsync()
|
|
{
|
|
// Arrange
|
|
var id = new Guid("55555555-5555-5555-5555-555555555555");
|
|
|
|
var jsonObject = new JsonObject { ["id"] = id.ToString(), ["properties"] = new JsonObject() };
|
|
|
|
jsonObject["properties"]!["hotelName"] = "Test Name";
|
|
|
|
this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(jsonObject))
|
|
};
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act
|
|
var result = await sut.GetAsync(id);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Equal(id, result.HotelId);
|
|
Assert.Equal("Test Name", result.HotelName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetExistingBatchRecordsReturnsValidRecordsAsync()
|
|
{
|
|
// Arrange
|
|
var id1 = new Guid("11111111-1111-1111-1111-111111111111");
|
|
var id2 = new Guid("22222222-2222-2222-2222-222222222222");
|
|
|
|
var jsonObject1 = new JsonObject { ["id"] = id1.ToString(), ["properties"] = new JsonObject() };
|
|
var jsonObject2 = new JsonObject { ["id"] = id2.ToString(), ["properties"] = new JsonObject() };
|
|
|
|
jsonObject1["properties"]!["hotelName"] = "Test Name 1";
|
|
jsonObject2["properties"]!["hotelName"] = "Test Name 2";
|
|
|
|
using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(jsonObject1)) };
|
|
using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(jsonObject2)) };
|
|
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(response1);
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(response2);
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act
|
|
var results = await sut.GetAsync([id1, id2]).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.NotNull(results[0]);
|
|
Assert.Equal(id1, results[0].HotelId);
|
|
Assert.Equal("Test Name 1", results[0].HotelName);
|
|
|
|
Assert.NotNull(results[1]);
|
|
Assert.Equal(id2, results[1].HotelId);
|
|
Assert.Equal("Test Name 2", results[1].HotelName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpsertReturnsRecordKeyAsync()
|
|
{
|
|
// Arrange
|
|
var id = new Guid("11111111-1111-1111-1111-111111111111");
|
|
var hotel = new WeaviateHotel { HotelId = id, HotelName = "Test Name" };
|
|
|
|
var batchResponse = new List<WeaviateUpsertCollectionObjectBatchResponse> { new() { Id = id, Result = new() { Status = "Success" } } };
|
|
|
|
this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(batchResponse)),
|
|
};
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act
|
|
await sut.UpsertAsync(hotel);
|
|
|
|
// Assert
|
|
var request = JsonSerializer.Deserialize<WeaviateUpsertCollectionObjectBatchRequest>(this._messageHandlerStub.RequestContent);
|
|
|
|
Assert.NotNull(request?.CollectionObjects);
|
|
|
|
var jsonObject = request.CollectionObjects[0];
|
|
|
|
Assert.Equal("11111111-1111-1111-1111-111111111111", jsonObject["id"]?.GetValue<string>());
|
|
Assert.Equal("Test Name", jsonObject["properties"]?["hotelName"]?.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpsertReturnsRecordKeysAsync()
|
|
{
|
|
// Arrange
|
|
var id1 = new Guid("11111111-1111-1111-1111-111111111111");
|
|
var id2 = new Guid("22222222-2222-2222-2222-222222222222");
|
|
|
|
var hotel1 = new WeaviateHotel { HotelId = id1, HotelName = "Test Name 1" };
|
|
var hotel2 = new WeaviateHotel { HotelId = id2, HotelName = "Test Name 2" };
|
|
|
|
var batchResponse = new List<WeaviateUpsertCollectionObjectBatchResponse>
|
|
{
|
|
new() { Id = id1, Result = new() { Status = "Success" } },
|
|
new() { Id = id2, Result = new() { Status = "Success" } }
|
|
};
|
|
|
|
this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(batchResponse)),
|
|
};
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act
|
|
await sut.UpsertAsync([hotel1, hotel2]);
|
|
|
|
// Assert
|
|
var request = JsonSerializer.Deserialize<WeaviateUpsertCollectionObjectBatchRequest>(this._messageHandlerStub.RequestContent);
|
|
|
|
Assert.NotNull(request?.CollectionObjects);
|
|
|
|
var jsonObject1 = request.CollectionObjects[0];
|
|
var jsonObject2 = request.CollectionObjects[1];
|
|
|
|
Assert.Equal("11111111-1111-1111-1111-111111111111", jsonObject1["id"]?.GetValue<string>());
|
|
Assert.Equal("Test Name 1", jsonObject1["properties"]?["hotelName"]?.GetValue<string>());
|
|
|
|
Assert.Equal("22222222-2222-2222-2222-222222222222", jsonObject2["id"]?.GetValue<string>());
|
|
Assert.Equal("Test Name 2", jsonObject2["properties"]?["hotelName"]?.GetValue<string>());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true, "http://test-endpoint/schema", "Bearer fake-key")]
|
|
[InlineData(false, "http://default-endpoint/schema", null)]
|
|
public async Task ItUsesHttpClientParametersAsync(bool initializeOptions, string expectedEndpoint, string? expectedHeader)
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "Collection";
|
|
|
|
var options = initializeOptions ?
|
|
new WeaviateCollectionOptions() { Endpoint = new Uri("http://test-endpoint"), ApiKey = "fake-key" } :
|
|
null;
|
|
|
|
using var collectionExistsResponse = new HttpResponseMessage(HttpStatusCode.NotFound);
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(collectionExistsResponse);
|
|
|
|
using var createCollectionResponse = new HttpResponseMessage(HttpStatusCode.OK);
|
|
this._messageHandlerStub.ResponseQueue.Enqueue(createCollectionResponse);
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName, options);
|
|
|
|
// Act
|
|
await sut.EnsureCollectionExistsAsync();
|
|
|
|
var headers = this._messageHandlerStub.RequestHeaders;
|
|
var endpoint = this._messageHandlerStub.RequestUri;
|
|
|
|
// Assert
|
|
Assert.Equal(expectedEndpoint, endpoint?.AbsoluteUri);
|
|
Assert.Equal(expectedHeader, headers?.Authorization?.ToString());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task SearchReturnsValidRecordAsync(bool includeVectors)
|
|
{
|
|
// Arrange
|
|
const string CollectionName = "SearchCollection";
|
|
var id = new Guid("55555555-5555-5555-5555-555555555555");
|
|
var vector = new ReadOnlyMemory<float>([30f, 31f, 32f, 33f]);
|
|
|
|
var jsonObject = new JsonObject
|
|
{
|
|
["data"] = new JsonObject
|
|
{
|
|
["Get"] = new JsonObject
|
|
{
|
|
[CollectionName] = new JsonArray
|
|
{
|
|
new JsonObject
|
|
{
|
|
["_additional"] = new JsonObject
|
|
{
|
|
["distance"] = 0.5,
|
|
["id"] = id.ToString(),
|
|
["vectors"] = new JsonObject
|
|
{
|
|
["descriptionEmbedding"] = new JsonArray(new List<float> {30, 31, 32, 33}.Select(l => (JsonNode)l).ToArray())
|
|
}
|
|
},
|
|
["description"] = "This is a great hotel.",
|
|
["hotelCode"] = 42,
|
|
["hotelName"] = "My Hotel",
|
|
["hotelRating"] = 4.5,
|
|
["parking_is_included"] = true,
|
|
["tags"] = new JsonArray(new List<string> { "t1", "t2" }.Select(l => (JsonNode)l).ToArray()),
|
|
["timestamp"] = "2024-08-28T10:11:12-07:00"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(jsonObject))
|
|
};
|
|
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, CollectionName);
|
|
|
|
// Act
|
|
var results = await sut.SearchAsync(vector, top: 3, new()
|
|
{
|
|
IncludeVectors = includeVectors
|
|
}).ToListAsync();
|
|
|
|
// Assert
|
|
Assert.Single(results);
|
|
|
|
var score = results[0].Score;
|
|
var record = results[0].Record;
|
|
|
|
Assert.Equal(0.5, score);
|
|
|
|
Assert.Equal(id, record.HotelId);
|
|
Assert.Equal("My Hotel", record.HotelName);
|
|
Assert.Equal("This is a great hotel.", record.Description);
|
|
Assert.Equal(42, record.HotelCode);
|
|
Assert.Equal(4.5f, record.HotelRating);
|
|
Assert.True(record.ParkingIncluded);
|
|
Assert.Equal(["t1", "t2"], record.Tags);
|
|
Assert.Equal(new DateTimeOffset(new DateTime(2024, 8, 28, 10, 11, 12), TimeSpan.FromHours(-7)), record.Timestamp);
|
|
|
|
if (includeVectors)
|
|
{
|
|
Assert.True(record.DescriptionEmbedding.HasValue);
|
|
Assert.Equal(vector.ToArray(), record.DescriptionEmbedding.Value.ToArray());
|
|
}
|
|
else
|
|
{
|
|
Assert.False(record.DescriptionEmbedding.HasValue);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchWithUnsupportedVectorTypeThrowsExceptionAsync()
|
|
{
|
|
// Arrange
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<NotSupportedException>(async () =>
|
|
await sut.SearchAsync(new List<double>([1, 2, 3]), top: 3).ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SearchWithNonExistentVectorPropertyNameThrowsExceptionAsync()
|
|
{
|
|
// Arrange
|
|
using var sut = new WeaviateCollection<Guid, WeaviateHotel>(this._mockHttpClient, "Collection");
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
|
await sut.SearchAsync(
|
|
new ReadOnlyMemory<float>([1f, 2f, 3f]),
|
|
top: 3,
|
|
new() { VectorProperty = r => "non-existent-property" })
|
|
.ToListAsync());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
this._mockHttpClient.Dispose();
|
|
this._messageHandlerStub.Dispose();
|
|
}
|
|
|
|
public static TheoryData<HttpResponseMessage, bool> CollectionExistsData => new()
|
|
{
|
|
{ new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(new WeaviateGetCollectionSchemaResponse { CollectionName = "Collection" })) }, true },
|
|
{ new HttpResponseMessage(HttpStatusCode.NotFound), false }
|
|
};
|
|
|
|
#region private
|
|
|
|
private void AssertSchemaProperty(
|
|
WeaviateCollectionSchemaProperty property,
|
|
string propertyName,
|
|
string dataType,
|
|
bool indexFilterable,
|
|
bool indexSearchable)
|
|
{
|
|
Assert.NotNull(property);
|
|
Assert.Equal(propertyName, property.Name);
|
|
Assert.Equal(dataType, property.DataType[0]);
|
|
Assert.Equal(indexFilterable, property.IndexFilterable);
|
|
Assert.Equal(indexSearchable, property.IndexSearchable);
|
|
}
|
|
|
|
#pragma warning disable CA1812
|
|
private sealed class TestModel
|
|
{
|
|
public Guid Id { get; set; }
|
|
|
|
public string? HotelName { get; set; }
|
|
}
|
|
#pragma warning restore CA1812
|
|
|
|
#endregion
|
|
}
|