Je sais que Django ne prend pas en charge les clés étrangères dans plusieurs bases de données : http://docs.djangoproject.com/en/1.3/topics/db/multi-db/#cross-database-relations
Mais je cherche une solution de rechange.
Ce qui ne marche pas
J'ai deux modèles, chacun sur une base de données distincte.
routeurs.py :
class NewsRouter(object):
def db_for_read(self, model, **hints):
if model._meta.app_label == 'news_app':
return 'news_db'
return None
def db_for_write(self, model, **hints):
if model._meta.app_label == 'news_app':
return 'news_db'
return None
def allow_relation(self, obj1, obj2, **hints):
if obj1._meta.app_label == 'news_app' or obj2._meta.app_label == 'news_app':
return True
return None
def allow_syncdb(self, db, model):
if db == 'news_db':
return model._meta.app_label == 'news_app'
elif model._meta.app_label == 'news_app':
return False
return None
Modèle 1 dans fruit_app/models.py :
from django.db import models
class Fruit(models.Model):
name = models.CharField(max_length=20)
Modèle 2 dans news_app/models.py :
from django.db import models
class Article(models.Model):
fruit = models.ForeignKey('fruit_app.Fruit')
intro = models.TextField()
Si vous essayez d'ajouter un "article" dans l'administration, vous obtenez l'erreur suivante, car le système recherche l'adresse suivante Fruit
sur la mauvaise base de données ( 'news_db'
):
DatabaseError at /admin/news_app/article/add/
(1146, "Table 'fkad_news.fruit_app_fruit' doesn't exist")
Méthode 1 : sous-classe IntegerField
J'ai créé un champ personnalisé, ForeignKeyAcrossDb, qui est une sous-classe de IntegerField. Le code est sur github à : https://github.com/saltycrane/django-foreign-key-across-db-testproject/tree/integerfield_subclass
fields.py :
from django.db import models
class ForeignKeyAcrossDb(models.IntegerField):
'''
Exists because foreign keys do not work across databases
'''
def __init__(self, model_on_other_db, **kwargs):
self.model_on_other_db = model_on_other_db
super(ForeignKeyAcrossDb, self).__init__(**kwargs)
def to_python(self, value):
# TODO: this db lookup is duplicated in get_prep_lookup()
if isinstance(value, self.model_on_other_db):
return value
else:
return self.model_on_other_db._default_manager.get(pk=value)
def get_prep_value(self, value):
if isinstance(value, self.model_on_other_db):
value = value.pk
return super(ForeignKeyAcrossDb, self).get_prep_value(value)
def get_prep_lookup(self, lookup_type, value):
# TODO: this db lookup is duplicated in to_python()
if not isinstance(value, self.model_on_other_db):
value = self.model_on_other_db._default_manager.get(pk=value)
return super(ForeignKeyAcrossDb, self).get_prep_lookup(lookup_type, value)
Et j'ai changé mon modèle d'article pour être :
class Article(models.Model):
fruit = ForeignKeyAcrossDb(Fruit)
intro = models.TextField()
Le problème est que, parfois, lorsque j'accède à Article.fruit, il s'agit d'un nombre entier, et parfois de l'objet Fruit. Je veux que ce soit toujours un objet Fruit. Que dois-je faire pour que l'accès à Article.fruit renvoie toujours un objet Fruit ?
Pour pallier à mon problème, j'ai ajouté une fonction fruit_obj
mais je voudrais l'éliminer si possible :
class Article(models.Model):
fruit = ForeignKeyAcrossDb(Fruit)
intro = models.TextField()
# TODO: shouldn't need fruit_obj if ForeignKeyAcrossDb field worked properly
@property
def fruit_obj(self):
if not hasattr(self, '_fruit_obj'):
# TODO: why is it sometimes an int and sometimes a Fruit object?
if isinstance(self.fruit, int) or isinstance(self.fruit, long):
print 'self.fruit IS a number'
self._fruit_obj = Fruit.objects.get(pk=self.fruit)
else:
print 'self.fruit IS NOT a number'
self._fruit_obj = self.fruit
return self._fruit_obj
def fruit_name(self):
return self.fruit_obj.name
Méthode 2 : sous-classez le champ ForeignKey
Dans un deuxième temps, j'ai essayé de sous-classer le champ ForeignKey. J'ai modifié ReverseSingleRelatedObjectDescriptor
pour utiliser la base de données spécifiée par forced_using
sur le gestionnaire de modèles de Fruit
. J'ai également retiré le validate()
sur la méthode ForeignKey
sous-classe. Cette méthode n'a pas eu le même problème que la méthode 1. Code sur github à : https://github.com/saltycrane/django-foreign-key-across-db-testproject/tree/foreignkey_subclass
fields.py :
from django.db import models
from django.db import router
from django.db.models.query import QuerySet
class ReverseSingleRelatedObjectDescriptor(object):
# This class provides the functionality that makes the related-object
# managers available as attributes on a model class, for fields that have
# a single "remote" value, on the class that defines the related field.
# In the example "choice.poll", the poll attribute is a
# ReverseSingleRelatedObjectDescriptor instance.
def __init__(self, field_with_rel):
self.field = field_with_rel
def __get__(self, instance, instance_type=None):
if instance is None:
return self
cache_name = self.field.get_cache_name()
try:
return getattr(instance, cache_name)
except AttributeError:
val = getattr(instance, self.field.attname)
if val is None:
# If NULL is an allowed value, return it.
if self.field.null:
return None
raise self.field.rel.to.DoesNotExist
other_field = self.field.rel.get_related_field()
if other_field.rel:
params = {'%s__pk' % self.field.rel.field_name: val}
else:
params = {'%s__exact' % self.field.rel.field_name: val}
# If the related manager indicates that it should be used for
# related fields, respect that.
rel_mgr = self.field.rel.to._default_manager
db = router.db_for_read(self.field.rel.to, instance=instance)
if getattr(rel_mgr, 'forced_using', False):
db = rel_mgr.forced_using
rel_obj = rel_mgr.using(db).get(**params)
elif getattr(rel_mgr, 'use_for_related_fields', False):
rel_obj = rel_mgr.using(db).get(**params)
else:
rel_obj = QuerySet(self.field.rel.to).using(db).get(**params)
setattr(instance, cache_name, rel_obj)
return rel_obj
def __set__(self, instance, value):
raise NotImplementedError()
class ForeignKeyAcrossDb(models.ForeignKey):
def contribute_to_class(self, cls, name):
models.ForeignKey.contribute_to_class(self, cls, name)
setattr(cls, self.name, ReverseSingleRelatedObjectDescriptor(self))
if isinstance(self.rel.to, basestring):
target = self.rel.to
else:
target = self.rel.to._meta.db_table
cls._meta.duplicate_targets[self.column] = (target, "o2m")
def validate(self, value, model_instance):
pass
fruit_app/models.py :
from django.db import models
class FruitManager(models.Manager):
forced_using = 'default'
class Fruit(models.Model):
name = models.CharField(max_length=20)
objects = FruitManager()
news_app/models.py :
from django.db import models
from foreign_key_across_db_testproject.fields import ForeignKeyAcrossDb
from foreign_key_across_db_testproject.fruit_app.models import Fruit
class Article(models.Model):
fruit = ForeignKeyAcrossDb(Fruit)
intro = models.TextField()
def fruit_name(self):
return self.fruit.name
Méthode 2a : Ajouter un routeur pour fruit_app
Cette solution utilise un routeur supplémentaire pour fruit_app
. Cette solution ne nécessite pas de modifier ForeignKey
qui étaient nécessaires dans la méthode 2. Après avoir examiné le comportement de routage par défaut de Django dans la section django.db.utils.ConnectionRouter
nous avons constaté que, même si nous nous attendions fruit_app
pour être sur le 'default'
par défaut, la base de données instance
transmis à db_for_read
pour les recherches de clés étrangères, mettez-le sur le 'news_db'
base de données. Nous avons ajouté un second routeur pour assurer fruit_app
ont toujours été lus à partir de l 'default'
base de données. A ForeignKey
n'est utilisée que pour "réparer" la classe ForeignKey.validate()
méthode. (Si Django voulait prendre en charge les clés étrangères à travers les bases de données, je dirais que c'est un bug de Django). Le code est sur github à : https://github.com/saltycrane/django-foreign-key-across-db-testproject
routeurs.py :
class NewsRouter(object):
def db_for_read(self, model, **hints):
if model._meta.app_label == 'news_app':
return 'news_db'
return None
def db_for_write(self, model, **hints):
if model._meta.app_label == 'news_app':
return 'news_db'
return None
def allow_relation(self, obj1, obj2, **hints):
if obj1._meta.app_label == 'news_app' or obj2._meta.app_label == 'news_app':
return True
return None
def allow_syncdb(self, db, model):
if db == 'news_db':
return model._meta.app_label == 'news_app'
elif model._meta.app_label == 'news_app':
return False
return None
class FruitRouter(object):
def db_for_read(self, model, **hints):
if model._meta.app_label == 'fruit_app':
return 'default'
return None
def db_for_write(self, model, **hints):
if model._meta.app_label == 'fruit_app':
return 'default'
return None
def allow_relation(self, obj1, obj2, **hints):
if obj1._meta.app_label == 'fruit_app' or obj2._meta.app_label == 'fruit_app':
return True
return None
def allow_syncdb(self, db, model):
if db == 'default':
return model._meta.app_label == 'fruit_app'
elif model._meta.app_label == 'fruit_app':
return False
return None
fruit_app/models.py :
from django.db import models
class Fruit(models.Model):
name = models.CharField(max_length=20)
news_app/models.py :
from django.db import models
from foreign_key_across_db_testproject.fields import ForeignKeyAcrossDb
from foreign_key_across_db_testproject.fruit_app.models import Fruit
class Article(models.Model):
fruit = ForeignKeyAcrossDb(Fruit)
intro = models.TextField()
def fruit_name(self):
return self.fruit.name
fields.py :
from django.core import exceptions
from django.db import models
from django.db import router
class ForeignKeyAcrossDb(models.ForeignKey):
def validate(self, value, model_instance):
if self.rel.parent_link:
return
models.Field.validate(self, value, model_instance)
if value is None:
return
using = router.db_for_read(self.rel.to, instance=model_instance) # is this more correct than Django's 1.2.5 version?
qs = self.rel.to._default_manager.using(using).filter(
**{self.rel.field_name: value}
)
qs = qs.complex_filter(self.rel.limit_choices_to)
if not qs.exists():
raise exceptions.ValidationError(self.error_messages['invalid'] % {
'model': self.rel.to._meta.verbose_name, 'pk': value})
Informations complémentaires
- Fil de discussion sur la liste django-users qui contient beaucoup d'informations : http://groups.google.com/group/django-users/browse_thread/thread/74bcd1afdeb2f0/0fdfce061124b915
- Historique des révisions de la documentation multi-db : http://code.djangoproject.com/log/django/trunk/docs/topics/db/multi-db.txt?verbose=on
Mise à jour
Nous avons appliqué la dernière méthode après avoir modifié nos routeurs. L'ensemble de l'implémentation a été assez pénible, ce qui nous fait penser que nous devons nous tromper. Sur la liste des choses à faire, il faut écrire des tests unitaires pour cette méthode.