1

I'm aware there are many other questions about the exact same issue, but I've tried their answers and none have worked so far.

I'm trying to delete records from a table which has relationships with other tables. The foreign key in those tables are nullable=false, so trying to delete a record which is in use by another table should raise an exception.

But even when surrounding the delete statement with a catchall try-except the error is still not caught, so I suspect the exception may be raised somewhere else.

I'm using SQLite with SQLAlchemy in a Pyramid framework, and my session is configured with the ZopeTransactionExtension.

This is how I'm trying to delete: In views.py

from sqlalchemy.exc import IntegrityError
from project.app.models import (
    DBSession,
    foo)

@view_config(route_name='fooview', renderer='json', permission='view')
def fooview(request):
    """ The fooview handles different cases for foo
        depending on the http method
    """
    if request.method == 'DELETE':
        if not request.has_permission('edit'):
            return HTTPForbidden()

        deleteid = request.matchdict['id']
        deletethis = DBSession.query(foo).filter_by(id=deleteid).first()

        try:
           qry = DBSession.delete(deletethis)
           transaction.commit()
           if qry == 0:
               return HTTPNotFound(text=u'Foo not found')
       except IntegrityError:
           DBSession.rollback()
           return HTTPConflict(text=u'Foo in use')

        return HTTPOk()

In models.py I set up DBSession and my models:

from zope.sqlalchemy import ZopeTransactionExtension
from sqlalchemy.orm import (
    scoped_session,
    sessionmaker,
    relationship,
    backref,
)

DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension('changed')))
Base = declarative_base()

class foo(Base):
    """ foo defines a unit used by bar
    """
    __tablename__ = 'foo'
    id = Column(Integer, primary_key=True)
    name = Column(Text(50))

    bars = relationship('bar')

class bar(Base):
    __tablename__ = 'bar'
    id = Column(Integer, primary_key=True)
    fooId = Column(Integer, ForeignKey('foo.id'), nullable=False)

    foo = relationship('foo')

And in __init__.py I configure my session like so:

from project.app.models import (
    DBSession,
    Base,
)

def main(global_config, **settings):
    """ This function returns a Pyramid WSGI application.
    """
    engine = engine_from_config(settings, 'sqlalchemy.')
    # fix for association_table cascade delete issues
    engine.dialect.supports_sane_rowcount = engine.dialect.supports_sane_multi_rowcount = False
    DBSession.configure(bind=engine)
    Base.metadata.bind = engine

Using this setup I get

IntegrityError: (IntegrityError) NOT NULL constraint failed

Traceback here.

If I replace transaction.commit() with DBSession.flush(), I get

ResourceClosedError: This transaction is closed

And if I remove the transaction.commit(), I still get the same error, but without a clear point of origin.

UPDATE: I ran some nose tests, and in some cases, but not all, the exception was handled correctly.

In my tests I import the session and configure it:

from optimate.app.models import (
    DBSession,
    Base,
    foo)

def _initTestingDB():
    """ Build a database with default data
    """
    engine = create_engine('sqlite://')
    Base.metadata.create_all(engine)
    DBSession.configure(bind=engine)
    with transaction.manager:
        # add test data

class TestFoo(unittest.TestCase):
    def setUp(self):
        self.config = testing.setUp()
        self.session = _initTestingDB()

    def tearDown(self):
        DBSession.remove()
        testing.tearDown()

    def _callFUT(self, request):
        from project.app.views import fooview
        return fooview(request)

    def test_delete_foo_keep(self):
        request = testing.DummyRequest()
        request.method = 'DELETE'
        request.matchdict['id'] = 1
        response = self._callFUT(request)
        # foo is used so it is not deleted
        self.assertEqual(response.code, 409)

    def test_delete_foo_remove(self):
        _registerRoutes(self.config)
        request = testing.DummyRequest()
        request.method = 'DELETE'
        request.matchdict['id'] = 2
        response = self._callFUT(request)
        # foo is not used so it is deleted
        self.assertEqual(response.code, 200)

