Added custom back-end to allow uniqueness

This commit is contained in:
Swen Kooij 2017-01-31 18:04:32 +02:00
parent ed1559ec31
commit 680383b636
6 changed files with 260 additions and 27 deletions

View File

@ -24,7 +24,6 @@ In the pipeline
We're working on making this easier to setup and use. Any feedback is apreciated. Here's a short list of things we're working to improve:
* Make it unnecesarry to add anything to your `INSTALLED_APPS`.
* Make it unnecesarry to modify your migrations manually to enable the PostgreSQL HStore extension.
Installation
------------
@ -45,6 +44,17 @@ Installation
'localized_fields'
]
3. Set the database engine to ``localized_fields.db_backend``:
.. code-block:: python
DATABASES = {
'default': {
...
'ENGINE': 'localized_fields.db_backend'
}
}
3. Set ``LANGUAGES` and `LANGUAGE_CODE`` in your settings:
.. code-block:: python
@ -72,23 +82,6 @@ Inherit your model from ``LocalizedModel`` and declare fields on your model as `
class MyModel(LocalizedModel):
title = LocalizedField()
Create your migrations using ``python manage.py makemigrations``. Open the generated migration in your favorite editor and setup the HStore extension before the first ``CreateModel`` or ``AddField`` operation by adding a migration with the `HStoreExtension` operation. For example:
.. code-block:: python
from django.contrib.postgres.operations import HStoreExtension
class Migration(migrations.Migration):
...
operations = [
HStoreExtension(),
...
]
Then apply the migration using ``python manage.py migrate``.
``django-localized-fields`` integrates with Django's i18n system, in order for certain languages to be available you have to correctly configure the ``LANGUAGES`` and ``LANGUAGE_CODE`` settings:
.. code-block:: python
@ -100,6 +93,8 @@ Then apply the migration using ``python manage.py migrate``.
('ro', 'Romanian')
)
All the ``LocalizedField`` you define now will be available in the configured languages.
Basic usage
^^^^^^^^^^^
.. code-block:: python
@ -141,22 +136,70 @@ You can also explicitly set a value in a certain language:
Constraints
^^^^^^^^^^^
By default, the following constraints apply to a ``LocalizedField``:
* Only the default language is ``required``. The other languages are optional and can be ``NULL``.
* If ``null=True`` is specified on the ``LocalizedField``, then none of the languages are required.
**Required/Optional**
At the moment, it is not possible to select two languages to be marked as required. The constraint is **not** enforced on a database level.
* Make the primary language **required** and the others optional (this is the **default**):
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(required=True)
* Make all languages optional:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(null=True)
**Uniqueness**
By default the values stored in a ``LocalizedField`` are *not unique*. You can enforce uniqueness for certain languages. This uniqueness constraint is enforced on a database level using a ``UNIQUE INDEX``.
* Enforce uniqueness for one or more languages:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=['en', 'ro'])
* Enforce uniqueness for **all** languages:
.. code-block:: python
from localized_fields import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=get_language_codes())
* Enforce uniqueness for one ore more languages **together** (similar to Django's ``unique_together``):
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=[('en', 'ro')])
* Enforce uniqueness for **all** languages **together**:
.. code-block:: python
from localized_fields import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=[(*get_language_codes())])
At the moment it is *not* possible to specifically instruct ``LocalizedField`` to mark certain languages as required or optional.
Other fields
^^^^^^^^^^^^
Besides ``LocalizedField``, there's also:
* ``LocalizedAutoSlugField``
Automatically creates a slug for every language from the specified field. Depends upon:
* django-autoslug
Automatically creates a slug for every language from the specified field.
Currently only supports `populate_from`. Example usage:
Currently only supports ``populate_from``. Example usage:
.. code-block:: python

View File

View File

@ -0,0 +1,176 @@
import importlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db.backends.postgresql.base import \
DatabaseWrapper as Psycopg2DatabaseWrapper
def _get_backend_base():
"""Gets the base class for the custom database back-end.
This should be the Django PostgreSQL back-end. However,
some people are already using a custom back-end from
another package. We are nice people and expose an option
that allows them to configure the back-end we base upon.
As long as the specified base eventually also has
the PostgreSQL back-end as a base, then everything should
work as intended.
"""
base_class_name = getattr(
settings,
'LOCALIZED_FIELDS_DB_BACKEND_BASE',
'django.db.backends.postgresql'
)
base_class_module = importlib.import_module(base_class_name + '.base')
base_class = getattr(base_class_module, 'DatabaseWrapper', None)
if not base_class:
raise ImproperlyConfigured((
'\'%s\' is not a valid database back-end.'
' The module does not define a DatabaseWrapper class.'
' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.'
))
if isinstance(base_class, Psycopg2DatabaseWrapper):
raise ImproperlyConfigured((
'\'%s\' is not a valid database back-end.'
' It does inherit from the PostgreSQL back-end.'
' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.'
))
return base_class
def _get_schema_editor_base():
"""Gets the base class for the schema editor.
We have to use the configured base back-end's
schema editor for this."""
return _get_backend_base().SchemaEditorClass
class SchemaEditor(_get_schema_editor_base()):
"""Custom schema editor for hstore indexes.
This allows us to put UNIQUE constraints for
localized fields."""
sql_hstore_unique_create = "CREATE UNIQUE INDEX {name} ON {table}{using} ({columns}){extra}"
sql_hstore_unique_drop = "DROP INDEX IF EXISTS {name}"
@staticmethod
def _hstore_unique_name(model, field, keys):
"""Gets the name for a UNIQUE INDEX that applies
to one or more keys in a hstore field.
Arguments:
model:
The model the field is a part of.
field:
The hstore field to create a
UNIQUE INDEX for.
key:
The name of the hstore key
to create the name for.
This can also be a tuple
of multiple names.
Returns:
The name for the UNIQUE index.
"""
postfix = '_'.join(keys)
return '{table_name}_{field_name}_unique_{postfix}'.format(
table_name=model._meta.db_table,
field_name=field.column,
postfix=postfix
)
def _drop_hstore_unique(self, model, field, keys):
"""Drops a UNIQUE constraint for the specified hstore keys."""
name = self._hstore_unique_name(model, field, keys)
sql = self.sql_hstore_unique_drop.format(name=name)
self.execute(sql)
def _create_hstore_unique(self, model, field, keys):
"""Creates a UNIQUE constraint for the specified hstore keys."""
name = self._hstore_unique_name(model, field, keys)
columns = [
'(%s->\'%s\')' % (field.column, key)
for key in keys
]
sql = self.sql_hstore_unique_create.format(
name=name,
table=model._meta.db_table,
using='',
columns=','.join(columns),
extra=''
)
self.execute(sql)
def _update_hstore_constraints(self, model, old_field, new_field):
"""Updates the UNIQUE constraints for the specified field."""
old_uniqueness = getattr(old_field, 'uniqueness', None)
new_uniqueness = getattr(new_field, 'uniqueness', None)
def _compose_keys(constraint):
if isinstance(constraint, str):
return [constraint]
return constraint
# drop any old uniqueness constraints
if old_uniqueness:
for keys in old_uniqueness:
self._drop_hstore_unique(
model,
old_field,
_compose_keys(keys)
)
# (re-)create uniqueness constraints
if new_uniqueness:
for keys in new_uniqueness:
self._create_hstore_unique(
model,
old_field,
_compose_keys(keys)
)
def _alter_field(self, model, old_field, new_field, *args, **kwargs):
"""Ran when the configuration on a field changed."""
super()._alter_field(
model, old_field, new_field,
*args, **kwargs
)
self._update_hstore_constraints(model, old_field, new_field)
class DatabaseWrapper(_get_backend_base()):
"""Wraps the standard PostgreSQL database back-end.
Overrides the schema editor with our custom
schema editor and makes sure the `hstore`
extension is enabled."""
SchemaEditorClass = SchemaEditor
def prepare_database(self):
"""Ran to prepare the configured database.
This is where we enable the `hstore` extension
if it wasn't enabled yet."""
super().prepare_database()
with self.cursor() as cursor:
cursor.execute('CREATE EXTENSION IF NOT EXISTS hstore')

View File

@ -4,7 +4,6 @@ from django import forms
from django.conf import settings
from django.utils.text import slugify
from ..forms import LocalizedFieldForm
from .localized_field import LocalizedField
from .localized_value import LocalizedValue

View File

@ -14,11 +14,13 @@ class LocalizedField(HStoreField):
Meta = None
def __init__(self, *args, **kwargs):
def __init__(self, *args, uniqueness=None, **kwargs):
"""Initializes a new instance of :see:LocalizedValue."""
super(LocalizedField, self).__init__(*args, **kwargs)
self.uniqueness = uniqueness
@staticmethod
def from_db_value(value, *_):
"""Turns the specified database value into its Python
@ -157,3 +159,14 @@ class LocalizedField(HStoreField):
defaults.update(kwargs)
return super().formfield(**defaults)
def deconstruct(self):
"""Gets the values to pass to :see:__init__ when
re-creating this object."""
values = super(LocalizedField, self).deconstruct()
values[3].update({
'uniqueness': self.uniqueness
})
return values

View File

@ -1,3 +1,5 @@
"""This module is unused, but should be contributed to Django."""
from typing import List
from django.db import models