The following sample code shows how we would use a Relation
class (also available in relation.py):
Chapter7/relation.py
from os import unlink db="/tmp/abcr.db" try: unlink(db) except: pass class Entity(AbstractEntity): database=db class Relation(AbstractRelation): database=db class A(Entity): pass class B(Entity): pass class AB(Relation): a=A b=B a1=A() a2=A() b1=B() b2=B() a1.add(b1) a1.add(b2) print(a1.get(B)) print(b1.get(A))
After defining a few entities, defining the relation between those entities follows the same pattern: we define a Relation
class that is a subclass of AbstractRelation
to establish a reference to a database that will be used.
Then we define an actual relation between two entities by subclassing Relation
and defining two class variables, a
and b
that refer to the Entity
classes that form each half of the relation.
If we instantiate a few entities, we may then define a few relations between these instances by using the add()
method and retrieve related entities with the get()
method.
Note that those methods are called on the Entity
instances, allowing for a much more natural idiom than the use of class methods in the Relation
class. These add()
and get()
methods were added to those entity classes by the MetaRelation
metaclass, and in the next section, we will see how this is accomplished.
The class diagram for relation classes looks nearly identical to the one for entities:
The implementation of the AbstractRelation
class is very minimalistic because it is only used to create some thread local storage and establish a relation with the MetaRelation
metaclass:
Chapter7/relation.py
class AbstractRelation(metaclass=MetaRelation): _local = threading.local()
No methods are specified since the metaclass will take care of adding suitable methods to the entity class that are a part of this relationship.
The MetaRelation
class has two goals: creating a database table that will hold records for each individual relation and adding methods to the entity classes involved, so that relations can be created, removed, and queried:
Chapter7/relation.py
class MetaRelation(type): @staticmethod def findattr(classes,attribute): a=None for c in classes: if hasattr(c,attribute): a=getattr(c,attribute) break if a is None: for c in classes: a = MetaRelation.findattr(c.__bases__,attribute) if not a is None: break return a def __new__(metaclass,classname,baseclasses,classdict): def connect(cls): if not hasattr(cls._local,'conn'): cls._local.conn=sqlite.connect(cls._database) cls._local.conn.execute('pragma foreign_keys = 1') cls._local.conn.row_factory = sqlite.Row return cls._local.conn def get(self,cls): return getattr(self,'get'+cls.__name__)() def getclass(self,cls,relname): clsname = cls.__name__ sql = 'select %s_id from %s where %s_id = ?'%( clsname,relname,self.__class__.__name__) cursor=self._connect().cursor() cursor.execute(sql,(self.id,)) return [cls(id=r[clsname+'_id']) for r in cursor] def add(self,entity): return getattr(self, 'add'+entity.__class__.__name__)(entity) def addclass(self,entity,Entity,relname): if not entity.__class__ == Entity : raise TypeError( 'entity not of the required class') sql = 'insert or replace into %(rel)s ' sql+= '(%(a)s_id,%(b)s_id) values (?,?)' sql%= { 'rel':relname, 'a':self.__class__.__name__, 'b':entity.__class__.__name__} with self._connect() as conn: cursor = conn.cursor() cursor.execute(sql,(self.id,entity.id)) relationdefinition = False if len(baseclasses): if not 'database' in classdict: classdict['_database']=MetaRelation.findattr( baseclasses,'database') if classdict['_database'] is None: raise AttributeError( '''subclass of AbstractRelation has no database class variable''') relationdefinition=True if not '_local' in classdict: classdict['_local']=MetaRelation.findattr( baseclasses,'_local') classdict['_connect']=classmethod(connect) if relationdefinition: a = classdict['a'] b = classdict['b'] if not issubclass(a,AbstractEntity) : raise TypeError('a not an AbstractEntity') if not issubclass(a,AbstractEntity) : raise TypeError('b not an AbstractEntity') sql = 'create table if not exists %(rel)s ' sql+= '( %(a)s_id references %(a)s ' sql+= 'on delete cascade, ' sql+= '%(b)s_id references %(b)s ' sql+= 'on delete cascade, ' sql+= 'unique(%(a)s_id,%(b)s_id))' sql%= { 'rel':classname, 'a':a.__name__, 'b':b.__name__} conn = sqlite.connect(classdict['_database']) conn.execute(sql) setattr(a,'get'+b.__name__, lambda self:getclass(self,b,classname)) setattr(a,'get',get) setattr(b,'get'+a.__name__, lambda self:getclass(self,a,classname)) setattr(b,'get',get) setattr(a,'add'+b.__name__, lambda self,entity:addclass(self, entity,b,classname)) setattr(a,'add',add) setattr(b,'add'+a.__name__, lambda self,entity:addclass(self, entity,a,classname)) setattr(b,'add',add) return type.__new__(metaclass, classname,baseclasses,classdict)
As was the case for the MetaEntity
class, MetaRelation
performs its magic through its __new__()
method.
First, we check if we are creating a subclass of AbstractRelation
by checking the length of the baseclasses
parameter (remember that MetaRelation
is defined as a metaclass for AbstractRelation
, meaning that not only its subclasses, but also AbstractRelation
itself will be processed by the metaclass machinery, something that is not really needed here).
If it is a subclass, we copy the database and thread local storage references to the class dictionary for quick access.
If there was no database
attribute specified, we know the class being defined is a subclass of Relation
, that is, a specific relation class and mark this in the relationdefinition
variable (highlighted).
If we are dealing with a concrete definition of a relation, we will have to work out which entities are involved. This is done by checking the class dictionary for attributes named a
and b
, that should be subclasses of AbstractEntity
(highlighted). These are both halves of the relation and their names are used to create a bridging table if not already present.
If we were to define a relation like this:
class Owner(Relation): a=Car b=User
The SQL statement generated would be:
create table if not exists Owner ( Car_id references Car on delete cascade, User_id references User on delete cascade, unique(Car_id,User_id) )
Each column references the primary key in the corresponding table (because we did specify just the table in the references clause) and the on delete cascade
constraint will make sure that if an entity is deleted, the relation is deleted as well. The final unique
constraint will make sure that if there is a relation between specific instances, there will be only one record reflecting this.
The final part of the __new__()
method deals with inserting methods in the entity classes that are involved in this relation. Adding methods to other classes may sound like magic, but in Python, classes themselves are objects too and have class dictionaries that hold the attributes of a class. Methods are just attributes that happen to have a value that is a function definition.
We can, therefore, add a new method at runtime to any class by assigning a reference to a suitable function to a class attribute. The MetaEntity
class only altered the class dictionary of an Entity
before it was created. The MetaRelation
class goes one step further and not only alters the class dictionary of the Relation
class, but also those of the Entity
classes involved.
Altering class definitions at runtime is not limited to metaclasses, but should be used sparingly because we expect classes to behave consistently anywhere in the code.
If we have two classes, A
and B
, we want to make sure each has its own complement of get and add methods. That is, we want to make sure the A
class has getB()
and addB()
methods and the B
class has getA()
and addA()
. We, therefore, define generic getclass()
and addclass()
functions and assign those with tailored lambda functions to the named attributes in the class concerned (highlighted).
If we assume again that the entity classes are called A
and B
and our relation is called AB
, the assignment:
setattr(a,'get'+b.__name__,lambda self:getclass(self,b,classname))
will mean that the A class will now have a method called getB
and if that method is called on an instance of A
(like a1.getB())
it will result in a call to getclass
like:
getclass(a1,B,'AB')
We also create (or redefine) a get()
method that when given a class as an argument will find the corresponding getXXX
method.
The getclass()
method is defined as follows:
def getclass(self,cls,relname): clsname = cls.__name__ sql = 'select %s_id from %s where %s_id = ?'%(clsname,relname,self.__class__.__name__) cursor=self._connect().cursor() cursor.execute(sql,(self.id,)) return [cls(id=r[clsname+'_id']) for r in cursor]
First, it constructs an SQL statement. If getclass()
was invoked like getclass(a1,B,'AB')
, this statement might look like this:
select B_id from AB where A_id = ?
Then it executes this statement with self.id
as the argument. The resulting list of IDs is returned as a list of instances.
The add functionality follows the same pattern, so we only take a quick look at the addclass()
function. It first checks if the entity we are trying to add is of the required class. Note that if we make a call like a1.addB(b1)
, it will refer to a function inserted by the MetaRelation
class that will then be called like addclass(a1,b1,B,'AB')
.
The SQL statement that is subsequently constructed may look like this:
insert or replace into AB (A_id,B_id) values (?,?)
Because of the unique constraint we specified earlier, a second insert that specifies the same specific relation may fail in which case we replace the record (that is effectively ignoring the failure). This way, we may call add()
twice with the same arguments, yet still end up with just a single record of the relation.
One of the most important tools for a user to interact with a collection of entities is a table. A table provides a logical interface to page through lists of data and present relevant attributes in columns. Other features often found in such a table interface are the options to sort on one or more attributes and to drill down, that is, to show only those entities that have some specific value for an attribute.
3.137.217.17