sqlphilosophy 0.1.2__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.
Files changed (47) hide show
  1. sqlphilosophy-0.1.3/MANIFEST.in +1 -0
  2. {sqlphilosophy-0.1.2/src/sqlphilosophy.egg-info → sqlphilosophy-0.1.3}/PKG-INFO +1 -1
  3. sqlphilosophy-0.1.3/src/sqlphilosophy/VERSION +1 -0
  4. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3/src/sqlphilosophy.egg-info}/PKG-INFO +1 -1
  5. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/SOURCES.txt +2 -17
  6. sqlphilosophy-0.1.2/src/sqlphilosophy/VERSION +0 -1
  7. sqlphilosophy-0.1.2/tests/test_async_last_mile.py +0 -98
  8. sqlphilosophy-0.1.2/tests/test_async_query.py +0 -74
  9. sqlphilosophy-0.1.2/tests/test_async_repository.py +0 -123
  10. sqlphilosophy-0.1.2/tests/test_audit.py +0 -99
  11. sqlphilosophy-0.1.2/tests/test_batch7a_behavior.py +0 -155
  12. sqlphilosophy-0.1.2/tests/test_close_coverage.py +0 -125
  13. sqlphilosophy-0.1.2/tests/test_coverage_gaps.py +0 -351
  14. sqlphilosophy-0.1.2/tests/test_final_coverage.py +0 -191
  15. sqlphilosophy-0.1.2/tests/test_import_contract.py +0 -61
  16. sqlphilosophy-0.1.2/tests/test_last_mile.py +0 -87
  17. sqlphilosophy-0.1.2/tests/test_package_boundaries.py +0 -43
  18. sqlphilosophy-0.1.2/tests/test_sorting.py +0 -73
  19. sqlphilosophy-0.1.2/tests/test_sql_helpers.py +0 -226
  20. sqlphilosophy-0.1.2/tests/test_sql_row_edges.py +0 -66
  21. sqlphilosophy-0.1.2/tests/test_sync_query.py +0 -161
  22. sqlphilosophy-0.1.2/tests/test_sync_repository.py +0 -159
  23. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/LICENSE +0 -0
  24. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/README.md +0 -0
  25. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/pyproject.toml +0 -0
  26. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/setup.cfg +0 -0
  27. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/__init__.py +0 -0
  28. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/__init__.py +0 -0
  29. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/protocols.py +0 -0
  30. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/query.py +0 -0
  31. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/aio/repository.py +0 -0
  32. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/__init__.py +0 -0
  33. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/context.py +0 -0
  34. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/fields.py +0 -0
  35. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/listener.py +0 -0
  36. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/audit/model.py +0 -0
  37. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/py.typed +0 -0
  38. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sorting.py +0 -0
  39. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sql.py +0 -0
  40. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/__init__.py +0 -0
  41. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/protocols.py +0 -0
  42. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/query.py +0 -0
  43. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/sync/repository.py +0 -0
  44. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy/types.py +0 -0
  45. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/dependency_links.txt +0 -0
  46. {sqlphilosophy-0.1.2 → sqlphilosophy-0.1.3}/src/sqlphilosophy.egg-info/requires.txt +0 -0
  47. {sqlphilosophy-0.1.2 → 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.2
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.2
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.2
@@ -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()