SQLAlchemy Queries helper
Gyver provides query helpers to address the following challenges:
- When you want to avoid scattering your ORM classes throughout your code.
- When you need a generic repository function for running queries.
Example
Let's consider a generic function for running a SELECT query on specific fields of the Person entity.
from gyver.database import Entity
from gyver.database.sqlalchemy import SaContext
import sqlalchemy as sa
class Address(Entity):
street = sa.Column(sa.Text)
number = sa.Column(sa.Integer)
country = sa.Column(sa.Text)
zip_code = sa.Column(sa.Text)
class Person(Entity):
name = sa.Column(sa.String(100))
email = sa.Column(sa.Text)
address_id = sa.Column(sa.Integer, sa.ForeignKey('address.id'))
address = make_relation(Address, use_list=True)
Without Gyver's helpers, the function might look like this:
def list_people(context: SaContext, **comparison):
q = sa.select(Person).where(
*(
getattr(Person, field, getattr(Address, field)) == value
for field, value in comparison.items()
)
)
with context as connection:
result = connection.execute(q)
# Then, call the function
list_people(context, name='anyname', zipcode="012345678")
With Gyver, you can achieve a more powerful approach:
from gyver.database import query
def list_people(context: SaContext, where: query.BindClause):
q = sa.select(Person).where(where.bind(Person))
with context as connection:
result = connection.execute(q)
list_people(
context,
query.and_(
# Access the 'name' attribute directly on the person
query.Where('name', 'anyname'),
# Access the 'zipcode' attribute on address; no conflict between Person and Address names
query.Where('address.zipcode', "012345678"),
# You can use comparisons other than equals
query.Where('address.number', 3, query.comp.greater)
)
)
Clauses
Query helpers from gyver adhere to the Clause interface, either from:
BindClause
, orApplyClause
BindClause
BindClauses are clauses that include the .bind(mapper) -> query.Comparison
method. They expect an entity class, a table instance, or a ColumnCollection from SQLAlchemy. The comparison interface is designed for a SQLAlchemy ORM comparison or a boolean column.
The BindClauses are:
query.Where(field: str, expected: T, comp: query.Comparator[T])
query.and_(*where: query.BindClause)
query.or_(*where: query.BindClause)
query.AlwaysTrue()
query.RawQuery(cmp: query.Comparison)
Apply Clause
ApplyClauses are clauses that utilize the .apply(query: sa.sql.Select) -> sa.sqlSelect
method. They expect a Select
query instance and return another Select
query instance.
The ApplyClauses are:
query.Paginate(limit: int, offset: int)
query.OrderBy(field: typing.Optional[str], direction: OrderDirection)
Where
The Where
class represents a comparison between a table and an expected value. It has the following parameters:
field: str
: the field on the entityexpected: T
: the expected valuecomp: query.Comparator[T]
: A callable that processes the comparison between the field and the expected value.
Comparators
The Comparator[T] protocol is defined as follows:
class Comparator(Protocol[T]):
def __call__(self, field: ColumnElement | sa.Column, target: T) -> typing.Any:
...
# valid comparators
# the equals comparator can accept any datatype
def equals(field: ColumnElement | sa.Column, target: Any) -> Any:
return field == target
class MyComparator:
def __init__(self, parser):
self.parser = parser
# MyComparator should only handle float values
# This is annotated on the Where class
def __call__(field: ColumnElement | sa.Column, target: float) -> Any:
return self.parser(field) == target
The Where
class is dependent on a comparator and gyver ships with a lot of defaults:
always_true(field: FieldType, target: typing.Any)
: returns always asa.true()
equals(field: FieldType, target: typing.Any)
: equivalent tofield == target
not_equals(field: FieldType, target: typing.Any)
: equivalent tofield != target
greater(field: FieldType, target: Sortable)
: equivalent tofield > target
- Sortable represents int, float, date, and time instances
greater_equals(field: FieldType, target: Sortable)
: equivalent tofield >= target
- lesser(field: FieldType, target: Sortable): equivalent to
field < target
lesser_equals(field: FieldType, target: Sortable)
: equivalent tofield <= target
between(field: FieldType, target: tuple[Sortable, Sortable])
equivalent totarget[0] <= field <= target[1]
range(field: FieldType, target: tuple[Sortable, Sortable])
: equivalent totarget[0] <= field < target[1]
like(field: FieldType, target: str)
: equivalent toFIELD LIKE '%{target}%'
rlike(field: FieldType, target: str)
: equivalent toFIELD LIKE '{target}%'
llike(field: FieldType, target: str)
: equivalent toFIELD LIKE '%{target}'
insensitive_like(opt: typing.Literal["like", "rlike", "llike"] = "like")
: equivalent to the like options but as insensitiveinsensitive_like
returns a comparator so it must be called instead of passed as parameter. Eg.:
isnull(field: FieldType, target: bool)
: equivalent tofield is None if target else field is not None
includes(field: FieldType, target: typing.Sequence)
: equivalent tofield in target
excludes(field: FieldType, target: typing.Sequence)
: equivalent tofield not in target
json_contains(field: FieldType, target: typing.Any)
: equivalent tojson_contains(field, '"target"')
(only mysql) (prefer normalizing table)json_empty(field: FieldType, target: typing.Any)
: equivalent tojson_length(field) == 0 if target else json_length(field != 0
make_relation_check(clause: BindClause)
: returns a relationship check (if exists) based on the clause.- Ex:
relation_exists(field: FieldType, target: bool)
: a relationship_check that checks only if exists
Usage
- All people with the name starting with G
- People with the name ending with s that have a gmail
query.and_(
query.Where('name', 's', query.comp.llike),
query.Where('email', 'gmail.com', query.comp.rlike)
).bind(Person)
- People with a given zipcode or with 'mayor' on the street name
query.or_(
query.Where('address.zipcode', '012345678'),
query.Where('address.street', 'mayor', query.comp.insensitive_like())
).bind(Person)
- People with no address registered or people with any address registered from rule above
query.or_(
query.Where('address', False, query.comp.relation_exists),
query.Where('address', True, query.comp.make_relation_check(
query.or_(
query.Where('address.zipcode', '012345678'),
query.Where('address.street', 'mayor', query.comp.insensitive_like())
)
)
)
).bind(Person)
Paginate
Paginate implements the ApplyClause
interface and provides a straightforward interface for paginating queries. It offers two implementations:
LimitOffsetPaginate
This implementation performs the following operations:
```python
q.limit(self.limit).offset(self.offset)
```
FieldPaginate
This implementation utilizes a specified field and comparison to perform pagination:
```python
q.where(query.Where(self.field, self.offset, self.jump_comparison)).limit(self.limit)
```
Both implementations have the same interface as Paginate.
Usage
q = sa.select(Person)
q = query.LimitOffsetPaginate(limit=10, offset=3).apply(q)
# or
q = FieldPaginate(limit=10, offset=3).apply(q)
OrderBy
Order by has a simple ApplyClause
interface to order a query
q = sa.select(Person)
q = query.OrderBy.asc('address.number').apply(q)
# or
q = query.OrderBy.desc('id').apply(q)
# or
q = query.OrderBy.none().apply(q) # do nothing
Helpers
There are also some helper functions to wrap comparisons on some specific cases
query.as_date
will do date(field) == targetquery.as_lower
will do lower(field) == targetquery.as_time
will do time(field) == targetquery.as_upper
will do upper(field) == target