Skip to content

Narrowly Scoped equality on SelectionSet models #3351

@AnthonyMDev

Description

@AnthonyMDev

The Problem

The generated SelectionSet models conform to Equatable comparing the entire DataDict of the two objects. In most cases, this is the intended behavior, but in some situations, particularly dealing with fragments, this is not the desired behavior.

Consider the following example:

fragment TaxonomyFragment on Animal {
  species
  genus
}

query AnimalQuery {
  animals {
    ...TaxonomyFragment
    ... on Pet {
        petName
    }
  }
}

Assuming a response (concatenated to only relevant data):

"animals" [
  {
    "__typename": "Dog",
    "petName": "Spot",
    "species": "Familiarus",
    "genus": "Canis"
  },
  {
    "__typename": "Dog",
    "petName": "Clifford",
    "species": "Familiarus",
    "genus": "Canis"
  }
]

When comparing two animals returned from this query, you would expect animals[0] == animals[1] to return false. However, when comparing just the TaxonomyFragment using animals[0]taxonomyFragment == animals[1]taxonomyFragment, the expected behavior is unclear.

You could argue that because these fragments are still representing entirely different entities that this should return false. However, if you are only comparing the TaxonomyFragment of each, those fields are equal, and you may expect this to return true. This becomes even more unclear in the (albeit contrived) situation where you are comparing the TaxonomyFragment of two response objects that actually do represent the same entity (same unique ID), but with differing other selected fields, due to being fetched from two different queries. And again, it is unclear how this should behave if TaxonomyFragment was instead an inline fragment (... on Taxonomical), since the generated fragment would then include a merged asPet field that would include petName (assuming you haven't disabled fragment field merging, which is not even implemented yet).

I think that it is more likely that the desired behavior is for animals[0].taxonomyFragment == animals[1].taxonomyFragment to return true in this situation. However, that is not how this not how this works today. Both animals[0] == animals[1] return false and animals[0].taxonomyFragment == animals[1].taxonomyFragment return false because we are comparing the entire underlying DataDict.

Complexities

Upon investigating this issue, we found that the expected behavior (and implementing it) becomes vastly more complex as you begin to consider nested inline fragment type cases (... as XXX); inline fragments with @include/@skip directives, and other complex yet valid queries.

Without giving multiple detailed examples, the summary is, you would need to keep track of each parent fragment of the selection set to be compared, as well as each possible child fragment; and compare all the relevant fields on the object. To be perfectly correct, this would require:

  • including all fields from the object compared
  • including all possible child fields from inline and named fragments on the object compared
  • When generated as a merged selection set spread into an operation:
    • including all direct parent fields
    • omitting all fields from other fragments spread into the parent
  • When generated as a selection set in an isolated named fragment:
    • omitting all fields from parents that are not directly selected by the entity in the fragment

Possible Solutions

While we have the capability to compute this list of fields during code generation, the inclusion of that list would noticeably increase binary size of the generated models for users with large operation sets or complex, deeply nested fragments. Other possible solutions we have considered would provide this information by listing the parent selection sets whose fields need to be compared. This would keep binary size growth to a minimum, but would require a "lightweight execution" that would traverse the selections and compare fields according to the algorithm above (at runtime instead of during code generation). This would likely decrease runtime performance of equality comparisons, especially in complex queries with many inline fragments. Lastly, we could explore using reflection to determine which fields to compare at run time. This would help with the binary size problem, but reflection in Swift is notoriously slow, and we don't feel it's appropriate to regress performance here with reflection in the ==, which should generally run as fast as is computationally possible.

We've determined that implementing this would cause performance and/or binary size regressions; that it is unclear when this is the desired behavior or not; and that in most circumstances, comparing the DataDicts does suffice. This would also be a breaking change of existing behavior. Due to this reasoning we don't believe that it is appropriate to implement this as the default handling of Equatable conformance at this time.

However we recognize that this is necessary behavior in some situations.

Suggested Solution

We propose allowing for the code generation to generate an equality function that compares only the data in the scope of the current fragment on an opt-in, per selection set basis. For the large majority of use cases where this is not necessary, the existing behavior will remain, and when you do need this behavior, you can force the code generation engine to generate it for you with a directive. Naming of this directive is still up for discussion, but something like @generateScopedEqualityFunction could be used.

Because we do not want to create ambiguity in the way that the == operator works, we don't intend for this directive to generate an override of ==. Instead, it should generate another function like func fragmentIsEqual(to:) -> Bool (name also TBD).

In addition, we are going to add some documentation to the existing == implementation to explain the existing semantic meaning behind the Equatable conformance of SelectionSet.

Current Workaround

This issue is being created to track this work, but we don't anticipate working on this new directive immediately. In the mean time, we recommend you work around this when necessary by creating custom implementations of a func fragmentIsEqual(to:) -> Bool manually. (We'd love any feedback on what you decide to name those functions!)

We've heard from a team that was generating == overloads for their entire set of generated models. We believe this is probably not the best approach, as it increases binary size for all of the == functions where this is not actually a problem. Instead we recommend you only create these functions in the specific cases where it is necessary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    codegenIssues related to or arising from code generation

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions