diff --git a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Resolvers/ExternalSetterExpressionHelper.cs b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Resolvers/ExternalSetterExpressionHelper.cs index 3e559676458..e5808e82d2d 100644 --- a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Resolvers/ExternalSetterExpressionHelper.cs +++ b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Resolvers/ExternalSetterExpressionHelper.cs @@ -81,6 +81,7 @@ public static void TryAddExternalSetter(InterfaceType type, InterfaceTypeConfigu { var statements = new List(); var variables = new List(); + var keyFields = CollectKeyFieldNames(type); foreach (var field in type.Fields) { @@ -92,6 +93,14 @@ public static void TryAddExternalSetter(InterfaceType type, InterfaceTypeConfigu var fieldType = field.Type is NonNullType nonNull ? nonNull.NullableType : field.Type; var path = Append(pathPrefix, field.Name); + // Fields that participate in a '@key' identify the entity and are owned by the reference + // resolver. The representation echoes them back only so the entity can be located, so the + // resolver's returned values are preserved instead of being overwritten. + if (keyFields?.Contains(field.Name) == true) + { + continue; + } + if (fieldType is ListType) { if (property.SetMethod is not null) @@ -149,6 +158,29 @@ public static void TryAddExternalSetter(InterfaceType type, InterfaceTypeConfigu return statements.Count == 0 ? null : Block(variables, statements); } + private static HashSet? CollectKeyFieldNames(ObjectType type) + { + HashSet? names = null; + + foreach (var directive in type.Directives) + { + if (directive.Type.RuntimeType != typeof(KeyDirective)) + { + continue; + } + + foreach (var selection in directive.ToValue().Fields.Selections) + { + if (selection is FieldNode keyField) + { + (names ??= []).Add(keyField.Name.Value); + } + } + } + + return names; + } + private static MethodCallExpression CreateTrySetValue( Type runtimeType, PropertyInfo property, diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index b2168a1f5ae..b821d7ab275 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -1,5 +1,6 @@ + diff --git a/src/HotChocolate/Fusion/benchmarks/Composition/CorpusPaths.cs b/src/HotChocolate/Fusion/benchmarks/Composition/CorpusPaths.cs new file mode 100644 index 00000000000..8867947841c --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Composition/CorpusPaths.cs @@ -0,0 +1,47 @@ +namespace HotChocolate.Fusion.Composition.Benchmarks; + +/// +/// Resolves the paths of the large federated graph benchmark corpus (the big-federated-graphs checkout) without +/// hard-coding machine-specific locations. The corpus root is taken from the +/// BIG_FEDERATED_GRAPHS environment variable when set; otherwise the directories above the +/// benchmark binary are searched for a sibling big-federated-graphs checkout. +/// +internal static class CorpusPaths +{ + private const string EnvironmentVariable = "BIG_FEDERATED_GRAPHS"; + private const string CorpusDirectoryName = "big-federated-graphs"; + + private static readonly Lazy s_root = new(ResolveRoot); + + public static string SubgraphsPath + => System.IO.Path.Combine(s_root.Value, "schemas", "edge0-v2", "subgraphs"); + + private static string ResolveRoot() + { + var configured = Environment.GetEnvironmentVariable(EnvironmentVariable); + + if (!string.IsNullOrEmpty(configured)) + { + return configured; + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + + while (current is not null) + { + var candidate = System.IO.Path.Combine(current.FullName, CorpusDirectoryName); + + if (Directory.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new InvalidOperationException( + $"The large federated graph benchmark corpus was not found. Set the {EnvironmentVariable} " + + $"environment variable to the {CorpusDirectoryName} checkout, or place the checkout " + + "next to an ancestor directory of the benchmark binary."); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Composition/HotChocolate.Fusion.Composition.Benchmarks.csproj b/src/HotChocolate/Fusion/benchmarks/Composition/HotChocolate.Fusion.Composition.Benchmarks.csproj new file mode 100644 index 00000000000..984e417538e --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Composition/HotChocolate.Fusion.Composition.Benchmarks.csproj @@ -0,0 +1,17 @@ + + + + Exe + net11.0 + net11.0 + true + Preview + HotChocolate.Fusion.Composition.Benchmarks + HotChocolate.Fusion.Composition.Benchmarks + + + + + + + diff --git a/src/HotChocolate/Fusion/benchmarks/Composition/Program.cs b/src/HotChocolate/Fusion/benchmarks/Composition/Program.cs new file mode 100644 index 00000000000..d5eced332e9 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Composition/Program.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using HotChocolate.Fusion; +using HotChocolate.Fusion.Composition.Benchmarks; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +var subgraphsPath = args.Length > 0 ? args[0] : CorpusPaths.SubgraphsPath; +var printErrors = args.Contains("--errors"); + +var files = Directory.GetFiles(subgraphsPath, "*.graphqls"); + +if (files.Length == 0) +{ + Console.Error.WriteLine($"No .graphqls files found in '{subgraphsPath}'."); + return 1; +} + +var sourceSchemas = new List(files.Length); + +foreach (var file in files) +{ + var name = Path.GetFileNameWithoutExtension(file); + sourceSchemas.Add(new SourceSchemaText(name, File.ReadAllText(file))); +} + +var options = new SchemaComposerOptions(); + +foreach (var sourceSchema in sourceSchemas) +{ + options.SourceSchemas[sourceSchema.Name] = new SourceSchemaOptions + { + Preprocessor = new SourceSchemaPreprocessorOptions { InferKeysFromLookups = false } + }; +} + +var log = new CompositionLog(); +var stopwatch = Stopwatch.StartNew(); +var result = new SchemaComposer(sourceSchemas, options, log).Compose(); +stopwatch.Stop(); + +var unsatisfiable = log + .Where(e => e.Code == LogEntryCodes.UnsatisfiableQueryPath) + .Select(e => e.Message) + .ToList(); + +Console.WriteLine($"subgraphs : {files.Length}"); +Console.WriteLine($"outcome : {(result.IsSuccess ? "SUCCESS" : "FAILURE")}"); +Console.WriteLine($"wall time : {stopwatch.Elapsed.TotalSeconds:0.00}s"); +Console.WriteLine($"UNSATISFIABLE_QUERY_PATH: {unsatisfiable.Count}"); +Console.WriteLine($"total error entries : {log.Count(e => e.Severity == LogSeverity.Error)}"); +Console.WriteLine("error codes (severity=Error):"); + +foreach (var group in log + .Where(e => e.Severity == LogSeverity.Error) + .GroupBy(e => e.Code) + .OrderByDescending(g => g.Count())) +{ + Console.WriteLine($" {group.Count(),5} {group.Key}"); + Console.WriteLine($" e.g. {group.First().Message}"); +} + +if (printErrors) +{ + foreach (var message in unsatisfiable) + { + Console.WriteLine("====="); + Console.WriteLine(message); + } +} + +return result.IsSuccess ? 0 : 2; diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixParsingBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusParsingBenchmark.cs similarity index 75% rename from src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixParsingBenchmark.cs rename to src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusParsingBenchmark.cs index 9b5e4081c21..41d288da195 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixParsingBenchmark.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusParsingBenchmark.cs @@ -7,18 +7,18 @@ namespace Fusion.Execution.Benchmarks; -// Parsing benchmarks for the composed Netflix corpus (745 sources). +// Parsing benchmarks for the composed corpus (745 sources). // // This isolates the document-parsing layer for the schema SDL and both operations. -// Full plan creation on the same corpus is measured by NetflixPlanningBenchmark +// Full plan creation on the same corpus is measured by CorpusPlanningBenchmark // (reachable now that PlannerTopologyCache scales to the 745-source graph). [MemoryDiagnoser] [Config(typeof(InProcessConfig))] -public class NetflixParsingBenchmark +public class CorpusParsingBenchmark { - private static readonly string SchemaPath = NetflixCorpusPaths.SchemaPath; - private static readonly string Query1Path = NetflixCorpusPaths.Query1Path; - private static readonly string Query2Path = NetflixCorpusPaths.Query2Path; + private static readonly string SchemaPath = CorpusPaths.SchemaPath; + private static readonly string Query1Path = CorpusPaths.Query1Path; + private static readonly string Query2Path = CorpusPaths.Query2Path; private byte[] _schemaSdl = null!; private byte[] _query1 = null!; diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixCorpusPaths.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs similarity index 87% rename from src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixCorpusPaths.cs rename to src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs index 688acd447b7..78d404f2360 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixCorpusPaths.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPaths.cs @@ -4,12 +4,12 @@ namespace HotChocolate.Fusion.Benchmarks; /// -/// Resolves the paths of the Netflix benchmark corpus (the big-federated-graphs checkout) without +/// Resolves the paths of the large federated graph benchmark corpus (the big-federated-graphs checkout) without /// hard-coding machine-specific locations. The corpus root is taken from the /// BIG_FEDERATED_GRAPHS environment variable when set; otherwise the directories above the /// benchmark binary are searched for a sibling big-federated-graphs checkout. /// -internal static class NetflixCorpusPaths +internal static class CorpusPaths { private const string EnvironmentVariable = "BIG_FEDERATED_GRAPHS"; private const string CorpusDirectoryName = "big-federated-graphs"; @@ -49,7 +49,7 @@ private static string ResolveRoot() } throw new InvalidOperationException( - $"The Netflix benchmark corpus was not found. Set the {EnvironmentVariable} " + $"The large federated graph benchmark corpus was not found. Set the {EnvironmentVariable} " + $"environment variable to the {CorpusDirectoryName} checkout, or place the checkout " + "next to an ancestor directory of the benchmark binary."); } diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs similarity index 87% rename from src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningBenchmark.cs rename to src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs index 7b26c426130..487abb640dc 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningBenchmark.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs @@ -15,21 +15,21 @@ namespace Fusion.Execution.Benchmarks; -// Planning benchmarks for the composed Netflix corpus (edge0-v2, 745 sources). +// Planning benchmarks for the composed corpus (edge0-v2, 745 sources). // // One iteration is a full plan creation: OperationPlanner.CreatePlan compiles the // rewritten operation and searches the plan space from scratch (there is no plan // cache at this layer, so every iteration plans anew). The FusionSchemaDefinition, // which is expensive to build on this corpus, is built once in GlobalSetup; its // cost is reported separately on the console rather than folded into the per-plan -// numbers. See NetflixPlanningProbe for the single-shot feasibility profile. +// numbers. See CorpusPlanningProbe for the single-shot feasibility profile. [MemoryDiagnoser] [Config(typeof(InProcessConfig))] -public class NetflixPlanningBenchmark +public class CorpusPlanningBenchmark { - private static readonly string SchemaPath = NetflixCorpusPaths.SchemaPath; - private static readonly string Query1Path = NetflixCorpusPaths.Query1Path; - private static readonly string Query2Path = NetflixCorpusPaths.Query2Path; + private static readonly string SchemaPath = CorpusPaths.SchemaPath; + private static readonly string Query1Path = CorpusPaths.Query1Path; + private static readonly string Query2Path = CorpusPaths.Query2Path; // A name-safe, at-least-eight-character identifier: it is threaded into the // names of synthesized lookup operations (Op_{shortHash}_{stepId}) and sliced diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningProbe.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs similarity index 96% rename from src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningProbe.cs rename to src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs index 81035af6e14..f992e1b5edb 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/NetflixPlanningProbe.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs @@ -14,25 +14,25 @@ namespace Fusion.Execution.Benchmarks; -// One-shot feasibility probe for the composed Netflix corpus (Phase A). +// One-shot feasibility probe for the composed corpus (Phase A). // Not a BenchmarkDotNet benchmark: it measures cold-start schema build and a // single plan per query with wall time + allocation deltas, so it can report a // "wall" (non-termination / crash) on any layer as a first-class finding. -internal static class NetflixPlanningProbe +internal static class CorpusPlanningProbe { public static void Run(string[] args) { var schemaPath = args.Length > 1 ? args[1] - : NetflixCorpusPaths.SchemaPath; + : CorpusPaths.SchemaPath; var query1Path = args.Length > 2 ? args[2] - : NetflixCorpusPaths.Query1Path; + : CorpusPaths.Query1Path; var query2Path = args.Length > 3 ? args[3] - : NetflixCorpusPaths.Query2Path; + : CorpusPaths.Query2Path; - Console.WriteLine("=== Netflix planning feasibility probe ==="); + Console.WriteLine("=== corpus planning feasibility probe ==="); Console.WriteLine($"runtime : {Environment.Version}, ServerGC={System.Runtime.GCSettings.IsServerGC}"); Console.WriteLine($"processors : {Environment.ProcessorCount}"); Console.WriteLine($"schema : {schemaPath}"); diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Program.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Program.cs index 281169b4c0b..92135eee144 100644 --- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Program.cs +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/Program.cs @@ -7,7 +7,7 @@ if (args.Length > 0 && args[0] == "probe") { - NetflixPlanningProbe.Run(args); + CorpusPlanningProbe.Run(args); } else if (args.Length == 0) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/GenerateLookupFields.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/GenerateLookupFields.cs index 6840b81c6db..1e1c62ee28c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/GenerateLookupFields.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/GenerateLookupFields.cs @@ -35,9 +35,23 @@ internal static class GenerateLookupFields /// public static void Apply(MutableSchemaDefinition schema) { + // A source schema can declare resolvable @key entities without a Query type (e.g. a + // mutation-only subgraph). Those entities still need lookups to be routable, so synthesize a + // Query type to host the generated @internal @lookup fields. It is removed again below if no + // lookup ends up being generated so an empty Query is never introduced. + var synthesizedQuery = false; + if (schema.QueryType is null) { - return; + if (!HasResolvableKeyEntity(schema)) + { + return; + } + + var syntheticQuery = new MutableObjectTypeDefinition("Query"); + schema.Types.Add(syntheticQuery); + schema.QueryType = syntheticQuery; + synthesizedQuery = true; } var internalDef = new MutableDirectiveDefinition("internal"); @@ -139,6 +153,44 @@ public static void Apply(MutableSchemaDefinition schema) } } } + + // If a Query type was synthesized but no lookup field was ultimately generated, drop it so + // the schema does not carry an empty Query type. + if (synthesizedQuery && schema.QueryType is { } synthetic && synthetic.Fields.Count == 0) + { + schema.Types.Remove(synthetic); + schema.QueryType = null; + } + } + + private static bool HasResolvableKeyEntity(MutableSchemaDefinition schema) + { + foreach (var type in schema.Types) + { + if (type is not MutableComplexTypeDefinition complexType) + { + continue; + } + + foreach (var keyDirective in complexType.Directives["key"]) + { + if (!keyDirective.Arguments.TryGetValue("fields", out var fieldsValue) + || fieldsValue is not StringValueNode) + { + continue; + } + + if (keyDirective.Arguments.TryGetValue("resolvable", out var resolvableValue) + && resolvableValue is BooleanValueNode { Value: false }) + { + continue; + } + + return true; + } + } + + return false; } private static GenerateLookupFieldResult? GenerateLookupField( diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Collections/SatisfiabilityPath.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Collections/SatisfiabilityPath.cs index 4fb1163b8a9..69d761e2092 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Collections/SatisfiabilityPath.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Collections/SatisfiabilityPath.cs @@ -9,60 +9,27 @@ namespace HotChocolate.Fusion.Collections; internal sealed class SatisfiabilityPath : IEnumerable { private readonly Stack _stack = []; - private readonly Dictionary _depthByItem = []; - private int _minCollisionDepth = int.MaxValue; + private readonly HashSet _hashSet = []; public bool Contains(SatisfiabilityPathItem item) { - return _depthByItem.ContainsKey(item); + return _hashSet.Contains(item); } public int Count => _stack.Count; public bool Push(SatisfiabilityPathItem item) { - if (_depthByItem.TryGetValue(item, out var existingDepth)) + if (!_hashSet.Add(item)) { - if (existingDepth < _minCollisionDepth) - { - _minCollisionDepth = existingDepth; - } - return false; } - _depthByItem[item] = _stack.Count; _stack.Push(item); return true; } - /// - /// Begins a scope that measures the shallowest cycle collision that occurs until the matching - /// call. Returns the enclosing scope's state to restore later. - /// - public int BeginCollisionScope() - { - var previous = _minCollisionDepth; - _minCollisionDepth = int.MaxValue; - - return previous; - } - - /// - /// Ends the current collision scope, returning the shallowest collision depth observed within - /// it. The enclosing scope is restored and also sees that collision so an inherited collision - /// propagates outward. - /// - /// The value returned by the matching . - public int EndCollisionScope(int previous) - { - var observed = _minCollisionDepth; - _minCollisionDepth = Math.Min(previous, observed); - - return observed; - } - public SatisfiabilityPathItem Pop() { if (_stack.Count == 0) @@ -71,7 +38,7 @@ public SatisfiabilityPathItem Pop() } var item = _stack.Pop(); - _depthByItem.Remove(item); + _hashSet.Remove(item); return item; } @@ -101,7 +68,7 @@ public bool TryPeek([MaybeNullWhen(false)] out SatisfiabilityPathItem item) public void Clear() { _stack.Clear(); - _depthByItem.Clear(); + _hashSet.Clear(); } public IEnumerator GetEnumerator() @@ -129,6 +96,13 @@ internal sealed record SatisfiabilityPathItem( public SelectionSetNode? ProvidedSelectionSet { get; init; } + /// + /// Gets a value indicating whether originates from an event + /// stream message rather than an @provides selection. Event stream messages genuinely gate + /// satisfiability; @provides is an optimization that never makes a path satisfiable. + /// + public bool ProvidedByEventStream { get; init; } + private readonly int _hashCode = HashCode.Combine(Field, Type, SchemaName); public override string ToString() diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/SatisfiabilityPathItemExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/SatisfiabilityPathItemExtensions.cs index 7af05e394b5..301a355a548 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/SatisfiabilityPathItemExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Extensions/SatisfiabilityPathItemExtensions.cs @@ -21,6 +21,19 @@ public bool Provides( MutableSchemaDefinition schema) => item.TryGetProvidedSelectionSet(field, type, schemaName, schema, out _); + /// + /// Determines whether the path item provides the given field via an event stream message. + /// Unlike , an @provides selection never counts here, because + /// @provides is an optimization that must not make a query path satisfiable. + /// + public bool ProvidesViaEventStream( + MutableOutputFieldDefinition field, + MutableObjectTypeDefinition type, + string schemaName, + MutableSchemaDefinition schema) + => item.ProvidedByEventStream + && item.TryGetProvidedSelectionSet(field, type, schemaName, schema, out _); + public bool TryGetProvidedSelectionSet( MutableOutputFieldDefinition field, MutableObjectTypeDefinition type, diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs index 33213e40bf0..cedef908597 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/RequirementsValidator.cs @@ -11,11 +11,11 @@ namespace HotChocolate.Fusion.Satisfiability; internal sealed class RequirementsValidator( MutableSchemaDefinition schema, FusionLookupDirectiveCache lookupCache, - SourceSchemaTransitionCache transitionCache, + SatisfiabilityFacts facts, bool includeSatisfiabilityPaths) { private readonly FusionLookupDirectiveCache _lookupCache = lookupCache; - private readonly SourceSchemaTransitionCache _transitionCache = transitionCache; + private readonly SatisfiabilityFacts _facts = facts; public ImmutableArray Validate( SelectionSetNode requirements, @@ -179,10 +179,10 @@ private ImmutableArray Visit( continue; } - // If the field is marked as partial, it must be provided by the current schema for it - // to be an option. + // A partial (@external) field is never a resolution candidate in its declaring schema; + // only an event stream message can make it an option. @provides never does (PR #231). if (field.IsPartial(schemaName) - && previousPathItem?.Provides(field, type, schemaName, schema) != true) + && previousPathItem?.ProvidesViaEventStream(field, type, schemaName, schema) != true) { continue; } @@ -202,8 +202,13 @@ private ImmutableArray Visit( continue; } - // Validate transition between source schemas. - if (previousSchemaName != schemaName) + // Validate transition between source schemas. The fixpoint answers the direct-lookup + // route in O(1); only when it cannot confirm the transition, or when a provided selection + // set narrows the context, do we fall back to the full recursion that builds the error. + if (previousSchemaName != schemaName + && (previousSchemaName is null + || previousPathItem?.ProvidedSelectionSet is not null + || !_facts.CanTransition(type, schemaName, previousSchemaName))) { var transitionErrors = ValidateSourceSchemaTransition( type, @@ -227,16 +232,20 @@ private ImmutableArray Visit( } } - // Validate field requirements (@require). + // Validate field requirements (@require). The fixpoint answers whether the requirement + // holds in O(1); only when it does not, or when a provided selection set narrows the + // context, do we re-run the recursion to build the error tree. var requirements = field.GetFusionRequiresRequirements(schemaName); - if (requirements is not null) + if (requirements is not null + && (previousPathItem?.ProvidedSelectionSet is not null + || !_facts.IsFieldResolvableOn(type, field, schemaName))) { var requirementErrors = new RequirementsValidator( schema, _lookupCache, - _transitionCache, + _facts, includeSatisfiabilityPaths).Validate( requirements, type, @@ -333,8 +342,6 @@ private ImmutableArray ValidateSourceSchemaTransition( { return SourceSchemaTransitionHelper.ValidateSourceSchemaTransition( _lookupCache, - _transitionCache, - cycleDetectionPath: context.CycleDetectionPath, type, transitionToSchemaName, [.. context.Path], diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs new file mode 100644 index 00000000000..7f8af448ae4 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFacts.cs @@ -0,0 +1,79 @@ +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.Satisfiability; + +internal sealed class SatisfiabilityFacts +{ + private readonly HashSet _canTransitionFacts; + private readonly HashSet _fieldAccessibleFacts; + private readonly HashSet _fieldResolvableFacts; + + internal SatisfiabilityFacts( + HashSet canTransitionFacts, + HashSet fieldAccessibleFacts, + HashSet fieldResolvableFacts) + { + _canTransitionFacts = [.. canTransitionFacts]; + _fieldAccessibleFacts = [.. fieldAccessibleFacts]; + _fieldResolvableFacts = [.. fieldResolvableFacts]; + } + + /// + /// Determines whether an entity of held on + /// can transition to . + /// + /// The object type being transitioned. + /// The source schema to transition to. + /// The source schema the entity is currently held on. + /// when the transition is satisfiable. + public bool CanTransition( + MutableObjectTypeDefinition type, + string targetSchema, + string fromSchema) + => _canTransitionFacts.Contains(new CanTransitionFactKey(type, targetSchema, fromSchema)); + + /// + /// Determines whether can be resolved on + /// when the entity is held on . + /// + /// The object type declaring the field. + /// The field whose accessibility is queried. + /// The source schema the entity is currently held on. + /// when the field is accessible. + public bool IsFieldAccessible( + MutableObjectTypeDefinition type, + MutableOutputFieldDefinition field, + string fromSchema) + => _fieldAccessibleFacts.Contains(new FieldAccessibleFactKey(type, field, fromSchema)); + + /// + /// Determines whether is resolvable as the entity's own field on + /// , that is the field exists there, is not partial, and its + /// @require is satisfied. This does not account for reaching + /// ; see for that. + /// + /// The object type declaring the field. + /// The field whose resolvability is queried. + /// The source schema the field is resolved on. + /// when the field is resolvable on the schema. + public bool IsFieldResolvableOn( + MutableObjectTypeDefinition type, + MutableOutputFieldDefinition field, + string sourceSchema) + => _fieldResolvableFacts.Contains(new FieldResolvableFactKey(type, field, sourceSchema)); + + internal readonly record struct CanTransitionFactKey( + MutableObjectTypeDefinition Type, + string TargetSchema, + string FromSchema); + + internal readonly record struct FieldAccessibleFactKey( + MutableObjectTypeDefinition Type, + MutableOutputFieldDefinition Field, + string FromSchema); + + internal readonly record struct FieldResolvableFactKey( + MutableObjectTypeDefinition Type, + MutableOutputFieldDefinition Field, + string SourceSchema); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs new file mode 100644 index 00000000000..da7ac7c500d --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SatisfiabilityFactsBuilder.cs @@ -0,0 +1,451 @@ +using HotChocolate.Fusion.Extensions; +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Language.Utf8GraphQLParser.Syntax; + +namespace HotChocolate.Fusion.Satisfiability; + +internal sealed class SatisfiabilityFactsBuilder +{ + private readonly MutableSchemaDefinition _schema; + private readonly FusionLookupDirectiveCache _lookupCache; + private readonly HashSet _reachablePositions = []; + private readonly HashSet _fieldResolvableFacts = []; + private readonly HashSet _canTransitionFacts = []; + private readonly HashSet _fieldAccessibleFacts = []; + private readonly HashSet _expandedFieldAccesses = []; + private readonly Dictionary _lookupKeySelectionSets = []; + private readonly Dictionary _candidateTargetsByType = []; + + /// + /// Initializes a new instance of . + /// + /// The merged schema whose satisfiability facts are computed. + /// The lookup directive cache for the merged schema. + public SatisfiabilityFactsBuilder( + MutableSchemaDefinition schema, + FusionLookupDirectiveCache lookupCache) + { + _schema = schema; + _lookupCache = lookupCache; + } + + /// + /// Builds the least fixpoint satisfiability fact table. + /// + /// The computed satisfiability facts. + public SatisfiabilityFacts Build() + { + SeedRootPositions(); + + var changed = true; + + while (changed) + { + changed = false; + var positions = _reachablePositions.ToArray(); + var reachedTypes = new HashSet(); + + foreach (var position in positions) + { + if (reachedTypes.Add(position.Type)) + { + changed |= DeriveResolvableFields(position.Type); + } + } + + foreach (var position in positions) + { + changed |= DeriveTransitions(position.Type, position.FromSchema); + } + + foreach (var position in positions) + { + changed |= DeriveAccessibleFields(position.Type, position.FromSchema); + } + } + + return new SatisfiabilityFacts(_canTransitionFacts, _fieldAccessibleFacts, _fieldResolvableFacts); + } + + private bool DeriveResolvableFields(MutableObjectTypeDefinition type) + { + var changed = false; + + foreach (var field in type.Fields) + { + if (field.HasFusionInaccessibleDirective()) + { + continue; + } + + foreach (var schemaName in field.GetSchemaNames()) + { + if (field.IsPartial(schemaName)) + { + continue; + } + + var requirements = field.GetFusionRequiresRequirements(schemaName); + + if (requirements is not null + && !CanResolveSelectionSet( + requirements, + type, + fromSchema: schemaName, + excludeSchema: schemaName, + SelectionSetResolutionMode.FieldRequire)) + { + continue; + } + + changed |= _fieldResolvableFacts.Add( + new SatisfiabilityFacts.FieldResolvableFactKey(type, field, schemaName)); + } + } + + return changed; + } + + private bool DeriveTransitions(MutableObjectTypeDefinition type, string fromSchema) + { + var changed = false; + + // Only the schemas that declare a lookup for the type are transition targets; scanning those + // instead of every source schema per round is the corpus-scale win. The per-target lookup + // call keeps the schema-specific union-membership filtering. + foreach (var targetSchema in GetCandidateTargetSchemas(type)) + { + if (targetSchema == fromSchema + || _canTransitionFacts.Contains( + new SatisfiabilityFacts.CanTransitionFactKey(type, targetSchema, fromSchema))) + { + continue; + } + + foreach (var lookup in _lookupCache.GetPossibleFusionLookupDirectives(type, targetSchema)) + { + var keySelectionSet = GetLookupKeySelectionSet(lookup); + + if (!CanResolveSelectionSet( + keySelectionSet, + type, + fromSchema, + excludeSchema: targetSchema, + SelectionSetResolutionMode.LookupKey)) + { + continue; + } + + if (_canTransitionFacts.Add( + new SatisfiabilityFacts.CanTransitionFactKey(type, targetSchema, fromSchema))) + { + changed = true; + changed |= _reachablePositions.Add(new ReachablePositionKey(type, targetSchema)); + } + + break; + } + } + + return changed; + } + + private string[] GetCandidateTargetSchemas(MutableObjectTypeDefinition type) + { + if (!_candidateTargetsByType.TryGetValue(type, out var targets)) + { + var set = new HashSet(StringComparer.Ordinal); + + foreach (var lookup in _lookupCache.GetPossibleFusionLookupDirectives(type)) + { + set.Add((string)lookup.Arguments[WellKnownArgumentNames.Schema].Value!); + } + + targets = [.. set]; + _candidateTargetsByType.Add(type, targets); + } + + return targets; + } + + private bool DeriveAccessibleFields(MutableObjectTypeDefinition type, string fromSchema) + { + var changed = false; + + foreach (var field in type.Fields) + { + if (field.HasFusionInaccessibleDirective()) + { + continue; + } + + foreach (var schemaName in field.GetSchemaNames()) + { + if (!IsFieldAccessibleVia(type, field, schemaName, fromSchema)) + { + continue; + } + + changed |= _fieldAccessibleFacts.Add( + new SatisfiabilityFacts.FieldAccessibleFactKey(type, field, fromSchema)); + changed |= AddReachableChildPositions(type, field, schemaName, fromSchema); + } + } + + return changed; + } + + private bool IsFieldAccessibleVia( + MutableObjectTypeDefinition type, + MutableOutputFieldDefinition field, + string sourceSchema, + string fromSchema) + { + return _fieldResolvableFacts.Contains( + new SatisfiabilityFacts.FieldResolvableFactKey(type, field, sourceSchema)) + && (sourceSchema == fromSchema + || _canTransitionFacts.Contains( + new SatisfiabilityFacts.CanTransitionFactKey(type, sourceSchema, fromSchema))); + } + + private bool AddReachableChildPositions( + MutableObjectTypeDefinition type, + MutableOutputFieldDefinition field, + string schemaName, + string fromSchema) + { + if (!_expandedFieldAccesses.Add(new ExpandedFieldAccessKey(type, field, schemaName, fromSchema))) + { + return false; + } + + var fieldType = field.Type.AsTypeDefinition(); + + if (fieldType.Kind is not TypeKind.Object and not TypeKind.Interface and not TypeKind.Union) + { + return false; + } + + var changed = false; + + foreach (var possibleType in fieldType.GetPossibleTypes(schemaName, _schema)) + { + changed |= _reachablePositions.Add(new ReachablePositionKey(possibleType, schemaName)); + } + + return changed; + } + + private bool CanResolveSelectionSet( + SelectionSetNode selectionSet, + MutableObjectTypeDefinition contextType, + string fromSchema, + string excludeSchema, + SelectionSetResolutionMode mode) + { + foreach (var selection in selectionSet.Selections) + { + if (!CanResolveSelection(selection, contextType, fromSchema, excludeSchema, mode)) + { + return false; + } + } + + return true; + } + + private bool CanResolveSelection( + ISelectionNode selection, + MutableObjectTypeDefinition contextType, + string fromSchema, + string excludeSchema, + SelectionSetResolutionMode mode) + { + switch (selection) + { + case FieldNode fieldNode: + return CanResolveFieldSelection(fieldNode, contextType, fromSchema, excludeSchema, mode); + + case InlineFragmentNode { TypeCondition: null } inlineFragmentNode: + return CanResolveSelectionSet( + inlineFragmentNode.SelectionSet, + contextType, + fromSchema, + excludeSchema, + mode); + + case InlineFragmentNode inlineFragmentNode: + var fragmentType = _schema.Types[inlineFragmentNode.TypeCondition.Name.Value]; + + foreach (var possibleType in _schema.GetPossibleTypes(fragmentType)) + { + if (!CanResolveSelectionSet( + inlineFragmentNode.SelectionSet, + possibleType, + fromSchema, + excludeSchema, + mode)) + { + return false; + } + } + + return true; + + default: + return true; + } + } + + private bool CanResolveFieldSelection( + FieldNode fieldNode, + MutableObjectTypeDefinition contextType, + string fromSchema, + string excludeSchema, + SelectionSetResolutionMode mode) + { + if (!contextType.Fields.TryGetField(fieldNode.Name.Value, out var field)) + { + return false; + } + + foreach (var schemaName in GetCandidateSchemaNames(field, excludeSchema, fieldNode, mode)) + { + if (!IsFieldAccessibleVia(contextType, field, schemaName, fromSchema)) + { + continue; + } + + if (fieldNode.SelectionSet is null) + { + return true; + } + + if (CanResolveChildSelectionSet( + field, + schemaName, + fieldNode.SelectionSet, + excludeSchema, + mode)) + { + return true; + } + } + + return false; + } + + private bool CanResolveChildSelectionSet( + MutableOutputFieldDefinition field, + string schemaName, + SelectionSetNode selectionSet, + string excludeSchema, + SelectionSetResolutionMode mode) + { + var fieldType = field.Type.AsTypeDefinition(); + + if (fieldType.Kind is not TypeKind.Object and not TypeKind.Interface and not TypeKind.Union) + { + return false; + } + + foreach (var possibleType in fieldType.GetPossibleTypes(schemaName, _schema)) + { + if (!CanResolveSelectionSet(selectionSet, possibleType, schemaName, excludeSchema, mode)) + { + return false; + } + } + + return true; + } + + private static IEnumerable GetCandidateSchemaNames( + MutableOutputFieldDefinition field, + string excludeSchema, + FieldNode fieldNode, + SelectionSetResolutionMode mode) + { + var excludeCandidate = + fieldNode.SelectionSet is null || mode == SelectionSetResolutionMode.LookupKey; + + foreach (var schemaName in field.GetSchemaNames()) + { + if (excludeCandidate && schemaName == excludeSchema) + { + continue; + } + + yield return schemaName; + } + } + + private SelectionSetNode GetLookupKeySelectionSet(IDirective lookup) + { + if (!_lookupKeySelectionSets.TryGetValue(lookup, out var selectionSet)) + { + var lookupKey = (string)lookup.Arguments[WellKnownArgumentNames.Key].Value!; + selectionSet = ParseSelectionSet($"{{ {lookupKey} }}"); + _lookupKeySelectionSets.Add(lookup, selectionSet); + } + + return selectionSet; + } + + private void SeedRootPositions() + { + SeedRootPosition(_schema.QueryType); + SeedRootPosition(_schema.MutationType); + SeedRootPosition(_schema.SubscriptionType); + } + + private void SeedRootPosition(MutableObjectTypeDefinition? rootType) + { + if (rootType is null) + { + return; + } + + foreach (var schemaName in GetTypeSchemaNames(rootType)) + { + _reachablePositions.Add(new ReachablePositionKey(rootType, schemaName)); + } + + foreach (var field in rootType.Fields) + { + foreach (var schemaName in field.GetSchemaNames()) + { + _reachablePositions.Add(new ReachablePositionKey(rootType, schemaName)); + } + } + } + + private static IEnumerable GetTypeSchemaNames(IDirectivesProvider type) + { + foreach (var directive in type.Directives.AsEnumerable()) + { + if (directive.Name == WellKnownDirectiveNames.FusionType) + { + yield return (string)directive.Arguments[WellKnownArgumentNames.Schema].Value!; + } + } + } + + private enum SelectionSetResolutionMode + { + FieldRequire, + LookupKey + } + + private readonly record struct ReachablePositionKey( + MutableObjectTypeDefinition Type, + string FromSchema); + + private readonly record struct ExpandedFieldAccessKey( + MutableObjectTypeDefinition Type, + MutableOutputFieldDefinition Field, + string SchemaName, + string FromSchema); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionCache.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionCache.cs deleted file mode 100644 index 25253d30f2a..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionCache.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Collections.Immutable; -using HotChocolate.Types.Mutable; - -namespace HotChocolate.Fusion.Satisfiability; - -/// -/// Memoizes the direct lookup portion of a source schema transition for a single validation run. -/// A cached entry records whether a direct lookup on a type is possible in a target source schema, -/// given the source schema the traversal arrived from, together with the errors describing the -/// lookups that were tried when it is not. -/// -internal sealed class SourceSchemaTransitionCache -{ - private readonly Dictionary _directLookups = []; - - /// - /// Attempts to get the cached direct lookup result for a transition. - /// - /// The type the transition needs to reach in the target schema. - /// The source schema the transition moves to. - /// The source schema the traversal arrived from. - /// The cached result when one exists. - /// true if a cached result exists; otherwise, false. - public bool TryGetDirectLookup( - MutableObjectTypeDefinition type, - string targetSchemaName, - string? previousSchemaName, - out DirectLookupResult result) - { - return _directLookups.TryGetValue( - new TransitionKey(type, targetSchemaName, previousSchemaName), - out result); - } - - /// - /// Stores the direct lookup result for a transition. - /// - /// The type the transition needs to reach in the target schema. - /// The source schema the transition moves to. - /// The source schema the traversal arrived from. - /// The result to store. - public void AddDirectLookup( - MutableObjectTypeDefinition type, - string targetSchemaName, - string? previousSchemaName, - DirectLookupResult result) - { - _directLookups[new TransitionKey(type, targetSchemaName, previousSchemaName)] = result; - } - - private readonly record struct TransitionKey( - MutableObjectTypeDefinition Type, - string TargetSchemaName, - string? PreviousSchemaName); -} - -/// -/// The memoized outcome of the direct lookup portion of a source schema transition. -/// -/// Whether a direct lookup on the type is possible. -/// -/// When not satisfiable, the errors describing every direct lookup that was tried; otherwise empty. -/// -internal readonly record struct DirectLookupResult( - bool Satisfiable, - ImmutableArray Errors); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionHelper.cs index 6772c6ce65c..cee716fc47e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Satisfiability/SourceSchemaTransitionHelper.cs @@ -23,12 +23,6 @@ internal static class SourceSchemaTransitionHelper /// is possible for . /// /// The lookup directive cache for the current validation run. - /// The transition satisfiability memo for the current validation run. - /// - /// The inherited cycle detection path, or when the caller validates - /// lookup requirements from a fresh cycle context. Used to keep a memoized verdict independent - /// of inherited cycle context. - /// /// /// The type we need to be holding in /// after the transition. @@ -65,8 +59,6 @@ internal static class SourceSchemaTransitionHelper /// public static ImmutableArray ValidateSourceSchemaTransition( FusionLookupDirectiveCache lookupCache, - SourceSchemaTransitionCache transitionCache, - SatisfiabilityPath? cycleDetectionPath, MutableObjectTypeDefinition type, string transitionToSchemaName, IReadOnlyList pathFromLeaf, @@ -83,73 +75,25 @@ public static ImmutableArray ValidateSourceSchemaTransition var lookupDirectives = lookupCache.GetPossibleFusionLookupDirectives(type, transitionToSchemaName); - // The direct-lookup verdict depends on the leaf only through its source schema (the transition - // gate) unless the leaf contributes an @provides selection, in which case it is not memoized. - var cacheEligible = leafPathItem is null - || (leafPathItem.ProvidedSelectionSet is null - && leafPathItem.Field.GetFusionFieldProvides(leafPathItem.SchemaName) is null); - var previousSchemaName = leafPathItem?.SchemaName; - - DirectLookupResult directLookup; + var directLookupErrors = new List(); + var satisfiable = false; - if (cacheEligible - && transitionCache.TryGetDirectLookup( - type, - transitionToSchemaName, - previousSchemaName, - out var cachedDirectLookup)) + // Direct lookup on `type` in the target schema. + foreach (var lookupDirective in lookupDirectives) { - directLookup = cachedDirectLookup; - } - else - { - var directLookupErrors = new List(); - var satisfiable = false; - - // Only cache a verdict that is independent of inherited cycle context: begin a collision - // scope at the current depth, and commit only if no cycle collision referenced an item that - // was already on the path when the direct-lookup computation began. - var entryDepth = cycleDetectionPath?.Count ?? 0; - var previousCollisionScope = cycleDetectionPath?.BeginCollisionScope() ?? int.MaxValue; - - // Direct lookup on `type` in the target schema. - foreach (var lookupDirective in lookupDirectives) + if (TryLookup(lookupDirective, type, leafPathItem, directLookupErrors)) { - if (TryLookup(lookupDirective, type, leafPathItem, directLookupErrors)) - { - satisfiable = true; - break; - } - } - - var inheritedCollision = false; - - if (cycleDetectionPath is not null) - { - var observedCollisionDepth = cycleDetectionPath.EndCollisionScope(previousCollisionScope); - inheritedCollision = observedCollisionDepth < entryDepth; - } - - directLookup = new DirectLookupResult( - satisfiable, - satisfiable ? [] : [.. directLookupErrors]); - - if (cacheEligible && !inheritedCollision) - { - transitionCache.AddDirectLookup( - type, - transitionToSchemaName, - previousSchemaName, - directLookup); + satisfiable = true; + break; } } - if (directLookup.Satisfiable) + if (satisfiable) { return []; } - errors.AddRange(directLookup.Errors); + errors.AddRange(directLookupErrors); // Parent entity call: walk from the leaf upward, find the nearest // ancestor on the path whose declaring type has a lookup in the target diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs index 42ee7a0e8bb..7597a33a134 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs @@ -20,7 +20,7 @@ internal sealed class SatisfiabilityValidator private readonly SatisfiabilityOptions _options; private readonly RequirementsValidator _requirementsValidator; private readonly FusionLookupDirectiveCache _lookupCache; - private readonly SourceSchemaTransitionCache _transitionCache; + private readonly SatisfiabilityFacts _facts; private readonly MutableSchemaDefinition _schema; private readonly ICompositionLog _log; @@ -33,9 +33,13 @@ public SatisfiabilityValidator( _log = log; _options = options ?? new SatisfiabilityOptions(); _lookupCache = new FusionLookupDirectiveCache(schema); - _transitionCache = new SourceSchemaTransitionCache(); + _facts = new SatisfiabilityFactsBuilder(schema, _lookupCache).Build(); _requirementsValidator = - new RequirementsValidator(schema, _lookupCache, _transitionCache, _options.IncludeSatisfiabilityPaths); + new RequirementsValidator( + schema, + _lookupCache, + _facts, + _options.IncludeSatisfiabilityPaths); } public CompositionResult Validate() @@ -142,21 +146,22 @@ private void VisitOutputField( continue; } - // If the field is marked as partial, it must be provided by the current schema for it - // to be an option. + // A partial (@external) field is never a resolution candidate in its declaring schema; + // only an event stream message can make it an option. @provides never does (PR #231). if (field.IsPartial(schemaName) - && path?.Item.Provides(field, type, schemaName, _schema) != true) + && path?.Item.ProvidesViaEventStream(field, type, schemaName, _schema) != true) { continue; } + var eventStreamMessage = field.GetFusionEventStreamMessage(schemaName); var pathItem = new SatisfiabilityPathItem( field, type, schemaName) { - ProvidedSelectionSet = - field.GetFusionEventStreamMessage(schemaName) ?? providedSelectionSet + ProvidedSelectionSet = eventStreamMessage ?? providedSelectionSet, + ProvidedByEventStream = eventStreamMessage is not null }; // Validate that we are not in a cycle by checking if this path item @@ -167,8 +172,15 @@ private void VisitOutputField( continue; } - // Validate transition between source schemas. - if (previousSchemaName is not null && previousSchemaName != schemaName) + // Validate transition between source schemas. The fixpoint answers the direct-lookup + // route in O(1); only when it cannot confirm the transition do we fall back to the full + // recursion, which also covers the parent-call and one-to-one routes and builds the error. + // A provided selection set (event stream message or @provides) narrows the context in a + // way the fixpoint does not model, so we always defer to the recursion in that case. + if (previousSchemaName is not null + && previousSchemaName != schemaName + && (path?.Item.ProvidedSelectionSet is not null + || !_facts.CanTransition(type, schemaName, previousSchemaName))) { var transitionErrors = ValidateSourceSchemaTransition(type, path, schemaName); @@ -187,10 +199,14 @@ private void VisitOutputField( } } - // Validate field requirements (@require). + // Validate field requirements (@require). The fixpoint answers whether the requirement + // holds in O(1); only when it does not, or when a provided selection set narrows the + // context, do we re-run the recursion to build the error tree. var requirements = field.GetFusionRequiresRequirements(schemaName); - if (requirements is not null) + if (requirements is not null + && (path?.Item.ProvidedSelectionSet is not null + || !_facts.IsFieldResolvableOn(type, field, schemaName))) { var requirementErrors = _requirementsValidator.Validate( @@ -348,8 +364,6 @@ private ImmutableArray ValidateSourceSchemaTransition( { return SourceSchemaTransitionHelper.ValidateSourceSchemaTransition( _lookupCache, - _transitionCache, - cycleDetectionPath: null, type, transitionToSchemaName, [.. path.EnumerateFromLeaf()], diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs new file mode 100644 index 00000000000..81a3ca95036 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/ExternalKeyLookupRepro.cs @@ -0,0 +1,55 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class ExternalKeyLookupRepro +{ + [Fact] + public void Compose_Should_Succeed_When_KeyFieldIsExternalInTargetSchema() + { + // arrange + // Schema B declares the entity with an @external key field (the Fed-1 stub idiom): the key + // value is caller-supplied, so the entity is still resolvable from B via its @key lookup. + const string a = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external", "@shareable"]) + + type Query { + thing: Thing + } + + type Thing @key(fields: "k") { + k: Int! + a: String + } + """; + + const string b = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@external", "@shareable"]) + + type Query { + other: String + } + + type Thing @key(fields: "k") @shareable { + k: Int! @external + b: String + } + """; + + var composer = new SchemaComposer( + [new SourceSchemaText("A", a), new SourceSchemaText("B", b)], + new SchemaComposerOptions(), + new CompositionLog()); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsSuccess); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/QueryLessSubgraphLookupTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/QueryLessSubgraphLookupTests.cs new file mode 100644 index 00000000000..acacca41acf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/QueryLessSubgraphLookupTests.cs @@ -0,0 +1,69 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Satisfiability; +using HotChocolate.Types.Mutable; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class QueryLessSubgraphLookupTests +{ + [Fact] + public void GenerateLookup_Should_EmitEntityLookup_When_SubgraphHasNoQueryType() + { + // arrange + // Schema A exposes Thing and owns the key. Schema B declares the same @key entity plus a + // Mutation but NO Query type. The entity in B must still be routable: its @key implies a + // lookup, which must be hosted even though B has no author-declared Query root. + const string a = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"]) + + type Query { + thing: Thing + } + + type Thing @key(fields: "k") { + k: Int! + a: String + } + """; + + const string b = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"]) + + type Mutation { + doThing: Boolean + } + + type Thing @key(fields: "k") @shareable { + k: Int! + b: String + } + """; + + var options = new SchemaComposerOptions(); + var composer = new SchemaComposer( + [new SourceSchemaText("A", a), new SourceSchemaText("B", b)], + options, + new CompositionLog()); + + // act + var result = composer.Compose(); + + // assert + // The merged Thing must carry a lookup for source schema B, otherwise B's fields are + // unreachable via any entity call. + var schema = result.Value!; + var thing = (MutableObjectTypeDefinition)schema.Types["Thing"]; + var lookupSchemas = new FusionLookupDirectiveCache(schema) + .GetPossibleFusionLookupDirectives(thing) + .Select(l => (string)l.Arguments[WellKnownArgumentNames.Schema].Value!) + .Distinct() + .ToList(); + + Assert.Contains("B", lookupSchemas); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/RequirementsValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/RequirementsValidatorTests.cs index 0620571873b..c3507fafc7c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/RequirementsValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/RequirementsValidatorTests.cs @@ -66,11 +66,12 @@ type Product { var schema = merger.Merge().Value; var queryType = (MutableObjectTypeDefinition)schema.Types["Query"]; var productByIdField = queryType.Fields["productById"]; + var lookupCache = new FusionLookupDirectiveCache(schema); var requirementsValidator = new RequirementsValidator( schema, - new FusionLookupDirectiveCache(schema), - new SourceSchemaTransitionCache(), + lookupCache, + new SatisfiabilityFactsBuilder(schema, lookupCache).Build(), includeSatisfiabilityPaths: false); var selectedValue = new FieldSelectionMapParser("specialField").Parse(); var selectionSet = diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityCycleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityCycleTests.cs new file mode 100644 index 00000000000..6108542f293 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityCycleTests.cs @@ -0,0 +1,155 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.Satisfiability; + +public sealed class SatisfiabilityCycleTests +{ + [Fact] + public void Compose_Should_Fail_When_RequireFormsResolutionCycle() + { + // arrange + var log = new CompositionLog(); + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + sku(description: String @require(field: "description")): String + } + """), + new SourceSchemaText( + "B", + """ + type Query { + productInBById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description(sku: String @require(field: "sku")): String + } + """) + ], + new SchemaComposerOptions { Merger = { AddFusionDefinitions = false } }, + log); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsFailure); + Assert.All(log, e => Assert.Equal(LogEntryCodes.UnsatisfiableQueryPath, e.Code)); + string.Join("\n\n", log.Select(e => e.Message)).MatchInlineSnapshot( + """ + Unable to access the field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Cycle detected in requirement: B:Product.description -> A:Product.sku -> B:Product.description. + + Unable to access the field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Cycle detected in requirement: A:Product.sku -> B:Product.description -> A:Product.sku. + + Unable to access the field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Cycle detected in requirement: B:Product.description -> A:Product.sku -> B:Product.description. + + Unable to access the field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Unable to satisfy the requirement '{ description }' on field 'A:Product.sku'. + Unable to satisfy the requirement 'description'. + Unable to access the required field 'Product.description'. + Unable to satisfy the requirement '{ sku }' on field 'B:Product.description'. + Unable to satisfy the requirement 'sku'. + Unable to access the required field 'Product.sku'. + Cycle detected in requirement: A:Product.sku -> B:Product.description -> A:Product.sku. + """); + } + + [Fact] + public void Compose_Should_Succeed_When_ResolutionCycleHasNonCyclicEscape() + { + // arrange + var log = new CompositionLog(); + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + sku(description: String @require(field: "description")): String + } + """), + new SourceSchemaText( + "B", + """ + type Query { + productInBById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description(sku: String @require(field: "sku")): String @shareable + } + """), + new SourceSchemaText( + "C", + """ + type Query { + productInCById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description: String @shareable + } + """) + ], + new SchemaComposerOptions { Merger = { AddFusionDefinitions = false } }, + log); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsSuccess); + Assert.Empty(log); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs new file mode 100644 index 00000000000..0992baa6bf2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/Satisfiability/SatisfiabilityFactsBuilderTests.cs @@ -0,0 +1,228 @@ +using HotChocolate.Fusion.Options; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.CompositionTestHelper; + +namespace HotChocolate.Fusion.Satisfiability; + +public sealed class SatisfiabilityFactsBuilderTests +{ + [Fact] + public void FieldAccessible_Should_BeTrue_When_FieldExistsInSingleSchema() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! + name: String + } + """ + ]); + var product = GetObjectType(schema, "Product"); + var name = product.Fields["name"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.True(facts.IsFieldAccessible(product, name, "A")); + } + + [Fact] + public void CanTransitionAndFieldAccessible_Should_BeTrue_When_TargetHasLookupAndKeyIsAccessible() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + name: String + } + """, + """ + # Schema B + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + price: Int + } + """ + ]); + var product = GetObjectType(schema, "Product"); + var price = product.Fields["price"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.True(facts.CanTransition(product, "B", "A")); + Assert.True(facts.IsFieldAccessible(product, price, "A")); + } + + [Fact] + public void CanTransitionAndFieldAccessible_Should_BeFalse_When_TargetHasNoLookup() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + name: String + } + """, + """ + # Schema B + type Query { + product: Product + } + + type Product { + id: ID! @shareable + price: Int + } + """ + ]); + var product = GetObjectType(schema, "Product"); + var price = product.Fields["price"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.False(facts.CanTransition(product, "B", "A")); + Assert.False(facts.IsFieldAccessible(product, price, "A")); + } + + [Fact] + public void FieldAccessible_Should_BeFalse_When_RequiresAreCircular() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + sku(description: String @require(field: "description")): String + } + """, + """ + # Schema B + type Query { + productInBById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description(sku: String @require(field: "sku")): String + } + """ + ]); + var product = GetObjectType(schema, "Product"); + var sku = product.Fields["sku"]; + var description = product.Fields["description"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.False(facts.IsFieldAccessible(product, sku, "A")); + Assert.False(facts.IsFieldAccessible(product, description, "B")); + } + + [Fact] + public void FieldAccessible_Should_BeTrue_When_RequireCycleHasPlainSourceEscape() + { + // arrange + var schema = CreateMergedSchema( + [ + """ + # Schema A + type Query { + productById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + sku(description: String @require(field: "description")): String + } + """, + """ + # Schema B + type Query { + productInBById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description(sku: String @require(field: "sku")): String @shareable + } + """, + """ + # Schema C + type Query { + productInCById(id: ID!): Product @lookup + } + + type Product { + id: ID! @shareable + description: String @shareable + } + """ + ]); + var product = GetObjectType(schema, "Product"); + var sku = product.Fields["sku"]; + var description = product.Fields["description"]; + + // act + var facts = BuildFacts(schema); + + // assert + Assert.True(facts.IsFieldAccessible(product, description, "A")); + Assert.True(facts.IsFieldAccessible(product, description, "B")); + Assert.True(facts.IsFieldAccessible(product, description, "C")); + Assert.True(facts.IsFieldAccessible(product, sku, "A")); + } + + private static SatisfiabilityFacts BuildFacts(MutableSchemaDefinition schema) + => new SatisfiabilityFactsBuilder(schema, new FusionLookupDirectiveCache(schema)).Build(); + + private static MutableSchemaDefinition CreateMergedSchema(string[] schemas) + { + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions(schemas), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + return merger.Merge().Value; + } + + private static MutableObjectTypeDefinition GetObjectType( + MutableSchemaDefinition schema, + string typeName) + => (MutableObjectTypeDefinition)schema.Types[typeName]; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/ProvidesInvalidFieldsRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/ProvidesInvalidFieldsRuleTests.cs index 22dc140a351..11a517c84b5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/ProvidesInvalidFieldsRuleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/ProvidesInvalidFieldsRuleTests.cs @@ -60,4 +60,115 @@ type UserDetails { """ ]); } + + // The canonical @provides shape: a field selected by @provides is @external on the providing + // schema (delivered by construction). Membership holds, so this is a deliverable selection. + [Fact] + public void Validate_ProvidesExternalFieldMember_Succeeds() + { + AssertValid( + [ + """ + type Query { + reviewById(id: ID!): Review + } + + type Review @key(fields: "id") { + id: ID! + author: User @provides(fields: "name") + } + + type User @key(fields: "id") { + id: ID! + name: String @external + } + """ + ]); + } + + // A @provides selection is deliverable only if every selected field, at every depth, exists on + // the type in the providing schema. Here the nested field "unknownField" does not exist on "Unit". + [Fact] + public void Validate_ProvidesNestedFieldDoesNotExist_Fails() + { + AssertInvalid( + [ + """ + type Query { + productById(id: ID!): Product + } + + type Product @key(fields: "id") { + id: ID! + dimension: Dimension @provides(fields: "unit { unknownField }") + } + + type Dimension { + unit: Unit + } + + type Unit { + code: String + } + """ + ], + [ + """ + { + "message": "The @provides directive on field 'Product.dimension' in schema 'A' specifies an invalid field selection.", + "code": "PROVIDES_INVALID_FIELDS", + "severity": "Error", + "coordinate": "Product.dimension", + "member": "provides", + "schema": "A", + "extensions": { + "errors": [ + "The field 'unknownField' does not exist on the type 'Unit'." + ] + } + } + """ + ]); + } + + // The same membership rule applies inside an inline fragment: the fragment is traversed and the + // field "unknownField" selected within it must exist on "Info". + [Fact] + public void Validate_ProvidesInlineFragmentFieldDoesNotExist_Fails() + { + AssertInvalid( + [ + """ + type Query { + productById(id: ID!): Product + } + + type Product @key(fields: "id") { + id: ID! + info: Info @provides(fields: "... on Info { unknownField }") + } + + type Info { + name: String + } + """ + ], + [ + """ + { + "message": "The @provides directive on field 'Product.info' in schema 'A' specifies an invalid field selection.", + "code": "PROVIDES_INVALID_FIELDS", + "severity": "Error", + "coordinate": "Product.info", + "member": "provides", + "schema": "A", + "extensions": { + "errors": [ + "The field 'unknownField' does not exist on the type 'Info'." + ] + } + } + """ + ]); + } }