pony icon indicating copy to clipboard operation
pony copied to clipboard

Abstract entity mixin

Open jasonmyers opened this issue 11 years ago • 8 comments

This might be an enhancement, but is there any way for an abstract entity (i.e. a mixin) that can contribute methods/columns to an entity, but doesn't show in the inheritance hierarchy? e.g. as in Django https://docs.djangoproject.com/en/1.7/topics/db/models/#abstract-base-classes

An example use case would be a created/updated timestamp mixin, e.g. something like

class Timestamped(db.Entity):
    _abstract_ = True
    created = Required(datetime, default=datetime.utcnow)
    updated = Required(datetime,  default=datetime.utcnow)
    def before_update(self):
        super().before_update()
        self.updated = datetime.utcnow()

class Student(Timestamped):
    ... Student table with timestamp columns ...

class Course(Timestamped):
    ... Course table with timestamp columns ...

where Timestamped can be re-used across multiple Entities as a mixin, can't be instantiated or queried, and doesn't trigger the _discriminator_ code

jasonmyers avatar Nov 12 '14 05:11 jasonmyers

Good idea, I think we should add such functionality. I'll think about it.

kozlovsky avatar Nov 12 '14 07:11 kozlovsky

+1

yarreg avatar Sep 22 '15 15:09 yarreg

+1

ghost avatar Jun 11 '17 16:06 ghost

Hi. Has this been considered? Currently it's the one thing that's stopping me from using Pony in my project.

Thanks.

indyo avatar Dec 31 '18 15:12 indyo

Any update about it?

Kaplas85 avatar Feb 22 '23 04:02 Kaplas85

EDIT: @VincentSch4rf provided a much cleaner solution below, assuming you don't mind the minor limitation I describe in my next comment.

For anyone still waiting for this, you can half accomplish it with monkey-patching. Not a perfect solution, but it gets me what I want (UUID IDs and timestamps on all classes without having to duplicate code):

# my_project/models.py
from pony import orm
from my_project.mixins import uuid_with_timestamps
db = orm.Database()

# All entity classes defined after this point will have a UUID ID and timestamps
uuid_with_timestamps()

class User(db.Entity):
    username = orm.Required(str)
    password_hash = orm.Required(str)
# my_project/mixins.py
import uuid
from datetime import datetime
from pony import orm

old_init = orm.core.EntityMeta.__init__

def uuid_with_timestamps():
    def shared_before_insert(self):
        self.updated_at = self.created_at

    def shared_before_update(self):
        self.updated_at = datetime.now()

    def new_init(entity_cls, cls_name, cls_bases, cls_dict):
        entity_cls.uuid = orm.PrimaryKey(uuid.UUID, default=uuid.uuid4)
        entity_cls.created_at = orm.Required(datetime, default=datetime.now)
        entity_cls.updated_at = orm.Optional(datetime)
        entity_cls.before_insert = shared_before_insert
        entity_cls.before_update = shared_before_update
        old_init(entity_cls, cls_name, cls_bases, cls_dict)

    orm.core.EntityMeta.__init__ = new_init

Caveats: I haven't tested this with inheritance (I have no intention of using that) and I don't know yet how hard the unit tests will be. Also, the shared before_ methods will overwrite any customizations you try to make. There are ways around that, but I wanted to keep this simple.

ibbathon avatar Dec 02 '23 05:12 ibbathon

You can also achieve this by creating a custom EntityMeta class, injecting the common fields into the attrs before passing it to the class' constructor like this:

class CustomMeta(EntityMeta):

    def __new__(metacls, name: str, bases, attrs):
        attrs['pk1'] = Required(datetime)
        attrs['pk2'] = Required(int)
        return super().__new__(metacls, name, bases, attrs)

By overriding the __init__ as well, you can also set composite primary keys, which are shared by a set of entities. I found this particularly useful when working with timescaledb.

class CustomMeta(EntityMeta):
   
    ...
    
    def __init__(cls, name, bases, cls_dict, **kwargs):
        indexes = [Index("pk1", "pk2", is_pk=True)]
        setattr(cls, "_indexes_", indexes)
        super(CustomMeta, cls).__init__(name, bases, cls_dict)

I would still love this to be supported natively :smile:

VincentSch4rf avatar Jan 17 '24 12:01 VincentSch4rf

@VincentSch4rf Interestingly, I had already tried that and discarded it, but forgot why. It turns out that some code deep in Pony rejects some queries on any models which do not explicitly have EntityMeta as their type (i.e. subclassed metas don't count). The only failing query I've found so far is User.select(), so it may be worth it for others. If so, here's a full minimalist example showing both it working (both shared attributes and shared before_insert) and the failure I mentioned:

import uuid
from datetime import datetime, timezone
from pony import orm

db = orm.Database()

class CustomMeta(orm.core.EntityMeta):
    def __new__(metacls, name, bases, attrs):
        attrs["uuid"] = orm.PrimaryKey(uuid.UUID, default=uuid.uuid4)
        old_before_insert = attrs.get("before_insert")
        def new_before_insert(self):
            self.updated_at = datetime.now(timezone.utc)
            if old_before_insert:
                old_before_insert()
        attrs["updated_at"] = orm.Optional(datetime)
        attrs["before_insert"] = new_before_insert
        return super().__new__(metacls, name, bases, attrs)

class User(db.Entity, metaclass=CustomMeta):
    username = orm.Required(str)

if __name__ == "__main__":
    db.bind(provider="sqlite", filename=":memory:")
    db.generate_mapping(create_tables=True)
    with orm.db_session:
        User(username="blah")
        print(list(orm.select((u.uuid, u.username, u.updated_at) for u in User)))
        # Fails in pony.orm.core.extract_vars due to pony.orm.ormtypes.normalize
        # not checking for subclass metaclasses
        print(list(User.select()))

And yes, having it supported natively would be amazing.

ibbathon avatar Feb 05 '24 17:02 ibbathon