Values and dates

edit

The handlers that take a TValue, in both builders, parse the value of the term before calling your handler. A value that cannot be parsed matches nothing.

Parsed types

TypeAccepted values
int, long, short, byte and their signed or unsigned versions, Int128, UInt128, BigInteger, nintIntegers, with the invariant culture.
double, float, Half, decimalNumbers with a . decimal separator and optional , thousands separators.
DateTime, DateTimeOffsetDates and times with the invariant culture, such as 2026-03-18 or 2026-03-18T10:30. A value without an offset is read as UTC, and the result is in UTC.
DateOnly, TimeOnlyDates and times with the invariant culture.
EnumsA name, case-insensitive, or a number.
Nullable<T> of these typesThe same as T.
Other typesTheir static TryParse(string, IFormatProvider, out T) with the invariant culture, or else their TryParse(string, out T), or else Convert.ChangeType.

Values never depend on the culture of the machine: 1.5 is one and a half everywhere.

Date keywords

When TValue is DateTime, DateTimeOffset or DateOnly, or their nullable versions, these keywords are ranges:

KeywordRange
todayThe current day.
yesterdayThe previous day.
this_week or "this week"From Monday to Sunday of the current week.
this_month or "this month"The current month.
last_month or "last month"The previous month.
this_year or "this year"The current year.
last_year or "last year"The previous year.

Days, weeks, months and years are in UTC, and the keywords are case-insensitive. They work with AddRangeHandler and with ExpressionQueryBuilder<T>.AddHandler<TValue>. The other handlers of QueryBuilder<T> receive a single date, not a range, so they do not accept them.

Relative dates

@today is the start of the current day in UTC. Follow it with an offset in days (d) or weeks (w): @today-3d, @today+1w. A relative date is a value like any other date, so it works with every operator and as a bound of a range: created>=@today-1w, created:@today-4w..@today-2w.

The current time

The keywords and relative dates use the current time when Build runs. Pass a TimeProvider to the constructor of the builder to choose it, for instance to test a query:

// Wednesday, March 18, 2026
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 3, 18, 10, 0, 0, TimeSpan.Zero));
var queryBuilder = new QueryBuilder<Issue>(timeProvider);
queryBuilder.AddRangeHandler<DateTime>("created", (issue, range) => range.IsInRange(issue.CreatedAt));

var issues = new[]
{
    new Issue("Monday", "alice", IsOpen: true, Comments: 0, new DateTime(2026, 3, 16, 9, 0, 0, DateTimeKind.Utc)),
    new Issue("March 1", "alice", IsOpen: true, Comments: 0, new DateTime(2026, 3, 1, 9, 0, 0, DateTimeKind.Utc)),
    new Issue("February 20", "alice", IsOpen: true, Comments: 0, new DateTime(2026, 2, 20, 9, 0, 0, DateTimeKind.Utc)),
    new Issue("New Year's Eve", "alice", IsOpen: true, Comments: 0, new DateTime(2025, 12, 31, 9, 0, 0, DateTimeKind.Utc)),
};

string[] Search(string query) => [.. issues.Where(queryBuilder.Build(query).Evaluate).Select(issue => issue.Title)];

// Weeks start on Monday
Assert.Equal(["Monday"], Search("created:this_week"));
Assert.Equal(["Monday", "March 1"], Search("created:\"this month\""));
Assert.Equal(["February 20"], Search("created:last_month"));
Assert.Equal(["New Year's Eve"], Search("created:last_year"));

// @today is the start of the current day in UTC
Assert.Equal(["Monday"], Search("created>=@today-1w"));
Assert.Equal(["March 1", "February 20"], Search("created:@today-4w..@today-2w"));

FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package. Without a TimeProvider, the builders use TimeProvider.System.

A query built yesterday still means yesterday's today: build the query again when it is kept for a long time.

Parse your own values

Pass a ScalarParser<TValue> to a handler to accept values of your own. It works like a TryParse method, and is also used for the bounds of a range:

var queryBuilder = new QueryBuilder<Issue>();
queryBuilder.AddRangeHandler<int>(
    "comments",
    (issue, range) => range.IsInRange(issue.Comments),
    (string value, out int result) =>
    {
        if (value.Equals("none", StringComparison.OrdinalIgnoreCase))
        {
            result = 0;
            return true;
        }

        return int.TryParse(value, CultureInfo.InvariantCulture, out result);
    });

var issue = new Issue("Crash when saving", "bob", IsOpen: true, Comments: 0, new DateTime(2026, 2, 3));
Assert.True(queryBuilder.Build("comments:none").Evaluate(issue));
Assert.True(queryBuilder.Build("comments:none..2").Evaluate(issue));
Assert.False(queryBuilder.Build("comments>none").Evaluate(issue));

For a date type, the keywords and relative dates are tried before your parser.