opteryx-sqlalchemy 0.0.0__py3-none-any.whl
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.
- opteryx_sqlalchemy-0.0.0.dist-info/METADATA +13 -0
- opteryx_sqlalchemy-0.0.0.dist-info/RECORD +9 -0
- opteryx_sqlalchemy-0.0.0.dist-info/WHEEL +5 -0
- opteryx_sqlalchemy-0.0.0.dist-info/entry_points.txt +2 -0
- opteryx_sqlalchemy-0.0.0.dist-info/licenses/LICENSE +201 -0
- opteryx_sqlalchemy-0.0.0.dist-info/top_level.txt +1 -0
- sqlalchemy_dialect/__init__.py +18 -0
- sqlalchemy_dialect/dbapi.py +651 -0
- sqlalchemy_dialect/dialect.py +346 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opteryx-sqlalchemy
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Opteryx SQLAlchemy Dialect
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: brotli-asgi
|
|
8
|
+
Requires-Dist: python-jose[cryptography]==3.3.0
|
|
9
|
+
Requires-Dist: cryptography>=40.0.0
|
|
10
|
+
Requires-Dist: requests>=2.30.0
|
|
11
|
+
Requires-Dist: python-multipart>=0.0.6
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
13
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
opteryx_sqlalchemy-0.0.0.dist-info/licenses/LICENSE,sha256=m8Z1IUiQRdgLd9_9LtMRjZSStEy0pZXJqy_2KTv14r4,11341
|
|
2
|
+
sqlalchemy_dialect/__init__.py,sha256=5OAksQuB-yEkNv9DQO3Qet149Spdj5Fb5rAkJereeXw,491
|
|
3
|
+
sqlalchemy_dialect/dbapi.py,sha256=LXBSHG1h0nBZEdqZm593hc7fbfzu0_OIUdFkCn85h4M,22683
|
|
4
|
+
sqlalchemy_dialect/dialect.py,sha256=pFE_6OrwurpQLR-BtF40bYqoc1dzQEEho_oSyodLApc,10573
|
|
5
|
+
opteryx_sqlalchemy-0.0.0.dist-info/METADATA,sha256=QHwEv2t3xLv3lPmYE5hutvlxbznUyD71_isLaknvNGk,381
|
|
6
|
+
opteryx_sqlalchemy-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
opteryx_sqlalchemy-0.0.0.dist-info/entry_points.txt,sha256=bli1cTeTUJrjjOE1a5Frpz2WHJsn8k4sOOC7yjvIPPo,74
|
|
8
|
+
opteryx_sqlalchemy-0.0.0.dist-info/top_level.txt,sha256=MXcCVk91HI49RJztG1b06V8eU0kEFraCdIxvtJb1cTQ,19
|
|
9
|
+
opteryx_sqlalchemy-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2025 Justin Joyce
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlalchemy_dialect
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SQLAlchemy dialect for Opteryx data service.
|
|
2
|
+
|
|
3
|
+
This module provides a SQLAlchemy dialect that connects to the Opteryx
|
|
4
|
+
data service via its HTTP API, enabling use of SQLAlchemy's ORM and
|
|
5
|
+
SQL expression language with Opteryx.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from sqlalchemy import create_engine
|
|
9
|
+
|
|
10
|
+
# Connect to Opteryx data service
|
|
11
|
+
engine = create_engine(
|
|
12
|
+
"opteryx://user:password@data.opteryx.app:443/default?ssl=true"
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .dialect import OptetyxDialect
|
|
17
|
+
|
|
18
|
+
__all__ = ["OptetyxDialect"]
|
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""DBAPI 2.0 (PEP 249) compliant interface for Opteryx data service.
|
|
2
|
+
|
|
3
|
+
This module implements a minimal DBAPI 2.0 interface that communicates
|
|
4
|
+
with the Opteryx data service via HTTP. It provides Connection and Cursor
|
|
5
|
+
classes that translate SQL queries into HTTP requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
from typing import Dict
|
|
14
|
+
from typing import List
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
from typing import Tuple
|
|
18
|
+
from typing import Union
|
|
19
|
+
from urllib.parse import urljoin
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
# Module globals required by PEP 249
|
|
24
|
+
apilevel = "2.0"
|
|
25
|
+
threadsafety = 1 # Threads may share the module, but not connections
|
|
26
|
+
paramstyle = "named" # Named style: WHERE name=:name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Error(Exception):
|
|
30
|
+
"""Base exception for DBAPI errors."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Warning(Exception): # noqa: A001
|
|
34
|
+
"""Warning exception."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InterfaceError(Error):
|
|
38
|
+
"""Exception for interface errors."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DatabaseError(Error):
|
|
42
|
+
"""Exception for database errors."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataError(DatabaseError):
|
|
46
|
+
"""Exception for data errors."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OperationalError(DatabaseError):
|
|
50
|
+
"""Exception for operational errors."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IntegrityError(DatabaseError):
|
|
54
|
+
"""Exception for integrity constraint errors."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InternalError(DatabaseError):
|
|
58
|
+
"""Exception for internal errors."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProgrammingError(DatabaseError):
|
|
62
|
+
"""Exception for programming errors."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NotSupportedError(DatabaseError):
|
|
66
|
+
"""Exception for not supported operations."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Type constructors (required by PEP 249)
|
|
70
|
+
def Date(year: int, month: int, day: int) -> str:
|
|
71
|
+
"""Construct a date value."""
|
|
72
|
+
return f"{year:04d}-{month:02d}-{day:02d}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def Time(hour: int, minute: int, second: int) -> str:
|
|
76
|
+
"""Construct a time value."""
|
|
77
|
+
return f"{hour:02d}:{minute:02d}:{second:02d}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def Timestamp(year: int, month: int, day: int, hour: int, minute: int, second: int) -> str:
|
|
81
|
+
"""Construct a timestamp value."""
|
|
82
|
+
return f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def Binary(string: bytes) -> bytes:
|
|
86
|
+
"""Construct a binary value."""
|
|
87
|
+
return string
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
STRING = str
|
|
91
|
+
BINARY = bytes
|
|
92
|
+
NUMBER = float
|
|
93
|
+
DATETIME = str
|
|
94
|
+
ROWID = str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Cursor:
|
|
98
|
+
"""DBAPI 2.0 Cursor implementation for Opteryx."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, connection: "Connection") -> None:
|
|
101
|
+
self._connection = connection
|
|
102
|
+
self._jwt_token: Optional[str] = None
|
|
103
|
+
self._description: Optional[
|
|
104
|
+
List[Tuple[str, Any, None, None, None, None, Optional[bool]]]
|
|
105
|
+
] = None
|
|
106
|
+
self._rowcount = -1
|
|
107
|
+
self._rows: List[Tuple[Any, ...]] = []
|
|
108
|
+
self._row_index = 0
|
|
109
|
+
self._arraysize = 1
|
|
110
|
+
self._closed = False
|
|
111
|
+
self._statement_handle: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
# Try to authenticate using client credentials (client credentials flow)
|
|
114
|
+
# client_id is connection._username and client_secret is connection._token
|
|
115
|
+
try:
|
|
116
|
+
username = getattr(self._connection, "_username", None)
|
|
117
|
+
secret = getattr(self._connection, "_token", None)
|
|
118
|
+
if username and secret:
|
|
119
|
+
host = getattr(self._connection, "_host", "localhost")
|
|
120
|
+
# Normalize domain and build auth host (auth.domain)
|
|
121
|
+
try:
|
|
122
|
+
domain = self._connection._normalize_domain(host)
|
|
123
|
+
except Exception:
|
|
124
|
+
domain = host
|
|
125
|
+
# Only add auth. prefix when domain looks like a DNS name (not 'localhost')
|
|
126
|
+
if "." in domain and not domain.startswith("localhost"):
|
|
127
|
+
auth_host = f"auth.{domain}"
|
|
128
|
+
else:
|
|
129
|
+
auth_host = domain
|
|
130
|
+
scheme = "https" if getattr(self._connection, "_ssl", False) else "http"
|
|
131
|
+
auth_url = f"{scheme}://{auth_host}/token"
|
|
132
|
+
|
|
133
|
+
# Build form-encoded payload
|
|
134
|
+
payload = {
|
|
135
|
+
"grant_type": "client_credentials",
|
|
136
|
+
"client_id": username,
|
|
137
|
+
"client_secret": secret,
|
|
138
|
+
}
|
|
139
|
+
# Use the connection session for auth so auth header set for all subsequent calls
|
|
140
|
+
sess = getattr(self._connection, "_session", requests.Session())
|
|
141
|
+
headers = {
|
|
142
|
+
"accept": "application/json",
|
|
143
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
144
|
+
}
|
|
145
|
+
resp = sess.post(
|
|
146
|
+
auth_url,
|
|
147
|
+
data=payload,
|
|
148
|
+
headers=headers,
|
|
149
|
+
timeout=getattr(self._connection, "_timeout", 30),
|
|
150
|
+
)
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
body = resp.json() if resp.text else {}
|
|
153
|
+
token = body.get("access_token") or body.get("token") or body.get("jwt")
|
|
154
|
+
if token:
|
|
155
|
+
self._jwt_token = token
|
|
156
|
+
# Set Authorization header for subsequent requests via the connection session
|
|
157
|
+
try:
|
|
158
|
+
self._connection._session.headers["Authorization"] = f"Bearer {token}"
|
|
159
|
+
except Exception:
|
|
160
|
+
# If connection session is not available or some other issue, ignore gracefully
|
|
161
|
+
pass
|
|
162
|
+
except requests.exceptions.RequestException:
|
|
163
|
+
# Authentication failed — don't raise here; we will attempt queries without the JWT
|
|
164
|
+
self._jwt_token = None
|
|
165
|
+
except Exception:
|
|
166
|
+
# Any unexpected failure in auth should not crash cursor creation
|
|
167
|
+
self._jwt_token = None
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def description(
|
|
171
|
+
self,
|
|
172
|
+
) -> Optional[List[Tuple[str, Any, None, None, None, None, Optional[bool]]]]:
|
|
173
|
+
"""Column description as required by PEP 249."""
|
|
174
|
+
return self._description
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def rowcount(self) -> int:
|
|
178
|
+
"""Number of rows affected by the last operation."""
|
|
179
|
+
return self._rowcount
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def arraysize(self) -> int:
|
|
183
|
+
"""Number of rows to fetch at a time."""
|
|
184
|
+
return self._arraysize
|
|
185
|
+
|
|
186
|
+
@arraysize.setter
|
|
187
|
+
def arraysize(self, value: int) -> None:
|
|
188
|
+
self._arraysize = value
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
"""Close the cursor."""
|
|
192
|
+
self._closed = True
|
|
193
|
+
self._rows = []
|
|
194
|
+
self._description = None
|
|
195
|
+
|
|
196
|
+
def _check_closed(self) -> None:
|
|
197
|
+
"""Raise exception if cursor is closed."""
|
|
198
|
+
if self._closed:
|
|
199
|
+
raise ProgrammingError("Cursor is closed")
|
|
200
|
+
|
|
201
|
+
def execute(
|
|
202
|
+
self,
|
|
203
|
+
operation: str,
|
|
204
|
+
parameters: Optional[Union[Dict[str, Any], Sequence[Any]]] = None,
|
|
205
|
+
) -> "Cursor":
|
|
206
|
+
"""Execute a SQL statement.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
operation: SQL statement to execute
|
|
210
|
+
parameters: Optional parameters for the statement
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Self for method chaining
|
|
214
|
+
"""
|
|
215
|
+
self._check_closed()
|
|
216
|
+
self._rows = []
|
|
217
|
+
self._row_index = 0
|
|
218
|
+
self._description = None
|
|
219
|
+
self._rowcount = -1
|
|
220
|
+
|
|
221
|
+
# Convert sequence parameters to dict if needed
|
|
222
|
+
params_dict: Optional[Dict[str, Any]] = None
|
|
223
|
+
if parameters is not None:
|
|
224
|
+
if isinstance(parameters, dict):
|
|
225
|
+
params_dict = parameters
|
|
226
|
+
else:
|
|
227
|
+
# Convert positional to named parameters
|
|
228
|
+
params_dict = {f"p{i}": v for i, v in enumerate(parameters)}
|
|
229
|
+
# Replace ? placeholders with :p0, :p1, etc.
|
|
230
|
+
for i in range(len(parameters)):
|
|
231
|
+
operation = operation.replace("?", f":p{i}", 1)
|
|
232
|
+
|
|
233
|
+
# Submit the statement
|
|
234
|
+
response = self._connection._submit_statement(operation, params_dict)
|
|
235
|
+
self._statement_handle = response.get("execution_id")
|
|
236
|
+
|
|
237
|
+
if not self._statement_handle:
|
|
238
|
+
raise DatabaseError("No statement handle returned from server")
|
|
239
|
+
|
|
240
|
+
# Poll for completion
|
|
241
|
+
self._poll_for_results()
|
|
242
|
+
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def _poll_for_results(self) -> None:
|
|
246
|
+
"""Poll the server until statement execution completes."""
|
|
247
|
+
if not self._statement_handle:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
max_wait = 300 # Maximum wait time in seconds
|
|
251
|
+
poll_interval = 0.5
|
|
252
|
+
elapsed = 0.0
|
|
253
|
+
|
|
254
|
+
while elapsed < max_wait:
|
|
255
|
+
status = self._connection._get_statement_status(self._statement_handle)
|
|
256
|
+
raw_state = status.get("status")
|
|
257
|
+
|
|
258
|
+
if isinstance(raw_state, dict):
|
|
259
|
+
state_value = raw_state.get("state")
|
|
260
|
+
status_details = raw_state
|
|
261
|
+
else:
|
|
262
|
+
state_value = raw_state
|
|
263
|
+
status_details = {}
|
|
264
|
+
|
|
265
|
+
if not state_value:
|
|
266
|
+
state_value = status.get("state")
|
|
267
|
+
|
|
268
|
+
normalized_state = (state_value or "UNKNOWN").upper()
|
|
269
|
+
|
|
270
|
+
if normalized_state in ("COMPLETED", "SUCCEEDED", "INCHOATE"):
|
|
271
|
+
self._fetch_results()
|
|
272
|
+
return
|
|
273
|
+
if normalized_state in ("FAILED", "CANCELLED"):
|
|
274
|
+
description = (
|
|
275
|
+
status_details.get("description")
|
|
276
|
+
or status.get("description")
|
|
277
|
+
or status.get("detail")
|
|
278
|
+
or "Unknown error"
|
|
279
|
+
)
|
|
280
|
+
raise DatabaseError(f"Statement execution failed: {description}")
|
|
281
|
+
if normalized_state in ("UNKNOWN", "SUBMITTED", "EXECUTING", "RUNNING"):
|
|
282
|
+
time.sleep(poll_interval)
|
|
283
|
+
elapsed += poll_interval
|
|
284
|
+
poll_interval = min(poll_interval * 1.5, 2.5)
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
raise DatabaseError(f"Unknown statement state: {state_value}")
|
|
288
|
+
|
|
289
|
+
raise OperationalError("Statement execution timed out")
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _rows_from_columnar_data(column_data: Sequence[Dict[str, Any]]) -> List[Tuple[Any, ...]]:
|
|
293
|
+
"""Convert column-oriented payloads into row tuples."""
|
|
294
|
+
column_values: List[List[Any]] = []
|
|
295
|
+
for column in column_data:
|
|
296
|
+
if not isinstance(column, dict):
|
|
297
|
+
continue
|
|
298
|
+
values = column.get("values") or []
|
|
299
|
+
column_values.append(list(values))
|
|
300
|
+
if not column_values:
|
|
301
|
+
return []
|
|
302
|
+
max_rows = max((len(values) for values in column_values), default=0)
|
|
303
|
+
return [
|
|
304
|
+
tuple(
|
|
305
|
+
column_values[col_index][row_index]
|
|
306
|
+
if row_index < len(column_values[col_index])
|
|
307
|
+
else None
|
|
308
|
+
for col_index in range(len(column_values))
|
|
309
|
+
)
|
|
310
|
+
for row_index in range(max_rows)
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
def _fetch_results(self) -> None:
|
|
314
|
+
"""Fetch results from a completed statement."""
|
|
315
|
+
if not self._statement_handle:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
page_size = max(self._opteryx_max_row_buffer or 10, self._arraysize)
|
|
319
|
+
offset = 0
|
|
320
|
+
has_description = False
|
|
321
|
+
rows: List[Tuple[Any, ...]] = []
|
|
322
|
+
total_rows: Optional[int] = None
|
|
323
|
+
|
|
324
|
+
def process_result_page(result: Dict[str, Any]) -> int:
|
|
325
|
+
nonlocal has_description, total_rows
|
|
326
|
+
new_rows = 0
|
|
327
|
+
if total_rows is None and "total_rows" in result:
|
|
328
|
+
try:
|
|
329
|
+
total_rows = int(result.get("total_rows", 0))
|
|
330
|
+
except (TypeError, ValueError):
|
|
331
|
+
total_rows = None
|
|
332
|
+
|
|
333
|
+
columns_meta = result.get("columns", [])
|
|
334
|
+
if columns_meta and not has_description:
|
|
335
|
+
self._description = [
|
|
336
|
+
(col.get("name", f"col{i}"), None, None, None, None, None, None)
|
|
337
|
+
for i, col in enumerate(columns_meta)
|
|
338
|
+
]
|
|
339
|
+
has_description = True
|
|
340
|
+
|
|
341
|
+
data = result.get("data", [])
|
|
342
|
+
if data:
|
|
343
|
+
if isinstance(data[0], dict) and "values" in data[0]:
|
|
344
|
+
if not has_description:
|
|
345
|
+
self._description = [
|
|
346
|
+
(col.get("name", f"col{i}"), None, None, None, None, None, None)
|
|
347
|
+
for i, col in enumerate(data)
|
|
348
|
+
]
|
|
349
|
+
has_description = True
|
|
350
|
+
column_rows = self._rows_from_columnar_data(data)
|
|
351
|
+
rows.extend(column_rows)
|
|
352
|
+
new_rows = len(column_rows)
|
|
353
|
+
else:
|
|
354
|
+
for row in data:
|
|
355
|
+
rows.append(tuple(row))
|
|
356
|
+
new_rows = len(data)
|
|
357
|
+
|
|
358
|
+
return new_rows
|
|
359
|
+
|
|
360
|
+
status_result = self._connection._get_statement_status(self._statement_handle) # pylint: disable=protected-access
|
|
361
|
+
process_result_page(status_result)
|
|
362
|
+
offset = len(rows)
|
|
363
|
+
|
|
364
|
+
while True:
|
|
365
|
+
if total_rows is not None and offset >= total_rows:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
result = self._connection._get_statement_results( # pylint: disable=protected-access
|
|
369
|
+
self._statement_handle, num_rows=page_size, offset=offset
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
fetched_this_page = process_result_page(result)
|
|
373
|
+
if fetched_this_page <= 0:
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
offset += fetched_this_page
|
|
377
|
+
if fetched_this_page < page_size:
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
# Finalize rows and counts
|
|
381
|
+
self._rows = rows
|
|
382
|
+
self._rowcount = len(self._rows)
|
|
383
|
+
|
|
384
|
+
def executemany(
|
|
385
|
+
self,
|
|
386
|
+
operation: str,
|
|
387
|
+
seq_of_parameters: Sequence[Union[Dict[str, Any], Sequence[Any]]],
|
|
388
|
+
) -> "Cursor":
|
|
389
|
+
"""Execute a SQL statement multiple times with different parameters."""
|
|
390
|
+
self._check_closed()
|
|
391
|
+
for parameters in seq_of_parameters:
|
|
392
|
+
self.execute(operation, parameters)
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
def fetchone(self) -> Optional[Tuple[Any, ...]]:
|
|
396
|
+
"""Fetch the next row of a query result set."""
|
|
397
|
+
self._check_closed()
|
|
398
|
+
if self._row_index >= len(self._rows):
|
|
399
|
+
return None
|
|
400
|
+
row = self._rows[self._row_index]
|
|
401
|
+
self._row_index += 1
|
|
402
|
+
return row
|
|
403
|
+
|
|
404
|
+
def fetchmany(self, size: Optional[int] = None) -> List[Tuple[Any, ...]]:
|
|
405
|
+
"""Fetch the next set of rows."""
|
|
406
|
+
self._check_closed()
|
|
407
|
+
if size is None:
|
|
408
|
+
size = self._arraysize
|
|
409
|
+
rows = self._rows[self._row_index : self._row_index + size]
|
|
410
|
+
self._row_index += len(rows)
|
|
411
|
+
return rows
|
|
412
|
+
|
|
413
|
+
def fetchall(self) -> List[Tuple[Any, ...]]:
|
|
414
|
+
"""Fetch all remaining rows."""
|
|
415
|
+
self._check_closed()
|
|
416
|
+
rows = self._rows[self._row_index :]
|
|
417
|
+
self._row_index = len(self._rows)
|
|
418
|
+
return rows
|
|
419
|
+
|
|
420
|
+
def setinputsizes(self, sizes: Sequence[Any]) -> None:
|
|
421
|
+
"""Set input sizes (no-op, but required by PEP 249)."""
|
|
422
|
+
_ = sizes
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
def setoutputsize(self, size: int, column: Optional[int] = None) -> None:
|
|
426
|
+
"""Set output size (no-op, but required by PEP 249)."""
|
|
427
|
+
_ = size
|
|
428
|
+
_ = column
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
def __iter__(self) -> "Cursor":
|
|
432
|
+
"""Make cursor iterable."""
|
|
433
|
+
return self
|
|
434
|
+
|
|
435
|
+
def __next__(self) -> Tuple[Any, ...]:
|
|
436
|
+
"""Get next row."""
|
|
437
|
+
row = self.fetchone()
|
|
438
|
+
if row is None:
|
|
439
|
+
raise StopIteration
|
|
440
|
+
return row
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class Connection:
|
|
444
|
+
"""DBAPI 2.0 Connection implementation for Opteryx.
|
|
445
|
+
|
|
446
|
+
Manages HTTP connections to the Opteryx data service.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
host: str = "localhost",
|
|
452
|
+
port: int = 8000,
|
|
453
|
+
username: Optional[str] = None,
|
|
454
|
+
token: Optional[str] = None,
|
|
455
|
+
database: Optional[str] = None,
|
|
456
|
+
ssl: bool = False,
|
|
457
|
+
timeout: float = 30.0,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Initialize connection to Opteryx data service.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
host: Hostname of the Opteryx data service
|
|
463
|
+
port: Port number
|
|
464
|
+
username: Username for authentication (optional)
|
|
465
|
+
token: Bearer token for authentication
|
|
466
|
+
database: Database/schema name (optional)
|
|
467
|
+
ssl: Whether to use HTTPS
|
|
468
|
+
timeout: Request timeout in seconds
|
|
469
|
+
"""
|
|
470
|
+
self._host = host
|
|
471
|
+
self._port = port
|
|
472
|
+
self._username = username
|
|
473
|
+
self._token = token
|
|
474
|
+
self._database = database
|
|
475
|
+
self._ssl = ssl
|
|
476
|
+
self._timeout = timeout
|
|
477
|
+
self._closed = False
|
|
478
|
+
|
|
479
|
+
# Build base URL
|
|
480
|
+
scheme = "https" if ssl else "http"
|
|
481
|
+
if (ssl and port == 443) or (not ssl and port == 80):
|
|
482
|
+
self._base_url = f"{scheme}://{host}"
|
|
483
|
+
else:
|
|
484
|
+
self._base_url = f"{scheme}://{host}:{port}"
|
|
485
|
+
|
|
486
|
+
# Create session for connection pooling
|
|
487
|
+
self._session = requests.Session()
|
|
488
|
+
if token:
|
|
489
|
+
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
490
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
491
|
+
|
|
492
|
+
def _normalize_domain(self, host: str) -> str:
|
|
493
|
+
"""Return the base domain for the given host by stripping known subdomain prefixes.
|
|
494
|
+
|
|
495
|
+
Examples:
|
|
496
|
+
'data.opteryx.app' -> 'opteryx.app'
|
|
497
|
+
'auth.opteryx.app' -> 'opteryx.app'
|
|
498
|
+
'opteryx.app' -> 'opteryx.app'
|
|
499
|
+
'localhost' -> 'localhost'
|
|
500
|
+
"""
|
|
501
|
+
domain = host
|
|
502
|
+
for p in ("data.", "auth."):
|
|
503
|
+
if domain.startswith(p):
|
|
504
|
+
domain = domain[len(p) :]
|
|
505
|
+
return domain
|
|
506
|
+
|
|
507
|
+
def _data_base_url(self) -> str:
|
|
508
|
+
"""Construct a base URL that targets the 'data' subdomain for API requests."""
|
|
509
|
+
scheme = "https" if self._ssl else "http"
|
|
510
|
+
domain = self._normalize_domain(self._host)
|
|
511
|
+
# Only add subdomain prefix for DNS-style hosts (e.g. example.com), not for localhost or IPs
|
|
512
|
+
if "." in domain and not domain.startswith("localhost"):
|
|
513
|
+
data_host = f"data.{domain}"
|
|
514
|
+
else:
|
|
515
|
+
data_host = domain
|
|
516
|
+
if (self._ssl and self._port == 443) or (not self._ssl and self._port == 80):
|
|
517
|
+
return f"{scheme}://{data_host}"
|
|
518
|
+
return f"{scheme}://{data_host}:{self._port}"
|
|
519
|
+
|
|
520
|
+
def _check_closed(self) -> None:
|
|
521
|
+
"""Raise exception if connection is closed."""
|
|
522
|
+
if self._closed:
|
|
523
|
+
raise ProgrammingError("Connection is closed")
|
|
524
|
+
|
|
525
|
+
def _submit_statement(
|
|
526
|
+
self, sql: str, parameters: Optional[Dict[str, Any]] = None
|
|
527
|
+
) -> Dict[str, Any]:
|
|
528
|
+
"""Submit a SQL statement to the data service."""
|
|
529
|
+
self._check_closed()
|
|
530
|
+
|
|
531
|
+
url = urljoin(self._data_base_url() + "/", "api/v1/statements")
|
|
532
|
+
payload: Dict[str, Any] = {"sqlText": sql}
|
|
533
|
+
if parameters:
|
|
534
|
+
payload["parameters"] = parameters
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
response = self._session.post(url, json=payload, timeout=self._timeout)
|
|
538
|
+
response.raise_for_status()
|
|
539
|
+
return response.json()
|
|
540
|
+
except requests.exceptions.HTTPError as e:
|
|
541
|
+
if e.response is not None:
|
|
542
|
+
try:
|
|
543
|
+
detail = e.response.json().get("detail", str(e))
|
|
544
|
+
except (ValueError, json.JSONDecodeError):
|
|
545
|
+
detail = e.response.text or str(e)
|
|
546
|
+
raise DatabaseError(f"HTTP error: {detail}") from e
|
|
547
|
+
raise DatabaseError(f"HTTP error: {e}") from e
|
|
548
|
+
except requests.exceptions.RequestException as e:
|
|
549
|
+
raise OperationalError(f"Connection error: {e}") from e
|
|
550
|
+
|
|
551
|
+
def _get_statement_status(self, statement_handle: str) -> Dict[str, Any]:
|
|
552
|
+
"""Get the status of a submitted statement."""
|
|
553
|
+
self._check_closed()
|
|
554
|
+
|
|
555
|
+
url = urljoin(self._data_base_url() + "/", f"api/v1/statements/{statement_handle}/status")
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
response = self._session.get(url, timeout=self._timeout)
|
|
559
|
+
response.raise_for_status()
|
|
560
|
+
return response.json()
|
|
561
|
+
except requests.exceptions.HTTPError as e:
|
|
562
|
+
if e.response is not None and e.response.status_code == 404:
|
|
563
|
+
raise ProgrammingError("Statement not found") from e
|
|
564
|
+
raise DatabaseError(f"HTTP error: {e}") from e
|
|
565
|
+
except requests.exceptions.RequestException as e:
|
|
566
|
+
raise OperationalError(f"Connection error: {e}") from e
|
|
567
|
+
|
|
568
|
+
def _get_statement_results(
|
|
569
|
+
self, statement_handle: str, num_rows: Optional[int] = None, offset: Optional[int] = None
|
|
570
|
+
) -> Dict[str, Any]:
|
|
571
|
+
"""Get results for a completed statement.
|
|
572
|
+
|
|
573
|
+
Note: The current API may return results as part of status response
|
|
574
|
+
or via a separate results endpoint. This method handles both cases.
|
|
575
|
+
"""
|
|
576
|
+
url = urljoin(self._data_base_url() + "/", f"api/v1/statements/{statement_handle}/results")
|
|
577
|
+
params: Dict[str, Any] = {}
|
|
578
|
+
if num_rows is not None:
|
|
579
|
+
params["num_rows"] = int(num_rows)
|
|
580
|
+
if offset is not None:
|
|
581
|
+
params["offset"] = int(offset)
|
|
582
|
+
try:
|
|
583
|
+
response = self._session.get(url, params=params or None, timeout=self._timeout)
|
|
584
|
+
response.raise_for_status()
|
|
585
|
+
return response.json()
|
|
586
|
+
except requests.exceptions.RequestException:
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
# Fallback to status endpoint if dedicated results are unavailable
|
|
590
|
+
return self._get_statement_status(statement_handle)
|
|
591
|
+
|
|
592
|
+
def close(self) -> None:
|
|
593
|
+
"""Close the connection."""
|
|
594
|
+
if not self._closed:
|
|
595
|
+
self._session.close()
|
|
596
|
+
self._closed = True
|
|
597
|
+
|
|
598
|
+
def commit(self) -> None:
|
|
599
|
+
"""Commit transaction (no-op for Opteryx as it's read-only)."""
|
|
600
|
+
self._check_closed()
|
|
601
|
+
|
|
602
|
+
def rollback(self) -> None:
|
|
603
|
+
"""Rollback transaction (no-op for Opteryx as it's read-only)."""
|
|
604
|
+
self._check_closed()
|
|
605
|
+
|
|
606
|
+
def cursor(self) -> Cursor:
|
|
607
|
+
"""Create a new cursor object."""
|
|
608
|
+
self._check_closed()
|
|
609
|
+
return Cursor(self)
|
|
610
|
+
|
|
611
|
+
def __enter__(self) -> "Connection":
|
|
612
|
+
"""Context manager entry."""
|
|
613
|
+
return self
|
|
614
|
+
|
|
615
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
616
|
+
"""Context manager exit."""
|
|
617
|
+
self.close()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def connect(
|
|
621
|
+
host: str = "localhost",
|
|
622
|
+
port: int = 8000,
|
|
623
|
+
username: Optional[str] = None,
|
|
624
|
+
token: Optional[str] = None,
|
|
625
|
+
database: Optional[str] = None,
|
|
626
|
+
ssl: bool = False,
|
|
627
|
+
timeout: float = 30.0,
|
|
628
|
+
) -> Connection:
|
|
629
|
+
"""Create a new connection to the Opteryx data service.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
host: Hostname of the Opteryx data service
|
|
633
|
+
port: Port number
|
|
634
|
+
username: Username for authentication (optional)
|
|
635
|
+
token: Bearer token for authentication
|
|
636
|
+
database: Database/schema name (optional)
|
|
637
|
+
ssl: Whether to use HTTPS
|
|
638
|
+
timeout: Request timeout in seconds
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
A new Connection object
|
|
642
|
+
"""
|
|
643
|
+
return Connection(
|
|
644
|
+
host=host,
|
|
645
|
+
port=port,
|
|
646
|
+
username=username,
|
|
647
|
+
token=token,
|
|
648
|
+
database=database,
|
|
649
|
+
ssl=ssl,
|
|
650
|
+
timeout=timeout,
|
|
651
|
+
)
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""SQLAlchemy dialect for Opteryx data service.
|
|
2
|
+
|
|
3
|
+
This module provides a SQLAlchemy dialect implementation that enables
|
|
4
|
+
connecting to the Opteryx data service using SQLAlchemy's engine and
|
|
5
|
+
ORM capabilities.
|
|
6
|
+
|
|
7
|
+
Connection URL format:
|
|
8
|
+
opteryx://[username:token@]host[:port]/[database][?ssl=true]
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
opteryx://data.opteryx.app/default
|
|
12
|
+
opteryx://user:mytoken@data.opteryx.app:443/default?ssl=true
|
|
13
|
+
opteryx://localhost:8000/default
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
from typing import Optional
|
|
21
|
+
from typing import Tuple
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import types as sqltypes
|
|
24
|
+
from sqlalchemy.engine import default
|
|
25
|
+
from sqlalchemy.engine.interfaces import ExecutionContext
|
|
26
|
+
from sqlalchemy.engine.url import URL
|
|
27
|
+
|
|
28
|
+
from . import dbapi
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _quote_identifier(identifier: str) -> str:
|
|
32
|
+
"""Safely quote a SQL identifier to prevent SQL injection.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
identifier: The identifier (table name, column name, etc.) to quote
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A safely quoted identifier string
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If the identifier contains invalid characters
|
|
42
|
+
"""
|
|
43
|
+
# Validate identifier format - alphanumeric, underscores, and dots only
|
|
44
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", identifier):
|
|
45
|
+
raise ValueError(f"Invalid identifier: {identifier}")
|
|
46
|
+
# Return double-quoted identifier (standard SQL quoting)
|
|
47
|
+
return f'"{identifier}"'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OptetyxDialect(default.DefaultDialect):
|
|
51
|
+
"""SQLAlchemy dialect for Opteryx data service.
|
|
52
|
+
|
|
53
|
+
This dialect communicates with the Opteryx data service via HTTP,
|
|
54
|
+
translating SQLAlchemy operations into API calls.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name = "opteryx"
|
|
58
|
+
driver = "http"
|
|
59
|
+
|
|
60
|
+
# Capabilities
|
|
61
|
+
supports_alter = False
|
|
62
|
+
supports_pk_autoincrement = False
|
|
63
|
+
supports_default_values = False
|
|
64
|
+
supports_empty_insert = False
|
|
65
|
+
supports_sequences = False
|
|
66
|
+
sequences_optional = True
|
|
67
|
+
supports_native_boolean = True
|
|
68
|
+
supports_native_decimal = True
|
|
69
|
+
supports_statement_cache = False
|
|
70
|
+
postfetch_lastrowid = False
|
|
71
|
+
|
|
72
|
+
# Opteryx is read-only (analytics engine)
|
|
73
|
+
supports_sane_rowcount = False
|
|
74
|
+
supports_sane_multi_rowcount = False
|
|
75
|
+
|
|
76
|
+
# Default SQL features
|
|
77
|
+
default_paramstyle = "named"
|
|
78
|
+
supports_native_enum = False
|
|
79
|
+
supports_simple_order_by_label = True
|
|
80
|
+
supports_comments = False
|
|
81
|
+
inline_comments = False
|
|
82
|
+
|
|
83
|
+
# Required for SQLAlchemy
|
|
84
|
+
preexecute_autoincrement_sequences = False
|
|
85
|
+
implicit_returning = False
|
|
86
|
+
full_returning = False
|
|
87
|
+
|
|
88
|
+
# Type mapping
|
|
89
|
+
colspecs = {}
|
|
90
|
+
ischema_names = {
|
|
91
|
+
"VARCHAR": sqltypes.String,
|
|
92
|
+
"STRING": sqltypes.String,
|
|
93
|
+
"TEXT": sqltypes.Text,
|
|
94
|
+
"INTEGER": sqltypes.Integer,
|
|
95
|
+
"INT": sqltypes.Integer,
|
|
96
|
+
"BIGINT": sqltypes.BigInteger,
|
|
97
|
+
"SMALLINT": sqltypes.SmallInteger,
|
|
98
|
+
"FLOAT": sqltypes.Float,
|
|
99
|
+
"DOUBLE": sqltypes.Float,
|
|
100
|
+
"REAL": sqltypes.Float,
|
|
101
|
+
"DECIMAL": sqltypes.Numeric,
|
|
102
|
+
"NUMERIC": sqltypes.Numeric,
|
|
103
|
+
"BOOLEAN": sqltypes.Boolean,
|
|
104
|
+
"BOOL": sqltypes.Boolean,
|
|
105
|
+
"DATE": sqltypes.Date,
|
|
106
|
+
"TIME": sqltypes.Time,
|
|
107
|
+
"TIMESTAMP": sqltypes.DateTime,
|
|
108
|
+
"DATETIME": sqltypes.DateTime,
|
|
109
|
+
"BLOB": sqltypes.LargeBinary,
|
|
110
|
+
"BINARY": sqltypes.LargeBinary,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def dbapi(cls) -> Any:
|
|
115
|
+
"""Return the DBAPI module."""
|
|
116
|
+
return dbapi
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def import_dbapi(cls) -> Any:
|
|
120
|
+
"""Import and return the DBAPI module."""
|
|
121
|
+
return dbapi
|
|
122
|
+
|
|
123
|
+
def create_connect_args(self, url: URL) -> Tuple[list, dict]:
|
|
124
|
+
"""Create connection arguments from SQLAlchemy URL.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
url: SQLAlchemy URL object
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple of (positional args, keyword args) for dbapi.connect()
|
|
131
|
+
"""
|
|
132
|
+
opts = {}
|
|
133
|
+
|
|
134
|
+
# Host
|
|
135
|
+
if url.host:
|
|
136
|
+
opts["host"] = url.host
|
|
137
|
+
|
|
138
|
+
# Port
|
|
139
|
+
if url.port:
|
|
140
|
+
opts["port"] = url.port
|
|
141
|
+
else:
|
|
142
|
+
# Default ports based on SSL setting
|
|
143
|
+
query = dict(url.query) if url.query else {}
|
|
144
|
+
ssl = query.get("ssl", "").lower() in ("true", "1", "yes")
|
|
145
|
+
opts["port"] = 443 if ssl else 8000
|
|
146
|
+
|
|
147
|
+
# Username and token (password field used for token)
|
|
148
|
+
if url.username:
|
|
149
|
+
opts["username"] = url.username
|
|
150
|
+
if url.password:
|
|
151
|
+
opts["token"] = url.password
|
|
152
|
+
|
|
153
|
+
# Database
|
|
154
|
+
if url.database:
|
|
155
|
+
opts["database"] = url.database
|
|
156
|
+
|
|
157
|
+
# Query parameters
|
|
158
|
+
if url.query:
|
|
159
|
+
query = dict(url.query)
|
|
160
|
+
if "ssl" in query:
|
|
161
|
+
opts["ssl"] = query["ssl"].lower() in ("true", "1", "yes")
|
|
162
|
+
if "timeout" in query:
|
|
163
|
+
try:
|
|
164
|
+
opts["timeout"] = float(query["timeout"])
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
return ([], opts)
|
|
169
|
+
|
|
170
|
+
def do_execute(
|
|
171
|
+
self,
|
|
172
|
+
cursor: Any,
|
|
173
|
+
statement: str,
|
|
174
|
+
parameters: Optional[Any],
|
|
175
|
+
context: Optional[ExecutionContext] = None,
|
|
176
|
+
) -> Any:
|
|
177
|
+
"""Propagate execution options so downstream code can react to them."""
|
|
178
|
+
execution_options = getattr(context, "execution_options", {}) if context is not None else {}
|
|
179
|
+
streaming_requested = bool(execution_options.get("stream_results"))
|
|
180
|
+
max_row_buffer = execution_options.get("max_row_buffer")
|
|
181
|
+
|
|
182
|
+
# Attach the parsed streaming hints to the DBAPI cursor for later use.
|
|
183
|
+
cursor._opteryx_execution_options = dict(execution_options)
|
|
184
|
+
cursor._opteryx_stream_results_requested = streaming_requested
|
|
185
|
+
cursor._opteryx_max_row_buffer = max_row_buffer
|
|
186
|
+
|
|
187
|
+
return super().do_execute(cursor, statement, parameters, context=context)
|
|
188
|
+
|
|
189
|
+
def do_ping(self, dbapi_connection: Any) -> bool:
|
|
190
|
+
"""Check if the connection is still alive.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
dbapi_connection: The DBAPI connection object
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if the connection is alive, False otherwise
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
cursor = dbapi_connection.cursor()
|
|
200
|
+
cursor.execute("SELECT 1")
|
|
201
|
+
cursor.fetchone()
|
|
202
|
+
cursor.close()
|
|
203
|
+
return True
|
|
204
|
+
except Exception:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
def get_isolation_level(self, dbapi_connection: Any) -> str:
|
|
208
|
+
"""Return the isolation level.
|
|
209
|
+
|
|
210
|
+
Opteryx doesn't support transactions, so we return a nominal value.
|
|
211
|
+
"""
|
|
212
|
+
return "AUTOCOMMIT"
|
|
213
|
+
|
|
214
|
+
def has_table(
|
|
215
|
+
self, connection: Any, table_name: str, schema: Optional[str] = None, **kw: Any
|
|
216
|
+
) -> bool:
|
|
217
|
+
"""Check if a table exists.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
connection: SQLAlchemy connection
|
|
221
|
+
table_name: Name of the table
|
|
222
|
+
schema: Optional schema name
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if the table exists
|
|
226
|
+
"""
|
|
227
|
+
# Try to query the table with a limit of 0 to check existence
|
|
228
|
+
try:
|
|
229
|
+
# Safely quote identifiers to prevent SQL injection
|
|
230
|
+
quoted_table = _quote_identifier(table_name)
|
|
231
|
+
if schema:
|
|
232
|
+
quoted_schema = _quote_identifier(schema)
|
|
233
|
+
full_name = f"{quoted_schema}.{quoted_table}"
|
|
234
|
+
else:
|
|
235
|
+
full_name = quoted_table
|
|
236
|
+
result = connection.execute(f"SELECT * FROM {full_name} LIMIT 0")
|
|
237
|
+
result.close()
|
|
238
|
+
return True
|
|
239
|
+
except (ValueError, Exception):
|
|
240
|
+
# ValueError from invalid identifier, or database error
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def get_columns(
|
|
244
|
+
self,
|
|
245
|
+
connection: Any,
|
|
246
|
+
table_name: str,
|
|
247
|
+
schema: Optional[str] = None,
|
|
248
|
+
**kw: Any,
|
|
249
|
+
) -> list:
|
|
250
|
+
"""Get column information for a table.
|
|
251
|
+
|
|
252
|
+
Returns an empty list as Opteryx may not support full introspection.
|
|
253
|
+
"""
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
def get_pk_constraint(
|
|
257
|
+
self,
|
|
258
|
+
connection: Any,
|
|
259
|
+
table_name: str,
|
|
260
|
+
schema: Optional[str] = None,
|
|
261
|
+
**kw: Any,
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""Get primary key constraint.
|
|
264
|
+
|
|
265
|
+
Opteryx doesn't have primary keys in the traditional sense.
|
|
266
|
+
"""
|
|
267
|
+
return {"constrained_columns": [], "name": None}
|
|
268
|
+
|
|
269
|
+
def get_foreign_keys(
|
|
270
|
+
self,
|
|
271
|
+
connection: Any,
|
|
272
|
+
table_name: str,
|
|
273
|
+
schema: Optional[str] = None,
|
|
274
|
+
**kw: Any,
|
|
275
|
+
) -> list:
|
|
276
|
+
"""Get foreign key information.
|
|
277
|
+
|
|
278
|
+
Opteryx doesn't support foreign keys.
|
|
279
|
+
"""
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
def get_indexes(
|
|
283
|
+
self,
|
|
284
|
+
connection: Any,
|
|
285
|
+
table_name: str,
|
|
286
|
+
schema: Optional[str] = None,
|
|
287
|
+
**kw: Any,
|
|
288
|
+
) -> list:
|
|
289
|
+
"""Get index information.
|
|
290
|
+
|
|
291
|
+
Opteryx doesn't expose index information.
|
|
292
|
+
"""
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
def get_table_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
|
|
296
|
+
"""Get list of table names.
|
|
297
|
+
|
|
298
|
+
Attempts to query Opteryx for available tables.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
result = connection.execute("SHOW TABLES")
|
|
302
|
+
tables = [row[0] for row in result.fetchall()]
|
|
303
|
+
result.close()
|
|
304
|
+
return tables
|
|
305
|
+
except Exception:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
def get_view_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
|
|
309
|
+
"""Get list of view names.
|
|
310
|
+
|
|
311
|
+
Opteryx may not distinguish between tables and views.
|
|
312
|
+
"""
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
def get_schema_names(self, connection: Any, **kw: Any) -> list:
|
|
316
|
+
"""Get list of schema names."""
|
|
317
|
+
try:
|
|
318
|
+
result = connection.execute("SHOW SCHEMAS")
|
|
319
|
+
schemas = [row[0] for row in result.fetchall()]
|
|
320
|
+
result.close()
|
|
321
|
+
return schemas
|
|
322
|
+
except Exception:
|
|
323
|
+
return ["default"]
|
|
324
|
+
|
|
325
|
+
def _get_server_version_info(self, connection: Any) -> Tuple[int, ...]:
|
|
326
|
+
"""Get server version information."""
|
|
327
|
+
return (0, 26, 1) # Match Opteryx version in pyproject.toml
|
|
328
|
+
|
|
329
|
+
def _check_unicode_returns(
|
|
330
|
+
self, connection: Any, additional_tests: Optional[list] = None
|
|
331
|
+
) -> bool:
|
|
332
|
+
"""Check if the connection returns unicode strings."""
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def _check_unicode_description(self, connection: Any) -> bool:
|
|
336
|
+
"""Check if column descriptions are unicode."""
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Register the dialect
|
|
341
|
+
def register_dialect() -> None:
|
|
342
|
+
"""Register the Opteryx dialect with SQLAlchemy."""
|
|
343
|
+
from sqlalchemy.dialects import registry
|
|
344
|
+
|
|
345
|
+
registry.register("opteryx", "app.sqlalchemy_dialect.dialect", "OptetyxDialect")
|
|
346
|
+
registry.register("opteryx.http", "app.sqlalchemy_dialect.dialect", "OptetyxDialect")
|