Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Smarter schema that handles dotted aliases and resolver methods #317

Merged
merged 6 commits into from Jan 29, 2022

Conversation

SmileyChris
Copy link
Contributor

@SmileyChris SmileyChris commented Jan 9, 2022

Fixes #291 (even though it was closed) and fixes #250 and adds in new functionality to allow calculated fields via resolve_ static methods on the response schema class.

Includes tests and docs.

@SmileyChris
Copy link
Contributor Author

SmileyChris commented Jan 9, 2022

I bumped the pydantic version to 1.9 to support the fancier DjangoGetter method being used. The alternative is to backport the pydantic fix to a method on the ninja Schema class:

    @classmethod
    def _decompose_class(cls, obj: Any) -> GetterDict:
        if isinstance(obj, GetterDict):
            return obj
        return super()._decompose_class(obj)

@vitalik
Copy link
Owner

vitalik commented Jan 10, 2022

I bumped the pydantic version to 1.9
well this cannot be merged - there ALOT projects that depends on it
I would keep minimum 1.6 pydantic while it still not breakable

@SmileyChris
Copy link
Contributor Author

SmileyChris commented Jan 12, 2022

well this cannot be merged - there ALOT projects that depends on it
I would keep minimum 1.6 pydantic while it still not breakable

Sure, I backported that little bit of logic from Pydantic into the Schema._decompose_class method and reverted the changes bumping the minimum version. Then I squashed everything to keep things tidier :)

@vitalik
Copy link
Owner

vitalik commented Jan 20, 2022

Hi @SmileyChris

I have one concern about this PR

Basically it's the performance penalty here:

    def __getitem__(self, key: str) -> Any:
        resolve_func = getattr(self._cls, f"resolve_{key}", None) if self._cls else None
        if resolve_func and callable(resolve_func):
            item = resolve_func(self._obj)
        else:
            try:
                item = attrgetter(key)(self._obj)
            except AttributeError as e:
                raise KeyError(key) from e
        return self.format_result(item)

Every attribute will call for getattr, callable check, attrgetter, etc

but in 99% of the cases schemas are just generally pulling existing atrs...

maybe solution here would be first try to get attr default way and if attr is not there try to resolve it with attrgetter/resolve_func:

try:
    return getattr(self._obj)
except AttributeError:
    if resolve_func and callable(resolve_func):
           return resolve_func(self._obj) 
    else:
        return attrgetter(key)(self._obj)

@SmileyChris
Copy link
Contributor Author

SmileyChris commented Jan 20, 2022

At first glance, that looked like a fine solution. But it'll get in the way allowing for resolve_<attr> functions if it's shadowing an existing attribute.

Doing some basic %timeit tests, it looks like it only adds about 400ns to each attr call, so 0.0000004s.

# Old class
In [8]: %timeit old_django_getter['name']
455 ns ± 2.44 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

# New class
In [8]: %timeit new_django_getter['name']
875 ns ± 15.7 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

The repr of setting that up, showing an example of using a resolver over an existing attribute:

In [1]: from django.conf import settings

In [2]: settings.configure()

In [3]: from ninja.schema import DjangoGetter, Schema

In [4]: class PersonSchema(Schema):
   ...:     name: str
   ...:     age: int
   ...:     @staticmethod
   ...:     def resolve_name(obj):
   ...:         return obj.name.title()
   ...: 

In [5]: class Person:
   ...:     name = 'chris beaven'
   ...:     age = 99
   ...: 

In [6]: new_django_getter = DjangoGetter(Person(), PersonSchema)

In [7]: new_django_getter['name']
Out[7]: 'Chris Beaven'

The only extra check for the 99% will be a check for the resolve method, then the use of attrgetter rather than getattr which looks like it's about 70ns slower. I'll optimize it to try a standard getattr first.

@SmileyChris
Copy link
Contributor Author

Ok, now the only overhead for the 99% case is a getattr for a resolve attr first. Python logic means it won't check if it's callable unless the resolve attr is found.

@vitalik
Copy link
Owner

vitalik commented Jan 22, 2022

@SmileyChris great, looks promising

I guess the only small design issue is this part:

    @staticmethod
    def resolve_owner(obj):

that you have to to mark method as static... I guess generally people will forget it and will use self as first argument (maybe need some validation on top to force it never forgotten)

or maybe just pass self somehow anyway ?

def resolve_owner(self, obj):

also would be nice to have access to self dict to cover long awaited feature #935 from pydantic:

class Rectangle(BaseModel):
    width: int
    length: int
    area: int

    def resolve_area(self) -> int:
        return self.width * self.length

@SmileyChris
Copy link
Contributor Author

SmileyChris commented Jan 23, 2022 via email

@SmileyChris SmileyChris force-pushed the smarter-schema branch 2 times, most recently from 7f78923 to 0b97ef4 Compare January 24, 2022 02:00
Standard methods have access to a self Schema instance even!
@SmileyChris
Copy link
Contributor Author

Ok, now resolvers can be standard methods, giving you access to self like you want (it has to be a bit magic, because the real schema instance is only created after all the fields are resolved fully).

@vitalik vitalik mentioned this pull request Jan 27, 2022
@vitalik
Copy link
Owner

vitalik commented Jan 27, 2022

Hi @SmileyChris

I've been running some test - so far looks good except the perfomance for from_orm

example - 50k executions for from_orm takes 10 seconds on my machine (while it's only 1 second before)

So I basically moved the function to find resolve_* attributes to one time call and cache on class level

see - da28fa1

It still in progress - there are few coverage missing lines, but so far looks promising

@SmileyChris
Copy link
Contributor Author

That's nice caching speedup. How about this metaclass approach to keep resolver data on their own classes rather than one mega dictionary?

@vitalik vitalik merged commit 055b427 into vitalik:master Jan 29, 2022
@vitalik
Copy link
Owner

vitalik commented Jan 29, 2022

@SmileyChris
Thank you! Works perfect

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Foreign key field value instead of id without nesting schemas ? Using a source attribute in schemas?
2 participants