Service Postgres added

This commit is contained in:
2025-01-13 22:17:09 +03:00
parent 3bc0146767
commit 5d8b37179d
26 changed files with 1569 additions and 308 deletions

View File

@@ -1,68 +0,0 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, TypeVar, Union
from sqlalchemy import Column, DateTime, Integer, inspect
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import Session
from Services.Postgres.database import Base, get_db
T = TypeVar('T')
class PostgresBase(Base):
"""Base class for all PostgreSQL models."""
__abstract__ = True
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True)
@declared_attr
def __tablename__(cls) -> str:
"""Generate table name automatically from class name."""
return cls.__name__.lower()
def as_dict(self) -> Dict[str, Any]:
"""Convert model instance to dictionary."""
return {c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs}
@classmethod
def filter_non_deleted(cls, db: Session):
"""Filter out soft-deleted records."""
return db.query(cls).filter(cls.deleted_at.is_(None))
@classmethod
def get_by_id(cls, db: Session, id: int) -> Optional['PostgresBase']:
"""Get record by ID if not deleted."""
return cls.filter_non_deleted(db).filter(cls.id == id).first()
def soft_delete(self) -> None:
"""Soft delete the record."""
self.deleted_at = datetime.utcnow()
with get_db() as db:
self.save(db)
def save(self, db: Session) -> None:
"""Save the current record."""
if not self.id:
db.add(self)
db.commit()
db.refresh(self)
def update(self, **kwargs: Dict[str, Any]) -> None:
"""Update record with given values."""
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
with get_db() as db:
self.save(db)
@classmethod
def create(cls, **kwargs: Dict[str, Any]) -> 'PostgresBase':
"""Create a new record."""
instance = cls(**kwargs)
with get_db() as db:
instance.save(db)
return instance

View File

@@ -1,49 +0,0 @@
from typing import Any, Dict, List, Optional, TypeVar, Generic
T = TypeVar('T')
class PostgresResponse(Generic[T]):
"""Base class for Postgres response handling."""
def __init__(
self,
status: bool,
message: str,
data: Optional[T] = None,
error: Optional[str] = None,
):
self.status = status
self.message = message
self.data = data
self.error = error
if isinstance(data, dict):
self.data_type = "dict"
elif isinstance(data, list):
self.data_type = "list"
else:
self.data_type = None
def as_dict(self) -> Dict[str, Any]:
"""Convert response to dictionary format."""
return {
"status": self.status,
"message": self.message,
"data": self.data,
"dataType": self.data_type,
"error": self.error,
}
@property
def all(self) -> Optional[List[T]]:
"""Get all data items if data is a list."""
if isinstance(self.data, list):
return self.data
return None
@property
def first(self) -> Optional[T]:
"""Get first data item if data is a list."""
if isinstance(self.data, list) and self.data:
return self.data[0]
return None

View File