Does anyone know what's going on?

Niel
  • 1,856
  • 2
  • 23
  • 45
  • Make readers aware of what is going on by showing code/configuration that sets up ZopeTransactionExtension in your pyramid application. Show complete code of your view method/function instead of excerpts to match it in the exception traceback. Show how you set up your nose tests to find out what is different there, since you are experiencing different behaviour. – Sascha Gottfried Dec 02 '15 at 12:25
  • The point of using ZopeTransactionExtension is that pyramid is handling transactions transparently. But your application code is interacting with the transaction. Please elaborate on your needs to do this. – Sascha Gottfried Dec 02 '15 at 12:38
  • Sometimes I need the result of a query before zope thinks the session has finished. And if I remove `transaction.commit()` the exception does not get raised in the try-block. – Niel Dec 02 '15 at 13:50
  • I'll work on adding more of my code. – Niel Dec 02 '15 at 13:51

2 Answers2

2

Might be you just "doing it wrong". Your question addresses two issues. Handling transaction level errors raised by database integrity errors and modelling application code/models/queries to implement business logic. My answer focuses on writing code that fits common patterns while using pyramid_tm for transaction management and sqlalchemy as an ORM.

In Pyramid, if you've configured your session (which the scaffold does for you automatically) to use the ZopeTransactionExtension, then session is not flushed/committed until after the view has executed. If you want to catch any SQL errors yourself in your view, you need to force a flush to send the SQL to the engine. DBSession.flush() should do it after the delete(...).

If you raise any of 4xx/5xx HTTP return codes like pyramid exception HTTPConflict the transaction will be aborted.

