From e9c44c90456ca4132ada498f1ef0c14d142f1471 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 7 Feb 2023 11:01:08 +0100 Subject: [PATCH 01/58] Increment version to 5.1.3 (used for pre-release builds from ci) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 15a7e94c7c..34807583da 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ 7.0.* 4.4.* 2.14.1 - 5.1.2 + 5.1.3 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable From 22761fe5fbe5efa101303bdd807a0b0397d904fd Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 9 Feb 2023 12:31:21 +0100 Subject: [PATCH 02/58] Empty commit to trigger cibuild From 0818619a8684a8c3ef31c9e3699e7b5a4e103b48 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 9 Feb 2023 13:19:07 +0100 Subject: [PATCH 03/58] Update GIT_ACCESS_TOKEN to use bkoelman instead of bart-degreed GitHub account --- appveyor.yml | 2 +- docs/usage/toc.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ff9191da7c..3328af0009 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -10,7 +10,7 @@ environment: PGUSER: postgres PGPASSWORD: Password12! GIT_ACCESS_TOKEN: - secure: vw2jhp7V38fTOqphzFgnXtLwHoHRW2zM2K5RJgDAnmkoaIKT6jXLDIfkFdyVz9nJ + secure: WPzhuEyDE7yuHeEgLi3RoGJ8we+AHU6nMksbFoWQ0AmI/HJLh4bjOR0Jnnzc6aaG branches: only: diff --git a/docs/usage/toc.md b/docs/usage/toc.md index c23d8f7308..61d3da8de0 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -23,7 +23,6 @@ # [Errors](errors.md) # [Metadata](meta.md) # [Caching](caching.md) - # [Common Pitfalls](common-pitfalls.md) # Extensibility From 8e63c6851c79041c714b14fa6f63983fdc670c0e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 9 Feb 2023 13:58:28 +0100 Subject: [PATCH 04/58] Dummy change to trigger docs deployment --- docs/usage/options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/options.md b/docs/usage/options.md index 919642c5c8..3512a9d9f2 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -42,7 +42,7 @@ If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is ## Relative Links -All links are absolute by default. However, you can configure relative links. +All links are absolute by default. However, you can configure relative links: ```c# options.UseRelativeLinks = true; From ac7d3ae179ec29376d06d8a3b7e67de90319f531 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Feb 2023 00:20:32 +0100 Subject: [PATCH 05/58] Fix formatting --- test/SourceGeneratorTests/ControllerGenerationTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index 9f5f9f83d3..7576e79417 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -490,9 +490,8 @@ public sealed class Item GeneratorDriverRunResult runResult = driver.GetRunResult(); - runResult.Should() - .HaveSingleDiagnostic( - "(6,21): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); + runResult.Should().HaveSingleDiagnostic( + "(6,21): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); runResult.Should().NotHaveProducedSourceCode(); } From 8cad0173fdddccced88dbb8ce2267eb319693e2d Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 16 Feb 2023 13:06:45 +0100 Subject: [PATCH 06/58] Simplify assertions (ShouldContainKey already checks for null) --- .../Meta/TopLevelCountTests.cs | 4 ---- .../ResourceInheritanceReadTests.cs | 24 +++++-------------- test/NoEntityFrameworkTests/WorkItemTests.cs | 1 - 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index eee8fa75e3..f9639c99bc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -52,8 +52,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); - responseDocument.Meta.ShouldContainKey("total").With(value => { JsonElement element = value.Should().BeOfType().Subject; @@ -78,8 +76,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); - responseDocument.Meta.ShouldContainKey("total").With(value => { JsonElement element = value.Should().BeOfType().Subject; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index ebca28dac9..ee974e0756 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -614,9 +614,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -624,9 +622,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) @@ -686,9 +682,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -696,9 +690,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) @@ -752,9 +744,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/chromeWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("paintColor"); + resource.Attributes.ShouldOnlyContainKeys("radius", "paintColor"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue.Where(value => value.Type == "carbonWheels")) @@ -762,9 +752,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => resource.Links.ShouldNotBeNull(); resource.Links.Self.Should().Be($"/carbonWheels/{resource.Id}"); - resource.Attributes.ShouldHaveCount(2); - resource.Attributes.ShouldContainKey("radius"); - resource.Attributes.ShouldContainKey("hasTube"); + resource.Attributes.ShouldOnlyContainKeys("radius", "hasTube"); } foreach (ResourceObject resource in responseDocument.Data.ManyValue) diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index 623f05ed1d..7bf09d35aa 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -119,7 +119,6 @@ public async Task Can_create_WorkItem() httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isBlocked").With(value => value.Should().Be(newWorkItem.IsBlocked)); responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newWorkItem.Title)); responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newWorkItem.DurationInHours)); From 9f31f922fbce48eada8ccf8fc0375f751549f230 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 16 Feb 2023 13:47:55 +0100 Subject: [PATCH 07/58] Fixed: on secondary endpoint, the incoming filter from query string was not applied when determining total resource count via inverse relationship --- .../Queries/Internal/QueryLayerComposer.cs | 4 +- .../IntegrationTests/Meta/SupportTicket.cs | 3 ++ .../Meta/TopLevelCountTests.cs | 40 +++++++++++++++++-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 4661a5bdda..0a1ff48ca0 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -82,13 +82,11 @@ public QueryLayerComposer(IEnumerable constraintProvid ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); - // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true FilterExpression[] filtersInSecondaryScope = constraints - .Where(constraint => secondaryScope.Equals(constraint.Scope)) + .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) .OfType() .ToArray(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index 619542ad6d..9ce245e951 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -10,4 +10,7 @@ public sealed class SupportTicket : Identifiable { [Attr] public string Description { get; set; } = null!; + + [HasOne] + public ProductFamily? ProductFamily { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index eee8fa75e3..a795a15190 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -32,19 +32,21 @@ public TopLevelCountTests(IntegrationTestContext, } [Fact] - public async Task Renders_resource_count_for_collection() + public async Task Renders_resource_count_for_primary_resources_endpoint_with_filter() { // Arrange - SupportTicket ticket = _fakers.SupportTicket.Generate(); + List tickets = _fakers.SupportTicket.Generate(2); + + tickets[1].Description = "Update firmware version"; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.SupportTickets.Add(ticket); + dbContext.SupportTickets.AddRange(tickets); await dbContext.SaveChangesAsync(); }); - const string route = "/supportTickets"; + const string route = "/supportTickets?filter=startsWith(description,'Update ')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -61,6 +63,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Renders_resource_count_for_secondary_resources_endpoint_with_filter() + { + // Arrange + ProductFamily family = _fakers.ProductFamily.Generate(); + family.Tickets = _fakers.SupportTicket.Generate(2); + + family.Tickets[1].Description = "Update firmware version"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(family); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/productFamilies/{family.StringId}/tickets?filter=contains(description,'firmware')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(1); + }); + } + [Fact] public async Task Renders_resource_count_for_empty_collection() { From 27321093f472db9846340ca1741f633db2acc960 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 18 Feb 2023 12:00:59 +0100 Subject: [PATCH 08/58] Package updates; fix culture in range parsing (see https://github.com/maxkoshevoi/DateOnlyTimeOnly.AspNet/issues/16) --- benchmarks/Benchmarks.csproj | 2 +- .../IntegrationTests/InputValidation/ModelState/SystemFile.cs | 4 ++-- test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj | 4 ++-- test/TestBuildingBlocks/TestBuildingBlocks.csproj | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 185f2919ac..23a6876af9 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 2fa659b6ef..ef90adcc79 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -22,10 +22,10 @@ public sealed class SystemFile : Identifiable public long SizeInBytes { get; set; } [Attr] - [Range(typeof(DateOnly), "2000-01-01", "2050-01-01")] + [Range(typeof(DateOnly), "2000-01-01", "2050-01-01", ParseLimitsInInvariantCulture = true)] public DateOnly CreatedOn { get; set; } [Attr] - [Range(typeof(TimeOnly), "09:00:00", "17:30:00")] + [Range(typeof(TimeOnly), "09:00:00", "17:30:00", ParseLimitsInInvariantCulture = true)] public TimeOnly CreatedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index 3bd3632461..8c4822b67a 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworkName) @@ -11,7 +11,7 @@ - + diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 999498ebaa..16dabc8d54 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -10,7 +10,7 @@ - + From a7668ae26a268c905e7d3337c154297904a55461 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Feb 2023 01:41:33 +0100 Subject: [PATCH 09/58] Changes LiteralConstantExpression to contain a typed constant instead of a string value. This effectively moves the type resolving/conversion logic from the EF query production phase to the filter parsing phase. By providing a richer QueryLayer model, it becomes easier to implement non-relational and non-EF Core-based repositories. And it enables to produce better errors earlier. Note we still need to wrap nullable values (see WhereClauseBuilder.ResolveCommonType), due to https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html. --- .../Expressions/LiteralConstantExpression.cs | 25 ++-- .../Queries/Internal/Parsing/FilterParser.cs | 120 ++++++++++-------- .../Queries/Internal/QueryLayerComposer.cs | 8 +- .../QueryableBuilding/WhereClauseBuilder.cs | 44 +++---- .../CompositeKeys/CarExpressionRewriter.cs | 7 +- .../Filtering/FilterDataTypeTests.cs | 7 +- .../Filtering/FilterOperatorTests.cs | 44 +++++++ 7 files changed, 156 insertions(+), 99 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 578643d5db..e8041f0cf2 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -8,13 +8,22 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { - public string Value { get; } + private readonly string _stringValue; - public LiteralConstantExpression(string text) + public object TypedValue { get; } + + public LiteralConstantExpression(object typedValue) + : this(typedValue, typedValue.ToString()!) + { + } + + public LiteralConstantExpression(object typedValue, string stringValue) { - ArgumentGuard.NotNull(text); + ArgumentGuard.NotNull(typedValue); + ArgumentGuard.NotNull(stringValue); - Value = text; + TypedValue = typedValue; + _stringValue = stringValue; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -24,8 +33,8 @@ public override TResult Accept(QueryExpressionVisitor rightConstantValueConverter; + + if (leftTerm is CountExpression) + { + rightConstantValueConverter = GetConstantValueConverterForCount(); + } + else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) + { + rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute); + } + else + { + // This temporary value never survives; it gets discarded during the second pass below. + rightConstantValueConverter = _ => 0; + } EatSingleCharacterToken(TokenKind.Comma); - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); + QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); EatSingleCharacterToken(TokenKind.CloseParen); - if (leftTerm is ResourceFieldChainExpression leftChain) + if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression) { - if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to have it fail when chain ends in relationship. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - - PropertyInfo leftProperty = leftChain.Fields[^1].Property; - - if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) - { - string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); - rightTerm = new LiteralConstantExpression(id); - } + // Run another pass over left chain to produce an error. + OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); } return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); @@ -173,16 +177,23 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName) EatText(matchFunctionName); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType; + + if (targetAttributeType != typeof(string)) + { + throw new QueryParseException("Attribute of type 'String' expected."); + } EatSingleCharacterToken(TokenKind.Comma); - LiteralConstantExpression constant = ParseConstant(); + Converter constantValueConverter = stringValue => stringValue; + LiteralConstantExpression constant = ParseConstant(constantValueConverter); EatSingleCharacterToken(TokenKind.CloseParen); var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttribute, constant, matchKind); + return new MatchTextExpression(targetAttributeChain, constant, matchKind); } protected AnyExpression ParseAny() @@ -191,19 +202,20 @@ protected AnyExpression ParseAny() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]); EatSingleCharacterToken(TokenKind.Comma); ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - LiteralConstantExpression constant = ParseConstant(); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { EatSingleCharacterToken(TokenKind.Comma); - constant = ParseConstant(); + constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); } @@ -211,32 +223,9 @@ protected AnyExpression ParseAny() IImmutableSet constantSet = constantsBuilder.ToImmutable(); - PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; - - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) - { - constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); - } - return new AnyExpression(targetAttribute, constantSet); } - private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet, - PropertyInfo targetAttributeProperty) - { - ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder(); - - foreach (LiteralConstantExpression idConstant in constantSet) - { - string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); - - idConstantsBuilder.Add(new LiteralConstantExpression(id)); - } - - return idConstantsBuilder.ToImmutable(); - } - protected HasExpression ParseHas() { EatText(Keywords.Has); @@ -360,7 +349,7 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem return ParseFieldChain(chainRequirements, "Count function or field name expected."); } - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) + protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) { CountExpression? count = TryParseCount(); @@ -369,7 +358,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return count; } - IdentifierExpression? constantOrNull = TryParseConstantOrNull(); + IdentifierExpression? constantOrNull = TryParseConstantOrNull(constantValueConverter); if (constantOrNull != null) { @@ -379,7 +368,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); } - protected IdentifierExpression? TryParseConstantOrNull() + protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter) { if (TokenStack.TryPeek(out Token? nextToken)) { @@ -392,28 +381,55 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen if (nextToken.Kind == TokenKind.QuotedText) { TokenStack.Pop(); - return new LiteralConstantExpression(nextToken.Value!); + + object constantValue = constantValueConverter(nextToken.Value!); + return new LiteralConstantExpression(constantValue, nextToken.Value!); } } return null; } - protected LiteralConstantExpression ParseConstant() + protected LiteralConstantExpression ParseConstant(Converter constantValueConverter) { if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) { - return new LiteralConstantExpression(token.Value!); + object constantValue = constantValueConverter(token.Value!); + return new LiteralConstantExpression(constantValue, token.Value!); } throw new QueryParseException("Value between quotes expected."); } - private string DeObfuscateStringId(Type resourceClrType, string stringId) + private Converter GetConstantValueConverterForCount() + { + return stringValue => ConvertStringToType(stringValue, typeof(int)); + } + + private object ConvertStringToType(string value, Type type) + { + try + { + return RuntimeTypeConverter.ConvertType(value, type)!; + } + catch (FormatException) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'."); + } + } + + private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) + { + return stringValue => attribute.Property.Name == nameof(IIdentifiable.Id) + ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) + : ConvertStringToType(stringValue, attribute.Property.PropertyType); + } + + private object DeObfuscateStringId(Type resourceClrType, string stringId) { IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString()!; + return tempResource.GetTypedId(); } protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 0a1ff48ca0..e22b4ba86b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -114,14 +114,14 @@ private static FilterExpression GetInverseHasOneRelationshipFilter(TId prim AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); - return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!)); } private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasManyAttribute inverseRelationship) { AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); - var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!)); return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); } @@ -360,12 +360,12 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? if (ids.Count == 1) { - var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); + var constant = new LiteralConstantExpression(ids.Single()!); filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } else if (ids.Count > 1) { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet(); filter = new AnyExpression(idChain, constants); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 1198a488ff..87cd066a85 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; /// calls. /// [PublicAPI] -public class WhereClauseBuilder : QueryClauseBuilder +public class WhereClauseBuilder : QueryClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); @@ -53,7 +53,7 @@ private Expression WhereExtensionMethodCall(LambdaExpression predicate) return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); } - public override Expression VisitHas(HasExpression expression, Type? argument) + public override Expression VisitHas(HasExpression expression, object? argument) { Expression property = Visit(expression.TargetCollection, argument); @@ -85,7 +85,7 @@ private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Exp : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitIsType(IsTypeExpression expression, Type? argument) + public override Expression VisitIsType(IsTypeExpression expression, object? argument) { Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor; TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); @@ -101,7 +101,7 @@ public override Expression VisitIsType(IsTypeExpression expression, Type? argume return Expression.AndAlso(typeCheck, filter); } - public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) + public override Expression VisitMatchText(MatchTextExpression expression, object? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -125,7 +125,7 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type? return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, Type? argument) + public override Expression VisitAny(AnyExpression expression, object? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -133,8 +133,7 @@ public override Expression VisitAny(AnyExpression expression, Type? argument) foreach (LiteralConstantExpression constant in expression.Constants) { - object? value = ConvertTextToTargetType(constant.Value, property.Type); - valueList.Add(value); + valueList.Add(constant.TypedValue); } ConstantExpression collection = Expression.Constant(valueList); @@ -146,7 +145,7 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, Type? argument) + public override Expression VisitLogical(LogicalExpression expression, object? argument) { var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); @@ -179,18 +178,18 @@ private static BinaryExpression Compose(Queue argumentQueue, Func constant.Value).ToArray(); + string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray(); return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); } @@ -100,7 +101,7 @@ private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldCha string licensePlateValue) { ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue.ToString())); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue)); ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, new LiteralConstantExpression(licensePlateValue)); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index b464a15083..b910b7e42e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -304,7 +304,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_filter_equality_on_incompatible_value() + public async Task Cannot_filter_equality_on_incompatible_values() { // Arrange var resource = new FilterableResource @@ -331,9 +331,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Query creation failed due to incompatible types."); + error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); - error.Source.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); } [Theory] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index b9fd8e2b2a..69dd7ca706 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -696,6 +696,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); } + [Fact] + public async Task Cannot_filter_text_match_on_non_string_value() + { + // Arrange + const string route = "/filterableResources?filter=contains(someInt32,'123')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("Attribute of type 'String' expected."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Theory] [InlineData("yes", "no", "'yes'")] [InlineData("two", "one two", "'one','two','three'")] @@ -842,6 +864,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } + [Fact] + public async Task Cannot_filter_on_count_with_incompatible_value() + { + // Arrange + const string route = "/filterableResources?filter=equals(count(children),'ABC')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Theory] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")] From a05a12e9d4bdb1ca43c8e3d55c7962d8abba7199 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Feb 2023 19:59:40 +0100 Subject: [PATCH 10/58] Simplify test --- .../UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 3fc221c190..97a35603b3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -361,7 +361,7 @@ public void Can_override_capabilities_on_Id_property() IResourceGraph resourceGraph = builder.Build(); ResourceType resourceType = resourceGraph.GetResourceType(); - AttrAttribute idAttribute = resourceType.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); idAttribute.Capabilities.Should().Be(AttrCapabilities.AllowFilter); } From bcc297515ef03d530395a2300747ca8891bbc0ce Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 24 Feb 2023 02:24:48 +0100 Subject: [PATCH 11/58] Removed pointless assertion (the preceeding code casts to HasManyAttribute, so this can never fail) --- .../Repositories/EntityFrameworkCoreRepository.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7cdd114301..50c300b036 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -529,8 +529,6 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) { - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); From 2bf940566173a32c2db3c34fafda3a06a2eb67df Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 24 Feb 2023 02:30:04 +0100 Subject: [PATCH 12/58] Removed resource ID from error message, as the error is unrelated to any specific record --- .../Errors/CannotClearRequiredRelationshipException.cs | 5 ++--- .../Repositories/EntityFrameworkCoreRepository.cs | 8 ++++---- .../ModelState/NoModelStateValidationTests.cs | 4 +--- .../RequiredRelationships/DefaultBehaviorTests.cs | 4 +--- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index b73b256d25..97377b0d7b 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -10,12 +10,11 @@ namespace JsonApiDotNetCore.Errors; [PublicAPI] public sealed class CannotClearRequiredRelationshipException : JsonApiException { - public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) + public CannotClearRequiredRelationshipException(string relationshipName, string resourceType) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " + - $"with ID '{resourceId}' cannot be cleared because it is a required relationship." + Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' cannot be cleared because it is a required relationship." }) { } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 50c300b036..074e902341 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -269,7 +269,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } @@ -288,7 +288,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, object? rightValue) { if (relationship is HasOneAttribute) { @@ -300,7 +300,7 @@ protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribut if (isRelationshipRequired && isClearingRelationship) { string resourceName = _resourceGraph.GetResourceType().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); } } } @@ -403,7 +403,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r object? rightValueEvaluated = await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 671123930e..6cd623ac94 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -128,8 +128,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.Detail.Should().Be("The relationship 'rootDirectory' on resource type 'systemVolumes' cannot be cleared because it is a required relationship."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index 3e1dfcaef0..5828de90d3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -256,9 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'customer' on resource type 'orders' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.Detail.Should().Be("The relationship 'customer' on resource type 'orders' cannot be cleared because it is a required relationship."); } [Fact] From 1c9a7c97c995da5c3f27f1d9f2e88abcaf7aaa3c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 24 Feb 2023 09:44:10 +0100 Subject: [PATCH 13/58] Fix naming/comment --- .../Internal/QueryableBuilding/SelectClauseBuilder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 1f1c10301a..9c7c7f9d81 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -136,10 +136,10 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) { - // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. - // And only selecting relationships implicitly means to select all attributes too. + // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. + // And only selecting relationships implicitly means to fetch all scalar properties as well. - IncludeAllAttributes(elementType, propertySelectors); + IncludeAllScalarProperties(elementType, propertySelectors); } IncludeFields(fieldSelectors, propertySelectors); @@ -148,7 +148,7 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe return propertySelectors.Values; } - private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors) + private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) { IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); From 202268469dedf17b12ad2d88f47435e6083815d1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 5 Mar 2023 13:45:02 +0100 Subject: [PATCH 14/58] Fixed: do not import namespace of resource class when same as controller namespace --- .../SourceCodeWriter.cs | 6 +- .../ControllerGenerationTests.cs | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index 13dc91a836..f138985157 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -66,7 +66,7 @@ public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEn WriteNullableEnable(); } - WriteNamespaceImports(loggerFactoryInterface, resourceType); + WriteNamespaceImports(loggerFactoryInterface, resourceType, controllerNamespace); if (controllerNamespace != null) { @@ -96,7 +96,7 @@ private void WriteNullableEnable() _sourceBuilder.AppendLine(); } - private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType) + private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace) { _sourceBuilder.AppendLine($@"using {loggerFactoryInterface.ContainingNamespace};"); @@ -104,7 +104,7 @@ private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INam _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;"); - if (!resourceType.ContainingNamespace.IsGlobalNamespace) + if (!resourceType.ContainingNamespace.IsGlobalNamespace && resourceType.ContainingNamespace.ToString() != controllerNamespace) { _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};"); } diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index 7576e79417..2ef87fc120 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -709,6 +709,66 @@ public sealed class Item : Identifiable using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; +public sealed partial class ItemsController : JsonApiController +{ + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } +} +"); + } + + [Fact] + public void Can_generate_for_shared_namespace() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi") + .WithCode(@" + [Resource(ControllerNamespace = ""ExampleApi"")] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(@"// + +using Microsoft.Extensions.Logging; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +namespace ExampleApi; + public sealed partial class ItemsController : JsonApiController { public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, From 177ae01a503b8a40b5357b6fa1d8fe401d54179d Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 5 Mar 2023 13:49:56 +0100 Subject: [PATCH 15/58] Fix indents in tests --- .../ControllerGenerationTests.cs | 232 +++++++++--------- 1 file changed, 116 insertions(+), 116 deletions(-) diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index 2ef87fc120..629c5d49b8 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -25,12 +25,12 @@ public void Can_generate_for_default_controller() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -87,12 +87,12 @@ public void Can_generate_for_read_only_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -149,12 +149,12 @@ public void Can_generate_for_write_only_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Command)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.Command)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -211,15 +211,15 @@ public void Can_generate_for_mixed_controller() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = NoRelationshipEndpoints)] - public sealed class Item : Identifiable - { - private const JsonApiEndpoints NoRelationshipEndpoints = JsonApiEndpoints.GetCollection | - JsonApiEndpoints.GetSingle | JsonApiEndpoints.Post | JsonApiEndpoints.Patch | JsonApiEndpoints.Delete; - - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = NoRelationshipEndpoints)] + public sealed class Item : Identifiable + { + private const JsonApiEndpoints NoRelationshipEndpoints = JsonApiEndpoints.GetCollection | + JsonApiEndpoints.GetSingle | JsonApiEndpoints.Post | JsonApiEndpoints.Patch | JsonApiEndpoints.Delete; + + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -284,11 +284,11 @@ public void Skips_for_resource_without_ResourceAttribute() .WithNamespaceImportFor(typeof(AttrAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -326,12 +326,12 @@ public void Skips_for_resource_with_no_endpoints() .WithNamespaceImportFor(typeof(JsonApiEndpoints)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -366,24 +366,24 @@ public void Skips_for_missing_dependency_on_JsonApiDotNetCore() string source = new SourceCodeBuilder() .InNamespace("ExampleApi.Models") .WithCode(@" - public abstract class Identifiable - { - } - - public sealed class ResourceAttribute : System.Attribute - { - } - - public sealed class AttrAttribute : System.Attribute - { - } - - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + public abstract class Identifiable + { + } + + public sealed class ResourceAttribute : System.Attribute + { + } + + public sealed class AttrAttribute : System.Attribute + { + } + + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -422,12 +422,12 @@ public void Skips_for_missing_dependency_on_LoggerFactory() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -465,12 +465,12 @@ public void Warns_for_resource_that_does_not_implement_IIdentifiable() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource] - public sealed class Item - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -491,7 +491,7 @@ public sealed class Item GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().HaveSingleDiagnostic( - "(6,21): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); + "(6,17): warning JADNC001: Type 'Item' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers"); runResult.Should().NotHaveProducedSourceCode(); } @@ -510,14 +510,14 @@ public void Adds_nullable_enable_for_nullable_reference_ID_type() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - #nullable enable - - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + #nullable enable + + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -555,12 +555,12 @@ public void Can_generate_for_custom_namespace() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(@" - [Resource(ControllerNamespace = ""Some.Path.To.Generate.Code.In"")] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(ControllerNamespace = ""Some.Path.To.Generate.Code.In"")] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -616,12 +616,12 @@ public void Can_generate_for_top_level_namespace() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("TopLevel") .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -676,12 +676,12 @@ public void Can_generate_for_global_namespace() .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithCode(@" - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -734,12 +734,12 @@ public void Can_generate_for_shared_namespace() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi") .WithCode(@" - [Resource(ControllerNamespace = ""ExampleApi"")] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - }") + [Resource(ControllerNamespace = ""ExampleApi"")] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() @@ -793,25 +793,25 @@ public void Generates_unique_file_names_for_duplicate_resource_name_in_different .WithNamespaceImportFor(typeof(IIdentifiable)) .WithNamespaceImportFor(typeof(ResourceAttribute)) .WithCode(@" - namespace The.First.One + namespace The.First.One + { + [Resource] + public sealed class Item : Identifiable { - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - } + [Attr] + public int Value { get; set; } } + } - namespace The.Second.One + namespace The.Second.One + { + [Resource] + public sealed class Item : Identifiable { - [Resource] - public sealed class Item : Identifiable - { - [Attr] - public int Value { get; set; } - } - }") + [Attr] + public int Value { get; set; } + } + }") .Build(); Compilation inputCompilation = new CompilationBuilder() From 9e1f9dcedd6eb7ca707ad6e58c35623676a01d78 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:57:48 +0100 Subject: [PATCH 16/58] Apply new suggestions from Resharper EAP --- .../Configuration/ResourceDescriptorAssemblyCache.cs | 5 +---- .../Middleware/JsonApiRoutingConvention.cs | 8 ++++---- .../Internal/QueryableBuilding/SelectClauseBuilder.cs | 5 +---- .../Internal/PaginationQueryStringParameterReader.cs | 6 +----- .../Serialization/Response/MetaBuilder.cs | 2 +- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index e73b48ee3d..0e9f5d753f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -12,10 +12,7 @@ internal sealed class ResourceDescriptorAssemblyCache public void RegisterAssembly(Assembly assembly) { - if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) - { - _resourceDescriptorsPerAssembly[assembly] = null; - } + _resourceDescriptorsPerAssembly.TryAdd(assembly, null); } public IReadOnlyCollection GetResourceDescriptors() diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 5c6b84cba7..a6ef712adf 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -101,10 +101,10 @@ public void Apply(ApplicationModel application) $"resource type '{resourceClrType}', which does not exist in the resource graph."); } - if (_controllerPerResourceTypeMap.ContainsKey(resourceType)) + if (_controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? existingModel)) { throw new InvalidConfigurationException( - $"Multiple controllers found for resource type '{resourceType}': '{_controllerPerResourceTypeMap[resourceType].ControllerType}' and '{controller.ControllerType}'."); + $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'."); } _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); @@ -119,10 +119,10 @@ public void Apply(ApplicationModel application) string template = TemplateFromResource(controller) ?? TemplateFromController(controller); - if (_registeredControllerNameByTemplate.ContainsKey(template)) + if (_registeredControllerNameByTemplate.TryGetValue(template, out string? controllerName)) { throw new InvalidConfigurationException( - $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); + $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{controllerName}' was already registered for this template."); } _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 9c7c7f9d81..6a4f43d7e1 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -185,10 +185,7 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary values) { ArgumentGuard.NotNull(values); - _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); + _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.TryGetValue(key, out object? value) ? value : _meta[key]); } /// From 82f04b29c0f16aa6675dc1e4663553af281d60d4 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Feb 2023 09:42:20 +0100 Subject: [PATCH 17/58] Improve change tracking performance | Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio | |---------------- |---------:|----------:|----------:|------:|-------:|----------:|------------:| | TrackChanges | 3.741 us | 0.0122 us | 0.0114 us | 1.00 | 0.0229 | 6.41 KB | 1.00 | | NewTrackChanges | 1.359 us | 0.0070 us | 0.0066 us | 0.36 | 0.0095 | 2.62 KB | 0.41 | using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging.Abstractions; namespace Benchmarks.ChangeTracking; // ReSharper disable once ClassCanBeSealed.Global [MarkdownExporter] [MemoryDiagnoser] public class ChangeTrackerBenchmarks { private readonly JsonApiRequest _request; private readonly TargetedFields _targetedFields; public ChangeTrackerBenchmarks() { IJsonApiOptions options = new JsonApiOptions(); IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); ResourceType resourceType = resourceGraph.GetResourceType(); _request = new JsonApiRequest { PrimaryResourceType = resourceType, IsCollection = true }; _targetedFields = new TargetedFields(); foreach (AttrAttribute attribute in resourceType.Attributes) { _targetedFields.Attributes.Add(attribute); } } [Benchmark(Baseline = true)] public void TrackChanges() { var changeTracker = new ResourceChangeTracker(_request, _targetedFields); var resource = new ExampleResource { Id = 1, Attr1 = "some", Attr2 = "more", Attr3 = "other", Attr4 = false, Attr5 = 123, Attr6 = default, Attr7 = default, Attr8 = default, Attr9 = DayOfWeek.Sunday }; changeTracker.SetInitiallyStoredAttributeValues(resource); resource = new ExampleResource { Id = 1, Attr1 = "new", Attr2 = "change", Attr3 = "this", Attr4 = true, Attr5 = 456, Attr6 = default, Attr7 = default, Attr8 = default, Attr9 = DayOfWeek.Saturday }; changeTracker.SetFinallyStoredAttributeValues(resource); changeTracker.HasImplicitChanges(); } [Benchmark] public void NewTrackChanges() { var changeTracker = new NewResourceChangeTracker(_request, _targetedFields); var resource = new ExampleResource { Id = 1, Attr1 = "some", Attr2 = "more", Attr3 = "other", Attr4 = false, Attr5 = 123, Attr6 = default, Attr7 = default, Attr8 = default, Attr9 = DayOfWeek.Sunday }; changeTracker.SetInitiallyStoredAttributeValues(resource); resource = new ExampleResource { Id = 1, Attr1 = "new", Attr2 = "change", Attr3 = "this", Attr4 = true, Attr5 = 456, Attr6 = default, Attr7 = default, Attr8 = default, Attr9 = DayOfWeek.Saturday }; changeTracker.SetFinallyStoredAttributeValues(resource); changeTracker.HasImplicitChanges(); } private sealed class ExampleResource : Identifiable { [Attr] public string? Attr1 { get; set; } [Attr] public string? Attr2 { get; set; } [Attr] public string? Attr3 { get; set; } [Attr] public bool Attr4 { get; set; } [Attr] public int Attr5 { get; set; } [Attr] public DateTime Attr6 { get; set; } [Attr] public DateTimeOffset Attr7 { get; set; } [Attr] public TimeSpan Attr8 { get; set; } [Attr] public DayOfWeek Attr9 { get; set; } } } --- .../Resources/ResourceChangeTracker.cs | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 89ba115a64..bcb9648320 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; @@ -13,9 +12,9 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _initiallyStoredAttributeValues; - private IDictionary? _requestAttributeValues; - private IDictionary? _finallyStoredAttributeValues; + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { @@ -50,15 +49,14 @@ public void SetFinallyStoredAttributeValues(TResource resource) _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { - var result = new Dictionary(); + var result = new Dictionary(); foreach (AttrAttribute attribute in attributes) { object? value = attribute.GetValue(resource); - string json = JsonSerializer.Serialize(value); - result.Add(attribute.PublicName, json); + result.Add(attribute.PublicName, value); } return result; @@ -71,21 +69,21 @@ public bool HasImplicitChanges() { foreach (string key in _initiallyStoredAttributeValues.Keys) { - if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) + if (_requestAttributeValues.TryGetValue(key, out object? requestValue)) { - string actualValue = _finallyStoredAttributeValues[key]; + object? actualValue = _finallyStoredAttributeValues[key]; - if (requestValue != actualValue) + if (!Equals(requestValue, actualValue)) { return true; } } else { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + object? initiallyStoredValue = _initiallyStoredAttributeValues[key]; + object? finallyStoredValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) + if (!Equals(initiallyStoredValue, finallyStoredValue)) { return true; } From 8f19d63826ccd2f561b9d2255086daeb0a52ce3d Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Feb 2023 08:49:57 +0100 Subject: [PATCH 18/58] Add workaround for failing 'dotnet pack' Caused by an unintentional breaking change in .NET SDK v7.0.200, which the latest AppVeyor image uses --- appveyor.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 3328af0009..befc9d9154 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,8 @@ image: - Ubuntu2004 - - Visual Studio 2022 + # Downgrade to workaround error NETSDK1194 during 'dotnet pack': The "--output" option isn't supported when building a solution. + # https://stackoverflow.com/questions/75453953/how-to-fix-github-actions-dotnet-publish-workflow-error-the-output-option-i + - Previous Visual Studio 2022 version: '{build}' @@ -32,7 +34,7 @@ for: - matrix: only: - - image: Visual Studio 2022 + - image: Previous Visual Studio 2022 services: - postgresql15 install: From d1ea9adf5bd61cd18e0aed11a22d68addb866ecf Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Feb 2023 10:08:13 +0100 Subject: [PATCH 19/58] Fixed: a duplicate trailing slash was rendered in Location header when request path ended with a slash. For example: /workItems/ => /workItems//1 --- src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs | 2 +- .../ReadWrite/Creating/CreateResourceTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 22efab2840..fb3cd2bd2d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -207,7 +207,7 @@ public virtual async Task PostAsync([FromBody] TResource resource TResource? newResource = await _create.CreateAsync(resource, cancellationToken); string resourceId = (newResource ?? resource).StringId!; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + string locationUrl = HttpContext.Request.Path.Add($"/{resourceId}"); if (newResource == null) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 587b7d8277..da8c60a34f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -49,7 +49,7 @@ public async Task Sets_location_header_for_created_resource() } }; - const string route = "/workItems"; + const string route = "/workItems/"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -61,7 +61,7 @@ public async Task Sets_location_header_for_created_resource() httpResponse.Headers.Location.Should().Be($"/workItems/{newWorkItemId}"); responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be("http://localhost/workItems"); + responseDocument.Links.Self.Should().Be("http://localhost/workItems/"); responseDocument.Links.First.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); From eae0e780d8417923a963380189c8e3e97134ec1f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 28 Feb 2023 11:33:11 +0100 Subject: [PATCH 20/58] Update usages of "paging" with "pagination" --- docs/usage/options.md | 4 ++-- .../Resources/Annotations/LinkTypes.shared.cs | 4 ++-- .../Configuration/IJsonApiOptions.cs | 2 +- .../Queries/IPaginationContext.cs | 13 +++++----- .../PaginationQueryStringParameterReader.cs | 2 +- .../Resources/IResourceDefinition.cs | 2 +- .../Serialization/Response/LinkBuilder.cs | 2 +- .../PaginationWithTotalCountTests.cs | 10 ++++---- .../Pagination/RangeValidationTests.cs | 6 ++--- .../RangeValidationWithMaximumTests.cs | 6 ++--- .../UnitTests/Links/LinkInclusionTests.cs | 24 +++++++++---------- .../PaginationParseTests.cs | 4 ++-- 12 files changed, 40 insertions(+), 39 deletions(-) diff --git a/docs/usage/options.md b/docs/usage/options.md index 3512a9d9f2..6c896b9698 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -22,7 +22,7 @@ options.AllowClientGeneratedIds = true; ## Pagination -The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to `null`. +The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`. The maximum page size and number allowed from client requests can be set too (unconstrained by default). You can also include the total number of resources in each response. @@ -38,7 +38,7 @@ options.IncludeTotalResourceCount = true; ``` To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined. -If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. +If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. ## Relative Links diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs index 61c7e9d927..7e996828b9 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs @@ -5,8 +5,8 @@ public enum LinkTypes { Self = 1 << 0, Related = 1 << 1, - Paging = 1 << 2, + Pagination = 1 << 2, NotConfigured = 1 << 3, None = 1 << 4, - All = Self | Related | Paging + All = Self | Related | Pagination } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index bc7d17d89f..fcec2af464 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -99,7 +99,7 @@ public interface IJsonApiOptions bool IncludeTotalResourceCount { get; } /// - /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. + /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default. /// PageSize? DefaultPageSize { get; } diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index 44578e5277..e39b3ca354 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -3,18 +3,19 @@ namespace JsonApiDotNetCore.Queries; /// -/// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. +/// Tracks values used for top-level pagination, which is a combined effort from options, query string parsing, resource definition callbacks and +/// fetching the total number of rows. /// public interface IPaginationContext { /// - /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. + /// The value 1, unless overridden from query string or resource definition. Should not be higher than . /// PageNumber PageNumber { get; set; } /// - /// The default page size from options, unless specified in query string. Can be null, which means no paging. Cannot be higher than - /// options.MaximumPageSize. + /// The default page size from options, unless overridden from query string or resource definition. Should not be higher than + /// . Can be null, which means pagination is disabled. /// PageSize? PageSize { get; set; } @@ -25,12 +26,12 @@ public interface IPaginationContext bool IsPageFull { get; set; } /// - /// The total number of resources. null when is set to false. + /// The total number of resources, or null when is set to false. /// int? TotalResourceCount { get; set; } /// - /// The total number of resource pages. null when is set to false or + /// The total number of resource pages, or null when is set to false or /// is null. /// int? TotalPageCount { get; } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index f97c03399e..c6ec12afb6 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -73,7 +73,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); + throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", exception.Message, exception); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 9af79831b2..31e4b9592a 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -60,7 +60,7 @@ public interface IResourceDefinition /// An optional existing pagination, coming from query string. Can be null. /// /// - /// The changed pagination, or null to use the first page with default size from options. To disable paging, set + /// The changed pagination, or null to use the first page with default size from options. To disable pagination, set /// to null. /// PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index fb86eb084c..c6d32e2648 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -85,7 +85,7 @@ private static string NoAsyncSuffix(string actionName) links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); } - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Pagination, resourceType)) { SetPaginationInTopLevelLinks(resourceType!, links); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 194e73a5dc..3f04403ee0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -88,7 +88,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -463,7 +463,7 @@ public async Task Cannot_paginate_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -485,7 +485,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -528,7 +528,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Returns_all_resources_when_paging_is_disabled() + public async Task Returns_all_resources_when_pagination_is_disabled() { // Arrange var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index bcbd864d65..6b715f0825 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -42,7 +42,7 @@ public async Task Cannot_use_negative_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -64,7 +64,7 @@ public async Task Cannot_use_zero_page_number() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -123,7 +123,7 @@ public async Task Cannot_use_negative_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page size cannot be negative."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index cdbb9ea4be..66cb0dca57 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -71,7 +71,7 @@ public async Task Cannot_use_page_number_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -93,7 +93,7 @@ public async Task Cannot_use_zero_page_size() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be("Page size cannot be unconstrained."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); @@ -144,7 +144,7 @@ public async Task Cannot_use_page_size_over_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index cbc63de58f..cc4583ed4c 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -21,37 +21,37 @@ public sealed class LinkInclusionTests [InlineData(LinkTypes.NotConfigured, LinkTypes.None, LinkTypes.None)] [InlineData(LinkTypes.NotConfigured, LinkTypes.Self, LinkTypes.Self)] [InlineData(LinkTypes.NotConfigured, LinkTypes.Related, LinkTypes.Related)] - [InlineData(LinkTypes.NotConfigured, LinkTypes.Paging, LinkTypes.Paging)] + [InlineData(LinkTypes.NotConfigured, LinkTypes.Pagination, LinkTypes.Pagination)] [InlineData(LinkTypes.NotConfigured, LinkTypes.All, LinkTypes.All)] [InlineData(LinkTypes.None, LinkTypes.NotConfigured, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.None, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.Self, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.Related, LinkTypes.None)] - [InlineData(LinkTypes.None, LinkTypes.Paging, LinkTypes.None)] + [InlineData(LinkTypes.None, LinkTypes.Pagination, LinkTypes.None)] [InlineData(LinkTypes.None, LinkTypes.All, LinkTypes.None)] [InlineData(LinkTypes.Self, LinkTypes.NotConfigured, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.None, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.Self, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.Related, LinkTypes.Self)] - [InlineData(LinkTypes.Self, LinkTypes.Paging, LinkTypes.Self)] + [InlineData(LinkTypes.Self, LinkTypes.Pagination, LinkTypes.Self)] [InlineData(LinkTypes.Self, LinkTypes.All, LinkTypes.Self)] [InlineData(LinkTypes.Related, LinkTypes.NotConfigured, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.None, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.Self, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.Related, LinkTypes.Related)] - [InlineData(LinkTypes.Related, LinkTypes.Paging, LinkTypes.Related)] + [InlineData(LinkTypes.Related, LinkTypes.Pagination, LinkTypes.Related)] [InlineData(LinkTypes.Related, LinkTypes.All, LinkTypes.Related)] - [InlineData(LinkTypes.Paging, LinkTypes.NotConfigured, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.None, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Self, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Related, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.Paging, LinkTypes.Paging)] - [InlineData(LinkTypes.Paging, LinkTypes.All, LinkTypes.Paging)] + [InlineData(LinkTypes.Pagination, LinkTypes.NotConfigured, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.None, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Self, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Related, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.Pagination, LinkTypes.Pagination)] + [InlineData(LinkTypes.Pagination, LinkTypes.All, LinkTypes.Pagination)] [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.All)] - [InlineData(LinkTypes.All, LinkTypes.Paging, LinkTypes.All)] + [InlineData(LinkTypes.All, LinkTypes.Pagination, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All)] public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { @@ -117,7 +117,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso topLevelLinks.Related.Should().BeNull(); } - if (expected.HasFlag(LinkTypes.Paging)) + if (expected.HasFlag(LinkTypes.Pagination)) { topLevelLinks.First.ShouldNotBeNull(); topLevelLinks.Last.ShouldNotBeNull(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index 5dc6e3ab75..615bae48e2 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -82,7 +82,7 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes ErrorObject error = exception.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be(errorMessage); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); @@ -118,7 +118,7 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa ErrorObject error = exception.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); + error.Title.Should().Be("The specified pagination is invalid."); error.Detail.Should().Be(errorMessage); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); From 2e1b804d7a7ad96811b1ed19235e28104409c611 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 28 Feb 2023 11:13:35 +0100 Subject: [PATCH 21/58] Fixed: missing test coverage for applying default page size in includes --- .../Pagination/PaginationWithTotalCountTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 3f04403ee0..9174c84058 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -500,6 +500,7 @@ public async Task Uses_default_page_number_and_size() Blog blog = _fakers.Blog.Generate(); blog.Posts = _fakers.BlogPost.Generate(3); + blog.Posts.ToList().ForEach(post => post.Labels = _fakers.Label.Generate(3).ToHashSet()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -507,7 +508,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/blogs/{blog.StringId}/posts"; + string route = $"/blogs/{blog.StringId}/posts?include=labels"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -519,10 +520,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); + responseDocument.Included.ShouldHaveCount(4); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}?page%5Bnumber%5D=2"); + responseDocument.Links.Last.Should().Be($"{responseDocument.Links.Self}&page%5Bnumber%5D=2"); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } From 4055c6f33b8ac6bca28018f0b54e8b5ea9f00544 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 28 Feb 2023 17:44:05 +0100 Subject: [PATCH 22/58] Simplify meta:total assertions and produce better error on failure Before: Expected element.GetInt32() to be 25, but found 2 (difference of -23). After: Expected responseDocument.Meta to be 25, but found 2 (difference of -23). --- .../Meta/TopLevelCountTests.cs | 19 +++----------- .../NamingConventions/KebabCasingTests.cs | 7 +----- .../Reading/ResourceDefinitionReadTests.cs | 25 +++---------------- .../ObjectAssertionsExtensions.cs | 10 ++++++++ 4 files changed, 18 insertions(+), 43 deletions(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index 53d6841b10..ff3360be30 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -54,11 +53,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); - }); + responseDocument.Meta.Should().ContainTotal(1); } [Fact] @@ -84,11 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); - }); + responseDocument.Meta.Should().ContainTotal(1); } [Fact] @@ -108,11 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(0); - }); + responseDocument.Meta.Should().ContainTotal(0); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index ab0a4a4a7e..d70f50de0e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; @@ -57,11 +56,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Relationships.Should().BeNull(); responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index db80d4c14b..6176b28a38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -238,11 +237,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(planets[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(planets[3].StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -297,11 +292,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(planets[3].StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); - }); + responseDocument.Meta.Should().ContainTotal(1); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -349,11 +340,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -405,11 +392,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs index a295e1eaf9..ee2be771e1 100644 --- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs +++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using FluentAssertions; +using FluentAssertions.Collections; using FluentAssertions.Numeric; using FluentAssertions.Primitives; using JetBrains.Annotations; @@ -65,4 +66,13 @@ private static string ToJsonString(JsonDocument document) writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } + + /// + /// Asserts that a "meta" dictionary contains a single element named "total" with the specified value. + /// + [CustomAssertion] + public static void ContainTotal(this GenericDictionaryAssertions, string, object?> source, int expectedTotal) + { + source.ContainKey("total").WhoseValue.Should().BeOfType().Subject.GetInt32().Should().Be(expectedTotal); + } } From adc51e1e6dce66bc67aa52f8599990eb1900bc3f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 2 Mar 2023 11:54:47 +0100 Subject: [PATCH 23/58] Clarify documentation --- src/JsonApiDotNetCore/Resources/IResourceDefinition.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 31e4b9592a..41d366b5c3 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -241,9 +241,9 @@ Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRe /// /// /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of - /// resources to be removed. + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . In contrast to other relationship methods, only the left ID and only the subset of right resources to be removed + /// are retrieved from the underlying data store. /// /// /// The to-many relationship being removed from. From 039ee720ab40d8dd1a2baf6e56a5ed2e43ea7cea Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:25:06 +0100 Subject: [PATCH 24/58] Package updates --- .config/dotnet-tools.json | 4 ++-- Directory.Build.props | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d77e271e4d..e713343770 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,13 +21,13 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.15", + "version": "5.1.19", "commands": [ "reportgenerator" ] }, "docfx": { - "version": "2.60.2", + "version": "2.62.2", "commands": [ "docfx" ] diff --git a/Directory.Build.props b/Directory.Build.props index 34807583da..a7ee36a2d9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ 6.0.* 7.0.* 7.0.* - 4.4.* + 4.5.* 2.14.1 5.1.3 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset @@ -17,7 +17,7 @@ - + From 77e838b1ed99c0aeec6b07088e03a86d321bbfde Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Mar 2023 01:29:26 +0100 Subject: [PATCH 25/58] Fix heading --- docs/getting-started/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index 2acde9d7a8..d47cf8618e 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -125,7 +125,7 @@ Your controller method needs to store the request state (URL, query string, and From within your background process job handler, reconstruct the request state, execute the appropriate `JsonApiResourceService` method and store the result. There's a basic example available at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1144, which processes a captured query string. -### What if I want to use something other than Entity Framework Core? +#### What if I want to use something other than Entity Framework Core? This basically means you'll need to implement data access yourself. There are two approaches for interception: at the resource service level and at the repository level. Either way, you can use the built-in query string and request body parsing, as well as routing, error handling, and rendering of responses. From 6ef75c93d5ea3858a1effcc5ac3b7abfdc1e5490 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 16 Mar 2023 01:47:08 +0100 Subject: [PATCH 26/58] Fix version mismatch on Microsoft.EntityFrameworkCore.Relational, originating from Microsoft.EntityFrameworkCore and Npgsql.EntityFrameworkCore.PostgreSQL (#1264) --- .../DatabasePerTenantExample/DatabasePerTenantExample.csproj | 2 +- .../JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj | 2 +- .../NoEntityFrameworkExample/NoEntityFrameworkExample.csproj | 2 +- src/Examples/ReportsExample/ReportsExample.csproj | 2 +- test/TestBuildingBlocks/TestBuildingBlocks.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index b243e99ec2..a48f472a70 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index b243e99ec2..a48f472a70 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 358d82c02f..a244287c13 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index b243e99ec2..a48f472a70 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 16dabc8d54..46193372a8 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -12,7 +12,7 @@ - + From d137c3a103f9332c62c58ba3c4a521b95d7acb74 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 29 Mar 2023 02:10:07 +0200 Subject: [PATCH 27/58] Refresh NuGet API key --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index befc9d9154..80b2374e8f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -83,14 +83,14 @@ for: - provider: NuGet skip_symbols: false api_key: - secure: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa + secure: oy2i3qo34xygjtxsjq5q5mcxpispceluvorutzd7kkxejm on: branch: master appveyor_repo_tag: true - provider: NuGet skip_symbols: false api_key: - secure: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa + secure: oy2i3qo34xygjtxsjq5q5mcxpispceluvorutzd7kkxejm on: branch: /release\/.+/ appveyor_repo_tag: true From d2e321c9b2023174e7bf6ff2f91911d6044c8a6f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:01:19 +0200 Subject: [PATCH 28/58] Refresh leaked NuGet API key --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 80b2374e8f..ca29435445 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -83,14 +83,14 @@ for: - provider: NuGet skip_symbols: false api_key: - secure: oy2i3qo34xygjtxsjq5q5mcxpispceluvorutzd7kkxejm + secure: hlP/zkfkHzmutSXPYAiINmPdv+QEj3TpAjKewHEkCtQnHnA2tSo+Xey0g6FVM6S5 on: branch: master appveyor_repo_tag: true - provider: NuGet skip_symbols: false api_key: - secure: oy2i3qo34xygjtxsjq5q5mcxpispceluvorutzd7kkxejm + secure: hlP/zkfkHzmutSXPYAiINmPdv+QEj3TpAjKewHEkCtQnHnA2tSo+Xey0g6FVM6S5 on: branch: /release\/.+/ appveyor_repo_tag: true From 5dd256f76c9f5cee1644bdf2dcc2b136db81e513 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 7 Apr 2023 18:08:49 +0200 Subject: [PATCH 29/58] Improve performance of response serialization for large number of included resources --- .../Serialization/Response/ResourceObjectTreeNode.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index 4c3a44fe38..6226d6e597 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -181,13 +181,14 @@ public IList GetResponseIncluded() VisitRelationshipChildrenInSubtree(child, visited); } - List includes = visited.Select(node => node.ResourceObject).ToList(); + ISet primaryResourceObjectSet = GetDirectChildren().Select(node => node.ResourceObject).ToHashSet(ResourceObjectComparer.Instance); + List includes = new(); - foreach (ResourceObject primaryResourceObject in GetDirectChildren().Select(node => node.ResourceObject)) + foreach (ResourceObject include in visited.Select(node => node.ResourceObject)) { - if (includes.Contains(primaryResourceObject, ResourceObjectComparer.Instance)) + if (!primaryResourceObjectSet.Contains(include)) { - includes.Remove(primaryResourceObject); + includes.Add(include); } } From db852cf15d112ae7e11aa18d6f3a80b50b1318c4 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 29 Mar 2023 12:37:10 +0200 Subject: [PATCH 30/58] Use deterministic culture when converting to string --- .../Resources/Internal/RuntimeTypeConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs index c76aa09b82..b209964232 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs @@ -46,7 +46,7 @@ public static class RuntimeTypeConverter return value; } - string? stringValue = value.ToString(); + string? stringValue = value is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, cultureInfo) : value.ToString(); if (string.IsNullOrEmpty(stringValue)) { From d47751da63014a6df9a23215bcc93b4ff40c0769 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 12 Nov 2022 09:49:41 +0100 Subject: [PATCH 31/58] Update to Resharper v2023.1 --- .config/dotnet-tools.json | 2 +- Build.ps1 | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e713343770..40d3ba4893 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2022.2.4", + "version": "2023.1.0", "commands": [ "jb" ] diff --git a/Build.ps1 b/Build.ps1 index 9b076bc4d8..a532a0113b 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -104,11 +104,8 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode -# https://youtrack.jetbrains.com/issue/RSRP-488628/Breaking-InspectCode-fails-with-Roslyn-Worker-process-exited-unexpectedly-after-update -if ($IsWindows) { - RunInspectCode - RunCleanupCode -} +RunInspectCode +RunCleanupCode dotnet test -c Release --no-build --collect:"XPlat Code Coverage" CheckLastExitCode From 87a7569b39512f2aa3709a0f5a6f1d284bb680b9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 12 Mar 2023 13:28:20 +0100 Subject: [PATCH 32/58] InspectCode: Include Roslyn diagnostic ID --- Build.ps1 | 2 +- JetBrainsInspectCodeTransform.xslt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Build.ps1 b/Build.ps1 index a532a0113b..2a143dd168 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -23,7 +23,7 @@ function RunInspectCode { $issueType = $xml.report.IssueTypes.SelectSingleNode("IssueType[@Id='$($_.TypeId)']") $severity = $_.Severity ?? $issueType.Severity - Write-Output "[$severity] $($_.File):$($_.Line) $($_.Message)" + Write-Output "[$severity] $($_.File):$($_.Line) $($_.TypeId): $($_.Message)" }) }) } diff --git a/JetBrainsInspectCodeTransform.xslt b/JetBrainsInspectCodeTransform.xslt index 098821f29f..28fa772b0f 100644 --- a/JetBrainsInspectCodeTransform.xslt +++ b/JetBrainsInspectCodeTransform.xslt @@ -25,6 +25,7 @@ File Line Number + Type Message @@ -35,6 +36,9 @@ + + + From 476dab04280e1d0b2d0becf77e3fc4fc269ca70e Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 12 Mar 2023 12:20:42 +0100 Subject: [PATCH 33/58] Enable new inspections --- JsonApiDotNetCore.sln.DotSettings | 14 ++++++++++---- WarningSeverities.DotSettings | 14 ++++++++++++++ .../CollectionConverter.cs | 11 ++++------- .../SourceCodeWriter.cs | 2 +- .../Configuration/ResourceGraphBuilder.cs | 2 +- src/JsonApiDotNetCore/Configuration/TypeLocator.cs | 2 +- .../Errors/InvalidModelStateException.cs | 2 +- .../Queries/Internal/Parsing/FilterParser.cs | 2 +- .../Internal/Parsing/QueryExpressionParser.cs | 2 +- .../Queries/Internal/SystemExpressionExtensions.cs | 2 +- .../Resources/SortExpressionLambdaConverter.cs | 6 +++--- .../Request/Adapters/ResourceIdentityAdapter.cs | 2 +- .../Serialization/Response/LinkBuilder.cs | 2 +- .../CompositeKeys/CarExpressionRewriter.cs | 2 +- 14 files changed, 41 insertions(+), 24 deletions(-) diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 05a03c584b..2b714c22d3 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -28,6 +28,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); SUGGESTION SUGGESTION WARNING + WARNING SUGGESTION SUGGESTION SUGGESTION @@ -54,16 +55,16 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); WARNING WARNING WARNING + SUGGESTION HINT WARNING DO_NOT_SHOW HINT SUGGESTION - WARNING - WARNING + SUGGESTION + SUGGESTION WARNING WARNING - SUGGESTION WARNING SUGGESTION SUGGESTION @@ -76,6 +77,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); SUGGESTION SUGGESTION SUGGESTION + WARNING WARNING WARNING WARNING @@ -88,8 +90,10 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); WARNING WARNING WARNING + SUGGESTION + SUGGESTION WARNING - <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags></Profile> + <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSReformatInactiveBranches>True</CSReformatInactiveBranches></Profile> JADNC Full Cleanup Required Required @@ -116,6 +120,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); False False False + False False False False @@ -134,6 +139,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$); False False CHOP_ALWAYS + False True True True diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index 0d4eeba96f..96f358da23 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -13,6 +13,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -70,6 +71,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -82,6 +84,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -97,8 +100,13 @@ WARNING WARNING WARNING + WARNING WARNING WARNING + WARNING + WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -109,12 +117,14 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -135,6 +145,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -152,6 +163,8 @@ WARNING WARNING WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -240,6 +253,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index fa1c0c90bd..6edce84335 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -45,7 +45,7 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType /// private Type ToConcreteCollectionType(Type collectionType) { - if (collectionType.IsInterface && collectionType.IsGenericType) + if (collectionType is { IsInterface: true, IsGenericType: true }) { Type openCollectionType = collectionType.GetGenericTypeDefinition(); @@ -101,14 +101,11 @@ public IReadOnlyCollection ExtractResources(object? value) /// public Type? FindCollectionElementType(Type? type) { - if (type != null) + if (type is { IsGenericType: true, GenericTypeArguments.Length: 1 }) { - if (type.IsGenericType && type.GenericTypeArguments.Length == 1) + if (type.IsOrImplementsInterface()) { - if (type.IsOrImplementsInterface()) - { - return type.GenericTypeArguments[0]; - } + return type.GenericTypeArguments[0]; } } diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index f138985157..3ac3f5cb64 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -61,7 +61,7 @@ public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEn WriteAutoGeneratedComment(); - if (idType.IsReferenceType && idType.NullableAnnotation == NullableAnnotation.Annotated) + if (idType is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated }) { WriteNullableEnable(); } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 2b6f19acd3..97548bd8fa 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -159,7 +159,7 @@ public ResourceGraphBuilder Add(DbContext dbContext) private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) { - return entityType.IsPropertyBag && entityType.HasSharedClrType; + return entityType is { IsPropertyBag: true, HasSharedClrType: true }; } /// diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 981c50c4e4..9f65b46b97 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -89,7 +89,7 @@ internal sealed class TypeLocator private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, Type[] interfaceTypeArguments) { - if (!nextType.IsNested && !nextType.IsAbstract && !nextType.IsInterface) + if (nextType is { IsNested: false, IsAbstract: false, IsInterface: false }) { foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index be4de87fc8..50cb511b14 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -67,7 +67,7 @@ private static IEnumerable FromModelStateDictionary(IReadOnlyDictio return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); } - if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + if (propertySegment is { PropertyName: nameof(OperationContainer.Resource), Parent: not null } && propertySegment.Parent.ModelType == typeof(IList)) { // Special case: Stepping over OperationContainer.Resource property. diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index cdada38fb4..bdac0d8962 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -372,7 +372,7 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen { if (TokenStack.TryPeek(out Token? nextToken)) { - if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) + if (nextToken is { Kind: TokenKind.Text, Value: Keywords.Null }) { TokenStack.Pop(); return NullConstantExpression.Instance; diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index 681c1dd8f4..27466e3b0a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -72,7 +72,7 @@ private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMe protected CountExpression? TryParseCount() { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count }) { TokenStack.Pop(); diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs index a314d5f20a..9c77f53938 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs @@ -21,7 +21,7 @@ public static Expression CreateTupleAccessExpressionForConstant(this object? val // Tuple.Create(value).Item1; MethodInfo tupleCreateUnboundMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); + .Single(method => method is { Name: "Create", IsGenericMethod: true } && method.GetGenericArguments().Length == 1); MethodInfo tupleCreateClosedMethod = tupleCreateUnboundMethod.MakeGenericMethod(type); diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs index f1efa204d4..8696195e28 100644 --- a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs +++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs @@ -69,7 +69,7 @@ private static Expression SkipConvert(Expression expression) private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression) { - if (expression is MethodCallExpression methodCallExpression && methodCallExpression.Method.Name == "Count") + if (expression is MethodCallExpression { Method.Name: "Count" } methodCallExpression) { if (methodCallExpression.Arguments.Count <= 1) { @@ -81,7 +81,7 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi if (expression is MemberExpression memberExpression) { - if (memberExpression.Member.MemberType == MemberTypes.Property && memberExpression.Member.Name is "Count" or "Length") + if (memberExpression.Member is { MemberType: MemberTypes.Property, Name: "Count" or "Length" }) { if (memberExpression.Member.GetCustomAttribute() == null) { @@ -114,7 +114,7 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi private Expression? ReadAttribute(Expression expression) { - if (expression is MemberExpression { Expression: { } } memberExpression) + if (expression is MemberExpression { Expression: not null } memberExpression) { ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Expression.Type); AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index e4c0df21df..6964427680 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -131,7 +131,7 @@ private static void AssertHasNoLid(ResourceIdentity identity, RequestAdapterStat private static void AssertNoIdWithLid(ResourceIdentity identity, RequestAdapterState state) { - if (identity.Id != null && identity.Lid != null) + if (identity is { Id: not null, Lid: not null }) { throw new ModelConversionException(state.Position, "The 'id' and 'lid' element are mutually exclusive.", null); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index c6d32e2648..7550cbf761 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -80,7 +80,7 @@ private static string NoAsyncSuffix(string actionName) links.Self = GetLinkForTopLevelSelf(); } - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) + if (_request is { Kind: EndpointKind.Relationship, Relationship: not null } && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) { links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index a599ed8eae..2c1e378e77 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -30,7 +30,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) { - if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) + if (expression is { Left: ResourceFieldChainExpression leftChain, Right: LiteralConstantExpression rightConstant }) { PropertyInfo leftProperty = leftChain.Fields[^1].Property; From 9c38d5548d71b0b36ca61066cb93b81574682ab1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:35:43 +0200 Subject: [PATCH 34/58] Apply Resharper hint: convert switch statement to expression --- .../SourceCodeWriter.cs | 19 +++------ .../OperationProcessorAccessor.cs | 39 +++++-------------- .../Middleware/ExceptionHandler.cs | 12 ++++-- .../QueryableBuilding/WhereClauseBuilder.cs | 32 ++++----------- .../EntityFrameworkCoreRepository.cs | 19 +++------ 5 files changed, 35 insertions(+), 86 deletions(-) diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index 3ac3f5cb64..648906e901 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -130,21 +130,12 @@ private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCo private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate) { - switch (endpointsToGenerate) + return endpointsToGenerate switch { - case JsonApiEndpointsCopy.Query: - { - return "JsonApiQueryController"; - } - case JsonApiEndpointsCopy.Command: - { - return "JsonApiCommandController"; - } - default: - { - return "JsonApiController"; - } - } + JsonApiEndpointsCopy.Query => "JsonApiQueryController", + JsonApiEndpointsCopy.Command => "JsonApiCommandController", + _ => "JsonApiController" + }; } private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFactoryInterface, JsonApiEndpointsCopy endpointsToGenerate, diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index c2397267b3..a052aa2d6e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -40,36 +40,15 @@ protected virtual IOperationProcessor ResolveProcessor(OperationContainer operat private static Type GetProcessorInterface(WriteOperationKind writeOperation) { - switch (writeOperation) + return writeOperation switch { - case WriteOperationKind.CreateResource: - { - return typeof(ICreateProcessor<,>); - } - case WriteOperationKind.UpdateResource: - { - return typeof(IUpdateProcessor<,>); - } - case WriteOperationKind.DeleteResource: - { - return typeof(IDeleteProcessor<,>); - } - case WriteOperationKind.SetRelationship: - { - return typeof(ISetRelationshipProcessor<,>); - } - case WriteOperationKind.AddToRelationship: - { - return typeof(IAddToRelationshipProcessor<,>); - } - case WriteOperationKind.RemoveFromRelationship: - { - return typeof(IRemoveFromRelationshipProcessor<,>); - } - default: - { - throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'."); - } - } + WriteOperationKind.CreateResource => typeof(ICreateProcessor<,>), + WriteOperationKind.UpdateResource => typeof(IUpdateProcessor<,>), + WriteOperationKind.DeleteResource => typeof(IDeleteProcessor<,>), + WriteOperationKind.SetRelationship => typeof(ISetRelationshipProcessor<,>), + WriteOperationKind.AddToRelationship => typeof(IAddToRelationshipProcessor<,>), + WriteOperationKind.RemoveFromRelationship => typeof(IRemoveFromRelationshipProcessor<,>), + _ => throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'.") + }; } } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index b8690402a5..80905d1231 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -71,15 +71,19 @@ protected virtual IReadOnlyList CreateErrorResponse(Exception excep { ArgumentGuard.NotNull(exception); - IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : - exception is OperationCanceledException ? new ErrorObject((HttpStatusCode)499) + IReadOnlyList errors = exception switch + { + JsonApiException jsonApiException => jsonApiException.Errors, + OperationCanceledException => new ErrorObject((HttpStatusCode)499) { Title = "Request execution was canceled." - }.AsArray() : new ErrorObject(HttpStatusCode.InternalServerError) + }.AsArray(), + _ => new ErrorObject(HttpStatusCode.InternalServerError) { Title = "An unhandled error occurred while processing this request.", Detail = exception.Message - }.AsArray(); + }.AsArray() + }; if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 87cd066a85..c2e8407c8e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -191,31 +191,15 @@ public override Expression VisitComparison(ComparisonExpression expression, obje Expression left = WrapInConvert(Visit(expression.Left, argument), commonType); Expression right = WrapInConvert(Visit(expression.Right, argument), commonType); - switch (expression.Operator) + return expression.Operator switch { - case ComparisonOperator.Equals: - { - return Expression.Equal(left, right); - } - case ComparisonOperator.LessThan: - { - return Expression.LessThan(left, right); - } - case ComparisonOperator.LessOrEqual: - { - return Expression.LessThanOrEqual(left, right); - } - case ComparisonOperator.GreaterThan: - { - return Expression.GreaterThan(left, right); - } - case ComparisonOperator.GreaterOrEqual: - { - return Expression.GreaterThanOrEqual(left, right); - } - } - - throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'."); + ComparisonOperator.Equals => Expression.Equal(left, right), + ComparisonOperator.LessThan => Expression.LessThan(left, right), + ComparisonOperator.LessOrEqual => Expression.LessThanOrEqual(left, right), + ComparisonOperator.GreaterThan => Expression.GreaterThan(left, right), + ComparisonOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, right), + _ => throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'.") + }; } private Type ResolveCommonType(QueryExpression left, QueryExpression right) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 074e902341..89cfe76b87 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -347,21 +347,12 @@ private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttri { EntityEntry entityEntry = _dbContext.Entry(resource); - switch (relationship) + return relationship switch { - case HasOneAttribute hasOneRelationship: - { - return entityEntry.Reference(hasOneRelationship.Property.Name); - } - case HasManyAttribute hasManyRelationship: - { - return entityEntry.Collection(hasManyRelationship.Property.Name); - } - default: - { - throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); - } - } + HasOneAttribute hasOneRelationship => entityEntry.Reference(hasOneRelationship.Property.Name), + HasManyAttribute hasManyRelationship => entityEntry.Collection(hasManyRelationship.Property.Name), + _ => throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'.") + }; } private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) From f7019615b4a3618256e0a38c698e47954007fc4a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:44:01 +0200 Subject: [PATCH 35/58] Apply Resharper hint: pass string interpolation --- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 2 +- src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 0e63c6a380..ec2ea30102 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -9,7 +9,7 @@ "Microsoft.EntityFrameworkCore.Update": "Critical", "Microsoft.EntityFrameworkCore.Database.Command": "Critical", "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", - "Program": "Information" + "JsonApiDotNetCoreExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index a2aa7a379b..4b4d82b62b 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -238,12 +238,12 @@ private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsed WriteIndent(builder, indent); builder.Append(Name); WritePadding(builder, indent, paddingLength); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,19:G}", timeElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{timeElapsedInSelf,19:G}"); if (!_excludeInRelativeCost) { builder.Append(" ... "); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,7:#0.00%}", scaleElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{scaleElapsedInSelf,7:#0.00%}"); } if (_stoppedAt == null) From 865b3a3ec68ce904cd2f6f59787490fec4197de4 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 25 Mar 2023 18:51:55 +0100 Subject: [PATCH 36/58] Reduce noice from IDE rules --- .editorconfig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.editorconfig b/.editorconfig index ca191cf90e..86cbbc3700 100644 --- a/.editorconfig +++ b/.editorconfig @@ -66,15 +66,18 @@ csharp_indent_case_contents_when_block = false csharp_preserve_single_line_statements = false # 'var' usage preferences -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion +csharp_style_var_for_built_in_types = false:none +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_elsewhere = false:none # Parentheses preferences dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:suggestion +# Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + #### Naming Style #### dotnet_diagnostic.IDE1006.severity = warning From 35eb36f1809178d6ce7e528b016b39af4d6f2fe2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 13 Apr 2023 13:29:04 +0200 Subject: [PATCH 37/58] Package update --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index a7ee36a2d9..820d031038 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,6 +35,6 @@ 3.2.* 4.18.* - 17.4.* + 17.5.* From 260b47e16286f540eb3cb433a795c0201f37c4f9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 20 Apr 2023 01:03:55 +0200 Subject: [PATCH 38/58] Update question template --- .github/ISSUE_TEMPLATE/question.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 689f2daa01..d63ee61032 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -7,14 +7,25 @@ assignees: '' --- + + #### SUMMARY - + #### DETAILS - + + #### STEPS TO REPRODUCE - #### SUMMARY From abb62333053541f0c5895976ecadc7e11f641b49 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:00:08 +0200 Subject: [PATCH 40/58] PostgreSQL connection strings: remove default port, add error details --- src/Examples/DatabasePerTenantExample/appsettings.json | 6 +++--- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 2 +- src/Examples/NoEntityFrameworkExample/appsettings.json | 2 +- .../Transactions/AtomicTransactionConsistencyTests.cs | 4 +++- test/TestBuildingBlocks/IntegrationTestContext.cs | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index c065f66c64..fafa64906b 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", - "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", - "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + "DefaultConnection": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", + "AdventureWorksConnection": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", + "ContosoConnection": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index ec2ea30102..4425e833fb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" + "DefaultConnection": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index cea6a7a623..e8ab902908 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,6 +1,6 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=NoEntityFrameworkExample;User ID=postgres;Password=###" + "DefaultConnection": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 46ef0c4784..d5e74fa4c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -28,7 +28,9 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 7856ba67f9..161005befc 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -65,8 +65,8 @@ private WebApplicationFactory CreateFactory() { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;" + - $"Password={postgresPassword};Include Error Detail=true"; + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword};Include Error Detail=true"; var factory = new IntegrationTestWebApplicationFactory(); From 93f6532c708b98a05cc3712db2ee849caea41dda Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:09:57 +0200 Subject: [PATCH 41/58] Use default ASP.NET location for connection strings --- .../DatabasePerTenantExample/Data/AppDbContext.cs | 2 +- src/Examples/DatabasePerTenantExample/appsettings.json | 8 ++++---- src/Examples/JsonApiDotNetCoreExample/Program.cs | 4 ++-- src/Examples/JsonApiDotNetCoreExample/appsettings.json | 4 ++-- src/Examples/NoEntityFrameworkExample/Program.cs | 2 +- .../NoEntityFrameworkExample/Services/WorkItemService.cs | 2 +- src/Examples/NoEntityFrameworkExample/appsettings.json | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index ba73b8bf3a..d6200f59d7 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -36,7 +36,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) private string GetConnectionString() { string? tenantName = GetTenantName(); - string? connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + string? connectionString = _configuration.GetConnectionString(tenantName ?? "Default"); if (connectionString == null) { diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index fafa64906b..d615577636 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", - "AdventureWorksConnection": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", - "ContosoConnection": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" + "ConnectionStrings": { + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=###;Include Error Detail=true", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=###;Include Error Detail=true", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index e2fbc66ffd..4c5ad211ef 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -48,8 +48,8 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddDbContext(options => { string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); + #if DEBUG options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); @@ -76,7 +76,7 @@ static void ConfigureServices(WebApplicationBuilder builder) static string? GetConnectionString(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } static void ConfigurePipeline(WebApplication webApplication) diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 4425e833fb..7c757dc4cb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" + "ConnectionStrings": { + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 363b58b19e..cb2d3505f3 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -29,7 +29,7 @@ static string? GetConnectionString(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 34a40755cb..a46a8a0cb5 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -16,7 +16,7 @@ public sealed class WorkItemService : IResourceService public WorkItemService(IConfiguration configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"]?.Replace("###", postgresPassword); + _connectionString = configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } public async Task> GetAsync(CancellationToken cancellationToken) diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index e8ab902908..ae9a16c7f0 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,6 +1,6 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" + "ConnectionStrings": { + "Default": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" }, "Logging": { "LogLevel": { From a4d0836f6b173dc6c5601342de3f6022b10efc96 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:54:43 +0200 Subject: [PATCH 42/58] Drop /v1 from API namespace, because it suggests API versioning should be done this way --- docs/internals/queries.md | 2 +- docs/usage/options.md | 4 ++-- docs/usage/routing.md | 8 ++++---- src/Examples/JsonApiDotNetCoreExample/Program.cs | 2 +- .../Properties/launchSettings.json | 4 ++-- src/Examples/NoEntityFrameworkExample/Program.cs | 2 +- .../Properties/launchSettings.json | 4 ++-- src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs | 6 +++--- .../Creating/AtomicCreateResourceTests.cs | 2 +- .../Deleting/AtomicDeleteResourceTests.cs | 2 +- .../Relationships/AtomicAddToToManyRelationshipTests.cs | 2 +- .../AtomicRemoveFromToManyRelationshipTests.cs | 2 +- .../Relationships/AtomicReplaceToManyRelationshipTests.cs | 2 +- .../Relationships/AtomicUpdateToOneRelationshipTests.cs | 2 +- .../Updating/Resources/AtomicUpdateResourceTests.cs | 2 +- test/NoEntityFrameworkTests/WorkItemTests.cs | 8 ++++---- 16 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 5c2b238a18..76f062c233 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -29,7 +29,7 @@ Processing a request involves the following steps: To get a sense of what this all looks like, let's look at an example query string: ``` -/api/v1/blogs? +/api/blogs? include=owner,posts.comments.author& filter=has(posts)& sort=count(posts)& diff --git a/docs/usage/options.md b/docs/usage/options.md index 6c896b9698..549bfc454c 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -55,8 +55,8 @@ options.UseRelativeLinks = true; "relationships": { "author": { "links": { - "self": "/api/v1/articles/4309/relationships/author", - "related": "/api/v1/articles/4309/author" + "self": "/articles/4309/relationships/author", + "related": "/articles/4309/author" } } } diff --git a/docs/usage/routing.md b/docs/usage/routing.md index a264622931..e3e021ec23 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -11,10 +11,10 @@ You can add a namespace to all URLs by specifying it at startup. ```c# // Program.cs -builder.Services.AddJsonApi(options => options.Namespace = "api/v1"); +builder.Services.AddJsonApi(options => options.Namespace = "api/shopping"); ``` -Which results in URLs like: https://yourdomain.com/api/v1/people +Which results in URLs like: https://yourdomain.com/api/shopping/articles ## Default routing convention @@ -66,14 +66,14 @@ It is possible to override the default routing convention for an auto-generated ```c# // Auto-generated [DisableRoutingConvention] -[Route("v1/custom/route/summaries-for-orders")] +[Route("custom/route/summaries-for-orders")] partial class OrderSummariesController { } // Hand-written [DisableRoutingConvention] -[Route("v1/custom/route/lines-in-order")] +[Route("custom/route/lines-in-order")] public class OrderLineController : JsonApiController { public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 4c5ad211ef..905e27355e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -60,7 +60,7 @@ static void ConfigureServices(WebApplicationBuilder builder) { builder.Services.AddJsonApi(options => { - options.Namespace = "api/v1"; + options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; options.SerializerOptions.WriteIndented = true; diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 6a5108a8ad..9448a05959 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchUrl": "api/todoItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchUrl": "api/todoItems", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index cb2d3505f3..df5de241c0 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -10,7 +10,7 @@ string? connectionString = GetConnectionString(builder.Configuration); builder.Services.AddNpgsql(connectionString); -builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +builder.Services.AddJsonApi(options => options.Namespace = "api", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); builder.Services.AddResourceService(); diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 82c88ace03..beecefbb9f 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/workItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/workItems", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index fcec2af464..bd068b5496 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -15,7 +15,7 @@ public interface IJsonApiOptions /// /// /// /// string? Namespace { get; } @@ -64,8 +64,8 @@ public interface IJsonApiOptions /// "relationships": { /// "author": { /// "links": { - /// "self": "/api/v1/articles/4309/relationships/author", - /// "related": "/api/v1/articles/4309/author" + /// "self": "/api/shopping/articles/4309/relationships/author", + /// "related": "/api/shopping/articles/4309/author" /// } /// } /// } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index dcd38763c9..5aff75498d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -501,7 +501,7 @@ public async Task Cannot_create_resource_for_href_element() new { op = "add", - href="/https/github.com/api/v1/musicTracks" + href="/https/github.com/api/musicTracks" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 4baf6c7816..c2dc97b612 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -334,7 +334,7 @@ public async Task Cannot_delete_resource_for_href_element() new { op = "remove", - href="/https/github.com/api/v1/musicTracks/1" + href="/https/github.com/api/musicTracks/1" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 92f1bc638b..909467ad18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -244,7 +244,7 @@ public async Task Cannot_add_for_href_element() new { op = "add", - href="/https/github.com/api/v1/musicTracks/1/relationships/performers" + href="/https/github.com/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index bed9b62d99..71b4a1bf09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -243,7 +243,7 @@ public async Task Cannot_remove_for_href_element() new { op = "remove", - href="/https/github.com/api/v1/musicTracks/1/relationships/performers" + href="/https/github.com/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index be0da69ad9..f36144ce70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -278,7 +278,7 @@ public async Task Cannot_replace_for_href_element() new { op = "update", - href="/https/github.com/api/v1/musicTracks/1/relationships/performers" + href="/https/github.com/api/musicTracks/1/relationships/performers" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 31b62a7c8e..026b4b0e1e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -531,7 +531,7 @@ public async Task Cannot_create_for_href_element() new { op = "update", - href="/https/github.com/api/v1/musicTracks/1/relationships/ownedBy" + href="/https/github.com/api/musicTracks/1/relationships/ownedBy" } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 2371ab092a..4b0a10fd82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -620,7 +620,7 @@ public async Task Cannot_update_resource_for_href_element() new { op = "update", - href="/https/github.com/api/v1/musicTracks/1" + href="/https/github.com/api/musicTracks/1" } } }; diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index 7bf09d35aa..25b132e006 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -45,7 +45,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/v1/workItems"; + const string route = "/api/workItems"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -71,7 +71,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/v1/workItems/{workItem.StringId}"; + string route = $"/api/workItems/{workItem.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -110,7 +110,7 @@ public async Task Can_create_WorkItem() } }; - const string route = "/api/v1/workItems/"; + const string route = "/api/workItems/"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); @@ -140,7 +140,7 @@ await RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/v1/workItems/{workItem.StringId}"; + string route = $"/api/workItems/{workItem.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await ExecuteDeleteAsync(route); From 67f3ef410f4207f7e191220890a3cc0c2709eb8c Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:16:31 +0200 Subject: [PATCH 43/58] Seed core example with sample data, open browser on run, use long IDs --- .../Data/AppDbContext.cs | 32 +++++++++-- .../Data/RotatingList.cs | 35 ++++++++++++ .../JsonApiDotNetCoreExample/Data/Seeder.cs | 56 +++++++++++++++++++ .../Definitions/TodoItemDefinition.cs | 4 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 10 +++- .../JsonApiDotNetCoreExample/Models/Tag.cs | 2 +- .../Models/TodoItem.cs | 9 ++- .../Models/TodoItemPriority.cs | 6 +- .../JsonApiDotNetCoreExample/Program.cs | 4 ++ .../Properties/launchSettings.json | 8 +-- 10 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 24378e3182..dd30287500 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; // @formatter:wrap_chained_method_calls chop_always @@ -18,14 +19,33 @@ public AppDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { - // When deleting a person, un-assign him/her from existing todo items. + // When deleting a person, un-assign him/her from existing todo-items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee!); + .WithOne(todoItem => todoItem.Assignee); - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany(); + // When deleting a person, the todo-items he/she owns are deleted too. + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + AdjustDeleteBehaviorForJsonApi(builder); + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs new file mode 100644 index 0000000000..59247532b9 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs @@ -0,0 +1,35 @@ +namespace JsonApiDotNetCoreExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = new(); + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList +{ + private int _index = -1; + + public IList Elements { get; } + + public RotatingList(IList elements) + { + Elements = elements; + } + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs new file mode 100644 index 0000000000..3bc2e4bacf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExample.Models; + +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int tagCount = 25; + + RotatingList people = RotatingList.Create(personCount, index => new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }); + + RotatingList tags = RotatingList.Create(tagCount, index => new Tag + { + Name = $"TagName{index + 1:D2}" + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index ee7b874fc4..c533143855 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TodoItemDefinition : JsonApiResourceDefinition +public sealed class TodoItemDefinition : JsonApiResourceDefinition { private readonly ISystemClock _systemClock; @@ -29,7 +29,7 @@ private SortExpression GetDefaultSortOrder() { return CreateSortExpressionFromLambda(new PropertySortOrder { - (todoItem => todoItem.Priority, ListSortDirection.Descending), + (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) }); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 5415d37bb3..d11fbffff6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Person : Identifiable +public sealed class Person : Identifiable { [Attr] public string? FirstName { get; set; } @@ -14,6 +15,13 @@ public sealed class Person : Identifiable [Attr] public string LastName { get; set; } = null!; + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + [HasMany] public ISet AssignedTodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 9095b0af80..8904ec01a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Tag : Identifiable +public sealed class Tag : Identifiable { [Attr] [MinLength(1)] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 5fe508f7f2..68df7cef27 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class TodoItem : Identifiable +public sealed class TodoItem : Identifiable { [Attr] public string Description { get; set; } = null!; @@ -16,6 +16,9 @@ public sealed class TodoItem : Identifiable [Required] public TodoItemPriority? Priority { get; set; } + [Attr] + public long? DurationInHours { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } @@ -25,9 +28,9 @@ public sealed class TodoItem : Identifiable [HasOne] public Person Owner { get; set; } = null!; - [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowSet)] + [HasOne] public Person? Assignee { get; set; } - [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter)] + [HasMany] public ISet Tags { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 9ef85348f1..84e3567b31 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] public enum TodoItemPriority { - Low, - Medium, - High + High = 1, + Medium = 2, + Low = 3 } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 905e27355e..ad09426d20 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -65,6 +65,7 @@ static void ConfigureServices(WebApplicationBuilder builder) options.IncludeTotalResourceCount = true; options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; @@ -98,5 +99,8 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureDeletedAsync(); await dbContext.Database.EnsureCreatedAsync(); + + await Seeder.CreateSampleDataAsync(dbContext); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 9448a05959..b3ca85be5a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "api/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/todoItems", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From f202021f7e34d7b5e93ac9f0a7670b3532af4967 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 14:49:23 +0200 Subject: [PATCH 44/58] Enable detailed logging --- .../Data/AppDbContext.cs | 7 ++-- .../DatabasePerTenantExample/Program.cs | 39 +++++++++++++------ .../DatabasePerTenantExample/appsettings.json | 2 + src/Examples/GettingStarted/Program.cs | 22 ++++++++++- .../Properties/launchSettings.json | 4 +- src/Examples/GettingStarted/appsettings.json | 2 + .../JsonApiDotNetCoreExample/Program.cs | 24 ++++++++---- .../Properties/launchSettings.json | 4 +- .../JsonApiDotNetCoreExample/appsettings.json | 9 +++-- src/Examples/MultiDbContextExample/Program.cs | 31 ++++++++++++++- .../Properties/launchSettings.json | 4 +- .../MultiDbContextExample/appsettings.json | 6 ++- .../NoEntityFrameworkExample/Program.cs | 27 +++++++++++-- .../NoEntityFrameworkExample/appsettings.json | 6 ++- src/Examples/ReportsExample/Program.cs | 12 +++++- src/Examples/ReportsExample/appsettings.json | 3 +- test/MultiDbContextTests/ResourceTests.cs | 4 +- .../IntegrationTestContext.cs | 16 +++++--- test/TestBuildingBlocks/TestableDbContext.cs | 7 +++- test/TestBuildingBlocks/appsettings.json | 5 +-- 20 files changed, 179 insertions(+), 55 deletions(-) diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index d6200f59d7..cfc82ab27a 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -16,7 +16,8 @@ public sealed class AppDbContext : DbContext public DbSet Employees => Set(); - public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + public AppDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : base(options) { _httpContextAccessor = httpContextAccessor; _configuration = configuration; @@ -27,10 +28,10 @@ public void SetTenantName(string tenantName) _forcedTenantName = tenantName; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + protected override void OnConfiguring(DbContextOptionsBuilder builder) { string connectionString = GetConnectionString(); - optionsBuilder.UseNpgsql(connectionString); + builder.UseNpgsql(connectionString); } private string GetConnectionString() diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs index b6f960831d..1414e28424 100644 --- a/src/Examples/DatabasePerTenantExample/Program.cs +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -1,20 +1,29 @@ +using System.Diagnostics; using DatabasePerTenantExample.Data; using DatabasePerTenantExample.Models; using JsonApiDotNetCore.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddSingleton(); -builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddDbContext(options => SetDbContextDebugOptions(options)); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -31,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); @@ -41,18 +58,18 @@ static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider servi dbContext.SetTenantName(tenantName); } - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - - if (tenantName != null) + if (await dbContext.Database.EnsureCreatedAsync()) { - dbContext.Employees.Add(new Employee + if (tenantName != null) { - FirstName = "John", - LastName = "Doe", - CompanyName = tenantName - }); + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); + } } } diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index d615577636..01687be022 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -7,7 +7,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index cf19380c6c..9ce6beda08 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,19 +1,31 @@ +using System.Diagnostics; using GettingStarted.Data; using GettingStarted.Models; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddSqlite("Data Source=sample.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDb.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -28,6 +40,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index bcf154605c..d806502bcd 100644 --- a/src/Examples/GettingStarted/Properties/launchSettings.json +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -19,7 +19,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "applicationUrl": "http://localhost:14141", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/GettingStarted/appsettings.json b/src/Examples/GettingStarted/appsettings.json index 270cabc088..590851ee61 100644 --- a/src/Examples/GettingStarted/appsettings.json +++ b/src/Examples/GettingStarted/appsettings.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index ad09426d20..2884e7750c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using JsonApiDotNetCore.Configuration; @@ -5,6 +6,7 @@ using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; [assembly: ExcludeFromCodeCoverage] @@ -50,10 +52,7 @@ static void ConfigureServices(WebApplicationBuilder builder) string? connectionString = GetConnectionString(builder.Configuration); options.UseNpgsql(connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + SetDbContextDebugOptions(options); }); using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) @@ -63,12 +62,12 @@ static void ConfigureServices(WebApplicationBuilder builder) options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; #endif }, discovery => discovery.AddCurrentAssembly()); } @@ -80,6 +79,14 @@ static void ConfigureServices(WebApplicationBuilder builder) return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); } +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static void ConfigurePipeline(WebApplication webApplication) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); @@ -99,8 +106,9 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - await Seeder.CreateSampleDataAsync(dbContext); + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index b3ca85be5a..54646922e1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/todoItems?include=tags&filter=equals(priority,'High')", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 7c757dc4cb..058685ecb1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -5,10 +5,11 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", + // Include server startup, JsonApiDotNetCore measurements, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "JsonApiDotNetCore": "Information", "JsonApiDotNetCoreExample": "Information" } }, diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index f8a99654de..a8acd7ae83 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using MultiDbContextExample.Data; using MultiDbContextExample.Models; using MultiDbContextExample.Repositories; @@ -7,13 +10,29 @@ // Add services to the container. -builder.Services.AddSqlite("Data Source=A.db;Pooling=False"); -builder.Services.AddSqlite("Data Source=B.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbA.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); + +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbB.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif }, dbContextTypes: new[] { typeof(DbContextA), @@ -35,6 +54,14 @@ app.Run(); +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index a77f78562b..9d3467265f 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/MultiDbContextExample/appsettings.json b/src/Examples/MultiDbContextExample/appsettings.json index d0229a3016..590851ee61 100644 --- a/src/Examples/MultiDbContextExample/appsettings.json +++ b/src/Examples/MultiDbContextExample/appsettings.json @@ -2,8 +2,10 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index df5de241c0..1f024adc16 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,4 +1,6 @@ using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; using NoEntityFrameworkExample.Services; @@ -7,10 +9,29 @@ // Add services to the container. -string? connectionString = GetConnectionString(builder.Configuration); -builder.Services.AddNpgsql(connectionString); +builder.Services.AddDbContext(options => +{ + string? connectionString = GetConnectionString(builder.Configuration); + options.UseNpgsql(connectionString); + +#if DEBUG + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warningsBuilder => warningsBuilder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +#endif +}); -builder.Services.AddJsonApi(options => options.Namespace = "api", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, resources: resourceGraphBuilder => resourceGraphBuilder.Add()); builder.Services.AddResourceService(); diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index ae9a16c7f0..7fe15f3562 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -5,8 +5,10 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 16abac381f..04920d0068 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -4,7 +4,17 @@ // Add services to the container. -builder.Services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); WebApplication app = builder.Build(); diff --git a/src/Examples/ReportsExample/appsettings.json b/src/Examples/ReportsExample/appsettings.json index 270cabc088..1e325ebe92 100644 --- a/src/Examples/ReportsExample/appsettings.json +++ b/src/Examples/ReportsExample/appsettings.json @@ -2,8 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup and incoming requests. "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information" } }, "AllowedHosts": "*" diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 3452b6aea9..fed2e4cbfc 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -33,7 +33,7 @@ public ResourceTests(WebApplicationFactory factory) public async Task Can_get_ResourceAs() { // Arrange - const string route = "/resourceAs"; + const string route = "/api/resourceAs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); @@ -49,7 +49,7 @@ public async Task Can_get_ResourceAs() public async Task Can_get_ResourceBs() { // Arrange - const string route = "/resourceBs"; + const string route = "/api/resourceBs"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 161005befc..05679aad07 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -6,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -81,11 +83,7 @@ private WebApplicationFactory CreateFactory() services.AddDbContext(options => { options.UseNpgsql(dbConnectionString, builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); - -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + SetDbContextDebugOptions(options); }); }); @@ -103,6 +101,14 @@ private WebApplicationFactory CreateFactory() return factoryWithConfiguredContentRoot; } + [Conditional("DEBUG")] + private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) + { + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); + } + public void ConfigureLogging(Action loggingConfiguration) { _loggingConfiguration = loggingConfiguration; diff --git a/test/TestBuildingBlocks/TestableDbContext.cs b/test/TestBuildingBlocks/TestableDbContext.cs index 18ef090baa..b92f6be261 100644 --- a/test/TestBuildingBlocks/TestableDbContext.cs +++ b/test/TestBuildingBlocks/TestableDbContext.cs @@ -15,7 +15,12 @@ protected TestableDbContext(DbContextOptions options) protected override void OnConfiguring(DbContextOptionsBuilder builder) { - // Writes SQL statements to the Output Window when debugging. + WriteSqlStatementsToOutputWindow(builder); + } + + [Conditional("DEBUG")] + private static void WriteSqlStatementsToOutputWindow(DbContextOptionsBuilder builder) + { builder.LogTo(message => Debug.WriteLine(message), DbLoggerCategory.Database.Name.AsArray(), LogLevel.Information); } diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json index 160ba78e0f..4ce03e86b3 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -3,9 +3,8 @@ "LogLevel": { "Default": "Warning", "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information" + "Microsoft.EntityFrameworkCore": "Warning", + "JsonApiDotNetCore": "Warning" } } } From c3f5baa7ebe29541d95ffd9d334eb6a6d364ee97 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 15:03:34 +0200 Subject: [PATCH 45/58] Feed includes from query string to the serializer when custom resource service is used --- .../OperationsSerializationBenchmarks.cs | 3 +- .../ResourceSerializationBenchmarks.cs | 3 +- .../Queries/Internal/EvaluatedIncludeCache.cs | 29 +++++++++++++++++++ .../Response/IncompleteResourceGraphTests.cs | 2 +- .../Response/ResponseModelAdapterTests.cs | 2 +- 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 471c9604c7..5bb97a3156 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -2,6 +2,7 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -130,6 +131,6 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - return new EvaluatedIncludeCache(); + return new EvaluatedIncludeCache(Array.Empty()); } } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index a985bd5936..a63a0d9cc4 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; @@ -142,7 +143,7 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG }.ToImmutableHashSet()) }.ToImmutableHashSet()); - var cache = new EvaluatedIncludeCache(); + var cache = new EvaluatedIncludeCache(Array.Empty()); cache.Set(include); return cache; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index bbd383fa28..6f3e0caf4e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -5,7 +5,16 @@ namespace JsonApiDotNetCore.Queries.Internal; /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { + private readonly IEnumerable _constraintProviders; private IncludeExpression? _include; + private bool _isAssigned; + + public EvaluatedIncludeCache(IEnumerable constraintProviders) + { + ArgumentGuard.NotNull(constraintProviders); + + _constraintProviders = constraintProviders; + } /// public void Set(IncludeExpression include) @@ -13,11 +22,31 @@ public void Set(IncludeExpression include) ArgumentGuard.NotNull(include); _include = include; + _isAssigned = true; } /// public IncludeExpression? Get() { + if (!_isAssigned) + { + // In case someone has replaced the built-in JsonApiResourceService with their own that "forgets" to populate the cache, + // then as a fallback, we feed the requested includes from query string to the response serializer. + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + _include = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + _isAssigned = true; + } + return _include; } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs index 4384d6aaa7..247da8d6a3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/IncompleteResourceGraphTests.cs @@ -34,7 +34,7 @@ public void Fails_when_derived_type_is_missing_in_resource_graph() var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty(), resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var responseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetCache, requestQueryStringAccessor); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs index 39279181dd..8bd13a941a 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -569,7 +569,7 @@ private ResponseModelAdapter CreateAdapter(IJsonApiOptions options, string? prim PrimaryId = primaryId }; - var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var evaluatedIncludeCache = new EvaluatedIncludeCache(Array.Empty()); var linkBuilder = new FakeLinkBuilder(); var metaBuilder = new FakeMetaBuilder(); var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); From 7b63f584dbe74a2a8378e734e37b87fc53d6e138 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:38:14 +0200 Subject: [PATCH 46/58] Updates NoEntityFrameworkExample to use a hardcoded in-memory dataset, demonstrating how to implement a custom read-only resource service and resource repository, which compiles the produced LINQ query and executes it against the dataset. --- .../Data/AppDbContext.cs | 16 - .../NoEntityFrameworkExample/Data/Database.cs | 131 +++ .../Data/InMemoryModel.cs | 25 + .../InMemoryInverseNavigationResolver.cs | 41 + .../NoEntityFrameworkExample/Models/Person.cs | 27 + .../NoEntityFrameworkExample/Models/Tag.cs | 18 + .../Models/TodoItem.cs | 31 + .../Models/TodoItemPriority.cs | 11 + .../Models/WorkItem.cs | 22 - .../NoEntityFrameworkExample.csproj | 2 - .../NullSafeExpressionRewriter.cs | 316 +++++++ .../NoEntityFrameworkExample/Program.cs | 39 +- .../Properties/launchSettings.json | 4 +- .../QueryLayerIncludeConverter.cs | 81 ++ .../QueryLayerToLinqConverter.cs | 44 + .../InMemoryResourceRepository.cs | 60 ++ .../Repositories/PersonRepository.cs | 21 + .../Repositories/TagRepository.cs | 21 + .../Repositories/TodoItemRepository.cs | 21 + .../Services/InMemoryResourceService.cs | 209 +++++ .../Services/TodoItemService.cs | 38 + .../Services/WorkItemService.cs | 109 --- .../NoEntityFrameworkExample/appsettings.json | 7 +- .../ReadWrite/Fetching/FetchResourceTests.cs | 2 +- .../NullSafeExpressionRewriterTests.cs | 881 ++++++++++++++++++ test/NoEntityFrameworkTests/PersonTests.cs | 226 +++++ test/NoEntityFrameworkTests/TodoItemTests.cs | 350 +++++++ test/NoEntityFrameworkTests/WorkItemTests.cs | 166 ---- 28 files changed, 2561 insertions(+), 358 deletions(-) delete mode 100644 src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Data/Database.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs create mode 100644 src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/Person.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/Tag.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs delete mode 100644 src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs create mode 100644 src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs create mode 100644 src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs delete mode 100644 src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs create mode 100644 test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs create mode 100644 test/NoEntityFrameworkTests/PersonTests.cs create mode 100644 test/NoEntityFrameworkTests/TodoItemTests.cs delete mode 100644 test/NoEntityFrameworkTests/WorkItemTests.cs diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs deleted file mode 100644 index c10cda8e6c..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using NoEntityFrameworkExample.Models; - -namespace NoEntityFrameworkExample.Data; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext -{ - public DbSet WorkItems => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/Database.cs b/src/Examples/NoEntityFrameworkExample/Data/Database.cs new file mode 100644 index 0000000000..eee64653ee --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/Database.cs @@ -0,0 +1,131 @@ +using JetBrains.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Database +{ + public static List TodoItems { get; } + public static List Tags { get; } + public static List People { get; } + + static Database() + { + int personIndex = 0; + int tagIndex = 0; + int todoItemIndex = 0; + + var john = new Person + { + Id = ++personIndex, + FirstName = "John", + LastName = "Doe" + }; + + var jane = new Person + { + Id = ++personIndex, + FirstName = "Jane", + LastName = "Doe" + }; + + var personalTag = new Tag + { + Id = ++tagIndex, + Name = "Personal" + }; + + var familyTag = new Tag + { + Id = ++tagIndex, + Name = "Family" + }; + + var businessTag = new Tag + { + Id = ++tagIndex, + Name = "Business" + }; + + TodoItems = new List + { + new() + { + Id = ++todoItemIndex, + Description = "Make homework", + DurationInHours = 3, + Priority = TodoItemPriority.High, + Owner = john, + Assignee = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Book vacation", + DurationInHours = 2, + Priority = TodoItemPriority.Low, + Owner = jane, + Tags = + { + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Cook dinner", + DurationInHours = 1, + Priority = TodoItemPriority.Medium, + Owner = jane, + Assignee = john, + Tags = + { + familyTag, + personalTag + } + }, + new() + { + Id = ++todoItemIndex, + Description = "Check emails", + DurationInHours = 1, + Priority = TodoItemPriority.Low, + Owner = john, + Assignee = john, + Tags = + { + businessTag + } + } + }; + + Tags = new List + { + personalTag, + familyTag, + businessTag + }; + + People = new List + { + john, + jane + }; + + foreach (Tag tag in Tags) + { + tag.TodoItems = TodoItems.Where(todoItem => todoItem.Tags.Any(tagInTodoItem => tagInTodoItem.Id == tag.Id)).ToHashSet(); + } + + foreach (Person person in People) + { + person.OwnedTodoItems = TodoItems.Where(todoItem => todoItem.Owner == person).ToHashSet(); + person.AssignedTodoItems = TodoItems.Where(todoItem => todoItem.Assignee == person).ToHashSet(); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs new file mode 100644 index 0000000000..c81aa07b8f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample.Data; + +internal sealed class InMemoryModel : RuntimeModel +{ + public InMemoryModel(IResourceGraph resourceGraph) + { + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + RuntimeEntityType entityType = AddEntityType(resourceType.ClrType.FullName!, resourceType.ClrType); + SetEntityProperties(entityType, resourceType); + } + } + + private static void SetEntityProperties(RuntimeEntityType entityType, ResourceType resourceType) + { + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + entityType.AddProperty(property.Name, property.PropertyType, property); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs new file mode 100644 index 0000000000..179eafd516 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample; + +internal sealed class InMemoryInverseNavigationResolver : IInverseNavigationResolver +{ + private readonly IResourceGraph _resourceGraph; + + public InMemoryInverseNavigationResolver(IResourceGraph resourceGraph) + { + _resourceGraph = resourceGraph; + } + + /// + public void Resolve() + { + ResourceType todoItemType = _resourceGraph.GetResourceType(); + RelationshipAttribute todoItemOwnerRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Owner)); + RelationshipAttribute todoItemAssigneeRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Assignee)); + RelationshipAttribute todoItemTagsRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Tags)); + + ResourceType personType = _resourceGraph.GetResourceType(); + RelationshipAttribute personOwnedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.OwnedTodoItems)); + RelationshipAttribute personAssignedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.AssignedTodoItems)); + + ResourceType tagType = _resourceGraph.GetResourceType(); + RelationshipAttribute tagTodoItemsRelationship = tagType.GetRelationshipByPropertyName(nameof(Tag.TodoItems)); + + // Inverse navigations are required for pagination on non-primary endpoints. + todoItemOwnerRelationship.InverseNavigationProperty = personOwnedTodoItemsRelationship.Property; + todoItemAssigneeRelationship.InverseNavigationProperty = personAssignedTodoItemsRelationship.Property; + todoItemTagsRelationship.InverseNavigationProperty = tagTodoItemsRelationship.Property; + + personOwnedTodoItemsRelationship.InverseNavigationProperty = todoItemOwnerRelationship.Property; + personAssignedTodoItemsRelationship.InverseNavigationProperty = todoItemAssigneeRelationship.Property; + + tagTodoItemsRelationship.InverseNavigationProperty = todoItemTagsRelationship.Property; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Person.cs b/src/Examples/NoEntityFrameworkExample/Models/Person.cs new file mode 100644 index 0000000000..47a7f4da9a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Person.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs new file mode 100644 index 0000000000..425fe0923f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs new file mode 100644 index 0000000000..75d948ca7c --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..7dfd01f570 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs deleted file mode 100644 index 43bf1f422e..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace NoEntityFrameworkExample.Models; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] -public sealed class WorkItem : Identifiable -{ - [Attr] - public bool IsBlocked { get; set; } - - [Attr] - public string Title { get; set; } = null!; - - [Attr] - public long DurationInHours { get; set; } - - [Attr] - public Guid ProjectId { get; set; } = Guid.NewGuid(); -} diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index a244287c13..9f0037b058 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -10,8 +10,6 @@ - - diff --git a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs new file mode 100644 index 0000000000..35b2a29e9e --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs @@ -0,0 +1,316 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace NoEntityFrameworkExample; + +/// +/// Inserts a null check on member dereference and extension method invocation, to prevent a from being thrown when +/// the expression is compiled and executed. +/// +/// For example, +/// todoItem.Assignee.Id == todoItem.Owner.Id) +/// ]]> +/// would throw if the database contains a +/// TodoItem that doesn't have an assignee. +/// +public sealed class NullSafeExpressionRewriter : ExpressionVisitor +{ + private const string MinValueName = nameof(long.MinValue); + private static readonly ConstantExpression Int32MinValueConstant = Expression.Constant(int.MinValue, typeof(int)); + + private static readonly ExpressionType[] ComparisonExpressionTypes = + { + ExpressionType.LessThan, + ExpressionType.LessThanOrEqual, + ExpressionType.GreaterThan, + ExpressionType.GreaterThanOrEqual, + ExpressionType.Equal + // ExpressionType.NotEqual is excluded because WhereClauseBuilder never produces that. + }; + + private readonly Stack _callStack = new(); + + public TExpression Rewrite(TExpression expression) + where TExpression : Expression + { + _callStack.Clear(); + + return (TExpression)Visit(expression); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.Name == "Where") + { + _callStack.Push(MethodType.Where); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (node.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending") + { + // Ordering can be improved by expanding into multiple OrderBy/ThenBy() calls, as described at + // https://stackoverflow.com/questions/26186527/linq-order-by-descending-with-null-values-on-bottom/26186585#26186585. + // For example: + // .OrderBy(element => element.First.Second.CharValue) + // Could be translated to: + // .OrderBy(element => element.First != null) + // .ThenBy(element => element.First == null ? false : element.First.Second != null) + // .ThenBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // Which correctly orders 'element.First == null' before 'element.First.Second == null'. + // The current implementation translates to: + // .OrderBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // in which the order of these two rows is undeterministic. + + _callStack.Push(MethodType.Ordering); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (_callStack.Count > 0) + { + MethodType outerMethodType = _callStack.Peek(); + + if (outerMethodType == MethodType.Ordering && node.Method.Name == "Count") + { + return ToNullSafeCountInvocationInOrderBy(node); + } + + if (outerMethodType == MethodType.Where && node.Method.Name == "Any") + { + return ToNullSafeAnyInvocationInWhere(node); + } + } + + return base.VisitMethodCall(node); + } + + private static Expression ToNullSafeCountInvocationInOrderBy(MethodCallExpression countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // OrderClauseBuilder never produces nested Count() calls. + + // SRC: some.Other.Children.Count() + // DST: some.Other == null ? int.MinValue : some.Other.Children == null ? int.MinValue : some.Other.Children.Count() + return ToConditionalMemberAccessInOrderBy(countMethodCall, memberArgument, Int32MinValueConstant); + } + + return countMethodCall; + } + + private static Expression ToConditionalMemberAccessInOrderBy(Expression outer, MemberExpression innerMember, ConstantExpression defaultValue) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.StringValue + // DST: first.Second == null ? null : first.Second.StringValue + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNull = Expression.Equal(currentMember, nullConstant); + result = Expression.Condition(isNull, defaultValue, result); + } + + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + private static bool IsStaticMemberAccess(MemberExpression member) + { + if (member.Member is FieldInfo field) + { + return field.IsStatic; + } + + if (member.Member is PropertyInfo property) + { + MethodInfo? getter = property.GetGetMethod(); + return getter != null && getter.IsStatic; + } + + return false; + } + + private Expression ToNullSafeAnyInvocationInWhere(MethodCallExpression anyMethodCall) + { + Expression thisArgument = anyMethodCall.Arguments.First(); + + if (thisArgument is MemberExpression memberArgument) + { + MethodCallExpression newAnyMethodCall = anyMethodCall; + + if (anyMethodCall.Arguments.Count > 1) + { + // SRC: .Any(first => first.Second.Value == 1) + // DST: .Any(first => first != null && first.Second != null && first.Second.Value == 1) + List newArguments = anyMethodCall.Arguments.Skip(1).Select(Visit).Cast().ToList(); + newArguments.Insert(0, thisArgument); + + newAnyMethodCall = anyMethodCall.Update(anyMethodCall.Object, newArguments); + } + + // SRC: some.Other.Any() + // DST: some != null && some.Other != null && some.Other.Any() + return ToConditionalMemberAccessInBooleanExpression(newAnyMethodCall, memberArgument, false); + } + + return anyMethodCall; + } + + private static Expression ToConditionalMemberAccessInBooleanExpression(Expression outer, MemberExpression innerMember, bool skipNullCheckOnLastAccess) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Null-check the last member access in the chain on extension method invocation. For example: a.b.c.Count() requires a null-check on 'c'. + // This is unneeded for boolean comparisons. For example: a.b.c == d does not require a null-check on 'c'. + if (!skipNullCheckOnLastAccess || currentMember != innerMember) + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.Value == 1 + // DST: first.Second != null && first.Second.Value == 1 + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNotNull = Expression.NotEqual(currentMember, nullConstant); + result = Expression.AndAlso(isNotNull, result); + } + } + + // Do not null-check the first member access in the chain, because that's the lambda parameter itself. + // For example, in: item => item.First.Second, 'item' does not require a null-check. + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Where) + { + if (ComparisonExpressionTypes.Contains(node.NodeType)) + { + Expression result = node; + + result = ToNullSafeTermInBinary(node.Right, result); + result = ToNullSafeTermInBinary(node.Left, result); + + return result; + } + } + + return base.VisitBinary(node); + } + + private static Expression ToNullSafeTermInBinary(Expression binaryTerm, Expression result) + { + if (binaryTerm is MemberExpression rightMember) + { + // SRC: some.Other.Value == 1 + // DST: some != null && some.Other != null && some.Other.Value == 1 + return ToConditionalMemberAccessInBooleanExpression(result, rightMember, true); + } + + if (binaryTerm is MethodCallExpression { Method.Name: "Count" } countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // SRC: some.Other.Count() == 1 + // DST: some != null && some.Other != null && some.Other.Count() == 1 + return ToConditionalMemberAccessInBooleanExpression(result, memberArgument, false); + } + } + + return result; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Ordering) + { + if (node.Expression is MemberExpression innerMember) + { + ConstantExpression defaultValue = CreateConstantForMemberIsNull(node.Type); + return ToConditionalMemberAccessInOrderBy(node, innerMember, defaultValue); + } + + return node; + } + + return base.VisitMember(node); + } + + private static ConstantExpression CreateConstantForMemberIsNull(Type type) + { + bool canContainNull = !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + if (canContainNull) + { + return Expression.Constant(null, type); + } + + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + ConstantExpression? constant = TryCreateConstantForStaticMinValue(innerType); + + if (constant != null) + { + return constant; + } + + object? defaultValue = Activator.CreateInstance(type); + return Expression.Constant(defaultValue, type); + } + + private static ConstantExpression? TryCreateConstantForStaticMinValue(Type type) + { + // Int32.MinValue is a field, while Int128.MinValue is a property. + + FieldInfo? field = type.GetField(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (field != null) + { + object? value = field.GetValue(null); + return Expression.Constant(value, type); + } + + PropertyInfo? property = type.GetProperty(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (property != null) + { + MethodInfo? getter = property.GetGetMethod(); + + if (getter != null) + { + object? value = getter.Invoke(null, Array.Empty()); + return Expression.Constant(value, type); + } + } + + return null; + } + + private enum MethodType + { + Where, + Ordering + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 1f024adc16..8b299e2c24 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,39 +1,24 @@ using JsonApiDotNetCore.Configuration; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; +using NoEntityFrameworkExample; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddDbContext(options => -{ - string? connectionString = GetConnectionString(builder.Configuration); - options.UseNpgsql(connectionString); - -#if DEBUG - options.EnableDetailedErrors(); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warningsBuilder => warningsBuilder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); -#endif -}); - builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; #endif -}, resources: resourceGraphBuilder => resourceGraphBuilder.Add()); +}, discovery => discovery.AddCurrentAssembly()); -builder.Services.AddResourceService(); +builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -43,20 +28,4 @@ app.UseJsonApi(); app.MapControllers(); -await CreateDatabaseAsync(app.Services); - app.Run(); - -static string? GetConnectionString(IConfiguration configuration) -{ - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); -} - -static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) -{ - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); -} diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index beecefbb9f..d1e2e0ca67 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs new file mode 100644 index 0000000000..c1db07b0fb --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerIncludeConverter.cs @@ -0,0 +1,81 @@ +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample; + +/// +/// Replaces all s with s. +/// +internal sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +{ + private readonly QueryLayer _queryLayer; + + public QueryLayerIncludeConverter(QueryLayer queryLayer) + { + _queryLayer = queryLayer; + } + + public void ConvertIncludesToSelections() + { + if (_queryLayer.Include != null) + { + Visit(_queryLayer.Include, _queryLayer); + _queryLayer.Include = null; + } + + EnsureNonEmptySelection(_queryLayer); + } + + public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) + { + foreach (IncludeElementExpression element in expression.Elements) + { + _ = Visit(element, queryLayer); + } + + return null; + } + + public override object? VisitIncludeElement(IncludeElementExpression expression, QueryLayer queryLayer) + { + QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); + + foreach (IncludeElementExpression nextIncludeElement in expression.Children) + { + Visit(nextIncludeElement, subLayer); + } + + return null; + } + + private static QueryLayer EnsureRelationshipInSelection(QueryLayer queryLayer, RelationshipAttribute relationship) + { + queryLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + if (!selectors.ContainsField(relationship)) + { + selectors.IncludeRelationship(relationship, new QueryLayer(relationship.RightType)); + } + + QueryLayer subLayer = selectors[relationship]!; + EnsureNonEmptySelection(subLayer); + + return subLayer; + } + + private static void EnsureNonEmptySelection(QueryLayer queryLayer) + { + if (queryLayer.Selection == null) + { + queryLayer.Selection = new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + foreach (AttrAttribute attribute in queryLayer.ResourceType.Attributes) + { + selectors.IncludeAttribute(attribute); + } + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs new file mode 100644 index 0000000000..fb65a46015 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample; + +internal sealed class QueryLayerToLinqConverter +{ + private readonly IResourceFactory _resourceFactory; + private readonly IModel _model; + + public QueryLayerToLinqConverter(IResourceFactory resourceFactory, IModel model) + { + _resourceFactory = resourceFactory; + _model = model; + } + + public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) + where TResource : class, IIdentifiable + { + // The Include() extension method from Entity Framework Core is unavailable, so rewrite into selectors. + var converter = new QueryLayerIncludeConverter(queryLayer); + converter.ConvertIncludesToSelections(); + + // Convert QueryLayer into LINQ expression. + Expression source = ((IEnumerable)resources).AsQueryable().Expression; + var nameFactory = new LambdaParameterNameFactory(); + var queryableBuilder = new QueryableBuilder(source, queryLayer.ResourceType.ClrType, typeof(Enumerable), nameFactory, _resourceFactory, _model); + Expression expression = queryableBuilder.ApplyQuery(queryLayer); + + // Insert null checks to prevent a NullReferenceException during execution of expressions such as: + // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. + NullSafeExpressionRewriter rewriter = new(); + expression = rewriter.Rewrite(expression); + + // Compile and execute LINQ expression against the in-memory database. + Delegate function = Expression.Lambda(expression).Compile(); + object result = function.DynamicInvoke()!; + return (IEnumerable)result; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs new file mode 100644 index 0000000000..0b88ee3222 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -0,0 +1,60 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Repositories; + +/// +/// Demonstrates how to replace the built-in . This read-only repository uses the built-in +/// to convert the incoming into a LINQ expression, then compiles and executes it against the +/// in-memory database. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceRepository : IResourceReadRepository + where TResource : class, IIdentifiable +{ + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult>(resources.ToList()); + } + + /// + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = filter + }; + + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult(resources.Count()); + } + + protected abstract IEnumerable GetDataSource(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs new file mode 100644 index 0000000000..d710cff0de --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PersonRepository : InMemoryResourceRepository +{ + public PersonRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.People; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs new file mode 100644 index 0000000000..da38005bb3 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TagRepository : InMemoryResourceRepository +{ + public TagRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.Tags; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs new file mode 100644 index 0000000000..38cd656e0a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemRepository : InMemoryResourceRepository +{ + public TodoItemRepository(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + protected override IEnumerable GetDataSource() + { + return Database.TodoItems; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs new file mode 100644 index 0000000000..de9450298f --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -0,0 +1,209 @@ +using System.Collections; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using NoEntityFrameworkExample.Data; + +namespace NoEntityFrameworkExample.Services; + +/// +/// Demonstrates how to replace the built-in . This read-only resource service uses the built-in +/// to convert the incoming query string parameters into a , then uses the built-in +/// to convert the into a LINQ expression, then compiles and executes it against the in-memory +/// database. +/// +/// +/// +/// This resource service is a simplified version of the built-in resource service. Instead of implementing a resource service, consider implementing a +/// resource repository, which only needs to provide data access. +/// +/// The incoming filter from query string is logged, just to show how you can access it directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceService : IResourceQueryService + where TResource : class, IIdentifiable +{ + private readonly IJsonApiOptions _options; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IEnumerable _constraintProviders; + private readonly ILogger> _logger; + private readonly ResourceType _resourceType; + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter; + + protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, + IResourceFactory resourceFactory, IPaginationContext paginationContext, IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + { + _options = options; + _queryLayerComposer = queryLayerComposer; + _paginationContext = paginationContext; + _constraintProviders = constraintProviders; + + _logger = loggerFactory.CreateLogger>(); + _resourceType = resourceGraph.GetResourceType(); + + var model = new InMemoryModel(resourceGraph); + _queryLayerToLinqConverter = new QueryLayerToLinqConverter(resourceFactory, model); + } + + /// + public Task> GetAsync(CancellationToken cancellationToken) + { + LogFiltersInTopScope(); + + if (SetPrimaryTotalCountIsZero()) + { + return Task.FromResult>(Array.Empty()); + } + + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_resourceType); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + List resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource).ToList(); + + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult>(resources); + } + + private void LogFiltersInTopScope() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + FilterExpression[] filtersInTopScope = _constraintProviders.SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filtersInTopScope); + + if (filter != null) + { + _logger.LogInformation($"Incoming top-level filter from query string: {filter}"); + } + } + + private bool SetPrimaryTotalCountIsZero() + { + if (_options.IncludeTotalResourceCount) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_resourceType) + }; + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + + if (_paginationContext.TotalResourceCount == 0) + { + return true; + } + } + + return false; + } + + /// + public Task GetAsync(TId id, CancellationToken cancellationToken) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetById(id, _resourceType, TopFieldSelection.PreserveExisting); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + TResource? resource = resources.SingleOrDefault(); + + if (resource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + return Task.FromResult(resource); + } + + /// + public Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + RelationshipAttribute? relationship = _resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _resourceType.PublicName); + } + + SetNonPrimaryTotalCount(id, relationship); + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(relationship.RightType); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _resourceType, id, relationship); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable primaryResources = _queryLayerToLinqConverter.ApplyQueryLayer(primaryLayer, dataSource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + + if (primaryResource == null) + { + throw new ResourceNotFoundException(id!.ToString()!, _resourceType.PublicName); + } + + object? rightValue = relationship.GetValue(primaryResource); + + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult(rightValue); + } + + private void SetNonPrimaryTotalCount(TId id, RelationshipAttribute relationship) + { + if (_options.IncludeTotalResourceCount && relationship is HasManyAttribute hasManyRelationship) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, hasManyRelationship); + + if (secondaryFilter == null) + { + return; + } + + var queryLayer = new QueryLayer(hasManyRelationship.RightType) + { + Filter = secondaryFilter + }; + + IEnumerable dataSource = GetDataSource(hasManyRelationship.RightType); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + } + } + + /// + public Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + return GetSecondaryAsync(id, relationshipName, cancellationToken); + } + + protected abstract IEnumerable GetDataSource(ResourceType resourceType); +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs new file mode 100644 index 0000000000..11a4ad0b4a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemService : InMemoryResourceService +{ + public TodoItemService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IResourceFactory resourceFactory, + IPaginationContext paginationContext, IEnumerable constraintProviders, ILoggerFactory loggerFactory) + : base(options, resourceGraph, queryLayerComposer, resourceFactory, paginationContext, constraintProviders, loggerFactory) + { + } + + protected override IEnumerable GetDataSource(ResourceType resourceType) + { + if (resourceType.ClrType == typeof(TodoItem)) + { + return Database.TodoItems; + } + + if (resourceType.ClrType == typeof(Person)) + { + return Database.People; + } + + if (resourceType.ClrType == typeof(Tag)) + { + return Database.Tags; + } + + throw new InvalidOperationException($"Unknown data source '{resourceType.ClrType}'."); + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs deleted file mode 100644 index a46a8a0cb5..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Dapper; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using NoEntityFrameworkExample.Models; -using Npgsql; - -namespace NoEntityFrameworkExample.Services; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class WorkItemService : IResourceService -{ - private readonly string? _connectionString; - - public WorkItemService(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration.GetConnectionString("Default")?.Replace("###", postgresPassword); - } - - public async Task> GetAsync(CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"""; - var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); - - return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - } - - public async Task GetAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; - - var commandDefinition = new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) - { - const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - - var commandDefinition = new CommandDefinition(commandText, new - { - title = resource.Title, - isBlocked = resource.IsBlocked, - durationInHours = resource.DurationInHours, - projectId = resource.ProjectId - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; - - await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken))); - } - - public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); - dbConnection.Open(); - - IEnumerable resources = await query(dbConnection); - return resources.ToList(); - } -} diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index 7fe15f3562..603d1f4f9f 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,14 +1,11 @@ { - "ConnectionStrings": { - "Default": "Host=localhost;Database=NoEntityFrameworkExample;User ID=postgres;Password=###;Include Error Detail=true" - }, "Logging": { "LogLevel": { "Default": "Warning", - // Include server startup, incoming requests and SQL commands. + // Include server startup, incoming requests and sample logging. "Microsoft.Hosting.Lifetime": "Information", "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "NoEntityFrameworkExample": "Information" } }, "AllowedHosts": "*" diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index ad3be1d66f..487c6d72d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -349,7 +349,7 @@ public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() } [Fact] - public async Task Cannot_get_secondary_resource_for_unknown_secondary_type() + public async Task Cannot_get_secondary_resource_for_unknown_relationship() { // Arrange WorkItem workItem = _fakers.WorkItem.Generate(); diff --git a/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs new file mode 100644 index 0000000000..57da032819 --- /dev/null +++ b/test/NoEntityFrameworkTests/NullSafeExpressionRewriterTests.cs @@ -0,0 +1,881 @@ +using System.Linq.Expressions; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class NullSafeExpressionRewriterTests +{ + [Fact] + public void Can_rewrite_where_clause_with_constant_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => source.Where(resource => resource.Parent!.Id == 3); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => ((resource.Parent != null) AndAlso (resource.Parent.Id == 3)))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_member_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + lastInDataSource.FirstChild!.Parent!.Id = lastInDataSource.Parent!.Id; + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Id == resource.FirstChild!.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.FirstChild != null) AndAlso ((resource.FirstChild.Parent != null) AndAlso (resource.Parent.Id == resource.FirstChild.Parent.Id)))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_not_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => !(resource.Parent!.Id == 3)); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => Not(((resource.Parent != null) AndAlso (resource.Parent.Id == 3))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Parent!.Parent!.Children.Any()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Parent != null) AndAlso ((resource.Parent.Parent != null) AndAlso ((resource.Parent.Parent.Children != null) AndAlso resource.Parent.Parent.Children.Any()))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + } + }; + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Parent!.Id == 3 || resource.FirstChild!.Children.Any(child => !(child.Parent!.Id == 1))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => (((resource.Parent != null) AndAlso (resource.Parent.Id == 3)) OrElse " + + "((resource.FirstChild != null) AndAlso ((resource.FirstChild.Children != null) AndAlso resource.FirstChild.Children.Any(child => Not(((child.Parent != null) AndAlso (child.Parent.Id == 1))))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(2); + resources[0].Id.Should().Be(dataSource[1].Id); + resources[1].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_nested_conditional_any_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext(), + Children = null! + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + } + }, + new() + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Jack" + } + } + } + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable once NegativeEqualityExpression + Expression, IEnumerable>> expression = source => source.Where(resource => + resource.Children.Any(child => child.Children.Any(childOfChild => childOfChild.Parent!.Name == "Jack"))); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.Where(resource => " + + "((resource.Children != null) AndAlso resource.Children.Any(child => ((child.Children != null) AndAlso child.Children.Any(childOfChild => ((childOfChild.Parent != null) AndAlso (childOfChild.Parent.Name == \"Jack\")))))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_where_clause_with_count_comparison() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + }, + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + TestResource lastInDataSource = dataSource.Last(); + + // ReSharper disable UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.Where(resource => resource.Children.Count() > resource.Parent!.Children.Count()); + // ReSharper restore UseCollectionCountProperty + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.Where(resource => ((resource.Children != null) AndAlso ((resource.Parent != null) AndAlso ((resource.Parent.Children != null) AndAlso (resource.Children.Count() > resource.Parent.Children.Count())))))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(1); + resources[0].Id.Should().Be(lastInDataSource.Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_long() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Id); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Id))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_IntPtr() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Pointer = (IntPtr)1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Pointer); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should() + .Be("source => source.OrderBy(resource => IIF((resource.Parent == null), -9223372036854775808, resource.Parent.Pointer))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_nullable_int() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Number = -1 + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_enum() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Enum = TestEnum.Two + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Enum); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), Zero, resource.Parent.Enum))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_string() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "X" + } + } + }; + + Expression, IEnumerable>> expression = source => source.OrderBy(resource => resource.Parent!.Name); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be("source => source.OrderBy(resource => IIF((resource.Parent == null), null, resource.Parent.Name))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(3); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + } + + [Fact] + public void Can_rewrite_order_by_clause_with_count() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = null! + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext() + }, + Children = + { + new TestResource + { + Id = generator.GetNext() + } + } + } + }; + + // ReSharper disable once UseCollectionCountProperty + Expression, IEnumerable>> expression = source => + source.OrderBy(resource => resource.Parent!.Children.Count()); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderBy(resource => IIF((resource.Parent == null), -2147483648, IIF((resource.Parent.Children == null), -2147483648, resource.Parent.Children.Count())))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(4); + resources[0].Id.Should().Be(dataSource[0].Id); + resources[1].Id.Should().Be(dataSource[1].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[3].Id); + } + + [Fact] + public void Can_rewrite_nested_descending_order_by_clauses() + { + // Arrange + var generator = new IdGenerator(); + + var dataSource = new List + { + new() + { + Id = generator.GetNext() + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "A", + Number = 10 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 1 + } + }, + new() + { + Id = generator.GetNext(), + Parent = new TestResource + { + Id = generator.GetNext(), + Name = "Z", + Number = 10 + } + } + }; + + Expression, IEnumerable>> expression = source => + source.OrderByDescending(resource => resource.Parent!.Name).ThenByDescending(resource => resource.Parent!.Number); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be( + "source => source.OrderByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Name)).ThenByDescending(resource => IIF((resource.Parent == null), null, resource.Parent.Number))"); + + TestResource[] resources = DynamicInvoke(safeExpression, dataSource); + resources.Should().HaveCount(5); + resources[0].Id.Should().Be(dataSource[4].Id); + resources[1].Id.Should().Be(dataSource[3].Id); + resources[2].Id.Should().Be(dataSource[2].Id); + resources[3].Id.Should().Be(dataSource[1].Id); + resources[4].Id.Should().Be(dataSource[0].Id); + } + + [Fact] + public void Does_not_rewrite_in_select() + { + // Arrange + Expression, IEnumerable>> expression = source => source.Select(resource => new TestResource + { + Id = resource.Id, + Name = resource.Name, + Parent = resource.Parent, + Children = resource.Children + }); + + var rewriter = new NullSafeExpressionRewriter(); + + // Act + Expression, IEnumerable>> safeExpression = rewriter.Rewrite(expression); + + // Assert + safeExpression.ToString().Should().Be(expression.ToString()); + } + + private static TestResource[] DynamicInvoke(Expression, IEnumerable>> expression, + IEnumerable dataSource) + { + Delegate function = expression.Compile(); + object enumerable = function.DynamicInvoke(dataSource)!; + return ((IEnumerable)enumerable).ToArray(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class TestResource : Identifiable + { + [Attr] + public string? Name { get; set; } + + [Attr] + public int? Number { get; set; } + + [Attr] + public IntPtr Pointer { get; set; } + + [Attr] + public TestEnum Enum { get; set; } + + [HasOne] + public TestResource? Parent { get; set; } + + [HasOne] + public TestResource? FirstChild => Children.FirstOrDefault(); + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private enum TestEnum + { + Zero, + One, + Two + } + + private sealed class IdGenerator + { + private long _lastId; + + public long GetNext() + { + return ++_lastId; + } + } +} diff --git a/test/NoEntityFrameworkTests/PersonTests.cs b/test/NoEntityFrameworkTests/PersonTests.cs new file mode 100644 index 0000000000..cd80320f45 --- /dev/null +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class PersonTests : IntegrationTest, IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/people?filter=equals(firstName,'Jane')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/people?filter=has(assignedTodoItems,equals(description,'Check emails'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("John")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + responseDocument.Data.ManyValue[1].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/people?sort=-count(assignedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + responseDocument.Data.ManyValue[1].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/people?page[size]=1&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/people?fields[people]=lastName,displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("lastName", "displayName")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/people?include=ownedTodoItems.assignee,assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/people/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/people/1/ownedTodoItems?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Make homework")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/people/2/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("1"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/TodoItemTests.cs b/test/NoEntityFrameworkTests/TodoItemTests.cs new file mode 100644 index 0000000000..f26470f72d --- /dev/null +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -0,0 +1,350 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; +using Xunit; + +namespace NoEntityFrameworkTests; + +public sealed class TodoItemTests : IntegrationTest, IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?filter=equals(priority,'High')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("priority").With(value => value.Should().Be(TodoItemPriority.High)); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_filter_in_related_resources() + { + // Arrange + const string route = "/api/todoItems?filter=not(equals(assignee.firstName,'Jane'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Can_sort_on_attribute_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=-id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("4"); + responseDocument.Data.ManyValue[1].Id.Should().Be("3"); + responseDocument.Data.ManyValue[2].Id.Should().Be("2"); + responseDocument.Data.ManyValue[3].Id.Should().Be("1"); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?sort=count(assignee.ownedTodoItems)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(4); + responseDocument.Data.ManyValue[0].Id.Should().Be("2"); + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?page[size]=3&page[number]=2&sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("description").With(value => value.Should().Be("Check emails")); + + responseDocument.Meta.Should().ContainTotal(4); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?fields[todoItems]=description,priority"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Attributes.ShouldOnlyContainKeys("description", "priority")); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + const string route = "/api/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Included.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resource() + { + // Arrange + const string route = "/api/todoItems/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be("1"); + } + + [Fact] + public async Task Cannot_get_primary_resource_for_unknown_ID() + { + // Arrange + const string route = "/api/todoItems/999999"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + const string route = "/api/todoItems/3/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Personal")); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("name").With(value => value.Should().Be("Family")); + + responseDocument.Meta.Should().ContainTotal(2); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + const string route = "/api/todoItems/2/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Jane")); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_primary_ID() + { + // Arrange + const string route = "/api/todoItems/999999/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' with ID '999999' does not exist."); + } + + [Fact] + public async Task Can_get_secondary_resource_for_unknown_secondary_ID() + { + // Arrange + const string route = "/api/todoItems/2/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Cannot_get_secondary_resource_for_unknown_relationship() + { + // Arrange + const string route = "/api/todoItems/2/unknown"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested relationship does not exist."); + error.Detail.Should().Be("Resource of type 'todoItems' does not contain a relationship named 'unknown'."); + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be("2"); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + const string route = "/api/todoItems/2/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + const string route = "/api/todoItems/4/relationships/tags?sort=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be("3"); + + responseDocument.Meta.Should().ContainTotal(1); + } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } +} diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs deleted file mode 100644 index 25b132e006..0000000000 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using TestBuildingBlocks; -using Xunit; - -namespace NoEntityFrameworkTests; - -public sealed class WorkItemTests : IntegrationTest, IClassFixture> -{ - private readonly WebApplicationFactory _factory; - - protected override JsonSerializerOptions SerializerOptions - { - get - { - var options = _factory.Services.GetRequiredService(); - return options.SerializerOptions; - } - } - - public WorkItemTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task Can_get_WorkItems() - { - var workItem = new WorkItem - { - Title = "Write some code." - }; - - // Arrange - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/api/workItems"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldNotBeEmpty(); - } - - [Fact] - public async Task Can_get_WorkItem_by_ID() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(workItem.StringId); - } - - [Fact] - public async Task Can_create_WorkItem() - { - // Arrange - var newWorkItem = new WorkItem - { - IsBlocked = true, - Title = "Some", - DurationInHours = 2, - ProjectId = Guid.NewGuid() - }; - - var requestBody = new - { - data = new - { - type = "workItems", - attributes = new - { - isBlocked = newWorkItem.IsBlocked, - title = newWorkItem.Title, - durationInHours = newWorkItem.DurationInHours, - projectId = newWorkItem.ProjectId - } - } - }; - - const string route = "/api/workItems/"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isBlocked").With(value => value.Should().Be(newWorkItem.IsBlocked)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newWorkItem.Title)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newWorkItem.DurationInHours)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("projectId").With(value => value.Should().Be(newWorkItem.ProjectId)); - } - - [Fact] - public async Task Can_delete_WorkItem() - { - // Arrange - var workItem = new WorkItem - { - Title = "Write some code." - }; - - await RunOnDatabaseAsync(async dbContext => - { - dbContext.WorkItems.Add(workItem); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/workItems/{workItem.StringId}"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await ExecuteDeleteAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - protected override HttpClient CreateClient() - { - return _factory.CreateClient(); - } - - private async Task RunOnDatabaseAsync(Func asyncAction) - { - await using AsyncServiceScope scope = _factory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await asyncAction(dbContext); - } -} From 2a57fe1aa0672e6cfa86be1abb36725d570939cc Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:49:25 +0200 Subject: [PATCH 47/58] Update documentation --- docs/getting-started/faq.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index d47cf8618e..e1caa85797 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -133,24 +133,24 @@ Here are some injectable request-scoped types to be aware of: - `IJsonApiRequest`: This contains routing information, such as whether a primary, secondary, or relationship endpoint is being accessed. - `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates). - `IEnumerable`: Provides access to the parsed query string parameters. -- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render, which you need to populate. -- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the attributes and relationship objects. You need to populate this as well. +- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render. +- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. -You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources and relationships). +You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships). So, back to the topic of where to intercept. It helps to familiarize yourself with the [execution pipeline](~/internals/queries.md). Replacing at the service level is the simplest. But it means you'll need to read the parsed query string parameters and invoke all resource definition callbacks yourself. And you won't get change detection (HTTP 203 Not Modified). Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs) to see what you're missing out on. -You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options, analyze query strings or populate caches for the serializer. +You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings. And most resource definition callbacks are handled. That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`. Now the hard part for you becomes reading that data structure and producing data access calls from that. If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs), which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/). -Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. -We use this for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. There's an example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs). +We use a similar approach for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs). > [!TIP] > [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees! From d037c75b52a86b8b6da0d32a4300fac448848ae9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 19 Apr 2023 12:56:41 +0200 Subject: [PATCH 48/58] Reduce logging from tests during cibuild --- test/MultiDbContextTests/ResourceTests.cs | 7 +++--- test/NoEntityFrameworkTests/PersonTests.cs | 7 +++--- test/NoEntityFrameworkTests/TodoItemTests.cs | 7 +++--- .../NoLoggingWebApplicationFactory.cs | 24 +++++++++++++++++++ test/TestBuildingBlocks/appsettings.json | 7 +++++- 5 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index fed2e4cbfc..ed25d0f274 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using MultiDbContextExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace MultiDbContextTests; -public sealed class ResourceTests : IntegrationTest, IClassFixture> +public sealed class ResourceTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public ResourceTests(WebApplicationFactory factory) + public ResourceTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } diff --git a/test/NoEntityFrameworkTests/PersonTests.cs b/test/NoEntityFrameworkTests/PersonTests.cs index cd80320f45..965cac9a3e 100644 --- a/test/NoEntityFrameworkTests/PersonTests.cs +++ b/test/NoEntityFrameworkTests/PersonTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NoEntityFrameworkExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace NoEntityFrameworkTests; -public sealed class PersonTests : IntegrationTest, IClassFixture> +public sealed class PersonTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public PersonTests(WebApplicationFactory factory) + public PersonTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } diff --git a/test/NoEntityFrameworkTests/TodoItemTests.cs b/test/NoEntityFrameworkTests/TodoItemTests.cs index f26470f72d..3fdd5683c5 100644 --- a/test/NoEntityFrameworkTests/TodoItemTests.cs +++ b/test/NoEntityFrameworkTests/TodoItemTests.cs @@ -3,7 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using NoEntityFrameworkExample.Models; using TestBuildingBlocks; @@ -11,9 +10,9 @@ namespace NoEntityFrameworkTests; -public sealed class TodoItemTests : IntegrationTest, IClassFixture> +public sealed class TodoItemTests : IntegrationTest, IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly NoLoggingWebApplicationFactory _factory; protected override JsonSerializerOptions SerializerOptions { @@ -24,7 +23,7 @@ protected override JsonSerializerOptions SerializerOptions } } - public TodoItemTests(WebApplicationFactory factory) + public TodoItemTests(NoLoggingWebApplicationFactory factory) { _factory = factory; } diff --git a/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs new file mode 100644 index 0000000000..94e21f2db4 --- /dev/null +++ b/test/TestBuildingBlocks/NoLoggingWebApplicationFactory.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Logging; + +namespace TestBuildingBlocks; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class NoLoggingWebApplicationFactory : WebApplicationFactory + where TEntryPoint : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + DisableLogging(builder); + } + + [Conditional("RELEASE")] + private static void DisableLogging(IWebHostBuilder builder) + { + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders()); + } +} diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json index 4ce03e86b3..c60110712a 100644 --- a/test/TestBuildingBlocks/appsettings.json +++ b/test/TestBuildingBlocks/appsettings.json @@ -2,9 +2,14 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. + "Microsoft.AspNetCore.Hosting.Diagnostics": "None", "Microsoft.Hosting.Lifetime": "Warning", "Microsoft.EntityFrameworkCore": "Warning", - "JsonApiDotNetCore": "Warning" + "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", + "Microsoft.EntityFrameworkCore.Update": "Critical", + "Microsoft.EntityFrameworkCore.Database.Command": "Critical", + "JsonApiDotNetCore": "Critical" } } } From 9e03e0f4c2eb671c3516a9efb1dcaabb5f6603df Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 10:10:08 +0200 Subject: [PATCH 49/58] Various (non-breaking) name changes for internal consistency --- .../Queries/Internal/Parsing/FilterParser.cs | 2 +- .../Internal/QueryableBuilding/SelectClauseBuilder.cs | 4 ++-- .../SparseFieldSets/SparseFieldSetTests.cs | 2 +- test/TestBuildingBlocks/DbContextExtensions.cs | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index bdac0d8962..541b50a220 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -420,7 +420,7 @@ private object ConvertStringToType(string value, Type type) private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) { - return stringValue => attribute.Property.Name == nameof(IIdentifiable.Id) + return stringValue => attribute.Property.Name == nameof(Identifiable.Id) ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) : ConvertStringToType(stringValue, attribute.Property.PropertyType); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index 6a4f43d7e1..c020653775 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -162,9 +162,9 @@ private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) { - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in fieldSelectors) + foreach ((ResourceFieldAttribute resourceField, QueryLayer? nextLayer) in fieldSelectors) { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); + var propertySelector = new PropertySelector(resourceField.Property, nextLayer); IncludeWritableProperty(propertySelector, propertySelectors); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 4036c62f13..60855a80f2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -791,7 +791,7 @@ public async Task Cannot_select_ToMany_relationship_with_blocked_capability() } [Fact] - public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attribute() + public async Task Fetches_all_scalar_properties_when_fieldset_contains_readonly_attribute() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index 8ce859f356..7f32073874 100644 --- a/test/TestBuildingBlocks/DbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -23,15 +23,15 @@ public static async Task ClearTablesAsync(this DbContext dbC await ClearTablesAsync(dbContext, typeof(TEntity1), typeof(TEntity2)); } - private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] models) + private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] modelTypes) { - foreach (Type model in models) + foreach (Type modelType in modelTypes) { - IEntityType? entityType = dbContext.Model.FindEntityType(model); + IEntityType? entityType = dbContext.Model.FindEntityType(modelType); if (entityType == null) { - throw new InvalidOperationException($"Table for '{model.Name}' not found."); + throw new InvalidOperationException($"Table for '{modelType.Name}' not found."); } string? tableName = entityType.GetTableName(); @@ -44,7 +44,7 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type } else { - await dbContext.Database.ExecuteSqlRawAsync($"delete from \"{tableName}\""); + await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM \"{tableName}\""); } } } From 11894ad9025d03da82410752797c908a5afba1d3 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 11:52:21 +0200 Subject: [PATCH 50/58] Harden Attr/Relationship attributes against invalid input --- .../Resources/Annotations/AttrAttribute.cs | 2 + .../Resources/Annotations/HasManyAttribute.cs | 30 +++ .../Resources/Annotations/HasOneAttribute.cs | 9 + .../Annotations/RelationshipAttribute.cs | 2 + .../Annotations/ResourceFieldAttribute.cs | 19 +- .../ResourceGraph/HasManyAttributeTests.cs | 141 ++++++++++ .../ResourceGraph/HasOneAttributeTests.cs | 69 +++++ .../ResourceFieldAttributeTests.cs | 254 ++++++++++++++++++ 8 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index 7a6cbd960f..26a660775a 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -31,6 +31,7 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -48,6 +49,7 @@ public override bool Equals(object? obj) return Capabilities == other.Capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(Capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index 5792744d5c..d310028ae6 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -1,3 +1,4 @@ +using System.Collections; using JetBrains.Annotations; // ReSharper disable NonReadonlyMemberInGetHashCode @@ -65,6 +66,34 @@ private bool EvaluateIsManyToMany() return false; } + /// + public override void SetValue(object resource, object? newValue) + { + ArgumentGuard.NotNull(newValue); + AssertIsIdentifiableCollection(newValue); + + base.SetValue(resource, newValue); + } + + private void AssertIsIdentifiableCollection(object newValue) + { + if (newValue is not IEnumerable enumerable) + { + throw new InvalidOperationException($"Resource of type '{newValue.GetType()}' must be a collection."); + } + + foreach (object? element in enumerable) + { + if (element == null) + { + throw new InvalidOperationException("Resource collection must not contain null values."); + } + + AssertIsIdentifiable(element); + } + } + + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -82,6 +111,7 @@ public override bool Equals(object? obj) return _capabilities == other._capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs index c0416c92fb..72212c76f2 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -64,6 +64,14 @@ private bool EvaluateIsOneToOne() return false; } + /// + public override void SetValue(object resource, object? newValue) + { + AssertIsIdentifiable(newValue); + base.SetValue(resource, newValue); + } + + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -81,6 +89,7 @@ public override bool Equals(object? obj) return _capabilities == other._capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index dd94bab221..0b4848ada1 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -86,6 +86,7 @@ public bool CanInclude set => _canInclude = value; } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -103,6 +104,7 @@ public override bool Equals(object? obj) return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(_rightType?.ClrType, Links, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index e8e1d17aca..3a3707442c 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -68,6 +68,7 @@ internal set public object? GetValue(object resource) { ArgumentGuard.NotNull(resource); + AssertIsIdentifiable(resource); if (Property.GetMethod == null) { @@ -82,7 +83,7 @@ internal set { throw new InvalidOperationException( $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } @@ -90,9 +91,10 @@ internal set /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified /// resource instance. /// - public void SetValue(object resource, object? newValue) + public virtual void SetValue(object resource, object? newValue) { ArgumentGuard.NotNull(resource); + AssertIsIdentifiable(resource); if (Property.SetMethod == null) { @@ -107,15 +109,25 @@ public void SetValue(object resource, object? newValue) { throw new InvalidOperationException( $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } + protected void AssertIsIdentifiable(object? resource) + { + if (resource != null && resource is not IIdentifiable) + { + throw new InvalidOperationException($"Resource of type '{resource.GetType()}' does not implement {nameof(IIdentifiable)}."); + } + } + + /// public override string? ToString() { return _publicName ?? (_property != null ? _property.Name : base.ToString()); } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -133,6 +145,7 @@ public override bool Equals(object? obj) return _publicName == other._publicName && _property == other._property; } + /// public override int GetHashCode() { return HashCode.Combine(_publicName, _property); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs new file mode 100644 index 0000000000..66b6dd0c81 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasManyAttributeTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class HasManyAttributeTests +{ + [Fact] + public void Cannot_set_value_to_null() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, null); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_set_value_to_primitive_type() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' must be a collection."); + } + + [Fact] + public void Cannot_set_value_to_single_resource() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, resource); + + // Assert + action.Should().ThrowExactly().WithMessage($"Resource of type '{typeof(TestResource).FullName}' must be a collection."); + } + + [Fact] + public void Can_set_value_to_collection_with_single_resource() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource + }; + + // Act + attribute.SetValue(resource, children); + + // Assert + attribute.GetValue(resource).Should().BeOfType>().Subject.ShouldHaveCount(1); + } + + [Fact] + public void Cannot_set_value_to_collection_with_null_element() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource, + null! + }; + + // Act + Action action = () => attribute.SetValue(resource, children); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource collection must not contain null values."); + } + + [Fact] + public void Cannot_set_value_to_collection_with_primitive_element() + { + // Arrange + var attribute = new HasManyAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Children))! + }; + + var resource = new TestResource(); + + var children = new List + { + resource, + 1 + }; + + // Act + Action action = () => attribute.SetValue(resource, children); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + private sealed class TestResource : Identifiable + { + [HasMany] + public IEnumerable Children { get; set; } = new HashSet(); + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs new file mode 100644 index 0000000000..bd2b44c418 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/HasOneAttributeTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class HasOneAttributeTests +{ + [Fact] + public void Can_set_value_to_null() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + attribute.SetValue(resource, null); + + // Assert + attribute.GetValue(resource).Should().BeNull(); + } + + [Fact] + public void Can_set_value_to_self() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + attribute.SetValue(resource, resource); + + // Assert + attribute.GetValue(resource).Should().Be(resource); + } + + [Fact] + public void Cannot_set_value_to_primitive_type() + { + // Arrange + var attribute = new HasOneAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.Parent))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + private sealed class TestResource : Identifiable + { + [HasOne] + public TestResource? Parent { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs new file mode 100644 index 0000000000..b7a028bd6e --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceFieldAttributeTests.cs @@ -0,0 +1,254 @@ +using System.Reflection; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph; + +public sealed class ResourceFieldAttributeTests +{ + [Fact] + public void Cannot_set_public_name_to_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = null!; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_set_public_name_to_empty() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = string.Empty; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_set_public_name_to_whitespace() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.PublicName = " "; + + // Assert + action.Should().ThrowExactly().WithMessage("Exposed name cannot be null, empty or contain only whitespace. (Parameter 'value')"); + } + + [Fact] + public void Cannot_get_value_for_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.GetValue(null!); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_get_value_for_primitive_type() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.GetValue(1); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + [Fact] + public void Cannot_get_value_for_write_only_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.WriteOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly().WithMessage("Property 'TestResource.WriteOnlyAttribute' is write-only."); + } + + [Fact] + public void Cannot_get_value_for_unknown_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(IHttpContextAccessor).GetProperty(nameof(IHttpContextAccessor.HttpContext))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly() + .WithMessage("Unable to get property value of 'IHttpContextAccessor.HttpContext' on instance of type 'TestResource'.") + .WithInnerException(); + } + + [Fact] + public void Cannot_get_value_for_throwing_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ThrowOnGetAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.GetValue(resource); + + // Assert + action.Should().ThrowExactly().WithInnerException().WithMessage("Getting value is not supported."); + } + + [Fact] + public void Cannot_set_value_for_null() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.SetValue(null!, "some"); + + // Assert + action.Should().ThrowExactly(); + } + + [Fact] + public void Cannot_set_value_for_primitive_type() + { + // Arrange + var attribute = new AttrAttribute(); + + // Act + Action action = () => attribute.SetValue(1, "some"); + + // Assert + action.Should().ThrowExactly().WithMessage("Resource of type 'System.Int32' does not implement IIdentifiable."); + } + + [Fact] + public void Cannot_set_value_for_read_only_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ReadOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, true); + + // Assert + action.Should().ThrowExactly().WithMessage("Property 'TestResource.ReadOnlyAttribute' is read-only."); + } + + [Fact] + public void Cannot_set_value_for_unknown_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(IHttpContextAccessor).GetProperty(nameof(IHttpContextAccessor.HttpContext))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, "some"); + + // Assert + action.Should().ThrowExactly() + .WithMessage("Unable to set property value of 'IHttpContextAccessor.HttpContext' on instance of type 'TestResource'.") + .WithInnerException(); + } + + [Fact] + public void Cannot_set_value_for_throwing_resource_property() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.ThrowOnSetAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, 1); + + // Assert + action.Should().ThrowExactly().WithInnerException().WithMessage("Setting value is not supported."); + } + + [Fact] + public void Cannot_set_value_to_incompatible_value() + { + // Arrange + var attribute = new AttrAttribute + { + Property = typeof(TestResource).GetProperty(nameof(TestResource.WriteOnlyAttribute))! + }; + + var resource = new TestResource(); + + // Act + Action action = () => attribute.SetValue(resource, DateTime.UtcNow); + + // Assert + action.Should().ThrowExactly().WithMessage("Object of type 'System.DateTime' cannot be converted to type 'System.Boolean'."); + } + + private sealed class TestResource : Identifiable + { + [Attr] + public bool ReadOnlyAttribute => true; + + [Attr] + public bool WriteOnlyAttribute + { + set => _ = value; + } + + [Attr] + public int ThrowOnGetAttribute => throw new NotSupportedException("Getting value is not supported."); + + [Attr] + public int ThrowOnSetAttribute + { + get => 1; + set => throw new NotSupportedException("Setting value is not supported."); + } + } +} From b51d68125a14c153048b3e9c32efcf6a5ef4adef Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 15 Apr 2023 11:52:40 +0200 Subject: [PATCH 51/58] Add missing null checks --- src/JsonApiDotNetCore/Queries/FieldSelectors.cs | 3 ++- .../Repositories/EntityFrameworkCoreRepository.cs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index ffd95c01bc..04b32b6499 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -52,9 +52,10 @@ public void IncludeAttributes(IEnumerable attributes) } } - public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer? queryLayer) + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer queryLayer) { ArgumentGuard.NotNull(relationship); + ArgumentGuard.NotNull(queryLayer); this[relationship] = queryLayer; } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 89cfe76b87..6f9c0cbb0d 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -163,6 +163,8 @@ public virtual Task GetForCreateAsync(Type resourceClrType, TId id, C id }); + ArgumentGuard.NotNull(resourceClrType); + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); resource.Id = id; From 858228b8466f7a609596e84606a16744d4042f1f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 22 Apr 2023 18:56:31 +0200 Subject: [PATCH 52/58] Remove unused dependencies on Moq --- test/MultiDbContextTests/MultiDbContextTests.csproj | 1 - test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj | 1 - test/SourceGeneratorTests/SourceGeneratorTests.csproj | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index b08c25805f..5ec0b1400c 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index d9d8b80ad8..b0c4838b1a 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -12,6 +12,5 @@ - diff --git a/test/SourceGeneratorTests/SourceGeneratorTests.csproj b/test/SourceGeneratorTests/SourceGeneratorTests.csproj index fd0fb92262..f9af731411 100644 --- a/test/SourceGeneratorTests/SourceGeneratorTests.csproj +++ b/test/SourceGeneratorTests/SourceGeneratorTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworkName) @@ -13,6 +13,5 @@ - From b96cc4ed917fa17550fbc4a6dd3c67428c776510 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:09:10 +0200 Subject: [PATCH 53/58] Revert workaround --- appveyor.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ca29435445..7d20248b68 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,8 +1,6 @@ image: - Ubuntu2004 - # Downgrade to workaround error NETSDK1194 during 'dotnet pack': The "--output" option isn't supported when building a solution. - # https://stackoverflow.com/questions/75453953/how-to-fix-github-actions-dotnet-publish-workflow-error-the-output-option-i - - Previous Visual Studio 2022 + - Visual Studio 2022 version: '{build}' @@ -34,7 +32,7 @@ for: - matrix: only: - - image: Previous Visual Studio 2022 + - image: Visual Studio 2022 services: - postgresql15 install: From 012cf4ecd90065d7f3b752b2c1e58eaafb2f4d32 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:24:27 +0200 Subject: [PATCH 54/58] Update scripts for breaking change in PowerShell 7.3 --- appveyor.yml | 3 +++ docs/build-dev.ps1 | 2 +- docs/generate-examples.ps1 | 2 +- docs/request-examples/001_GET_Books.ps1 | 2 ++ .../request-examples/002_GET_Person-by-ID.ps1 | 2 ++ .../003_GET_Books-including-Author.ps1 | 2 ++ .../004_GET_Books-PublishYear.ps1 | 2 ++ .../005_GET_People-Filter_Partial.ps1 | 2 ++ ...Books-sorted-by-PublishYear-descending.ps1 | 2 ++ .../007_GET_Books-paginated.ps1 | 2 ++ docs/request-examples/010_CREATE_Person.ps1 | 10 +++++---- .../011_CREATE_Book-with-Author.ps1 | 22 ++++++++++--------- docs/request-examples/012_PATCH_Book.ps1 | 12 +++++----- docs/request-examples/013_DELETE_Book.ps1 | 2 ++ 14 files changed, 46 insertions(+), 21 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 7d20248b68..0139480fec 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -98,6 +98,9 @@ build_script: Write-Output ".NET version:" dotnet --version + Write-Output "PowerShell version:" + pwsh --version + Write-Output "PostgreSQL version:" if ($IsWindows) { . "${env:ProgramFiles}\PostgreSQL\15\bin\psql" --version diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index 5212429b7d..bdd13d16b8 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development. diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index 468b8447ac..cbe7d13e9d 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 7.0 +#Requires -Version 7.3 # This script generates response documents for ./request-examples diff --git a/docs/request-examples/001_GET_Books.ps1 b/docs/request-examples/001_GET_Books.ps1 index 559bbfb4d5..f89f9cdd4f 100644 --- a/docs/request-examples/001_GET_Books.ps1 +++ b/docs/request-examples/001_GET_Books.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books diff --git a/docs/request-examples/002_GET_Person-by-ID.ps1 b/docs/request-examples/002_GET_Person-by-ID.ps1 index d565c7cf53..77851b3116 100644 --- a/docs/request-examples/002_GET_Person-by-ID.ps1 +++ b/docs/request-examples/002_GET_Person-by-ID.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people/1 diff --git a/docs/request-examples/003_GET_Books-including-Author.ps1 b/docs/request-examples/003_GET_Books-including-Author.ps1 index 33f5dcd487..6dd71f1f4e 100644 --- a/docs/request-examples/003_GET_Books-including-Author.ps1 +++ b/docs/request-examples/003_GET_Books-including-Author.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?include=author diff --git a/docs/request-examples/004_GET_Books-PublishYear.ps1 b/docs/request-examples/004_GET_Books-PublishYear.ps1 index a08cb7e6a0..d07cc7bc1a 100644 --- a/docs/request-examples/004_GET_Books-PublishYear.ps1 +++ b/docs/request-examples/004_GET_Books-PublishYear.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear diff --git a/docs/request-examples/005_GET_People-Filter_Partial.ps1 b/docs/request-examples/005_GET_People-Filter_Partial.ps1 index 2e2339f76c..092a54ef1e 100644 --- a/docs/request-examples/005_GET_People-Filter_Partial.ps1 +++ b/docs/request-examples/005_GET_People-Filter_Partial.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/people?filter=contains(name,'Shell')" diff --git a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 index 309ad6dcc6..fd96cb3c86 100644 --- a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 +++ b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?sort=-publishYear diff --git a/docs/request-examples/007_GET_Books-paginated.ps1 b/docs/request-examples/007_GET_Books-paginated.ps1 index c088163a52..a744886801 100644 --- a/docs/request-examples/007_GET_Books-paginated.ps1 +++ b/docs/request-examples/007_GET_Books-paginated.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/books?page%5Bsize%5D=1&page%5Bnumber%5D=2" diff --git a/docs/request-examples/010_CREATE_Person.ps1 b/docs/request-examples/010_CREATE_Person.ps1 index 1a76f0cad1..e8f95020cd 100644 --- a/docs/request-examples/010_CREATE_Person.ps1 +++ b/docs/request-examples/010_CREATE_Person.ps1 @@ -1,10 +1,12 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"people\", - \"attributes\": { - \"name\": \"Alice\" + "data": { + "type": "people", + "attributes": { + "name": "Alice" } } }' diff --git a/docs/request-examples/011_CREATE_Book-with-Author.ps1 b/docs/request-examples/011_CREATE_Book-with-Author.ps1 index bf839f5a85..0737689408 100644 --- a/docs/request-examples/011_CREATE_Book-with-Author.ps1 +++ b/docs/request-examples/011_CREATE_Book-with-Author.ps1 @@ -1,17 +1,19 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"books\", - \"attributes\": { - \"title\": \"Valperga\", - \"publishYear\": 1823 + "data": { + "type": "books", + "attributes": { + "title": "Valperga", + "publishYear": 1823 }, - \"relationships\": { - \"author\": { - \"data\": { - \"type\": \"people\", - \"id\": \"1\" + "relationships": { + "author": { + "data": { + "type": "people", + "id": "1" } } } diff --git a/docs/request-examples/012_PATCH_Book.ps1 b/docs/request-examples/012_PATCH_Book.ps1 index d704c8c8c8..61ea6bee76 100644 --- a/docs/request-examples/012_PATCH_Book.ps1 +++ b/docs/request-examples/012_PATCH_Book.ps1 @@ -1,12 +1,14 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -H "Content-Type: application/vnd.api+json" ` -X PATCH ` -d '{ - \"data\": { - \"type\": \"books\", - \"id\": \"1\", - \"attributes\": { - \"publishYear\": 1820 + "data": { + "type": "books", + "id": "1", + "attributes": { + "publishYear": 1820 } } }' diff --git a/docs/request-examples/013_DELETE_Book.ps1 b/docs/request-examples/013_DELETE_Book.ps1 index d5fdd8e103..bbd7ba7445 100644 --- a/docs/request-examples/013_DELETE_Book.ps1 +++ b/docs/request-examples/013_DELETE_Book.ps1 @@ -1,2 +1,4 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -X DELETE From 6678d87c8304fae88f16f358f8cae2419f1625d2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 28 Apr 2023 11:10:27 +0200 Subject: [PATCH 55/58] Fixed: incoming string representation should not be part of the semantic expression value; fix potential NullReferenceException; use culture-insensitive conversion when no string specified --- .../Expressions/LiteralConstantExpression.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index e8041f0cf2..e5ca0c0318 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,3 +1,4 @@ +using System.Globalization; using JetBrains.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; @@ -8,12 +9,13 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { + // Only used to show the original input, in case expression parse failed. Not part of the semantic expression value. private readonly string _stringValue; public object TypedValue { get; } public LiteralConstantExpression(object typedValue) - : this(typedValue, typedValue.ToString()!) + : this(typedValue, GetStringValue(typedValue)!) { } @@ -26,6 +28,13 @@ public LiteralConstantExpression(object typedValue, string stringValue) _stringValue = stringValue; } + private static string? GetStringValue(object typedValue) + { + ArgumentGuard.NotNull(typedValue); + + return typedValue is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, CultureInfo.InvariantCulture) : typedValue.ToString(); + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitLiteralConstant(this, argument); @@ -56,11 +65,11 @@ public override bool Equals(object? obj) var other = (LiteralConstantExpression)obj; - return Equals(TypedValue, other.TypedValue) && _stringValue == other._stringValue; + return TypedValue.Equals(other.TypedValue); } public override int GetHashCode() { - return HashCode.Combine(TypedValue, _stringValue); + return TypedValue.GetHashCode(); } } From e0464f5bdf1e187f94e3b580a697b7c29daf1121 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 3 May 2023 09:42:03 +0200 Subject: [PATCH 56/58] Correct forgotten part in rename --- .../Serialization/ResourceSerializationBenchmarks.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index a63a0d9cc4..3f9efcc11d 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -122,12 +122,12 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - ResourceType resourceAType = resourceGraph.GetResourceType(); + ResourceType resourceType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + RelationshipAttribute single2 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); var include = new IncludeExpression(new HashSet { From 779daea02e50b1289e0aa8d2675e19869baef23a Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Thu, 11 May 2023 01:24:29 +0200 Subject: [PATCH 57/58] Fixes two problems when using owned entities: 1. Owned entity properties are not retrieved when using sparse fieldsets (because they are modeled as navigations in EF Core, instead of scalar properties) 2. When producing a LINQ query that includes an owned entity, EF Core produces an error, indicating that the query must be marked as non-tracked. Due to potential performance impact, a virtual method is provided that enables tweaking the behavior. --- .../Tools/NeverResourceDefinitionAccessor.cs | 2 ++ .../QueryableBuilding/SelectClauseBuilder.cs | 13 +++++--- .../EntityFrameworkCoreRepository.cs | 19 ++++++++++- .../Resources/IResourceDefinitionAccessor.cs | 9 ++++++ .../Resources/ResourceDefinitionAccessor.cs | 10 ++++++ .../IntegrationTests/Serialization/Address.cs | 12 +++++++ .../Serialization/MeetingAttendee.cs | 3 ++ .../Serialization/SerializationDbContext.cs | 10 ++++++ .../Serialization/SerializationFakers.cs | 9 +++++- .../Serialization/SerializationTests.cs | 32 ++++++++++++++++--- .../FakeResourceDefinitionAccessor.cs | 2 ++ 11 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs index 6e93519dae..3de20cb7fd 100644 --- a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -12,6 +12,8 @@ namespace Benchmarks.Tools; /// internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index c020653775..d206bd8b17 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -150,12 +150,17 @@ private ICollection ToPropertySelectors(FieldSelectors fieldSe private void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + IEntityType entityType = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - foreach (IProperty entityProperty in entityProperties) + foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo!); + var propertySelector = new PropertySelector(property.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + + foreach (INavigation navigation in entityType.GetNavigations().Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) + { + var propertySelector = new PropertySelector(navigation.PropertyInfo!); IncludeWritableProperty(propertySelector, propertySelectors); } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 6f9c0cbb0d..66abfafbe0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -151,7 +151,24 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) protected virtual IQueryable GetAll() { - return _dbContext.Set(); + IQueryable source = _dbContext.Set(); + + return GetTrackingBehavior() switch + { + QueryTrackingBehavior.NoTrackingWithIdentityResolution => source.AsNoTrackingWithIdentityResolution(), + QueryTrackingBehavior.NoTracking => source.AsNoTracking(), + QueryTrackingBehavior.TrackAll => source.AsTracking(), + _ => source + }; + } + + protected virtual QueryTrackingBehavior? GetTrackingBehavior() + { + // EF Core rejects the way we project sparse fieldsets when owned entities are involved, unless the query is explicitly + // marked as non-tracked (see https://github.com/dotnet/EntityFramework.Docs/issues/2205#issuecomment-1542914439). +#pragma warning disable CS0618 + return _resourceDefinitionAccessor.IsReadOnlyRequest ? QueryTrackingBehavior.NoTrackingWithIdentityResolution : null; +#pragma warning restore CS0618 } /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 55f32ead40..df0061a5aa 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -11,6 +11,15 @@ namespace JsonApiDotNetCore.Resources; /// public interface IResourceDefinitionAccessor { + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + bool IsReadOnlyRequest { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 79b48c99eb..6b7ac6625b 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -15,6 +15,16 @@ public class ResourceDefinitionAccessor : IResourceDefinitionAccessor private readonly IResourceGraph _resourceGraph; private readonly IServiceProvider _serviceProvider; + /// + public bool IsReadOnlyRequest + { + get + { + var request = _serviceProvider.GetRequiredService(); + return request.IsReadOnly; + } + } + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { ArgumentGuard.NotNull(resourceGraph); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs new file mode 100644 index 0000000000..97017706e0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Address.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class Address +{ + public string Street { get; set; } = null!; + public string? ZipCode { get; set; } + public string City { get; set; } = null!; + public string Country { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs index 4580d21c52..118e0c9df5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs @@ -11,6 +11,9 @@ public sealed class MeetingAttendee : Identifiable [Attr] public string DisplayName { get; set; } = null!; + [Attr] + public Address HomeAddress { get; set; } = null!; + [HasOne] public Meeting? Meeting { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs index 885c3b950a..32e093214c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; +// @formatter:wrap_chained_method_calls chop_always + namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization; [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -14,4 +16,12 @@ public SerializationDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .OwnsOne(meetingAttendee => meetingAttendee.HomeAddress); + + base.OnModelCreating(builder); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs index a7dded542c..6f327deef4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -29,7 +29,14 @@ internal sealed class SerializationFakers : FakerContainer private readonly Lazy> _lazyMeetingAttendeeFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) - .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String())); + .RuleFor(attendee => attendee.DisplayName, faker => faker.Random.Utf16String()) + .RuleFor(attendee => attendee.HomeAddress, faker => new Address + { + Street = faker.Address.StreetAddress(), + ZipCode = faker.Address.ZipCode(), + City = faker.Address.City(), + Country = faker.Address.Country() + })); public Faker Meeting => _lazyMeetingFaker.Value; public Faker MeetingAttendee => _lazyMeetingAttendeeFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index 63eeabb4c9..efe7f1353c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -142,7 +142,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -191,7 +197,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + attendee.StringId + @""", ""attributes"": { - ""displayName"": """ + attendee.DisplayName + @""" + ""displayName"": """ + attendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + attendee.HomeAddress.Street + @""", + ""zipCode"": """ + attendee.HomeAddress.ZipCode + @""", + ""city"": """ + attendee.HomeAddress.City + @""", + ""country"": """ + attendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -465,7 +477,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""", + ""homeAddress"": { + ""street"": """ + meeting.Attendees[0].HomeAddress.Street + @""", + ""zipCode"": """ + meeting.Attendees[0].HomeAddress.ZipCode + @""", + ""city"": """ + meeting.Attendees[0].HomeAddress.City + @""", + ""country"": """ + meeting.Attendees[0].HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { @@ -704,7 +722,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + existingAttendee.StringId + @""", ""attributes"": { - ""displayName"": """ + existingAttendee.DisplayName + @""" + ""displayName"": """ + existingAttendee.DisplayName + @""", + ""homeAddress"": { + ""street"": """ + existingAttendee.HomeAddress.Street + @""", + ""zipCode"": """ + existingAttendee.HomeAddress.ZipCode + @""", + ""city"": """ + existingAttendee.HomeAddress.City + @""", + ""country"": """ + existingAttendee.HomeAddress.Country + @""" + } }, ""relationships"": { ""meeting"": { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs index 8cd108deae..6126b9d744 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/FakeResourceDefinitionAccessor.cs @@ -9,6 +9,8 @@ namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response; internal sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor { + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { return existingIncludes; From 94a6981930dadeee90b6221248083522746e1085 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Fri, 12 May 2023 13:58:57 +0200 Subject: [PATCH 58/58] Update version to 5.2.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 820d031038..e843788a40 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ 7.0.* 4.5.* 2.14.1 - 5.1.3 + 5.2.0 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable