Compare commits

..

No commits in common. "master" and "v2.5.1" have entirely different histories.

32 changed files with 186 additions and 308 deletions

View file

@ -6,27 +6,20 @@ on:
pull_request:
branches: [ master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
django-version: ['3.2', '4.1', '4.2']
python-version: ['3.7', '3.8', '3.9', '3.10']
django-version: ['2.2', '3.2', '4.0']
exclude:
- python-version: '3.11'
django-version: '3.2'
- python-version: '3.11'
django-version: '4.1'
- python-version: '3.10'
django-version: '2.2'
- python-version: '3.7'
django-version: '4.1'
- python-version: '3.7'
django-version: '4.2'
django-version: '4.0'
services:
redis:
image: redis:6
@ -77,7 +70,7 @@ jobs:
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
sudo apt-get install -y libmemcached-dev zlib1g-dev libpq-dev
sudo apt-get install -y libmemcached-dev zlib1g-dev
python -m pip install --upgrade pip wheel
python -m pip install tox tox-gh-actions coveralls
- name: Tox Test

1
.gitignore vendored
View file

@ -57,7 +57,6 @@ coverage.xml
# Django stuff:
*.log
local_settings.py
*.sqlite3
db.sqlite3
db.sqlite3-journal

View file

@ -1,25 +1,6 @@
Whats new in django-cachalot?
==============================
2.6.0
-----
- Dropped Django 2.2 and 4.0 support
- Added Django 4.2 and Python 3.11 support
- Added psycopg support (#229)
- Updated tests to account for the `BEGIN` and `COMMIT` query changes in Django 4.2
- Standardized django version comparisons in tests
2.5.3
-----
- Verify get_meta isn't none before requesting db_table (#225 #226)
2.5.2
-----
- Added Django 4.1 support (#217)
2.5.1
-----

View file

@ -39,7 +39,7 @@ Table of Contents:
Quickstart
----------
Cachalot officially supports Python 3.7-3.11 and Django 3.2, 4.1, 4.2 with the databases PostgreSQL, SQLite, and MySQL.
Cachalot officially supports Python 3.7-3.10 and Django 2.2, 3.2, and 4.0 with the databases PostgreSQL, SQLite, and MySQL.
Note: an upper limit on Django version is set for your safety. Please do not ignore it.

View file

@ -1,4 +1,10 @@
VERSION = (2, 6, 0)
VERSION = (2, 5, 1)
__version__ = ".".join(map(str, VERSION))
default_app_config = "cachalot.apps.CachalotConfig"
try:
from django import VERSION as DJANGO_VERSION
if DJANGO_VERSION < (3, 2):
default_app_config = "cachalot.apps.CachalotConfig"
except ImportError: # pragma: no cover
default_app_config = "cachalot.apps.CachalotConfig"

View file

@ -1,6 +0,0 @@
from django.contrib import admin
from .models import TestModel
@admin.register(TestModel)
class TestModelAdmin(admin.ModelAdmin):
list_display = ('name', 'owner')

View file

@ -1,52 +0,0 @@
# Generated by Django 4.1.7 on 2023-03-10 19:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.db.models.functions.text
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TestModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=20)),
(
"owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("name",),
},
),
migrations.AddConstraint(
model_name="testmodel",
constraint=models.UniqueConstraint(
fields=["name"],
condition=models.Q(owner=None),
name="unique_name",
),
),
]

View file

@ -1,18 +0,0 @@
from django.conf import settings
from django.db.models import Q, UniqueConstraint, Model, CharField, ForeignKey, SET_NULL
class TestModel(Model):
name = CharField(max_length=20)
owner = ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=SET_NULL)
class Meta:
ordering = ('name',)
constraints = [
UniqueConstraint(
fields=["name"],
condition=Q(owner=None),
name="unique_name",
)
]

View file

