Summary
Combining .HasQueryName(alias) with .HasConversion<string>() on the same property works for a
reference-type property, but silently loses the conversion for a value-type (struct) property, causing
an UnsupportedValueException-style failure ("Unsupported value 'x' for type 'Y'") when filtering by the
alias. Filtering by the property's real (unaliased) name works fine in both cases. This looks like the gap
left by #10, which introduced HasConversion but didn't add a test for it combined with HasQueryName.
Repro
// Works: reference-type value object
public record EmailAddress(string? Value);
public class EmailRow { public EmailAddress? Email { get; set; } }
var config = new QueryKitConfiguration(settings =>
settings.Property<EmailRow>(row => row.Email!).HasQueryName("email").HasConversion<string>());
rows.ApplyQueryKitFilter("email == \"b@x.com\"", config); // finds the row
// Fails: value-type (struct) value object, otherwise identical shape
public readonly record struct WrappedId
{
public WrappedId(int value) => Value = value;
public WrappedId(string value) : this(int.Parse(value)) { }
public int Value { get; }
}
public class WrappedRow { public WrappedId Id { get; set; } }
var config = new QueryKitConfiguration(settings =>
settings.Property<WrappedRow>(row => row.Id).HasQueryName("wrappedid").HasConversion<string>());
rows.ApplyQueryKitFilter("wrappedid == \"2\"", config);
// throws: Unsupported value '2' for type 'WrappedId'
// For comparison, this succeeds -- same property, no alias:
var noAliasConfig = new QueryKitConfiguration(settings =>
settings.Property<WrappedRow>(row => row.Id).HasConversion<string>());
rows.ApplyQueryKitFilter("Id == \"2\"", noAliasConfig); // finds the row
I've also confirmed this is independent of chaining order (.HasQueryName().HasConversion() vs.
.HasConversion().HasQueryName()) and independent of any implicit conversion operators on the struct —
only the reference-vs-value-type distinction changes the outcome, tested on 1.14.2.
Root cause
ParseFilter rewrites the filter text early, replacing the alias with the real property path via
PropertyMappings.ReplaceAliasesWithPropertyPaths(input) -- "wrappedid == \"2\"" becomes
"Id == \"2\"" before any comparison is built. That part works correctly.
Later, both CreateLeftExprParser and CreateRightExpr need to check whether that property uses a
conversion, and do so with:
var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath);
propertyPath here is the already-resolved real path ("Id"), not the original alias. But
GetPropertyInfoByQueryName searches for an entry whose QueryName field matches the argument:
public QueryKitPropertyInfo? GetPropertyInfoByQueryName(string? queryName)
=> _propertyMappings.Values.FirstOrDefault(info =>
info.QueryName != null && info.QueryName.Equals(queryName, StringComparison.InvariantCultureIgnoreCase));
Once .HasQueryName("wrappedid") is called, that entry's QueryName is "wrappedid", not "Id". So
GetPropertyInfoByQueryName("Id") finds nothing, UsesConversion is never seen as true, and the
conversion is skipped.
This is masked whenever no alias is set: Property<TModel>(selector) defaults QueryName to the
property's own full path, so path and query name start out equal and the lookup succeeds by
coincidence. It only breaks once QueryName diverges from the path, i.e. exactly when HasQueryName
is actually used for its intended purpose.
I haven't been able to pin down why the reference-type case in my repro still succeeds despite the same
lookup returning null there too -- there may be a second, independent path elsewhere that happens to
retry a (string) constructor for reference types regardless of UsesConversion. I'd treat that as a
lucky side effect rather than something to rely on.
Proposed fix
At both call sites, look up by the resolved property path using the path-keyed lookup, not the
query-name-keyed one:
// instead of:
var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath);
// use:
var propertyConfig = config.PropertyMappings.GetPropertyInfo(propertyPath);
By the time these checks run, propertyPath is always the real path (aliases were already resolved
upstream by ReplaceAliasesWithPropertyPaths), and GetPropertyInfo is keyed by exactly that -- the
dictionary's actual key -- so it isn't sensitive to whatever QueryName happens to be set to. This
should leave the no-alias case unaffected (path lookup still finds the same entry) while fixing the
aliased case. I haven't attempted a PR since I can't run the full QueryKit test suite locally, but I'm
happy to if that's useful.
Environment
Summary
Combining
.HasQueryName(alias)with.HasConversion<string>()on the same property works for areference-type property, but silently loses the conversion for a value-type (struct) property, causing
an
UnsupportedValueException-style failure ("Unsupported value 'x' for type 'Y'") when filtering by thealias. Filtering by the property's real (unaliased) name works fine in both cases. This looks like the gap
left by #10, which introduced
HasConversionbut didn't add a test for it combined withHasQueryName.Repro
I've also confirmed this is independent of chaining order (
.HasQueryName().HasConversion()vs..HasConversion().HasQueryName()) and independent of any implicit conversion operators on the struct —only the reference-vs-value-type distinction changes the outcome, tested on 1.14.2.
Root cause
ParseFilterrewrites the filter text early, replacing the alias with the real property path viaPropertyMappings.ReplaceAliasesWithPropertyPaths(input)--"wrappedid == \"2\""becomes"Id == \"2\""before any comparison is built. That part works correctly.Later, both
CreateLeftExprParserandCreateRightExprneed to check whether that property uses aconversion, and do so with:
propertyPathhere is the already-resolved real path ("Id"), not the original alias. ButGetPropertyInfoByQueryNamesearches for an entry whoseQueryNamefield matches the argument:Once
.HasQueryName("wrappedid")is called, that entry'sQueryNameis"wrappedid", not"Id". SoGetPropertyInfoByQueryName("Id")finds nothing,UsesConversionis never seen astrue, and theconversion is skipped.
This is masked whenever no alias is set:
Property<TModel>(selector)defaultsQueryNameto theproperty's own full path, so path and query name start out equal and the lookup succeeds by
coincidence. It only breaks once
QueryNamediverges from the path, i.e. exactly whenHasQueryNameis actually used for its intended purpose.
I haven't been able to pin down why the reference-type case in my repro still succeeds despite the same
lookup returning null there too -- there may be a second, independent path elsewhere that happens to
retry a
(string)constructor for reference types regardless ofUsesConversion. I'd treat that as alucky side effect rather than something to rely on.
Proposed fix
At both call sites, look up by the resolved property path using the path-keyed lookup, not the
query-name-keyed one:
By the time these checks run,
propertyPathis always the real path (aliases were already resolvedupstream by
ReplaceAliasesWithPropertyPaths), andGetPropertyInfois keyed by exactly that -- thedictionary's actual key -- so it isn't sensitive to whatever
QueryNamehappens to be set to. Thisshould leave the no-alias case unaffected (path lookup still finds the same entry) while fixing the
aliased case. I haven't attempted a PR since I can't run the full QueryKit test suite locally, but I'm
happy to if that's useful.
Environment