ensure unhashable types work the same in 1.0 as in 0.9 and former

Issue #3416 resolved
Konsta Vesterinen created an issue

Before SA 1.0 I could have denormalized schemas where foreign keys pointed to unhashable types (let's say HSTORE). This was very common in same cases for me as I had for example denormalized translation columns using HSTORE as the underlying data type. Then foreign keys with onupdate='CASCADE' where used for automatic real-time denormalization when data changed. I created a simplified test case:

import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import HSTORE


engine = sa.create_engine(
    'postgres://postgres@localhost/sqlalchemy_utils_test'
)
Base = declarative_base()
Session = sessionmaker(bind=engine)
session = Session()

session.execute('CREATE EXTENSION IF NOT EXISTS hstore')
session.commit()


class Category(Base):
    __tablename__ = 'category'
    id = sa.Column(sa.Integer, primary_key=True)
    data = sa.Column(HSTORE)
    __table_args__ = (
        sa.Index(
            'some_index',
            data,
            id,
            unique=True
        ),
    )


class Article(Base):
    __tablename__ = 'article'
    id = sa.Column(sa.Integer, primary_key=True)

    name = sa.Column(sa.String)
    category_id = sa.Column(sa.Integer)
    category_data = sa.Column(HSTORE)
    category = sa.orm.relationship(Category)

    __table_args__ = (
        sa.ForeignKeyConstraint(
            [category_data, category_id],
            ['category.data', 'category.id'],
            onupdate='CASCADE'
        ),
    )


Base.metadata.create_all(bind=session.bind)


article = Article(name='Some article', category=Category(data={'1': '2'}))
session.add(article)
session.commit()
category = Category(data={'2': '2'})
session.commit()
article.category = category
session.commit()


Base.metadata.drop_all(bind=session.bind)

throws Exception

...
    elif orm_util._never_set.intersection(params.values()):
TypeError: unhashable type: 'dict'

The problem is even deeper than this as the following code block illustrates:

import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.dialects.postgresql import HSTORE


engine = sa.create_engine(
    'postgres://postgres@localhost/sqlalchemy_utils_test'
)
Base = declarative_base()
Session = sessionmaker(bind=engine)
session = Session()

session.execute('CREATE EXTENSION IF NOT EXISTS hstore')
session.commit()


class Article(Base):
    __tablename__ = 'article'
    id = sa.Column(HSTORE, primary_key=True)


Base.metadata.create_all(bind=session.bind)


article = Article(id={'1': '2'})
session.add(article)
session.commit()


Base.metadata.drop_all(bind=session.bind)

Which throws exception:

Traceback (most recent call last):
  File "sa_test.py", line 42, in <module>
    session.commit()
  File ".../sqlalchemy/orm/session.py", line 790, in commit
    self.transaction.commit()
  File ".../sqlalchemy/orm/session.py", line 392, in commit
    self._prepare_impl()
  File ".../sqlalchemy/orm/session.py", line 372, in _prepare_impl
    self.session.flush()
  File ".../sqlalchemy/orm/session.py", line 2004, in flush
    self._flush(objects)
  File ".../sqlalchemy/orm/session.py", line 2122, in _flush
    transaction.rollback(_capture_exception=True)
  File ".../sqlalchemy/util/langhelpers.py", line 60, in __exit__
    compat.reraise(exc_type, exc_value, exc_tb)
  File ".../sqlalchemy/util/compat.py", line 182, in reraise
    raise value
  File ".../sqlalchemy/orm/session.py", line 2086, in _flush
    flush_context.execute()
  File ".../sqlalchemy/orm/unitofwork.py", line 373, in execute
    rec.execute(self)
  File ".../sqlalchemy/orm/unitofwork.py", line 532, in execute
    uow
  File ".../sqlalchemy/orm/persistence.py", line 149, in save_obj
    base_mapper, states, uowtransaction
  File ".../sqlalchemy/orm/persistence.py", line 292, in _organize_states_for_save
    instance_key in uowtransaction.session.identity_map:
  File ".../sqlalchemy/orm/identity.py", line 96, in __contains__
    if key in self._dict:
TypeError: unhashable type: 'dict'

The second issue is not critical for me but the first is. I have many projects using HSTORE based translations and denormalization.

Comments (10)

  1. Mike Bayer repo owner

    OK that second issue, that is definitely not a new problem - we hash PKs in every SQLA version. Can you confirm that ? the first one with the _never_set(), that's fixable, we don't require that mapped classes or datatypes are hashable in general.

  2. Mike Bayer repo owner

    second case: nothing new:

    #!
    
    
    classics-MacBook-Pro:~ classic$ cd tmp/sa099
    classics-MacBook-Pro:sa099 classic$ python ~/dev/sqlalchemy/test.py
    Traceback (most recent call last):
      File "/Users/classic/dev/sqlalchemy/test.py", line 28, in <module>
        session.commit()
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 788, in commit
        self.transaction.commit()
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 384, in commit
        self._prepare_impl()
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 364, in _prepare_impl
        self.session.flush()
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 1985, in flush
        self._flush(objects)
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 2103, in _flush
        transaction.rollback(_capture_exception=True)
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/util/langhelpers.py", line 60, in __exit__
        compat.reraise(exc_type, exc_value, exc_tb)
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/session.py", line 2067, in _flush
        flush_context.execute()
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/unitofwork.py", line 372, in execute
        rec.execute(self)
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/unitofwork.py", line 526, in execute
        uow
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/persistence.py", line 46, in save_obj
        uowtransaction)
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/persistence.py", line 162, in _organize_states_for_save
        instance_key in uowtransaction.session.identity_map:
      File "/Users/classic/tmp/sa099/lib/sqlalchemy/orm/identity.py", line 87, in __contains__
        if dict.__contains__(self, key):
    TypeError: unhashable type: 'dict'
    classics-MacBook-Pro:sa099 classic$ cd ../  
    classics-MacBook-Pro:tmp classic$ cd sa084/
    classics-MacBook-Pro:sa084 classic$ python ~/dev/sqlalchemy/test.py
    Traceback (most recent call last):
      File "/Users/classic/dev/sqlalchemy/test.py", line 28, in <module>
        session.commit()
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 721, in commit
        self.transaction.commit()
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 354, in commit
        self._prepare_impl()
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 334, in _prepare_impl
        self.session.flush()
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 1818, in flush
        self._flush(objects)
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 1936, in _flush
        transaction.rollback(_capture_exception=True)
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/util/langhelpers.py", line 58, in __exit__
        compat.reraise(exc_type, exc_value, exc_tb)
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/session.py", line 1900, in _flush
        flush_context.execute()
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/unitofwork.py", line 372, in execute
        rec.execute(self)
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/unitofwork.py", line 525, in execute
        uow
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/persistence.py", line 45, in save_obj
        uowtransaction)
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/persistence.py", line 161, in _organize_states_for_save
        instance_key in uowtransaction.session.identity_map:
      File "/Users/classic/tmp/sa084/lib/sqlalchemy/orm/identity.py", line 83, in __contains__
        if dict.__contains__(self, key):
    TypeError: unhashable type: 'dict'
    
  3. Mike Bayer repo owner

    in 0.9, if we have a mapping like this:

    class Article(Base):
        __tablename__ = 'article'
        id = sa.Column(sa.Integer, primary_key=True)
    
        name = sa.Column(sa.String)
        category_id = sa.Column(sa.Integer)
        category_data = sa.Column(HSTORE)
        category = sa.orm.relationship(Category, load_on_pending=True)
    

    then load from pending:

    article = Article(name='Some article',
    category_id=cat.id, category_data={"foo": "bar"})
    session.add(article)
    print article.category
    

    we hit the same logic that's failing here. in 0.9, it is this:

            if pending:
                bind_values = sql_util.bind_values(lazy_clause)
                if None in bind_values:
                    return None
    

    so doesn't fail. in 1.0 because we're checking for more than None it's this:

            if pending and orm_util._none_set.intersection(params.values()):
                return None
            elif orm_util._never_set.intersection(params.values()):
                return None
    

    obviously if i change that to do a piecemeal check for each value, we are less efficient in the vast majority of cases. not too big a deal to fix though. relationship() on a dictionary type, pretty odd case (I'd never do it, but we can support it).

  4. Mike Bayer repo owner
    • Fixed unexpected-use regression where in the odd case that the primaryjoin of a relationship involved comparison to an unhashable type such as an HSTORE, lazy loads would fail due to a hash-oriented check on the statement parameters, modified in 1.0 as a result of 🎫3061 to use hashing and modified in 🎫3368 to occur in cases more common than "load on pending". The values are now checked for the __hash__ attribute beforehand. fixes #3416

    → <<cset 1b120563905e>>

  5. Mike Bayer repo owner

    unhashable PK values is a much bigger deal, but also I'd favor that PK values always use types that are hashable, in the case of an HSTORE a custom type might need to implement something; such as returning as an immutabledict which is hashable.

  6. Konsta Vesterinen reporter

    Thanks for very fast response Mike!

    Also never mind the unhashable PKs (I thought it was a bug but I apparently didn't test it with 0.9 series). Could we make it fail faster though and with a better error message? We could check that the python types of the primary key column types are hashable.

    from collections import Hashable
    
    isinstance(column.type.python_type, Hashable)
    
  7. Mike Bayer repo owner

    OK I'd kind of want that to be just a warning if its for 1.0.4. Tons of things broke in 1.0 and I really don't want any more surprises for people

  8. Mike Bayer repo owner

    but also 1.0.4 should be today maybe put that into 1.1 as a new issue? it's pretty low priority.

  9. Log in to comment