Skip to content

Commit 6db12cb

Browse files
paulirwinclaude
andauthored
Support collection fields and OData lambda operators (closes #6) (#33)
Implements Collection(Edm.*) field types end-to-end: multi-valued indexing via Lucene's native multi-field model, JSON-array round-trip through a sidecar stored field, and OData any/all lambda support (Tags/any(t: t eq 'red'), Sizes/all(s: s ge 12), search.in inside lambdas, any() with no body). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ec17f8 commit 6db12cb

6 files changed

Lines changed: 1070 additions & 60 deletions

File tree

AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,173 @@ public async Task SearchDocuments_SearchIsMatchScoring_ShouldReturnResults()
598598
await indexClient.DeleteIndexAsync(indexName);
599599
}
600600

601+
// Collection field tests (issue #6)
602+
603+
[Fact]
604+
public async Task CollectionField_UploadAndGet_ReturnsArrayValues()
605+
{
606+
const string indexName = "test-collection-roundtrip";
607+
var indexClient = factory.CreateSearchIndexClient();
608+
var searchClient = factory.CreateSearchClient(indexName);
609+
610+
await CreateTaggedProductIndexAsync(indexClient, indexName);
611+
await UploadTaggedProductsAsync(searchClient);
612+
613+
var doc = await searchClient.GetDocumentAsync<TaggedProduct>("1");
614+
615+
Assert.NotNull(doc.Value);
616+
Assert.Equal("1", doc.Value.Id);
617+
Assert.Equal(new[] { "red", "cotton", "shirt" }, doc.Value.Tags);
618+
Assert.Equal(new[] { 8, 10, 12 }, doc.Value.Sizes);
619+
620+
await indexClient.DeleteIndexAsync(indexName);
621+
}
622+
623+
[Fact]
624+
public async Task CollectionField_FilterAnyEqual_ReturnsMatchingDocuments()
625+
{
626+
const string indexName = "test-collection-any-eq";
627+
var indexClient = factory.CreateSearchIndexClient();
628+
var searchClient = factory.CreateSearchClient(indexName);
629+
630+
await CreateTaggedProductIndexAsync(indexClient, indexName);
631+
await UploadTaggedProductsAsync(searchClient);
632+
633+
var options = new SearchOptions { Filter = "Tags/any(t: t eq 'cotton')", Size = 50 };
634+
var results = await searchClient.SearchAsync<TaggedProduct>("*", options);
635+
var ids = (await results.Value.GetResultsAsync().ToListAsync())
636+
.Select(r => r.Document.Id)
637+
.OrderBy(id => id)
638+
.ToList();
639+
640+
// Doc 1 (Red Shirt) and doc 4 (Green Socks) both have 'cotton' in their tags.
641+
Assert.Equal(["1", "4"], ids);
642+
}
643+
644+
[Fact]
645+
public async Task CollectionField_FilterAnySearchIn_ReturnsMatchingDocuments()
646+
{
647+
const string indexName = "test-collection-any-searchin";
648+
var indexClient = factory.CreateSearchIndexClient();
649+
var searchClient = factory.CreateSearchClient(indexName);
650+
651+
await CreateTaggedProductIndexAsync(indexClient, indexName);
652+
await UploadTaggedProductsAsync(searchClient);
653+
654+
var options = new SearchOptions { Filter = "Tags/any(t: search.in(t, 'wool,denim'))", Size = 50 };
655+
var results = await searchClient.SearchAsync<TaggedProduct>("*", options);
656+
var ids = (await results.Value.GetResultsAsync().ToListAsync())
657+
.Select(r => r.Document.Id)
658+
.OrderBy(id => id)
659+
.ToList();
660+
661+
Assert.Equal(["2", "3"], ids);
662+
}
663+
664+
[Fact]
665+
public async Task CollectionField_FilterAllNotEqual_ExcludesDocsContainingValue()
666+
{
667+
const string indexName = "test-collection-all-ne";
668+
var indexClient = factory.CreateSearchIndexClient();
669+
var searchClient = factory.CreateSearchClient(indexName);
670+
671+
await CreateTaggedProductIndexAsync(indexClient, indexName);
672+
await UploadTaggedProductsAsync(searchClient);
673+
674+
// all(t: t ne 'cotton') — only docs whose tags do NOT contain 'cotton' should match.
675+
// Doc 1 and doc 4 contain 'cotton', so only doc 2 (jeans) and doc 3 (hat) should match.
676+
var options = new SearchOptions { Filter = "Tags/all(t: t ne 'cotton')", Size = 50 };
677+
var results = await searchClient.SearchAsync<TaggedProduct>("*", options);
678+
var ids = (await results.Value.GetResultsAsync().ToListAsync())
679+
.Select(r => r.Document.Id)
680+
.OrderBy(id => id)
681+
.ToList();
682+
683+
Assert.Equal(["2", "3"], ids);
684+
}
685+
686+
[Fact]
687+
public async Task CollectionField_FilterAnyNumericRange_MatchesDocsWithAnyValueInRange()
688+
{
689+
const string indexName = "test-collection-any-numeric";
690+
var indexClient = factory.CreateSearchIndexClient();
691+
var searchClient = factory.CreateSearchClient(indexName);
692+
693+
await CreateTaggedProductIndexAsync(indexClient, indexName);
694+
await UploadTaggedProductsAsync(searchClient);
695+
696+
// Doc 1 has Sizes [8,10,12] — 12 is >= 12. Doc 2 has Sizes [30,32,34] — all >= 12.
697+
var options = new SearchOptions { Filter = "Sizes/any(s: s ge 12)", Size = 50 };
698+
var results = await searchClient.SearchAsync<TaggedProduct>("*", options);
699+
var ids = (await results.Value.GetResultsAsync().ToListAsync())
700+
.Select(r => r.Document.Id)
701+
.OrderBy(id => id)
702+
.ToList();
703+
704+
Assert.Equal(["1", "2"], ids);
705+
}
706+
707+
[Fact]
708+
public async Task CollectionField_FullTextSearchOnSearchableCollection_MatchesAcrossElements()
709+
{
710+
const string indexName = "test-collection-fulltext";
711+
var indexClient = factory.CreateSearchIndexClient();
712+
var searchClient = factory.CreateSearchClient(indexName);
713+
714+
await CreateTaggedProductIndexAsync(indexClient, indexName);
715+
await UploadTaggedProductsAsync(searchClient);
716+
717+
// Tags is searchable; a free-text search for 'denim' should match doc 2 even though
718+
// 'denim' is just one entry in its tags array.
719+
var options = new SearchOptions { SearchFields = { "Tags" }, Size = 50 };
720+
var results = await searchClient.SearchAsync<TaggedProduct>("denim", options);
721+
var items = await results.Value.GetResultsAsync().ToListAsync();
722+
723+
Assert.Single(items);
724+
Assert.Equal("2", items[0].Document.Id);
725+
}
726+
601727
// Helper Methods
602728

