diff --git a/dictionary.txt b/dictionary.txt index fdebf7dec77..0cdce2781d9 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -136,6 +136,7 @@ Rgba Rhai Roslynator runbooks +Satisfiability Senn shoooe Skywalker diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 22a48f4fd9f..8018a15419c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -60,8 +60,8 @@ - + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/CompositionContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/CompositionContext.cs new file mode 100644 index 00000000000..a356991c860 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/CompositionContext.cs @@ -0,0 +1,20 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Logging.Contracts; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion; + +internal sealed class CompositionContext( + ImmutableArray schemaDefinitions, + ICompositionLog compositionLog) +{ + /// + /// Gets the schema definitions. + /// + public ImmutableArray SchemaDefinitions { get; } = schemaDefinitions; + + /// + /// Gets the composition log. + /// + public ICompositionLog Log { get; } = compositionLog; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/CompositionError.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/CompositionError.cs new file mode 100644 index 00000000000..c4081105cd5 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/CompositionError.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Fusion.Errors; + +public sealed class CompositionError(string message) +{ + public string Message { get; } = message; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/ErrorHelper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/ErrorHelper.cs new file mode 100644 index 00000000000..4841f244835 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Errors/ErrorHelper.cs @@ -0,0 +1,10 @@ +using HotChocolate.Fusion.PreMergeValidation.Contracts; +using static HotChocolate.Fusion.Properties.CompositionResources; + +namespace HotChocolate.Fusion.Errors; + +internal static class ErrorHelper +{ + public static CompositionError PreMergeValidationRuleFailed(IPreMergeValidationRule rule) + => new(string.Format(ErrorHelper_PreMergeValidationRuleFailed, rule.GetType().Name)); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Extensions/TypeDefinitionExtensions.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Extensions/TypeDefinitionExtensions.cs new file mode 100644 index 00000000000..741b60e4fd1 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Extensions/TypeDefinitionExtensions.cs @@ -0,0 +1,16 @@ +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion.Extensions; + +internal static class TypeDefinitionExtensions +{ + public static ITypeDefinition InnerNullableType(this ITypeDefinition type) + { + return type switch + { + ListTypeDefinition listType => listType.ElementType.NullableType(), + NonNullTypeDefinition nonNullType => nonNullType.NullableType, + _ => type + }; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj index f11759cfaec..a7d4025054b 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/HotChocolate.Fusion.Composition.csproj @@ -1,8 +1,31 @@ - + HotChocolate.Fusion.Composition HotChocolate.Fusion + + + + + + + + + + + ResXFileCodeGenerator + CompositionResources.Designer.cs + + + + + + True + True + CompositionResources.resx + + + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/CompositionLog.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/CompositionLog.cs new file mode 100644 index 00000000000..2f9cc4b9cea --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/CompositionLog.cs @@ -0,0 +1,27 @@ +using System.Collections; +using HotChocolate.Fusion.Logging.Contracts; + +namespace HotChocolate.Fusion.Logging; + +public sealed class CompositionLog : ICompositionLog, IEnumerable +{ + public bool IsEmpty => _entries.Count == 0; + + private readonly List _entries = []; + + public void Write(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + _entries.Add(entry); + } + + public ILoggingSession CreateSession() + { + return new LoggingSession(this); + } + + public IEnumerator GetEnumerator() => _entries.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ICompositionLog.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ICompositionLog.cs new file mode 100644 index 00000000000..1251c6adf64 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ICompositionLog.cs @@ -0,0 +1,21 @@ +namespace HotChocolate.Fusion.Logging.Contracts; + +/// +/// Defines an interface for logging composition information, warnings, and errors. +/// +public interface ICompositionLog +{ + bool IsEmpty { get; } + + /// + /// Writes the specified to the log. + /// + /// The to write. + void Write(LogEntry entry); + + /// + /// Creates a new logging session that keeps track of the number of info, warning, and error + /// entries logged. + /// + ILoggingSession CreateSession(); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ILoggingSession.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ILoggingSession.cs new file mode 100644 index 00000000000..56f1b5b7547 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/Contracts/ILoggingSession.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Logging.Contracts; + +public interface ILoggingSession +{ + int InfoCount { get; } + + int WarningCount { get; } + + int ErrorCount { get; } + + void Write(LogEntry entry); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntry.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntry.cs new file mode 100644 index 00000000000..cad3c9a3d28 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntry.cs @@ -0,0 +1,71 @@ +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion.Logging; + +/// +/// Represents an entry in a composition log that describes an issue encountered during the +/// composition process. +/// +public sealed record LogEntry +{ + /// + /// Initializes a new instance of the record with the specified values. + /// + public LogEntry( + string message, + string code, + LogSeverity severity = LogSeverity.Error, + SchemaCoordinate? coordinate = null, + ITypeSystemMemberDefinition? member = null, + SchemaDefinition? schema = null, + object? extension = null) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(code); + + Message = message; + Code = code; + Severity = severity; + Coordinate = coordinate; + Member = member; + Schema = schema; + Extension = extension; + } + + /// + /// Gets the message associated with this log entry. + /// + public string Message { get; } + + /// + /// Gets the code associated with this log entry. + /// + public string Code { get; } + + /// + /// Gets the severity of this log entry. + /// + public LogSeverity Severity { get; } + + /// + /// Gets the schema coordinate associated with this log entry. + /// + public SchemaCoordinate? Coordinate { get; } + + /// + /// Gets the type system member associated with this log entry. + /// + public ITypeSystemMemberDefinition? Member { get; } + + /// + /// Gets the schema associated with this log entry. + /// + public SchemaDefinition? Schema { get; } + + /// + /// Gets the extension object associated with this log entry. + /// + public object? Extension { get; } + + public override string ToString() => Message; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs new file mode 100644 index 00000000000..298f766cf33 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -0,0 +1,7 @@ +namespace HotChocolate.Fusion.Logging; + +public static class LogEntryCodes +{ + public const string DisallowedInaccessible = "DISALLOWED_INACCESSIBLE"; + public const string OutputFieldTypesNotMergeable = "OUTPUT_FIELD_TYPES_NOT_MERGEABLE"; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs new file mode 100644 index 00000000000..f678870839e --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogEntryHelper.cs @@ -0,0 +1,88 @@ +using HotChocolate.Skimmed; +using static HotChocolate.Fusion.Properties.CompositionResources; + +namespace HotChocolate.Fusion.Logging; + +internal static class LogEntryHelper +{ + public static LogEntry DisallowedInaccessibleScalar( + ScalarTypeDefinition scalar, + SchemaDefinition schema) + => new( + string.Format(LogEntryHelper_DisallowedInaccessibleScalar, scalar.Name), + LogEntryCodes.DisallowedInaccessible, + LogSeverity.Error, + new SchemaCoordinate(scalar.Name), + scalar, + schema); + + public static LogEntry DisallowedInaccessibleIntrospectionType( + INamedTypeDefinition type, + SchemaDefinition schema) + => new( + string.Format(LogEntryHelper_DisallowedInaccessibleIntrospectionType, type.Name), + LogEntryCodes.DisallowedInaccessible, + LogSeverity.Error, + new SchemaCoordinate(type.Name), + type, + schema); + + public static LogEntry DisallowedInaccessibleIntrospectionField( + OutputFieldDefinition field, + string typeName, + SchemaDefinition schema) + => new( + string.Format( + LogEntryHelper_DisallowedInaccessibleIntrospectionField, + field.Name, + typeName), + LogEntryCodes.DisallowedInaccessible, + LogSeverity.Error, + new SchemaCoordinate(typeName, field.Name), + field, + schema); + + public static LogEntry DisallowedInaccessibleIntrospectionArgument( + InputFieldDefinition argument, + string fieldName, + string typeName, + SchemaDefinition schema) + { + var coordinate = new SchemaCoordinate(typeName, fieldName, argument.Name); + + return new LogEntry( + string.Format( + LogEntryHelper_DisallowedInaccessibleIntrospectionArgument, + argument.Name, + coordinate), + LogEntryCodes.DisallowedInaccessible, + LogSeverity.Error, + coordinate, + argument, + schema); + } + + public static LogEntry DisallowedInaccessibleDirectiveArgument( + InputFieldDefinition argument, + string directiveName, + SchemaDefinition schema) + => new( + string.Format( + LogEntryHelper_DisallowedInaccessibleDirectiveArgument, + argument.Name, + directiveName), + LogEntryCodes.DisallowedInaccessible, + LogSeverity.Error, + new SchemaCoordinate(directiveName, argumentName: argument.Name, ofDirective: true), + schema: schema); + + public static LogEntry OutputFieldTypesNotMergeable(string typeName, string fieldName) + => new( + string.Format( + LogEntryHelper_OutputFieldTypesNotMergeable, + typeName, + fieldName), + LogEntryCodes.OutputFieldTypesNotMergeable, + LogSeverity.Error, + new SchemaCoordinate(typeName, fieldName)); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogSeverity.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogSeverity.cs new file mode 100644 index 00000000000..70cc9994b49 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LogSeverity.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Fusion.Logging; + +/// +/// Defines the severity of the log entry. +/// +public enum LogSeverity +{ + /// + /// The entry contains an informational message. + /// + Info, + + /// + /// The entry contains a warning message. + /// + Warning, + + /// + /// The entry contains an error message. + /// + Error +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LoggingSession.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LoggingSession.cs new file mode 100644 index 00000000000..6ee1b23df63 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Logging/LoggingSession.cs @@ -0,0 +1,37 @@ +using HotChocolate.Fusion.Logging.Contracts; + +namespace HotChocolate.Fusion.Logging; + +public sealed class LoggingSession(ICompositionLog compositionLog) : ILoggingSession +{ + public int InfoCount { get; private set; } + + public int WarningCount { get; private set; } + + public int ErrorCount { get; private set; } + + public void Write(LogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + switch (entry.Severity) + { + case LogSeverity.Info: + InfoCount++; + break; + + case LogSeverity.Warning: + WarningCount++; + break; + + case LogSeverity.Error: + ErrorCount++; + break; + + default: + throw new InvalidOperationException(); + } + + compositionLog.Write(entry); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PostMergeValidation/PostMergeValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PostMergeValidation/PostMergeValidator.cs new file mode 100644 index 00000000000..fb5234a33b3 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PostMergeValidation/PostMergeValidator.cs @@ -0,0 +1,13 @@ +using HotChocolate.Fusion.Results; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion.PostMergeValidation; + +internal sealed class PostMergeValidator +{ + public CompositionResult Validate(SchemaDefinition _) + { + // FIXME: Implement. + return CompositionResult.Success(); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Contracts/IPreMergeValidationRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Contracts/IPreMergeValidationRule.cs new file mode 100644 index 00000000000..a9a60320a91 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Contracts/IPreMergeValidationRule.cs @@ -0,0 +1,8 @@ +using HotChocolate.Fusion.Results; + +namespace HotChocolate.Fusion.PreMergeValidation.Contracts; + +internal interface IPreMergeValidationRule +{ + CompositionResult Run(PreMergeValidationContext context); +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidationContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidationContext.cs new file mode 100644 index 00000000000..538df65d0ca --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidationContext.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Logging.Contracts; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion.PreMergeValidation; + +internal sealed class PreMergeValidationContext(CompositionContext context) +{ + public ImmutableArray SchemaDefinitions => context.SchemaDefinitions; + public ICompositionLog Log => context.Log; + public ImmutableArray OutputTypeInfo = []; + + public void Initialize() + { + InitializeOutputTypeInfo(); + } + + /// + /// Initializes a structure that makes it easier to access combined output types, fields, and + /// arguments for validation purposes. + /// + private void InitializeOutputTypeInfo() + { + OutputTypeInfo = + [ + .. SchemaDefinitions + .SelectMany(s => s.Types) + .Where(t => t.IsOutputType()) + .OfType() + .GroupBy( + t => t.Name, + (typeName, types) => + { + types = types.ToImmutableArray(); + + var fieldInfo = types + .SelectMany(t => t.Fields) + .GroupBy( + f => f.Name, + (fieldName, fields) => + { + fields = fields.ToImmutableArray(); + + var argumentInfo = fields + .SelectMany(f => f.Arguments) + .GroupBy( + a => a.Name, + (argumentName, arguments) => + new OutputArgumentInfo( + argumentName, + [.. arguments])); + + return new OutputFieldInfo( + fieldName, + [.. fields], + [.. argumentInfo]); + }); + + return new OutputTypeInfo(typeName, [.. types], [.. fieldInfo]); + }) + ]; + } +} + +internal record OutputTypeInfo( + string TypeName, + ImmutableArray Types, + ImmutableArray FieldInfo); + +internal record OutputFieldInfo( + string FieldName, + ImmutableArray Fields, + ImmutableArray Arguments); + +internal record OutputArgumentInfo( + string ArgumentName, + ImmutableArray Arguments); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs new file mode 100644 index 00000000000..1d812523f12 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/PreMergeValidator.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Errors; +using HotChocolate.Fusion.PreMergeValidation.Contracts; +using HotChocolate.Fusion.PreMergeValidation.Rules; +using HotChocolate.Fusion.Results; + +namespace HotChocolate.Fusion.PreMergeValidation; + +internal sealed class PreMergeValidator +{ + private readonly ImmutableArray _validationRules = + [ + new DisallowedInaccessibleElementsRule(), + new OutputFieldTypesMergeableRule() + ]; + + public CompositionResult Validate(CompositionContext compositionContext) + { + var preMergeValidationContext = new PreMergeValidationContext(compositionContext); + preMergeValidationContext.Initialize(); + + var errors = new List(); + + foreach (var validationRule in _validationRules) + { + var result = validationRule.Run(preMergeValidationContext); + + if (result.IsFailure) + { + errors.AddRange(result.Errors); + } + } + + return errors; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/DisallowedInaccessibleElementsRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/DisallowedInaccessibleElementsRule.cs new file mode 100644 index 00000000000..b01d1f11c78 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/DisallowedInaccessibleElementsRule.cs @@ -0,0 +1,94 @@ +using HotChocolate.Fusion.Errors; +using HotChocolate.Fusion.PreMergeValidation.Contracts; +using HotChocolate.Fusion.Results; +using HotChocolate.Skimmed; +using static HotChocolate.Fusion.Logging.LogEntryHelper; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// This rule ensures that certain essential elements of a GraphQL schema, particularly built-in +/// scalars, directive arguments, and introspection types, cannot be marked as @inaccessible. These +/// types are fundamental to GraphQL. Making these elements inaccessible would break core GraphQL +/// functionality. +/// +/// +/// Specification +/// +internal sealed class DisallowedInaccessibleElementsRule : IPreMergeValidationRule +{ + public CompositionResult Run(PreMergeValidationContext context) + { + var loggingSession = context.Log.CreateSession(); + + foreach (var schema in context.SchemaDefinitions) + { + foreach (var type in schema.Types) + { + if (type is ScalarTypeDefinition { IsSpecScalar: true } scalar + && !ValidationHelper.IsAccessible(type)) + { + loggingSession.Write(DisallowedInaccessibleScalar(scalar, schema)); + } + + if (type.IsIntrospectionType) + { + if (!ValidationHelper.IsAccessible(type)) + { + loggingSession.Write(DisallowedInaccessibleIntrospectionType(type, schema)); + } + + if (type is ComplexTypeDefinition complexType) + { + foreach (var field in complexType.Fields) + { + if (!ValidationHelper.IsAccessible(field)) + { + loggingSession.Write( + DisallowedInaccessibleIntrospectionField( + field, + type.Name, + schema)); + } + + foreach (var argument in field.Arguments) + { + if (!ValidationHelper.IsAccessible(argument)) + { + loggingSession.Write( + DisallowedInaccessibleIntrospectionArgument( + argument, + field.Name, + type.Name, + schema)); + } + } + } + } + } + } + + foreach (var directive in schema.DirectiveDefinitions) + { + if (BuiltIns.IsBuiltInDirective(directive.Name)) + { + foreach (var argument in directive.Arguments) + { + if (!ValidationHelper.IsAccessible(argument)) + { + loggingSession.Write( + DisallowedInaccessibleDirectiveArgument( + argument, + directive.Name, + schema)); + } + } + } + } + } + + return loggingSession.ErrorCount == 0 + ? CompositionResult.Success() + : ErrorHelper.PreMergeValidationRuleFailed(this); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/OutputFieldTypesMergeableRule.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/OutputFieldTypesMergeableRule.cs new file mode 100644 index 00000000000..e93c6e91b14 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/PreMergeValidation/Rules/OutputFieldTypesMergeableRule.cs @@ -0,0 +1,39 @@ +using HotChocolate.Fusion.Errors; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.PreMergeValidation.Contracts; +using HotChocolate.Fusion.Results; + +namespace HotChocolate.Fusion.PreMergeValidation.Rules; + +/// +/// Fields on objects or interfaces that have the same name are considered semantically equivalent +/// and mergeable when they have a mergeable field type. +/// +/// +/// Specification +/// +internal sealed class OutputFieldTypesMergeableRule : IPreMergeValidationRule +{ + public CompositionResult Run(PreMergeValidationContext context) + { + var loggingSession = context.Log.CreateSession(); + + foreach (var outputTypeInfo in context.OutputTypeInfo) + { + foreach (var fieldInfo in outputTypeInfo.FieldInfo) + { + if (!ValidationHelper.FieldsAreMergeable(fieldInfo.Fields)) + { + loggingSession.Write( + LogEntryHelper.OutputFieldTypesNotMergeable( + fieldInfo.FieldName, + outputTypeInfo.TypeName)); + } + } + } + + return loggingSession.ErrorCount == 0 + ? CompositionResult.Success() + : ErrorHelper.PreMergeValidationRuleFailed(this); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs new file mode 100644 index 00000000000..b3a9cb1b02d --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -0,0 +1,125 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace HotChocolate.Fusion.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class CompositionResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal CompositionResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.Fusion.Properties.CompositionResources", typeof(CompositionResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Pre-merge validation rule '{0}' failed. View the composition log for details.. + /// + internal static string ErrorHelper_PreMergeValidationRuleFailed { + get { + return ResourceManager.GetString("ErrorHelper_PreMergeValidationRuleFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The argument '{0}' on built-in directive type '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleDirectiveArgument { + get { + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleDirectiveArgument", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The introspection argument '{0}' with schema coordinate '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionArgument { + get { + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionArgument", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The introspection field '{0}' on type '{1}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionField { + get { + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionField", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The introspection type '{0}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleIntrospectionType { + get { + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleIntrospectionType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The built-in scalar type '{0}' is not accessible.. + /// + internal static string LogEntryHelper_DisallowedInaccessibleScalar { + get { + return ResourceManager.GetString("LogEntryHelper_DisallowedInaccessibleScalar", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Field '{0}' on type '{1}' is not mergeable.. + /// + internal static string LogEntryHelper_OutputFieldTypesNotMergeable { + get { + return ResourceManager.GetString("LogEntryHelper_OutputFieldTypesNotMergeable", resourceCulture); + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx new file mode 100644 index 00000000000..d291d0f232a --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Properties/CompositionResources.resx @@ -0,0 +1,42 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Pre-merge validation rule '{0}' failed. View the composition log for details. + + + The built-in scalar type '{0}' is not accessible. + + + The introspection type '{0}' is not accessible. + + + The introspection field '{0}' on type '{1}' is not accessible. + + + The introspection argument '{0}' with schema coordinate '{1}' is not accessible. + + + The argument '{0}' on built-in directive type '{1}' is not accessible. + + + Field '{0}' on type '{1}' is not mergeable. + + diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult.cs new file mode 100644 index 00000000000..4bbe1f88e62 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Errors; + +namespace HotChocolate.Fusion.Results; + +public readonly record struct CompositionResult +{ + public bool IsFailure { get; } + + public bool IsSuccess { get; } + + public ImmutableArray Errors { get; } = []; + + public CompositionResult() + { + IsSuccess = true; + } + + private CompositionResult(CompositionError error) + { + Errors = [error]; + IsFailure = true; + } + + private CompositionResult(ImmutableArray errors) + { + if (errors.Length == 0) + { + IsSuccess = true; + } + else + { + Errors = errors; + IsFailure = true; + } + } + + public static CompositionResult Success() => new(); + + /// + /// Creates a from a composition error. + /// + public static implicit operator CompositionResult(CompositionError error) + { + ArgumentNullException.ThrowIfNull(error); + + return new CompositionResult(error); + } + + /// + /// Creates a from an array of composition errors. + /// + public static implicit operator CompositionResult(ImmutableArray errors) + { + return new CompositionResult(errors); + } + + /// + /// Creates a from a list of composition errors. + /// + public static implicit operator CompositionResult(List errors) + { + ArgumentNullException.ThrowIfNull(errors); + + return new CompositionResult([.. errors]); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult~1.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult~1.cs new file mode 100644 index 00000000000..6e801fac65c --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/Results/CompositionResult~1.cs @@ -0,0 +1,79 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Errors; + +namespace HotChocolate.Fusion.Results; + +public readonly record struct CompositionResult +{ + public bool IsFailure { get; } + + public bool IsSuccess { get; } + + public ImmutableArray Errors { get; } = []; + + public TValue Value => IsSuccess + ? _value + : throw new Exception("Value may not be accessed on an unsuccessful result."); + + private readonly TValue _value = default!; + + private CompositionResult(TValue value) + { + _value = value; + IsSuccess = true; + } + + private CompositionResult(CompositionError error) + { + Errors = [error]; + IsFailure = true; + } + + private CompositionResult(ImmutableArray errors) + { + if (errors.Length == 0) + { + IsSuccess = true; + } + else + { + Errors = errors; + IsFailure = true; + } + } + + /// + /// Creates a from a value. + /// + public static implicit operator CompositionResult(TValue value) + { + return new CompositionResult(value); + } + + /// + /// Creates a from a composition error. + /// + public static implicit operator CompositionResult(CompositionError error) + { + ArgumentNullException.ThrowIfNull(error); + + return new CompositionResult(error); + } + + /// + /// Creates a from an array of composition errors. + /// + public static implicit operator CompositionResult( + ImmutableArray errors) + { + return new CompositionResult(errors); + } + + /// + /// Creates a from a . + /// + public static implicit operator CompositionResult(CompositionResult result) + { + return new CompositionResult(result.Errors); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SatisfiabilityValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SatisfiabilityValidator.cs new file mode 100644 index 00000000000..70be0ec4862 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SatisfiabilityValidator.cs @@ -0,0 +1,13 @@ +using HotChocolate.Fusion.Results; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion; + +internal sealed class SatisfiabilityValidator +{ + public CompositionResult Validate(SchemaDefinition _) + { + // FIXME: Implement. + return CompositionResult.Success(); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs new file mode 100644 index 00000000000..e4525fba52c --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SchemaComposer.cs @@ -0,0 +1,44 @@ +using HotChocolate.Fusion.Logging.Contracts; +using HotChocolate.Fusion.Results; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion; + +public sealed class SchemaComposer +{ + public CompositionResult Compose( + IEnumerable schemaDefinitions, + ICompositionLog compositionLog) + { + ArgumentNullException.ThrowIfNull(schemaDefinitions); + ArgumentNullException.ThrowIfNull(compositionLog); + + var context = new CompositionContext([.. schemaDefinitions], compositionLog); + + // Validate Source Schemas + var validationResult = new SourceSchemaValidator().Validate(context); + + if (validationResult.IsFailure) + { + return validationResult; + } + + // Merge Source Schemas + var mergeResult = new SourceSchemaMerger().Merge(context); + + if (mergeResult.IsFailure) + { + return mergeResult; + } + + // Validate Satisfiability + var satisfiabilityResult = new SatisfiabilityValidator().Validate(mergeResult.Value); + + if (satisfiabilityResult.IsFailure) + { + return satisfiabilityResult; + } + + return mergeResult; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs new file mode 100644 index 00000000000..eb2266bc711 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaMerger.cs @@ -0,0 +1,44 @@ +using HotChocolate.Fusion.PostMergeValidation; +using HotChocolate.Fusion.PreMergeValidation; +using HotChocolate.Fusion.Results; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion; + +internal sealed class SourceSchemaMerger +{ + public CompositionResult Merge(CompositionContext context) + { + // Pre Merge Validation + var preMergeValidationResult = new PreMergeValidator().Validate(context); + + if (preMergeValidationResult.IsFailure) + { + return preMergeValidationResult; + } + + // Merge + var mergeResult = MergeSchemaDefinitions(context); + + if (mergeResult.IsFailure) + { + return mergeResult; + } + + // Post Merge Validation + var postMergeValidationResult = new PostMergeValidator().Validate(mergeResult.Value); + + if (postMergeValidationResult.IsFailure) + { + return postMergeValidationResult; + } + + return mergeResult; + } + + private CompositionResult MergeSchemaDefinitions(CompositionContext _) + { + // FIXME: Implement. + return new SchemaDefinition(); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaValidator.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaValidator.cs new file mode 100644 index 00000000000..67e91bc4258 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/SourceSchemaValidator.cs @@ -0,0 +1,12 @@ +using HotChocolate.Fusion.Results; + +namespace HotChocolate.Fusion; + +internal sealed class SourceSchemaValidator +{ + public CompositionResult Validate(CompositionContext _) + { + // FIXME: Implement. + return CompositionResult.Success(); + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/ValidationHelper.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/ValidationHelper.cs new file mode 100644 index 00000000000..85d98039087 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/ValidationHelper.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using HotChocolate.Skimmed; + +namespace HotChocolate.Fusion; + +internal sealed class ValidationHelper +{ + public static bool FieldsAreMergeable(ImmutableArray fields) + { + for (var i = 0; i < fields.Length - 1; i++) + { + var typeA = fields[i].Type; + var typeB = fields[i + 1].Type; + + if (!SameTypeShape(typeA, typeB)) + { + return false; + } + } + + return true; + } + + public static bool IsAccessible(IDirectivesProvider type) + { + return !type.Directives.ContainsName(WellKnownDirectiveNames.Inaccessible); + } + + public static bool SameTypeShape(ITypeDefinition typeA, ITypeDefinition typeB) + { + while (true) + { + if (typeA is NonNullTypeDefinition && typeB is not NonNullTypeDefinition) + { + typeA = typeA.InnerType(); + + continue; + } + + if (typeB is NonNullTypeDefinition && typeA is not NonNullTypeDefinition) + { + typeB = typeB.InnerType(); + + continue; + } + + if (typeA is ListTypeDefinition || typeB is ListTypeDefinition) + { + if (typeA is not ListTypeDefinition || typeB is not ListTypeDefinition) + { + return false; + } + + typeA = typeA.InnerType(); + typeB = typeB.InnerType(); + + continue; + } + + if (typeA.Kind != typeB.Kind) + { + return false; + } + + if (typeA.NamedType().Name != typeB.NamedType().Name) + { + return false; + } + + return true; + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownDirectiveNames.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownDirectiveNames.cs new file mode 100644 index 00000000000..b4cf8adf77c --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Composition/WellKnownDirectiveNames.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Fusion; + +internal static class WellKnownDirectiveNames +{ + public const string Inaccessible = "inaccessible"; +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj index b2fc6a7f637..7c9e08f086f 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj @@ -1,4 +1,4 @@ - + HotChocolate.Fusion.Composition.Tests @@ -11,4 +11,8 @@ + + + + diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/DisallowedInaccessibleElementsRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/DisallowedInaccessibleElementsRuleTests.cs new file mode 100644 index 00000000000..b46fee89348 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/DisallowedInaccessibleElementsRuleTests.cs @@ -0,0 +1,130 @@ +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 DisallowedInaccessibleElementsRuleTests +{ + [Test] + [MethodDataSource(nameof(ValidExamplesData))] + public async Task Examples_Valid(string[] sdl) + { + // arrange + var log = new CompositionLog(); + var context = new PreMergeValidationContext( + new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log)); + + context.Initialize(); + + // act + var result = new DisallowedInaccessibleElementsRule().Run(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 PreMergeValidationContext( + new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log)); + + context.Initialize(); + + // act + var result = new DisallowedInaccessibleElementsRule().Run(context); + + // assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(log.Count()).IsEqualTo(1); + await Assert.That(log.First().Code).IsEqualTo("DISALLOWED_INACCESSIBLE"); + await Assert.That(log.First().Severity).IsEqualTo(LogSeverity.Error); + } + + public static IEnumerable> ValidExamplesData() + { + return + [ + // Here, the String type is not marked as @inaccessible, which adheres to the rule. + () => + [ + """ + type Product { + price: Float + name: String + } + """ + ] + ]; + } + + public static IEnumerable> InvalidExamplesData() + { + return + [ + // In this example, the String scalar is marked as @inaccessible. This violates the rule + // because String is a required built-in type that cannot be inaccessible. + () => + [ + """ + scalar String @inaccessible + + type Product { + price: Float + name: String + } + """ + ], + // In this example, the introspection type __Type is marked as @inaccessible. This + // violates the rule because introspection types must remain accessible for GraphQL + // introspection queries to work. + () => + [ + """ + type __Type @inaccessible { + kind: __TypeKind! + name: String + fields(includeDeprecated: Boolean = false): [__Field!] + } + """ + ], + // Inaccessible introspection field. + () => + [ + """ + type __Type { + kind: __TypeKind! @inaccessible + name: String + fields(includeDeprecated: Boolean = false): [__Field!] + } + """ + ], + // Inaccessible introspection argument. + () => + [ + """ + type __Type { + kind: __TypeKind! + name: String + fields(includeDeprecated: Boolean = false @inaccessible): [__Field!] + } + """ + ], + // Inaccessible built-in directive argument. + () => + [ + """ + directive @skip(if: Boolean! @inaccessible) + on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + """ + ] + ]; + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/OutputFieldTypesMergeableRuleTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/OutputFieldTypesMergeableRuleTests.cs new file mode 100644 index 00000000000..998ee86ef0d --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/PreMergeValidation/Rules/OutputFieldTypesMergeableRuleTests.cs @@ -0,0 +1,144 @@ +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 OutputFieldTypesMergeableRuleTests +{ + [Test] + [MethodDataSource(nameof(ValidExamplesData))] + public async Task Examples_Valid(string[] sdl) + { + // arrange + var log = new CompositionLog(); + var context = new PreMergeValidationContext( + new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log)); + + context.Initialize(); + + // act + var result = new OutputFieldTypesMergeableRule().Run(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 PreMergeValidationContext( + new CompositionContext([.. sdl.Select(SchemaParser.Parse)], log)); + + context.Initialize(); + + // act + var result = new OutputFieldTypesMergeableRule().Run(context); + + // assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(log.Count()).IsEqualTo(1); + await Assert.That(log.First().Code).IsEqualTo("OUTPUT_FIELD_TYPES_NOT_MERGEABLE"); + await Assert.That(log.First().Severity).IsEqualTo(LogSeverity.Error); + } + + public static IEnumerable> ValidExamplesData() + { + return + [ + // Fields with the same type are mergeable. + () => + [ + """ + type User { + birthdate: String + } + """, + """ + type User { + birthdate: String + } + """ + ], + // Fields with different nullability are mergeable, resulting in a merged field with a + // nullable type. + () => + [ + """ + type User { + birthdate: String! + } + """, + """ + type User { + birthdate: String + } + """ + ], + () => + [ + """ + type User { + tags: [String!] + } + """, + """ + type User { + tags: [String]! + } + """, + """ + type User { + tags: [String] + } + """ + ] + ]; + } + + public static IEnumerable> InvalidExamplesData() + { + return + [ + // Fields are not mergeable if the named types are different in kind or name. + () => + [ + """ + type User { + birthdate: String! + } + """, + """ + type User { + birthdate: DateTime! + } + """ + ], + () => + [ + """ + type User { + tags: [Tag] + } + + type Tag { + value: String + } + """, + """ + type User { + tags: [Tag] + } + + scalar Tag + """ + ] + ]; + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/ValidationHelperTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/ValidationHelperTests.cs new file mode 100644 index 00000000000..26ee73906b3 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.Composition.Tests/ValidationHelperTests.cs @@ -0,0 +1,78 @@ +using HotChocolate.Fusion; +using HotChocolate.Skimmed; +using HotChocolate.Skimmed.Serialization; + +namespace HotChocolate.Composition; + +public sealed class ValidationHelperTests +{ + [Test] + [Arguments("Int", "Int")] + [Arguments("[Int]", "[Int]")] + [Arguments("[[Int]]", "[[Int]]")] + // Different nullability. + [Arguments("Int", "Int!")] + [Arguments("[Int]", "[Int!]")] + [Arguments("[Int]", "[Int!]!")] + [Arguments("[[Int]]", "[[Int!]]")] + [Arguments("[[Int]]", "[[Int!]!]")] + [Arguments("[[Int]]", "[[Int!]!]!")] + public async Task SameTypeShape_True(string sdlTypeA, string sdlTypeB) + { + // arrange + var schema1 = SchemaParser.Parse($$"""type Test { field: {{sdlTypeA}} }"""); + var schema2 = SchemaParser.Parse($$"""type Test { field: {{sdlTypeB}} }"""); + var typeA = ((ObjectTypeDefinition)schema1.Types["Test"]).Fields["field"].Type; + var typeB = ((ObjectTypeDefinition)schema2.Types["Test"]).Fields["field"].Type; + + // act + var result = ValidationHelper.SameTypeShape(typeA, typeB); + + // assert + await Assert.That(result).IsTrue(); + } + + [Test] + // Different type kind. + [Arguments("Tag", "Tag")] + [Arguments("[Tag]", "[Tag]")] + [Arguments("[[Tag]]", "[[Tag]]")] + // Different type name. + [Arguments("String", "DateTime")] + [Arguments("[String]", "[DateTime]")] + [Arguments("[[String]]", "[[DateTime]]")] + // Different depth. + [Arguments("String", "[String]")] + [Arguments("String", "[[String]]")] + [Arguments("[String]", "[[String]]")] + [Arguments("[[String]]", "[[[String]]]")] + // Different depth and nullability. + [Arguments("String", "[String!]")] + [Arguments("String", "[String!]!")] + public async Task SameTypeShape_False(string sdlTypeA, string sdlTypeB) + { + // arrange + var schema1 = SchemaParser.Parse( + $$""" + type Test { field: {{sdlTypeA}} } + + type Tag { value: String } + """); + + var schema2 = SchemaParser.Parse( + $$""" + type Test { field: {{sdlTypeB}} } + + scalar Tag + """); + + var typeA = ((ObjectTypeDefinition)schema1.Types["Test"]).Fields["field"].Type; + var typeB = ((ObjectTypeDefinition)schema2.Types["Test"]).Fields["field"].Type; + + // act + var result = ValidationHelper.SameTypeShape(typeA, typeB); + + // assert + await Assert.That(result).IsFalse(); + } +} diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Contracts/ITypeDefinition.cs b/src/HotChocolate/Skimmed/src/Skimmed/Contracts/ITypeDefinition.cs index 26b0aca7ec5..5c0bd5e02ff 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Contracts/ITypeDefinition.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Contracts/ITypeDefinition.cs @@ -12,6 +12,11 @@ public interface ITypeDefinition : IEquatable /// TypeKind Kind { get; } + /// + /// Gets a value indicating whether the type is an introspection type. + /// + bool IsIntrospectionType => this is INamedTypeDefinition type && type.Name.StartsWith("__"); + /// /// Indicates whether the current object is equal to another object of the same type. /// diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs b/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs index 7b8fa0de483..a1a081157a9 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs @@ -48,6 +48,15 @@ public static ITypeDefinition InnerType(this ITypeDefinition type) _ => type, }; + public static ITypeDefinition NullableType(this ITypeDefinition type) + { + ArgumentNullException.ThrowIfNull(type); + + return type.Kind == TypeKind.NonNull + ? ((NonNullTypeDefinition)type).NullableType + : type; + } + public static INamedTypeDefinition NamedType(this ITypeDefinition type) { while (true) diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs index 28640e6c5eb..d3aa0da048b 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs @@ -38,19 +38,17 @@ private static void DiscoverDirectives(SchemaDefinition schema, DocumentNode doc { if (definition is DirectiveDefinitionNode def) { - if (BuiltIns.IsBuiltInDirective(def.Name.Value)) - { - // If a built-in directive is redefined in the schema, we just ignore it. - continue; - } - if (schema.DirectiveDefinitions.ContainsName(def.Name.Value)) { // TODO : parsing error throw new Exception("duplicate"); } - schema.DirectiveDefinitions.Add(new DirectiveDefinition(def.Name.Value)); + schema.DirectiveDefinitions.Add( + new DirectiveDefinition(def.Name.Value) + { + IsSpecDirective = BuiltIns.IsBuiltInDirective(def.Name.Value) + }); } } } @@ -61,11 +59,6 @@ private static void DiscoverTypes(SchemaDefinition schema, DocumentNode document { if (definition is ITypeDefinitionNode typeDef) { - if (BuiltIns.IsBuiltInScalar(typeDef.Name.Value)) - { - continue; - } - if (schema.Types.ContainsName(typeDef.Name.Value)) { // TODO : parsing error @@ -91,7 +84,11 @@ private static void DiscoverTypes(SchemaDefinition schema, DocumentNode document break; case ScalarTypeDefinitionNode: - schema.Types.Add(new ScalarTypeDefinition(typeDef.Name.Value)); + schema.Types.Add( + new ScalarTypeDefinition(typeDef.Name.Value) + { + IsSpecScalar = BuiltIns.IsBuiltInScalar(typeDef.Name.Value) + }); break; case UnionTypeDefinitionNode: @@ -196,11 +193,6 @@ private static void BuildTypes(SchemaDefinition schema, DocumentNode document) break; case ScalarTypeDefinitionNode typeDef: - if (BuiltIns.IsBuiltInScalar(typeDef.Name.Value)) - { - continue; - } - BuildScalarType( schema, (ScalarTypeDefinition)schema.Types[typeDef.Name.Value], @@ -545,11 +537,6 @@ private static void BuildDirectiveTypes(SchemaDefinition schema, DocumentNode do { if (definition is DirectiveDefinitionNode directiveDef) { - if (BuiltIns.IsBuiltInDirective(directiveDef.Name.Value)) - { - continue; - } - BuildDirectiveType( schema, schema.DirectiveDefinitions[directiveDef.Name.Value], diff --git a/src/HotChocolate/Skimmed/test/Skimmed.Tests/SchemaParserTests.cs b/src/HotChocolate/Skimmed/test/Skimmed.Tests/SchemaParserTests.cs index f827677511c..1149a6bbb62 100644 --- a/src/HotChocolate/Skimmed/test/Skimmed.Tests/SchemaParserTests.cs +++ b/src/HotChocolate/Skimmed/test/Skimmed.Tests/SchemaParserTests.cs @@ -1,5 +1,6 @@ using System.Text; using HotChocolate.Skimmed.Serialization; +using HotChocolate.Types; namespace HotChocolate.Skimmed; @@ -118,4 +119,46 @@ extend type Foo { }); }); } + + [Fact] + public void Parse_With_Custom_BuiltIn_Scalar_Type() + { + // arrange + var sdl = + """ + "Custom description" + scalar String @custom + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + var scalar = schema.Types["String"]; + + // assert + Assert.Equal("Custom description", scalar.Description); + Assert.True(scalar.Directives.ContainsName("custom")); + } + + [Fact] + public void Parse_With_Custom_BuiltIn_Directive() + { + // arrange + var sdl = + """ + "Custom description" + directive @skip("Custom argument description" ifCustom: String! @custom) on ENUM_VALUE + """; + + // act + var schema = SchemaParser.Parse(Encoding.UTF8.GetBytes(sdl)); + var directive = schema.DirectiveDefinitions["skip"]; + var argument = directive.Arguments["ifCustom"]; + + // assert + Assert.Equal("Custom description", directive.Description); + Assert.Equal("Custom argument description", argument.Description); + Assert.Equal("String", argument.Type.NamedType().Name); + Assert.True(argument.Directives.ContainsName("custom")); + Assert.Equal(DirectiveLocation.EnumValue, directive.Locations); + } }