yaddd-sqlalchemy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110
+ .pdm.toml
111
+ .pdm-python
112
+ .pdm-build/
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # mypy
144
+ .mypy_cache/
145
+ .dmypy.json
146
+ dmypy.json
147
+
148
+ # Pyre type checker
149
+ .pyre/
150
+
151
+ # pytype static type analyzer
152
+ .pytype/
153
+
154
+ # Cython debug symbols
155
+ cython_debug/
156
+
157
+ # IDE
158
+ .idea
159
+ .vscode/!launch.json
160
+ .vscode/!tasks.json
161
+
162
+ # OS
163
+ .DS_Store
164
+
165
+ *_old/
File without changes
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: yaddd-sqlalchemy
3
+ Version: 0.1.0
4
+ Summary: SQLAlchemy integration for yaddd: SqlCrudRepository, SqlUnitOfWork, VOTypeDecorator
5
+ Project-URL: Homepage, https://github.com/netstuff/yaddd
6
+ Author-email: Yuri Andreev <andreev.jr@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Internet
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
20
+ Requires-Dist: yaddd>=0.1
21
+ Description-Content-Type: text/markdown
22
+
23
+ # yaddd-sqlalchemy
24
+ SQLAlchemy integration for [yaddd](https://github.com/pyDDD/yaddd).
25
+
26
+ - `SqlCrudRepository` — SQLAlchemy Core-based implementation of the `CrudRepository` port;
27
+ - `SqlUnitOfWork` — `UnitOfWork` over `AsyncSession`;
28
+ - `VOTypeDecorator` — `TypeDecorator` persisting `ValueObject` instances as raw values.
29
+
30
+ Wiring is explicit: instantiate in your composition root, no auto-discovery.
31
+ Port conformance is proven by the contract suites in `yaddd.testing`.
@@ -0,0 +1,9 @@
1
+ # yaddd-sqlalchemy
2
+ SQLAlchemy integration for [yaddd](https://github.com/pyDDD/yaddd).
3
+
4
+ - `SqlCrudRepository` — SQLAlchemy Core-based implementation of the `CrudRepository` port;
5
+ - `SqlUnitOfWork` — `UnitOfWork` over `AsyncSession`;
6
+ - `VOTypeDecorator` — `TypeDecorator` persisting `ValueObject` instances as raw values.
7
+
8
+ Wiring is explicit: instantiate in your composition root, no auto-discovery.
9
+ Port conformance is proven by the contract suites in `yaddd.testing`.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "yaddd-sqlalchemy"
3
+ description = "SQLAlchemy integration for yaddd: SqlCrudRepository, SqlUnitOfWork, VOTypeDecorator"
4
+ authors = [
5
+ {name = "Yuri Andreev", email = "andreev.jr@gmail.com"},
6
+ ]
7
+ license = 'MIT'
8
+ license-files = ['LICENSE']
9
+ classifiers = [
10
+ 'Development Status :: 4 - Beta',
11
+ 'Programming Language :: Python',
12
+ 'Programming Language :: Python :: 3.12',
13
+ 'Programming Language :: Python :: 3.13',
14
+ 'Programming Language :: Python :: 3.14',
15
+ 'Intended Audience :: Developers',
16
+ 'Operating System :: OS Independent',
17
+ 'Topic :: Software Development :: Libraries :: Python Modules',
18
+ 'Topic :: Internet',
19
+ ]
20
+ requires-python = '>=3.12'
21
+ dependencies = [
22
+ "yaddd>=0.1",
23
+ "sqlalchemy[asyncio]>=2.0",
24
+ ]
25
+ version = "0.1.0"
26
+ readme = 'README.md'
27
+
28
+ [project.urls]
29
+ Homepage = 'https://github.com/netstuff/yaddd'
30
+
31
+ [tool.uv.sources]
32
+ yaddd = { workspace = true }
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/yaddd_sqlalchemy"]
@@ -0,0 +1,8 @@
1
+ """SQLAlchemy integration for yaddd."""
2
+
3
+ from yaddd_sqlalchemy.repositories import SqlCrudRepository
4
+ from yaddd_sqlalchemy.uow import SqlUnitOfWork
5
+ from yaddd_sqlalchemy.value_objects import VOTypeDecorator
6
+
7
+
8
+ __all__ = ["SqlCrudRepository", "SqlUnitOfWork", "VOTypeDecorator"]
File without changes
@@ -0,0 +1,87 @@
1
+ """SQLAlchemy Core implementation of the ``CrudRepository`` port."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, ClassVar
5
+
6
+ from sqlalchemy import Column, Table, delete, insert, select, update
7
+ from sqlalchemy.engine import Row
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from yaddd.domain.entities import AggregateRoot, PrimaryKey
10
+ from yaddd.domain.repositories import CrudRepository
11
+ from yaddd.exceptions import EntityNotFoundError
12
+
13
+
14
+ __all__ = ["SqlCrudRepository"]
15
+
16
+
17
+ class SqlCrudRepository[T: AggregateRoot](CrudRepository[T], ABC):
18
+ """SQLAlchemy Core-based repository for a single aggregate type.
19
+
20
+ No ORM declarative opinions: the subclass declares a Core ``table`` and
21
+ the mapping between rows and aggregates. The repository never commits —
22
+ transaction boundaries belong to ``SqlUnitOfWork``.
23
+
24
+ Primary keys must be single-column, and ``pk`` values must be natively
25
+ bindable by the primary-key column type (use a ``TypeDecorator`` column
26
+ or string/integer primary keys otherwise).
27
+
28
+ Example:
29
+ class OrderRepository(SqlCrudRepository[Order]):
30
+ table = orders_table
31
+
32
+ def to_domain(self, row: Row[Any]) -> Order:
33
+ return Order(id=row.id, status=row.status, total=row.total)
34
+
35
+ def to_row(self, instance: Order) -> dict[str, Any]:
36
+ return {"id": instance.pk, "status": instance.status, "total": instance.total}
37
+ """
38
+
39
+ table: ClassVar[Table]
40
+
41
+ def __init__(self, session: AsyncSession) -> None:
42
+ self._session = session
43
+
44
+ @abstractmethod
45
+ def to_domain(self, row: Row[Any]) -> T:
46
+ """Reconstitute the aggregate from a table row."""
47
+
48
+ @abstractmethod
49
+ def to_row(self, instance: T) -> dict[str, Any]:
50
+ """Map the aggregate to a dict of column values."""
51
+
52
+ @property
53
+ def _pk_column(self) -> Column[Any]:
54
+ columns = list(self.table.primary_key.columns)
55
+ if len(columns) != 1:
56
+ raise NotImplementedError("SqlCrudRepository supports single-column primary keys only")
57
+ return columns[0]
58
+
59
+ async def get(self, pk: PrimaryKey) -> T | None:
60
+ result = await self._session.execute(select(self.table).where(self._pk_column == pk))
61
+ row = result.first()
62
+ return None if row is None else self.to_domain(row)
63
+
64
+ async def read(self, filter: dict[str, Any] | None = None, *, slice: tuple[int, int] = (0, 0)) -> list[T]:
65
+ stmt = select(self.table)
66
+ for key, value in (filter or {}).items():
67
+ stmt = stmt.where(self.table.c[key] == value)
68
+ offset, limit = slice
69
+ stmt = stmt.offset(offset)
70
+ if limit:
71
+ stmt = stmt.limit(limit)
72
+ result = await self._session.execute(stmt)
73
+ return [self.to_domain(row) for row in result.all()]
74
+
75
+ async def create(self, instance: T) -> T:
76
+ await self._session.execute(insert(self.table).values(**self.to_row(instance)))
77
+ return instance
78
+
79
+ async def update(self, instance: T) -> T:
80
+ if await self.get(instance.pk) is None:
81
+ raise EntityNotFoundError(f"{type(instance).__name__}({instance.pk})")
82
+ stmt = update(self.table).where(self._pk_column == instance.pk).values(**self.to_row(instance))
83
+ await self._session.execute(stmt)
84
+ return instance
85
+
86
+ async def delete(self, pk: PrimaryKey) -> None:
87
+ await self._session.execute(delete(self.table).where(self._pk_column == pk))
@@ -0,0 +1,47 @@
1
+ """``UnitOfWork`` implementation over a SQLAlchemy ``AsyncSession``."""
2
+
3
+ from types import TracebackType
4
+ from typing import Self
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from yaddd.application.uow import UnitOfWork
8
+
9
+
10
+ __all__ = ["SqlUnitOfWork"]
11
+
12
+
13
+ class SqlUnitOfWork(UnitOfWork):
14
+ """Transactional boundary over a single ``AsyncSession``.
15
+
16
+ ``commit()`` flushes and commits the session. Leaving the context without
17
+ a commit — or with an exception — rolls back; the session is always
18
+ closed on exit.
19
+ """
20
+
21
+ def __init__(self, session: AsyncSession) -> None:
22
+ self._session = session
23
+ self._committed = False
24
+
25
+ async def __aenter__(self) -> Self:
26
+ return self
27
+
28
+ async def __aexit__(
29
+ self,
30
+ exc_type: type[BaseException] | None,
31
+ exc_value: BaseException | None,
32
+ traceback: TracebackType | None,
33
+ ) -> None:
34
+ try:
35
+ if exc_type is not None or not self._committed:
36
+ await self.rollback()
37
+ finally:
38
+ await self._session.close()
39
+
40
+ async def commit(self) -> None:
41
+ """Persist all changes made within the unit of work."""
42
+ await self._session.commit()
43
+ self._committed = True
44
+
45
+ async def rollback(self) -> None:
46
+ """Discard all changes made within the unit of work."""
47
+ await self._session.rollback()
@@ -0,0 +1,33 @@
1
+ """SQLAlchemy type decorator persisting ``ValueObject`` instances."""
2
+
3
+ from typing import Any, ClassVar
4
+
5
+ from sqlalchemy.engine import Dialect
6
+ from sqlalchemy.types import TypeDecorator
7
+ from yaddd.domain.value_object import ValueObject
8
+
9
+
10
+ __all__ = ["VOTypeDecorator"]
11
+
12
+
13
+ class VOTypeDecorator(TypeDecorator[ValueObject[Any]]):
14
+ """Persist a ``ValueObject`` as its raw value and rebuild it on load.
15
+
16
+ Subclass, declare ``vo_class`` and pick the storage type via ``impl``::
17
+
18
+ class EmailType(VOTypeDecorator):
19
+ impl = String(255)
20
+ cache_ok = True
21
+ vo_class = Email
22
+
23
+ Binding unwraps ``value.value``; loading revalidates through
24
+ ``vo_class(value)``. ``None`` passes through untouched in both directions.
25
+ """
26
+
27
+ vo_class: ClassVar[type[ValueObject[Any]]]
28
+
29
+ def process_bind_param(self, value: ValueObject[Any] | None, dialect: Dialect) -> Any:
30
+ return None if value is None else value.value
31
+
32
+ def process_result_value(self, value: Any, dialect: Dialect) -> ValueObject[Any] | None:
33
+ return None if value is None else self.vo_class(value)
@@ -0,0 +1,54 @@
1
+ from collections.abc import AsyncIterator
2
+ from typing import Any
3
+ from uuid import uuid4
4
+
5
+ import pytest
6
+ from sqlalchemy import Column, Integer, MetaData, String, Table
7
+ from sqlalchemy.engine import Row
8
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
9
+
10
+ from yaddd.domain import AggregateRoot
11
+ from yaddd_sqlalchemy import SqlCrudRepository
12
+
13
+
14
+ metadata = MetaData()
15
+
16
+ orders_table = Table(
17
+ "orders",
18
+ metadata,
19
+ Column("id", String(32), primary_key=True),
20
+ Column("status", String(16), nullable=False),
21
+ Column("total", Integer, nullable=False),
22
+ )
23
+
24
+
25
+ class Order(AggregateRoot):
26
+ id: str
27
+ status: str
28
+ total: int
29
+
30
+
31
+ class OrderRepository(SqlCrudRepository[Order]):
32
+ table = orders_table
33
+
34
+ def to_domain(self, row: Row[Any]) -> Order:
35
+ return Order(id=str(row.id), status=str(row.status), total=int(row.total))
36
+
37
+ def to_row(self, instance: Order) -> dict[str, Any]:
38
+ return {"id": instance.pk, "status": instance.status, "total": instance.total}
39
+
40
+
41
+ @pytest.fixture
42
+ async def engine() -> AsyncIterator[AsyncEngine]:
43
+ engine = create_async_engine("sqlite+aiosqlite://")
44
+ async with engine.begin() as connection:
45
+ await connection.run_sync(metadata.create_all)
46
+ yield engine
47
+ await engine.dispose()
48
+
49
+
50
+ @pytest.fixture
51
+ async def session(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
52
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
53
+ async with session_factory() as session:
54
+ yield session
@@ -0,0 +1,33 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+ from uuid import uuid4
4
+
5
+ import pytest
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from yaddd.domain import AggregateRoot, CrudRepository
9
+ from yaddd.testing import CrudRepositoryContract
10
+
11
+ from conftest import Order, OrderRepository
12
+
13
+
14
+ class TestSqlCrudRepository(CrudRepositoryContract):
15
+ """The contract suite proves port conformance against in-memory sqlite."""
16
+
17
+ @pytest.fixture
18
+ def repo(self, session: AsyncSession) -> CrudRepository[Order]:
19
+ return OrderRepository(session)
20
+
21
+ @pytest.fixture
22
+ def aggregate_factory(self) -> Callable[[], Order]:
23
+ def make() -> Order:
24
+ token = uuid4().hex
25
+ return Order(id=token, status=f"status-{token[:8]}", total=int(token[:6], 16) % 1000)
26
+
27
+ return make
28
+
29
+
30
+ async def test_get_missing_returns_none_for_unknown_pk(session: AsyncSession):
31
+ repo: CrudRepository[Any] = OrderRepository(session)
32
+
33
+ assert await repo.get("no-such-id") is None
@@ -0,0 +1,65 @@
1
+ from collections.abc import Awaitable, Callable
2
+
3
+ import pytest
4
+ from sqlalchemy import select
5
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
6
+
7
+ from yaddd.application.uow import UnitOfWork
8
+ from yaddd.testing import UnitOfWorkContract
9
+ from yaddd_sqlalchemy import SqlUnitOfWork
10
+
11
+ from conftest import Order, OrderRepository, orders_table
12
+
13
+
14
+ class TestSqlUnitOfWork(UnitOfWorkContract):
15
+ @pytest.fixture
16
+ def uow(self, session: AsyncSession) -> UnitOfWork:
17
+ return SqlUnitOfWork(session)
18
+
19
+ @pytest.fixture
20
+ def assert_rolled_back(self, session: AsyncSession) -> Callable[[], Awaitable[None]]:
21
+ async def check() -> None:
22
+ assert not session.in_transaction()
23
+ assert not session.new and not session.dirty
24
+
25
+ return check
26
+
27
+
28
+ async def test_commit_persists_changes(engine: AsyncEngine, session: AsyncSession):
29
+ async with SqlUnitOfWork(session) as uow:
30
+ await OrderRepository(session).create(Order(id="order-1", status="new", total=10))
31
+ await uow.commit()
32
+
33
+ async with AsyncSession(engine) as verify:
34
+ result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-1"))
35
+ assert result.first() is not None
36
+
37
+
38
+ async def test_exception_inside_context_rolls_back(engine: AsyncEngine, session: AsyncSession):
39
+ with pytest.raises(RuntimeError, match="boom"):
40
+ async with SqlUnitOfWork(session):
41
+ await OrderRepository(session).create(Order(id="order-2", status="new", total=10))
42
+ raise RuntimeError("boom")
43
+
44
+ async with AsyncSession(engine) as verify:
45
+ result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-2"))
46
+ assert result.first() is None
47
+
48
+
49
+ async def test_exit_without_commit_rolls_back(engine: AsyncEngine, session: AsyncSession):
50
+ async with SqlUnitOfWork(session):
51
+ await OrderRepository(session).create(Order(id="order-3", status="new", total=10))
52
+
53
+ async with AsyncSession(engine) as verify:
54
+ result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-3"))
55
+ assert result.first() is None
56
+
57
+
58
+ async def test_rollback_discards_pending_changes(engine: AsyncEngine, session: AsyncSession):
59
+ async with SqlUnitOfWork(session) as uow:
60
+ await OrderRepository(session).create(Order(id="order-4", status="new", total=10))
61
+ await uow.rollback()
62
+
63
+ async with AsyncSession(engine) as verify:
64
+ result = await verify.execute(select(orders_table).where(orders_table.c.id == "order-4"))
65
+ assert result.first() is None
@@ -0,0 +1,61 @@
1
+ import pytest
2
+ from sqlalchemy import Column, MetaData, String, Table, insert, select
3
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
4
+
5
+ from yaddd.domain import ValueObject
6
+ from yaddd.exceptions import ValidationError
7
+ from yaddd_sqlalchemy import VOTypeDecorator
8
+
9
+
10
+ class Email(ValueObject[str]):
11
+ @classmethod
12
+ def validate(cls, value: str) -> str:
13
+ if "@" not in value:
14
+ raise ValidationError(f"not an email: {value}")
15
+ return value.lower()
16
+
17
+
18
+ class EmailType(VOTypeDecorator):
19
+ impl = String(255)
20
+ cache_ok = True
21
+ vo_class = Email
22
+
23
+
24
+ contacts_metadata = MetaData()
25
+
26
+ contacts_table = Table(
27
+ "contacts",
28
+ contacts_metadata,
29
+ Column("id", String(32), primary_key=True),
30
+ Column("email", EmailType, nullable=True),
31
+ )
32
+
33
+
34
+ @pytest.fixture
35
+ async def session() -> AsyncSession:
36
+ engine = create_async_engine("sqlite+aiosqlite://")
37
+ async with engine.begin() as connection:
38
+ await connection.run_sync(contacts_metadata.create_all)
39
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
40
+ async with session_factory() as session:
41
+ yield session
42
+ await engine.dispose()
43
+
44
+
45
+ async def test_value_object_round_trip(session: AsyncSession):
46
+ await session.execute(insert(contacts_table).values(id="c-1", email=Email("User@Example.com")))
47
+ await session.commit()
48
+
49
+ result = await session.execute(select(contacts_table.c.email).where(contacts_table.c.id == "c-1"))
50
+ loaded = result.scalar_one()
51
+
52
+ assert isinstance(loaded, Email)
53
+ assert loaded == Email("user@example.com")
54
+
55
+
56
+ async def test_none_round_trip(session: AsyncSession):
57
+ await session.execute(insert(contacts_table).values(id="c-2", email=None))
58
+ await session.commit()
59
+
60
+ result = await session.execute(select(contacts_table.c.email).where(contacts_table.c.id == "c-2"))
61
+ assert result.scalar_one() is None