pallets-eco / flask-admin

Simple and extensible administrative interface framework for Flask
https://flask-admin.readthedocs.io
BSD 3-Clause "New" or "Revised" License
5.79k stars 1.57k forks source link

Comparasing between objects causes crash if _eq_ of db model class is "occupied" #2437

Open stenhardvara opened 4 months ago

stenhardvara commented 4 months ago

There seems to be an incorrect check for object equality. It works if dunder eq is not used in application since it will probably default to object reference equality.

Look into changing ""==" to "is" in https://github.com/pallets-eco/flask-admin/blob/7709a4dc78787b1a9100e56b0c2a31481c9bebb2/flask_admin/contrib/sqla/fields.py#L101

stenhardvara commented 4 months ago

I started to think about this. I think it might be quite hard to solve actually. I couldn't find if the eq has any definition (except for python default) or "==" is used anywhere in sqlalchemy for a model class.

The only reason it crashed for me seems to be that I used the eq for application purposes and it was initialised to a non-callable (for my application to get an exception if "==" was used before equality was defined).

I am not really sure about the purpose for the test for equality in the line above, is to check for model object equality in the sense having the same data and private key? Or is it to check if it actually is the same object by checking memory address?

Looking at the previous implementation is seems that a corresponding check is done towards a list with "in". Which will return true if any of the two cases with either "is" or "==" is true with any element in the list. Maybe the solution here is to write a specific check that never uses "==" and takes in consideration the "get_pk" method when specified. With no get_pk compare keys and that the object is of same type with isinstance,

ElLorans commented 2 months ago

Can you provide a reproducible example causing the error?

stenhardvara commented 2 months ago

@ElLorans Sure, when editing a scoreboard object here it will crash trying to call the dunder eq for the class result:

File "/home/.venv/lib/python3.11/site-packages/flask_admin/contrib/sqla/fields.py", line 170, in iter_choices
    yield (pk, self.get_label(obj), obj in self.data)
                                    ^^^^^^^^^^^^^^^^^
  File "/home/admin_test.py", line 28, in __eq__
    return Result.score_eq(self, value)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: 'NoneType' object is not callable
from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()
app = Flask(__name__, instance_relative_config=True)

class Scoreboard(db.Model):
    id = db.Column(db.Integer, primary_key = True)
    name = db.Column(db.Text)

    results = db.relationship('Result')

    def __str__(self):
        return f'{self.name}'

class ScoreboardView(ModelView):
    create_modal = True
    edit_modal = True
    form_columns = ['name','results']

class Result(db.Model):
    score_gt = None
    score_eq = None

    def __eq__(self, value: object) -> bool:
        return Result.score_eq(self, value)

    def __gt__(self, value: object) -> bool:
        return Result.score_gt(self, value)

    @classmethod
    def set_score_comparison(cls, gt, eq):
        cls.score_gt = gt
        cls.score_eq = eq

    id = db.Column(db.Integer, primary_key = True)
    name = db.Column(db.Text)
    score = db.Column(db.Integer)
    scoreboard_id = db.Column(db.Integer, db.ForeignKey('scoreboard.id'))

    scoreboard = db.relationship('Scoreboard', back_populates='results', lazy='joined')

class ResultView(ModelView):
    create_modal = True
    edit_modal = True
    form_columns = ['name','score','scoreboard']

def create_app():
    app.config['SECRET_KEY'] = 'secret_stuff'
    app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite://'
    app.config['FLASK_ADMIN_SWATCH'] = 'darkly' 

    admin = Admin(template_mode="bootstrap3")

    db.init_app(app)
    admin.init_app(app)

    with app.app_context():
        db.create_all()

    admin.add_view(ScoreboardView(Scoreboard, db.session))
    admin.add_view(ResultView(Result, db.session))

@app.route("/")
def show_scores():
    return "<p>*Not EQ*</p>"

if __name__ == "__main__":
    create_app()

    app.run(debug=True)
ElLorans commented 2 months ago

I believe the error is in your code:

class Result(db.Model): score_gt = None score_eq = None # fix this

stenhardvara commented 2 months ago

@ElLorans No that is not an error! That is a feature on my application code. What I am saying that application code should be able to have any thing or any callable in that line without braking flask-admin. Right now flask-admin as a library is dependent on that the '''eq()''' method is defined and also means some specific form of equality for application specific classes.

I don't think that is suitable since equality of objects (in this case application specific objects) means different things in different applications. Imagine having a class "person" that map to your application database and you add 'eq()' to check is height is equal. Then flask-admin would not provide expected behaviour, but start to list some of the persons as selectable in some forms.

ElLorans commented 2 months ago

it's the 'in' operator failing on your code because your eq calls a None value. This is not a flask-admin issue but a design flaw in your code. You can easily solve it.

stenhardvara commented 2 months ago

@ElLorans No you don't understand. That fact that is crashes just exposes what I think is a very bad behavoiur of flask-admin. I think it is a fundamental design error of flask-admin to use the == operator on database mapped class instances. The "==" is not defined as the same idea of equality in different applications. Flask-admin cannot check "equality" with "==" of the database table mapped classes instances since it not good assumption that it is equality in the same abstract sense in different applications. Equality can mean different thing in different cases.

ElLorans commented 2 months ago

