diff --git a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs index 40dea8f..b054570 100644 --- a/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs +++ b/AzureSearchEmulator.IntegrationTests/EmulatorIntegrationTests.cs @@ -254,6 +254,194 @@ public async Task DeleteIndex_ShouldSucceed() Assert.Equal(404, exception.Status); } + [Fact] + public async Task SearchDocuments_SearchIsMatch_BasicQuery_ShouldReturnResults() + { + const string indexName = "test-ismatch-basic"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Use search.ismatch to search for 'laptop' in all searchable fields + var options = new SearchOptions + { + Filter = "search.ismatch('laptop')", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.True(items.Any(r => r.Document.Name.Contains("Laptop", StringComparison.OrdinalIgnoreCase)), + "Should return documents with 'laptop' in Name or Description"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task SearchDocuments_SearchIsMatch_WithFieldName_ShouldReturnResults() + { + const string indexName = "test-ismatch-field"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Search in specific field using search.ismatch(search, field) + var options = new SearchOptions + { + Filter = "search.ismatch('laptop', 'Name')", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.True(items.All(r => r.Document.Name.Contains("Laptop", StringComparison.OrdinalIgnoreCase)), + "All results should have 'laptop' in Name field"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task SearchDocuments_SearchIsMatch_WithMultipleFields_ShouldReturnResults() + { + const string indexName = "test-ismatch-multi-field"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Search in multiple fields using search.ismatch(search, fields) + var options = new SearchOptions + { + Filter = "search.ismatch('16000 DPI', 'Name,Description')", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.Contains(items, r => r.Document.Name == "Gaming Mouse" || r.Document.Description.Contains("16000 DPI")); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task SearchDocuments_SearchIsMatch_WithBooleanOperator_ShouldReturnResults() + { + const string indexName = "test-ismatch-and"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Combine search.ismatch with other filters using AND + var options = new SearchOptions + { + Filter = "search.ismatch('laptop') and InStock eq true", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.True(items.All(r => r.Document.InStock), "All results should have InStock = true"); + Assert.True(items.All(r => r.Document.Name.Contains("Laptop", StringComparison.OrdinalIgnoreCase) || + r.Document.Description.Contains("Laptop", StringComparison.OrdinalIgnoreCase)), + "All results should contain 'laptop'"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task SearchDocuments_SearchIsMatch_WithNegation_ShouldReturnResults() + { + const string indexName = "test-ismatch-not"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Use NOT to exclude search.ismatch results + var options = new SearchOptions + { + Filter = "not search.ismatch('keyboard')", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.True(items.All(r => !r.Document.Name.Contains("Keyboard", StringComparison.OrdinalIgnoreCase) && + !r.Document.Description.Contains("Keyboard", StringComparison.OrdinalIgnoreCase)), + "All results should NOT contain 'keyboard'"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + + [Fact] + public async Task SearchDocuments_SearchIsMatchScoring_ShouldReturnResults() + { + const string indexName = "test-ismatchscoring"; + var indexClient = factory.CreateSearchIndexClient(); + var searchClient = factory.CreateSearchClient(indexName); + + // Arrange - Create index and upload documents + await CreateIndexAsync(indexClient, indexName); + await UploadDocumentsAsync(searchClient); + + // Act - Use search.ismatchscoring (scoring variant) + var options = new SearchOptions + { + Filter = "search.ismatchscoring('laptop')", + Size = 50 + }; + var results = await searchClient.SearchAsync("*", options); + + // Assert + Assert.NotNull(results); + var resultsList = results.Value.GetResultsAsync(); + var items = await resultsList.ToListAsync(); + Assert.NotEmpty(items); + Assert.True(items.Any(r => r.Document.Name.Contains("Laptop", StringComparison.OrdinalIgnoreCase)), + "Should return documents with 'laptop'"); + + // Cleanup + await indexClient.DeleteIndexAsync(indexName); + } + // Helper Methods private static async Task CreateIndexAsync(SearchIndexClient indexClient, string indexName) diff --git a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs index 9aac8e6..4625e19 100644 --- a/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs +++ b/AzureSearchEmulator/Searching/LuceneNetIndexSearcher.cs @@ -63,7 +63,7 @@ public Task Search(SearchIndex index, SearchRequest request) }); } - var filter = GetFilterFromRequest(request); + var filter = GetFilterFromRequest(request, index); var sort = GetSortFromRequest(index, request); @@ -216,7 +216,7 @@ private static SortFieldType GetSortFieldType(SearchField field) }; } - private static Filter? GetFilterFromRequest(SearchRequest request) + private static Filter? GetFilterFromRequest(SearchRequest request, SearchIndex? index = null) { if (string.IsNullOrEmpty(request.Filter)) { @@ -231,7 +231,7 @@ private static SortFieldType GetSortFieldType(SearchField field) return null; } - var query = filterQuery.Accept(new ODataQueryVisitor()); + var query = filterQuery.Accept(new ODataQueryVisitor(index)); return new QueryWrapperFilter(query); } diff --git a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs index a79f040..8568bb6 100644 --- a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs +++ b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs @@ -1,12 +1,19 @@ -using Lucene.Net.Index; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.SearchData; +using Lucene.Net.Analysis; +using Lucene.Net.Index; +using Lucene.Net.QueryParsers.Flexible.Standard; +using Lucene.Net.QueryParsers.Simple; using Lucene.Net.Search; using Microsoft.OData.UriParser; using Microsoft.OData.UriParser.Aggregation; +using Operator = Lucene.Net.QueryParsers.Flexible.Standard.Config.StandardQueryConfigHandler.Operator; namespace AzureSearchEmulator.Searching; -public class ODataQueryVisitor : ISyntacticTreeVisitor +public class ODataQueryVisitor(SearchIndex? index = null) : ISyntacticTreeVisitor { + private readonly SearchIndex? _index = index; public Query Visit(AllToken tokenIn) { throw new NotImplementedException(); @@ -38,19 +45,18 @@ public Query Visit(BinaryOperatorToken tokenIn) if (tokenIn is { Left: EndPathToken { Identifier: string path }, - OperatorKind: BinaryOperatorKind.Equal, Right: LiteralToken literalToken }) { - return literalToken.Value switch + return tokenIn.OperatorKind switch { - string stringValue => new TermQuery(new Term(path, stringValue)), - int intValue => NumericRangeQuery.NewInt32Range(path, intValue, intValue, true, true), - long longValue => NumericRangeQuery.NewInt64Range(path, longValue, longValue, true, true), - float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, floatValue, true, true), - double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, doubleValue, true, true), - bool boolValue => NumericRangeQuery.NewInt32Range(path, boolValue ? 1 : 0, boolValue ? 1 : 0, true, true), - _ => throw new NotImplementedException() + BinaryOperatorKind.Equal => HandleEqualComparison(path, literalToken), + BinaryOperatorKind.LessThan => HandleLessThanComparison(path, literalToken), + BinaryOperatorKind.LessThanOrEqual => HandleLessThanOrEqualComparison(path, literalToken), + BinaryOperatorKind.GreaterThan => HandleGreaterThanComparison(path, literalToken), + BinaryOperatorKind.GreaterThanOrEqual => HandleGreaterThanOrEqualComparison(path, literalToken), + BinaryOperatorKind.NotEqual => HandleNotEqualComparison(path, literalToken), + _ => throw new NotImplementedException($"Operator {tokenIn.OperatorKind} not implemented") }; } @@ -67,6 +73,81 @@ private static Occur GetOccurFromOperator(BinaryOperatorKind operatorKind) }; } + private static Query HandleEqualComparison(string path, LiteralToken literalToken) + { + return literalToken.Value switch + { + string stringValue => new TermQuery(new Term(path, stringValue)), + int intValue => NumericRangeQuery.NewInt32Range(path, intValue, intValue, true, true), + long longValue => NumericRangeQuery.NewInt64Range(path, longValue, longValue, true, true), + float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, floatValue, true, true), + double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, doubleValue, true, true), + bool boolValue => NumericRangeQuery.NewInt32Range(path, boolValue ? 1 : 0, boolValue ? 1 : 0, true, true), + _ => throw new NotImplementedException() + }; + } + + private static Query HandleLessThanComparison(string path, LiteralToken literalToken) + { + return literalToken.Value switch + { + int intValue => NumericRangeQuery.NewInt32Range(path, int.MinValue, intValue, true, false), + long longValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, longValue, true, false), + float floatValue => NumericRangeQuery.NewSingleRange(path, float.NegativeInfinity, floatValue, true, false), + double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, false), + _ => throw new NotImplementedException($"Less than comparison not supported for type {literalToken.Value?.GetType().Name}") + }; + } + + private static Query HandleLessThanOrEqualComparison(string path, LiteralToken literalToken) + { + return literalToken.Value switch + { + int intValue => NumericRangeQuery.NewInt32Range(path, int.MinValue, intValue, true, true), + long longValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, longValue, true, true), + float floatValue => NumericRangeQuery.NewSingleRange(path, float.NegativeInfinity, floatValue, true, true), + double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, true), + _ => throw new NotImplementedException($"Less than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") + }; + } + + private static Query HandleGreaterThanComparison(string path, LiteralToken literalToken) + { + return literalToken.Value switch + { + int intValue => NumericRangeQuery.NewInt32Range(path, intValue, int.MaxValue, false, true), + long longValue => NumericRangeQuery.NewInt64Range(path, longValue, long.MaxValue, false, true), + float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, float.PositiveInfinity, false, true), + double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, false, true), + _ => throw new NotImplementedException($"Greater than comparison not supported for type {literalToken.Value?.GetType().Name}") + }; + } + + private static Query HandleGreaterThanOrEqualComparison(string path, LiteralToken literalToken) + { + return literalToken.Value switch + { + int intValue => NumericRangeQuery.NewInt32Range(path, intValue, int.MaxValue, true, true), + long longValue => NumericRangeQuery.NewInt64Range(path, longValue, long.MaxValue, true, true), + float floatValue => NumericRangeQuery.NewSingleRange(path, floatValue, float.PositiveInfinity, true, true), + double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, true, true), + _ => throw new NotImplementedException($"Greater than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") + }; + } + + private static Query HandleNotEqualComparison(string path, LiteralToken literalToken) + { + var equalQuery = HandleEqualComparison(path, literalToken); + return new BooleanQuery + { + Clauses = + { + new BooleanClause(new MatchAllDocsQuery(), Occur.MUST), + new BooleanClause(equalQuery, Occur.MUST_NOT) + } + }; + } + public Query Visit(CountSegmentToken tokenIn) { throw new NotImplementedException(); @@ -121,12 +202,13 @@ public Query Visit(ExpandTermToken tokenIn) public Query Visit(FunctionCallToken tokenIn) { - if (tokenIn.Name == "search.in") + return tokenIn.Name switch { - return VisitSearchIn(tokenIn); - } - - throw new NotImplementedException($"Function {tokenIn.Name} not implemented"); + "search.in" => VisitSearchIn(tokenIn), + "search.ismatch" => VisitSearchIsMatch(tokenIn), + "search.ismatchscoring" => VisitSearchIsMatchScoring(tokenIn), + _ => throw new NotImplementedException($"Function {tokenIn.Name} not implemented") + }; } private static BooleanQuery VisitSearchIn(FunctionCallToken tokenIn) @@ -224,7 +306,20 @@ public Query Visit(StarToken tokenIn) public Query Visit(UnaryOperatorToken tokenIn) { - throw new NotImplementedException(); + if (tokenIn.OperatorKind == UnaryOperatorKind.Not) + { + var operand = tokenIn.Operand.Accept(this); + return new BooleanQuery + { + Clauses = + { + new BooleanClause(new MatchAllDocsQuery(), Occur.MUST), + new BooleanClause(operand, Occur.MUST_NOT) + } + }; + } + + throw new NotImplementedException($"Unary operator {tokenIn.OperatorKind} not implemented"); } public Query Visit(FunctionParameterToken tokenIn) @@ -256,4 +351,198 @@ public Query Visit(RootPathToken tokenIn) { throw new NotImplementedException(); } + + private Query VisitSearchIsMatch(FunctionCallToken tokenIn) + { + return BuildFullTextSearchQuery(tokenIn, includeScoring: false); + } + + private Query VisitSearchIsMatchScoring(FunctionCallToken tokenIn) + { + return BuildFullTextSearchQuery(tokenIn, includeScoring: true); + } + + private Query BuildFullTextSearchQuery(FunctionCallToken tokenIn, bool includeScoring) + { + var args = tokenIn.Arguments.ToList(); + + // search.ismatch(searchText) or search.ismatch(searchText, searchFields) or search.ismatch(searchText, searchFields, queryType, searchMode) + if (args.Count is < 1 or > 4) + { + throw new ArgumentException($"search.ismatch requires 1 to 4 arguments, got {args.Count}"); + } + + // First argument: search text (required) + if (args[0].ValueToken is not LiteralToken { Value: string searchText }) + { + throw new InvalidOperationException("First argument to search.ismatch must be a string"); + } + + // Second argument: search fields (optional) + string? searchFields = null; + if (args.Count >= 2 && args[1].ValueToken is LiteralToken { Value: string fields }) + { + searchFields = fields; + } + + // Third argument: query type (optional) + string queryType = "simple"; + if (args.Count >= 3 && args[2].ValueToken is LiteralToken { Value: string qType }) + { + queryType = qType; + } + + // Fourth argument: search mode (optional) + string searchMode = "any"; + if (args.Count >= 4 && args[3].ValueToken is LiteralToken { Value: string sMode }) + { + searchMode = sMode; + } + + if (_index == null) + { + throw new InvalidOperationException("SearchIndex is required for search.ismatch function"); + } + + return ParseFullTextSearchQuery(searchText, searchFields, queryType, searchMode); + } + + private Query ParseFullTextSearchQuery(string searchText, string? searchFields, string queryType, string searchMode) + { + if (_index == null) + { + throw new InvalidOperationException("SearchIndex is required"); + } + + // Get the analyzer for this index + var analyzer = AnalyzerHelper.GetPerFieldSearchAnalyzer(_index.Fields); + + // Parse the search text + var query = queryType switch + { + "full" => ParseFullQuery(searchText, analyzer), + _ => ParseSimpleQuery(searchText, searchFields, searchMode, analyzer) + }; + + // Apply field restrictions if specified + if (!string.IsNullOrEmpty(searchFields)) + { + query = RestrictQueryToFields(query, searchFields); + } + + return query; + } + + private Query ParseSimpleQuery(string searchText, string? searchFields, string searchMode, Analyzer analyzer) + { + if (searchText == "*" || searchText == "*:*") + { + return new MatchAllDocsQuery(); + } + + var fieldsToSearch = GetSearchFieldsForQuery(searchFields); + + if (fieldsToSearch.Count == 0) + { + // If no specific fields, use all searchable fields + fieldsToSearch = _index!.Fields + .Where(i => i.Searchable.GetValueOrDefault()) + .Select(i => i.Name) + .ToList(); + } + + var weights = new Dictionary( + fieldsToSearch.Select(i => new KeyValuePair(i, 1.0f)) + ); + + var queryParser = new SimpleQueryParser(analyzer, weights) + { + DefaultOperator = GetDefaultOccur(searchMode), + }; + + return queryParser.Parse(searchText); + } + + private Query ParseFullQuery(string searchText, Analyzer analyzer) + { + if (searchText == "*" || searchText == "*:*") + { + return new MatchAllDocsQuery(); + } + + var firstTextField = _index?.Fields.FirstOrDefault(i => i.Searchable.GetValueOrDefault()); + var fieldName = firstTextField?.Name ?? "Text"; + + var queryParser = new StandardQueryParser(analyzer) + { + DefaultOperator = Operator.OR, + }; + + return queryParser.Parse(searchText, fieldName); + } + + private List GetSearchFieldsForQuery(string? searchFields) + { + if (string.IsNullOrEmpty(searchFields) || _index == null) + { + return []; + } + + var fields = searchFields.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + return _index.Fields + .Where(i => i.Searchable.GetValueOrDefault() && fields.Contains(i.Name, StringComparer.OrdinalIgnoreCase)) + .Select(i => i.Name) + .ToList(); + } + + private Query RestrictQueryToFields(Query query, string searchFields) + { + if (_index == null) + { + return query; + } + + var allowedFields = GetSearchFieldsForQuery(searchFields); + + if (allowedFields.Count == 0) + { + return query; + } + + // If the query is already field-specific (e.g., "name:laptop"), keep it + // Otherwise, wrap it with field restrictions + if (query is TermQuery termQuery) + { + // For term queries, we can apply field restriction more directly + if (allowedFields.Count == 1) + { + return new TermQuery(new Term(allowedFields[0], termQuery.Term.Text)); + } + else + { + // Multiple fields: use BooleanQuery with SHOULD clauses + var boolQuery = new BooleanQuery(); + foreach (var field in allowedFields) + { + boolQuery.Add(new TermQuery(new Term(field, termQuery.Term.Text)), Occur.SHOULD); + } + return boolQuery; + } + } + + // For more complex queries, return as-is since they may already have field restrictions + // The query parser handles field-specific syntax (e.g., "name:laptop") + return query; + } + + private static Occur GetDefaultOccur(string? searchMode) + { + return searchMode switch + { + "any" => Occur.SHOULD, + "all" => Occur.MUST, + _ => Occur.SHOULD + }; + } }