729+
private static async Task<SearchIndex> CreateTaggedProductIndexAsync(SearchIndexClient indexClient, string indexName)
730+
{
731+
try
732+
{
733+
await indexClient.DeleteIndexAsync(indexName);
734+
}
735+
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
736+
{
737+
// expected
738+
}
739+
740+
var index = new SearchIndex(indexName)
741+
{
742+
Fields =
743+
[
744+
new SearchField(nameof(TaggedProduct.Id), SearchFieldDataType.String) { IsKey = true, IsStored = true, IsFilterable = true },
745+
new SearchField(nameof(TaggedProduct.Name), SearchFieldDataType.String) { IsSearchable = true, IsStored = true },
746+
new SearchField(nameof(TaggedProduct.Tags), SearchFieldDataType.Collection(SearchFieldDataType.String)) { IsSearchable = true, IsFilterable = true, IsStored = true },
747+
new SearchField(nameof(TaggedProduct.Sizes), SearchFieldDataType.Collection(SearchFieldDataType.Int32)) { IsFilterable = true, IsStored = true }
748+
]
749+
};
750+
751+
await indexClient.CreateIndexAsync(index);
752+
return index;
753+
}
754+
755+
private static async Task UploadTaggedProductsAsync(SearchClient searchClient)
756+
{
757+
var documents = new List<TaggedProduct>
758+
{
759+
new() { Id = "1", Name = "Red Shirt", Tags = ["red", "cotton", "shirt"], Sizes = [8, 10, 12] },
760+
new() { Id = "2", Name = "Blue Jeans", Tags = ["blue", "denim", "pants"], Sizes = [30, 32, 34] },
761+
new() { Id = "3", Name = "Wool Hat", Tags = ["wool", "warm"], Sizes = [] },
762+
new() { Id = "4", Name = "Green Socks", Tags = ["green", "cotton"], Sizes = [9, 10, 11] },
763+
};
764+
var batch = IndexDocumentsBatch.Upload(documents);
765+
await searchClient.IndexDocumentsAsync(batch);
766+
}
767+
603768
private static async Task<SearchIndex> CreateIndexAsync(SearchIndexClient indexClient, string indexName)
604769
{
605770
// Clean up any existing index

AzureSearchEmulator.IntegrationTests/Product.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,18 @@ public class Product
1717

1818
public bool InStock { get; init; }
1919
}
20+
21+
/// <summary>
22+
/// Product model with collection fields, used to exercise Collection(Edm.*) support
23+
/// (issue #6) end-to-end through the Azure Search SDK.
24+
/// </summary>
25+
public class TaggedProduct
26+
{
27+
public required string Id { get; init; }
28+
29+
public required string Name { get; init; }
30+
31+
public required string[] Tags { get; init; }
32+
33+
public required int[] Sizes { get; init; }
34+
}

0 commit comments

Comments
 (0)