diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index 7bc42043d..e8a18bd17 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -172,6 +172,26 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, fieldContext.CancellationToken); }); + + public static readonly IFieldResolver References = Resolvers.Async(async (source, fieldContext, context) => + { + var query = fieldContext.BuildODataQuery(); + + var q = Q.Empty.WithODataQuery(query).WithReferencing(source.Id).WithoutTotal(); + + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + fieldContext.CancellationToken); + }); + + public static readonly IFieldResolver ReferencesWithTotal = Resolvers.Async(async (source, fieldContext, context) => + { + var query = fieldContext.BuildODataQuery(); + + var q = Q.Empty.WithODataQuery(query).WithReferencing(source.Id); + + return await context.QueryContentsAsync(fieldContext.FieldDefinition.SchemaId(), q, + fieldContext.CancellationToken); + }); } public static class Create diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs index db884677b..0d3b6cda0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentGraphType.cs @@ -14,25 +14,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { internal sealed class ContentGraphType : ObjectGraphType { - private readonly DomainId schemaId; - public ContentGraphType(SchemaInfo schemaInfo) { // The name is used for equal comparison. Therefore it is important to treat it as readonly. Name = schemaInfo.ContentType; - - IsTypeOf = CheckType; - - schemaId = schemaInfo.Schema.Id; - } - - private bool CheckType(object value) - { - return value is IContentEntity content && content.SchemaId?.Id == schemaId; } public void Initialize(Builder builder, SchemaInfo schemaInfo, IEnumerable allSchemas) { + var schemaId = schemaInfo.Schema.Id; + + IsTypeOf = value => + { + return value is IContentEntity content && content.SchemaId?.Id == schemaId; + }; + AddField(ContentFields.Id); AddField(ContentFields.Version); AddField(ContentFields.Created); @@ -74,11 +70,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }); } - foreach (var other in allSchemas.Where(IsReferencingThis)) + foreach (var other in allSchemas.Where(x => IsReference(x, schemaInfo))) { AddReferencingQueries(builder, other); } + foreach (var other in allSchemas.Where(x => IsReference(schemaInfo, x))) + { + AddReferencesQueries(builder, other); + } + AddResolvedInterface(builder.SharedTypes.ContentInterface); Description = $"The structure of a {schemaInfo.DisplayName} content type."; @@ -109,12 +110,37 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }).WithSchemaId(referencingSchemaInfo); } - private bool IsReferencingThis(SchemaInfo other) + private void AddReferencesQueries(Builder builder, SchemaInfo referencesSchemaInfo) + { + var contentType = builder.GetContentType(referencesSchemaInfo); + + AddField(new FieldType + { + Name = $"references{referencesSchemaInfo.TypeName}Contents", + Arguments = ContentActions.QueryOrReferencing.Arguments, + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), + Resolver = ContentActions.QueryOrReferencing.References, + Description = $"Query {referencesSchemaInfo.DisplayName} content items." + }).WithSchemaId(referencesSchemaInfo); + + var contentResultsTyp = builder.GetContentResultType(referencesSchemaInfo); + + AddField(new FieldType + { + Name = $"references{referencesSchemaInfo.TypeName}ContentsWithTotal", + Arguments = ContentActions.QueryOrReferencing.Arguments, + ResolvedType = contentResultsTyp, + Resolver = ContentActions.QueryOrReferencing.ReferencesWithTotal, + Description = $"Query {referencesSchemaInfo.DisplayName} content items with total count." + }).WithSchemaId(referencesSchemaInfo); + } + + private static bool IsReference(SchemaInfo from, SchemaInfo to) { - return other.Schema.SchemaDef.Fields.Any(IsReferencingThis); + return from.Schema.SchemaDef.Fields.Any(x => IsReferencing(x, to.Schema.Id)); } - private bool IsReferencingThis(IField field) + private static bool IsReferencing(IField field, DomainId schemaId) { switch (field) { @@ -124,7 +150,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents reference.Properties.SchemaIds.Count == 0 || reference.Properties.SchemaIds.Contains(schemaId); case IArrayField arrayField: - return arrayField.Fields.Any(IsReferencingThis); + return arrayField.Fields.Any(x => IsReferencing(x, schemaId)); } return false; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 53505e800..098df2c3c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -129,7 +129,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", assetId); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.That.HasIdsWithoutTotal(assetId), A._)) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, + A.That.HasIdsWithoutTotal(assetId), A._)) .Returns(ResultList.CreateFrom(1)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -158,7 +159,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", assetId); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.That.HasIdsWithoutTotal(assetId), A._)) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, + A.That.HasIdsWithoutTotal(assetId), A._)) .Returns(ResultList.CreateFrom(1, asset)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -292,7 +294,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -321,7 +324,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(10, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -350,7 +354,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -423,10 +428,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(0, contentRef)); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -489,7 +496,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentRefId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(1, contentRef)); A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), content.SchemaId.Id.ToString(), @@ -553,12 +561,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentRefId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(1, contentRef)); A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), content.SchemaId.Id.ToString(), A.That.Matches(x => x.QueryAsOdata == "?$top=30&$skip=5" && x.Reference == contentRefId && !x.NoTotal), A._)) - .Returns(ResultList.CreateFrom(1, content)); + .Returns(ResultList.CreateFrom(10, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -571,7 +580,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL id = contentRefId, referencingMySchemaContentsWithTotal = new { - total = 1, + total = 10, items = new[] { new @@ -594,6 +603,113 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_also_fetch_references_contents_if_field_is_included_in_query() + { + var contentRefId = DomainId.NewGuid(); + var contentRef = TestContent.CreateRef(TestSchemas.Ref1Id, contentRefId, "ref1-field", "ref1"); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId, contentRefId); + + var query = CreateQuery(@" + query { + findMySchemaContent(id: '') { + id + referencesMyRefSchema1Contents(top: 30, skip: 5) { + id + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .Returns(ResultList.CreateFrom(1, content)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), contentRef.SchemaId.Id.ToString(), + A.That.Matches(x => x.QueryAsOdata == "?$top=30&$skip=5" && x.Referencing == contentId && x.NoTotal), A._)) + .Returns(ResultList.CreateFrom(1, contentRef)); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = contentId, + referencesMyRefSchema1Contents = new[] + { + new + { + id = contentRefId + } + } + } + } + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_also_fetch_references_contents_with_total_if_field_is_included_in_query() + { + var contentRefId = DomainId.NewGuid(); + var contentRef = TestContent.CreateRef(TestSchemas.Ref1Id, contentRefId, "ref1-field", "ref1"); + + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId, contentRefId); + + var query = CreateQuery(@" + query { + findMySchemaContent(id: '') { + id + referencesMyRefSchema1ContentsWithTotal(top: 30, skip: 5) { + total + items { + id + } + } + } + }", contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) + .Returns(ResultList.CreateFrom(1, content)); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), contentRef.SchemaId.Id.ToString(), + A.That.Matches(x => x.QueryAsOdata == "?$top=30&$skip=5" && x.Referencing == contentId), A._)) + .Returns(ResultList.CreateFrom(10, contentRef)); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = contentId, + referencesMyRefSchema1ContentsWithTotal = new + { + total = 10, + items = new[] + { + new + { + id = contentRefId + } + } + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_also_fetch_union_contents_if_field_is_included_in_query() { @@ -627,10 +743,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentRefId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentRefId), A._)) .Returns(ResultList.CreateFrom(0, contentRef)); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -693,10 +811,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); - A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A.That.HasIdsWithoutTotal(assetRefId), A._)) + A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, + A.That.HasIdsWithoutTotal(assetRefId), A._)) .Returns(ResultList.CreateFrom(0, assetRef)); var result = await ExecuteAsync(new ExecutionOptions { Query = query }); @@ -752,7 +872,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }", contentId); - A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A.That.HasIdsWithoutTotal(contentId), A._)) + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), + A.That.HasIdsWithoutTotal(contentId), A._)) .Returns(ResultList.CreateFrom(1, content)); var result = await ExecuteAsync(new ExecutionOptions { Query = query });