graphql-dotnet / graphql-dotnet

GraphQL for .NET

Home Page:https://graphql-dotnet.github.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

IDataLoaderContextAccessor.Context and IResolveFieldContext.RequestServices nullability are not very usable

ccurrens opened this issue · comments

Description

The nullable annotations for IDataLoaderContextAccessor.Context are correct, but not very usable for developers. It is correct to be nullable, since it only has a value during document execution and is null at other times.

The problem is that any code that has nullable references enabled and is defining fields that use data loaders either have to add null checks on Context or use the null forgiveness operator (e.g. accessor.Context!.GetOrAddBatchLoader).

One suggestion is that the Context property could be made non-null, and throw if the underlying asynclocal is null. Framework guidelines says to avoid throwing from getters, but it may be acceptable here since it would only be thrown if the getter is used outside of a context when it is valid -- normal use will never see this exception thrown.

We'd like to avoid having the null forgiveness operator everywhere in our code, but also if there are valid cases where this property can be null while executing field resolvers, we'd want to know that to, so we can ensure our code is correct.

Steps to reproduce

Write code like this:

        Field<ListGraphType<StringGraphType>>("description")
            .Resolve(context =>
            {
                var loader = accessor.Context.GetOrAddBatchLoader<Guid, string>(
                    "key",
                    storage.GetDecriptionByIdsAsync);

                return loader.LoadAsync(context.Source.ItemGuid);
            })

Expected result

No CS8604 warning about possible null reference argument for parameter 'context' in 'GetOrAddCollectionBatchLoader'

Actual result

You get a CS8604 warning about possible null reference argument for parameter 'context' in 'GetOrAddCollectionBatchLoader'

Environment

C# with nullable reference types enabled

Makes sense. In the meantime, you could add an extension method similar to this:

public static IDataLoaderContext DataLoaders(this IResolveFieldContext context)
    => (context.RequestServices
        ?? throw new InvalidOperationException("RequestServices not set"))
        .GetRequiredService<IDataLoaderContextAccessor>().Context
        ?? throw new InvalidOperationException("Data loader context not initialized");

// sample
        Field<ListGraphType<StringGraphType>>("description")
            .Resolve(context =>
            {
                var loader = context.DataLoaders().GetOrAddBatchLoader<Guid, string>(
                    "key",
                    storage.GetDecriptionByIdsAsync);

                return loader.LoadAsync(context.Source.ItemGuid);
            });

We have the same issue with the IResolveFieldContext.RequestServices property -- for implementations that set this within ExecutionOptions, it is always non-null, but then any use needs to use the null forgiveness operator. And if it is not set, then nobody is using it. It's simply more useful to be marked as non-null, although this is inaccurate.

TLDR; I agree, but it was also nice to find some supporting principals in the .NET runtime guidance

I was looking for some coding guidelines for nullable reference types and found this interesting reference in the .NET runtime. It lists a few scenarios related to the validity of annotations when an object is not used correctly and special-casing certain properties/fields on structs.

  1. With object implementing IDisposable, using the object after it's disposed is a violation of its contract. So the nullable annotations aren't meaningful after disposal. If the field is non-null except after being disposed, it should be marked as non-null.
  2. With IEnumerator.Current, it's invalid to access it before MoveNext has called or returned false. But the guidance is to mark it as non-null, since that is what it would return in the correct use case.
  3. With structs, it's a little more complicated, I suggest reading it if you're interested. However a key quote is this:

    Making them nullable, while technically correct, would harm the 100% correct use case in favor of the 0% invalid use case.

So, it seems that the guiding principal here is that the annotations should represent the correct usage of the type, not necessarily the technically correct nullability.

It seems to me that in both cases, the IResolveFieldContext.RequestServices and IDataLoaderContextAccessor.Context properties are intended to be used in a way with DataLoaderDocumentListener or other configuration to the request pipeline to ensure those properties are non-null.

I am happy to submit an MR for the IDataLoaderContextAccessor.Context, if you'd like. I could try my hand at RequestServices as well.

Make sense. I'd suggest that we change the annotation for IDataLoaderContextAccessor.Context to non-null now (master branch / v7). Feel free to write a PR.

The RequestServices property is a bit different. We allow ExecutionOptions.RequestServices to be null; it's a valid 'state' to pass through the execution engine. We have a few options:

  1. Leave ExecutionOptions.RequestServices as nullable and modify IResolveFieldContext.RequestServices to be non-null. We simply assume that if the user is using it, they have set ExecutionOptions.RequestServices. I don't like this answer because extension methods (some are included within GraphQL.NET) rely on RequestServices when it is not available. These extension methods currently check for null and throw a meaningful exception, but the check "shouldn't exist" if the NRT annotations indicate it is non-null.

  2. Modify both ExecutionOptions.RequestServices and IResolveFieldContext.RequestServices to be non-null (probably for v8 / develop branch), with a corresponding note in the migration notes. This way, we are essentially indicating that it is required, although the execution engine does not normally require it, and setting the value to null! is available for backwards compatibility (but some features may not work such as .Resolve().WithScope()). The benefit here is that some features rely on RequestServices being available anyway.

  3. Leave ExecutionOptions.RequestServices as nullable, but if null, internally set the value to be an implementation that always throws an exception upon calling GetService. This way, any use will throw a meaningful exception (vs NRE), stating that ExecutionOptions.RequestServices must be set. I like this idea the best. Probably target v8 / develop with a note in the migration doc.

Thoughts?

Aside from fixing the annotation on IResolveFieldContext.RequestServices, I think it's important to notify the user when it is used incorrectly. So for that reason, I like the third option the best.

But since we always set it to a implementation that throws an exception, it doesn't make sense to me to keep ExecutionOptions.RequestServices as nullable. In this scenario, it would never actually be nullable and thus null checks shouldn't be necessary. However, this is most scoped to internal library use rather than to applications consuming the graphql library (aside from advanced use cases) so it will effectively solve this reported issue.

However, if the internal implementation throws an exception, it seems that we could make it non-null and it would remove the need for the extension methods that throw MissingRequestServicesException. That could be done inside of the dummy implementation.

I could go either way, but I slightly favor making it non-null.