SQLAlchemy a-t-il un équivalent de get_or_create de Django?


160

Je veux obtenir un objet de la base de données s'il existe déjà (en fonction des paramètres fournis) ou le créer si ce n'est pas le cas.

Django get_or_create(ou source ) le fait. Existe-t-il un raccourci équivalent dans SQLAlchemy?

Je l'écris actuellement explicitement comme ceci:

def get_or_create_instrument(session, serial_number):
    instrument = session.query(Instrument).filter_by(serial_number=serial_number).first()
    if instrument:
        return instrument
    else:
        instrument = Instrument(serial_number)
        session.add(instrument)
        return instrument

4
Pour ceux qui veulent simplement ajouter un objet s'il n'existe pas encore, voir session.merge: stackoverflow.com/questions/12297156/...
Anton Tarasenko

Réponses:


96

C'est fondamentalement la façon de le faire, il n'y a pas de raccourci facilement disponible AFAIK.

Vous pouvez bien sûr généraliser:

def get_or_create(session, model, defaults=None, **kwargs):
    instance = session.query(model).filter_by(**kwargs).first()
    if instance:
        return instance, False
    else:
        params = dict((k, v) for k, v in kwargs.iteritems() if not isinstance(v, ClauseElement))
        params.update(defaults or {})
        instance = model(**params)
        session.add(instance)
        return instance, True

2
Je pense que là où vous lisez "session.Query (model.filter_by (** kwargs) .first ()", vous devriez lire "session.Query (model.filter_by (** kwargs)). First ()".
pkoch

3
Devrait-il y avoir un verrou autour de cela afin qu'un autre thread ne crée pas d'instance avant que ce thread ait une chance de le faire?
EoghanM

2
@EoghanM: Normalement, votre session serait threadlocal donc cela n'a pas d'importance. La session SQLAlchemy n'est pas censée être thread-safe.
Wolph

5
@WolpH cela peut être un autre processus essayant de créer le même enregistrement simultanément. Regardez l'implémentation Django de get_or_create. Il vérifie les erreurs d'intégrité et repose sur une utilisation appropriée des contraintes uniques.
Ivan Virabyan

1
@IvanVirabyan: J'ai supposé que @EoghanM parlait de l'instance de session. Dans ce cas, il devrait y avoir un try...except IntegrityError: instance = session.Query(...)autour du session.addbloc.
Wolph

109

Suite à la solution de @WoLpH, voici le code qui a fonctionné pour moi (version simple):

def get_or_create(session, model, **kwargs):
    instance = session.query(model).filter_by(**kwargs).first()
    if instance:
        return instance
    else:
        instance = model(**kwargs)
        session.add(instance)
        session.commit()
        return instance

Avec cela, je suis capable de get_or_create n'importe quel objet de mon modèle.

Supposons que mon objet modèle soit:

class Country(Base):
    __tablename__ = 'countries'
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True)

Pour obtenir ou créer mon objet, j'écris:

myCountry = get_or_create(session, Country, name=countryName)

3
Pour ceux d'entre vous qui recherchent comme moi, c'est la bonne solution pour créer une ligne si elle n'existe pas déjà.
Spencer Rathbun

3
N'avez-vous pas besoin d'ajouter la nouvelle instance à la session? Sinon, si vous émettez une session.commit () dans le code d'appel, rien ne se passera car la nouvelle instance n'est pas ajoutée à la session.
CadentOrange

1
Merci pour ça. J'ai trouvé cela si utile que j'en ai créé un résumé pour une utilisation future. gist.github.com/jangeador/e7221fc3b5ebeeac9a08
jangeador

où dois-je mettre le code ?, j'obtiens une erreur de contexte d'exécution?
Victor Alvarado

7
Étant donné que vous passez la session en argument, il peut être préférable d'éviter commit(ou du moins d'utiliser uniquement un à la flushplace). Cela laisse le contrôle de session à l'appelant de cette méthode et ne risque pas d'émettre une validation prématurée. En outre, utiliser one_or_none()au lieu de first()peut être légèrement plus sûr.
exhuma

