ob-driver-core 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_driver_core-0.1.0/.gitignore +35 -0
- ob_driver_core-0.1.0/LICENSE +70 -0
- ob_driver_core-0.1.0/PKG-INFO +33 -0
- ob_driver_core-0.1.0/README.md +15 -0
- ob_driver_core-0.1.0/pyproject.toml +43 -0
- ob_driver_core-0.1.0/src/ob_driver_core/__init__.py +5 -0
- ob_driver_core-0.1.0/src/ob_driver_core/compiler.py +52 -0
- ob_driver_core-0.1.0/src/ob_driver_core/detection.py +36 -0
- ob_driver_core-0.1.0/src/ob_driver_core/exceptions.py +57 -0
- ob_driver_core-0.1.0/src/ob_driver_core/type_codes.py +41 -0
- ob_driver_core-0.1.0/tests/test_compiler.py +86 -0
- ob_driver_core-0.1.0/tests/test_detection.py +34 -0
- ob_driver_core-0.1.0/tests/test_exceptions.py +63 -0
- ob_driver_core-0.1.0/tests/test_type_codes.py +55 -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*
|
|
@@ -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/.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ob-driver-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared foundation for OrionBelt DB-API 2.0 drivers (PEP 249 types, OBML detection, compilation bridge)
|
|
5
|
+
Author-email: Ralf Becher <info@orionbelt.ai>
|
|
6
|
+
License: BSL-1.1
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: db-api,obml,orionbelt,pep249,semantic-layer
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ob-driver-core
|
|
20
|
+
|
|
21
|
+
Shared foundation for OrionBelt DB-API 2.0 drivers (PEP 249 types, OBML detection, compilation bridge)
|
|
22
|
+
|
|
23
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralfbecher/orionbelt-semantic-layer) project.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install ob-driver-core
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ob-driver-core
|
|
2
|
+
|
|
3
|
+
Shared foundation for OrionBelt DB-API 2.0 drivers (PEP 249 types, OBML detection, compilation bridge)
|
|
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-driver-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ob-driver-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared foundation for OrionBelt DB-API 2.0 drivers (PEP 249 types, OBML detection, compilation bridge)"
|
|
9
|
+
license = { text = "BSL-1.1" }
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
authors = [{ name = "Ralf Becher", email = "info@orionbelt.ai" }]
|
|
12
|
+
keywords = ["semantic-layer", "obml", "db-api", "orionbelt", "pep249"]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pyyaml>=6.0",
|
|
16
|
+
"httpx>=0.27",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"mypy>=1.10",
|
|
23
|
+
"ruff>=0.4",
|
|
24
|
+
"respx>=0.21",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/ob_driver_core"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ANN"]
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
python_version = "3.12"
|
|
39
|
+
strict = true
|
|
40
|
+
files = ["src/ob_driver_core"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""OrionBelt compilation bridge for DB-API 2.0 drivers.
|
|
2
|
+
|
|
3
|
+
Compiles OBML query dicts to SQL by calling the OrionBelt REST API.
|
|
4
|
+
Assumes the API is running in single-model mode (MODEL_FILE set).
|
|
5
|
+
Uses the ``/v1/query/sql`` shortcut endpoint — no session or model ID required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ob_driver_core.exceptions import OperationalError, ProgrammingError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compile_obml(
|
|
19
|
+
obml: dict[str, Any],
|
|
20
|
+
*,
|
|
21
|
+
dialect: str,
|
|
22
|
+
ob_api_url: str = "http://localhost:8000",
|
|
23
|
+
ob_timeout: int = 30,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Compile an OBML query dict to SQL via the OrionBelt REST API.
|
|
26
|
+
|
|
27
|
+
Calls ``POST /v1/query/sql?dialect=...`` which auto-resolves the
|
|
28
|
+
single session and model in single-model mode.
|
|
29
|
+
"""
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
url = f"{ob_api_url.rstrip('/')}/v1/query/sql"
|
|
33
|
+
try:
|
|
34
|
+
response = httpx.post(
|
|
35
|
+
url,
|
|
36
|
+
params={"dialect": dialect},
|
|
37
|
+
json=obml,
|
|
38
|
+
timeout=ob_timeout,
|
|
39
|
+
)
|
|
40
|
+
except httpx.ConnectError as exc:
|
|
41
|
+
raise OperationalError(f"OB API unavailable at {ob_api_url}: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
if not response.is_success:
|
|
44
|
+
try:
|
|
45
|
+
detail = response.json().get("detail", response.text)
|
|
46
|
+
except Exception:
|
|
47
|
+
detail = response.text
|
|
48
|
+
if response.status_code < 500:
|
|
49
|
+
raise ProgrammingError(f"OB compile error: {detail}")
|
|
50
|
+
raise OperationalError(f"OB API error: {detail}")
|
|
51
|
+
|
|
52
|
+
return response.json()["sql"] # type: ignore[no-any-return]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""OBML YAML query detection.
|
|
2
|
+
|
|
3
|
+
Determines whether a query string is an OBML semantic query or plain SQL.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_obml(query: str) -> bool:
|
|
14
|
+
"""Return True if *query* is an OBML YAML query (starts with ``select:`` and
|
|
15
|
+
contains ``dimensions`` or ``measures``).
|
|
16
|
+
"""
|
|
17
|
+
stripped = query.strip()
|
|
18
|
+
if not stripped.lower().startswith("select:"):
|
|
19
|
+
return False
|
|
20
|
+
try:
|
|
21
|
+
parsed = yaml.safe_load(stripped)
|
|
22
|
+
except yaml.YAMLError:
|
|
23
|
+
return False
|
|
24
|
+
if not isinstance(parsed, dict):
|
|
25
|
+
return False
|
|
26
|
+
# YAML preserves key case — normalise to lowercase for lookup
|
|
27
|
+
lower_keys = {k.lower() if isinstance(k, str) else k: v for k, v in parsed.items()}
|
|
28
|
+
select = lower_keys.get("select", {})
|
|
29
|
+
if not isinstance(select, dict):
|
|
30
|
+
return False
|
|
31
|
+
return "dimensions" in select or "measures" in select
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_obml(query: str) -> dict[str, Any]:
|
|
35
|
+
"""Parse an OBML YAML string into a dict. Call only after :func:`is_obml`."""
|
|
36
|
+
return yaml.safe_load(query.strip()) # type: ignore[return-value]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""PEP 249 DB-API 2.0 exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Hierarchy (per PEP 249):
|
|
4
|
+
StandardError (builtin Exception)
|
|
5
|
+
├── Warning
|
|
6
|
+
└── Error
|
|
7
|
+
├── InterfaceError
|
|
8
|
+
└── DatabaseError
|
|
9
|
+
├── DataError
|
|
10
|
+
├── OperationalError
|
|
11
|
+
├── IntegrityError
|
|
12
|
+
├── InternalError
|
|
13
|
+
├── ProgrammingError
|
|
14
|
+
└── NotSupportedError
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Warning(Exception): # noqa: A001
|
|
21
|
+
"""Exception raised for important warnings (e.g. data truncation)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Error(Exception):
|
|
25
|
+
"""Base class for all DB-API errors."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InterfaceError(Error):
|
|
29
|
+
"""Exception for errors related to the database interface, not the database itself."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatabaseError(Error):
|
|
33
|
+
"""Exception for errors related to the database."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataError(DatabaseError):
|
|
37
|
+
"""Exception for errors due to problems with processed data."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OperationalError(DatabaseError):
|
|
41
|
+
"""Exception for errors related to the database's operation."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IntegrityError(DatabaseError):
|
|
45
|
+
"""Exception for errors related to database integrity."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InternalError(DatabaseError):
|
|
49
|
+
"""Exception for internal database errors."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProgrammingError(DatabaseError):
|
|
53
|
+
"""Exception for programming errors (e.g. bad SQL, table not found)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotSupportedError(DatabaseError):
|
|
57
|
+
"""Exception for optional features not supported by the database."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""PEP 249 DB-API 2.0 type objects.
|
|
2
|
+
|
|
3
|
+
Type objects compare equal to any value in their set, enabling:
|
|
4
|
+
if cursor.description[i][1] == NUMBER:
|
|
5
|
+
...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DBAPITypeObject:
|
|
12
|
+
"""PEP 249 type object that compares equal to any value in its set."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *values: str) -> None:
|
|
15
|
+
self._values = frozenset(values)
|
|
16
|
+
|
|
17
|
+
def __eq__(self, other: object) -> bool:
|
|
18
|
+
if isinstance(other, str):
|
|
19
|
+
return other in self._values
|
|
20
|
+
if isinstance(other, DBAPITypeObject):
|
|
21
|
+
return self._values == other._values
|
|
22
|
+
return NotImplemented
|
|
23
|
+
|
|
24
|
+
def __hash__(self) -> int:
|
|
25
|
+
return hash(self._values)
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f"DBAPITypeObject({', '.join(sorted(self._values))})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
STRING = DBAPITypeObject("STRING", "VARCHAR", "TEXT", "CHAR", "NVARCHAR", "NCHAR")
|
|
32
|
+
BINARY = DBAPITypeObject("BINARY", "BLOB", "VARBINARY", "BYTEA", "BYTES")
|
|
33
|
+
NUMBER = DBAPITypeObject(
|
|
34
|
+
"NUMBER", "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT",
|
|
35
|
+
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", "HUGEINT",
|
|
36
|
+
)
|
|
37
|
+
DATETIME = DBAPITypeObject(
|
|
38
|
+
"DATETIME", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ",
|
|
39
|
+
"TIMESTAMP_NTZ", "TIMESTAMP_LTZ", "TIMESTAMP_TZ",
|
|
40
|
+
)
|
|
41
|
+
ROWID = DBAPITypeObject("ROWID")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Unit tests for the compilation bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from ob_driver_core.compiler import compile_obml
|
|
11
|
+
from ob_driver_core.exceptions import OperationalError, ProgrammingError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SAMPLE_OBML = {"select": {"dimensions": ["Region"], "measures": ["Revenue"]}}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_compile_success() -> None:
|
|
18
|
+
"""Successful REST call returns SQL."""
|
|
19
|
+
mock_response = MagicMock()
|
|
20
|
+
mock_response.is_success = True
|
|
21
|
+
mock_response.json.return_value = {"sql": "SELECT region, SUM(amount) FROM orders GROUP BY region"}
|
|
22
|
+
|
|
23
|
+
with patch("httpx.post", return_value=mock_response) as mock_post:
|
|
24
|
+
sql = compile_obml(SAMPLE_OBML, dialect="duckdb")
|
|
25
|
+
assert sql == "SELECT region, SUM(amount) FROM orders GROUP BY region"
|
|
26
|
+
mock_post.assert_called_once()
|
|
27
|
+
call_kwargs = mock_post.call_args
|
|
28
|
+
assert call_kwargs.kwargs["params"] == {"dialect": "duckdb"}
|
|
29
|
+
assert "/v1/query/sql" in call_kwargs.args[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_compile_connect_error_raises_operational() -> None:
|
|
33
|
+
"""Connection failure to REST API raises OperationalError."""
|
|
34
|
+
with patch("httpx.post", side_effect=httpx.ConnectError("refused")):
|
|
35
|
+
with pytest.raises(OperationalError, match="unavailable"):
|
|
36
|
+
compile_obml(SAMPLE_OBML, dialect="duckdb")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_compile_4xx_raises_programming() -> None:
|
|
40
|
+
"""4xx response from REST API raises ProgrammingError."""
|
|
41
|
+
mock_response = MagicMock()
|
|
42
|
+
mock_response.is_success = False
|
|
43
|
+
mock_response.status_code = 400
|
|
44
|
+
mock_response.json.return_value = {"detail": "bad query"}
|
|
45
|
+
mock_response.text = "bad query"
|
|
46
|
+
|
|
47
|
+
with patch("httpx.post", return_value=mock_response):
|
|
48
|
+
with pytest.raises(ProgrammingError, match="bad query"):
|
|
49
|
+
compile_obml(SAMPLE_OBML, dialect="duckdb")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_compile_5xx_raises_operational() -> None:
|
|
53
|
+
"""5xx response from REST API raises OperationalError."""
|
|
54
|
+
mock_response = MagicMock()
|
|
55
|
+
mock_response.is_success = False
|
|
56
|
+
mock_response.status_code = 500
|
|
57
|
+
mock_response.json.return_value = {"detail": "internal"}
|
|
58
|
+
mock_response.text = "internal"
|
|
59
|
+
|
|
60
|
+
with patch("httpx.post", return_value=mock_response):
|
|
61
|
+
with pytest.raises(OperationalError, match="internal"):
|
|
62
|
+
compile_obml(SAMPLE_OBML, dialect="duckdb")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_compile_custom_api_url() -> None:
|
|
66
|
+
"""Custom ob_api_url is used in the request."""
|
|
67
|
+
mock_response = MagicMock()
|
|
68
|
+
mock_response.is_success = True
|
|
69
|
+
mock_response.json.return_value = {"sql": "SELECT 1"}
|
|
70
|
+
|
|
71
|
+
with patch("httpx.post", return_value=mock_response) as mock_post:
|
|
72
|
+
compile_obml(SAMPLE_OBML, dialect="postgres", ob_api_url="http://my-api:9000")
|
|
73
|
+
url = mock_post.call_args.args[0]
|
|
74
|
+
assert url == "http://my-api:9000/v1/query/sql"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_compile_trailing_slash_stripped() -> None:
|
|
78
|
+
"""Trailing slash in ob_api_url is stripped."""
|
|
79
|
+
mock_response = MagicMock()
|
|
80
|
+
mock_response.is_success = True
|
|
81
|
+
mock_response.json.return_value = {"sql": "SELECT 1"}
|
|
82
|
+
|
|
83
|
+
with patch("httpx.post", return_value=mock_response) as mock_post:
|
|
84
|
+
compile_obml(SAMPLE_OBML, dialect="duckdb", ob_api_url="http://localhost:8000/")
|
|
85
|
+
url = mock_post.call_args.args[0]
|
|
86
|
+
assert url == "http://localhost:8000/v1/query/sql"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Unit tests for OBML YAML detection."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ob_driver_core.detection 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 FROM 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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Verify the PEP 249 exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from ob_driver_core.exceptions import (
|
|
4
|
+
DataError,
|
|
5
|
+
DatabaseError,
|
|
6
|
+
Error,
|
|
7
|
+
IntegrityError,
|
|
8
|
+
InterfaceError,
|
|
9
|
+
InternalError,
|
|
10
|
+
NotSupportedError,
|
|
11
|
+
OperationalError,
|
|
12
|
+
ProgrammingError,
|
|
13
|
+
Warning,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_warning_is_exception() -> None:
|
|
18
|
+
assert issubclass(Warning, Exception)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_error_is_exception() -> None:
|
|
22
|
+
assert issubclass(Error, Exception)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_interface_error_is_error() -> None:
|
|
26
|
+
assert issubclass(InterfaceError, Error)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_database_error_is_error() -> None:
|
|
30
|
+
assert issubclass(DatabaseError, Error)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_data_error_is_database_error() -> None:
|
|
34
|
+
assert issubclass(DataError, DatabaseError)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_operational_error_is_database_error() -> None:
|
|
38
|
+
assert issubclass(OperationalError, DatabaseError)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_integrity_error_is_database_error() -> None:
|
|
42
|
+
assert issubclass(IntegrityError, DatabaseError)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_internal_error_is_database_error() -> None:
|
|
46
|
+
assert issubclass(InternalError, DatabaseError)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_programming_error_is_database_error() -> None:
|
|
50
|
+
assert issubclass(ProgrammingError, DatabaseError)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_not_supported_error_is_database_error() -> None:
|
|
54
|
+
assert issubclass(NotSupportedError, DatabaseError)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_can_raise_and_catch_by_parent() -> None:
|
|
58
|
+
try:
|
|
59
|
+
raise ProgrammingError("bad query")
|
|
60
|
+
except DatabaseError as exc:
|
|
61
|
+
assert str(exc) == "bad query"
|
|
62
|
+
except Exception:
|
|
63
|
+
raise AssertionError("ProgrammingError should be catchable as DatabaseError")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Verify PEP 249 type objects."""
|
|
2
|
+
|
|
3
|
+
from ob_driver_core.type_codes import BINARY, DATETIME, NUMBER, ROWID, STRING, DBAPITypeObject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_string_matches() -> None:
|
|
7
|
+
assert STRING == "STRING"
|
|
8
|
+
assert STRING == "VARCHAR"
|
|
9
|
+
assert STRING == "TEXT"
|
|
10
|
+
assert STRING != "INTEGER"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_number_matches() -> None:
|
|
14
|
+
assert NUMBER == "INT"
|
|
15
|
+
assert NUMBER == "INTEGER"
|
|
16
|
+
assert NUMBER == "BIGINT"
|
|
17
|
+
assert NUMBER == "FLOAT"
|
|
18
|
+
assert NUMBER == "DECIMAL"
|
|
19
|
+
assert NUMBER != "VARCHAR"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_datetime_matches() -> None:
|
|
23
|
+
assert DATETIME == "TIMESTAMP"
|
|
24
|
+
assert DATETIME == "DATE"
|
|
25
|
+
assert DATETIME == "TIMESTAMP_NTZ"
|
|
26
|
+
assert DATETIME != "INT"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_binary_matches() -> None:
|
|
30
|
+
assert BINARY == "BLOB"
|
|
31
|
+
assert BINARY == "BYTEA"
|
|
32
|
+
assert BINARY != "TEXT"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_rowid_matches() -> None:
|
|
36
|
+
assert ROWID == "ROWID"
|
|
37
|
+
assert ROWID != "INT"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_equality_between_type_objects() -> None:
|
|
41
|
+
other = DBAPITypeObject("STRING", "VARCHAR", "TEXT", "CHAR", "NVARCHAR", "NCHAR")
|
|
42
|
+
assert STRING == other
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_repr() -> None:
|
|
46
|
+
r = repr(ROWID)
|
|
47
|
+
assert "ROWID" in r
|
|
48
|
+
assert "DBAPITypeObject" in r
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_hash_stable() -> None:
|
|
52
|
+
a = DBAPITypeObject("A", "B")
|
|
53
|
+
b = DBAPITypeObject("B", "A")
|
|
54
|
+
assert hash(a) == hash(b)
|
|
55
|
+
assert a == b
|