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

Enhancement: Advanced Filtering API #1468

Merged

Conversation

michael-genson
Copy link
Collaborator

@michael-genson michael-genson commented Jul 4, 2022

This extends the get_all routes to include a robust query filter. It allows the user to combine multiple relational and logical operators and group them arbitrarily.

Supported Relational Operators : =, <>, >, <, >=, <=
Supported Logical Operators: AND, OR

This enables simple query strings such as:
name = "Pasta Fagioli" OR name = "My Favorite Recipe"
createdAt > "2021-02-22"

But parenthesis can be used for more robust filters such as:
name="cup" OR (useAbbreviation=f AND (name="ounce" OR createdAt > "2022-07-04T19:49:27Z"))

The implementation uses a combination of enums (for operators), attribute validation (to prevent calling the database with an attribute that doesn't exist) and parameterized queries (to prevent SQL injection) for validation. It also throws a 400 error when predictable mistakes are made (invalid attributes, unbalanced parenthesis, or invalid date/datetime formats).

The meat of this PR is in query_filter.py which does all the parsing and validation; the remaining codebase remains largely unchanged.


Assuming this PR is accepted, I will do a proper writeup in the docs

@hay-kot
Copy link
Collaborator

hay-kot commented Jul 7, 2022

This is nuts. Looks really powerful and something I want to get into Mealie, It looks like a pretty solid implementation I have a couple of questions.

  1. Did you base this off of any other Web API?
  2. Have you thought about how we would implement this on the frontend? At first glance it seems pretty complicated to wrap a UI around this other than exposing a "SQL Query Mode"
  3. I do want to break up your test into more isolated cases, I can do that though if you don't want to take a stab at it. I know utilizing Pytest stuff can be a bit magical....

@michael-genson
Copy link
Collaborator Author

Thank you! I was really happy with how it came out. I wrote the implementation myself, but I took inspiration from Freshdesk's relatively-new filter API which is functionally very similar:
https://developers.freshdesk.com/api/#filter_tickets


For a frontend wrapper, if we wanted to fully support it I've seen things that are essentially query builders:
https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_N647582.html

Or something like this (this is for calculations, not queries, but we could adapt a similar UI):
https://knowledge.hubspot.com/crm-setup/create-calculation-properties

The alternative would be something more lightweight that doesn't expose the full functionality. We could pull the schema, list all the fields, and allow users to select the operator/value. Something like:

[ ] Name       [=, <>, >, <] [Input]
[ ] Created At [=, <>, >, <] [Input]
[x] Updated At [=, <>, >, <] [Input]
...etc.

You can't get as granular with this (mainly because of parenthesis) but it would be simpler to implement.


I'm happy to add some more tests! Pytest is a bit of an enigma to me but using what you've already done as reference has made it pretty easy. What sort of isolated cases did you have in mind? Just more specific stuff?

Copy link
Collaborator

@hay-kot hay-kot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very Cool, I added some notes on how I'd like to see the test broken out, if you don't get to it by Sunday Morning, I'll take a stab at it some I can get this merged before I take some time off.

Thanks for the additional context on your though process. A frontend query builder could be pretty interesting, I'll have to take a stab at that at some point.

Comment on lines 150 to 193
# check that basic filtering is working
query = PaginationQuery(page=1, per_page=-1, query_filter='name="test unit 2"')
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 1
assert unit_results[0].id == unit_2.id

# check that datetimes are resolving properly
dt = unit_2.created_at.isoformat()
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 2
assert unit_1.id not in [unit.id for unit in unit_results]

# check that booleans are resolving properly
query = PaginationQuery(page=1, per_page=-1, query_filter="useAbbreviation=true")
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 1
assert unit_results[0].id == unit_1.id

# check an advanced filter
dt = unit_3.created_at.isoformat()
qf = f'name="test unit 1" OR (useAbbreviation=f AND (name="test unit 2" OR createdAt > "{dt}"))'
query = PaginationQuery(page=1, per_page=-1, query_filter=qf)
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 2
assert unit_3.id not in [unit.id for unit in unit_results]

# verify that improper queries throw 400 errors
route = "/api/units"

# unbalanced parenthesis
qf = '(name="test name" AND useAbbreviation=f))'
response = api_client.get(route, params={"queryFilter": qf}, headers=unique_user.token)
assert response.status_code == 400

# invalid datetime format
qf = 'createdAt="this is not a valid datetime format"'
response = api_client.get(route, params={"queryFilter": qf}, headers=unique_user.token)
assert response.status_code == 400

# invalid attribute
qf = 'badAttribute="test value"'
response = api_client.get(route, params={"queryFilter": qf}, headers=unique_user.token)
assert response.status_code == 400
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to add some more tests! Pytest is a bit of an enigma to me but using what you've already done as reference has made it pretty easy. What sort of isolated cases did you have in mind? Just more specific stuff?

I think you cover most of the cases you've introduced. Basically I'm looking for each one of these comments to be their own test so it's easier to isolate specific cases that I end up breaking. You could probably accomplish this by running the setup in a fixture and injecting what you need into the function from there and just set the fixture up to only run once.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, so separate them out into their own functions so it handles them as separate tests. That makes sense! I'll see if I can get it done tonight or tomorrow morning; I'll be out of town for a few days (leaving tomorrow) so if I don't get to it by then it's all yours :)

@hay-kot hay-kot merged commit 7f50071 into mealie-recipes:mealie-next Jul 10, 2022
@michael-genson michael-genson deleted the enhancement/apifiltering branch July 10, 2022 04:57
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.

2 participants