Refactor docs, add merge_references
This commit is contained in:
@@ -4,12 +4,13 @@ Changelog
|
|||||||
Here you can see the full list of changes between each SQLAlchemy-Utils release.
|
Here you can see the full list of changes between each SQLAlchemy-Utils release.
|
||||||
|
|
||||||
|
|
||||||
0.26.1 (2014-05-xx)
|
0.26.1 (2014-05-14)
|
||||||
^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
- Added get_bind
|
- Added get_bind
|
||||||
- Added group_foreign_keys
|
- Added group_foreign_keys
|
||||||
- Added get_mapper
|
- Added get_mapper
|
||||||
|
- Added merge_references
|
||||||
|
|
||||||
|
|
||||||
0.26.0 (2014-05-07)
|
0.26.0 (2014-05-07)
|
||||||
|
@@ -5,18 +5,6 @@ Database helpers
|
|||||||
.. module:: sqlalchemy_utils.functions
|
.. module:: sqlalchemy_utils.functions
|
||||||
|
|
||||||
|
|
||||||
is_indexed_foreign_key
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autofunction:: is_indexed_foreign_key
|
|
||||||
|
|
||||||
|
|
||||||
non_indexed_foreign_keys
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autofunction:: non_indexed_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
database_exists
|
database_exists
|
||||||
^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
40
docs/foreign_key_helpers.rst
Normal file
40
docs/foreign_key_helpers.rst
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
Foreign key helpers
|
||||||
|
===================
|
||||||
|
|
||||||
|
.. module:: sqlalchemy_utils.functions
|
||||||
|
|
||||||
|
|
||||||
|
dependent_objects
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: dependent_objects
|
||||||
|
|
||||||
|
|
||||||
|
get_referencing_foreign_keys
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: get_referencing_foreign_keys
|
||||||
|
|
||||||
|
|
||||||
|
group_foreign_keys
|
||||||
|
^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: group_foreign_keys
|
||||||
|
|
||||||
|
|
||||||
|
is_indexed_foreign_key
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: is_indexed_foreign_key
|
||||||
|
|
||||||
|
|
||||||
|
merge_references
|
||||||
|
^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: merge_references
|
||||||
|
|
||||||
|
|
||||||
|
non_indexed_foreign_keys
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. autofunction:: non_indexed_foreign_keys
|
@@ -1,5 +1,5 @@
|
|||||||
Generic relationship
|
Generic relationships
|
||||||
====================
|
=====================
|
||||||
|
|
||||||
Generic relationship is a form of relationship that supports creating a 1 to many relationship to any target model.
|
Generic relationship is a form of relationship that supports creating a 1 to many relationship to any target model.
|
||||||
|
|
||||||
|
@@ -16,6 +16,7 @@ SQLAlchemy-Utils provides custom data types and various utility functions for SQ
|
|||||||
decorators
|
decorators
|
||||||
generic_relationship
|
generic_relationship
|
||||||
database_helpers
|
database_helpers
|
||||||
model_helpers
|
foreign_key_helpers
|
||||||
|
orm_helpers
|
||||||
utility_classes
|
utility_classes
|
||||||
license
|
license
|
||||||
|
@@ -1,15 +1,9 @@
|
|||||||
Model helpers
|
ORM helpers
|
||||||
=============
|
===========
|
||||||
|
|
||||||
.. module:: sqlalchemy_utils.functions
|
.. module:: sqlalchemy_utils.functions
|
||||||
|
|
||||||
|
|
||||||
dependent_objects
|
|
||||||
^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autofunction:: dependent_objects
|
|
||||||
|
|
||||||
|
|
||||||
escape_like
|
escape_like
|
||||||
^^^^^^^^^^^
|
^^^^^^^^^^^
|
||||||
|
|
||||||
@@ -46,24 +40,12 @@ get_primary_keys
|
|||||||
.. autofunction:: get_primary_keys
|
.. autofunction:: get_primary_keys
|
||||||
|
|
||||||
|
|
||||||
get_referencing_foreign_keys
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autofunction:: get_referencing_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
get_tables
|
get_tables
|
||||||
^^^^^^^^^^
|
^^^^^^^^^^
|
||||||
|
|
||||||
.. autofunction:: get_tables
|
.. autofunction:: get_tables
|
||||||
|
|
||||||
|
|
||||||
group_foreign_keys
|
|
||||||
^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autofunction:: group_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
query_entities
|
query_entities
|
||||||
^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^
|
||||||
|
|
2
setup.py
2
setup.py
@@ -44,7 +44,7 @@ for name, requirements in extras_require.items():
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='SQLAlchemy-Utils',
|
name='SQLAlchemy-Utils',
|
||||||
version='0.26.0',
|
version='0.26.1',
|
||||||
url='https://github.com/kvesteri/sqlalchemy-utils',
|
url='https://github.com/kvesteri/sqlalchemy-utils',
|
||||||
license='BSD',
|
license='BSD',
|
||||||
author='Konsta Vesterinen, Ryan Leckey, Janne Vanhala, Vesa Uimonen',
|
author='Konsta Vesterinen, Ryan Leckey, Janne Vanhala, Vesa Uimonen',
|
||||||
|
@@ -20,6 +20,7 @@ from .functions import (
|
|||||||
get_tables,
|
get_tables,
|
||||||
group_foreign_keys,
|
group_foreign_keys,
|
||||||
identity,
|
identity,
|
||||||
|
merge_references,
|
||||||
mock_engine,
|
mock_engine,
|
||||||
naturally_equivalent,
|
naturally_equivalent,
|
||||||
render_expression,
|
render_expression,
|
||||||
@@ -32,7 +33,6 @@ from .listeners import (
|
|||||||
force_auto_coercion,
|
force_auto_coercion,
|
||||||
force_instant_defaults
|
force_instant_defaults
|
||||||
)
|
)
|
||||||
from .merge import merge, Merger
|
|
||||||
from .generic import generic_relationship
|
from .generic import generic_relationship
|
||||||
from .proxy_dict import ProxyDict, proxy_dict
|
from .proxy_dict import ProxyDict, proxy_dict
|
||||||
from .query_chain import QueryChain
|
from .query_chain import QueryChain
|
||||||
@@ -67,7 +67,7 @@ from .types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__version__ = '0.26.0'
|
__version__ = '0.26.1'
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -95,7 +95,7 @@ __all__ = (
|
|||||||
group_foreign_keys,
|
group_foreign_keys,
|
||||||
identity,
|
identity,
|
||||||
instrumented_list,
|
instrumented_list,
|
||||||
merge,
|
merge_references,
|
||||||
mock_engine,
|
mock_engine,
|
||||||
naturally_equivalent,
|
naturally_equivalent,
|
||||||
proxy_dict,
|
proxy_dict,
|
||||||
@@ -120,7 +120,6 @@ __all__ = (
|
|||||||
IPAddressType,
|
IPAddressType,
|
||||||
JSONType,
|
JSONType,
|
||||||
LocaleType,
|
LocaleType,
|
||||||
Merger,
|
|
||||||
NumericRangeType,
|
NumericRangeType,
|
||||||
Password,
|
Password,
|
||||||
PasswordType,
|
PasswordType,
|
||||||
|
@@ -8,20 +8,23 @@ from .database import (
|
|||||||
drop_database,
|
drop_database,
|
||||||
escape_like,
|
escape_like,
|
||||||
is_auto_assigned_date_column,
|
is_auto_assigned_date_column,
|
||||||
|
)
|
||||||
|
from .foreign_keys import (
|
||||||
|
dependent_objects,
|
||||||
|
get_referencing_foreign_keys,
|
||||||
|
group_foreign_keys,
|
||||||
is_indexed_foreign_key,
|
is_indexed_foreign_key,
|
||||||
|
merge_references,
|
||||||
non_indexed_foreign_keys,
|
non_indexed_foreign_keys,
|
||||||
)
|
)
|
||||||
from .orm import (
|
from .orm import (
|
||||||
dependent_objects,
|
|
||||||
get_bind,
|
get_bind,
|
||||||
get_columns,
|
get_columns,
|
||||||
get_declarative_base,
|
get_declarative_base,
|
||||||
get_mapper,
|
get_mapper,
|
||||||
get_primary_keys,
|
get_primary_keys,
|
||||||
get_referencing_foreign_keys,
|
|
||||||
get_tables,
|
get_tables,
|
||||||
getdotattr,
|
getdotattr,
|
||||||
group_foreign_keys,
|
|
||||||
has_changes,
|
has_changes,
|
||||||
identity,
|
identity,
|
||||||
naturally_equivalent,
|
naturally_equivalent,
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from sqlalchemy.engine.url import make_url
|
from sqlalchemy.engine.url import make_url
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.schema import MetaData, Table, ForeignKeyConstraint
|
from sqlalchemy.schema import MetaData, Table, ForeignKeyConstraint
|
||||||
@@ -186,55 +185,3 @@ def drop_database(url):
|
|||||||
else:
|
else:
|
||||||
text = "DROP DATABASE %s" % database
|
text = "DROP DATABASE %s" % database
|
||||||
engine.execute(text)
|
engine.execute(text)
|
||||||
|
|
||||||
|
|
||||||
def non_indexed_foreign_keys(metadata, engine=None):
|
|
||||||
"""
|
|
||||||
Finds all non indexed foreign keys from all tables of given MetaData.
|
|
||||||
|
|
||||||
Very useful for optimizing postgresql database and finding out which
|
|
||||||
foreign keys need indexes.
|
|
||||||
|
|
||||||
:param metadata: MetaData object to inspect tables from
|
|
||||||
"""
|
|
||||||
reflected_metadata = MetaData()
|
|
||||||
|
|
||||||
if metadata.bind is None and engine is None:
|
|
||||||
raise Exception(
|
|
||||||
'Either pass a metadata object with bind or '
|
|
||||||
'pass engine as a second parameter'
|
|
||||||
)
|
|
||||||
|
|
||||||
constraints = defaultdict(list)
|
|
||||||
|
|
||||||
for table_name in metadata.tables.keys():
|
|
||||||
table = Table(
|
|
||||||
table_name,
|
|
||||||
reflected_metadata,
|
|
||||||
autoload=True,
|
|
||||||
autoload_with=metadata.bind or engine
|
|
||||||
)
|
|
||||||
|
|
||||||
for constraint in table.constraints:
|
|
||||||
if not isinstance(constraint, ForeignKeyConstraint):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not is_indexed_foreign_key(constraint):
|
|
||||||
constraints[table.name].append(constraint)
|
|
||||||
|
|
||||||
return dict(constraints)
|
|
||||||
|
|
||||||
|
|
||||||
def is_indexed_foreign_key(constraint):
|
|
||||||
"""
|
|
||||||
Whether or not given foreign key constraint's columns have been indexed.
|
|
||||||
|
|
||||||
:param constraint: ForeignKeyConstraint object to check the indexes
|
|
||||||
"""
|
|
||||||
for index in constraint.table.indexes:
|
|
||||||
index_column_names = set(
|
|
||||||
column.name for column in index.columns
|
|
||||||
)
|
|
||||||
if index_column_names == set(constraint.columns):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
350
sqlalchemy_utils/functions/foreign_keys.py
Normal file
350
sqlalchemy_utils/functions/foreign_keys.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
|
import six
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine import reflection
|
||||||
|
from sqlalchemy.orm import object_session, mapperlib
|
||||||
|
from sqlalchemy.schema import MetaData, Table, ForeignKeyConstraint
|
||||||
|
|
||||||
|
from .orm import get_mapper, get_tables
|
||||||
|
from ..query_chain import QueryChain
|
||||||
|
|
||||||
|
|
||||||
|
def get_foreign_key_values(fk, obj):
|
||||||
|
return {
|
||||||
|
fk.constraint.columns[index].key:
|
||||||
|
getattr(obj, element.column.key)
|
||||||
|
for
|
||||||
|
index, element
|
||||||
|
in
|
||||||
|
enumerate(fk.constraint.elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def group_foreign_keys(foreign_keys):
|
||||||
|
"""
|
||||||
|
Return a groupby iterator that groups given foreign keys by table.
|
||||||
|
|
||||||
|
:param foreign_keys: a sequence of foreign keys
|
||||||
|
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
foreign_keys = get_referencing_foreign_keys(User)
|
||||||
|
|
||||||
|
for table, fks in group_foreign_keys(foreign_keys):
|
||||||
|
# do something
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
.. seealso:: :func:`get_referencing_foreign_keys`
|
||||||
|
|
||||||
|
.. versionadded: 0.26.1
|
||||||
|
"""
|
||||||
|
foreign_keys = sorted(
|
||||||
|
foreign_keys, key=lambda key: key.constraint.table.name
|
||||||
|
)
|
||||||
|
return groupby(foreign_keys, lambda key: key.constraint.table)
|
||||||
|
|
||||||
|
|
||||||
|
def get_referencing_foreign_keys(mixed):
|
||||||
|
"""
|
||||||
|
Returns referencing foreign keys for given Table object or declarative
|
||||||
|
class.
|
||||||
|
|
||||||
|
:param mixed:
|
||||||
|
SA Table object or SA declarative class
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
get_referencing_foreign_keys(User) # set([ForeignKey('user.id')])
|
||||||
|
|
||||||
|
get_referencing_foreign_keys(User.__table__)
|
||||||
|
|
||||||
|
|
||||||
|
This function also understands inheritance. This means it returns
|
||||||
|
all foreign keys that reference any table in the class inheritance tree.
|
||||||
|
|
||||||
|
Let's say you have three classes which use joined table inheritance,
|
||||||
|
namely TextItem, Article and BlogPost with Article and BlogPost inheriting
|
||||||
|
TextItem.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
# This will check all foreign keys that reference either article table
|
||||||
|
# or textitem table.
|
||||||
|
get_referencing_foreign_keys(Article)
|
||||||
|
|
||||||
|
.. seealso:: :func:`get_tables`
|
||||||
|
"""
|
||||||
|
if isinstance(mixed, sa.Table):
|
||||||
|
tables = [mixed]
|
||||||
|
else:
|
||||||
|
tables = get_tables(mixed)
|
||||||
|
|
||||||
|
referencing_foreign_keys = set()
|
||||||
|
|
||||||
|
for table in mixed.metadata.tables.values():
|
||||||
|
if table not in tables:
|
||||||
|
for constraint in table.constraints:
|
||||||
|
if isinstance(constraint, sa.sql.schema.ForeignKeyConstraint):
|
||||||
|
for fk in constraint.elements:
|
||||||
|
if any(fk.references(t) for t in tables):
|
||||||
|
referencing_foreign_keys.add(fk)
|
||||||
|
return referencing_foreign_keys
|
||||||
|
|
||||||
|
|
||||||
|
def merge_references(from_, to, foreign_keys=None):
|
||||||
|
"""
|
||||||
|
Merge the references of an entity into another entity.
|
||||||
|
|
||||||
|
Consider the following models::
|
||||||
|
|
||||||
|
class User(self.Base):
|
||||||
|
__tablename__ = 'user'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name = sa.Column(sa.Unicode(255))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'User(name=%r)' % self.name
|
||||||
|
|
||||||
|
class BlogPost(self.Base):
|
||||||
|
__tablename__ = 'blog_post'
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
title = sa.Column(sa.Unicode(255))
|
||||||
|
author_id = sa.Column(sa.Integer, sa.ForeignKey('user.id'))
|
||||||
|
|
||||||
|
author = sa.orm.relationship(User)
|
||||||
|
|
||||||
|
|
||||||
|
Now lets add some data::
|
||||||
|
|
||||||
|
john = self.User(name=u'John')
|
||||||
|
jack = self.User(name=u'Jack')
|
||||||
|
post = self.BlogPost(title=u'Some title', author=john)
|
||||||
|
post2 = self.BlogPost(title=u'Other title', author=jack)
|
||||||
|
self.session.add_all([
|
||||||
|
john,
|
||||||
|
jack,
|
||||||
|
post,
|
||||||
|
post2
|
||||||
|
])
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
If we wanted to merge all John's references to Jack it would be as easy as
|
||||||
|
::
|
||||||
|
|
||||||
|
merge_references(john, jack)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
post.author # User(name='Jack')
|
||||||
|
post2.author # User(name='Jack')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
:param from_: an entity to merge into another entity
|
||||||
|
:param to: an entity to merge another entity into
|
||||||
|
:param foreign_keys: A sequence of foreign keys. By default this is None
|
||||||
|
indicating all referencing foreign keys should be used.
|
||||||
|
|
||||||
|
.. seealso: :func:`dependent_objects`
|
||||||
|
|
||||||
|
.. versionadded: 0.26.1
|
||||||
|
"""
|
||||||
|
if from_.__tablename__ != to.__tablename__:
|
||||||
|
raise TypeError('The tables of given arguments do not match.')
|
||||||
|
|
||||||
|
session = object_session(from_)
|
||||||
|
foreign_keys = get_referencing_foreign_keys(from_)
|
||||||
|
|
||||||
|
for fk in foreign_keys:
|
||||||
|
old_values = get_foreign_key_values(fk, from_)
|
||||||
|
new_values = get_foreign_key_values(fk, to)
|
||||||
|
criteria = (
|
||||||
|
getattr(fk.constraint.table.c, key) == value
|
||||||
|
for key, value in six.iteritems(old_values)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
mapper = get_mapper(fk.constraint.table)
|
||||||
|
except ValueError:
|
||||||
|
query = (
|
||||||
|
fk.constraint.table
|
||||||
|
.update()
|
||||||
|
.where(sa.and_(*criteria))
|
||||||
|
.values(new_values)
|
||||||
|
)
|
||||||
|
session.execute(query)
|
||||||
|
else:
|
||||||
|
print old_values, new_values
|
||||||
|
(
|
||||||
|
session.query(mapper.class_)
|
||||||
|
.filter_by(**old_values)
|
||||||
|
.update(
|
||||||
|
new_values,
|
||||||
|
'evaluate'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dependent_objects(obj, foreign_keys=None):
|
||||||
|
"""
|
||||||
|
Return a :class:`~sqlalchemy_utils.query_chain.QueryChain` that iterates
|
||||||
|
through all dependent objects for given SQLAlchemy object.
|
||||||
|
|
||||||
|
Consider a User object is referenced in various articles and also in
|
||||||
|
various orders. Getting all these dependent objects is as easy as:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from sqlalchemy_utils import dependent_objects
|
||||||
|
|
||||||
|
|
||||||
|
dependent_objects(user)
|
||||||
|
|
||||||
|
|
||||||
|
If you expect an object to have lots of dependent_objects it might be good
|
||||||
|
to limit the results::
|
||||||
|
|
||||||
|
|
||||||
|
dependent_objects(user).limit(5)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
The common use case is checking for all restrict dependent objects before
|
||||||
|
deleting parent object and inform the user if there are dependent objects
|
||||||
|
with ondelete='RESTRICT' foreign keys. If this kind of checking is not used
|
||||||
|
it will lead to nasty IntegrityErrors being raised.
|
||||||
|
|
||||||
|
In the following example we delete given user if it doesn't have any
|
||||||
|
foreign key restricted dependent objects.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy_utils import get_referencing_foreign_keys
|
||||||
|
|
||||||
|
|
||||||
|
user = session.query(User).get(some_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
deps = list(
|
||||||
|
dependent_objects(
|
||||||
|
user,
|
||||||
|
(
|
||||||
|
fk for fk in get_referencing_foreign_keys(User)
|
||||||
|
# On most databases RESTRICT is the default mode hence we
|
||||||
|
# check for None values also
|
||||||
|
if fk.ondelete == 'RESTRICT' or fk.ondelete is None
|
||||||
|
)
|
||||||
|
).limit(5)
|
||||||
|
)
|
||||||
|
|
||||||
|
if deps:
|
||||||
|
# Do something to inform the user
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
session.delete(user)
|
||||||
|
|
||||||
|
|
||||||
|
:param obj: SQLAlchemy declarative model object
|
||||||
|
:param foreign_keys:
|
||||||
|
A sequence of foreign keys to use for searching the dependent_objects
|
||||||
|
for given object. By default this is None, indicating that all foreign
|
||||||
|
keys referencing the object will be used.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
This function does not support exotic mappers that use multiple tables
|
||||||
|
|
||||||
|
.. seealso:: :func:`get_referencing_foreign_keys`
|
||||||
|
.. seealso:: :func:`merge_references`
|
||||||
|
|
||||||
|
.. versionadded: 0.26.0
|
||||||
|
"""
|
||||||
|
if foreign_keys is None:
|
||||||
|
foreign_keys = get_referencing_foreign_keys(obj)
|
||||||
|
|
||||||
|
session = object_session(obj)
|
||||||
|
|
||||||
|
chain = QueryChain([])
|
||||||
|
classes = obj.__class__._decl_class_registry
|
||||||
|
|
||||||
|
for table, keys in group_foreign_keys(foreign_keys):
|
||||||
|
for class_ in classes.values():
|
||||||
|
if hasattr(class_, '__table__') and class_.__table__ == table:
|
||||||
|
criteria = []
|
||||||
|
visited_constraints = []
|
||||||
|
for key in keys:
|
||||||
|
if key.constraint not in visited_constraints:
|
||||||
|
visited_constraints.append(key.constraint)
|
||||||
|
subcriteria = [
|
||||||
|
getattr(class_, column.key) ==
|
||||||
|
getattr(
|
||||||
|
obj,
|
||||||
|
key.constraint.elements[index].column.key
|
||||||
|
)
|
||||||
|
for index, column
|
||||||
|
in enumerate(key.constraint.columns)
|
||||||
|
]
|
||||||
|
criteria.append(sa.and_(*subcriteria))
|
||||||
|
|
||||||
|
query = session.query(class_).filter(
|
||||||
|
sa.or_(
|
||||||
|
*criteria
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chain.queries.append(query)
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def non_indexed_foreign_keys(metadata, engine=None):
|
||||||
|
"""
|
||||||
|
Finds all non indexed foreign keys from all tables of given MetaData.
|
||||||
|
|
||||||
|
Very useful for optimizing postgresql database and finding out which
|
||||||
|
foreign keys need indexes.
|
||||||
|
|
||||||
|
:param metadata: MetaData object to inspect tables from
|
||||||
|
"""
|
||||||
|
reflected_metadata = MetaData()
|
||||||
|
|
||||||
|
if metadata.bind is None and engine is None:
|
||||||
|
raise Exception(
|
||||||
|
'Either pass a metadata object with bind or '
|
||||||
|
'pass engine as a second parameter'
|
||||||
|
)
|
||||||
|
|
||||||
|
constraints = defaultdict(list)
|
||||||
|
|
||||||
|
for table_name in metadata.tables.keys():
|
||||||
|
table = Table(
|
||||||
|
table_name,
|
||||||
|
reflected_metadata,
|
||||||
|
autoload=True,
|
||||||
|
autoload_with=metadata.bind or engine
|
||||||
|
)
|
||||||
|
|
||||||
|
for constraint in table.constraints:
|
||||||
|
if not isinstance(constraint, ForeignKeyConstraint):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not is_indexed_foreign_key(constraint):
|
||||||
|
constraints[table.name].append(constraint)
|
||||||
|
|
||||||
|
return dict(constraints)
|
||||||
|
|
||||||
|
|
||||||
|
def is_indexed_foreign_key(constraint):
|
||||||
|
"""
|
||||||
|
Whether or not given foreign key constraint's columns have been indexed.
|
||||||
|
|
||||||
|
:param constraint: ForeignKeyConstraint object to check the indexes
|
||||||
|
"""
|
||||||
|
for index in constraint.table.indexes:
|
||||||
|
index_column_names = set(
|
||||||
|
column.name for column in index.columns
|
||||||
|
)
|
||||||
|
if index_column_names == set(constraint.columns):
|
||||||
|
return True
|
||||||
|
return False
|
@@ -61,8 +61,12 @@ def get_mapper(mixed):
|
|||||||
]
|
]
|
||||||
if len(mappers) > 1:
|
if len(mappers) > 1:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Could not get mapper for '%r'. Multiple mappers found."
|
"Multiple mappers found for table '%s'."
|
||||||
% mixed
|
% mixed.name
|
||||||
|
)
|
||||||
|
elif not mappers:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not get mapper for table '%s'."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return mappers[0]
|
return mappers[0]
|
||||||
@@ -104,188 +108,6 @@ def get_bind(obj):
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def dependent_objects(obj, foreign_keys=None):
|
|
||||||
"""
|
|
||||||
Return a :class:`~sqlalchemy_utils.query_chain.QueryChain` that iterates
|
|
||||||
through all dependent objects for given SQLAlchemy object.
|
|
||||||
|
|
||||||
Consider a User object is referenced in various articles and also in
|
|
||||||
various orders. Getting all these dependent objects is as easy as:
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
from sqlalchemy_utils import dependent_objects
|
|
||||||
|
|
||||||
|
|
||||||
dependent_objects(user)
|
|
||||||
|
|
||||||
|
|
||||||
If you expect an object to have lots of dependent_objects it might be good
|
|
||||||
to limit the results::
|
|
||||||
|
|
||||||
|
|
||||||
dependent_objects(user).limit(5)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The common use case is checking for all restrict dependent objects before
|
|
||||||
deleting parent object and inform the user if there are dependent objects
|
|
||||||
with ondelete='RESTRICT' foreign keys. If this kind of checking is not used
|
|
||||||
it will lead to nasty IntegrityErrors being raised.
|
|
||||||
|
|
||||||
In the following example we delete given user if it doesn't have any
|
|
||||||
foreign key restricted dependent objects.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy_utils import get_referencing_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
user = session.query(User).get(some_user_id)
|
|
||||||
|
|
||||||
|
|
||||||
deps = list(
|
|
||||||
dependent_objects(
|
|
||||||
user,
|
|
||||||
(
|
|
||||||
fk for fk in get_referencing_foreign_keys(User)
|
|
||||||
# On most databases RESTRICT is the default mode hence we
|
|
||||||
# check for None values also
|
|
||||||
if fk.ondelete == 'RESTRICT' or fk.ondelete is None
|
|
||||||
)
|
|
||||||
).limit(5)
|
|
||||||
)
|
|
||||||
|
|
||||||
if deps:
|
|
||||||
# Do something to inform the user
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
session.delete(user)
|
|
||||||
|
|
||||||
|
|
||||||
:param obj: SQLAlchemy declarative model object
|
|
||||||
:param foreign_keys:
|
|
||||||
A sequence of foreign keys to use for searching the dependent_objects
|
|
||||||
for given object. By default this is None, indicating that all foreign
|
|
||||||
keys referencing the object will be used.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
This function does not support exotic mappers that use multiple tables
|
|
||||||
|
|
||||||
.. seealso:: :func:`get_referencing_foreign_keys`
|
|
||||||
|
|
||||||
.. versionadded: 0.26.0
|
|
||||||
"""
|
|
||||||
if foreign_keys is None:
|
|
||||||
foreign_keys = get_referencing_foreign_keys(obj)
|
|
||||||
|
|
||||||
session = object_session(obj)
|
|
||||||
|
|
||||||
chain = QueryChain([])
|
|
||||||
classes = obj.__class__._decl_class_registry
|
|
||||||
|
|
||||||
for table, keys in group_foreign_keys(foreign_keys):
|
|
||||||
for class_ in classes.values():
|
|
||||||
if hasattr(class_, '__table__') and class_.__table__ == table:
|
|
||||||
criteria = []
|
|
||||||
visited_constraints = []
|
|
||||||
for key in keys:
|
|
||||||
if key.constraint not in visited_constraints:
|
|
||||||
visited_constraints.append(key.constraint)
|
|
||||||
subcriteria = [
|
|
||||||
getattr(class_, column.key) ==
|
|
||||||
getattr(
|
|
||||||
obj,
|
|
||||||
key.constraint.elements[index].column.key
|
|
||||||
)
|
|
||||||
for index, column
|
|
||||||
in enumerate(key.constraint.columns)
|
|
||||||
]
|
|
||||||
criteria.append(sa.and_(*subcriteria))
|
|
||||||
|
|
||||||
query = session.query(class_).filter(
|
|
||||||
sa.or_(
|
|
||||||
*criteria
|
|
||||||
)
|
|
||||||
)
|
|
||||||
chain.queries.append(query)
|
|
||||||
return chain
|
|
||||||
|
|
||||||
|
|
||||||
def group_foreign_keys(foreign_keys):
|
|
||||||
"""
|
|
||||||
Return a groupby iterator that groups given foreign keys by table.
|
|
||||||
|
|
||||||
:param foreign_keys: a sequence of foreign keys
|
|
||||||
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
foreign_keys = get_referencing_foreign_keys(User)
|
|
||||||
|
|
||||||
for table, fks in group_foreign_keys(foreign_keys):
|
|
||||||
# do something
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
.. also:: :func:`get_referencing_foreign_keys`
|
|
||||||
|
|
||||||
.. versionadded: 0.26.1
|
|
||||||
"""
|
|
||||||
foreign_keys = sorted(
|
|
||||||
foreign_keys, key=lambda key: key.constraint.table.name
|
|
||||||
)
|
|
||||||
return groupby(foreign_keys, lambda key: key.constraint.table)
|
|
||||||
|
|
||||||
|
|
||||||
def get_referencing_foreign_keys(mixed):
|
|
||||||
"""
|
|
||||||
Returns referencing foreign keys for given Table object or declarative
|
|
||||||
class.
|
|
||||||
|
|
||||||
:param mixed:
|
|
||||||
SA Table object or SA declarative class
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
get_referencing_foreign_keys(User) # set([ForeignKey('user.id')])
|
|
||||||
|
|
||||||
get_referencing_foreign_keys(User.__table__)
|
|
||||||
|
|
||||||
|
|
||||||
This function also understands inheritance. This means it returns
|
|
||||||
all foreign keys that reference any table in the class inheritance tree.
|
|
||||||
|
|
||||||
Let's say you have three classes which use joined table inheritance,
|
|
||||||
namely TextItem, Article and BlogPost with Article and BlogPost inheriting
|
|
||||||
TextItem.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
# This will check all foreign keys that reference either article table
|
|
||||||
# or textitem table.
|
|
||||||
get_referencing_foreign_keys(Article)
|
|
||||||
|
|
||||||
.. seealso:: :func:`get_tables`
|
|
||||||
"""
|
|
||||||
if isinstance(mixed, sa.Table):
|
|
||||||
tables = [mixed]
|
|
||||||
else:
|
|
||||||
tables = get_tables(mixed)
|
|
||||||
|
|
||||||
referencing_foreign_keys = set()
|
|
||||||
|
|
||||||
for table in mixed.metadata.tables.values():
|
|
||||||
if table not in tables:
|
|
||||||
for constraint in table.constraints:
|
|
||||||
if isinstance(constraint, sa.sql.schema.ForeignKeyConstraint):
|
|
||||||
for fk in constraint.elements:
|
|
||||||
if any(fk.references(t) for t in tables):
|
|
||||||
referencing_foreign_keys.add(fk)
|
|
||||||
return referencing_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
def get_primary_keys(mixed):
|
def get_primary_keys(mixed):
|
||||||
"""
|
"""
|
||||||
Return an OrderedDict of all primary keys for given Table object,
|
Return an OrderedDict of all primary keys for given Table object,
|
||||||
|
@@ -1,124 +0,0 @@
|
|||||||
import six
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.engine import reflection
|
|
||||||
from sqlalchemy.orm import object_session, mapperlib
|
|
||||||
|
|
||||||
|
|
||||||
def dependent_foreign_keys(model_class):
|
|
||||||
"""
|
|
||||||
Returns dependent foreign keys as dicts for given model class.
|
|
||||||
|
|
||||||
** Experimental function **
|
|
||||||
"""
|
|
||||||
session = object_session(model_class)
|
|
||||||
|
|
||||||
engine = session.bind
|
|
||||||
inspector = reflection.Inspector.from_engine(engine)
|
|
||||||
table_names = inspector.get_table_names()
|
|
||||||
|
|
||||||
dependent_foreign_keys = {}
|
|
||||||
|
|
||||||
for table_name in table_names:
|
|
||||||
fks = inspector.get_foreign_keys(table_name)
|
|
||||||
if fks:
|
|
||||||
dependent_foreign_keys[table_name] = []
|
|
||||||
for fk in fks:
|
|
||||||
if fk['referred_table'] == model_class.__tablename__:
|
|
||||||
dependent_foreign_keys[table_name].append(fk)
|
|
||||||
return dependent_foreign_keys
|
|
||||||
|
|
||||||
|
|
||||||
class Merger(object):
|
|
||||||
def memory_merge(self, session, table_name, old_values, new_values):
|
|
||||||
# try to fetch mappers for given table and update in memory objects as
|
|
||||||
# well as database table
|
|
||||||
found = False
|
|
||||||
for mapper in mapperlib._mapper_registry:
|
|
||||||
class_ = mapper.class_
|
|
||||||
if table_name == class_.__table__.name:
|
|
||||||
try:
|
|
||||||
(
|
|
||||||
session.query(mapper.class_)
|
|
||||||
.filter_by(**old_values)
|
|
||||||
.update(
|
|
||||||
new_values,
|
|
||||||
'fetch'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except sa.exc.IntegrityError:
|
|
||||||
pass
|
|
||||||
found = True
|
|
||||||
return found
|
|
||||||
|
|
||||||
def raw_merge(self, session, table, old_values, new_values):
|
|
||||||
conditions = []
|
|
||||||
for key, value in six.iteritems(old_values):
|
|
||||||
conditions.append(getattr(table.c, key) == value)
|
|
||||||
sql = (
|
|
||||||
table
|
|
||||||
.update()
|
|
||||||
.where(sa.and_(
|
|
||||||
*conditions
|
|
||||||
))
|
|
||||||
.values(
|
|
||||||
new_values
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
session.execute(sql)
|
|
||||||
except sa.exc.IntegrityError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def merge_update(self, table_name, from_, to, foreign_key):
|
|
||||||
session = object_session(from_)
|
|
||||||
constrained_columns = foreign_key['constrained_columns']
|
|
||||||
referred_columns = foreign_key['referred_columns']
|
|
||||||
metadata = from_.metadata
|
|
||||||
table = metadata.tables[table_name]
|
|
||||||
|
|
||||||
new_values = {}
|
|
||||||
for index, column in enumerate(constrained_columns):
|
|
||||||
new_values[column] = getattr(
|
|
||||||
to, referred_columns[index]
|
|
||||||
)
|
|
||||||
|
|
||||||
old_values = {}
|
|
||||||
for index, column in enumerate(constrained_columns):
|
|
||||||
old_values[column] = getattr(
|
|
||||||
from_, referred_columns[index]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.memory_merge(session, table_name, old_values, new_values):
|
|
||||||
self.raw_merge(session, table, old_values, new_values)
|
|
||||||
|
|
||||||
def __call__(self, from_, to):
|
|
||||||
"""
|
|
||||||
Merges entity into another entity. After merging deletes the from_
|
|
||||||
argument entity.
|
|
||||||
"""
|
|
||||||
if from_.__tablename__ != to.__tablename__:
|
|
||||||
raise Exception()
|
|
||||||
|
|
||||||
session = object_session(from_)
|
|
||||||
foreign_keys = dependent_foreign_keys(from_)
|
|
||||||
|
|
||||||
for table_name in foreign_keys:
|
|
||||||
for foreign_key in foreign_keys[table_name]:
|
|
||||||
self.merge_update(table_name, from_, to, foreign_key)
|
|
||||||
|
|
||||||
session.delete(from_)
|
|
||||||
|
|
||||||
|
|
||||||
def merge(from_, to, merger=Merger):
|
|
||||||
"""
|
|
||||||
Merges entity into another entity. After merging deletes the from_ argument
|
|
||||||
entity.
|
|
||||||
|
|
||||||
After merging the from_ entity is deleted from database.
|
|
||||||
|
|
||||||
:param from_: an entity to merge into another entity
|
|
||||||
:param to: an entity to merge another entity into
|
|
||||||
:param merger: Merger class, by default this is sqlalchemy_utils.Merger
|
|
||||||
class
|
|
||||||
"""
|
|
||||||
return Merger()(from_, to)
|
|
@@ -71,3 +71,18 @@ class TestGetMapperWithMultipleMappersFound(object):
|
|||||||
alias = sa.orm.aliased(self.Building.__table__)
|
alias = sa.orm.aliased(self.Building.__table__)
|
||||||
with raises(ValueError):
|
with raises(ValueError):
|
||||||
get_mapper(alias)
|
get_mapper(alias)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMapperForTableWithoutMapper(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
metadata = sa.MetaData()
|
||||||
|
self.building = sa.Table('building', metadata)
|
||||||
|
|
||||||
|
def test_table(self):
|
||||||
|
with raises(ValueError):
|
||||||
|
get_mapper(self.building)
|
||||||
|
|
||||||
|
def test_table_alias(self):
|
||||||
|
alias = sa.orm.aliased(self.building)
|
||||||
|
with raises(ValueError):
|
||||||
|
get_mapper(alias)
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy_utils import merge
|
from sqlalchemy_utils import merge_references
|
||||||
|
|
||||||
from tests import TestCase
|
from tests import TestCase
|
||||||
|
|
||||||
|
|
||||||
class TestMerge(TestCase):
|
class TestMergeReferences(TestCase):
|
||||||
def create_models(self):
|
def create_models(self):
|
||||||
class User(self.Base):
|
class User(self.Base):
|
||||||
__tablename__ = 'user'
|
__tablename__ = 'user'
|
||||||
@@ -36,21 +36,29 @@ class TestMerge(TestCase):
|
|||||||
self.session.add(post)
|
self.session.add(post)
|
||||||
self.session.add(post2)
|
self.session.add(post2)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
merge(john, jack)
|
merge_references(john, jack)
|
||||||
|
self.session.commit()
|
||||||
assert post.author == jack
|
assert post.author == jack
|
||||||
assert post2.author == jack
|
assert post2.author == jack
|
||||||
|
|
||||||
def test_deletes_from_entity(self):
|
def test_object_merging_whenever_possible(self):
|
||||||
john = self.User(name=u'John')
|
john = self.User(name=u'John')
|
||||||
jack = self.User(name=u'Jack')
|
jack = self.User(name=u'Jack')
|
||||||
|
post = self.BlogPost(title=u'Some title', author=john)
|
||||||
|
post2 = self.BlogPost(title=u'Other title', author=jack)
|
||||||
self.session.add(john)
|
self.session.add(john)
|
||||||
self.session.add(jack)
|
self.session.add(jack)
|
||||||
|
self.session.add(post)
|
||||||
|
self.session.add(post2)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
merge(john, jack)
|
# Load the author for post
|
||||||
assert john in self.session.deleted
|
assert post.author_id == john.id
|
||||||
|
merge_references(john, jack)
|
||||||
|
assert post.author_id == jack.id
|
||||||
|
assert post2.author_id == jack.id
|
||||||
|
|
||||||
|
|
||||||
class TestMergeManyToManyAssociations(TestCase):
|
class TestMergeReferencesWithManyToManyAssociations(TestCase):
|
||||||
def create_models(self):
|
def create_models(self):
|
||||||
class User(self.Base):
|
class User(self.Base):
|
||||||
__tablename__ = 'user'
|
__tablename__ = 'user'
|
||||||
@@ -88,7 +96,7 @@ class TestMergeManyToManyAssociations(TestCase):
|
|||||||
self.User = User
|
self.User = User
|
||||||
self.Team = Team
|
self.Team = Team
|
||||||
|
|
||||||
def test_when_association_only_exists_in_from_entity(self):
|
def test_supports_associations(self):
|
||||||
john = self.User(name=u'John')
|
john = self.User(name=u'John')
|
||||||
jack = self.User(name=u'Jack')
|
jack = self.User(name=u'Jack')
|
||||||
team = self.Team(name=u'Team')
|
team = self.Team(name=u'Team')
|
||||||
@@ -96,29 +104,12 @@ class TestMergeManyToManyAssociations(TestCase):
|
|||||||
self.session.add(john)
|
self.session.add(john)
|
||||||
self.session.add(jack)
|
self.session.add(jack)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
merge(john, jack)
|
merge_references(john, jack)
|
||||||
assert john not in team.members
|
assert john not in team.members
|
||||||
assert jack in team.members
|
assert jack in team.members
|
||||||
|
|
||||||
# def test_when_association_exists_in_both(self):
|
|
||||||
# john = self.User(name=u'John')
|
|
||||||
# jack = self.User(name=u'Jack')
|
|
||||||
# team = self.Team(name=u'Team')
|
|
||||||
# team.members.append(john)
|
|
||||||
# team.members.append(jack)
|
|
||||||
# self.session.add(john)
|
|
||||||
# self.session.add(jack)
|
|
||||||
# self.session.commit()
|
|
||||||
# merge(john, jack)
|
|
||||||
# assert john not in team.members
|
|
||||||
# assert jack in team.members
|
|
||||||
# count = self.session.execute(
|
|
||||||
# 'SELECT COUNT(1) FROM team_member'
|
|
||||||
# ).fetchone()[0]
|
|
||||||
# assert count == 1
|
|
||||||
|
|
||||||
|
class TestMergeReferencesWithManyToManyAssociationObjects(TestCase):
|
||||||
class TestMergeManyToManyAssociationObjects(TestCase):
|
|
||||||
def create_models(self):
|
def create_models(self):
|
||||||
class Team(self.Base):
|
class Team(self.Base):
|
||||||
__tablename__ = 'team'
|
__tablename__ = 'team'
|
||||||
@@ -164,7 +155,7 @@ class TestMergeManyToManyAssociationObjects(TestCase):
|
|||||||
self.TeamMember = TeamMember
|
self.TeamMember = TeamMember
|
||||||
self.Team = Team
|
self.Team = Team
|
||||||
|
|
||||||
def test_when_association_only_exists_in_from_entity(self):
|
def test_supports_associations(self):
|
||||||
john = self.User(name=u'John')
|
john = self.User(name=u'John')
|
||||||
jack = self.User(name=u'Jack')
|
jack = self.User(name=u'Jack')
|
||||||
team = self.Team(name=u'Team')
|
team = self.Team(name=u'Team')
|
||||||
@@ -173,24 +164,8 @@ class TestMergeManyToManyAssociationObjects(TestCase):
|
|||||||
self.session.add(jack)
|
self.session.add(jack)
|
||||||
self.session.add(team)
|
self.session.add(team)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
merge(john, jack)
|
merge_references(john, jack)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
users = [member.user for member in team.members]
|
users = [member.user for member in team.members]
|
||||||
assert john not in users
|
assert john not in users
|
||||||
assert jack in users
|
assert jack in users
|
||||||
|
|
||||||
# def test_when_association_exists_in_both(self):
|
|
||||||
# john = self.User(name=u'John')
|
|
||||||
# jack = self.User(name=u'Jack')
|
|
||||||
# team = self.Team(name=u'Team')
|
|
||||||
# team.members.append(self.TeamMember(user=john))
|
|
||||||
# team.members.append(self.TeamMember(user=jack))
|
|
||||||
# self.session.add(john)
|
|
||||||
# self.session.add(jack)
|
|
||||||
# self.session.add(team)
|
|
||||||
# self.session.commit()
|
|
||||||
# merge(john, jack)
|
|
||||||
# users = [member.user for member in team.members]
|
|
||||||
# assert john not in users
|
|
||||||
# assert jack in users
|
|
||||||
# assert self.session.query(self.TeamMember).count() == 1
|
|
Reference in New Issue
Block a user