diff --git a/ArangoDB.Extensions.VectorData.Tests/ArangoDB.Extensions.VectorData.Tests.csproj b/ArangoDB.Extensions.VectorData.Tests/ArangoDB.Extensions.VectorData.Tests.csproj new file mode 100644 index 00000000..a44e3b46 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/ArangoDB.Extensions.VectorData.Tests.csproj @@ -0,0 +1,128 @@ + + + + net9.0 + enable + enable + false + 733488cf-ebe1-45f3-8f3d-64056225edb6 + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + ArangoCollectionUnitTests.cs + + + + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + ArangoCollectionIntegrationTests.cs + + + + + + + ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs + + + ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs + + + ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs + + + ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs + + + ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs + + + + diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.CollectionExistsAsync.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.CollectionExistsAsync.cs new file mode 100644 index 00000000..cccc3e29 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.CollectionExistsAsync.cs @@ -0,0 +1,41 @@ +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +[TestFixture] +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task CollectionExistsAsync_ShouldReturnTrue_WhenCollectionExists() + { + // Arrange + string collectionName = Faker.Name.LastName(); + await ArangoDbClient + .Collection + .PostCollectionAsync(new () + { + Name = collectionName + }); + using VectorStoreCollection collection = VectorStore + .GetCollection(collectionName, null); + + // Act + bool exists = await collection.CollectionExistsAsync(); + + // Assert + exists.ShouldBeTrue(); + } + + [Test] + public async Task CollectionExistsAsync_ShouldReturnFalse_WhenCollectionDoesNotExist() + { + // Arrange + string randomCollectionName = Faker.Random.Word().ToLower(); + VectorStoreCollection collection = VectorStore + .GetCollection(randomCollectionName, null); + + // Act + bool exists = await collection.CollectionExistsAsync(); + + // Assert + exists.ShouldBeFalse(); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.DeleteAsync.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.DeleteAsync.cs new file mode 100644 index 00000000..98c9c569 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.DeleteAsync.cs @@ -0,0 +1,48 @@ +using ArangoDBNetStandard.DocumentApi.Models; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task DeleteAsync_ShouldDeleteDocument_WhenDocumentExists() + { + // Arrange + VectorStoreCollection collection = VectorStore + .GetCollection(CollectionName, null); + string key = Guid.NewGuid().ToString(); + TestRecord record = new() + { + Key = key, + Name = Faker.Person.FullName + }; + PostDocumentResponse postDocumentResponse = await ArangoDbClient + .Document + .PostDocumentAsync(collection.Name, record); + + // Act + await collection.DeleteAsync(postDocumentResponse._key); + + // Assert + using (Assert.EnterMultipleScope()) + { + try + { + TestRecord testRecord = await ArangoDbClient + .Document + .GetDocumentAsync( + postDocumentResponse._id, + Arg.Any(), + Arg.Any()); + } + catch (ApiErrorException ex) + { + ex.ShouldBeOfType(); + ex.ApiError.Code.ShouldBe(HttpStatusCode.NotFound); + } + } + } + +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionDeletedAsync.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionDeletedAsync.cs new file mode 100644 index 00000000..863d131f --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionDeletedAsync.cs @@ -0,0 +1,36 @@ +using ArangoDBNetStandard.CollectionApi.Models; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldDeleteCollection_WhenCollectionExists() + { + // Arrange + string collectionName = Faker.Random.String2(10); + await ArangoDbClient + .Collection + .PostCollectionAsync(new() + { + Name = collectionName, + Type = CollectionType.Document + }); + VectorStoreCollection collection = VectorStore + .GetCollection(collectionName, null); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionDeletedAsync()); + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldIgnoreException_WhenDocumentDoesNotExist() + { + // Arrange + VectorStoreCollection collection = VectorStore + .GetCollection(Faker.Random.String2(10), null); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionDeletedAsync()); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionExistsAsync.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionExistsAsync.cs new file mode 100644 index 00000000..6c5addfa --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.EnsureCollectionExistsAsync.cs @@ -0,0 +1,47 @@ +using ArangoDBNetStandard.CollectionApi.Models; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task EnsureCollectionExistsAsync_ShouldCreateCollection_WhenDoesNotExist() + { + // Arrange + string collectionName = Faker.Random.String2(10); + VectorStoreCollection collection = VectorStore.GetCollection( + collectionName, + null); + + // Act + + // Assert + using (Assert.EnterMultipleScope()) + { + await Should.NotThrowAsync(() + => collection.EnsureCollectionExistsAsync()); + GetCollectionResponse getCollectionResponse = await ArangoDbClient + .Collection + .GetCollectionAsync(collectionName); + getCollectionResponse.Name.ShouldBe(collectionName); + getCollectionResponse.Type.ShouldBe(CollectionType.Document); + getCollectionResponse.Code.ShouldBe(HttpStatusCode.OK); + } + } + + + [Test] + public async Task EnsureCollectionExistsAsync_ShouldIgnoreException_WhenAlreadyExists() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + + // Act & Assert + await Should.NotThrowAsync(() + => collection.EnsureCollectionExistsAsync()); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsync.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsync.cs new file mode 100644 index 00000000..204d1e9e --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsync.cs @@ -0,0 +1,56 @@ +using ArangoDBNetStandard.DocumentApi.Models; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAysnc_ReturnsDocument_WhenDocumentExists() + { + // Arrange + string documentName = Faker.Random.Word(); + TestRecord rec = new() + { + Name = documentName, + }; + PostDocumentResponse newDoc = await ArangoDbClient + .Document + .PostDocumentAsync(CollectionName, rec); + + using var collection = VectorStore.GetCollection(CollectionName, null); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + TestRecord? fetchedDoc = null; + await Should.NotThrowAsync(async () => fetchedDoc = await collection.GetAsync(newDoc._id)); + fetchedDoc.ShouldNotBeNull(); + fetchedDoc.Name.ShouldBe(documentName); + } + } + + [Test] + public async Task GetAysnc_ReturnsNullWithoutThrowingException_WhenDocumentDoesNotExist() + { + // Arrange + string documentName = Faker.Name.LastName(); + TestRecord rec = new() + { + Name = documentName, + }; + string id=$"{CollectionName}/{Faker.Name.LastName()}"; + + using var collection = VectorStore.GetCollection(CollectionName, null); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + TestRecord? fetchedDoc = null; + await Should.NotThrowAsync(async () => + { + fetchedDoc = await collection.GetAsync(id); + }); + fetchedDoc.ShouldBeNull(); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.CollectionContainsMethods.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.CollectionContainsMethods.cs new file mode 100644 index 00000000..c5dce386 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.CollectionContainsMethods.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithListContainsMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + List names = ["TestWithListContains", "TestWithListContains2"]; + Expression> filter = r => names.Contains(r.Name); + List expectedRecords = + [ + new () { Name = "TestWithListContains" }, + new () { Name = "TestWithListContains2" }, + new () { Name = "NothingTestWithListNotContains" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("TestWithListContains"); + results[1].Name.ShouldBe("TestWithListContains2"); + } + } + + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithIEnumerableContainsMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + IEnumerable names = ["TestWithIEnumerableContains", "TestWithIEnumerableContains2"]; + Expression> filter = r => names.Contains(r.Name); + List expectedRecords = + [ + new () { Name = "TestWithIEnumerableContains" }, + new () { Name = "TestWithIEnumerableContains2" }, + new () { Name = "NothingTestWithIEnumerableContains" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("TestWithIEnumerableContains"); + results[1].Name.ShouldBe("TestWithIEnumerableContains2"); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualityOperator.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualityOperator.cs new file mode 100644 index 00000000..fe4f3765 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualityOperator.cs @@ -0,0 +1,78 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAsyncWithFilterWithEqualityOperator_ShouldReturnExactlyOneRecord_WhenComapringWithEqualityOperator() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name == "TestEqualityOperator"; + List expectedRecords = + [ + new () { Name = "TestEqualityOperator" }, + new () { Name = "TestEqualityOperator2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(1); + results.ShouldAllBe(r => r.Name != "Nothing"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnNoRecord_WhenComparingWithEqualityOperator() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name == "test"; + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + results.Count.ShouldBe(0); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualsMethods.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualsMethods.cs new file mode 100644 index 00000000..9da39648 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.EqualsMethods.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithEqualsMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Equals("TestWithEqualsMethod"); + List expectedRecords = + [ + new () { Name = "TestWithEqualsMethod" }, + new () { Name = "TestWithEqualsMethod2" }, + new () { Name = "NothingTestWithEqualsMethod" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(1); + results[0].Name.ShouldBe("TestWithEqualsMethod"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithEqualsMethodWithStringComparison() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Equals("testWithEqualsmethodWithComparisonArg", StringComparison.OrdinalIgnoreCase); + List expectedRecords = + [ + new () { Name = "TestWithEqualsMethodWithComparisonArg" }, + new () { Name = "TestWithEqualsMethodWithComparisonArg2" }, + new () { Name = "NothingTestWithEqualsMethodWithComparisonArg" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(1); + results[0].Name.ShouldBe("TestWithEqualsMethodWithComparisonArg"); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.LikeMethods.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.LikeMethods.cs new file mode 100644 index 00000000..25697014 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.LikeMethods.cs @@ -0,0 +1,162 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithLikeMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => AqlFilters.Like(r.Name, "Test"); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComapringWithExtensionMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Like("Test"); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComapringWithLikeMethodWithStringComparison() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => AqlFilters.Like(r.Name, "test", StringComparison.OrdinalIgnoreCase); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithLikeMethodWithStringComparison() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Like("test", StringComparison.OrdinalIgnoreCase); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.StringContainsMethods.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.StringContainsMethods.cs new file mode 100644 index 00000000..ab5c306e --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.StringContainsMethods.cs @@ -0,0 +1,289 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithContainsMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Contains("Test"); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnNoRecord_WhenComparingWithContainsMethod() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Contains("test"); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + results.Count.ShouldBe(0); + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithContainsMethodWithStringComparison() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Contains("test", StringComparison.OrdinalIgnoreCase); + List expectedRecords = + [ + new () { Name = "Test" }, + new () { Name = "Test2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("Test"); + results[1].Name.ShouldBe("Test2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnOneRecord_WhenComapringWithContainsMethodAndSkipProvided() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + FilteredRecordRetrievalOptions options = new() + { + Skip = 1 + }; + Expression> filter = r => r.Name.Contains("TestContainsWithSkip"); + List expectedRecords = + [ + new () { Name = "TestContainsWithSkip" }, + new () { Name = "TestContainsWithSkip2" }, + new () { Name = "TestUnknown" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2, options)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(1); + results[0].Name.ShouldBe("TestContainsWithSkip2"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithContainsMethodAndSortDefinitionProvided() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Contains("Test"); + + FilteredRecordRetrievalOptions options = new() + { + OrderBy = def => def.Descending(r => r.Name) + }; + List expectedRecords = + [ + new () { Name = "TestWithContainsMethodAndSortDefinitionProvided" }, + new () { Name = "TestWithContainsMethodAndSortDefinitionProvided2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2, options)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("TestWithContainsMethodAndSortDefinitionProvided2"); + results[1].Name.ShouldBe("TestWithContainsMethodAndSortDefinitionProvided"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnOneRecord_WhenFilterContainsSortDefinitionAndSkipProvided() + { + // Arrange + + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + Expression> filter = r => r.Name.Contains("TestContainswithSortandSkip"); + + FilteredRecordRetrievalOptions options = new() + { + Skip = 1, + OrderBy = def => def.Descending(r => r.Name) + }; + List expectedRecords = + [ + new () { Name = "TestContainswithSortandSkip" }, + new () { Name = "TestContainswithSortandSkip2" }, + new () { Name = "Nothing" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2, options)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(1); + results[0].Name.ShouldBe("TestContainswithSortandSkip"); + } + } + + [Test] + public async Task GetAsyncWithFilter_ShouldReturnTwoRecords_WhenComparingWithContainsMethodWithParam() + { + // Arrange + VectorStoreCollection collection = VectorStore.GetCollection( + CollectionName, + null); + string filterText = "TestWithContainsMethodWithParam"; + Expression> filter = r => r.Name.Contains(filterText); + List expectedRecords = + [ + new () { Name = "TestWithContainsMethodWithParam" }, + new () { Name = "TestWithContainsMethodWithParam2" }, + new () { Name = "NothingWithNotContainsMethodWithParam" } + ]; + await ArangoDbClient.Document + .PostDocumentsAsync( + CollectionName, + expectedRecords, + null, + null, + null, + default); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, 2)) + { + results.Add(record); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.Count.ShouldBe(2); + results[0].Name.ShouldBe("TestWithContainsMethodWithParam"); + results[1].Name.ShouldBe("TestWithContainsMethodWithParam2"); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs new file mode 100644 index 00000000..2ac410b7 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.GetAsyncWithFilter.cs @@ -0,0 +1,5 @@ +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +public partial class ArangoCollectionIntegrationTests +{ +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.cs new file mode 100644 index 00000000..e3fe970d --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoCollectionIntegrationTests.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +[ExcludeFromCodeCoverage] +public partial class ArangoCollectionIntegrationTests : ArangoDbIntegrationTestBase +{ + public class TestRecord + { + [JsonPropertyName("_key")] + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public float[]? Embedding { get; set; } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoDbIntegrationTestBase.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoDbIntegrationTestBase.cs new file mode 100644 index 00000000..72b3382d --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoDbIntegrationTestBase.cs @@ -0,0 +1,118 @@ +using ArangoDBNetStandard.CollectionApi.Models; +using ArangoDBNetStandard.Transport.Http; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using OpenAI.Embeddings; + +using Testcontainers.ArangoDb; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +[ExcludeFromCodeCoverage] +public abstract class ArangoDbIntegrationTestBase +{ + protected readonly IServiceCollection _services; + protected IEmbeddingGenerator> _embeddingGenerator; + protected HttpApiTransport _transport; + protected AsyncServiceScope _scope; + private readonly ArangoDbContainer _arangoDbContainer; + + public ArangoDbIntegrationTestBase() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder + .Configuration + .AddUserSecrets(true, true) + .AddEnvironmentVariables(); + _services = builder.Services; + Faker = new Faker(); + _arangoDbContainer = new ArangoDbBuilder() + .WithImage("arangodb:latest") + .WithPortBinding(8529, true) + .WithEnvironment("ARANGO_NO_AUTH", "1") + //.WithEnvironment("ARANGO_ROOT_PASSWORD", _rootPassword) + .Build(); + } + + public string Hostname => _arangoDbContainer.Hostname; + public int Port => _arangoDbContainer.GetMappedPublicPort(); + public IArangoDBClient ArangoDbClient { get; private set; } + public string UserName { get; private set; } + public string DatabaseName { get; private set; } + public string CollectionName { get; private set; } + public Faker Faker { get; private set; } + public ServiceProvider ServiceProvider { get; private set; } + public IServiceProvider ScopedServiceProvider { get; private set; } + public VectorStore VectorStore { get; private set; } + + [OneTimeSetUp] + public virtual async Task InitializeAsync() + { + await _arangoDbContainer.StartAsync(); + DatabaseName = Faker.Random.String2(15); + CollectionName = Faker.Random.String2(15).ToLower(); + ArangoDbClient = CreateArangoDbClient(); + await ArangoDbClient.Database.PostDatabaseAsync(new() + { + Name = DatabaseName + }); + await ArangoDbClient.Collection.PostCollectionAsync(new() + { + Name = CollectionName, + Type = CollectionType.Document, + WaitForSync = true, + }); + _services.AddSingleton(ArangoDbClient); + _services.AddArangoVectorDatabase(); + _services.AddScoped(sp => + { + IConfiguration config = sp.GetRequiredService(); + string? apiKey = config["OpenAIKey"]; + if (string.IsNullOrEmpty(apiKey)) + { + throw new InvalidOperationException("OpenAIKey configuration is missing."); + } + + EmbeddingClient embeddingClient = new("text-embedding-3-small", apiKey); + + // Adapt the EmbeddingClient to the IEmbeddingGenerator interface + IEmbeddingGenerator> generator = embeddingClient.AsIEmbeddingGenerator(); + return generator; + }); + ServiceProvider = _services.BuildServiceProvider(); + _scope = ServiceProvider.CreateAsyncScope(); + ScopedServiceProvider = _scope.ServiceProvider; + VectorStore = ScopedServiceProvider.GetRequiredService(); + _embeddingGenerator = ScopedServiceProvider.GetRequiredService>>(); + } + + [OneTimeTearDown] + public virtual async Task DisposeAsync() + { + await ArangoDbClient.Database.DeleteDatabaseAsync(DatabaseName); + ArangoDbClient.Dispose(); + VectorStore.Dispose(); + _embeddingGenerator.Dispose(); + await _scope.DisposeAsync(); + await ServiceProvider.DisposeAsync(); + _services.Clear(); + await _arangoDbContainer.DisposeAsync(); + } + + private IArangoDBClient CreateArangoDbClient() + { + Uri baseUri = new($"http://{Hostname}:{Port}/"); + _transport = HttpApiTransport.UsingBasicAuth( + baseUri, + "_system", + string.Empty); + IArangoDBClient client = new ArangoDBClient(_transport, true); + return client; + } +} + diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoHybridSearchableIntegrationTests.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoHybridSearchableIntegrationTests.cs new file mode 100644 index 00000000..9eb44d87 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoHybridSearchableIntegrationTests.cs @@ -0,0 +1,232 @@ +using Microsoft.Extensions.AI; + +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +[ExcludeFromCodeCoverage] +public class ArangoHybridSearchableIntegrationTests : ArangoDbIntegrationTestBase +{ + [Test] + public async Task SearchAsync_WithoutSkippingData_CoversEmbeddingGenerationLogic() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoHybridSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + HybridSearchOptions options = new() + { + IncludeVectors = true, + VectorProperty = e => e.Embedding, + AdditionalProperty = e => e.Description + }; + + // Act - This covers lines 68-82: embedding generation and vector processing + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.HybridSearchAsync( + "artificial intelligence", + ["AI"], + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } + + [Test] + public async Task SearchAsync_WithSkippingData_CoversEmbeddingGenerationLogic() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoHybridSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + HybridSearchOptions options = new() + { + IncludeVectors = true, + VectorProperty = e => e.Embedding, + AdditionalProperty = e => e.Description, + Skip = 1 + }; + + // Act + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.HybridSearchAsync( + "artificial intelligence", + ["AI"], + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } + + [Test] + public async Task SearchAsync_WithProjection_ReturnsProjectedColswithoutTheVectorProperty() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoHybridSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + HybridSearchOptions options = new() + { + IncludeVectors = false, + VectorProperty = e => e.Embedding, + AdditionalProperty = e => e.Description, + Skip = 1 + }; + + // Act - This covers lines 68-82: embedding generation and vector processing + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.HybridSearchAsync( + "artificial intelligence", + ["AI"], + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } + public class TestRecord + { + [JsonPropertyName("_key")] + public string Key { get; set; } = string.Empty; + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + [JsonPropertyName("embedding")] + public float[] Embedding { get; set; } = []; + } + +} \ No newline at end of file diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorSearchableIntegrationTests.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorSearchableIntegrationTests.cs new file mode 100644 index 00000000..264103b4 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorSearchableIntegrationTests.cs @@ -0,0 +1,226 @@ +using Microsoft.Extensions.AI; + +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +[ExcludeFromCodeCoverage] +public class ArangoVectorSearchableIntegrationTests : ArangoDbIntegrationTestBase +{ + public class TestRecord + { + [JsonPropertyName("_key")] + public string Key { get; set; } = string.Empty; + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + [JsonPropertyName("embedding")] + public float[] Embedding { get; set; } = []; + } + + [Test] + public async Task SearchAsync_WithoutSkippingData_CoversEmbeddingGenerationLogic() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoVectorSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + VectorSearchOptions options = new() + { + IncludeVectors = true, + VectorProperty = e => e.Embedding + }; + + // Act - This covers lines 68-82: embedding generation and vector processing + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.SearchAsync( + "artificial intelligence", + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } + + [Test] + public async Task SearchAsync_WithSkippingData_CoversEmbeddingGenerationLogic() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoVectorSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + VectorSearchOptions options = new() + { + IncludeVectors = true, + VectorProperty = e => e.Embedding, + Skip = 1 + }; + + // Act - This covers lines 68-82: embedding generation and vector processing + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.SearchAsync( + "artificial intelligence", + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } + + [Test] + public async Task SearchAsync_WithProjection_ReturnsProjectedColswithoutTheVectorProperty() + { + // Arrange + try + { + // Insert test documents with vector embeddings + TestRecord[] testDocs = [ + new() { Key = "doc1", Id = "doc1", Name = "Machine Learning", Description = "AI and ML concepts", }, + new() { Key = "doc2", Id = "doc2", Name = "Data Science", Description = "Statistics and analysis", }, + new() { Key = "doc3", Id = "doc3", Name = "Deep Learning", Description = "Neural networks", } + ]; + + List stringsToCreateEmbeddingFor = [ + ..testDocs.Select(d => $"{d.Name}: {d.Description}") + ]; + (string Value, Embedding Embedding)[] values = await _embeddingGenerator.GenerateAndZipAsync(stringsToCreateEmbeddingFor); + + for (int i = 0; i < testDocs.Length; i++) + { + TestRecord doc = testDocs[i]; + doc.Embedding = values[i].Embedding.Vector.Span.ToArray(); + await ArangoDbClient.Document.PostDocumentAsync(CollectionName, doc); + } + + VectorStoreCollectionDefinition definition = new(); + + ArangoVectorSearchable vectorSearchable = new( + CollectionName, + definition, + ServiceProvider); + + VectorSearchOptions options = new() + { + IncludeVectors = false, + VectorProperty = e => e.Embedding, + Skip = 1 + }; + + // Act - This covers lines 68-82: embedding generation and vector processing + List> results = []; + await foreach (VectorSearchResult result in vectorSearchable.SearchAsync( + "artificial intelligence", + 10, + options)) + { + results.Add(result); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + results.ShouldNotBeNull(); + results.ShouldNotBeEmpty(); + results.ForEach(result => + { + result.Record.ShouldNotBeNull(); + result.Score.ShouldNotBeNull(); + result.Score.Value.ShouldBeGreaterThanOrEqualTo(0.0); + }); + } + } + finally + { + + } + } +} \ No newline at end of file diff --git a/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorStoreIntegrationTests.cs b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorStoreIntegrationTests.cs new file mode 100644 index 00000000..0b54fd85 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/IntegrationTests/ArangoVectorStoreIntegrationTests.cs @@ -0,0 +1,327 @@ +using ArangoDBNetStandard.CollectionApi.Models; +using ArangoDBNetStandard.Transport.Http; + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.IntegrationTests; + +[ExcludeFromCodeCoverage] +public class ArangoVectorStoreIntegrationTests : ArangoDbIntegrationTestBase +{ + // Your test methods go here + [Test] + public async Task GetCollection_ShouldReturnCollection_WhenCollectionExists() + { + // Act + using VectorStoreCollection> collectionToAssert = VectorStore + .GetCollection>(CollectionName, null); + + // Assert + GetCollectionResponse getCollectionResponse = await ArangoDbClient.Collection + .GetCollectionAsync(CollectionName); + + using (Assert.EnterMultipleScope()) + { + collectionToAssert.ShouldNotBeNull(); + collectionToAssert.Name.ShouldBe(getCollectionResponse.Name); + collectionToAssert.ShouldBeOfType>>(); + getCollectionResponse.Code.ShouldBe(HttpStatusCode.OK); + getCollectionResponse.Error.ShouldBeFalse(); + } + } + + // Your test methods go here + [Test] + public async Task GetDyncamicCollection_ShouldReturnCollection_WhenCollectionExists() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + + // Act + using VectorStoreCollection> collectionToAssert = store + .GetDynamicCollection(CollectionName, new VectorStoreCollectionDefinition()); + + // Assert + GetCollectionResponse getCollectionResponse = await ArangoDbClient.Collection + .GetCollectionAsync(CollectionName); + + using (Assert.EnterMultipleScope()) + { + collectionToAssert.ShouldNotBeNull(); + collectionToAssert.Name.ShouldBe(getCollectionResponse.Name); + collectionToAssert.ShouldBeOfType(); + getCollectionResponse.Code.ShouldBe(HttpStatusCode.OK); + getCollectionResponse.Error.ShouldBeFalse(); + } + } + + // Your test methods go here + [Test] + public async Task CollectionExistAsync_ShouldReturnTrue_WhenCollectionExists() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + + // Act + bool exists = await store + .CollectionExistsAsync(CollectionName); + + // Assert + GetCollectionResponse getCollectionResponse = await ArangoDbClient.Collection + .GetCollectionAsync(CollectionName); + + using (Assert.EnterMultipleScope()) + { + exists.ShouldBeTrue(); + getCollectionResponse.Error.ShouldBeFalse(); + getCollectionResponse.Code.ShouldBe(HttpStatusCode.OK); + } + } + + // Your test methods go here + [Test] + public async Task CollectionExistAsync_ShouldReturnFalse_WhenCollectionDoesNotExist() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + string nonExistentCollectionName = Faker.Random.String2(5); + + // Act + bool exists = await store.CollectionExistsAsync(nonExistentCollectionName); + + // Assert + try + { + GetCollectionResponse getCollectionResponse = await ArangoDbClient.Collection + .GetCollectionAsync(nonExistentCollectionName); + } + catch (ApiErrorException ex) + { + using (Assert.EnterMultipleScope()) + { + exists.ShouldBeFalse(); + ex.ShouldBeOfType(); + ex.ApiError.Code.ShouldBe(HttpStatusCode.NotFound); + ex.ApiError.Error.ShouldBeTrue(); + } + } + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldDeleteCollection_WhenCollectionExists() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + string newCollection = Faker.Random.String2(5); + await ArangoDbClient.Collection.PostCollectionAsync(new() + { + Name = newCollection + }); + + // Act and Assert + await store.EnsureCollectionDeletedAsync(newCollection); + + // Assert + try + { + GetCollectionResponse getCollectionResponse = await ArangoDbClient.Collection + .GetCollectionAsync(newCollection); + } + catch (ApiErrorException ex) + { + using (Assert.EnterMultipleScope()) + { + ex.ShouldBeOfType(); + ex.ApiError.Code.ShouldBe(HttpStatusCode.NotFound); + ex.ApiError.Error.ShouldBeTrue(); + } + } + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldNotThrowException_WhenCollectionDoesNotExist() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + string nonExistentCollection = Faker.Random.String2(5); + + // Act and Assert + await Should.NotThrowAsync(() => + store.EnsureCollectionDeletedAsync(nonExistentCollection) + ); + } + + [Test] + public async Task ListCollectionNamesAsync_ShouldReturnListOfCollectionNames_WhenCollectionDoesNotExist() + { + // Arrange + VectorStore store = ScopedServiceProvider.GetRequiredService(); + string nonExistentCollection = Faker.Random.String2(5); + GetCollectionsResponse getCollectionsResponse = await ArangoDbClient.Collection + .GetCollectionsAsync(); + + // Act and Assert + List collectionNames = []; + await foreach (string colName in store.ListCollectionNamesAsync()) + { + collectionNames.Add(colName); + } + + // Assert + using (Assert.EnterMultipleScope()) + { + collectionNames.ShouldNotBeNull(); + collectionNames.Count.ShouldBe(getCollectionsResponse.Result.Count); + collectionNames.ShouldBe( + getCollectionsResponse.Result.Select(r => r.Name), + caseSensitivity: Case.Sensitive); + } + } + + [Test] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void GetService_ReturnsServiceWithoutThrowingException_WhenServiceKeyNotProvidedAndProperlyRegistered( + object? serviceKey) + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IServiceCollection services = builder.Services; + HttpApiTransport transport = null!; + services.AddScoped(_ => CreateArangoDbClient(out transport)); + services.AddArangoVectorDatabase(); + IServiceProvider rootServiceProvider = services.BuildServiceProvider(); + using IServiceScope scope = rootServiceProvider.CreateScope(); + IServiceProvider serviceProvider = scope.ServiceProvider; + VectorStore vectorStore = serviceProvider.GetRequiredService(); + object expectedService = serviceProvider.GetRequiredService(); + Type serviceType = typeof(VectorStore); + Type expectedServiceType = typeof(ArangoVectorStore); + + // Act and Assert + using (Assert.EnterMultipleScope()) + { + object? actualService = null; + Should.NotThrow(() => + actualService = vectorStore.GetService(serviceType, serviceKey)); + actualService.ShouldNotBeNull(); + actualService.ShouldBe(expectedService); + actualService.GetType().ShouldBe(expectedServiceType); + } + transport.Dispose(); + } + + [Test] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void GetService_ReturnsNullWithoutThrowingException_WhenServiceKeyNotProvidedAndNotRegistered( + object? serviceKey) + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IServiceCollection services = builder.Services; + HttpApiTransport transport = null!; + services.AddScoped(_ => CreateArangoDbClient(out transport)); + services.AddArangoVectorDatabase(); + IServiceProvider rootServiceProvider = services.BuildServiceProvider(); + using IServiceScope scope = rootServiceProvider.CreateScope(); + IServiceProvider serviceProvider = scope.ServiceProvider; + VectorStore vectorStore = serviceProvider.GetRequiredService(); + Type serviceType = typeof(ArangoVectorStore); + + // Act + + // Assert + using (Assert.EnterMultipleScope()) + { + object? actualService = null; + Should.NotThrow(() => + actualService = vectorStore.GetService( + serviceType, + serviceKey)); + actualService.ShouldBeNull(); + } + transport.Dispose(); + } + + [Test] + [TestCase("serviceKey1")] + public async Task GetService_ReturnsServiceWithoutThrowingException_WhenServiceKeyProvidedAndProperlyRegistered( + object serviceKey) + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IServiceCollection services = builder.Services; + HttpApiTransport transport = null!; + services.AddScoped(_ => CreateArangoDbClient(out transport)); + services.AddArangoVectorDatabase(); + services.AddKeyedScoped(serviceKey); + IServiceProvider rootServiceProvider = services.BuildServiceProvider(); + await using AsyncServiceScope scope = rootServiceProvider.CreateAsyncScope(); + IServiceProvider serviceProvider = scope.ServiceProvider; + using VectorStore expectedService = serviceProvider.GetRequiredKeyedService(serviceKey); + using VectorStore vectorStore = serviceProvider.GetRequiredKeyedService(serviceKey); + Type expectedServiceType = typeof(VectorStore); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + object? actualService = null; + Should.NotThrow(() => + { + actualService = vectorStore.GetService(expectedServiceType, serviceKey); + return actualService; + }); + actualService.ShouldBe(expectedService); + } + transport.Dispose(); + } + + [Test] + [TestCase("serviceKey1")] + public async Task GetService_ReturnsNullWithoutThrowingException_WhenServiceKeyProvidedAndNotRegistered( + object serviceKey) + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IServiceCollection services = builder.Services; + HttpApiTransport transport = null!; + services.AddScoped(_ => CreateArangoDbClient(out transport)); + services.AddArangoVectorDatabase(); + IServiceProvider rootServiceProvider = services.BuildServiceProvider(); + await using AsyncServiceScope scope = rootServiceProvider.CreateAsyncScope(); + IServiceProvider serviceProvider = scope.ServiceProvider; + using VectorStore vectorStore = serviceProvider.GetRequiredService(); + Type serviceType = typeof(ArangoVectorStore); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + object? actualService = null; + Should.NotThrow(() => + { + actualService = vectorStore.GetService(serviceType, serviceKey); + return actualService; + }); + actualService.ShouldBeNull(); + } + transport.Dispose(); + } + + private static IArangoDBClient CreateArangoDbClient(out HttpApiTransport transport) + { + Uri baseUri = new($"http://localhost:1234/"); + transport = HttpApiTransport.UsingBasicAuth( + baseUri, + "_system", + string.Empty); + IArangoDBClient client = new ArangoDBClient(transport, true); + return client; + } +} + diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.DeleteAsync.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.DeleteAsync.cs new file mode 100644 index 00000000..776b1af0 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.DeleteAsync.cs @@ -0,0 +1,260 @@ +using ArangoDBNetStandard.DocumentApi.Models; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + [TestCase("abc", "/abc")] + public async Task DeleteAsync_ShouldExecute_WhenKeyStartsWithSlash( + string collectionName, + string key) + { + // Arrange + DeleteDocumentResponse response = new() + { + _key = key, + _id = $"{collectionName}/{key}", + }; + _arangoClient + .Document + .DeleteDocumentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() + => collection.DeleteAsync(key)); + } + + [Test] + [TestCase("abc", " abc ")] + public async Task DeleteAsync_ShouldExecute_WhenKeyContainsStartingOrTrailingSpace( + string collectionName, + string key) + { + // Arrange + DeleteDocumentResponse response = new() + { + _key = key, + _id = $"{collectionName}/{key}", + }; + _arangoClient + .Document + .DeleteDocumentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() + => collection.DeleteAsync(key)); + } + + [Test] + [TestCase("abc", 1)] + public async Task DeleteAsync_ShouldExecute_WhenKeyIsNotString( + string collectionName, + int key) + { + // Arrange + DeleteDocumentResponse response = new() + { + _key = key.ToString(), + _id = $"{collectionName}/{key}", + }; + _arangoClient + .Document + .DeleteDocumentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() + => collection.DeleteAsync(key)); + } + + [Test] + [TestCase("abc", "abc")] + public async Task DeleteAsync_ShouldExecute_WhenKeyIsCombinationOfCollectionNameAndDocumentKey( + string collectionName, + string key) + { + // Arrange + DeleteDocumentResponse response = new() + { + _key = key, + _id = $"{collectionName}/{key}", + }; + _arangoClient + .Document + .DeleteDocumentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() + => collection.DeleteAsync($" {collectionName}/{key} ")); + } + + [Test] + [TestCase("abc", "abc/")] + public async Task DeleteAsync_ShouldThrowFormatException_WhenKeyEndsWithSlash( + string collectionName, + string key) + { + // Arrange + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + FormatException exception = await Should.ThrowAsync(() + => collection.DeleteAsync(key)); + exception.Message.ShouldBe("Key string cannot end with a slash."); + } + } + + [Test] + [TestCase("abc", "abc def")] + public async Task DeleteAsync_ShouldThrowFormatException_WhenKeyContainsSpace( + string collectionName, + string key) + { + // Arrange + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + collectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + FormatException exception = await Should.ThrowAsync(() + => collection.DeleteAsync(key)); + exception.Message.ShouldBe("Key cannot contain spaces."); + } + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public async Task DeleteAsync_ShouldThrowArgumentException_WhenKeyIsNullOrWhitespace( + string? key) + { + // Arrange + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + ArgumentException exception = await Should.ThrowAsync(() + => collection.DeleteAsync(key!)); + exception.ParamName.ShouldBe("key"); + exception.Message.ShouldContain("Key can't be null or empty."); + } + } + + [Test] + [TestCase("abc", "abc")] + public async Task DeleteAsync_ShouldThrowNotSupportedException_WhenKeyContainSlashButCollectionNameDoesNotMatch( + string collectionName, + string key) + { + // Arrange + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + NotSupportedException exception = await Should.ThrowAsync(() + => collection.DeleteAsync($"{collectionName}/{key}")); + exception.Message.ShouldBe("A document from another collection can't be accessed."); + } + } + + [Test] + [TestCase("abc", "abc")] + public async Task DeleteAsync_ShouldThrowFormatException_WhenKeyContainMultipleSlashes( + string collectionName, + string key) + { + // Arrange + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + FormatException exception = await Should.ThrowAsync(() + => collection.DeleteAsync($"{collectionName}/{key}/abc")); + exception.Message.ShouldBe("The 'Key' can either be the document key or the fully qualified id (CollectionName/DocumentKey)."); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionDeletedAsync.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionDeletedAsync.cs new file mode 100644 index 00000000..edbc7929 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionDeletedAsync.cs @@ -0,0 +1,66 @@ +using NSubstitute.ExceptionExtensions; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldRethrowException_WhenOtherGenericExceptionOccurs() + { + // Arrange + string nonExistentCollection = _faker.Random.String2(10); + _vectorStore + .EnsureCollectionDeletedAsync(nonExistentCollection) + .ThrowsAsync(); + ArangoCollection collection = new( + _vectorStore, + nonExistentCollection, + _serviceProvider); + + // Act & Assert + await Should.ThrowAsync(() + => collection.EnsureCollectionDeletedAsync()); + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldIgnoreException_WhenCollectionDoesNotExist() + { + // Arrange + _vectorStore + .EnsureCollectionDeletedAsync(Arg.Any()) + .ThrowsAsync(new ApiErrorException(new ApiErrorResponse() + { + Code = HttpStatusCode.NotFound, + ErrorMessage = "collection not found" + })); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionDeletedAsync()); + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ShouldIgnoreException_WhenCollectionNotFound() + { + // Arrange + _vectorStore + .EnsureCollectionDeletedAsync(Arg.Any()) + .ThrowsAsync(new ApiErrorException(new ApiErrorResponse() + { + ErrorNum = 1203, // ArangoDB error code for "collection not found" + ErrorMessage = "collection not found" + })); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionDeletedAsync()); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionExistsAsync.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionExistsAsync.cs new file mode 100644 index 00000000..41cbb7f8 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.EnsureCollectionExistsAsync.cs @@ -0,0 +1,86 @@ +using ArangoDBNetStandard.CollectionApi.Models; + +using NSubstitute.ExceptionExtensions; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + public async Task EnsureCollectionExistsAsync_ShouldIgnoreException_WhenAlreadyExists() + { + // Arrange + _arangoClient + .Collection + .PostCollectionAsync( + Arg.Any(), + null, + token: Arg.Any()) + .ThrowsAsync(new ApiErrorException(new ApiErrorResponse() + { + Code = HttpStatusCode.Conflict, + ErrorMessage = "collection already exists" + })); + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionExistsAsync()); + } + + [Test] + public async Task EnsureCollectionExistsAsync_ShouldIgnoreException_WhenAlreadyExistsWithErrorNum() + { + // Arrange + _arangoClient + .Collection + .PostCollectionAsync( + Arg.Any(), + token: Arg.Any()) + .ThrowsAsync(new ApiErrorException(new ApiErrorResponse() + { + ErrorNum = 1207, + ErrorMessage = "collection already exists" + })); + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.NotThrowAsync(() => collection.EnsureCollectionExistsAsync()); + } + + [Test] + public async Task EnsureCollectionExistsAsync_ShouldThrowException_WhenGenericExceptionOccurs() + { + // Arrange + _arangoClient + .Collection + .PostCollectionAsync( + Arg.Any(), + token: Arg.Any()) + .ThrowsAsync(new InvalidOperationException()); + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.ThrowAsync(() + => collection.EnsureCollectionExistsAsync()); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsync.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsync.cs new file mode 100644 index 00000000..0381f972 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsync.cs @@ -0,0 +1,77 @@ +using ArangoDBNetStandard.DocumentApi.Models; + +using NSubstitute.ExceptionExtensions; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + [TestCase("")] + [TestCase(" ")] + public async Task GetAsync_ShouldThrowArgumentException_WhenKeyIsNullOrWhitespace( + string? key) + { + // Arrange + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + ArgumentException exception = await Should.ThrowAsync(() + => collection.GetAsync(key!)); + string paramName = "key"; + exception.ParamName.ShouldBe(paramName); + exception.Message.ShouldBe($"Key can't be null or empty. (Parameter '{paramName}')"); + } + } + + [Test] + public async Task GetAsync_ShouldThrowArgumentException_WhenKeyEndsWithSlash() + { + // Arrange + TestRecord rec = new(); + string key = "abc/"; + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + FormatException exception = await Should.ThrowAsync(() + => collection.GetAsync(key)); + exception.Message.ShouldBe("Key string cannot end with a slash."); + } + } + + [Test] + public async Task GetAsync_ShouldRethrowException_WhenGenericExceptionOccurs() + { + // Arrange + TestRecord rec = new(); + string key = "abc"; + _arangoClient + .Document + .GetDocumentAsync( + Arg.Any(), + Arg.Any(), + token: Arg.Any()) + .ThrowsAsync(new InvalidOperationException()); + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(_arangoClient); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Act & Assert + await Should.ThrowAsync(() + => collection.GetAsync(key)); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsyncWithFilter.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsyncWithFilter.cs new file mode 100644 index 00000000..fa041c40 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetAsyncWithFilter.cs @@ -0,0 +1,25 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + public async Task GetAsync_WithFilter_ShouldReturnEmpty_WhenTopIsZeroOrNegative() + { + // Arrange + ArangoCollection collection = new(_vectorStore, CollectionName, _serviceProvider); + Expression> filter = r => r.Name == "Test"; + int top = _faker.Random.Int(max: 0); + + // Act + List results = []; + await foreach (TestRecord record in collection.GetAsync(filter, top)) + { + results.Add(record); + } + + // Assert + results.ShouldBeEmpty(); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetService.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetService.cs new file mode 100644 index 00000000..66b62e12 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.GetService.cs @@ -0,0 +1,105 @@ +using ArangoDBNetStandard.Transport.Http; + +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute.ExceptionExtensions; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + public void GetService_ShouldReturnService_WhenServiceExists() + { + // Arrange + using IArangoDBClient expectedService = new ArangoDBClient( + new HttpApiTransport(new(), HttpContentType.VPack)); + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns(expectedService); + ArangoCollection collection = new(_vectorStore, CollectionName, _serviceProvider); + + // Act + object? result = collection.GetService(typeof(IArangoDBClient)); + + // Assert + using (Assert.EnterMultipleScope()) + { + result.ShouldBe(expectedService); + result.ShouldBeOfType(); + } + } + + [Test] + public void GetService_ShouldReturnNullWithoutThrowingException_WhenServiceDoesNotExist() + { + // Arrange + _serviceProvider + .GetService(typeof(IArangoDBClient)) + .Returns((IArangoDBClient?)null); + ArangoCollection collection = new(_vectorStore, CollectionName, _serviceProvider); + + // Act and Assert + using (Assert.EnterMultipleScope()) + { + object? result = Should.NotThrow(() + => collection.GetService(typeof(IArangoDBClient))); + + result.ShouldBeNull(); + } + } + + [Test] + public void GetService_WithServiceKey_ShouldReturnKeyedService_WhenServiceExists() + { + // Arrange + string serviceKey = "test_key"; + using IArangoDBClient expectedService = new ArangoDBClient( + new HttpApiTransport(new(), HttpContentType.VPack)); + IKeyedServiceProvider keyedServiceProvider = Substitute.For(); + keyedServiceProvider + .GetRequiredKeyedService(typeof(IArangoDBClient), serviceKey) + .Returns(expectedService); + using ArangoCollection collection = new( + _vectorStore, + CollectionName, + keyedServiceProvider); + + // Act + object? result = collection.GetService(typeof(IArangoDBClient), serviceKey); + + // Assert + using (Assert.EnterMultipleScope()) + { + result.ShouldBe(expectedService); + result.ShouldBeOfType(); + } + } + + [Test] + public void GetService_WithServiceKey_ShouldReturnNull_WhenServiceDoesNotExist() + { + // Arrange + string serviceKey = "test_key"; + IKeyedServiceProvider keyedServiceProvider = Substitute.For(); + keyedServiceProvider + .GetRequiredKeyedService(typeof(IArangoDBClient), serviceKey) + .Throws(); + ArangoCollection collection = new( + _vectorStore, + CollectionName, + keyedServiceProvider); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + object? result = null; + Should.NotThrow(() => + result = collection.GetService(typeof(IArangoDBClient), serviceKey) + ); + + // Assert + result.ShouldBeNull(); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.SearchAsync.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.SearchAsync.cs new file mode 100644 index 00000000..acc6ec74 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.SearchAsync.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.AI; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +public partial class ArangoCollectionUnitTests +{ + [Test] + public async Task SearchAsync_ShouldThrowInvalidOperationException_WhenEmbeddingGeneratorNotFound() + { + // Arrange + _serviceProvider + .GetService(typeof(IEmbeddingGenerator>)) + .Returns((IEmbeddingGenerator>?)null); + ArangoCollection collection = new(_vectorStore, CollectionName, _serviceProvider); + string searchValue = "test search"; + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + InvalidOperationException exception = await Should.ThrowAsync(async () => + { + await foreach (VectorSearchResult result in collection.SearchAsync(searchValue, 1)) + { + // Should throw before any iteration + } + }); + exception.Message.ShouldContain("Vector search requires options.EmbeddingGenerator"); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.cs new file mode 100644 index 00000000..39b7a693 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoCollectionUnitTests.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.AI; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +[ExcludeFromCodeCoverage] +public partial class ArangoCollectionUnitTests +{ + private const string CollectionName = "test_collection"; + private readonly Faker _faker = new(); + private IServiceProvider _serviceProvider; + private VectorStore _vectorStore; + private IArangoDBClient _arangoClient; + private IDocumentApiClient _documentClient; + private ICollectionApiClient _collectionClient; + private ICursorApiClient _cursorClient; + private IEmbeddingGenerator> _embeddingGenerator; + + public ArangoCollectionUnitTests() + { + _vectorStore = Substitute.For(); + _arangoClient = Substitute.For(); + _documentClient = Substitute.For(); + _collectionClient = Substitute.For(); + _cursorClient = Substitute.For(); + _embeddingGenerator = Substitute.For>>(); + + _arangoClient.Document.Returns(_documentClient); + _arangoClient.Collection.Returns(_collectionClient); + _arangoClient.Cursor.Returns(_cursorClient); + + _serviceProvider = Substitute.For(); + } + + [Test] + public void Constructor_WithStoreAndName_ShouldInitializeCorrectly() + { + // Act + ArangoCollection collection = new( + _vectorStore, + CollectionName, + _serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + collection.Name.ShouldBe(CollectionName); + collection.Definition.ShouldBeNull(); + } + } + + [Test] + [TestCase((string?)null)] + [TestCase("")] + [TestCase(" ")] + public void Constructor_WithStoreButWithoutName_ShouldThrowArgumentNullException( + string? collectionName) + { + // Act and Assert + using (Assert.EnterMultipleScope()) + { + ArgumentNullException exception = Should.Throw(() => + { + ArangoCollection collection = new( + _vectorStore, + collectionName!, + _serviceProvider); + }); + + string paramName = "name"; + exception.Message.ShouldBe($"Collection name cannot be null or empty. (Parameter '{paramName}')"); + exception.ParamName.ShouldBe(paramName); + } + } + + [Test] + public void Constructor_WithDefinition_ShouldInitializeCorrectly() + { + // Arrange + VectorStoreCollectionDefinition definition = new(); + + // Act + ArangoCollection collection = new( + _vectorStore, + CollectionName, + definition, + _serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + collection.Name.ShouldBe(CollectionName); + collection.Definition.ShouldBe(definition); + } + } + + [OneTimeSetUp] + public void Setup() + { + _vectorStore = Substitute.For(); + _arangoClient = Substitute.For(); + _documentClient = Substitute.For(); + _collectionClient = Substitute.For(); + _cursorClient = Substitute.For(); + _embeddingGenerator = Substitute.For>>(); + + _arangoClient.Document.Returns(_documentClient); + _arangoClient.Collection.Returns(_collectionClient); + _arangoClient.Cursor.Returns(_cursorClient); + + _serviceProvider = Substitute.For(); + } + + [OneTimeTearDown] + public void Cleanup() + { + _vectorStore.Dispose(); + _arangoClient.Dispose(); + _embeddingGenerator.Dispose(); + } + + public class TestRecord + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public float[]? Embedding { get; set; } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoHybridSearchableUnitTests.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoHybridSearchableUnitTests.cs new file mode 100644 index 00000000..4de643c2 --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoHybridSearchableUnitTests.cs @@ -0,0 +1,329 @@ +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute.ExceptionExtensions; + +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +[ExcludeFromCodeCoverage] +public class ArangoHybridSearchableUnitTests +{ + private readonly Faker _faker = new(); + + public class TestRecord + { + [JsonPropertyName("_key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("embedding")] + public float[] Embedding { get; set; } = []; + } + + public class TestRecordWithoutProperties + { + } + + [Test] + public void Constructor_WithNameAndServiceProvider_SetsPropertiesCorrectly() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + searchable.Name.ShouldBe(name); + searchable.Definition.ShouldBeNull(); + } + } + + [Test] + public void Constructor_WithNameDefinitionAndServiceProvider_SetsPropertiesCorrectly() + { + // Arrange + string name = _faker.Lorem.Word(); + VectorStoreCollectionDefinition definition = new(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoHybridSearchable searchable = new(name, definition, serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + searchable.Name.ShouldBe(name); + searchable.Definition.ShouldBe(definition); + } + } + + [Test] + public void GetService_WithRegisteredService_ReturnsService() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + services.AddSingleton( + new ArangoDBClient(new HttpClient())); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + IArangoDBClient? result = (IArangoDBClient?)searchable.GetService(typeof(IArangoDBClient)); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeOfType(); + if (result is IDisposable disposable) + { + disposable.Dispose(); + } + } + + [Test] + public void GetService_WithUnregisteredService_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(IArangoDBClient)); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public void GetService_WithKeyedService_ReturnsKeyedService() + { + // Arrange + string name = _faker.Lorem.Word(); + string serviceKey = _faker.Lorem.Word(); + + // Create a mock service provider that implements IKeyedServiceProvider + IServiceProvider serviceProvider = Substitute.For(); + object expectedService = new(); + ((IKeyedServiceProvider)serviceProvider) + .GetRequiredKeyedService(typeof(string), serviceKey) + .Returns(expectedService); + + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string), serviceKey); + + // Assert + using (Assert.EnterMultipleScope()) + { + result.ShouldBe(expectedService); + ((IKeyedServiceProvider)serviceProvider).Received(1).GetRequiredKeyedService(typeof(string), serviceKey); + } + } + + [Test] + public void GetService_WithServiceKey_WhenInvalidOperationException_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + string serviceKey = _faker.Lorem.Word(); + + // Create a mock service provider that implements IKeyedServiceProvider + IServiceProvider serviceProvider = Substitute.For(); + ((IKeyedServiceProvider)serviceProvider) + .GetRequiredKeyedService(typeof(string), serviceKey) + .Throws(); + + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string), serviceKey); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task HybridSearchAsync_WithZeroTop_ReturnsEmpty() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + IAsyncEnumerable> results = searchable.HybridSearchAsync("test", ["keyword"], 0); + + // Assert + await foreach (VectorSearchResult result in results) + { + Assert.Fail("Should not return any results"); + } + } + + [Test] + public async Task HybridSearchAsync_WithNegativeTop_ReturnsEmpty() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Act + IAsyncEnumerable> results = searchable.HybridSearchAsync("test", ["keyword"], -1); + + // Assert + await foreach (VectorSearchResult result in results) + { + Assert.Fail("Should not return any results"); + } + } + + [Test] + public async Task HybridSearchAsync_WithoutVectorProperty_ThrowsArgumentNullException() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + HybridSearchOptions options = new() + { + AdditionalProperty = x => x.Name + }; + + // Act & Assert + ArgumentNullException exception = await Should.ThrowAsync(async () => + { + await foreach (VectorSearchResult result in searchable.HybridSearchAsync("test", ["keyword"], 10, options)) + { + // Should not reach here + } + }); + + exception.ParamName.ShouldBe("VectorProperty"); + } + + [Test] + public async Task HybridSearchAsync_WithoutAdditionalProperty_ThrowsArgumentNullException() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + HybridSearchOptions options = new() + { + VectorProperty = x => x.Embedding + }; + + // Act & Assert + ArgumentNullException exception = await Should.ThrowAsync(async () => + { + await foreach (VectorSearchResult result in searchable.HybridSearchAsync("test", ["keyword"], 10, options)) + { + // Should not reach here + } + }); + + exception.ParamName.ShouldBe("AdditionalProperty"); + } + + [Test] + public async Task HybridSearchAsync_WithoutEmbeddingGenerator_ThrowsInvalidOperationException() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + services.AddSingleton(Substitute.For()); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoHybridSearchable searchable = new(name, serviceProvider); + + HybridSearchOptions options = new() + { + VectorProperty = x => x.Embedding, + AdditionalProperty = x => x.Name + }; + + // Act & Assert + InvalidOperationException exception = await Should.ThrowAsync(async () => + { + await foreach (VectorSearchResult result in searchable.HybridSearchAsync("test", ["keyword"], 10, options)) + { + // Should not reach here + } + }); + + exception.Message.ShouldContain("IEmbeddingGenerator"); + } + + [Test] + public void Name_Property_ReturnsConstructorValue() + { + // Arrange + string expectedName = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoHybridSearchable searchable = new(expectedName, serviceProvider); + + // Assert + searchable.Name.ShouldBe(expectedName); + } + + [Test] + public void Definition_Property_WithDefinition_ReturnsDefinition() + { + // Arrange + string name = _faker.Lorem.Word(); + VectorStoreCollectionDefinition expectedDefinition = new(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoHybridSearchable searchable = new(name, expectedDefinition, serviceProvider); + + // Assert + searchable.Definition.ShouldBe(expectedDefinition); + } + + [Test] + public void Definition_Property_WithoutDefinition_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoHybridSearchable searchable = new(name, serviceProvider); + + // Assert + searchable.Definition.ShouldBeNull(); + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorSearchableUnitTests.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorSearchableUnitTests.cs new file mode 100644 index 00000000..4b596f4d --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorSearchableUnitTests.cs @@ -0,0 +1,513 @@ +using Microsoft.Extensions.DependencyInjection; + +using NSubstitute.ExceptionExtensions; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +[ExcludeFromCodeCoverage] +public class ArangoVectorSearchableUnitTests +{ + private readonly Faker _faker = new(); + + public class TestRecord + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public float[] Embedding { get; set; } = []; + } + + public class TestRecord2 + { + } + + [Test] + public void Constructor_WithNameAndServiceProvider_SetsPropertiesCorrectly() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + searchable.Name.ShouldBe(name); + searchable.Definition.ShouldBeNull(); + } + } + + [Test] + public void Constructor_WithNameDefinitionAndServiceProvider_SetsPropertiesCorrectly() + { + // Arrange + string name = _faker.Lorem.Word(); + VectorStoreCollectionDefinition definition = new(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(name, definition, serviceProvider); + + // Assert + using (Assert.EnterMultipleScope()) + { + searchable.Name.ShouldBe(name); + searchable.Definition.ShouldBe(definition); + } + } + + [Test] + public void GetService_WithoutServiceKey_CallsServiceProviderGetService() + { + // Arrange + string name = _faker.Lorem.Word(); + IServiceProvider serviceProvider = Substitute.For(); + object expectedService = new(); + serviceProvider.GetService(typeof(string)).Returns(expectedService); + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string)); + + // Assert + using (Assert.EnterMultipleScope()) + { + result.ShouldBe(expectedService); + serviceProvider.Received(1).GetService(typeof(string)); + } + } + + [Test] + public void GetService_WithServiceKey_CallsServiceProviderGetRequiredKeyedService() + { + // Arrange + string name = _faker.Lorem.Word(); + string serviceKey = _faker.Lorem.Word(); + + // Create a mock service provider that implements IKeyedServiceProvider + IServiceProvider serviceProvider = Substitute.For(); + object expectedService = new(); + ((IKeyedServiceProvider)serviceProvider) + .GetRequiredKeyedService(typeof(string), serviceKey) + .Returns(expectedService); + + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string), serviceKey); + + // Assert + using (Assert.EnterMultipleScope()) + { + result.ShouldBe(expectedService); + ((IKeyedServiceProvider)serviceProvider).Received(1).GetRequiredKeyedService(typeof(string), serviceKey); + } + } + + [Test] + public void GetService_WithServiceKey_WhenInvalidOperationException_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + string serviceKey = _faker.Lorem.Word(); + + // Create a mock service provider that implements IKeyedServiceProvider + IServiceProvider serviceProvider = Substitute.For(); + ((IKeyedServiceProvider)serviceProvider) + .GetRequiredKeyedService(typeof(string), serviceKey) + .Throws(); + + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string), serviceKey); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task SearchAsync_WithZeroTop_YieldsNoResults() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act + List> results = []; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 0)) + { + results.Add(result); + } + + // Assert + results.ShouldBeEmpty(); + } + + [Test] + public async Task SearchAsync_WithNegativeTop_YieldsNoResults() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act + List> results = []; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, -5)) + { + results.Add(result); + } + + // Assert + results.ShouldBeEmpty(); + } + + [Test] + public async Task SearchAsync_WithoutArangoDBClient_ThrowsInvalidOperationException() + { + // Arrange + string name = _faker.Lorem.Word(); + + // Create a service collection without required services + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + InvalidOperationException exception = await Should.ThrowAsync(async () => + { + VectorSearchOptions options = new() + { + VectorProperty = x => x.Embedding // Provide VectorProperty to pass null check + }; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 5, options)) + { + // This should throw before yielding any results + } + }); + + exception.Message.ShouldContain("No service for type"); + } + } + + [Test] + public async Task SearchAsync_WithoutEmbeddingGenerator_ThrowsInvalidOperationException() + { + // Arrange + string name = _faker.Lorem.Word(); + + // Create a service collection with client but without embedding generator + ServiceCollection services = new(); + IArangoDBClient client = Substitute.For(); + services.AddSingleton(client); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + InvalidOperationException exception = await Should.ThrowAsync(async () => + { + VectorSearchOptions options = new() + { + VectorProperty = x => x.Embedding // Provide VectorProperty to pass null check + }; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 5, options)) + { + // This should throw before yielding any results + } + }); + + exception.Message.ShouldContain("Vector search requires options.EmbeddingGenerator implementing IEmbeddingGenerator"); + } + } + + [Test] + public void Name_Property_ReturnsCorrectValue() + { + // Arrange + string expectedName = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(expectedName, serviceProvider); + + // Assert + searchable.Name.ShouldBe(expectedName); + } + + [Test] + public void Definition_Property_WithDefinition_ReturnsCorrectValue() + { + // Arrange + string name = _faker.Lorem.Word(); + VectorStoreCollectionDefinition expectedDefinition = new(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(name, expectedDefinition, serviceProvider); + + // Assert + searchable.Definition.ShouldBe(expectedDefinition); + } + + [Test] + public void Definition_Property_WithoutDefinition_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Assert + searchable.Definition.ShouldBeNull(); + } + + [Test] + public void GetService_WithNullServiceKey_ReturnsServiceFromProvider() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + string testService = "test-service"; + services.AddSingleton(testService); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string)); + + // Assert + result.ShouldBe(testService); + } + + [Test] + public void GetService_WithNonExistentService_ReturnsNull() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string)); + + // Assert + result.ShouldBeNull(); + } + + [Test] + public async Task SearchAsync_WithVectorSearchOptions_AcceptsGenericOptions() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act & Assert - This should compile without issues, demonstrating the correct generic signature + using (Assert.EnterMultipleScope()) + { + InvalidOperationException exception = await Should.ThrowAsync(async () => + { + VectorSearchOptions options = new() + { + VectorProperty = x => x.Embedding // Provide VectorProperty to pass null check + }; + + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 5, options)) + { + // This should throw before yielding any results due to missing dependencies + } + }); + + exception.Message.ShouldContain("No service for type"); + } + } + + [Test] + public void Name_Property_IsImmutableAfterConstruction() + { + // Arrange + string expectedName = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable = new(expectedName, serviceProvider); + string retrievedName1 = searchable.Name; + string retrievedName2 = searchable.Name; + + // Assert - Name should be consistent and immutable + using (Assert.EnterMultipleScope()) + { + retrievedName1.ShouldBe(expectedName); + retrievedName2.ShouldBe(expectedName); + retrievedName1.ShouldBe(retrievedName2); + } + } + + [Test] + public void Constructor_WithDifferentServiceProviders_MaintainsIndependentState() + { + // Arrange + string name1 = _faker.Lorem.Word(); + string name2 = _faker.Lorem.Word(); + ServiceCollection services1 = new(); + ServiceCollection services2 = new(); + + using ServiceProvider serviceProvider1 = services1.BuildServiceProvider(); + using ServiceProvider serviceProvider2 = services2.BuildServiceProvider(); + + // Act + ArangoVectorSearchable searchable1 = new(name1, serviceProvider1); + ArangoVectorSearchable searchable2 = new(name2, serviceProvider2); + + // Assert - Each instance should maintain its own state + using (Assert.EnterMultipleScope()) + { + searchable1.Name.ShouldBe(name1); + searchable2.Name.ShouldBe(name2); + searchable1.Name.ShouldNotBe(searchable2.Name); + } + } + + [Test] + public void GetService_ServiceProviderDisposed_ThrowsObjectDisposedException() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + serviceProvider.Dispose(); + + // Assert + Assert.Throws(() => searchable.GetService(typeof(string))); + } + + [Test] + public async Task SearchAsync_WithOptionsButNoTop_YieldsNoResults() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + VectorSearchOptions options = new(); + + // Act + List> results = []; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 0, options)) + { + results.Add(result); + } + + // Assert + results.ShouldBeEmpty(); + } + + [Test] + [TestCase(-1)] + [TestCase(-10)] + [TestCase(-100)] + public async Task SearchAsync_WithNegativeTopValues_YieldsNoResults(int negativeTop) + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + // Act + List> results = []; + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, negativeTop)) + { + results.Add(result); + } + + // Assert + results.ShouldBeEmpty(); + } + + [Test] + public void GetService_WithKeyedServiceProvider_ReturnsCorrectService() + { + // Arrange + string name = _faker.Lorem.Word(); + string serviceKey = _faker.Lorem.Word(); + string expectedService = _faker.Lorem.Sentence(); + + ServiceCollection services = new(); + services.AddKeyedSingleton(serviceKey, expectedService); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + + ArangoVectorSearchable searchable = new(name, serviceProvider); + + // Act + object? result = searchable.GetService(typeof(string), serviceKey); + + // Assert + result.ShouldBe(expectedService); + } + + [Test] + public async Task SearchAsync_WithNullVectorProperty_ThrowsArgumentNullException() + { + // Arrange + string name = _faker.Lorem.Word(); + ServiceCollection services = new(); + IArangoDBClient client = Substitute.For(); + services.AddSingleton(client); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + ArangoVectorSearchable searchable = new(name, serviceProvider); + string searchValue = _faker.Lorem.Sentence(); + + VectorSearchOptions options = new() + { + VectorProperty = null // This should cause ArgumentNullException + }; + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + ArgumentNullException exception = await Should.ThrowAsync(async () => + { + await foreach (VectorSearchResult result in searchable.SearchAsync(searchValue, 5, options)) + { + // Should not reach here + } + }); + + exception.ParamName.ShouldBe("VectorProperty"); + exception.Message.ShouldContain("VectorProperty must be specified for vector search operations."); + } + } +} diff --git a/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorStoreUnitTests.cs b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorStoreUnitTests.cs new file mode 100644 index 00000000..013c44db --- /dev/null +++ b/ArangoDB.Extensions.VectorData.Tests/UnitTests/ArangoVectorStoreUnitTests.cs @@ -0,0 +1,107 @@ +using NSubstitute.ExceptionExtensions; + +using System.Net; + +namespace ArangoDB.Extensions.VectorData.Tests.UnitTests; + +[ExcludeFromCodeCoverage] +public class ArangoVectorStoreUnitTests +{ + [Test] + public async Task CollectionExistAsync_ThrowsApiErrorException_WhenFound() + { + // Arrange + IArangoDBClient client = Substitute.For(); + IServiceProvider serviceProvider = Substitute.For(); + ArangoVectorStore arangoVectorStore = new(client, serviceProvider); + client + .Collection + .GetCollectionAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new ApiErrorException() + { + ApiError = new ApiErrorResponse() + { + Code = System.Net.HttpStatusCode.BadRequest, + ErrorMessage = "Bad Request" + } + }); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + ApiErrorException apiErrorException = await Should.ThrowAsync( + () => arangoVectorStore.CollectionExistsAsync(null!)); + apiErrorException.ApiError.ShouldNotBeNull(); + } + } + + [Test] + public async Task CollectionExistAsync_ThrowsException_WhenOtherExceptionOccured() + { + // Arrange + IArangoDBClient client = Substitute.For(); + IServiceProvider serviceProvider = Substitute.For(); + ArangoVectorStore arangoVectorStore = new(client, serviceProvider); + client + .Collection + .GetCollectionAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + Exception exception = await Should.ThrowAsync( + () => arangoVectorStore.CollectionExistsAsync(null!)); + exception.ShouldNotBeOfType(); + } + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ThrowsApiErrorException_WhenFound() + { + // Arrange + IArangoDBClient client = Substitute.For(); + IServiceProvider serviceProvider = Substitute.For(); + ArangoVectorStore arangoVectorStore = new(client, serviceProvider); + client + .Collection + .DeleteCollectionAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new ApiErrorException() + { + ApiError = new ApiErrorResponse() + { + Code = HttpStatusCode.BadRequest, + ErrorMessage = "Bad Request" + } + }); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + ApiErrorException apiErrorException = await Should.ThrowAsync( + () => arangoVectorStore.EnsureCollectionDeletedAsync(null!)); + apiErrorException.ApiError.ShouldNotBeNull(); + } + } + + [Test] + public async Task EnsureCollectionDeletedAsync_ThrowsException_WhenOtherExceptionOccured() + { + // Arrange + IArangoDBClient client = Substitute.For(); + IServiceProvider serviceProvider = Substitute.For(); + ArangoVectorStore arangoVectorStore = new(client, serviceProvider); + client + .Collection + .DeleteCollectionAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(); + + // Act & Assert + using (Assert.EnterMultipleScope()) + { + Exception exception = await Should.ThrowAsync( + () => arangoVectorStore.EnsureCollectionDeletedAsync(null!)); + exception.ShouldNotBeOfType(); + } + } +} diff --git a/ArangoDB.Extensions.VectorData/ArangoCollection.cs b/ArangoDB.Extensions.VectorData/ArangoCollection.cs new file mode 100644 index 00000000..c1a14348 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoCollection.cs @@ -0,0 +1,423 @@ +using ArangoDB.Extensions.VectorData.Helpers; +using ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +using ArangoDBNetStandard; +using ArangoDBNetStandard.CollectionApi.Models; +using ArangoDBNetStandard.CursorApi.Models; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +using System.Linq.Expressions; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ArangoDB.Extensions.VectorData; + +public sealed partial class ArangoCollection + : VectorStoreCollection +where TKey : notnull +where TRecord : class +{ + private bool _disposedValue = false; + private readonly VectorStore _store; + private readonly IServiceProvider _serviceProvider; + + public ArangoCollection( + VectorStore store, + string name, + IServiceProvider serviceProvider + ) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException( + nameof(name), + "Collection name cannot be null or empty."); + } + (Name, _store, _serviceProvider) = (name, store, serviceProvider); + } + + public ArangoCollection( + VectorStore store, + string name, + VectorStoreCollectionDefinition? definition, + IServiceProvider serviceProvider + ) : this(store, name, serviceProvider) + { + Definition = definition; + } + + public override string Name { get; } + + public VectorStoreCollectionDefinition? Definition { get; } + + public override async Task CollectionExistsAsync(CancellationToken cancellationToken = default) + { + return await _store + .CollectionExistsAsync(Name, cancellationToken) + .ConfigureAwait(false); + } + + public override async Task DeleteAsync( + TKey key, + CancellationToken cancellationToken = default) + { + string documentId = key.SanitizeKeyAndGetId(Name); + + using IArangoDBClient client = GetRequiredService(); + await client.Document + .DeleteDocumentAsync(documentId, token: cancellationToken) + .ConfigureAwait(false); + } + + public override async Task EnsureCollectionDeletedAsync( + CancellationToken cancellationToken = default) + { + try + { + await _store.EnsureCollectionDeletedAsync( + Name, + cancellationToken); + } + catch (ApiErrorException ex) when (ex.ApiError is { Code: HttpStatusCode.NotFound } or { ErrorNum: 1203 }) + { + // If the collection does not exist, ignore the exception + return; + } + catch (Exception) + { + throw; + } + } + + public override async Task EnsureCollectionExistsAsync( + CancellationToken cancellationToken = default) + { + try + { + PostCollectionBody body = new() + { + Name = Name, + Type = CollectionType.Document, + }; + using IArangoDBClient client = GetRequiredService(); + await client.Collection + .PostCollectionAsync( + body, + token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex.ApiError is { Code: HttpStatusCode.Conflict } or { ErrorNum: 1207 }) + { + // If the collection already exists, ignore the exception + return; + } + catch (Exception) + { + throw; + } + } + + public override async Task GetAsync( + TKey key, + RecordRetrievalOptions? options = null, + CancellationToken cancellationToken = default) + { + string id = key.SanitizeKeyAndGetId(Name); + + try + { + using IArangoDBClient client = GetRequiredService(); + TRecord res = await client.Document + .GetDocumentAsync(id, token: cancellationToken) + .ConfigureAwait(false); + return res; + } + catch (ApiErrorException ex) when (ex.ApiError is { Code: HttpStatusCode.NotFound } or { ErrorNum: 1202 }) + { + return null; + } + catch (Exception) + { + throw; + } + } + + public override async IAsyncEnumerable GetAsync( + Expression> filter, + int top, + FilteredRecordRetrievalOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (top <= 0) + { + yield break; + } + + Dictionary bindVars = []; + + bindVars["limit"] = top; + if (options?.Skip is int skip && skip > 0) + { + bindVars["skip"] = skip; + } + string limitClause = bindVars.ContainsKey("skip") + ? "@skip, @limit" + : "@limit"; + + StringBuilder queryBuilder = new($"FOR doc IN {Name}"); + string whereClause = filter.BuildWhereClause(bindVars); + if (!string.IsNullOrWhiteSpace(whereClause)) + { + queryBuilder.Append($" FILTER {whereClause}"); + } + FilteredRecordRetrievalOptions.OrderByDefinition orderByDefinition = new(); + string sortClause = options? + .OrderBy? + .Invoke(orderByDefinition) + .BuildOrderByClause() + ?? string.Empty; + if (!string.IsNullOrWhiteSpace(sortClause)) + { + queryBuilder.Append($" SORT {sortClause}"); + } + queryBuilder.Append($" LIMIT {limitClause} RETURN doc"); + + string query = queryBuilder.ToString(); + + using IArangoDBClient client = GetRequiredService(); + CursorResponse response = await client.Cursor + .PostCursorAsync( + query, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (TRecord record in response.Result) + { + yield return record; + } + } + + public override object? GetService( + Type serviceType, + object? serviceKey = null) + { + if (serviceKey is null) + { + return _serviceProvider.GetService(serviceType); + } + try + { + return _serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + catch (InvalidOperationException) + { + return null; + } + } + + public override async IAsyncEnumerable> SearchAsync( + TInput searchValue, + int top, + VectorSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (top <= 0) + { + yield break; + } + + using IArangoDBClient client = GetRequiredService(); + IEmbeddingGenerator>? generator + = GetService>>() + ?? throw new InvalidOperationException("Vector search requires options.EmbeddingGenerator implementing IEmbeddingGenerator>."); + + if (Definition is { EmbeddingGenerator: null }) + { + Definition.EmbeddingGenerator = generator; + } + + Embedding embedding = await generator + .GenerateAsync( + searchValue, + new EmbeddingGenerationOptions + { + + }, + cancellationToken) + .ConfigureAwait(false); + float[] vector = embedding.Vector.Span.ToArray(); + + // Build optional filter clause from options.Filter + Dictionary bindVars = new() + { + ["queryVec"] = vector, + ["limit"] = top + }; + if (options?.Skip is int skip && skip > 0) + { + bindVars["skip"] = skip; + } + string filterClause = options?.Filter?.BuildFilterClause(bindVars) + ?? string.Empty; + + // LIMIT clause supports paging with skip + string limitClause = bindVars.ContainsKey("skip") + ? "LIMIT @skip, @limit" + : "LIMIT @limit"; + + // Resolve vector field path + string? vectorFieldPath = options?.IncludeVectors == true && options.VectorProperty is LambdaExpression vecExpr + ? vecExpr.BuildMemberAccessPath() + : null; + string? similarityTarget = string.IsNullOrWhiteSpace(vectorFieldPath) + ? null + : vectorFieldPath; + + // If caller requested vectors, return the whole document (including vectors). + string docProjectionExpr = options?.IncludeVectors == true + ? "doc" + : ProcessProjectionexpression(vectorFieldPath); + + string aql = $"FOR doc IN {Name}{filterClause} LET score = COSINE_SIMILARITY({similarityTarget}, @queryVec) SORT score DESC {limitClause} RETURN {{ doc: {docProjectionExpr}, score: score }}"; + + CursorResponse> response = await client.Cursor + .PostCursorAsync>( + aql, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (CursorRow row in response.Result) + { + TRecord? rec = row.Doc; + double score = row.Score; + if (rec is not null) + { + yield return new VectorSearchResult(rec, score); + } + } + } + + + public override async Task UpsertAsync( + TRecord record, + CancellationToken cancellationToken = default) + { + IArangoDBClient client = GetRequiredService(); + try + { + await client.Document + .PutDocumentAsync(Name, record, token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex.ApiError?.Code == HttpStatusCode.NotFound) + { + await client.Document + .PostDocumentAsync(Name, record, token: cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) + { + throw; + } + finally + { + client.Dispose(); + } + } + + public override async Task UpsertAsync( + IEnumerable records, + CancellationToken cancellationToken = default) + { + using IArangoDBClient client = GetRequiredService(); + try + { + await client.Document + .PutDocumentsAsync(Name, records, token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex.ApiError?.Code == HttpStatusCode.NotFound) + { + await client.Document + .PostDocumentsAsync(Name, records, token: cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) + { + throw; + } + finally + { + client.Dispose(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (_disposedValue) + { + // If already disposed, do nothing + return; + } + + if (!disposing) + { + // Dispose unmanaged resources here if any + } + + // Dispose managed resources here if any + _store.Dispose(); + _disposedValue = true; + } + + private static string ProcessProjectionexpression(string? vectorFieldPath) + { + string docProjectionExpr; + string vectorName = vectorFieldPath ?? "embedding"; + + string[] props = typeof(TRecord) + .GetPublicPropertyNamesExcluding(vectorName); + + if (props.Length == 0) + { + // If no properties found (or all excluded), return an empty object + docProjectionExpr = "{}"; + } + else + { + // Build AQL object projection: { prop1: doc.prop1, prop2: doc.prop2, ... } + string projection = string.Join( + ", ", + props.Select(p => $"{p}: doc.{p}") + ); + docProjectionExpr = $"{{ {projection} }}"; + } + + return docProjectionExpr; + } + + private T? GetService( + object? serviceKey = null + ) + { + return serviceKey is null + ? _serviceProvider.GetService() + : _serviceProvider.GetKeyedService(serviceKey); + } + + private T GetRequiredService( + object? serviceKey = null + ) where T : notnull + { + return serviceKey is null + ? _serviceProvider.GetRequiredService() + : _serviceProvider.GetRequiredKeyedService(serviceKey); + } +} diff --git a/ArangoDB.Extensions.VectorData/ArangoDB.Extensions.VectorData.csproj b/ArangoDB.Extensions.VectorData/ArangoDB.Extensions.VectorData.csproj new file mode 100644 index 00000000..0afd549f --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoDB.Extensions.VectorData.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + latest + enable + true + + + + + + + + + + diff --git a/ArangoDB.Extensions.VectorData/ArangoDynamicCollection.cs b/ArangoDB.Extensions.VectorData/ArangoDynamicCollection.cs new file mode 100644 index 00000000..8c8b385e --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoDynamicCollection.cs @@ -0,0 +1,372 @@ +using ArangoDB.Extensions.VectorData.Helpers; +using ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +using ArangoDBNetStandard; +using ArangoDBNetStandard.CollectionApi.Models; +using ArangoDBNetStandard.CursorApi.Models; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +using System.Linq.Expressions; +using System.Net; +using System.Runtime.CompilerServices; + +namespace ArangoDB.Extensions.VectorData; + +public sealed partial class ArangoDynamicCollection( + VectorStore store, + string name, + IServiceProvider serviceProvider +) : VectorStoreCollection> +{ + private bool _disposedValue = false; + + public ArangoDynamicCollection( + VectorStore store, + string name, + VectorStoreCollectionDefinition? definition, + IServiceProvider serviceProvider + ) : this(store, name, serviceProvider) + { + Definition = definition; + } + + public override string Name { get; } = name; + + public VectorStoreCollectionDefinition? Definition { get; } + + public override async Task CollectionExistsAsync(CancellationToken cancellationToken = default) + { + return await store.CollectionExistsAsync(Name, cancellationToken).ConfigureAwait(false); + } + + public override async Task DeleteAsync( + object key, + CancellationToken cancellationToken = default) + { + string id = key.ToString(); + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Key string is null or empty.", nameof(key)); + } + + if (!id.Contains('/')) + { + id = $"{Name}/{id}"; + } + using IArangoDBClient client = GetRequiredService(); + await client.Document + .DeleteDocumentAsync(id, token: cancellationToken) + .ConfigureAwait(false); + } + + public override Task EnsureCollectionDeletedAsync( + CancellationToken cancellationToken = default) + { + return store.EnsureCollectionDeletedAsync( + Name, + cancellationToken); + } + + public override async Task EnsureCollectionExistsAsync( + CancellationToken cancellationToken = default) + { + if (!await CollectionExistsAsync(cancellationToken).ConfigureAwait(false)) + { + PostCollectionBody body = new() + { + Name = Name + }; + using IArangoDBClient client = GetRequiredService(); + await client.Collection + .PostCollectionAsync( + body, + token: cancellationToken) + .ConfigureAwait(false); + } + } + + public override async Task?> GetAsync( + object key, + RecordRetrievalOptions? options = null, + CancellationToken cancellationToken = default) + { + string id = key.ToString(); + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Key string is null or empty.", nameof(key)); + } + + if (!id.Contains('/')) + { + id = $"{Name}/{id}"; + } + + try + { + using IArangoDBClient client = GetRequiredService(); + Dictionary res = await client.Document + .GetDocumentAsync>(id, token: cancellationToken) + .ConfigureAwait(false); + return res; + } + catch (ApiErrorException) + { + return null; + } + } + + public override async IAsyncEnumerable> GetAsync( + Expression, bool>> filter, + int top, + FilteredRecordRetrievalOptions>? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (top <= 0) + { + yield break; + } + + Dictionary bindVars = []; + string whereClause = filter.BuildWhereClause(bindVars); + + string query = string.IsNullOrWhiteSpace(whereClause) + ? $"FOR doc IN {Name} LIMIT @limit RETURN doc" + : $"FOR doc IN {Name} FILTER {whereClause} LIMIT @limit RETURN doc"; + + bindVars["limit"] = top; + + using IArangoDBClient client = GetRequiredService(); + CursorResponse> response = await client.Cursor + .PostCursorAsync>( + query, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (Dictionary record in response.Result) + { + yield return record; + } + } + + public override object? GetService( + Type serviceType, + object? serviceKey = null) + { + if (serviceKey is null) + { + return serviceProvider.GetService(serviceType); + } + try + { + return serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + catch (InvalidOperationException) + { + return null; + } + } + + public override async IAsyncEnumerable>> SearchAsync( + TInput searchValue, + int top, + VectorSearchOptions>? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (top <= 0) + { + yield break; + } + + using IArangoDBClient client = GetRequiredService(); + IEmbeddingGenerator>? generator + = GetService>>() + ?? throw new InvalidOperationException("Vector search requires options.EmbeddingGenerator implementing IEmbeddingGenerator>."); + + if (Definition is { EmbeddingGenerator: null }) + { + Definition.EmbeddingGenerator = generator; + } + + Embedding embedding = await generator + .GenerateAsync( + searchValue, + new EmbeddingGenerationOptions + { + + }, + cancellationToken) + .ConfigureAwait(false); + float[] vector = embedding.Vector.Span.ToArray(); + + // Build optional filter clause from options.Filter + Dictionary bindVars = new() + { + ["queryVec"] = vector, + ["limit"] = top + }; + if (options?.Skip is int skip && skip > 0) + { + bindVars["skip"] = skip; + } + string filterClause = options?.Filter?.BuildFilterClause(bindVars) + ?? string.Empty; + + // LIMIT clause supports paging with skip + string limitClause = bindVars.ContainsKey("skip") + ? "LIMIT @skip, @limit" + : "LIMIT @limit"; + + // Resolve vector field path + string? vectorFieldPath = options?.IncludeVectors == true && options.VectorProperty is LambdaExpression vecExpr + ? vecExpr.BuildMemberAccessPath() + : null; + string? similarityTarget = string.IsNullOrWhiteSpace(vectorFieldPath) + ? null + : vectorFieldPath; + + // If caller requested vectors, return the whole document (including vectors). + string docProjectionExpr = options?.IncludeVectors == true + ? "doc" + : ProcessProjectionexpression(vectorFieldPath); + + string aql = $"FOR doc IN {Name}{filterClause} LET score = COSINE_SIMILARITY({similarityTarget}, @queryVec) SORT score DESC {limitClause} RETURN {{ doc: {docProjectionExpr}, score: score }}"; + + CursorResponse>> response = await client.Cursor + .PostCursorAsync>>( + aql, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (CursorRow> row in response.Result) + { + Dictionary? rec = row.Doc; + double score = row.Score; + if (rec is not null) + { + yield return new VectorSearchResult>(rec, score); + } + } + } + + public override async Task UpsertAsync( + Dictionary record, + CancellationToken cancellationToken = default) + { + IArangoDBClient client = GetRequiredService(); + try + { + await client.Document + .PutDocumentAsync(Name, record, token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex.ApiError?.Code == HttpStatusCode.NotFound) + { + await client.Document + .PostDocumentAsync(Name, record, token: cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) + { + throw; + } + finally + { + client.Dispose(); + } + } + + public override async Task UpsertAsync( + IEnumerable> records, + CancellationToken cancellationToken = default) + { + using IArangoDBClient client = GetRequiredService(); + try + { + await client.Document + .PutDocumentsAsync(Name, records, token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex.ApiError?.Code == HttpStatusCode.NotFound) + { + await client.Document + .PostDocumentsAsync(Name, records, token: cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) + { + throw; + } + finally + { + client.Dispose(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (_disposedValue) + { + // If already disposed, do nothing + return; + } + + if (!disposing) + { + // Dispose unmanaged resources here if any + } + + // Dispose managed resources here if any + store.Dispose(); + _disposedValue = true; + } + + private static string ProcessProjectionexpression(string? vectorFieldPath) + { + string docProjectionExpr; + string vectorName = vectorFieldPath ?? "embedding"; + + string[] props = typeof(Dictionary) + .GetPublicPropertyNamesExcluding(vectorName); + + if (props.Length == 0) + { + // If no properties found (or all excluded), return an empty object + docProjectionExpr = "{}"; + } + else + { + // Build AQL object projection: { prop1: doc.prop1, prop2: doc.prop2, ... } + string projection = string.Join( + ", ", + props.Select(p => $"{p}: doc.{p}") + ); + docProjectionExpr = $"{{ {projection} }}"; + } + + return docProjectionExpr; + } + + private T? GetService( + object? serviceKey = null + ) + { + return serviceKey is null + ? serviceProvider.GetService() + : serviceProvider.GetKeyedService(serviceKey); + } + + private T GetRequiredService( + object? serviceKey = null + ) where T : notnull + { + return serviceKey is null + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey); + } +} diff --git a/ArangoDB.Extensions.VectorData/ArangoHybridSearchable.cs b/ArangoDB.Extensions.VectorData/ArangoHybridSearchable.cs new file mode 100644 index 00000000..6bff5cdc --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoHybridSearchable.cs @@ -0,0 +1,217 @@ +using ArangoDB.Extensions.VectorData.Helpers; +using ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +using ArangoDBNetStandard; +using ArangoDBNetStandard.CursorApi.Models; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +using System.Linq.Expressions; +using System.Runtime.CompilerServices; + +namespace ArangoDB.Extensions.VectorData; + +public sealed class ArangoHybridSearchable( + string name, + IServiceProvider serviceProvider +) : IKeywordHybridSearchable +{ + public ArangoHybridSearchable( + string name, + VectorStoreCollectionDefinition definition, + IServiceProvider serviceProvider + ) : this(name, serviceProvider) + { + Definition = definition; + } + + public string Name { get; } = name; + + public VectorStoreCollectionDefinition? Definition { get; } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null) + { + return serviceProvider.GetService(serviceType); + } + try + { + return serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + public async IAsyncEnumerable> HybridSearchAsync( + TInput searchValue, + ICollection keywords, + int top, + HybridSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) where TInput : notnull + { + if (top <= 0) + { + yield break; + } + + // Validate that VectorProperty is provided - hybrid search requires vector search + if (options?.VectorProperty is not Expression> vectorProperty) + { + throw new ArgumentNullException(nameof(options.VectorProperty), "VectorProperty must be specified for hybrid search operations."); + } + + // Validate that AdditionalProperty is provided - hybrid search requires keyword search field + if (options?.AdditionalProperty is not Expression> keywordProperty) + { + throw new ArgumentNullException(nameof(options.AdditionalProperty), "AdditionalProperty must be specified for hybrid search operations to identify the keyword search field."); + } + + using IArangoDBClient client = GetRequiredService(); + + // First try to get embedding generator from definition, then from DI + IEmbeddingGenerator>? generator = + Definition?.EmbeddingGenerator as IEmbeddingGenerator> + ?? GetService>>() + ?? throw new InvalidOperationException("Hybrid search requires options.EmbeddingGenerator implementing IEmbeddingGenerator>."); + + // Generate embedding for vector search + Embedding embedding = await generator + .GenerateAsync( + searchValue, + new EmbeddingGenerationOptions + { + + }, + cancellationToken) + .ConfigureAwait(false); + float[] vector = embedding.Vector.Span.ToArray(); + + // Build optional filter clause from options.Filter + Dictionary bindVars = new() + { + ["vector"] = vector, + ["limit"] = top, + ["keywords"] = keywords.ToArray() + }; + if (options?.Skip is int skip && skip > 0) + { + bindVars["skip"] = skip; + } + + string filterClause = options?.Filter?.BuildFilterClause(bindVars) + ?? string.Empty; + + // LIMIT clause supports paging with skip + string limitClause = bindVars.ContainsKey("skip") + ? "LIMIT @skip, @limit" + : "LIMIT @limit"; + + // Resolve vector field path + string? vectorFieldPath = vectorProperty is LambdaExpression vecExpr + ? vecExpr.BuildMemberAccessPath() + : null; + string? similarityTarget = string.IsNullOrWhiteSpace(vectorFieldPath) + ? "doc.embedding" // Default vector field name + : vectorFieldPath; + + // Resolve keyword search field path + string? keywordFieldPath = keywordProperty is LambdaExpression keywordExpr + ? keywordExpr.BuildMemberAccessPath() + : null; + string keywordTarget = string.IsNullOrWhiteSpace(keywordFieldPath) + ? "doc.text" // Default text field name + : $"doc.{keywordFieldPath}"; + + // Build keyword search clause using the specified field + string keywordClause = string.Empty; + keywordClause = keywords.Count > 0 + ? $@"LET keywordScore = {keywordTarget} IN @keywords ? 1 : 0" + : "LET keywordScore = 0"; + + // If caller requested vectors, return the whole document (including vectors). + string docProjectionExpr = options?.IncludeVectors == true + ? "doc" + : ProcessProjectionExpression(vectorFieldPath); + + // Combine vector similarity score with keyword score + // Weight the scores - you can adjust these weights based on your needs + double vectorWeight = 0.7; // 70% weight for vector similarity + double keywordWeight = 0.3; // 30% weight for keyword matching + + string aql = $@"FOR doc IN {Name}{filterClause} + LET vectorScore = COSINE_SIMILARITY({similarityTarget}, @vector) + {keywordClause} + LET hybridScore = ({vectorWeight} * vectorScore) + ({keywordWeight} * keywordScore) + SORT hybridScore DESC + {limitClause} + RETURN {{ doc: {docProjectionExpr}, score: hybridScore, vectorScore: vectorScore, keywordScore: keywordScore }}"; + + CursorResponse> response = await client.Cursor + .PostCursorAsync>( + aql, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (HybridSearchRow row in response.Result) + { + TRecord? rec = row.Doc; + double score = row.Score; + if (rec is not null) + { + yield return new VectorSearchResult(rec, score); + } + } + } + + private T GetRequiredService( + object? serviceKey = null + ) where T : notnull + { + return serviceKey is null + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey); + } + + private T? GetService( + object? serviceKey = null + ) + { + return serviceKey is null + ? serviceProvider.GetService() + : serviceProvider.GetKeyedService(serviceKey); + } + + private static string ProcessProjectionExpression(string? vectorFieldPath) + { + string docProjectionExpr; + string vectorName = vectorFieldPath ?? "embedding"; + + string[] props = typeof(TRecord) + .GetPublicPropertyNamesExcluding(vectorName); + + if (props.Length == 0) + { + // If no properties found (or all excluded), return an empty object + docProjectionExpr = "{}"; + } + else + { + // Build AQL object projection: { prop1: doc.prop1, prop2: doc.prop2, ... } + string projection = string.Join( + ", ", + props.Select(p => $"{p}: doc.{p}") + ); + docProjectionExpr = $"{{ {projection} }}"; + } + + return docProjectionExpr; + } +} \ No newline at end of file diff --git a/ArangoDB.Extensions.VectorData/ArangoVectorSearchable.cs b/ArangoDB.Extensions.VectorData/ArangoVectorSearchable.cs new file mode 100644 index 00000000..04fac76a --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoVectorSearchable.cs @@ -0,0 +1,182 @@ +using ArangoDB.Extensions.VectorData.Helpers; +using ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +using ArangoDBNetStandard; +using ArangoDBNetStandard.CursorApi.Models; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +using System.Linq.Expressions; +using System.Runtime.CompilerServices; + +namespace ArangoDB.Extensions.VectorData; + +public sealed class ArangoVectorSearchable( + string name, + IServiceProvider serviceProvider +) : IVectorSearchable +{ + public ArangoVectorSearchable( + string name, + VectorStoreCollectionDefinition definition, + IServiceProvider serviceProvider + ) : this(name, serviceProvider) + { + Definition = definition; + } + + public string Name { get; } = name; + + public VectorStoreCollectionDefinition? Definition { get; } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null) + { + return serviceProvider.GetService(serviceType); + } + try + { + return serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// + public async IAsyncEnumerable> SearchAsync( + TInput searchValue, + int top, + VectorSearchOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) where TInput : notnull + { + if (top <= 0) + { + yield break; + } + + // Validate that VectorProperty is provided - vector search is meaningless without it + if (options?.VectorProperty is not Expression> vectorProperty) + { + throw new ArgumentNullException(nameof(options.VectorProperty), "VectorProperty must be specified for vector search operations."); + } + + using IArangoDBClient client = GetRequiredService(); + + // First try to get embedding generator from definition, then from DI + IEmbeddingGenerator>? generator = + Definition?.EmbeddingGenerator as IEmbeddingGenerator> + ?? GetService>>() + ?? throw new InvalidOperationException("Vector search requires options.EmbeddingGenerator implementing IEmbeddingGenerator>."); + + Embedding embedding = await generator + .GenerateAsync( + searchValue, + new EmbeddingGenerationOptions + { + + }, + cancellationToken) + .ConfigureAwait(false); + float[] vector = embedding.Vector.Span.ToArray(); + + // Build optional filter clause from options.Filter + Dictionary bindVars = new() + { + ["vector"] = vector, + ["limit"] = top + }; + if (options?.Skip is int skip && skip > 0) + { + bindVars["skip"] = skip; + } + string filterClause = options?.Filter?.BuildFilterClause(bindVars) + ?? string.Empty; + + // LIMIT clause supports paging with skip + string limitClause = bindVars.ContainsKey("skip") + ? "LIMIT @skip, @limit" + : "LIMIT @limit"; + + // Resolve vector field path + string? vectorFieldPath = vectorProperty is LambdaExpression vecExpr + ? vecExpr.BuildMemberAccessPath() + : null; + string? similarityTarget = string.IsNullOrWhiteSpace(vectorFieldPath) + ? "doc.embedding" // Default vector field name + : vectorFieldPath; + + // If caller requested vectors, return the whole document (including vectors). + string docProjectionExpr = options?.IncludeVectors == true + ? "doc" + : ProcessProjectionexpression(vectorFieldPath); + + string aql = $"FOR doc IN {Name}{filterClause} LET score = COSINE_SIMILARITY({similarityTarget}, @vector) SORT score DESC {limitClause} RETURN {{ doc: {docProjectionExpr}, score: score }}"; + + CursorResponse> response = await client.Cursor + .PostCursorAsync>( + aql, + bindVars, + token: cancellationToken) + .ConfigureAwait(false); + + foreach (CursorRow row in response.Result) + { + TRecord? rec = row.Doc; + double score = row.Score; + if (rec is not null) + { + yield return new VectorSearchResult(rec, score); + } + } + } + + private T GetRequiredService( + object? serviceKey = null + ) where T : notnull + { + return serviceKey is null + ? serviceProvider.GetRequiredService() + : serviceProvider.GetRequiredKeyedService(serviceKey); + } + + private T? GetService( + object? serviceKey = null + ) + { + return serviceKey is null + ? serviceProvider.GetService() + : serviceProvider.GetKeyedService(serviceKey); + } + + private static string ProcessProjectionexpression(string? vectorFieldPath) + { + string docProjectionExpr; + string vectorName = vectorFieldPath ?? "embedding"; + + string[] props = typeof(TRecord) + .GetPublicPropertyNamesExcluding(vectorName); + + if (props.Length == 0) + { + // If no properties found (or all excluded), return an empty object + docProjectionExpr = "{}"; + } + else + { + // Build AQL object projection: { prop1: doc.prop1, prop2: doc.prop2, ... } + string projection = string.Join( + ", ", + props.Select(p => $"{p}: doc.{p}") + ); + docProjectionExpr = $"{{ {projection} }}"; + } + + return docProjectionExpr; + } +} diff --git a/ArangoDB.Extensions.VectorData/ArangoVectorStore.cs b/ArangoDB.Extensions.VectorData/ArangoVectorStore.cs new file mode 100644 index 00000000..a3c8635f --- /dev/null +++ b/ArangoDB.Extensions.VectorData/ArangoVectorStore.cs @@ -0,0 +1,138 @@ +using ArangoDBNetStandard; +using ArangoDBNetStandard.CollectionApi.Models; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.CompilerServices; + +namespace ArangoDB.Extensions.VectorData; + +public class ArangoVectorStore( + IArangoDBClient client, + IServiceProvider serviceProvider +) : VectorStore +{ + private bool _disposedValue; + + public override async Task CollectionExistsAsync( + string name, + CancellationToken cancellationToken = default) + { + try + { + GetCollectionResponse getCollectionResponse = await client.Collection + .GetCollectionAsync(name, cancellationToken); + return getCollectionResponse.Name == name; + } + catch (ApiErrorException ex) when (ex.ApiError?.Code == HttpStatusCode.NotFound) + { + return false; + } + catch (ApiErrorException ex) when (ex.ApiError?.Code != HttpStatusCode.NotFound) + { + throw; + } + catch (Exception) + { + throw; + } + } + + public override async Task EnsureCollectionDeletedAsync( + string name, + CancellationToken cancellationToken = default) + { + try + { + await client.Collection + .DeleteCollectionAsync( + name, + token: cancellationToken) + .ConfigureAwait(false); + } + catch (ApiErrorException ex) when (ex is { ApiError.Code: HttpStatusCode.NotFound }) + { + // Ignore if doesn't exist + } + catch (Exception) + { + throw; + } + } + + public override VectorStoreCollection GetCollection( + string name, + VectorStoreCollectionDefinition? definition = null) + { + return new ArangoCollection( + this, + name, + definition, + serviceProvider); + } + + public override VectorStoreCollection> GetDynamicCollection( + string name, + VectorStoreCollectionDefinition definition) + { + return new ArangoDynamicCollection( + this, + name, + definition, + serviceProvider); + } + + public override object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null + || (serviceKey is string serviceKeyString + && string.IsNullOrWhiteSpace(serviceKeyString))) + { + return serviceProvider.GetService(serviceType); + } + try + { + return serviceProvider.GetRequiredKeyedService(serviceType, serviceKey); + } + catch (InvalidOperationException) + { + return null; + } + } + + public override async IAsyncEnumerable ListCollectionNamesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + GetCollectionsResponse response = await client.Collection + .GetCollectionsAsync(token: cancellationToken) + .ConfigureAwait(false); + foreach (GetCollectionsResponseResult col in response.Result) + { + yield return col.Name; + } + } + + [ExcludeFromCodeCoverage] + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (_disposedValue) + { + // If already disposed, do nothing + return; + } + + if (!disposing) + { + // Dispose unmanaged resources here if any + } + + // Dispose unmanaged resources here if any + client.Dispose(); + + _disposedValue = true; + } +} diff --git a/ArangoDB.Extensions.VectorData/CursorRow.cs b/ArangoDB.Extensions.VectorData/CursorRow.cs new file mode 100644 index 00000000..b08d8890 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/CursorRow.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData; + +// Cursor row type matching the AQL RETURN {{ doc: , score: score }} +public class CursorRow +{ + [JsonPropertyName("doc")] + public TRecord? Doc { get; set; } + + [JsonPropertyName("score")] + public double Score { get; set; } +} diff --git a/ArangoDB.Extensions.VectorData/DependencyRegistration.cs b/ArangoDB.Extensions.VectorData/DependencyRegistration.cs new file mode 100644 index 00000000..5cfa2c41 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/DependencyRegistration.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.VectorData; + +namespace ArangoDB.Extensions.VectorData; + +public static class DependencyRegistration +{ + public static IServiceCollection AddArangoVectorDatabase( + this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(typeof(IVectorSearchable<>), typeof(ArangoVectorSearchable<>)); + services.AddScoped(typeof(IKeywordHybridSearchable<>), typeof(ArangoHybridSearchable<>)); + return services; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/AqlFilters.cs b/ArangoDB.Extensions.VectorData/Helpers/AqlFilters.cs new file mode 100644 index 00000000..8f4a9f94 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/AqlFilters.cs @@ -0,0 +1,35 @@ +namespace ArangoDB.Extensions.VectorData.Helpers; + +public static class AqlFilters +{ + public static bool Like(this string str, string otherString) + { + throw new NotImplementedException(); + } + + + public static bool Like( + this string str, + string otherString, + AqlLikeWildcardPositions wildcardPosition) + { + throw new NotImplementedException(); + } + + public static bool Like( + this string str, + string otherString, + StringComparison stringComparison) + { + throw new NotImplementedException(); + } + + public static bool Like( + this string str, + string otherString, + StringComparison stringComparison, + AqlLikeWildcardPositions wildcardPosition) + { + throw new NotImplementedException(); + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/AqlLikeWildcardPositions.cs b/ArangoDB.Extensions.VectorData/Helpers/AqlLikeWildcardPositions.cs new file mode 100644 index 00000000..2fe910c5 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/AqlLikeWildcardPositions.cs @@ -0,0 +1,8 @@ +namespace ArangoDB.Extensions.VectorData.Helpers; + +public enum AqlLikeWildcardPositions : byte +{ + Both = 0 << 1, + Start = 1 << 1, + End = 2 << 1, +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/AqlParameterizedQueryHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/AqlParameterizedQueryHelpers.cs new file mode 100644 index 00000000..3a69859f --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/AqlParameterizedQueryHelpers.cs @@ -0,0 +1,13 @@ +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class AqlParameterizedQueryHelpers +{ + public static string AddBindVar( + this Dictionary bindVars, + object? value) + { + string name = $"p{bindVars.Count}"; + bindVars[name] = value!; + return $"@{name}"; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/FilterRetrievalOptions.cs b/ArangoDB.Extensions.VectorData/Helpers/FilterRetrievalOptions.cs new file mode 100644 index 00000000..24043684 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/FilterRetrievalOptions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.VectorData; + +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class FilterRetrievalOptions +{ + public static string BuildSortOrder( + this FilteredRecordRetrievalOptions.OrderByDefinition.SortInfo info + ) where TRecord : class + { + return info.Ascending ? "ASC" : "DESC"; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/KeyHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/KeyHelpers.cs new file mode 100644 index 00000000..f58d0892 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/KeyHelpers.cs @@ -0,0 +1,50 @@ +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class KeyHelpers +{ + public static string SanitizeKeyAndGetId(this TKey key, string collectionName) + where TKey : notnull + { + if (key is not string keyString) + { + keyString = key.ToString(); + } + if (string.IsNullOrWhiteSpace(keyString)) + { + throw new ArgumentException("Key can't be null or empty.", nameof(key)); + } + else if (!keyString.Contains('/')) + { + string trimmedKey = keyString.Trim(); + return trimmedKey.Contains(" ") + ? throw new FormatException("Key cannot contain spaces.") + : $"{collectionName}/{trimmedKey}"; + } + else if (keyString.StartsWith("/")) + { + return $"{collectionName}{keyString}"; + } + else if (keyString.EndsWith("/")) + { + throw new FormatException("Key string cannot end with a slash."); + } + + ReadOnlyMemory keyParts = keyString.Trim().Split('/').AsMemory(); + if (keyParts.Length > 2) + { + throw new FormatException("The 'Key' can either be the document key or the fully qualified id (CollectionName/DocumentKey)."); + } + else if (IsCollectionNameMismatch(collectionName, keyParts)) + { + throw new NotSupportedException("A document from another collection can't be accessed."); + } + + return keyString.Trim(); + } + + private static bool IsCollectionNameMismatch(string collectionName, ReadOnlyMemory keyParts) + { + return keyParts.Length == 2 + && !string.Equals(keyParts.Span[0], collectionName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/BinaryExpressionHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/BinaryExpressionHelpers.cs new file mode 100644 index 00000000..fab86a29 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/BinaryExpressionHelpers.cs @@ -0,0 +1,35 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +internal static class BinaryExpressionHelpers +{ + public static string BuildBinary( + this BinaryExpression be, + Dictionary bindVars) + { + string op = be.NodeType switch + { + ExpressionType.Equal => "==", + ExpressionType.NotEqual => "!=", + ExpressionType.GreaterThan => ">", + ExpressionType.GreaterThanOrEqual => ">=", + ExpressionType.LessThan => "<", + ExpressionType.LessThanOrEqual => "<=", + ExpressionType.AndAlso => "&&", + ExpressionType.OrElse => "||", + _ => throw new NotSupportedException($"Unsupported binary operator: {be.NodeType}") + }; + + if (be.NodeType == ExpressionType.AndAlso || be.NodeType == ExpressionType.OrElse) + { + string leftLogic = be.Left.BuildWhereClause(bindVars); + string rightLogic = be.Right.BuildWhereClause(bindVars); + return $"({leftLogic}) {op} ({rightLogic})"; + } + + string left = be.Left.BuildOperand(bindVars); + string right = be.Right.BuildOperand(bindVars); + return $"({left} {op} {right})"; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/ExpressionHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/ExpressionHelpers.cs new file mode 100644 index 00000000..06f2d2d6 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/ExpressionHelpers.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using System.Numerics; +using System.Text.Json; + +namespace ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +internal static class ExpressionHelpers +{ + public static string BuildWhereClause( + this Expression expression, + Dictionary bindVars) + { + return expression switch + { + BinaryExpression be => be.BuildBinary(bindVars), + UnaryExpression ue when ue.NodeType == ExpressionType.Not + => $"NOT ({ue.Operand.BuildWhereClause(bindVars)})", + MemberExpression me => me.BuildMemberAccess(), + ConstantExpression ce => bindVars.AddBindVar(ce.Value), + MethodCallExpression mce => mce.HandleOperationInFilterCondition(bindVars), + _ => throw new NotSupportedException($"Unsupported expression: {expression.NodeType}") + }; + } + + public static string BuildOperand( + this Expression expr, + Dictionary bindVars, + MethodCallExpression? mce = null, + AqlLikeWildcardPositions? wildcardPosition = null) + { + switch (expr) + { + case MemberExpression me: + return me.BuildMemberAccess(); + case ConstantExpression ce + when ce.Value is List strings: + return bindVars.AddBindVar($"[{string.Join(", ", strings.Select(s => $"\"{s}\""))}]"); + case ConstantExpression ce + when ce.Value is IEnumerable strings: + return bindVars.AddBindVar($"[{string.Join(", ", strings.Select(s => $"\"{s}\""))}]"); + case ConstantExpression ce + when ce.Value is IEnumerable or IEnumerable or IEnumerable or IEnumerable or IEnumerable: + return bindVars.AddBindVar(JsonSerializer.Serialize(ce.Value)); + case ConstantExpression ce + when mce is not null + && wildcardPosition is null or AqlLikeWildcardPositions.Both: + return bindVars.AddBindVar($"%{ce.Value}%"); + case ConstantExpression ce + when mce is not null + && wildcardPosition is AqlLikeWildcardPositions.Start: + return bindVars.AddBindVar($"%{ce.Value}"); + case ConstantExpression ce + when mce is not null + && wildcardPosition is AqlLikeWildcardPositions.End: + return bindVars.AddBindVar($"{ce.Value}%"); + case ConstantExpression ce: + return bindVars.AddBindVar(ce.Value); + default: + object value = Expression + .Lambda(expr) + .Compile() + .DynamicInvoke(); + return bindVars.AddBindVar(value); + } + } + + public static string BuildOperand( + this Expression expr, + Dictionary bindVars) + { + string value = expr.Compile(); + return bindVars.AddBindVar(value); + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/LambdaExpressionHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/LambdaExpressionHelpers.cs new file mode 100644 index 00000000..ad93d424 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/LambdaExpressionHelpers.cs @@ -0,0 +1,97 @@ +using System.Linq.Expressions; +using System.Numerics; + +namespace ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +internal static class LambdaExpressionHelpers +{ + public static string? BuildMemberAccessPath( + this LambdaExpression? lambda) + { + return lambda is null + ? null + : lambda.Body is MemberExpression memberExpression + ? memberExpression.BuildMemberAccess() + : null; + } + + public static string BuildFilterClause( + this LambdaExpression? lambda, + Dictionary bindVars) + { + if (lambda is null) + { + return string.Empty; + } + + string where = lambda.BuildWhereClause(bindVars); + return string.IsNullOrWhiteSpace(where) ? string.Empty : $" FILTER {where}"; + } + + public static string? BuildTopLevelMemberName( + this LambdaExpression? lambda) + { + if (lambda is null) + { + return null; + } + + Expression current = lambda.Body; + string? topLevel = null; + while (current is MemberExpression m) + { + // If the parent is the parameter (e.g., doc), this member is the top-level field + if (m.Expression is ParameterExpression) + { + topLevel = m.Member.Name; + break; + } + current = m.Expression!; + } + return topLevel; + } + + public static string BuildWhereClause( + this LambdaExpression lambda, + Dictionary bindVars) + { + return lambda.Body.BuildWhereClause(bindVars); + } + + public static string BuildOrderByClause( + this LambdaExpression lambda, + Dictionary bindVars) + { + return lambda.Body.BuildWhereClause(bindVars); + } + + public static string BuildOperand( + this LambdaExpression expr, + Dictionary bindVars) + { + object result = expr.Compile().DynamicInvoke(); + return result switch + { + string str => bindVars.AddBindVar(str), + int number => bindVars.AddBindVar(number), + float single => bindVars.AddBindVar(single), + double dbl => bindVars.AddBindVar(dbl), + decimal dec => bindVars.AddBindVar(dec), + BigInteger bigInt => bindVars.AddBindVar(bigInt), + List strings => bindVars.AddBindVar(strings), + List ints => bindVars.AddBindVar(ints), + List floats => bindVars.AddBindVar(floats), + List doubles => bindVars.AddBindVar(doubles), + List decimals => bindVars.AddBindVar(decimals), + List bigInts => bindVars.AddBindVar(bigInts), + IEnumerable strings => bindVars.AddBindVar(strings.ToList()), + IEnumerable ints => bindVars.AddBindVar(ints.ToList()), + IEnumerable floats => bindVars.AddBindVar(floats.ToList()), + IEnumerable doubles => bindVars.AddBindVar(doubles.ToList()), + IEnumerable decimals => bindVars.AddBindVar(decimals.ToList()), + IEnumerable bigInts => bindVars.AddBindVar(bigInts.ToList()), + _ => throw new NotSupportedException($"Unsupported expression: {expr.NodeType}") + }; + + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MemberExpressionHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MemberExpressionHelpers.cs new file mode 100644 index 00000000..bf9feaae --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MemberExpressionHelpers.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; + +namespace ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +internal static class MemberExpressionHelpers +{ + public static string BuildMemberAccess( + this MemberExpression me) + { + Stack parts = new(); + Expression? current = me; + while (current is MemberExpression m) + { + parts.Push(m.Member.Name); + current = m.Expression; + } + return $"doc.{string.Join(".", parts)}"; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MethodCallExpressionHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MethodCallExpressionHelpers.cs new file mode 100644 index 00000000..19ef6833 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/LinqExpressionHelpers/MethodCallExpressionHelpers.cs @@ -0,0 +1,296 @@ +using System.Linq.Expressions; +using System.Numerics; + +namespace ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +internal static class MethodCallExpressionHelpers +{ + /// + /// Handles Method Calls in the lambda expression in order to generate filter condition in the AQL. + /// here is where T is any type and TResult is . + /// It can handle the following methods: + /// + /// + ///

Equals Operator

+ /// + /// Expression<Func<TestRecord, bool>> filter = r => r.Name == "test"; + /// Produces: FILTER doc.Name == @param1 + /// + ///
+ /// + ///

Method

+ /// + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Equals("test"); + /// Produces: FILTER doc.Name == @param1 + /// + ///
+ /// + ///

+ /// , , and Extension Methods + ///

+ /// + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Like("test", , ); + /// Produces: FILTER LIKE (doc.Name, @param1). (When is not provided or , , ) + /// Produces: FILTER LIKE (doc.Name, @param1, true). (When is provided as , , ) + /// param1 would be enclose with wildcards at the both side like %abc% if is used or not used at all. + /// Otherwise it will use %abc for or abc% for + /// + ///
+ /// + ///

+ /// Method + ///
+ /// Method + ///

+ /// + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Contains("test"); + /// Produces: FILTER CONTAINS(doc.Name, @param1) + /// + /// + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Contains("test", ); + /// Produces: FILTER CONTAINS(LOWER(doc.Name), LOWER(@param1)) + /// + /// + /// filterText = "test"; + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Contains(filterText); + /// Produces: FILTER CONTAINS(doc.Name, @param1) + /// + /// + /// filterText = "test"; + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Contains(filterText, ); + /// Produces: FILTER CONTAINS(LOWER(doc.Name), LOWER(@param1)) + /// + ///
+ /// + ///

Method

+ /// + /// filterTexts = ["test"]; + /// Expression<Func<TestRecord, bool>> filter = r => filterTexts.Contains(r.Name); + /// Produces: FILTER doc.Name in @param1 + /// Example: FILTER doc.Name in ["test"] + ///
+ /// Note: supports , , , , and . + ///
+ ///
+ /// + ///

Extension Method

+ /// + /// filterTexts = ["test"]; + /// Expression<Func<TestRecord, bool>> filter = r => r.Name.Contains(filterText, ); + /// Produces: FILTER doc.Name in @param1 + /// Example: FILTER doc.Name in ["test"] + ///
+ /// Note: supports , , , , and . + ///
+ ///
+ ///
+ ///
+ /// + /// + /// + public static string HandleOperationInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars) + { + return mce switch + { + // r => r.Name.Like("test") Extension Method + { Method.Name: nameof(AqlFilters.Like), Arguments.Count: 2 } + => mce.HandleLikeOperationInFilterCondition(bindVars), + + // IEnumerable list = [ "test" ]; + // r => list.Contains(r.Name) Extension Method + { Method.Name: nameof(Enumerable.Contains) } + when mce is { Arguments.Count: 2 } + && mce.Arguments[0] is MemberExpression + && mce.Arguments[1] is MemberExpression + && mce.Method.IsStatic + => mce.HandleCollectionContainsOperationInFilterCondition(bindVars), + + // List list = [ "test" ]; + // r => list.Contains(r.Name) Instance Method + { Method.Name: nameof(List<>.Contains) } + when mce is { Object: MemberExpression, Arguments.Count: 1 } + && mce.Arguments[0] is MemberExpression + && (mce.Method.DeclaringType == typeof(List)) + => mce.HandleListContainsOperationInFilterCondition(bindVars), + + // string filterText = "test"; + // r => r.Name.Contains(filterText) Instance Method and when param is a lambda expression + { Method.Name: nameof(string.Contains) } + when mce is { Object: MemberExpression, Arguments.Count: 1 } + && mce.Arguments[0] is MemberExpression me + && Expression.Lambda(me) is LambdaExpression lambdaParam + => mce.HandleStringContainsOperationInFilterCondition(bindVars, lambdaParam), + + // r => r.Name.Contains("test") Instance Method and when param is a constant expression + { Method.Name: nameof(string.Contains) } + when mce is { Object: MemberExpression, Arguments.Count: 1 } + && mce.Arguments[0] is ConstantExpression + => mce.HandleStringContainsOperationInFilterCondition(bindVars), + + // string filterText = "test"; + // r => r.Name.Equals(filterText) Instance Method and when param is a lambda expression + { Method.Name: nameof(string.Contains) } + when mce is { Object: MemberExpression, Arguments.Count: 1 } + && mce.Arguments[0] is MemberExpression me + && Expression.Lambda(me) is LambdaExpression lambdaParam + => mce.HandleEqualsOperationsInFilterCondition(bindVars, lambdaParam), + + // r => r.Name.Equals("test") Instance Method and when param is a constant expression + { Method.Name: nameof(string.Equals) } + => mce.HandleEqualsOperationsInFilterCondition(bindVars), + _ => string.Empty + }; + } + + public static string HandleLikeOperationInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars) + { + return mce switch + { + // r => r.Name.Like("test") + { Arguments.Count: 2 } + when mce.Arguments[0] is MemberExpression memberExpr + && mce.Arguments[1] is ConstantExpression valueExpr + => $"LIKE ({memberExpr.BuildMemberAccess()}, {valueExpr.BuildOperand(bindVars, mce)})", + // r => r.Name.Like("test", StringComparison.OrdinalIgnoreCase) + { Arguments.Count: 3 } + when mce.Arguments[0] is MemberExpression memberExpr + && mce.Arguments[1] is ConstantExpression valueExpr + && mce.Arguments[2] is ConstantExpression comparisonExpression + && comparisonExpression.Value is StringComparison stringComparison + => $"LIKE ({memberExpr.BuildMemberAccess()}, {valueExpr.BuildOperand(bindVars, mce)}, {stringComparison.GetComparisonOptionsForLike()})", + // r => r.Name.Like("test", AqlLikeWildcardPositions.Both) + { Arguments.Count: 3 } + when mce.Arguments[0] is MemberExpression memberExpr + && mce.Arguments[1] is ConstantExpression valueExpr + && mce.Arguments[2] is ConstantExpression wildcardExpression + && wildcardExpression.Value is AqlLikeWildcardPositions wildcardPosition + => $"LIKE ({memberExpr.BuildMemberAccess()}, {valueExpr.BuildOperand(bindVars, mce, wildcardPosition)}, true)", + // r => r.Name.Like("test", StringComparison.OrdinalIgnoreCase, AqlLikeWildcardPositions.Both) + { Arguments.Count: 4 } + when mce.Arguments[0] is MemberExpression memberExpr + && mce.Arguments[1] is ConstantExpression valueExpr + && mce.Arguments[2] is ConstantExpression comparisonExpression + && mce.Arguments[3] is ConstantExpression wildcardExpression + && comparisonExpression.Value is StringComparison stringComparison + && wildcardExpression.Value is AqlLikeWildcardPositions wildcardPosition + => $"LIKE ({memberExpr.BuildMemberAccess()}, {valueExpr.BuildOperand(bindVars, mce, wildcardPosition)}, {stringComparison.GetComparisonOptionsForLike()})", + _ => throw new NotSupportedException($"Unsupported expression: {mce.NodeType}") + }; + } + + public static string HandleStringContainsOperationInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars, + LambdaExpression? lambdaParam = null) + { + return mce switch + { + // r => r.Name.Contains("test") + { Object: MemberExpression memberExpr, Arguments.Count: 1 } + when mce.Arguments[0] is ConstantExpression ce + => $"CONTAINS ({memberExpr.BuildMemberAccess()}, {ce.BuildOperand(bindVars)})", + + // r => r.Name.Contains("test") + { Object: MemberExpression memberExpr, Arguments.Count: 2 } + when mce.Arguments[0] is ConstantExpression constantExpression + && mce.Arguments[1] is ConstantExpression comparisonExpression + && comparisonExpression.Value is StringComparison stringComparison + && string.IsNullOrWhiteSpace(stringComparison.GetComparisonOptionsForContainsOrEquals()) + => $"CONTAINS ({memberExpr.BuildMemberAccess()}, {constantExpression.BuildOperand(bindVars)})", + + // r => r.Name.Contains("test", STringComparison.OrdinalIgnoreCase) + { Object: MemberExpression memberExpr, Arguments.Count: 2 } + when mce.Arguments[0] is ConstantExpression constantExpression + && mce.Arguments[1] is ConstantExpression comparisonExpression + && comparisonExpression.Value is StringComparison stringComparison + && stringComparison.GetComparisonOptionsForContainsOrEquals() is string stringComparisonOp + => $"CONTAINS ({stringComparisonOp}({memberExpr.BuildMemberAccess()}), {stringComparisonOp}({constantExpression.BuildOperand(bindVars)}))", + + // string filterText = "test"; + // r => r.Name.Contains(filterText) + { Object: MemberExpression memberExpr, Arguments.Count: 1 } + when mce.Arguments[0] is MemberExpression + && lambdaParam is not null + => $"CONTAINS ({memberExpr.BuildMemberAccess()}, {lambdaParam.BuildOperand(bindVars)})", + _ => throw new NotSupportedException($"Unsupported expression: {mce.NodeType}") + }; + } + + public static string HandleListContainsOperationInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars) + { + return mce switch + { + // List list = [ "test" ]; + // r => list.Contains(r.Name) + { Object: MemberExpression collectionExpr, Arguments.Count: 1 } + when mce.Arguments[0] is MemberExpression memberExpr + && Expression.Lambda(collectionExpr) is var lambda + && lambda is not null + => $"{memberExpr.BuildMemberAccess()} in {lambda.BuildOperand(bindVars)}", + _ => throw new NotSupportedException($"Unsupported expression: {mce.NodeType}") + }; + } + + public static string HandleCollectionContainsOperationInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars) + { + return mce switch + { + // IEnumerable list = [ "test" ]; + // r => list.Contains(r.Name) + { Arguments.Count: 2 } + when mce.Arguments[0] is MemberExpression collectionExpr + && Expression.Lambda(collectionExpr) is var lambda + && lambda is not null + && mce.Arguments[1] is MemberExpression memberExpr + => $"{memberExpr.BuildMemberAccess()} in {lambda.BuildOperand(bindVars)}", + _ => throw new NotSupportedException($"Unsupported expression: {mce.NodeType}") + }; + } + + public static string HandleEqualsOperationsInFilterCondition( + this MethodCallExpression mce, + Dictionary bindVars, + LambdaExpression? lambdaParam = null) + { + return mce switch + { + // r => r.Name.Equals("test") + { Object: MemberExpression memberExpr, Arguments.Count: 1 } + when mce.Arguments[0] is ConstantExpression constantExpression + => $"{memberExpr.BuildMemberAccess()} == {constantExpression.BuildOperand(bindVars)}", + + // r => r.Name.Equals("test") + { Object: MemberExpression memberExpr, Arguments.Count: 2 } + when mce.Arguments[0] is ConstantExpression constantExpression + && mce.Arguments[1] is ConstantExpression comparisonExpression + && comparisonExpression.Value is StringComparison stringComparison + && string.IsNullOrWhiteSpace(stringComparison.GetComparisonOptionsForContainsOrEquals()) + => $"{memberExpr.BuildMemberAccess()} == {constantExpression.BuildOperand(bindVars)}", + + // r => r.Name.Equals("test", StringComparison.OrdinalIgnoreCase) + { Object: MemberExpression memberExpr, Arguments.Count: 2 } + when mce.Arguments[0] is ConstantExpression constantExpression + && mce.Arguments[1] is ConstantExpression comparisonExpression + && comparisonExpression.Value is StringComparison stringComparison + && stringComparison.GetComparisonOptionsForContainsOrEquals() is string stringComparisonOp + => $"{stringComparisonOp}({memberExpr.BuildMemberAccess()}) == {stringComparisonOp}({constantExpression.BuildOperand(bindVars)})", + + // string filterText = "test"; + // r => r.Name.Equals(filterText) + { Object: MemberExpression memberExpr, Arguments.Count: 1 } + when mce.Arguments[0] is MemberExpression + && lambdaParam is not null + => $"{memberExpr.BuildMemberAccess()} == {lambdaParam.BuildOperand(bindVars)}", + + _ => throw new NotSupportedException($"Unsupported expression: {mce.NodeType}") + }; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/SortOperationHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/SortOperationHelpers.cs new file mode 100644 index 00000000..702d2b4d --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/SortOperationHelpers.cs @@ -0,0 +1,28 @@ +using ArangoDB.Extensions.VectorData.Helpers.LinqExpressionHelpers; + +using Microsoft.Extensions.VectorData; + +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class SortOperationHelpers +{ + public static string BuildOrderByClause( + this FilteredRecordRetrievalOptions.OrderByDefinition? orderByDefinition + ) where TRecord : class + { + if (orderByDefinition is null || orderByDefinition.Values.Count == 0) + { + return string.Empty; + } + + List fields = + [ + ..orderByDefinition.Values + .Select(info => $"{info.PropertySelector.BuildMemberAccessPath()} {info.BuildSortOrder()}") + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => path!) + ]; + string commaSeparatedFields = string.Join(", ", fields); + return commaSeparatedFields; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/StringComparisonHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/StringComparisonHelpers.cs new file mode 100644 index 00000000..52c0bb78 --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/StringComparisonHelpers.cs @@ -0,0 +1,30 @@ +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class StringComparisonHelpers +{ + public static string GetComparisonOptionsForLike( + this StringComparison stringComparison) + { + return stringComparison switch + { + StringComparison.OrdinalIgnoreCase + or StringComparison.CurrentCultureIgnoreCase + or StringComparison.InvariantCultureIgnoreCase + => "true", + _ => "false" + }; + } + + public static string? GetComparisonOptionsForContainsOrEquals( + this StringComparison stringComparison) + { + return stringComparison switch + { + StringComparison.OrdinalIgnoreCase + or StringComparison.CurrentCultureIgnoreCase + or StringComparison.InvariantCultureIgnoreCase + => "LOWER", + _ => null + }; + } +} diff --git a/ArangoDB.Extensions.VectorData/Helpers/TypeHelpers.cs b/ArangoDB.Extensions.VectorData/Helpers/TypeHelpers.cs new file mode 100644 index 00000000..4cc3768c --- /dev/null +++ b/ArangoDB.Extensions.VectorData/Helpers/TypeHelpers.cs @@ -0,0 +1,25 @@ +using System.Reflection; + +namespace ArangoDB.Extensions.VectorData.Helpers; + +internal static class TypeHelpers +{ + public static string[] GetPublicPropertyNamesExcluding( + this Type type, + string? excludePropertyName) + { + if (type is null) + { + return []; + } + + string[] props = [.. type + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => !string.Equals(p.Name, excludePropertyName, StringComparison.OrdinalIgnoreCase)) + .Where(p => !p.GetCustomAttributes(true) + .Any(a => string.Equals(a.GetType().Name, "JsonIgnoreAttribute", StringComparison.Ordinal))) + .Select(p => p.Name)]; + + return props; + } +} diff --git a/ArangoDB.Extensions.VectorData/HybridSearchRow.cs b/ArangoDB.Extensions.VectorData/HybridSearchRow.cs new file mode 100644 index 00000000..543652bd --- /dev/null +++ b/ArangoDB.Extensions.VectorData/HybridSearchRow.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace ArangoDB.Extensions.VectorData; + +// Extended cursor row type for hybrid search results +public class HybridSearchRow +{ + [JsonPropertyName("doc")] + public TRecord? Doc { get; set; } + + [JsonPropertyName("score")] + public double Score { get; set; } + + [JsonPropertyName("vectorScore")] + public double VectorScore { get; set; } + + [JsonPropertyName("keywordScore")] + public double KeywordScore { get; set; } +} diff --git a/ArangoDB.KernelMemory/ArangoDB.KernelMemory.csproj b/ArangoDB.KernelMemory/ArangoDB.KernelMemory.csproj new file mode 100644 index 00000000..a6eabcfc --- /dev/null +++ b/ArangoDB.KernelMemory/ArangoDB.KernelMemory.csproj @@ -0,0 +1,13 @@ + + + + net9.0;net8.0 + + + + + + + + + diff --git a/ArangoDB.KernelMemory/ArangoMemoryDb.cs b/ArangoDB.KernelMemory/ArangoMemoryDb.cs new file mode 100644 index 00000000..92c20857 --- /dev/null +++ b/ArangoDB.KernelMemory/ArangoMemoryDb.cs @@ -0,0 +1,71 @@ +using Microsoft.KernelMemory; +using Microsoft.KernelMemory.MemoryStorage; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ArangoDB.KernelMemory; + +public sealed class ArangoMemoryDb : IMemoryDb +{ + public Task CreateIndexAsync( + string index, + int vectorSize, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task DeleteAsync( + string index, + MemoryRecord record, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task DeleteIndexAsync( + string index, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> GetIndexesAsync( + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetListAsync( + string index, + ICollection filters = null, + int limit = 1, + bool withEmbeddings = false, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable<(MemoryRecord, double)> GetSimilarListAsync( + string index, + string text, + ICollection filters = null, + double minRelevance = 0, + int limit = 1, + bool withEmbeddings = false, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task UpsertAsync( + string index, + MemoryRecord record, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/ArangoDB.KernelMemory/ArangoSearchClient.cs b/ArangoDB.KernelMemory/ArangoSearchClient.cs new file mode 100644 index 00000000..148e60e6 --- /dev/null +++ b/ArangoDB.KernelMemory/ArangoSearchClient.cs @@ -0,0 +1,33 @@ +using Microsoft.KernelMemory; +using Microsoft.KernelMemory.Context; +using Microsoft.KernelMemory.Search; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ArangoDB.KernelMemory; + +public sealed class ArangoSearchClient : ISearchClient +{ + public Task AskAsync(string index, string question, ICollection filters = null, double minRelevance = 0, IContext context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable AskStreamingAsync(string index, string question, ICollection filters = null, double minRelevance = 0, IContext context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> ListIndexesAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task SearchAsync(string index, string query, ICollection filters = null, double minRelevance = 0, int limit = -1, IContext context = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} diff --git a/ArangoDB.KernelMemory/DependencyRegistration.cs b/ArangoDB.KernelMemory/DependencyRegistration.cs new file mode 100644 index 00000000..12f19c9e --- /dev/null +++ b/ArangoDB.KernelMemory/DependencyRegistration.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.KernelMemory; +using Microsoft.KernelMemory.MemoryStorage; +using Microsoft.KernelMemory.Search; + +using System; + +namespace ArangoDB.KernelMemory; + +public static class DependencyRegistration +{ + public static KernelMemoryBuilder AddArangoMemory( + this IServiceCollection services) + { + KernelMemoryBuilder builder = new(services); + builder.WithArangoMemory(); + return builder; + } + + public static KernelMemoryBuilder WithArangoMemory( + this KernelMemoryBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder + .WithCustomMemoryDb() + .WithCustomSearchClient(); + return builder; + } + + public static KernelMemoryBuilder WithArangoMemory( + this KernelMemoryBuilder builder, + string serviceKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serviceKey); + builder.Services.AddKeyedSingleton(serviceKey); + builder.Services.AddKeyedSingleton(serviceKey); + builder + .WithCustomMemoryDb() + .WithCustomSearchClient(); + return builder; + } +} diff --git a/arangodb-net-standard.lutconfig b/arangodb-net-standard.lutconfig new file mode 100644 index 00000000..596a8603 --- /dev/null +++ b/arangodb-net-standard.lutconfig @@ -0,0 +1,6 @@ + + + true + true + 180000 + \ No newline at end of file diff --git a/arangodb-net-standard.sln b/arangodb-net-standard.sln index 5f916081..40af9e65 100644 --- a/arangodb-net-standard.sln +++ b/arangodb-net-standard.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32210.238 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11018.127 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArangoDBNetStandard", "arangodb-net-standard\ArangoDBNetStandard.csproj", "{A46089A1-FF27-4C00-AAF4-134ACF6E8FB2}" EndProject @@ -11,6 +11,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErrorEnumGenerator", "ErrorEnumGenerator\ErrorEnumGenerator.csproj", "{D9863444-75DE-4C54-A9B0-9F817CE3B119}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArangoDB.Extensions.VectorData", "ArangoDB.Extensions.VectorData\ArangoDB.Extensions.VectorData.csproj", "{9507A1DE-EEDB-38F8-D7C5-0106C633E2B1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArangoDB.Extensions.VectorData.Tests", "ArangoDB.Extensions.VectorData.Tests\ArangoDB.Extensions.VectorData.Tests.csproj", "{DE5A463D-694E-5C34-702C-57EC3F777060}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArangoDB.KernelMemory", "ArangoDB.KernelMemory\ArangoDB.KernelMemory.csproj", "{99C83FFB-8848-828E-75C0-66F8A048007E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,6 +35,18 @@ Global {D9863444-75DE-4C54-A9B0-9F817CE3B119}.Debug|Any CPU.Build.0 = Debug|Any CPU {D9863444-75DE-4C54-A9B0-9F817CE3B119}.Release|Any CPU.ActiveCfg = Release|Any CPU {D9863444-75DE-4C54-A9B0-9F817CE3B119}.Release|Any CPU.Build.0 = Release|Any CPU + {9507A1DE-EEDB-38F8-D7C5-0106C633E2B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9507A1DE-EEDB-38F8-D7C5-0106C633E2B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9507A1DE-EEDB-38F8-D7C5-0106C633E2B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9507A1DE-EEDB-38F8-D7C5-0106C633E2B1}.Release|Any CPU.Build.0 = Release|Any CPU + {DE5A463D-694E-5C34-702C-57EC3F777060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE5A463D-694E-5C34-702C-57EC3F777060}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE5A463D-694E-5C34-702C-57EC3F777060}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE5A463D-694E-5C34-702C-57EC3F777060}.Release|Any CPU.Build.0 = Release|Any CPU + {99C83FFB-8848-828E-75C0-66F8A048007E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99C83FFB-8848-828E-75C0-66F8A048007E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99C83FFB-8848-828E-75C0-66F8A048007E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99C83FFB-8848-828E-75C0-66F8A048007E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE