Changeset 397

Show
Ignore:
Timestamp:
08/21/08 17:32:31 (5 years ago)
Author:
ged
Message:

New features:
- The local_colname and remote_colname arguments on ManyToMany relationships

can now also be used to set custom names for the ManyToMany table columns.
This effectively replace the column_format on ManyToMany relationships which
is now deprecated. Change based on a patch from Diez B. Roggisch.

- Added (or rather fixed and documented) a "table" argument on ManyToMany

relationships to allow using a manually-defined Table (closes #44).

- Added a "schema" argument on ManyToMany relationship to be able to create the

ManyToMany table in a custom schema and not necessarily the same schema as
the table of the "source" entity (patch from Diez B. Roggisch).

Changes:
- Renamed remote_side and local_side ManyToMany arguments to remote_colname and

local_colname respectively to not collide with the remote_side argument
provided by SA (it doesn't make much sense on ManyToMany relationships but
still).

Bug fixes:
- Changed slightly the algorithm to generate the name of the table for

bidirectional self-referential ManyToMany relationships so that it doesn't
depend on the order of declaration of each side (closes #19). If you are
upgrading an application with existing data from an earlier version of
Elixir, you are STRONGLY ADVISED to read the upgrade notes!
=============================
TEMPORARY BUT IMPORTANT NOTE:
=============================
For now it is even worse: the table works but the relationship's meaning is
reversed. Will be fixed before 0.7 ships (see ticket #69).
=============================

- Added missing documentation for the "filter" argument on OneToMany

relationships

Not logged in CHANGES:
- bumped version
- added a few tests
- completed/fixed the API doc a bit
- added some comments in the code so that I remember why things are done this

way.

Location:
elixir/trunk
Files:
8 modified

Legend:

Unmodified
Added
Removed
  • elixir/trunk/CHANGES

    r392 r397  
     10.7.0 
     2 
     3Please see http://elixir.ematia.de/trac/wiki/Migrate06to07 for detailed 
     4upgrade notes. 
     5 
     6New features: 
     7- The local_colname and remote_colname arguments on ManyToMany relationships 
     8  can now also be used to set custom names for the ManyToMany table columns. 
     9  This effectively replace the column_format on ManyToMany relationships which 
     10  is now deprecated. Change based on a patch from Diez B. Roggisch. 
     11- Added (or rather fixed and documented) a "table" argument on ManyToMany 
     12  relationships to allow using a manually-defined Table (closes #44). 
     13- Added a "schema" argument on ManyToMany relationship to be able to create the 
     14  ManyToMany table in a custom schema and not necessarily the same schema as 
     15  the table of the "source" entity (patch from Diez B. Roggisch). 
     16 
     17Changes: 
     18- Renamed remote_side and local_side ManyToMany arguments to remote_colname and 
     19  local_colname respectively to not collide with the remote_side argument 
     20  provided by SA (it doesn't make much sense on ManyToMany relationships but 
     21  still). 
     22 
     23Bug fixes: 
     24- Changed slightly the algorithm to generate the name of the table for 
     25  bidirectional self-referential ManyToMany relationships so that it doesn't 
     26  depend on the order of declaration of each side (closes #19). If you are 
     27  upgrading an application with existing data from an earlier version of 
     28  Elixir, you are STRONGLY ADVISED to read the upgrade notes! 
     29  ================= 
     30  TEMPORARY NOTICE: 
     31  ================= 
     32  For now it is even worse: the table works but the relationship's meaning is 
     33  reversed. Will be fixed before 0.7 ships (see ticket #69). 
     34  ================= 
     35- Added missing documentation for the "filter" argument on OneToMany 
     36  relationships 
     37 
    1380.6.1 - 2008-08-18 
    239 
  • elixir/trunk/elixir/__init__.py

    r385 r397  
    4242 
    4343 
    44 __version__ = '0.6.1' 
     44__version__ = '0.7.0' 
    4545 
    4646__all__ = ['Entity', 'EntityMeta', 'EntityCollection', 'entities', 
  • elixir/trunk/elixir/options.py

    r349 r397  
    185185POLYMORPHIC_COL_TYPE = String(POLYMORPHIC_COL_SIZE) 
    186186 
     187# debugging/migration constants 
     188CHECK_TABLENAME_CHANGES = False 
     189 
    187190# 
    188191options_defaults = dict( 
  • elixir/trunk/elixir/relationships.py

    r389 r397  
    165165|                    | by a minus (for descending order).                     | 
    166166+--------------------+--------------------------------------------------------+ 
     167| ``filter``         | Specify a filter criterion (as a clause element) for   | 
     168|                    | this relationship. This criterion will be and_'ed with | 
     169|                    | the normal join criterion (primaryjoin) generated by   | 
     170|                    | Elixir for the relationship. For example:              | 
     171|                    | boston_addresses = \                                   | 
     172|                    |  OneToMany('Address', filter=Address.city == 'Boston') | 
     173+--------------------+--------------------------------------------------------+ 
    167174 
    168175Additionally, Elixir supports an alternate, DSL-based, syntax to define 
    169176OneToMany_ relationships, with the has_many_ statement. 
    170  
    171 Also, as for standard SQLAlchemy relations, the ``order_by`` keyword argument 
    172177 
    173178 
     
    220225 
    221226If the entity containing your ``ManyToMany`` relationship is 
    222 autoloaded, you **must** specify at least one of either the ``remote_side`` or 
    223 ``local_side`` argument. 
     227autoloaded, you **must** specify at least one of either the ``remote_colname`` 
     228or ``local_colname`` argument. 
    224229 
    225230In addition to keyword arguments inherited from SQLAlchemy, ``ManyToMany`` 
     
    234239|                    | database.                                              | 
    235240+--------------------+--------------------------------------------------------+ 
    236 | ``remote_side``    | A column name or list of column names specifying       | 
    237 |                    | which column(s) in the intermediary table are used     | 
    238 |                    | for the "remote" part of a self-referential            | 
    239 |                    | relationship. This argument has an effect only when    | 
    240 |                    | your entities are autoloaded.                          | 
    241 +--------------------+--------------------------------------------------------+ 
    242 | ``local_side``     | A column name or list of column names specifying       | 
    243 |                    | which column(s) in the intermediary table are used     | 
    244 |                    | for the "local" part of a self-referential             | 
    245 |                    | relationship. This argument has an effect only when    | 
    246 |                    | your entities are autoloaded.                          | 
     241| ``schema``         | Specify a custom schema for the intermediate table.    | 
     242|                    | This can be used both when the tables needs to         | 
     243|                    | be created and when the table is autoloaded/reflected  | 
     244|                    | from the database.                                     | 
     245+--------------------+--------------------------------------------------------+ 
     246| ``remote_colname`` | A string or list of strings specifying the names of    | 
     247|                    | the column(s) in the intermediary table which          | 
     248|                    | reference the "remote"/target entity's table.          | 
     249+--------------------+--------------------------------------------------------+ 
     250| ``local_colname``  | A string or list of strings specifying the names of    | 
     251|                    | the column(s) in the intermediary table which          | 
     252|                    | reference the "local"/current entity's table.          | 
     253+--------------------+--------------------------------------------------------+ 
     254| ``table``          | Use a manually created table. If this argument is      | 
     255|                    | used, Elixir won't generate a table for this           | 
     256|                    | relationship, and use the one given instead.           | 
    247257+--------------------+--------------------------------------------------------+ 
    248258| ``order_by``       | Specify which field(s) should be used to sort the      | 
     
    252262|                    | entity. These field names can optionally be prefixed   | 
    253263|                    | by a minus (for descending order).                     | 
    254 +--------------------+--------------------------------------------------------+ 
    255 | ``column_format``  | Specify an alternate format string for naming the      | 
     264+----------------------+------------------------------------------------------+ 
     265| ``ondelete``       | Value for the foreign key constraint ondelete clause.  | 
     266|                    | May be one of: ``cascade``, ``restrict``,              | 
     267|                    | ``set null``, or ``set default``.                      | 
     268+--------------------+--------------------------------------------------------+ 
     269| ``onupdate``       | Value for the foreign key constraint onupdate clause.  | 
     270|                    | May be one of: ``cascade``, ``restrict``,              | 
     271|                    | ``set null``, or ``set default``.                      | 
     272+--------------------+--------------------------------------------------------+ 
     273| ``column_format``  | DEPRECATED. Specify an alternate format string for     | 
     274|                    | naming the                                             | 
    256275|                    | columns in the mapping table.  The default value is    | 
    257276|                    | defined in ``elixir.options.M2MCOL_NAMEFORMAT``.  You  | 
     
    356375 
    357376import sys 
    358  
    359 from sqlalchemy         import ForeignKeyConstraint, Column, \ 
    360                               Table, and_ 
    361 from sqlalchemy.orm     import relation, backref 
     377import warnings 
     378 
     379from sqlalchemy import ForeignKeyConstraint, Column, Table, and_ 
     380from sqlalchemy.orm import relation, backref 
    362381from sqlalchemy.ext.associationproxy import association_proxy 
    363382 
    364383import options 
    365 from elixir.statements  import ClassMutator 
    366 from elixir.fields      import Field 
    367 from elixir.properties  import Property 
    368 from elixir.entity      import EntityDescriptor, EntityMeta 
     384from elixir.statements import ClassMutator 
     385from elixir.fields import Field 
     386from elixir.properties import Property 
     387from elixir.entity import EntityDescriptor, EntityMeta 
    369388 
    370389 
     
    418437 
    419438        # transform callable arguments 
    420         for arg in ('primaryjoin', 'secondaryjoin', 'remote_side'): 
     439        for arg in ('primaryjoin', 'secondaryjoin', 'remote_side', 'filter'): 
    421440            kwarg = kwargs.get(arg, None) 
    422441            if callable(kwarg): 
     
    669688 
    670689        if self.entity.table is self.target.table: 
     690            # this is needed because otherwise SA has no way to know what is 
     691            # the direction of the relationship since both columns present in  
     692            # the primaryjoin belong to the same table. In other words, it is  
     693            # necessary to know if this particular relation  
     694            # is the many-to-one side, or the one-to-xxx side. The foreignkey  
     695            # doesn't help in this case. 
    671696            kwargs['remote_side'] = \ 
    672697                [col for col in self.target.table.primary_key.columns] 
     
    686711        self.filter = kwargs.pop('filter', None) 
    687712        if self.filter is not None: 
    688             kwargs['viewonly'] = True 
     713            # We set viewonly to True by default for filtered relationship, 
     714            # unless manually overridden. 
     715            # This is not strictly necessary, as SQLAlchemy allows non viewonly 
     716            # relationships with a custom join/filter. The example at: 
     717            # SADOCS/05/mappers.html#advdatamapping_relation_customjoin 
     718            # is not viewonly. Those relationships can be used as if the extra 
     719            # filter wasn't present when inserting. This can lead to a 
     720            # confusing behavior (if you insert data which doesn't match the 
     721            # extra criterion it'll get inserted anyway but you won't see it  
     722            # when you query back the attribute after a round-trip to the  
     723            # database). 
     724            if 'viewonly' not in kwargs: 
     725                kwargs['viewonly'] = True 
    689726        super(OneToOne, self).__init__(*args, **kwargs) 
    690727 
     
    701738                      "inheritance you " 
    702739                      "might need to specify inverse relationships " 
    703                       "manually by using the inverse keyword." 
     740                      "manually by using the 'inverse' argument." 
    704741                      % (self.target.__name__, self.name, 
    705742                         self.entity.__name__)) 
     
    711748        # So, we should either complete the selfref test to prove that they 
    712749        # are indeed useful, or remove them. It might be they are indeed 
    713         # useless because of the primaryjoin, and that the remote_side is 
    714         # already setup in the other way (belongs_to). 
     750        # useless because the remote_side is already setup in the other way  
     751        # (ManyToOne). 
    715752        if self.entity.table is self.target.table: 
    716753            #FIXME: IF this code is of any use, it will probably break for 
     
    738775    def __init__(self, *args, **kwargs): 
    739776        self.user_tablename = kwargs.pop('tablename', None) 
    740         self.local_side = kwargs.pop('local_side', []) 
    741         if self.local_side and not isinstance(self.local_side, list): 
    742             self.local_side = [self.local_side] 
    743         self.remote_side = kwargs.pop('remote_side', []) 
    744         if self.remote_side and not isinstance(self.remote_side, list): 
    745             self.remote_side = [self.remote_side] 
     777        self.local_colname = kwargs.pop('local_colname', []) 
     778        if self.local_colname and not isinstance(self.local_colname, list): 
     779            self.local_colname = [self.local_colname] 
     780        self.remote_colname = kwargs.pop('remote_colname', []) 
     781        if self.remote_colname and not isinstance(self.remote_colname, list): 
     782            self.remote_colname = [self.remote_colname] 
    746783        self.ondelete = kwargs.pop('ondelete', None) 
    747784        self.onupdate = kwargs.pop('onupdate', None) 
    748         self.column_format = kwargs.pop('column_format', options.M2MCOL_NAMEFORMAT) 
     785        if 'column_format' in kwargs: 
     786            warnings.warn("The 'column_format' argument on ManyToMany " 
     787                "relationships is deprecated. Please use the 'local_colname' " 
     788                "and/or 'remote_colname' arguments if you want custom " 
     789                "column names for this table only, or modify " 
     790                "options.M2MCOL_NAMEFORMAT if you want a custom format for " 
     791                "all ManyToMany tables", DeprecationWarning, stacklevel=3) 
     792        self.column_format = kwargs.pop('column_format', 
     793                                        options.M2MCOL_NAMEFORMAT) 
    749794 
    750795        self.secondary_table = kwargs.pop('table', None) 
     796        self.schema = kwargs.pop('schema', None) 
     797 
    751798        self.primaryjoin_clauses = list() 
    752799        self.secondaryjoin_clauses = list() 
     
    762809        # autoloaded entities 
    763810        if self.secondary_table: 
     811            self._reflect_table() 
    764812            return 
    765813 
     
    776824        e1_schema = e1_desc.table_options.get('schema', None) 
    777825        e2_schema = e2_desc.table_options.get('schema', None) 
    778         assert e1_schema == e2_schema, \ 
    779                "Schema %r for entity %s differs from schema %r of entity %s" \ 
     826        schema = (self.schema is not None) and schema or e1_schema 
     827 
     828        assert e1_schema == e2_schema or self.schema, \ 
     829               "Schema %r for entity %s differs from schema %r of entity %s." \ 
     830               " Consider using the schema-parameter. "\ 
    780831               % (e1_schema, self.entity.__name__, 
    781832                  e2_schema, self.target.__name__) 
     
    804855            # We need to keep the table name consistent (independant of 
    805856            # whether this relation or its inverse is setup first). 
    806             if self.inverse and e1_desc.tablename < e2_desc.tablename: 
     857            if self.inverse and source_part < target_part: 
     858                #TODO: if self-relf, only include source_part 
    807859                tablename = "%s__%s" % (target_part, source_part) 
     860                if options.CHECK_TABLENAME_CHANGES and \ 
     861                   e1_desc.tablename >= e2_desc.tablename: 
     862                    oldname = "%s__%s" % (source_part, target_part) 
     863 
     864                    raise Exception( 
     865                        "The generated table name for the '%s' relationship " 
     866                        "on the '%s' entity changed from '%s' (the name " 
     867                        "generated by Elixir 0.6.1 and earlier) to '%s'. " 
     868                        "You should either rename the table in the database " 
     869                        "to the new name or use the tablename argument on the " 
     870                        "relationship to force the old name: tablename='%s'!" 
     871                        % (self.name, self.entity.__name__, oldname, 
     872                           tablename, oldname)) 
    808873            else: 
    809874                tablename = "%s__%s" % (source_part, target_part) 
    810875 
    811876        if e1_desc.autoload: 
    812             self._reflect_table(tablename) 
     877            if not e2_desc.autoload: 
     878                raise Exception( 
     879                    "Entity '%s' is autoloaded and its '%s' " 
     880                    "ManyToMany relationship points to " 
     881                    "the '%s' entity which is not autoloaded" 
     882                    % (self.entity.__name__, self.name, 
     883                       self.target.__name__)) 
     884 
     885            self.secondary_table = Table(tablename, e1_desc.metadata, 
     886                                         autoload=True) 
     887            self._reflect_table() 
    813888        else: 
    814889            # We pre-compute the names of the foreign key constraints 
     
    828903 
    829904            joins = (self.primaryjoin_clauses, self.secondaryjoin_clauses) 
    830             for num, desc, fk_name, rel in ( 
    831                     (0, e1_desc, source_fk_name, self), 
    832                     (1, e2_desc, target_fk_name, self.inverse)): 
     905            for num, desc, fk_name, rel, colnames in ( 
     906              (0, e1_desc, source_fk_name, self, self.local_colname), 
     907              (1, e2_desc, target_fk_name, self.inverse, self.remote_colname)): 
     908 
    833909                fk_colnames = list() 
    834910                fk_refcols = list() 
    835911 
    836                 for pk_col in desc.primary_keys: 
    837                     colname = self.column_format % \ 
    838                               {'tablename': desc.tablename, 
    839                                'key': pk_col.key, 
    840                                'entity': desc.entity.__name__.lower()} 
    841  
    842                     # In case we have a many-to-many self-reference, we 
    843                     # need to tweak the names of the columns so that we 
    844                     # don't end up with twice the same column name. 
    845                     if self.entity is self.target: 
    846                         colname += str(num + 1) 
    847  
     912                if colnames: 
     913                    assert len(colnames) == len(desc.primary_keys) 
     914                else: 
     915                    for pk_col in desc.primary_keys: 
     916                        colname = self.column_format % \ 
     917                                  {'tablename': desc.tablename, 
     918                                   'key': pk_col.key, 
     919                                   'entity': desc.entity.__name__.lower()} 
     920                        # In case we have a many-to-many self-reference, we 
     921                        # need to tweak the names of the columns so that we 
     922                        # don't end up with twice the same column name. 
     923                        if self.entity is self.target: 
     924                            colname += str(num + 1) 
     925                        colnames.append(colname) 
     926 
     927                for pk_col, colname in zip(desc.primary_keys, colnames): 
    848928                    col = Column(colname, pk_col.type, primary_key=True) 
    849929                    columns.append(col) 
     
    873953 
    874954            self.secondary_table = Table(tablename, e1_desc.metadata, 
    875                                          schema=e1_schema, *args) 
    876  
    877     def _reflect_table(self, tablename): 
    878         if not self.target._descriptor.autoload: 
    879             raise Exception( 
    880                 "Entity '%s' is autoloaded and its '%s' " 
    881                 "ManyToMany relationship points to " 
    882                 "the '%s' entity which is not autoloaded" 
    883                 % (self.entity.__name__, self.name, 
    884                    self.target.__name__)) 
    885  
    886         self.secondary_table = Table(tablename, 
    887                                      self.entity._descriptor.metadata, 
    888                                      autoload=True) 
    889  
     955                                         schema=schema, *args) 
     956 
     957    def _reflect_table(self): 
    890958        # In the case we have a self-reference, we need to build join clauses 
    891959        if self.entity is self.target: 
    892960            #CHECKME: maybe we should try even harder by checking if that 
    893961            # information was defined on the inverse relationship) 
    894             if not self.local_side and not self.remote_side: 
     962            if not self.local_colname and not self.remote_colname: 
    895963                raise Exception( 
    896964                    "Self-referential ManyToMany " 
    897965                    "relationships in autoloaded entities need to have at " 
    898                     "least one of either 'local_side' or 'remote_side' " 
     966                    "least one of either 'local_colname' or 'remote_colname' " 
    899967                    "argument specified. The '%s' relationship in the '%s' " 
    900968                    "entity doesn't have either." 
     
    903971            self.primaryjoin_clauses, self.secondaryjoin_clauses = \ 
    904972                _get_join_clauses(self.secondary_table, 
    905                                   self.local_side, self.remote_side, 
     973                                  self.local_colname, self.remote_colname, 
    906974                                  self.entity.table) 
    907975 
     
    9461014 
    9471015            # if all columns point to the correct table, we use the constraint 
     1016            #TODO: check that it contains as many columns as the pk of the 
     1017            #target entity, or even that it points to the actual pk columns 
    9481018            for fk in constraint.elements: 
    9491019                if fk.references(target_table): 
  • elixir/trunk/setup.cfg

    r377 r397  
    99modules = elixir, elixir.ext.associable, elixir.ext.versioned, 
    1010          elixir.ext.encrypted, elixir.ext.list 
    11 trac_browser_url = http://elixir.ematia.de/trac/browser/elixir/tags/0.6.1 
     11trac_browser_url = http://elixir.ematia.de/trac/browser/elixir/tags/0.7.0 
    1212 
  • elixir/trunk/setup.py

    r377 r397  
    22 
    33setup(name="Elixir", 
    4       version="0.6.1", 
     4      version="0.7.0", 
    55      description="Declarative Mapper for SQLAlchemy", 
    66      long_description=""" 
  • elixir/trunk/tests/test_autoload.py

    r388 r397  
    6060        appreciate = ManyToMany('Person', 
    6161                                tablename='person_person', 
    62                                 local_side='person_id1') 
     62                                local_colname='person_id1') 
    6363        isappreciatedby = ManyToMany('Person', 
    6464                                tablename='person_person', 
    65                                 local_side='person_id2') 
     65                                local_colname='person_id2') 
    6666 
    6767    class Animal(Entity): 
  • elixir/trunk/tests/test_m2m.py

    r349 r397  
    121121    def test_selfref(self): 
    122122        class Person(Entity): 
     123            using_options(shortnames=True) 
    123124            name = Field(String(30)) 
    124125 
    125126            friends = ManyToMany('Person') 
     127 
     128        setup_all(True) 
     129 
     130        barney = Person(name="Barney") 
     131        homer = Person(name="Homer", friends=[barney]) 
     132        barney.friends.append(homer) 
     133 
     134        session.commit() 
     135        session.clear() 
     136 
     137        homer = Person.get_by(name="Homer") 
     138        barney = Person.get_by(name="Barney") 
     139 
     140        assert homer in barney.friends 
     141        assert barney in homer.friends 
     142 
     143    def test_bidirectional_selfref(self): 
     144        class Person(Entity): 
     145            using_options(shortnames=True) 
     146            name = Field(String(30)) 
     147 
     148            friends = ManyToMany('Person') 
     149            is_friend_of = ManyToMany('Person') 
    126150 
    127151        setup_all(True) 
     
    170194        assert not a3.bs 
    171195 
     196    def test_local_and_remote_colnames(self): 
     197        class A(Entity): 
     198            using_options(shortnames=True) 
     199            key1 = Field(Integer, primary_key=True, autoincrement=False) 
     200            key2 = Field(String(40), primary_key=True) 
     201 
     202            bs_ = ManyToMany('B', local_colname=['foo', 'bar'], 
     203                                  remote_colname="baz") 
     204 
     205        class B(Entity): 
     206            using_options(shortnames=True) 
     207            name = Field(String(60)) 
     208            as_ = ManyToMany('A', remote_colname=['foo', 'bar'], 
     209                                  local_colname="baz") 
     210 
     211        setup_all(True) 
     212 
     213        b1 = B(name='b1', as_=[A(key1=10, key2='a1')]) 
     214 
     215        session.commit() 
     216        session.clear() 
     217 
     218        a = A.query.one() 
     219        b = B.query.one() 
     220 
     221        assert a in b.as_ 
     222        assert b in a.bs_ 
     223 
     224    def test_manual_table_auto_joins(self): 
     225        from sqlalchemy import Table, Column, ForeignKey, ForeignKeyConstraint 
     226 
     227        a_b = Table('a_b', metadata, 
     228                    Column('a_key1', None), 
     229                    Column('a_key2', None), 
     230                    Column('b_id', None, ForeignKey('b.id')), 
     231                    ForeignKeyConstraint(['a_key1', 'a_key2'], 
     232                                         ['a.key1', 'a.key2'])) 
     233 
     234        class A(Entity): 
     235            using_options(shortnames=True) 
     236            key1 = Field(Integer, primary_key=True, autoincrement=False) 
     237            key2 = Field(String(40), primary_key=True) 
     238 
     239            bs_ = ManyToMany('B', table=a_b) 
     240 
     241        class B(Entity): 
     242            using_options(shortnames=True) 
     243            name = Field(String(60)) 
     244            as_ = ManyToMany('A', table=a_b) 
     245 
     246        setup_all(True) 
     247 
     248        b1 = B(name='b1', as_=[A(key1=10, key2='a1')]) 
     249 
     250        session.commit() 
     251        session.clear() 
     252 
     253        a = A.query.one() 
     254        b = B.query.one() 
     255 
     256        assert a in b.as_ 
     257        assert b in a.bs_ 
     258 
     259    def test_manual_table_manual_joins(self): 
     260        from sqlalchemy import Table, Column, ForeignKey, \ 
     261                               ForeignKeyConstraint, and_ 
     262 
     263        a_b = Table('a_b', metadata, 
     264                    Column('a_key1', Integer), 
     265                    Column('a_key2', String(40)), 
     266                    Column('b_id', String(60))) 
     267 
     268        class A(Entity): 
     269            using_options(shortnames=True) 
     270            key1 = Field(Integer, primary_key=True, autoincrement=False) 
     271            key2 = Field(String(40), primary_key=True) 
     272 
     273            bs_ = ManyToMany('B', table=a_b, 
     274                             primaryjoin=lambda: and_(A.key1 == a_b.c.a_key1, 
     275                                                      A.key2 == a_b.c.a_key2), 
     276                             secondaryjoin=lambda: B.id == a_b.c.b_id, 
     277                             foreign_keys=[a_b.c.a_key1, a_b.c.a_key2, 
     278                                 a_b.c.b_id]) 
     279 
     280        class B(Entity): 
     281            using_options(shortnames=True) 
     282            name = Field(String(60)) 
     283 
     284        setup_all(True) 
     285 
     286        a1 = A(key1=10, key2='a1', bs_=[B(name='b1')]) 
     287 
     288        session.commit() 
     289        session.clear() 
     290 
     291        a = A.query.one() 
     292        b = B.query.one() 
     293 
     294        assert b in a.bs_