ob-mysql 0.1.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.
- ob_mysql-0.1.0/.gitignore +35 -0
- ob_mysql-0.1.0/CLAUDE.md +87 -0
- ob_mysql-0.1.0/LICENSE +70 -0
- ob_mysql-0.1.0/PKG-INFO +37 -0
- ob_mysql-0.1.0/README.md +15 -0
- ob_mysql-0.1.0/pyproject.toml +52 -0
- ob_mysql-0.1.0/src/ob_mysql/__init__.py +133 -0
- ob_mysql-0.1.0/src/ob_mysql/compiler.py +8 -0
- ob_mysql-0.1.0/src/ob_mysql/connection.py +65 -0
- ob_mysql-0.1.0/src/ob_mysql/cursor.py +173 -0
- ob_mysql-0.1.0/src/ob_mysql/exceptions.py +32 -0
- ob_mysql-0.1.0/src/ob_mysql/type_codes.py +47 -0
- ob_mysql-0.1.0/tests/test_driver.py +406 -0
- ob_mysql-0.1.0/tests/test_yaml_detection.py +34 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
*.egg
|
|
9
|
+
.eggs/
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
.env
|
|
13
|
+
*.db
|
|
14
|
+
*.duckdb
|
|
15
|
+
*.sqlite3
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
htmlcov/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
*.log
|
|
23
|
+
.DS_Store
|
|
24
|
+
.claude/
|
|
25
|
+
security/
|
|
26
|
+
infra/
|
|
27
|
+
site/
|
|
28
|
+
scripts/
|
|
29
|
+
.mcpregistry_github_token
|
|
30
|
+
.mcpregistry_registry_token
|
|
31
|
+
_*/
|
|
32
|
+
blog/
|
|
33
|
+
review*
|
|
34
|
+
design/
|
|
35
|
+
examples/test*
|
ob_mysql-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ob-mysql — OrionBelt Semantic Layer Driver for MySQL
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
PEP 249 DB-API 2.0 driver wrapping `mysql-connector-python >= 8.0` that intercepts OBML YAML
|
|
6
|
+
queries, compiles them to MySQL SQL via the OrionBelt REST API (standalone mode), and executes
|
|
7
|
+
them natively.
|
|
8
|
+
|
|
9
|
+
Unlike the postgres driver (which uses ADBC for native Arrow), this driver fetches rows via
|
|
10
|
+
`mysql-connector-python` tuples and converts to PyArrow Tables on demand in `fetch_arrow_table()`.
|
|
11
|
+
|
|
12
|
+
**OB dialect string:** `"mysql"`
|
|
13
|
+
**Author:** Ralf Becher / RALFORION d.o.o. (info@orionbelt.ai)
|
|
14
|
+
**License:** Apache 2.0
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Module Map
|
|
19
|
+
|
|
20
|
+
| File | Responsibility |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `__init__.py` | PEP 249 connect() + module constants |
|
|
23
|
+
| `connection.py` | Connection class |
|
|
24
|
+
| `cursor.py` | Cursor class — OBML detection + execution + Arrow conversion |
|
|
25
|
+
| `compiler.py` | Re-export from ob-driver-core (OBML detection + REST compilation) |
|
|
26
|
+
| `exceptions.py` | PEP 249 exception hierarchy (re-export from ob-driver-core) |
|
|
27
|
+
| `type_codes.py` | PEP 249 type objects + MySQL field type mapping |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## connect() Parameters
|
|
32
|
+
|
|
33
|
+
### MySQL-specific
|
|
34
|
+
| Parameter | Type | Default | Description |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| host | str | localhost | MySQL host |
|
|
37
|
+
| port | int | 3306 | MySQL port |
|
|
38
|
+
| database | str | — | Database name |
|
|
39
|
+
| user | str | None | Username |
|
|
40
|
+
| password | str | None | Password |
|
|
41
|
+
| ssl_ca | str | None | Path to CA cert |
|
|
42
|
+
| ssl_cert | str | None | Path to client cert |
|
|
43
|
+
| ssl_key | str | None | Path to client key |
|
|
44
|
+
| charset | str | utf8mb4 | Character set |
|
|
45
|
+
|
|
46
|
+
### OrionBelt parameters (same across all vendors)
|
|
47
|
+
| Parameter | Default | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| ob_api_url | http://localhost:8000 | OB REST API URL (must be running in single-model mode) |
|
|
50
|
+
| ob_timeout | 30 | HTTP timeout in seconds |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Vendor-Specific Notes
|
|
55
|
+
|
|
56
|
+
- `mysql-connector-python` uses `%s` (format) paramstyle
|
|
57
|
+
- No official Apache ADBC MySQL driver exists. A third-party option
|
|
58
|
+
([Columnar `adbc-drivers/mysql`](https://github.com/columnar-com/adbc-drivers), Apache-2.0,
|
|
59
|
+
Oct 2025) is available but installed via `dbc install mysql`, not a standalone PyPI package.
|
|
60
|
+
Current approach uses `mysql-connector-python` with emulated `fetch_arrow_table()` for stability.
|
|
61
|
+
Re-evaluate if a first-party ADBC driver becomes available or Columnar publishes to PyPI.
|
|
62
|
+
- `commit()` and `rollback()` are meaningful — MySQL uses real transactions
|
|
63
|
+
- Default charset is `utf8mb4` for full Unicode support
|
|
64
|
+
- MySQL 8.0+ required (CTE support)
|
|
65
|
+
- `group_concat_max_len` may need increasing for large LISTAGG results
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Type System
|
|
70
|
+
|
|
71
|
+
`mysql-connector-python` cursor.description returns field type constants as integers.
|
|
72
|
+
`MYSQL_TYPE_MAP` maps common MySQL field types to PEP 249 type objects:
|
|
73
|
+
- 0=DECIMAL, 1=TINY, 3=LONG, 4=FLOAT, 5=DOUBLE, 8=LONGLONG → NUMBER
|
|
74
|
+
- 7=TIMESTAMP, 10=DATE, 11=TIME, 12=DATETIME → DATETIME
|
|
75
|
+
- 15=VARCHAR, 253=VAR_STRING, 254=STRING → STRING
|
|
76
|
+
- 249-252=BLOB variants → BINARY
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Dependencies
|
|
81
|
+
|
|
82
|
+
```toml
|
|
83
|
+
[project.dependencies]
|
|
84
|
+
mysql-connector-python = ">=8.0"
|
|
85
|
+
pyarrow = ">=16.0"
|
|
86
|
+
ob-driver-core = ">=0.1"
|
|
87
|
+
```
|
ob_mysql-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: RALFORION d.o.o.
|
|
6
|
+
Licensed Work: OrionBelt Semantic Layer 1.0
|
|
7
|
+
The Licensed Work is (c) 2025 RALFORION d.o.o.
|
|
8
|
+
Additional Use Grant: You may use the Licensed Work for any purpose,
|
|
9
|
+
including production use, PROVIDED THAT you may
|
|
10
|
+
not embed OrionBelt in a commercial product or
|
|
11
|
+
provide it as part of a commercial service
|
|
12
|
+
without purchasing a commercial license from
|
|
13
|
+
the Licensor. Contact licensing@ralforion.com
|
|
14
|
+
for commercial licensing inquiries.
|
|
15
|
+
Change Date: 2030-03-16
|
|
16
|
+
Change License: Apache License, Version 2.0
|
|
17
|
+
|
|
18
|
+
-----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
Business Source License 1.1
|
|
21
|
+
|
|
22
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
23
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
24
|
+
|
|
25
|
+
Terms
|
|
26
|
+
|
|
27
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
28
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
29
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
30
|
+
production use.
|
|
31
|
+
|
|
32
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
33
|
+
available distribution of a specific version of the Licensed Work under this
|
|
34
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
35
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
36
|
+
above terminate.
|
|
37
|
+
|
|
38
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
39
|
+
currently in effect as described in this License, you must purchase a
|
|
40
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
41
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
42
|
+
|
|
43
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
44
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
45
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
46
|
+
for each version of the Licensed Work released by Licensor.
|
|
47
|
+
|
|
48
|
+
You must conspicuously display this License on each original or modified copy
|
|
49
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
50
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
51
|
+
License apply to your use of that work.
|
|
52
|
+
|
|
53
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
54
|
+
terminate your rights under this License for the current and all other
|
|
55
|
+
versions of the Licensed Work.
|
|
56
|
+
|
|
57
|
+
This License does not grant you any right in any trademark or logo of
|
|
58
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
59
|
+
Licensor as expressly required by this License).
|
|
60
|
+
|
|
61
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
62
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
63
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
64
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
65
|
+
TITLE.
|
|
66
|
+
|
|
67
|
+
MariaDB hereby grants you permission to use this License's text to license
|
|
68
|
+
your works, and to refer to it using the trademark "Business Source License",
|
|
69
|
+
as long as you comply with the Covenants of Licensor of MariaDB's Business Source
|
|
70
|
+
License Trademark Policy available at https://mariadb.com/bsl11/.
|
ob_mysql-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ob-mysql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OrionBelt Semantic Layer driver for MySQL 8.0+ (DB-API 2.0)
|
|
5
|
+
Author-email: Ralf Becher <info@orionbelt.ai>
|
|
6
|
+
License: BSL-1.1
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: db-api,mysql,obml,orionbelt,semantic-layer
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: mysql-connector-python>=8.0
|
|
11
|
+
Requires-Dist: ob-driver-core>=0.1
|
|
12
|
+
Requires-Dist: pyarrow>=16.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
19
|
+
Provides-Extra: sqlalchemy
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ob-mysql
|
|
24
|
+
|
|
25
|
+
OrionBelt Semantic Layer driver for MySQL 8.0+ (DB-API 2.0)
|
|
26
|
+
|
|
27
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralfbecher/orionbelt-semantic-layer) project.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install ob-mysql
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
ob_mysql-0.1.0/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ob-mysql
|
|
2
|
+
|
|
3
|
+
OrionBelt Semantic Layer driver for MySQL 8.0+ (DB-API 2.0)
|
|
4
|
+
|
|
5
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralfbecher/orionbelt-semantic-layer) project.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ob-mysql
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ob-mysql"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OrionBelt Semantic Layer driver for MySQL 8.0+ (DB-API 2.0)"
|
|
9
|
+
license = { text = "BSL-1.1" }
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
authors = [{ name = "Ralf Becher", email = "info@orionbelt.ai" }]
|
|
12
|
+
keywords = ["mysql", "semantic-layer", "obml", "db-api", "orionbelt"]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ob-driver-core>=0.1",
|
|
16
|
+
"mysql-connector-python>=8.0",
|
|
17
|
+
"pyarrow>=16.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
sqlalchemy = ["sqlalchemy>=2.0"]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"pytest-asyncio>=0.23",
|
|
25
|
+
"mypy>=1.10",
|
|
26
|
+
"ruff>=0.4",
|
|
27
|
+
"respx>=0.21",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
31
|
+
"ob.mysql" = "ob_mysql.dialect:OBMySQLDialect"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/ob_mysql"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
target-version = "py312"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ANN"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
python_version = "3.12"
|
|
45
|
+
strict = true
|
|
46
|
+
files = ["src/ob_mysql"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
markers = ["integration: requires live DB + OB API"]
|
|
52
|
+
addopts = "-m 'not integration'"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""ob-mysql — OrionBelt Semantic Layer driver for MySQL 8.0+ (PEP 249 DB-API 2.0).
|
|
2
|
+
|
|
3
|
+
Uses ``mysql-connector-python`` for database access. OBML queries are compiled
|
|
4
|
+
to MySQL SQL via the OrionBelt REST API running in single-model mode.
|
|
5
|
+
|
|
6
|
+
``fetch_arrow_table()`` is emulated by fetching rows and converting to a
|
|
7
|
+
PyArrow Table (no native Arrow support in mysql-connector-python).
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
import ob_mysql
|
|
12
|
+
|
|
13
|
+
conn = ob_mysql.connect(database="mydb", user="me", password="secret")
|
|
14
|
+
with conn.cursor() as cur:
|
|
15
|
+
cur.execute("select:\\n dimensions:\\n - Region\\n measures:\\n - Revenue")
|
|
16
|
+
print(cur.fetchall())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import mysql.connector # type: ignore[import-untyped]
|
|
24
|
+
|
|
25
|
+
from ob_mysql.connection import Connection
|
|
26
|
+
from ob_mysql.exceptions import (
|
|
27
|
+
DatabaseError,
|
|
28
|
+
DataError,
|
|
29
|
+
Error,
|
|
30
|
+
IntegrityError,
|
|
31
|
+
InterfaceError,
|
|
32
|
+
InternalError,
|
|
33
|
+
NotSupportedError,
|
|
34
|
+
OperationalError,
|
|
35
|
+
ProgrammingError,
|
|
36
|
+
Warning,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# PEP 249 module-level constants
|
|
40
|
+
apilevel = "2.0"
|
|
41
|
+
threadsafety = 1 # threads may share the module but not connections
|
|
42
|
+
paramstyle = "format" # mysql-connector-python uses %s placeholders
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def connect(
|
|
46
|
+
dsn: str | None = None,
|
|
47
|
+
*,
|
|
48
|
+
host: str = "localhost",
|
|
49
|
+
port: int = 3306,
|
|
50
|
+
database: str = "mysql",
|
|
51
|
+
user: str | None = None,
|
|
52
|
+
password: str | None = None,
|
|
53
|
+
ssl_ca: str | None = None,
|
|
54
|
+
ssl_cert: str | None = None,
|
|
55
|
+
ssl_key: str | None = None,
|
|
56
|
+
charset: str = "utf8mb4",
|
|
57
|
+
# OrionBelt parameters
|
|
58
|
+
ob_api_url: str = "http://localhost:8000",
|
|
59
|
+
ob_timeout: int = 30,
|
|
60
|
+
) -> Connection:
|
|
61
|
+
"""Open a MySQL connection with OBML support.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
dsn : str, optional
|
|
66
|
+
Not used for MySQL — provided for API consistency with other drivers.
|
|
67
|
+
host : str
|
|
68
|
+
MySQL host (default: ``localhost``).
|
|
69
|
+
port : int
|
|
70
|
+
MySQL port (default: ``3306``).
|
|
71
|
+
database : str
|
|
72
|
+
Database name (default: ``mysql``).
|
|
73
|
+
user : str, optional
|
|
74
|
+
Username.
|
|
75
|
+
password : str, optional
|
|
76
|
+
Password.
|
|
77
|
+
ssl_ca : str, optional
|
|
78
|
+
Path to CA certificate file.
|
|
79
|
+
ssl_cert : str, optional
|
|
80
|
+
Path to client certificate file.
|
|
81
|
+
ssl_key : str, optional
|
|
82
|
+
Path to client key file.
|
|
83
|
+
charset : str
|
|
84
|
+
Character set (default: ``utf8mb4`` for full Unicode support).
|
|
85
|
+
ob_api_url : str
|
|
86
|
+
OrionBelt REST API URL (must be running in single-model mode).
|
|
87
|
+
ob_timeout : int
|
|
88
|
+
HTTP timeout in seconds for OBML compilation.
|
|
89
|
+
"""
|
|
90
|
+
kwargs: dict[str, Any] = {
|
|
91
|
+
"host": host,
|
|
92
|
+
"port": port,
|
|
93
|
+
"database": database,
|
|
94
|
+
"charset": charset,
|
|
95
|
+
}
|
|
96
|
+
if user is not None:
|
|
97
|
+
kwargs["user"] = user
|
|
98
|
+
if password is not None:
|
|
99
|
+
kwargs["password"] = password
|
|
100
|
+
|
|
101
|
+
# SSL configuration — only add non-None values
|
|
102
|
+
if ssl_ca:
|
|
103
|
+
kwargs["ssl_ca"] = ssl_ca
|
|
104
|
+
if ssl_cert:
|
|
105
|
+
kwargs["ssl_cert"] = ssl_cert
|
|
106
|
+
if ssl_key:
|
|
107
|
+
kwargs["ssl_key"] = ssl_key
|
|
108
|
+
|
|
109
|
+
native = mysql.connector.connect(**kwargs)
|
|
110
|
+
return Connection(
|
|
111
|
+
native,
|
|
112
|
+
ob_api_url=ob_api_url,
|
|
113
|
+
ob_timeout=ob_timeout,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = [
|
|
118
|
+
"apilevel",
|
|
119
|
+
"threadsafety",
|
|
120
|
+
"paramstyle",
|
|
121
|
+
"connect",
|
|
122
|
+
"Connection",
|
|
123
|
+
"Warning",
|
|
124
|
+
"Error",
|
|
125
|
+
"InterfaceError",
|
|
126
|
+
"DatabaseError",
|
|
127
|
+
"DataError",
|
|
128
|
+
"OperationalError",
|
|
129
|
+
"IntegrityError",
|
|
130
|
+
"InternalError",
|
|
131
|
+
"ProgrammingError",
|
|
132
|
+
"NotSupportedError",
|
|
133
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""OrionBelt compilation bridge — delegates to ob-driver-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ob_driver_core.compiler import compile_obml
|
|
6
|
+
from ob_driver_core.detection import is_obml, parse_obml
|
|
7
|
+
|
|
8
|
+
__all__ = ["compile_obml", "is_obml", "parse_obml"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""PEP 249 Connection wrapping ``mysql.connector.connection``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ob_mysql.cursor import Cursor
|
|
8
|
+
from ob_mysql.exceptions import ProgrammingError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Connection:
|
|
12
|
+
"""DB-API 2.0 connection that wraps a mysql-connector-python connection.
|
|
13
|
+
|
|
14
|
+
OBML queries are compiled to MySQL SQL via the OrionBelt REST API.
|
|
15
|
+
``fetch_arrow_table()`` is emulated by fetching rows and converting
|
|
16
|
+
to a PyArrow Table (mysql-connector-python has no native Arrow support).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
native: Any,
|
|
22
|
+
*,
|
|
23
|
+
ob_api_url: str = "http://localhost:8000",
|
|
24
|
+
ob_timeout: int = 30,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._native = native
|
|
27
|
+
self._closed = False
|
|
28
|
+
self._ob_api_url = ob_api_url
|
|
29
|
+
self._ob_timeout = ob_timeout
|
|
30
|
+
|
|
31
|
+
def _check_open(self) -> None:
|
|
32
|
+
if self._closed:
|
|
33
|
+
raise ProgrammingError("Connection is closed.")
|
|
34
|
+
|
|
35
|
+
def cursor(self) -> Cursor:
|
|
36
|
+
"""Return a new Cursor for this connection."""
|
|
37
|
+
self._check_open()
|
|
38
|
+
native_cursor = self._native.cursor()
|
|
39
|
+
return Cursor(
|
|
40
|
+
native_cursor,
|
|
41
|
+
ob_api_url=self._ob_api_url,
|
|
42
|
+
ob_timeout=self._ob_timeout,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def commit(self) -> None:
|
|
46
|
+
"""Commit the current transaction."""
|
|
47
|
+
self._check_open()
|
|
48
|
+
self._native.commit()
|
|
49
|
+
|
|
50
|
+
def rollback(self) -> None:
|
|
51
|
+
"""Rollback the current transaction."""
|
|
52
|
+
self._check_open()
|
|
53
|
+
self._native.rollback()
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the connection."""
|
|
57
|
+
if not self._closed:
|
|
58
|
+
self._native.close()
|
|
59
|
+
self._closed = True
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> Connection:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exc: object) -> None:
|
|
65
|
+
self.close()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""PEP 249 Cursor with OBML interception for MySQL.
|
|
2
|
+
|
|
3
|
+
Uses ``mysql-connector-python`` for database access. Unlike the ADBC-based
|
|
4
|
+
PostgreSQL driver, ``fetch_arrow_table()`` is emulated by fetching all rows
|
|
5
|
+
via ``fetchall()`` and converting to a PyArrow Table using column metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ob_mysql.compiler import compile_obml, is_obml, parse_obml
|
|
13
|
+
from ob_mysql.exceptions import NotSupportedError, ProgrammingError
|
|
14
|
+
from ob_mysql.type_codes import MYSQL_TYPE_MAP, STRING
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cursor:
|
|
18
|
+
"""DB-API 2.0 cursor that intercepts OBML YAML queries.
|
|
19
|
+
|
|
20
|
+
If the query is OBML, it is compiled to MySQL SQL via the OrionBelt
|
|
21
|
+
REST API before execution. Plain SQL is passed through unchanged.
|
|
22
|
+
|
|
23
|
+
``fetch_arrow_table()`` converts row results to a PyArrow Table.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
arraysize: int = 1
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
native: Any,
|
|
31
|
+
*,
|
|
32
|
+
ob_api_url: str = "http://localhost:8000",
|
|
33
|
+
ob_timeout: int = 30,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._native = native
|
|
36
|
+
self._closed = False
|
|
37
|
+
self._ob_api_url = ob_api_url
|
|
38
|
+
self._ob_timeout = ob_timeout
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def description(
|
|
42
|
+
self,
|
|
43
|
+
) -> tuple[tuple[str, Any, None, None, None, None, None], ...] | None:
|
|
44
|
+
"""PEP 249 cursor description — 7-item tuples per column."""
|
|
45
|
+
native_desc = self._native.description
|
|
46
|
+
if native_desc is None:
|
|
47
|
+
return None
|
|
48
|
+
cols: list[tuple[str, Any, None, None, None, None, None]] = []
|
|
49
|
+
for col in native_desc:
|
|
50
|
+
name = col[0]
|
|
51
|
+
type_code = col[1]
|
|
52
|
+
mapped = MYSQL_TYPE_MAP.get(type_code, STRING) if isinstance(type_code, int) else STRING
|
|
53
|
+
cols.append((name, mapped, None, None, None, None, None))
|
|
54
|
+
return tuple(cols)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def rowcount(self) -> int:
|
|
58
|
+
return self._native.rowcount # type: ignore[no-any-return]
|
|
59
|
+
|
|
60
|
+
def _check_open(self) -> None:
|
|
61
|
+
if self._closed:
|
|
62
|
+
raise ProgrammingError("Cursor is closed.")
|
|
63
|
+
|
|
64
|
+
def execute(self, operation: str, parameters: Any = None) -> Cursor:
|
|
65
|
+
"""Execute a query — OBML YAML or plain SQL."""
|
|
66
|
+
self._check_open()
|
|
67
|
+
sql = self._resolve_sql(operation)
|
|
68
|
+
if parameters is not None:
|
|
69
|
+
self._native.execute(sql, parameters)
|
|
70
|
+
else:
|
|
71
|
+
self._native.execute(sql)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def executemany(self, operation: str, seq_of_parameters: Any) -> None:
|
|
75
|
+
"""Execute against all parameter sequences.
|
|
76
|
+
|
|
77
|
+
OBML queries are not supported with executemany — raises NotSupportedError.
|
|
78
|
+
"""
|
|
79
|
+
self._check_open()
|
|
80
|
+
if is_obml(operation):
|
|
81
|
+
raise NotSupportedError("executemany() is not supported for OBML queries.")
|
|
82
|
+
for params in seq_of_parameters:
|
|
83
|
+
self._native.execute(operation, params)
|
|
84
|
+
|
|
85
|
+
def _resolve_sql(self, operation: str) -> str:
|
|
86
|
+
"""Compile OBML to SQL or return plain SQL unchanged."""
|
|
87
|
+
if not is_obml(operation):
|
|
88
|
+
return operation
|
|
89
|
+
obml = parse_obml(operation)
|
|
90
|
+
return compile_obml(
|
|
91
|
+
obml,
|
|
92
|
+
dialect="mysql",
|
|
93
|
+
ob_api_url=self._ob_api_url,
|
|
94
|
+
ob_timeout=self._ob_timeout,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def fetchone(self) -> tuple[Any, ...] | None:
|
|
98
|
+
"""Fetch the next row."""
|
|
99
|
+
self._check_open()
|
|
100
|
+
row = self._native.fetchone()
|
|
101
|
+
return tuple(row) if row is not None else None
|
|
102
|
+
|
|
103
|
+
def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
|
|
104
|
+
"""Fetch the next *size* rows."""
|
|
105
|
+
self._check_open()
|
|
106
|
+
n = size if size is not None else self.arraysize
|
|
107
|
+
rows = self._native.fetchmany(n)
|
|
108
|
+
return [tuple(r) for r in rows]
|
|
109
|
+
|
|
110
|
+
def fetchall(self) -> list[tuple[Any, ...]]:
|
|
111
|
+
"""Fetch all remaining rows."""
|
|
112
|
+
self._check_open()
|
|
113
|
+
rows = self._native.fetchall()
|
|
114
|
+
return [tuple(r) for r in rows]
|
|
115
|
+
|
|
116
|
+
def fetch_arrow_table(self) -> Any:
|
|
117
|
+
"""Fetch all remaining rows as a PyArrow Table.
|
|
118
|
+
|
|
119
|
+
mysql-connector-python has no native Arrow support, so this method
|
|
120
|
+
fetches all rows via ``fetchall()`` and converts them to a PyArrow
|
|
121
|
+
Table using column names from ``description``.
|
|
122
|
+
"""
|
|
123
|
+
import pyarrow as pa
|
|
124
|
+
|
|
125
|
+
self._check_open()
|
|
126
|
+
rows = self._native.fetchall()
|
|
127
|
+
desc = self._native.description or []
|
|
128
|
+
col_names = [col[0] for col in desc]
|
|
129
|
+
|
|
130
|
+
if not rows:
|
|
131
|
+
# Return empty table with correct schema
|
|
132
|
+
arrays = [pa.array([], type=pa.utf8()) for _ in col_names]
|
|
133
|
+
return pa.table(dict(zip(col_names, arrays, strict=True)))
|
|
134
|
+
|
|
135
|
+
# Transpose rows to columns and let PyArrow infer types
|
|
136
|
+
columns: dict[str, list[Any]] = {name: [] for name in col_names}
|
|
137
|
+
for row in rows:
|
|
138
|
+
for i, name in enumerate(col_names):
|
|
139
|
+
columns[name].append(row[i])
|
|
140
|
+
|
|
141
|
+
return pa.table(columns)
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
"""Close the cursor."""
|
|
145
|
+
if not self._closed:
|
|
146
|
+
self._native.close()
|
|
147
|
+
self._closed = True
|
|
148
|
+
|
|
149
|
+
def setinputsizes(self, sizes: Any) -> None:
|
|
150
|
+
"""No-op — required by PEP 249."""
|
|
151
|
+
|
|
152
|
+
def setoutputsize(self, size: int, column: int | None = None) -> None:
|
|
153
|
+
"""No-op — required by PEP 249."""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def lastrowid(self) -> int | None:
|
|
157
|
+
"""Return the last inserted row ID."""
|
|
158
|
+
return self._native.lastrowid # type: ignore[no-any-return]
|
|
159
|
+
|
|
160
|
+
def __enter__(self) -> Cursor:
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *exc: object) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
def __iter__(self) -> Cursor:
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __next__(self) -> tuple[Any, ...]:
|
|
170
|
+
row = self.fetchone()
|
|
171
|
+
if row is None:
|
|
172
|
+
raise StopIteration
|
|
173
|
+
return row
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PEP 249 exceptions — re-exported from ob-driver-core for convenience.
|
|
2
|
+
|
|
3
|
+
Allows users to do: ``from ob_mysql.exceptions import ProgrammingError``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ob_driver_core.exceptions import (
|
|
9
|
+
DatabaseError,
|
|
10
|
+
DataError,
|
|
11
|
+
Error,
|
|
12
|
+
IntegrityError,
|
|
13
|
+
InterfaceError,
|
|
14
|
+
InternalError,
|
|
15
|
+
NotSupportedError,
|
|
16
|
+
OperationalError,
|
|
17
|
+
ProgrammingError,
|
|
18
|
+
Warning,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Warning",
|
|
23
|
+
"Error",
|
|
24
|
+
"InterfaceError",
|
|
25
|
+
"DatabaseError",
|
|
26
|
+
"DataError",
|
|
27
|
+
"OperationalError",
|
|
28
|
+
"IntegrityError",
|
|
29
|
+
"InternalError",
|
|
30
|
+
"ProgrammingError",
|
|
31
|
+
"NotSupportedError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""PEP 249 type objects — re-exported from ob-driver-core plus MySQL field type mapping.
|
|
2
|
+
|
|
3
|
+
mysql-connector-python cursor.description returns integer field type constants.
|
|
4
|
+
The MYSQL_TYPE_MAP maps common field types to PEP 249 type objects.
|
|
5
|
+
|
|
6
|
+
MySQL field type constants:
|
|
7
|
+
https://dev.mysql.com/doc/dev/mysql-server/latest/field__types_8h.html
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ob_driver_core.type_codes import BINARY, DATETIME, NUMBER, ROWID, STRING
|
|
13
|
+
|
|
14
|
+
__all__ = ["STRING", "BINARY", "NUMBER", "DATETIME", "ROWID", "MYSQL_TYPE_MAP"]
|
|
15
|
+
|
|
16
|
+
# Common MySQL field type IDs from the wire protocol
|
|
17
|
+
MYSQL_TYPE_MAP: dict[int, object] = {
|
|
18
|
+
# Numeric types
|
|
19
|
+
0: NUMBER, # DECIMAL
|
|
20
|
+
1: NUMBER, # TINY (tinyint)
|
|
21
|
+
2: NUMBER, # SHORT (smallint)
|
|
22
|
+
3: NUMBER, # LONG (int)
|
|
23
|
+
4: NUMBER, # FLOAT
|
|
24
|
+
5: NUMBER, # DOUBLE
|
|
25
|
+
8: NUMBER, # LONGLONG (bigint)
|
|
26
|
+
9: NUMBER, # INT24 (mediumint)
|
|
27
|
+
16: NUMBER, # BIT
|
|
28
|
+
246: NUMBER, # NEWDECIMAL
|
|
29
|
+
# Date/time types
|
|
30
|
+
7: DATETIME, # TIMESTAMP
|
|
31
|
+
10: DATETIME, # DATE
|
|
32
|
+
11: DATETIME, # TIME
|
|
33
|
+
12: DATETIME, # DATETIME
|
|
34
|
+
13: DATETIME, # YEAR
|
|
35
|
+
14: DATETIME, # NEWDATE
|
|
36
|
+
# String types
|
|
37
|
+
6: STRING, # NULL
|
|
38
|
+
15: STRING, # VARCHAR
|
|
39
|
+
245: STRING, # JSON
|
|
40
|
+
253: STRING, # VAR_STRING
|
|
41
|
+
254: STRING, # STRING
|
|
42
|
+
# Binary types
|
|
43
|
+
249: BINARY, # TINY_BLOB
|
|
44
|
+
250: BINARY, # MEDIUM_BLOB
|
|
45
|
+
251: BINARY, # LONG_BLOB
|
|
46
|
+
252: BINARY, # BLOB
|
|
47
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Unit tests for the ob-mysql DB-API 2.0 driver.
|
|
2
|
+
|
|
3
|
+
All tests mock mysql.connector — no live MySQL needed.
|
|
4
|
+
OBML tests additionally mock the REST API call to ``/v1/query/sql``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import namedtuple
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import ob_mysql
|
|
15
|
+
from ob_mysql.connection import Connection
|
|
16
|
+
from ob_mysql.exceptions import NotSupportedError, ProgrammingError
|
|
17
|
+
from ob_mysql.type_codes import DATETIME, NUMBER, STRING
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helper to build a mock mysql.connector connection
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# mysql-connector description columns
|
|
24
|
+
MysqlColumn = namedtuple(
|
|
25
|
+
"Column",
|
|
26
|
+
["name", "type_code", "display_size", "internal_size", "precision", "scale", "null_ok"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_mock_native() -> MagicMock:
|
|
31
|
+
"""Return a mock mysql.connector connection."""
|
|
32
|
+
mock_conn = MagicMock()
|
|
33
|
+
mock_cursor = MagicMock()
|
|
34
|
+
mock_conn.cursor.return_value = mock_cursor
|
|
35
|
+
# Default: no results
|
|
36
|
+
mock_cursor.description = None
|
|
37
|
+
mock_cursor.rowcount = -1
|
|
38
|
+
mock_cursor.fetchone.return_value = None
|
|
39
|
+
mock_cursor.fetchall.return_value = []
|
|
40
|
+
mock_cursor.fetchmany.return_value = []
|
|
41
|
+
mock_cursor.lastrowid = None
|
|
42
|
+
return mock_conn
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mock_api_response(sql: str) -> MagicMock:
|
|
46
|
+
"""Create a mock httpx response returning the given SQL."""
|
|
47
|
+
resp = MagicMock()
|
|
48
|
+
resp.is_success = True
|
|
49
|
+
resp.json.return_value = {"sql": sql}
|
|
50
|
+
return resp
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# PEP 249 module-level constants
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_apilevel() -> None:
|
|
59
|
+
assert ob_mysql.apilevel == "2.0"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_threadsafety() -> None:
|
|
63
|
+
assert ob_mysql.threadsafety == 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_paramstyle() -> None:
|
|
67
|
+
assert ob_mysql.paramstyle == "format"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# connect()
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_connect_returns_connection() -> None:
|
|
76
|
+
with patch("mysql.connector.connect") as mock_connect:
|
|
77
|
+
mock_connect.return_value = _make_mock_native()
|
|
78
|
+
conn = ob_mysql.connect(database="testdb", user="testuser")
|
|
79
|
+
assert isinstance(conn, Connection)
|
|
80
|
+
mock_connect.assert_called_once()
|
|
81
|
+
kwargs = mock_connect.call_args.kwargs
|
|
82
|
+
assert kwargs["database"] == "testdb"
|
|
83
|
+
assert kwargs["user"] == "testuser"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_connect_default_port() -> None:
|
|
87
|
+
with patch("mysql.connector.connect") as mock_connect:
|
|
88
|
+
mock_connect.return_value = _make_mock_native()
|
|
89
|
+
ob_mysql.connect()
|
|
90
|
+
kwargs = mock_connect.call_args.kwargs
|
|
91
|
+
assert kwargs["port"] == 3306
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_connect_default_charset() -> None:
|
|
95
|
+
with patch("mysql.connector.connect") as mock_connect:
|
|
96
|
+
mock_connect.return_value = _make_mock_native()
|
|
97
|
+
ob_mysql.connect()
|
|
98
|
+
kwargs = mock_connect.call_args.kwargs
|
|
99
|
+
assert kwargs["charset"] == "utf8mb4"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_connect_context_manager() -> None:
|
|
103
|
+
with patch("mysql.connector.connect") as mock_connect:
|
|
104
|
+
mock_native = _make_mock_native()
|
|
105
|
+
mock_connect.return_value = mock_native
|
|
106
|
+
with ob_mysql.connect() as conn:
|
|
107
|
+
assert isinstance(conn, Connection)
|
|
108
|
+
mock_native.close.assert_called_once()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Connection
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_connection_close_is_idempotent() -> None:
|
|
117
|
+
mock_native = _make_mock_native()
|
|
118
|
+
conn = Connection(mock_native)
|
|
119
|
+
conn.close()
|
|
120
|
+
conn.close() # should not raise
|
|
121
|
+
mock_native.close.assert_called_once()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_connection_cursor_after_close_raises() -> None:
|
|
125
|
+
mock_native = _make_mock_native()
|
|
126
|
+
conn = Connection(mock_native)
|
|
127
|
+
conn.close()
|
|
128
|
+
with pytest.raises(ProgrammingError, match="closed"):
|
|
129
|
+
conn.cursor()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_connection_commit() -> None:
|
|
133
|
+
mock_native = _make_mock_native()
|
|
134
|
+
conn = Connection(mock_native)
|
|
135
|
+
conn.commit()
|
|
136
|
+
mock_native.commit.assert_called_once()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_connection_rollback() -> None:
|
|
140
|
+
mock_native = _make_mock_native()
|
|
141
|
+
conn = Connection(mock_native)
|
|
142
|
+
conn.rollback()
|
|
143
|
+
mock_native.rollback.assert_called_once()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Cursor — plain SQL
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_cursor_execute_calls_native() -> None:
|
|
152
|
+
mock_native = _make_mock_native()
|
|
153
|
+
conn = Connection(mock_native)
|
|
154
|
+
with conn.cursor() as cur:
|
|
155
|
+
cur.execute("SELECT 1")
|
|
156
|
+
mock_native.cursor().execute.assert_called_once_with("SELECT 1")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_cursor_execute_with_params() -> None:
|
|
160
|
+
mock_native = _make_mock_native()
|
|
161
|
+
conn = Connection(mock_native)
|
|
162
|
+
with conn.cursor() as cur:
|
|
163
|
+
cur.execute("SELECT %s + %s", [3, 4])
|
|
164
|
+
mock_native.cursor().execute.assert_called_once_with("SELECT %s + %s", [3, 4])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_cursor_execute_returns_self() -> None:
|
|
168
|
+
mock_native = _make_mock_native()
|
|
169
|
+
conn = Connection(mock_native)
|
|
170
|
+
with conn.cursor() as cur:
|
|
171
|
+
result = cur.execute("SELECT 1")
|
|
172
|
+
assert result is cur
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_cursor_description() -> None:
|
|
176
|
+
mock_native = _make_mock_native()
|
|
177
|
+
mock_cursor = mock_native.cursor()
|
|
178
|
+
mock_cursor.description = [
|
|
179
|
+
MysqlColumn("num", 3, None, None, None, None, None), # LONG → NUMBER
|
|
180
|
+
MysqlColumn("txt", 253, None, None, None, None, None), # VAR_STRING → STRING
|
|
181
|
+
MysqlColumn("dt", 12, None, None, None, None, None), # DATETIME → DATETIME
|
|
182
|
+
]
|
|
183
|
+
conn = Connection(mock_native)
|
|
184
|
+
cur = conn.cursor()
|
|
185
|
+
desc = cur.description
|
|
186
|
+
assert desc is not None
|
|
187
|
+
assert len(desc) == 3
|
|
188
|
+
assert all(len(col) == 7 for col in desc)
|
|
189
|
+
assert desc[0][0] == "num"
|
|
190
|
+
assert desc[0][1] == NUMBER
|
|
191
|
+
assert desc[1][0] == "txt"
|
|
192
|
+
assert desc[1][1] == STRING
|
|
193
|
+
assert desc[2][0] == "dt"
|
|
194
|
+
assert desc[2][1] == DATETIME
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_cursor_description_none_before_execute() -> None:
|
|
198
|
+
mock_native = _make_mock_native()
|
|
199
|
+
mock_native.cursor().description = None
|
|
200
|
+
conn = Connection(mock_native)
|
|
201
|
+
cur = conn.cursor()
|
|
202
|
+
assert cur.description is None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_cursor_fetchone() -> None:
|
|
206
|
+
mock_native = _make_mock_native()
|
|
207
|
+
mock_native.cursor().fetchone.return_value = (42,)
|
|
208
|
+
conn = Connection(mock_native)
|
|
209
|
+
cur = conn.cursor()
|
|
210
|
+
row = cur.fetchone()
|
|
211
|
+
assert row == (42,)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_cursor_fetchone_exhausted() -> None:
|
|
215
|
+
mock_native = _make_mock_native()
|
|
216
|
+
mock_native.cursor().fetchone.return_value = None
|
|
217
|
+
conn = Connection(mock_native)
|
|
218
|
+
cur = conn.cursor()
|
|
219
|
+
assert cur.fetchone() is None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cursor_fetchall() -> None:
|
|
223
|
+
mock_native = _make_mock_native()
|
|
224
|
+
mock_native.cursor().fetchall.return_value = [(1, "a"), (2, "b"), (3, "c")]
|
|
225
|
+
conn = Connection(mock_native)
|
|
226
|
+
cur = conn.cursor()
|
|
227
|
+
rows = cur.fetchall()
|
|
228
|
+
assert len(rows) == 3
|
|
229
|
+
assert rows[0] == (1, "a")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_cursor_fetchmany() -> None:
|
|
233
|
+
mock_native = _make_mock_native()
|
|
234
|
+
mock_native.cursor().fetchmany.return_value = [(1,), (2,), (3,)]
|
|
235
|
+
conn = Connection(mock_native)
|
|
236
|
+
cur = conn.cursor()
|
|
237
|
+
batch = cur.fetchmany(3)
|
|
238
|
+
assert len(batch) == 3
|
|
239
|
+
mock_native.cursor().fetchmany.assert_called_with(3)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_cursor_iteration() -> None:
|
|
243
|
+
mock_native = _make_mock_native()
|
|
244
|
+
mock_native.cursor().fetchone.side_effect = [(0,), (1,), (2,), None]
|
|
245
|
+
conn = Connection(mock_native)
|
|
246
|
+
cur = conn.cursor()
|
|
247
|
+
rows = list(cur)
|
|
248
|
+
assert len(rows) == 3
|
|
249
|
+
assert rows[0] == (0,)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_cursor_close_then_fetch_raises() -> None:
|
|
253
|
+
mock_native = _make_mock_native()
|
|
254
|
+
conn = Connection(mock_native)
|
|
255
|
+
cur = conn.cursor()
|
|
256
|
+
cur.close()
|
|
257
|
+
with pytest.raises(ProgrammingError, match="closed"):
|
|
258
|
+
cur.fetchone()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_cursor_executemany_plain_sql() -> None:
|
|
262
|
+
mock_native = _make_mock_native()
|
|
263
|
+
conn = Connection(mock_native)
|
|
264
|
+
cur = conn.cursor()
|
|
265
|
+
cur.executemany("INSERT INTO t VALUES (%s, %s)", [(1, "a"), (2, "b")])
|
|
266
|
+
assert mock_native.cursor().execute.call_count == 2
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_cursor_executemany_obml_raises() -> None:
|
|
270
|
+
mock_native = _make_mock_native()
|
|
271
|
+
conn = Connection(mock_native)
|
|
272
|
+
cur = conn.cursor()
|
|
273
|
+
obml = "select:\n dimensions:\n - Region\n measures:\n - Revenue\n"
|
|
274
|
+
with pytest.raises(NotSupportedError, match="executemany"):
|
|
275
|
+
cur.executemany(obml, [])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_cursor_setinputsizes_noop() -> None:
|
|
279
|
+
mock_native = _make_mock_native()
|
|
280
|
+
conn = Connection(mock_native)
|
|
281
|
+
cur = conn.cursor()
|
|
282
|
+
cur.setinputsizes([]) # should not raise
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_cursor_setoutputsize_noop() -> None:
|
|
286
|
+
mock_native = _make_mock_native()
|
|
287
|
+
conn = Connection(mock_native)
|
|
288
|
+
cur = conn.cursor()
|
|
289
|
+
cur.setoutputsize(1000) # should not raise
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_cursor_lastrowid() -> None:
|
|
293
|
+
mock_native = _make_mock_native()
|
|
294
|
+
mock_native.cursor().lastrowid = 42
|
|
295
|
+
conn = Connection(mock_native)
|
|
296
|
+
cur = conn.cursor()
|
|
297
|
+
assert cur.lastrowid == 42
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_cursor_rowcount() -> None:
|
|
301
|
+
mock_native = _make_mock_native()
|
|
302
|
+
mock_native.cursor().rowcount = 5
|
|
303
|
+
conn = Connection(mock_native)
|
|
304
|
+
cur = conn.cursor()
|
|
305
|
+
assert cur.rowcount == 5
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Cursor — OBML queries (mocked REST API)
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_obml_compile_and_execute() -> None:
|
|
314
|
+
"""OBML query is compiled via REST API then executed on MySQL."""
|
|
315
|
+
mock_native = _make_mock_native()
|
|
316
|
+
compiled_sql = "SELECT `region`, SUM(`amount`) AS `revenue` FROM `orders` GROUP BY `region`"
|
|
317
|
+
mock_native.cursor().description = [
|
|
318
|
+
MysqlColumn("region", 253, None, None, None, None, None),
|
|
319
|
+
MysqlColumn("revenue", 5, None, None, None, None, None),
|
|
320
|
+
]
|
|
321
|
+
mock_native.cursor().fetchall.return_value = [
|
|
322
|
+
("EMEA", 300.0),
|
|
323
|
+
("APAC", 150.0),
|
|
324
|
+
("AMER", 550.0),
|
|
325
|
+
]
|
|
326
|
+
conn = Connection(mock_native)
|
|
327
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
328
|
+
with conn.cursor() as cur:
|
|
329
|
+
cur.execute("select:\n dimensions:\n - Region\n measures:\n - Revenue\n")
|
|
330
|
+
rows = cur.fetchall()
|
|
331
|
+
assert len(rows) == 3
|
|
332
|
+
# Verify the compiled SQL was passed to native cursor
|
|
333
|
+
mock_native.cursor().execute.assert_called_once_with(compiled_sql)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_obml_rest_dialect_is_mysql() -> None:
|
|
337
|
+
"""REST API is called with dialect=mysql."""
|
|
338
|
+
mock_native = _make_mock_native()
|
|
339
|
+
conn = Connection(mock_native)
|
|
340
|
+
compiled_sql = "SELECT 1"
|
|
341
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post:
|
|
342
|
+
with conn.cursor() as cur:
|
|
343
|
+
cur.execute("select:\n measures:\n - Revenue\n")
|
|
344
|
+
url = mock_post.call_args.args[0]
|
|
345
|
+
assert "/v1/query/sql" in url
|
|
346
|
+
assert mock_post.call_args.kwargs["params"] == {"dialect": "mysql"}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_obml_custom_api_url() -> None:
|
|
350
|
+
"""Custom ob_api_url is forwarded to the REST call."""
|
|
351
|
+
mock_native = _make_mock_native()
|
|
352
|
+
conn = Connection(mock_native, ob_api_url="http://my-api:9000")
|
|
353
|
+
compiled_sql = "SELECT 1"
|
|
354
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post:
|
|
355
|
+
with conn.cursor() as cur:
|
|
356
|
+
cur.execute("select:\n measures:\n - Revenue\n")
|
|
357
|
+
url = mock_post.call_args.args[0]
|
|
358
|
+
assert url == "http://my-api:9000/v1/query/sql"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_plain_sql_passthrough() -> None:
|
|
362
|
+
"""Plain SQL is passed through without OBML compilation — no REST call."""
|
|
363
|
+
mock_native = _make_mock_native()
|
|
364
|
+
conn = Connection(mock_native)
|
|
365
|
+
with patch("httpx.post") as mock_post, conn.cursor() as cur:
|
|
366
|
+
cur.execute("SELECT COUNT(*) FROM orders")
|
|
367
|
+
mock_post.assert_not_called()
|
|
368
|
+
mock_native.cursor().execute.assert_called_once_with("SELECT COUNT(*) FROM orders")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# fetch_arrow_table()
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_fetch_arrow_table() -> None:
|
|
377
|
+
"""fetch_arrow_table() converts rows to PyArrow Table."""
|
|
378
|
+
mock_native = _make_mock_native()
|
|
379
|
+
mock_cursor = mock_native.cursor()
|
|
380
|
+
mock_cursor.description = [
|
|
381
|
+
MysqlColumn("id", 3, None, None, None, None, None),
|
|
382
|
+
MysqlColumn("name", 253, None, None, None, None, None),
|
|
383
|
+
]
|
|
384
|
+
mock_cursor.fetchall.return_value = [(1, "Alice"), (2, "Bob")]
|
|
385
|
+
conn = Connection(mock_native)
|
|
386
|
+
cur = conn.cursor()
|
|
387
|
+
table = cur.fetch_arrow_table()
|
|
388
|
+
assert table.num_rows == 2
|
|
389
|
+
assert table.num_columns == 2
|
|
390
|
+
assert table.column_names == ["id", "name"]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_fetch_arrow_table_empty() -> None:
|
|
394
|
+
"""fetch_arrow_table() returns empty table with correct columns."""
|
|
395
|
+
mock_native = _make_mock_native()
|
|
396
|
+
mock_cursor = mock_native.cursor()
|
|
397
|
+
mock_cursor.description = [
|
|
398
|
+
MysqlColumn("id", 3, None, None, None, None, None),
|
|
399
|
+
MysqlColumn("name", 253, None, None, None, None, None),
|
|
400
|
+
]
|
|
401
|
+
mock_cursor.fetchall.return_value = []
|
|
402
|
+
conn = Connection(mock_native)
|
|
403
|
+
cur = conn.cursor()
|
|
404
|
+
table = cur.fetch_arrow_table()
|
|
405
|
+
assert table.num_rows == 0
|
|
406
|
+
assert table.column_names == ["id", "name"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Unit tests for OBML YAML detection — no external services required."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ob_mysql.compiler import is_obml, parse_obml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
("query", "expected"),
|
|
10
|
+
[
|
|
11
|
+
("select:\n dimensions:\n - Region\n measures:\n - Revenue\n", True),
|
|
12
|
+
("select:\n measures:\n - Revenue\n", True),
|
|
13
|
+
("select:\n dimensions:\n - Country\n", True),
|
|
14
|
+
("SELECT:\n dimensions:\n - X\n", True),
|
|
15
|
+
(" select:\n dimensions:\n - X\n", True),
|
|
16
|
+
("SELECT * FROM orders", False),
|
|
17
|
+
("SELECT id\nFROM customers WHERE id = 1", False),
|
|
18
|
+
("", False),
|
|
19
|
+
("model:\n name: test\n", False),
|
|
20
|
+
("select:\n filters:\n - x = 1\n", False),
|
|
21
|
+
("select:\n - [unclosed", False),
|
|
22
|
+
("select:\n - dimension1\n", False),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
def test_obml_detection(query: str, expected: bool) -> None:
|
|
26
|
+
assert is_obml(query) == expected
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_obml_returns_dict() -> None:
|
|
30
|
+
q = "select:\n dimensions:\n - Region\n measures:\n - Revenue\nlimit: 100\n"
|
|
31
|
+
result = parse_obml(q)
|
|
32
|
+
assert result["select"]["dimensions"] == ["Region"]
|
|
33
|
+
assert result["select"]["measures"] == ["Revenue"]
|
|
34
|
+
assert result["limit"] == 100
|