I understand perfectly. Once again, the issue is not in flask-admin which is calling the 'in' operator. If you believe the 'in' operator should not call eq and you are unwilling to change your code so that your object is able to be compared, you can post an issue on cPython.

stenhardvara commented 2 months ago

First of all. No, flask admin does not call the in operator where this happens. Flask-admin calls the "==" operator.

Calling the "==" in flask-admin on application defined database table row mapped class makes absolutly no sense. Because equality is application dependent in sqlalchemy table mapped classes . Database table row equality is in the private key/id. Object instance equality is in the memory address/reference of the objects. These are not necessarly the same! There can be many instances from the same table row. Then the private key/id is the same but the object reference is different. And on top of that "==" can mean whatever the application want.

stenhardvara commented 2 months ago

Could you explain how it is supposed to make sense to check for equality on user defined database table mapped classes instances with "=="?

ElLorans commented 2 months ago

First of all. No, flask admin does not call the in operator where this happens. Flask-admin calls the "==" operator.

That's not what the error says. Take this as an example:

class Result:
    score_gt = None
    score_eq = None

    def __eq__(self, value: object) -> bool:
        return Result.score_eq(self, value)

    def __gt__(self, value: object) -> bool:
        return Result.score_gt(self, value)

    @classmethod
    def set_score_comparison(cls, gt, eq):
        cls.score_gt = gt
        cls.score_eq = eq

    def __init__(self, id, name, score, scoreboard):
        self.id = id
        self.name = name
        self.score = score
        self.scoreboard = scoreboard

a = Result(1, "a", 0, 0)
b_named_a = Result(1, "a", 0, 0)
c = Result(1, "c", 1, 0)
d = Result(1, "d", 0, 1)

"a" in [a, b_named_a, c, d] # this will raise an error

I would expect the in operator to work on a collection of objects, but your design makes it impossible: you would first need to set the score_eq function on each object inside the collection. Is that really what your logic requires you to do? I would say you are not following OOP.

stenhardvara commented 2 months ago

Sorry I messed up in my exemple, it is not crashing where I intended. Didn't look close enough at the error.

The intention was to fail at the line in my original post where "==" is.

My application code does not look like this and been significantly refactored since July.

I agree that the in operator should work on a collection. So "==" should throw NotImplemented if not implemented and then in will check "is".

However I still thing that "==" in my original post should be either changed to "is" for reference equality or to "a.get_pk() == b.get()" where a and be is being compared. This depends of the purpose for flask-admin to check this equality.

ElLorans commented 2 months ago

That you can do it yourself by overriding correctly the eq method

stenhardvara commented 2 months ago

I think that is an incorrect statement. The eq method is supposed to be used for value equality. Which will cause in some cases sqlachemy to do multiple queries and unexpected results.

Either the private key (table row the same) or the object reference (same object) is what should make sense for flask-admin to consider as equality.

ElLorans commented 2 months ago

fask-admin will happily do that if you tell your object to do it.

You only need to change to this: '''

def eq(self, value: object) -> bool: return self.id == value.id '''

stenhardvara commented 2 months ago

I know that I can set it to whatever I want in my application context. That is also irrelevant in my specific application at this point since everything relating to this is already refactored away for other reasons.

I still think flask-admin as a library should not be dependent on "==" on sqlalchemy database mapped object instances.

ElLorans commented 2 months ago

your example crashes on the 'in' operator, that calls the == operator. You can run my example or look at the error you provided to verify that.

stenhardvara commented 2 months ago

Yes I have clearly stated that is a misstake by me when attempted to recreate the problem. Please ignore the example.

Please look at the specific line provided in the first comment when I reported this.

ElLorans commented 2 months ago

okay now I understand what you are talking about. But that boolean is needed to understand what field must be marked as 'selected'. using 'is' would not work.

stenhardvara commented 2 months ago

If "is" wont work and test is to check if a object match the selected one in seems to me that the primary key equality is perfect.

Then if a user have multiple entries in the database have same values (for other then primary key) not cause any problems. Like two people with the same name or something else.

And no extra queries would automatically be made by sqlalchemy to get unloaded fields/relationships that is not necessary.

So I would suggest doing something like obj.get_pk() == self.data.get_pk() if that doesn't create problems.

ElLorans commented 2 months ago

but self.data is the wtf form, it does not have a pk. Comparing a orm table's attribute vs a form value using == makes total sense.

stenhardvara commented 2 months ago

But then "==" doesn't make sense either because that is a comparison between different objects? A sql mapped class object always have a primary key unless it is comittes to database. If is not yet committed uniqueness is not guaranteed as far as I know. You could have many objects with the same data (except for primary key) in a database.

I think this is a really hard problem to get right.

ElLorans commented 2 months ago

it really isn't. == compares values, so if the table row column A has value 1, and the form attribute A has value 1, the == will return True.

stenhardvara commented 2 months ago

Nope, it will be way messier.

If table column A has value 4 and table column B has value "cucumber". Then it will be dependent on how the query looks if both A or B is loaded from database at the same time or not. Lazyloading is also default for relationship loading in sqlalchemy. So there is concurrency issues there as well.

But this doesn't really makes sense here either, since one of the values that should be compared if checking value equality is the primary key. If thats the case then this check should be always be false(the wtform don't have a PK yet). Something is strange here.