Update row (SQLAlchemy) with data from marshmallow

2019-04-20 11:59发布

问题:

I'm using Flask, Flask-SQLAlchemy, Flask-Marshmallow + marshmallow-sqlalchemy, trying to implement REST api PUT method. I haven't found any tutorial using SQLA and Marshmallow implementing update.

Here is the code:

class NodeSchema(ma.Schema):
    # ...


class NodeAPI(MethodView):
    decorators = [login_required, ]
    model = Node

    def get_queryset(self):
        if g.user.is_admin:
            return self.model.query
        return self.model.query.filter(self.model.owner == g.user)

    def put(self, node_id):
        json_data = request.get_json()
        if not json_data:
            return jsonify({'message': 'Invalid request'}), 400

        # Here is part which I can't make it work for me
        data, errors = node_schema.load(json_data)
        if errors:
            return jsonify(errors), 422

        queryset = self.get_queryset()


        node = queryset.filter(Node.id == node_id).first_or_404()
        # Here I need some way to update this object
        node.update(data) #=> raises AttributeError: 'Node' object has no attribute 'update'

        # Also tried:
        # node = queryset.filter(Node.id == node_id)
        # node.update(data) <-- It doesn't if know there is any object
        # Wrote testcase, when user1 tries to modify node of user2. Node doesn't change (OK), but user1 gets status code 200 (NOT OK).

        db.session.commit()
        return jsonify(), 200

回答1:

You have to pass the object being edited as parameter in schema.load(), like this:

node_schema.load(json_data, instance=Node().query.get(node_id))

And if you want to load without all required fields of Model, you can add the "partial=True", like this:

node_schema.load(json_data, instance=Node().query.get(node_id), partial=True)

http://marshmallow-sqlalchemy.readthedocs.org/en/latest/api_reference.html#marshmallow_sqlalchemy.ModelSchema.load



回答2:

I wrestled with this issue for some time, and in consequence came back again and again to this post. In the end what made my situation difficult was that there was a confounding issue involving SQLAlchemy sessions. I figure this is common enough to Flask, Flask-SQLAlchemy, SQLAlchemy, and Marshmallow, to put down a discussion. I certainly, do not claim to be an expert on this, and yet I believe what I state below is essentially correct.

The db.session is, in fact, closely tied to the process of updating the DB with Marshmallow, and because of that decided to to give the details, but first the short of it.

Short Answer

Here is the answer I arrived at for updating the database using Marshmallow. It is a different approach from the very helpful post of Jair Perrut. I did look at the Marshmallow API and yet was unable to get his solution working in the code presented, because at the time I was experimenting with his solution I was not managing my SQLAlchemy sessions properly. To go a bit further, one might say that I wasn't managing them at all. The model can be updated in the following way:

if user['id']:
    user_model = user_schema.load(user)
    db.session.add(user_model.data)
    db.session.commit()
else:
    user_model = user_schema.load(user)
    db.session.add(user_model.data)
    db.sessiom.commit()

Give the session.add() a model with primary key and it will assume an update, leave the primary key out and a new record is created instead. This isn't all that surprising since MySQL has an ON DUPLICATE KEY UPDATE clause which performs an update if the key is present and creates if not.

Details

SQLAlchemy sessions are handled by Flask-SQLAlchemy during a request to the application. At the beginning of the request the session is opened, and when the request is closed that session is also closed. Flask provides hooks for setting up and tearing down the application where code for managing sessions and connections may be found. In the end, though, the SQLAlchemy session is managed by the developer, and Flask-SQLAlchemy just helps. Here is a particular case that illustrates the management of sessions.

Consider a function that gets a user dictionary as an argument and uses that with Marshmallow() to load the dictionary into a model. In this case, what is required is not the creation of a new object, but the update of an existing object. There are 2 things to keep in mind at the start:

  • The model classes are defined in a python module separate from any code, and these models require the session. Often the developer (Flask documentation) will put a line db = SQLAlchemy() at the head of this file to meet this requirement. This in fact, creates a session for the model.
    • from flask_sqlalchemy import SQLAlchemy
    • db = SQLAlchemy()
  • In some other separate file there may be a need for a SQLAlchemy session as well. For example, the code may need to update the model, or create a new entry, by calling a function there. Here is where one might find db.session.add(user_model) and db.session.commit(). This session is created in the same way as in the bullet point above.

There are 2 SQLAlchemy sessions created. The model sits in one (SignallingSession) and the module uses its own (scoped_session). In fact, there are 3. The Marshmallow UserSchema has sqla_session = db.session: a session is attached to it. This then is the third, and the details are found in the code below:

