Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
7cce951
basic prepared parametrized queryset implementation
RuslanUC Feb 4, 2026
bcef40d
add support for string filters
RuslanUC Feb 4, 2026
b27f585
trying to cache prepared queries for params-collections (i.e. tuple/l…
RuslanUC Feb 8, 2026
543020c
cache prepared queries for params-collections with different sizes
RuslanUC Feb 8, 2026
175f0da
rewrite collections params processing in CachedSql
RuslanUC Feb 10, 2026
39f2c3c
check collection length in make_filled_params
RuslanUC Feb 10, 2026
39ae78b
add comparison of prepared/not-prepared queries
RuslanUC Feb 13, 2026
6f85c4e
fix params in subqueries
RuslanUC Feb 14, 2026
99650c7
add global prepared queryset cache
RuslanUC Feb 16, 2026
f67ee85
use decorator to disallow queryset methods (.filter, .get, etc.) on P…
RuslanUC Feb 16, 2026
4511f98
add prepared UpdateQuery implementation
RuslanUC Feb 16, 2026
5c0ca0f
create test file for prepared querysets
RuslanUC Feb 16, 2026
ffdde8a
implement prepared DeleteQuery class
RuslanUC Feb 16, 2026
73f1431
implement prepared ExistsQuery class
RuslanUC Feb 16, 2026
7c353c3
implement prepared CountQuery class;
RuslanUC Feb 16, 2026
1824428
implement prepared ValuesQuery class;
RuslanUC Feb 16, 2026
f70be99
fix typing of PreparedQuerySet methods that return QuerySetSingle
RuslanUC Feb 17, 2026
8554339
move all prepared-query duplicated code into separate class
RuslanUC Feb 19, 2026
e4d142b
support updating foreign keys in PreparedUpdateQuery
RuslanUC Feb 22, 2026
f192c7a
move prepared queryset fields initialisation from mixin to prepared-q…
RuslanUC Feb 22, 2026
2a51547
fix most of the typing errors
RuslanUC Feb 22, 2026
03397c2
dont cache database executor in PreparedQuerySet because it is anyway…
RuslanUC Feb 22, 2026
bf981ca
cache prepared sql queries by dialect
RuslanUC Feb 22, 2026
6fdb888
add __slots__ to CachedSql
RuslanUC Feb 22, 2026
5b7ff63
fix style issues
RuslanUC Feb 22, 2026
6ea62f4
fix rest of the typing errors
RuslanUC Feb 22, 2026
3c56e3f
fix typing.Self import failing on python3.10
RuslanUC Feb 22, 2026
576d871
dont use concat in startswith/endswith/contains/etc.
RuslanUC Feb 22, 2026
bed1c7e
return correct parameter placeholders in CollectionParameter.get_sql
RuslanUC Feb 22, 2026
4d0ae89
add Parameter support in mysql string filters
RuslanUC Feb 22, 2026
3f073b7
Merge branch 'develop' into feat/add-prepared-parametrized-queries
RuslanUC Feb 23, 2026
88870e6
fix tests after upgrading to upstream
RuslanUC Feb 23, 2026
62fccf0
reset queryset prepared db when pulling query from cache
RuslanUC Feb 24, 2026
7f3440a
rewrite prepared query classes
RuslanUC Feb 24, 2026
dc4b90c
clone all fields in PreparedValuesListQuery._clone and PreparedValues…
RuslanUC Feb 24, 2026
68c0054
fix model type within prepared query not resolving
RuslanUC Mar 1, 2026
165522d
fix style and typing issues
RuslanUC Mar 1, 2026
bf411ee
fix return type of PreparedQuerySetSingle.execute
RuslanUC Mar 1, 2026
b678f76
remove unused "F" import
RuslanUC Mar 4, 2026
65acb11
Merge branch 'develop' into feat/add-prepared-parametrized-queries
RuslanUC Mar 4, 2026
6c08eb0
use QuerySet methods for creating queries in .values_list, .values, .…
RuslanUC Mar 4, 2026
5cd827a
add ability to remove prepared query from model's cache
RuslanUC Mar 4, 2026
488a01e
add .sql method implementation to prepared queries
RuslanUC Mar 4, 2026
69311e1
fix style issue in test_prepared_query_get_sql;
RuslanUC Mar 4, 2026
0da28ce
return PreparedDeleteQuery from _PreparedQueryMixin.delete instead of…
RuslanUC Mar 4, 2026
8abf5c0
rewrite compiled query classes without using QuerySet as a base class;
RuslanUC Mar 5, 2026
a8e7205
implement CompiledValuesListQuery and CompiledValuesQuery
RuslanUC Mar 5, 2026
ff6c257
support Parameters in QuerySet.limit and .offset
RuslanUC Mar 5, 2026
4ae9004
fix style and typing issues
RuslanUC Mar 5, 2026
dd3bbcb
type single CompiledQuerySet, CompiledValuesListQuery and CompiledVa…
RuslanUC Mar 5, 2026
e82a4be
add lru cache for queries that contain collections
RuslanUC Mar 5, 2026
839970a
set _db to None when cloning compiled queries
RuslanUC Mar 5, 2026
ebd33cb
copy _sql_cache_maxsize and _dynamic_params_init when cloning compile…
RuslanUC Mar 5, 2026
7fc32eb
fix compiled queries using model object when filtering by foreign key…
RuslanUC Mar 9, 2026
e6af426
raise error if compiled parameters are not provided
RuslanUC Mar 9, 2026
61d3a31
calculate collection_params in BaseCompiledQuery constructor
RuslanUC Mar 10, 2026
8931d74
set collection params' sizes when sql was not in cache
RuslanUC Mar 10, 2026
513e321
don't store collection param name in sql cache, only length
RuslanUC Mar 10, 2026
472f36e
turn CollectionParameter into Criterion and generate 1=1 or 1=0 if pr…
RuslanUC Mar 13, 2026
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
451 changes: 451 additions & 0 deletions tests/test_queryset_compiled.py

Large diffs are not rendered by default.

64 changes: 56 additions & 8 deletions tortoise/backends/mysql/executor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import enum
from collections.abc import Callable
from typing import Any

from pypika_tortoise import SqlContext, functions
from pypika_tortoise.enums import SqlTypes
Expand Down Expand Up @@ -32,6 +34,7 @@
search,
starts_with,
)
from tortoise.parameter import Parameter


