Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/pr-build-merge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ jobs:
--health-interval 2s
--health-timeout 5s
--health-retries 15
mongo:
image: mongo:7
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand(\"ping\")'"
--health-interval 2s
--health-timeout 5s
--health-retries 15

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -58,6 +67,7 @@ jobs:
run: uv run pytest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/requela
MONGO_URL: mongodb://localhost:27017

- name: Compute added/removed lines for notification
if: ${{ github.event_name == 'pull_request' }}
Expand Down
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,89 @@ class UserRules(ModelRQLRules):
query = rules.build_query("and(eq(name,John),gt(age,30),eq(events.born.at,2024-01-01))&order_by(name,-age)")
```

### MongoDB

MongoDB collections are described with pydantic models or dataclasses. Install PyMongo (4.13 or
later) alongside requela:

```bash
pip install requela pymongo
```

Set the builder explicitly with `__builder__`, as there is no ORM model to infer it from. An
abstract base class shares it with every rules class, including the nested ones:

```python
from datetime import datetime

from bson import ObjectId
from pydantic import BaseModel, ConfigDict, Field

from requela import FieldRule, ModelRQLRules, RelationshipRule
from requela.builders.mongo import MongoQueryBuilder


class Tag(BaseModel):
name: str
value: str


class Event(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)

id: ObjectId = Field(alias="_id")
time: datetime
level: str
tags: list[Tag] = []


class MongoRules(ModelRQLRules):
__abstract__ = True
__builder__ = MongoQueryBuilder


class TagRules(MongoRules):
__model__ = Tag

name = FieldRule()
value = FieldRule()


class EventRules(MongoRules):
__model__ = Event

id = FieldRule()
time = FieldRule()
level = FieldRule()
tags = RelationshipRule(rules=TagRules())


query = EventRules().build_query("and(eq(level,ERROR),any(tags,eq(tags.name,env)))&order_by(-time)")
# query.filter == {"$and": [{"level": "ERROR"}, {"tags": {"$elemMatch": {"name": "env"}}}]}
# query.sort == [("time", -1)]

documents = collection.find(query.filter).sort(query.sort).to_list() # pymongo.MongoClient
documents = await collection.find(query.filter).sort(query.sort).to_list() # pymongo.AsyncMongoClient
```

The builder never runs queries, so its output works with both the blocking and the asyncio
PyMongo clients. Fields are filtered on their stored keys: pydantic aliases (`id` stored as
`_id`) or, for dataclasses, `field(metadata={"mongo_key": "_id"})`.

Things to know:

* Fields inside a list of nested models can only be filtered through `any()`, which matches
one single element (`$elemMatch`).
* `null()` also matches missing fields, and `ne`, `out` and `not` also match documents where
the field is null or missing.
* Quote ObjectIds that start with a digit: `eq(id,'65f0c0ffee0ddba11ad0beef')`.
* When the stored format differs from the model type, convert the value with
`value_converter`, e.g. a datetime stored as a Unix timestamp:
`time = FieldRule(value_converter=lambda value: int(value.timestamp()))`.




## License

ReQueLa is licensed under the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0) and is copyright SoftwareOne AG.

19 changes: 19 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,30 @@ services:
timeout: 5s
retries: 15

mongo:
image: mongo:${MONGO_VERSION:-7}
ports:
- "${MONGO_PORT:-27017}:27017"
# Throwaway test database: keep the data in memory
tmpfs:
- /data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 2s
timeout: 5s
retries: 15

tests:
build: .
command: pytest -v
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/requela
MONGO_URL: mongodb://mongo:27017
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy


bash:
Expand All @@ -34,6 +50,9 @@ services:
tty: true
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/requela
MONGO_URL: mongodb://mongo:27017
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors = [

license = {file = "LICENSE.txt"}
requires-python = ">=3.12,<4"
keywords = ["rql", "sqlalchemy", "query", "filter", "orm"]
keywords = ["rql", "sqlalchemy", "django", "mongodb", "query", "filter", "orm"]

classifiers = [
"Development Status :: 4 - Beta",
Expand Down Expand Up @@ -48,6 +48,8 @@ dev = [
"django>=5.1.6",
"pre-commit>=4.1.0",
"pytest-randomly>=3.16.0",
"pymongo>=4.13",
"pydantic>=2",
]


Expand Down
3 changes: 3 additions & 0 deletions src/requela/builders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def get_builder_for_model(
resolve_alias_callback: Callable | None = None,
validate_operator_and_field_callback: Callable | None = None,
validate_ordering_callback: Callable | None = None,
get_field_rule_callback: Callable | None = None,
):
"""Returns appropriate builder based on model type"""

Expand All @@ -21,6 +22,7 @@ def get_builder_for_model(
resolve_alias_callback=resolve_alias_callback,
validate_operator_and_field_callback=validate_operator_and_field_callback,
validate_ordering_callback=validate_ordering_callback,
get_field_rule_callback=get_field_rule_callback,
)
# Django model
elif hasattr(model, "_meta"): # pragma: no branch
Expand All @@ -31,6 +33,7 @@ def get_builder_for_model(
resolve_alias_callback=resolve_alias_callback,
validate_operator_and_field_callback=validate_operator_and_field_callback,
validate_ordering_callback=validate_ordering_callback,
get_field_rule_callback=get_field_rule_callback,
)
else: # pragma: no cover
raise ValueError(f"Unsupported model type: {type(model)}")
Expand Down
23 changes: 22 additions & 1 deletion src/requela/builders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from datetime import date, datetime
from enum import Enum
from functools import partial
from typing import Any

from lark.exceptions import VisitError

from requela.dataclasses import FilterExpression, Operator, OperatorFunctions, OrderByExpression
from requela.dataclasses import (
DEFAULT_OPERATORS,
FilterExpression,
Operator,
OperatorFunctions,
OrderByExpression,
)
from requela.parser import parse
from requela.transformer import RQLTransformer

Expand All @@ -21,11 +28,13 @@ def __init__(
resolve_alias_callback: Callable | None = None,
validate_operator_and_field_callback: Callable | None = None,
validate_ordering_callback: Callable | None = None,
get_field_rule_callback: Callable | None = None,
):
self.model_class = model_class
self.resolve_alias_callback = resolve_alias_callback
self.validate_operator_and_field_callback = validate_operator_and_field_callback
self.validate_ordering_callback = validate_ordering_callback
self.get_field_rule_callback = get_field_rule_callback
self.transformer = RQLTransformer(
OperatorFunctions(
and_op=self.apply_and,
Expand Down Expand Up @@ -129,6 +138,18 @@ def validate_operator_and_field(self, field: str, operator: Operator) -> None:
if self.validate_operator_and_field_callback:
return self.validate_operator_and_field_callback(field, operator)

def get_default_operators(self, field_type: type) -> set[Operator] | None:
if issubclass(field_type, Enum):
return DEFAULT_OPERATORS.get(Enum)
return DEFAULT_OPERATORS.get(field_type)

def convert_value(self, prop: str, value: Any) -> Any:
"""Applies the field rule's `value_converter`, if any, to a value already cast."""
if value is None or not self.get_field_rule_callback:
return value
converter = getattr(self.get_field_rule_callback(prop), "value_converter", None)
return converter(value) if converter else value

def resolve_alias(self, alias: str) -> str:
if self.resolve_alias_callback:
return self.resolve_alias_callback(alias)
Expand Down
20 changes: 12 additions & 8 deletions src/requela/builders/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ def __init__(
resolve_alias_callback: Callable | None = None,
validate_operator_and_field_callback: Callable | None = None,
validate_ordering_callback: Callable | None = None,
get_field_rule_callback: Callable | None = None,
):
super().__init__(
model_class,
resolve_alias_callback=resolve_alias_callback,
validate_operator_and_field_callback=validate_operator_and_field_callback,
validate_ordering_callback=validate_ordering_callback,
get_field_rule_callback=get_field_rule_callback,
)
self.prefetches: list[str] = []

Expand Down Expand Up @@ -70,30 +72,32 @@ def apply_not(self, condition: Q) -> Q:
def apply_eq(self, prop: str, value: str | bool | date | datetime | int | float | None) -> Q:
if value is None:
return Q(**{f"{self.resolve_property(prop)}__isnull": True})
return Q(**{self.resolve_property(prop): value})
return Q(**{self.resolve_property(prop): self.convert_value(prop, value)})

def apply_ne(self, prop: str, value: str | bool | date | datetime | int | float | None) -> Q:
if value is None:
return Q(**{f"{self.resolve_property(prop)}__isnull": False})
return ~Q(**{self.resolve_property(prop): value})
return ~Q(**{self.resolve_property(prop): self.convert_value(prop, value)})

def apply_gt(self, prop: str, value: date | datetime | int | float) -> Q:
return Q(**{f"{self.resolve_property(prop)}__gt": value})
return Q(**{f"{self.resolve_property(prop)}__gt": self.convert_value(prop, value)})

def apply_lt(self, prop: str, value: date | datetime | int | float) -> Q:
return Q(**{f"{self.resolve_property(prop)}__lt": value})
return Q(**{f"{self.resolve_property(prop)}__lt": self.convert_value(prop, value)})

def apply_gte(self, prop: str, value: date | datetime | int | float) -> Q:
return Q(**{f"{self.resolve_property(prop)}__gte": value})
return Q(**{f"{self.resolve_property(prop)}__gte": self.convert_value(prop, value)})

def apply_lte(self, prop: str, value: date | datetime | int | float) -> Q:
return Q(**{f"{self.resolve_property(prop)}__lte": value})
return Q(**{f"{self.resolve_property(prop)}__lte": self.convert_value(prop, value)})

def apply_in(self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int]) -> Q:
return Q(**{f"{self.resolve_property(prop)}__in": value})
values = [self.convert_value(prop, item) for item in value]
return Q(**{f"{self.resolve_property(prop)}__in": values})

def apply_out(self, prop: str, value: Sequence[str] | Sequence[float] | Sequence[int]) -> Q:
return ~Q(**{f"{self.resolve_property(prop)}__in": value})
values = [self.convert_value(prop, item) for item in value]
return ~Q(**{f"{self.resolve_property(prop)}__in": values})

def apply_like(self, prop: str, value: str) -> Q:
return self.apply_pattern(prop, value, case_insensitive=False)
Expand Down
Loading
Loading