@ -1,19 +0,0 @@
from django.test import TestCase
from django.contrib.auth.models import User
from .models import TestModel
from django.test import Client
class AdminTestCase(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create(username='admin', is_staff=True, is_superuser=True)
def test_save_test_model(self):
"""
Model 'TestModel' has UniqueConstraint which caused problems when saving TestModelAdmin in Django >= 4.1
"""
self.client.force_login(self.user)
response = self.client.post('/admin/admin_tests/testmodel/add/', {'name': 'test', 'public': True})
self.assertEqual(response.status_code, 302)
self.assertEqual(TestModel.objects.count(), 1)

View file

@ -1,5 +1,4 @@
from contextlib import contextmanager
from typing import Any, Optional, Tuple, Union
from django.apps import apps
from django.conf import settings
@ -46,11 +45,7 @@ def _get_tables(tables_or_models):
else table_or_model._meta.db_table)
def invalidate(
*tables_or_models: Tuple[Union[str, Any], ...],
cache_alias: Optional[str] = None,
db_alias: Optional[str] = None,
) -> None:
def invalidate(*tables_or_models, **kwargs):
"""
Clears what was cached by django-cachalot implying one or more SQL tables
or models from ``tables_or_models``.
@ -67,9 +62,19 @@ def invalidate(
(or a combination)
:type tables_or_models: tuple of strings or models
:arg cache_alias: Alias from the Django ``CACHES`` setting
:type cache_alias: string or NoneType
:arg db_alias: Alias from the Django ``DATABASES`` setting
:type db_alias: string or NoneType
:returns: Nothing
:rtype: NoneType
"""
# TODO: Replace with positional arguments when we drop Python 2 support.
cache_alias = kwargs.pop('cache_alias', None)
db_alias = kwargs.pop('db_alias', None)
for k in kwargs:
raise TypeError(
"invalidate() got an unexpected keyword argument '%s'" % k)
send_signal = False
invalidated = set()
for cache_alias, db_alias, tables in _cache_db_tables_iterator(
@ -85,11 +90,7 @@ def invalidate(
post_invalidation.send(table, db_alias=db_alias)
def get_last_invalidation(
*tables_or_models: Tuple[Union[str, Any], ...],
cache_alias: Optional[str] = None,
db_alias: Optional[str] = None,
) -> float:
def get_last_invalidation(*tables_or_models, **kwargs):
"""
Returns the timestamp of the most recent invalidation of the given
``tables_or_models``. If ``tables_or_models`` is not specified,
@ -105,9 +106,19 @@ def get_last_invalidation(
(or a combination)
:type tables_or_models: tuple of strings or models
:arg cache_alias: Alias from the Django ``CACHES`` setting
:type cache_alias: string or NoneType
:arg db_alias: Alias from the Django ``DATABASES`` setting
:type db_alias: string or NoneType
:returns: The timestamp of the most recent invalidation
:rtype: float
"""
# TODO: Replace with positional arguments when we drop Python 2 support.
cache_alias = kwargs.pop('cache_alias', None)
db_alias = kwargs.pop('db_alias', None)
for k in kwargs:
raise TypeError("get_last_invalidation() got an unexpected "
"keyword argument '%s'" % k)
last_invalidation = 0.0
for cache_alias, db_alias, tables in _cache_db_tables_iterator(
list(_get_tables(tables_or_models)), cache_alias, db_alias):
@ -123,7 +134,7 @@ def get_last_invalidation(
@contextmanager
def cachalot_disabled(all_queries: bool = False):
def cachalot_disabled(all_queries=False):
"""
Context manager for temporarily disabling cachalot.
If you evaluate the same queryset a second time,
@ -147,6 +158,7 @@ def cachalot_disabled(all_queries: bool = False):
the original and duplicate query.
:arg all_queries: Any query, including already evaluated queries, are re-evaluated.
:type all_queries: bool
"""
was_enabled = getattr(LOCAL_STORAGE, "cachalot_enabled", cachalot_settings.CACHALOT_ENABLED)
LOCAL_STORAGE.cachalot_enabled = False

View file

@ -9,6 +9,8 @@ SUPPORTED_DATABASE_ENGINES = {
'django.db.backends.sqlite3',
'django.db.backends.postgresql',
'django.db.backends.mysql',
# TODO: Remove when we drop Django 2.x support.
'django.db.backends.postgresql_psycopg2',
# GeoDjango
'django.contrib.gis.db.backends.spatialite',
@ -18,6 +20,8 @@ SUPPORTED_DATABASE_ENGINES = {
# django-transaction-hooks
'transaction_hooks.backends.sqlite3',
'transaction_hooks.backends.postgis',
# TODO: Remove when we drop Django 2.x support.
'transaction_hooks.backends.postgresql_psycopg2',
'transaction_hooks.backends.mysql',
# django-prometheus wrapped engines

View file

@ -2,7 +2,7 @@ from django import VERSION as DJANGO_VERSION
from django.conf import settings
from django.contrib.postgres.fields import (
ArrayField, HStoreField, IntegerRangeField,
DateRangeField, DateTimeRangeField, DecimalRangeField)
DateRangeField, DateTimeRangeField)
from django.contrib.postgres.operations import (
HStoreExtension, UnaccentExtension)
from django.db import models, migrations
@ -21,6 +21,19 @@ def extra_regular_available_fields():
def extra_postgres_available_fields():
fields = []
try:
# TODO Remove when Dj31 support is dropped
from django.contrib.postgres.fields import FloatRangeField
fields.append(('float_range', FloatRangeField(null=True, blank=True)))
except ImportError:
pass
try:
# TODO Add to module import when Dj31 is dropped
from django.contrib.postgres.fields import DecimalRangeField
fields.append(('decimal_range', DecimalRangeField(null=True, blank=True)))
except ImportError:
pass
# Future proofing with Django 40 deprecation
if DJANGO_VERSION[0] < 4:
@ -90,7 +103,6 @@ class Migration(migrations.Migration):
('int_range', IntegerRangeField(null=True, blank=True)),
('date_range', DateRangeField(null=True, blank=True)),
('datetime_range', DateTimeRangeField(null=True, blank=True)),
('decimal_range', DecimalRangeField(null=True, blank=True))
] + extra_postgres_available_fields(),
),
migrations.RunSQL('CREATE TABLE cachalot_unmanagedmodel '

View file

@ -58,7 +58,7 @@ class PostgresModel(Model):
null=True, blank=True)
hstore = HStoreField(null=True, blank=True)
if DJANGO_VERSION < (4, 0):
if DJANGO_VERSION[0] < 4:
from django.contrib.postgres.fields import JSONField
json = JSONField(null=True, blank=True)

View file

@ -1,5 +1,6 @@
from unittest import skipIf
from django import VERSION as DJANGO_VERSION
from django.conf import settings
from django.db import DEFAULT_DB_ALIAS, connections, transaction
from django.test import TransactionTestCase
@ -26,6 +27,24 @@ class MultiDatabaseTestCase(TransactionTestCase):
# will execute an extra SQL request below.
connection2.cursor()
def is_django_21_below_and_sqlite2(self):
"""
Note: See test_utils.py with this function name
Checks if Django 2.1 or below and SQLite2
"""
django_version = DJANGO_VERSION
if not self.is_sqlite2:
# Immediately know if SQLite
return False
if django_version[0] < 2:
# Takes Django 0 and 1 out of the picture
return True
else:
if django_version[0] == 2 and django_version[1] < 2:
# Takes Django 2.0-2.1 out
return True
return False
def test_read(self):
with self.assertNumQueries(1):
data1 = list(Test.objects.all())
@ -47,7 +66,8 @@ class MultiDatabaseTestCase(TransactionTestCase):
data1 = list(Test.objects.using(self.db_alias2))
self.assertListEqual(data1, [])
with self.assertNumQueries(1, using=self.db_alias2):
with self.assertNumQueries(2 if self.is_django_21_below_and_sqlite2() else 1,
using=self.db_alias2):
t3 = Test.objects.using(self.db_alias2).create(name='test3')
with self.assertNumQueries(1, using=self.db_alias2):
@ -62,7 +82,8 @@ class MultiDatabaseTestCase(TransactionTestCase):
data1 = list(Test.objects.all())
self.assertListEqual(data1, [self.t1, self.t2])
with self.assertNumQueries(1, using=self.db_alias2):
with self.assertNumQueries(2 if self.is_django_21_below_and_sqlite2() else 1,
using=self.db_alias2):
Test.objects.using(self.db_alias2).create(name='test3')
with self.assertNumQueries(0):

View file

@ -3,7 +3,7 @@ from unittest import skipIf
from uuid import UUID
from decimal import Decimal
from django import VERSION as DJANGO_VERSION
from django import VERSION as django_version
from django.conf import settings
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
@ -21,7 +21,7 @@ from cachalot.cache import cachalot_caches
from ..settings import cachalot_settings
from ..utils import UncachableQuery
from .models import Test, TestChild, TestParent, UnmanagedModel
from .test_utils import TestUtilsMixin, FilteredTransactionTestCase
from .test_utils import TestUtilsMixin
from .tests_decorators import all_final_sql_checks, with_final_sql_check, no_final_sql_check
@ -36,7 +36,7 @@ def is_field_available(name):
return name in fields
class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
class ReadTestCase(TestUtilsMixin, TransactionTestCase):
"""
Tests if every SQL request that only reads data is cached.
@ -52,13 +52,12 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.group__permissions = list(Permission.objects.all()[:3])
self.group.permissions.add(*self.group__permissions)
self.user = User.objects.create_user('user')
self.user__permissions = list(Permission.objects.filter(content_type__app_label='auth')[3:6])
self.user__permissions = list(Permission.objects.all()[3:6])
self.user.groups.add(self.group)
self.user.user_permissions.add(*self.user__permissions)
self.admin = User.objects.create_superuser('admin', 'admin@test.me',
'password')
self.t1__permission = (Permission.objects
.order_by('?')
self.t1__permission = (Permission.objects.order_by('?')
.select_related('content_type')[0])
self.t1 = Test.objects.create(
name='test1', owner=self.user,
@ -353,7 +352,7 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
@all_final_sql_checks
def test_subquery(self):
additional_tables = []
if DJANGO_VERSION >= (4, 0) and DJANGO_VERSION < (4, 1) and settings.CACHALOT_FINAL_SQL_CHECK:
if django_version[0] >= 4 and settings.CACHALOT_FINAL_SQL_CHECK:
# with Django 4.0 comes some query optimalizations that do selects little differently.
additional_tables.append('django_content_type')
qs = Test.objects.filter(owner__in=User.objects.all())
@ -694,7 +693,7 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assert_query_cached(qs)
with self.assertRaisesMessage(
AssertionError if DJANGO_VERSION < (4, 0) else TypeError,
AssertionError if django_version[0] < 4 else TypeError,
'Cannot combine queries on two different base models.'
):
Test.objects.all() | Permission.objects.all()
@ -739,7 +738,7 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assert_query_cached(qs)
with self.assertRaisesMessage(
AssertionError if DJANGO_VERSION < (4, 0) else TypeError,
AssertionError if django_version[0] < 4 else TypeError,
'Cannot combine queries on two different base models.'):
Test.objects.all() & Permission.objects.all()
@ -896,7 +895,7 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assert_query_cached(qs, [self.t2, self.t1])
def test_table_inheritance(self):
with self.assertNumQueries(2):
with self.assertNumQueries(3 if self.is_sqlite else 2):
t_child = TestChild.objects.create(name='test_child')
with self.assertNumQueries(1):
@ -908,13 +907,18 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
def test_explain(self):
explain_kwargs = {}
if self.is_sqlite:
expected = (r'\d+ 0 0 SCAN cachalot_test\n'
expected = (r'\d+ 0 0 SCAN TABLE cachalot_test\n'
r'\d+ 0 0 USE TEMP B-TREE FOR ORDER BY')
elif self.is_mysql:
expected = (
r'-> Sort row IDs: cachalot_test.`name` \(cost=[\d\.]+ rows=\d\)\n '
r'-> Table scan on cachalot_test \(cost=[\d\.]+ rows=\d\)\n'
)
if self.django_version < (3, 1):
expected = (
r'1 SIMPLE cachalot_test '
r'(?:None )?ALL None None None None 2 100\.0 Using filesort')
else:
expected = (
r'-> Sort row IDs: cachalot_test.`name` \(cost=[\d\.]+ rows=\d\)\n '
r'-> Table scan on cachalot_test \(cost=[\d\.]+ rows=\d\)\n'
)
else:
explain_kwargs.update(
analyze=True,
@ -930,7 +934,9 @@ class ReadTestCase(TestUtilsMixin, FilteredTransactionTestCase):
r'Planning Time: [\d\.]+ ms\n'
r'Execution Time: [\d\.]+ ms$') % (operation_detail,
operation_detail)
with self.assertNumQueries(1):
with self.assertNumQueries(
2 if self.is_mysql and django_version[0] < 3
else 1):
explanation1 = Test.objects.explain(**explain_kwargs)
self.assertRegex(explanation1, expected)
with self.assertNumQueries(0):

View file

@ -1,8 +1,6 @@
from django import VERSION as DJANGO_VERSION
from django.core.management.color import no_style
from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from django.db import connection, transaction
from ..utils import _get_tables
from .models import PostgresModel
@ -13,6 +11,7 @@ class TestUtilsMixin:
self.is_sqlite = connection.vendor == 'sqlite'
self.is_mysql = connection.vendor == 'mysql'
self.is_postgresql = connection.vendor == 'postgresql'
self.django_version = DJANGO_VERSION
self.force_reopen_connection()
# TODO: Remove this workaround when this issue is fixed:
@ -20,6 +19,8 @@ class TestUtilsMixin:
def tearDown(self):
if connection.vendor == 'postgresql':
flush_args = [no_style(), (PostgresModel._meta.db_table,),]
if float(".".join(map(str, DJANGO_VERSION[:2]))) < 3.1:
flush_args.append(())
flush_sql_list = connection.ops.sql_flush(*flush_args)
with transaction.atomic():
for sql in flush_sql_list:
@ -60,61 +61,3 @@ class TestUtilsMixin:
assert_function(data2, data1)
if result is not None:
assert_function(data2, result)
class FilteredTransactionTestCase(TransactionTestCase):
"""
TransactionTestCase with assertNumQueries that ignores BEGIN, COMMIT and ROLLBACK
queries.
"""
def assertNumQueries(self, num, func=None, *args, using=DEFAULT_DB_ALIAS, **kwargs):
conn = connections[using]
context = FilteredAssertNumQueriesContext(self, num, conn)
if func is None:
return context
with context:
func(*args, **kwargs)
class FilteredAssertNumQueriesContext(CaptureQueriesContext):
"""
Capture queries and assert their number ignoring BEGIN, COMMIT and ROLLBACK queries.
"""
EXCLUDE = ('BEGIN', 'COMMIT', 'ROLLBACK')
def __init__(self, test_case, num, connection):
self.test_case = test_case
self.num = num
super().__init__(connection)
def __exit__(self, exc_type, exc_value, traceback):
super().__exit__(exc_type, exc_value, traceback)
if exc_type is not None:
return
filtered_queries = []
excluded_queries = []
for q in self.captured_queries:
if q['sql'].upper() not in self.EXCLUDE:
filtered_queries.append(q)
else:
excluded_queries.append(q)
executed = len(filtered_queries)
self.test_case.assertEqual(
executed,
self.num,
f"\n{executed} queries executed, {self.num} expected\n" +
"\nCaptured queries were:\n" +
"".join(
f"{i}. {query['sql']}\n"
for i, query in enumerate(filtered_queries, start=1)
) +
"\nCaptured queries, that were excluded:\n" +
"".join(
f"{i}. {query['sql']}\n"
for i, query in enumerate(excluded_queries, start=1)
)
)

View file

@ -1,10 +1,10 @@
from threading import Thread
from django.db import connection, transaction
from django.test import skipUnlessDBFeature
from django.test import TransactionTestCase, skipUnlessDBFeature
from .models import Test
from .test_utils import TestUtilsMixin, FilteredTransactionTestCase
from .test_utils import TestUtilsMixin
class TestThread(Thread):
@ -19,7 +19,7 @@ class TestThread(Thread):
@skipUnlessDBFeature('test_db_allows_multiple_connections')
class ThreadSafetyTestCase(TestUtilsMixin, FilteredTransactionTestCase):
class ThreadSafetyTestCase(TestUtilsMixin, TransactionTestCase):
def test_concurrent_caching(self):
t1 = TestThread().start_and_join()
t = Test.objects.create(name='test')

View file

@ -1,17 +1,16 @@
from cachalot.transaction import AtomicCache
from django.contrib.auth.models import User
from django.core.cache import cache
from django.db import transaction, connection, IntegrityError
from django.test import SimpleTestCase, skipUnlessDBFeature
from django.test import SimpleTestCase, TransactionTestCase, skipUnlessDBFeature
from .models import Test
from .test_utils import TestUtilsMixin, FilteredTransactionTestCase
from .test_utils import TestUtilsMixin
class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
class AtomicTestCase(TestUtilsMixin, TransactionTestCase):
def test_successful_read_atomic(self):
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
with transaction.atomic():
data1 = list(Test.objects.all())
self.assertListEqual(data1, [])
@ -21,7 +20,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data2, [])
def test_unsuccessful_read_atomic(self):
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
try:
with transaction.atomic():
data1 = list(Test.objects.all())
@ -39,21 +38,21 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
data1 = list(Test.objects.all())
self.assertListEqual(data1, [])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
with transaction.atomic():
t1 = Test.objects.create(name='test1')
with self.assertNumQueries(1):
data2 = list(Test.objects.all())
self.assertListEqual(data2, [t1])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
with transaction.atomic():
t2 = Test.objects.create(name='test2')
with self.assertNumQueries(1):
data3 = list(Test.objects.all())
self.assertListEqual(data3, [t1, t2])
with self.assertNumQueries(3):
with self.assertNumQueries(4 if self.is_sqlite else 3):
with transaction.atomic():
data4 = list(Test.objects.all())
t3 = Test.objects.create(name='test3')
@ -68,7 +67,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
data1 = list(Test.objects.all())
self.assertListEqual(data1, [])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
try:
with transaction.atomic():
Test.objects.create(name='test')
@ -83,7 +82,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
Test.objects.get(name='test')
def test_cache_inside_atomic(self):
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
with transaction.atomic():
data1 = list(Test.objects.all())
data2 = list(Test.objects.all())
@ -91,7 +90,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data2, [])
def test_invalidation_inside_atomic(self):
with self.assertNumQueries(3):
with self.assertNumQueries(4 if self.is_sqlite else 3):
with transaction.atomic():
data1 = list(Test.objects.all())
t = Test.objects.create(name='test')
@ -100,7 +99,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data2, [t])
def test_successful_nested_read_atomic(self):
with self.assertNumQueries(6):
with self.assertNumQueries(7 if self.is_sqlite else 6):
with transaction.atomic():
list(Test.objects.all())
with transaction.atomic():
@ -115,7 +114,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
list(User.objects.all())
def test_unsuccessful_nested_read_atomic(self):
with self.assertNumQueries(5):
with self.assertNumQueries(6 if self.is_sqlite else 5):
with transaction.atomic():
try:
with transaction.atomic():
@ -128,7 +127,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
list(Test.objects.all())
def test_successful_nested_write_atomic(self):
with self.assertNumQueries(12):
with self.assertNumQueries(13 if self.is_sqlite else 12):
with transaction.atomic():
t1 = Test.objects.create(name='test1')
with transaction.atomic():
@ -145,7 +144,7 @@ class AtomicTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data3, [t1, t2, t3, t4])
def test_unsuccessful_nested_write_atomic(self):
with self.assertNumQueries(15):
with self.assertNumQueries(16 if self.is_sqlite else 15):
with transaction.atomic():
t1 = Test.objects.create(name='test1')
try:

View file

@ -1,5 +1,6 @@
from unittest import skipIf, skipUnless
from django import VERSION as DJANGO_VERSION
from django.contrib.auth.models import User, Permission, Group
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import MultipleObjectsReturned
@ -11,10 +12,10 @@ from django.db.models.expressions import RawSQL
from django.test import TransactionTestCase, skipUnlessDBFeature
from .models import Test, TestParent, TestChild
from .test_utils import TestUtilsMixin, FilteredTransactionTestCase
from .test_utils import TestUtilsMixin
class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
class WriteTestCase(TestUtilsMixin, TransactionTestCase):
"""
Tests if every SQL request writing data is not cached and invalidates the
implied data.
@ -55,7 +56,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
data1 = list(Test.objects.all())
self.assertListEqual(data1, [])
with self.assertNumQueries(2):
with self.assertNumQueries(3 if self.is_sqlite else 2):
t, created = Test.objects.get_or_create(name='test')
self.assertTrue(created)
@ -77,14 +78,14 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
with self.assertNumQueries(1):
self.assertListEqual(list(Test.objects.all()), [])
with self.assertNumQueries(4):
with self.assertNumQueries(5 if self.is_sqlite else 4):
t, created = Test.objects.update_or_create(
name='test', defaults={'public': True})
self.assertTrue(created)
self.assertEqual(t.name, 'test')
self.assertEqual(t.public, True)
with self.assertNumQueries(2):
with self.assertNumQueries(3 if self.is_sqlite else 2):
t, created = Test.objects.update_or_create(
name='test', defaults={'public': False})
self.assertFalse(created)
@ -93,7 +94,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
# The number of SQL queries doesnt decrease because update_or_create
# always calls an UPDATE, even when data wasnt changed.
with self.assertNumQueries(2):
with self.assertNumQueries(3 if self.is_sqlite else 2):
t, created = Test.objects.update_or_create(
name='test', defaults={'public': False})
self.assertFalse(created)
@ -108,12 +109,12 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
data1 = list(Test.objects.all())
self.assertListEqual(data1, [])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
unsaved_tests = [Test(name='test%02d' % i) for i in range(1, 11)]
Test.objects.bulk_create(unsaved_tests)
self.assertEqual(Test.objects.count(), 10)
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
unsaved_tests = [Test(name='test%02d' % i) for i in range(1, 11)]
Test.objects.bulk_create(unsaved_tests)
self.assertEqual(Test.objects.count(), 20)
@ -159,12 +160,12 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data1, [t1.name, t2.name])
self.assertListEqual(data2, [t1.name])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
Test.objects.bulk_create([Test(name='test%s' % i)
for i in range(2, 11)])
with self.assertNumQueries(1):
self.assertEqual(Test.objects.count(), 10)
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
Test.objects.all().delete()
with self.assertNumQueries(1):
self.assertEqual(Test.objects.count(), 0)
@ -359,7 +360,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertListEqual(data4, [user1, user2])
self.assertListEqual([u.n for u in data4], [1, 0])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
Test.objects.bulk_create([
Test(name='test3', owner=user1),
Test(name='test4', owner=user2),
@ -587,7 +588,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
data2 = list(Test.objects.select_related('owner'))
self.assertListEqual(data2, [])
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
Test.objects.bulk_create([
Test(name='test1', owner=u1),
Test(name='test2', owner=u2),
@ -601,7 +602,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertEqual(data3[2].owner, u2)
self.assertEqual(data3[3].owner, u1)
with self.assertNumQueries(1):
with self.assertNumQueries(2 if self.is_sqlite else 1):
Test.objects.filter(name__in=['test1', 'test2']).delete()
with self.assertNumQueries(1):
data4 = list(Test.objects.select_related('owner'))
@ -633,7 +634,12 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
self.assertEqual(data3[0].owner, u)
self.assertListEqual(list(data3[0].owner.groups.all()), [])
with self.assertNumQueries(4):
with self.assertNumQueries(
8 if self.is_sqlite and DJANGO_VERSION[0] == 2 and DJANGO_VERSION[1] == 2
else 4 if self.is_postgresql and DJANGO_VERSION[0] > 2
else 4 if self.is_mysql and DJANGO_VERSION[0] > 2
else 6
):
group = Group.objects.create(name='test_group')
permissions = list(Permission.objects.all()[:5])
group.permissions.add(*permissions)
@ -846,7 +852,7 @@ class WriteTestCase(TestUtilsMixin, FilteredTransactionTestCase):
with self.assertRaises(TestChild.DoesNotExist):
TestChild.objects.get()
with self.assertNumQueries(2):
with self.assertNumQueries(3 if self.is_sqlite else 2):
t_child = TestChild.objects.create(name='test_child')
with self.assertNumQueries(1):

View file

@ -35,6 +35,13 @@ CACHABLE_PARAM_TYPES = {
}
UNCACHABLE_FUNCS = {Now, TransactionNow}
try:
# TODO Drop after Dj30 drop
from django.contrib.postgres.fields.jsonb import JsonAdapter
CACHABLE_PARAM_TYPES.update((JsonAdapter,))
except ImportError:
pass
try:
from psycopg2 import Binary
from psycopg2.extras import (
@ -124,7 +131,13 @@ def _find_rhs_lhs_subquery(side):
elif h_class is QuerySet:
return side.query
elif h_class in (Subquery, Exists): # Subquery allows QuerySet & Query
return side.query.query if side.query.__class__ is QuerySet else side.query
try:
return side.query.query if side.query.__class__ is QuerySet else side.query
except AttributeError: # TODO Remove try/except closure after drop Django 2.2
try:
return side.queryset.query
except AttributeError:
return None
elif h_class in UNCACHABLE_FUNCS:
raise UncachableQuery
@ -204,8 +217,7 @@ def _get_tables(db_alias, query, compiler=False):
# Gets all tables already found by the ORM.
tables = set(query.table_map)
if query.get_meta():
tables.add(query.get_meta().db_table)
tables.add(query.get_meta().db_table)
# Gets tables in subquery annotations.
for annotation in query.annotations.values():

View file

@ -11,8 +11,8 @@ Caches your Django ORM queries and automatically invalidates them.
.. image:: http://img.shields.io/pypi/v/django-cachalot.svg?style=flat-square&maxAge=3600
:target: https://pypi.python.org/pypi/django-cachalot
.. image:: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml/badge.svg
:target: https://github.com/noripyt/django-cachalot/actions/workflows/ci.yml
.. image:: http://img.shields.io/travis/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
:target: https://travis-ci.org/noripyt/django-cachalot
.. image:: http://img.shields.io/coveralls/noripyt/django-cachalot/master.svg?style=flat-square&maxAge=3600
:target: https://coveralls.io/r/noripyt/django-cachalot?branch=master

View file

@ -65,9 +65,11 @@ in a multi-processes project, if you use RQ or Celery for instance.
Filebased
.........
Filebased, a simple persistent cache implemented in Django, had a small bug
Filebased, a simple persistent cache implemented in Django, has a small bug
(`#25501 <https://code.djangoproject.com/ticket/25501>`_):
it cannot cache some objects, like psycopg2 ranges. This bug was fixed in 2015, if you sill use an affected Django version and you use range fields from `django.contrib.postgres`, you need to add the tables using range fields
it cannot cache some objects, like psycopg2 ranges.
If you use range fields from `django.contrib.postgres` and your Django
version is affected by this bug, you need to add the tables using range fields
to :ref:`CACHALOT_UNCACHABLE_TABLES`.
.. _MySQL:

View file

@ -4,8 +4,8 @@ Quick start
Requirements
............
- Django 3.2, 4.1, 4.2
- Python 3.7-3.11
- Django 2.2, 3.2, 4.0
- Python 3.7-3.10
- a cache configured as ``'default'`` with one of these backends:
- `django-redis <https://github.com/niwinz/django-redis>`_

View file

@ -1 +1 @@
Django>=3.2,<4.3
Django>=2.2,<4.1

View file

@ -2,8 +2,7 @@
django>=2
psycopg2
psycopg
psycopg2-binary
mysqlclient
django-redis
python-memcached

View file

@ -9,6 +9,6 @@ if __name__ == '__main__':
django.setup()
from django.test.runner import DiscoverRunner
test_runner = DiscoverRunner(verbosity=2, interactive=False)
failures = test_runner.run_tests(['cachalot.tests', 'cachalot.admin_tests'])
failures = test_runner.run_tests(['cachalot.tests'])
if failures:
sys.exit(failures)

View file

@ -1,7 +1,6 @@
import debug_toolbar
from django.urls import path, re_path, include
from django.urls import re_path, include
from django.http import HttpResponse
from django.contrib import admin
def empty_page(request):
@ -11,5 +10,4 @@ def empty_page(request):
urlpatterns = [
re_path(r'^$', empty_page),
re_path(r'^__debug__/', include(debug_toolbar.urls)),
path('admin/', admin.site.urls),
]

View file

@ -12,7 +12,6 @@ DATABASES = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'cachalot',
'USER': 'cachalot',
'PASSWORD': 'password',
'HOST': '127.0.0.1',
},
'mysql': {
@ -91,13 +90,9 @@ elif DEFAULT_CACHE_ALIAS == 'pylibmc':
INSTALLED_APPS = [
'cachalot',
'cachalot.admin_tests',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.postgres', # Enables the unaccent lookup.
'django.contrib.sessions',
'django.contrib.admin',
'django.contrib.messages',
]
MIGRATION_MODULES = {
@ -109,12 +104,6 @@ TEMPLATES = [
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
}
},
{
'BACKEND': 'django.template.backends.jinja2.Jinja2',
@ -127,11 +116,7 @@ TEMPLATES = [
}
]
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
]
MIDDLEWARE = []
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
SECRET_KEY = 'its not important in tests but we have to set it'

View file

@ -25,15 +25,14 @@ setup(
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Framework :: Django :: 2.2',
'Framework :: Django :: 3.2',
'Framework :: Django :: 4.1',
'Framework :: Django :: 4.2',
'Framework :: Django :: 4.0',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Internet :: WWW/HTTP',
],
license='BSD',

20
tox.ini
View file

@ -1,25 +1,22 @@
[tox]
envlist =
py{37,38,39}-django2.2-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{37,38,39,310}-django3.2-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{38,39,310}-django4.1-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{38,39,310,311}-django4.2-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{38,39,310,311}-djangomain-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{38,39,310}-django4.0-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
py{38,39,310}-djangomain-{sqlite3,postgresql,mysql}-{redis,memcached,pylibmc,locmem,filebased},
[testenv]
passenv = *
basepython =
py37: python3.7
py38: python3.8
py39: python3.9
py310: python3.10
py311: python3.11
deps =
django2.2: Django>=2.2,<2.3
django3.2: Django>=3.2,<4.0
django4.1: Django>=4.1,<4.2
django4.2: Django>=4.2,<4.3
django4.0: Django>=4.0,<4.1
djangomain: https://github.com/django/django/archive/main.tar.gz
psycopg2>=2.9.5,<3.0
psycopg
psycopg2-binary>=2.8,<2.9
mysqlclient
django-redis
python-memcached
@ -48,11 +45,10 @@ python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
[gh-actions:env]
DJANGO =
2.2: django2.2
3.2: django3.2
4.1: django4.1
4.2: django4.2
4.0: django4.0
main: djangomain