csrd-repository 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.
- csrd_repository-0.1.0/.gitignore +217 -0
- csrd_repository-0.1.0/PKG-INFO +13 -0
- csrd_repository-0.1.0/README.md +23 -0
- csrd_repository-0.1.0/pyproject.toml +28 -0
- csrd_repository-0.1.0/src/csrd/repository/__init__.py +20 -0
- csrd_repository-0.1.0/src/csrd/repository/_base_repository.py +68 -0
- csrd_repository-0.1.0/src/csrd/repository/_database_adapter.py +140 -0
- csrd_repository-0.1.0/src/csrd/repository/execute_result.py +14 -0
- csrd_repository-0.1.0/src/csrd/repository/protocols.py +44 -0
- csrd_repository-0.1.0/src/csrd/repository/py.typed +0 -0
- csrd_repository-0.1.0/src/csrd/repository/sqlite_adapter.py +164 -0
- csrd_repository-0.1.0/src/csrd/repository/types.py +15 -0
- csrd_repository-0.1.0/src/csrd/repository/utils.py +15 -0
- csrd_repository-0.1.0/tests/test_sqlite_adapter.py +138 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
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
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-repository
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Base repository and database adapter abstractions
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/repository
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: aiosqlite<1,>=0.21
|
|
11
|
+
Requires-Dist: csrd-models
|
|
12
|
+
Provides-Extra: sql
|
|
13
|
+
Requires-Dist: sqlalchemy<3,>=2.0; extra == 'sql'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# csrd-repository
|
|
2
|
+
|
|
3
|
+
Base repository and database adapter abstractions for FastAPI microservices.
|
|
4
|
+
|
|
5
|
+
**Package**: `csrd.repository` · **Import**: `from csrd.repository import BaseRepository, SQLiteAdapter`
|
|
6
|
+
|
|
7
|
+
## What's included
|
|
8
|
+
|
|
9
|
+
- `BaseRepository` — abstract repository with model parsing (requires an adapter)
|
|
10
|
+
- `ABCDatabaseAdapter` / `DBProtocol` — async database adapter abstraction with lifecycle (`connect()` / `close()` / `async with`)
|
|
11
|
+
- `SQLiteAdapter` — async SQLite implementation via aiosqlite (persistent connection, atomic upsert)
|
|
12
|
+
- `ExecuteResult` — immutable snapshot of query metadata (replaces raw cursor return)
|
|
13
|
+
- Optional `sqlalchemy` integration for query building (`pip install csrd-repository[sql]`)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv pip install "csrd-repository @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/repository"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
- `csrd-models` (Tier 2)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-repository"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Base repository and database adapter abstractions"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiosqlite>=0.21,<1",
|
|
9
|
+
"csrd-models",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
sql = ["sqlalchemy>=2.0,<3"]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
csrd-models = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
20
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/repository"
|
|
21
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/csrd"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from ._base_repository import BaseRepository
|
|
2
|
+
from ._database_adapter import ABCDatabaseAdapter, DBProtocol
|
|
3
|
+
from .execute_result import ExecuteResult
|
|
4
|
+
from .protocols import CursorLike, RowLike
|
|
5
|
+
from .sqlite_adapter import SQLiteAdapter
|
|
6
|
+
from .types import DBParams, DBQuery
|
|
7
|
+
from .utils import unpack_params
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ABCDatabaseAdapter",
|
|
11
|
+
"BaseRepository",
|
|
12
|
+
"CursorLike",
|
|
13
|
+
"DBParams",
|
|
14
|
+
"DBProtocol",
|
|
15
|
+
"DBQuery",
|
|
16
|
+
"ExecuteResult",
|
|
17
|
+
"RowLike",
|
|
18
|
+
"SQLiteAdapter",
|
|
19
|
+
"unpack_params",
|
|
20
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from csrd.models.model_parser import ModelParserMixin
|
|
5
|
+
|
|
6
|
+
from ._database_adapter import DBProtocol
|
|
7
|
+
from .types import DBQuery
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _compile(query: DBQuery, params: dict[str, Any] | None) -> tuple[str, dict[str, Any] | None]:
|
|
11
|
+
"""Convert a query (raw SQL *or* SQLAlchemy Executable) into (sql_string, params)."""
|
|
12
|
+
if isinstance(query, str):
|
|
13
|
+
return query, params
|
|
14
|
+
|
|
15
|
+
# SQLAlchemy Executable — compile to SQL + extract bound params
|
|
16
|
+
compiled = query.compile(compile_kwargs={"literal_binds": False})
|
|
17
|
+
sql = str(compiled)
|
|
18
|
+
compiled_params = dict(compiled.params) if compiled.params else {}
|
|
19
|
+
if params:
|
|
20
|
+
compiled_params.update(params)
|
|
21
|
+
return sql, compiled_params or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseRepository(ModelParserMixin):
|
|
25
|
+
_adapter: DBProtocol
|
|
26
|
+
|
|
27
|
+
def __init__(self, adapter: DBProtocol):
|
|
28
|
+
if adapter is None:
|
|
29
|
+
raise TypeError("BaseRepository requires a database adapter — got None")
|
|
30
|
+
self._adapter = adapter
|
|
31
|
+
super().__init__(extractor=self._adapter.extractor)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def extract(self):
|
|
35
|
+
return self._adapter.extractor.extract
|
|
36
|
+
|
|
37
|
+
async def fetch_one(self, query: DBQuery, params: dict[str, Any] | None = None) -> Any | None:
|
|
38
|
+
sql, resolved = _compile(query, params)
|
|
39
|
+
raw = await self._adapter.fetch_one(sql, resolved)
|
|
40
|
+
return self._resolve_payload(raw)
|
|
41
|
+
|
|
42
|
+
async def fetch_all(
|
|
43
|
+
self, query: DBQuery, params: dict[str, Any] | None = None
|
|
44
|
+
) -> Sequence[Any]:
|
|
45
|
+
sql, resolved = _compile(query, params)
|
|
46
|
+
raw = await self._adapter.fetch_all(sql, resolved)
|
|
47
|
+
result = self._resolve_payload(raw)
|
|
48
|
+
return list(result) if isinstance(result, (list, tuple)) else [result]
|
|
49
|
+
|
|
50
|
+
async def execute(self, query: DBQuery, params: dict[str, Any] | None = None) -> Any:
|
|
51
|
+
sql, resolved = _compile(query, params)
|
|
52
|
+
return await self._adapter.execute(sql, resolved)
|
|
53
|
+
|
|
54
|
+
async def execute_returning(self, query: DBQuery, params: dict[str, Any] | None = None) -> Any:
|
|
55
|
+
sql, resolved = _compile(query, params)
|
|
56
|
+
return await self._adapter.execute_returning(sql, resolved)
|
|
57
|
+
|
|
58
|
+
async def insert(self, table: str, values: dict[str, Any], *args, **kwargs) -> Any:
|
|
59
|
+
return await self._adapter.insert(table, values, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
async def update(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
62
|
+
return await self._adapter.update(table, values, where)
|
|
63
|
+
|
|
64
|
+
async def upsert(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
65
|
+
return await self._adapter.upsert(table, values, where)
|
|
66
|
+
|
|
67
|
+
async def delete(self, table: str, where: dict[str, Any]) -> int:
|
|
68
|
+
return await self._adapter.delete(table, where)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any, Protocol, Self
|
|
5
|
+
|
|
6
|
+
from csrd.models.model_parser import PayloadExtractor
|
|
7
|
+
|
|
8
|
+
from .execute_result import ExecuteResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DBProtocol(Protocol):
|
|
12
|
+
"""Protocol defining a pluggable interface for database operations.
|
|
13
|
+
|
|
14
|
+
Implementations must support an async lifecycle (``connect`` / ``close``)
|
|
15
|
+
and the standard query / mutation methods.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def extractor(self) -> PayloadExtractor:
|
|
20
|
+
"""The extractor used to parse raw DB rows into structured data."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
async def connect(self) -> None:
|
|
24
|
+
"""Open the underlying database connection (or pool)."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
async def close(self) -> None:
|
|
28
|
+
"""Close the underlying database connection (or pool)."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
async def fetch_one(self, query: str, params: dict[str, Any] | None = None) -> dict | None:
|
|
32
|
+
"""Fetch a single row from the database."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
async def fetch_all(self, query: str, params: dict[str, Any] | None = None) -> Sequence[dict]:
|
|
36
|
+
"""Fetch multiple rows from the database."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def execute(self, query: str, params: dict[str, Any] | None = None) -> int:
|
|
40
|
+
"""Execute a DML statement and return the number of rows affected."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
async def execute_returning(
|
|
44
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
45
|
+
) -> ExecuteResult:
|
|
46
|
+
"""Execute a statement and return an ``ExecuteResult`` with metadata."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
async def insert(self, table: str, values: dict[str, Any]) -> Any:
|
|
50
|
+
"""Insert a new row into the specified table."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
async def update(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
54
|
+
"""Update existing rows in the table matching the condition."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def upsert(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
58
|
+
"""Insert or update a row depending on whether the condition is met."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
async def delete(self, table: str, where: dict[str, Any]) -> int:
|
|
62
|
+
"""Delete rows matching the condition."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ABCDatabaseAdapter(ABC, DBProtocol):
|
|
67
|
+
"""Abstract base for concrete database adapters.
|
|
68
|
+
|
|
69
|
+
Subclasses must implement ``connect``, ``close``, and all query methods.
|
|
70
|
+
The ``async with`` context-manager protocol is provided for free.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_dsn: str
|
|
74
|
+
_extractor: PayloadExtractor
|
|
75
|
+
|
|
76
|
+
def __init__(self, dsn: str, extractor: PayloadExtractor):
|
|
77
|
+
self._dsn = dsn
|
|
78
|
+
self._extractor = extractor
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def extractor(self) -> PayloadExtractor:
|
|
82
|
+
return self._extractor
|
|
83
|
+
|
|
84
|
+
# ── Lifecycle ────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
async def connect(self) -> None:
|
|
88
|
+
"""Open the underlying connection (or pool). Idempotent."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Close the underlying connection (or pool). Idempotent."""
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self) -> Self:
|
|
95
|
+
await self.connect()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(
|
|
99
|
+
self,
|
|
100
|
+
exc_type: type[BaseException] | None,
|
|
101
|
+
exc_val: BaseException | None,
|
|
102
|
+
exc_tb: TracebackType | None,
|
|
103
|
+
) -> None:
|
|
104
|
+
await self.close()
|
|
105
|
+
|
|
106
|
+
# ── Query interface ──────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def fetch_one(self, query: str, params: dict[str, Any] | None = None) -> dict | None:
|
|
110
|
+
"""Fetch a single row from the database."""
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
async def fetch_all(self, query: str, params: dict[str, Any] | None = None) -> Sequence[dict]:
|
|
114
|
+
"""Fetch multiple rows from the database."""
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
async def execute(self, query: str, params: dict[str, Any] | None = None) -> int:
|
|
118
|
+
"""Execute a DML statement and return the number of rows affected."""
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
async def execute_returning(
|
|
122
|
+
self, query: str, params: dict[str, Any] | None = None
|
|
123
|
+
) -> ExecuteResult:
|
|
124
|
+
"""Execute a statement and return an ``ExecuteResult`` with metadata."""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
async def insert(self, table: str, values: dict[str, Any]) -> Any:
|
|
128
|
+
"""Insert a new row into the specified table."""
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
async def update(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
132
|
+
"""Update existing rows in the table matching the condition."""
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
async def upsert(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
136
|
+
"""Insert or update a row depending on whether the condition is met."""
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
async def delete(self, table: str, where: dict[str, Any]) -> int:
|
|
140
|
+
"""Delete rows matching the condition."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True, slots=True)
|
|
5
|
+
class ExecuteResult:
|
|
6
|
+
"""Immutable snapshot of metadata from an executed SQL statement.
|
|
7
|
+
|
|
8
|
+
Returned by ``execute_returning`` so callers can inspect ``lastrowid``
|
|
9
|
+
and ``rowcount`` without holding a reference to a raw database cursor
|
|
10
|
+
(which may be invalidated when the connection or transaction closes).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
rowcount: int
|
|
14
|
+
lastrowid: int | None = None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Backend-agnostic protocols for database cursor and row objects."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class CursorLike(Protocol):
|
|
9
|
+
"""Backend-agnostic contract for DB cursor-like objects."""
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def rowcount(self) -> int:
|
|
13
|
+
"""Number of rows affected by last operation."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def lastrowid(self) -> int | None:
|
|
18
|
+
"""ID of the last inserted row, if applicable."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def __getitem__(self, key: Any) -> Any:
|
|
22
|
+
"""Support for key or index access to rows."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def __iter__(self) -> Iterator[Any]:
|
|
26
|
+
"""Support for iteration over rows."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class RowLike(Protocol):
|
|
32
|
+
"""Represents a single row from a database cursor result."""
|
|
33
|
+
|
|
34
|
+
def __getitem__(self, key: Any) -> Any:
|
|
35
|
+
"""Allows key or index-based access to column values."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def keys(self) -> list[str]:
|
|
39
|
+
"""Returns a list of column names for key-based access."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def __iter__(self) -> Iterator[Any]:
|
|
43
|
+
"""Allows iteration over column values."""
|
|
44
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import aiosqlite
|
|
5
|
+
|
|
6
|
+
from csrd.models import BaseModel
|
|
7
|
+
from csrd.models.model_parser import PayloadExtractor
|
|
8
|
+
|
|
9
|
+
from ._database_adapter import ABCDatabaseAdapter
|
|
10
|
+
from .execute_result import ExecuteResult
|
|
11
|
+
from .types import DBParams
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SQLiteExtractor(PayloadExtractor):
|
|
15
|
+
def extract(self, source: Any) -> dict | list[dict]:
|
|
16
|
+
"""
|
|
17
|
+
Extracts payload from raw SQLite rows. Assumes source is either:
|
|
18
|
+
- A single row as a dict
|
|
19
|
+
- A list of rows (each as a dict)
|
|
20
|
+
- Already a dict/list
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(source, list):
|
|
23
|
+
return [
|
|
24
|
+
{key: row[key] for key in row.keys()} # noqa: SIM118 — sqlite3.Row iterates values, not keys
|
|
25
|
+
for row in source
|
|
26
|
+
if hasattr(row, "keys")
|
|
27
|
+
]
|
|
28
|
+
if hasattr(source, "keys") and hasattr(source, "__getitem__"):
|
|
29
|
+
# Row-like object from SQLite with keys
|
|
30
|
+
return {key: source[key] for key in source.keys()} # noqa: SIM118
|
|
31
|
+
if isinstance(source, tuple) and hasattr(source, "description"):
|
|
32
|
+
# Raw cursor tuples with description - unsupported in this extractor
|
|
33
|
+
raise ValueError("Raw tuples with description unsupported. Use dict_factory.")
|
|
34
|
+
return dict(source) # type: ignore[call-overload]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SQLiteAdapter(ABCDatabaseAdapter):
|
|
38
|
+
"""Async SQLite adapter with a persistent connection.
|
|
39
|
+
|
|
40
|
+
Use as an async context manager or call ``connect()`` / ``close()``
|
|
41
|
+
explicitly::
|
|
42
|
+
|
|
43
|
+
async with SQLiteAdapter("app.db") as db:
|
|
44
|
+
row = await db.fetch_one("SELECT ...")
|
|
45
|
+
|
|
46
|
+
The adapter opens a **single** ``aiosqlite`` connection and reuses it
|
|
47
|
+
for all queries. Call ``close()`` (or exit the context manager) when
|
|
48
|
+
the application shuts down.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_db: aiosqlite.Connection | None
|
|
52
|
+
|
|
53
|
+
def __init__(self, db_path: str | None = None, *, extractor: PayloadExtractor | None = None):
|
|
54
|
+
"""Initialise with a file path or ``:memory:`` (default)."""
|
|
55
|
+
super().__init__(dsn=db_path or ":memory:", extractor=extractor or SQLiteExtractor())
|
|
56
|
+
self._db = None
|
|
57
|
+
|
|
58
|
+
# ── Lifecycle ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
async def connect(self) -> None:
|
|
61
|
+
"""Open the SQLite connection. Idempotent."""
|
|
62
|
+
if self._db is not None:
|
|
63
|
+
return
|
|
64
|
+
self._db = await aiosqlite.connect(self._dsn)
|
|
65
|
+
self._db.row_factory = aiosqlite.Row
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""Close the SQLite connection. Idempotent."""
|
|
69
|
+
if self._db is not None:
|
|
70
|
+
await self._db.close()
|
|
71
|
+
self._db = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def _connection(self) -> aiosqlite.Connection:
|
|
75
|
+
"""Return the live connection or raise if not connected."""
|
|
76
|
+
if self._db is None:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
"SQLiteAdapter is not connected. "
|
|
79
|
+
"Call await adapter.connect() or use 'async with adapter:'."
|
|
80
|
+
)
|
|
81
|
+
return self._db
|
|
82
|
+
|
|
83
|
+
# ── Query interface ──────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async def fetch_one(self, query: str, params: DBParams = None) -> dict | None:
|
|
86
|
+
"""Fetch a single row and parse it via the extractor."""
|
|
87
|
+
async with self._connection.execute(query, params or {}) as cursor:
|
|
88
|
+
row = await cursor.fetchone()
|
|
89
|
+
return self.extractor.extract(row) if row else None
|
|
90
|
+
|
|
91
|
+
async def fetch_all(self, query: str, params: DBParams = None) -> Sequence[dict]:
|
|
92
|
+
"""Fetch multiple rows and parse them via the extractor."""
|
|
93
|
+
async with self._connection.execute(query, params or {}) as cursor:
|
|
94
|
+
rows = await cursor.fetchall()
|
|
95
|
+
return list(self.extractor.extract(rows)) # type: ignore[arg-type]
|
|
96
|
+
|
|
97
|
+
async def execute(self, query: str, params: DBParams = None) -> int:
|
|
98
|
+
"""Execute a DML statement and return the number of affected rows."""
|
|
99
|
+
cursor = await self._connection.execute(query, params or {})
|
|
100
|
+
await self._connection.commit()
|
|
101
|
+
return cursor.rowcount
|
|
102
|
+
|
|
103
|
+
async def execute_returning(self, query: str, params: DBParams = None) -> ExecuteResult:
|
|
104
|
+
"""Execute a statement and return an ``ExecuteResult`` snapshot."""
|
|
105
|
+
cursor = await self._connection.execute(query, params or {})
|
|
106
|
+
await self._connection.commit()
|
|
107
|
+
return ExecuteResult(rowcount=cursor.rowcount, lastrowid=cursor.lastrowid)
|
|
108
|
+
|
|
109
|
+
async def insert(
|
|
110
|
+
self, table: str, values: dict[str, Any], *, model: type[BaseModel] | None = None
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Insert a row. Optionally return a model instance."""
|
|
113
|
+
keys = ", ".join(values.keys())
|
|
114
|
+
placeholders = ", ".join(f":{key}" for key in values)
|
|
115
|
+
query = f"INSERT INTO {table} ({keys}) VALUES ({placeholders})"
|
|
116
|
+
result = await self.execute_returning(query, values)
|
|
117
|
+
|
|
118
|
+
last_id = result.lastrowid
|
|
119
|
+
if model:
|
|
120
|
+
return model(**{**values, "id": last_id}) # type: ignore[misc]
|
|
121
|
+
values["id"] = last_id
|
|
122
|
+
return values
|
|
123
|
+
|
|
124
|
+
async def update(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
125
|
+
"""Update rows matching the WHERE condition."""
|
|
126
|
+
set_clause = ", ".join(f"{key} = :{key}" for key in values)
|
|
127
|
+
where_clause = " AND ".join(f"{key} = :where_{key}" for key in where)
|
|
128
|
+
combined_params = {**values, **{f"where_{k}": v for k, v in where.items()}}
|
|
129
|
+
query = f"UPDATE {table} SET {set_clause} WHERE {where_clause}"
|
|
130
|
+
return await self.execute(query, combined_params)
|
|
131
|
+
|
|
132
|
+
async def upsert(self, table: str, values: dict[str, Any], where: dict[str, Any]) -> int:
|
|
133
|
+
"""Atomic upsert via ``INSERT ... ON CONFLICT``.
|
|
134
|
+
|
|
135
|
+
``where`` keys identify the conflict target (unique columns).
|
|
136
|
+
``values`` contains the full row data to insert or update.
|
|
137
|
+
"""
|
|
138
|
+
all_values = {**where, **values}
|
|
139
|
+
keys = ", ".join(all_values.keys())
|
|
140
|
+
placeholders = ", ".join(f":{k}" for k in all_values)
|
|
141
|
+
conflict_cols = ", ".join(where.keys())
|
|
142
|
+
|
|
143
|
+
# Columns to update on conflict (everything except the conflict keys)
|
|
144
|
+
update_cols = [k for k in values if k not in where]
|
|
145
|
+
|
|
146
|
+
if update_cols:
|
|
147
|
+
set_clause = ", ".join(f"{k} = excluded.{k}" for k in update_cols)
|
|
148
|
+
query = (
|
|
149
|
+
f"INSERT INTO {table} ({keys}) VALUES ({placeholders}) "
|
|
150
|
+
f"ON CONFLICT({conflict_cols}) DO UPDATE SET {set_clause}"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
query = (
|
|
154
|
+
f"INSERT INTO {table} ({keys}) VALUES ({placeholders}) "
|
|
155
|
+
f"ON CONFLICT({conflict_cols}) DO NOTHING"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return await self.execute(query, all_values)
|
|
159
|
+
|
|
160
|
+
async def delete(self, table: str, where: dict[str, Any]) -> int:
|
|
161
|
+
"""Delete rows matching the WHERE condition."""
|
|
162
|
+
where_clause = " AND ".join(f"{key} = :{key}" for key in where)
|
|
163
|
+
query = f"DELETE FROM {table} WHERE {where_clause}"
|
|
164
|
+
return await self.execute(query, where)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Either a dict of named params (for SQLite)
|
|
7
|
+
# or an ordered sequence of values (for Postgres)
|
|
8
|
+
DBParams = Mapping[str, Any] | Sequence[Any] | None
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from sqlalchemy.sql.expression import Executable
|
|
12
|
+
|
|
13
|
+
DBQuery = str | Executable
|
|
14
|
+
except ImportError: # sqlalchemy not installed
|
|
15
|
+
DBQuery = str # type: ignore[misc]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .types import DBParams
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def unpack_params(params: DBParams) -> Sequence[Any]:
|
|
8
|
+
"""Unpack params into a positional sequence (used by Postgres)."""
|
|
9
|
+
if params is None:
|
|
10
|
+
return []
|
|
11
|
+
if isinstance(params, Mapping):
|
|
12
|
+
return list(params.values())
|
|
13
|
+
if isinstance(params, Sequence):
|
|
14
|
+
return list(params)
|
|
15
|
+
raise TypeError(f"Unsupported params type: {type(params)}")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for SQLiteAdapter."""
|
|
2
|
+
|
|
3
|
+
import aiosqlite
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from csrd.repository.sqlite_adapter import SQLiteAdapter, SQLiteExtractor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
async def seeded_adapter(tmp_path):
|
|
11
|
+
"""Adapter with a pre-seeded table using the async context manager lifecycle."""
|
|
12
|
+
db_path = str(tmp_path / "test.db")
|
|
13
|
+
|
|
14
|
+
async with aiosqlite.connect(db_path) as db:
|
|
15
|
+
db.row_factory = aiosqlite.Row
|
|
16
|
+
await db.execute(
|
|
17
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, price REAL)"
|
|
18
|
+
)
|
|
19
|
+
await db.execute("INSERT INTO items (name, price) VALUES ('Widget', 9.99)")
|
|
20
|
+
await db.execute("INSERT INTO items (name, price) VALUES ('Gadget', 19.99)")
|
|
21
|
+
await db.commit()
|
|
22
|
+
|
|
23
|
+
async with SQLiteAdapter(db_path) as adapter:
|
|
24
|
+
yield adapter
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestSQLiteExtractor:
|
|
28
|
+
def test_extract_dict(self):
|
|
29
|
+
ext = SQLiteExtractor()
|
|
30
|
+
result = ext.extract({"name": "test", "value": 42})
|
|
31
|
+
assert result == {"name": "test", "value": 42}
|
|
32
|
+
|
|
33
|
+
def test_extract_list_passthrough(self):
|
|
34
|
+
ext = SQLiteExtractor()
|
|
35
|
+
result = ext.extract([{"name": "a"}, {"name": "b"}])
|
|
36
|
+
# With real Row objects this extracts keys; with plain dicts it handles gracefully
|
|
37
|
+
assert isinstance(result, list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestSQLiteAdapter:
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_not_connected_raises(self, tmp_path):
|
|
43
|
+
adapter = SQLiteAdapter(str(tmp_path / "unused.db"))
|
|
44
|
+
with pytest.raises(RuntimeError, match="not connected"):
|
|
45
|
+
await adapter.fetch_one("SELECT 1")
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_connect_is_idempotent(self, tmp_path):
|
|
49
|
+
adapter = SQLiteAdapter(str(tmp_path / "idem.db"))
|
|
50
|
+
await adapter.connect()
|
|
51
|
+
db1 = adapter._db
|
|
52
|
+
await adapter.connect() # second call is a no-op
|
|
53
|
+
assert adapter._db is db1
|
|
54
|
+
await adapter.close()
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_insert_and_fetch(self, seeded_adapter):
|
|
58
|
+
result = await seeded_adapter.fetch_one(
|
|
59
|
+
"SELECT * FROM items WHERE name = :name", {"name": "Widget"}
|
|
60
|
+
)
|
|
61
|
+
assert result is not None
|
|
62
|
+
assert result["name"] == "Widget"
|
|
63
|
+
assert result["price"] == 9.99
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_fetch_all(self, seeded_adapter):
|
|
67
|
+
results = await seeded_adapter.fetch_all("SELECT * FROM items")
|
|
68
|
+
assert len(results) == 2
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_fetch_one_missing(self, seeded_adapter):
|
|
72
|
+
result = await seeded_adapter.fetch_one(
|
|
73
|
+
"SELECT * FROM items WHERE name = :name", {"name": "NonExistent"}
|
|
74
|
+
)
|
|
75
|
+
assert result is None
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_insert(self, seeded_adapter):
|
|
79
|
+
result = await seeded_adapter.insert("items", {"name": "New", "price": 5.0})
|
|
80
|
+
assert result["name"] == "New"
|
|
81
|
+
assert "id" in result
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_update(self, seeded_adapter):
|
|
85
|
+
rows = await seeded_adapter.update("items", {"price": 99.99}, {"name": "Widget"})
|
|
86
|
+
assert rows == 1
|
|
87
|
+
updated = await seeded_adapter.fetch_one(
|
|
88
|
+
"SELECT * FROM items WHERE name = :name", {"name": "Widget"}
|
|
89
|
+
)
|
|
90
|
+
assert updated["price"] == 99.99
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_delete(self, seeded_adapter):
|
|
94
|
+
rows = await seeded_adapter.delete("items", {"name": "Widget"})
|
|
95
|
+
assert rows == 1
|
|
96
|
+
remaining = await seeded_adapter.fetch_all("SELECT * FROM items")
|
|
97
|
+
assert len(remaining) == 1
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_upsert_insert(self, seeded_adapter):
|
|
101
|
+
rows = await seeded_adapter.upsert(
|
|
102
|
+
"items", {"name": "Brand New", "price": 1.0}, {"name": "Brand New"}
|
|
103
|
+
)
|
|
104
|
+
assert rows == 1
|
|
105
|
+
result = await seeded_adapter.fetch_one(
|
|
106
|
+
"SELECT * FROM items WHERE name = :name", {"name": "Brand New"}
|
|
107
|
+
)
|
|
108
|
+
assert result is not None
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_upsert_update(self, seeded_adapter):
|
|
112
|
+
rows = await seeded_adapter.upsert(
|
|
113
|
+
"items", {"name": "Widget", "price": 0.01}, {"name": "Widget"}
|
|
114
|
+
)
|
|
115
|
+
assert rows == 1
|
|
116
|
+
result = await seeded_adapter.fetch_one(
|
|
117
|
+
"SELECT * FROM items WHERE name = :name", {"name": "Widget"}
|
|
118
|
+
)
|
|
119
|
+
assert result["price"] == 0.01
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_execute(self, seeded_adapter):
|
|
123
|
+
rowcount = await seeded_adapter.execute(
|
|
124
|
+
"DELETE FROM items WHERE name = :name", {"name": "Widget"}
|
|
125
|
+
)
|
|
126
|
+
assert rowcount == 1
|
|
127
|
+
|
|
128
|
+
@pytest.mark.asyncio
|
|
129
|
+
async def test_execute_returning(self, seeded_adapter):
|
|
130
|
+
from csrd.repository import ExecuteResult
|
|
131
|
+
|
|
132
|
+
result = await seeded_adapter.execute_returning(
|
|
133
|
+
"INSERT INTO items (name, price) VALUES (:name, :price)",
|
|
134
|
+
{"name": "Thingamajig", "price": 3.50},
|
|
135
|
+
)
|
|
136
|
+
assert isinstance(result, ExecuteResult)
|
|
137
|
+
assert result.lastrowid is not None
|
|
138
|
+
assert result.rowcount == 1
|