Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ __pycache__/
codekit-config.json
.pycharm_helpers/

# Virtual environment
.venv

# Distribution / packaging
.Python
env/
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# 3.1.0

* The Django OpaqueKeyField subclasses like CourseKeyField now specify a default
max_length of 255 characters.
* The Django OpaqueKeyField subclasses now are case-insensitive on SQLite by
default, to match the behavior they had on MySQL.
* The Django OpaqueKeyField subclasses can now be made case-sensitive by passing
the new case_sensitive=True parameter (recommended).

# 3.0.0

* Breaking: LibraryItemKey is replaced by CollectionKey and ContainerKey
Expand Down
39 changes: 36 additions & 3 deletions opaque_keys/edx/django/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

try:
from django.core.exceptions import ValidationError
from django.db.models import CharField
from django.db.models import CharField, Field
from django.db.models.lookups import IsNull
except ImportError: # pragma: no cover
# Django is unavailable, none of the classes below will work,
Expand Down Expand Up @@ -96,8 +96,9 @@ class OpaqueKeyField(CreatorMixin, CharField):

def __init__(self, *args, **kwargs):
if self.KEY_CLASS is None:
raise ValueError('Must specify KEY_CLASS in OpaqueKeyField subclasses')

raise ValueError('Must specify KEY_CLASS in OpaqueKeyField subclasses') # pragma: no cover
kwargs.setdefault("max_length", 255) # Default max length for all opaque key fields
self.case_sensitive = kwargs.pop("case_sensitive", False) # see self.db_parameters() for details
super().__init__(*args, **kwargs)

def to_python(self, value): # pylint: disable=missing-function-docstring
Expand Down Expand Up @@ -164,6 +165,38 @@ def run_validators(self, value):

return super().run_validators(value)

def db_parameters(self, connection):
"""
Return database parameters for this field. This adds collation info, to
make the key field case-sensitive (optionally).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep seeing PRs about adding/fixing postgres support to the platform, so I wonder if it's worth making the case-insensitivity work on postgres too. At the very least, could you add your postgres note from the PR description here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my research, getting case-insensitivity to work on PostgreSQL is not possible without a migration to create a case-insensitive collation (once, for the whole database), which we could then use. Since this library has no migrations, I don't think we should do that.

I can definitely add the note here though.

Previously, these fields were case-sensitive on SQLite and
case-insensitive on MySQL, which was not ideal. Now they are generally
case-insensitive by default (for backwards compatibility), and
case-sensitive if case_sensitive=True is specified (recommended!).
"""
db_params = Field.db_parameters(self, connection)

if connection.vendor == "sqlite":
db_params["collation"] = "BINARY" if self.case_sensitive else "NOCASE"
elif connection.vendor == "mysql": # pragma: no cover
db_params["collation"] = "utf8mb4_bin" if self.case_sensitive else "utf8mb4_unicode_ci"
# We're using utf8mb4_unicode_ci to keep MariaDB compatibility, since their collation support diverges after
# this. MySQL is now on utf8mb4_0900_ai_ci based on Unicode 9, while MariaDB has uca1400_ai_ci (Unicode 14).

return db_params

def deconstruct(self):
"""
How to serialize our Field for the migration file.

Just add our custom "case_sensitive" field if needed.
"""
name, path, args, kwargs = super().deconstruct()
if self.case_sensitive:
kwargs["case_sensitive"] = True
return name, path, args, kwargs


class OpaqueKeyFieldEmptyLookupIsNull(IsNull):
"""
Expand Down
11 changes: 6 additions & 5 deletions opaque_keys/edx/django/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ class ComplexModel(Model):
"""A Django Model for testing Course/Usage/Location/BlockType Key fields."""

id = CharField(primary_key=True, max_length=255) # pylint: disable=invalid-name
course_key = CourseKeyField(max_length=255, validators=[is_edx])
block_type_key = BlockTypeKeyField(max_length=255, blank=True)
usage_key = UsageKeyField(max_length=255, blank=False)
collection_key = CollectionKeyField(max_length=255, blank=False)
container_key = ContainerKeyField(max_length=255, blank=False)
course_key = CourseKeyField(validators=[is_edx])
course_key_cs = CourseKeyField(case_sensitive=True, max_length=100)
block_type_key = BlockTypeKeyField(blank=True)
usage_key = UsageKeyField(blank=False)
collection_key = CollectionKeyField(blank=False)
container_key = ContainerKeyField(blank=False)
38 changes: 38 additions & 0 deletions opaque_keys/edx/django/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def setUp(self):
self.model = ComplexModel(
id='foobar',
course_key=self.course_key,
course_key_cs=self.course_key,
usage_key=self.usage_key,
collection_key=self.collection_key,
container_key=self.container_key,
Expand All @@ -91,6 +92,43 @@ def test_fetch_from_db_with_str(self):
fetched = ComplexModel.objects.filter(course_key=str(self.course_key)).first()
self.assertEqual(fetched, self.model)

def test_fetch_from_db_with_str_case_insensitive(self):
"""Fetching keys should be case-insensitive by default for backwards compatibility"""
assert str(self.course_key).lower() != str(self.course_key)
fetched = ComplexModel.objects.get(course_key=str(self.course_key).lower())
assert str(fetched.course_key) == str(self.model.course_key) # fetched has the correct capitalization though

def test_fetch_from_db_with_str_case_sensitive(self):
"""Fetching keys should be case-sensitive when case_sensitive=True"""
assert str(self.course_key).lower() != str(self.course_key)
with self.assertRaises(ComplexModel.DoesNotExist):
ComplexModel.objects.get(course_key_cs=str(self.course_key).lower())
# But this works:
fetched = ComplexModel.objects.get(course_key_cs=str(self.course_key))
assert fetched.course_key == self.course_key

def test_custom_max_length(self):
"""
Test that fields can override max_length to be different from the default of 255
"""
key_100 = CourseKey.from_string(
"course-v1:fiftyfiftyfiftyfiftyfiftyfiftyfiftyfiftyfiftyfifty+thishasten+twenty-eight-more-characters",
)
key_101 = CourseKey.from_string(
"course-v1:fiftyfiftyfiftyfiftyfiftyfiftyfiftyfiftyfiftyfifty+thishasten+twenty-eight-more-charactersX",
)
# Note that ComplexModel.course_key_cs specifies max_length=100. So this should work:
assert len(str(key_100)) == 100
self.model.course_key_cs = key_100
self.model.full_clean()
# But this should fail:
assert len(str(key_101)) == 101
self.model.course_key_cs = key_101
with self.assertRaises(ValidationError):
self.model.full_clean()
# Note: we do not test the actual database constraints on length, because SQLite does not enforce it.
# But the Django validation above reflects the same limits, and MySQL should enforce them.

def test_validation_no_errors(self):
self.model.clean_fields()

Expand Down