52

J'ai joué avec ce problème et j'ai fini avec une solution assez robuste:

def get_one_or_create(session,
                      model,
                      create_method='',
                      create_method_kwargs=None,
                      **kwargs):
    try:
        return session.query(model).filter_by(**kwargs).one(), False
    except NoResultFound:
        kwargs.update(create_method_kwargs or {})
        created = getattr(model, create_method, model)(**kwargs)
        try:
            session.add(created)
            session.flush()
            return created, True
        except IntegrityError:
            session.rollback()
            return session.query(model).filter_by(**kwargs).one(), False

Je viens d'écrire un article de blog assez volumineux sur tous les détails, mais quelques idées assez détaillées sur les raisons pour lesquelles j'ai utilisé cela.

  1. Il se décompresse en un tuple qui vous indique si l'objet existait ou non. Cela peut souvent être utile dans votre flux de travail.

  2. La fonction donne la possibilité de travailler avec des @classmethodfonctions de créateur décorées (et des attributs qui leur sont spécifiques).

  3. La solution protège contre les conditions de concurrence lorsque vous avez plus d'un processus connecté à la banque de données.

EDIT: Je l' ai changé session.commit()pour , session.flush()comme expliqué dans ce billet de blog . Notez que ces décisions sont spécifiques à la banque de données utilisée (Postgres dans ce cas).

EDIT 2: J'ai mis à jour en utilisant un {} comme valeur par défaut dans la fonction car c'est un gotcha typique de Python. Merci pour le commentaire , Nigel! Si vous êtes curieux de savoir ce que vous en pensez , consultez cette question StackOverflow et cet article de blog .


1
Par rapport à ce que dit spencer , cette solution est la bonne car elle empêche les conditions de course (en validant / vidant la session, attention) et imite parfaitement ce que fait Django.
kiddouk

@kiddouk Non, ça n'imite pas "parfaitement". Django get_or_createn'est pas thread-safe. Ce n'est pas atomique. De plus, Django get_or_createrenvoie un drapeau True si l'instance a été créée ou un drapeau False dans le cas contraire.
Kar

@Kate si vous regardez Django, get_or_createil fait presque exactement la même chose. Cette solution renvoie également l' True/Falseindicateur pour signaler si l'objet a été créé ou récupéré, et n'est pas non plus atomique. Cependant, la sécurité des threads et les mises à jour atomiques sont une préoccupation pour la base de données, pas pour Django, Flask ou SQLAlchemy, et dans cette solution et Django, sont résolues par des transactions sur la base de données.
erik

1
Supposons qu'un champ non nul ait reçu une valeur nulle pour un nouvel enregistrement, cela lèvera IntegrityError. Le tout est foiré, maintenant nous ne savons pas ce qui s'est réellement passé et nous obtenons une autre erreur, qu'aucun enregistrement n'est trouvé.
rajat

2
Le IntegrityErrorcas ne devrait-il pas revenir Falsepuisque ce client n'a pas créé l'objet?
kevmitch

11

Une version modifiée de l'excellente réponse d'Erik

def get_one_or_create(session,
                      model,
                      create_method='',
                      create_method_kwargs=None,
                      **kwargs):
    try:
        return session.query(model).filter_by(**kwargs).one(), True
    except NoResultFound:
        kwargs.update(create_method_kwargs or {})
        try:
            with session.begin_nested():
                created = getattr(model, create_method, model)(**kwargs)
                session.add(created)
            return created, False
        except IntegrityError:
            return session.query(model).filter_by(**kwargs).one(), True
  • Utilisez une transaction imbriquée pour annuler uniquement l'ajout du nouvel élément au lieu de tout annuler (voir cette réponse pour utiliser des transactions imbriquées avec SQLite)
  • Bougez create_method. Si l'objet créé a des relations et que des membres lui sont affectés via ces relations, il est automatiquement ajouté à la session. Par exemple, créez un book, qui a user_idet usercomme relation correspondante, puis faire à l' book.user=<user object>intérieur de create_methodajoutera bookà la session. Cela signifie qu'il create_methoddoit être à l'intérieur withpour bénéficier d'un éventuel retour en arrière. Notez que begin_nesteddéclenche automatiquement un flush.

Notez que si vous utilisez MySQL, le niveau d'isolation des transactions doit être défini sur READ COMMITTEDplutôt que REPEATABLE READpour que cela fonctionne. Get_or_create de Django (et ici ) utilise le même stratagème, voir aussi la documentation Django .


J'aime le fait que cela évite d'annuler des modifications non liées, mais la IntegrityErrornouvelle requête peut toujours échouer NoResultFoundavec le niveau d'isolement par défaut de MySQL REPEATABLE READsi la session avait précédemment interrogé le modèle dans la même transaction. La meilleure solution que je pourrais trouver est d'appeler session.commit()avant cette requête, ce qui n'est pas non plus idéal car l'utilisateur peut ne pas s'y attendre. La réponse référencée n'a pas ce problème puisque la session.rollback () a le même effet de démarrer une nouvelle transaction.
kevmitch

Huh, TIL. Le fait de placer la requête dans une transaction imbriquée fonctionnerait-il? Vous avez raison de dire qu'à l' commitintérieur de cette fonction est sans doute pire que de faire un rollback, même si pour des cas d'utilisation spécifiques, cela peut être acceptable.
Adversus

Oui, placer la requête initiale dans une transaction imbriquée permet au moins à la deuxième requête de fonctionner. Cela échouera toujours si l'utilisateur a explicitement interrogé le modèle auparavant dans la même transaction. J'ai décidé que cela était acceptable et que l'utilisateur devrait simplement être averti de ne pas le faire ou d'attraper l'exception et de décider de ne commit()pas le faire. Si ma compréhension du code est correcte, c'est ce que fait Django.
kevmitch

Dans la documentation de django , ils disent d'utiliser `READ COMMITTED , so it does not look like they try to handle this. Looking at the [source](https://github.com/django/django/blob/master/django/db/models/query.py#L491) confirms this. I'm not sure I understand your reply, you mean the user should put his/her query in a nested transaction? It's not clear to me how a SAVEPOINT` influence les lectures avec REPEATABLE READ. Si aucun effet alors la situation semble irréversible, si effet alors la toute dernière requête pourrait être imbriquée?
Adversus

C'est intéressant READ COMMITED, je devrais peut-être repenser ma décision de ne pas toucher aux valeurs par défaut de la base de données. J'ai testé que la restauration d'un SAVEPOINTavant qu'une requête ne soit effectuée donne l'impression que cette requête ne s'est jamais produite REPEATABLE READ. Par conséquent, j'ai trouvé nécessaire d'inclure la requête dans la clause try dans une transaction imbriquée afin que la requête de la IntegrityErrorclause except puisse fonctionner du tout.
kevmitch

6

Cette recette SQLALchemy fait le travail et élégant.

La première chose à faire est de définir une fonction qui reçoit une Session avec laquelle travailler, et associe un dictionnaire à Session () qui garde la trace des clés uniques actuelles .

def _unique(session, cls, hashfunc, queryfunc, constructor, arg, kw):
    cache = getattr(session, '_unique_cache', None)
    if cache is None:
        session._unique_cache = cache = {}

    key = (cls, hashfunc(*arg, **kw))
    if key in cache:
        return cache[key]
    else:
        with session.no_autoflush:
            q = session.query(cls)
            q = queryfunc(q, *arg, **kw)
            obj = q.first()
            if not obj:
                obj = constructor(*arg, **kw)
                session.add(obj)
        cache[key] = obj
        return obj

Un exemple d'utilisation de cette fonction serait dans un mixin:

