pytest-orm-boundaries 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-orm-boundaries
3
- Version: 0.3.1
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 queries your test suite executes and
45
- reports the ones that step outside their aggregate, whether through `__`
46
- lookups, subqueries, or other joins.
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/purchases.py:29
80
- code: return list(Purchase.objects.filter(client__name=name))
81
- crosses: client, purchase
82
- affected tests (1):
83
- test_purchases.py::test_get_purchases_by_client_name
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
- The run exits non-zero when there are violations, so CI catches them.
89
- Pass `-v` to list all affected tests.
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 queries your test suite executes and
19
- reports the ones that step outside their aggregate, whether through `__`
20
- lookups, subqueries, or other joins.
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/purchases.py:29
54
- code: return list(Purchase.objects.filter(client__name=name))
55
- crosses: client, purchase
56
- affected tests (1):
57
- test_purchases.py::test_get_purchases_by_client_name
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
- The run exits non-zero when there are violations, so CI catches them.
63
- Pass `-v` to list all affected tests.
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.3.1"
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 find_in_project_frames(*, root: Path) -> list[tuple[str, int]]:
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
@@ -1,27 +1,26 @@
1
- """Django boundary guard: the aggregate-crossing rule and the query
2
- interception that records crossings.
1
+ """Django boundary guard: applies the aggregate-crossing rule to the queries a
2
+ test suite executes and records the crossings.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import functools
8
7
  from dataclasses import dataclass, field
9
8
  from pathlib import Path
10
9
  from typing import TYPE_CHECKING
11
10
 
12
- from pytest_orm_boundaries.call_stack import find_in_project_frames
13
- from pytest_orm_boundaries.ignores import IgnoreTracker
14
- from pytest_orm_boundaries.read_config import (
11
+ from pytest_orm_boundaries.callstack import find_frames_inside_project
12
+ from pytest_orm_boundaries.config import (
15
13
  load_aggregates_from_config,
16
14
  load_ignored_files_from_config,
17
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
18
19
 
19
20
  if TYPE_CHECKING:
20
- from collections.abc import Callable, Iterable
21
- from typing import Any
21
+ from collections.abc import Iterable
22
22
 
23
- from django.db.models import Model
24
- from django.db.models.sql.query import Query
23
+ from django.db.backends.base.base import BaseDatabaseWrapper
25
24
 
26
25
 
27
26
  @dataclass
@@ -40,7 +39,7 @@ class ViolationRecord:
40
39
 
41
40
 
42
41
  class BoundaryGuard:
43
- """Intercepts Django's executed queries and records aggregate crossings."""
42
+ """Records aggregate crossings in the queries a test suite executes."""
44
43
 
45
44
  def __init__(
46
45
  self,
@@ -51,27 +50,47 @@ class BoundaryGuard:
51
50
  ) -> None:
52
51
  self._aggregates_config = aggregates_config
53
52
  self._ignore_tracker = ignore_tracker
54
- self._root = Path(root)
55
- self._original_execute_sql: Callable[..., Any] | None = None
53
+ self._root_path = Path(root)
56
54
  self._current_test: str | None = None
57
55
  self._violations: dict[tuple[str, int, tuple[str, ...]], ViolationRecord] = {}
56
+ self._attached_connections: list[BaseDatabaseWrapper] = []
58
57
 
59
58
  def install(self) -> None:
60
- from django.db.models.sql.compiler import SQLCompiler
61
-
62
- original_execute_sql = SQLCompiler.execute_sql
63
- self._original_execute_sql = original_execute_sql
64
-
65
- def patched_execute_sql(compiler, *args, **kwargs):
66
- self._handle_query(query=compiler.query)
67
- return original_execute_sql(compiler, *args, **kwargs)
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
68
64
 
69
- SQLCompiler.execute_sql = patched_execute_sql
65
+ connection_created.connect(self._handle_connection_created, weak=False)
66
+ for connection in connections.all(initialized_only=True):
67
+ self._attach_wrapper(connection)
70
68
 
71
69
  def uninstall(self) -> None:
72
- from django.db.models.sql.compiler import SQLCompiler
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)
73
84
 
74
- SQLCompiler.execute_sql = self._original_execute_sql
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)
75
94
 
