opteryx-sqlalchemy 0.0.5__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.5.dist-info/METADATA +13 -0
- opteryx_sqlalchemy-0.0.5.dist-info/RECORD +9 -0
- opteryx_sqlalchemy-0.0.5.dist-info/WHEEL +5 -0
- opteryx_sqlalchemy-0.0.5.dist-info/entry_points.txt +2 -0
- opteryx_sqlalchemy-0.0.5.dist-info/licenses/LICENSE +201 -0
- opteryx_sqlalchemy-0.0.5.dist-info/top_level.txt +1 -0
- sqlalchemy_dialect/__init__.py +19 -0
- sqlalchemy_dialect/dbapi.py +825 -0
- sqlalchemy_dialect/dialect.py +370 -0
|
@@ -0,0 +1,370 @@
|
|
|
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://jobs.opteryx.app/default
|
|
12
|
+
opteryx://user:mytoken@jobs.opteryx.app:443/default?ssl=true
|
|
13
|
+
opteryx://localhost:8000/default
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from typing import Tuple
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import types as sqltypes
|
|
25
|
+
from sqlalchemy.engine import default
|
|
26
|
+
from sqlalchemy.engine.interfaces import ExecutionContext
|
|
27
|
+
from sqlalchemy.engine.url import URL
|
|
28
|
+
|
|
29
|
+
from . import dbapi
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("sqlalchemy.dialects.opteryx")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quote_identifier(identifier: str) -> str:
|
|
35
|
+
"""Safely quote a SQL identifier to prevent SQL injection.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
identifier: The identifier (table name, column name, etc.) to quote
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A safely quoted identifier string
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If the identifier contains invalid characters
|
|
45
|
+
"""
|
|
46
|
+
# Validate identifier format - alphanumeric, underscores, and dots only
|
|
47
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", identifier):
|
|
48
|
+
raise ValueError(f"Invalid identifier: {identifier}")
|
|
49
|
+
# Return double-quoted identifier (standard SQL quoting)
|
|
50
|
+
return f'"{identifier}"'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OptetyxDialect(default.DefaultDialect):
|
|
54
|
+
"""SQLAlchemy dialect for Opteryx data service.
|
|
55
|
+
|
|
56
|
+
This dialect communicates with the Opteryx data service via HTTP,
|
|
57
|
+
translating SQLAlchemy operations into API calls.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name = "opteryx"
|
|
61
|
+
driver = "http"
|
|
62
|
+
|
|
63
|
+
# Capabilities
|
|
64
|
+
supports_alter = False
|
|
65
|
+
supports_pk_autoincrement = False
|
|
66
|
+
supports_default_values = False
|
|
67
|
+
supports_empty_insert = False
|
|
68
|
+
supports_sequences = False
|
|
69
|
+
sequences_optional = True
|
|
70
|
+
supports_native_boolean = True
|
|
71
|
+
supports_native_decimal = True
|
|
72
|
+
supports_statement_cache = False
|
|
73
|
+
postfetch_lastrowid = False
|
|
74
|
+
|
|
75
|
+
# Opteryx is read-only (analytics engine)
|
|
76
|
+
supports_sane_rowcount = False
|
|
77
|
+
supports_sane_multi_rowcount = False
|
|
78
|
+
|
|
79
|
+
# Default SQL features
|
|
80
|
+
default_paramstyle = "named"
|
|
81
|
+
supports_native_enum = False
|
|
82
|
+
supports_simple_order_by_label = True
|
|
83
|
+
supports_comments = False
|
|
84
|
+
inline_comments = False
|
|
85
|
+
|
|
86
|
+
# Required for SQLAlchemy
|
|
87
|
+
preexecute_autoincrement_sequences = False
|
|
88
|
+
implicit_returning = False
|
|
89
|
+
full_returning = False
|
|
90
|
+
|
|
91
|
+
# Type mapping
|
|
92
|
+
colspecs = {}
|
|
93
|
+
ischema_names = {
|
|
94
|
+
"VARCHAR": sqltypes.String,
|
|
95
|
+
"STRING": sqltypes.String,
|
|
96
|
+
"TEXT": sqltypes.Text,
|
|
97
|
+
"INTEGER": sqltypes.Integer,
|
|
98
|
+
"INT": sqltypes.Integer,
|
|
99
|
+
"BIGINT": sqltypes.BigInteger,
|
|
100
|
+
"SMALLINT": sqltypes.SmallInteger,
|
|
101
|
+
"FLOAT": sqltypes.Float,
|
|
102
|
+
"DOUBLE": sqltypes.Float,
|
|
103
|
+
"REAL": sqltypes.Float,
|
|
104
|
+
"DECIMAL": sqltypes.Numeric,
|
|
105
|
+
"NUMERIC": sqltypes.Numeric,
|
|
106
|
+
"BOOLEAN": sqltypes.Boolean,
|
|
107
|
+
"BOOL": sqltypes.Boolean,
|
|
108
|
+
"DATE": sqltypes.Date,
|
|
109
|
+
"TIME": sqltypes.Time,
|
|
110
|
+
"TIMESTAMP": sqltypes.DateTime,
|
|
111
|
+
"DATETIME": sqltypes.DateTime,
|
|
112
|
+
"BLOB": sqltypes.LargeBinary,
|
|
113
|
+
"VARBINARY": sqltypes.LargeBinary,
|
|
114
|
+
"BINARY": sqltypes.LargeBinary,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def dbapi(cls) -> Any:
|
|
119
|
+
"""Return the DBAPI module."""
|
|
120
|
+
return dbapi
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def import_dbapi(cls) -> Any:
|
|
124
|
+
"""Import and return the DBAPI module."""
|
|
125
|
+
return dbapi
|
|
126
|
+
|
|
127
|
+
def create_connect_args(self, url: URL) -> Tuple[list, dict]:
|
|
128
|
+
"""Create connection arguments from SQLAlchemy URL.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
url: SQLAlchemy URL object
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Tuple of (positional args, keyword args) for dbapi.connect()
|
|
135
|
+
"""
|
|
136
|
+
opts = {}
|
|
137
|
+
|
|
138
|
+
# Host
|
|
139
|
+
if url.host:
|
|
140
|
+
opts["host"] = url.host
|
|
141
|
+
|
|
142
|
+
# Port
|
|
143
|
+
if url.port:
|
|
144
|
+
opts["port"] = url.port
|
|
145
|
+
else:
|
|
146
|
+
# Default ports based on SSL setting
|
|
147
|
+
query = dict(url.query) if url.query else {}
|
|
148
|
+
ssl = query.get("ssl", "").lower() in ("true", "1", "yes")
|
|
149
|
+
opts["port"] = 443 if ssl else 8000
|
|
150
|
+
|
|
151
|
+
# Username and token (password field used for token)
|
|
152
|
+
if url.username:
|
|
153
|
+
opts["username"] = url.username
|
|
154
|
+
if url.password:
|
|
155
|
+
opts["token"] = url.password
|
|
156
|
+
|
|
157
|
+
# Database
|
|
158
|
+
if url.database:
|
|
159
|
+
opts["database"] = url.database
|
|
160
|
+
|
|
161
|
+
# Query parameters
|
|
162
|
+
if url.query:
|
|
163
|
+
query = dict(url.query)
|
|
164
|
+
if "ssl" in query:
|
|
165
|
+
opts["ssl"] = query["ssl"].lower() in ("true", "1", "yes")
|
|
166
|
+
if "timeout" in query:
|
|
167
|
+
try:
|
|
168
|
+
opts["timeout"] = float(query["timeout"])
|
|
169
|
+
except ValueError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
return ([], opts)
|
|
173
|
+
|
|
174
|
+
def do_execute(
|
|
175
|
+
self,
|
|
176
|
+
cursor: Any,
|
|
177
|
+
statement: str,
|
|
178
|
+
parameters: Optional[Any],
|
|
179
|
+
context: Optional[ExecutionContext] = None,
|
|
180
|
+
) -> Any:
|
|
181
|
+
"""Propagate execution options so downstream code can react to them."""
|
|
182
|
+
execution_options = getattr(context, "execution_options", {}) if context is not None else {}
|
|
183
|
+
streaming_requested = bool(execution_options.get("stream_results"))
|
|
184
|
+
max_row_buffer = execution_options.get("max_row_buffer")
|
|
185
|
+
|
|
186
|
+
# Attach the parsed streaming hints to the DBAPI cursor for later use.
|
|
187
|
+
cursor._opteryx_execution_options = dict(execution_options)
|
|
188
|
+
cursor._opteryx_stream_results_requested = streaming_requested
|
|
189
|
+
cursor._opteryx_max_row_buffer = max_row_buffer
|
|
190
|
+
|
|
191
|
+
return super().do_execute(cursor, statement, parameters, context=context)
|
|
192
|
+
|
|
193
|
+
def do_ping(self, dbapi_connection: Any) -> bool:
|
|
194
|
+
"""Check if the connection is still alive.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
dbapi_connection: The DBAPI connection object
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if the connection is alive, False otherwise
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
cursor = dbapi_connection.cursor()
|
|
204
|
+
cursor.execute("SELECT 1")
|
|
205
|
+
cursor.fetchone()
|
|
206
|
+
cursor.close()
|
|
207
|
+
logger.debug("Connection ping successful")
|
|
208
|
+
return True
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.warning("Connection ping failed: %s", e)
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def get_isolation_level(self, dbapi_connection: Any) -> str:
|
|
214
|
+
"""Return the isolation level.
|
|
215
|
+
|
|
216
|
+
Opteryx doesn't support transactions, so we return a nominal value.
|
|
217
|
+
"""
|
|
218
|
+
return "AUTOCOMMIT"
|
|
219
|
+
|
|
220
|
+
def has_table(
|
|
221
|
+
self, connection: Any, table_name: str, schema: Optional[str] = None, **kw: Any
|
|
222
|
+
) -> bool:
|
|
223
|
+
"""Check if a table exists.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
connection: SQLAlchemy connection
|
|
227
|
+
table_name: Name of the table
|
|
228
|
+
schema: Optional schema name
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if the table exists
|
|
232
|
+
"""
|
|
233
|
+
# Try to query the table with a limit of 0 to check existence
|
|
234
|
+
try:
|
|
235
|
+
# Safely quote identifiers to prevent SQL injection
|
|
236
|
+
quoted_table = _quote_identifier(table_name)
|
|
237
|
+
if schema:
|
|
238
|
+
quoted_schema = _quote_identifier(schema)
|
|
239
|
+
full_name = f"{quoted_schema}.{quoted_table}"
|
|
240
|
+
else:
|
|
241
|
+
full_name = quoted_table
|
|
242
|
+
logger.debug("Checking if table exists: %s", full_name)
|
|
243
|
+
result = connection.execute(f"SELECT * FROM {full_name} LIMIT 0")
|
|
244
|
+
result.close()
|
|
245
|
+
logger.debug("Table exists: %s", full_name)
|
|
246
|
+
return True
|
|
247
|
+
except (ValueError, Exception) as e:
|
|
248
|
+
# ValueError from invalid identifier, or database error
|
|
249
|
+
logger.debug("Table does not exist or error checking: %s - %s", table_name, e)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def get_columns(
|
|
253
|
+
self,
|
|
254
|
+
connection: Any,
|
|
255
|
+
table_name: str,
|
|
256
|
+
schema: Optional[str] = None,
|
|
257
|
+
**kw: Any,
|
|
258
|
+
) -> list:
|
|
259
|
+
"""Get column information for a table.
|
|
260
|
+
|
|
261
|
+
Returns an empty list as Opteryx may not support full introspection.
|
|
262
|
+
"""
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
def get_pk_constraint(
|
|
266
|
+
self,
|
|
267
|
+
connection: Any,
|
|
268
|
+
table_name: str,
|
|
269
|
+
schema: Optional[str] = None,
|
|
270
|
+
**kw: Any,
|
|
271
|
+
) -> dict:
|
|
272
|
+
"""Get primary key constraint.
|
|
273
|
+
|
|
274
|
+
Opteryx doesn't have primary keys in the traditional sense.
|
|
275
|
+
"""
|
|
276
|
+
return {"constrained_columns": [], "name": None}
|
|
277
|
+
|
|
278
|
+
def get_foreign_keys(
|
|
279
|
+
self,
|
|
280
|
+
connection: Any,
|
|
281
|
+
table_name: str,
|
|
282
|
+
schema: Optional[str] = None,
|
|
283
|
+
**kw: Any,
|
|
284
|
+
) -> list:
|
|
285
|
+
"""Get foreign key information.
|
|
286
|
+
|
|
287
|
+
Opteryx doesn't support foreign keys.
|
|
288
|
+
"""
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
def get_indexes(
|
|
292
|
+
self,
|
|
293
|
+
connection: Any,
|
|
294
|
+
table_name: str,
|
|
295
|
+
schema: Optional[str] = None,
|
|
296
|
+
**kw: Any,
|
|
297
|
+
) -> list:
|
|
298
|
+
"""Get index information.
|
|
299
|
+
|
|
300
|
+
Opteryx doesn't expose index information.
|
|
301
|
+
"""
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
def get_table_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
|
|
305
|
+
"""Get list of table names.
|
|
306
|
+
|
|
307
|
+
Attempts to query Opteryx for available tables.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
result = connection.execute("SHOW TABLES")
|
|
311
|
+
tables = [row[0] for row in result.fetchall()]
|
|
312
|
+
result.close()
|
|
313
|
+
return tables
|
|
314
|
+
except Exception:
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
def get_view_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
|
|
318
|
+
"""Get list of view names.
|
|
319
|
+
|
|
320
|
+
Opteryx may not distinguish between tables and views.
|
|
321
|
+
"""
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
def get_schema_names(self, connection: Any, **kw: Any) -> list:
|
|
325
|
+
"""Get list of schema names."""
|
|
326
|
+
try:
|
|
327
|
+
result = connection.execute("SHOW SCHEMAS")
|
|
328
|
+
schemas = [row[0] for row in result.fetchall()]
|
|
329
|
+
result.close()
|
|
330
|
+
return schemas
|
|
331
|
+
except Exception:
|
|
332
|
+
return ["default"]
|
|
333
|
+
|
|
334
|
+
def _get_server_version_info(self, connection: Any) -> Tuple[int, ...]:
|
|
335
|
+
"""Get server version information."""
|
|
336
|
+
return (0, 26, 1) # Match Opteryx version in pyproject.toml
|
|
337
|
+
|
|
338
|
+
def _check_unicode_returns(
|
|
339
|
+
self, connection: Any, additional_tests: Optional[list] = None
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""Check if the connection returns unicode strings."""
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
def _check_unicode_description(self, connection: Any) -> bool:
|
|
345
|
+
"""Check if column descriptions are unicode."""
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Register the dialect
|
|
350
|
+
def register_dialect() -> None:
|
|
351
|
+
"""Register the Opteryx dialect with SQLAlchemy.
|
|
352
|
+
|
|
353
|
+
This function is a convenience for development/editable installs where
|
|
354
|
+
the package's entry points may not be present. Calling it (or importing
|
|
355
|
+
this module, which calls it automatically) ensures SQLAlchemy can find
|
|
356
|
+
the dialect when `create_engine("opteryx://...")` is used.
|
|
357
|
+
"""
|
|
358
|
+
from sqlalchemy.dialects import registry
|
|
359
|
+
|
|
360
|
+
# Register using the correct module path for the installed package
|
|
361
|
+
registry.register("opteryx", "sqlalchemy_dialect.dialect", "OptetyxDialect")
|
|
362
|
+
registry.register("opteryx.http", "sqlalchemy_dialect.dialect", "OptetyxDialect")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# Ensure the dialect is registered on import so it works in editable/test mode
|
|
366
|
+
try:
|
|
367
|
+
register_dialect()
|
|
368
|
+
except Exception:
|
|
369
|
+
# Best-effort registration; failures here shouldn't break imports
|
|
370
|
+
pass
|