web-dev-qa-db-fra.com

Comment contourner le manque de prise en charge des clés étrangères dans les bases de données dans Django

Je sais/ Django ne prend pas en charge les clés étrangères de plusieurs bases de données (à l'origine, Django 1.3 docs)

Mais je cherche une solution de contournement.

Ce qui ne marche pas

J'ai deux modèles chacun sur une base de données séparée.

routers.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()

Essayer d'ajouter un "article" dans l'admin donne l'erreur suivante car il cherche le modèle 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 d'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 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 résoudre ce problème, j’ai ajouté une propriété fruit_obj, mais j’aimerais le supprimer 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: champ ForeignKey de la sous-classe

Lors d'une seconde tentative, 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 supprimé la méthode validate() de la sous-classe ForeignKey. Cette méthode n’a pas le même problème que la méthode 1. Code sur github à l’adresse: 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 les modifications de ForeignKey requises dans la méthode 2. Après avoir examiné le comportement de Django en matière de routage par défaut dans Django.db.utils.ConnectionRouter , nous avons constaté que, même si nous nous attendions à ce que fruit_app soit présent dans la base de données 'default', l'indice instance passé à db_for_read pour les recherches de clé étrangère, placez-le dans la base de données 'news_db'. Nous avons ajouté un deuxième routeur pour nous assurer que les modèles fruit_app étaient toujours lus à partir de la base de données 'default'. Une sous-classe ForeignKey sert uniquement à "corriger" la méthode ForeignKey.validate(). (Si Django voulait prendre en charge des clés étrangères dans plusieurs bases de données, je dirais que c'est un bogue de Django.) Le code est sur github à l'adresse: https://github.com/saltycrane/Django-foreign-key-across-db-testproject

routers.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})

Information additionnelle

Mettre à jour

Nous avons implémenté la dernière méthode après avoir peaufiné nos routeurs. Toute la mise en œuvre a été assez pénible, ce qui nous fait penser que nous devons faire les choses mal. Sur la liste TODO est écrit des tests unitaires pour cela.

75
saltycrane

Après m'être cassé la tête quelques jours, j'ai réussi à obtenir ma clé étrangère SUR LA MÊME BANQUE!

Peut être fait un changement sur le formulaire pour chercher une clé étrangère dans une autre banque!

Tout d’abord, ajoutez une RECHARGE de CHAMPS, les deux directement (casser) ma forme, en fonction INIT

app.form.py

# -*- coding: utf-8 -*-
from Django import forms
import datetime
from app_ti_helpdesk import models as mdp

#classe para formulario de Novo HelpDesk
class FormNewHelpDesk(forms.ModelForm):
    class Meta:
        model = mdp.TblHelpDesk
        fields = (
        "problema_alegado",
        "cod_direcionacao",
        "data_prevista",
        "hora_prevista",
        "atendimento_relacionado_a",
        "status",
        "cod_usuario",
        )

    def __init__(self, *args, **kwargs):
        #-------------------------------------
        #  using remove of kwargs
        #-------------------------------------
        db = kwargs.pop("using", None)

        # CASE use Unique Keys
        self.Meta.model.db = db

        super(FormNewHelpDesk, self).__init__(*args,**kwargs)

        #-------------------------------------
        #   recreates the fields manually
        from copy import deepcopy
        self.fields = deepcopy( forms.fields_for_model( self.Meta.model, self.Meta.fields, using=db ) )
        #
        #-------------------------------------

        #### follows the standard template customization, if necessary

        self.fields['problema_alegado'].widget.attrs['rows'] = 3
        self.fields['problema_alegado'].widget.attrs['cols'] = 22
        self.fields['problema_alegado'].required = True
        self.fields['problema_alegado'].error_messages={'required': 'Necessário informar o motivo da solicitação de ajuda!'}


        self.fields['data_prevista'].widget.attrs['class'] = 'calendario'
        self.fields['data_prevista'].initial = (datetime.timedelta(4)+datetime.datetime.now().date()).strftime("%Y-%m-%d")

        self.fields['hora_prevista'].widget.attrs['class'] = 'hora'
        self.fields['hora_prevista'].initial =datetime.datetime.now().time().strftime("%H:%M")

        self.fields['status'].initial = '0'                 #aberto
        self.fields['status'].widget.attrs['disabled'] = True

        self.fields['atendimento_relacionado_a'].initial = '07'

        self.fields['cod_direcionacao'].required = True
        self.fields['cod_direcionacao'].label = "Direcionado a"
        self.fields['cod_direcionacao'].initial = '2'
        self.fields['cod_direcionacao'].error_messages={'required': 'Necessário informar para quem é direcionado a ajuda!'}

        self.fields['cod_usuario'].widget = forms.HiddenInput()

appeler le formulaire depuis la vue

app.view.py

form = forms.FormNewHelpDesk(request.POST or None, using=banco)

Maintenant, le changement dans le code source Django

Seuls les champs de type ForeignKey, ManyToManyField et OneToOneField peuvent utiliser l'option 'using', ainsi un IF ...

Django.forms.models.py

# line - 133: add using=None
def fields_for_model(model, fields=None, exclude=None, widgets=None, formfield_callback=None, using=None):

# line - 159

if formfield_callback is None:
    #----------------------------------------------------
    from Django.db.models.fields.related import (ForeignKey, ManyToManyField, OneToOneField)
    if type(f) in (ForeignKey, ManyToManyField, OneToOneField):
        kwargs['using'] = using

    formfield = f.formfield(**kwargs)
    #----------------------------------------------------
Elif not callable(formfield_callback):
    raise TypeError('formfield_callback must be a function or callable')
else:
    formfield = formfield_callback(f, **kwargs)

ALTER SUIVRE LE FICHIER

Django.db.models.base.py

modifier

# line 717
qs = model_class._default_manager.filter(**lookup_kwargs)

for

# line 717
qs = model_class._default_manager.using(getattr(self, 'db', None)).filter(**lookup_kwargs)

Prêt: D

2
arannasousa

En ce qui concerne la partie ForeignKeyAcrossDb, ne pourriez-vous pas éventuellement ajuster votre classe dans __init__? Vérifiez si le champ approprié est Integer sinon, chargez-le à partir de la base de données ou effectuez toute autre opération requise. Python __class__es peut être modifié au moment de l'exécution sans trop de problèmes.

2
julkiewicz

Vous pouvez créer une vue dans la base de données contenant la requête croisée, puis définir le modèle de la vue dans un fichier séparé pour que Syncdb fonctionne.

Bonne programmation. :)

2
adorablepuppy

Je sais que Djano-nosql prend en charge les clés et une certaine magie de http://www.allbuttonspressed.com/projects/Django-dbindexer . Peut-être que cela pourrait aider.

De la description:

"vous pouvez simplement indiquer à dbindexer quels modèles et champs doivent prendre en charge ces requêtes et il s’occupera de la gestion des index requis pour vous."

-Kerry

2
Kerry Hatcher

