Skip to content

Latest commit

 

History

History
799 lines (601 loc) · 18.8 KB

File metadata and controls

799 lines (601 loc) · 18.8 KB

Upgrading to 35.0.0

Version 35 has two breaking changes.

  • The projection based FieldBuilder APIs infer their type arguments, and Field(...) on the EF graph types returns an EfFieldBuilder. Source breaking for callers of the old signatures. #1377
  • The where and orderBy arguments are input types generated per entity. Breaking for every query that passes either argument, and for code that declares or reads them. #1401

Projection based FieldBuilder APIs

From #1377. The explicit type arguments and casts are gone from the projection based FieldBuilder APIs, and the analyzer sees those calls.

  • WithProjection takes Expression<Func<TSource, TProjection>>, so the projection lambda no longer needs a cast.
  • Field(...) on EfObjectGraphType and EfInterfaceGraphType returns EfFieldBuilder<TDbContext, TSource, TReturn>, which carries the IEfGraphQLService. The projection based Resolve and ResolveAsync are instance methods on it, with only TProjection inferred.
  • The fluent FieldBuilder methods (Description, Argument, Configure, Resolve, and so on) are overridden with covariant return types, so the projection based methods remain available anywhere in a chain.
  • The extension methods Resolve, ResolveAsync, ResolveList and ResolveListAsync take the IEfGraphQLService<TDbContext> as their first argument. All type arguments are inferred, and the service is used directly at execution time instead of being located through RequestServices.
  • FieldBuilderResolveAnalyzer previously never matched the extension methods, so GQLEF003 could not fire. It now resolves the receiver type for extension methods and identifies projection based calls by their projection parameter.

The old extension methods and the LambdaExpression overload of WithProjection are removed rather than kept as overloads.

WithProjection

Drop the cast.

Before:

.WithProjection((Expression<Func<OrderEntity, OrderStatus>>)(_ => _.Status))

After:

.WithProjection(_ => _.Status)

Projection based Resolve and ResolveAsync inside an EfObjectGraphType or EfInterfaceGraphType

Drop the type arguments. Field(...) returns an EfFieldBuilder that already knows the service.

Before:

Field<NonNullGraphType<StringGraphType>, string>("parentName")
    .Resolve<MyDbContext, ChildEntity, string, ParentEntity?>(
        projection: _ => _.Parent,
        resolve: _ => _.Projection?.Name);

After:

Field<NonNullGraphType<StringGraphType>, string>("parentName")
    .Resolve(
        projection: _ => _.Parent,
        resolve: _ => _.Projection?.Name);

Fluent calls from GraphQL.NET can appear before or after the projection based resolve. Extension methods from other libraries (for example Authorize()) return the base FieldBuilder, so place them after it.

Projection based resolve on a plain ObjectGraphType, or ResolveList and ResolveListAsync

Pass the IEfGraphQLService<TDbContext> as the first argument instead of type arguments.

Before:

.ResolveList<MyDbContext, ParentEntity, ChildEntity, ICollection<ChildEntity>>(
    projection: _ => _.Children,
    resolve: _ => _.Projection);

After:

.ResolveList(
    graphQlService,
    projection: _ => _.Children,
    resolve: _ => _.Projection);

The service no longer needs to be resolvable from RequestServices for these fields.

Identity projections

projection: _ => _ in a projection based resolve inside an EF graph type is now reported at compile time as GQLEF003, an error, rather than only failing at runtime. Use the regular Resolve() for primary and foreign key access, or project the required navigation.

Typed where and orderBy

From #1401. The where and orderBy arguments changed from string based inputs to input types generated per entity. Every query that passes either argument needs to change, as does any code that declares or reads them. Each shape below shows the old query and the new one.

Why

The old where took a path string, a comparison and a value list of strings. The library resolved the path and parsed the strings at execution time, so a typo in a property name or a value the property type could not parse failed only when the query ran, and parsing depended on the server culture. orderBy took a path string the same way.

The new arguments are described by the schema. A mistyped property or a value of the wrong type is rejected at validation, tooling can complete them, and values arrive as GraphQL scalars so no string parsing happens in the library.

The generated types

