Issue: Hybrid methods not using table aliases
Author: mikemill
State: closed
Created: 2019-07-09 21:55:49

Peewee 3.8.2

I'm using a hybrid_method to simplify my querying.  I found, however, that it ignores table aliases.  In my use case the table has a foreign key to itself and I need to check the dates on the joined table and not on the parent table.

Here is an example script:
```python
from datetime import date
from peewee import DateField, ForeignKeyField, IntegerField, Model
from playhouse.hybrid import hybrid_method

from .database import db


class DateTest(Model):
    value = IntegerField()
    start = DateField(default=date.today)
    finish = DateField(null=True)
    parent = ForeignKeyField('self', null=True, on_delete='SET NULL')

    @hybrid_method
    def current_on(self, date):
        return self.start <= date and (self.finish is None or self.finish >= date)

    @current_on.expression
    def current_on(cls, date):
        return (cls.start <= date) & (cls.finish.is_null(True) | (cls.finish >= date))

    class Meta:
        database = db


if __name__ == '__main__':

    db.create_tables([DateTest], safe=True)

    parent = DateTest.create(value=123)
    child = DateTest.create(value=456, parent=parent)

    query = (
        DateTest.select()
        .where(DateTest.current_on(date.today()))
    )

    print(query.sql())

    Child = DateTest.alias()
    query2 = (
        DateTest.select()
        .join(Child)
        .where(Child.current_on(date.today()))
    )

    print(query2.sql())
```

The output is:
```
('SELECT `t1`.`id`, `t1`.`value`, `t1`.`start`, `t1`.`finish`, `t1`.`parent_id` FROM `datetest` AS `t1` WHERE ((`t1`.`start` >= %s) AND (((`t1`.`finish` IS %s) OR `t1`.`finish`) <= %s))', [datetime.date(2019, 7, 9), None, datetime.date(2019, 7, 9)])

('SELECT `t1`.`id`, `t1`.`value`, `t1`.`start`, `t1`.`finish`, `t1`.`parent_id` FROM `datetest` AS `t1` INNER JOIN `datetest` AS `t2` ON (`t1`.`parent_id` = `t2`.`id`) WHERE ((`t1`.`start` >= %s) AND (((`t1`.`finish` IS %s) OR `t1`.`finish`) <= %s))', [datetime.date(2019, 7, 9), None, datetime.date(2019, 7, 9)])
```
As you can see in the second query, it is using `t1` instead of `t2` for the `current_on` expression.

Looking at the parameters to `current_on` I don't see how I could detect that an alias was used to change the results.

Well, the problem is either the descriptor protocol, or the implementation of `ModelAlias`.

I find it simpler to look at this example, which I'm going to walk through just to get it all out in front of me to look at:

```python
class Interval(Model):
    start = IntegerField()
    end = IntegerField()

    @hybrid_property
    def length(self):
        return self.end - self.start
```

The `length` attribute implements the descriptor protocol, so it has `__get__()` which is called when the attribute is accessed as either a class attribute or instance attribute. `hybrid_property` is the simple case where we can pass either the model class or a model instance into the `length()` function as `self`.

So we either end up with:

```python
Interval.length  ->  length(self=Interval)   ->  Interval.end - Interval.start

obj = Interval()
obj.length  -> length(self=obj)   -> obj.end - obj.start
```

Model aliases are not subclasses of the model being aliased (though perhaps they ought to be) -- rather they are implemented as a simple proxy object. So when you look up the length attribute on a model alias, you get:

```python
IA = Interval.alias()
IA.length  ->  ModelAlias(model=Interval)  ->  self.model.length  ->  length(self=Interval)
```

I'm not sure how to fix this one off the top of my head.

So it's possible to look at the model's `__dict__` while resolving the attribute on the alias, and then (if it's a hybrid property) explicitly passing `self` instead of `self.model` into the appropriate method on the `hybrid_property` instance....but...yuck.

Oh, I think I recall one of the reasons why we aren't doing model aliases as dynamically-created subclasses -- because of side-effects that occur when you declare foreign-keys in a derived class. So you subclass a model that has a foreign-key, it adds some stuff to the related model's metadata.

```python
class User(Model):
    username = TextField()

class Tweet(Model):
    user = ForeignKeyField(User, backref='tweets')
    content = TextField()

class SubTweet(Tweet):
    pass

# Now User._meta.backrefs contains a reference to "SubTweet.user", e.g.
```

That said, getting rid of ModelAlias would remove some rather yucky conditionals and things... Probably be a really big win if I can pin down these side-effects and handle them nicely. Just thinking out loud.

I'm gonna leave this with the patch from 9e4f1fa at the moment.