Cette solution est à l'origine écrite pour une base de données gérée avec des migrations et une ou plusieurs bases de données héritées avec des modèles Meta managed=False connectés au niveau de la base de données à la même base de données. Si une optiondb_tablecontient un nom de base de données et le nom de table quoted correctement par '`' (MySQL) ou par '"' ( db), par exemple db_table = '"DB2"."table_b"', alors il n’est plus cité par Django. Les requêtes sont compilées par Django ORM correctement, même avec les commandes JOIN:

class TableB(models.Model):
    ....
    class Meta:    
        db_table = '`DB2`.`table_b`'    # for MySQL
        # db_table = '"DB2"."table_b"'  # for all other backends
        managed = False

Ensemble de requêtes:

>>> qs = TableB.objects.all()
>>> str(qs.query)
'SELECT "DB2"."table_b"."id" FROM DB2"."table_b"'

Cela est supporté par tous les backb db dans Django.

(Il semble que j'ai commencé une prime sur une nouvelle question en double où ma réponse continue.)

1
hynekcer

Un champ de clé étrangère implique que vous pouvez - interroger la relation en joignant ie fruit__name - vérifier l'intégrité référentielle - assurer l'intégrité référentielle lors de la suppression - fonctionnalité de recherche d'identifiant brut de l'administrateur - (un peu plus ...)

Le premier cas d'utilisation serait toujours problématique. Il existe probablement dans la base de code quelques autres cas spéciaux de clés étrangères qui ne fonctionneraient pas non plus.

Je gère un site Django plutôt volumineux et nous utilisons actuellement un champ entier simple. Pour le moment, je pense que la sous-classification de integerfield et l'ajout de l'identifiant à la conversion d'objet seraient les plus faciles (dans la version 1.2, il a fallu corriger certaines parties de Django, espérons que celles-ci seront améliorées maintenant). Nous vous indiquerons quelle solution nous trouvons.

1
Thierry

Problème similaire de nécessité de référencer (principalement) des données statiques sur plusieurs (5) bases de données. Nous avons légèrement modifié le descripteur ReversedSingleRelatedObjectDescriptor afin de permettre la définition du modèle associé. Il n'implémente pas la relation inverse atm.

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. Used with
LinkedField.
"""
def __init__(self, field_with_rel):
    self.field = field_with_rel
    self.cache_name = self.field.get_cache_name()

def __get__(self, instance, instance_type=None):
    if instance is None:
        return self

    try:
        return getattr(instance, self.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, self.cache_name, rel_obj)
        return rel_obj

def __set__(self, instance, value):
    if instance is None:
        raise AttributeError("%s must be accessed via instance" % self.field.name)

    # If null=True, we can assign null here, but otherwise the value needs to be an instance of the related class.
    if value is None and self.field.null is False:
        raise ValueError('Cannot assign None: "%s.%s" does not allow null values.' %
                         (instance._meta.object_name, self.field.names))
    Elif value is not None and not isinstance(value, self.field.rel.to):
        raise ValueError('Cannot assign "%r": "%s.%s" must be a "%s" instance.' %
                         (value, instance._meta.object_name, self.field.name, self.field.rel.to._meta.object_name))
    Elif value is not None:
        # Only check the instance state db, LinkedField implies that the value is on a different database
        if instance._state.db is None:
            instance._state.db = router.db_for_write(instance.__class__, instance=value)

    # Is not used by OneToOneField, no extra measures to take here

    # Set the value of the related field
    try:
        val = getattr(value, self.field.rel.get_related_field().attname)
    except AttributeError:
        val = None
    setattr(instance, self.field.attname, val)

    # Since we already know what the related object is, seed the related object caches now, too. This avoids another
    # db hit if you get the object you just set
    setattr(instance, self.cache_name, value)
    if value is not None and not self.field.rel.multiple:
        setattr(value, self.field.related.get_cache_name(), instance)

et

class LinkedField(models.ForeignKey):
"""
Field class used to link models across databases. Does not ensure referrential integraty like ForeignKey
"""
def _description(self):
    return "Linked Field (type determined by related field)"

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
1
Jonathan

J'ai une nouvelle solution pour Django v1.10. Il y a deux parties. Cela fonctionne avec Django.admin et Django.rest-framework.

  1. Héritez de la classe ForeignKey et créez ForeignKeyAcrossDb, et remplacez la fonction validate(), en fonction de ce ticket et de ce post .
class ForeignKeyAcrossDb(models.ForeignKey):
        def validate(self, value, model_instance):
            if self.remote_field.parent_link:
                return
            super(models.ForeignKey, self).validate(value, model_instance)
            if value is None:
                return
            using = router.db_for_read(self.remote_field.model, instance=model_instance)
            qs = self.remote_field.model._default_manager.using(using).filter(
                **{self.remote_field.field_name: value}
            )
            qs = qs.complex_filter(self.get_limit_choices_to())
            if not qs.exists():
                raise exceptions.ValidationError(
                    self.error_messages['invalid'],
                    code='invalid',
                    params={
                        'model': self.remote_field.model._meta.verbose_name, 'pk': value,
                        'field': self.remote_field.field_name, 'value': value,
                    },  # 'pk' is included for backwards compatibility
                )
  1. Dans la déclaration de champ, utilisez db_constraint=False, par exemple,

album=ForeignKeyAcrossDb(Singer, db_constraint=False, on_delete=models.DO_NOTHING)

0
Diansheng

Inspiré par le commentaire de @Frans. Ma solution consiste à le faire dans la couche de gestion. Dans l'exemple donné cette question. Je mettrais des fruits à une IntegerField sur Article, comme "ne pas faire de contrôle d'intégrité dans la couche de données".

class Fruit(models.Model):
    name = models.CharField()

class Article(models.Model):
    fruit = models.IntegerField()
    intro = models.TextField()

Respectez ensuite la relation de référence dans le code d'application (couche de gestion). Prenez Django admin, par exemple, afin d'afficher les fruits en tant que choix dans la page d'ajout d'article, vous créez une liste de choix pour les fruits manuellement.

# admin.py in App article
class ArticleAdmin(admin.ModelAdmin):
    class ArticleForm(forms.ModelForm):
        fields = ['fruit', 'intro']

        # populate choices for fruit
        choices = [(obj.id, obj.name) for obj in Fruit.objects.all()]
        widgets = {
            'fruit': forms.Select(choices=choices)}

    form = ArticleForm
    list_diaplay = ['fruit', 'intro']

Bien entendu, vous devrez peut-être prendre en charge la validation du champ de formulaire (contrôle d'intégrité).

0
Sky