class UniqueMixin(object):
    @classmethod
    def unique_hash(cls, *arg, **kw):
        raise NotImplementedError()

    @classmethod
    def unique_filter(cls, query, *arg, **kw):
        raise NotImplementedError()

    @classmethod
    def as_unique(cls, session, *arg, **kw):
        return _unique(
                    session,
                    cls,
                    cls.unique_hash,
                    cls.unique_filter,
                    cls,
                    arg, kw
            )

Et enfin la création du modèle unique get_or_create:

from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

engine = create_engine('sqlite://', echo=True)

Session = sessionmaker(bind=engine)

class Widget(UniqueMixin, Base):
    __tablename__ = 'widget'

    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True, nullable=False)

    @classmethod
    def unique_hash(cls, name):
        return name

    @classmethod
    def unique_filter(cls, query, name):
        return query.filter(Widget.name == name)

Base.metadata.create_all(engine)

session = Session()

w1, w2, w3 = Widget.as_unique(session, name='w1'), \
                Widget.as_unique(session, name='w2'), \
                Widget.as_unique(session, name='w3')
w1b = Widget.as_unique(session, name='w1')

assert w1 is w1b
assert w2 is not w3
assert w2 is not w1

session.commit()

La recette approfondit l'idée et propose différentes approches, mais j'ai utilisé celle-ci avec beaucoup de succès.


1
J'aime cette recette si un seul objet SQLAlchemy Session peut modifier la base de données. Je me trompe peut-être, mais si d'autres sessions (SQLAlchemy ou non) modifient la base de données simultanément, je ne vois pas comment cela protège contre les objets qui auraient pu être créés par d'autres sessions pendant que la transaction est en cours. Dans ces cas, je pense que les solutions qui reposent sur le rinçage après session.add () et la gestion des exceptions comme stackoverflow.com/a/21146492/3690333 sont plus fiables.
TrilceAC

3

Le plus proche sémantiquement est probablement:

def get_or_create(model, **kwargs):
    """SqlAlchemy implementation of Django's get_or_create.
    """
    session = Session()
    instance = session.query(model).filter_by(**kwargs).first()
    if instance:
        return instance, False
    else:
        instance = model(**kwargs)
        session.add(instance)
        session.commit()
        return instance, True

Je ne sais pas à quel point il est casher de s'appuyer sur une définition globale Sessiondans sqlalchemy, mais la version Django ne prend pas de connexion alors ...

Le tuple retourné contient l'instance et un booléen indiquant si l'instance a été créée (c'est-à-dire qu'il est faux si nous lisons l'instance à partir de la base de données).

Django get_or_createest souvent utilisé pour s'assurer que les données globales sont disponibles, donc je m'engage le plus tôt possible.


cela devrait fonctionner tant que Session est créée et suivie par scoped_session, qui devrait implémenter la gestion de session thread-safe (cela existait-il en 2014?).
cowbert

2

J'ai légèrement simplifié @Kevin. solution pour éviter d'encapsuler toute la fonction dans une instruction if/ else. De cette façon, il n'y en a qu'un return, que je trouve plus propre:

def get_or_create(session, model, **kwargs):
    instance = session.query(model).filter_by(**kwargs).first()

    if not instance:
        instance = model(**kwargs)
        session.add(instance)

    return instance

1

Selon le niveau d'isolement que vous avez adopté, aucune des solutions ci-dessus ne fonctionnerait. La meilleure solution que j'ai trouvée est un RAW SQL sous la forme suivante:

INSERT INTO table(f1, f2, unique_f3) 
SELECT 'v1', 'v2', 'v3' 
WHERE NOT EXISTS (SELECT 1 FROM table WHERE f3 = 'v3')

Ceci est transactionnellement sûr quels que soient le niveau d'isolement et le degré de parallélisme.

Attention: pour le rendre efficace, il serait judicieux d'avoir un INDEX pour la colonne unique.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.