@@ -0,0 +1,379 @@
"""
Response handlers for SQLAlchemy query results with pagination support.
This module provides a set of response classes for handling different types of data:
- Single PostgreSQL records
- Multiple SQLAlchemy records
- List data
- Dictionary data
Each response includes pagination information and supports data transformation
through response models.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Type, Union, TypeVar, Protocol
from dataclasses import dataclass
from fastapi import status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Query
from ApiValidations.Request.base_validations import PydanticBaseModel
from ApiValidations.handler import BaseModelRegular
from Services.Postgres.Models.response import PostgresResponse
from ErrorHandlers.ApiErrorHandlers.api_exc_handler import HTTPExceptionApi
T = TypeVar("T")
class DataValidator(Protocol):
"""Protocol for data validation methods."""
@staticmethod
def validate_data(data: Any, cls_object: Any) -> None:
"""Validate data and raise HTTPExceptionApi if invalid."""
...
@dataclass
class PaginationConfig:
"""
Configuration for pagination settings.
Attributes:
page: Current page number (default: 1)
size: Items per page (default: 10)
order_field: Field to order by (default: "id")
order_type: Order direction (default: "asc")
"""
page: int = 1
size: int = 10
order_field: str = "id"
order_type: str = "asc"
class Pagination:
"""
Handles pagination logic for query results.
Manages page size, current page, ordering, and calculates total pages
and items based on the data source.
Attributes:
DEFAULT_SIZE: Default number of items per page (10)
MIN_SIZE: Minimum allowed page size (10)
MAX_SIZE: Maximum allowed page size (40)
"""
DEFAULT_SIZE = 10
MIN_SIZE = 10
MAX_SIZE = 40
def __init__(self):
self.size: int = self.DEFAULT_SIZE
self.page: int = 1
self.order_field: str = "id"
self.order_type: str = "asc"
self.page_count: int = 1
self.total_count: int = 0
self.total_pages: int = 1
def change(self, config: PaginationConfig) -> None:
"""Update pagination settings from config."""
self.size = (
config.size
if self.MIN_SIZE <= config.size <= self.MAX_SIZE
else self.DEFAULT_SIZE
)
self.page = config.page
self.order_field = config.order_field
self.order_type = config.order_type
self._update_page_counts()
def feed(self, data: Union[List[Any], PostgresResponse, Query]) -> None:
"""Calculate pagination based on data source."""
self.total_count = (
len(data)
if isinstance(data, list)
else data.count if isinstance(data, PostgresResponse) else data.count()
)
self._update_page_counts()
def _update_page_counts(self) -> None:
"""Update page counts and validate current page."""
self.total_pages = max(1, (self.total_count + self.size - 1) // self.size)
self.page = max(1, min(self.page, self.total_pages))
self.page_count = (
self.total_count % self.size
if self.page == self.total_pages and self.total_count % self.size
else self.size
)
def as_dict(self) -> Dict[str, Any]:
"""Convert pagination state to dictionary format."""
return {
"size": self.size,
"page": self.page,
"totalCount": self.total_count,
"totalPages": self.total_pages,
"pageCount": self.page_count,
"orderField": self.order_field,
"orderType": self.order_type,
}
@dataclass
class ResponseConfig:
"""
Configuration for response formatting.
Attributes:
status_code: HTTP status code (default: "HTTP_200_OK")
message: Response message
completed: Operation completion status
cls_object: Class object for error handling
"""
status_code: str = "HTTP_200_OK"
message: str = ""
completed: bool = True
cls_object: Optional[Any] = None
class BaseJsonResponse:
"""
Base class for JSON response handling.
Provides common functionality for all response types:
- Response formatting
- Pagination handling
- Data transformation
"""
def __init__(
self,
config: ResponseConfig,
response_model: Optional[Type[T]] = None,
filter_attributes: Optional[Any] = None,
):
self.status_code = getattr(status, config.status_code, status.HTTP_200_OK)
self.message = config.message
self.completed = config.completed
self.filter_attributes = filter_attributes
self.response_model = response_model
self.cls_object = config.cls_object
def _create_pagination(self) -> Pagination:
"""Create and configure pagination instance."""
pagination = Pagination()
if self.filter_attributes:
pagination.change(
PaginationConfig(
page=self.filter_attributes.page,
size=self.filter_attributes.size,
order_field=self.filter_attributes.order_field,
order_type=self.filter_attributes.order_type,
)
)
return pagination
def _format_response(self, pagination: Pagination, data: Any) -> JSONResponse:
"""Format final JSON response with pagination."""
return JSONResponse(
status_code=self.status_code,
content={
"pagination": pagination.as_dict(),
"completed": self.completed,
"message": self.message,
"data": data,
},
)
@staticmethod
def _validate_data(data: Any, expected_type: Type, cls_object: Any) -> None:
"""Validate data type and raise exception if invalid."""
if not isinstance(data, expected_type):
raise HTTPExceptionApi(
lang=cls_object.lang,
error_code="HTTP_400_BAD_REQUEST",
)
class SinglePostgresResponse(BaseJsonResponse):
"""
Handles single record responses from PostgreSQL queries.
Used when expecting a single record from a database query.
Validates that the result is a PostgresResponse and contains exactly one record.
"""
def __new__(
cls,
message: str,
result: PostgresResponse,
response_model: Optional[Type[T]] = None,
status_code: str = "HTTP_200_OK",
completed: bool = True,
cls_object: Optional[Any] = None,
) -> JSONResponse:
cls._validate_data(result, PostgresResponse, cls_object)
if not result.first:
raise HTTPExceptionApi(
lang=cls_object.lang,
error_code="HTTP_400_BAD_REQUEST",
)
instance = cls()
instance.__init__(
ResponseConfig(
status_code=status_code,
message=message,
completed=completed,
cls_object=cls_object,
),
response_model=response_model,
)
pagination = instance._create_pagination()
data = result.data.get_dict()
if response_model:
data = response_model(**data).dump()
return instance._format_response(pagination, data)
class AlchemyJsonResponse(BaseJsonResponse):
"""
Handles multiple record responses from SQLAlchemy queries.
Used for database queries returning multiple records.
Validates that the result is a PostgresResponse and contains multiple records.
Supports pagination and data transformation through response models.
"""
def __new__(
cls,
message: str,
result: PostgresResponse,
response_model: Optional[Type[T]] = None,
status_code: str = "HTTP_200_OK",
completed: bool = True,
cls_object: Optional[Any] = None,
filter_attributes: Optional[Any] = None,
) -> JSONResponse:
cls._validate_data(result, PostgresResponse, cls_object)
if result.first:
raise HTTPExceptionApi(
lang=cls_object.lang,
error_code="HTTP_400_BAD_REQUEST",
)
instance = cls()
instance.__init__(
ResponseConfig(
status_code=status_code,
message=message,
completed=completed,
cls_object=cls_object,
),
response_model=response_model,
filter_attributes=filter_attributes,
)
pagination = instance._create_pagination()
data = [
(
response_model(**item.get_dict()).dump()
if response_model
else item.get_dict()
)
for item in result.data
]
pagination.feed(data)
return instance._format_response(pagination, data)
class ListJsonResponse(BaseJsonResponse):
"""
Handles responses for list data.
Used when working with Python lists that need to be paginated
and optionally transformed through a response model.
Validates that the input is a list.
"""
def __new__(
cls,
message: str,
result: List[Any],
response_model: Optional[Type[T]] = None,
status_code: str = "HTTP_200_OK",
completed: bool = True,
cls_object: Optional[Any] = None,
filter_attributes: Optional[Any] = None,
) -> JSONResponse:
cls._validate_data(result, list, cls_object)
instance = cls()
instance.__init__(
ResponseConfig(
status_code=status_code,
message=message,
completed=completed,
cls_object=cls_object,
),
response_model=response_model,
filter_attributes=filter_attributes,
)
pagination = instance._create_pagination()
data = [
response_model(**item).dump() if response_model else item for item in result
]
pagination.feed(data)
return instance._format_response(pagination, data)
class DictJsonResponse(BaseJsonResponse):
"""
Handles responses for dictionary data.
Used when working with single dictionary objects that need to be
transformed through a response model. Validates that the input
is a dictionary.
"""
def __new__(
cls,
message: str,
result: Dict[str, Any],
response_model: Optional[Type[T]] = None,
status_code: str = "HTTP_200_OK",
completed: bool = True,
cls_object: Optional[Any] = None,
filter_attributes: Optional[Any] = None,
) -> JSONResponse:
cls._validate_data(result, dict, cls_object)
instance = cls()
instance.__init__(
ResponseConfig(
status_code=status_code,
message=message,
completed=completed,
cls_object=cls_object,
),
response_model=response_model,
filter_attributes=filter_attributes,
)
pagination = instance._create_pagination()
data = response_model(**result).dump() if response_model else result
return instance._format_response(pagination, data)

View File

@@ -117,4 +117,4 @@ class BaseModel:
# db: Session = Depends(get_db_session)
# ):
# with User.create_with_session(**user_data) as new_user:
# return new_user
# return new_user

View File

@@ -0,0 +1,526 @@
"""
Advanced filtering functionality for SQLAlchemy models.
This module provides a comprehensive set of filtering capabilities for SQLAlchemy models,
including pagination, ordering, and complex query building.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Type, TypeVar, Union, Tuple, Protocol
from dataclasses import dataclass
from json import dumps
from sqlalchemy import BinaryExpression, desc, asc
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Query, Session
from sqlalchemy.sql.elements import BinaryExpression
from ApiLibrary import system_arrow
from ErrorHandlers.ApiErrorHandlers import HTTPExceptionApi
from Services.PostgresDb.Models.response import PostgresResponse
# Type variable for class methods returning self
T = TypeVar("T", bound="FilterAttributes")
class HTTPException(Exception):
"""Base exception for HTTP errors."""
def __init__(self, status_code: str, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
class HTTPStatus(Protocol):
"""Protocol defining required HTTP status codes."""
HTTP_400_BAD_REQUEST: str
HTTP_404_NOT_FOUND: str
HTTP_304_NOT_MODIFIED: str
@dataclass
class FilterConfig:
"""Configuration for filtering and pagination."""
page: int = 1
size: int = 10
order_field: str = "id"
order_type: str = "asc"
include_joins: List[str] = None
query: Dict[str, Any] = None
def __post_init__(self):
"""Initialize default values for None fields."""
self.include_joins = self.include_joins or []
self.query = self.query or {}
class QueryConfig:
"""Configuration for query building and execution."""
def __init__(
self,
pre_query: Optional[Query] = None,
filter_config: Optional[FilterConfig] = None,
http_exception: Optional[Type[HTTPException]] = HTTPException,
status: Optional[Type[HTTPStatus]] = None,
):
self.pre_query = pre_query
self.filter_config = filter_config or FilterConfig()
self.http_exception = http_exception
self.status = status
self.total_count: Optional[int] = None
def update_filter_config(self, **kwargs) -> None:
"""Update filter configuration parameters."""
for key, value in kwargs.items():
if hasattr(self.filter_config, key):
setattr(self.filter_config, key, value)
def set_total_count(self, count: int) -> None:
"""Set the total count of records."""
self.total_count = count
class FilterAttributes:
"""
Advanced filtering capabilities for SQLAlchemy models.
Features:
- Pagination and ordering
- Complex query building
- Active/deleted/confirmed status filtering
- Expiry date handling
- Transaction management
Usage:
# Initialize configuration
config = QueryConfig(filter_config=FilterConfig(page=1, size=10))
# Create model with configuration
class User(FilterAttributes):
query_config = config
# Filter multiple records
users = User.filter_by_all(db, name="John").data
# Update configuration
User.query_config.update_filter_config(page=2, size=20)
next_users = User.filter_all(db).data
"""
__abstract__ = True
# Class-level configuration
query_config: QueryConfig = QueryConfig()
@classmethod
def flush(cls: Type[T], db: Session) -> T:
"""
Flush the current session to the database.
Args:
db: Database session
Returns:
Self instance
Raises:
HTTPException: If database operation fails
"""
try:
db.flush()
return cls
except SQLAlchemyError as e:
raise HTTPExceptionApi(
error_code="HTTP_304_NOT_MODIFIED",
lang=cls.lang or "tr",
)
@classmethod
def destroy(cls: Type[T], db: Session) -> None:
"""
Delete the record from the database.
Args:
db: Database session
"""
db.delete(cls)
db.commit()
@classmethod
def save_via_metadata(cls: Type[T], db: Session) -> None:
"""
Save or rollback based on metadata.
Args:
db: Database session
Raises:
HTTPException: If save operation fails
"""
try:
meta_data = getattr(cls, "meta_data", {})
if meta_data.get("created", False):
db.commit()
db.rollback()
except SQLAlchemyError as e:
raise HTTPExceptionApi(
error_code="HTTP_304_NOT_MODIFIED",
lang=cls.lang or "tr",
)
@classmethod
def save(cls: Type[T], db: Session) -> None:
"""
Commit changes to database.
Args:
db: Database session
Raises:
HTTPException: If commit fails
"""
try:
db.commit()
except SQLAlchemyError as e:
raise HTTPExceptionApi(
error_code="HTTP_304_NOT_MODIFIED",
lang=cls.lang or "tr",
)
@classmethod
def rollback(cls: Type[T], db: Session) -> None:
"""
Rollback current transaction.
Args:
db: Database session
"""
db.rollback()
@classmethod
def save_and_confirm(cls: Type[T], db: Session) -> None:
"""
Save changes and mark record as confirmed.
Args:
db: Database session
Raises:
HTTPException: If operation fails
"""
try:
cls.save(db)
cls.update(db, is_confirmed=True)
cls.save(db)
except SQLAlchemyError as e:
raise HTTPExceptionApi(
error_code="HTTP_304_NOT_MODIFIED",
lang=cls.lang or "tr",
)
@classmethod
def _query(cls: Type[T], db: Session) -> Query:
"""
Get base query for model.
Args:
db: Database session
Returns:
SQLAlchemy Query object
"""
return (
cls.query_config.pre_query if cls.query_config.pre_query else db.query(cls)
)
@classmethod
def add_query_to_filter(
cls: Type[T], query: Query, filter_list: Dict[str, Any]
) -> Query:
"""
Add pagination and ordering to query.
Args:
query: Base query
filter_list: Dictionary containing pagination and ordering parameters
Returns:
Modified query with pagination and ordering
"""
order_field = getattr(cls, filter_list.get("order_field"))
order_func = desc if str(filter_list.get("order_type"))[0] == "d" else asc
return (
query.order_by(order_func(order_field))
.limit(filter_list.get("size"))
.offset((filter_list.get("page") - 1) * filter_list.get("size"))
.populate_existing()
)
@classmethod
def get_filter_attributes(cls) -> Dict[str, Any]:
"""
Get filter configuration from attributes.
Returns:
Dictionary containing pagination and filtering parameters
"""
return {
"page": getattr(cls.query_config.filter_config, "page", 1),
"size": getattr(cls.query_config.filter_config, "size", 10),
"order_field": getattr(cls.query_config.filter_config, "order_field", "id"),
"order_type": getattr(cls.query_config.filter_config, "order_type", "asc"),
"include_joins": getattr(
cls.query_config.filter_config, "include_joins", []
),
"query": getattr(cls.query_config.filter_config, "query", {}),
}
@classmethod
def add_new_arg_to_args(
cls,
args_list: Tuple[BinaryExpression, ...],
argument: str,
value: BinaryExpression,
) -> Tuple[BinaryExpression, ...]:
"""
Add new argument to filter arguments if not exists.
Args:
args_list: Current filter arguments
argument: Argument name to check
value: New argument to add
Returns:
Updated argument tuple
"""
new_args = [arg for arg in args_list if isinstance(arg, BinaryExpression)]
arg_left = lambda arg_obj: getattr(getattr(arg_obj, "left", None), "key", None)
if not any(arg_left(arg) == argument for arg in new_args):
new_args.append(value)
return tuple(new_args)
@classmethod
def get_not_expired_query_arg(
cls, args: Tuple[BinaryExpression, ...]
) -> Tuple[BinaryExpression, ...]:
"""
Add expiry date conditions to query.
Args:
args: Current query arguments
Returns:
Updated arguments with expiry conditions
"""
current_time = str(system_arrow.now())
args = cls.add_new_arg_to_args(
args, "expiry_ends", cls.expiry_ends > current_time
)
args = cls.add_new_arg_to_args(
args, "expiry_starts", cls.expiry_starts <= current_time
)
return args
@classmethod
def get_active_and_confirmed_query_arg(
cls, args: Tuple[BinaryExpression, ...]
) -> Tuple[BinaryExpression, ...]:
"""
Add status conditions to query.
Args:
args: Current query arguments
Returns:
Updated arguments with status conditions
"""
args = cls.add_new_arg_to_args(args, "is_confirmed", cls.is_confirmed == True)
args = cls.add_new_arg_to_args(args, "active", cls.active == True)
args = cls.add_new_arg_to_args(args, "deleted", cls.deleted == False)
return args
@classmethod
def select_only(
cls: Type[T],
db: Session,
*args: BinaryExpression,
select_args: List[Any],
order_by: Optional[Any] = None,
limit: Optional[int] = None,
system: bool = False,
) -> PostgresResponse:
"""
Select specific columns from filtered query.
Args:
db: Database session
args: Filter conditions
select_args: Columns to select
order_by: Optional ordering
limit: Optional result limit
system: If True, skip status filtering
Returns:
Query response with selected columns
"""
if not system:
args = cls.get_active_and_confirmed_query_arg(args)
args = cls.get_not_expired_query_arg(args)
query = cls._query(db).filter(*args).with_entities(*select_args)
cls.query_config.set_total_count(query.count())
if order_by is not None:
query = query.order_by(order_by)
if limit:
query = query.limit(limit)
return PostgresResponse(query=query, first=False)
@classmethod
def filter_by_all(
cls: Type[T], db: Session, system: bool = False, **kwargs
) -> PostgresResponse:
"""
Filter multiple records by keyword arguments.
Args:
db: Database session
system: If True, skip status filtering
**kwargs: Filter criteria
Returns:
Query response with matching records
"""
if "is_confirmed" not in kwargs and not system:
kwargs["is_confirmed"] = True
kwargs.pop("system", None)
query = cls._query(db).filter_by(**kwargs)
cls.query_config.set_total_count(query.count())
if cls.query_config.filter_config:
filter_list = cls.get_filter_attributes()
query = cls.add_query_to_filter(query, filter_list)
return PostgresResponse(query=query, first=False)
@classmethod
def filter_by_one(
cls: Type[T], db: Session, system: bool = False, **kwargs
) -> PostgresResponse:
"""
Filter single record by keyword arguments.
Args:
db: Database session
system: If True, skip status filtering
**kwargs: Filter criteria
Returns:
Query response with single record
"""
if "is_confirmed" not in kwargs and not system:
kwargs["is_confirmed"] = True
kwargs.pop("system", None)
query = cls._query(db).filter_by(**kwargs)
cls.query_config.set_total_count(1)
return PostgresResponse(query=query, first=True)
@classmethod
def filter_all(
cls: Type[T], db: Session, *args: BinaryExpression, system: bool = False
) -> PostgresResponse:
"""
Filter multiple records by expressions.
Args:
db: Database session
args: Filter expressions
system: If True, skip status filtering
Returns:
Query response with matching records
"""
if not system:
args = cls.get_active_and_confirmed_query_arg(args)
args = cls.get_not_expired_query_arg(args)
filter_list = cls.get_filter_attributes()
if filter_list.get("query"):
for smart_iter in cls.filter_expr(**filter_list["query"]):
if key := getattr(getattr(smart_iter, "left", None), "key", None):
args = cls.add_new_arg_to_args(args, key, smart_iter)
query = cls._query(db)
cls.query_config.set_total_count(query.count())
query = query.filter(*args)
if cls.query_config.filter_config:
query = cls.add_query_to_filter(query, filter_list)
return PostgresResponse(query=query, first=False)
@classmethod
def filter_one(
cls: Type[T],
db: Session,
*args: BinaryExpression,
system: bool = False,
expired: bool = False,
) -> PostgresResponse:
"""
Filter single record by expressions.
Args:
db: Database session
args: Filter expressions
system: If True, skip status filtering
expired: If True, include expired records
Returns:
Query response with single record
"""
if not system:
args = cls.get_active_and_confirmed_query_arg(args)
if not expired:
args = cls.get_not_expired_query_arg(args)
query = cls._query(db).filter(*args)
cls.query_config.set_total_count(1)
return PostgresResponse(query=query, first=True)
@classmethod
def raise_http_exception(
cls,
status_code: str,
error_case: str,
data: Dict[str, Any],
message: str,
) -> None:
"""
Raise HTTP exception with formatted error details.
Args:
status_code: HTTP status code string
error_case: Error type
data: Additional error data
message: Error message
Raises:
HTTPException: With formatted error details
"""
raise HTTPExceptionApi(
error_code="HTTP_304_NOT_MODIFIED",
lang=cls.lang or "tr",
)

View File

@@ -17,9 +17,9 @@ from Services.Postgres.database import get_db
# Example Model Definition
class User(CrudCollection):
"""Example user model demonstrating CrudCollection usage."""
__tablename__ = "users"
# Additional fields (id and other common fields come from CrudCollection)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(100), unique=True)
@@ -28,15 +28,13 @@ class User(CrudCollection):
# Example Usage
def example_create():
"""Example of creating a new record."""
with get_db() as db:
# Create a new user
user = User.find_or_create(
db,
username="john_doe",
email="john@example.com",
age=30
db, username="john_doe", email="john@example.com", age=30
)
db.commit()
return user
@@ -50,12 +48,10 @@ def example_batch_create():
users = []
for i in range(3):
user = User.find_or_create(
db,
username=f"user_{i}",
email=f"user_{i}@example.com"
db, username=f"user_{i}", email=f"user_{i}@example.com"
)
users.append(user)
db.commit()
return users
except Exception:
@@ -69,11 +65,7 @@ def example_update():
# Find user and update
user = db.query(User).filter(User.username == "john_doe").first()
if user:
user.update(
db,
email="john.doe@newdomain.com",
age=31
)
user.update(db, email="john.doe@newdomain.com", age=31)
db.commit()
return user
@@ -94,11 +86,7 @@ def example_query():
# Get active (non-deleted) users
active_users = (
db.query(User)
.filter(
User.active == True,
User.deleted == False,
User.age >= 18
)
.filter(User.active == True, User.deleted == False, User.age >= 18)
.order_by(User.created_at.desc())
.all()
)
@@ -110,25 +98,21 @@ def example_complex_transaction():
with get_db() as db:
try:
# Multiple operations in single transaction
user = User.find_or_create(
db,
username="new_user",
email="new@example.com"
)
user = User.find_or_create(db, username="new_user", email="new@example.com")
# Update existing user
other_user = db.query(User).filter(User.username == "old_user").first()
if other_user:
other_user.update(db, email="updated@example.com")
# Soft delete another user
deleted_user = db.query(User).filter(User.username == "to_delete").first()
if deleted_user:
deleted_user.update(db, deleted=True)
# Commit all changes at once
db.commit()
except Exception:
# Rollback all changes if any operation fails
db.rollback()
@@ -141,19 +125,12 @@ def example_serialization():
user = db.query(User).first()
if user:
# Get all fields except specified ones
dict_with_excludes = user.get_dict(
exclude=["created_at", "updated_at"]
)
dict_with_excludes = user.get_dict(exclude=["created_at", "updated_at"])
# Get only specified fields
dict_with_includes = user.get_dict(
include=["id", "username", "email"]
)
return {
"excluded": dict_with_excludes,
"included": dict_with_includes
}
dict_with_includes = user.get_dict(include=["id", "username", "email"])
return {"excluded": dict_with_excludes, "included": dict_with_includes}
def example_confirmation():
@@ -176,17 +153,17 @@ def example_error_handling():
user = User.find_or_create(
db,
username="existing_user", # This might cause unique constraint violation
email="exists@example.com"
email="exists@example.com",
)
db.commit()
return {"status": "success", "user": user.get_dict()}
except Exception as e:
db.rollback()
return {
"status": "error",
"message": str(e),
"error_type": e.__class__.__name__
"error_type": e.__class__.__name__,
}
@@ -199,18 +176,15 @@ def example_date_handling():
db.query(User)
.filter(
User.expiry_starts <= datetime.utcnow(),
User.expiry_ends > datetime.utcnow()
User.expiry_ends > datetime.utcnow(),
)
.all()
)
# Set expiry for a user
user = db.query(User).first()
if user:
user.update(
db,
expiry_ends=datetime(2024, 12, 31, 23, 59, 59)
)
user.update(db, expiry_ends=datetime(2024, 12, 31, 23, 59, 59))
db.commit()
return current_users