class MySQLRegexpComparators(enum.Enum):
Expand All @@ -49,10 +52,47 @@ def get_value_sql(self, ctx: SqlContext) -> str:
return format_quotes(value, quote_char)


# TODO: maybe there is better way to do this?
class StrParamWrapper(ValueWrapper):
value: Parameter

def get_value_sql(self, ctx: SqlContext) -> str:
quote_char = ctx.secondary_quote_char or ""
real_encoder = self.value.encode

def _encoder(val: str) -> str:
if real_encoder is not None:
val = real_encoder(val)
val = val.replace(quote_char, quote_char * 2)
return format_quotes(val, quote_char)

new_param = self.value.clone()
new_param.encode = _encoder
return new_param # type: ignore[return-value]


def escape_like(val: str) -> str:
return val.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


def _format_str_or_parameter(
value: str | Parameter,
like_start: bool = False,
like_end: bool = False,
escape_func: Callable[[Any], str] = escape_like,
) -> Term:
like_at_start = "%" if like_start else ""
like_at_end = "%" if like_end else ""

if isinstance(value, Parameter):
value.encode = escape_func
if like_start or like_end:
value.encode = lambda val: f"{like_at_start}{escape_func(val)}{like_at_end}"
return StrParamWrapper(value)
else:
return StrWrapper(f"{like_at_start}{escape_func(value)}{like_at_end}")