76
95
  def set_current_test(self, nodeid: str | None) -> None:
77
96
  """Remember which test is running so a recorded crossing can name it."""
@@ -88,20 +107,23 @@ class BoundaryGuard:
88
107
  def find_stale_patterns(self) -> list[str]:
89
108
  return self._ignore_tracker.find_stale_patterns()
90
109
 
91
- def _handle_query(self, *, query: Query) -> None:
92
- """Handle one executed query: gather stack context, apply the aggregate
93
- rule, and record a crossing (unless it's clean or its place is ignored).
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).
94
113
  """
95
114
  # ``frames`` is the in-project part of this query's call stack,
96
115
  # used by the ignore/stale check and the report.
97
116
  frames: list[tuple[str, int]] | None = None
98
- file_paths: list[str] | None = None
117
+ file_paths: set[str] | None = None
99
118
  if self._ignore_tracker.is_active:
100
- frames = find_in_project_frames(root=self._root)
101
- file_paths = self._extract_file_paths(frames)
119
+ frames = find_frames_inside_project(root=self._root_path)
120
+ file_paths = {path for path, _ in frames}
102
121
  self._ignore_tracker.mark_seen(file_paths=file_paths)
103
122
 
104
- labels = self._read_labels(query=query)
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)
105
127
  crossing = self._find_crossing(labels=labels)
106
128
  if crossing is None:
107
129
  return
@@ -113,25 +135,11 @@ class BoundaryGuard:
113
135
  return
114
136
 
115
137
  if frames is None:
116
- frames = find_in_project_frames(root=self._root)
138
+ frames = find_frames_inside_project(root=self._root_path)
117
139
  self._record_violation(
118
140
  call_place=frames[0] if frames else None, crossing=crossing
119
141
  )
120
142
 
121
- def _read_labels(self, *, query: Query) -> list[str]:
122
- """Model labels of the tables the query actually reads.
123
-
124
- A filter on a foreign-key id reads a column already on the current
125
- table, so the table it points to isn't read -- and isn't counted.
126
- """
127
- table_to_model = _map_tables_to_models()
128
- return [
129
- table_to_model[table.table_name]._meta.label
130
- for alias, table in query.alias_map.items()
131
- if table.table_name in table_to_model
132
- and query.alias_refcount.get(alias, 0) > 0
133
- ]
134
-
135
143
  def _find_crossing(
136
144
  self, *, labels: Iterable[str]
137
145
  ) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
@@ -171,10 +179,6 @@ class BoundaryGuard:
171
179
  if self._current_test is not None:
172
180
  record.tests.add(self._current_test)
173
181
 
174
- @staticmethod
175
- def _extract_file_paths(frames: Iterable[tuple[str, int]]) -> set[str]:
176
- return {path for path, _ in frames}
177
-
178
182
 
179
183
  def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
180
184
  aggregates_by_model = load_aggregates_from_config(path=config_path)
@@ -187,11 +191,3 @@ def build_guard(*, rootpath: Path, config_path: Path) -> BoundaryGuard | None:
187
191
  return BoundaryGuard(
188
192
  aggregates_config=aggregates_by_model, ignore_tracker=tracker, root=rootpath
189
193
  )
190
-
191
-
192
- @functools.cache
193
- def _map_tables_to_models() -> dict[str, type[Model]]:
194
- """Map db_table -> model class for every installed model."""
195
- from django.apps import apps
196
-
197
- return {model._meta.db_table: model for model in apps.get_models()}
@@ -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()}
@@ -7,13 +7,13 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  import pytest
9
9
 
10
- from pytest_orm_boundaries import build_report
11
- from pytest_orm_boundaries.guard import build_guard
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
- build_report.report_violations(
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
- build_report.report_stale_ignores(terminalreporter=terminalreporter, stale=stale)
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 build_report.render_header(config_path=config_path)
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.read_config import CONFIG_FILE_NAME
7
+ from pytest_orm_boundaries.config import CONFIG_FILE_NAME
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from pathlib import Path
@@ -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)