diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 3c7f298..6e9c324 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -122,4 +122,56 @@ public async Task query_name_that_is_not_a_plain_identifier_filters_by_its_prope people.Should().ContainSingle(); people[0].Id.Should().Be(fakePerson.Id); } + + [Fact] + public async Task non_public_mapped_property_filters_in_the_database() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var nickname = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder().Build(); + fakePerson.Nickname = nickname; + var otherPerson = new FakeTestingPersonBuilder().Build(); + otherPerson.Nickname = new Faker().Lorem.Sentence(); + await testingServiceScope.InsertAsync(fakePerson, otherPerson); + + var input = $"""nickname == "{nickname}" """; + + // Act + var queryable = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var people = await queryable.ToListAsync(); + + // Assert + queryable.ToQueryString().Should().Contain("""p.nickname = """); + people.Should().ContainSingle(); + people[0].Id.Should().Be(fakePerson.Id); + } + + [Fact] + public async Task non_public_mapped_property_filters_when_unknown_properties_are_allowed() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var nickname = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder().Build(); + fakePerson.Nickname = nickname; + var otherPerson = new FakeTestingPersonBuilder().Build(); + otherPerson.Nickname = new Faker().Lorem.Sentence(); + await testingServiceScope.InsertAsync(fakePerson, otherPerson); + + var input = $"""Nickname == "{nickname}" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(); + people[0].Id.Should().Be(fakePerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 9ffb715..6d4337e 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -721,4 +721,123 @@ public void unknown_property_in_arithmetic_throws_an_argument_exception() act.Should().Throw() .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } + + [Theory] + [InlineData("InternalScore > 30", "x => (x.InternalScore > 30)")] + [InlineData("internalscore > 30", "x => (x.InternalScore > 30)")] + [InlineData("""ProtectedNote == "a" """, """x => (x.ProtectedNote == "a")""")] + [InlineData("secretRank == 7", "x => (x.secretRank == 7)")] + [InlineData("""Owner.InternalAlias == "Ann" """, """x => (x.Owner.InternalAlias == "Ann")""")] + [InlineData("(InternalScore, Rating) > 3", "x => ((x.InternalScore > 3) OrElse (x.Rating > 3))")] + public void non_public_member_filters_like_a_public_member(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be(expected); + } + + [Fact] + public void non_public_member_filters_the_rows() + { + var models = new List + { + new(internalScore: 50, rank: 7), + new(internalScore: 20, rank: 3), + }; + + var result = models.ApplyQueryKitFilter("InternalScore > 30 && secretRank == 7").ToList(); + + result.Should().ContainSingle().Which.Should().BeSameAs(models[0]); + } + + [Fact] + public void non_public_member_filters_when_unknown_properties_are_allowed() + { + var input = """secretRank > 100 || Rating == 1"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => ((x.secretRank > 100) OrElse (x.Rating == 1))"); + } + + [Fact] + public void query_name_on_a_non_public_member_filters_by_that_member() + { + var input = """score > 30"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.InternalScore).HasQueryName("score"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.InternalScore > 30)"); + } + + [Fact] + public void public_property_matches_before_a_non_public_field_with_the_same_name() + { + var input = """rank == 1"""; + + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be("x => (x.Rank == 1)"); + } + + [Theory] + [InlineData("""Item == "x" """)] + [InlineData("""item == "x" """)] + [InlineData("""(Item, Rating) == "x" """)] + public void indexer_is_an_unknown_property(string input) + { + var act = () => FilterParser.ParseFilter(input); + + act.Should().Throw() + .WithMessage("The filter property 'Item' was not recognized."); + } + + [Fact] + public void indexer_clause_is_true_equals_true_when_unknown_properties_are_allowed() + { + var input = """Item == "x" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); + } + + private class MemberLookupOwner + { + internal string InternalAlias { get; set; } = ""; + } + + private class MemberLookupModel + { + public MemberLookupModel() { } + + public MemberLookupModel(int internalScore, int rank) + { + InternalScore = internalScore; + secretRank = rank; + } + + public int Rating { get; set; } + public int Rank { get; set; } + public MemberLookupOwner Owner { get; set; } = new(); + internal int InternalScore { get; set; } + protected string ProtectedNote { get; set; } = ""; + private int secretRank; +#pragma warning disable CS0169 // Never read - the field only tests that the public Rank property matches first + private int rank; +#pragma warning restore CS0169 + public string this[string key] => key; + } } diff --git a/QueryKit.WebApiTestProject/AssemblyInfo.cs b/QueryKit.WebApiTestProject/AssemblyInfo.cs new file mode 100644 index 0000000..c33452c --- /dev/null +++ b/QueryKit.WebApiTestProject/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QueryKit.IntegrationTests")] \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Database/PersonConfiguration.cs b/QueryKit.WebApiTestProject/Database/PersonConfiguration.cs index 7ea53d5..9ee9289 100644 --- a/QueryKit.WebApiTestProject/Database/PersonConfiguration.cs +++ b/QueryKit.WebApiTestProject/Database/PersonConfiguration.cs @@ -14,6 +14,8 @@ public void Configure(EntityTypeBuilder builder) .HasConversion(x => x.Value, x => new EmailAddress(x)) .HasColumnName("email") .IsRequired(false); + + builder.Property(x => x.Nickname); builder.OwnsOne(x => x.PhysicalAddress, opts => { diff --git a/QueryKit.WebApiTestProject/Entities/TestingPerson.cs b/QueryKit.WebApiTestProject/Entities/TestingPerson.cs index 0ae9cf1..50176eb 100644 --- a/QueryKit.WebApiTestProject/Entities/TestingPerson.cs +++ b/QueryKit.WebApiTestProject/Entities/TestingPerson.cs @@ -16,6 +16,7 @@ public class TestingPerson public Guid Id { get; set; } = Guid.NewGuid(); public EmailAddress Email { get; set; } = null!; public Address PhysicalAddress { get; set; } = null!; + internal string? Nickname { get; set; } } public enum BirthMonthEnum diff --git a/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.Designer.cs b/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.Designer.cs new file mode 100644 index 0000000..aafad93 --- /dev/null +++ b/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.Designer.cs @@ -0,0 +1,359 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using QueryKit.WebApiTestProject.Database; + +#nullable disable + +namespace QueryKit.WebApiTestProject.Migrations +{ + [DbContext(typeof(TestingDbContext))] + [Migration("20261001161512_AddPersonNickname")] + partial class AddPersonNickname + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "fuzzystrmatch"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("AUT") + .StartsAt(100045702L); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Authors.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("InternalIdentifier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("internal_identifier") + .HasDefaultValueSql("concat('AUT', nextval('\"AUT\"'))"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RecipeId") + .HasColumnType("uuid") + .HasColumnName("recipe_id"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.HasIndex("RecipeId") + .IsUnique() + .HasDatabaseName("ix_authors_recipe_id"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExpiresOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on"); + + b.Property("Measure") + .IsRequired() + .HasColumnType("text") + .HasColumnName("measure"); + + b.Property("MinimumQuality") + .HasColumnType("integer") + .HasColumnName("minimum_quality"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("QualityLevel") + .HasColumnType("bigint") + .HasColumnName("quality_level"); + + b.Property("Quantity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("quantity"); + + b.Property("RecipeId") + .HasColumnType("uuid") + .HasColumnName("recipe_id"); + + b.HasKey("Id") + .HasName("pk_ingredients"); + + b.HasIndex("RecipeId") + .HasDatabaseName("ix_ingredients_recipe_id"); + + b.ToTable("ingredients", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IngredientId") + .HasColumnType("uuid") + .HasColumnName("ingredient_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_ingredient_preparations"); + + b.HasIndex("IngredientId") + .HasDatabaseName("ix_ingredient_preparations_ingredient_id"); + + b.ToTable("ingredient_preparations", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DateOfOrigin") + .HasColumnType("date") + .HasColumnName("date_of_origin"); + + b.Property("Directions") + .IsRequired() + .HasColumnType("text") + .HasColumnName("directions"); + + b.Property("HaveMadeItMyself") + .HasColumnType("boolean") + .HasColumnName("have_made_it_myself"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("SecondaryId") + .HasColumnType("uuid") + .HasColumnName("secondary_id"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text") + .HasColumnName("visibility"); + + b.ComplexProperty(typeof(Dictionary), "CollectionEmail", "QueryKit.WebApiTestProject.Entities.Recipes.Recipe.CollectionEmail#EmailAddress", b1 => + { + b1.IsRequired(); + + b1.Property("Value") + .HasColumnType("text") + .HasColumnName("collection_email"); + }); + + b.HasKey("Id") + .HasName("pk_recipes"); + + b.ToTable("recipes", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.TestingPerson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Age") + .HasColumnType("integer") + .HasColumnName("age"); + + b.Property("BirthMonth") + .HasColumnType("integer") + .HasColumnName("birth_month"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Favorite") + .HasColumnType("boolean") + .HasColumnName("favorite"); + + b.Property("FirstName") + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasColumnType("text") + .HasColumnName("last_name"); + + b.Property("Nickname") + .HasColumnType("text") + .HasColumnName("nickname"); + + b.Property("Rating") + .HasColumnType("numeric") + .HasColumnName("rating"); + + b.Property("SpecificDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("specific_date"); + + b.Property("SpecificDateTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("specific_date_time"); + + b.Property("Time") + .HasColumnType("time without time zone") + .HasColumnName("time"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_people"); + + b.ToTable("people", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Authors.Author", b => + { + b.HasOne("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", "Recipe") + .WithOne("Author") + .HasForeignKey("QueryKit.WebApiTestProject.Entities.Authors.Author", "RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_authors_recipes_recipe_id"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", b => + { + b.HasOne("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingredients_recipes_recipe_id"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.HasOne("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", null) + .WithMany("Preparations") + .HasForeignKey("IngredientId") + .HasConstraintName("fk_ingredient_preparations_ingredients_ingredient_id"); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.TestingPerson", b => + { + b.OwnsOne("QueryKit.WebApiTestProject.Entities.Address", "PhysicalAddress", b1 => + { + b1.Property("TestingPersonId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("City") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_city"); + + b1.Property("Country") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_country"); + + b1.Property("Line1") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_line1"); + + b1.Property("Line2") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_line2"); + + b1.Property("PostalCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_postal_code"); + + b1.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("physical_address_state"); + + b1.HasKey("TestingPersonId"); + + b1.ToTable("people"); + + b1.WithOwner() + .HasForeignKey("TestingPersonId") + .HasConstraintName("fk_people_people_id"); + }); + + b.Navigation("PhysicalAddress") + .IsRequired(); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", b => + { + b.Navigation("Preparations"); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => + { + b.Navigation("Author") + .IsRequired(); + + b.Navigation("Ingredients"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.cs b/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.cs new file mode 100644 index 0000000..f26d910 --- /dev/null +++ b/QueryKit.WebApiTestProject/Migrations/20261001161512_AddPersonNickname.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace QueryKit.WebApiTestProject.Migrations +{ + /// + public partial class AddPersonNickname : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "nickname", + table: "people", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "nickname", + table: "people"); + } + } +} diff --git a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs index 3a7f6ff..39faa82 100644 --- a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs +++ b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "fuzzystrmatch"); @@ -175,7 +175,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("visibility"); - b.ComplexProperty>("CollectionEmail", "QueryKit.WebApiTestProject.Entities.Recipes.Recipe.CollectionEmail#EmailAddress", b1 => + b.ComplexProperty(typeof(Dictionary), "CollectionEmail", "QueryKit.WebApiTestProject.Entities.Recipes.Recipe.CollectionEmail#EmailAddress", b1 => { b1.IsRequired(); @@ -225,6 +225,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("last_name"); + b.Property("Nickname") + .HasColumnType("text") + .HasColumnName("nickname"); + b.Property("Rating") .HasColumnType("numeric") .HasColumnName("rating"); diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 2fbe954..4645ef5 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -76,8 +76,10 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer return PropertyReference.NotMember(PropertyReferenceKind.Unknown, reference, null, unknownSegment!); } - // Matches each segment to a public member, ignoring case. A segment after a collection resolves on the element type. - // After a collection, only properties match: the first segment in the exact case, a later segment in any case. + // Matches each segment to a member, ignoring case, in the order of Expression.PropertyOrField like v1.14.2: + // a public property, a public field, a non-public property, and then a non-public field. An indexer does not match. + // A segment after a collection resolves on the element type. + // After a collection, only public properties match: the first segment in the exact case, a later segment in any case. // A segment after a collection that does not match throws NullReferenceException. private static string? ResolveMemberPath(Type rootType, string path, out string? unknownSegment) { @@ -96,19 +98,22 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer MemberInfo? member; if (firstAfterCollection || afterCollection) { - member = (firstAfterCollection ? currentType.GetProperty(segment) : currentType.GetProperty(segment, MemberFlags)) + member = (firstAfterCollection ? currentType.GetProperty(segment) : currentType.GetProperty(segment, PublicMemberFlags)) ?? throw new NullReferenceException(); afterCollection = true; } else { - member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) - ?? currentType.GetField(segment, MemberFlags); + member = (MemberInfo?)currentType.GetProperty(segment, PublicMemberFlags) + ?? (MemberInfo?)currentType.GetField(segment, PublicMemberFlags) + ?? (MemberInfo?)currentType.GetProperty(segment, NonPublicMemberFlags) + ?? currentType.GetField(segment, NonPublicMemberFlags); } - if (member == null) + if (member == null || member is PropertyInfo indexer && indexer.GetIndexParameters().Length > 0) { - unknownSegment = segment; + // v1.14.2 named an unknown member by the name of the public property with that name, if there was one. + unknownSegment = currentType.GetProperty(segment, PublicMemberFlags)?.Name ?? segment; return null; } @@ -120,7 +125,8 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer return string.Join(".", memberNames); } - private const BindingFlags MemberFlags = BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance; + private const BindingFlags PublicMemberFlags = BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance; + private const BindingFlags NonPublicMemberFlags = BindingFlags.IgnoreCase | BindingFlags.NonPublic | BindingFlags.Instance; private static bool IsCollection(Type type) => type != typeof(string) && type.IsGenericType &&