DynamicWhere.ex
DynamicWhere.exv3.4.0·docs

DataType

DataType declares the logical type of a Condition's values. The library uses it to pick the right comparison expression, parse incoming JSON values, and validate that the chosen Operator is legal for that type.

Values

Seven logical types. Each row lists every operator the library will accept when paired with that type. An unsupported pairing is not caught by validation: the predicate builder throws a LogicException reading Unsupported combination of DataType '<type>' and Operator '<op>'.

ValueDescriptionSupported Operators
TextString data.All text operators including the case-insensitive I* variants (IEqual, IContains, IStartsWith, IEndsWith, IIn, etc.), In / NotIn, IsNull / IsNotNull.
GuidGUID stored as a string.Equal, NotEqual, In, NotIn, IsNull, IsNotNull.
NumberAny numeric value — byte through decimal (including short, int, long, float, double). The value is read as the expression parser reads it, and has to compare with the member (3.3.0) — see Number values below.Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Between, NotBetween, In, NotIn, IsNull, IsNotNull.
Booleantrue or false.Equal, NotEqual, IsNull, IsNotNull.
DateTimeFull timestamp (date + time), compared as the member's own type — DateTime, DateTimeOffset or DateOnly, nullable or not. Values must be in one of the date formats below.Equal, NotEqual, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Between, NotBetween, IsNull, IsNotNull.
DateDate-only — compared via the .Date part of the underlying property (.Value.Date on a nullable one), so the time component is ignored. A DateOnly member is already a day and is compared as it is.Same as DateTime (the comparison strips the time component on both sides).
EnumAn enum member, matched by name (any case) or by number. The column may store either.Equal, NotEqual, In, NotIn, IsNull, IsNotNull. The string operators pass validation but throw ParseException on an enum-typed member.
How the two date types build their predicate
DateTime and Date read the member's CLR type before building the predicate. The null guard is emitted only where the value can be null, so on a non-nullable member of the entity itself IsNull answers false and IsNotNull answers true. On a nullable one the guard wraps the whole comparison, so a null row fails NotEqual and NotBetween too. Reached through a navigation — Approval.ApprovedAt — the navigation is guarded instead, and IsNull / IsNotNull ask whether it is there: the provider reads the member of a missing approval as NULL. A DateTimeOffset member is compared against a DateTimeOffset literal and a DateTime member against a DateTime literal. A DateOnly member is compared against a DateOnly — as a day under both data types — so on PostgreSQL an Equal becomes WHERE "Day" = DATE '2026-09-01'. Before 3.1.0 every comparison on a DateTimeOffset member threw, so did Date on any nullable date member, and no comparison on a DateOnly member worked — see breaking changes.
Enum storage does not matter; the operator list does
DataType.Enum matches a member by name or by number, and EF Core translates it for an integer column as readily as for a string one. What the type decides is which operators work: Contains, StartsWith, EndsWith and their negations are accepted by validation and then throw ParseException (“No applicable method 'Contains' exists in type”) against an enum-typed member, whatever the storage. For a string column that merely holds enum names and needs those operators, use DataType.Text.

Date formats

A Date or DateTime value is read against an explicit list of formats, never the lenient .NET parser — at validation and in the predicate builder alike, as the member's own DateTime, DateTimeOffset or DateOnly. The host's culture and calendar play no part, so a value is accepted or refused the same way on every server — including one that declares a local format, since a format whose text the built-in readers also read is refused at configuration. Every deployment accepts these forms:

FormExamples
ISO 8601 date2026-09-01 — the month and day may take one or two digits, so 2026-9-1 too
… with a timeAfter a T or a space: 2026-09-01T12:30, 2026-09-01 12:30:15, 2026-09-01T12:30:15.123
… with a zoneZ or an offset after the time: 2026-09-01T12:30:00Z, 2026-09-01T12:30:00+03:00, +0300, +03
… as other systems write itA lowercase t or z, a comma before the fraction, and a fraction of more than seven digits — Go and Java write nine — which is cut to the seven a DateTime holds
Year-first, with / or .2026/09/01, 2026.09.01, with the same optional time and zone

A C# DateTime, DateTimeOffset or DateOnly placed in Values is written in one of these forms (see Value coercion), so a C# caller is never refused for sending one. Anything else is refused, with one of two codes:

ValueError Code
A numeric date that leads with a day or a month — 01/09/2026, 15/09/2026, 09/15/2026, 01.09.2026, 01-09-2026, 1/9/26 — with or without a timeAmbiguousDateFormat
Anything else, including what the lenient parser used to accept silently: 12:00 (today at noon), 1/9 (a day of the current year), Sep 2026, 1 September 2026InvalidFormat
A day-first or month-first date is refused by its shape
AmbiguousDateFormat does not look at the numbers. "15/09/2026" has only one valid reading and is refused all the same, on purpose: refusing only the values with two readings would fail on the 5th of the month and pass on the 15th, so a client would find out in production instead of on its first request. The field — or the Having alias — is on LogicException.Subject, as it is for an InvalidFormat raised by a date value. An unguarded query names the field by its canonical path. Under ApplyPolicy it is named as the caller wrote it, even under a [DwAlias]. Send ISO 8601 ("2026-09-15", "2026-09-15T12:00:00Z"), or declare the form your clients send — see breaking changes.

Declaring a local format

A deployment whose clients send a local form declares it once, at startup. With dd/MM/yyyy declared, "01/09/2026" is 1 September on every server.

using DynamicWhere.ex.Source;

// A day-first API.
DwDates.Configure(o => o.Formats.Add("dd/MM/yyyy"));

// Or from configuration. A key nothing answers to — "Fromats" — refuses to start,
// and so does a single value where the list belongs: "Formats": "dd/MM/yyyy".
DwDates.Configure(new DwDateOptions().Bind(configuration.GetSection("DynamicWhere:Dates")));
{
  "DynamicWhere": {
    "Dates": {
      "Formats": [ "dd/MM/yyyy" ]
    }
  }
}
  • A format is a .NET exact format string, read with the invariant culture. ISO 8601 and year-first dates stay accepted alongside it.
  • Exact means exact: dd/MM/yyyy reads neither 1/9/2026 nor 01/09/2026 12:30. Declare d/M/yyyy or dd/MM/yyyy HH:mm beside it if clients send those. A value no declared format reads is refused as before, so 09/15/2026 sent to a dd/MM/yyyy deployment is still AmbiguousDateFormat.
  • Two formats that read one text as different dates — dd/MM/yyyy beside MM/dd/yyyy, or a format that contradicts ISO 8601 such as yyyy-dd-MM — are refused when configured, with ArgumentException, rather than left to disagree on a request. So is a blank format.
  • So is a format with no year — dd/MM, HH:mm, t. The parser completes a missing year from the clock, and a missing date from today, so the same value would name a different date depending on when the query ran. A format with a year but no day, such as yyyy-MM, is accepted and reads the 1st.
  • So are a malformed format; a format that reads part of what it writes back differently — dd/MM/yyyy hh:mm, a 12-hour clock with no tt, reads 4 PM as 4 AM; a format with a day but no month — dd/mm/yyyy, where mm is minutes; and two formats that put the day and the month in opposite orders even in different shapes, since dd/MM/yyyy HH:mm beside MM/dd/yyyy would read 01/09/2026 00:00 as 1 September and 01/09/2026 as 9 January.
  • So is a format whose own text ISO 8601 or a year-first date already reads. yyyy-MM-dd, yyyy/M/d, yyyy-MM-dd HH:mm:ss and yyyy-MM-dd'T'HH:mm:ss'Z' are refused; dd/MM/yyyy, dd/MM/yyyy HH:mm, yyyy-MM, dd MMM yyyy and d/M/yy are accepted. Declaring one can only change what such a value means: the 'Z' in yyyy-MM-dd'T'HH:mm:ss'Z' is a quoted letter rather than a zone, so that format reads 12:00 as a wall time where ISO 8601 reads an instant — and on a DateTime member the ISO reading converts to the host's local time, so off UTC the two readings differed and every such value was refused as AmbiguousDateFormat, on that host only. The refusal at configuration is the same on every host. It runs after the checks above, which name a sharper reason.
  • The formats are process-wide and every query reads them without a lock, so they are set once: a second DwDates.Configure call throws InvalidOperationException.
