-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add ModelTopicConsumer and DbzModelTopicConsumer, refs #10
- Loading branch information
1 parent
1d95646
commit d1084e6
Showing
9 changed files
with
382 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
from unittest import mock | ||
|
||
from django.db.models import Model | ||
from django.test import TestCase | ||
|
||
from django_kafka.exceptions import DjangoKafkaError | ||
from django_kafka.topic.debezium import DbzModelTopicConsumer | ||
|
||
|
||
class TestDbzModelTopicConsumer(TestCase): | ||
def _get_model_topic_consumer(self) -> DbzModelTopicConsumer: | ||
class SomeModelTopicConsumer(DbzModelTopicConsumer): | ||
name = "name" | ||
model = Model | ||
|
||
return SomeModelTopicConsumer() | ||
|
||
def test_get_model__uses_reroute_model_map(self): | ||
mock_model = mock.Mock() | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.reroute_model_map = {"topic": mock_model} | ||
topic_consumer.reroute_key_field = "table_identifier_key" | ||
|
||
self.assertEqual( | ||
topic_consumer.get_model({"table_identifier_key": "topic"}, {}), | ||
mock_model, | ||
) | ||
|
||
def test_get_model__uses_direct_model(self): | ||
mock_model = mock.Mock() | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.model = mock_model | ||
topic_consumer.reroute_model_map = None | ||
|
||
self.assertEqual(topic_consumer.get_model({}, {}), mock_model) | ||
|
||
def test_get_model__raises_when_nothing_set(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.model = None | ||
topic_consumer.reroute_model_map = None | ||
|
||
with self.assertRaises(DjangoKafkaError): | ||
topic_consumer.get_model({}, {}) | ||
|
||
def test_get_model__raises_when_reroute_key_present(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.model = Model | ||
topic_consumer.reroute_model_map = None | ||
topic_consumer.reroute_key_field = "table_identifier_key" | ||
|
||
with self.assertRaises(DjangoKafkaError): | ||
topic_consumer.get_model({"table_identifier_key": "table"}, {}) | ||
|
||
def test_check_deletion(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
|
||
self.assertEqual( | ||
topic_consumer.check_deletion(Model, {}, {}), | ||
False, | ||
) | ||
self.assertEqual( | ||
topic_consumer.check_deletion(Model, {}, {"__deleted": False}), | ||
False, | ||
) | ||
self.assertEqual( | ||
topic_consumer.check_deletion( | ||
Model, | ||
{}, | ||
{"name": "name", "__deleted": True}, | ||
), | ||
True, | ||
) | ||
self.assertEqual(topic_consumer.check_deletion(Model, {}, None), True) | ||
|
||
def test_get_lookup_kwargs(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
mock_model = mock.Mock(**{"_meta.pk.name": "identity_key"}) | ||
|
||
self.assertEqual( | ||
topic_consumer.get_lookup_kwargs(mock_model, {"identity_key": 1}, {}), | ||
{"identity_key": 1}, | ||
) | ||
|
||
def test_get_lookup_kwargs__raises_when_pk_not_present(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
mock_model = mock.Mock(**{"_meta.pk.name": "identity_key"}) | ||
|
||
with self.assertRaises(KeyError): | ||
topic_consumer.get_lookup_kwargs(mock_model, {"something": 1}, {}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
from unittest import mock | ||
|
||
from django.db.models import Model | ||
from django.test import TestCase | ||
|
||
from django_kafka.exceptions import DjangoKafkaError | ||
from django_kafka.models import KafkaSkipMixin | ||
from django_kafka.topic.model import ModelTopicConsumer | ||
|
||
|
||
class TestModelTopicConsumer(TestCase): | ||
def _get_model_topic_consumer(self): | ||
class SomeModelTopicConsumer(ModelTopicConsumer): | ||
name = "name" | ||
model = Model | ||
|
||
def get_lookup_kwargs(self, model, key, value) -> dict: | ||
return {} | ||
|
||
def check_deletion(self, *args, **kwargs): | ||
return False | ||
|
||
return SomeModelTopicConsumer() | ||
|
||
def test_get_defaults(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
|
||
defaults = topic_consumer.get_defaults(model=Model, value={"name": 1}) | ||
|
||
self.assertEqual(defaults, {"name": 1}) | ||
|
||
def test_get_defaults__adds_kafka_skip(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
|
||
class KafkaSkip(KafkaSkipMixin): | ||
pass | ||
|
||
defaults = topic_consumer.get_defaults(model=KafkaSkip, value={"name": 1}) | ||
|
||
self.assertEqual(defaults, {"name": 1, "kafka_skip": True}) | ||
|
||
def test_get_defaults__calls_transform_attr(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.transform_name = mock.Mock(return_value=("name_new", 2)) | ||
|
||
defaults = topic_consumer.get_defaults(model=Model, value={"name": 1}) | ||
|
||
topic_consumer.transform_name.assert_called_once_with( | ||
topic_consumer.model, | ||
"name", | ||
1, | ||
) | ||
self.assertEqual(defaults, {"name_new": 2}) | ||
|
||
def test_sync(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.get_lookup_kwargs = mock.Mock(return_value={"id": "id"}) | ||
topic_consumer.get_defaults = mock.Mock(return_value={"name": "name"}) | ||
model = mock.Mock() | ||
|
||
topic_consumer.sync(model, {"key": "key"}, {"value": "value"}) | ||
|
||
topic_consumer.get_lookup_kwargs.assert_called_once_with( | ||
model, | ||
{"key": "key"}, | ||
{"value": "value"}, | ||
) | ||
topic_consumer.get_defaults.assert_called_once_with( | ||
model, | ||
{"value": "value"}, | ||
) | ||
model.objects.update_or_create.assert_called_once_with( | ||
id="id", | ||
defaults={"name": "name"}, | ||
) | ||
|
||
def test_sync__deleted(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.get_lookup_kwargs = mock.Mock(return_value={"id": "id"}) | ||
topic_consumer.get_defaults = mock.Mock() | ||
topic_consumer.check_deletion = mock.Mock(return_value=True) | ||
model = mock.Mock() | ||
|
||
topic_consumer.sync(model, {"key": "key"}, {"value": "value"}) | ||
|
||
topic_consumer.get_lookup_kwargs.assert_called_once_with( | ||
model, | ||
{"key": "key"}, | ||
{"value": "value"}, | ||
) | ||
topic_consumer.get_defaults.assert_not_called() | ||
model.objects.get.assert_called_once_with(id="id") | ||
model.objects.get.return_value.delete.assert_called_once() | ||
model.objects.update_or_create.assert_not_called() | ||
|
||
def test_get_model(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.model = mock.Mock() | ||
|
||
self.assertEqual( | ||
topic_consumer.get_model({}, {}), | ||
topic_consumer.model, | ||
) | ||
|
||
def test_get_model__raises_when_model_not_set(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.model = None | ||
|
||
with self.assertRaises(DjangoKafkaError): | ||
topic_consumer.get_model({}, {}) | ||
|
||
def test_consume(self): | ||
topic_consumer = self._get_model_topic_consumer() | ||
topic_consumer.get_model = mock.Mock() | ||
msg_key = {"key": "key"} | ||
msg_value = {"value": "value"} | ||
topic_consumer.deserialize = mock.Mock(side_effect=[msg_key, msg_value]) | ||
topic_consumer.sync = mock.Mock() | ||
|
||
topic_consumer.consume(mock.Mock()) | ||
|
||
topic_consumer.get_model.assert_called_once_with(msg_key, msg_value) | ||
topic_consumer.sync.assert_called_once_with( | ||
topic_consumer.get_model.return_value, | ||
msg_key, | ||
msg_value, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
from abc import ABC | ||
from typing import Optional, Type | ||
|
||
from django.db.models import Model | ||
|
||
from django_kafka.exceptions import DjangoKafkaError | ||
from django_kafka.topic.model import ModelTopicConsumer | ||
|
||
|
||
class DbzModelTopicConsumer(ModelTopicConsumer, ABC): | ||
"""Syncs a debezium source connector topic directly in to Django model instances""" | ||
|
||
reroute_key_field = "__dbz__physicalTableIdentifier" | ||
reroute_model_map: Optional[dict[str, Type[Model]]] = None | ||
|
||
def get_model(self, key, value) -> Type[Model]: | ||
if self.reroute_key_field in key: | ||
if not self.reroute_model_map: | ||
raise DjangoKafkaError( | ||
f"To obtain the correct model, reroute_model_map must be set when " | ||
f"`{self.reroute_key_field}` is present in the message key", | ||
) | ||
table = key[self.reroute_key_field] | ||
if table not in self.reroute_model_map: | ||
raise DjangoKafkaError(f"Unrecognised rerouted topic `{table}`") | ||
return self.reroute_model_map[table] | ||
return super().get_model(key, value) | ||
|
||
def check_deletion(self, model, key, value) -> bool: | ||
return value is None or value.pop("__deleted", False) | ||
|
||
def get_lookup_kwargs(self, model, key, value) -> dict: | ||
pk_field = model._meta.pk.name | ||
return {pk_field: key[pk_field]} |
Oops, something went wrong.