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.
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/CHANGELOG.md +22 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/PKG-INFO +16 -13
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/README.md +15 -12
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/pyproject.toml +2 -2
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/server.json +2 -2
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/__init__.py +1 -1
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/config.py +5 -1
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/mcp_server.py +3 -3
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/lineage.py +4 -1
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/parser.py +7 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/service.py +51 -19
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/.gitignore +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/LICENSE +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-multi.yaml +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-shared.yaml +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/sql-safe-mcp.example-simple.yaml +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/__main__.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/__init__.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/extras.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/mysql.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/reflection.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/registry.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/db/sqlserver.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/diagnostics.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/errors.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/models.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/__init__.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/dialect.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/executor.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/pipeline.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/policy.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/reasons.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/schema.py +0 -0
- {sql_safe_mcp-1.5.0 → sql_safe_mcp-1.5.2}/src/sql_safe_mcp/security/tokens.py +0 -0
- {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.
|
|
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](
|
|
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](
|
|
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
|
-
> [
|
|
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.
|
|
136
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
|
|
137
137
|
|
|
138
|
-
Requires Python 3.12
|
|
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 [
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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](
|
|
278
|
-
- [Checks](
|
|
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](
|
|
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](
|
|
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](
|
|
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
|
-
> [
|
|
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.
|
|
98
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==1.5.2`.
|
|
99
99
|
|
|
100
|
-
Requires Python 3.12
|
|
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 [
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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](
|
|
240
|
-
- [Checks](
|
|
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](
|
|
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.
|
|
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.
|
|
10
|
+
"version": "1.5.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "sql-safe-mcp",
|
|
15
|
-
"version": "1.5.
|
|
15
|
+
"version": "1.5.2",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -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
|
-
|
|
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)
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|