sql-safe-mcp 1.5.0__tar.gz → 1.5.2__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 (35) hide show
  1. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/CHANGELOG.md +22 -0
  2. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/PKG-INFO +16 -13
  3. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/README.md +15 -12
  4. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/pyproject.toml +2 -2
  5. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/server.json +2 -2
  6. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/__init__.py +1 -1
  7. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/config.py +5 -1
  8. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/mcp_server.py +3 -3
  9. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/lineage.py +4 -1
  10. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/parser.py +7 -0
  11. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/service.py +51 -19
  12. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/.gitignore +0 -0
  13. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/LICENSE +0 -0
  14. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-multi.yaml +0 -0
  15. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-shared.yaml +0 -0
  16. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-simple.yaml +0 -0
  17. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/__main__.py +0 -0
  18. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/__init__.py +0 -0
  19. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/extras.py +0 -0
  20. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/mysql.py +0 -0
  21. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/reflection.py +0 -0
  22. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/registry.py +0 -0
  23. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/sqlserver.py +0 -0
  24. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/diagnostics.py +0 -0
  25. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/errors.py +0 -0
  26. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/models.py +0 -0
  27. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/__init__.py +0 -0
  28. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/dialect.py +0 -0
  29. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/executor.py +0 -0
  30. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/pipeline.py +0 -0
  31. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/policy.py +0 -0
  32. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/reasons.py +0 -0
  33. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/schema.py +0 -0
  34. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/tokens.py +0 -0
  35. {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/validated_query.py +0 -0
@@ -7,6 +7,28 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.2] - 2026-10-05
11
+
12
+ ### Security
13
+
14
+ - `execute_sql` now rejects a projection whose alias names anything other than a direct column,
15
+ a literal, or `COUNT(*)`, such as `SELECT (Email) AS e FROM Users`. Before, the expression was
16
+ classified as a literal, so a protected column wrapped in parentheses was returned in clear,
17
+ and on MySQL/MariaDB a projected comparison such as `(Email > 'm') AS x` acted as an oracle on
18
+ the protected value. The lineage stage also fails closed on any untraceable projection.
19
+
20
+ ## [1.5.1] - 2026-09-23
21
+
22
+ ### Fixed
23
+
24
+ - Malformed `connection_url` values now return a sanitized `CONFIG_ERROR` instead of an unhandled
25
+ traceback. DBAPI errors now prefer supported SQLSTATE and native error codes over driver text.
26
+
27
+ ### Security
28
+
29
+ - The Bash and PowerShell demo seeding scripts reject reader-password characters unsafe for their
30
+ SQL command boundary.
31
+
10
32
  ## [1.5.0] - 2026-09-22
11
33
 
12
34
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-safe-mcp
3
- Version: 1.5.0
3
+ Version: 1.5.2
4
4
  Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
5
5
  License: MIT License
6
6
 
@@ -83,7 +83,7 @@ query real rows while configured personal data stays hidden behind authenticated
83
83
 
84
84
  - **Fails closed** - SQL validation is an allowlist. Unknown syntax, unresolved lineage, and
85
85
  unsupported protected-value types are refused, not guessed at. The verification evidence is in
86
- the [security model](SECURITY-MODEL.md).
86
+ the [security model](docs/security-model.md).
87
87
  - **Least access first** - `access_level: metadata` (the default) exposes database navigation and
88
88
  table structure; `meta_and_code` additionally exposes stored procedures; `execute_sql` needs an
89
89
  explicit `all_pii_safe` alias with its own key. Database permissions stay the primary control,
@@ -98,7 +98,7 @@ query real rows while configured personal data stays hidden behind authenticated
98
98
  adversarial corpus of hostile statements, property-based tests (token isolation, tamper
99
99
  resistance), a live attack run against a really writable login with before/after snapshots, and
100
100
  mutation testing. Results are in
