Skip to content

Commit 39e876f

Browse files
committed
Mongodb backend.
1 parent 1af5673 commit 39e876f

File tree

7 files changed

+1161
-0
lines changed

7 files changed

+1161
-0
lines changed

django_mongodb_backend/cache.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import pickle
2+
from datetime import datetime, timezone
3+
4+
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
5+
from django.db import connections, router
6+
from django.utils.functional import cached_property
7+
8+
9+
class MongoSerializer:
10+
def __init__(self, protocol=None):
11+
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
12+
13+
def dumps(self, obj):
14+
if isinstance(obj, int):
15+
return obj
16+
try:
17+
return pickle.dumps(obj, self.protocol)
18+
except pickle.PickleError as ex:
19+
raise ValueError("Object cannot be pickled") from ex
20+
21+
def loads(self, data):
22+
try:
23+
return int(data)
24+
except (ValueError, TypeError):
25+
if not isinstance(data, bytes):
26+
raise ValueError("Invalid data type for unpickling") from None
27+
return pickle.loads(data, fix_imports=False) # noqa: S301
28+
29+
30+
class Options:
31+
"""A class that will quack like a Django model _meta class.
32+
33+
This allows cache operations to be controlled by the router
34+
"""
35+
36+
def __init__(self, collection_name):
37+
self.collection_name = collection_name
38+
self.app_label = "django_cache"
39+
self.model_name = "cacheentry"
40+
self.verbose_name = "cache entry"
41+
self.verbose_name_plural = "cache entries"
42+
self.object_name = "CacheEntry"
43+
self.abstract = False
44+
self.managed = True
45+
self.proxy = False
46+
self.swapped = False
47+
48+
49+
class BaseDatabaseCache(BaseCache):
50+
def __init__(self, collection_name, params):
51+
super().__init__(params)
52+
self._collection_name = collection_name
53+
54+
class CacheEntry:
55+
_meta = Options(collection_name)
56+
57+
self.cache_model_class = CacheEntry
58+
59+
60+
class MongoDBCache(BaseDatabaseCache):
61+
def __init__(self, *args, **options):
62+
super().__init__(*args, **options)
63+
64+
@cached_property
65+
def serializer(self):
66+
return MongoSerializer()
67+
68+
@cached_property
69+
def collection(self):
70+
db = router.db_for_read(self.cache_model_class)
71+
return connections[db].get_collection(self._collection_name)
72+
73+
def get(self, key, default=None, version=None):
74+
key = self.make_and_validate_key(key, version=version)
75+
return self.collection.find_one({"key": key}) or default
76+
77+
def get_many(self, keys, version=None):
78+
if not keys:
79+
return {}
80+
keys_map = {self.make_and_validate_key(key, version=version): key for key in keys}
81+
with self.collection.find({"key": {"$in": tuple(keys_map)}}) as cursor:
82+
return {keys_map[row["key"]]: row["value"] for row in cursor}
83+
84+
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
85+
key = self.make_and_validate_key(key, version=version)
86+
serialized_data = self.serializer.dumps(value)
87+
return self.collection.update_one(
88+
{"key": key},
89+
{"key": key, "value": serialized_data, "expire_at": self._get_expiration_time(timeout)},
90+
{"upsert": True},
91+
)
92+
93+
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
94+
key = self.make_and_validate_key(key, version=version)
95+
serialized_data = self.serializer.dumps(value)
96+
try:
97+
self.collection.insert_one(
98+
{
99+
"key": key,
100+
"value": serialized_data,
101+
"expire_at": self._get_expiration_time(timeout),
102+
}
103+
)
104+
except Exception:
105+
# check the exception name to catch when the key exists
106+
return False
107+
return True
108+
109+
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
110+
key = self.make_and_validate_key(key, version=version)
111+
return self.collection.update_one(
112+
{"key": key}, {"$set": {"expire_at": self._get_expiration_time(timeout)}}
113+
)
114+
115+
def _get_expiration_time(self, timeout=None):
116+
timestamp = self.get_backend_timeout(timeout)
117+
if timeout is None:
118+
return None
119+
# do I need to truncate? i don't think so.
120+
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
121+
122+
def delete(self, key, version=None):
123+
return self.delete_many([key], version)
124+
125+
def delete_many(self, keys, version=None):
126+
if not keys:
127+
return False
128+
keys = [self.make_and_validate_key(key, version=version) for key in keys]
129+
return bool(self.collection.delete_many({"key": {"$in": tuple(keys)}}).deleted_count)
130+
131+
def has_key(self, key, version=None):
132+
key = self.make_and_validate_key(key, version=version)
133+
return self.collection.count({"key": key}) > 0
134+
135+
def clear(self):
136+
self.collection.delete_many({})

django_mongodb_backend/management/__init__.py

Whitespace-only changes.

django_mongodb_backend/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from django.conf import settings
2+
from django.core.cache import caches
3+
from django.core.management.base import BaseCommand
4+
from django.db import (
5+
DEFAULT_DB_ALIAS,
6+
connections,
7+
router,
8+
)
9+
10+
from django_mongodb_backend.cache import BaseDatabaseCache
11+
12+
13+
class Command(BaseCommand):
14+
help = "Creates the collections needed to use the MongoDB cache backend."
15+
16+
requires_system_checks = []
17+
18+
def add_arguments(self, parser):
19+
parser.add_argument(
20+
"args",
21+
metavar="collection_name",
22+
nargs="*",
23+
help=(
24+
"Optional collections names. Otherwise, settings.CACHES is used to find "
25+
"cache collections."
26+
),
27+
)
28+
parser.add_argument(
29+
"--database",
30+
default=DEFAULT_DB_ALIAS,
31+
help="Nominates a database onto which the cache collections will be "
32+
'installed. Defaults to the "default" database.',
33+
)
34+
35+
# parser.add_argument(
36+
# "--dry-run",
37+
# action="store_true",
38+
# help="Does not create the table, just prints the SQL that would be run.",
39+
# )
40+
41+
def handle(self, *collection_names, **options):
42+
db = options["database"]
43+
self.verbosity = options["verbosity"]
44+
# dry_run = options["dry_run"]
45+
if collection_names:
46+
# Legacy behavior, collection_name specified as argument
47+
for collection_name in collection_names:
48+
self.check_collection(db, collection_name)
49+
else:
50+
for cache_alias in settings.CACHES:
51+
cache = caches[cache_alias]
52+
if isinstance(cache, BaseDatabaseCache):
53+
self.check_collection(db, cache._collection_name)
54+
55+
def check_collection(self, database, collection_name):
56+
cache = BaseDatabaseCache(collection_name, {})
57+
if not router.allow_migrate_model(database, cache.cache_model_class):
58+
return
59+
connection = connections[database]
60+
61+
if collection_name in connection.introspection.table_names():
62+
if self.verbosity > 0:
63+
self.stdout.write("Cache table '%s' already exists." % collection_name)
64+
return
65+
mongo_col = connection.get_collection(self.collection_name)
66+
mongo_col.create_index("expire_at", expireAfterSeconds=0)
67+
mongo_col.create_index("key", unique=True)

tests/cache_/__init__.py

Whitespace-only changes.

tests/cache_/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.db import models
2+
from django.utils import timezone
3+
4+
5+
def expensive_calculation():
6+
expensive_calculation.num_runs += 1
7+
return timezone.now()
8+
9+
10+
class Poll(models.Model):
11+
question = models.CharField(max_length=200)
12+
answer = models.CharField(max_length=200)
13+
pub_date = models.DateTimeField("date published", default=expensive_calculation)

0 commit comments

Comments
 (0)