Skip to content

Commit

Permalink
[Fusion] Added pre-merge validation rule ExternalMissingOnBaseRule (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
glen-84 authored Dec 17, 2024
1 parent 664a518 commit 7cb0019
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Collections.Immutable;

namespace HotChocolate.Fusion.Extensions;

internal static class ImmutableArrayExtensions
{
public static int Count<T>(this ImmutableArray<T> array, Predicate<T> predicate)
{
var count = 0;

foreach (var item in array)
{
if (predicate(item))
{
count++;
}
}

return count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ namespace HotChocolate.Fusion.Logging;
public static class LogEntryCodes
{
public const string DisallowedInaccessible = "DISALLOWED_INACCESSIBLE";
public const string ExternalMissingOnBase = "EXTERNAL_MISSING_ON_BASE";
public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE";
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ public static LogEntry DisallowedInaccessibleDirectiveArgument(
new SchemaCoordinate(directiveName, argumentName: argument.Name, ofDirective: true),
schema: schema);

public static LogEntry ExternalMissingOnBase(string fieldName, string typeName)
=> new(
string.Format(
LogEntryHelper_ExternalMissingOnBase,
fieldName,
typeName),
LogEntryCodes.ExternalMissingOnBase,
LogSeverity.Error,
new SchemaCoordinate(typeName, fieldName));

public static LogEntry OutputFieldTypesNotMergeable(string fieldName, string typeName)
=> new(
string.Format(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using HotChocolate.Fusion.Events;
using HotChocolate.Fusion.Extensions;
using static HotChocolate.Fusion.Logging.LogEntryHelper;

namespace HotChocolate.Fusion.PreMergeValidation.Rules;

/// <summary>
/// This rule ensures that any field marked as <c>@external</c> in a source schema is actually
/// defined (non-<c>@external</c>) in at least one other source schema. The <c>@external</c>
/// directive is used to indicate that the field is not usually resolved by the source schema it is
/// declared in, implying it should be resolvable by at least one other source schema.
/// </summary>
/// <seealso href="https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Missing-on-Base">
/// Specification
/// </seealso>
internal sealed class ExternalMissingOnBaseRule : IEventHandler<OutputFieldGroupEvent>
{
public void Handle(OutputFieldGroupEvent @event, CompositionContext context)
{
var (fieldName, fieldGroup, typeName) = @event;

var externalFieldCount = fieldGroup.Count(i => ValidationHelper.IsExternal(i.Field));
var nonExternalFieldCount = fieldGroup.Length - externalFieldCount;

if (externalFieldCount != 0 && nonExternalFieldCount == 0)
{
context.Log.Write(ExternalMissingOnBase(fieldName, typeName));
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<data name="LogEntryHelper_DisallowedInaccessibleDirectiveArgument" xml:space="preserve">
<value>The argument '{0}' on built-in directive type '{1}' is not accessible.</value>
</data>
<data name="LogEntryHelper_ExternalMissingOnBase" xml:space="preserve">
<value>Field '{0}' on type '{1}' is only declared as external.</value>
</data>
<data name="LogEntryHelper_OutputFieldTypesNotMergeable" xml:space="preserve">
<value>Field '{0}' on type '{1}' is not mergeable.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ private CompositionResult<SchemaDefinition> MergeSchemaDefinitions(CompositionCo
private static readonly List<object> _preMergeValidationRules =
[
new DisallowedInaccessibleElementsRule(),
new ExternalMissingOnBaseRule(),
new OutputFieldTypesMergeableRule()
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using HotChocolate.Fusion;
using HotChocolate.Fusion.Logging;
using HotChocolate.Fusion.PreMergeValidation;
using HotChocolate.Fusion.PreMergeValidation.Rules;
using HotChocolate.Skimmed.Serialization;

namespace HotChocolate.Composition.PreMergeValidation.Rules;

public sealed class ExternalMissingOnBaseRuleTests
{
[Test]
[MethodDataSource(nameof(ValidExamplesData))]
public async Task Examples_Valid(string[] sdl)
{
// arrange
var log = new CompositionLog();
var context = new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log);
var preMergeValidator = new PreMergeValidator([new ExternalMissingOnBaseRule()]);

// act
var result = preMergeValidator.Validate(context);

// assert
await Assert.That(result.IsSuccess).IsTrue();
await Assert.That(log.IsEmpty).IsTrue();
}

[Test]
[MethodDataSource(nameof(InvalidExamplesData))]
public async Task Examples_Invalid(string[] sdl)
{
// arrange
var log = new CompositionLog();
var context = new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log);
var preMergeValidator = new PreMergeValidator([new ExternalMissingOnBaseRule()]);

// act
var result = preMergeValidator.Validate(context);

// assert
await Assert.That(result.IsFailure).IsTrue();
await Assert.That(log.Count()).IsEqualTo(1);
await Assert.That(log.First().Code).IsEqualTo("EXTERNAL_MISSING_ON_BASE");
await Assert.That(log.First().Severity).IsEqualTo(LogSeverity.Error);
}

public static IEnumerable<Func<string[]>> ValidExamplesData()
{
return
[
// Here, the `name` field on Product is defined in source schema A and marked as
// @external in source schema B, which is valid because there is a base definition in
// source schema A.
() =>
[
"""
# Source schema A
type Product {
id: ID
name: String
}
""",
"""
# Source schema B
type Product {
id: ID
name: String @external
}
"""
]
];
}

public static IEnumerable<Func<string[]>> InvalidExamplesData()
{
return
[
// In this example, the `name` field on Product is marked as @external in source schema
// B but has no non-@external declaration in any other source schema, violating the
// rule.
() =>
[
"""
# Source schema A
type Product {
id: ID
}
""",
"""
# Source schema B
type Product {
id: ID
name: String @external
}
"""
],
// The `name` field is external in both source schemas.
() =>
[
"""
# Source schema A
type Product {
id: ID
name: String @external
}
""",
"""
# Source schema B
type Product {
id: ID
name: String @external
}
"""
]
];
}
}

0 comments on commit 7cb0019

Please sign in to comment.