ob-duckdb 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_duckdb-0.1.0/.gitignore +35 -0
- ob_duckdb-0.1.0/CLAUDE.md +179 -0
- ob_duckdb-0.1.0/LICENSE +70 -0
- ob_duckdb-0.1.0/PKG-INFO +35 -0
- ob_duckdb-0.1.0/README.md +15 -0
- ob_duckdb-0.1.0/pyproject.toml +49 -0
- ob_duckdb-0.1.0/src/ob_duckdb/__init__.py +90 -0
- ob_duckdb-0.1.0/src/ob_duckdb/compiler.py +8 -0
- ob_duckdb-0.1.0/src/ob_duckdb/connection.py +70 -0
- ob_duckdb-0.1.0/src/ob_duckdb/cursor.py +163 -0
- ob_duckdb-0.1.0/src/ob_duckdb/exceptions.py +32 -0
- ob_duckdb-0.1.0/src/ob_duckdb/type_codes.py +45 -0
- ob_duckdb-0.1.0/tests/test_driver.py +333 -0
- ob_duckdb-0.1.0/tests/test_yaml_detection.py +29 -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,179 @@
|
|
|
1
|
+
# ob-duckdb — OrionBelt Semantic Layer Driver for DuckDB
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
PEP 249 DB-API 2.0 driver wrapping `duckdb` that intercepts OBML YAML
|
|
6
|
+
queries, compiles them to DuckDB SQL via the OrionBelt CompilationPipeline
|
|
7
|
+
(direct import) or OB REST API (standalone), and executes them natively.
|
|
8
|
+
|
|
9
|
+
**OB dialect string:** `"duckdb"`
|
|
10
|
+
**Author:** Ralf Becher / RALFORION d.o.o. (info@orionbelt.ai)
|
|
11
|
+
**License:** Apache 2.0
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why DuckDB is Special
|
|
16
|
+
|
|
17
|
+
DuckDB is unique among the OB-supported vendors in two ways:
|
|
18
|
+
|
|
19
|
+
1. **In-process** — DuckDB runs embedded in the same Python process, no
|
|
20
|
+
server required. This makes it the ideal driver for local development,
|
|
21
|
+
testing, and notebook use without any infrastructure.
|
|
22
|
+
|
|
23
|
+
2. **Already has a DB-API 2.0 interface** — `duckdb.connect()` returns a
|
|
24
|
+
PEP 249-compliant connection directly. This driver is a thin OBML-aware
|
|
25
|
+
wrapper, not a full reimplementation.
|
|
26
|
+
|
|
27
|
+
DuckDB is also the recommended backend for **testing all other OB drivers**
|
|
28
|
+
— load a Parquet or CSV file into DuckDB, point the OB model at it, compile
|
|
29
|
+
with dialect="duckdb", and validate query output without needing Snowflake
|
|
30
|
+
or Postgres credentials.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Module Map
|
|
35
|
+
|
|
36
|
+
| File | Responsibility |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `__init__.py` | PEP 249 connect() + module constants |
|
|
39
|
+
| `connection.py` | Connection class wrapping duckdb.DuckDBPyConnection |
|
|
40
|
+
| `cursor.py` | Cursor class — OBML detection + execution |
|
|
41
|
+
| `compiler.py` | Direct OB import or REST fallback (shared pattern) |
|
|
42
|
+
| `exceptions.py` | PEP 249 exception hierarchy |
|
|
43
|
+
| `type_codes.py` | PEP 249 type objects |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## connect() Parameters
|
|
48
|
+
|
|
49
|
+
### DuckDB-specific
|
|
50
|
+
| Parameter | Type | Description |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| database | str | Path to .duckdb file, or `:memory:` (default) |
|
|
53
|
+
| read_only | bool | Open in read-only mode (default: False) |
|
|
54
|
+
| config | dict | DuckDB config options (threads, memory_limit, etc.) |
|
|
55
|
+
|
|
56
|
+
### OrionBelt parameters (same across all vendors)
|
|
57
|
+
| Parameter | Default | Description |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| ob_model_id | None | Pre-loaded OB model ID |
|
|
60
|
+
| ob_model_yaml | None | OBML YAML string to load |
|
|
61
|
+
| ob_model_file | None | Path to .obml.yaml file |
|
|
62
|
+
| ob_api_url | http://localhost:8000 | OB REST API URL (standalone mode only) |
|
|
63
|
+
| ob_timeout | 30 | HTTP timeout in seconds |
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Key DuckDB-Specific Notes
|
|
68
|
+
|
|
69
|
+
- `duckdb.connect()` default is `:memory:` — in-memory database, ideal for
|
|
70
|
+
testing and local development with Parquet/CSV sources
|
|
71
|
+
- DuckDB supports reading Parquet, CSV, JSON directly in SQL:
|
|
72
|
+
`SELECT * FROM 'data.parquet'` — OBML models can reference these as
|
|
73
|
+
data objects with `code: 'path/to/file.parquet'`
|
|
74
|
+
- No server process — commit() and rollback() are supported but DuckDB
|
|
75
|
+
auto-commits by default in most contexts
|
|
76
|
+
- DuckDB's DB-API cursor `.description` uses duckdb type names (VARCHAR,
|
|
77
|
+
INTEGER, DOUBLE, TIMESTAMP, etc.) — map to PEP 249 type codes accordingly
|
|
78
|
+
- DuckDB is the best choice for the `ob_model_file` pattern: load a model
|
|
79
|
+
pointing at local Parquet files, query with OBML, zero infrastructure
|
|
80
|
+
- Thread safety: DuckDB connections are NOT thread-safe by default.
|
|
81
|
+
Use `duckdb.connect(database, check_same_thread=False)` for multi-threaded
|
|
82
|
+
use, or create one connection per thread.
|
|
83
|
+
- DuckDB dialect in OB generates standard ANSI SQL with DuckDB extensions
|
|
84
|
+
for time grains: `DATE_TRUNC('month', col)`, `EXTRACT(year FROM col)`
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Ideal Use Cases
|
|
89
|
+
|
|
90
|
+
1. **Local development & testing** — validate OBML models against sample
|
|
91
|
+
data without any cloud credentials
|
|
92
|
+
2. **CI/CD pipelines** — unit test semantic models with DuckDB + Parquet
|
|
93
|
+
fixtures, fast and dependency-free
|
|
94
|
+
3. **Notebooks / data science** — OBML queries against local files in Jupyter
|
|
95
|
+
4. **ob-flight-extension testing** — spin up a Flight SQL server backed by
|
|
96
|
+
DuckDB for end-to-end driver testing without cloud access
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Example Usage
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import ob_duckdb
|
|
104
|
+
|
|
105
|
+
# In-memory with local Parquet files
|
|
106
|
+
conn = ob_duckdb.connect(
|
|
107
|
+
database=":memory:",
|
|
108
|
+
ob_model_file="orionbelt_1_model.obml.yaml",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
with conn.cursor() as cur:
|
|
112
|
+
cur.execute("""
|
|
113
|
+
select:
|
|
114
|
+
dimensions:
|
|
115
|
+
- Region
|
|
116
|
+
measures:
|
|
117
|
+
- Revenue
|
|
118
|
+
limit: 10
|
|
119
|
+
""")
|
|
120
|
+
print(cur.fetchall())
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Compiler Bridge
|
|
126
|
+
|
|
127
|
+
Same auto-detect pattern as all other vendor drivers:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
def compile_obml(obml, *, model, dialect="duckdb", **kwargs) -> str:
|
|
131
|
+
try:
|
|
132
|
+
from orionbelt.compiler.pipeline import CompilationPipeline
|
|
133
|
+
return CompilationPipeline().compile(obml, model, "duckdb").sql
|
|
134
|
+
except ImportError:
|
|
135
|
+
return _compile_rest(obml, dialect="duckdb", **kwargs)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Type Mapping
|
|
141
|
+
|
|
142
|
+
| DuckDB type | PEP 249 type code |
|
|
143
|
+
|---|---|
|
|
144
|
+
| INTEGER, BIGINT, HUGEINT | NUMBER |
|
|
145
|
+
| FLOAT, DOUBLE, DECIMAL | NUMBER |
|
|
146
|
+
| VARCHAR, TEXT | STRING |
|
|
147
|
+
| BOOLEAN | STRING |
|
|
148
|
+
| DATE | DATETIME |
|
|
149
|
+
| TIMESTAMP, TIMESTAMPTZ | DATETIME |
|
|
150
|
+
| BLOB | BINARY |
|
|
151
|
+
| LIST, STRUCT, MAP | STRING (JSON repr) |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Build Order
|
|
156
|
+
|
|
157
|
+
Session 1: exceptions.py + type_codes.py + compiler.py + unit tests
|
|
158
|
+
Session 2: connection.py + cursor.py (thin wrapper over duckdb native)
|
|
159
|
+
Session 3: __init__.py + connect() + integration tests with :memory: DB
|
|
160
|
+
Session 4: SQLAlchemy dialect (ob+duckdb:// URL scheme)
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Dependencies
|
|
165
|
+
|
|
166
|
+
```toml
|
|
167
|
+
[project.dependencies]
|
|
168
|
+
duckdb = ">=1.0"
|
|
169
|
+
pyyaml = ">=6.0"
|
|
170
|
+
httpx = ">=0.27"
|
|
171
|
+
|
|
172
|
+
[project.optional-dependencies]
|
|
173
|
+
sqlalchemy = ["sqlalchemy>=2.0"]
|
|
174
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.4"]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Note: no `respx` needed for unit tests — DuckDB in-memory means integration
|
|
178
|
+
tests run without any external service, so the unit/integration distinction
|
|
179
|
+
is less relevant than for other vendors.
|
ob_duckdb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: RALFORION d.o.o.
|
|
6
|
+
Licensed Work: OrionBelt Semantic Layer 1.0
|
|
7
|
+
The Licensed Work is (c) 2025 RALFORION d.o.o.
|
|
8
|
+
Additional Use Grant: You may use the Licensed Work for any purpose,
|
|
9
|
+
including production use, PROVIDED THAT you may
|
|
10
|
+
not embed OrionBelt in a commercial product or
|
|
11
|
+
provide it as part of a commercial service
|
|
12
|
+
without purchasing a commercial license from
|
|
13
|
+
the Licensor. Contact licensing@ralforion.com
|
|
14
|
+
for commercial licensing inquiries.
|
|
15
|
+
Change Date: 2030-03-16
|
|
16
|
+
Change License: Apache License, Version 2.0
|
|
17
|
+
|
|
18
|
+
-----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
Business Source License 1.1
|
|
21
|
+
|
|
22
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
23
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
24
|
+
|
|
25
|
+
Terms
|
|
26
|
+
|
|
27
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
28
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
29
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
30
|
+
production use.
|
|
31
|
+
|
|
32
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
33
|
+
available distribution of a specific version of the Licensed Work under this
|
|
34
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
35
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
36
|
+
above terminate.
|
|
37
|
+
|
|
38
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
39
|
+
currently in effect as described in this License, you must purchase a
|
|
40
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
41
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
42
|
+
|
|
43
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
44
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
45
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
46
|
+
for each version of the Licensed Work released by Licensor.
|
|
47
|
+
|
|
48
|
+
You must conspicuously display this License on each original or modified copy
|
|
49
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
50
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
51
|
+
License apply to your use of that work.
|
|
52
|
+
|
|
53
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
54
|
+
terminate your rights under this License for the current and all other
|
|
55
|
+
versions of the Licensed Work.
|
|
56
|
+
|
|
57
|
+
This License does not grant you any right in any trademark or logo of
|
|
58
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
59
|
+
Licensor as expressly required by this License).
|
|
60
|
+
|
|
61
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
62
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
63
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
64
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
65
|
+
TITLE.
|
|
66
|
+
|
|
67
|
+
MariaDB hereby grants you permission to use this License's text to license
|
|
68
|
+
your works, and to refer to it using the trademark "Business Source License",
|
|
69
|
+
as long as you comply with the Covenants of Licensor of MariaDB's Business Source
|
|
70
|
+
License Trademark Policy available at https://mariadb.com/bsl11/.
|
ob_duckdb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ob-duckdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OrionBelt Semantic Layer driver for DuckDB (DB-API 2.0)
|
|
5
|
+
Author-email: Ralf Becher <info@orionbelt.ai>
|
|
6
|
+
License: BSL-1.1
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: db-api,duckdb,obml,orionbelt,semantic-layer
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: duckdb>=1.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: ruff>=0.4; extra == 'dev'
|
|
17
|
+
Provides-Extra: sqlalchemy
|
|
18
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# ob-duckdb
|
|
22
|
+
|
|
23
|
+
OrionBelt Semantic Layer driver for DuckDB (DB-API 2.0)
|
|
24
|
+
|
|
25
|
+
Part of the [OrionBelt Semantic Layer](https://github.com/ralfbecher/orionbelt-semantic-layer) project.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install ob-duckdb
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ob-duckdb
|
|
2
|
+
|
|
3
|
+
OrionBelt Semantic Layer driver for DuckDB (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-duckdb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## License
|
|
14
|
+
|
|
15
|
+
BSL-1.1 — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ob-duckdb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OrionBelt Semantic Layer driver for DuckDB (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 = ["duckdb", "semantic-layer", "obml", "db-api", "orionbelt"]
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"ob-driver-core>=0.1",
|
|
16
|
+
"duckdb>=1.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
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
29
|
+
"ob.duckdb" = "ob_duckdb.dialect:OBDuckDBDialect"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/ob_duckdb"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 100
|
|
36
|
+
target-version = "py312"
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ANN"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
python_version = "3.12"
|
|
43
|
+
strict = true
|
|
44
|
+
files = ["src/ob_duckdb"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
# Note: no integration marker needed — DuckDB :memory: runs anywhere
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""ob-duckdb — OrionBelt Semantic Layer driver for DuckDB (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_duckdb
|
|
9
|
+
|
|
10
|
+
conn = ob_duckdb.connect(database=":memory:")
|
|
11
|
+
with conn.cursor() as cur:
|
|
12
|
+
cur.execute("select:\\n dimensions:\\n - Region\\n measures:\\n - Revenue")
|
|
13
|
+
print(cur.fetchall())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import duckdb
|
|
21
|
+
|
|
22
|
+
from ob_duckdb.connection import Connection
|
|
23
|
+
from ob_duckdb.exceptions import (
|
|
24
|
+
DataError,
|
|
25
|
+
DatabaseError,
|
|
26
|
+
Error,
|
|
27
|
+
IntegrityError,
|
|
28
|
+
InterfaceError,
|
|
29
|
+
InternalError,
|
|
30
|
+
NotSupportedError,
|
|
31
|
+
OperationalError,
|
|
32
|
+
ProgrammingError,
|
|
33
|
+
Warning,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# PEP 249 module-level constants
|
|
37
|
+
apilevel = "2.0"
|
|
38
|
+
threadsafety = 1 # threads may share the module but not connections
|
|
39
|
+
paramstyle = "qmark" # DuckDB uses ? placeholders
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def connect(
|
|
43
|
+
database: str = ":memory:",
|
|
44
|
+
*,
|
|
45
|
+
read_only: bool = False,
|
|
46
|
+
config: dict[str, Any] | None = None,
|
|
47
|
+
# OrionBelt parameters
|
|
48
|
+
ob_api_url: str = "http://localhost:8000",
|
|
49
|
+
ob_timeout: int = 30,
|
|
50
|
+
) -> Connection:
|
|
51
|
+
"""Open a DuckDB connection with OBML support.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
database : str
|
|
56
|
+
Path to ``.duckdb`` file or ``":memory:"`` (default).
|
|
57
|
+
read_only : bool
|
|
58
|
+
Open in read-only mode.
|
|
59
|
+
config : dict
|
|
60
|
+
DuckDB configuration options (threads, memory_limit, etc.).
|
|
61
|
+
ob_api_url : str
|
|
62
|
+
OrionBelt REST API URL (must be running in single-model mode).
|
|
63
|
+
ob_timeout : int
|
|
64
|
+
HTTP timeout in seconds for OBML compilation.
|
|
65
|
+
"""
|
|
66
|
+
native = duckdb.connect(database=database, read_only=read_only, config=config or {})
|
|
67
|
+
return Connection(
|
|
68
|
+
native,
|
|
69
|
+
ob_api_url=ob_api_url,
|
|
70
|
+
ob_timeout=ob_timeout,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"apilevel",
|
|
76
|
+
"threadsafety",
|
|
77
|
+
"paramstyle",
|
|
78
|
+
"connect",
|
|
79
|
+
"Connection",
|
|
80
|
+
"Warning",
|
|
81
|
+
"Error",
|
|
82
|
+
"InterfaceError",
|
|
83
|
+
"DatabaseError",
|
|
84
|
+
"DataError",
|
|
85
|
+
"OperationalError",
|
|
86
|
+
"IntegrityError",
|
|
87
|
+
"InternalError",
|
|
88
|
+
"ProgrammingError",
|
|
89
|
+
"NotSupportedError",
|
|
90
|
+
]
|
|
@@ -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,70 @@
|
|
|
1
|
+
"""PEP 249 Connection wrapping ``duckdb.DuckDBPyConnection``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
from ob_duckdb.cursor import Cursor
|
|
8
|
+
from ob_duckdb.exceptions import ProgrammingError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Connection:
|
|
12
|
+
"""DB-API 2.0 connection that wraps a native DuckDB 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: duckdb.DuckDBPyConnection,
|
|
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
|
+
"""Commit — DuckDB auto-commits by default, so this is usually a no-op."""
|
|
46
|
+
self._check_open()
|
|
47
|
+
self._native.commit()
|
|
48
|
+
|
|
49
|
+
def rollback(self) -> None:
|
|
50
|
+
"""Rollback the current transaction.
|
|
51
|
+
|
|
52
|
+
No-op if no transaction is active (DuckDB auto-commits by default).
|
|
53
|
+
"""
|
|
54
|
+
self._check_open()
|
|
55
|
+
try:
|
|
56
|
+
self._native.rollback()
|
|
57
|
+
except duckdb.TransactionException:
|
|
58
|
+
pass # no active transaction — nothing to roll back
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
"""Close the connection."""
|
|
62
|
+
if not self._closed:
|
|
63
|
+
self._native.close()
|
|
64
|
+
self._closed = True
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> Connection:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc: object) -> None:
|
|
70
|
+
self.close()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""PEP 249 Cursor with OBML interception for DuckDB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import duckdb
|
|
8
|
+
|
|
9
|
+
from ob_duckdb.compiler import compile_obml, is_obml, parse_obml
|
|
10
|
+
from ob_duckdb.exceptions import NotSupportedError, ProgrammingError
|
|
11
|
+
from ob_duckdb.type_codes import DUCKDB_TYPE_MAP, STRING
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Cursor:
|
|
15
|
+
"""DB-API 2.0 cursor that intercepts OBML YAML queries.
|
|
16
|
+
|
|
17
|
+
If the query is OBML, it is compiled to DuckDB SQL via the OrionBelt
|
|
18
|
+
REST API before execution. Plain SQL is passed through unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
arraysize: int = 1
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
native: duckdb.DuckDBPyConnection,
|
|
26
|
+
*,
|
|
27
|
+
ob_api_url: str = "http://localhost:8000",
|
|
28
|
+
ob_timeout: int = 30,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._native = native
|
|
31
|
+
self._closed = False
|
|
32
|
+
self._ob_api_url = ob_api_url
|
|
33
|
+
self._ob_timeout = ob_timeout
|
|
34
|
+
self._description: tuple[tuple[str, Any, None, None, None, None, None], ...] | None = None
|
|
35
|
+
self._rowcount: int = -1
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def description(
|
|
39
|
+
self,
|
|
40
|
+
) -> tuple[tuple[str, Any, None, None, None, None, None], ...] | None:
|
|
41
|
+
"""PEP 249 cursor description — 7-item tuples per column."""
|
|
42
|
+
return self._description
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def rowcount(self) -> int:
|
|
46
|
+
return self._rowcount
|
|
47
|
+
|
|
48
|
+
def _check_open(self) -> None:
|
|
49
|
+
if self._closed:
|
|
50
|
+
raise ProgrammingError("Cursor is closed.")
|
|
51
|
+
|
|
52
|
+
def _build_description(self) -> None:
|
|
53
|
+
"""Build PEP 249 description from native cursor."""
|
|
54
|
+
native_desc = self._native.description
|
|
55
|
+
if native_desc is None:
|
|
56
|
+
self._description = None
|
|
57
|
+
return
|
|
58
|
+
cols: list[tuple[str, Any, None, None, None, None, None]] = []
|
|
59
|
+
for col in native_desc:
|
|
60
|
+
name = col[0]
|
|
61
|
+
raw_type = col[1] if len(col) > 1 else None
|
|
62
|
+
type_code = STRING # default
|
|
63
|
+
# DuckDB returns DuckDBPyType objects — convert to string for lookup
|
|
64
|
+
type_str = str(raw_type).upper() if raw_type is not None else ""
|
|
65
|
+
type_code = DUCKDB_TYPE_MAP.get(type_str, STRING)
|
|
66
|
+
cols.append((name, type_code, None, None, None, None, None))
|
|
67
|
+
self._description = tuple(cols)
|
|
68
|
+
|
|
69
|
+
def execute(self, operation: str, parameters: Any = None) -> Cursor:
|
|
70
|
+
"""Execute a query — OBML YAML or plain SQL."""
|
|
71
|
+
self._check_open()
|
|
72
|
+
sql = self._resolve_sql(operation)
|
|
73
|
+
if parameters is not None:
|
|
74
|
+
self._native.execute(sql, parameters)
|
|
75
|
+
else:
|
|
76
|
+
self._native.execute(sql)
|
|
77
|
+
self._build_description()
|
|
78
|
+
self._rowcount = -1
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def executemany(self, operation: str, seq_of_parameters: Any) -> None:
|
|
82
|
+
"""Execute against all parameter sequences.
|
|
83
|
+
|
|
84
|
+
OBML queries are not supported with executemany — raises NotSupportedError.
|
|
85
|
+
"""
|
|
86
|
+
self._check_open()
|
|
87
|
+
if is_obml(operation):
|
|
88
|
+
raise NotSupportedError("executemany() is not supported for OBML queries.")
|
|
89
|
+
self._native.executemany(operation, seq_of_parameters)
|
|
90
|
+
self._build_description()
|
|
91
|
+
|
|
92
|
+
def _resolve_sql(self, operation: str) -> str:
|
|
93
|
+
"""Compile OBML to SQL or return plain SQL unchanged."""
|
|
94
|
+
if not is_obml(operation):
|
|
95
|
+
return operation
|
|
96
|
+
obml = parse_obml(operation)
|
|
97
|
+
return compile_obml(
|
|
98
|
+
obml,
|
|
99
|
+
dialect="duckdb",
|
|
100
|
+
ob_api_url=self._ob_api_url,
|
|
101
|
+
ob_timeout=self._ob_timeout,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def fetchone(self) -> tuple[Any, ...] | None:
|
|
105
|
+
"""Fetch the next row."""
|
|
106
|
+
self._check_open()
|
|
107
|
+
row = self._native.fetchone()
|
|
108
|
+
return tuple(row) if row is not None else None
|
|
109
|
+
|
|
110
|
+
def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
|
|
111
|
+
"""Fetch the next *size* rows."""
|
|
112
|
+
self._check_open()
|
|
113
|
+
n = size if size is not None else self.arraysize
|
|
114
|
+
rows = self._native.fetchmany(n)
|
|
115
|
+
return [tuple(r) for r in rows]
|
|
116
|
+
|
|
117
|
+
def fetchall(self) -> list[tuple[Any, ...]]:
|
|
118
|
+
"""Fetch all remaining rows."""
|
|
119
|
+
self._check_open()
|
|
120
|
+
rows = self._native.fetchall()
|
|
121
|
+
return [tuple(r) for r in rows]
|
|
122
|
+
|
|
123
|
+
def fetch_arrow_table(self) -> Any:
|
|
124
|
+
"""Fetch all remaining rows as a PyArrow Table (zero-copy from DuckDB).
|
|
125
|
+
|
|
126
|
+
Returns a ``pyarrow.Table``. This avoids materialising intermediate
|
|
127
|
+
Python row objects and is significantly more memory-efficient for
|
|
128
|
+
large result sets.
|
|
129
|
+
"""
|
|
130
|
+
self._check_open()
|
|
131
|
+
return self._native.fetch_arrow_table()
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the cursor."""
|
|
135
|
+
if not self._closed:
|
|
136
|
+
self._native.close()
|
|
137
|
+
self._closed = True
|
|
138
|
+
|
|
139
|
+
def setinputsizes(self, sizes: Any) -> None:
|
|
140
|
+
"""No-op — required by PEP 249."""
|
|
141
|
+
|
|
142
|
+
def setoutputsize(self, size: int, column: int | None = None) -> None:
|
|
143
|
+
"""No-op — required by PEP 249."""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def lastrowid(self) -> None:
|
|
147
|
+
"""DuckDB does not support lastrowid."""
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def __enter__(self) -> Cursor:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, *exc: object) -> None:
|
|
154
|
+
self.close()
|
|
155
|
+
|
|
156
|
+
def __iter__(self) -> Cursor:
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __next__(self) -> tuple[Any, ...]:
|
|
160
|
+
row = self.fetchone()
|
|
161
|
+
if row is None:
|
|
162
|
+
raise StopIteration
|
|
163
|
+
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_duckdb.exceptions import ProgrammingError``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ob_driver_core.exceptions import (
|
|
9
|
+
DataError,
|
|
10
|
+
DatabaseError,
|
|
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,45 @@
|
|
|
1
|
+
"""PEP 249 type objects — re-exported from ob-driver-core plus DuckDB mapping.
|
|
2
|
+
|
|
3
|
+
DuckDB cursor.description uses uppercase type names like 'VARCHAR', 'INTEGER', etc.
|
|
4
|
+
The DUCKDB_TYPE_MAP maps these 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", "DUCKDB_TYPE_MAP"]
|
|
12
|
+
|
|
13
|
+
DUCKDB_TYPE_MAP: dict[str, object] = {
|
|
14
|
+
# String types
|
|
15
|
+
"VARCHAR": STRING,
|
|
16
|
+
"TEXT": STRING,
|
|
17
|
+
"CHAR": STRING,
|
|
18
|
+
"BOOLEAN": STRING,
|
|
19
|
+
"UUID": STRING,
|
|
20
|
+
"ENUM": STRING,
|
|
21
|
+
# Numeric types
|
|
22
|
+
"TINYINT": NUMBER,
|
|
23
|
+
"SMALLINT": NUMBER,
|
|
24
|
+
"INTEGER": NUMBER,
|
|
25
|
+
"BIGINT": NUMBER,
|
|
26
|
+
"HUGEINT": NUMBER,
|
|
27
|
+
"UTINYINT": NUMBER,
|
|
28
|
+
"USMALLINT": NUMBER,
|
|
29
|
+
"UINTEGER": NUMBER,
|
|
30
|
+
"UBIGINT": NUMBER,
|
|
31
|
+
"FLOAT": NUMBER,
|
|
32
|
+
"DOUBLE": NUMBER,
|
|
33
|
+
"DECIMAL": NUMBER,
|
|
34
|
+
# Date/time types
|
|
35
|
+
"DATE": DATETIME,
|
|
36
|
+
"TIME": DATETIME,
|
|
37
|
+
"TIMESTAMP": DATETIME,
|
|
38
|
+
"TIMESTAMP WITH TIME ZONE": DATETIME,
|
|
39
|
+
"TIMESTAMP_S": DATETIME,
|
|
40
|
+
"TIMESTAMP_MS": DATETIME,
|
|
41
|
+
"TIMESTAMP_NS": DATETIME,
|
|
42
|
+
"INTERVAL": DATETIME,
|
|
43
|
+
# Binary types
|
|
44
|
+
"BLOB": BINARY,
|
|
45
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Unit tests for the ob-duckdb DB-API 2.0 driver.
|
|
2
|
+
|
|
3
|
+
Plain SQL tests run against DuckDB :memory: — no external services needed.
|
|
4
|
+
OBML tests 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_duckdb
|
|
14
|
+
from ob_duckdb.connection import Connection
|
|
15
|
+
from ob_duckdb.exceptions import NotSupportedError, ProgrammingError
|
|
16
|
+
from ob_duckdb.type_codes import DATETIME, NUMBER, STRING
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# PEP 249 module-level constants
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_apilevel() -> None:
|
|
25
|
+
assert ob_duckdb.apilevel == "2.0"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_threadsafety() -> None:
|
|
29
|
+
assert ob_duckdb.threadsafety == 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_paramstyle() -> None:
|
|
33
|
+
assert ob_duckdb.paramstyle == "qmark"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# connect()
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_connect_returns_connection() -> None:
|
|
42
|
+
conn = ob_duckdb.connect()
|
|
43
|
+
assert isinstance(conn, Connection)
|
|
44
|
+
conn.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_connect_memory_default() -> None:
|
|
48
|
+
conn = ob_duckdb.connect()
|
|
49
|
+
with conn.cursor() as cur:
|
|
50
|
+
cur.execute("SELECT 42 AS answer")
|
|
51
|
+
assert cur.fetchone() == (42,)
|
|
52
|
+
conn.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_connect_context_manager() -> None:
|
|
56
|
+
with ob_duckdb.connect() as conn:
|
|
57
|
+
with conn.cursor() as cur:
|
|
58
|
+
cur.execute("SELECT 1")
|
|
59
|
+
assert cur.fetchone() == (1,)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Connection
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_connection_close_is_idempotent() -> None:
|
|
68
|
+
conn = ob_duckdb.connect()
|
|
69
|
+
conn.close()
|
|
70
|
+
conn.close() # should not raise
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_connection_cursor_after_close_raises() -> None:
|
|
74
|
+
conn = ob_duckdb.connect()
|
|
75
|
+
conn.close()
|
|
76
|
+
with pytest.raises(ProgrammingError, match="closed"):
|
|
77
|
+
conn.cursor()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_connection_commit() -> None:
|
|
81
|
+
conn = ob_duckdb.connect()
|
|
82
|
+
conn.commit() # should not raise
|
|
83
|
+
conn.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_connection_rollback() -> None:
|
|
87
|
+
conn = ob_duckdb.connect()
|
|
88
|
+
conn.rollback() # should not raise
|
|
89
|
+
conn.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Cursor — plain SQL
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_cursor_execute_select() -> None:
|
|
98
|
+
with ob_duckdb.connect() as conn:
|
|
99
|
+
with conn.cursor() as cur:
|
|
100
|
+
cur.execute("SELECT 1 AS a, 'hello' AS b, CAST(3.14 AS DOUBLE) AS c")
|
|
101
|
+
row = cur.fetchone()
|
|
102
|
+
assert row == (1, "hello", 3.14)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_cursor_description() -> None:
|
|
106
|
+
with ob_duckdb.connect() as conn:
|
|
107
|
+
with conn.cursor() as cur:
|
|
108
|
+
cur.execute("SELECT 1 AS num, 'txt' AS str, CURRENT_DATE AS dt")
|
|
109
|
+
desc = cur.description
|
|
110
|
+
assert desc is not None
|
|
111
|
+
assert len(desc) == 3
|
|
112
|
+
# Each entry is a 7-tuple
|
|
113
|
+
assert all(len(col) == 7 for col in desc)
|
|
114
|
+
# Column names
|
|
115
|
+
assert desc[0][0] == "num"
|
|
116
|
+
assert desc[1][0] == "str"
|
|
117
|
+
assert desc[2][0] == "dt"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_cursor_description_type_codes() -> None:
|
|
121
|
+
with ob_duckdb.connect() as conn:
|
|
122
|
+
with conn.cursor() as cur:
|
|
123
|
+
cur.execute(
|
|
124
|
+
"SELECT "
|
|
125
|
+
"CAST(1 AS INTEGER) AS i, "
|
|
126
|
+
"CAST('x' AS VARCHAR) AS v, "
|
|
127
|
+
"CAST('2024-01-01' AS DATE) AS d, "
|
|
128
|
+
"CAST(1.5 AS DOUBLE) AS f"
|
|
129
|
+
)
|
|
130
|
+
desc = cur.description
|
|
131
|
+
assert desc is not None
|
|
132
|
+
assert desc[0][1] == NUMBER
|
|
133
|
+
assert desc[1][1] == STRING
|
|
134
|
+
assert desc[2][1] == DATETIME
|
|
135
|
+
assert desc[3][1] == NUMBER
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_cursor_fetchall() -> None:
|
|
139
|
+
with ob_duckdb.connect() as conn:
|
|
140
|
+
with conn.cursor() as cur:
|
|
141
|
+
cur.execute("SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t(id, name)")
|
|
142
|
+
rows = cur.fetchall()
|
|
143
|
+
assert len(rows) == 3
|
|
144
|
+
assert rows[0] == (1, "a")
|
|
145
|
+
assert rows[2] == (3, "c")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_cursor_fetchmany() -> None:
|
|
149
|
+
with ob_duckdb.connect() as conn:
|
|
150
|
+
with conn.cursor() as cur:
|
|
151
|
+
cur.execute("SELECT * FROM range(10) AS t(n)")
|
|
152
|
+
batch = cur.fetchmany(3)
|
|
153
|
+
assert len(batch) == 3
|
|
154
|
+
rest = cur.fetchall()
|
|
155
|
+
assert len(rest) == 7
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_cursor_fetchone_exhausted() -> None:
|
|
159
|
+
with ob_duckdb.connect() as conn:
|
|
160
|
+
with conn.cursor() as cur:
|
|
161
|
+
cur.execute("SELECT 1")
|
|
162
|
+
assert cur.fetchone() is not None
|
|
163
|
+
assert cur.fetchone() is None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_cursor_iteration() -> None:
|
|
167
|
+
with ob_duckdb.connect() as conn:
|
|
168
|
+
with conn.cursor() as cur:
|
|
169
|
+
cur.execute("SELECT * FROM range(5) AS t(n)")
|
|
170
|
+
rows = list(cur)
|
|
171
|
+
assert len(rows) == 5
|
|
172
|
+
assert rows[0] == (0,)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_cursor_execute_returns_self() -> None:
|
|
176
|
+
with ob_duckdb.connect() as conn:
|
|
177
|
+
with conn.cursor() as cur:
|
|
178
|
+
result = cur.execute("SELECT 1")
|
|
179
|
+
assert result is cur
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_cursor_close_then_fetch_raises() -> None:
|
|
183
|
+
with ob_duckdb.connect() as conn:
|
|
184
|
+
cur = conn.cursor()
|
|
185
|
+
cur.execute("SELECT 1")
|
|
186
|
+
cur.close()
|
|
187
|
+
with pytest.raises(ProgrammingError, match="closed"):
|
|
188
|
+
cur.fetchone()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_cursor_executemany_plain_sql() -> None:
|
|
192
|
+
with ob_duckdb.connect() as conn:
|
|
193
|
+
with conn.cursor() as cur:
|
|
194
|
+
cur.execute("CREATE TABLE t (id INTEGER, name VARCHAR)")
|
|
195
|
+
cur.executemany("INSERT INTO t VALUES (?, ?)", [(1, "a"), (2, "b")])
|
|
196
|
+
cur.execute("SELECT COUNT(*) FROM t")
|
|
197
|
+
assert cur.fetchone() == (2,)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_cursor_executemany_obml_raises() -> None:
|
|
201
|
+
with ob_duckdb.connect() as conn:
|
|
202
|
+
with conn.cursor() as cur:
|
|
203
|
+
obml = "select:\n dimensions:\n - Region\n measures:\n - Revenue\n"
|
|
204
|
+
with pytest.raises(NotSupportedError, match="executemany"):
|
|
205
|
+
cur.executemany(obml, [])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_cursor_setinputsizes_noop() -> None:
|
|
209
|
+
with ob_duckdb.connect() as conn:
|
|
210
|
+
with conn.cursor() as cur:
|
|
211
|
+
cur.setinputsizes([]) # should not raise
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_cursor_setoutputsize_noop() -> None:
|
|
215
|
+
with ob_duckdb.connect() as conn:
|
|
216
|
+
with conn.cursor() as cur:
|
|
217
|
+
cur.setoutputsize(1000) # should not raise
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_cursor_lastrowid_is_none() -> None:
|
|
221
|
+
with ob_duckdb.connect() as conn:
|
|
222
|
+
with conn.cursor() as cur:
|
|
223
|
+
assert cur.lastrowid is None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_cursor_with_parameters() -> None:
|
|
227
|
+
with ob_duckdb.connect() as conn:
|
|
228
|
+
with conn.cursor() as cur:
|
|
229
|
+
cur.execute("SELECT ? + ? AS result", [3, 4])
|
|
230
|
+
assert cur.fetchone() == (7,)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Cursor — OBML queries (mocked REST API)
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.fixture
|
|
239
|
+
def duckdb_conn_with_data() -> Connection:
|
|
240
|
+
"""DuckDB connection with sample data (no model — OBML goes via REST)."""
|
|
241
|
+
conn = ob_duckdb.connect()
|
|
242
|
+
with conn.cursor() as cur:
|
|
243
|
+
cur.execute("""
|
|
244
|
+
CREATE TABLE orders (
|
|
245
|
+
order_id VARCHAR,
|
|
246
|
+
region VARCHAR,
|
|
247
|
+
amount DOUBLE
|
|
248
|
+
)
|
|
249
|
+
""")
|
|
250
|
+
cur.execute("""
|
|
251
|
+
INSERT INTO orders VALUES
|
|
252
|
+
('O1', 'EMEA', 100.0),
|
|
253
|
+
('O2', 'EMEA', 200.0),
|
|
254
|
+
('O3', 'APAC', 150.0),
|
|
255
|
+
('O4', 'AMER', 300.0),
|
|
256
|
+
('O5', 'AMER', 250.0)
|
|
257
|
+
""")
|
|
258
|
+
return conn
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _mock_api_response(sql: str) -> MagicMock:
|
|
262
|
+
"""Create a mock httpx response returning the given SQL."""
|
|
263
|
+
resp = MagicMock()
|
|
264
|
+
resp.is_success = True
|
|
265
|
+
resp.json.return_value = {"sql": sql}
|
|
266
|
+
return resp
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_obml_compile_and_execute(duckdb_conn_with_data: Connection) -> None:
|
|
270
|
+
"""Full round-trip: OBML → REST compile → execute on DuckDB."""
|
|
271
|
+
compiled_sql = "SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region"
|
|
272
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
273
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
274
|
+
cur.execute("select:\n dimensions:\n - Region\n measures:\n - Revenue\n")
|
|
275
|
+
rows = cur.fetchall()
|
|
276
|
+
assert len(rows) == 3
|
|
277
|
+
desc = cur.description
|
|
278
|
+
assert desc is not None
|
|
279
|
+
assert len(desc) == 2
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_obml_measures_only(duckdb_conn_with_data: Connection) -> None:
|
|
283
|
+
"""OBML with measures only (no dimensions)."""
|
|
284
|
+
compiled_sql = "SELECT SUM(amount) AS revenue FROM orders"
|
|
285
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
286
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
287
|
+
cur.execute("select:\n measures:\n - Revenue\n")
|
|
288
|
+
rows = cur.fetchall()
|
|
289
|
+
assert len(rows) == 1
|
|
290
|
+
assert rows[0][0] == 1000.0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_obml_count_measure(duckdb_conn_with_data: Connection) -> None:
|
|
294
|
+
"""OBML count aggregation."""
|
|
295
|
+
compiled_sql = "SELECT COUNT(order_id) AS order_count FROM orders"
|
|
296
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
297
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
298
|
+
cur.execute("select:\n measures:\n - Order Count\n")
|
|
299
|
+
rows = cur.fetchall()
|
|
300
|
+
assert len(rows) == 1
|
|
301
|
+
assert rows[0][0] == 5
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_obml_with_limit(duckdb_conn_with_data: Connection) -> None:
|
|
305
|
+
"""OBML with limit clause."""
|
|
306
|
+
compiled_sql = (
|
|
307
|
+
"SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region LIMIT 2"
|
|
308
|
+
)
|
|
309
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)):
|
|
310
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
311
|
+
cur.execute(
|
|
312
|
+
"select:\n dimensions:\n - Region\n measures:\n - Revenue\nlimit: 2\n"
|
|
313
|
+
)
|
|
314
|
+
rows = cur.fetchall()
|
|
315
|
+
assert len(rows) == 2
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_obml_rest_api_url_forwarded(duckdb_conn_with_data: Connection) -> None:
|
|
319
|
+
"""The ob_api_url is forwarded to the REST call."""
|
|
320
|
+
compiled_sql = "SELECT 1"
|
|
321
|
+
with patch("httpx.post", return_value=_mock_api_response(compiled_sql)) as mock_post:
|
|
322
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
323
|
+
cur.execute("select:\n measures:\n - Revenue\n")
|
|
324
|
+
url = mock_post.call_args.args[0]
|
|
325
|
+
assert "/v1/query/sql" in url
|
|
326
|
+
assert mock_post.call_args.kwargs["params"] == {"dialect": "duckdb"}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_plain_sql_passthrough(duckdb_conn_with_data: Connection) -> None:
|
|
330
|
+
"""Plain SQL is passed through without OBML compilation."""
|
|
331
|
+
with duckdb_conn_with_data.cursor() as cur:
|
|
332
|
+
cur.execute("SELECT COUNT(*) FROM orders")
|
|
333
|
+
assert cur.fetchone() == (5,)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Unit tests for OBML YAML detection — runs without any external service."""
|
|
2
|
+
import pytest
|
|
3
|
+
from ob_duckdb.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 FROM 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
|