diff --git a/calcite-rs-jni/py_graphql_sql/.gitignore b/calcite-rs-jni/py_graphql_sql/.gitignore new file mode 100644 index 0000000..642f3d4 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Tests +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# Logs +*.log diff --git a/calcite-rs-jni/py_graphql_sql/README.md b/calcite-rs-jni/py_graphql_sql/README.md new file mode 100644 index 0000000..a79e5e8 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/README.md @@ -0,0 +1,157 @@ +# Python DB-API for Hasura DDN + +This is a Python DB-API 2.0 compliant implementation for connecting to Hasura DDN endpoints using SQL through the Hasura GraphQL JDBC driver. It allows you to query Hasura DDN endpoints using SQL:2003 syntax through a JDBC bridge. + +## Installation + +```bash +# Using poetry (recommended) +poetry add python-db-api + +# Or using pip +pip install python-db-api +``` + +## Prerequisites + +1. Python 3.9 or higher +2. Java JDK 11 or higher installed and accessible in your system path +3. `graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar` - this single JAR file contains all required dependencies + +## Basic Usage + +Here's a simple example of how to use the DB-API: + +```python +from python_db_api import connect +import os + +# Connection parameters +host = "http://localhost:3000/graphql" # Your Hasura DDN endpoint +jdbc_args = {"role": "admin"} # Connection properties + +# Path to directory containing the all-in-one driver JAR +driver_paths = ["/path/to/jdbc/target"] # Directory containing graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar + +# Create connection using context manager +with connect(host, jdbc_args, driver_paths) as conn: + with conn.cursor() as cur: + # Execute SQL:2003 query + cur.execute("SELECT * FROM Albums") + + # Fetch results + for row in cur.fetchall(): + print(f"Result: {row}") +``` + +## Connection Parameters + +### Required Parameters + +- `host`: The Hasura DDN endpoint URL (e.g., "http://localhost:3000/graphql") +- `driver_paths`: List containing the directory path where `graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar` is located + +### Optional Parameters + +- `jdbc_args`: Dictionary of connection properties + - Supported properties: "role", "user", "auth" + - Example: `{"role": "admin", "auth": "bearer token"}` + +## Connection Properties + +You can pass various connection properties through the `jdbc_args` parameter: + +```python +jdbc_args = { + "role": "admin", # Hasura role + "user": "username", # Optional username + "auth": "token" # Optional auth token +} +``` + +## Directory Structure + +The driver requires a single JAR file. Example structure: + +``` +/path/to/jdbc/ +└── target/ + └── graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar +``` + +## Error Handling + +The implementation provides clear error messages for common issues: + +```python +try: + with connect(host, jdbc_args, driver_paths) as conn: + # ... your code ... +except DatabaseError as e: + print(f"Database error occurred: {e}") +``` + +Common errors: +- Missing driver JAR file +- Invalid driver path +- Connection failures +- Invalid SQL:2003 queries + +## Context Manager Support + +The implementation supports both context manager and traditional connection patterns: + +```python +# Using context manager (recommended) +with connect(host, jdbc_args, driver_paths) as conn: + # ... your code ... + +# Traditional approach +conn = connect(host, jdbc_args, driver_paths) +try: + # ... your code ... +finally: + conn.close() +``` + +## Type Hints + +The implementation includes type hints for better IDE support and code completion: + +```python +from python_db_api import connect +from typing import List + +def get_connection( + host: str, + properties: dict[str, str], + paths: List[str] +) -> None: + with connect(host, properties, paths) as conn: + # Your code here + pass +``` + +## Thread Safety + +The connection is not thread-safe. Each thread should create its own connection instance. + +## Dependencies + +- `jaydebeapi`: Java Database Connectivity (JDBC) bridge +- `jpype1`: Java to Python integration +- Java JDK 11+ +- `graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar` + +## Limitations + +- One JVM per Python process +- Cannot modify classpath after JVM starts + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License. diff --git a/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py b/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py new file mode 100644 index 0000000..9591e46 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py @@ -0,0 +1,31 @@ +"""Example usage of the DB-API implementation.""" +from py_graphql_sql import connect +import os + +def main() -> None: + """Basic example of connecting to a database and executing queries.""" + # Connection parameters + host = "http://localhost:3000/graphql" + jdbc_args = {"role": "admin"} + + # Get paths to JAR directories + current_dir = os.path.dirname(os.path.abspath(__file__)) + driver_paths = [ + os.path.abspath(os.path.join(current_dir, "../../jdbc/target")) # Add additional paths as needed + ] + + # Create connection using context manager + with connect(host, jdbc_args, driver_paths) as conn: + with conn.cursor() as cur: + # Execute a query + cur.execute("SELECT * FROM Albums", []) + + # Fetch all results + rows = cur.fetchall() + + # Display all rows + for row in rows: + print(f"Result: {row}") + +if __name__ == "__main__": + main() diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/__init__.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/__init__.py new file mode 100644 index 0000000..13ae508 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/__init__.py @@ -0,0 +1,33 @@ +"""DB-API 2.0 compliant JDBC connector.""" + +from .connection import Connection, connect +from .cursor import Cursor +from .exceptions import ( + Error, Warning, InterfaceError, DatabaseError, + DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError +) + +# DB-API 2.0 required globals +apilevel = '2.0' +threadsafety = 1 # Threads may share the module but not connections +paramstyle = 'qmark' # Question mark style, e.g. ...WHERE name=? + +__all__ = [ + 'Connection', + 'Cursor', + 'connect', + 'apilevel', + 'threadsafety', + 'paramstyle', + 'Error', + 'Warning', + 'InterfaceError', + 'DatabaseError', + 'DataError', + 'OperationalError', + 'IntegrityError', + 'InternalError', + 'ProgrammingError', + 'NotSupportedError', +] diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/connection.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/connection.py new file mode 100644 index 0000000..a5292b5 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/connection.py @@ -0,0 +1,115 @@ +"""DB-API 2.0 Connection implementation.""" +from __future__ import annotations +from contextlib import AbstractContextManager +from typing import Optional, Any, List +import jaydebeapi +import jpype +import os +import glob + +from .exceptions import DatabaseError +from .db_types import JDBCArgs, JDBCPath + +JDBC_DRIVER = "com.hasura.GraphQLDriver" +EXCLUDED_JAR = "graphql-jdbc-driver-1.0.0.jar" + +class Connection(AbstractContextManager['Connection']): + """DB-API 2.0 Connection class.""" + + def __init__( + self, + host: str, + jdbc_args: JDBCArgs = None, + driver_paths: List[str] = None + ) -> None: + """Initialize connection.""" + try: + # Start JVM if it's not already started + if not jpype.isJVMStarted(): + # Build classpath from all JARs in provided directories + classpath = [] + if driver_paths: + for path in driver_paths: + if not os.path.exists(path): + raise DatabaseError(f"Driver path not found: {path}") + + # Find all JAR files in the directory + jar_files = glob.glob(os.path.join(path, "*.jar")) + + # Add all JARs except the excluded one + for jar in jar_files: + if os.path.basename(jar) != EXCLUDED_JAR: + classpath.append(jar) + + if not classpath: + raise DatabaseError("No JAR files found in provided paths") + + # Join all paths with OS-specific path separator + classpath_str = os.pathsep.join(classpath) + + jpype.startJVM( + jpype.getDefaultJVMPath(), + f"-Djava.class.path={classpath_str}", + convertStrings=True + ) + + # Construct JDBC URL + jdbc_url = f"jdbc:graphql:{host}" + + # Create Properties object + props = jpype.JClass('java.util.Properties')() + + # Add any properties from jdbc_args + if jdbc_args: + if isinstance(jdbc_args, dict): + for key, value in jdbc_args.items(): + props.setProperty(key, str(value)) + elif isinstance(jdbc_args, list) and len(jdbc_args) > 0: + props.setProperty("role", jdbc_args[0]) + + # Connect using URL and properties + self._jdbc_connection = jaydebeapi.connect( + jclassname=JDBC_DRIVER, + url=jdbc_url, + driver_args=[props], + jars=None + ) + self.closed: bool = False + except Exception as e: + raise DatabaseError(f"Failed to connect: {str(e)}") from e + + def __enter__(self) -> 'Connection': + """Enter context manager.""" + return self + + def __exit__(self, exc_type: Optional[type], exc_val: Optional[Exception], + exc_tb: Optional[Any]) -> None: + """Exit context manager.""" + self.close() + + def close(self) -> None: + """Close the connection.""" + if not self.closed: + self._jdbc_connection.close() + self.closed = True + + def cursor(self): + """Create a new cursor.""" + if self.closed: + raise DatabaseError("Connection is closed") + return self._jdbc_connection.cursor() + +def connect( + host: str, + jdbc_args: JDBCArgs = None, + driver_paths: List[str] = None, +) -> Connection: + """ + Create a new database connection. + + Args: + host: The GraphQL server host (e.g., 'http://localhost:3000/graphql') + jdbc_args: Optional connection arguments (dict or list) + driver_paths: List of paths to directories containing JAR files + """ + return Connection(host, jdbc_args, driver_paths) diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/cursor.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/cursor.py new file mode 100644 index 0000000..ecf2a9c --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/cursor.py @@ -0,0 +1,106 @@ +"""DB-API 2.0 Cursor implementation.""" +from __future__ import annotations +from typing import Any, Optional, Sequence, Tuple, Type +from contextlib import AbstractContextManager + +from .db_types import ConnectionProtocol, Row, RowSequence +from .exceptions import DatabaseError + + +class Cursor(AbstractContextManager["Cursor"]): + """DB-API 2.0 Cursor class.""" + + description: Optional[ + Sequence[ + Tuple[ + str, # name + Any, # type_code + Optional[int], # display_size + Optional[int], # internal_size + Optional[int], # precision + Optional[int], # scale + Optional[bool], # null_ok + ] + ] + ] + + def __init__(self, connection: ConnectionProtocol) -> None: + """Initialize cursor.""" + self._connection = connection + self._cursor = connection.jdbc_connection.cursor() + self.arraysize: int = 1 + self.description = None + self.rowcount: int = -1 + + def __enter__(self) -> Cursor: + """Enter context manager.""" + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[Any], + ) -> None: + """Exit context manager.""" + self.close() + + def close(self) -> None: + """Close the cursor.""" + self._cursor.close() + + def execute(self, operation: str, parameters: Optional[Sequence[Any]] = None) -> Cursor: + """Execute a database operation.""" + try: + if parameters: + self._cursor.execute(operation, parameters) + else: + self._cursor.execute(operation) + + if self._cursor.description: + self.description = self._cursor.description + + self.rowcount = self._cursor.rowcount + return self + except Exception as e: + raise DatabaseError(str(e)) from e + + def executemany(self, operation: str, seq_of_parameters: Sequence[Sequence[Any]]) -> None: + """Execute the same operation multiple times.""" + try: + for parameters in seq_of_parameters: + self.execute(operation, parameters) + except Exception as e: + raise DatabaseError(str(e)) from e + + def fetchone(self) -> Optional[Row]: + """Fetch the next row.""" + try: + row = self._cursor.fetchone() + if row is None: + return None + return tuple(row) if isinstance(row, (list, tuple)) else (row,) + except Exception as e: + raise DatabaseError(str(e)) from e + + def fetchmany(self, size: Optional[int] = None) -> RowSequence: + """Fetch the next set of rows.""" + try: + if size is None: + size = self.arraysize + rows = self._cursor.fetchmany(size) + if not rows: + return [] + return [tuple(row) if isinstance(row, (list, tuple)) else (row,) for row in rows] + except Exception as e: + raise DatabaseError(str(e)) from e + + def fetchall(self) -> RowSequence: + """Fetch all remaining rows.""" + try: + rows = self._cursor.fetchall() + if not rows: + return [] + return [tuple(row) if isinstance(row, (list, tuple)) else (row,) for row in rows] + except Exception as e: + raise DatabaseError(str(e)) from e diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/db_types.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/db_types.py new file mode 100644 index 0000000..c316dbd --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/db_types.py @@ -0,0 +1,67 @@ +""""Type definitions for the DB-API package.""" +from typing import Any, Optional, Sequence, Tuple, Protocol +from typing_extensions import TypeAlias + + +# Row type definitions +Row: TypeAlias = Tuple[Any, ...] +RowSequence: TypeAlias = Sequence[Row] + +# JDBC related types +JDBCArgs: TypeAlias = Optional[Sequence[Any]] +JDBCPath: TypeAlias = Optional[str] + + +class ConnectionProtocol(Protocol): + """Protocol defining the Connection interface.""" + + closed: bool + _jdbc_connection: Any + + def close(self) -> None: ... + + def commit(self) -> None: ... + + def rollback(self) -> None: ... + + def cursor(self) -> "CursorProtocol": ... + + @property + def jdbc_connection(self): + return self._jdbc_connection + + +class CursorProtocol(Protocol): + """Protocol defining the Cursor interface.""" + + arraysize: int + description: Optional[ + Sequence[ + Tuple[ + str, + Any, + Optional[int], + Optional[int], + Optional[int], + Optional[int], + Optional[bool], + ] + ] + ] + rowcount: int + + def close(self) -> None: ... + + def execute( + self, operation: str, parameters: Optional[Sequence[Any]] = None + ) -> "CursorProtocol": ... + + def executemany( + self, operation: str, seq_of_parameters: Sequence[Sequence[Any]] + ) -> None: ... + + def fetchone(self) -> Optional[Row]: ... + + def fetchmany(self, size: Optional[int] = None) -> RowSequence: ... + + def fetchall(self) -> RowSequence: ... diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/exceptions.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/exceptions.py new file mode 100644 index 0000000..e02abde --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/exceptions.py @@ -0,0 +1,31 @@ +"""DB-API 2.0 required exceptions.""" + +class Error(Exception): + """Base class for all DB-API 2.0 errors.""" + +class Warning(Exception): # pylint: disable=redefined-builtin + """Important warnings.""" + +class InterfaceError(Error): + """Database interface errors.""" + +class DatabaseError(Error): + """Database errors.""" + +class DataError(DatabaseError): + """Data errors.""" + +class OperationalError(DatabaseError): + """Database operation errors.""" + +class IntegrityError(DatabaseError): + """Database integrity errors.""" + +class InternalError(DatabaseError): + """Database internal errors.""" + +class ProgrammingError(DatabaseError): + """Programming errors.""" + +class NotSupportedError(DatabaseError): + """Feature not supported errors.""" diff --git a/calcite-rs-jni/py_graphql_sql/tests/test_dbapi.py b/calcite-rs-jni/py_graphql_sql/tests/test_dbapi.py new file mode 100644 index 0000000..0dd6872 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/tests/test_dbapi.py @@ -0,0 +1,25 @@ +"""Tests for DB-API 2.0 compliance.""" + +from py_graphql_sql import ( + Error, + Warning, # pylint: disable=redefined-builtin + apilevel, + connect, + paramstyle, + threadsafety, +) + +def test_dbapi_globals() -> None: + """Test that required DB-API 2.0 globals are present and correct.""" + assert apilevel == '2.0' + assert threadsafety in (0, 1, 2, 3) + assert paramstyle in ('qmark', 'numeric', 'named', 'format', 'pyformat') + +def test_exceptions_hierarchy() -> None: + """Test that the exception hierarchy is correct.""" + assert issubclass(Warning, Exception) + assert issubclass(Error, Exception) + +def test_module_interface() -> None: + """Test that the module interface is complete.""" + assert hasattr(connect, '__call__')