ob-databricks 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.
@@ -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,104 @@
1
+ # ob-databricks — OrionBelt Semantic Layer Driver for Databricks
2
+
3
+ ## Purpose
4
+
5
+ PEP 249 DB-API 2.0 driver wrapping `databricks-sql-connector = ">=3.0"` that intercepts OBML YAML
6
+ queries, compiles them to databricks SQL via the OrionBelt CompilationPipeline
7
+ (direct import) or OB REST API (standalone), and executes them natively.
8
+
9
+ **OB dialect string:** `"databricks"`
10
+ **Author:** Ralf Becher / RALFORION d.o.o. (info@orionbelt.ai)
11
+ **License:** Apache 2.0
12
+
13
+ ---
14
+
15
+ ## Module Map
16
+
17
+ | File | Responsibility |
18
+ |---|---|
19
+ | `__init__.py` | PEP 249 connect() + module constants |
20
+ | `connection.py` | Connection class |
21
+ | `cursor.py` | Cursor class — OBML detection + execution |
22
+ | `compiler.py` | Direct OB import or REST fallback |
23
+ | `exceptions.py` | PEP 249 exception hierarchy |
24
+ | `type_codes.py` | PEP 249 type objects |
25
+
26
+ ---
27
+
28
+ ## connect() Parameters
29
+
30
+ ### databricks-specific
31
+ | server_hostname | str | Databricks workspace hostname |
32
+ | http_path | str | SQL warehouse HTTP path |
33
+ | access_token | str | Personal access token or M2M OAuth token |
34
+ | catalog | str | Unity Catalog name (default: hive_metastore) |
35
+ | schema | str | Schema name |
36
+
37
+ ### OrionBelt parameters (same across all vendors)
38
+ | Parameter | Default | Description |
39
+ |---|---|---|
40
+ | ob_model_id | None | Pre-loaded OB model ID |
41
+ | ob_model_yaml | None | OBML YAML string to load |
42
+ | ob_model_file | None | Path to .obml.yaml file |
43
+ | ob_api_url | http://localhost:8000 | OB REST API URL (standalone mode only) |
44
+ | ob_timeout | 30 | HTTP timeout in seconds |
45
+
46
+ ---
47
+
48
+ ## Compiler Bridge (compiler.py)
49
+
50
+ Auto-detects whether OB core is importable (same process as orionbelt-api)
51
+ or whether to fall back to REST:
52
+
53
+ ```python
54
+ def compile_obml(obml: dict, model, dialect: str) -> str:
55
+ try:
56
+ # Direct call — zero overhead, used when embedded in orionbelt-api
57
+ from orionbelt.compiler.pipeline import CompilationPipeline
58
+ result = CompilationPipeline().compile(obml, model, dialect)
59
+ return result.sql
60
+ except ImportError:
61
+ # Standalone mode — call REST API
62
+ return _compile_via_rest(obml, dialect)
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Vendor-Specific Notes
68
+
69
+ - databricks-sql-connector uses %s paramstyle
70
+ - Unity Catalog three-part names: catalog.schema.table
71
+ - OB dialect "databricks" generates Databricks SQL syntax (DATEADD, DATEDIFF etc.)
72
+ - Access token auth preferred over OAuth for server-side enterprise installs
73
+ - HTTP path format: /sql/1.0/warehouses/<warehouse-id>
74
+ - commit() is a no-op; Databricks SQL warehouses are stateless
75
+
76
+ ---
77
+
78
+ ## Type System
79
+
80
+ Use databricks.sql.client cursor description for metadata.
81
+ Types: STRING→str, INT/BIGINT→int, DOUBLE/FLOAT→float, TIMESTAMP→datetime, BOOLEAN→bool.
82
+
83
+ ---
84
+
85
+ ## Build Order
86
+
87
+ Session 1: exceptions.py + type_codes.py + compiler.py + unit tests
88
+ Session 2: connection.py + cursor.py (OBML detection, core execute)
89
+ Session 3: __init__.py + connect() + integration tests
90
+ Session 4: SQLAlchemy dialect (ob+databricks:// URL scheme)
91
+
92
+ ---
93
+
94
+ ## Dependencies
95
+
96
+ ```toml
97
+ [project.dependencies]
98
+ databricks-sql-connector = ">=3.0"
99
+ pyyaml = ">=6.0"
100
+
101
+ [project.optional-dependencies]
102
+ sqlalchemy = ["sqlalchemy>=2.0"]
103
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.4", "respx>=0.21"]
104
+ ```
@@ -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,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: ob-databricks
3
+ Version: 0.1.0
4
+ Summary: OrionBelt Semantic Layer driver for Databricks (DB-API 2.0)
5
+ Author-email: Ralf Becher <info@orionbelt.ai>
6
+ License: BSL-1.1
7
+ License-File: LICENSE
8
+ Keywords: databricks,db-api,obml,orionbelt,semantic-layer
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: databricks-sql-connector>=3.0
11
+ Requires-Dist: ob-driver-core>=0.1
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.10; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: respx>=0.21; extra == 'dev'
17
+ Requires-Dist: ruff>=0.4; extra == 'dev'
18
+ Provides-Extra: sqlalchemy
19
+ Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ob-databricks
23
+
24
+ OrionBelt Semantic Layer driver for Databricks (DB-API 2.0)
25
+
26
+ Part of the [OrionBelt Semantic Layer](https://github.com/ralfbecher/orionbelt-semantic-layer) project.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install ob-databricks
32
+ ```
33
+
34
+ ## License
35
+
36
+ BSL-1.1 — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,15 @@
1
+ # ob-databricks
2
+
3
+ OrionBelt Semantic Layer driver for Databricks (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-databricks
11
+ ```
12
+
13
+ ## License
14
+
15
+ BSL-1.1 — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ob-databricks"
7
+ version = "0.1.0"
8
+ description = "OrionBelt Semantic Layer driver for Databricks (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 = ["databricks", "semantic-layer", "obml", "db-api", "orionbelt"]
13
+ requires-python = ">=3.12"
14
+ dependencies = [
15
+ "ob-driver-core>=0.1",
16
+ "databricks-sql-connector>=3.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ sqlalchemy = ["sqlalchemy>=2.0"]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "pytest-asyncio>=0.23",
24
+ "mypy>=1.10",
25
+ "ruff>=0.4",
26
+ "respx>=0.21",
27
+ ]
28
+
29
+ [project.entry-points."sqlalchemy.dialects"]
30
+ "ob.databricks" = "ob_databricks.dialect:OBDatabricksDialect"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/ob_databricks"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py312"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B", "SIM", "ANN"]
41
+
42
+ [tool.mypy]
43
+ python_version = "3.12"
44
+ strict = true
45
+ files = ["src/ob_databricks"]
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
49
+ testpaths = ["tests"]
50
+ markers = ["integration: requires live DB + OB API"]
51
+ addopts = "-m 'not integration'"
@@ -0,0 +1,113 @@
1
+ """ob-databricks — OrionBelt Semantic Layer driver for Databricks (PEP 249 DB-API 2.0).
2
+
3
+ Requires the OrionBelt REST API running in single-model mode (MODEL_FILE set).
4
+ OBML queries are compiled to SQL via ``POST /v1/query/sql``.
5
+
6
+ Usage::
7
+
8
+ import ob_databricks
9
+
10
+ conn = ob_databricks.connect(
11
+ server_hostname="adb-xxx.azuredatabricks.net",
12
+ http_path="/sql/1.0/warehouses/abc123",
13
+ access_token="dapi...",
14
+ )
15
+ with conn.cursor() as cur:
16
+ cur.execute("select:\\n dimensions:\\n - Region\\n measures:\\n - Revenue")
17
+ print(cur.fetchall())
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from databricks import sql
25
+
26
+ from ob_databricks.connection import Connection
27
+ from ob_databricks.exceptions import (
28
+ DatabaseError,
29
+ DataError,
30
+ Error,
31
+ IntegrityError,
32
+ InterfaceError,
33
+ InternalError,
34
+ NotSupportedError,
35
+ OperationalError,
36
+ ProgrammingError,
37
+ Warning,
38
+ )
39
+
40
+ # PEP 249 module-level constants
41
+ apilevel = "2.0"
42
+ threadsafety = 1 # threads may share the module but not connections
43
+ paramstyle = "pyformat" # databricks-sql-connector uses %(name)s
44
+
45
+
46
+ def connect(
47
+ server_hostname: str,
48
+ http_path: str,
49
+ *,
50
+ access_token: str | None = None,
51
+ catalog: str = "hive_metastore",
52
+ schema: str = "default",
53
+ # OrionBelt parameters
54
+ ob_api_url: str = "http://localhost:8000",
55
+ ob_timeout: int = 30,
56
+ **kwargs: Any, # noqa: ANN401
57
+ ) -> Connection:
58
+ """Open a Databricks connection with OBML support.
59
+
60
+ Parameters
61
+ ----------
62
+ server_hostname : str
63
+ Databricks workspace hostname (e.g. ``adb-xxx.azuredatabricks.net``).
64
+ http_path : str
65
+ SQL warehouse HTTP path (e.g. ``/sql/1.0/warehouses/abc123``).
66
+ access_token : str, optional
67
+ Personal access token or OAuth token.
68
+ catalog : str
69
+ Unity Catalog name (default: ``hive_metastore``).
70
+ schema : str
71
+ Schema name (default: ``default``).
72
+ ob_api_url : str
73
+ OrionBelt REST API URL (must be running in single-model mode).
74
+ ob_timeout : int
75
+ HTTP timeout in seconds for OBML compilation.
76
+ **kwargs
77
+ Extra keyword arguments forwarded to ``databricks.sql.connect()``.
78
+ """
79
+ connect_kwargs: dict[str, Any] = {
80
+ "server_hostname": server_hostname,
81
+ "http_path": http_path,
82
+ "catalog": catalog,
83
+ "schema": schema,
84
+ }
85
+ if access_token is not None:
86
+ connect_kwargs["access_token"] = access_token
87
+ connect_kwargs.update(kwargs)
88
+
89
+ native = sql.connect(**connect_kwargs)
90
+ return Connection(
91
+ native,
92
+ ob_api_url=ob_api_url,
93
+ ob_timeout=ob_timeout,
94
+ )
95
+
96
+
97
+ __all__ = [
98
+ "apilevel",
99
+ "threadsafety",
100
+ "paramstyle",
101
+ "connect",
102
+ "Connection",
103
+ "Warning",
104
+ "Error",
105
+ "InterfaceError",
106
+ "DatabaseError",
107
+ "DataError",
108
+ "OperationalError",
109
+ "IntegrityError",
110
+ "InternalError",
111
+ "ProgrammingError",
112
+ "NotSupportedError",
113
+ ]
@@ -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,62 @@
1
+ """PEP 249 Connection wrapping ``databricks.sql`` connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ob_databricks.cursor import Cursor
8
+ from ob_databricks.exceptions import ProgrammingError
9
+
10
+
11
+ class Connection:
12
+ """DB-API 2.0 connection that wraps a native Databricks SQL connector connection.
13
+
14
+ OBML queries are compiled to SQL via the OrionBelt REST API
15
+ (single-model mode, ``/v1/query/sql`` shortcut).
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ native: Any, # noqa: ANN401
21
+ *,
22
+ ob_api_url: str = "http://localhost:8000",
23
+ ob_timeout: int = 30,
24
+ ) -> None:
25
+ self._native = native
26
+ self._closed = False
27
+ self._ob_api_url = ob_api_url
28
+ self._ob_timeout = ob_timeout
29
+
30
+ def _check_open(self) -> None:
31
+ if self._closed:
32
+ raise ProgrammingError("Connection is closed.")
33
+
34
+ def cursor(self) -> Cursor:
35
+ """Return a new Cursor for this connection."""
36
+ self._check_open()
37
+ native_cursor = self._native.cursor()
38
+ return Cursor(
39
+ native_cursor,
40
+ ob_api_url=self._ob_api_url,
41
+ ob_timeout=self._ob_timeout,
42
+ )
43
+
44
+ def commit(self) -> None:
45
+ """No-op — Databricks SQL warehouses auto-commit all statements."""
46
+ self._check_open()
47
+
48
+ def rollback(self) -> None:
49
+ """No-op — Databricks SQL warehouses do not support transactions."""
50
+ self._check_open()
51
+
52
+ def close(self) -> None:
53
+ """Close the connection."""
54
+ if not self._closed:
55
+ self._native.close()
56
+ self._closed = True
57
+
58
+ def __enter__(self) -> Connection:
59
+ return self
60
+
61
+ def __exit__(self, *exc: object) -> None:
62
+ self.close()
@@ -0,0 +1,153 @@
1
+ """PEP 249 Cursor with OBML interception for Databricks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ob_databricks.compiler import compile_obml, is_obml, parse_obml
8
+ from ob_databricks.exceptions import NotSupportedError, ProgrammingError
9
+ from ob_databricks.type_codes import DBR_TYPE_MAP, STRING
10
+
11
+
12
+ class Cursor:
13
+ """DB-API 2.0 cursor that intercepts OBML YAML queries.
14
+
15
+ If the query is OBML, it is compiled to Databricks SQL via the OrionBelt
16
+ REST API before execution. Plain SQL is passed through unchanged.
17
+ """
18
+
19
+ arraysize: int = 1
20
+
21
+ def __init__(
22
+ self,
23
+ native: Any, # noqa: ANN401
24
+ *,
25
+ ob_api_url: str = "http://localhost:8000",
26
+ ob_timeout: int = 30,
27
+ ) -> None:
28
+ self._native = native
29
+ self._closed = False
30
+ self._ob_api_url = ob_api_url
31
+ self._ob_timeout = ob_timeout
32
+
33
+ @property
34
+ def description(
35
+ self,
36
+ ) -> tuple[tuple[str, Any, None, None, None, None, None], ...] | None:
37
+ """PEP 249 cursor description — 7-item tuples per column.
38
+
39
+ Databricks SQL connector returns string type names in ``type_code``
40
+ (index 1 of each description column). These are mapped to PEP 249
41
+ type objects via :data:`DBR_TYPE_MAP`.
42
+ """
43
+ native_desc = self._native.description
44
+ if native_desc is None:
45
+ return None
46
+ cols: list[tuple[str, Any, None, None, None, None, None]] = []
47
+ for col in native_desc:
48
+ name = col[0]
49
+ type_name = str(col[1]).lower() if col[1] is not None else ""
50
+ type_code = DBR_TYPE_MAP.get(type_name, STRING)
51
+ cols.append((name, type_code, None, None, None, None, None))
52
+ return tuple(cols)
53
+
54
+ @property
55
+ def rowcount(self) -> int:
56
+ return self._native.rowcount
57
+
58
+ def _check_open(self) -> None:
59
+ if self._closed:
60
+ raise ProgrammingError("Cursor is closed.")
61
+
62
+ def execute(self, operation: str, parameters: Any = None) -> Cursor: # noqa: ANN401
63
+ """Execute a query — OBML YAML or plain SQL."""
64
+ self._check_open()
65
+ sql = self._resolve_sql(operation)
66
+ if parameters is not None:
67
+ self._native.execute(sql, parameters)
68
+ else:
69
+ self._native.execute(sql)
70
+ return self
71
+
72
+ def executemany(self, operation: str, seq_of_parameters: Any) -> None: # noqa: ANN401
73
+ """Execute against all parameter sequences.
74
+
75
+ OBML queries are not supported with executemany — raises NotSupportedError.
76
+ """
77
+ self._check_open()
78
+ if is_obml(operation):
79
+ raise NotSupportedError("executemany() is not supported for OBML queries.")
80
+ for params in seq_of_parameters:
81
+ self._native.execute(operation, params)
82
+
83
+ def _resolve_sql(self, operation: str) -> str:
84
+ """Compile OBML to SQL or return plain SQL unchanged."""
85
+ if not is_obml(operation):
86
+ return operation
87
+ obml = parse_obml(operation)
88
+ return compile_obml(
89
+ obml,
90
+ dialect="databricks",
91
+ ob_api_url=self._ob_api_url,
92
+ ob_timeout=self._ob_timeout,
93
+ )
94
+
95
+ def fetchone(self) -> tuple[Any, ...] | None:
96
+ """Fetch the next row."""
97
+ self._check_open()
98
+ row = self._native.fetchone()
99
+ return tuple(row) if row is not None else None
100
+
101
+ def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
102
+ """Fetch the next *size* rows."""
103
+ self._check_open()
104
+ n = size if size is not None else self.arraysize
105
+ rows = self._native.fetchmany(n)
106
+ return [tuple(r) for r in rows]
107
+
108
+ def fetchall(self) -> list[tuple[Any, ...]]:
109
+ """Fetch all remaining rows."""
110
+ self._check_open()
111
+ rows = self._native.fetchall()
112
+ return [tuple(r) for r in rows]
113
+
114
+ def fetch_arrow_table(self) -> Any: # noqa: ANN401
115
+ """Fetch all remaining rows as a PyArrow Table.
116
+
117
+ Databricks SQL connector returns Arrow natively via its internal
118
+ Thrift/Arrow result format. More memory-efficient than ``fetchall()``.
119
+ """
120
+ self._check_open()
121
+ return self._native.fetchall_arrow()
122
+
123
+ def close(self) -> None:
124
+ """Close the cursor."""
125
+ if not self._closed:
126
+ self._native.close()
127
+ self._closed = True
128
+
129
+ def setinputsizes(self, sizes: Any) -> None: # noqa: ANN401
130
+ """No-op — required by PEP 249."""
131
+
132
+ def setoutputsize(self, size: int, column: int | None = None) -> None:
133
+ """No-op — required by PEP 249."""
134
+
135
+ @property
136
+ def lastrowid(self) -> None:
137
+ """Databricks does not expose lastrowid via connector cursor."""
138
+ return None
139
+
140
+ def __enter__(self) -> Cursor:
141
+ return self
142
+
143
+ def __exit__(self, *exc: object) -> None:
144
+ self.close()
145
+
146
+ def __iter__(self) -> Cursor:
147
+ return self
148
+
149
+ def __next__(self) -> tuple[Any, ...]:
150
+ row = self.fetchone()
151
+ if row is None:
152
+ raise StopIteration
153
+ 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_databricks.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,42 @@
1
+ """PEP 249 type objects — re-exported from ob-driver-core plus Databricks type mapping.
2
+
3
+ databricks-sql-connector cursor.description returns string type names as type_code.
4
+ The DBR_TYPE_MAP maps Databricks type names to PEP 249 type objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ob_driver_core.type_codes import BINARY, DATETIME, NUMBER, ROWID, STRING
10
+
11
+ __all__ = ["STRING", "BINARY", "NUMBER", "DATETIME", "ROWID", "DBR_TYPE_MAP"]
12
+
13
+ # Databricks SQL connector type names from cursor.description
14
+ # https://docs.databricks.com/en/sql/language-manual/sql-ref-datatypes.html
15
+ DBR_TYPE_MAP: dict[str, object] = {
16
+ # Numeric
17
+ "boolean": STRING,
18
+ "byte": NUMBER,
19
+ "tinyint": NUMBER,
20
+ "short": NUMBER,
21
+ "smallint": NUMBER,
22
+ "int": NUMBER,
23
+ "integer": NUMBER,
24
+ "long": NUMBER,
25
+ "bigint": NUMBER,
26
+ "float": NUMBER,
27
+ "double": NUMBER,
28
+ "decimal": NUMBER,
29
+ # String
30
+ "string": STRING,
31
+ "char": STRING,
32
+ "varchar": STRING,
33
+ # Date/Time
34
+ "date": DATETIME,
35
+ "timestamp": DATETIME,
36
+ # Binary
37
+ "binary": BINARY,
38
+ # Complex (serialize as string)
39
+ "array": STRING,
40
+ "map": STRING,
41
+ "struct": STRING,
42
+ }
@@ -0,0 +1,546 @@
1
+ """Unit tests for the ob-databricks DB-API 2.0 driver.
2
+
3
+ All tests mock ``databricks.sql`` — no live Databricks cluster needed.
4
+ OBML tests additionally mock the REST API call to ``/v1/query/sql``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ import ob_databricks
14
+ from ob_databricks.connection import Connection
15
+ from ob_databricks.cursor import Cursor
16
+ from ob_databricks.exceptions import NotSupportedError, ProgrammingError
17
+ from ob_databricks.type_codes import DATETIME, NUMBER, STRING
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Helper to build a mock databricks connection
21
+ # ---------------------------------------------------------------------------
22
+
23
+ # databricks-sql-connector description columns are tuples:
24
+ # (name, type_code, display_size, internal_size, precision, scale, null_ok)
25
+ # type_code is a string type name (e.g. "int", "string", "timestamp")
26
+
27
+
28
+ def _make_dbr_desc(name: str, type_name: str) -> tuple[str, str, None, None, None, None, None]:
29
+ """Build a single Databricks description column tuple."""
30
+ return (name, type_name, None, None, None, None, None)
31
+
32
+
33
+ def _make_mock_native() -> MagicMock:
34
+ """Return a mock databricks.sql connection."""
35
+ mock_conn = MagicMock()
36
+ mock_cursor = MagicMock()
37
+ mock_conn.cursor.return_value = mock_cursor
38
+ # Default: no results
39
+ mock_cursor.description = None
40
+ mock_cursor.rowcount = -1
41
+ mock_cursor.fetchone.return_value = None
42
+ mock_cursor.fetchall.return_value = []
43
+ mock_cursor.fetchmany.return_value = []
44
+ return mock_conn
45
+
46
+
47
+ def _mock_api_response(sql: str) -> MagicMock:
48
+ """Create a mock httpx response returning the given SQL."""
49
+ resp = MagicMock()
50
+ resp.is_success = True
51
+ resp.json.return_value = {"sql": sql}
52
+ return resp
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # PEP 249 module-level constants
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def test_apilevel() -> None:
61
+ assert ob_databricks.apilevel == "2.0"
62
+
63
+
64
+ def test_threadsafety() -> None:
65
+ assert ob_databricks.threadsafety == 1
66
+
67
+
68
+ def test_paramstyle() -> None:
69
+ assert ob_databricks.paramstyle == "pyformat"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # connect()
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def test_connect_returns_connection() -> None:
78
+ with patch("databricks.sql.connect") as mock_connect:
79
+ mock_connect.return_value = _make_mock_native()
80
+ conn = ob_databricks.connect(
81
+ server_hostname="adb-123.azuredatabricks.net",
82
+ http_path="/sql/1.0/warehouses/abc",
83
+ access_token="dapi_token",
84
+ )
85
+ assert isinstance(conn, Connection)
86
+ mock_connect.assert_called_once()
87
+ kwargs = mock_connect.call_args.kwargs
88
+ assert kwargs["server_hostname"] == "adb-123.azuredatabricks.net"
89
+ assert kwargs["http_path"] == "/sql/1.0/warehouses/abc"
90
+ assert kwargs["access_token"] == "dapi_token"
91
+
92
+
93
+ def test_connect_default_catalog_and_schema() -> None:
94
+ with patch("databricks.sql.connect") as mock_connect:
95
+ mock_connect.return_value = _make_mock_native()
96
+ ob_databricks.connect(
97
+ server_hostname="adb-123.azuredatabricks.net",
98
+ http_path="/sql/1.0/warehouses/abc",
99
+ )
100
+ kwargs = mock_connect.call_args.kwargs
101
+ assert kwargs["catalog"] == "hive_metastore"
102
+ assert kwargs["schema"] == "default"
103
+
104
+
105
+ def test_connect_custom_catalog_and_schema() -> None:
106
+ with patch("databricks.sql.connect") as mock_connect:
107
+ mock_connect.return_value = _make_mock_native()
108
+ ob_databricks.connect(
109
+ server_hostname="adb-123.azuredatabricks.net",
110
+ http_path="/sql/1.0/warehouses/abc",
111
+ catalog="unity_catalog",
112
+ schema="analytics",
113
+ )
114
+ kwargs = mock_connect.call_args.kwargs
115
+ assert kwargs["catalog"] == "unity_catalog"
116
+ assert kwargs["schema"] == "analytics"
117
+
118
+
119
+ def test_connect_context_manager() -> None:
120
+ with patch("databricks.sql.connect") as mock_connect:
121
+ mock_native = _make_mock_native()
122
+ mock_connect.return_value = mock_native
123
+ with ob_databricks.connect(
124
+ server_hostname="adb-123.azuredatabricks.net",
125
+ http_path="/sql/1.0/warehouses/abc",
126
+ ) as conn:
127
+ assert isinstance(conn, Connection)
128
+ mock_native.close.assert_called_once()
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Connection
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def test_connection_close_is_idempotent() -> None:
137
+ mock_native = _make_mock_native()
138
+ conn = Connection(mock_native)
139
+ conn.close()
140
+ conn.close() # should not raise
141
+ mock_native.close.assert_called_once()
142
+
143
+
144
+ def test_connection_cursor_after_close_raises() -> None:
145
+ mock_native = _make_mock_native()
146
+ conn = Connection(mock_native)
147
+ conn.close()
148
+ with pytest.raises(ProgrammingError, match="closed"):
149
+ conn.cursor()
150
+
151
+
152
+ def test_connection_commit_noop() -> None:
153
+ mock_native = _make_mock_native()
154
+ conn = Connection(mock_native)
155
+ conn.commit() # should not raise, no-op
156
+ # Databricks auto-commits — native commit is NOT called
157
+ mock_native.commit.assert_not_called()
158
+
159
+
160
+ def test_connection_rollback_noop() -> None:
161
+ mock_native = _make_mock_native()
162
+ conn = Connection(mock_native)
163
+ conn.rollback() # should not raise, no-op
164
+ # Databricks does not support transactions — native rollback is NOT called
165
+ mock_native.rollback.assert_not_called()
166
+
167
+
168
+ def test_connection_commit_after_close_raises() -> None:
169
+ mock_native = _make_mock_native()
170
+ conn = Connection(mock_native)
171
+ conn.close()
172
+ with pytest.raises(ProgrammingError, match="closed"):
173
+ conn.commit()
174
+
175
+
176
+ def test_connection_rollback_after_close_raises() -> None:
177
+ mock_native = _make_mock_native()
178
+ conn = Connection(mock_native)
179
+ conn.close()
180
+ with pytest.raises(ProgrammingError, match="closed"):
181
+ conn.rollback()
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Cursor — plain SQL
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ def test_cursor_execute_calls_native() -> None:
190
+ mock_native = _make_mock_native()
191
+ conn = Connection(mock_native)
192
+ with conn.cursor() as cur:
193
+ cur.execute("SELECT 1")
194
+ mock_native.cursor().execute.assert_called_once_with("SELECT 1")
195
+
196
+
197
+ def test_cursor_execute_with_params() -> None:
198
+ mock_native = _make_mock_native()
199
+ conn = Connection(mock_native)
200
+ with conn.cursor() as cur:
201
+ cur.execute("SELECT %(a)s + %(b)s", {"a": 3, "b": 4})
202
+ mock_native.cursor().execute.assert_called_once_with(
203
+ "SELECT %(a)s + %(b)s", {"a": 3, "b": 4}
204
+ )
205
+
206
+
207
+ def test_cursor_execute_returns_self() -> None:
208
+ mock_native = _make_mock_native()
209
+ conn = Connection(mock_native)
210
+ with conn.cursor() as cur:
211
+ result = cur.execute("SELECT 1")
212
+ assert result is cur
213
+
214
+
215
+ def test_cursor_description() -> None:
216
+ mock_native = _make_mock_native()
217
+ mock_cursor = mock_native.cursor()
218
+ mock_cursor.description = [
219
+ _make_dbr_desc("num", "int"),
220
+ _make_dbr_desc("txt", "string"),
221
+ _make_dbr_desc("dt", "timestamp"),
222
+ ]
223
+ conn = Connection(mock_native)
224
+ cur = conn.cursor()
225
+ desc = cur.description
226
+ assert desc is not None
227
+ assert len(desc) == 3
228
+ assert all(len(col) == 7 for col in desc)
229
+ assert desc[0][0] == "num"
230
+ assert desc[0][1] == NUMBER
231
+ assert desc[1][0] == "txt"
232
+ assert desc[1][1] == STRING
233
+ assert desc[2][0] == "dt"
234
+ assert desc[2][1] == DATETIME
235
+
236
+
237
+ def test_cursor_description_type_mapping_comprehensive() -> None:
238
+ """Verify all major Databricks types map correctly."""
239
+ mock_native = _make_mock_native()
240
+ mock_cursor = mock_native.cursor()
241
+ mock_cursor.description = [
242
+ _make_dbr_desc("c_bool", "boolean"),
243
+ _make_dbr_desc("c_byte", "byte"),
244
+ _make_dbr_desc("c_tinyint", "tinyint"),
245
+ _make_dbr_desc("c_short", "short"),
246
+ _make_dbr_desc("c_smallint", "smallint"),
247
+ _make_dbr_desc("c_int", "int"),
248
+ _make_dbr_desc("c_integer", "integer"),
249
+ _make_dbr_desc("c_long", "long"),
250
+ _make_dbr_desc("c_bigint", "bigint"),
251
+ _make_dbr_desc("c_float", "float"),
252
+ _make_dbr_desc("c_double", "double"),
253
+ _make_dbr_desc("c_decimal", "decimal"),
254
+ _make_dbr_desc("c_string", "string"),
255
+ _make_dbr_desc("c_char", "char"),
256
+ _make_dbr_desc("c_varchar", "varchar"),
257
+ _make_dbr_desc("c_date", "date"),
258
+ _make_dbr_desc("c_ts", "timestamp"),
259
+ _make_dbr_desc("c_binary", "binary"),
260
+ _make_dbr_desc("c_array", "array"),
261
+ _make_dbr_desc("c_map", "map"),
262
+ _make_dbr_desc("c_struct", "struct"),
263
+ ]
264
+ conn = Connection(mock_native)
265
+ cur = conn.cursor()
266
+ desc = cur.description
267
+ assert desc is not None
268
+ # boolean -> STRING
269
+ assert desc[0][1] == STRING
270
+ # numeric types -> NUMBER
271
+ for i in range(1, 12):
272
+ assert desc[i][1] == NUMBER, f"Column {desc[i][0]} should be NUMBER"
273
+ # string types -> STRING
274
+ for i in range(12, 15):
275
+ assert desc[i][1] == STRING, f"Column {desc[i][0]} should be STRING"
276
+ # date/time -> DATETIME
277
+ assert desc[15][1] == DATETIME
278
+ assert desc[16][1] == DATETIME
279
+ # binary -> BINARY
280
+ from ob_databricks.type_codes import BINARY
281
+
282
+ assert desc[17][1] == BINARY
283
+ # complex -> STRING
284
+ for i in range(18, 21):
285
+ assert desc[i][1] == STRING, f"Column {desc[i][0]} should be STRING"
286
+
287
+
288
+ def test_cursor_description_unknown_type_defaults_to_string() -> None:
289
+ mock_native = _make_mock_native()
290
+ mock_cursor = mock_native.cursor()
291
+ mock_cursor.description = [
292
+ _make_dbr_desc("c_unknown", "some_future_type"),
293
+ ]
294
+ conn = Connection(mock_native)
295
+ cur = conn.cursor()
296
+ desc = cur.description
297
+ assert desc is not None
298
+ assert desc[0][1] == STRING
299
+
300
+
301
+ def test_cursor_description_none_before_execute() -> None:
302
+ mock_native = _make_mock_native()
303
+ mock_native.cursor().description = None
304
+ conn = Connection(mock_native)
305
+ cur = conn.cursor()
306
+ assert cur.description is None
307
+
308
+
309
+ def test_cursor_fetchone() -> None:
310
+ mock_native = _make_mock_native()
311
+ mock_native.cursor().fetchone.return_value = (42,)
312
+ conn = Connection(mock_native)
313
+ cur = conn.cursor()
314
+ row = cur.fetchone()
315
+ assert row == (42,)
316
+
317
+
318
+ def test_cursor_fetchone_exhausted() -> None:
319
+ mock_native = _make_mock_native()
320
+ mock_native.cursor().fetchone.return_value = None
321
+ conn = Connection(mock_native)
322
+ cur = conn.cursor()
323
+ assert cur.fetchone() is None
324
+
325
+
326
+ def test_cursor_fetchall() -> None:
327
+ mock_native = _make_mock_native()
328
+ mock_native.cursor().fetchall.return_value = [(1, "a"), (2, "b"), (3, "c")]
329
+ conn = Connection(mock_native)
330
+ cur = conn.cursor()
331
+ rows = cur.fetchall()
332
+ assert len(rows) == 3
333
+ assert rows[0] == (1, "a")
334
+
335
+
336
+ def test_cursor_fetchmany() -> None:
337
+ mock_native = _make_mock_native()
338
+ mock_native.cursor().fetchmany.return_value = [(1,), (2,), (3,)]
339
+ conn = Connection(mock_native)
340
+ cur = conn.cursor()
341
+ batch = cur.fetchmany(3)
342
+ assert len(batch) == 3
343
+ mock_native.cursor().fetchmany.assert_called_with(3)
344
+
345
+
346
+ def test_cursor_fetchmany_default_arraysize() -> None:
347
+ mock_native = _make_mock_native()
348
+ mock_native.cursor().fetchmany.return_value = [(1,)]
349
+ conn = Connection(mock_native)
350
+ cur = conn.cursor()
351
+ cur.fetchmany()
352
+ mock_native.cursor().fetchmany.assert_called_with(1) # default arraysize
353
+
354
+
355
+ def test_cursor_iteration() -> None:
356
+ mock_native = _make_mock_native()
357
+ mock_native.cursor().fetchone.side_effect = [(0,), (1,), (2,), None]
358
+ conn = Connection(mock_native)
359
+ cur = conn.cursor()
360
+ rows = list(cur)
361
+ assert len(rows) == 3
362
+ assert rows[0] == (0,)
363
+
364
+
365
+ def test_cursor_close_then_fetch_raises() -> None:
366
+ mock_native = _make_mock_native()
367
+ conn = Connection(mock_native)
368
+ cur = conn.cursor()
369
+ cur.close()
370
+ with pytest.raises(ProgrammingError, match="closed"):
371
+ cur.fetchone()
372
+
373
+
374
+ def test_cursor_close_then_fetchall_raises() -> None:
375
+ mock_native = _make_mock_native()
376
+ conn = Connection(mock_native)
377
+ cur = conn.cursor()
378
+ cur.close()
379
+ with pytest.raises(ProgrammingError, match="closed"):
380
+ cur.fetchall()
381
+
382
+
383
+ def test_cursor_close_then_fetchmany_raises() -> None:
384
+ mock_native = _make_mock_native()
385
+ conn = Connection(mock_native)
386
+ cur = conn.cursor()
387
+ cur.close()
388
+ with pytest.raises(ProgrammingError, match="closed"):
389
+ cur.fetchmany()
390
+
391
+
392
+ def test_cursor_close_then_execute_raises() -> None:
393
+ mock_native = _make_mock_native()
394
+ conn = Connection(mock_native)
395
+ cur = conn.cursor()
396
+ cur.close()
397
+ with pytest.raises(ProgrammingError, match="closed"):
398
+ cur.execute("SELECT 1")
399
+
400
+
401
+ def test_cursor_close_is_idempotent() -> None:
402
+ mock_native = _make_mock_native()
403
+ conn = Connection(mock_native)
404
+ cur = conn.cursor()
405
+ cur.close()
406
+ cur.close() # should not raise
407
+ mock_native.cursor().close.assert_called_once()
408
+
409
+
410
+ def test_cursor_executemany_plain_sql() -> None:
411
+ mock_native = _make_mock_native()
412
+ conn = Connection(mock_native)
413
+ cur = conn.cursor()
414
+ cur.executemany(
415
+ "INSERT INTO t VALUES (%(a)s, %(b)s)",
416
+ [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}],
417
+ )
418
+ assert mock_native.cursor().execute.call_count == 2
419
+
420
+
421
+ def test_cursor_executemany_obml_raises() -> None:
422
+ mock_native = _make_mock_native()
423
+ conn = Connection(mock_native)
424
+ cur = conn.cursor()
425
+ obml = "select:\n dimensions:\n - Region\n measures:\n - Revenue\n"
426
+ with pytest.raises(NotSupportedError, match="executemany"):
427
+ cur.executemany(obml, [])
428
+
429
+
430
+ def test_cursor_setinputsizes_noop() -> None:
431
+ mock_native = _make_mock_native()
432
+ conn = Connection(mock_native)
433
+ cur = conn.cursor()
434
+ cur.setinputsizes([]) # should not raise
435
+
436
+
437
+ def test_cursor_setoutputsize_noop() -> None:
438
+ mock_native = _make_mock_native()
439
+ conn = Connection(mock_native)
440
+ cur = conn.cursor()
441
+ cur.setoutputsize(1000) # should not raise
442
+
443
+
444
+ def test_cursor_lastrowid_is_none() -> None:
445
+ mock_native = _make_mock_native()
446
+ conn = Connection(mock_native)
447
+ cur = conn.cursor()
448
+ assert cur.lastrowid is None
449
+
450
+
451
+ def test_cursor_rowcount() -> None:
452
+ mock_native = _make_mock_native()
453
+ mock_native.cursor().rowcount = 5
454
+ conn = Connection(mock_native)
455
+ cur = conn.cursor()
456
+ assert cur.rowcount == 5
457
+
458
+
459
+ def test_cursor_context_manager() -> None:
460
+ mock_native = _make_mock_native()
461
+ conn = Connection(mock_native)
462
+ with conn.cursor() as cur:
463
+ assert isinstance(cur, Cursor)
464
+ mock_native.cursor().close.assert_called_once()
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Cursor — OBML queries (mocked REST API)
469
+ # ---------------------------------------------------------------------------
470
+
471
+
472
+ def test_obml_compile_and_execute() -> None:
473
+ """OBML query is compiled via REST API then executed on Databricks."""
474
+ mock_native = _make_mock_native()
475
+ compiled_sql = "SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region"
476
+ mock_native.cursor().description = [
477
+ _make_dbr_desc("region", "string"),
478
+ _make_dbr_desc("revenue", "double"),
479
+ ]
480
+ mock_native.cursor().fetchall.return_value = [
481
+ ("EMEA", 300.0),
482
+ ("APAC", 150.0),
483
+ ("AMER", 550.0),
484
+ ]
485
+ conn = Connection(mock_native)
486
+ with (
487
+ patch("httpx.post", return_value=_mock_api_response(compiled_sql)),
488
+ conn.cursor() as cur,
489
+ ):
490
+ cur.execute("select:\n dimensions:\n - Region\n measures:\n - Revenue\n")
491
+ rows = cur.fetchall()
492
+ assert len(rows) == 3
493
+ # Verify the compiled SQL was passed to native cursor
494
+ mock_native.cursor().execute.assert_called_once_with(compiled_sql)
495
+
496
+
497
+ def test_obml_rest_dialect_is_databricks() -> None:
498
+ """REST API is called with dialect=databricks."""
499
+ mock_native = _make_mock_native()
500
+ conn = Connection(mock_native)
501
+ compiled_sql = "SELECT 1"
502
+ with (
503
+ patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post,
504
+ conn.cursor() as cur,
505
+ ):
506
+ cur.execute("select:\n measures:\n - Revenue\n")
507
+ url = mock_post.call_args.args[0]
508
+ assert "/v1/query/sql" in url
509
+ assert mock_post.call_args.kwargs["params"] == {"dialect": "databricks"}
510
+
511
+
512
+ def test_obml_custom_api_url() -> None:
513
+ """Custom ob_api_url is forwarded to the REST call."""
514
+ mock_native = _make_mock_native()
515
+ conn = Connection(mock_native, ob_api_url="http://my-api:9000")
516
+ compiled_sql = "SELECT 1"
517
+ with (
518
+ patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post,
519
+ conn.cursor() as cur,
520
+ ):
521
+ cur.execute("select:\n measures:\n - Revenue\n")
522
+ url = mock_post.call_args.args[0]
523
+ assert url == "http://my-api:9000/v1/query/sql"
524
+
525
+
526
+ def test_obml_custom_timeout() -> None:
527
+ """Custom ob_timeout is forwarded to the REST call."""
528
+ mock_native = _make_mock_native()
529
+ conn = Connection(mock_native, ob_timeout=60)
530
+ compiled_sql = "SELECT 1"
531
+ with (
532
+ patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post,
533
+ conn.cursor() as cur,
534
+ ):
535
+ cur.execute("select:\n measures:\n - Revenue\n")
536
+ assert mock_post.call_args.kwargs["timeout"] == 60
537
+
538
+
539
+ def test_plain_sql_passthrough() -> None:
540
+ """Plain SQL is passed through without OBML compilation — no REST call."""
541
+ mock_native = _make_mock_native()
542
+ conn = Connection(mock_native)
543
+ with patch("httpx.post") as mock_post, conn.cursor() as cur:
544
+ cur.execute("SELECT COUNT(*) FROM orders")
545
+ mock_post.assert_not_called()
546
+ 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_databricks.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