Skip to content

Commit b283c93

Browse files
committed
Add support for signals
1 parent f76323c commit b283c93

File tree

5 files changed

+280
-3
lines changed

5 files changed

+280
-3
lines changed

docs/features.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,81 @@ Note that a single call to `upsert` results in a single `INSERT INTO ... ON CONF
101101
)
102102

103103
assert obj1.id == obj2.id
104+
105+
# Signals
106+
Django has long supported "signals", a feature that can be really useful. It allows you to get notified in a callback when a certain event occurs. One of the most common use cases is connecting to model signals. These signals get triggered when a model gets saved, or deleted.
107+
108+
Django's built-in signals have one major flaw:
109+
110+
* The `QuerySet.update(..)` method does **not** emit **any** signals.
111+
112+
Because of this limitation, Django's signals cannot be reliably used to be signaled on model changes. `django-postges-extra` adds three new signals which are much more primitive, but work reliably across the board.
113+
114+
The signals defined by this library are completely valid, standard Django signals. Therefore, their documentation also applies: [https://docs.djangoproject.com/en/1.10/topics/signals/](https://docs.djangoproject.com/en/1.10/topics/signals/).
115+
116+
Each of the signals send upon model modification send one parameter containing the value of the primary key of the row that was affected. Therefore the signal's signature looks like this:
117+
118+
def my_receiver(sender, pk: int):
119+
# pk is the primary key, a keyword argument
120+
121+
* `psqlextra.signals.create`
122+
* Send **after** a new model instance was created.
123+
124+
from django.db import models
125+
from psqlextra.models import PostgresModel
126+
from psqlextra import signals
127+
128+
class MyModel(PostgresModel):
129+
myfield = models.CharField(max_length=255, unique=True)
130+
131+
def on_create(sender, **kwargs):
132+
print('model created with pk %d' % kwargs['pk'])
133+
134+
signals.create.connect(MyModel, on_create, weak=False)
135+
136+
# this will trigger the signal
137+
instance = MyModel(myfield='cookies')
138+
instance.save()
139+
140+
# but so will this
141+
MyModel.objects.create(myfield='cheers')
142+
143+
* `psqlextra.signals.update`
144+
* Send **after** a new model instance was updated.
145+
146+
from django.db import models
147+
from psqlextra.models import PostgresModel
148+
from psqlextra import signals
149+
150+
class MyModel(PostgresModel):
151+
myfield = models.CharField(max_length=255, unique=True)
152+
153+
def on_update(sender, **kwargs):
154+
print('model updated with pk %d' % kwargs['pk'])
155+
156+
signals.update.connect(MyModel, on_update, weak=False)
157+
158+
# for every row that is affected, the signal will be send
159+
MyModel.objects.filter(myfield='cookies').update(myfield='cheers')
160+
161+
* `psqlextra.signals.delete`
162+
* Send **before** a new model instance is deleted.
163+
164+
from django.db import models
165+
from psqlextra.models import PostgresModel
166+
from psqlextra import signals
167+
168+
class MyModel(PostgresModel):
169+
myfield = models.CharField(max_length=255, unique=True)
170+
171+
def on_delete(sender, **kwargs):
172+
print('model deleted with pk %d' % kwargs['pk'])
173+
174+
signals.delete.connect(MyModel, on_update, weak=False)
175+
176+
# for every row that is affected, the signal will be send
177+
MyModel.objects.filter(myfield='cookies').delete()
178+
179+
# in this case, a single row is deleted, the signal will be send
180+
# for this particular row
181+
MyModel.objects.get(id=1).delete()

psqlextra/compiler.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
from django.core.exceptions import SuspiciousOperation
2-
from django.db.models.sql.compiler import SQLInsertCompiler
2+
from django.db.models.sql.compiler import SQLInsertCompiler, SQLUpdateCompiler
3+
4+
5+
class PostgresSQLReturningUpdateCompiler(SQLUpdateCompiler):
6+
"""Compiler for SQL UPDATE statements that return
7+
the primary keys of the affected rows."""
8+
9+
def execute_sql(self, _result_type):
10+
sql, params = self.as_sql()
11+
sql += self._form_returning()
12+
13+
with self.connection.cursor() as cursor:
14+
cursor.execute(sql, params)
15+
primary_keys = cursor.fetchall()
16+
17+
return primary_keys
18+
19+
def _form_returning(self):
20+
"""Builds the RETURNING part of the query."""
21+
22+
qn = self.connection.ops.quote_name
23+
return 'RETURNING %s' % qn(self.query.model._meta.pk.name)
324

425

526
class PostgresSQLUpsertCompiler(SQLInsertCompiler):

psqlextra/manager.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,46 @@
33
import django
44
from django.conf import settings
55
from django.core.exceptions import ImproperlyConfigured
6-
from django.db import models
6+
from django.db import models, transaction
7+
from django.db.models.sql import UpdateQuery
8+
from django.db.models.sql.constants import CURSOR
79

8-
from .compiler import PostgresSQLUpsertCompiler
10+
from . import signals
11+
from .compiler import (PostgresSQLReturningUpdateCompiler,
12+
PostgresSQLUpsertCompiler)
913
from .query import PostgresUpsertQuery
1014

1115

16+
class PostgresQuerySet(models.QuerySet):
17+
"""Adds support for PostgreSQL specifics."""
18+
19+
def update(self, **fields):
20+
"""Updates all rows that match the filter."""
21+
22+
# build up the query to execute
23+
self._for_write = True
24+
query = self.query.clone(UpdateQuery)
25+
query._annotations = None
26+
query.add_update_values(fields)
27+
28+
# build the compiler for form the query
29+
connection = django.db.connections[self.db]
30+
compiler = PostgresSQLReturningUpdateCompiler(query, connection, self.db)
31+
32+
# execute the query
33+
with transaction.atomic(using=self.db, savepoint=False):
34+
rows = compiler.execute_sql(CURSOR)
35+
self._result_cache = None
36+
37+
# send out a signal for each row
38+
for row in rows:
39+
signals.update.send(self.model, pk=row[0])
40+
41+
# the original update(..) returns the amount of rows
42+
# affected, let's do the same
43+
return len(rows)
44+
45+
1246
class PostgresManager(models.Manager):
1347
"""Adds support for PostgreSQL specifics."""
1448

@@ -27,6 +61,35 @@ def __init__(self, *args, **kwargs):
2761
'the \'psqlextra.backend\'. Set DATABASES.ENGINE.'
2862
) % db_backend)
2963

64+
# hook into django signals to then trigger our own
65+
66+
def on_model_save(sender, **kwargs):
67+
"""When a model gets created or updated."""
68+
69+
created, instance = kwargs['created'], kwargs['instance']
70+
71+
if created:
72+
signals.create.send(sender, pk=instance.pk)
73+
else:
74+
signals.update.send(sender, pk=instance.pk)
75+
76+
django.db.models.signals.post_save.connect(
77+
on_model_save, sender=self.model, weak=False)
78+
79+
def on_model_delete(sender, **kwargs):
80+
"""When a model gets deleted."""
81+
82+
instance = kwargs['instance']
83+
signals.delete.send(sender, pk=instance.pk)
84+
85+
django.db.models.signals.pre_delete.connect(
86+
on_model_delete, sender=self.model, weak=False)
87+
88+
def get_queryset(self):
89+
"""Gets the query set to be used on this manager."""
90+
91+
return PostgresQuerySet(self.model, using=self._db)
92+
3093
def upsert(self, conflict_target: List, fields: Dict) -> int:
3194
"""Creates a new record or updates the existing one
3295
with the specified data.

psqlextra/signals.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.dispatch import Signal
2+
3+
create = Signal(providing_args=['pk'])
4+
update = Signal(providing_args=['pk'])
5+
delete = Signal(providing_args=['pk'])

tests/test_signals.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from django.db import models
5+
from django.test import TestCase
6+
7+
from psqlextra import signals
8+
9+
from .fake_model import get_fake_model
10+
11+
12+
@pytest.mark.django_db
13+
class SignalsTestCase(TestCase):
14+
"""Tests whether all signals work correctly."""
15+
16+
def mock_signal_handler(self, signal):
17+
"""Creates a new model and attaches the specified
18+
signal to a mocked signal handler.
19+
20+
Returns:
21+
The created model and the mocked signal handler.
22+
"""
23+
24+
model = get_fake_model({
25+
'title': models.CharField(max_length=255)
26+
})
27+
28+
signal_handler = Mock()
29+
signal.connect(signal_handler, sender=model, weak=False)
30+
31+
return model, signal_handler
32+
33+
def test_create(self):
34+
"""Tests whether the create signal is properly emitted
35+
when using QuerySet.create."""
36+
37+
model, signal_handler = self.mock_signal_handler(signals.create)
38+
instance = model.objects.create(title='beer')
39+
40+
assert signal_handler.call_count == 1
41+
assert signal_handler.call_args[1]['pk'] == instance.pk
42+
43+
def test_model_save_create(self):
44+
"""Tests whether the create signal is properly
45+
emitted when using Model.save()."""
46+
47+
model, signal_handler = self.mock_signal_handler(signals.create)
48+
49+
instance = model(title='beer')
50+
instance.save()
51+
52+
assert signal_handler.call_count == 1
53+
assert signal_handler.call_args[1]['pk'] == instance.pk
54+
55+
def test_model_save_update(self):
56+
"""Tests whether the update signal properly
57+
emitted when using Model.save()."""
58+
59+
model, signal_handler = self.mock_signal_handler(signals.update)
60+
61+
instance = model(title='beer')
62+
instance.save() # create
63+
instance.save() # update
64+
65+
assert signal_handler.call_count == 1
66+
assert signal_handler.call_args[1]['pk'] == instance.pk
67+
68+
def test_model_delete(self):
69+
"""Tests whether the delete signal properly
70+
emitted when using Model.delete()."""
71+
72+
model, signal_handler = self.mock_signal_handler(signals.delete)
73+
instance = model.objects.create(title='beer')
74+
instance_pk = instance.pk
75+
instance.delete()
76+
77+
assert signal_handler.call_count == 1
78+
assert signal_handler.call_args[1]['pk'] == instance_pk
79+
80+
def test_query_set_delete(self):
81+
"""Tests whether the delete signal is emitted
82+
for each row that is deleted."""
83+
84+
model, signal_handler = self.mock_signal_handler(signals.delete)
85+
86+
instance_1 = model.objects.create(title='beer')
87+
instance_1_pk = instance_1.pk
88+
instance_2 = model.objects.create(title='more boar')
89+
instance_2_pk = instance_2.pk
90+
91+
model.objects.all().delete()
92+
93+
assert signal_handler.call_count == 2
94+
assert signal_handler.call_args_list[0][1]['pk'] == instance_1_pk
95+
assert signal_handler.call_args_list[1][1]['pk'] == instance_2_pk
96+
97+
def test_query_set_update(self):
98+
"""Tests whether the update signal is emitted
99+
for each row that has been updated."""
100+
101+
model, signal_handler = self.mock_signal_handler(signals.update)
102+
103+
instance_1 = model.objects.create(title='beer')
104+
instance_2 = model.objects.create(title='more boar')
105+
106+
model.objects.all().update(title='cookies')
107+
108+
assert signal_handler.call_count == 2
109+
assert signal_handler.call_args_list[0][1]['pk'] == instance_1.pk
110+
assert signal_handler.call_args_list[1][1]['pk'] == instance_2.pk

0 commit comments

Comments
 (0)