101
- [Verification of the SQL boundary](SECURITY-MODEL.md#verification-of-the-sql-boundary).
101
+ [Verification of the SQL boundary](docs/security-model.md#verification-of-the-sql-boundary).
102
102
  - **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
103
103
  log records the connection stage, elapsed time, and driver error for the same reference, with
104
104
  credentials removed. See [Logging](docs/configuration.md#logging).
@@ -117,7 +117,7 @@ query real rows while configured personal data stays hidden behind authenticated
117
117
  > **Status:** SQL Server supports every tool. MySQL and MariaDB (`engine: mysql` or `mariadb`,
118
118
  > `mysql+pymysql` URLs) support every tool too. `schema` is always `null` there because the
119
119
  > database is the catalog, and `execute_sql` uses `LIMIT` instead of `TOP`. See
120
- > [ARCHITECTURE.md](ARCHITECTURE.md).
120
+ > [architecture.md](docs/architecture.md).
121
121
 
122
122
  ## Quick start
123
123
 
@@ -133,14 +133,14 @@ or
133
133
  pip install sql-safe-mcp
134
134
  ```
135
135
 
136
- Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.0`.
136
+ Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
137
137
 
138
- Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
138
+ Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
139
139
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
140
140
  when you connect to SQL Server. MySQL and MariaDB use the bundled PyMySQL driver and need nothing
141
141
  else.
142
142
 
143
- Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [CHECKS.md](CHECKS.md)).
143
+ Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [checks.md](docs/checks.md)).
144
144
 
145
145
  <details>
146
146
  <summary><b>Or you can even ask an agent to install it</b></summary>
@@ -265,23 +265,26 @@ only to stderr.
265
265
  ```
266
266
 
267
267
  can return a row such as `[42, "pii:v1:..."]`: the agent can follow the customer without reading
268
- the email address. A token is accepted only in `=` and `IN` predicates on the same protected column
269
- and server alias. Protection covers the columns you list, so list every column that holds personal
270
- data.
268
+ the email address. A token is accepted only in `=` and `IN` predicates on the same server alias.
269
+ If `FirstName` is protected, `SELECT FirstName FROM Users WHERE FirstName = 'Alice'` is rejected
270
+ before it reaches the database. Filter protected columns only with authentic `pii:v1:...` tokens
271
+ returned by `execute_sql` for the same alias; a matching prefix alone is not enough. Each token is
272
+ authenticated and alias-bound, then validated and converted to a bound database value.
273
+ Protection covers the columns you list, so list every column that holds personal data.
271
274
 
272
275
  ## Documentation
273
276
 
274
277
  - [Configuration reference](docs/configuration.md) - aliases, connection URLs, PII rules, runtime
275
278
  limits, and logging.
276
279
  - [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
277
- - [Security model](SECURITY-MODEL.md) - trust boundaries, guarantees, limitations, and verification.
278
- - [Checks](CHECKS.md) - local, integration, and security verification commands.
280
+ - [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
281
+ - [Checks](docs/checks.md) - local, integration, and security verification commands.
279
282
 
280
283
  ## Security
281
284
 
282
285
  The MCP caller, SQL input, database metadata, rows, and tokens are untrusted. Database permissions
283
286
  remain the primary authorization control - this server never widens them. Read the full
284
- [security model](SECURITY-MODEL.md), including its limitations and verification evidence. To report
287
+ [security model](docs/security-model.md), including its limitations and verification evidence. To report
285
288
  a vulnerability, use the private channel in [SECURITY.md](SECURITY.md).
286
289
 
287
290
  ## Contributing
@@ -45,7 +45,7 @@ query real rows while configured personal data stays hidden behind authenticated
45
45
 
46
46
  - **Fails closed** - SQL validation is an allowlist. Unknown syntax, unresolved lineage, and
47
47
  unsupported protected-value types are refused, not guessed at. The verification evidence is in
48
- the [security model](SECURITY-MODEL.md).
48
+ the [security model](docs/security-model.md).
49
49
  - **Least access first** - `access_level: metadata` (the default) exposes database navigation and
50
50
  table structure; `meta_and_code` additionally exposes stored procedures; `execute_sql` needs an
51
51
  explicit `all_pii_safe` alias with its own key. Database permissions stay the primary control,
@@ -60,7 +60,7 @@ query real rows while configured personal data stays hidden behind authenticated
60
60
  adversarial corpus of hostile statements, property-based tests (token isolation, tamper
61
61
  resistance), a live attack run against a really writable login with before/after snapshots, and
62
62
  mutation testing. Results are in
63
- [Verification of the SQL boundary](SECURITY-MODEL.md#verification-of-the-sql-boundary).
63
+ [Verification of the SQL boundary](docs/security-model.md#verification-of-the-sql-boundary).
64
64
  - **Diagnosable failures** - a timeout or connection error carries a `Reference`, and the stderr
65
65
  log records the connection stage, elapsed time, and driver error for the same reference, with
66
66
  credentials removed. See [Logging](docs/configuration.md#logging).
@@ -79,7 +79,7 @@ query real rows while configured personal data stays hidden behind authenticated
79
79
  > **Status:** SQL Server supports every tool. MySQL and MariaDB (`engine: mysql` or `mariadb`,
80
80
  > `mysql+pymysql` URLs) support every tool too. `schema` is always `null` there because the
81
81
  > database is the catalog, and `execute_sql` uses `LIMIT` instead of `TOP`. See
82
- > [ARCHITECTURE.md](ARCHITECTURE.md).
82
+ > [architecture.md](docs/architecture.md).
83
83
 
84
84
  ## Quick start
85
85
 
@@ -95,14 +95,14 @@ or
95
95
  pip install sql-safe-mcp
96
96
  ```
97
97
 
98
- Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.0`.
98
+ Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
99
99
 
100
- Requires Python 3.12+, [uv](https://docs.astral.sh/uv/) (or `pip`), and
100
+ Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
101
101
  [Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
102
102
  when you connect to SQL Server. MySQL and MariaDB use the bundled PyMySQL driver and need nothing
103
103
  else.
104
104
 
105
- Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [CHECKS.md](CHECKS.md)).
105
+ Verified against SQL Server 2022, MySQL 8.4, and MariaDB 11.4 (see [checks.md](docs/checks.md)).
106
106
 
107
107
  <details>
108
108
  <summary><b>Or you can even ask an agent to install it</b></summary>
@@ -227,23 +227,26 @@ only to stderr.
227
227
  ```
228
228
 
229
229
  can return a row such as `[42, "pii:v1:..."]`: the agent can follow the customer without reading
230
- the email address. A token is accepted only in `=` and `IN` predicates on the same protected column
231
- and server alias. Protection covers the columns you list, so list every column that holds personal
232
- data.
230
+ the email address. A token is accepted only in `=` and `IN` predicates on the same server alias.
231
+ If `FirstName` is protected, `SELECT FirstName FROM Users WHERE FirstName = 'Alice'` is rejected
232
+ before it reaches the database. Filter protected columns only with authentic `pii:v1:...` tokens
233
+ returned by `execute_sql` for the same alias; a matching prefix alone is not enough. Each token is
234
+ authenticated and alias-bound, then validated and converted to a bound database value.
235
+ Protection covers the columns you list, so list every column that holds personal data.
233
236
 
234
237
  ## Documentation
235
238
 
236
239
  - [Configuration reference](docs/configuration.md) - aliases, connection URLs, PII rules, runtime
237
240
  limits, and logging.
238
241
  - [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
239
- - [Security model](SECURITY-MODEL.md) - trust boundaries, guarantees, limitations, and verification.
240
- - [Checks](CHECKS.md) - local, integration, and security verification commands.
242
+ - [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
243
+ - [Checks](docs/checks.md) - local, integration, and security verification commands.
241
244
 
242
245
  ## Security
243
246
 
244
247
  The MCP caller, SQL input, database metadata, rows, and tokens are untrusted. Database permissions
245
248
  remain the primary authorization control - this server never widens them. Read the full
246
- [security model](SECURITY-MODEL.md), including its limitations and verification evidence. To report
249
+ [security model](docs/security-model.md), including its limitations and verification evidence. To report
247
250
  a vulnerability, use the private channel in [SECURITY.md](SECURITY.md).
248
251
 
249
252
  ## Contributing
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-safe-mcp"
3
- version = "1.5.0"
3
+ version = "1.5.2"
4
4
  description = "Minimal, read-only, PII-safe MCP server for SQL databases."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.15"
@@ -62,7 +62,7 @@ markers = [
62
62
 
63
63
  [tool.mutmut]
64
64
  source_paths = ["src/sql_safe_mcp"]
65
- also_copy = ["server.json", "README.md"]
65
+ also_copy = ["server.json", "README.md", "tools", "scripts", "compose.sqlserver.yml", "compose.mysql.yml"]
66
66
  only_mutate = ["*security/*.py", "*config.py"]
67
67
  mutate_only_covered_lines = true
68
68
  pytest_add_cli_args = ["-q", "-m", "not integration and not deep", "-p", "no:cacheprovider"]
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/proprock/sql-safe-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.5.0",
10
+ "version": "1.5.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "sql-safe-mcp",
15
- "version": "1.5.0",
15
+ "version": "1.5.2",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1,3 +1,3 @@
1
1
  """Minimal, PII-safe MCP server for relational databases."""
2
2
 
3
- __version__ = "1.5.0"
3
+ __version__ = "1.5.2"
@@ -13,6 +13,7 @@ from urllib.parse import quote
13
13
  import yaml
14
14
  from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, model_validator
15
15
  from sqlalchemy.engine import make_url
16
+ from sqlalchemy.exc import ArgumentError
16
17
 
17
18
  from sql_safe_mcp.errors import DomainError, ErrorCode
18
19
 
@@ -113,7 +114,10 @@ class ServerConfigBase(BaseModel):
113
114
 
114
115
  @model_validator(mode="after")
115
116
  def validate_connection_url(self) -> ServerConfigBase:
116
- url = make_url(self.connection_url.get_secret_value())
117
+ try:
118
+ url = make_url(self.connection_url.get_secret_value())
119
+ except ArgumentError as exc:
120
+ raise ValueError("connection_url must be a valid SQLAlchemy URL") from exc
117
121
  if url.host and _PERCENT_ESCAPE.search(url.host):
118
122
  raise ValueError(
119
123
  "connection_url host contains an encoded character; a ${NAME} placeholder inside "
@@ -8,6 +8,7 @@ from mcp.server.mcpserver import Context, MCPServer
8
8
  from mcp.server.mcpserver.exceptions import ToolError
9
9
  from mcp_types import ToolAnnotations
10
10
 
11
+ from sql_safe_mcp import __version__
11
12
  from sql_safe_mcp.config import AppConfig
12
13
  from sql_safe_mcp.db.registry import EngineRegistry
13
14
  from sql_safe_mcp.diagnostics import configure_logging
@@ -29,7 +30,6 @@ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
29
30
  @dataclass(slots=True)
30
31
  class AppContext:
31
32
  service: DatabaseService
32
- registry: EngineRegistry
33
33
 
34
34
 
35
35
  async def _domain_call[T](operation: Callable[[], Awaitable[T]]) -> T:
@@ -44,7 +44,7 @@ def create_server(config: AppConfig) -> MCPServer[AppContext]:
44
44
  async def lifespan(_server: MCPServer[AppContext]) -> AsyncIterator[AppContext]:
45
45
  registry = EngineRegistry(config)
46
46
  try:
47
- yield AppContext(service=DatabaseService(config, registry), registry=registry)
47
+ yield AppContext(service=DatabaseService(config, registry))
48
48
  finally:
49
49
  registry.dispose()
50
50
 
@@ -52,7 +52,7 @@ def create_server(config: AppConfig) -> MCPServer[AppContext]:
52
52
  server: MCPServer[AppContext] = MCPServer(
53
53
  "sql-safe-mcp",
54
54
  description="Minimal, read-only, PII-safe SQL database navigation.",
55
- version="1.5.0",
55
+ version=__version__,
56
56
  lifespan=lifespan,
57
57
  log_level=config.logging.level,
58
58
  )
@@ -142,8 +142,11 @@ def _analyze_projections(
142
142
  outputs.append(OutputColumn(alias or canonical, "column", source, binding.name))
143
143
  elif isinstance(value, exp.Count):
144
144
  outputs.append(OutputColumn(alias or f"column_{index}", "count", None))
145
- else:
145
+ elif isinstance(value, exp.Literal | exp.Null | exp.Neg):
146
146
  outputs.append(OutputColumn(alias or f"column_{index}", "literal", None))
147
+ else:
148
+ # Fail closed: a projection whose source cannot be traced is never a "literal".
149
+ raise reject(Reason.PROJECTION_UNSUPPORTED)
147
150
  return outputs
148
151
 
149
152
 
@@ -80,6 +80,10 @@ _ALLOWED_ARGS: dict[type[exp.Expr], frozenset[str]] = {
80
80
  **{comparison: frozenset({"this", "expression"}) for comparison in _COMPARISONS},
81
81
  }
82
82
  _PLACEHOLDER_ARGS = frozenset({"this"})
83
+ # What an alias may name: a direct column, a literal, or COUNT(*). Anything else (a Paren, a
84
+ # comparison, ...) would be invisible to lineage and could project a protected column in clear.
85
+ # A Star under an alias is still rejected by the Star branch below with its own reason.
86
+ _ALIASED = (exp.Column, exp.Star, exp.Literal, exp.Null, exp.Neg, exp.Count)
83
87
  _PROJECTIONS = (exp.Column, exp.Star, exp.Literal, exp.Null, exp.Neg, exp.Alias, exp.Count)
84
88
 
85
89
 
@@ -110,6 +114,9 @@ def _check_node(node: exp.Expr, allow_placeholders: bool) -> None:
110
114
  elif isinstance(node, exp.Column):
111
115
  if not isinstance(node.this, (exp.Identifier, exp.Star)):
112
116
  raise reject(Reason.COLUMN_REFERENCE_UNSUPPORTED)
117
+ elif isinstance(node, exp.Alias):
118
+ if not isinstance(node.this, _ALIASED):
119
+ raise reject(Reason.PROJECTION_UNSUPPORTED)
113
120
  elif isinstance(node, exp.Count):
114
121
  if not isinstance(node.this, exp.Star):
115
122
  raise reject(Reason.COUNT_STAR_ONLY)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import re
4
5
  import time
5
6
  from collections.abc import Callable
6
7
  from typing import TypeVar
@@ -42,6 +43,12 @@ logger = logging.getLogger(__name__)
42
43
  CatalogFactory = Callable[[Connection, str, str], TableCatalog]
43
44
  _SCHEMA_CACHE_ENTRIES = 1024
44
45
  _SCHEMA_CACHE_TTL_SECONDS = 300.0
46
+ _TIMEOUT_SQLSTATES = frozenset({"HYT00", "HYT01"})
47
+ _CONNECTION_SQLSTATES = frozenset({"08001", "08004"})
48
+ _ACCESS_DENIED_SQLSTATES = frozenset({"28000"})
49
+ _TIMEOUT_NATIVE_CODES = frozenset({-2})
50
+ _CONNECTION_NATIVE_CODES = frozenset({1049, 2003, 4060})
51
+ _ACCESS_DENIED_NATIVE_CODES = frozenset({229, 1044, 1045, 18456})
45
52
  T = TypeVar("T")
46
53
 
47
54
 
@@ -63,6 +70,48 @@ def _connection_error() -> DomainError:
63
70
  )
64
71
 
65
72
 
73
+ def _driver_codes(exc: DBAPIError) -> tuple[frozenset[str], frozenset[int]]:
74
+ sqlstates: set[str] = set()
75
+ native_codes: set[int] = set()
76
+ for value in getattr(exc.orig, "args", ()):
77
+ if isinstance(value, str):
78
+ if re.fullmatch(r"[A-Za-z0-9]{5}", value):
79
+ sqlstates.add(value.upper())
80
+ native_codes.update(int(code) for code in re.findall(r"\((-?\d+)\)", value))
81
+ elif isinstance(value, int) and not isinstance(value, bool):
82
+ native_codes.add(value)
83
+ return frozenset(sqlstates), frozenset(native_codes)
84
+
85
+
86
+ def _classify_dbapi_error(exc: DBAPIError) -> DomainError | None:
87
+ sqlstates, native_codes = _driver_codes(exc)
88
+ if sqlstates & _TIMEOUT_SQLSTATES or native_codes & _TIMEOUT_NATIVE_CODES:
89
+ return _timeout_error()
90
+ if sqlstates & _CONNECTION_SQLSTATES or native_codes & _CONNECTION_NATIVE_CODES:
91
+ return _connection_error()
92
+ if sqlstates & _ACCESS_DENIED_SQLSTATES or native_codes & _ACCESS_DENIED_NATIVE_CODES:
93
+ return DomainError(ErrorCode.ACCESS_DENIED, "The database denied this operation.")
94
+
95
+ message = str(exc.orig).casefold()
96
+ if any(value in message for value in ("timeout", "timed out", "hyt00", "hyt01")):
97
+ return _timeout_error()
98
+ if any(
99
+ value in message
100
+ for value in (
101
+ "permission denied",
102
+ "permission was denied",
103
+ "access denied",
104
+ "not authorized",
105
+ )
106
+ ):
107
+ return DomainError(ErrorCode.ACCESS_DENIED, "The database denied this operation.")
108
+ if any(
109
+ value in message for value in ("cannot open database", "(4060)", "08001", "08004")
110
+ ) or isinstance(exc, OperationalError):
111
+ return _connection_error()
112
+ return None
113
+
114
+
66
115
  def _contains(value: str, needle: str | None) -> bool:
67
116
  return needle is None or needle.casefold() in value.casefold()
68
117
 
@@ -158,25 +207,8 @@ class DatabaseService:
158
207
  except SQLAlchemyTimeoutError as exc:
159
208
  raise self._classified(alias, database, operation_name, _timeout_error(), exc) from exc
160
209
  except DBAPIError as exc:
161
- message = str(exc.orig).casefold()
162
- if any(value in message for value in ("timeout", "timed out", "hyt00", "hyt01")):
163
- raise self._classified(
164
- alias, database, operation_name, _timeout_error(), exc
165
- ) from exc
166
- if any(value in message for value in ("permission", "denied", "not authorized")):
167
- raise self._classified(
168
- alias,
169
- database,
170
- operation_name,
171
- DomainError(ErrorCode.ACCESS_DENIED, "The database denied this operation."),
172
- exc,
173
- ) from exc
174
- if any(
175
- value in message for value in ("cannot open database", "(4060)", "08001", "08004")
176
- ) or isinstance(exc, OperationalError):
177
- raise self._classified(
178
- alias, database, operation_name, _connection_error(), exc
179
- ) from exc
210
+ if error := _classify_dbapi_error(exc):
211
+ raise self._classified(alias, database, operation_name, error, exc) from exc
180
212
  raise self._unexpected(
181
213
  "Database operation failed", alias, database, operation_name, exc
182
214
  ) from exc
File without changes
File without changes