from marshmallow_sqlalchemy import ModelSchema
from donate_api.models.donation import UserModel
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

class UserSchema(ModelSchema):
    class Meta(object):
        model = UserModel
        strict = True
        sqla_session = db.session

def some_function(user):
    user_schema = UserSchema()
    user['customer_id'] = '654321'
    if user['id']:
        user_model = user_schema.load(user)

        # Debug code:
        user_model_query = UserModel.query.filter_by(id=3255161).first()
        print db.session.object_session(user_model_query)
        print db.session.object_session(user_model.data)
        print db.session

        db.session.add(user_model.data)
        db.session.commit()
    else:
        user_model = user_schema.load(user)
        db.session.add(user_model.data)
        db.sessiom.commit()
    return

At the head of this module the model is imported, which creates its session, and then the module will create its own. Of course, as pointed out there is also the Marshmallow session. This is entirely acceptable to some degree because SQLAlchemy allows the developer to manage the sessions. Consider what happens when some_function(user) is called where user['id'] is assigned some value that exists in the database.

Since the user includes a valid primary key then db.session.add(user_model.data) knows that it is not creating a new row, but updating an existing one. This behavior should not be surprising, and is to be at least somewhat expected since from the MySQL documentation:

13.2.5.2 INSERT ... ON DUPLICATE KEY UPDATE Syntax
If you specify an ON DUPLICATE KEY UPDATE clause and a row to be inserted would cause a duplicate value in a UNIQUE index or PRIMARY KEY, an UPDATE of the old row occurs.

The snippet of code is then seen to be updating the customer_id on the dictionary for the user with primary key 32155161. The new customer_id is '654321'. The dictionary is loaded with Marshmallow and a commit done to the database. Examining the database it can be found that it was indeed updated. You might try two ways of verifying this:

  • In the code: db.session.query(UserModel).filter_by(id=325516).first()
  • In MySQL: select * from user

If you were to consider the following:

  • In the code: UserModel.query.filter_by(id=3255161).customer_id

You would find that the query brings back None. The model is not synchronized with the database. I have failed to manage our SQLAlchemy sessions correctly. In an attempt to bring clarity to this consider the output of the print statements when separate imports are made:

  • <sqlalchemy.orm.session.SignallingSession object at 0x7f81b9107b90>
  • <sqlalchemy.orm.session.SignallingSession object at 0x7f81b90a6150>
  • <sqlalchemy.orm.scoping.scoped_session object at 0x7f81b95eac50>

In this case the UserModel.query session is different from the Marshmallow session. The Marshmallow session is what gets loaded and added. This means that querying the model will not show our changes. In fact, if we do:

  • db.session.object_session(user_model.data).commit()

The model query will now bring back the updated customer_id! Consider the second alternative where the imports are done through flask_essentials:

from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow

db = SQLAlchemy()
ma = Marshmallow()
  • <sqlalchemy.orm.session.SignallingSession object at 0x7f00fe227910>
  • <sqlalchemy.orm.session.SignallingSession object at 0x7f00fe227910>
  • <sqlalchemy.orm.scoping.scoped_session object at 0x7f00fed38710>

And the UserModel.query session is now the same as the user_model.data (Marshmallow) session. Now the UserModel.query does reflect the change in the database: the Marshmallow and UserModel.query sessions are the same.

A note: the signalling session is the default session that Flask-SQLAlchemy uses. It extends the default session system with bind selection and modification tracking.



回答3:

I have rolled out own solution. Hope it helps someone else. Solution implements update method on Node model.

Solution:

class Node(db.Model):
    # ...

    def update(self, **kwargs):
        # py2 & py3 compatibility do:
        # from six import iteritems
        # for key, value in six.iteritems(kwargs):
        for key, value in  kwargs.items():
            setattr(self, key, value)


class NodeAPI(MethodView):
    decorators = [login_required, ]
    model = Node

    def get_queryset(self):
        if g.user.is_admin:
            return self.model.query
        return self.model.query.filter(self.model.owner == g.user)

    def put(self, node_id):
        json_data = request.get_json()
        if not json_data:
            abort(400)

        data, errors = node_schema.load(json_data)  # validate with marshmallow
        if errors:
            return jsonify(errors), 422

        queryset = self.get_queryset()
        node = queryset.filter(self.model.id == node_id).first_or_404()
        node.update(**data)
        db.session.commit()
        return jsonify(message='Successfuly updated'), 200