Skip to content

HasQueryName alias silently drops HasConversion for value-type (struct) properties #106

Description

@IngbertPalm

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

  • QueryKit 1.14.2
  • .NET 10

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions