diff --git a/calcite-rs-jni/py_graphql_sql/LICENSE.md b/calcite-rs-jni/py_graphql_sql/LICENSE.md new file mode 100644 index 0000000..a22a2da --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/LICENSE.md @@ -0,0 +1 @@ +MIT diff --git a/calcite-rs-jni/py_graphql_sql/MANIFEST.in b/calcite-rs-jni/py_graphql_sql/MANIFEST.in new file mode 100644 index 0000000..bb6d67d --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/MANIFEST.in @@ -0,0 +1 @@ +include py_graphql_sql/jars/* diff --git a/calcite-rs-jni/py_graphql_sql/__init__.py b/calcite-rs-jni/py_graphql_sql/__init__.py new file mode 100644 index 0000000..76f0dad --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/__init__.py @@ -0,0 +1,46 @@ +"""DB-API 2.0 compliant JDBC connector for GraphQL.""" + +# Re-export everything from the inner module +from .py_graphql_sql import ( + # DB-API 2.0 main exports + connect, Connection, Cursor, + apilevel, threadsafety, paramstyle, + + # DB-API 2.0 type objects + STRING, BINARY, NUMBER, DATETIME, ROWID, + + # DB-API 2.0 type constructors + Date, Time, Timestamp, + DateFromTicks, TimeFromTicks, TimestampFromTicks, + Binary, + + # DB-API 2.0 exceptions + Warning, Error, InterfaceError, DatabaseError, + DataError, OperationalError, IntegrityError, + InternalError, ProgrammingError, NotSupportedError, + + # Version info + VERSION, __version__, + + # SQLAlchemy dialect + HasuraDDNDialect +) + +# SQLAlchemy registration +from sqlalchemy.dialects import registry +registry.register('hasura.graphql', 'py_graphql_sql.sqlalchemy.hasura.ddnbase', 'HasuraDDNDialect') + +# Make all these available at package level +__all__ = [ + 'connect', 'Connection', 'Cursor', + 'apilevel', 'threadsafety', 'paramstyle', + 'STRING', 'BINARY', 'NUMBER', 'DATETIME', 'ROWID', + 'Date', 'Time', 'Timestamp', + 'DateFromTicks', 'TimeFromTicks', 'TimestampFromTicks', + 'Binary', + 'Warning', 'Error', 'InterfaceError', 'DatabaseError', + 'DataError', 'OperationalError', 'IntegrityError', + 'InternalError', 'ProgrammingError', 'NotSupportedError', + 'VERSION', '__version__', + 'HasuraDDNDialect' +] diff --git a/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py b/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py index e36737e..f25759e 100644 --- a/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py +++ b/calcite-rs-jni/py_graphql_sql/examples/basic_usage.py @@ -10,16 +10,8 @@ def main() -> None: 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 connect(host, jdbc_args) as conn: with conn.cursor() as cur: # Execute a query cur.execute("SELECT * FROM Albums", []) diff --git a/calcite-rs-jni/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar b/calcite-rs-jni/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar new file mode 100644 index 0000000..f38ae66 Binary files /dev/null and b/calcite-rs-jni/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar differ 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 index 7ad9c89..d6e6890 100644 --- a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/__init__.py +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/__init__.py @@ -1,4 +1,5 @@ """DB-API 2.0 compliant JDBC connector for GraphQL.""" +import logging from datetime import date, datetime, time, timedelta from typing import Any, Type @@ -10,12 +11,26 @@ ProgrammingError, NotSupportedError ) +# Add SQLAlchemy dialect registration +from sqlalchemy.dialects import registry +from .sqlalchemy.hasura.ddnbase import HasuraDDNDialect + +# Set up logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +try: + logger.debug("Attempting to register the 'hasura.graphql' dialect.") + registry.register('hasura.graphql', 'py_graphql_sql.sqlalchemy.hasura.ddnbase', 'HasuraDDNDialect') + logger.debug("Successfully registered the 'hasura.graphql' dialect.") +except Exception as e: + logger.error("Failed to register the 'hasura.graphql' dialect.", exc_info=True) + # DB-API 2.0 Module Interface apilevel = "2.0" threadsafety = 1 # Threads may share module, but not connections paramstyle = "qmark" # JDBC uses ? style - # DB-API 2.0 Type Objects class DBAPITypeObject: def __init__(self, *values: str) -> None: @@ -89,5 +104,8 @@ def Binary(string: bytes) -> bytes: 'InternalError', 'ProgrammingError', 'NotSupportedError', # Version info - 'VERSION', '__version__' + 'VERSION', '__version__', + + # Add SQLAlchemy dialect + 'HasuraDDNDialect' ] 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 index 99877aa..6af212d 100644 --- a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/connection.py +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/connection.py @@ -20,11 +20,18 @@ class Connection(AbstractContextManager): def __init__( self, host: str, - jdbc_args: Optional[JDBCArgs] = None, - driver_paths: Optional[List[str]] = None + jdbc_args: Optional[JDBCArgs] = None ) -> None: """Initialize connection.""" try: + # Get paths to JAR directories + current_dir = os.path.dirname(os.path.abspath(__file__)) + driver_paths = [ + os.path.abspath( + os.path.join(current_dir, "./jars") + ) # Add additional paths as needed + ] + # Start JVM if it's not already started if not jpype.isJVMStarted(): # Build classpath from all JARs in provided directories @@ -135,8 +142,7 @@ def jdbc_connection(self) -> Any: def connect( host: str, - jdbc_args: Optional[JDBCArgs] = None, - driver_paths: Optional[List[str]] = None, + jdbc_args: Optional[JDBCArgs] = None ) -> Connection: """ Create a new read-only database connection. @@ -149,4 +155,4 @@ def connect( Returns: Connection: A new database connection """ - return Connection(host, jdbc_args, driver_paths) + return Connection(host, jdbc_args) diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar new file mode 100644 index 0000000..f38ae66 Binary files /dev/null and b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/jars/graphql-jdbc-driver-1.0.0-jar-with-dependencies.jar differ diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/__init__.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/__init__.py new file mode 100644 index 0000000..5fdca76 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/__init__.py @@ -0,0 +1,3 @@ +from . import hasura + +__all__ = ['hasura'] diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/__init__.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/__init__.py new file mode 100644 index 0000000..61b5871 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/__init__.py @@ -0,0 +1,4 @@ +from .ddnbase import HasuraDDNDialect + +# SQLAlchemy requires this +dialect = HasuraDDNDialect diff --git a/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/ddnbase.py b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/ddnbase.py new file mode 100644 index 0000000..7f3d7e8 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/py_graphql_sql/sqlalchemy/hasura/ddnbase.py @@ -0,0 +1,238 @@ +from sqlalchemy.engine import default +from sqlalchemy import types +from sqlalchemy.types import ( + Integer, Float, String, DateTime, Boolean, + Date, Time, TIMESTAMP, DECIMAL +) +from typing import Any, Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + + +class HasuraDDNDialect(default.DefaultDialect): + name = 'hasura' + driver = 'graphql' + + # Existing flags + supports_alter = False + supports_transactions = False + supports_native_boolean = True + supports_statement_cache = False + postfetch_lastrowid = False + + # Schema Support + supports_schemas = True # Calcite does support schemas via INFORMATION_SCHEMA + + # View Support + supports_views = True # Calcite supports views in its SQL layer + + # Row Count Support + supports_sane_rowcount = True # Calcite provides accurate row counts for queries + supports_sane_multi_rowcount = False # Multiple statement execution not typically used + + # Insert/Update Features - Should all be False for read-only + supports_default_values = False # No INSERT support + supports_empty_insert = False # No INSERT support + supports_multivalues_insert = False # No INSERT support + implicit_returning = False # No RETURNING clause since no writes + + # SQL Language Features + requires_name_normalize = False # Calcite handles case sensitivity properly + supports_native_decimal = True # Calcite supports DECIMAL type + supports_unicode_statements = True # Calcite handles Unicode in SQL + supports_unicode_binds = True # Calcite handles Unicode parameters + supports_is_distinct_from = True # Calcite supports IS DISTINCT FROM + + # Additional Calcite-specific flags + supports_window_functions = True # Calcite supports window functions + supports_json = True # Calcite has JSON operations + supports_native_arrays = False # Conservative setting for array types + + # Type mapping from JDBC/Calcite types to SQLAlchemy + type_map = { + 'INTEGER': Integer, + 'INT': Integer, + 'FLOAT': Float, + 'VARCHAR': String, + 'TIMESTAMP': TIMESTAMP, + 'TIMESTAMP(0)': TIMESTAMP, # Add support for timestamp with precision + 'BOOLEAN': Boolean, + 'DATE': Date, + 'TIME': Time, + 'DECIMAL': DECIMAL, + 'JavaType(int)': Integer, + 'JavaType(class java.lang.String)': String, + 'JavaType(class java.lang.Integer)': Integer, + 'JavaType(class java.lang.Short)': Integer, + } + + def get_schema_names(self, connection, **kw) -> List[str]: + """Return fixed schema names.""" + try: + return ['GRAPHQL', 'metadata'] + except Exception as e: + logger.error(f"Error getting schema names: {str(e)}") + return [] + + def get_table_names(self, connection, schema: Optional[str] = None, **kw) -> List[str]: + """Get table names from metadata.TABLES.""" + if schema is None: + schema = 'GRAPHQL' + + try: + query = """ + SELECT DISTINCT tableName + FROM metadata.TABLES + WHERE tableSchem = ? + ORDER BY tableName + """ + result = connection.execute(query, [schema]) + return [row[0] for row in result] + except Exception as e: + logger.error(f"Error getting table names for schema {schema}: {str(e)}") + return [] + + def get_columns(self, connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: + """Get column information from metadata.COLUMNS.""" + if schema is None: + schema = 'GRAPHQL' + + try: + query = """ + SELECT + columnName, + typeName, + nullable, + columnSize, + decimalDigits, + numPrecRadix, + ordinalPosition, + columnDef, + isNullable + FROM metadata.COLUMNS + WHERE tableSchem = ? + AND tableName = ? + ORDER BY ordinalPosition + """ + result = connection.execute(query, [schema, table_name]) + + columns = [] + for row in result: + # Convert JDBC type info to SQLAlchemy type + type_name = row[1] + column_size = row[3] + decimal_digits = row[4] + nullable = row[2] == 1 # JDBC nullable is an int + + # Determine SQLAlchemy type + sql_type = self._get_column_type(type_name, column_size, decimal_digits) + + column = { + 'name': row[0], + 'type': sql_type, + 'nullable': nullable, + 'default': row[7], # columnDef + 'autoincrement': False, # Read-only connection + 'primary_key': False, # Would need additional metadata + 'ordinal_position': row[6], + } + columns.append(column) + return columns + + except Exception as e: + logger.error(f"Error getting columns for {schema}.{table_name}: {str(e)}") + return [] + + def _get_column_type(self, type_name: str, size: Optional[int], + decimal_digits: Optional[int]) -> types.TypeEngine: + """Convert JDBC type information to SQLAlchemy type.""" + if type_name is None: + return String() + + # Handle full type name including precision/scale + type_name = type_name.strip() + + # Direct lookup first + if type_name in self.type_map: + base_type = self.type_map[type_name] + else: + # Try without precision/scale + base_name = type_name.split('(')[0].upper() + base_type = self.type_map.get(base_name, String) + + # Add precision/scale for specific types + if base_type == DECIMAL and size is not None: + return DECIMAL(precision=size, scale=decimal_digits or 0) + elif base_type == String and size is not None: + return String(length=size) + + return base_type() + + def get_view_names(self, connection, schema: Optional[str] = None, **kw) -> List[str]: + """Get view names if any exist.""" + if schema is None: + schema = 'GRAPHQL' + + try: + query = """ + SELECT DISTINCT tableName + FROM metadata.TABLES + WHERE tableSchem = ? + AND tableType = 'VIEW' + ORDER BY tableName + """ + result = connection.execute(query, [schema]) + return [row[0] for row in result] + except Exception as e: + logger.error(f"Error getting view names for schema {schema}: {str(e)}") + return [] + + def has_table(self, connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: + """Check if a table exists in the given schema.""" + if schema is None: + schema = 'GRAPHQL' + + try: + query = """ + SELECT 1 + FROM metadata.TABLES + WHERE tableSchem = ? + AND tableName = ? + """ + result = connection.execute(query, [schema, table_name]) + return result.fetchone() is not None + except Exception as e: + logger.error(f"Error checking if table exists {schema}.{table_name}: {str(e)}") + return False + + @classmethod + def dbapi(cls): + import py_graphql_sql + return py_graphql_sql + + def create_connect_args(self, url): + """Convert SQLAlchemy URL to your connect() parameters""" + jdbc_args = dict(url.query) + host = jdbc_args.pop('url', '') + return [], { + 'host': host, + 'jdbc_args': jdbc_args + } + + def do_rollback(self, dbapi_connection): + """Don't roll back - this is a read-only connection""" + pass + + # Stub implementations for unsupported features + def get_pk_constraint(self, connection, table_name: str, schema: Optional[str] = None, **kw) -> Dict: + """Not implemented for read-only connection.""" + return {'constrained_columns': [], 'name': None} + + def get_foreign_keys(self, connection, table_name: str, schema: Optional[str] = None, **kw) -> List: + """Not implemented for read-only connection.""" + return [] + + def get_indexes(self, connection, table_name: str, schema: Optional[str] = None, **kw) -> List: + """Not implemented for read-only connection.""" + return [] diff --git a/calcite-rs-jni/py_graphql_sql/pyproject.toml b/calcite-rs-jni/py_graphql_sql/pyproject.toml index a6da997..1f08281 100644 --- a/calcite-rs-jni/py_graphql_sql/pyproject.toml +++ b/calcite-rs-jni/py_graphql_sql/pyproject.toml @@ -1,60 +1,44 @@ -[tool.poetry] +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] name = "py_graphql_sql" version = "0.1.0" -description = "Read-only DB-API 2.0 compliant JDBC connector for GraphQL" -authors = ["Your Name "] -license = "MIT" -readme = "README.md" -packages = [ - { include = "py_graphql_sql" }, - { include = "examples" } +description = "A SQLAlchemy dialect for interacting with Hasura GraphQL endpoints." +requires-python = ">=3.8" +readme = {file = "README.md", content-type = "text/markdown"} +license = {file = "LICENSE.md", content-type = "text/markdown"} +authors = [ + {name = "Kenneth Stott", email = "ken@hasura.io"} ] - -[tool.poetry.dependencies] -python = ">=3.8,<4.0" # Set explicit Python version range -jaydebeapi = ">=1.2.3" -JPype1 = ">=1.2.0" -typing-extensions = ">=4.5.0" - -[tool.poetry.group.dev.dependencies] -pytest = "^7.3.1" -pytest-cov = "^4.1.0" -black = "^23.7.0" -pylint = "^2.17.5" -isort = "^5.12.0" -mypy = "^1.4.1" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" - -[tool.pylint.messages_control] -disable = [ - "C0111", # missing-docstring - "C0103", # invalid-name (for DB-API compatibility) - "R0903", # too-few-public-methods - "W0622", # redefined-builtin (for Warning) +dependencies = [ + "sqlalchemy >= 1.4.0", + "JPype1 >= 1.2.0", + "jaydebeapi >= 1.2.3", + "typing-extensions >= 4.5.0" +] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" ] -[tool.pylint.format] -max-line-length = 88 +# Optional dependencies section +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] +docs = ["sphinx", "sphinx-rtd-theme"] +dev = ["black", "isort"] -[tool.black] -line-length = 88 -target-version = ['py38'] -include = '\.pyi?$' +[project.urls] +homepage = "https://github.com/hasura/ndc-calcite" -[tool.isort] -profile = "black" -multi_line_output = 3 +[project.entry-points."sqlalchemy.dialects"] +"hasura.graphql" = "py_graphql_sql.sqlalchemy.hasura.ddnbase:HasuraDDNDialect" -[tool.mypy] -python_version = "3.8" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true -check_untyped_defs = true +[tool.setuptools] +packages = { find = {} } +include-package-data = true -[[tool.mypy.overrides]] -module = ["jaydebeapi.*", "jpype.*"] -ignore_missing_imports = true +[tool.setuptools.package-data] +"py_graphql_sql" = ["jars/*"] diff --git a/calcite-rs-jni/py_graphql_sql/setup.cfg b/calcite-rs-jni/py_graphql_sql/setup.cfg new file mode 100644 index 0000000..f34e953 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/setup.cfg @@ -0,0 +1,72 @@ +[metadata] +name = py_graphql_sql +version = 0.1.0 +description = Read-only DB-API 2.0 compliant JDBC connector for GraphQL +author = Your Name +author_email = your.email@example.com +license = MIT +license_file = LICENSE +long_description = file: README.md +long_description_content_type = text/markdown + +[options] +python_requires = >=3.8,<4.0 +packages = find: +include_package_data = True +install_requires = + jaydebeapi>=1.2.3 + JPype1>=1.2.0 + typing-extensions>=4.5.0 + sqlalchemy>=1.4.0 + +[options.packages.find] +include = + py_graphql_sql* + +[options.extras_require] +dev = + pytest>=7.3.1 + pytest-cov>=4.1.0 + black>=23.7.0 + pylint>=2.17.5 + isort>=5.12.0 + mypy>=1.4.1 + +[options.entry_points] +sqlalchemy.dialects = + hasura_ddn = py_graphql_sql.sqlalchemy.hasura_ddn:HasuraDDNDialect + +[pylint] +disable = + C0111, # missing-docstring + C0103, # invalid-name (for DB-API compatibility) + R0903, # too-few-public-methods + W0622 # redefined-builtin (for Warning) +max-line-length = 88 + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* + +[mypy] +python_version = 3.8 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +check_untyped_defs = True + +[[mypy.overrides]] +module = + jaydebeapi.* + jpype.* +ignore_missing_imports = True + +[isort] +profile = black +multi_line_output = 3 + +[black] +line-length = 88 +target-version = py38 +include = \.pyi?$ diff --git a/calcite-rs-jni/py_graphql_sql/setup.py b/calcite-rs-jni/py_graphql_sql/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/calcite-rs-jni/py_graphql_sql/test.py b/calcite-rs-jni/py_graphql_sql/test.py new file mode 100644 index 0000000..ec91841 --- /dev/null +++ b/calcite-rs-jni/py_graphql_sql/test.py @@ -0,0 +1,59 @@ +# test.py +from sqlalchemy import create_engine, inspect +from sqlalchemy.dialects import registry +import logging +from pprint import pprint + +# Enable logging +logging.basicConfig(level=logging.DEBUG) + +print("\n=== Testing Connection ===") + +# Ensure dialect is registered +from py_graphql_sql.sqlalchemy.hasura.ddnbase import HasuraDDNDialect + +url = 'hasura+graphql:///?url=http://localhost:3000/graphql' + +try: + print(f"\nCreating engine with URL: {url}") + engine = create_engine(url, echo=True) + print("Engine created successfully") + print(f"Dialect name: {engine.dialect.name}") + print(f"Driver name: {engine.dialect.driver}") + + with engine.connect() as conn: + print("\nConnection successful") + + # Test schema retrieval + print("\n=== Testing Schema Names ===") + inspector = inspect(engine) + schemas = inspector.get_schema_names() + print("Available schemas:", schemas) + + # Test table retrieval for each schema + for schema in schemas: + print(f"\n=== Testing Tables in Schema: {schema} ===") + tables = inspector.get_table_names(schema=schema) + print(f"Tables in {schema}:", tables) + + # Test column retrieval for first table + if tables: + first_table = tables[0] + print(f"\n=== Testing Columns for {schema}.{first_table} ===") + columns = inspector.get_columns(first_table, schema=schema) + print("Columns:") + pprint(columns) + + # Test a simple query + print("\n=== Testing Simple Query ===") + query = "SELECT * FROM GRAPHQL.Albums LIMIT 1" + result = conn.execute(query) + print("Query result:") + for row in result: + pprint(dict(row)) + +except Exception as e: + print(f"\nError: {type(e).__name__} - {str(e)}") + import traceback + + traceback.print_exc()