Django like query engine for any objects.
Project description
Smort Query
Lazy evaluated query implementation for searching through Python objects inspired by Django QuerySets.
Rationale
In many moments of our programming tasks we have to filter iterables in search of the right objects in right order. I realized that most of the time code looks almost the same, but what kind of interface will be easiest to use? In that moment I figured out that Django QuerySets implementation is kinda handy and well known.
So I decided to write small query engine that interface will be similar to Django one. But it will work for Python objects. Additional assumption was that it will be lazy evaluated to avoid memory consumption.
Lookup format
Whole idea relies on keywords arguments naming format.
Let's consider following qualname attr1.attr2 which can we used to get or set value for attribute.
This engine does things similarly but instead of separating by dot(.) we are separating by __ signs.
So above example can be converted to keyword argument name like that attr1__attr2. Due to fact that we can't use . in argument names.
For some methods like filter and exclude, we can also specify comparator.
By default those methods are comparing against equality ==. But we can easily change it.
If we want to compare by using <= we can use __le or __lte postfix.
So we will end up with argument name like attr1__attr2__lt.
All supported comparators are described here in supported comparators section.
Installation
pip install smort-query
Importing
from smort_query import ObjectQuery
# or by alias
from smort_query import OQ
How it works?
Basics
Each method in ObjectQuery produces new query. Which makes chaining very easy.
The most important thing is that ObjectQuery instances are unevaluated - it means that
they are not loading an objects to the memory even when we are chaining them.
Query sets can be evaluated in several ways:
-
Iteration:
query = ObjectQuery(range(5)) for obj in query: print(obj) """out: 1 2 3 4 5 """
-
Checking length:
query = ObjectQuery(range(10)) len(query) """out: 10 """
-
Reversing query:
query = ObjectQuery(range(10)) query.reverse() """out: <ObjectQuery for <reversed object at 0x04E8B460>> """ list(list(query.reverse())) """out [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] """
-
Getting items:
- Getting by index evaluates query:
query = ObjectQuery(range(10)) query[5] """out: 5 """
- But slices not! They creates another query.
query = ObjectQuery(range(10)) query[5:0:-1] """out: <ObjectQuery for <generator object islice_extended at 0x0608B338>> """ list(query[5:0:-1]) """out: [5, 4, 3, 2, 1] """
- Getting by index evaluates query:
-
Initializing other objects that used iterators/iterables (it is still almost same mechanism like normal iteration):
query1 = ObjectQuery(range(10)) query2 = ObjectQuery(range(10)) list(query1) """out: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] """ tuple(query2) """out: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9) """
Use cases
Let's consider following code for populating faked humans:
from random import randint, choice
class Human:
def __init__(self, name, age, sex, height, weight):
self.name = name
self.age = age
self.sex = sex
self.height = height
self.weight = weight
def __repr__(self):
return str(self.__dict__)
def make_random_human(name):
return Human(
name=name,
age=randint(20, 80),
sex=choice(('female', 'male')),
height=randint(160, 210),
weight=randint(60, 80),
)
Creating 10 random humans:
humans = [make_random_human(i) for i in range(10)]
"""out:
[{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
Filtering and excluding
Finding people from age between [30; 75). To do that we will use specialized comparators:
list(ObjectQuery(humans).filter(age__ge=30, age__lt=75))
"""out:
[{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
We can also exclude males in a similar way:
list(ObjectQuery(humans).exclude(sex="male"))
"""out:
[{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
Ordering
Ordering by sex attributes in ascending order:
list(ObjectQuery(humans).order_by("sex"))
"""out
[{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72}]
"""
Ordering by sex attributes in descending order:
list(ObjectQuery(humans).order_by("-sex"))
"""out
[{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
Ordering by multiple attributes:
list(ObjectQuery(humans).order_by("-sex", "height"))
"""out:
[{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71}]
"""
Annotate
If some attributes worth of filtering and ordering are not available by hand we can calculate them on the fly:
# Sorry for example if someone feels offended
root_query = ObjectQuery(humans)
only_females = root_query.filter(sex="female") # reduce objects for annotation calculation
bmi_annotated_females = only_females.annotate(bmi=lambda obj: obj.weight / (obj.height / 100) ** 2)
overweight_females = bmi_annotated_females.filter(bmi__gt=25)
overweight_females_ordered_by_age = overweight_females.order_by("age")
list(overweight_females_ordered_by_age)
"""out:
[{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71, 'bmi': 27.390918560240728},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75, 'bmi': 25.95155709342561},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78, 'bmi': 26.061679307694877}]
"""
Copying
Each method query is returning copy. Where iteration over newly created ones does not affect object sources.
root_query = ObjectQuery(humans).filter(age__ge=30, age__lt=75)
query1 = root_query.filter(weight__gt=75)
query2 = root_query.filter(weight__in=[78, 62])
list(query1)
"""out:
[{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
list(query2)
"""out:
[{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
list(root_query)
"""out:
[{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
But sometimes evaluating some query in middle of chain may break it, so when you explicitly
want to save somewhere copy of query and be sure that further actions on root will not
affect on query, you can do:
root_query = ObjectQuery(humans)
copy = root_query.all()
Reversing
You can also reverse query, but remember that it will evaluate query:
root_query = ObjectQuery(humans).reverse()
list(root_query)
"""out:
[{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71}]
"""
OR
Bitwise OR combines two queries together. Same as union method.
Note that after ORing two queries or even more, ordering might be needed:
root_query = ObjectQuery(humans)
males = root_query.filter(sex="male")
females = root_query.filter(sex="female")
both1 = (males | females)
both2 = males.union(females)
list(both1)
"""out:
[{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
list(both2)
"""out:
[{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78}]
"""
Ascending and descending ordering
The asc() and desc() methods are shorthands for order_by() with a predefined direction:
list(ObjectQuery(humans).asc("age"))
"""out:
[{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77}]
"""
list(ObjectQuery(humans).desc("age"))
"""out:
[{'name': 5, 'age': 75, 'sex': 'male', 'height': 189, 'weight': 77},
{'name': 4, 'age': 73, 'sex': 'male', 'height': 174, 'weight': 62},
{'name': 6, 'age': 64, 'sex': 'male', 'height': 179, 'weight': 63},
{'name': 8, 'age': 64, 'sex': 'male', 'height': 188, 'weight': 72},
{'name': 3, 'age': 48, 'sex': 'female', 'height': 173, 'weight': 78},
{'name': 2, 'age': 45, 'sex': 'female', 'height': 186, 'weight': 74},
{'name': 9, 'age': 43, 'sex': 'female', 'height': 198, 'weight': 78},
{'name': 7, 'age': 35, 'sex': 'female', 'height': 170, 'weight': 75},
{'name': 1, 'age': 33, 'sex': 'female', 'height': 205, 'weight': 67},
{'name': 0, 'age': 24, 'sex': 'female', 'height': 161, 'weight': 71}]
"""
Removing duplicates
unique_justseen() removes consecutive duplicates, while unique_everseen() removes all duplicates keeping the first occurrence. Both accept optional attribute names for comparison:
list(ObjectQuery([1, 1, 2, 2, 3, 1]).unique_justseen())
"""out:
[1, 2, 3, 1]
"""
list(ObjectQuery([1, 1, 2, 2, 3, 1]).unique_everseen())
"""out:
[1, 2, 3]
"""
With attribute-based comparison:
root_query = ObjectQuery(humans)
# Remove consecutive duplicates by sex
list(root_query.unique_justseen("sex"))
# Remove all duplicates by sex (keeps first female and first male)
list(root_query.unique_everseen("sex"))
Intersection
The intersection() method returns objects present in both queries.
Comparison can be done by equality or by specific attributes:
young = ObjectQuery(humans).filter(age__lt=50)
tall = ObjectQuery(humans).filter(height__gt=180)
# Find young AND tall humans
list(young.intersection(tall))
# Or compare by specific attribute
q1 = ObjectQuery(humans)[:5]
q2 = ObjectQuery(humans)[3:]
list(q1.intersection(q2, "name"))
Supported Comparators
Project supports many comparators that can be chosen as postfix for lookup:
- Default comparator is
eq eqmakesa == bexactmakesa == binmakesa in bcontainsmakesb in agtmakesa > bgtemakesa >= bgemakesa >= bltmakesa < bltemakesa <= blemakesa <= b
TODOs
- Sphinx documentation.
Contribution
Any form of contribution is appreciated. Finding issues, new ideas, new features. And of course you are welcome to create PR for this project.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file smort_query-2.1.0.tar.gz.
File metadata
- Download URL: smort_query-2.1.0.tar.gz
- Upload date:
- Size: 123.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e551335f31a01dd66ca8128affc056037bde098fb06f5e61fbd5882f2ba6cd7f
|
|
| MD5 |
af0c0a7dab65553173517236d8bd3efe
|
|
| BLAKE2b-256 |
10a86e8e035a7c08c715bef1f272963db8013a6613e4b21c41916bd9e1eb614c
|
Provenance
The following attestation bundles were made for smort_query-2.1.0.tar.gz:
Publisher:
python-publish.yml on matiuszka/smort-query
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
smort_query-2.1.0.tar.gz -
Subject digest:
e551335f31a01dd66ca8128affc056037bde098fb06f5e61fbd5882f2ba6cd7f - Sigstore transparency entry: 1293691310
- Sigstore integration time:
-
Permalink:
matiuszka/smort-query@c7816e321a74ab2b3514acdbcafd9466b848cbb7 -
Branch / Tag:
refs/tags/2.1.0 - Owner: https://github.com/matiuszka
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@c7816e321a74ab2b3514acdbcafd9466b848cbb7 -
Trigger Event:
release
-
Statement type:
File details
Details for the file smort_query-2.1.0-py3-none-any.whl.
File metadata
- Download URL: smort_query-2.1.0-py3-none-any.whl
- Upload date:
- Size: 10.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c90237d120f0efbe31e9b6669e45cb7c148b54e99fa081b086e0694981281b78
|
|
| MD5 |
7076cd3afa4d6e3e02e4fdf41cf8beab
|
|
| BLAKE2b-256 |
63b4fbffb0f68401892ff71a8111a6da00550f1ee383e43e2043ff2d3b66b69e
|
Provenance
The following attestation bundles were made for smort_query-2.1.0-py3-none-any.whl:
Publisher:
python-publish.yml on matiuszka/smort-query
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
smort_query-2.1.0-py3-none-any.whl -
Subject digest:
c90237d120f0efbe31e9b6669e45cb7c148b54e99fa081b086e0694981281b78 - Sigstore transparency entry: 1293691311
- Sigstore integration time:
-
Permalink:
matiuszka/smort-query@c7816e321a74ab2b3514acdbcafd9466b848cbb7 -
Branch / Tag:
refs/tags/2.1.0 - Owner: https://github.com/matiuszka
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@c7816e321a74ab2b3514acdbcafd9466b848cbb7 -
Trigger Event:
release
-
Statement type: