ponyorm / pony

Pony Object Relational Mapper
Apache License 2.0
3.58k stars 243 forks source link

Abstract entity mixin #104

Open jasonmyers opened 9 years ago

jasonmyers commented 9 years ago

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

kozlovsky commented 9 years ago

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

yarreg commented 8 years ago

+1

ghost commented 7 years ago

+1

indyo commented 5 years ago

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

Thanks.

Kaplas85 commented 1 year ago

Any update about it?

ibbathon commented 7 months ago

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.

VincentSch4rf commented 6 months ago

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:

ibbathon commented 5 months ago

@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.