From ed7821231b7dbf34a6c8ca65be3b9bcbda4a0703 Mon Sep 17 00:00:00 2001 From: Marc Tamlyn Date: Tue, 15 Jul 2014 10:35:29 +0100 Subject: [PATCH] Fixed #19463 -- Added UUIDField Uses native support in postgres, and char(32) on other backends. --- django/db/backends/mysql/base.py | 8 ++ django/db/backends/mysql/creation.py | 1 + django/db/backends/oracle/base.py | 8 ++ django/db/backends/oracle/creation.py | 1 + .../db/backends/postgresql_psycopg2/base.py | 2 + .../backends/postgresql_psycopg2/creation.py | 1 + django/db/backends/sqlite3/base.py | 10 ++- django/db/backends/sqlite3/creation.py | 1 + django/db/models/fields/__init__.py | 43 +++++++++ django/forms/fields.py | 25 +++++- docs/ref/databases.txt | 3 +- docs/ref/forms/fields.txt | 14 +++ docs/ref/models/fields.txt | 25 ++++++ docs/releases/1.8.txt | 10 +++ tests/forms_tests/tests/test_fields.py | 24 ++++- tests/model_fields/models.py | 13 +++ tests/model_fields/test_uuid.py | 89 +++++++++++++++++++ 17 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 tests/model_fields/test_uuid.py diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index 69084aaa62b..aefe4262e6e 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -8,6 +8,7 @@ from __future__ import unicode_literals import datetime import re import sys +import uuid import warnings try: @@ -398,6 +399,8 @@ class DatabaseOperations(BaseDatabaseOperations): converters = super(DatabaseOperations, self).get_db_converters(internal_type) if internal_type in ['BooleanField', 'NullBooleanField']: converters.append(self.convert_booleanfield_value) + if internal_type == 'UUIDField': + converters.append(self.convert_uuidfield_value) return converters def convert_booleanfield_value(self, value, field): @@ -405,6 +408,11 @@ class DatabaseOperations(BaseDatabaseOperations): value = bool(value) return value + def convert_uuidfield_value(self, value, field): + if value is not None: + value = uuid.UUID(value) + return value + class DatabaseWrapper(BaseDatabaseWrapper): vendor = 'mysql' diff --git a/django/db/backends/mysql/creation.py b/django/db/backends/mysql/creation.py index 565b1fd9a19..fd3a595eb48 100644 --- a/django/db/backends/mysql/creation.py +++ b/django/db/backends/mysql/creation.py @@ -30,6 +30,7 @@ class DatabaseCreation(BaseDatabaseCreation): 'SmallIntegerField': 'smallint', 'TextField': 'longtext', 'TimeField': 'time', + 'UUIDField': 'char(32)', } def sql_table_creation_suffix(self): diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index 17457dfcea9..e48c82f0d85 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -10,6 +10,7 @@ import decimal import re import platform import sys +import uuid import warnings @@ -264,6 +265,8 @@ WHEN (new.%(col_name)s IS NULL) converters.append(self.convert_datefield_value) elif internal_type == 'TimeField': converters.append(self.convert_timefield_value) + elif internal_type == 'UUIDField': + converters.append(self.convert_uuidfield_value) converters.append(self.convert_empty_values) return converters @@ -310,6 +313,11 @@ WHEN (new.%(col_name)s IS NULL) value = value.time() return value + def convert_uuidfield_value(self, value, field): + if value is not None: + value = uuid.UUID(value) + return value + def deferrable_sql(self): return " DEFERRABLE INITIALLY DEFERRED" diff --git a/django/db/backends/oracle/creation.py b/django/db/backends/oracle/creation.py index 4b9146da7bc..ef0156bc18d 100644 --- a/django/db/backends/oracle/creation.py +++ b/django/db/backends/oracle/creation.py @@ -44,6 +44,7 @@ class DatabaseCreation(BaseDatabaseCreation): 'TextField': 'NCLOB', 'TimeField': 'TIMESTAMP', 'URLField': 'VARCHAR2(%(max_length)s)', + 'UUIDField': 'VARCHAR2(32)', } data_type_check_constraints = { diff --git a/django/db/backends/postgresql_psycopg2/base.py b/django/db/backends/postgresql_psycopg2/base.py index 4cd327a22bc..975eee4df2e 100644 --- a/django/db/backends/postgresql_psycopg2/base.py +++ b/django/db/backends/postgresql_psycopg2/base.py @@ -22,6 +22,7 @@ from django.utils.timezone import utc try: import psycopg2 as Database import psycopg2.extensions + import psycopg2.extras except ImportError as e: from django.core.exceptions import ImproperlyConfigured raise ImproperlyConfigured("Error loading psycopg2 module: %s" % e) @@ -33,6 +34,7 @@ psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY) psycopg2.extensions.register_adapter(SafeBytes, psycopg2.extensions.QuotedString) psycopg2.extensions.register_adapter(SafeText, psycopg2.extensions.QuotedString) +psycopg2.extras.register_uuid() def utc_tzinfo_factory(offset): diff --git a/django/db/backends/postgresql_psycopg2/creation.py b/django/db/backends/postgresql_psycopg2/creation.py index 6a190bf0922..95731072716 100644 --- a/django/db/backends/postgresql_psycopg2/creation.py +++ b/django/db/backends/postgresql_psycopg2/creation.py @@ -31,6 +31,7 @@ class DatabaseCreation(BaseDatabaseCreation): 'SmallIntegerField': 'smallint', 'TextField': 'text', 'TimeField': 'time', + 'UUIDField': 'uuid', } data_type_check_constraints = { diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 1da25423825..0e1070e6e5f 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -8,8 +8,9 @@ from __future__ import unicode_literals import datetime import decimal -import warnings import re +import uuid +import warnings from django.conf import settings from django.db import utils @@ -273,6 +274,8 @@ class DatabaseOperations(BaseDatabaseOperations): converters.append(self.convert_timefield_value) elif internal_type == 'DecimalField': converters.append(self.convert_decimalfield_value) + elif internal_type == 'UUIDField': + converters.append(self.convert_uuidfield_value) return converters def convert_decimalfield_value(self, value, field): @@ -295,6 +298,11 @@ class DatabaseOperations(BaseDatabaseOperations): value = parse_time(value) return value + def convert_uuidfield_value(self, value, field): + if value is not None: + value = uuid.UUID(value) + return value + def bulk_insert_sql(self, fields, num_values): res = [] res.append("SELECT %s" % ", ".join( diff --git a/django/db/backends/sqlite3/creation.py b/django/db/backends/sqlite3/creation.py index 43b3924f4f4..ea91c4a2b74 100644 --- a/django/db/backends/sqlite3/creation.py +++ b/django/db/backends/sqlite3/creation.py @@ -33,6 +33,7 @@ class DatabaseCreation(BaseDatabaseCreation): 'SmallIntegerField': 'smallint', 'TextField': 'text', 'TimeField': 'time', + 'UUIDField': 'char(32)', } data_types_suffix = { 'AutoField': 'AUTOINCREMENT', diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index c41d4eaad22..de7631c2463 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -6,6 +6,7 @@ import copy import datetime import decimal import math +import uuid import warnings from base64 import b64decode, b64encode from itertools import tee @@ -40,6 +41,7 @@ __all__ = [str(x) for x in ( 'GenericIPAddressField', 'IPAddressField', 'IntegerField', 'NOT_PROVIDED', 'NullBooleanField', 'PositiveIntegerField', 'PositiveSmallIntegerField', 'SlugField', 'SmallIntegerField', 'TextField', 'TimeField', 'URLField', + 'UUIDField', )] @@ -2217,3 +2219,44 @@ class BinaryField(Field): if isinstance(value, six.text_type): return six.memoryview(b64decode(force_bytes(value))) return value + + +class UUIDField(Field): + default_error_messages = { + 'invalid': _("'%(value)s' is not a valid UUID."), + } + description = 'Universally unique identifier' + empty_strings_allowed = False + + def __init__(self, **kwargs): + kwargs['max_length'] = 32 + super(UUIDField, self).__init__(**kwargs) + + def get_internal_type(self): + return "UUIDField" + + def get_prep_value(self, value): + if isinstance(value, uuid.UUID): + return value.hex + if isinstance(value, six.string_types): + return value.replace('-', '') + return value + + def to_python(self, value): + if value and not isinstance(value, uuid.UUID): + try: + return uuid.UUID(value) + except ValueError: + raise exceptions.ValidationError( + self.error_messages['invalid'], + code='invalid', + params={'value': value}, + ) + return value + + def formfield(self, **kwargs): + defaults = { + 'form_class': forms.UUIDField, + } + defaults.update(kwargs) + return super(UUIDField, self).formfield(**defaults) diff --git a/django/forms/fields.py b/django/forms/fields.py index 2b44c739965..6403509fbbc 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -9,6 +9,7 @@ import datetime import os import re import sys +import uuid import warnings from decimal import Decimal, DecimalException from io import BytesIO @@ -41,7 +42,7 @@ __all__ = ( 'BooleanField', 'NullBooleanField', 'ChoiceField', 'MultipleChoiceField', 'ComboField', 'MultiValueField', 'FloatField', 'DecimalField', 'SplitDateTimeField', 'IPAddressField', 'GenericIPAddressField', 'FilePathField', - 'SlugField', 'TypedChoiceField', 'TypedMultipleChoiceField' + 'SlugField', 'TypedChoiceField', 'TypedMultipleChoiceField', 'UUIDField', ) @@ -1224,3 +1225,25 @@ class SlugField(CharField): def clean(self, value): value = self.to_python(value).strip() return super(SlugField, self).clean(value) + + +class UUIDField(CharField): + default_error_messages = { + 'invalid': _('Enter a valid UUID.'), + } + + def prepare_value(self, value): + if isinstance(value, uuid.UUID): + return value.hex + return value + + def to_python(self, value): + value = super(UUIDField, self).to_python(value) + if value in self.empty_values: + return None + if not isinstance(value, uuid.UUID): + try: + value = uuid.UUID(value) + except ValueError: + raise ValidationError(self.error_messages['invalid'], code='invalid') + return value diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index ca909fcd043..7d1578bc59f 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -92,7 +92,8 @@ below for information on how to set up your database correctly. PostgreSQL notes ================ -Django supports PostgreSQL 9.0 and higher. +Django supports PostgreSQL 9.0 and higher. It requires the use of Psycopg2 +2.0.9 or higher. PostgreSQL connection settings ------------------------------- diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index bd7cb93389e..d7fcef2e75f 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -888,6 +888,20 @@ For each field, we describe the default widget used if you don't specify These are the same as ``CharField.max_length`` and ``CharField.min_length``. +``UUIDField`` +------------- + +.. versionadded:: 1.8 + +.. class:: UUIDField(**kwargs) + + * Default widget: :class:`TextInput` + * Empty value: ``''`` (an empty string) + * Normalizes to: A :class:`~python:uuid.UUID` object. + * Error message keys: ``required``, ``invalid`` + + This field will accept any string format accepted as the ``hex`` argument + to the :class:`~python:uuid.UUID` constructor. Slightly complex built-in ``Field`` classes ------------------------------------------- diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index e7717e9d908..fe35f979cad 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1012,6 +1012,31 @@ Like all :class:`CharField` subclasses, :class:`URLField` takes the optional :attr:`~CharField.max_length` argument. If you don't specify :attr:`~CharField.max_length`, a default of 200 is used. +UUIDField +--------- + +.. versionadded:: 1.8 + +.. class:: UUIDField([**options]) + +A field for storing universally unique identifiers. Uses Python's +:class:`~python:uuid.UUID` class. When used on PostgreSQL, this stores in a +``uuid`` datatype, otherwise in a ``char(32)``. + +Universally unique identifiers are a good alternative to :class:`AutoField` for +:attr:`~Field.primary_key`. The database will not generate the UUID for you, so +it is recommended to use :attr:`~Field.default`:: + + import uuid + from django.db import models + + class MyUUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # other fields + +Note that a callable (with the parentheses omitted) is passed to ``default``, +not an instance of ``UUID``. + Relationship fields =================== diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index 51febddd9a1..a8e9ce80156 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -35,6 +35,14 @@ site. .. _django-secure: https://pypi.python.org/pypi/django-secure +New data types +~~~~~~~~~~~~~~ + +* Django now has a :class:`~django.db.models.UUIDField` for storing + universally unique identifiers. There is a corresponding :class:`form field + `. It is stored as the native ``uuid`` data type on + PostgreSQL and as a fixed length character field on other backends. + Minor features ~~~~~~~~~~~~~~ @@ -474,6 +482,8 @@ officially supports. This also includes dropping support for PostGIS 1.3 and 1.4 as these versions are not supported on versions of PostgreSQL later than 8.4. +Django also now requires the use of Psycopg2 version 2.0.9 or higher. + Support for MySQL versions older than 5.5 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/forms_tests/tests/test_fields.py b/tests/forms_tests/tests/test_fields.py index b64e55b0666..22af2cf23db 100644 --- a/tests/forms_tests/tests/test_fields.py +++ b/tests/forms_tests/tests/test_fields.py @@ -30,6 +30,7 @@ import datetime import pickle import re import os +import uuid from decimal import Decimal from unittest import skipIf import warnings @@ -46,7 +47,7 @@ from django.forms import ( Form, forms, HiddenInput, ImageField, IntegerField, MultipleChoiceField, NullBooleanField, NumberInput, PasswordInput, RadioSelect, RegexField, SplitDateTimeField, TextInput, Textarea, TimeField, TypedChoiceField, - TypedMultipleChoiceField, URLField, ValidationError, Widget, + TypedMultipleChoiceField, URLField, UUIDField, ValidationError, Widget, ) from django.test import SimpleTestCase from django.utils import formats @@ -1342,3 +1343,24 @@ class FieldsTests(SimpleTestCase): self.assertTrue(f.has_changed(datetime.datetime(2008, 5, 6, 12, 40, 00), ['2008-05-06', '12:40:00'])) self.assertFalse(f.has_changed(datetime.datetime(2008, 5, 6, 12, 40, 00), ['06/05/2008', '12:40'])) self.assertTrue(f.has_changed(datetime.datetime(2008, 5, 6, 12, 40, 00), ['06/05/2008', '12:41'])) + + def test_uuidfield_1(self): + field = UUIDField() + value = field.clean('550e8400e29b41d4a716446655440000') + self.assertEqual(value, uuid.UUID('550e8400e29b41d4a716446655440000')) + + def test_uuidfield_2(self): + field = UUIDField(required=False) + value = field.clean('') + self.assertEqual(value, None) + + def test_uuidfield_3(self): + field = UUIDField() + with self.assertRaises(ValidationError) as cm: + field.clean('550e8400') + self.assertEqual(cm.exception.messages[0], 'Enter a valid UUID.') + + def test_uuidfield_4(self): + field = UUIDField() + value = field.prepare_value(uuid.UUID('550e8400e29b41d4a716446655440000')) + self.assertEqual(value, '550e8400e29b41d4a716446655440000') diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index 430722cf4bd..ed1cfa8e8d5 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -1,5 +1,6 @@ import os import tempfile +import uuid import warnings try: @@ -294,3 +295,15 @@ if Image: width_field='headshot_width') ############################################################################### + + +class UUIDModel(models.Model): + field = models.UUIDField() + + +class NullableUUIDModel(models.Model): + field = models.UUIDField(blank=True, null=True) + + +class PrimaryKeyUUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4) diff --git a/tests/model_fields/test_uuid.py b/tests/model_fields/test_uuid.py new file mode 100644 index 00000000000..b5bc37a48df --- /dev/null +++ b/tests/model_fields/test_uuid.py @@ -0,0 +1,89 @@ +import json +import uuid + +from django.core import exceptions, serializers +from django.db import models +from django.test import TestCase + +from .models import UUIDModel, NullableUUIDModel, PrimaryKeyUUIDModel + + +class TestSaveLoad(TestCase): + def test_uuid_instance(self): + instance = UUIDModel.objects.create(field=uuid.uuid4()) + loaded = UUIDModel.objects.get() + self.assertEqual(loaded.field, instance.field) + + def test_str_instance_no_hyphens(self): + UUIDModel.objects.create(field='550e8400e29b41d4a716446655440000') + loaded = UUIDModel.objects.get() + self.assertEqual(loaded.field, uuid.UUID('550e8400e29b41d4a716446655440000')) + + def test_str_instance_hyphens(self): + UUIDModel.objects.create(field='550e8400-e29b-41d4-a716-446655440000') + loaded = UUIDModel.objects.get() + self.assertEqual(loaded.field, uuid.UUID('550e8400e29b41d4a716446655440000')) + + def test_str_instance_bad_hyphens(self): + UUIDModel.objects.create(field='550e84-00-e29b-41d4-a716-4-466-55440000') + loaded = UUIDModel.objects.get() + self.assertEqual(loaded.field, uuid.UUID('550e8400e29b41d4a716446655440000')) + + def test_null_handling(self): + NullableUUIDModel.objects.create(field=None) + loaded = NullableUUIDModel.objects.get() + self.assertEqual(loaded.field, None) + + +class TestQuerying(TestCase): + def setUp(self): + self.objs = [ + NullableUUIDModel.objects.create(field=uuid.uuid4()), + NullableUUIDModel.objects.create(field='550e8400e29b41d4a716446655440000'), + NullableUUIDModel.objects.create(field=None), + ] + + def test_exact(self): + self.assertSequenceEqual( + NullableUUIDModel.objects.filter(field__exact='550e8400e29b41d4a716446655440000'), + [self.objs[1]] + ) + + def test_isnull(self): + self.assertSequenceEqual( + NullableUUIDModel.objects.filter(field__isnull=True), + [self.objs[2]] + ) + + +class TestSerialization(TestCase): + test_data = '[{"fields": {"field": "550e8400-e29b-41d4-a716-446655440000"}, "model": "model_fields.uuidmodel", "pk": null}]' + + def test_dumping(self): + instance = UUIDModel(field=uuid.UUID('550e8400e29b41d4a716446655440000')) + data = serializers.serialize('json', [instance]) + self.assertEqual(json.loads(data), json.loads(self.test_data)) + + def test_loading(self): + instance = list(serializers.deserialize('json', self.test_data))[0].object + self.assertEqual(instance.field, uuid.UUID('550e8400-e29b-41d4-a716-446655440000')) + + +class TestValidation(TestCase): + def test_invalid_uuid(self): + field = models.UUIDField() + with self.assertRaises(exceptions.ValidationError) as cm: + field.clean('550e8400', None) + self.assertEqual(cm.exception.code, 'invalid') + self.assertEqual(cm.exception.message % cm.exception.params, "'550e8400' is not a valid UUID.") + + def test_uuid_instance_ok(self): + field = models.UUIDField() + field.clean(uuid.uuid4(), None) # no error + + +class TestAsPrimaryKey(TestCase): + def test_creation(self): + PrimaryKeyUUIDModel.objects.create() + loaded = PrimaryKeyUUIDModel.objects.get() + self.assertIsInstance(loaded.pk, uuid.UUID)