def mysql_contains(field: Term, value: str) -> Criterion:
return Like(
functions.Cast(field, SqlTypes.CHAR), StrWrapper(f"%{escape_like(value)}%"), escape=""
Expand All @@ -61,51 +101,59 @@ def mysql_contains(field: Term, value: str) -> Criterion:

def mysql_starts_with(field: Term, value: str) -> Criterion:
return Like(
functions.Cast(field, SqlTypes.CHAR), StrWrapper(f"{escape_like(value)}%"), escape=""
functions.Cast(field, SqlTypes.CHAR),
_format_str_or_parameter(value, False, True, escape_like),
escape="",
)


def mysql_ends_with(field: Term, value: str) -> Criterion:
return Like(
functions.Cast(field, SqlTypes.CHAR), StrWrapper(f"%{escape_like(value)}"), escape=""
functions.Cast(field, SqlTypes.CHAR),
_format_str_or_parameter(value, True, False, escape_like),
escape="",
)


def mysql_insensitive_exact(field: Term, value: str) -> Criterion:
return functions.Upper(functions.Cast(field, SqlTypes.CHAR)).eq(functions.Upper(str(value)))
return functions.Upper(functions.Cast(field, SqlTypes.CHAR)).eq(
functions.Upper(_format_str_or_parameter(value, escape_func=str))
)


def mysql_insensitive_contains(field: Term, value: str) -> Criterion:
return Like(
functions.Upper(functions.Cast(field, SqlTypes.CHAR)),
functions.Upper(StrWrapper(f"%{escape_like(value)}%")),
functions.Upper(_format_str_or_parameter(value, True, True, escape_like)),
escape="",
)


def mysql_insensitive_starts_with(field: Term, value: str) -> Criterion:
return Like(
functions.Upper(functions.Cast(field, SqlTypes.CHAR)),
functions.Upper(StrWrapper(f"{escape_like(value)}%")),
functions.Upper(_format_str_or_parameter(value, False, True, escape_like)),
escape="",
)


def mysql_insensitive_ends_with(field: Term, value: str) -> Criterion:
return Like(
functions.Upper(functions.Cast(field, SqlTypes.CHAR)),
functions.Upper(StrWrapper(f"%{escape_like(value)}")),
functions.Upper(_format_str_or_parameter(value, True, False, escape_like)),
escape="",
)


def mysql_search(field: Term, value: str) -> SearchCriterion:
return SearchCriterion(field, expr=StrWrapper(value))
return SearchCriterion(field, expr=_format_str_or_parameter(value, escape_func=lambda x: x))


def mysql_posix_regex(field: Term, value: str) -> BasicCriterion:
return BasicCriterion(
MySQLRegexpComparators.REGEXP, Coalesce(Cast(field, SqlTypes.CHAR)), StrWrapper(value)
MySQLRegexpComparators.REGEXP,
Coalesce(Cast(field, SqlTypes.CHAR)),
_format_str_or_parameter(value, escape_func=lambda x: x),
)


Expand Down
29 changes: 22 additions & 7 deletions tortoise/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
from tortoise.exceptions import FieldError, OperationalError
from tortoise.fields.base import Field
from tortoise.fields.data import JSONField
from tortoise.fields.relational import RelationalField
from tortoise.fields.relational import ForeignKeyFieldInstance, RelationalField
from tortoise.filters import FilterInfoDict
from tortoise.parameter import Parameter
from tortoise.query_utils import (
QueryModifier,
TableCriterionTuple,
Expand Down Expand Up @@ -386,6 +387,9 @@ def _process_filter_kwarg(
else:
filter_info = model._meta.get_filter(key)

if isinstance(value, Parameter):
value.model = model

field_object = None
if "table" in filter_info:
# join the table
Expand All @@ -395,15 +399,26 @@ def _process_filter_kwarg(
== filter_info["table"][filter_info["backward_key"]],
)
if "value_encoder" in filter_info:
value = filter_info["value_encoder"](value, model)
if isinstance(value, Parameter):
value.value_encoder = filter_info["value_encoder"]
else:
value = filter_info["value_encoder"](value, model)
table = filter_info["table"]
elif not isinstance(value, Term):
field_object = model._meta.fields_map[filter_info["field"]]
value = (
filter_info["value_encoder"](value, model, field_object)
if "value_encoder" in filter_info
else field_object.to_db_value(value, model)
)
value_encoder = filter_info["value_encoder"] if "value_encoder" in filter_info else None
if isinstance(value, Parameter):
if isinstance(field_object.reference, ForeignKeyFieldInstance):
fk_to_field = field_object.reference.to_field
value.value_getter = lambda obj: getattr(obj, fk_to_field)
value.field_object = field_object
value.value_encoder = value_encoder
else:
value = (
value_encoder(value, model, field_object)
if value_encoder is not None
else field_object.to_db_value(value, model)
)
op = filter_info["operator"]
term: Term = table[filter_info.get("source_field", filter_info["field"])]
if field_object is not None:
Expand Down
63 changes: 48 additions & 15 deletions tortoise/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from tortoise.contrib.postgres.fields import ArrayField, TSVectorField
from tortoise.fields import Field, JSONField
from tortoise.fields.relational import BackwardFKRelation, ManyToManyFieldInstance
from tortoise.parameter import CollectionParameter, Parameter

if sys.version_info >= (3, 11): # pragma:nocoverage
from typing import NotRequired
Expand Down Expand Up @@ -102,6 +103,8 @@ def array_encoder(value: Any | Sequence[Any], instance: Model, field: Field) ->

def is_in(field: Term, value: Any) -> Criterion:
if value:
if isinstance(value, Parameter):
return CollectionParameter(field, value, True)
return field.isin(value)
# SQL has no False, so we return 1=0
return BasicCriterion(
Expand All @@ -113,6 +116,8 @@ def is_in(field: Term, value: Any) -> Criterion:

def not_in(field: Term, value: Any) -> Criterion:
if value:
if isinstance(value, Parameter):
return CollectionParameter(field, value, False)
return field.notin(value) | field.isnull()
# SQL has no True, so we return 1=1
return BasicCriterion(
Expand Down Expand Up @@ -142,8 +147,11 @@ def not_null(field: Term, value: Any) -> Criterion:
return field.isnull()


def contains(field: Term, value: str) -> Criterion:
return Like(Cast(field, SqlTypes.VARCHAR), field.wrap_constant(f"%{escape_like(value)}%"))
def contains(field: Term, value: str | Parameter) -> Criterion:
return Like(
Cast(field, SqlTypes.VARCHAR),
field.wrap_constant(_format_str_or_parameter(field, value, True, True)),
)


def search(field: Term, value: str) -> Any:
Expand All @@ -165,33 +173,58 @@ def insensitive_posix_regex(field: Term, value: str):
)


def starts_with(field: Term, value: str) -> Criterion:
return Like(Cast(field, SqlTypes.VARCHAR), field.wrap_constant(f"{escape_like(value)}%"))
def _format_str_or_parameter(
field: Term,
value: str | Parameter,
like_start: bool = False,
like_end: bool = False,
escape_func: Callable[[Any], str] = escape_like,
) -> Term:
like_at_start = "%" if like_start else ""
like_at_end = "%" if like_end else ""

if isinstance(value, Parameter):
value.encode = escape_func
wrapped = ValueWrapper(value)
if like_start or like_end:
value.encode = lambda val: f"{like_at_start}{escape_func(val)}{like_at_end}"
return wrapped
else:
return field.wrap_constant(f"{like_at_start}{escape_func(value)}{like_at_end}")

def ends_with(field: Term, value: str) -> Criterion:
return Like(Cast(field, SqlTypes.VARCHAR), field.wrap_constant(f"%{escape_like(value)}"))

def starts_with(field: Term, value: str | Parameter) -> Criterion:
return Like(Cast(field, SqlTypes.VARCHAR), _format_str_or_parameter(field, value, False, True))

def insensitive_exact(field: Term, value: str) -> Criterion:
return Upper(Cast(field, SqlTypes.VARCHAR)).eq(Upper(str(value)))

def ends_with(field: Term, value: str | Parameter) -> Criterion:
return Like(Cast(field, SqlTypes.VARCHAR), _format_str_or_parameter(field, value, True, False))

def insensitive_contains(field: Term, value: str) -> Criterion:

def insensitive_exact(field: Term, value: str | Parameter) -> Criterion:
return Upper(Cast(field, SqlTypes.VARCHAR)).eq(
Upper(_format_str_or_parameter(field, value, escape_func=str))
)


def insensitive_contains(field: Term, value: str | Parameter) -> Criterion:
return Like(
Upper(Cast(field, SqlTypes.VARCHAR)), field.wrap_constant(Upper(f"%{escape_like(value)}%"))
Upper(Cast(field, SqlTypes.VARCHAR)),
field.wrap_constant(Upper(_format_str_or_parameter(field, value, True, True))),
)


def insensitive_starts_with(field: Term, value: str) -> Criterion:
def insensitive_starts_with(field: Term, value: str | Parameter) -> Criterion:
return Like(
Upper(Cast(field, SqlTypes.VARCHAR)), field.wrap_constant(Upper(f"{escape_like(value)}%"))
Upper(Cast(field, SqlTypes.VARCHAR)),
field.wrap_constant(Upper(_format_str_or_parameter(field, value, False, True))),
)


def insensitive_ends_with(field: Term, value: str) -> Criterion:
def insensitive_ends_with(field: Term, value: str | Parameter) -> Criterion:
return Like(
Upper(Cast(field, SqlTypes.VARCHAR)), field.wrap_constant(Upper(f"%{escape_like(value)}"))
Upper(Cast(field, SqlTypes.VARCHAR)),
field.wrap_constant(Upper(_format_str_or_parameter(field, value, True, False))),
)


Expand Down Expand Up @@ -240,7 +273,7 @@ def json_contained_by(field: Term, value: str) -> Criterion:


def json_filter(field: Term, value: dict) -> Criterion:
raise NotImplementedError("must be overridden in each xecutor")
raise NotImplementedError("must be overridden in each executor")


def array_contains(field: Term, value: Any | Sequence[Any]) -> Criterion:
Expand Down
7 changes: 7 additions & 0 deletions tortoise/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
QuerySetSingle,
RawSQLQuery,
)
from tortoise.queryset_compiled import BaseCompiledQuery
from tortoise.router import router
from tortoise.signals import Signals
from tortoise.transactions import in_transaction
Expand Down Expand Up @@ -215,6 +216,7 @@ class MetaInfo:
"db_complex_fields",
"_default_ordering",
"_ordering_validated",
"query_cache",
)

def __init__(self, meta: Model.Meta) -> None:
Expand Down Expand Up @@ -255,6 +257,7 @@ def __init__(self, meta: Model.Meta) -> None:
self.db_native_fields: list[tuple[str, str, Field]] = []
self.db_default_fields: list[tuple[str, str, Field]] = []
self.db_complex_fields: list[tuple[str, str, Field]] = []
self.query_cache: dict[str, BaseCompiledQuery] = {}

@property
def full_name(self) -> str:
Expand Down Expand Up @@ -1610,6 +1613,10 @@ async def fetch_for_list(
db = using_db or cls._choose_db()
await db.executor_class(model=cls, db=db).fetch_for_list(instance_list, *args)

@classmethod
def remove_compiled_query(cls, key: str) -> None:
cls._meta.query_cache.pop(key, None)

@classmethod
def _check(cls) -> None:
"""
Expand Down
Loading
Loading