pytest-orm-boundaries 0.3.0__tar.gz → 0.4.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.
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/PKG-INFO +61 -11
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/README.md +59 -10
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/pyproject.toml +5 -2
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/call_stack.py → pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/callstack.py +1 -1
- pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/guard.py +193 -0
- pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/model_resolution.py +29 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/plugin.py +6 -6
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/build_report.py → pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/report.py +14 -11
- pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/sql_parsing.py +58 -0
- pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/guard.py +0 -185
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/LICENSE +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/__init__.py +0 -0
- /pytest_orm_boundaries-0.3.0/src/pytest_orm_boundaries/read_config.py → /pytest_orm_boundaries-0.4.0/src/pytest_orm_boundaries/config.py +0 -0
- {pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/ignores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-orm-boundaries
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today).
|
|
5
5
|
Keywords: django,pytest,plugin,orm,ddd,aggregate,boundaries,architecture
|
|
6
6
|
Author: Evgeniia Chibisova
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
18
|
Classifier: Topic :: Software Development :: Testing
|
|
19
19
|
Requires-Dist: pytest>=8
|
|
20
|
+
Requires-Dist: sqlglot>=30,<31
|
|
20
21
|
Requires-Python: >=3.11
|
|
21
22
|
Project-URL: Homepage, https://github.com/evchibisova/pytest-orm-boundaries
|
|
22
23
|
Project-URL: Repository, https://github.com/evchibisova/pytest-orm-boundaries
|
|
@@ -41,9 +42,9 @@ lookups make it easy to cross those boundaries silently:
|
|
|
41
42
|
Purchase.objects.get(client__name="John")
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
`pytest-orm-boundaries` watches the
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
`pytest-orm-boundaries` watches the SQL your test suite executes and reports the
|
|
46
|
+
queries that step outside their aggregate — whether through `__` lookups,
|
|
47
|
+
`select_related`, subqueries, or hand-written `.raw()` SQL.
|
|
47
48
|
|
|
48
49
|
## Install
|
|
49
50
|
|
|
@@ -68,6 +69,50 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
|
68
69
|
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
69
70
|
not checked. Without a config file the plugin emits a warning and runs no checks.
|
|
70
71
|
|
|
72
|
+
## What it catches
|
|
73
|
+
|
|
74
|
+
The plugin works from the SQL your suite actually executes, so it flags any
|
|
75
|
+
single statement that reads tables from two different aggregates - however the
|
|
76
|
+
query was written. Each example below joins the `purchase` and `client` aggregates:
|
|
77
|
+
|
|
78
|
+
- `__` relation lookups:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
Purchase.objects.get(client__name="John")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `select_related`
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
Purchase.objects.select_related("client")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- Subqueries - a table reached through a subquery still counts:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
berlin_clients = Client.objects.filter(city="Berlin").values("id")
|
|
94
|
+
Purchase.objects.filter(client_id__in=berlin_clients)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Hand-written `.raw()` SQL:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
Purchase.objects.raw(
|
|
101
|
+
"SELECT p.id FROM bookshop_purchase p "
|
|
102
|
+
"JOIN bookshop_client c ON p.client_id = c.id"
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
107
|
+
|
|
108
|
+
Queries that don't actually join across the boundary are **not** flagged — for
|
|
109
|
+
example a foreign-key lookup by id, which Django resolves without a join:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
Purchase.objects.filter(client_id=42) # reads one table
|
|
113
|
+
Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
114
|
+
```
|
|
115
|
+
|
|
71
116
|
## The report
|
|
72
117
|
|
|
73
118
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
@@ -76,17 +121,18 @@ At the end of the run, the plugin prints one grouped entry per offending place:
|
|
|
76
121
|
===================== orm-boundaries: boundary violations ======================
|
|
77
122
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
78
123
|
|
|
79
|
-
bookshop/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
test_purchases.py::
|
|
124
|
+
bookshop/reports.py:13
|
|
125
|
+
crossed aggregates: client ↔ purchase
|
|
126
|
+
models: bookshop.Client, bookshop.Purchase
|
|
127
|
+
1 test(s) affected:
|
|
128
|
+
test_purchases.py::test_list_purchases_with_client
|
|
84
129
|
|
|
85
130
|
orm-boundaries: FAILED - 1 boundary violation(s), run exits non-zero.
|
|
86
131
|
```
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
Pass `-v` to
|
|
133
|
+
Each entry names the aggregates the query crossed and the models it joined.
|
|
134
|
+
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
135
|
+
(otherwise the list is capped at 5 per place).
|
|
90
136
|
|
|
91
137
|
## Ignoring files
|
|
92
138
|
|
|
@@ -116,6 +162,10 @@ Remove them from boundaries.toml:
|
|
|
116
162
|
- app/billing.py
|
|
117
163
|
```
|
|
118
164
|
|
|
165
|
+
## Known gaps
|
|
166
|
+
|
|
167
|
+
- `prefetch_related` loads doesn't detected in current version.
|
|
168
|
+
|
|
119
169
|
## Status
|
|
120
170
|
|
|
121
171
|
Alpha - testing basic version.
|
|
@@ -15,9 +15,9 @@ lookups make it easy to cross those boundaries silently:
|
|
|
15
15
|
Purchase.objects.get(client__name="John")
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`pytest-orm-boundaries` watches the
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
`pytest-orm-boundaries` watches the SQL your test suite executes and reports the
|
|
19
|
+
queries that step outside their aggregate — whether through `__` lookups,
|
|
20
|
+
`select_related`, subqueries, or hand-written `.raw()` SQL.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -42,6 +42,50 @@ purchase = ["bookshop.Purchase", "bookshop.PurchaseLine"]
|
|
|
42
42
|
Models are written as `app_label.Model`. Models not listed in any aggregate are
|
|
43
43
|
not checked. Without a config file the plugin emits a warning and runs no checks.
|
|
44
44
|
|
|
45
|
+
## What it catches
|
|
46
|
+
|
|
47
|
+
The plugin works from the SQL your suite actually executes, so it flags any
|
|
48
|
+
single statement that reads tables from two different aggregates - however the
|
|
49
|
+
query was written. Each example below joins the `purchase` and `client` aggregates:
|
|
50
|
+
|
|
51
|
+
- `__` relation lookups:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
Purchase.objects.get(client__name="John")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `select_related`
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
Purchase.objects.select_related("client")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Subqueries - a table reached through a subquery still counts:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
berlin_clients = Client.objects.filter(city="Berlin").values("id")
|
|
67
|
+
Purchase.objects.filter(client_id__in=berlin_clients)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- Hand-written `.raw()` SQL:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
Purchase.objects.raw(
|
|
74
|
+
"SELECT p.id FROM bookshop_purchase p "
|
|
75
|
+
"JOIN bookshop_client c ON p.client_id = c.id"
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- Bare `cursor.execute()` - the same join reached through a raw cursor.
|
|
80
|
+
|
|
81
|
+
Queries that don't actually join across the boundary are **not** flagged — for
|
|
82
|
+
example a foreign-key lookup by id, which Django resolves without a join:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
Purchase.objects.filter(client_id=42) # reads one table
|
|
86
|
+
Purchase.objects.filter(client__pk=42) # Django trims the join
|
|
87
|
+
```
|
|
88
|
+
|
|
45
89
|
## The report
|
|
46
90
|
|
|
47
91
|
At the end of the run, the plugin prints one grouped entry per offending place:
|
|
@@ -50,17 +94,18 @@ At the end of the run, the plugin prints one grouped entry per offending place:
|
|
|
50
94
|
===================== orm-boundaries: boundary violations ======================
|
|
51
95
|
1 place(s) in your code crossed aggregate boundaries, affecting 1 test(s):
|
|
52
96
|
|
|
53
|
-
bookshop/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
test_purchases.py::
|
|
97
|
+
bookshop/reports.py:13
|
|
98
|
+
crossed aggregates: client ↔ purchase
|
|
99
|
+
models: bookshop.Client, bookshop.Purchase
|
|
100
|
+
1 test(s) affected:
|
|
101
|
+
test_purchases.py::test_list_purchases_with_client
|
|
58
102
|
|
|
59
103
|
orm-boundaries: FAILED - 1 boundary violation(s), run exits non-zero.
|
|
60
104
|
```
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
Pass `-v` to
|
|
106
|
+
Each entry names the aggregates the query crossed and the models it joined.
|
|
107
|
+
Places are ordered by how many tests they affect. Pass `-v` to see every affected test
|
|
108
|
+
(otherwise the list is capped at 5 per place).
|
|
64
109
|
|
|
65
110
|
## Ignoring files
|
|
66
111
|
|
|
@@ -90,6 +135,10 @@ Remove them from boundaries.toml:
|
|
|
90
135
|
- app/billing.py
|
|
91
136
|
```
|
|
92
137
|
|
|
138
|
+
## Known gaps
|
|
139
|
+
|
|
140
|
+
- `prefetch_related` loads doesn't detected in current version.
|
|
141
|
+
|
|
93
142
|
## Status
|
|
94
143
|
|
|
95
144
|
Alpha - testing basic version.
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-orm-boundaries"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Pytest plugin that fails tests when ORM queries cross DDD aggregate boundaries (Django supported today)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -33,7 +33,7 @@ classifiers = [
|
|
|
33
33
|
"Topic :: Software Development :: Quality Assurance",
|
|
34
34
|
"Topic :: Software Development :: Testing",
|
|
35
35
|
]
|
|
36
|
-
dependencies = ["pytest>=8"]
|
|
36
|
+
dependencies = ["pytest>=8", "sqlglot>=30,<31"]
|
|
37
37
|
|
|
38
38
|
[project.urls]
|
|
39
39
|
Homepage = "https://github.com/evchibisova/pytest-orm-boundaries"
|
|
@@ -44,6 +44,9 @@ Changelog = "https://github.com/evchibisova/pytest-orm-boundaries/blob/main/CHAN
|
|
|
44
44
|
[project.entry-points.pytest11]
|
|
45
45
|
boundaries = "pytest_orm_boundaries.plugin"
|
|
46
46
|
|
|
47
|
+
[tool.uv]
|
|
48
|
+
exclude-newer = "2 weeks"
|
|
49
|
+
|
|
47
50
|
[tool.pytest.ini_options]
|
|
48
51
|
testpaths = ["tests"]
|
|
49
52
|
|
|
@@ -15,7 +15,7 @@ def _is_third_party(*, relative: Path) -> bool:
|
|
|
15
15
|
return not _THIRD_PARTY_SEGMENTS.isdisjoint(relative.parts)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def find_frames_inside_project(*, root: Path) -> list[tuple[str, int]]:
|
|
19
19
|
"""(root-relative path, line) for each in-project frame, innermost first.
|
|
20
20
|
|
|
21
21
|
Skips stdlib (outside root) and installed packages; frames[0] is the line
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Django boundary guard: applies the aggregate-crossing rule to the queries a
|
|
2
|
+
test suite executes and records the crossings.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from pytest_orm_boundaries.callstack import find_frames_inside_project
|
|
12
|
+
from pytest_orm_boundaries.config import (
|
|
13
|
+
load_aggregates_from_config,
|
|
14
|
+
load_ignored_files_from_config,
|
|
15
|
+
)
|
|
16
|
+
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
17
|
+
from pytest_orm_boundaries.model_resolution import resolve_labels
|
|
18
|
+
from pytest_orm_boundaries.sql_parsing import extract_table_names, looks_like_data_query
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
|
|
23
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ViolationRecord:
|
|
28
|
+
"""One offending call place and the tests that reached it.
|
|
29
|
+
|
|
30
|
+
Grouped by call place so a crossing shared by many tests is one entry,
|
|
31
|
+
not one line per test.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
file: str
|
|
35
|
+
line_number: int
|
|
36
|
+
crossed_aggregates: tuple[str, ...] # ("order", "payment")
|
|
37
|
+
joined_models: tuple[str, ...] # ("order.Invoice", "payrolls.IncomePayment")
|
|
38
|
+
tests: set[str] = field(default_factory=set)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BoundaryGuard:
|
|
42
|
+
"""Records aggregate crossings in the queries a test suite executes."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
aggregates_config: dict[str, str],
|
|
48
|
+
ignore_tracker: IgnoreTracker,
|
|
49
|
+
root: Path,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._aggregates_config = aggregates_config
|
|
52
|
+
self._ignore_tracker = ignore_tracker
|
|
53
|
+
self._root_path = Path(root)
|
|
54
|
+
self._current_test: str | None = None
|
|
55
|
+
self._violations: dict[tuple[str, int, tuple[str, ...]], ViolationRecord] = {}
|
|
56
|
+
self._attached_connections: list[BaseDatabaseWrapper] = []
|
|
57
|
+
|
|
58
|
+
def install(self) -> None:
|
|
59
|
+
"""Attach to every DB connection: those open now and any opened later
|
|
60
|
+
(each new connection fires ``connection_created``).
|
|
61
|
+
"""
|
|
62
|
+
from django.db import connections
|
|
63
|
+
from django.db.backends.signals import connection_created
|
|
64
|
+
|
|
65
|
+
connection_created.connect(self._handle_connection_created, weak=False)
|
|
66
|
+
for connection in connections.all(initialized_only=True):
|
|
67
|
+
self._attach_wrapper(connection)
|
|
68
|
+
|
|
69
|
+
def uninstall(self) -> None:
|
|
70
|
+
from django.db.backends.signals import connection_created
|
|
71
|
+
|
|
72
|
+
connection_created.disconnect(self._handle_connection_created)
|
|
73
|
+
for connection in self._attached_connections:
|
|
74
|
+
try:
|
|
75
|
+
connection.execute_wrappers.remove(self._execute_wrapper)
|
|
76
|
+
except ValueError:
|
|
77
|
+
pass
|
|
78
|
+
self._attached_connections.clear()
|
|
79
|
+
|
|
80
|
+
def _handle_connection_created(
|
|
81
|
+
self, *, connection: BaseDatabaseWrapper, **_
|
|
82
|
+
) -> None:
|
|
83
|
+
self._attach_wrapper(connection)
|
|
84
|
+
|
|
85
|
+
def _attach_wrapper(self, connection: BaseDatabaseWrapper) -> None:
|
|
86
|
+
if self._execute_wrapper not in connection.execute_wrappers:
|
|
87
|
+
connection.execute_wrappers.append(self._execute_wrapper)
|
|
88
|
+
self._attached_connections.append(connection)
|
|
89
|
+
|
|
90
|
+
def _execute_wrapper(self, execute, sql, params, many, context):
|
|
91
|
+
if looks_like_data_query(sql):
|
|
92
|
+
self._check_violations_in_query(sql, context["connection"].vendor)
|
|
93
|
+
return execute(sql, params, many, context)
|
|
94
|
+
|
|
95
|
+
def set_current_test(self, nodeid: str | None) -> None:
|
|
96
|
+
"""Remember which test is running so a recorded crossing can name it."""
|
|
97
|
+
self._current_test = nodeid
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def violations(self) -> list[ViolationRecord]:
|
|
101
|
+
"""Recorded crossings, most-affecting first (then by call place)."""
|
|
102
|
+
return sorted(
|
|
103
|
+
self._violations.values(),
|
|
104
|
+
key=lambda v: (-len(v.tests), v.file, v.line_number),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def find_stale_patterns(self) -> list[str]:
|
|
108
|
+
return self._ignore_tracker.find_stale_patterns()
|
|
109
|
+
|
|
110
|
+
def _check_violations_in_query(self, sql: str, vendor: str) -> None:
|
|
111
|
+
"""Handle one executed data query: gather stack context, apply the
|
|
112
|
+
aggregate rule, and record a crossing (unless it's clean or ignored).
|
|
113
|
+
"""
|
|
114
|
+
# ``frames`` is the in-project part of this query's call stack,
|
|
115
|
+
# used by the ignore/stale check and the report.
|
|
116
|
+
frames: list[tuple[str, int]] | None = None
|
|
117
|
+
file_paths: set[str] | None = None
|
|
118
|
+
if self._ignore_tracker.is_active:
|
|
119
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
120
|
+
file_paths = {path for path, _ in frames}
|
|
121
|
+
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
122
|
+
|
|
123
|
+
table_names = extract_table_names(sql, vendor)
|
|
124
|
+
if table_names is None: # unparseable data query -- skip it (see ROADMAP)
|
|
125
|
+
return
|
|
126
|
+
labels = resolve_labels(table_names)
|
|
127
|
+
crossing = self._find_crossing(labels=labels)
|
|
128
|
+
if crossing is None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if file_paths is not None and self._ignore_tracker.has_ignore_for(
|
|
132
|
+
file_paths=file_paths
|
|
133
|
+
):
|
|
134
|
+
self._ignore_tracker.mark_used(file_paths=file_paths)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if frames is None:
|
|
138
|
+
frames = find_frames_inside_project(root=self._root_path)
|
|
139
|
+
self._record_violation(
|
|
140
|
+
call_place=frames[0] if frames else None, crossing=crossing
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _find_crossing(
|
|
144
|
+
self, *, labels: Iterable[str]
|
|
145
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
|
|
146
|
+
"""Return ``(crossed aggregate names, joined model labels)`` if the query
|
|
147
|
+
crosses a boundary, else None.
|
|
148
|
+
|
|
149
|
+
e.g. ``(("order", "payment"), ("order.Invoice", "payrolls.IncomePayment"))``.
|
|
150
|
+
"""
|
|
151
|
+
aggregate_by_label = {
|
|
152
|
+
label: aggregate
|
|
153
|
+
for label in labels
|
|
154
|
+
if (aggregate := self._aggregates_config.get(label.lower())) is not None
|
|
155
|
+
}
|
|
156
|
+
aggregates = set(aggregate_by_label.values())
|
|
157
|
+
if len(aggregates) <= 1:
|
|
158
|
+
return None
|
|
159
|
+
return tuple(sorted(aggregates)), tuple(sorted(aggregate_by_label.keys()))
|
|
160
|
+
|
|
161
|
+
def _record_violation(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
call_place: tuple[str, int] | None,
|
|
165
|
+
crossing: tuple[tuple[str, ...], tuple[str, ...]],
|
|
166
|
+
) -> None:
|
|
167
|
+
crossed_aggregates, joined_models = crossing
|
|
168
|
+
file, line = call_place if call_place is not None else ("<unknown>", 0)
|
|
169
|
+
key = (file, line, crossed_aggregates)
|
|
170
|
+
record = self._violations.get(key)
|
|
171
|
+
if record is None:
|
|
172
|
+
record = ViolationRecord(
|
|
173
|
+
file=file,
|
|
174
|
+
line_number=line,
|
|
175
|
+
crossed_aggregates=crossed_aggregates,
|
|
176
|
+
joined_models=joined_models,
|
|
177
|
+
)
|
|
178
|
+
self._violations[key] = record
|
|
179
|
+
if self._current_test is not None:
|
|
180
|
+
record.tests.add(self._current_test)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
184
|
+
aggregates_by_model = load_aggregates_from_config(path=config_path)
|
|
185
|
+
if not aggregates_by_model:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
ignore_patterns = load_ignored_files_from_config(path=config_path)
|
|
189
|
+
tracker = IgnoreTracker(patterns=ignore_patterns)
|
|
190
|
+
|
|
191
|
+
return BoundaryGuard(
|
|
192
|
+
aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
|
|
193
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Resolve Django model labels for the table names a query reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
|
|
11
|
+
from django.db.models import Model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_labels(table_names: Iterable[str]) -> list[str]:
|
|
15
|
+
"""Resolve model labels for the given table names, dropping unmapped names."""
|
|
16
|
+
table_to_model = map_tables_to_models()
|
|
17
|
+
return [
|
|
18
|
+
table_to_model[table]._meta.label
|
|
19
|
+
for table in table_names
|
|
20
|
+
if table in table_to_model
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@functools.cache
|
|
25
|
+
def map_tables_to_models() -> dict[str, type[Model]]:
|
|
26
|
+
"""Map db_table -> model class for every installed model."""
|
|
27
|
+
from django.apps import apps
|
|
28
|
+
|
|
29
|
+
return {model._meta.db_table: model for model in apps.get_models()}
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/plugin.py
RENAMED
|
@@ -7,13 +7,13 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from pytest_orm_boundaries import
|
|
11
|
-
from pytest_orm_boundaries.
|
|
12
|
-
from pytest_orm_boundaries.read_config import (
|
|
10
|
+
from pytest_orm_boundaries import report
|
|
11
|
+
from pytest_orm_boundaries.config import (
|
|
13
12
|
CONFIG_FILE_NAME,
|
|
14
13
|
BoundariesConfigError,
|
|
15
14
|
discover_config_path,
|
|
16
15
|
)
|
|
16
|
+
from pytest_orm_boundaries.guard import build_guard
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from pytest_orm_boundaries.guard import BoundaryGuard
|
|
@@ -108,15 +108,15 @@ def pytest_terminal_summary(
|
|
|
108
108
|
guard = _installed_guard(config)
|
|
109
109
|
if guard is None:
|
|
110
110
|
return
|
|
111
|
-
|
|
111
|
+
report.report_violations(
|
|
112
112
|
terminalreporter=terminalreporter,
|
|
113
113
|
violations=guard.violations,
|
|
114
114
|
verbose=config.getoption("verbose", 0) > 0,
|
|
115
115
|
)
|
|
116
116
|
stale = guard.find_stale_patterns()
|
|
117
|
-
|
|
117
|
+
report.report_stale_ignores(terminalreporter=terminalreporter, stale=stale)
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
def pytest_report_header(config: pytest.Config) -> str:
|
|
121
121
|
config_path = config.stash.get(config_path_key, None)
|
|
122
|
-
return
|
|
122
|
+
return report.render_header(config_path=config_path)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
-
from pytest_orm_boundaries.
|
|
7
|
+
from pytest_orm_boundaries.config import CONFIG_FILE_NAME
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from pathlib import Path
|
|
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|
|
13
13
|
|
|
14
14
|
from pytest_orm_boundaries.guard import ViolationRecord
|
|
15
15
|
|
|
16
|
-
MAX_TESTS_SHOWN =
|
|
16
|
+
MAX_TESTS_SHOWN = 5
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def report_violations(
|
|
@@ -22,8 +22,8 @@ def report_violations(
|
|
|
22
22
|
violations: list[ViolationRecord],
|
|
23
23
|
verbose: bool = False,
|
|
24
24
|
) -> None:
|
|
25
|
-
"""Print one grouped entry per offending place:
|
|
26
|
-
|
|
25
|
+
"""Print one grouped entry per offending call place: which aggregates the
|
|
26
|
+
query crossed, the models it joined, and which tests reached it.
|
|
27
27
|
"""
|
|
28
28
|
if not violations:
|
|
29
29
|
return
|
|
@@ -58,23 +58,26 @@ def _write_violation(
|
|
|
58
58
|
) -> None:
|
|
59
59
|
terminalreporter.write_line("")
|
|
60
60
|
terminalreporter.write_line(f"{violation.file}:{violation.line_number}", bold=True)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
aggregates = " ↔ ".join(violation.crossed_aggregates)
|
|
62
|
+
terminalreporter.write_line(
|
|
63
|
+
f" crossed aggregates: {aggregates}", yellow=True, bold=True
|
|
64
|
+
)
|
|
65
|
+
models = ", ".join(violation.joined_models)
|
|
66
|
+
terminalreporter.write_line(f" models: {models}")
|
|
64
67
|
|
|
65
68
|
tests = sorted(violation.tests)
|
|
66
69
|
if not tests:
|
|
67
|
-
terminalreporter.write_line(" affected
|
|
70
|
+
terminalreporter.write_line(" tests affected: (none captured)", light=True)
|
|
68
71
|
return
|
|
69
72
|
|
|
70
73
|
shown = tests if verbose else tests[:MAX_TESTS_SHOWN]
|
|
71
|
-
terminalreporter.write_line(f"
|
|
74
|
+
terminalreporter.write_line(f" {len(tests)} test(s) affected:", light=True)
|
|
72
75
|
for nodeid in shown:
|
|
73
|
-
terminalreporter.write_line(f" {nodeid}")
|
|
76
|
+
terminalreporter.write_line(f" {nodeid}", light=True)
|
|
74
77
|
hidden = len(tests) - len(shown)
|
|
75
78
|
if hidden:
|
|
76
79
|
terminalreporter.write_line(
|
|
77
|
-
f" ... +{hidden} more (
|
|
80
|
+
f" ... +{hidden} more (-v to list all)", light=True
|
|
78
81
|
)
|
|
79
82
|
|
|
80
83
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Read the tables a SQL statement touches by parsing the SQL text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import sqlglot
|
|
9
|
+
from sqlglot import expressions as exp
|
|
10
|
+
from sqlglot.errors import SqlglotError
|
|
11
|
+
|
|
12
|
+
# Decreasing amount of work - only these statements read tables,
|
|
13
|
+
# transaction control, PRAGMA and DDL are ignored before we bother parsing.
|
|
14
|
+
_DATA_QUERY = re.compile(
|
|
15
|
+
r"\s*(?:WITH|SELECT|INSERT|UPDATE|DELETE|REPLACE|MERGE)\b", re.IGNORECASE
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Django backend vendor -> sqlglot dialect; unmapped vendors parse dialect-free.
|
|
19
|
+
_DIALECT_BY_VENDOR = {
|
|
20
|
+
"sqlite": "sqlite",
|
|
21
|
+
"postgresql": "postgres",
|
|
22
|
+
"mysql": "mysql",
|
|
23
|
+
"oracle": "oracle",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def looks_like_data_query(sql: str) -> bool:
|
|
28
|
+
"""Whether the statement is one that can read tables (worth parsing)."""
|
|
29
|
+
return _DATA_QUERY.match(sql) is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _blank_placeholders(sql: str) -> str:
|
|
33
|
+
"""Replace ``%s`` / ``%(name)s`` placeholders with a literal so the SQL parses,
|
|
34
|
+
leaving any placeholder-looking text inside quoted strings untouched.
|
|
35
|
+
"""
|
|
36
|
+
placeholder = re.compile(r"""('(?:[^']|'')*')|("(?:[^"]|"")*")|(%\(\w+\)s|%s)""")
|
|
37
|
+
|
|
38
|
+
return placeholder.sub(
|
|
39
|
+
lambda match: "NULL" if match.group(3) else match.group(0), sql
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@functools.lru_cache(maxsize=4096)
|
|
44
|
+
def extract_table_names(sql: str, vendor: str) -> frozenset[str] | None:
|
|
45
|
+
"""Names of the tables the statement reads, or None if it could not be parsed."""
|
|
46
|
+
dialect = _DIALECT_BY_VENDOR.get(vendor)
|
|
47
|
+
blanked_sql = _blank_placeholders(sql)
|
|
48
|
+
try:
|
|
49
|
+
tree = sqlglot.parse_one(blanked_sql, dialect=dialect)
|
|
50
|
+
except (SqlglotError, RecursionError):
|
|
51
|
+
return None
|
|
52
|
+
if tree is None:
|
|
53
|
+
return frozenset()
|
|
54
|
+
# common table expression - can be named as an existed table, but it is not a table
|
|
55
|
+
# e.g. `report` in ` WITH report AS (SELECT id FROM orders WHERE created > '2026-01-01')
|
|
56
|
+
cte_names = {cte.alias_or_name for cte in tree.find_all(exp.CTE)}
|
|
57
|
+
tables = {table.name for table in tree.find_all(exp.Table)}
|
|
58
|
+
return frozenset(tables - cte_names)
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
"""Django boundary guard: the aggregate-crossing rule and the query
|
|
2
|
-
interception that records crossings.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import functools
|
|
8
|
-
import linecache
|
|
9
|
-
from dataclasses import dataclass, field
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import TYPE_CHECKING
|
|
12
|
-
|
|
13
|
-
from pytest_orm_boundaries.call_stack import find_in_project_frames
|
|
14
|
-
from pytest_orm_boundaries.ignores import IgnoreTracker
|
|
15
|
-
from pytest_orm_boundaries.read_config import (
|
|
16
|
-
load_aggregates_from_config,
|
|
17
|
-
load_ignored_files_from_config,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
if TYPE_CHECKING:
|
|
21
|
-
from collections.abc import Callable, Iterable
|
|
22
|
-
from typing import Any
|
|
23
|
-
|
|
24
|
-
from django.db.models import Model
|
|
25
|
-
from django.db.models.sql.query import Query
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass
|
|
29
|
-
class ViolationRecord:
|
|
30
|
-
"""One offending call place and the tests that reached it.
|
|
31
|
-
|
|
32
|
-
Grouped by call place so a crossing shared by many tests is one entry,
|
|
33
|
-
not one line per test.
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
file: str
|
|
37
|
-
line_number: int
|
|
38
|
-
line_code: str
|
|
39
|
-
crossed: str
|
|
40
|
-
tests: set[str] = field(default_factory=set)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class BoundaryGuard:
|
|
44
|
-
"""Intercepts Django's executed queries and records aggregate crossings."""
|
|
45
|
-
|
|
46
|
-
def __init__(
|
|
47
|
-
self,
|
|
48
|
-
*,
|
|
49
|
-
aggregates_config: dict[str, str],
|
|
50
|
-
ignore_tracker: IgnoreTracker,
|
|
51
|
-
root: Path,
|
|
52
|
-
) -> None:
|
|
53
|
-
self._aggregates_config = aggregates_config
|
|
54
|
-
self._ignore_tracker = ignore_tracker
|
|
55
|
-
self._root = Path(root)
|
|
56
|
-
self._original_execute_sql: Callable[..., Any] | None = None
|
|
57
|
-
self._current_test: str | None = None
|
|
58
|
-
self._violations: dict[tuple[str, int, str], ViolationRecord] = {}
|
|
59
|
-
|
|
60
|
-
def install(self) -> None:
|
|
61
|
-
from django.db.models.sql.compiler import SQLCompiler
|
|
62
|
-
|
|
63
|
-
original_execute_sql = SQLCompiler.execute_sql
|
|
64
|
-
self._original_execute_sql = original_execute_sql
|
|
65
|
-
|
|
66
|
-
def patched_execute_sql(compiler, *args, **kwargs):
|
|
67
|
-
self._handle_query(query=compiler.query)
|
|
68
|
-
return original_execute_sql(compiler, *args, **kwargs)
|
|
69
|
-
|
|
70
|
-
SQLCompiler.execute_sql = patched_execute_sql
|
|
71
|
-
|
|
72
|
-
def uninstall(self) -> None:
|
|
73
|
-
from django.db.models.sql.compiler import SQLCompiler
|
|
74
|
-
|
|
75
|
-
SQLCompiler.execute_sql = self._original_execute_sql
|
|
76
|
-
|
|
77
|
-
def set_current_test(self, nodeid: str | None) -> None:
|
|
78
|
-
"""Remember which test is running so a recorded crossing can name it."""
|
|
79
|
-
self._current_test = nodeid
|
|
80
|
-
|
|
81
|
-
@property
|
|
82
|
-
def violations(self) -> list[ViolationRecord]:
|
|
83
|
-
return sorted(self._violations.values(), key=lambda v: (v.file, v.line_number))
|
|
84
|
-
|
|
85
|
-
def find_stale_patterns(self) -> list[str]:
|
|
86
|
-
return self._ignore_tracker.find_stale_patterns()
|
|
87
|
-
|
|
88
|
-
def _handle_query(self, *, query: Query) -> None:
|
|
89
|
-
"""Handle one executed query: gather stack context, apply the aggregate
|
|
90
|
-
rule, and record a crossing (unless it's clean or its place is ignored).
|
|
91
|
-
"""
|
|
92
|
-
# ``frames`` is the in-project part of this query's call stack,
|
|
93
|
-
# used by the ignore/stale check and the report.
|
|
94
|
-
frames: list[tuple[str, int]] | None = None
|
|
95
|
-
file_paths: list[str] | None = None
|
|
96
|
-
if self._ignore_tracker.is_active:
|
|
97
|
-
frames = find_in_project_frames(root=self._root)
|
|
98
|
-
file_paths = self._extract_file_paths(frames)
|
|
99
|
-
self._ignore_tracker.mark_seen(file_paths=file_paths)
|
|
100
|
-
|
|
101
|
-
labels = self._read_labels(query=query)
|
|
102
|
-
crossed = self._find_crossing(labels=labels)
|
|
103
|
-
if crossed is None:
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
if file_paths is not None and self._ignore_tracker.has_ignore_for(
|
|
107
|
-
file_paths=file_paths
|
|
108
|
-
):
|
|
109
|
-
self._ignore_tracker.mark_used(file_paths=file_paths)
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
if frames is None:
|
|
113
|
-
frames = find_in_project_frames(root=self._root)
|
|
114
|
-
self._record_violation(call_place=frames[0] if frames else None, crossed=crossed)
|
|
115
|
-
|
|
116
|
-
def _read_labels(self, *, query: Query) -> list[str]:
|
|
117
|
-
"""Model labels of the tables the query actually reads.
|
|
118
|
-
|
|
119
|
-
A filter on a foreign-key id reads a column already on the current
|
|
120
|
-
table, so the table it points to isn't read -- and isn't counted.
|
|
121
|
-
"""
|
|
122
|
-
table_to_model = _map_tables_to_models()
|
|
123
|
-
return [
|
|
124
|
-
table_to_model[table.table_name]._meta.label
|
|
125
|
-
for alias, table in query.alias_map.items()
|
|
126
|
-
if table.table_name in table_to_model
|
|
127
|
-
and query.alias_refcount.get(alias, 0) > 0
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
def _find_crossing(self, *, labels: Iterable[str]) -> str | None:
|
|
131
|
-
"""Return the crossed aggregate names ("order, payment") if the query
|
|
132
|
-
crosses a boundary, else None."""
|
|
133
|
-
aggregates = {
|
|
134
|
-
aggregate
|
|
135
|
-
for label in labels
|
|
136
|
-
if (aggregate := self._aggregates_config.get(label.lower())) is not None
|
|
137
|
-
}
|
|
138
|
-
if len(aggregates) <= 1:
|
|
139
|
-
return None
|
|
140
|
-
return ", ".join(sorted(aggregates))
|
|
141
|
-
|
|
142
|
-
def _record_violation(self, *, call_place: tuple[str, int] | None, crossed: str) -> None:
|
|
143
|
-
file, line = call_place if call_place is not None else ("<unknown>", 0)
|
|
144
|
-
key = (file, line, crossed)
|
|
145
|
-
record = self._violations.get(key)
|
|
146
|
-
if record is None:
|
|
147
|
-
line_code = self._read_line_code(file=file, line=line)
|
|
148
|
-
record = ViolationRecord(
|
|
149
|
-
file=file, line_number=line, line_code=line_code, crossed=crossed
|
|
150
|
-
)
|
|
151
|
-
self._violations[key] = record
|
|
152
|
-
if self._current_test is not None:
|
|
153
|
-
record.tests.add(self._current_test)
|
|
154
|
-
|
|
155
|
-
def _read_line_code(self, *, file: str, line: int) -> str:
|
|
156
|
-
"""The offending line of code, stripped - or "" if it can't be read."""
|
|
157
|
-
if line <= 0 or file == "<unknown>":
|
|
158
|
-
return ""
|
|
159
|
-
absolute_path = str(self._root / file)
|
|
160
|
-
return linecache.getline(absolute_path, line).strip()
|
|
161
|
-
|
|
162
|
-
@staticmethod
|
|
163
|
-
def _extract_file_paths(frames: Iterable[tuple[str, int]]) -> set[str]:
|
|
164
|
-
return {path for path, _ in frames}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
|
|
168
|
-
aggregates_by_model = load_aggregates_from_config(path=config_path)
|
|
169
|
-
if not aggregates_by_model:
|
|
170
|
-
return None
|
|
171
|
-
|
|
172
|
-
ignore_patterns = load_ignored_files_from_config(path=config_path)
|
|
173
|
-
tracker = IgnoreTracker(patterns=ignore_patterns)
|
|
174
|
-
|
|
175
|
-
return BoundaryGuard(
|
|
176
|
-
aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
@functools.cache
|
|
181
|
-
def _map_tables_to_models() -> dict[str, type[Model]]:
|
|
182
|
-
"""Map db_table -> model class for every installed model."""
|
|
183
|
-
from django.apps import apps
|
|
184
|
-
|
|
185
|
-
return {model._meta.db_table: model for model in apps.get_models()}
|
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{pytest_orm_boundaries-0.3.0 → pytest_orm_boundaries-0.4.0}/src/pytest_orm_boundaries/ignores.py
RENAMED
|
File without changes
|