For an entity Person with a Company navigation and an Addresses collection, the schema gains:

input PersonWhere {
  and: [PersonWhere!]
  or: [PersonWhere!]
  not: PersonWhere
  id: GuidComparison
  name: StringComparison
  age: Int32Comparison
  company: CompanyWhere
  addresses: AddressCollectionWhere
}

input AddressCollectionWhere {
  any: AddressWhere
  all: AddressWhere
  none: AddressWhere
}

input StringComparison {
  equal: String
  notEqual: String
  in: [String]
  startsWith: String
  endsWith: String
  contains: String
  like: String
}

input Int32Comparison {
  equal: Int
  notEqual: Int
  in: [Int]
  greaterThan: Int
  greaterThanOrEqual: Int
  lessThan: Int
  lessThanOrEqual: Int
}

input PersonOrderBy {
  id: SortDirection
  name: SortDirection
  age: SortDirection
  company: CompanyOrderBy
}

enum SortDirection {
  ascending
  descending
}

Names follow the CLR type: {Type}Where, {Type}CollectionWhere, {Type}OrderBy, and {ValueType}Comparison. The comparison names are the ones the old comparison enum used, so equal, notEqual, in, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, contains, startsWith, endsWith and like carry over unchanged. notIn is gone; use not around an in.

Which comparisons a property offers depends on its type. string gets the text comparisons and no ordering ones, bool and enums get equal, notEqual and in only, and everything else gets equality and ordering.

Field arguments

Before:

entities(where: [WhereExpression!], orderBy: [OrderBy!], skip: Int, take: Int, ids: [ID!])

After:

entities(where: EntityWhere, orderBy: [EntityOrderBy!], skip: Int, take: Int, ids: [ID!])

where is a single object rather than a list. ids, skip and take are unchanged.

Single comparison

The path becomes a field, the comparison becomes a field inside it, and the value is its value.

Before:

{
  entities (where: {path: "Property", comparison: equal, value: "the value"})
  {
    property
  }
}

After:

{
  entities (where: {property: {equal: "the value"}})
  {
    property
  }
}

The field name is the camel cased property name, as it is for output fields. A where that omitted comparison defaulted to equal; write equal explicitly.

Typed values

Values were strings. They are now the scalar of the property type.

Before:

{
  entities (where: {path: "Age", comparison: greaterThan, value: "30"})
  {
    name
  }
}

After:

{
  entities (where: {age: {greaterThan: 30}})
  {
    name
  }
}

The same applies to the other types:

Property type Before After
int, long, short, decimal, double value: "30" equal: 30
bool value: "true" equal: true
enum value: "Thursday" equal: THURSDAY
Guid value: "00000000-0000-0000-0000-000000000001" equal: "00000000-0000-0000-0000-000000000001"
DateTime value: "2020-10-01T10:11:12Z" equal: "2020-10-01T10:11:12Z"
DateOnly value: "2020-10-1" equal: "2020-10-01"
TimeOnly value: "10:11 AM" equal: "10:11:00"

Enum values use the enum's GraphQL name, which is the constant case GraphQL.NET generates unless a custom enum graph type is registered. Dates and times follow the ISO 8601 forms their scalars accept.

Multiple comparisons on one property

Before:

{
  entities
  (where:
    [
      {path: "Property", comparison: startsWith, value: "Valu"}
      {path: "Property", comparison: endsWith, value: "ue3"}
    ]
  )
  {
    property
  }
}

After, the comparisons sit on the same field and are and'ed:

{
  entities (where: {property: {startsWith: "Valu", endsWith: "ue3"}})
  {
    property
  }
}

Multiple properties

Before:

{
  entities
  (where:
    [
      {path: "Name", comparison: startsWith, value: "A"}
      {path: "Age", comparison: greaterThan, value: "30"}
    ]
  )
  {
    name
  }
}

After, fields on the same object are and'ed:

{
  entities (where: {name: {startsWith: "A"}, age: {greaterThan: 30}})
  {
    name
  }
}

Where In

Before:

{
  entities (where: {path: "Property", comparison: in, value: ["Value1", "Value2"]})
  {
    property
  }
}

After:

{
  entities (where: {property: {in: ["Value1", "Value2"]}})
  {
    property
  }
}

A null in the list still matches a null property: in: [1, null].

Grouping with connectors

Before, connector on an expression joined it to the next one, and groupedExpressions grouped a list:

{
  entities
  (where:
    [
      {path: "Property", comparison: startsWith, value: "Valu"},
      {
        groupedExpressions: [
          {path: "Property", comparison: endsWith, value: "ue", connector: "or"},
          {path: "Property", comparison: endsWith, value: "id"}
        ]
      }
    ]
  )
  {
    property
  }
}

After, and and or take lists of where objects:

{
  entities
  (where: {
    property: {startsWith: "Valu"},
    or: [
      {property: {endsWith: "ue"}},
      {property: {endsWith: "id"}}
    ]
  })
  {
    property
  }
}

Both read as Property.StartsWith("Valu") && (Property.EndsWith("ue") || Property.EndsWith("id")).

Negation

Before, negate: true on an expression or a group:

{
  entities
  (where:
    [
      {path: "Property", comparison: startsWith, value: "Valu", negate: true},
      {
        negate: true,
        groupedExpressions: [
          {path: "Property", comparison: endsWith, value: "ue", connector: "or"},
          {path: "Property", comparison: endsWith, value: "id"}
        ]
      }
    ]
  )
  {
    property
  }
}

After, wrap the expression in not:

{
  entities
  (where: {
    not: {property: {startsWith: "Valu"}},
    and: [
      {
        not: {
          or: [
            {property: {endsWith: "ue"}},
            {property: {endsWith: "id"}}
          ]
        }
      }
    ]
  })
  {
    property
  }
}

A single not alongside other fields is enough for one negation. The and above is only needed because an object can carry one not.

notIn

Before:

{
  entities (where: {path: "Property", comparison: notIn, value: ["Value1", "Value2"]})
  {
    property
  }
}

After:

{
  entities (where: {not: {property: {in: ["Value1", "Value2"]}}})
  {
    property
  }
}

Nested properties

Before, a dotted path:

{
  entities (where: {path: "Address.Street", comparison: startsWith, value: "Main"})
  {
    property
  }
}

After, a nested object. This covers reference navigations, owned types and complex properties:

{
  entities (where: {address: {street: {startsWith: "Main"}}})
  {
    property
  }
}

List members

Before, the member path in square brackets, which matched when any item did:

{
  entities (where: {path: "ListProperty[Property]", comparison: startsWith, value: "Valu"})
  {
    property
  }
}

After, a collection where with any:

{
  entities (where: {listProperty: {any: {property: {startsWith: "Valu"}}}})
  {
    property
  }
}

all and none are new. The item where can carry several conditions, and any: {} matches entities that have at least one item.

Null

Before, null was expressed by omitting value:

{
  entities (where: {path: "Property", comparison: equal})
  {
    property
  }
}

After, pass null:

{
  entities (where: {property: {equal: null}})
  {
    property
  }
}

An empty where, {}, applies no filter. The old empty list, [], matched nothing.

Variables

Before, values were passed as strings, and a whole where as [WhereExpression!]:

query ($value: String!)
{
  entities (where: {path: "Property", comparison: equal, value: [$value]})
  {
    property
  }
}

After, a value variable has the scalar type of the property, and a whole where has the entity's where type:

query ($value: String!)
{
  entities (where: {property: {equal: $value}})
  {
    property
  }
}
query ($where: EntityWhere)
{
  entities (where: $where)
  {
    property
  }
}

The variable value follows the same shape:

{
  "where": {
    "property": {"equal": "the value"}
  }
}

OrderBy

Before:

{
  entities (orderBy: {path: "Property"})
  {
    property
  }
}
{
  entities (orderBy: {path: "Property", descending: true})
  {
    property
  }
}

After:

{
  entities (orderBy: {property: ascending})
  {
    property
  }
}
{
  entities (orderBy: {property: descending})
  {
    property
  }
}

Several keys stay a list, one property per item. An item that sets no property, or more than one, is an error, since the order of fields inside an object is not something a client can rely on:

{
  entities (orderBy: [{property: descending}, {id: ascending}])
  {
    property
  }
}

A dotted path becomes a nested object:

Before:

{
  entities (orderBy: {path: "Parent.Property"})
  {
    property
  }
}

After:

{
  entities (orderBy: {parent: {property: ascending}})
  {
    property
  }
}

Collection navigations cannot be ordered by and have no field.

Navigation fields

Navigation list and connection fields take the same arguments, so their queries change the same way:

Before:

{
  parentEntities
  {
    children(where: {path: "property", comparison: equal, value: "Value1"}, orderBy: {path: "property"})
    {
      property
    }
  }
}

After:

{
  parentEntities
  {
    children(where: {property: {equal: "Value1"}}, orderBy: {property: ascending})
    {
      property
    }
  }
}

Code that declares or reads the arguments

Fields added through AddQueryField, AddSingleField, AddFirstField, AddNavigationListField, AddNavigationConnectionField and AutoMap get the new arguments without any change.

Code that added the argument to a plain Field and applied it by hand changes as follows.

Before:

Field<ListGraphType<EmployeeSummaryGraphType>>("employeeSummary")
    .Argument<ListGraphType<WhereExpressionGraph>>("where")
    .Resolve(context =>
    {
        var dbContext = ResolveDbContext(context);
        IQueryable<Employee> query = dbContext.Employees;

        if (context.HasArgument("where"))
        {
            var wheres = context.GetArgument<List<WhereExpression>>("where");

            var predicate = ExpressionBuilder<Employee>.BuildPredicate(wheres);
            query = query.Where(predicate);
        }
        ...
    });

After:

Field<ListGraphType<EmployeeSummaryGraphType>>("employeeSummary")
    .Argument<WhereGraph<Employee>>("where")
    .Resolve(context =>
    {
        var dbContext = ResolveDbContext(context);
        IQueryable<Employee> query = dbContext.Employees;

        if (context.HasArgument("where"))
        {
            var where = context.GetArgument<WhereExpression>("where");

            var predicate = ExpressionBuilder<Employee>.BuildPredicate(where);
            query = query.Where(predicate);
        }

        return query
            .GroupBy(_ => _.CompanyId)
            .Select(_ => new EmployeeSummary
            {
                CompanyId = _.Key,
                AverageAge = _.Average(_ => _.Age),
            });
    });

snippet source | anchor

WhereGraph<T> is the generated input type for T. It is registered by EfGraphQLConventions.RegisterInContainer, along with OrderByGraph<T>, ComparisonGraph<T>, CollectionWhereGraph<T> and SortDirectionGraph, so nothing extra needs registering. The argument parses to a single WhereExpression, and ExpressionBuilder<T>.BuildPredicate has an overload that takes one.

WhereExpression

WhereExpression remains the model the predicate builder consumes, and code that built one by hand still works with two changes:

  • Value is object?[] rather than string[]. Strings are still accepted and parsed, with the invariant culture, and a value of the property type is used as is.
  • A new Quantifier property marks a collection node: Path is the collection and GroupedExpressions is the predicate on its items.

Comparison, Connector, Negate and GroupedExpressions are unchanged. Comparison.NotIn remains only for this model; the schema does not expose it.

Removed types

  • WhereExpressionGraph: replaced by WhereGraph<T>
  • ComparisonGraph (the enum graph): replaced by ComparisonGraph<TValue>, an input type
  • ConnectorGraph: connectors are and and or fields

IEfGraphQLService<TDbContext> now extends a non generic IEfGraphQLService exposing the IModel, which the generated types read the entity members from.

Members that get a field

From the EF model: every mapped, non shadow property with a public getter whose type is one of the supported scalar types or an enum, every reference and collection navigation including many to many, and every complex property. Properties of other types, such as byte arrays and primitive collections, get no field, as they had no usable comparison before either. A type the model does not know, such as a projection or dto, gets its public scalar properties.

Properties declared only on a derived type are not on the base type's where, which matches the old path resolution.

Naming

Two entity types with the same CLR type name in one schema would generate two input types with one name, which GraphQL.NET rejects. Rename one of the entity types, or map only one of them.