Source code for polyfactory.factories.sqlalchemy_factory

from __future__ import annotations

import enum
from collections.abc import Awaitable, Collection, Mapping
from dataclasses import is_dataclass
from datetime import date, datetime
from typing import (
    TYPE_CHECKING,
    Annotated,
    Any,
    Callable,
    ClassVar,
    Generic,
    Protocol,
    TypeVar,
    Union,
    cast,
)

from sqlalchemy.util.langhelpers import duck_type_collection

from polyfactory.exceptions import ConfigurationException, MissingDependencyException, ParameterException
from polyfactory.factories.base import BaseFactory
from polyfactory.factories.base import BuildContext as BaseBuildContext
from polyfactory.field_meta import Constraints, FieldMeta
from polyfactory.persistence import AsyncPersistenceProtocol, SyncPersistenceProtocol
from polyfactory.utils.types import Frozendict

try:
    from sqlalchemy import ARRAY, Column, Numeric, String, inspect, types
    from sqlalchemy.dialects import mssql, mysql, postgresql, sqlite
    from sqlalchemy.exc import NoInspectionAvailable
    from sqlalchemy.ext.associationproxy import AssociationProxy
    from sqlalchemy.ext.asyncio import async_scoped_session
    from sqlalchemy.orm import InstanceState, Mapper, RelationshipProperty, scoped_session, sessionmaker
except ImportError as e:
    msg = "sqlalchemy is not installed"
    raise MissingDependencyException(msg) from e

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
    from sqlalchemy.orm import Session
    from sqlalchemy.sql.type_api import TypeEngine
    from typing_extensions import NotRequired, TypeGuard
else:
    try:
        from sqlalchemy.ext.asyncio import async_sessionmaker
    except ImportError:
        async_sessionmaker = sessionmaker


T = TypeVar("T")


[docs] class SQLAlchemyBuildContext(BaseBuildContext): skip_computed_fields: bool
[docs] class SQLAlchemyConstraints(Constraints): computed: NotRequired[bool]
[docs] class SQLAlchemyPersistenceMethod(enum.Enum): FLUSH = "flush" COMMIT = "commit"
[docs] class SQLASyncPersistence(SyncPersistenceProtocol[T]):
[docs] def __init__( self, session: Session, persistence_method: SQLAlchemyPersistenceMethod = SQLAlchemyPersistenceMethod.COMMIT, cleanup_callback: Callable[[], None] | None = None, ) -> None: """Sync persistence handler for SQLAFactory.""" self.session = session self.persistence_method = persistence_method self.cleanup_callback = cleanup_callback
def _flush_or_commit(self) -> None: if self.persistence_method == SQLAlchemyPersistenceMethod.FLUSH: self.session.flush() elif self.persistence_method == SQLAlchemyPersistenceMethod.COMMIT: self.session.commit()
[docs] def save(self, data: T) -> T: try: self.session.add(data) self._flush_or_commit() return data finally: if self.cleanup_callback is not None: self.cleanup_callback()
[docs] def save_many(self, data: list[T]) -> list[T]: try: self.session.add_all(data) self._flush_or_commit() return data finally: if self.cleanup_callback is not None: self.cleanup_callback()
[docs] class SQLAASyncPersistence(AsyncPersistenceProtocol[T]):
[docs] def __init__( self, session: AsyncSession, persistence_method: SQLAlchemyPersistenceMethod = SQLAlchemyPersistenceMethod.COMMIT, cleanup_callback: Callable[[], Awaitable[None]] | None = None, ) -> None: """Async persistence handler for SQLAFactory.""" self.session = session self.persistence_method = persistence_method self.cleanup_callback = cleanup_callback
async def _flush_or_commit(self, session: AsyncSession) -> None: if self.persistence_method == SQLAlchemyPersistenceMethod.FLUSH: await session.flush() elif self.persistence_method == SQLAlchemyPersistenceMethod.COMMIT: await session.commit()
[docs] async def save(self, data: T) -> T: try: self.session.add(data) await self._flush_or_commit(self.session) await self.session.refresh(data) return data finally: if self.cleanup_callback is not None: await self.cleanup_callback()
[docs] async def save_many(self, data: list[T]) -> list[T]: try: self.session.add_all(data) await self._flush_or_commit(self.session) for batch_item in data: await self.session.refresh(batch_item) return data finally: if self.cleanup_callback is not None: await self.cleanup_callback()
_T_co = TypeVar("_T_co", covariant=True) class _SessionMaker(Protocol[_T_co]): def __call__(self, **kwargs: Any) -> _T_co: ...
[docs] class SQLAlchemyFactory(BaseFactory[T], Generic[T]): """Base factory for SQLAlchemy models.""" __is_base_factory__ = True __set_primary_key__: ClassVar[bool] = True """Configuration to consider primary key columns as a field or not.""" __set_foreign_keys__: ClassVar[bool] = True """Configuration to consider columns with foreign keys as a field or not.""" __set_relationships__: ClassVar[bool] = True """Configuration to consider relationships property as a model field or not.""" __set_association_proxy__: ClassVar[bool] = True """Configuration to consider AssociationProxy property as a model field or not.""" __session__: ClassVar[Session | _SessionMaker[Session] | scoped_session[Session] | None] = None __async_session__: ClassVar[ AsyncSession | _SessionMaker[AsyncSession] | async_scoped_session[AsyncSession] | None ] = None __persistence_method__: ClassVar[SQLAlchemyPersistenceMethod] = SQLAlchemyPersistenceMethod.COMMIT """Configuration to use flush() or commit() for persistence.""" __config_keys__ = ( *BaseFactory.__config_keys__, "__set_primary_key__", "__set_foreign_keys__", "__set_relationships__", "__set_association_proxy__", "__persistence_method__", ) @classmethod def _get_build_context( cls, build_context: BaseBuildContext | SQLAlchemyBuildContext | None ) -> SQLAlchemyBuildContext: build_context = cast("SQLAlchemyBuildContext", super()._get_build_context(build_context)) if build_context.get("skip_computed_fields") is None: build_context["skip_computed_fields"] = False return build_context
[docs] @classmethod def create_sync(cls, **kwargs: Any) -> T: build_context = cls._get_build_context(kwargs.get("_build_context")) build_context["skip_computed_fields"] = True kwargs["_build_context"] = build_context return super().create_sync(**kwargs)
[docs] @classmethod async def create_async(cls, **kwargs: Any) -> T: build_context = cls._get_build_context(kwargs.get("_build_context")) build_context["skip_computed_fields"] = True kwargs["_build_context"] = build_context return await super().create_async(**kwargs)
[docs] @classmethod def get_sqlalchemy_types(cls) -> dict[Any, Callable[[], Any]]: """Get mapping of types where column type should be used directly. For sqlalchemy dialect `JSON` type, accepted only basic types in pydict in case sqlalchemy process `JSON` raise serialize error. """ return { types.TupleType: cls.__faker__.pytuple, mssql.JSON: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), mysql.YEAR: lambda: cls.__random__.randint(1901, 2155), mysql.JSON: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), postgresql.CIDR: lambda: cls.__faker__.ipv4(network=True), postgresql.DATERANGE: lambda: (cls.__faker__.past_date(), date.today()), # noqa: DTZ011 postgresql.INET: lambda: cls.__faker__.ipv4(network=False), postgresql.INT4RANGE: lambda: tuple(sorted([cls.__faker__.pyint(), cls.__faker__.pyint()])), postgresql.INT8RANGE: lambda: tuple(sorted([cls.__faker__.pyint(), cls.__faker__.pyint()])), postgresql.MACADDR: lambda: cls.__faker__.hexify(text="^^:^^:^^:^^:^^:^^", upper=True), postgresql.NUMRANGE: lambda: tuple(sorted([cls.__faker__.pyint(), cls.__faker__.pyint()])), postgresql.TSRANGE: lambda: (cls.__faker__.past_datetime(), datetime.now()), # noqa: DTZ005 postgresql.TSTZRANGE: lambda: (cls.__faker__.past_datetime(), datetime.now()), # noqa: DTZ005 postgresql.HSTORE: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), postgresql.JSON: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), postgresql.JSONB: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), sqlite.JSON: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), types.JSON: lambda: cls.__faker__.pydict(value_types=(str, int, bool, float)), }
[docs] @classmethod def get_sqlalchemy_constraints(cls) -> dict[type[TypeEngine], dict[str, str]]: """Get mapping of SQLA type engine to attribute to constraints key.""" return { String: { "length": "max_length", }, Numeric: { "precision": "max_digits", "scale": "decimal_places", }, }
[docs] @classmethod def get_provider_map(cls) -> dict[Any, Callable[[], Any]]: providers_map = super().get_provider_map() providers_map.update(cls.get_sqlalchemy_types()) return providers_map
[docs] @classmethod def is_supported_type(cls, value: Any) -> TypeGuard[type[T]]: try: inspected = inspect(value) except NoInspectionAvailable: return False return isinstance(inspected, (Mapper, InstanceState))
[docs] @classmethod def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: Any) -> bool: build_context = kwargs.get("_build_context", {}) if field_meta.constraints: constraints = cast("SQLAlchemyConstraints", field_meta.constraints) if constraints.get("computed") and build_context.get("skip_computed_fields"): return False return super().should_set_field_value(field_meta, **kwargs)
@classmethod def should_column_be_set(cls, column: Any) -> bool: if not isinstance(column, Column): return False if not cls.__set_primary_key__ and column.primary_key: return False if not cls.should_dataclass_init_field(column.name): return False return bool(cls.__set_foreign_keys__ or not column.foreign_keys) @classmethod def should_dataclass_init_field(cls, field_name: str) -> bool: if not is_dataclass(cls.__model__): return True dataclass_fields = cls.__model__.__dataclass_fields__ try: return dataclass_fields[field_name].init except KeyError: return True @classmethod def _get_type_from_type_engine(cls, type_engine: TypeEngine) -> type: if type(type_engine) in cls.get_sqlalchemy_types(): return type(type_engine) annotation: type try: annotation = type_engine.python_type except NotImplementedError: if not hasattr(type_engine, "impl"): msg = f"Unsupported type engine: {type_engine}.\nOverride get_sqlalchemy_types to support" raise ParameterException(msg) from None annotation = type_engine.impl.python_type # pyright: ignore[reportAttributeAccessIssue] constraints: SQLAlchemyConstraints = {} for type_, constraint_fields in cls.get_sqlalchemy_constraints().items(): if not isinstance(type_engine, type_): continue for sqlalchemy_field, constraint_field in constraint_fields.items(): if (value := getattr(type_engine, sqlalchemy_field, None)) is not None: constraints[constraint_field] = value # type: ignore[literal-required] if constraints: annotation = Annotated[annotation, Frozendict(constraints)] # type: ignore[assignment] return annotation @classmethod def get_type_from_column(cls, column: Column) -> type: annotation: type if isinstance(column.type, (ARRAY, postgresql.ARRAY)): item_type = cls._get_type_from_type_engine(column.type.item_type) annotation = list[item_type] # type: ignore[valid-type] else: annotation = cls._get_type_from_type_engine(column.type) if column.nullable: annotation = Union[annotation, None] # type: ignore[assignment] if column.computed: constraints: SQLAlchemyConstraints = {"computed": True} annotation = Annotated[annotation, Frozendict(constraints)] # type: ignore[assignment] return annotation @classmethod def get_type_from_collection_class( cls, collection_class: type[Collection[Any]] | Callable[[], Collection[Any]], entity_class: Any, ) -> type[Any]: annotation: type[Any] if isinstance(collection_class, type): if issubclass(collection_class, Mapping): annotation = dict[Any, entity_class] else: if not (duck_typed_as := duck_type_collection(collection_class)): msg = f"Cannot infer type from collection_class {collection_class}" raise ConfigurationException( msg, ) annotation = duck_typed_as[entity_class] # pyright: ignore[reportIndexIssue] else: annotation = dict[Any, entity_class] return annotation @classmethod def _get_relationship_type(cls, relationship: RelationshipProperty[Any]) -> type: class_ = relationship.entity.class_ annotation: type if relationship.uselist: collection_class = relationship.collection_class if collection_class is None: annotation = list[class_] # type: ignore[valid-type] else: annotation = cls.get_type_from_collection_class(collection_class, class_) else: annotation = class_ return annotation @classmethod def _get_association_proxy_type(cls, table: Mapper, proxy: AssociationProxy) -> type | None: target_collection = table.relationships.get(proxy.target_collection) if not target_collection: return None target_class = target_collection.entity.class_ target_attr = getattr(target_class, proxy.value_attr) if not target_attr: return None class_ = target_attr.entity.class_ return class_ if not target_collection.uselist else list[class_] # type: ignore[valid-type]
[docs] @classmethod def get_model_fields(cls) -> list[FieldMeta]: fields_meta: list[FieldMeta] = [] table: Mapper = inspect(cls.__model__) # type: ignore[assignment] fields_meta.extend( FieldMeta.from_type( annotation=cls.get_type_from_column(column), name=name, ) for name, column in table.columns.items() if cls.should_column_be_set(column) ) if cls.__set_relationships__: for name, relationship in table.relationships.items(): if not cls.should_dataclass_init_field(name): continue annotation = cls._get_relationship_type(relationship) fields_meta.append( FieldMeta.from_type( name=name, annotation=annotation, ) ) if cls.__set_association_proxy__: for name, attr in table.all_orm_descriptors.items(): if isinstance(attr, AssociationProxy): if not cls.should_dataclass_init_field(name): continue # Read-only proxies derive from the underlying relationship and shouldn't be set directly. if not getattr(attr, "creator", None): continue if annotation := cls._get_association_proxy_type(table, attr): # type: ignore[assignment] fields_meta.append( FieldMeta.from_type( name=name, annotation=annotation, ) ) return fields_meta
@classmethod def _get_sync_persistence(cls) -> SyncPersistenceProtocol[T]: if cls.__session__ is not None: session = cls.__session__() if callable(cls.__session__) else cls.__session__ if isinstance(cls.__session__, sessionmaker): cleanup_callback = session.close elif isinstance(cls.__session__, scoped_session): cleanup_callback = cls.__session__.remove else: cleanup_callback = None return SQLASyncPersistence( session, persistence_method=cls.__persistence_method__, cleanup_callback=cleanup_callback, ) return super()._get_sync_persistence() @classmethod def _get_async_persistence(cls) -> AsyncPersistenceProtocol[T]: if cls.__async_session__ is not None: session = cls.__async_session__() if callable(cls.__async_session__) else cls.__async_session__ if isinstance(cls.__async_session__, async_sessionmaker): cleanup_callback = session.close elif isinstance(cls.__async_session__, async_scoped_session): cleanup_callback = cls.__async_session__.remove else: cleanup_callback = None return SQLAASyncPersistence( session, persistence_method=cls.__persistence_method__, cleanup_callback=cleanup_callback, ) return super()._get_async_persistence()