Skip to content

Releases: EntityGraphQL/EntityGraphQL

6.3.0

Choose a tag to compare

@lukemurray lukemurray released this 10 Oct 19:06
9847158

Changes

  • ToGraphQLSchemaStringAsync(ClaimsPrincipal? user) (and ToGraphQLSchemaString(QueryRequestContext? requestContext)) output SDL containing only what that user may access, using the schema's AuthorizationService as query execution does. The result is self-consistent: a type the filtering leaves empty is hidden along with the fields returning it, a field is hidden when an argument's type is hidden, hidden interfaces and union members are dropped from implements and union lists, and a directive with an argument of a hidden type is hidden. Useful for handing a schema to a user or AI agent (e.g. an MCP server tool) where SDL is more compact than introspection. ToGraphQLSchemaString() without a user still outputs the full schema. Both are default interface methods on ISchemaProvider, so custom implementations do not break.
  • Introspection for a user now hides the same things the SDL does. Previously a type with no type-level authorization but every field protected was still listed (with fields: []), a field taking an argument of a protected enum or input type was listed, protected interfaces still appeared in interfaces, and a directive with an argument of a protected type was listed. These are now hidden, so a client building a schema from introspection gets a valid one. Introspection without a user is unchanged.
  • [Required] on an argument (a property/field of an arguments object or a method parameter) now behaves like ArgumentHelper.Required<T>() - the argument has no default value and must be provided by the query. Previously a value type kept its implicit default(T) as the schema default, so [Required] int Take printed take: Int! = 0 (optional for clients) and a query leaving it out ran with 0. It is now take: Int! and leaving it out is a missing required argument error. Remove [Required] if you relied on the old default.
  • Introspection __InputValue.defaultValue no longer strips the quotes off string-like defaults. The spec defines it as the GraphQL literal, so a string default of hello is now returned as "hello" - the same text as in the SDL. Clients building a schema from introspection (buildClientSchema, GraphiQL, codegen against an endpoint) previously dropped such defaults or failed to parse them (Guid, strings with spaces). If you read defaultValue directly as text rather than parsing it as GraphQL, string defaults now include their quotes.
  • Added IFieldExtension.ProcessExpressionPostSelection (a default interface method, and virtual on BaseFieldExtension), called with a list field's projected result so an extension can wrap it.
  • ConnectionEdgeExtension(Type listType, bool isQueryable) is obsolete - isQueryable is not used. Use ConnectionEdgeExtension(Type listType). It will be removed in 7.0.

Fixes

  • #562 - Fixed invalid SDL for argument default values of value types. Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan and char defaults were printed bare (runId: ID! = 00000000-0000-0000-0000-000000000000), which tools like GraphQL Code Generator reject. They are now quoted string literals (dates in ISO format). Numeric defaults are printed with the invariant culture (3.14, not 3,14 under de-DE) and string defaults are escaped (", \, newlines).
  • Fixed UseOffsetPaging() on a field that is not on the root query type when its collection comes from EF - e.g. schema.Type<Actor>().ReplaceField("movies", new { }, (a, _) => a.Movies.OrderBy(m => m.Released)).UseOffsetPaging() - failing with The LINQ expression 'p_Movie => new Dynamic_items...' could not be translated. The collection is part of the parent's projection, so EF has to translate the paging, and items was paged with EntityGraphQL's own Skip/Take(int?) helpers, which EF does not know. A collection that is not an IQueryable is now paged with System.Linq's Skip/Take (a null skip is 0, a null take is the rest of the collection - int.MaxValue - skip, as EF pages a nested collection with row <= skip + take, which would overflow an int on SQL Server and Postgres). hasNextPage on such a field is translated too: EF evaluated EntityGraphQL's PageHasNext helper in memory, loading the whole collection for every parent, and it is now an EXISTS query. Paging on root query fields was not affected.
  • #574 - Fixed UseConnectionPaging() on a field that is not on the root query type when its collection comes from EF - e.g. schema.Type<Actor>().ReplaceField("movies", a => a.Movies.OrderByDescending(m => m.Released)).UseConnectionPaging() - failing with The LINQ expression 'edgeNode => ...' could not be translated. As with offset paging above, the edges are now paged with System.Linq's Skip/Take. Cursors were assigned by ConnectionHelper.ApplyCursors while enumerating the page, which EF can not translate inside a parent's projection, so for such a collection they are now set on the edges after EF materializes them. last (without before) on such a collection pages from the end of each parent's own collection - it relied on one total count shared by the whole request - and is taken as Reverse().Take(last) so EF can translate it. With after too, the rows up to after are then dropped from that page. last with or without before on such a collection needs it to be ordered (e.g. a.Movies.OrderBy(m => m.Released)) when it comes from EF, which can not Reverse() an unordered collection - it fails with EF's could not be translated error. An in-memory collection does not need to be ordered. pageInfo { hasNextPage } alone is an EXISTS query rather than loading the whole collection. Paging on root query fields is unchanged.
  • Fixed a connection field paging a collection of the same type at the root (IQueryable) and on a parent object sharing one ConnectionEdgeExtension, which used whether the first configured field was IQueryable for both (No generic method 'Skip' on type 'QueryableExtensions'). It is now decided per use.
  • Fixed connection paging pageInfo.hasPreviousPage being false for last without before when exactly one item comes before the page (e.g. last: 2 of 3 items).
  • Fixed connection paging with last and a before cursor past the end of the collection - e.g. last: 3, before: <cursor 5> of 3 items - returning too few items (2 and 3 rather than all 3), as the page was taken as ending at the cursor. It is now the last items of the collection, with matching cursors and pageInfo. On a nested collection the cursor is shared by every parent and can be past the end of only some of them: each parent's page is chosen from its own count, at the cost of also selecting the last last items of each parent alongside the page.
  • Fixed connection paging pageInfo.hasNextPage being false for before when the cursor is the last item (e.g. last: 3, before: <cursor 6> of 6 items), although the item at the cursor comes after the page.
  • A connection's pageInfo is no longer null checked - it can't be null. The check repeated the whole connection expression, so EF translated it twice - pageInfo { hasNextPage } on a nested connection ran its EXISTS subquery twice per parent.

