Skip to content

Commit

Permalink
v3.0.0: add RepositoryConfig - dataclass to separate configuration an…
Browse files Browse the repository at this point in the history
…d main login of repository classes.
  • Loading branch information
ALittleMoron committed Jul 8, 2024
1 parent d721bce commit ccc5353
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 209 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ dev = [

[project]
name = "sqlrepo"
version = "2.0.0"
version = "3.0.0"
description = "sqlalchemy repositories with crud operations and other utils for it."
authors = [{ name = "Dmitriy Lunev", email = "dima.lunev14@gmail.com" }]
requires-python = ">=3.11"
Expand Down
1 change: 1 addition & 0 deletions sqlrepo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .config import RepositoryConfig as RepositoryConfig
from .queries import BaseAsyncQuery as BaseAsyncQuery
from .queries import BaseQuery as BaseQuery
from .queries import BaseSyncQuery as BaseSyncQuery
Expand Down
153 changes: 153 additions & 0 deletions sqlrepo/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import datetime
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias

from dev_utils.sqlalchemy.filters.converters import (
AdvancedOperatorFilterConverter,
BaseFilterConverter,
DjangoLikeFilterConverter,
SimpleFilterConverter,
)
from dev_utils.sqlalchemy.filters.types import FilterConverterStrategiesLiteral
from sqlalchemy.orm import selectinload

StrField: TypeAlias = str


if TYPE_CHECKING:
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.strategy_options import _AbstractLoad # type: ignore


filter_convert_classes: Final[dict[FilterConverterStrategiesLiteral, type[BaseFilterConverter]]] = {
"simple": SimpleFilterConverter,
"advanced": AdvancedOperatorFilterConverter,
"django": DjangoLikeFilterConverter,
}
"""Final convert class filters mapping."""


@dataclass(slots=True)
class RepositoryConfig:
"""Repository config as dataclass."""

# TODO: add specific_column_mapping to filters, joins and loads.
specific_column_mapping: "dict[str, InstrumentedAttribute[Any]]" = field(default_factory=dict)
"""
Warning! Current version of sqlrepo doesn't support this mapping for filters, joins and loads.
Uses as mapping for some attributes, that you need to alias or need to specify column
from other models.
Warning: if you specify column from other model, it may cause errors. For example, update
doesn't use it for filters, because joins are not presents in update.
"""
use_flush: bool = field(default=True)
"""
Uses as flag of flush method in SQLAlchemy session.
By default, True, because repository has (mostly) multiple methods evaluate use. For example,
generally, you want to create some model instances, create some other (for example, log table)
and then receive other model instance in one use (for example, in Unit of work pattern).
If you will work with repositories as single methods uses, switch to use_flush=False. It will
make queries commit any changes.
"""
update_set_none: bool = field(default=False)
"""
Uses as flag of set None option in ``update_instance`` method.
If True, allow to force ``update_instance`` instance columns with None value. Works together
with ``update_allowed_none_fields``.
By default False, because it's not safe to set column to None - current version if sqlrepo
not able to check optional type. Will be added in next versions, and ``then update_set_none``
will be not necessary.
"""
update_allowed_none_fields: 'Literal["*"] | set[StrField]' = field(default="*")
"""
Set of strings, which represents columns of model.
Uses as include or exclude for given data in ``update_instance`` method.
By default allow any fields. Not dangerous, because ``update_set_none`` by default set to False,
and there will be no affect on ``update_instance`` method
"""
allow_disable_filter_by_value: bool = field(default=True)
"""
Uses as flag of filtering in disable method.
If True, make additional filter, which will exclude items, which already disabled.
Logic of disable depends on type of disable column. See ``disable_field`` docstring for more
information.
By default True, because it will make more efficient query to not override disable column. In
some cases (like datetime disable field) it may be better to turn off this flag to save disable
with new context (repeat disable, if your domain supports repeat disable and it make sense).
"""
disable_field_type: type[datetime.datetime] | type[bool] | None = field(default=None)
"""
Uses as choice of type of disable field.
By default, None. Needs to be set manually, because this option depends on user custom
implementation of disable_field. If None and ``disable`` method was evaluated, there will be
RepositoryAttributeError exception raised by Repository class.
"""
disable_field: "InstrumentedAttribute[Any] | StrField | None" = field(default=None)
"""
Uses as choice of used defined disable field.
By default, None. Needs to be set manually, because this option depends on user custom
implementation of disable_field. If None and ``disable`` method was evaluated, there will be
RepositoryAttributeError exception raised by Repository class.
"""
disable_id_field: "InstrumentedAttribute[Any] |StrField | None" = field(default=None)
"""
Uses as choice of used defined id field in model, which supports disable.
By default, None. Needs to be set manually, because this option depends on user custom
implementation of disable_field. If None and ``disable`` method was evaluated, there will be
RepositoryAttributeError exception raised by Repository class.
"""
unique_list_items: bool = field(default=True)
"""
Warning! Ambiguous option!
==========================
Current version of ``sqlrepo`` works with load strategies with user configured option
``load_strategy``. In order to make ``list`` method works stable, this option is used.
If you don't work with relationships in your model or you don't need unique (for example,
if you use selectinload), set this option to False. Otherwise keep it in True state.
"""
filter_convert_strategy: "FilterConverterStrategiesLiteral" = field(default="simple")
"""
Uses as choice of filter convert.
By default "simple", so you able to pass filters with ``key-value`` structure. You still can
pass raw filters (just list of SQLAlchemy filters), but if you pass dict, it will be converted
to SQLAlchemy filters with passed strategy.
Currently, supported converters:
``simple`` - ``key-value`` dict.
``advanced`` - dict with ``field``, ``value`` and ``operator`` keys.
List of operators:
``=, >, <, >=, <=, is, is_not, between, contains``
``django-like`` - ``key-value`` dict with django-like lookups system. See django docs for
more info.
"""
# FIXME: remove it. Will cause many errors. Just pass _AbstractLoad instances itself. Not str
default_load_strategy: Callable[..., "_AbstractLoad"] = field(default=selectinload)
"""
Uses as choice of SQLAlchemy load strategies.
By default selectinload, because it makes less errors.
"""

def get_filter_convert_class(self) -> type[BaseFilterConverter]:
"""Get filter convert class from passed strategy."""
return filter_convert_classes[self.filter_convert_strategy]
43 changes: 29 additions & 14 deletions sqlrepo/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
from dev_utils.core.utils import get_utc_now
from dev_utils.sqlalchemy.filters.converters import BaseFilterConverter
from dev_utils.sqlalchemy.utils import apply_joins, apply_loads, get_sqlalchemy_attribute
from sqlalchemy import CursorResult, and_, delete
from sqlalchemy import CursorResult, and_, delete, func, insert, or_, select, text, update
from sqlalchemy import exc as sqlalchemy_exc
from sqlalchemy import func, insert, or_, select, text, update
from sqlalchemy.orm import joinedload

from sqlrepo.exc import QueryError
Expand Down Expand Up @@ -604,10 +603,10 @@ def delete_item( # pragma: no coverage
self.session.commit()
except sqlalchemy_exc.SQLAlchemyError as exc:
self.session.rollback()
msg = f"Delete from database error: {exc}" # noqa: S608
msg = f"Error delete db_item: {exc}" # noqa: S608
self.logger.warning(msg)
return False
msg = f"Delete from database success. Item: {item_repr}" # noqa: S608
msg = f"Success delete db_item. Item: {item_repr}" # noqa: S608
self.logger.debug(msg)
return True

Expand All @@ -616,8 +615,8 @@ def disable_items(
*,
model: type["BaseSQLAlchemyModel"],
ids_to_disable: set[Any],
id_field: "StrField",
disable_field: "StrField",
id_field: "InstrumentedAttribute[Any] | StrField",
disable_field: "InstrumentedAttribute[Any] | StrField",
field_type: type[datetime.datetime] | type[bool] = datetime.datetime,
allow_filter_by_value: bool = True,
extra_filters: "Filter | None" = None,
Expand All @@ -627,8 +626,16 @@ def disable_items(
stmt = self._disable_items_stmt(
model=model,
ids_to_disable=ids_to_disable,
id_field=get_sqlalchemy_attribute(model, id_field, only_columns=True),
disable_field=get_sqlalchemy_attribute(model, disable_field, only_columns=True),
id_field=(
get_sqlalchemy_attribute(model, id_field, only_columns=True)
if isinstance(id_field, str)
else id_field
),
disable_field=(
get_sqlalchemy_attribute(model, disable_field, only_columns=True)
if isinstance(disable_field, str)
else disable_field
),
field_type=field_type,
allow_filter_by_value=allow_filter_by_value,
extra_filters=extra_filters,
Expand Down Expand Up @@ -905,10 +912,10 @@ async def delete_item( # pragma: no coverage
await self.session.commit()
except sqlalchemy_exc.SQLAlchemyError as exc:
await self.session.rollback()
msg = f"Delete from database error: {exc}" # noqa: S608
msg = f"Error delete db_item: {exc}" # noqa: S608
self.logger.warning(msg)
return False
msg = f"Delete from database success. Item: {item_repr}" # noqa: S608
msg = f"Success delete db_item. Item: {item_repr}" # noqa: S608
self.logger.debug(msg)
return True

Expand All @@ -917,8 +924,8 @@ async def disable_items(
*,
model: type["BaseSQLAlchemyModel"],
ids_to_disable: set[Any],
id_field: "StrField",
disable_field: "StrField",
id_field: "InstrumentedAttribute[Any] | StrField",
disable_field: "InstrumentedAttribute[Any] | StrField",
field_type: type[datetime.datetime] | type[bool] = datetime.datetime,
allow_filter_by_value: bool = True,
extra_filters: "Filter | None" = None,
Expand All @@ -928,8 +935,16 @@ async def disable_items(
stmt = self._disable_items_stmt(
model=model,
ids_to_disable=ids_to_disable,
id_field=get_sqlalchemy_attribute(model, id_field, only_columns=True),
disable_field=get_sqlalchemy_attribute(model, disable_field, only_columns=True),
id_field=(
get_sqlalchemy_attribute(model, id_field, only_columns=True)
if isinstance(id_field, str)
else id_field
),
disable_field=(
get_sqlalchemy_attribute(model, disable_field, only_columns=True)
if isinstance(disable_field, str)
else disable_field
),
field_type=field_type,
allow_filter_by_value=allow_filter_by_value,
extra_filters=extra_filters,
Expand Down
Loading

0 comments on commit ccc5353

Please sign in to comment.