Member (DynamicWhere.ex.Source)Description
DwDates.Configure(Action<DwDateOptions>)Fills in a fresh DwDateOptions and configures it.
DwDates.Configure(DwDateOptions)Checks and freezes the options and sets them for the process. Throws ArgumentNullException for null; ArgumentException for a blank or malformed format, a format that cannot read back what it writes, a format with no year or with a day but no month, two that read one text as different dates, two that put the day and the month in opposite orders, or a format that writes text ISO 8601 or a year-first date already reads; and InvalidOperationException on a second call.
DwDates.OptionsThe DwDateOptions in force. Frozen; declares no formats until a deployment configures some.
DwDates.IsConfiguredtrue once Configure has succeeded.
DwDateOptions.FormatsIList<string> — the formats accepted in addition to ISO 8601 and year-first dates. Read-only once configured.
DwDateOptions.IsFrozentrue once the options have been handed to DwDates.Configure.
DwDateOptions.Bind(IConfiguration)Extension method. Reads Formats from a section and returns the same instance. Throws InvalidOperationException for a key nothing answers to, for a single value where the list belongs ("Formats": "dd/MM/yyyy", or one DynamicWhere__Dates__Formats environment variable), or when the options are already frozen.
Zones: DateTimeOffset reads as UTC, DateTime as host local time
On a DateTimeOffset member the value is normalized to UTC, and one carrying no zone is read as UTC, so Date compares the calendar day you wrote — send a day comparison without a zone. The member's own day is the provider's: its UTC day on PostgreSQL, but the day in its stored offset in memory, so a row at 2026-09-01T01:00+03:00 is 31 August on one and 1 September on the other. On a DateTime member a value carrying a zone is converted to the host's local time. A C# DateTime placed in Values is written with no zone, and so is read as UTC on a DateTimeOffset member — with one exception. A DateTime whose Kind is Local (DateTime.Now, or a value Newtonsoft.Json produced from a string carrying an offset), compared under DataType.DateTime with a DateTimeOffset or DateTimeOffset? member or with a Having alias over such a member's aggregate, is written with its offset — 2026-09-17T15:00:00+03:00 — and filters on the moment it holds. Under DataType.Date it keeps no zone, so DateTime.Today compares the day it was written for rather than the UTC day of local midnight.

JSON examples per type

Text

{
  "sort": 1,
  "field": "Name",
  "dataType": "Text",
  "operator": "IContains",
  "values": ["phone"]
}

Guid

{
  "sort": 1,
  "field": "CustomerId",
  "dataType": "Guid",
  "operator": "Equal",
  "values": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
}

Number

{
  "sort": 1,
  "field": "Price",
  "dataType": "Number",
  "operator": "Between",
  "values": [0, 1.569]
}

Number values (3.3.0)

A number is written into the generated expression unquoted, exactly as sent, so it is read the way the expression parser reads it rather than the way the host's culture does. Two steps.

The grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent (e or E, an optional sign, digits). No leading plus, no thousands separator, no trailing sign, no parentheses, no NaN and no Infinity. An integer, meaning one with neither a fraction nor an exponent, must fit UInt64, or Int64 when negative; a real has no bound, and 1e400 reads as infinity. A suffix (5L, 5m), hex (0x1F) and - 5 are refused as they always were, though the parser would read them: nothing is accepted now that was not accepted before.

The member. In a Where condition, for the operators that write the value into a comparison — Equal, NotEqual, In, NotIn, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Between and NotBetween — the literal also has to compare with the member the condition names. The parser itself is asked, against the member's declared type: a collection at the end of the path stands for itself, one along the path stands for its elements. Refused there, where the parser used to throw:

  • a literal written with a point and no exponent (1.5, 0.1, 1.0) on a nullable integral member (int?, long?, …). A non-nullable int still takes 1.5, exactly as before;
  • an exponent form (1e5, 1E-7) on decimal or decimal?, and a real with more digits than a decimal holds;
  • an integer above Int64.MaxValue on a signed integral member (sbyte, short, int, long, nullable or not): such a literal reads as a ulong, which none of them converts to;
  • a negative number on ulong or ulong?;
  • any number on a string, bool, Guid, DateTime or char member, or on a collection of simple values such as List<int> — a path through a collection, Lines.Quantity, still works;
  • a nullable enum under an ordering operator. Equality works on it, and a non-nullable enum orders.

A Having condition reads the grammar and stops: an alias names an aggregate, so there is no member type to ask the parser about. Every refusal is InvalidFormat, the same in both policy tiers, and a denied field is still refused by the gate before any value is read.

JSON.stringify writes small numbers in exponent form
JavaScript writes 0.0000001 as 1e-7, which a decimal member refuses. Send it as the string "0.0000001".
Before 3.3.0 these passed validation and then threw
The check was byte / short / int / long / float / double / decimal TryParse in the host's culture. "1,000", "5-", "+5", ".5", "5.", "NaN", "Infinity" and an integer past UInt64 all passed and then threw ParseException when the query was built, which a host maps to a five-hundred. "1,5" passed on a German host and was refused on an English one. And "NaN" and "Infinity" were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns. See breaking point 44.

Boolean

{
  "sort": 1,
  "field": "IsActive",
  "dataType": "Boolean",
  "operator": "Equal",
  "values": [true]
}

DateTime

{
  "sort": 1,
  "field": "CreatedAt",
  "dataType": "DateTime",
  "operator": "Equal",
  "values": ["2024-06-15T14:30:00"]
}

Date

{
  "sort": 1,
  "field": "CreatedAt",
  "dataType": "Date",
  "operator": "GreaterThan",
  "values": ["2024-01-01"]
}

Enum

{
  "sort": 1,
  "field": "Status",
  "dataType": "Enum",
  "operator": "In",
  "values": ["Active", "Pending"]
}

Value coercion

Values is List<object>. Whatever the front-end sends, the library normalizes each element before validating and building the expression.

Incoming runtime typeNormalized form
stringas-is
bool"true" / "false" (lowercase)
JsonElementUnwrapped by ValueKind: string → text, number → raw JSON token, True / False → lowercase string.
DateTime / DateTimeOffset / DateOnlyYear-first text: "2026-09-01T12:30:00" (no zone marker), "2026-09-01T12:30:00+03:00", "2026-09-01". The exception is a DateTime of Kind Local compared under DataType.DateTime with a DateTimeOffset member, which keeps its offset: "2026-09-01T12:30:00+03:00". Before 3.1.0 these took the month-first invariant form, such as "09/01/2026 12:30:00", which is now refused.
numeric / other IFormattableInvariantCulture formatting
anything else (e.g. JValue)value.ToString()
nullstring.Empty
Note
Old clients that send ["true"] or ["100"] (quoted strings) keep working unchanged — strings deserialize into the List<object> as string elements and the normalizer passes them through.

C# usage

using DynamicWhere.ex.Enums;

var condition = new Condition
{
    Sort = 1,
    Field = "Price",
    DataType = DataType.Number,
    Operator = Operator.GreaterThan,
    Values = new List<object> { 50 }
};