@view_config(route_name='fooview', renderer='json', permission='view')
def fooview(request):
    """ The fooview handles different cases for foo
        depending on the http method
    """
    if request.method == 'DELETE':
        if not request.has_permission('edit'):
            return HTTPForbidden()

        deleteid = request.matchdict['id']
        deletethis = DBSession.query(foo).filter_by(id=deleteid).first()
        if not deletethis:
            raise HTTPNotFound()

        try:
           DBSession.delete(deletethis)
           DBSession.flush()
        except IntegrityError as e:
           log.debug("delete operation not possible for id {0}".format(deleteid)
           raise HTTPConflict(text=u'Foo in use')

        return HTTPOk()

This excerpt from todopyramid/models.py highlights how to delete a collection item without using DBSession object.

def delete_todo(self, todo_id):
    """given a todo ID we delete it is contained in user todos 

    delete from a collection
    http://docs.sqlalchemy.org/en/latest/orm/session.html#deleting-from-collections
    https://stackoverflow.com/questions/10378468/deleting-an-object-from-collection-in-sqlalchemy"""
    todo_item = self.todo_list.filter(
            TodoItem.id == todo_id)

    todo_item.delete()

This sample code from pyramid_blogr show clearly how simple pyramid view code to delete SQL database items could look like. Usually you do not have to interact with the transaction. This is a feature - as advertised as one the unique feature of pyramid. Just pick any of the available pyramid tutorials that use sqlalchemy and try to stick to the patterns as much as possible. If you address the problem at the application model level the transaction machinery will hide in the background unless you have a clear need for its services.

@view_config(route_name='blog_action', match_param="action=delete", permission='delete')
def blog_delete(request):
    entry_id = request.params.get('id', -1)
    entry = Entry.by_id(entry_id)
    if not entry:
        return HTTPNotFound()
    DBSession.delete(entry)
    return HTTPFound(location=request.route_url('home'))

To provide meaningful error messages to application users you either catch errors on database contraints at database model layer or at pyramid view layer. Catching sqlalchemy exceptions to provide error messages could look like in this sample code

from sqlalchemy.exc import OperationalError as SqlAlchemyOperationalError

@view_config(context=SqlAlchemyOperationalError)
def failed_sqlalchemy(exception, request):
    """catch missing database, logout and redirect to homepage, add flash message with error

    implementation inspired by pylons group message 
    https://groups.google.com/d/msg/pylons-discuss/BUtbPrXizP4/0JhqB2MuoL4J
    """
    msg = 'There was an error connecting to database'
    request.session.flash(msg, queue='error')
    headers = forget(request)

    # Send the user back home, everything else is protected  
    return HTTPFound(request.route_url('home'), headers=headers)

References

Community
  • 1
  • 1
Sascha Gottfried
  • 3,303
  • 20
  • 30
  • 1
    I understand what you're trying to say, but my goal is to prevent deletion of records without having to check every relationship, and provide a custom error response. I had hoped using `NOT NULL` and `IntegrityError` would be the most elegant solution. Also, the `delete_todo` function deletes items in a collection, I need to delete the item directly. – Niel Dec 02 '15 at 13:59
  • 1
    I added code sample that handles loss of database connection. Adapt it to your needs by catching IntegrityError. – Sascha Gottfried Dec 02 '15 at 14:21
  • 1
    I added another code sample inspired by one of the related/linked posts – Sascha Gottfried Dec 02 '15 at 17:31
  • 1
    With regards to using `DBSession.flush()`, I have already tried that and it gave me problems. If I omit `DBSession.rollback()`, as in your example, I get `InvalidRequestError: This Session's transaction has been rolled back due to a previous exception during flush.`, if I add it I get `ResourceClosedError: This transaction is closed`. – Niel Dec 02 '15 at 18:04
  • 1
    Means the code is working as expected. The exception is catched in view code and then it is your application code that returns HTTPConflict. Return HTTPOk and find out if that is what you want. – Sascha Gottfried Dec 02 '15 at 18:22
  • 1
    I'm sorry I don't understand? – Niel Dec 02 '15 at 18:28
  • 1
    With the sample code provided you can now catch SQLAlchemy IntegrityError. So your question is answered. Watch out for your logging statement in the exception handler. I think your current problem is more related to application design, but a potential answer deserves even more application context from you. Please do that in another question. – Sascha Gottfried Dec 02 '15 at 20:21
  • 1
    I think I understand what you mean...because I'm returning an HTTP error response the transaction manager is re-raising the error, I'll ask about working around it in a separate question. – Niel Dec 02 '15 at 23:41
  • 1
    The transaction manager is not re-raising the error. The transaction manager aborts the current transaction and tells you why. Actually because you returned HTTPConflict after catching IntegrityError. If you decide not to catch IntegrityError this will be reason to abort the transaction. What you need to do depends on what service you want to offer or what callers expect. – Sascha Gottfried Dec 03 '15 at 14:55
  • 1
    Yes because I'm raising HTTPConflict the transaction is aborted and I get an error message. The caller is expecting an HTTP error message so I need to implement it somehow. – Niel Dec 03 '15 at 15:18
  • 1
    Apparently you need to raise these exceptions instead of returning them. I edit my answer accordingly. http://pyramid-sqlalchemy.readthedocs.org/en/latest/transactions.html#using-pyramid-tm http://docs.pylonsproject.org/projects/pyramid-tm/en/latest/#transaction-usage – Sascha Gottfried Dec 03 '15 at 16:29
  • 1
    Oh my...that worked perfectly thanks so much! I never even considered the difference between raising and returning. – Niel Dec 03 '15 at 16:34
1

Not sure if this helps - I did not quite capture from the traceback what goes wrong, would need more time. But you can use transaction manager like this:

from sqlalchemy.exc import IntegrityError


try:
   with transaction.manager:
        deletethis = DBSession.query(foo).filter_by(id=deleteid).first()
        qry = DBSession.delete(deletethis)
        if qry == 0:
            return HTTPNotFound()
    # transaction.manager commits when with context manager exits here
except IntegrityError:
   DBSession.rollback()
   return HTTPConflict()

return HTTPOk()  
Mikko Ohtamaa
  • 82,057
  • 50
  • 264
  • 435