sqlphilosophy 0.1.1__tar.gz → 0.1.3__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.
- sqlphilosophy-0.1.3/MANIFEST.in +1 -0
- {sqlphilosophy-0.1.1/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.3}/PKG-INFO +1 -1
- sqlphilosophy-0.1.3/src/sqlphilosophy/VERSION +1 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3/src/sqlphilosophy.egg-info}/PKG-INFO +1 -1
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -17
- sqlphilosophy-0.1.1/src/sqlphilosophy/VERSION +0 -1
- sqlphilosophy-0.1.1/tests/test_async_last_mile.py +0 -98
- sqlphilosophy-0.1.1/tests/test_async_query.py +0 -74
- sqlphilosophy-0.1.1/tests/test_async_repository.py +0 -123
- sqlphilosophy-0.1.1/tests/test_audit.py +0 -99
- sqlphilosophy-0.1.1/tests/test_batch7a_behavior.py +0 -155
- sqlphilosophy-0.1.1/tests/test_close_coverage.py +0 -125
- sqlphilosophy-0.1.1/tests/test_coverage_gaps.py +0 -351
- sqlphilosophy-0.1.1/tests/test_final_coverage.py +0 -191
- sqlphilosophy-0.1.1/tests/test_import_contract.py +0 -61
- sqlphilosophy-0.1.1/tests/test_last_mile.py +0 -87
- sqlphilosophy-0.1.1/tests/test_package_boundaries.py +0 -43
- sqlphilosophy-0.1.1/tests/test_sorting.py +0 -73
- sqlphilosophy-0.1.1/tests/test_sql_helpers.py +0 -226
- sqlphilosophy-0.1.1/tests/test_sql_row_edges.py +0 -66
- sqlphilosophy-0.1.1/tests/test_sync_query.py +0 -161
- sqlphilosophy-0.1.1/tests/test_sync_repository.py +0 -159
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/LICENSE +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/README.md +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/pyproject.toml +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/setup.cfg +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/__init__.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/__init__.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/protocols.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/query.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/repository.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/__init__.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/context.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/fields.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/listener.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/model.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/py.typed +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sorting.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sql.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/__init__.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/protocols.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/query.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/repository.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy/types.py +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/requires.txt +0 -0
- {sqlphilosophy-0.1.1 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prune tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlphilosophy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Portable SQLAlchemy repository kit: sync and async CRUD, statement builders, sort/pagination, and SQL helpers.
|
|
5
5
|
Author-email: Josh Martin <denverprogrammer@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlphilosophy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Portable SQLAlchemy repository kit: sync and async CRUD, statement builders, sort/pagination, and SQL helpers.
|
|
5
5
|
Author-email: Josh Martin <denverprogrammer@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
2
3
|
README.md
|
|
3
4
|
pyproject.toml
|
|
4
5
|
src/sqlphilosophy/VERSION
|
|
@@ -24,20 +25,4 @@ src/sqlphilosophy/audit/model.py
|
|
|
24
25
|
src/sqlphilosophy/sync/__init__.py
|
|
25
26
|
src/sqlphilosophy/sync/protocols.py
|
|
26
27
|
src/sqlphilosophy/sync/query.py
|
|
27
|
-
src/sqlphilosophy/sync/repository.py
|
|
28
|
-
tests/test_async_last_mile.py
|
|
29
|
-
tests/test_async_query.py
|
|
30
|
-
tests/test_async_repository.py
|
|
31
|
-
tests/test_audit.py
|
|
32
|
-
tests/test_batch7a_behavior.py
|
|
33
|
-
tests/test_close_coverage.py
|
|
34
|
-
tests/test_coverage_gaps.py
|
|
35
|
-
tests/test_final_coverage.py
|
|
36
|
-
tests/test_import_contract.py
|
|
37
|
-
tests/test_last_mile.py
|
|
38
|
-
tests/test_package_boundaries.py
|
|
39
|
-
tests/test_sorting.py
|
|
40
|
-
tests/test_sql_helpers.py
|
|
41
|
-
tests/test_sql_row_edges.py
|
|
42
|
-
tests/test_sync_query.py
|
|
43
|
-
tests/test_sync_repository.py
|
|
28
|
+
src/sqlphilosophy/sync/repository.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.1.1
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"""Async last-mile coverage."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from conftest import Child
|
|
7
|
-
from conftest import Parent
|
|
8
|
-
from conftest import Tag
|
|
9
|
-
from conftest import UpdatableTag
|
|
10
|
-
from conftest import Widget
|
|
11
|
-
from sqlalchemy import literal_column
|
|
12
|
-
from sqlalchemy import select
|
|
13
|
-
from sqlphilosophy.aio.query import AsyncSqlAlchemyStatementBuilder
|
|
14
|
-
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
15
|
-
from sqlphilosophy.sorting import ListQuery
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@pytest.mark.asyncio
|
|
19
|
-
async def test_async_query_remaining(async_session) -> None:
|
|
20
|
-
parent = Parent()
|
|
21
|
-
async_session.add(parent)
|
|
22
|
-
await async_session.flush()
|
|
23
|
-
async_session.add(Child(parent_id=parent.id))
|
|
24
|
-
await async_session.flush()
|
|
25
|
-
b = AsyncSqlAlchemyStatementBuilder(async_session, Parent)
|
|
26
|
-
b.select_table()
|
|
27
|
-
b.select_columns(Parent.id)
|
|
28
|
-
b.select_from(Parent)
|
|
29
|
-
b.distinct(Parent.id)
|
|
30
|
-
b.group_by(Parent.id)
|
|
31
|
-
b.correlate(Parent)
|
|
32
|
-
b.correlate_except(Child)
|
|
33
|
-
b.as_lateral("p")
|
|
34
|
-
b.as_cte("p")
|
|
35
|
-
b.with_params({"x": 1})
|
|
36
|
-
with pytest.raises(ValueError, match="limit must be >= 0"):
|
|
37
|
-
b.limit(-1)
|
|
38
|
-
with pytest.raises(ValueError, match="offset must be >= 0"):
|
|
39
|
-
b.offset(-1)
|
|
40
|
-
await b.count()
|
|
41
|
-
await b.scalar()
|
|
42
|
-
await b.mappings().all()
|
|
43
|
-
await b.mappings().first()
|
|
44
|
-
await b.mappings().one()
|
|
45
|
-
await b.scalars().all()
|
|
46
|
-
await b.scalars().first()
|
|
47
|
-
b.build_select()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@pytest.mark.asyncio
|
|
51
|
-
async def test_async_query_filter_and_count(async_session) -> None:
|
|
52
|
-
await AsyncBaseRepository(Widget, async_session).create(name="f")
|
|
53
|
-
b = AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
54
|
-
b.filter_by(name="f")
|
|
55
|
-
await b.count()
|
|
56
|
-
empty = AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
57
|
-
empty._stmt = select(literal_column("1"))
|
|
58
|
-
await empty.count()
|
|
59
|
-
await empty.scalar()
|
|
60
|
-
await empty.count_distinct(Widget.id)
|
|
61
|
-
await empty.fetch_page(ListQuery(offset=0, limit=0))
|
|
62
|
-
with pytest.raises(ValueError, match="limit must be >= 0"):
|
|
63
|
-
await empty.fetch_page(ListQuery(offset=0, limit=-1))
|
|
64
|
-
await (
|
|
65
|
-
AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
66
|
-
.select_entity()
|
|
67
|
-
.where(Widget.name == "f")
|
|
68
|
-
.count_distinct(Widget.id)
|
|
69
|
-
)
|
|
70
|
-
empty.build_select()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@pytest.mark.asyncio
|
|
74
|
-
async def test_async_repository_remaining(async_session) -> None:
|
|
75
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
76
|
-
row = await repo.create(name="left")
|
|
77
|
-
await repo.create(name="delete-params")
|
|
78
|
-
await repo.delete_where(criteria=[Widget.name == "delete-params"], params={"p": 1})
|
|
79
|
-
await repo.get_with_join(Tag, Widget.id == Tag.id, join_on=Widget.id == Tag.id)
|
|
80
|
-
assert await repo.update_partial(99999, {"name": "n"}, frozenset({"name"})) == 0
|
|
81
|
-
assert await repo.update_partial(row.id, {"name": "n"}, frozenset()) == 0
|
|
82
|
-
assert await repo.update_where(criteria=[Widget.id == row.id], values={}) == 0
|
|
83
|
-
import uuid
|
|
84
|
-
|
|
85
|
-
await repo.get_or_create(name=f"unique-{uuid.uuid4()}")
|
|
86
|
-
upd_repo = AsyncBaseRepository(UpdatableTag, async_session)
|
|
87
|
-
upd = await upd_repo.create(label="u")
|
|
88
|
-
await upd_repo.update_partial(
|
|
89
|
-
upd.id, {"label": "u2"}, frozenset({"label"}), touch_updated_on=True
|
|
90
|
-
)
|
|
91
|
-
with pytest.raises(ValueError, match="page must be >= 1"):
|
|
92
|
-
await repo.filter(page=0)
|
|
93
|
-
assert await repo.delete_many([]) == 0
|
|
94
|
-
await repo.batched_purge_ids(criteria=[Widget.name == "missing"], batch_size=5)
|
|
95
|
-
with pytest.raises(ValueError, match="limit must be >= 1"):
|
|
96
|
-
await repo.get_all(limit=0)
|
|
97
|
-
with pytest.raises(ValueError, match="page must be >= 1"):
|
|
98
|
-
await repo.get_all(page=0)
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""Async AsyncSqlAlchemyStatementBuilder behavior."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from conftest import Widget
|
|
7
|
-
from sqlphilosophy.aio.query import AsyncSqlAlchemyStatementBuilder
|
|
8
|
-
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
9
|
-
from sqlphilosophy.sorting import ListQuery
|
|
10
|
-
from sqlphilosophy.sorting import SortConfig
|
|
11
|
-
from sqlphilosophy.sorting import SortSpec
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pytest.mark.asyncio
|
|
15
|
-
async def test_async_query_builder(async_session) -> None:
|
|
16
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
17
|
-
await repo.create(name="z")
|
|
18
|
-
await repo.create(name="a")
|
|
19
|
-
builder = (
|
|
20
|
-
AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
21
|
-
.select_entity()
|
|
22
|
-
.where(Widget.active.is_(True))
|
|
23
|
-
.order_by(Widget.name)
|
|
24
|
-
.limit(10)
|
|
25
|
-
.offset(0)
|
|
26
|
-
)
|
|
27
|
-
scalars = await builder.scalars().all()
|
|
28
|
-
assert len(scalars) == 2
|
|
29
|
-
assert await builder.scalars().first() is not None
|
|
30
|
-
assert await builder.select_columns(Widget.id).limit(1).scalar() is not None
|
|
31
|
-
mappings = await builder.select_entity().mappings().all()
|
|
32
|
-
assert mappings
|
|
33
|
-
assert await builder.count() == 2
|
|
34
|
-
assert await builder.count_distinct(Widget.id) == 2
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@pytest.mark.asyncio
|
|
38
|
-
async def test_async_mappings_terminals(async_session) -> None:
|
|
39
|
-
await AsyncBaseRepository(Widget, async_session).create(name="m")
|
|
40
|
-
b = AsyncSqlAlchemyStatementBuilder(async_session, Widget).select_columns(
|
|
41
|
-
Widget.id, Widget.name
|
|
42
|
-
)
|
|
43
|
-
assert await b.mappings().all()
|
|
44
|
-
assert await b.mappings().first() is not None
|
|
45
|
-
assert (await b.mappings().one())["name"] == "m"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
@pytest.mark.asyncio
|
|
49
|
-
async def test_async_fetch_page(async_session) -> None:
|
|
50
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
51
|
-
for i in range(4):
|
|
52
|
-
await repo.create(name=f"n{i}")
|
|
53
|
-
sort = SortConfig(
|
|
54
|
-
default=SortSpec("name", "asc"),
|
|
55
|
-
columns={"name": {"asc": Widget.name, "desc": Widget.name.desc()}},
|
|
56
|
-
)
|
|
57
|
-
rows, total = await (
|
|
58
|
-
AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
59
|
-
.select_columns(Widget.id, Widget.name)
|
|
60
|
-
.fetch_page(ListQuery(offset=0, limit=2), sort=sort)
|
|
61
|
-
)
|
|
62
|
-
assert total == 4
|
|
63
|
-
assert len(rows) == 2
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@pytest.mark.asyncio
|
|
67
|
-
async def test_async_limit_offset_validation(async_session) -> None:
|
|
68
|
-
b = AsyncSqlAlchemyStatementBuilder(async_session, Widget)
|
|
69
|
-
with pytest.raises(ValueError, match="limit must be >= 0"):
|
|
70
|
-
b.limit(-1)
|
|
71
|
-
with pytest.raises(ValueError, match="offset must be >= 0"):
|
|
72
|
-
b.offset(-1)
|
|
73
|
-
with pytest.raises(ValueError, match="limit must be >= 0"):
|
|
74
|
-
await b.fetch_page(ListQuery(offset=0, limit=-1), sort=None)
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
"""Async AsyncBaseRepository behavior."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
from unittest.mock import MagicMock
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from conftest import Widget
|
|
8
|
-
from conftest import WidgetTag
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
-
from sqlphilosophy.aio.protocols import AsyncRepositoryFactory
|
|
11
|
-
from sqlphilosophy.aio.query import AsyncSqlAlchemyStatementBuilder
|
|
12
|
-
from sqlphilosophy.aio.repository import AsyncBaseRepository
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class _OtherAsyncRepo(AsyncBaseRepository[Widget]):
|
|
16
|
-
def __init__(self, session: AsyncSession, factory: AsyncRepositoryFactory) -> None:
|
|
17
|
-
super().__init__(Widget, session, factory)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class _FakeAsyncFactory(AsyncRepositoryFactory):
|
|
21
|
-
def __init__(self, session: AsyncSession) -> None:
|
|
22
|
-
self._session = session
|
|
23
|
-
self.created: list[type] = []
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def session(self) -> AsyncSession:
|
|
27
|
-
return self._session
|
|
28
|
-
|
|
29
|
-
def create_statement(self, model: type) -> AsyncSqlAlchemyStatementBuilder:
|
|
30
|
-
self.created.append(model)
|
|
31
|
-
return AsyncSqlAlchemyStatementBuilder(self._session, model)
|
|
32
|
-
|
|
33
|
-
def get_repository(self, repo_class: type):
|
|
34
|
-
return repo_class(self._session, self)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@pytest.mark.asyncio
|
|
38
|
-
async def test_async_create_get(async_session: AsyncSession) -> None:
|
|
39
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
40
|
-
created = await repo.create(name="async")
|
|
41
|
-
assert created.id is not None
|
|
42
|
-
assert await repo.get_by_id(created.id) is created
|
|
43
|
-
assert (await repo.get(created.id)).name == "async"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@pytest.mark.asyncio
|
|
47
|
-
async def test_async_get_many_filter_count(async_session: AsyncSession) -> None:
|
|
48
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
49
|
-
a = await repo.create(name="a", active=True)
|
|
50
|
-
await repo.create(name="b", active=False)
|
|
51
|
-
assert len(await repo.get_many([a.id])) == 1
|
|
52
|
-
assert await repo.get_many([]) == []
|
|
53
|
-
assert await repo.count(active=True) == 1
|
|
54
|
-
assert len(await repo.filter(active=True)) == 1
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.mark.asyncio
|
|
58
|
-
async def test_async_delete_many(async_session: AsyncSession) -> None:
|
|
59
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
60
|
-
row = await repo.create(name="del")
|
|
61
|
-
assert await repo.remove(row.id) is True
|
|
62
|
-
one = await repo.create(name="x")
|
|
63
|
-
two = await repo.create(name="y")
|
|
64
|
-
assert await repo.delete_many([one.id, two.id]) == 2
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@pytest.mark.asyncio
|
|
68
|
-
async def test_async_statement_without_factory(async_session: AsyncSession) -> None:
|
|
69
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
70
|
-
builder = repo.statement()
|
|
71
|
-
assert isinstance(builder, AsyncSqlAlchemyStatementBuilder)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@pytest.mark.asyncio
|
|
75
|
-
async def test_async_for_repo_without_factory_raises(async_session: AsyncSession) -> None:
|
|
76
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
77
|
-
with pytest.raises(RuntimeError, match="AsyncRepositoryFactory"):
|
|
78
|
-
repo.for_repo(_OtherAsyncRepo)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@pytest.mark.asyncio
|
|
82
|
-
async def test_async_for_repo_with_factory(async_session: AsyncSession) -> None:
|
|
83
|
-
factory = _FakeAsyncFactory(async_session)
|
|
84
|
-
repo = AsyncBaseRepository(Widget, async_session, factory)
|
|
85
|
-
other = repo.for_repo(_OtherAsyncRepo)
|
|
86
|
-
assert isinstance(other, _OtherAsyncRepo)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_async_composite_pk_rejected() -> None:
|
|
90
|
-
with pytest.raises(TypeError, match="single-column primary key"):
|
|
91
|
-
AsyncBaseRepository(WidgetTag, MagicMock())
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@pytest.mark.asyncio
|
|
95
|
-
async def test_async_extended_helpers(async_session: AsyncSession) -> None:
|
|
96
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
97
|
-
row = await repo.create(name="ext")
|
|
98
|
-
assert await repo.exists(row.id)
|
|
99
|
-
assert await repo.exists_where(name="ext")
|
|
100
|
-
obj, created = await repo.get_or_create(name="new-async")
|
|
101
|
-
assert created is True
|
|
102
|
-
again, created2 = await repo.get_or_create(name="new-async")
|
|
103
|
-
assert created2 is False
|
|
104
|
-
assert again.id == obj.id
|
|
105
|
-
updated = await repo.update_partial(row.id, {"name": "changed"}, frozenset({"name"}))
|
|
106
|
-
assert updated == 1
|
|
107
|
-
deleted = await repo.delete_where(criteria=[Widget.name == "changed"])
|
|
108
|
-
assert deleted == 1
|
|
109
|
-
names = await repo.list_table_names()
|
|
110
|
-
assert "widget" in names
|
|
111
|
-
assert await repo.has_table("widget")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
async def test_list_table_names_without_inspector(
|
|
115
|
-
async_session, monkeypatch: pytest.MonkeyPatch
|
|
116
|
-
) -> None:
|
|
117
|
-
repo = AsyncBaseRepository(Widget, async_session)
|
|
118
|
-
|
|
119
|
-
def _no_inspect(_conn: object) -> None:
|
|
120
|
-
return None
|
|
121
|
-
|
|
122
|
-
monkeypatch.setattr("sqlphilosophy.aio.repository.sa_inspect", _no_inspect)
|
|
123
|
-
assert await repo.list_table_names() == frozenset()
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
"""Audit context, listeners, and models."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from conftest import SoftWidget
|
|
8
|
-
from conftest import Widget
|
|
9
|
-
from sqlalchemy.orm import Session
|
|
10
|
-
from sqlphilosophy.audit.context import audit_context
|
|
11
|
-
from sqlphilosophy.audit.context import AuditContext
|
|
12
|
-
from sqlphilosophy.audit.context import get_audit_actor_id
|
|
13
|
-
from sqlphilosophy.audit.context import get_audit_context
|
|
14
|
-
from sqlphilosophy.audit.context import set_audit_context
|
|
15
|
-
from sqlphilosophy.audit.fields import AuditColumns
|
|
16
|
-
from sqlphilosophy.audit.fields import is_audit_model
|
|
17
|
-
from sqlphilosophy.audit.listener import configure_audit_listeners
|
|
18
|
-
from sqlphilosophy.audit.listener import get_audit_listener
|
|
19
|
-
from sqlphilosophy.audit.listener import soft_delete
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_audit_context_nested_and_reset() -> None:
|
|
23
|
-
assert get_audit_actor_id() is None
|
|
24
|
-
with audit_context(10):
|
|
25
|
-
assert get_audit_actor_id() == 10
|
|
26
|
-
with audit_context(20):
|
|
27
|
-
assert get_audit_actor_id() == 20
|
|
28
|
-
assert get_audit_actor_id() == 10
|
|
29
|
-
assert get_audit_actor_id() is None
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_audit_context_resets_on_exception() -> None:
|
|
33
|
-
with pytest.raises(RuntimeError):
|
|
34
|
-
with audit_context(5):
|
|
35
|
-
raise RuntimeError("boom")
|
|
36
|
-
assert get_audit_actor_id() is None
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_set_audit_context() -> None:
|
|
40
|
-
set_audit_context(AuditContext(actor_id=7))
|
|
41
|
-
assert get_audit_context() == AuditContext(actor_id=7)
|
|
42
|
-
set_audit_context(None)
|
|
43
|
-
assert get_audit_context() is None
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_timestamp_model_stamps_on_insert(sync_session: Session) -> None:
|
|
47
|
-
with audit_context(42):
|
|
48
|
-
row = Widget(name="audited")
|
|
49
|
-
sync_session.add(row)
|
|
50
|
-
sync_session.flush()
|
|
51
|
-
assert isinstance(row.created_on, datetime)
|
|
52
|
-
assert isinstance(row.updated_on, datetime)
|
|
53
|
-
assert row.created_by_id == 42
|
|
54
|
-
assert row.updated_by_id == 42
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_timestamp_model_stamps_on_update(sync_session: Session) -> None:
|
|
58
|
-
row = Widget(name="before")
|
|
59
|
-
sync_session.add(row)
|
|
60
|
-
sync_session.flush()
|
|
61
|
-
before = row.updated_on
|
|
62
|
-
with audit_context(99):
|
|
63
|
-
row.name = "after"
|
|
64
|
-
sync_session.flush()
|
|
65
|
-
assert row.updated_on >= before
|
|
66
|
-
assert row.updated_by_id == 99
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_soft_delete(sync_session: Session) -> None:
|
|
70
|
-
row = SoftWidget(name="soft")
|
|
71
|
-
sync_session.add(row)
|
|
72
|
-
sync_session.flush()
|
|
73
|
-
with audit_context(3):
|
|
74
|
-
soft_delete(row)
|
|
75
|
-
assert row.deleted_on is not None
|
|
76
|
-
assert row.deleted_by_id == 3
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_audit_columns_and_is_audit_model() -> None:
|
|
80
|
-
cols = AuditColumns.for_model(Widget)
|
|
81
|
-
assert cols.created == "created_on"
|
|
82
|
-
assert is_audit_model(Widget(name="x")) is True
|
|
83
|
-
assert is_audit_model(object()) is False
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def test_configure_audit_listeners_idempotent() -> None:
|
|
87
|
-
configure_audit_listeners()
|
|
88
|
-
listener = get_audit_listener()
|
|
89
|
-
listener.stamp_on_insert(Widget(name="direct"))
|
|
90
|
-
assert listener.now().tzinfo is not None
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def test_listener_set_helpers() -> None:
|
|
94
|
-
listener = get_audit_listener()
|
|
95
|
-
target = Widget(name="t")
|
|
96
|
-
listener._set_if_empty(target, "created_on", listener.now())
|
|
97
|
-
listener._set(target, "updated_by_id", 1)
|
|
98
|
-
listener._set_if_empty(target, "missing_attr", 1)
|
|
99
|
-
listener._set(object(), "anything", 1)
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
"""Batch 7A behavioral tests: transaction ownership, destructive helpers, async parity."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
from sqlalchemy import create_engine
|
|
7
|
-
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
8
|
-
from sqlalchemy.ext.asyncio import create_async_engine
|
|
9
|
-
from sqlalchemy.orm import sessionmaker
|
|
10
|
-
|
|
11
|
-
from conftest import Base
|
|
12
|
-
from conftest import Widget
|
|
13
|
-
from sqlphilosophy.aio.query import AsyncSqlAlchemyStatementBuilder
|
|
14
|
-
from sqlphilosophy.sorting import ListQuery
|
|
15
|
-
from sqlphilosophy.sorting import SortConfig
|
|
16
|
-
from sqlphilosophy.sorting import SortSpec
|
|
17
|
-
from sqlphilosophy.sync.query import SqlAlchemyStatementBuilder
|
|
18
|
-
from sqlphilosophy.sync.repository import BaseRepository
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_sync_and_aio_subpackages_have_no_reexports() -> None:
|
|
22
|
-
import sqlphilosophy.aio as aio_pkg
|
|
23
|
-
import sqlphilosophy.sync as sync_pkg
|
|
24
|
-
|
|
25
|
-
assert sync_pkg.__all__ == []
|
|
26
|
-
assert aio_pkg.__all__ == []
|
|
27
|
-
for attr in ("BaseRepository", "SqlAlchemyStatementBuilder"):
|
|
28
|
-
assert not hasattr(sync_pkg, attr)
|
|
29
|
-
for attr in ("AsyncBaseRepository", "AsyncSqlAlchemyStatementBuilder"):
|
|
30
|
-
assert not hasattr(aio_pkg, attr)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_create_flushes_without_commit_until_caller_commits() -> None:
|
|
34
|
-
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
35
|
-
Base.metadata.create_all(engine)
|
|
36
|
-
session = sessionmaker(bind=engine)()
|
|
37
|
-
try:
|
|
38
|
-
repo = BaseRepository(Widget, session)
|
|
39
|
-
row = repo.create(name="pending")
|
|
40
|
-
row_id = row.id
|
|
41
|
-
session.close()
|
|
42
|
-
|
|
43
|
-
verify = sessionmaker(bind=engine)()
|
|
44
|
-
try:
|
|
45
|
-
assert verify.get(Widget, row_id) is None
|
|
46
|
-
finally:
|
|
47
|
-
verify.close()
|
|
48
|
-
finally:
|
|
49
|
-
engine.dispose()
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_create_visible_after_explicit_commit() -> None:
|
|
53
|
-
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
54
|
-
Base.metadata.create_all(engine)
|
|
55
|
-
session = sessionmaker(bind=engine)()
|
|
56
|
-
try:
|
|
57
|
-
repo = BaseRepository(Widget, session)
|
|
58
|
-
row = repo.create(name="committed")
|
|
59
|
-
row_id = row.id
|
|
60
|
-
session.commit()
|
|
61
|
-
session.close()
|
|
62
|
-
|
|
63
|
-
verify = sessionmaker(bind=engine)()
|
|
64
|
-
try:
|
|
65
|
-
assert verify.get(Widget, row_id) is not None
|
|
66
|
-
finally:
|
|
67
|
-
verify.close()
|
|
68
|
-
finally:
|
|
69
|
-
engine.dispose()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_delete_all_does_not_commit(sync_session, monkeypatch) -> None:
|
|
73
|
-
repo = BaseRepository(Widget, sync_session)
|
|
74
|
-
repo.create(name="stay")
|
|
75
|
-
sync_session.commit()
|
|
76
|
-
|
|
77
|
-
commits: list[bool] = []
|
|
78
|
-
original_commit = sync_session.commit
|
|
79
|
-
|
|
80
|
-
def track_commit() -> None:
|
|
81
|
-
commits.append(True)
|
|
82
|
-
return original_commit()
|
|
83
|
-
|
|
84
|
-
monkeypatch.setattr(sync_session, "commit", track_commit)
|
|
85
|
-
deleted = repo.delete_all()
|
|
86
|
-
assert deleted >= 1
|
|
87
|
-
assert commits == []
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_batched_purge_ids_commits_each_batch(sync_session, monkeypatch) -> None:
|
|
91
|
-
repo = BaseRepository(Widget, sync_session)
|
|
92
|
-
repo.create(name="purge-a")
|
|
93
|
-
repo.create(name="purge-b")
|
|
94
|
-
sync_session.commit()
|
|
95
|
-
|
|
96
|
-
commits: list[bool] = []
|
|
97
|
-
original_commit = sync_session.commit
|
|
98
|
-
|
|
99
|
-
def track_commit() -> None:
|
|
100
|
-
commits.append(True)
|
|
101
|
-
return original_commit()
|
|
102
|
-
|
|
103
|
-
monkeypatch.setattr(sync_session, "commit", track_commit)
|
|
104
|
-
total = repo.batched_purge_ids(criteria=[Widget.name.like("purge-%")], batch_size=1)
|
|
105
|
-
assert total == 2
|
|
106
|
-
assert len(commits) >= 1
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def test_apply_sort_rejects_disallowed_order_by_in_fetch_page(sync_session) -> None:
|
|
110
|
-
repo = BaseRepository(Widget, sync_session)
|
|
111
|
-
repo.create(name="alpha")
|
|
112
|
-
repo.create(name="beta")
|
|
113
|
-
sync_session.commit()
|
|
114
|
-
|
|
115
|
-
sort = SortConfig(
|
|
116
|
-
default=SortSpec("name", "asc"),
|
|
117
|
-
columns={"name": {"asc": Widget.name, "desc": Widget.name.desc()}},
|
|
118
|
-
)
|
|
119
|
-
rows, total = (
|
|
120
|
-
SqlAlchemyStatementBuilder(sync_session, Widget)
|
|
121
|
-
.select_entity()
|
|
122
|
-
.apply_sort(sort, {"evil_column": "asc"})
|
|
123
|
-
.fetch_page(ListQuery(offset=0, limit=10))
|
|
124
|
-
)
|
|
125
|
-
names = [row["name"] for row in rows]
|
|
126
|
-
assert total == 2
|
|
127
|
-
assert names == ["alpha", "beta"]
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
@pytest.mark.asyncio
|
|
131
|
-
async def test_async_apply_sort_parity_with_sync() -> None:
|
|
132
|
-
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
133
|
-
async with engine.begin() as conn:
|
|
134
|
-
await conn.run_sync(Base.metadata.create_all)
|
|
135
|
-
maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
136
|
-
|
|
137
|
-
async with maker() as session:
|
|
138
|
-
session.add(Widget(name="alpha"))
|
|
139
|
-
session.add(Widget(name="beta"))
|
|
140
|
-
await session.commit()
|
|
141
|
-
|
|
142
|
-
sort = SortConfig(
|
|
143
|
-
default=SortSpec("name", "asc"),
|
|
144
|
-
columns={"name": {"asc": Widget.name, "desc": Widget.name.desc()}},
|
|
145
|
-
)
|
|
146
|
-
rows, total = await (
|
|
147
|
-
AsyncSqlAlchemyStatementBuilder(session, Widget)
|
|
148
|
-
.select_entity()
|
|
149
|
-
.apply_sort(sort, {"name": "desc"})
|
|
150
|
-
.fetch_page(ListQuery(offset=0, limit=10))
|
|
151
|
-
)
|
|
152
|
-
assert total == 2
|
|
153
|
-
assert [row["name"] for row in rows] == ["beta", "alpha"]
|
|
154
|
-
|
|
155
|
-
await engine.dispose()
|