strawberry icon indicating copy to clipboard operation
strawberry copied to clipboard

Object Type Extensions

Open erikwrede opened this issue 1 year ago • 6 comments

Currently, Strawberry lacks a mechanism for extending types with reusable logic, such as pagination, database model mappers for libraries like sqlalchemy, or compatibility extensions like strawberry.experimental.pydantic. Much of the logic necessary for these to work has to be written separately for each use case. Opinionating the way we extend strawberry types could standardize behavior and improve compatibility and maintainability. This issue proposes an ObjectTypeExtension API that provides a foundation to address the mentioned points.

API proposal

ObjectTypeExtensions provide custom functionality, can modify the underlying TypeDefinition or may offer the possibility to implement standardized hooks (more on that later).

class ObjectTypeExtension(ABC):

    def apply(strawberry_type: TypeDefinition):
		"""
		In this method, modifications can be made to TypeDefinition. 
		Custom fields or directives can be added here. It will be called 		 
         after the TypeDefinition is initialized
		"""
		pass

A DjangoModelExtension could use apply to setup all the automatic model fields, similar to a PydanticModelExtension or SQLAlchemyModelExtension

Similar to Field Extensions (#2168 , #2567), ObjectTypeExtension instances are passed to the @strawberry.type annotation:

@strawberry.type(extensions=[DjangoModelExtension(model=CarModel)])
class Car:
	...

will resolve to:

type Car{
	id: ID!
	manufacturer: String!
	model: String!
	doors: Int!
}

Extensions and Polymorphism

Using the annotation to define extensions is favorable over polymorphism of the actual MyType class, as that is a dataclass with resolver logic. The Extensions will provide behavioral logic and extended functionality and are a better fit for StrawberryType. Extensions themselves support polymorphism. The DjangoExtension could natively support OffsetPaginationExtension or RelayPaginationExtension.

Initialization

Extensions are initialized after TypeDefinition initialization. Additionally, we can provide helper methods to make dealing with polymorphic extensions easier:

@dataclass
class TypeDefinition(StrawberrryType):
    extensions: List[ObjectTypeExtension]

    def __post_init__():
    	for extension in self.extensions:
        	extension.apply(self)
   			 ...
    		#rest of current post init

    def get_extension(extension_type: Type[ObjectTypeExtension]):
      # extensions can be polymorphic (DjangoModelExtension can inherit from PaginationExtension...)
      return next(filter(self.extensions, lambda x: isinstance(x, extension_type)))

Interacting with Object Type Extensions

A major API to interact with extensions will be the FieldExtensionAPI. In cases like Pagination, FieldExtensions have a synergy with ObjectTypeExtensions by defining the user-facing pagination logic on the FieldExtension and using the ObjectTypeExtension to actually resolve the data. This way, only one FieldExtension is necessary to implement OffsetPagination, which is compatible with both DjangoModelExtension, SQLAlchemyModelExtension and more:

class DjangoModelExtension(OffsetPaginatableTypeExtension, RelayTypeExtension):
	def __init__(model: DjangoModel)
	def resolve_offset_paginated_items(offset, limit):
		# all the django db logic here
@strawberry.type
class Query
   cars: list[Car] = strawberry.field(extensions=[OffsetPaginatedFieldExtension(default_limit=100)])
   relayed_cars: list[Car] = strawberry.field(extensions=[RelayPaginatedFieldExtension()])

resolves to

type Query   {
	cars(offset: Int, limit: Int =  100): [Car!]!
	relayedCars(
    	before: String
		after: String
		first: Int
		last: Int
  ): CarConnection!
}

using

class OffsetPaginatedFieldExtension(FieldExtension):
   def apply(field: StrawberryField):
		assert isinstance(field.type, StrawberryList)
		# side note: some helpers may be useful here
		listed_type : TypeDefinition = get_nested_type_defintion(field)
		# Returns DjangoModelExtension 	in this case
		self.offset_pagination_extension = listed_type.get_extension(OffsetPaginatableTypeExtension) 
		assert offset_pagination_extension
		# add necessary arguments etc
	
	def resolve(info, offset, limit):
		# shortened
		return self.offset_pagination_extension.resolve_offset_paginated_items(info, offset, limit)

Using this approach streamlines user-facing behavior and helps standardize the internal logic for all extensions of strawberry. Filtering or sorting are other great options to use the combination of FieldExtensions and ObjectTypeExtensions

Decisions

  • [ ] Dealing with type hints We need to decide how to handle automatic database model-derived fields. Should we require strawberry.auto-typed fields on the actual types (similar to the current pydantic extension), or can the user just pass an empty object type? strawberry.auto provides little benefit to the user in case of manual use, as it will not reveal any type information. As such, it might be better to only enfore its use in override-cases (e.g. change the default description, type or alias a model field)

  • [ ] Implement hooks Hooks such as wrap_resolve wrapping the resolver of each object type could provide additonal on-resolve functionality. Cases like unnecessary database calls just to resolve an ID field may be avoided using wrap_resolve by parsing the selection_set before any resolver is actually called. However, their use might be an antipattern to the proposed ObjectTypeExtension + FieldExtension synergy as it's easier and more explicit to implement that using FieldExtensions. My personal preference is to not provide standardized resolve-time hooks and implement that functionality using FieldExtensions instead.

Upvote & Fund

  • We're using Polar.sh so you can upvote and help fund this issue.
  • We receive the funding once the issue is completed & confirmed by you.
  • Thank you in advance for helping prioritize & fund our backlog.
Fund with Polar

erikwrede avatar Mar 02 '23 17:03 erikwrede