6.2.4

Choose a tag to compare

@lukemurray lukemurray released this 08 Sep 17:45
dd0d510

Fixes

  • Fixed No generic method 'SelectWithNullCheck' for a sub-selection on a field returning IAsyncEnumerable<T> or ValueTask<TCollection> - { people { tags { name } } } where tags has a ResolveAsync returning either. MakeSelectWithDynamicType emits a SelectWithNullCheck whose overload is resolved by the exact type of the expression it projects, and only IEnumerable<T> and Task<IEnumerable<T>> had one. IAsyncEnumerable<T> now has an overload of its own that projects lazily, so the stream is still buffered by the engine with the request's CancellationToken rather than being enumerated during compilation, and ValueTask<T> is handed on as the Task<T> the rest of the pipeline already handles, next to the existing Task<TCollection> normalization.
  • Fixed Object of type 'Dynamic_...' cannot be converted to type 'Dynamic_...' when buffering an IAsyncEnumerable<T> whose items are rebuilt - the list rebuild mismatch below, one layer down. BufferAsyncEnumerable created a List<T> from the declared element type before resolving anything and added each resolved item to it, so an item rebuilt to carry an awaited member no longer fit. Items are now resolved first and the list type chosen from them, which is what the IEnumerable path already did - both paths now share that step. The same guard added above for Task<T> and ValueTask<T> in GetResolvedFieldType applies to IAsyncEnumerable<T>, which is reachable now that these shapes compile.
  • Fixed Object of type 'System.Collections.Generic.List1[System.Object]' cannot be converted to type 'System.Collections.Generic.IEnumerable1[Dynamic_...]' for a query selecting an async service field below an async service list field - { people { tags { name label } } } where tags has a ResolveAsync returning a list and label on the item type has one of its own. Resolving an item of the outer list rebuilds it, because the item projection holds an async member, so the finished list no longer holds the item type the outer field was declared with and correctly falls back to List<object>. GetResolvedFieldType unwrapped Task<T> to T unconditionally, so the rebuilt parent still declared the member IEnumerable<T> and setting the resolved list on it threw. T is now only kept when the resolved value still is one, otherwise the resolved value's own type is used - which is what the non-async path in the same method already did. ValueTask<T> had the same hole and takes the same guard.

6.2.3

Choose a tag to compare

@lukemurray lukemurray released this 08 Sep 17:45
991f180

Fixes

  • Fixed An item with the same key has already been added when a service field's resolver builds an object from more than one context member as an argument to the service call - (ctx, srv) => srv.Get(new Key(ctx.A, ctx.B)). ExpressionExtractor credited both member reads to the enclosing construction rather than to themselves, so it emitted the same Expression instance twice under the one name and ExpressionReplacer added each of them to a dictionary keyed by node identity. A construction is now walked through rather than treated as a leaf, so each read is extracted on its own. ExpressionReplacer also tolerates a repeated expression, which the extractor still emits for other shapes (a conditional argument, where the branch reads are both credited to the conditional).

6.2.2

Choose a tag to compare

@lukemurray lukemurray released this 03 Sep 02:20

Fixes

  • Fixed a field whose dotnet type is a nullable value type (Instant?, DateTime?, int?) resolving to default(T) instead of null, for a query that also selects an async field. Results are rebuilt after any async field is awaited, and each member's new type was taken from the resolved value's runtime type.

6.2.1

Choose a tag to compare

@lukemurray lukemurray released this 18 Aug 11:52

Fixes

  • Fixed IsNullable() (and any other change to a field's return type) on a field whose dotnet type has an AddTypeMapping leaking to every other field of that type. The mapping's GqlTypeInfo was handed out as the field's own ReturnType, and that object is mutable - IsNullable() writes to it - so one AddField(...).IsNullable(true) rewrote the registered mapping itself, changing every field already using it and every field added afterwards. Fields now get a copy, so an explicit IsNullable() after AddField() applies to that field only and the mapping stays the default it describes.

6.2.0

Choose a tag to compare

@lukemurray lukemurray released this 18 Aug 10:48
85c372f

Changes

  • A resolver can now be told what the engine will read off the objects it returns, so it can fetch only that instead of everything - the point of batching with ResolveBulk when the data comes from another database or service. Take an IFieldSelection parameter and the engine supplies it, the way it supplies CancellationToken and QueryRequestContext; new two-service ResolveBulk/ResolveBulkAsync overloads let a loader take it alongside its own service. It is not the caller's selection set: a selected field that is itself resolved from a service is replaced by the member its resolver reads, nested objects come through as paths (Address.City), @skip/@include are applied, fragments are expanded, aliases collapse, __typename is excluded, and one load is told the union of every place the field is selected. See Fetching only the fields that will be used.
  • A field with a ResolveBulk/ResolveBulkAsync resolver that has to resolve per item instead of bulk loading now logs a warning to the ILogger the schema was built with, naming the field and why the bulk load could not run - the query shape usually controls it, so it is something a caller can act on. Turn it off with ExecutionOptions.WarnOnBulkResolverFallback = false. ISchemaProvider.Logger is new (exceptions keep going through LogException); it returns null by default so existing implementations are unaffected.
  • New field.SetMaxAliases(n) caps how many times a single field may be aliased in one operation, alongside the document-wide ExecutionOptions.MaxFieldAliases. Useful for the few fields a batched-alias attack would target (a login mutation, an expensive report) without tightening the limit for everything. Counts aliased selections of that field, fragment contents included. QueryLimitExceededContext.FieldName names the field for report-only mode. See Query limits.
  • ToGraphQLSchemaString() takes an optional includeDescriptions argument. Pass false to leave the """...""" descriptions out of the generated SDL.

Fixes

  • Fixed an IArgumentsTracker parameter on a mutation or subscription method failing with Service IArgumentsTracker not found for dependency injection whenever a service provider is passed to ExecuteRequest - i.e. in any real app. The engine builds and populates the tracker per call, but the branch binding it to the parameter sat below the dependency-injection catch-all, so it was only reachable with a null service provider (which is how the existing tests exercised it). Registering IArgumentsTracker in DI was not a workaround: it bound a different, empty tracker, so IsSet returned false for every argument and the mutation silently took the "nothing was supplied" path. It is now bound before the DI branch, the same as CancellationToken.
  • An IArgumentsTracker parameter now works on query fields built from methods ([GraphQLField] methods, AddFieldsFrom), not just mutations - it previously fell through to the service provider and failed with Service IArgumentsTracker not found in service provider. The field's arguments object is built deriving from ArgumentsTracker when a parameter asks for one, and the parameter binds to that object, so the tracker is not a service and a field that has no other services stays on the database-bound pass.
  • An argument explicitly supplied as null on a query field is now reported as set by IArgumentsTracker - previously only a non-null value or a schema default counted, so IsSet could not tell an explicit null from an omitted argument, which is the distinction it exists to make. This also applies to [GraphQLArguments] / input types deriving from ArgumentsTracker. Mutation arguments already behaved this way.
  • AddTypeMapping now applies to every field returning the mapped dotnet type, not only the ones auto-created from the context. A mapping like AddTypeMapping<NpgsqlPolygon>("[Point!]!") describes the whole GraphQL type - list-ness and nullability come from the mapping string, not from the dotnet type - but only the auto-populated path returned the mapping's GqlTypeInfo. Everything else went through SchemaBuilder.MakeGraphQlType, which resolved the type name (Point) and then recomputed IsList/TypeNotNullable from the dotnet type, so AddField(), AddField().Resolve(), root fields on Query(), [GraphQLField] methods and expression fields all described the field as Point instead of [Point!]!. Introspection, the SDL and any client generated from them were wrong; execution is unchanged. Two notes: the [GraphQLField] method case is a regression in 6.0 (the other paths never honoured the mapping), and an async [GraphQLField] method now matches the mapping too - the lookup used to be made with the declared Task<T> return type. Type mappings still need to be registered in SchemaBuilderOptions.PreBuildSchemaFromContext so they exist before the context is reflected.

6.1.7

Choose a tag to compare

@lukemurray lukemurray released this 06 Aug 05:00

Fixes

  • Fixed a single-object field with a ResolveBulk resolver whose per-item resolver queries the schema context - e.g. .Resolve<MyContext>((p, ctx) => ctx.Site.Movies.FirstOrDefault(m => m.DirectorId == p.Id)) - failing with Could not find extension method Select on types System.Linq.Enumerable. A regression in 6.1.3: such a field was moved onto the collection selection path (to keep it translatable), but a bulk resolver's value on that pass is the loaded dictionary's entry, a single object, so there is no collection to select through. Bulk resolved fields stay on the single-object projection. Reported in #543 - thanks @soilidokay for the reproduction.
  • Fixed a service field selected on the items of a paging field whose own resolver uses a service - e.g. .Resolve<MyService>((p, srv) => p.Tasks.Where(t => srv.Include(t))) with UseOffsetPaging()/UseConnectionPaging() on a nested type - failing with Could not find field egql__x_Id on type X. Such a paging field cannot be split across the two passes so it is built in one go on the services pass, from the entity rather than from a first-pass projection; a service field on its items had nowhere to read its own extracted dependency from. It now reads that dependency straight off the entity.

6.1.6

Choose a tag to compare

@lukemurray lukemurray released this 04 Aug 03:32

Fixes

  • The field-error logging added in 6.1.5 is now covered for the case it exists for: outside development mode the caller gets only Field 'x' - Error occurred while the original exception - its own message and stack trace - reaches the ILogger. 6.1.5's test ran with the default IsDevelopment = true, where nothing is swallowed, so it did not check that.

6.1.5

Choose a tag to compare

@lukemurray lukemurray released this 03 Aug 23:46
dbf53cd

Fixes

  • Field-level exceptions are logged again, with the field name and stack trace, to the ILogger the schema was built with. A regression in 6.0: partial results turned a field failure into a GraphQL error instead of rethrowing it, so nothing reached the request-level log and the only way to see why a field failed was development mode or AllowedExceptions - both of which return the detail to the caller. Responses are unchanged. Document and validation errors, whose message the caller already gets in full, are not logged.

6.1.4

Choose a tag to compare

@lukemurray lukemurray released this 03 Aug 00:57
fb469a2

Fixes

  • Fixed ResolveBulk / ResolveBulkAsync nested under a root service-resolved list (e.g. Query.apiKeys from .Resolve<TService>(...) rather than a context/DbSet property) so nested bulk fields load once instead of falling back to per-item Resolve (or previously failing with a null BulkParameter). Root service list fields now participate in the two-pass flow. UseFilter()/UseSort() on those root lists still apply on the first pass and are not re-applied against the first-pass Dynamic on the second. Regression tests in ServiceRootListBulkTests / ServiceBackedCollectionExtensionsTests. A root service list/object only takes the two-pass path when the selection actually has a bulk resolver in it (including through a fragment spread) - otherwise it stays on the single-pass path, as the extra pass costs a compile and a projection of every row for nothing. Note that for the fields that do take it, the service now runs during the first pass, so a BeforeExecuting hook sees two executions for them (isFinal false then true) where it saw one.
  • Fixed ResolveBulk nested under a root service-resolved object that exposes a list (status-page shape: statusPage { items { bulkField } }). First pass used to return only ExtractedFieldsFromServices for root service objects, so nested list selection and bulk registration were skipped; the second pass then either N+1'd via Resolve or failed converting entity types to first-pass Dynamic_* types (No coercion operator...). Root service objects now participate in the two-pass flow (like root service lists), wrap once with ProjectWithNullCheck on the first pass, and on the second pass select from the materialized anon instead of re-invoking the service. Also rebinds the ResolveBulk DataSelector parameter (a different ParameterExpression than FieldParam) so multi-property keys no longer fail with variable 'row' ... is not defined. Nullable-nav bulk keys (row.DetexyBoard == null ? null : row.DetexyBoard.SerialNumber) are rewritten via ExpressionReplacer.VisitConditional onto the first-pass extracted field instead of leaving a second navigation MemberExpression unbound. Exact regression: HardwareSensorStatusPageBulkTests (mirrors Offline Active hardwareSensorStatusPage { items { lastSeen isOffline floor { currentFloorStatus } } }). Also: ServiceResolvedPage_ItemsWithResolveBulk_Works, ServiceResolvedPage_ItemsWithComplexResolveBulkKey_Works.