View File

@@ -25,25 +25,23 @@ from sqlalchemy import (
Boolean,
SmallInteger,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
Session
)
from sqlalchemy.orm import Mapped, mapped_column, Session
from sqlalchemy_mixins.serialize import SerializeMixin
from sqlalchemy_mixins.repr import ReprMixin
from sqlalchemy_mixins.smartquery import SmartQueryMixin
from api_library import DateTimeLocal, system_arrow
from Services.Postgres.database import Base, get_db
from ApiLibrary import DateTimeLocal, system_arrow
from Services.Postgres.database import Base
from Services.PostgresDb.Models.filter_functions import FilterAttributes
# Type variable for class methods returning self
T = TypeVar('T', bound='CrudMixin')
T = TypeVar("T", bound="CrudMixin")
class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
"""
Base mixin providing CRUD operations and common fields for PostgreSQL models.
Features:
- Automatic timestamps (created_at, updated_at)
- Soft delete capability
@@ -51,30 +49,54 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
- Data serialization
- Multi-language support
"""
__abstract__ = True
# System fields that should be handled automatically during creation
__system__fields__create__ = (
"created_at", "updated_at", "cryp_uu_id",
"created_by", "created_by_id", "updated_by", "updated_by_id",
"replication_id", "confirmed_by", "confirmed_by_id",
"is_confirmed", "deleted", "active",
"is_notification_send", "is_email_send",
"created_at",
"updated_at",
"cryp_uu_id",
"created_by",
"created_by_id",
"updated_by",
"updated_by_id",
"replication_id",
"confirmed_by",
"confirmed_by_id",
"is_confirmed",
"deleted",
"active",
"is_notification_send",
"is_email_send",
)
# System fields that should be handled automatically during updates
__system__fields__update__ = (
"cryp_uu_id", "created_at", "updated_at",
"created_by", "created_by_id", "confirmed_by", "confirmed_by_id",
"updated_by", "updated_by_id", "replication_id",
"cryp_uu_id",
"created_at",
"updated_at",
"created_by",
"created_by_id",
"confirmed_by",
"confirmed_by_id",
"updated_by",
"updated_by_id",
"replication_id",
)
# Default fields to exclude from serialization
__system_default_model__ = [
"cryp_uu_id", "is_confirmed", "deleted",
"is_notification_send", "replication_id", "is_email_send",
"confirmed_by_id", "confirmed_by", "updated_by_id", "created_by_id",
"cryp_uu_id",
"is_confirmed",
"deleted",
"is_notification_send",
"replication_id",
"is_email_send",
"confirmed_by_id",
"confirmed_by",
"updated_by_id",
"created_by_id",
]
# User credentials and preferences
@@ -89,20 +111,20 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
TIMESTAMP(timezone=True),
server_default=func.now(),
nullable=False,
comment="Record validity start timestamp"
comment="Record validity start timestamp",
)
expiry_ends: Mapped[TIMESTAMP] = mapped_column(
TIMESTAMP(timezone=True),
default="2099-12-31",
server_default="2099-12-31",
comment="Record validity end timestamp"
comment="Record validity end timestamp",
)
@classmethod
def set_user_define_properties(cls, token: Any) -> None:
"""
Set user-specific properties from the authentication token.
Args:
token: Authentication token containing user preferences
"""
@@ -114,10 +136,10 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
def remove_non_related_inputs(cls, kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""
Filter out inputs that don't correspond to model fields.
Args:
kwargs: Dictionary of field names and values
Returns:
Dictionary containing only valid model fields
"""
@@ -128,21 +150,22 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
}
@classmethod
def extract_system_fields(cls, filter_kwargs: dict, create: bool = True) -> Dict[str, Any]:
def extract_system_fields(
cls, filter_kwargs: dict, create: bool = True
) -> Dict[str, Any]:
"""
Remove system-managed fields from input dictionary.
Args:
filter_kwargs: Input dictionary of fields
create: If True, use creation field list, else use update field list
Returns:
Dictionary with system fields removed
"""
system_fields = filter_kwargs.copy()
extract_fields = (
cls.__system__fields__create__ if create
else cls.__system__fields__update__
cls.__system__fields__create__ if create else cls.__system__fields__update__
)
for field in extract_fields:
system_fields.pop(field, None)
@@ -152,30 +175,30 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
def iterate_over_variables(cls, val: Any, key: str) -> tuple[bool, Optional[Any]]:
"""
Process a field value based on its type and convert it to the appropriate format.
Args:
val: Field value
key: Field name
Returns:
Tuple of (should_include, processed_value)
"""
key_ = cls.__annotations__.get(key, None)
is_primary = key in cls.primary_keys
row_attr = bool(getattr(getattr(cls, key), "foreign_keys", None))
# Skip primary keys and foreign keys
if is_primary or row_attr:
return False, None
# Handle None values
if val is None:
return True, None
# Special handling for UUID fields
if str(key[-5:]).lower() == "uu_id":
return True, str(val)
# Handle typed fields
if key_:
if key_ == Mapped[int]:
@@ -190,7 +213,7 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
)
elif key_ == Mapped[str]:
return True, str(val)
# Handle based on Python types
else:
if isinstance(val, datetime.datetime):
@@ -207,35 +230,35 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
return True, str(val)
elif val is None:
return True, None
return False, None
@classmethod
def find_or_create(cls: Type[T], db: Session, **kwargs) -> T:
"""
Find an existing record matching the criteria or create a new one.
Args:
db: Database session
**kwargs: Search/creation criteria
Returns:
Existing or newly created record
"""
check_kwargs = cls.extract_system_fields(kwargs)
# Search for existing record
query = db.query(cls).filter(
cls.expiry_ends > str(system_arrow.now()),
cls.expiry_starts <= str(system_arrow.now()),
)
for key, value in check_kwargs.items():
if hasattr(cls, key):
query = query.filter(getattr(cls, key) == value)
already_record = query.first()
# Handle existing record
if already_record:
if already_record.deleted:
@@ -252,7 +275,7 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
"message": "",
}
return already_record
already_record.meta_data = {
"created": False,
"error_case": "AlreadyExists",
@@ -263,75 +286,81 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
# Create new record
check_kwargs = cls.remove_non_related_inputs(check_kwargs)
created_record = cls()
for key, value in check_kwargs.items():
setattr(created_record, key, value)
if getattr(cls.creds, "person_id", None) and getattr(cls.creds, "person_name", None):
if getattr(cls.creds, "person_id", None) and getattr(
cls.creds, "person_name", None
):
created_record.created_by_id = cls.creds.person_id
created_record.created_by = cls.creds.person_name
db.add(created_record)
db.flush()
created_record.meta_data = {"created": True, "error_case": None, "message": ""}
return created_record
def update(self, db: Session, **kwargs) -> 'CrudMixin':
def update(self, db: Session, **kwargs) -> "CrudMixin":
"""
Update the record with new values.
Args:
db: Database session
**kwargs: Fields to update
Returns:
Updated record
Raises:
ValueError: If attempting to update is_confirmed with other fields
"""
check_kwargs = self.remove_non_related_inputs(kwargs)
is_confirmed_argument = kwargs.get("is_confirmed", None)
if is_confirmed_argument and not len(kwargs) == 1:
raise ValueError("Confirm field cannot be updated with other fields")
check_kwargs = self.extract_system_fields(check_kwargs, create=False)
for key, value in check_kwargs.items():
setattr(self, key, value)
# Update confirmation or modification tracking
if is_confirmed_argument:
if getattr(self.creds, "person_id", None) and getattr(self.creds, "person_name", None):
if getattr(self.creds, "person_id", None) and getattr(
self.creds, "person_name", None
):
self.confirmed_by_id = self.creds.person_id
self.confirmed_by = self.creds.person_name
else:
if getattr(self.creds, "person_id", None) and getattr(self.creds, "person_name", None):
if getattr(self.creds, "person_id", None) and getattr(
self.creds, "person_name", None
):
self.updated_by_id = self.creds.person_id
self.updated_by = self.creds.person_name
db.flush()
return self
def get_dict(
self,
self,
exclude: Optional[List[str]] = None,
include: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Convert model instance to dictionary with customizable fields.
Args:
exclude: List of fields to exclude
include: List of fields to include (takes precedence over exclude)
Returns:
Dictionary representation of the model
"""
return_dict: Dict[str, Any] = {}
if include:
# Handle explicitly included fields
exclude_list = [
@@ -341,22 +370,30 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
]
columns_include_list = list(set(include).difference(set(exclude_list)))
columns_include_list.extend(["uu_id"])
for key in columns_include_list:
val = getattr(self, key)
correct, value_of_database = self.iterate_over_variables(val, key)
if correct:
return_dict[key] = value_of_database
elif exclude:
# Handle explicitly excluded fields
exclude.extend(list(set(getattr(self, '__exclude__fields__', []) or []).difference(exclude)))
exclude.extend([
element
for element in self.__system_default_model__
if str(element)[-2:] == "id"
])
exclude.extend(
list(
set(getattr(self, "__exclude__fields__", []) or []).difference(
exclude
)
)
)
exclude.extend(
[
element
for element in self.__system_default_model__
if str(element)[-2:] == "id"
]
)
columns_excluded_list = list(set(self.columns).difference(set(exclude)))
columns_excluded_list.extend(["uu_id", "active"])
@@ -367,37 +404,41 @@ class CrudMixin(Base, SmartQueryMixin, SerializeMixin):
return_dict[key] = value_of_database
else:
# Handle default field selection
exclude_list = (getattr(self, '__exclude__fields__', []) or []) + self.__system_default_model__
exclude_list = (
getattr(self, "__exclude__fields__", []) or []
) + self.__system_default_model__
columns_list = list(set(self.columns).difference(set(exclude_list)))
columns_list = [col for col in columns_list if str(col)[-2:] != "id"]
columns_list.extend([col for col in self.columns if str(col)[-5:].lower() == "uu_id"])
columns_list.extend(
[col for col in self.columns if str(col)[-5:].lower() == "uu_id"]
)
for remove_field in self.__system_default_model__:
if remove_field in columns_list:
columns_list.remove(remove_field)
for key in columns_list:
val = getattr(self, key)
correct, value_of_database = self.iterate_over_variables(val, key)
if correct:
return_dict[key] = value_of_database
return return_dict
class BaseCollection(CrudMixin, ReprMixin):
"""Base model class with minimal fields."""
__abstract__ = True
__repr__ = ReprMixin.__repr__
id: Mapped[int] = mapped_column(primary_key=True)
class CrudCollection(CrudMixin, SmartQueryMixin):
class CrudCollection(CrudMixin, SmartQueryMixin, FilterAttributes):
"""
Full-featured model class with all common fields.
Includes:
- UUID and reference ID
- Timestamps
@@ -406,7 +447,7 @@ class CrudCollection(CrudMixin, SmartQueryMixin):
- Soft delete
- Notification flags
"""
__abstract__ = True
__repr__ = ReprMixin.__repr__
@@ -417,22 +458,19 @@ class CrudCollection(CrudMixin, SmartQueryMixin):
server_default=text("gen_random_uuid()"),
index=True,
unique=True,
comment="Unique identifier UUID"
comment="Unique identifier UUID",
)
ref_id: Mapped[str] = mapped_column(
String(100),
nullable=True,
index=True,
comment="External reference ID"
String(100), nullable=True, index=True, comment="External reference ID"
)
# Timestamps
created_at: Mapped[TIMESTAMP] = mapped_column(
TIMESTAMP(timezone=True),
server_default=func.now(),
nullable=False,
index=True,
comment="Record creation timestamp"
comment="Record creation timestamp",
)
updated_at: Mapped[TIMESTAMP] = mapped_column(
TIMESTAMP(timezone=True),
@@ -440,95 +478,70 @@ class CrudCollection(CrudMixin, SmartQueryMixin):
onupdate=func.now(),
nullable=False,
index=True,
comment="Last update timestamp"
comment="Last update timestamp",
)
# Cryptographic and user tracking
cryp_uu_id: Mapped[str] = mapped_column(
String,
nullable=True,
index=True,
comment="Cryptographic UUID"
String, nullable=True, index=True, comment="Cryptographic UUID"
)
created_by: Mapped[str] = mapped_column(
String,
nullable=True,
comment="Creator name"
String, nullable=True, comment="Creator name"
)
created_by_id: Mapped[int] = mapped_column(
Integer,
nullable=True,
comment="Creator ID"
Integer, nullable=True, comment="Creator ID"
)
updated_by: Mapped[str] = mapped_column(
String,
nullable=True,
comment="Last modifier name"
String, nullable=True, comment="Last modifier name"
)
updated_by_id: Mapped[int] = mapped_column(
Integer,
nullable=True,
comment="Last modifier ID"
Integer, nullable=True, comment="Last modifier ID"
)
confirmed_by: Mapped[str] = mapped_column(
String,
nullable=True,
comment="Confirmer name"
String, nullable=True, comment="Confirmer name"
)
confirmed_by_id: Mapped[int] = mapped_column(
Integer,
nullable=True,
comment="Confirmer ID"
Integer, nullable=True, comment="Confirmer ID"
)
# Status flags
is_confirmed: Mapped[bool] = mapped_column(
Boolean,
server_default="0",
comment="Record confirmation status"
Boolean, server_default="0", comment="Record confirmation status"
)
replication_id: Mapped[int] = mapped_column(
SmallInteger,
server_default="0",
comment="Replication identifier"
SmallInteger, server_default="0", comment="Replication identifier"
)
deleted: Mapped[bool] = mapped_column(
Boolean,
server_default="0",
comment="Soft delete flag"
Boolean, server_default="0", comment="Soft delete flag"
)
active: Mapped[bool] = mapped_column(
Boolean,
server_default="1",
comment="Record active status"
Boolean, server_default="1", comment="Record active status"
)
is_notification_send: Mapped[bool] = mapped_column(
Boolean,
server_default="0",
comment="Notification sent flag"
Boolean, server_default="0", comment="Notification sent flag"
)
is_email_send: Mapped[bool] = mapped_column(
Boolean,
server_default="0",
comment="Email sent flag"
Boolean, server_default="0", comment="Email sent flag"
)
@classmethod
def retrieve_language_model(cls, lang: str, response_model: Any) -> Dict[str, str]:
"""
Retrieve language-specific model headers and validation messages.
Args:
lang: Language code
response_model: Model containing language annotations
Returns:
Dictionary of field names to localized headers
"""
headers_and_validation = {}
__language_model__ = getattr(cls.__language_model__, lang, "tr")
for field in response_model.__annotations__.keys():
headers_and_validation[field] = getattr(__language_model__, field, "Lang Not found")
headers_and_validation[field] = getattr(
__language_model__, field, "Lang Not found"
)
return headers_and_validation

