From 6274ebaee535c436c5f08f0e27e158d9c61f3f27 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Thu, 26 Dec 2024 17:57:14 +0300 Subject: [PATCH 1/6] feat(sqla_factory): added __set_association_proxy__ attribute --- polyfactory/factories/sqlalchemy_factory.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/polyfactory/factories/sqlalchemy_factory.py b/polyfactory/factories/sqlalchemy_factory.py index 4b15ac52..cc2a3ccf 100644 --- a/polyfactory/factories/sqlalchemy_factory.py +++ b/polyfactory/factories/sqlalchemy_factory.py @@ -15,6 +15,7 @@ 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.orm import InstanceState, Mapper except ImportError as e: msg = "sqlalchemy is not installed" @@ -76,6 +77,8 @@ class SQLAlchemyFactory(Generic[T], BaseFactory[T]): """Configuration to consider columns with foreign keys as a field or not.""" __set_relationships__: ClassVar[bool] = False """Configuration to consider relationships property as a model field or not.""" + __set_association_proxy__: ClassVar[bool] = False + """Configuration to consider AssociationProxy property as a model field or not.""" __session__: ClassVar[Session | Callable[[], Session] | None] = None __async_session__: ClassVar[AsyncSession | Callable[[], AsyncSession] | None] = None @@ -85,6 +88,7 @@ class SQLAlchemyFactory(Generic[T], BaseFactory[T]): "__set_primary_key__", "__set_foreign_keys__", "__set_relationships__", + "__set_association_proxy__", ) @classmethod @@ -213,6 +217,23 @@ def get_model_fields(cls) -> list[FieldMeta]: random=cls.__random__, ), ) + if cls.__set_association_proxy__: + for name, attr in table.all_orm_descriptors.items(): + if isinstance(attr, AssociationProxy): + target_collection = table.relationships.get(attr.target_collection) + if target_collection: + target_class = target_collection.entity.class_ + target_attr = getattr(target_class, attr.value_attr) + if target_attr: + class_ = target_attr.entity.class_ + annotation = class_ if not target_collection.uselist else List[class_] # type: ignore[valid-type] + fields_meta.append( + FieldMeta.from_type( + name=name, + annotation=annotation, + random=cls.__random__, + ) + ) return fields_meta From 95d74ccc46f9724395a9b59df0b40cf2efa176de Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Wed, 15 Jan 2025 13:32:18 +0300 Subject: [PATCH 2/6] test(sqla_factory): added association_proxy tests for SQLAlchemy v1.4 and v2 --- .../test_association_proxy_v1.py | 83 +++++++++++++++++++ .../test_association_proxy_v2.py | 69 +++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/sqlalchemy_factory/test_association_proxy_v1.py create mode 100644 tests/sqlalchemy_factory/test_association_proxy_v2.py diff --git a/tests/sqlalchemy_factory/test_association_proxy_v1.py b/tests/sqlalchemy_factory/test_association_proxy_v1.py new file mode 100644 index 00000000..ac60aa6f --- /dev/null +++ b/tests/sqlalchemy_factory/test_association_proxy_v1.py @@ -0,0 +1,83 @@ +from typing import Optional + +import pytest +from sqlalchemy import Column, ForeignKey, Integer, String, __version__ +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.orm import relationship +from sqlalchemy.orm.decl_api import DeclarativeMeta, registry + +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory + +if __version__.startswith("2"): + pytest.skip(allow_module_level=True) + +_registry = registry() + + +class Base(metaclass=DeclarativeMeta): + __abstract__ = True + __allow_unmapped__ = True + + registry = _registry + metadata = _registry.metadata + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + name = Column(String) + + user_keyword_associations = relationship( + "UserKeywordAssociation", + back_populates="user", + ) + keywords = association_proxy( + "user_keyword_associations", "keyword", creator=lambda keyword_obj: UserKeywordAssociation(keyword=keyword_obj) + ) + + +class UserKeywordAssociation(Base): + __tablename__ = "user_keyword" + + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + keyword_id = Column(Integer, ForeignKey("keywords.id"), primary_key=True) + + user = relationship(User, back_populates="user_keyword_associations") + keyword = relationship("Keyword") + + # for prevent mypy error: Unexpected keyword argument "keyword" for "UserKeywordAssociation" [call-arg] + def __init__(self, keyword: Optional["Keyword"] = None): + self.keyword = keyword + + +class Keyword(Base): + __tablename__ = "keywords" + + id = Column(Integer, primary_key=True) + keyword = Column(String) + + +def test_association_proxy() -> None: + class UserFactory(SQLAlchemyFactory[User]): + __set_association_proxy__ = True + + user = UserFactory.build() + assert isinstance(user.keywords[0], Keyword) + assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) + + +async def test_complex_association_proxy() -> None: + class KeywordFactory(SQLAlchemyFactory[Keyword]): ... + + class ComplexUserFactory(SQLAlchemyFactory[User]): + __set_association_proxy__ = True + + keywords = KeywordFactory.batch(3) + + user = ComplexUserFactory.build() + assert isinstance(user, User) + assert isinstance(user.keywords[0], Keyword) + assert len(user.keywords) == 3 + assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) + assert len(user.user_keyword_associations) == 3 diff --git a/tests/sqlalchemy_factory/test_association_proxy_v2.py b/tests/sqlalchemy_factory/test_association_proxy_v2.py new file mode 100644 index 00000000..ca8caf13 --- /dev/null +++ b/tests/sqlalchemy_factory/test_association_proxy_v2.py @@ -0,0 +1,69 @@ +import pytest +from sqlalchemy import ForeignKey, __version__, orm +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.orm import Mapped, relationship + +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory + +if __version__.startswith("1"): + pytest.skip(allow_module_level=True) + + +class Base(orm.DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = orm.mapped_column(primary_key=True) + name: Mapped[str] + + user_keyword_associations: Mapped[list["UserKeywordAssociation"]] = relationship( + back_populates="user", + ) + keywords: AssociationProxy[list["Keyword"]] = association_proxy( + "user_keyword_associations", + "keyword", + creator=lambda keyword_obj: UserKeywordAssociation(keyword=keyword_obj), + ) + + +class UserKeywordAssociation(Base): + __tablename__ = "user_keyword" + user_id: Mapped[int] = orm.mapped_column(ForeignKey("users.id"), primary_key=True) + keyword_id: Mapped[int] = orm.mapped_column(ForeignKey("keywords.id"), primary_key=True) + + user: Mapped[User] = relationship(back_populates="user_keyword_associations") + keyword: Mapped["Keyword"] = relationship() + + +class Keyword(Base): + __tablename__ = "keywords" + id: Mapped[int] = orm.mapped_column(primary_key=True) + keyword: Mapped[str] + + +def test_association_proxy() -> None: + class UserFactory(SQLAlchemyFactory[User]): + __set_association_proxy__ = True + + user = UserFactory.build() + assert isinstance(user.keywords[0], Keyword) + assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) + + +async def test_complex_association_proxy() -> None: + class KeywordFactory(SQLAlchemyFactory[Keyword]): ... + + class ComplexUserFactory(SQLAlchemyFactory[User]): + __set_association_proxy__ = True + + keywords = KeywordFactory.batch(3) + + user = ComplexUserFactory.build() + assert isinstance(user, User) + assert isinstance(user.keywords[0], Keyword) + assert len(user.keywords) == 3 + assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) + assert len(user.user_keyword_associations) == 3 From 33d6a259421f2a896a18ac2bbafc7d7882650c78 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Wed, 15 Jan 2025 15:15:55 +0300 Subject: [PATCH 3/6] docs(sqla_factory): updated Usage Guide Configuration section with association_proxy --- .../sqlalchemy_factory/test_example_2.py | 4 +- .../test_example_association_proxy.py | 60 +++++++++++++++++++ .../library_factories/sqlalchemy_factory.rst | 32 +++++++--- 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 docs/examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py diff --git a/docs/examples/library_factories/sqlalchemy_factory/test_example_2.py b/docs/examples/library_factories/sqlalchemy_factory/test_example_2.py index 0218bb0d..b3e00d5b 100644 --- a/docs/examples/library_factories/sqlalchemy_factory/test_example_2.py +++ b/docs/examples/library_factories/sqlalchemy_factory/test_example_2.py @@ -32,12 +32,12 @@ class AuthorFactoryWithRelationship(SQLAlchemyFactory[Author]): __set_relationships__ = True -def test_sqla_factory_without_relationship() -> None: +def test_sqla_factory() -> None: author = AuthorFactory.build() assert author.books == [] -def test_sqla_factory() -> None: +def test_sqla_factory_with_relationship() -> None: author = AuthorFactoryWithRelationship.build() assert isinstance(author, Author) assert isinstance(author.books[0], Book) diff --git a/docs/examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py b/docs/examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py new file mode 100644 index 00000000..6064581f --- /dev/null +++ b/docs/examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory + + +class Base(DeclarativeBase): ... + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + + user_keyword_associations: Mapped[list["UserKeywordAssociation"]] = relationship( + back_populates="user", + ) + keywords: AssociationProxy[list["Keyword"]] = association_proxy( + "user_keyword_associations", + "keyword", + creator=lambda keyword_obj: UserKeywordAssociation(keyword=keyword_obj), + ) + + +class UserKeywordAssociation(Base): + __tablename__ = "user_keyword" + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + keyword_id: Mapped[int] = mapped_column(ForeignKey("keywords.id"), primary_key=True) + + user: Mapped[User] = relationship(back_populates="user_keyword_associations") + keyword: Mapped["Keyword"] = relationship() + + +class Keyword(Base): + __tablename__ = "keywords" + id: Mapped[int] = mapped_column(primary_key=True) + keyword: Mapped[str] + + +class UserFactory(SQLAlchemyFactory[User]): ... + + +class UserFactoryWithAssociation(SQLAlchemyFactory[User]): + __set_association_proxy__ = True + + +def test_sqla_factory() -> None: + user = UserFactory.build() + assert not user.user_keyword_associations + assert not user.keywords + + +def test_sqla_factory_with_association() -> None: + user = UserFactoryWithAssociation.build() + assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) + assert isinstance(user.keywords[0], Keyword) diff --git a/docs/usage/library_factories/sqlalchemy_factory.rst b/docs/usage/library_factories/sqlalchemy_factory.rst index ee72c9e9..826a7d65 100644 --- a/docs/usage/library_factories/sqlalchemy_factory.rst +++ b/docs/usage/library_factories/sqlalchemy_factory.rst @@ -1,5 +1,5 @@ SQLAlchemyFactory -=================== +================= Basic usage is like other factories @@ -10,21 +10,39 @@ Basic usage is like other factories .. note:: The examples here require SQLAlchemy 2 to be installed. The factory itself supports both 1.4 and 2. + Configuration ------------------------------- +------------- + +SQLAlchemyFactory allows to override some configuration attributes so that a described factory can use a behavior from SQLAlchemy ORM such as `relationship() `_ or `Association Proxy `_. + +Relationship +++++++++++++ -By default, relationships will not be set. This can be overridden via ``__set_relationships__``. +By default, ``__set_relationships__`` is set to ``False``. If it is ``True``, all fields with the SQLAlchemy `relationship() `_ will be included in the resulting mock dictionary created by ``build`` method. .. literalinclude:: /examples/library_factories/sqlalchemy_factory/test_example_2.py :caption: Setting relationships :language: python .. note:: - In general, foreign keys are not automatically generated by ``.build``. This can be resolved by setting the fields yourself and/or using ``create_sync``/ ``create_async`` so models can be added to a SQLA session so these are set. + In general, ForeignKey fields are automatically generated by ``build`` method because :class:`__set_foreign_keys__ ` is set to ``True`` by default. But their values can be overwritten by using ``create_sync``/ ``create_async`` methods, so SQLAlchemy ORM creates them. + +Association Proxy ++++++++++++++++++ + +By default, ``__set_association_proxy__`` is set to ``False``. If it is ``True``, all SQLAlchemy fields mapped to ORM `Association Proxy `_ class will be included in the resulting mock dictionary created by ``build`` method. + +.. literalinclude:: /examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py + :caption: Setting association_proxy + :language: python + +.. note:: + If ``__set_relationships__`` attribute is set to ``True``, the Polyfactory will create both fields from a particular SQLAlchemy model (association_proxy and its relationship), but eventually a relationship field will be overwritten by using ``create_sync``/ ``create_async`` methods via SQLAlchemy ORM with a proper instance from an Association Proxy relation. Persistence ------------------------------- +----------- A handler is provided to allow persistence. This can be used by setting ``__session__`` attribute on a factory. @@ -38,7 +56,7 @@ Similarly for ``__async_session__`` and ``create_async``. Adding global overrides ------------------------------- +----------------------- By combining the above and using other settings, a global base factory can be set up for other factories. @@ -48,5 +66,5 @@ By combining the above and using other settings, a global base factory can be se API reference ------------------------------- +------------- Full API docs are available :class:`here `. From 5096f1d5d3a3d1c74f0a622f239b5be6da0eeb99 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Tue, 14 Jan 2025 18:07:34 +0300 Subject: [PATCH 4/6] test(sqla_factory): hotfix python list to typing List --- tests/sqlalchemy_factory/test_association_proxy_v2.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sqlalchemy_factory/test_association_proxy_v2.py b/tests/sqlalchemy_factory/test_association_proxy_v2.py index ca8caf13..1f44d5e6 100644 --- a/tests/sqlalchemy_factory/test_association_proxy_v2.py +++ b/tests/sqlalchemy_factory/test_association_proxy_v2.py @@ -1,3 +1,5 @@ +from typing import List + import pytest from sqlalchemy import ForeignKey, __version__, orm from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy @@ -19,10 +21,10 @@ class User(Base): id: Mapped[int] = orm.mapped_column(primary_key=True) name: Mapped[str] - user_keyword_associations: Mapped[list["UserKeywordAssociation"]] = relationship( + user_keyword_associations: Mapped[List["UserKeywordAssociation"]] = relationship( back_populates="user", ) - keywords: AssociationProxy[list["Keyword"]] = association_proxy( + keywords: AssociationProxy[List["Keyword"]] = association_proxy( "user_keyword_associations", "keyword", creator=lambda keyword_obj: UserKeywordAssociation(keyword=keyword_obj), From f274e1d90ec2a3159e4a5623f3e22a2d7967e830 Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Fri, 17 Jan 2025 10:24:35 +0300 Subject: [PATCH 5/6] docs(sqla_factory): fix Usage Guide Configuration --- docs/usage/library_factories/sqlalchemy_factory.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/usage/library_factories/sqlalchemy_factory.rst b/docs/usage/library_factories/sqlalchemy_factory.rst index 826a7d65..b7ca70eb 100644 --- a/docs/usage/library_factories/sqlalchemy_factory.rst +++ b/docs/usage/library_factories/sqlalchemy_factory.rst @@ -19,26 +19,26 @@ SQLAlchemyFactory allows to override some configuration attributes so that a des Relationship ++++++++++++ -By default, ``__set_relationships__`` is set to ``False``. If it is ``True``, all fields with the SQLAlchemy `relationship() `_ will be included in the resulting mock dictionary created by ``build`` method. +By default, ``__set_relationships__`` is set to ``False``. If it is ``True``, all fields with the SQLAlchemy `relationship() `_ will be included in the result created by ``build`` method. .. literalinclude:: /examples/library_factories/sqlalchemy_factory/test_example_2.py :caption: Setting relationships :language: python .. note:: - In general, ForeignKey fields are automatically generated by ``build`` method because :class:`__set_foreign_keys__ ` is set to ``True`` by default. But their values can be overwritten by using ``create_sync``/ ``create_async`` methods, so SQLAlchemy ORM creates them. + If ``__set_relationships__ = True``, ForeignKey fields associated with relationship() will be automatically generated by ``build`` method because :class:`__set_foreign_keys__ ` is set to ``True`` by default. But their values will be overwritten by using ``create_sync``/ ``create_async`` methods, so SQLAlchemy ORM creates them. Association Proxy +++++++++++++++++ -By default, ``__set_association_proxy__`` is set to ``False``. If it is ``True``, all SQLAlchemy fields mapped to ORM `Association Proxy `_ class will be included in the resulting mock dictionary created by ``build`` method. +By default, ``__set_association_proxy__`` is set to ``False``. If it is ``True``, all SQLAlchemy fields mapped to ORM `Association Proxy `_ class will be included in the result created by ``build`` method. .. literalinclude:: /examples/library_factories/sqlalchemy_factory/test_example_association_proxy.py :caption: Setting association_proxy :language: python .. note:: - If ``__set_relationships__`` attribute is set to ``True``, the Polyfactory will create both fields from a particular SQLAlchemy model (association_proxy and its relationship), but eventually a relationship field will be overwritten by using ``create_sync``/ ``create_async`` methods via SQLAlchemy ORM with a proper instance from an Association Proxy relation. + If ``__set_relationships__ = True``, the Polyfactory will create both fields from a particular SQLAlchemy model (association_proxy and its relationship), but eventually a relationship field will be overwritten by using ``create_sync``/ ``create_async`` methods via SQLAlchemy ORM with a proper instance from an Association Proxy relation. Persistence From eee2c5b4cbb29ebfe0281b6c2a21bfc58a87b7aa Mon Sep 17 00:00:00 2001 From: Nikita Semenov Date: Fri, 17 Jan 2025 10:27:37 +0300 Subject: [PATCH 6/6] test(sqla_factory): fix association_proxy tests --- ..._proxy_v1.py => test_association_proxy.py} | 8 +-- .../test_association_proxy_v2.py | 71 ------------------- 2 files changed, 2 insertions(+), 77 deletions(-) rename tests/sqlalchemy_factory/{test_association_proxy_v1.py => test_association_proxy.py} (91%) delete mode 100644 tests/sqlalchemy_factory/test_association_proxy_v2.py diff --git a/tests/sqlalchemy_factory/test_association_proxy_v1.py b/tests/sqlalchemy_factory/test_association_proxy.py similarity index 91% rename from tests/sqlalchemy_factory/test_association_proxy_v1.py rename to tests/sqlalchemy_factory/test_association_proxy.py index ac60aa6f..b3badba8 100644 --- a/tests/sqlalchemy_factory/test_association_proxy_v1.py +++ b/tests/sqlalchemy_factory/test_association_proxy.py @@ -1,16 +1,12 @@ from typing import Optional -import pytest -from sqlalchemy import Column, ForeignKey, Integer, String, __version__ +from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import relationship from sqlalchemy.orm.decl_api import DeclarativeMeta, registry from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory -if __version__.startswith("2"): - pytest.skip(allow_module_level=True) - _registry = registry() @@ -67,7 +63,7 @@ class UserFactory(SQLAlchemyFactory[User]): assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) -async def test_complex_association_proxy() -> None: +def test_complex_association_proxy() -> None: class KeywordFactory(SQLAlchemyFactory[Keyword]): ... class ComplexUserFactory(SQLAlchemyFactory[User]): diff --git a/tests/sqlalchemy_factory/test_association_proxy_v2.py b/tests/sqlalchemy_factory/test_association_proxy_v2.py deleted file mode 100644 index 1f44d5e6..00000000 --- a/tests/sqlalchemy_factory/test_association_proxy_v2.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import List - -import pytest -from sqlalchemy import ForeignKey, __version__, orm -from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy -from sqlalchemy.orm import Mapped, relationship - -from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory - -if __version__.startswith("1"): - pytest.skip(allow_module_level=True) - - -class Base(orm.DeclarativeBase): - pass - - -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = orm.mapped_column(primary_key=True) - name: Mapped[str] - - user_keyword_associations: Mapped[List["UserKeywordAssociation"]] = relationship( - back_populates="user", - ) - keywords: AssociationProxy[List["Keyword"]] = association_proxy( - "user_keyword_associations", - "keyword", - creator=lambda keyword_obj: UserKeywordAssociation(keyword=keyword_obj), - ) - - -class UserKeywordAssociation(Base): - __tablename__ = "user_keyword" - user_id: Mapped[int] = orm.mapped_column(ForeignKey("users.id"), primary_key=True) - keyword_id: Mapped[int] = orm.mapped_column(ForeignKey("keywords.id"), primary_key=True) - - user: Mapped[User] = relationship(back_populates="user_keyword_associations") - keyword: Mapped["Keyword"] = relationship() - - -class Keyword(Base): - __tablename__ = "keywords" - id: Mapped[int] = orm.mapped_column(primary_key=True) - keyword: Mapped[str] - - -def test_association_proxy() -> None: - class UserFactory(SQLAlchemyFactory[User]): - __set_association_proxy__ = True - - user = UserFactory.build() - assert isinstance(user.keywords[0], Keyword) - assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) - - -async def test_complex_association_proxy() -> None: - class KeywordFactory(SQLAlchemyFactory[Keyword]): ... - - class ComplexUserFactory(SQLAlchemyFactory[User]): - __set_association_proxy__ = True - - keywords = KeywordFactory.batch(3) - - user = ComplexUserFactory.build() - assert isinstance(user, User) - assert isinstance(user.keywords[0], Keyword) - assert len(user.keywords) == 3 - assert isinstance(user.user_keyword_associations[0], UserKeywordAssociation) - assert len(user.user_keyword_associations) == 3