From 6119590ac1eff563280fdf446fcc53360e932692 Mon Sep 17 00:00:00 2001 From: Daniel Green <757038+danielgreen@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:16:30 +0100 Subject: [PATCH] Support gt, ge, lt and le filters on date fields A range filter on an Edm.DateTimeOffset field, such as "Updated ge 2025-01-01T00:00:00Z", returned 400 with "comparison not supported for type DateTimeOffset". Only eq worked. Dates are indexed as Unix milliseconds, so each range handler now compares on that, as the eq handler already does. --- .../DateRangeFilterTests.cs | 117 ++++++++++++++++++ .../Searching/ODataQueryVisitor.cs | 4 + 2 files changed, 121 insertions(+) create mode 100644 AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs diff --git a/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs b/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs new file mode 100644 index 0000000..de503cd --- /dev/null +++ b/AzureSearchEmulator.UnitTests/DateRangeFilterTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json.Nodes; +using AzureSearchEmulator.Indexing; +using AzureSearchEmulator.Models; +using AzureSearchEmulator.Searching; +using Lucene.Net.Documents; +using Lucene.Net.Search; +using Microsoft.OData.UriParser; +using Xunit; + +namespace AzureSearchEmulator.UnitTests; + +/// +/// Covers gt, ge, lt and le on an Edm.DateTimeOffset field, +/// which previously failed with "comparison not supported for type DateTimeOffset". +/// +/// +/// Document 2 is dated exactly on the boundary used by every test, so each test also shows +/// whether its operator includes or excludes the boundary. +/// +public class DateRangeFilterTests : IDisposable +{ + private const string Boundary = "2024-06-01T00:00:00Z"; + + private readonly LuceneTestHelper _helper; + private readonly IndexSearcher _searcher; + + public DateRangeFilterTests() + { + _helper = new LuceneTestHelper(CreateIndex(), CreateDocuments()); + _searcher = _helper.CreateSearcher(); + } + + public void Dispose() => _helper.Dispose(); + + private static SearchIndex CreateIndex() => new() + { + Name = "dated", + Fields = + [ + new SearchField { Name = "Id", Type = "Edm.String", Key = true, Searchable = true }, + new SearchField { Name = "Updated", Type = "Edm.DateTimeOffset", Filterable = true }, + ] + }; + + private static List CreateDocuments() => + [ + Doc("1", "2024-01-01T00:00:00Z"), + Doc("2", Boundary), + Doc("3", "2025-01-01T00:00:00Z"), + Doc("4", null), + ]; + + private static Document Doc(string id, string? updated) + { + var json = new JsonObject { ["Id"] = id }; + + if (updated != null) + { + json["Updated"] = DateTimeOffset.Parse(updated); + } + + var doc = new Document(); + + foreach (var field in CreateIndex().Fields) + { + if (json[field.Name] is not { } value) + { + continue; + } + + foreach (var luceneField in field.CreateFields(value)) + { + doc.Add(luceneField); + } + } + + return doc; + } + + private List Search(string filter) + { + var query = new UriQueryExpressionParser(100) + .ParseFilter(filter) + .Accept(new ODataQueryVisitor(_helper.Index)); + + return _searcher.Search(query, 100).ScoreDocs + .Select(sd => _searcher.Doc(sd.Doc).Get("Id")) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + } + + [Theory] + [InlineData($"Updated gt {Boundary}", new[] { "3" })] + [InlineData($"Updated ge {Boundary}", new[] { "2", "3" })] + [InlineData($"Updated lt {Boundary}", new[] { "1" })] + [InlineData($"Updated le {Boundary}", new[] { "1", "2" })] + public void RangeOnDateField_MatchesDocumentsOnTheRightSideOfTheBoundary(string filter, string[] expected) + { + Assert.Equal(expected, Search(filter)); + } + + /// + /// The same instant written with a different offset must give the same result, since the + /// comparison is between instants, not between the written forms. + /// + [Fact] + public void RangeOnDateField_ComparesInstantsWhateverTheOffset() + { + Assert.Equal(["2", "3"], Search("Updated ge 2024-06-01T02:00:00+02:00")); + } + + [Fact] + public void ClosedDateRange_MatchesDocumentsBetweenTheBounds() + { + Assert.Equal(["2"], Search("Updated gt 2024-01-01T00:00:00Z and Updated lt 2025-01-01T00:00:00Z")); + } +} diff --git a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs index 9115442..add4979 100644 --- a/AzureSearchEmulator/Searching/ODataQueryVisitor.cs +++ b/AzureSearchEmulator/Searching/ODataQueryVisitor.cs @@ -612,6 +612,7 @@ private static Query HandleLessThanComparison(string path, LiteralToken literalT float floatValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)floatValue, true, false), double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, false), decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)decimalValue, true, false), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, dateValue.ToUnixTimeMilliseconds(), true, false), _ => throw new NotImplementedException($"Less than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -626,6 +627,7 @@ private static Query HandleLessThanOrEqualComparison(string path, LiteralToken l float floatValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)floatValue, true, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, doubleValue, true, true), decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, double.NegativeInfinity, (double)decimalValue, true, true), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, long.MinValue, dateValue.ToUnixTimeMilliseconds(), true, true), _ => throw new NotImplementedException($"Less than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -640,6 +642,7 @@ private static Query HandleGreaterThanComparison(string path, LiteralToken liter float floatValue => NumericRangeQuery.NewDoubleRange(path, (double)floatValue, double.PositiveInfinity, false, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, false, true), decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, (double)decimalValue, double.PositiveInfinity, false, true), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, dateValue.ToUnixTimeMilliseconds(), long.MaxValue, false, true), _ => throw new NotImplementedException($"Greater than comparison not supported for type {literalToken.Value?.GetType().Name}") }; } @@ -654,6 +657,7 @@ private static Query HandleGreaterThanOrEqualComparison(string path, LiteralToke float floatValue => NumericRangeQuery.NewDoubleRange(path, (double)floatValue, double.PositiveInfinity, true, true), double doubleValue => NumericRangeQuery.NewDoubleRange(path, doubleValue, double.PositiveInfinity, true, true), decimal decimalValue => NumericRangeQuery.NewDoubleRange(path, (double)decimalValue, double.PositiveInfinity, true, true), + DateTimeOffset dateValue => NumericRangeQuery.NewInt64Range(path, dateValue.ToUnixTimeMilliseconds(), long.MaxValue, true, true), _ => throw new NotImplementedException($"Greater than or equal comparison not supported for type {literalToken.Value?.GetType().Name}") }; }