ob-postgres 2.1.0__tar.gz → 2.36.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_postgres-2.36.0/.gitignore +67 -0
- ob_postgres-2.36.0/CLAUDE.md +1 -0
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/LICENSE +2 -2
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/PKG-INFO +6 -6
- ob_postgres-2.36.0/README.md +15 -0
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/pyproject.toml +20 -3
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/cursor.py +91 -5
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/type_codes.py +1 -1
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/tests/test_driver.py +86 -21
- ob_postgres-2.36.0/tests/test_yaml_detection.py +34 -0
- ob_postgres-2.1.0/.gitignore +0 -37
- ob_postgres-2.1.0/README.md +0 -15
- ob_postgres-2.1.0/tests/test_yaml_detection.py +0 -29
- ob_postgres-2.1.0/CLAUDE.md → ob_postgres-2.36.0/AGENTS.md +0 -0
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/__init__.py +10 -10
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/compiler.py +0 -0
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/connection.py +0 -0
- {ob_postgres-2.1.0 → ob_postgres-2.36.0}/src/ob_postgres/exceptions.py +7 -7
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
# Generated FOCUS export (20 MB); rebuilt by scripts/build_finops_duckdb.py.
|
|
16
|
+
# The committed excerpt is examples/finops_charges_sample.json.
|
|
17
|
+
examples/finops_data/
|
|
18
|
+
*.sqlite3
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
htmlcov/
|
|
23
|
+
.coverage
|
|
24
|
+
.coverage.*
|
|
25
|
+
*.log
|
|
26
|
+
.DS_Store
|
|
27
|
+
.claude/
|
|
28
|
+
# Playwright MCP scratch output: accessibility snapshots and console logs
|
|
29
|
+
# written per browser session. The console logs were already caught by
|
|
30
|
+
# *.log above, which left only the .yml page dumps showing as untracked.
|
|
31
|
+
.playwright-mcp/
|
|
32
|
+
# Playwright writes screenshots to the working directory when the tool is
|
|
33
|
+
# given a bare filename, which is the repo root. Twice now one has been
|
|
34
|
+
# swept into a commit by ``git add -A``.
|
|
35
|
+
*.playwright.png
|
|
36
|
+
gradio*-production.png
|
|
37
|
+
ui-gradio*.png
|
|
38
|
+
/cache/
|
|
39
|
+
/usage/
|
|
40
|
+
security/
|
|
41
|
+
infra/
|
|
42
|
+
site/
|
|
43
|
+
scripts-infra
|
|
44
|
+
.mcpregistry_github_token
|
|
45
|
+
.mcpregistry_registry_token
|
|
46
|
+
_*/
|
|
47
|
+
blog/
|
|
48
|
+
review*
|
|
49
|
+
design/
|
|
50
|
+
examples/test*
|
|
51
|
+
# TPC-DS sweep harness. The queries, the runner and the compiled SQL are
|
|
52
|
+
# published so the coverage is auditable; the generated database, the working
|
|
53
|
+
# notes and the drafts that do not yet match the reference are not.
|
|
54
|
+
examples/tpcds_queries/*
|
|
55
|
+
!examples/tpcds_queries/README.md
|
|
56
|
+
!examples/tpcds_queries/sweep.py
|
|
57
|
+
!examples/tpcds_queries/Q*.yml
|
|
58
|
+
!examples/tpcds_queries/sql/
|
|
59
|
+
examples/tpcds_queries/*.duckdb
|
|
60
|
+
examples/tpcds_queries/REPORT.md
|
|
61
|
+
examples/tpcds_queries/tpcds_run.py
|
|
62
|
+
examples/tpcds_queries/drafts/
|
|
63
|
+
semantic-layer-spec.md
|
|
64
|
+
|
|
65
|
+
# Generated per-vendor seed scripts (backup of what the drift seed executed)
|
|
66
|
+
tests/integration/drift/vendor_exec/seed_sql/
|
|
67
|
+
examples/tpcds_queries/results/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -3,8 +3,8 @@ Business Source License 1.1
|
|
|
3
3
|
Parameters
|
|
4
4
|
|
|
5
5
|
Licensor: RALFORION d.o.o.
|
|
6
|
-
Licensed Work: OrionBelt Semantic Layer
|
|
7
|
-
The Licensed Work is (c) 2025 RALFORION d.o.o.
|
|
6
|
+
Licensed Work: OrionBelt Semantic Layer (all versions)
|
|
7
|
+
The Licensed Work is (c) 2025-2026 RALFORION d.o.o.
|
|
8
8
|
Additional Use Grant: You may use the Licensed Work for any purpose,
|
|
9
9
|
including production use, PROVIDED THAT you may
|
|
10
10
|
not embed OrionBelt in a commercial product or
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: ob-postgres
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.36.0
|
|
4
4
|
Summary: OrionBelt Semantic Layer driver for PostgreSQL (DB-API 2.0)
|
|
5
5
|
Author-email: Ralf Becher <info@orionbelt.ai>
|
|
6
|
-
License:
|
|
6
|
+
License-Expression: BUSL-1.1
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Keywords: db-api,obml,orionbelt,postgres,semantic-layer
|
|
9
9
|
Requires-Python: >=3.12
|
|
10
10
|
Requires-Dist: adbc-driver-postgresql>=1.0
|
|
11
|
-
Requires-Dist: ob-driver-core>=0
|
|
11
|
+
Requires-Dist: ob-driver-core>=2.36.0
|
|
12
12
|
Requires-Dist: pyarrow>=16.0
|
|
13
13
|
Provides-Extra: dev
|
|
14
14
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
OrionBelt Semantic Layer driver for PostgreSQL (DB-API 2.0)
|
|
26
26
|
|
|
27
|
-
Part of the [OrionBelt Semantic Layer](https://github.com/
|
|
27
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralforion/orionbelt-semantic-layer) project.
|
|
28
28
|
|
|
29
29
|
## Installation
|
|
30
30
|
|
|
@@ -34,4 +34,4 @@ pip install ob-postgres
|
|
|
34
34
|
|
|
35
35
|
## License
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
BUSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ob-postgres
|
|
2
|
+
|
|
3
|
+
OrionBelt Semantic Layer driver for PostgreSQL (DB-API 2.0)
|
|
4
|
+
|
|
5
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralforion/orionbelt-semantic-layer) project.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ob-postgres
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
BUSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -4,15 +4,16 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ob-postgres"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.36.0"
|
|
8
8
|
description = "OrionBelt Semantic Layer driver for PostgreSQL (DB-API 2.0)"
|
|
9
|
-
license =
|
|
9
|
+
license = "BUSL-1.1"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
10
11
|
readme = "README.md"
|
|
11
12
|
authors = [{ name = "Ralf Becher", email = "info@orionbelt.ai" }]
|
|
12
13
|
keywords = ["postgres", "semantic-layer", "obml", "db-api", "orionbelt"]
|
|
13
14
|
requires-python = ">=3.12"
|
|
14
15
|
dependencies = [
|
|
15
|
-
"ob-driver-core>=0
|
|
16
|
+
"ob-driver-core>=2.36.0",
|
|
16
17
|
"adbc-driver-postgresql>=1.0",
|
|
17
18
|
"pyarrow>=16.0",
|
|
18
19
|
]
|
|
@@ -39,12 +40,28 @@ target-version = "py312"
|
|
|
39
40
|
|
|
40
41
|
[tool.ruff.lint]
|
|
41
42
|
select = ["E", "F", "I", "UP", "B", "SIM", "ANN"]
|
|
43
|
+
ignore = [
|
|
44
|
+
# PEP 249 is an untyped boundary by specification: ``execute()`` takes
|
|
45
|
+
# whatever the caller passes, and every cursor wraps a native object no
|
|
46
|
+
# vendor annotates. ``Any`` there is the honest signature, not a missing one.
|
|
47
|
+
"ANN401",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.per-file-ignores]
|
|
51
|
+
# A test reads better with its patches nested, and its return type says nothing
|
|
52
|
+
# a reader needs.
|
|
53
|
+
"tests/**" = ["ANN001", "ANN201", "SIM117"]
|
|
42
54
|
|
|
43
55
|
[tool.mypy]
|
|
44
56
|
python_version = "3.12"
|
|
45
57
|
strict = true
|
|
46
58
|
files = ["src/ob_postgres"]
|
|
47
59
|
|
|
60
|
+
# Untyped upstream - neither ships a py.typed marker.
|
|
61
|
+
[[tool.mypy.overrides]]
|
|
62
|
+
module = ["pyarrow.*", "adbc_driver_postgresql.*"]
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
|
|
48
65
|
[tool.pytest.ini_options]
|
|
49
66
|
asyncio_mode = "auto"
|
|
50
67
|
testpaths = ["tests"]
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from ob_postgres.compiler import compile_obml, is_obml, parse_obml
|
|
13
13
|
from ob_postgres.exceptions import NotSupportedError, ProgrammingError
|
|
14
|
-
from ob_postgres.type_codes import PG_OID_MAP, STRING
|
|
14
|
+
from ob_postgres.type_codes import BINARY, DATETIME, NUMBER, PG_OID_MAP, STRING
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class Cursor:
|
|
@@ -42,7 +42,14 @@ class Cursor:
|
|
|
42
42
|
def description(
|
|
43
43
|
self,
|
|
44
44
|
) -> tuple[tuple[str, Any, None, None, None, None, None], ...] | None:
|
|
45
|
-
"""PEP 249 cursor description — 7-item tuples per column.
|
|
45
|
+
"""PEP 249 cursor description — 7-item tuples per column.
|
|
46
|
+
|
|
47
|
+
``adbc-driver-postgresql`` returns PyArrow ``DataType`` objects
|
|
48
|
+
(or ``OpaqueType`` for vendor-specific types like ``numeric``)
|
|
49
|
+
as the ``type_code`` slot. psycopg2 returns OID integers. We
|
|
50
|
+
handle both so downstream type-hint mapping (``_map_type_code``
|
|
51
|
+
in db_executor.py) sees the right PEP 249 type constant.
|
|
52
|
+
"""
|
|
46
53
|
native_desc = self._native.description
|
|
47
54
|
if native_desc is None:
|
|
48
55
|
return None
|
|
@@ -50,14 +57,20 @@ class Cursor:
|
|
|
50
57
|
for col in native_desc:
|
|
51
58
|
name = col[0]
|
|
52
59
|
type_code = col[1]
|
|
53
|
-
|
|
54
|
-
mapped = PG_OID_MAP.get(type_code, STRING) if isinstance(type_code, int) else STRING
|
|
60
|
+
mapped = _classify_type_code(type_code)
|
|
55
61
|
cols.append((name, mapped, None, None, None, None, None))
|
|
56
62
|
return tuple(cols)
|
|
57
63
|
|
|
58
64
|
@property
|
|
59
65
|
def rowcount(self) -> int:
|
|
60
|
-
|
|
66
|
+
"""Rows the last statement produced, or -1 where the driver says nothing.
|
|
67
|
+
|
|
68
|
+
``self._native`` is untyped, so the value arrives as ``Any``; PEP 249
|
|
69
|
+
promises an ``int`` and -1 is what it reserves for "unknown", which is
|
|
70
|
+
what a connector answering ``None`` means.
|
|
71
|
+
"""
|
|
72
|
+
count = self._native.rowcount
|
|
73
|
+
return int(count) if isinstance(count, int) else -1
|
|
61
74
|
|
|
62
75
|
def _check_open(self) -> None:
|
|
63
76
|
if self._closed:
|
|
@@ -156,3 +169,76 @@ class Cursor:
|
|
|
156
169
|
if row is None:
|
|
157
170
|
raise StopIteration
|
|
158
171
|
return row
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Substrings inside an OpaqueType's repr that identify the underlying
|
|
175
|
+
# Postgres type. ADBC wraps types it can't represent natively (NUMERIC,
|
|
176
|
+
# MONEY, JSON, …) in ``OpaqueType`` whose repr embeds ``type_name=<pg>``.
|
|
177
|
+
_OPAQUE_NUMBER_NAMES = ("numeric", "money", "decimal")
|
|
178
|
+
_OPAQUE_DATETIME_NAMES = ("timestamp", "date", "time", "interval")
|
|
179
|
+
_OPAQUE_BINARY_NAMES = ("bytea",)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _classify_type_code(type_code: object) -> object:
|
|
183
|
+
"""Map a native ADBC type identifier to a PEP 249 type constant.
|
|
184
|
+
|
|
185
|
+
ADBC's PostgreSQL driver populates ``cursor.description[i][1]``
|
|
186
|
+
with PyArrow ``DataType`` objects — ``DataType(int32)``,
|
|
187
|
+
``TimestampType(timestamp[us, tz=UTC])``, ``DataType(string)`` —
|
|
188
|
+
plus ``OpaqueType`` wrappers for types Arrow can't natively
|
|
189
|
+
represent (NUMERIC ends up as
|
|
190
|
+
``OpaqueType(extension<arrow.opaque[storage_type=string,
|
|
191
|
+
type_name=numeric, vendor_name=PostgreSQL]>)``).
|
|
192
|
+
The legacy psycopg2 path supplies OID integers, kept for
|
|
193
|
+
compatibility.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
# psycopg2 / classic PEP 249 — bare OID.
|
|
197
|
+
if isinstance(type_code, int):
|
|
198
|
+
return PG_OID_MAP.get(type_code, STRING)
|
|
199
|
+
|
|
200
|
+
# ADBC native PyArrow types — use pyarrow.types helpers when the
|
|
201
|
+
# module is importable. ``pa.types.is_*`` calls ``t.id`` internally,
|
|
202
|
+
# so an unrelated object (OpaqueType subclass, mock, etc.) trips
|
|
203
|
+
# ``AttributeError`` — catch that and fall through to the
|
|
204
|
+
# repr-substring branch below.
|
|
205
|
+
try:
|
|
206
|
+
import pyarrow as pa
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if pa.types.is_integer(type_code):
|
|
210
|
+
return NUMBER
|
|
211
|
+
if pa.types.is_floating(type_code) or pa.types.is_decimal(type_code):
|
|
212
|
+
return NUMBER
|
|
213
|
+
if pa.types.is_boolean(type_code):
|
|
214
|
+
return STRING
|
|
215
|
+
if (
|
|
216
|
+
pa.types.is_timestamp(type_code)
|
|
217
|
+
or pa.types.is_date(type_code)
|
|
218
|
+
or pa.types.is_time(type_code)
|
|
219
|
+
or pa.types.is_duration(type_code)
|
|
220
|
+
):
|
|
221
|
+
return DATETIME
|
|
222
|
+
if (
|
|
223
|
+
pa.types.is_binary(type_code)
|
|
224
|
+
or pa.types.is_large_binary(type_code)
|
|
225
|
+
or pa.types.is_fixed_size_binary(type_code)
|
|
226
|
+
):
|
|
227
|
+
return BINARY
|
|
228
|
+
if pa.types.is_string(type_code) or pa.types.is_large_string(type_code):
|
|
229
|
+
return STRING
|
|
230
|
+
except (AttributeError, TypeError):
|
|
231
|
+
pass
|
|
232
|
+
except ImportError:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# OpaqueType (NUMERIC, MONEY, JSON, …) — the type's repr embeds
|
|
236
|
+
# ``type_name=<pg type>``. Match against the substring lists.
|
|
237
|
+
s = str(type_code).lower()
|
|
238
|
+
if any(name in s for name in _OPAQUE_NUMBER_NAMES):
|
|
239
|
+
return NUMBER
|
|
240
|
+
if any(name in s for name in _OPAQUE_DATETIME_NAMES):
|
|
241
|
+
return DATETIME
|
|
242
|
+
if any(name in s for name in _OPAQUE_BINARY_NAMES):
|
|
243
|
+
return BINARY
|
|
244
|
+
return STRING
|
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from ob_driver_core.type_codes import BINARY, DATETIME, NUMBER, ROWID, STRING
|
|
10
10
|
|
|
11
|
-
__all__ = ["
|
|
11
|
+
__all__ = ["BINARY", "DATETIME", "NUMBER", "PG_OID_MAP", "ROWID", "STRING"]
|
|
12
12
|
|
|
13
13
|
# Common PostgreSQL OIDs — see pg_type catalog
|
|
14
14
|
# https://www.postgresql.org/docs/current/catalog-pg-type.html
|
|
@@ -16,14 +16,15 @@ from ob_postgres.connection import Connection
|
|
|
16
16
|
from ob_postgres.exceptions import NotSupportedError, ProgrammingError
|
|
17
17
|
from ob_postgres.type_codes import DATETIME, NUMBER, STRING
|
|
18
18
|
|
|
19
|
-
|
|
20
19
|
# ---------------------------------------------------------------------------
|
|
21
20
|
# Helper to build a mock psycopg2 connection
|
|
22
21
|
# ---------------------------------------------------------------------------
|
|
23
22
|
|
|
24
23
|
# psycopg2 description columns are named tuples
|
|
25
|
-
PgColumn = namedtuple(
|
|
26
|
-
|
|
24
|
+
PgColumn = namedtuple(
|
|
25
|
+
"Column",
|
|
26
|
+
["name", "type_code", "display_size", "internal_size", "precision", "scale", "null_ok"],
|
|
27
|
+
)
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _make_mock_native() -> MagicMock:
|
|
@@ -62,7 +63,10 @@ def test_threadsafety() -> None:
|
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
def test_paramstyle() -> None:
|
|
65
|
-
|
|
66
|
+
# ADBC uses ``?`` placeholders — paramstyle ``qmark``. The legacy
|
|
67
|
+
# psycopg2-based driver used ``%s`` (``format``); the migration to
|
|
68
|
+
# ``adbc-driver-postgresql`` changed this.
|
|
69
|
+
assert ob_postgres.paramstyle == "qmark"
|
|
66
70
|
|
|
67
71
|
|
|
68
72
|
# ---------------------------------------------------------------------------
|
|
@@ -71,27 +75,30 @@ def test_paramstyle() -> None:
|
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
def test_connect_returns_connection() -> None:
|
|
74
|
-
with patch("
|
|
78
|
+
with patch("adbc_driver_postgresql.dbapi.connect") as mock_connect:
|
|
75
79
|
mock_connect.return_value = _make_mock_native()
|
|
76
80
|
conn = ob_postgres.connect(dbname="testdb", user="testuser")
|
|
77
81
|
assert isinstance(conn, Connection)
|
|
78
82
|
mock_connect.assert_called_once()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
# ADBC takes a URI as its first positional arg — the kwargs are
|
|
84
|
+
# encoded into it. Verify both made the trip.
|
|
85
|
+
(uri,) = mock_connect.call_args.args
|
|
86
|
+
assert "testdb" in uri
|
|
87
|
+
assert "testuser" in uri
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
def test_connect_with_dsn() -> None:
|
|
85
|
-
with patch("
|
|
91
|
+
with patch("adbc_driver_postgresql.dbapi.connect") as mock_connect:
|
|
86
92
|
mock_connect.return_value = _make_mock_native()
|
|
87
|
-
|
|
93
|
+
dsn = "postgresql://localhost:5432/mydb"
|
|
94
|
+
conn = ob_postgres.connect(dsn=dsn)
|
|
88
95
|
assert isinstance(conn, Connection)
|
|
89
|
-
|
|
90
|
-
assert
|
|
96
|
+
(uri,) = mock_connect.call_args.args
|
|
97
|
+
assert uri == dsn
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
def test_connect_context_manager() -> None:
|
|
94
|
-
with patch("
|
|
101
|
+
with patch("adbc_driver_postgresql.dbapi.connect") as mock_connect:
|
|
95
102
|
mock_native = _make_mock_native()
|
|
96
103
|
mock_connect.return_value = mock_native
|
|
97
104
|
with ob_postgres.connect() as conn:
|
|
@@ -167,8 +174,8 @@ def test_cursor_description() -> None:
|
|
|
167
174
|
mock_native = _make_mock_native()
|
|
168
175
|
mock_cursor = mock_native.cursor()
|
|
169
176
|
mock_cursor.description = [
|
|
170
|
-
PgColumn("num", 23, None, None, None, None, None),
|
|
171
|
-
PgColumn("txt", 25, None, None, None, None, None),
|
|
177
|
+
PgColumn("num", 23, None, None, None, None, None), # int4 → NUMBER
|
|
178
|
+
PgColumn("txt", 25, None, None, None, None, None), # text → STRING
|
|
172
179
|
PgColumn("dt", 1114, None, None, None, None, None), # timestamp → DATETIME
|
|
173
180
|
]
|
|
174
181
|
conn = Connection(mock_native)
|
|
@@ -185,6 +192,63 @@ def test_cursor_description() -> None:
|
|
|
185
192
|
assert desc[2][1] == DATETIME
|
|
186
193
|
|
|
187
194
|
|
|
195
|
+
def test_cursor_description_classifies_arrow_datatypes() -> None:
|
|
196
|
+
"""ADBC returns PyArrow ``DataType`` objects, not OIDs.
|
|
197
|
+
|
|
198
|
+
The legacy code path checked ``isinstance(type_code, int)`` and
|
|
199
|
+
fell back to ``STRING`` for everything else — which caused every
|
|
200
|
+
column from a live ADBC PostgreSQL connection to surface as TEXT,
|
|
201
|
+
breaking Tableau (NUMERIC measures decoded as strings → SUM = 0).
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
import pyarrow as pa
|
|
205
|
+
|
|
206
|
+
mock_native = _make_mock_native()
|
|
207
|
+
mock_cursor = mock_native.cursor()
|
|
208
|
+
mock_cursor.description = [
|
|
209
|
+
PgColumn("a", pa.int32(), None, None, None, None, None),
|
|
210
|
+
PgColumn("b", pa.float64(), None, None, None, None, None),
|
|
211
|
+
PgColumn("c", pa.string(), None, None, None, None, None),
|
|
212
|
+
PgColumn("d", pa.timestamp("us"), None, None, None, None, None),
|
|
213
|
+
PgColumn("e", pa.binary(), None, None, None, None, None),
|
|
214
|
+
]
|
|
215
|
+
conn = Connection(mock_native)
|
|
216
|
+
cur = conn.cursor()
|
|
217
|
+
desc = cur.description
|
|
218
|
+
assert desc is not None
|
|
219
|
+
assert desc[0][1] == NUMBER # int32
|
|
220
|
+
assert desc[1][1] == NUMBER # float64
|
|
221
|
+
assert desc[2][1] == STRING # string
|
|
222
|
+
assert desc[3][1] == DATETIME # timestamp
|
|
223
|
+
from ob_postgres.type_codes import BINARY
|
|
224
|
+
|
|
225
|
+
assert desc[4][1] == BINARY # binary
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_cursor_description_classifies_opaque_numeric() -> None:
|
|
229
|
+
"""ADBC wraps Postgres NUMERIC in an OpaqueType — repr contains ``type_name=numeric``.
|
|
230
|
+
|
|
231
|
+
Falls through pa.types.is_decimal() (which only matches Arrow
|
|
232
|
+
DecimalType) and into the OpaqueType repr-substring branch.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
class _FakeOpaque:
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
return (
|
|
238
|
+
"OpaqueType(extension<arrow.opaque[storage_type=string, "
|
|
239
|
+
"type_name=numeric, vendor_name=PostgreSQL]>)"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
mock_native = _make_mock_native()
|
|
243
|
+
mock_native.cursor().description = [
|
|
244
|
+
PgColumn("total_sales", _FakeOpaque(), None, None, None, None, None),
|
|
245
|
+
]
|
|
246
|
+
cur = Connection(mock_native).cursor()
|
|
247
|
+
desc = cur.description
|
|
248
|
+
assert desc is not None
|
|
249
|
+
assert desc[0][1] == NUMBER
|
|
250
|
+
|
|
251
|
+
|
|
188
252
|
def test_cursor_description_none_before_execute() -> None:
|
|
189
253
|
mock_native = _make_mock_native()
|
|
190
254
|
mock_native.cursor().description = None
|
|
@@ -309,7 +373,9 @@ def test_obml_compile_and_execute() -> None:
|
|
|
309
373
|
PgColumn("revenue", 701, None, None, None, None, None),
|
|
310
374
|
]
|
|
311
375
|
mock_native.cursor().fetchall.return_value = [
|
|
312
|
-
("EMEA", 300.0),
|
|
376
|
+
("EMEA", 300.0),
|
|
377
|
+
("APAC", 150.0),
|
|
378
|
+
("AMER", 550.0),
|
|
313
379
|
]
|
|
314
380
|
conn = Connection(mock_native)
|
|
315
381
|
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
@@ -350,8 +416,7 @@ def test_plain_sql_passthrough() -> None:
|
|
|
350
416
|
"""Plain SQL is passed through without OBML compilation — no REST call."""
|
|
351
417
|
mock_native = _make_mock_native()
|
|
352
418
|
conn = Connection(mock_native)
|
|
353
|
-
with patch("httpx.post") as mock_post:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
mock_native.cursor().execute.assert_called_once_with("SELECT COUNT(*) FROM orders")
|
|
419
|
+
with patch("httpx.post") as mock_post, conn.cursor() as cur:
|
|
420
|
+
cur.execute("SELECT COUNT(*) FROM orders")
|
|
421
|
+
mock_post.assert_not_called()
|
|
422
|
+
mock_native.cursor().execute.assert_called_once_with("SELECT COUNT(*) FROM orders")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Unit tests for OBML YAML detection — no external services required."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from ob_postgres.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, expected):
|
|
26
|
+
assert is_obml(query) == expected
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_obml_returns_dict():
|
|
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
|
ob_postgres-2.1.0/.gitignore
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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*
|
|
36
|
-
examples/tpcds_queries/
|
|
37
|
-
semantic-layer-spec.md
|
ob_postgres-2.1.0/README.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# ob-postgres
|
|
2
|
-
|
|
3
|
-
OrionBelt Semantic Layer driver for PostgreSQL (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-postgres
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## License
|
|
14
|
-
|
|
15
|
-
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"""Unit tests for OBML YAML detection — no external services required."""
|
|
2
|
-
import pytest
|
|
3
|
-
from ob_postgres.compiler import is_obml, parse_obml
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@pytest.mark.parametrize("query,expected", [
|
|
7
|
-
("select:\n dimensions:\n - Region\n measures:\n - Revenue\n", True),
|
|
8
|
-
("select:\n measures:\n - Revenue\n", True),
|
|
9
|
-
("select:\n dimensions:\n - Country\n", True),
|
|
10
|
-
("SELECT:\n dimensions:\n - X\n", True),
|
|
11
|
-
(" select:\n dimensions:\n - X\n", True),
|
|
12
|
-
("SELECT * FROM orders", False),
|
|
13
|
-
("SELECT id\nFROM customers WHERE id = 1", False),
|
|
14
|
-
("", False),
|
|
15
|
-
("model:\n name: test\n", False),
|
|
16
|
-
("select:\n filters:\n - x = 1\n", False),
|
|
17
|
-
("select:\n - [unclosed", False),
|
|
18
|
-
("select:\n - dimension1\n", False),
|
|
19
|
-
])
|
|
20
|
-
def test_obml_detection(query, expected):
|
|
21
|
-
assert is_obml(query) == expected
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_parse_obml_returns_dict():
|
|
25
|
-
q = "select:\n dimensions:\n - Region\n measures:\n - Revenue\nlimit: 100\n"
|
|
26
|
-
result = parse_obml(q)
|
|
27
|
-
assert result["select"]["dimensions"] == ["Region"]
|
|
28
|
-
assert result["select"]["measures"] == ["Revenue"]
|
|
29
|
-
assert result["limit"] == 100
|
|
File without changes
|
|
@@ -139,19 +139,19 @@ def connect(
|
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
__all__ = [
|
|
142
|
-
"apilevel",
|
|
143
|
-
"threadsafety",
|
|
144
|
-
"paramstyle",
|
|
145
|
-
"connect",
|
|
146
142
|
"Connection",
|
|
147
|
-
"Warning",
|
|
148
|
-
"Error",
|
|
149
|
-
"InterfaceError",
|
|
150
|
-
"DatabaseError",
|
|
151
143
|
"DataError",
|
|
152
|
-
"
|
|
144
|
+
"DatabaseError",
|
|
145
|
+
"Error",
|
|
153
146
|
"IntegrityError",
|
|
147
|
+
"InterfaceError",
|
|
154
148
|
"InternalError",
|
|
155
|
-
"ProgrammingError",
|
|
156
149
|
"NotSupportedError",
|
|
150
|
+
"OperationalError",
|
|
151
|
+
"ProgrammingError",
|
|
152
|
+
"Warning",
|
|
153
|
+
"apilevel",
|
|
154
|
+
"connect",
|
|
155
|
+
"paramstyle",
|
|
156
|
+
"threadsafety",
|
|
157
157
|
]
|
|
File without changes
|
|
File without changes
|
|
@@ -6,8 +6,8 @@ Allows users to do: ``from ob_postgres.exceptions import ProgrammingError``
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from ob_driver_core.exceptions import (
|
|
9
|
-
DataError,
|
|
10
9
|
DatabaseError,
|
|
10
|
+
DataError,
|
|
11
11
|
Error,
|
|
12
12
|
IntegrityError,
|
|
13
13
|
InterfaceError,
|
|
@@ -19,14 +19,14 @@ from ob_driver_core.exceptions import (
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
|
-
"Warning",
|
|
23
|
-
"Error",
|
|
24
|
-
"InterfaceError",
|
|
25
|
-
"DatabaseError",
|
|
26
22
|
"DataError",
|
|
27
|
-
"
|
|
23
|
+
"DatabaseError",
|
|
24
|
+
"Error",
|
|
28
25
|
"IntegrityError",
|
|
26
|
+
"InterfaceError",
|
|
29
27
|
"InternalError",
|
|
30
|
-
"ProgrammingError",
|
|
31
28
|
"NotSupportedError",
|
|
29
|
+
"OperationalError",
|
|
30
|
+
"ProgrammingError",
|
|
31
|
+
"Warning",
|
|
32
32
|
]
|