View File

@@ -3,7 +3,8 @@ from typing import Any, List, Optional, TypeVar, Union
from sqlalchemy.orm import Query
from sqlalchemy.orm.session import Session
T = TypeVar('T')
T = TypeVar("T")
class QueryResponse:
"""Handler for SQLAlchemy query results with error handling."""

View File

@@ -0,0 +1,81 @@
"""
Response handler for PostgreSQL query results.
This module provides a wrapper class for SQLAlchemy query results,
adding convenience methods for accessing data and managing query state.
"""
from typing import Any, Dict, List, Optional, TypeVar, Generic, Union
from sqlalchemy.orm import Query
T = TypeVar("T")
class PostgresResponse(Generic[T]):
"""
Wrapper for PostgreSQL/SQLAlchemy query results.
Attributes:
query: SQLAlchemy query object
first: Whether to return first result only
data: Query results (lazy loaded)
count: Total count of results
Properties:
all: All results as list
first_item: First result only
"""
def __init__(
self,
query: Query,
first: bool = False,
status: bool = True,
message: str = "",
error: Optional[str] = None,
):
self._query = query
self._first = first
self.status = status
self.message = message
self.error = error
self._data: Optional[Union[List[T], T]] = None
self._count: Optional[int] = None
@property
def data(self) -> Union[List[T], T, None]:
"""
Lazy load and return query results.
Returns first item if first=True, otherwise returns all results.
"""
if self._data is None:
results = self._query.all()
self._data = results[0] if self._first and results else results
return self._data
@property
def count(self) -> int:
"""Lazy load and return total count of results."""
if self._count is None:
self._count = self._query.count()
return self._count
@property
def all(self) -> List[T]:
"""Get all results as list."""
return self.data if isinstance(self.data, list) else [self.data] if self.data else []
@property
def first_item(self) -> Optional[T]:
"""Get first result only."""
return self.data if self._first else (self.data[0] if self.data else None)
def as_dict(self) -> Dict[str, Any]:
"""Convert response to dictionary format."""
return {
"status": self.status,
"message": self.message,
"data": self.data,
"count": self.count,
"error": self.error,
}

View File

@@ -0,0 +1,6 @@
from Services.PostgresDb.Models.mixins import CrudCollection, BaseCollection
__all__ = [
"CrudCollection",
"BaseCollection",
]

View File

@@ -22,6 +22,7 @@ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
@contextmanager
def get_db() -> Generator[Session, None, None]:
"""Get database session with context management."""