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.
@@ -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