diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eeccf1..f5c75a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2020-02-24 +### Added +- Added support for user control over Channel Layer groups in subscriptions and made it the default +### Removed +- Removed `SubscriptionEvent` and `ModelSubscriptionEvent` classes as well as `post_save_subscription` and `post_delete_subscription` signal handlers. + ## [1.0.2] - 2019-12-11 ### Added - Fixed bug causing subscriptions with variables to fail diff --git a/README.md b/README.md index 62ba2c0..fb7bac3 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,14 @@ A plug-and-play GraphQL subscription implementation for Graphene + Django built }) ``` -5. Connect signals for any models you want to create subscriptions for +5. Add `SubscriptionModelMixin` to any models you want to enable subscriptions for ```python - # your_app/signals.py - from django.db.models.signals import post_save, post_delete - from graphene_subscriptions.signals import post_save_subscription, post_delete_subscription + # your_app/models.py + from graphene_subscriptions.models import SubscriptionModelMixin - from your_app.models import YourModel - - post_save.connect(post_save_subscription, sender=YourModel, dispatch_uid="your_model_post_save") - post_delete.connect(post_delete_subscription, sender=YourModel, dispatch_uid="your_model_post_delete") - - # your_app/apps.py - from django.apps import AppConfig - - class YourAppConfig(AppConfig): - name = 'your_app' - - def ready(self): - import your_app.signals + class YourModel(SubscriptionModelMixin, models.Model): + # ... ``` 6. Define your subscriptions and connect them to your project schema @@ -88,15 +76,28 @@ A plug-and-play GraphQL subscription implementation for Graphene + Django built ```python #your_project/schema.py import graphene + from graphene_django.types import DjangoObjectType + + from your_app.models import YourModel + + + class YourModelType(DjangoObjectType): + class Meta: + model = YourModel + - from your_app.graphql.subscriptions import YourSubscription + class YourModelCreatedSubscription(graphene.ObjectType): + your_model_created = graphene.Field(YourModelType) + + def resolve_your_model_created(root, info): + return root.subscribe('yourModelCreated') class Query(graphene.ObjectType): base = graphene.String() - class Subscription(YourSubscription): + class Subscription(YourModelCreatedSubscription): pass @@ -125,143 +126,87 @@ class Subscription(graphene.ObjectType): .map(lambda i: "hello world!") ``` -## Responding to Model Events - -Each subscription that you define will receive a an `Observable` of `SubscriptionEvent`'s as the `root` parameter, which will emit a new `SubscriptionEvent` each time one of the connected signals are fired. - -A `SubscriptionEvent` has two attributes: the `operation` that triggered the event, usually `CREATED`, `UPDATED` or `DELETED`) and the `instance` that triggered the signal. +## Subscribing to Events -Since `root` is an `Observable`, you can apply any `rxpy` operations before returning it. - -### Model Created Subscriptions - -For example, let's create a subscription called `yourModelCreated` that will be fired whenever an instance of `YourModel` is created. Since `root` receives a new event *every time a connected signal is fired*, we'll need to filter for only the events we want. In this case, we want all events where `operation` is `created` and the event `instance` is an instance of our model. +Most of the time you will want your subscriptions to be able to listen for events that occur in other parts of your application. When you define a subscription resolver, you can use the `subscribe` method of the `root` value to subscribe to a set of events. `subscribe` takes a unique group name as an argument, and returns an `Observable` of all events that are sent to that group. Since the return value of `root.subscribe` is an `Observable`, you can apply any `rxpy` operations and return the result. ```python -import graphene -from graphene_django.types import DjangoObjectType -from graphene_subscriptions.events import CREATED - -from your_app.models import YourModel - - -class YourModelType(DjangoObjectType) - class Meta: - model = YourModel - +class CustomSubscription(graphene.ObjectType): + custom_subscription = graphene.String() -class Subscription(graphene.ObjectType): - your_model_created = graphene.Field(YourModelType) - - def resolve_your_model_created(root, info): - return root.filter( - lambda event: - event.operation == CREATED and - isinstance(event.instance, YourModel) - ).map(lambda event: event.instance) + def resolve_custom_subscription(root, info): + return root.subscribe('customSubscription') ``` -### Model Updated Subscriptions - -You can also filter events based on a subscription's arguments. For example, here's a subscription that fires whenever a model is updated: +You can then trigger events from other parts of your application using the `trigger_subscription` helper. `trigger_subscription` takes two arguments: the name of the group to send the event to, and the value to send. Make sure that the value you pass to `trigger_subscription` is compatible with the return type you've defined for your subscription resolver, and is either a Django model or a JSON serializable value. ```python -import graphene -from graphene_django.types import DjangoObjectType -from graphene_subscriptions.events import UPDATED +from graphene_subscriptions.events import trigger_subscription -from your_app.models import YourModel +trigger_subscription('trigger_subscription', 'hello world!') +``` -class YourModelType(DjangoObjectType) - class Meta: - model = YourModel +## Model Events +Often you'll want to define subscriptions that fire when a Django model is created, updated, or deleted. `graphene-subscriptions` includes a handy model mixin that configures the triggering of these events for you. You can use it by configuring your model to inherit from `SubscriptionModelMixin`. -class Subscription(graphene.ObjectType): - your_model_updated = graphene.Field(YourModelType, id=graphene.ID()) +```python +# your_app/models.py +from graphene_subscriptions.models import SubscriptionModelMixin - def resolve_your_model_updated(root, info, id): - return root.filter( - lambda event: - event.operation == UPDATED and - isinstance(event.instance, YourModel) and - event.instance.pk == int(id) - ).map(lambda event: event.instance) +class YourModel(SubscriptionModelMixin, models.Model): + # ... ``` -### Model Updated Subscriptions - -Defining a subscription that is fired whenever a given model instance is deleted can be accomplished like so +`SubscriptionModelMixin` will create unique group names for created, updated, and deleted events based on the name of your model, and will send events to these groups automatically. -```python -import graphene -from graphene_django.types import DjangoObjectType -from graphene_subscriptions.events import DELETED -from your_app.models import YourModel +## Model Created Subscriptions +`SubscriptionModelMixin` automatically sends model created events to a unique group called `"Created"`. For example, if your model is called `YourModel`, then model created events will be sent to the group `"yourModelCreated"`. -class YourModelType(DjangoObjectType) - class Meta: - model = YourModel +You can create a model created subscription that listens for events in this group and returns them to the client by using the `root.subscribe` helper, like so: +```python +class YourModelCreatedSubscription(graphene.ObjectType): + your_model_created = graphene.Field(YourModelType) -class Subscription(graphene.ObjectType): - your_model_deleted = graphene.Field(YourModelType, id=graphene.ID()) - - def resolve_your_model_deleted(root, info, id): - return root.filter( - lambda event: - event.operation == DELETED and - isinstance(event.instance, YourModel) and - event.instance.pk == int(id) - ).map(lambda event: event.instance) + def resolve_your_model_created(root, info): + return root.subscribe('yourModelCreated') ``` -## Custom Events +### Model Updated Subscriptions -Sometimes you need to create subscriptions which responds to events other than Django signals. In this case, you can use the `SubscriptionEvent` class directly. (Note: in order to maintain compatibility with Django channels, all `instance` values must be json serializable) +Much like model created events, `SubscriptionModelMixin` automatically sends model updated events to a group called `"Updated."`. For example, if your model is called `YourModel` and an instance with `pk == 1` is updated, then a model updated event will be sent to the group `"yourModelUpdated.1"`. -For example, a custom event subscription might look like this: +Your subscription resolver can send model updated events from this group to the client by using the `root.subscribe` helper: ```python -import graphene +class YourModelUpdatedSubscription(graphene.ObjectType): + your_model_updated = graphene.Field(YourModelType, id=graphene.String()) -CUSTOM_EVENT = 'custom_event' + def resolve_your_model_updated(root, info, id): + return root.subscribe(f'yourModelUpdated.{id}') +``` -class CustomEventSubscription(graphene.ObjectType): - custom_subscription = graphene.Field(CustomType) - def resolve_custom_subscription(root, info): - return root.filter( - lambda event: - event.operation == CUSTOM_EVENT - ).map(lambda event: event.instance) +### Model Deleted Subscriptions +In a similar manner, `SubscriptionModelMixin` automatically sends model deleted events to a group called `"Deleted."`. For example, if your model is called `YourModel` and an instance with `pk == 1` is deleted, then a model deleted event will be sent to the group `"yourModelDeleted.1"`. -# elsewhere in your app: -from graphene_subscriptions.events import SubscriptionEvent +Your subscription resolver can send model deleted events from this group to the client by using the `root.subscribe` helper: -event = SubscriptionEvent( - operation=CUSTOM_EVENT, - instance= -) +```python +class YourModelDeletedSubscription(graphene.ObjectType): + your_model_deleted = graphene.Field(YourModelType, id=graphene.String()) -event.send() + def resolve_your_model_deleted(root, info, id): + return root.subscribe(f'yourModelDeleted.{id}') ``` -## Production Readiness - -This implementation was spun out of an internal implementation I developed which we've been using in production for the past 6 months at [Jetpack](https://www.tryjetpack.com/). We've had relatively few issues with it, and I am confident that it can be reliably used in production environments. - -However, being a startup, our definition of production-readiness may be slightly different from your own. Also keep in mind that the scale at which we operate hasn't been taxing enough to illuminate where the scaling bottlenecks in this implementation may hide. - -If you end up running this in production, please [reach out](https://twitter.com/jayden_windle) and let me know! - - ## Contributing PRs and other contributions are very welcome! To set up `graphene_subscriptions` in a development envrionment, do the following: diff --git a/graphene_subscriptions/consumers.py b/graphene_subscriptions/consumers.py index e185300..5915e07 100644 --- a/graphene_subscriptions/consumers.py +++ b/graphene_subscriptions/consumers.py @@ -1,22 +1,11 @@ import functools -import json -from django.utils.module_loading import import_string -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from graphene_django.settings import graphene_settings -from graphql import parse from asgiref.sync import async_to_sync -from channels.consumer import SyncConsumer -from channels.exceptions import StopConsumer -from rx import Observable +from channels.generic.websocket import JsonWebsocketConsumer from rx.subjects import Subject -from django.core.serializers import deserialize -from graphene_subscriptions.events import SubscriptionEvent - - -stream = Subject() +from graphene_subscriptions.events import deserialize_value # GraphQL types might use info.context.user to access currently authenticated user. @@ -34,18 +23,28 @@ def get(self, item): return self.data.get(item) -class GraphqlSubscriptionConsumer(SyncConsumer): - def websocket_connect(self, message): - async_to_sync(self.channel_layer.group_add)("subscriptions", self.channel_name) +class GraphqlSubscriptionConsumer(JsonWebsocketConsumer): + groups = {} + + def subscribe(self, name): + stream = Subject() + if name not in self.groups: + self.groups[name] = stream + async_to_sync(self.channel_layer.group_add)(name, self.channel_name) + + return stream - self.send({"type": "websocket.accept", "subprotocol": "graphql-ws"}) + def connect(self): + self.accept("graphql-ws") - def websocket_disconnect(self, message): - self.send({"type": "websocket.close", "code": 1000}) - raise StopConsumer() + def disconnect(self, close_code): + for group in self.groups: + async_to_sync(self.channel_layer.group_discard)( + group, + self.channel_name + ) - def websocket_receive(self, message): - request = json.loads(message["text"]) + def receive_json(self, request): id = request.get("id") if request["type"] == "connection_init": @@ -62,7 +61,7 @@ def websocket_receive(self, message): operation_name=payload.get("operationName"), variables=payload.get("variables"), context=context, - root=stream, + root=self, allow_subscriptions=True, ) @@ -74,24 +73,25 @@ def websocket_receive(self, message): elif request["type"] == "stop": pass - def signal_fired(self, message): - stream.on_next(SubscriptionEvent.from_dict(message["event"])) + def subscription_triggered(self, message): + group = message['group'] + + if group in self.groups: + stream = self.groups[group] + value = deserialize_value(message['value']) + + stream.on_next(value) def _send_result(self, id, result): errors = result.errors - self.send( + self.send_json( { - "type": "websocket.send", - "text": json.dumps( - { - "id": id, - "type": "data", - "payload": { - "data": result.data, - "errors": list(map(str, errors)) if errors else None, - }, - } - ), + "id": id, + "type": "data", + "payload": { + "data": result.data, + "errors": list(map(str, errors)) if errors else None, + }, } ) diff --git a/graphene_subscriptions/events.py b/graphene_subscriptions/events.py index ffafb9e..a68f26d 100644 --- a/graphene_subscriptions/events.py +++ b/graphene_subscriptions/events.py @@ -1,57 +1,16 @@ -import importlib -from django.db import models -from django.core.serializers import serialize, deserialize from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -CREATED = "created" -UPDATED = "updated" -DELETED = "deleted" +from graphene_subscriptions.serialize import serialize_value, deserialize_value -class SubscriptionEvent: - def __init__(self, operation=None, instance=None): - self.operation = operation - self.instance = instance - - def send(self): - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - "subscriptions", {"type": "signal.fired", "event": self.to_dict()} - ) - - def to_dict(self): - return { - "operation": self.operation, - "instance": self.instance, - "__class__": (self.__module__, self.__class__.__name__), +def trigger_subscription(group, value): + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + group, + { + "type": "subscription.triggered", + "value": serialize_value(value), + "group": group } - - @staticmethod - def from_dict(_dict): - module_name, class_name = _dict.get("__class__") - module = importlib.import_module(module_name) - cls = getattr(module, class_name) - - return cls(operation=_dict.get("operation"), instance=_dict.get("instance")) - - -class ModelSubscriptionEvent(SubscriptionEvent): - def __init__(self, operation=None, instance=None): - super(ModelSubscriptionEvent, self).__init__(operation, instance) - - if type(self.instance) == str: - # deserialize django object - self.instance = list(deserialize("json", self.instance))[0].object - - if not isinstance(self.instance, models.Model): - raise ValueError( - "ModelSubscriptionEvent instance value must be a Django model" - ) - - def to_dict(self): - _dict = super(ModelSubscriptionEvent, self).to_dict() - - _dict["instance"] = serialize("json", [self.instance]) - - return _dict + ) diff --git a/graphene_subscriptions/mixins.py b/graphene_subscriptions/mixins.py new file mode 100644 index 0000000..448e83a --- /dev/null +++ b/graphene_subscriptions/mixins.py @@ -0,0 +1,29 @@ +from django_lifecycle import LifecycleModelMixin, hook + +from graphene_subscriptions.events import trigger_subscription + + +class SubscriptionModelMixin(LifecycleModelMixin): + + @hook('after_create') + def trigger_subscription_on_create(self): + model_name = self.__class__.__name__ + model_camel_case = model_name[0].lower() + model_name[1:] + + trigger_subscription(f"{model_camel_case}Created", self) + + + @hook('after_update') + def trigger_subscription_on_update(self): + model_name = self.__class__.__name__ + model_camel_case = model_name[0].lower() + model_name[1:] + + trigger_subscription(f"{model_camel_case}Updated.{self.pk}", self) + + @hook('before_delete') + def trigger_subscription_on_delete(self): + model_name = self.__class__.__name__ + model_camel_case = model_name[0].lower() + model_name[1:] + + trigger_subscription(f"{model_camel_case}Deleted.{self.pk}", self) + pass \ No newline at end of file diff --git a/graphene_subscriptions/serialize.py b/graphene_subscriptions/serialize.py new file mode 100644 index 0000000..24edea0 --- /dev/null +++ b/graphene_subscriptions/serialize.py @@ -0,0 +1,16 @@ +import json +from django.db import models +from django.core.serializers import serialize, deserialize +from django.core.serializers.base import DeserializationError + +def serialize_value(value): + if isinstance(value, models.Model): + return serialize("json", [value]) + + return json.dumps(value) + +def deserialize_value(value): + try: + return list(deserialize("json", value))[0].object + except DeserializationError: + return json.loads(value) diff --git a/graphene_subscriptions/signals.py b/graphene_subscriptions/signals.py deleted file mode 100644 index 645486c..0000000 --- a/graphene_subscriptions/signals.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.db.models.signals import post_save, post_delete - -from graphene_subscriptions.events import ( - ModelSubscriptionEvent, - CREATED, - UPDATED, - DELETED, -) - - -def post_save_subscription(sender, instance, created, **kwargs): - event = ModelSubscriptionEvent( - operation=CREATED if created else UPDATED, instance=instance - ) - event.send() - - -def post_delete_subscription(sender, instance, **kwargs): - event = ModelSubscriptionEvent(operation=DELETED, instance=instance) - event.send() diff --git a/poetry.lock b/poetry.lock index e52e734..362ac23 100644 --- a/poetry.lock +++ b/poetry.lock @@ -193,6 +193,14 @@ asgiref = ">=3.2,<4.0" pytz = "*" sqlparse = ">=0.2.2" +[[package]] +category = "main" +description = "Declarative model lifecycle hooks." +name = "django-lifecycle" +optional = false +python-versions = "*" +version = "0.7.1" + [[package]] category = "dev" description = "Docutils -- Python Documentation Utilities" @@ -727,7 +735,7 @@ version = "4.7.1" setuptools = "*" [metadata] -content-hash = "47ccb1f55bce5c84af5d159342f1e9c8e267877cf6c56ce53a177bc39a6febbc" +content-hash = "f76ba4b86c2b89ceac3afe1a757e94a27a41dba344ea2af622af84041abfd55e" python-versions = ">=3.6" [metadata.hashes] @@ -750,6 +758,7 @@ constantly = ["586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35" cryptography = ["02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c", "1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595", "369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad", "3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651", "44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2", "4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff", "58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d", "6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42", "7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d", "73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e", "7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912", "90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793", "971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13", "a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7", "b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0", "b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879", "d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f", "de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9", "df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2", "ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf", "fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8"] daphne = ["46345f01c550db9a46519ee7143ce17a37bc67888f7c20f3ff0531f7a4d913ba", "5af4d83c8ecf11a4d8ed65ced02d8336d52d90d213dfcd184d07222d36da433c"] django = ["6f857bd4e574442ba35a7172f1397b303167dae964cf18e53db5e85fe248d000", "d98c9b6e5eed147bc51f47c014ff6826bd1ab50b166956776ee13db5a58804ae"] +django-lifecycle = ["9ad17d934993035d92a1be499fca0ca289c102741a842c08d737b75164de1b7d"] docutils = ["6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", "9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", "a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"] filelock = ["18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", "929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"] graphene = ["09165f03e1591b76bf57b133482db9be6dac72c74b0a628d3c93182af9c5a896", "2cbe6d4ef15cfc7b7805e0760a0e5b80747161ce1b0f990dfdc0d2cf497c12f9"] diff --git a/pyproject.toml b/pyproject.toml index bffd8e3..bcfe3cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "graphene_subscriptions" -version = "1.0.2" +version = "1.0.3" description = "A plug-and-play GraphQL subscription implementation for Graphene + Django built using Django Channels." homepage = "https://github.com/jaydenwindle/graphene-subscriptions" repository = "https://github.com/jaydenwindle/graphene-subscriptions" @@ -29,9 +29,10 @@ classifiers = [ [tool.poetry.dependencies] python = ">=3.6" -django = ">=1.11" +django = ">=2.2" channels = "^2.3" graphene-django = "^2.7" +django-lifecycle = "^0.7.1" [tool.poetry.dev-dependencies] pytest = "^5.3" diff --git a/tests/models.py b/tests/models.py index 3fcf5fd..e98c06e 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,5 +1,7 @@ from django.db import models +from graphene_subscriptions.mixins import SubscriptionModelMixin -class SomeModel(models.Model): + +class SomeModel(SubscriptionModelMixin, models.Model): name = models.CharField(max_length=50) diff --git a/tests/schema.py b/tests/schema.py index e9da1d7..e073175 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -2,8 +2,6 @@ from graphene_django.types import DjangoObjectType from rx import Observable -from graphene_subscriptions.events import CREATED, UPDATED, DELETED - from tests.models import SomeModel @@ -19,48 +17,34 @@ class SomeModelCreatedSubscription(graphene.ObjectType): some_model_created = graphene.Field(SomeModelType) def resolve_some_model_created(root, info): - return root.filter( - lambda event: event.operation == CREATED - and isinstance(event.instance, SomeModel) - ).map(lambda event: event.instance) + return root.subscribe('someModelCreated') class SomeModelUpdatedSubscription(graphene.ObjectType): - some_model_updated = graphene.Field(SomeModelType, id=graphene.ID()) + some_model_updated = graphene.Field(SomeModelType, id=graphene.String()) def resolve_some_model_updated(root, info, id): - return root.filter( - lambda event: event.operation == UPDATED - and isinstance(event.instance, SomeModel) - and event.instance.pk == int(id) - ).map(lambda event: event.instance) - + return root.subscribe(f'someModelUpdated.{id}') class SomeModelDeletedSubscription(graphene.ObjectType): - some_model_deleted = graphene.Field(SomeModelType, id=graphene.ID()) + some_model_deleted = graphene.Field(SomeModelType, id=graphene.String()) def resolve_some_model_deleted(root, info, id): - return root.filter( - lambda event: event.operation == DELETED - and isinstance(event.instance, SomeModel) - and event.instance.pk == int(id) - ).map(lambda event: event.instance) + return root.subscribe(f'someModelDeleted.{id}') -class CustomEventSubscription(graphene.ObjectType): +class CustomSubscription(graphene.ObjectType): custom_subscription = graphene.String() def resolve_custom_subscription(root, info): - return root.filter(lambda event: event.operation == CUSTOM_EVENT).map( - lambda event: event.instance - ) + return root.subscribe('customSubscription') class Subscription( - CustomEventSubscription, + CustomSubscription, SomeModelCreatedSubscription, SomeModelUpdatedSubscription, - SomeModelDeletedSubscription, + SomeModelDeletedSubscription ): hello = graphene.String() diff --git a/tests/test_model_subscriptions.py b/tests/test_model_subscriptions.py index d8c8d5e..8344065 100644 --- a/tests/test_model_subscriptions.py +++ b/tests/test_model_subscriptions.py @@ -1,230 +1,219 @@ -import time import pytest import asyncio -from django.test import override_settings +import graphene + +from django.test import override_settings from django.db.models.signals import post_save, post_delete from channels.testing import WebsocketCommunicator -from channels.layers import get_channel_layer from asgiref.sync import sync_to_async -from graphene_django.settings import graphene_settings +from channels.db import database_sync_to_async +from channels.layers import get_channel_layer from graphene_subscriptions.consumers import GraphqlSubscriptionConsumer -from graphene_subscriptions.events import SubscriptionEvent -from graphene_subscriptions.signals import ( - post_delete_subscription, - post_save_subscription, -) +from graphene_subscriptions.events import trigger_subscription, serialize_value from tests.models import SomeModel -from tests.schema import CUSTOM_EVENT -async def query(query, communicator, variables=None): +async def subscribe(query, variables=None): + communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") + connected, subprotocol = await communicator.connect() + assert connected + await communicator.send_json_to( {"id": 1, "type": "start", "payload": {"query": query, "variables": variables}} ) + return communicator + @pytest.mark.asyncio @pytest.mark.django_db async def test_consumer_schema_execution_works(): - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected - - subscription = """ + query = """ subscription { hello } """ - await query(subscription, communicator) + subscription = await subscribe(query) - response = await communicator.receive_json_from() + response = await subscription.receive_json_from() assert response["payload"] == {"data": {"hello": "hello world!"}, "errors": None} + await subscription.disconnect() @pytest.mark.asyncio @pytest.mark.django_db -async def test_model_created_subscription_succeeds(): - post_save.connect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_save" - ) +async def test_custom_subscription_works(): + query = """ + subscription { + customSubscription + } + """ - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected + subscription = await subscribe(query) - subscription = """ + await asyncio.sleep(0.01) + + await sync_to_async(trigger_subscription)('customSubscription', 'success') + + response = await subscription.receive_json_from() + + assert response['payload']['data']['customSubscription'] == 'success' + + await subscription.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_model_created_subscription(): + query = """ subscription { someModelCreated { + id name } } """ - await query(subscription, communicator) + subscription = await subscribe(query) - s = await sync_to_async(SomeModel.objects.create)(name="test name") + await asyncio.sleep(0.01) - response = await communicator.receive_json_from() + instance = await database_sync_to_async(SomeModel.objects.create)(name="test 123") - assert response["payload"] == { - "data": {"someModelCreated": {"name": s.name}}, - "errors": None, - } + response = await subscription.receive_json_from() - post_save.disconnect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_save" - ) + assert response['payload']['data']['someModelCreated']['id'] == str(instance.pk) + assert response['payload']['data']['someModelCreated']['name'] == "test 123" + + await subscription.disconnect() @pytest.mark.asyncio @pytest.mark.django_db -async def test_model_updated_subscription_succeeds(): - post_save.connect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_delete" - ) - - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected - - s = await sync_to_async(SomeModel.objects.create)(name="test name") - - subscription = ( - """ - subscription { - someModelUpdated(id: %d) { +async def test_model_updated_subscription(): + query = """ + subscription SomeModelUpdated($id: String) { + someModelUpdated(id: $id) { + id name } } """ - % s.pk - ) - - await query(subscription, communicator) - await sync_to_async(s.save)() + instance = await database_sync_to_async(SomeModel.objects.create)(name="test 123") - response = await communicator.receive_json_from() + subscription = await subscribe(query, { "id": instance.pk }) - assert response["payload"] == { - "data": {"someModelUpdated": {"name": s.name}}, - "errors": None, - } + await asyncio.sleep(0.01) - post_save.disconnect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_delete" - ) + instance.name = "test 234" + await database_sync_to_async(instance.save)() + response = await subscription.receive_json_from() -@pytest.mark.asyncio -@pytest.mark.django_db -async def test_model_deleted_subscription_succeeds(): - post_delete.connect( - post_delete_subscription, - sender=SomeModel, - dispatch_uid="some_model_post_delete", - ) + assert response['payload']['data']['someModelUpdated']['id'] == str(instance.pk) + assert response['payload']['data']['someModelUpdated']['name'] == "test 234" - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected + await subscription.disconnect() - s = await sync_to_async(SomeModel.objects.create)(name="test name") - subscription = ( - """ - subscription { - someModelDeleted(id: %d) { +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_model_deleted_subscription(): + query = """ + subscription SomeModelDeleted($id: String) { + someModelDeleted(id: $id) { + id name } } """ - % s.pk - ) + instance = await database_sync_to_async(SomeModel.objects.create)(name="test 123") - await query(subscription, communicator) + subscription = await subscribe(query, { "id": instance.pk }) - await sync_to_async(s.delete)() + await asyncio.sleep(0.01) - response = await communicator.receive_json_from() + instance_pk = instance.pk - assert response["payload"] == { - "data": {"someModelDeleted": {"name": s.name}}, - "errors": None, - } + await database_sync_to_async(instance.delete)() - post_delete.disconnect( - post_delete_subscription, - sender=SomeModel, - dispatch_uid="some_model_post_delete", - ) + response = await subscription.receive_json_from() + assert response['payload']['data']['someModelDeleted']['id'] == str(instance_pk) + assert response['payload']['data']['someModelDeleted']['name'] == "test 123" -@pytest.mark.asyncio -@pytest.mark.django_db -async def test_model_subscription_with_variables_succeeds(): - post_save.connect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_delete" - ) - - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected + await subscription.disconnect() - s = await sync_to_async(SomeModel.objects.create)(name="test name") - subscription = """ - subscription SomeModelUpdated($id: ID){ +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_model_updated_subscription_no_conflicts(): + query = """ + subscription SomeModelUpdated($id: String) { someModelUpdated(id: $id) { + id name } } """ - await query(subscription, communicator, { "id": s.pk }) + instance1 = await database_sync_to_async(SomeModel.objects.create)(name="instance 1") + instance2 = await database_sync_to_async(SomeModel.objects.create)(name="instance 2") - await sync_to_async(s.save)() + subscription = await subscribe(query, { "id": instance1.pk }) - response = await communicator.receive_json_from() + await asyncio.sleep(0.01) - assert response["payload"] == { - "data": {"someModelUpdated": {"name": s.name}}, - "errors": None, - } + instance1.name = "instance 1 rules" + await database_sync_to_async(instance1.save)() - post_save.disconnect( - post_save_subscription, sender=SomeModel, dispatch_uid="some_model_post_delete" - ) + response = await subscription.receive_json_from() + + assert response['payload']['data']['someModelUpdated']['id'] == str(instance1.pk) + assert response['payload']['data']['someModelUpdated']['name'] == "instance 1 rules" + + instance2.name = "instance 2 drools" + await database_sync_to_async(instance2.save)() + + assert await subscription.receive_nothing() + + await subscription.disconnect() @pytest.mark.asyncio @pytest.mark.django_db -async def test_custom_event_subscription_succeeds(): - communicator = WebsocketCommunicator(GraphqlSubscriptionConsumer, "/graphql/") - connected, subprotocol = await communicator.connect() - assert connected - - subscription = """ - subscription { - customSubscription +async def test_model_deleted_subscription_no_conflicts(): + query = """ + subscription SomeModelDeleted($id: String) { + someModelDeleted(id: $id) { + id + name + } } """ + instance1 = await database_sync_to_async(SomeModel.objects.create)(name="instance 1") + instance1_pk = instance1.pk + instance2 = await database_sync_to_async(SomeModel.objects.create)(name="instance 2") + instance2_pk = instance2.pk + + subscription = await subscribe(query, { "id": instance1_pk }) + + await asyncio.sleep(0.01) - await query(subscription, communicator) + await database_sync_to_async(instance1.delete)() - time.sleep(0.5) # not sure why this is needed + response = await subscription.receive_json_from() - event = SubscriptionEvent(operation=CUSTOM_EVENT, instance="some value") + assert response['payload']['data']['someModelDeleted']['id'] == str(instance1_pk) + assert response['payload']['data']['someModelDeleted']['name'] == "instance 1" - await sync_to_async(event.send)() + await database_sync_to_async(instance2.delete)() - response = await communicator.receive_json_from() + assert await subscription.receive_nothing() - assert response["payload"] == { - "data": {"customSubscription": "some value"}, - "errors": None, - } + await subscription.disconnect() \ No newline at end of file diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..8e74a6f --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,22 @@ +import pytest + +from graphene_subscriptions.events import serialize_value, deserialize_value +from tests.models import SomeModel + +def test_serialize_deserialize_model(): + instance = SomeModel(name="test") + + serialized = serialize_value(instance) + + deserialized = deserialize_value(serialized) + + assert isinstance(deserialized, SomeModel) + assert deserialized.name == "test" + +def test_serialize_deserialize_value(): + assert deserialize_value(serialize_value(1)) == 1 + assert deserialize_value(serialize_value("string")) == "string" + assert deserialize_value(serialize_value(1.1)) == 1.1 + assert deserialize_value(serialize_value([1, 2])) == [1, 2] + assert deserialize_value(serialize_value({"hello": "world"})) == {"hello": "world"} + \ No newline at end of file