From 1e0970d069b4094ed4451fc48128ea9bfec5b81a Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Wed, 15 Jan 2025 01:37:50 +0530 Subject: [PATCH 1/7] init test connection --- __init__.py | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 18 ++++ 2 files changed, 277 insertions(+) create mode 100644 __init__.py create mode 100644 setup.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..fcbdb0b --- /dev/null +++ b/__init__.py @@ -0,0 +1,259 @@ +# parseable_connector.py +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +import requests +import json +import sys +from sqlalchemy.engine import default +from sqlalchemy.sql import compiler +from sqlalchemy import types +from sqlalchemy.engine import reflection +from sqlalchemy.engine.base import Connection +from sqlalchemy.engine.interfaces import Dialect +import base64 + +# DBAPI required attributes +apilevel = '2.0' +threadsafety = 1 +paramstyle = 'named' + +# DBAPI exceptions +class Error(Exception): + pass + +class InterfaceError(Error): + pass + +class DatabaseError(Error): + pass + +def parse_timestamp(timestamp_str: str) -> datetime: + try: + return datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + except ValueError: + return None + +class ParseableCursor: + def __init__(self, connection): + self.connection = connection + self._rows = [] + self._rowcount = 0 + self.description = None + self.arraysize = 1 + + def execute(self, operation: str, parameters: Optional[Dict] = None): + # Extract time range from query parameters if provided + start_time = "10m" # default + end_time = "now" # default + + if parameters and 'start_time' in parameters: + start_time = parameters['start_time'] + if parameters and 'end_time' in parameters: + end_time = parameters['end_time'] + + # Prepare request + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Basic {self.connection.credentials}' + } + + data = { + 'query': operation, + 'startTime': start_time, + 'endTime': end_time + } + + + # Log the request details + print("Debug: Sending request to Parseable", file=sys.stderr) + print(f"URL: {self.connection.host}/api/v1/query", file=sys.stderr) + print(f"Headers: {headers}", file=sys.stderr) + print(f"Payload: {json.dumps(data, indent=2)}", file=sys.stderr) + + + # Make request to Parseable + response = requests.post( + f"{self.connection.host}/api/v1/query", + headers=headers, + json=data + ) + + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Query failed: {response.text}") + + # Process response + result = response.json() + + if not result: + self._rows = [] + self._rowcount = 0 + self.description = None + return + + # Set up column descriptions (required for DBAPI compliance) + if result and len(result) > 0: + first_row = result[0] + self.description = [] + for column_name in first_row.keys(): + # (name, type_code, display_size, internal_size, precision, scale, null_ok) + self.description.append((column_name, None, None, None, None, None, None)) + + self._rows = result + self._rowcount = len(result) + + def executemany(self, operation: str, seq_of_parameters: List[Dict]): + raise NotImplementedError("executemany is not supported") + + def fetchall(self) -> List[Tuple]: + return [tuple(row.values()) for row in self._rows] + + def fetchone(self) -> Optional[Tuple]: + if not self._rows: + return None + return tuple(self._rows.pop(0).values()) + + def fetchmany(self, size: Optional[int] = None) -> List[Tuple]: + if size is None: + size = self.arraysize + result = self._rows[:size] + self._rows = self._rows[size:] + return [tuple(row.values()) for row in result] + + @property + def rowcount(self) -> int: + return self._rowcount + + def close(self): + self._rows = [] + + def setinputsizes(self, sizes): + pass + + def setoutputsize(self, size, column=None): + pass + +class ParseableConnection: + def __init__(self, host: str, port: str, username: str, password: str): + self.host = f"http://{host}:{port}".rstrip('/') + credentials = f"{username}:{password}" + self.credentials = base64.b64encode(credentials.encode()).decode() + + def cursor(self): + return ParseableCursor(self) + + def close(self): + pass + + def commit(self): + pass + + def rollback(self): + pass + +def connect(username: Optional[str] = None, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + **kwargs) -> ParseableConnection: + """ + Connect to a Parseable instance. + + :param username: Username for authentication (default: admin) + :param password: Password for authentication (default: admin) + :param host: Host address (default: localhost) + :param port: Port number (default: 8000) + :return: ParseableConnection object + """ + username = username or 'admin' + password = password or 'admin' + host = host or 'localhost' + port = port or '8000' + + return ParseableConnection(host=host, port=port, username=username, password=password) + +# SQLAlchemy dialect +class ParseableCompiler(compiler.SQLCompiler): + def visit_select(self, select, **kwargs): + return super().visit_select(select, **kwargs) + +class ParseableDialect(default.DefaultDialect): + name = 'parseable' + driver = 'rest' + + supports_alter = False + supports_pk_autoincrement = False + supports_default_values = False + supports_empty_insert = False + supports_unicode_statements = True + supports_unicode_binds = True + returns_unicode_strings = True + description_encoding = None + supports_native_boolean = True + + @classmethod + def dbapi(cls): + return sys.modules[__name__] + + def create_connect_args(self, url): + kwargs = { + 'host': url.host or 'localhost', + 'port': str(url.port or 8000), + 'username': url.username or 'admin', + 'password': url.password or 'admin' + } + return [], kwargs + + def do_ping(self, dbapi_connection): + try: + cursor = dbapi_connection.cursor() + cursor.execute('SELECT * FROM "adheip" LIMIT 1') + return True + except: + return False + + def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: + return [ + { + 'name': 'timestamp', + 'type': types.TIMESTAMP(), + 'nullable': True, + 'default': None, + }, + { + 'name': 'message', + 'type': types.String(), + 'nullable': True, + 'default': None, + } + ] + + def get_table_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: + return ["adheip"] + + def get_view_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: + return [] + + def get_schema_names(self, connection: Connection, **kw) -> List[str]: + return ['default'] + + def get_pk_constraint(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> Dict[str, Any]: + return {'constrained_columns': [], 'name': None} + + def get_foreign_keys(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: + return [] + + def get_indexes(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: + return [] + + def do_rollback(self, dbapi_connection): + pass + + def _check_unicode_returns(self, connection: Connection, additional_tests: Optional[List] = None): + pass + + def _check_unicode_description(self, connection: Connection): + pass + \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..182198b --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup + +setup( + name='sqlalchemy-parseable', + version='0.1.0', + description='SQLAlchemy dialect for Parseable', + author='Your Name', + packages=['parseable_connector'], + entry_points={ + 'sqlalchemy.dialects': [ + 'parseable = parseable_connector:ParseableDialect' + ] + }, + install_requires=[ + 'sqlalchemy>=1.4.0', + 'requests>=2.25.0' + ] +) From 9885b01369afe56ea2b8b2723787c542b89eafbd Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Wed, 15 Jan 2025 03:41:34 +0530 Subject: [PATCH 2/7] update table, columns and compiler --- __init__.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 153 insertions(+), 17 deletions(-) diff --git a/__init__.py b/__init__.py index fcbdb0b..a9a1c29 100644 --- a/__init__.py +++ b/__init__.py @@ -176,12 +176,19 @@ def connect(username: Optional[str] = None, # SQLAlchemy dialect class ParseableCompiler(compiler.SQLCompiler): - def visit_select(self, select, **kwargs): - return super().visit_select(select, **kwargs) + def visit_table(self, table, asfrom=False, iscrud=False, ashint=False, fromhints=None, **kwargs): + # Get the original table representation + text = super().visit_table(table, asfrom, iscrud, ashint, fromhints, **kwargs) + + # Remove schema prefix (anything before the dot) + if '.' in text: + return text.split('.')[-1] + return text class ParseableDialect(default.DefaultDialect): name = 'parseable' driver = 'rest' + statement_compiler = ParseableCompiler supports_alter = False supports_pk_autoincrement = False @@ -215,23 +222,153 @@ def do_ping(self, dbapi_connection): return False def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: - return [ - { - 'name': 'timestamp', - 'type': types.TIMESTAMP(), - 'nullable': True, - 'default': None, - }, - { - 'name': 'message', - 'type': types.String(), - 'nullable': True, - 'default': None, + try: + # Get host and credentials from the connection object + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare the headers for authorization + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', } - ] + + # Fetch the schema for the given table (log stream) + response = requests.get(f"{base_url}/api/v1/logstream/{table_name}/schema", headers=headers) + + # Log the response details for debugging + print(f"Debug: Fetching schema for {table_name} from {base_url}/api/v1/logstream/{table_name}/schema", file=sys.stderr) + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Failed to fetch schema for {table_name}: {response.text}") + + # Parse the schema response + schema_data = response.json() + + if not isinstance(schema_data, dict) or 'fields' not in schema_data: + raise DatabaseError(f"Unexpected schema format for {table_name}: {response.text}") + + columns = [] + + # Map each field to a SQLAlchemy column descriptor + for field in schema_data['fields']: + column_name = field['name'] + data_type = field['data_type'] + nullable = field['nullable'] + + # Map Parseable data types to SQLAlchemy types + if data_type == 'Utf8': + sql_type = types.String() + elif data_type == 'Int64': + sql_type = types.BigInteger() + elif data_type == 'Float64': + sql_type = types.Float() + else: + sql_type = types.String() # Default type if unknown + + # Append column definition to columns list + columns.append({ + 'name': column_name, + 'type': sql_type, + 'nullable': nullable, + 'default': None, # Assuming no default for now, adjust as needed + }) + + return columns + + except Exception as e: + raise DatabaseError(f"Error fetching columns for {table_name}: {str(e)}") + def get_table_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: - return ["adheip"] + """ + Fetch the list of log streams (tables) from the Parseable instance. + + :param connection: SQLAlchemy Connection object. + :param schema: Optional schema (not used for Parseable). + :param kw: Additional keyword arguments. + :return: List of table names (log streams). + """ + try: + # Get host and credentials from the connection object + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare the headers + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', + } + + # Make the GET request + response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) + + # Log the response details for debugging + print(f"Debug: Fetching table names from {base_url}/api/v1/logstream", file=sys.stderr) + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Failed to fetch table names: {response.text}") + + # Parse the response JSON + log_streams = response.json() + if not isinstance(log_streams, list): + raise DatabaseError(f"Unexpected response format: {response.text}") + + # Extract table names (log stream names) + return [stream['name'] for stream in log_streams if 'name' in stream] + except Exception as e: + raise DatabaseError(f"Error fetching table names: {str(e)}") + + def has_table(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: + """ + Check if a table (log stream) exists in Parseable. + + :param connection: SQLAlchemy Connection object + :param table_name: Name of the table (log stream) to check + :param schema: Schema name (not used for Parseable) + :return: True if the table exists, False otherwise + """ + try: + # Get connection details + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare headers + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', + } + + # Make request to list log streams + response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) + + if response.status_code != 200: + return False + + log_streams = response.json() + + # Check if the table name exists in the list of log streams + return any(stream['name'] == table_name for stream in log_streams if 'name' in stream) + + except Exception as e: + print(f"Error checking table existence: {str(e)}", file=sys.stderr) + return False def get_view_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: return [] @@ -256,4 +393,3 @@ def _check_unicode_returns(self, connection: Connection, additional_tests: Optio def _check_unicode_description(self, connection: Connection): pass - \ No newline at end of file From 0fedb5a9cffaa4a53b191e476e64c77f5fac83c1 Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Fri, 17 Jan 2025 15:28:23 +0530 Subject: [PATCH 3/7] add connector folder --- parseable_connector/__init__.py | 425 ++++++++++++++++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 22760 bytes 2 files changed, 425 insertions(+) create mode 100644 parseable_connector/__init__.py create mode 100644 parseable_connector/__pycache__/__init__.cpython-311.pyc diff --git a/parseable_connector/__init__.py b/parseable_connector/__init__.py new file mode 100644 index 0000000..fdb2e37 --- /dev/null +++ b/parseable_connector/__init__.py @@ -0,0 +1,425 @@ +# parseable_connector.py +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +import requests +import json +import sys +from sqlalchemy.engine import default +from sqlalchemy.sql import compiler +from sqlalchemy import types +from sqlalchemy.engine import reflection +from sqlalchemy.engine.base import Connection +from sqlalchemy.engine.interfaces import Dialect +import base64 + +# DBAPI required attributes +apilevel = '2.0' +threadsafety = 1 +paramstyle = 'named' + +# DBAPI exceptions +class Error(Exception): + pass + +class InterfaceError(Error): + pass + +class DatabaseError(Error): + pass + +def parse_timestamp(timestamp_str: str) -> datetime: + try: + return datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + except ValueError: + return None + +class ParseableCursor: + def __init__(self, connection): + self.connection = connection + self._rows = [] + self._rowcount = 0 + self.description = None + self.arraysize = 1 + + def _extract_and_remove_time_conditions(self, query: str) -> Tuple[str, str, str]: + """ + Extract time conditions from WHERE clause and remove them from query. + Returns (modified_query, start_time, end_time) + """ + import re + + # Default values + start_time = None + end_time = None + modified_query = query + + # Find timestamp conditions in WHERE clause + timestamp_pattern = r"WHERE\s+p_timestamp\s*>=\s*'([^']+)'\s*AND\s+p_timestamp\s*<\s*'([^']+)'" + match = re.search(timestamp_pattern, query, re.IGNORECASE) + + if match: + # Extract the timestamps + start_time = match.group(1) + end_time = match.group(2) + + # Convert to Parseable format (adding Z for UTC) + start_time = start_time.replace(' ', 'T') + 'Z' + end_time = end_time.replace(' ', 'T') + 'Z' + + # Remove the WHERE clause with timestamp conditions + where_clause = match.group(0) + modified_query = query.replace(where_clause, '') + + # If there's a WHERE clause with other conditions, preserve them + if 'WHERE' in modified_query.upper(): + modified_query = modified_query.replace('AND', 'WHERE', 1) + + return modified_query.strip(), start_time, end_time + + def execute(self, operation: str, parameters: Optional[Dict] = None): + # Extract and remove time conditions from query + modified_query, start_time, end_time = self._extract_and_remove_time_conditions(operation) + + # Use extracted times or defaults + start_time = start_time or "10m" + end_time = end_time or "now" + + # Log the transformation + print("Debug: Query transformation", file=sys.stderr) + print(f"Original query: {operation}", file=sys.stderr) + print(f"Modified query: {modified_query}", file=sys.stderr) + print(f"Time range: {start_time} to {end_time}", file=sys.stderr) + + # Prepare request + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Basic {self.connection.credentials}' + } + + data = { + 'query': modified_query, + 'startTime': start_time, + 'endTime': end_time + } + + # Make request to Parseable + response = requests.post( + f"{self.connection.host}/api/v1/query", + headers=headers, + json=data + ) + + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Query failed: {response.text}") + + # Process response + result = response.json() + + if not result: + self._rows = [] + self._rowcount = 0 + self.description = None + return + + # Set up column descriptions + if result and len(result) > 0: + first_row = result[0] + self.description = [] + for column_name in first_row.keys(): + self.description.append((column_name, None, None, None, None, None, None)) + + self._rows = result + self._rowcount = len(result) + + def executemany(self, operation: str, seq_of_parameters: List[Dict]): + raise NotImplementedError("executemany is not supported") + + def fetchall(self) -> List[Tuple]: + return [tuple(row.values()) for row in self._rows] + + def fetchone(self) -> Optional[Tuple]: + if not self._rows: + return None + return tuple(self._rows.pop(0).values()) + + def fetchmany(self, size: Optional[int] = None) -> List[Tuple]: + if size is None: + size = self.arraysize + result = self._rows[:size] + self._rows = self._rows[size:] + return [tuple(row.values()) for row in result] + + @property + def rowcount(self) -> int: + return self._rowcount + + def close(self): + self._rows = [] + + def setinputsizes(self, sizes): + pass + + def setoutputsize(self, size, column=None): + pass + +class ParseableConnection: + def __init__(self, host: str, port: str, username: str, password: str): + self.host = f"http://{host}:{port}".rstrip('/') + credentials = f"{username}:{password}" + self.credentials = base64.b64encode(credentials.encode()).decode() + + def cursor(self): + return ParseableCursor(self) + + def close(self): + pass + + def commit(self): + pass + + def rollback(self): + pass + +def connect(username: Optional[str] = None, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + **kwargs) -> ParseableConnection: + """ + Connect to a Parseable instance. + + :param username: Username for authentication (default: admin) + :param password: Password for authentication (default: admin) + :param host: Host address (default: localhost) + :param port: Port number (default: 8000) + :return: ParseableConnection object + """ + username = username or 'admin' + password = password or 'admin' + host = host or 'localhost' + port = port or '8000' + + return ParseableConnection(host=host, port=port, username=username, password=password) + +# SQLAlchemy dialect +class ParseableCompiler(compiler.SQLCompiler): + def visit_table(self, table, asfrom=False, iscrud=False, ashint=False, fromhints=None, **kwargs): + # Get the original table representation + text = super().visit_table(table, asfrom, iscrud, ashint, fromhints, **kwargs) + + # Remove schema prefix (anything before the dot) + if '.' in text: + return text.split('.')[-1] + return text + +class ParseableDialect(default.DefaultDialect): + name = 'parseable' + driver = 'rest' + statement_compiler = ParseableCompiler + + supports_alter = False + supports_pk_autoincrement = False + supports_default_values = False + supports_empty_insert = False + supports_unicode_statements = True + supports_unicode_binds = True + returns_unicode_strings = True + description_encoding = None + supports_native_boolean = True + + @classmethod + def dbapi(cls): + return sys.modules[__name__] + + def create_connect_args(self, url): + kwargs = { + 'host': url.host or 'localhost', + 'port': str(url.port or 8000), + 'username': url.username or 'admin', + 'password': url.password or 'admin' + } + return [], kwargs + + def do_ping(self, dbapi_connection): + try: + cursor = dbapi_connection.cursor() + cursor.execute('SELECT * FROM "adheip" LIMIT 1') + return True + except: + return False + + def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: + try: + # Get host and credentials from the connection object + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare the headers for authorization + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', + } + + # Fetch the schema for the given table (log stream) + response = requests.get(f"{base_url}/api/v1/logstream/{table_name}/schema", headers=headers) + + # Log the response details for debugging + print(f"Debug: Fetching schema for {table_name} from {base_url}/api/v1/logstream/{table_name}/schema", file=sys.stderr) + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Failed to fetch schema for {table_name}: {response.text}") + + # Parse the schema response + schema_data = response.json() + + if not isinstance(schema_data, dict) or 'fields' not in schema_data: + raise DatabaseError(f"Unexpected schema format for {table_name}: {response.text}") + + columns = [] + + # Map each field to a SQLAlchemy column descriptor + for field in schema_data['fields']: + column_name = field['name'] + data_type = field['data_type'] + nullable = field['nullable'] + + # Map Parseable data types to SQLAlchemy types + if data_type == 'Utf8': + sql_type = types.String() + elif data_type == 'Int64': + sql_type = types.BigInteger() + elif data_type == 'Float64': + sql_type = types.Float() + else: + sql_type = types.String() # Default type if unknown + + # Append column definition to columns list + columns.append({ + 'name': column_name, + 'type': sql_type, + 'nullable': nullable, + 'default': None, # Assuming no default for now, adjust as needed + }) + + return columns + + except Exception as e: + raise DatabaseError(f"Error fetching columns for {table_name}: {str(e)}") + + + def get_table_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: + """ + Fetch the list of log streams (tables) from the Parseable instance. + + :param connection: SQLAlchemy Connection object. + :param schema: Optional schema (not used for Parseable). + :param kw: Additional keyword arguments. + :return: List of table names (log streams). + """ + try: + # Get host and credentials from the connection object + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare the headers + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', + } + + # Make the GET request + response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) + + # Log the response details for debugging + print(f"Debug: Fetching table names from {base_url}/api/v1/logstream", file=sys.stderr) + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + if response.status_code != 200: + raise DatabaseError(f"Failed to fetch table names: {response.text}") + + # Parse the response JSON + log_streams = response.json() + if not isinstance(log_streams, list): + raise DatabaseError(f"Unexpected response format: {response.text}") + + # Extract table names (log stream names) + return [stream['name'] for stream in log_streams if 'name' in stream] + except Exception as e: + raise DatabaseError(f"Error fetching table names: {str(e)}") + + def has_table(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: + """ + Check if a table (log stream) exists in Parseable. + + :param connection: SQLAlchemy Connection object + :param table_name: Name of the table (log stream) to check + :param schema: Schema name (not used for Parseable) + :return: True if the table exists, False otherwise + """ + try: + # Get connection details + host = connection.engine.url.host + port = connection.engine.url.port + username = connection.engine.url.username + password = connection.engine.url.password + base_url = f"http://{host}:{port}" + + # Prepare headers + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + 'Authorization': f'Basic {encoded_credentials}', + } + + # Make request to list log streams + response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) + + if response.status_code != 200: + return False + + log_streams = response.json() + + # Check if the table name exists in the list of log streams + return any(stream['name'] == table_name for stream in log_streams if 'name' in stream) + + except Exception as e: + print(f"Error checking table existence: {str(e)}", file=sys.stderr) + return False + + def get_view_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: + return [] + + def get_schema_names(self, connection: Connection, **kw) -> List[str]: + return ['default'] + + def get_pk_constraint(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> Dict[str, Any]: + return {'constrained_columns': [], 'name': None} + + def get_foreign_keys(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: + return [] + + def get_indexes(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: + return [] + + def do_rollback(self, dbapi_connection): + pass + + def _check_unicode_returns(self, connection: Connection, additional_tests: Optional[List] = None): + pass + + def _check_unicode_description(self, connection: Connection): + pass diff --git a/parseable_connector/__pycache__/__init__.cpython-311.pyc b/parseable_connector/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..000209835fab2a53399f890de71d086d8c3e9e2f GIT binary patch literal 22760 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 XLN!>K&YjQIZ~2S`e!fZ+CL;bXUGCx- literal 0 HcmV?d00001 From 47e5b2218d995f57d1b118530a9fecc91aa00857 Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Thu, 30 Jan 2025 00:59:41 +0530 Subject: [PATCH 4/7] working code --- README.md | 35 +- __init__.py | 395 --------------- parseable_connector/__init__.py | 465 ++++++++---------- .../__pycache__/__init__.cpython-311.pyc | Bin 22760 -> 22785 bytes setup.py | 28 +- 5 files changed, 255 insertions(+), 668 deletions(-) delete mode 100644 __init__.py diff --git a/README.md b/README.md index bd03117..01286b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # sqlalchemy-parseable -A DBAPI and SQLAlchemy dialect for Parseable + +A DBAPI and SQLAlchemy dialect for Parseable. + +## Getting Started on local machine. + +- Install superset, initalise parseable connector and configure superset. + +## Install Superset + +- Make sure ```Python 3.11.6``` is installed. + +``` +python3 -m venv venv +. venv/bin/activate +pip install apache-superset +export SUPERSET_SECRET_KEY=YOUR-SECRET-KEY +export FLASK_APP=superset +superset db upgrade +superset fab create-admin +superset init +``` + +- Initalise parseable connector. + +``` +cd sqlalchemy-parseable +pip install -e . +``` + +- Run superset. + +``` +superset run -p 8088 --with-threads --reload --debugger +``` \ No newline at end of file diff --git a/__init__.py b/__init__.py deleted file mode 100644 index a9a1c29..0000000 --- a/__init__.py +++ /dev/null @@ -1,395 +0,0 @@ -# parseable_connector.py -from typing import Any, Dict, List, Optional, Tuple -from datetime import datetime -import requests -import json -import sys -from sqlalchemy.engine import default -from sqlalchemy.sql import compiler -from sqlalchemy import types -from sqlalchemy.engine import reflection -from sqlalchemy.engine.base import Connection -from sqlalchemy.engine.interfaces import Dialect -import base64 - -# DBAPI required attributes -apilevel = '2.0' -threadsafety = 1 -paramstyle = 'named' - -# DBAPI exceptions -class Error(Exception): - pass - -class InterfaceError(Error): - pass - -class DatabaseError(Error): - pass - -def parse_timestamp(timestamp_str: str) -> datetime: - try: - return datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - except ValueError: - return None - -class ParseableCursor: - def __init__(self, connection): - self.connection = connection - self._rows = [] - self._rowcount = 0 - self.description = None - self.arraysize = 1 - - def execute(self, operation: str, parameters: Optional[Dict] = None): - # Extract time range from query parameters if provided - start_time = "10m" # default - end_time = "now" # default - - if parameters and 'start_time' in parameters: - start_time = parameters['start_time'] - if parameters and 'end_time' in parameters: - end_time = parameters['end_time'] - - # Prepare request - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Basic {self.connection.credentials}' - } - - data = { - 'query': operation, - 'startTime': start_time, - 'endTime': end_time - } - - - # Log the request details - print("Debug: Sending request to Parseable", file=sys.stderr) - print(f"URL: {self.connection.host}/api/v1/query", file=sys.stderr) - print(f"Headers: {headers}", file=sys.stderr) - print(f"Payload: {json.dumps(data, indent=2)}", file=sys.stderr) - - - # Make request to Parseable - response = requests.post( - f"{self.connection.host}/api/v1/query", - headers=headers, - json=data - ) - - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) - - if response.status_code != 200: - raise DatabaseError(f"Query failed: {response.text}") - - # Process response - result = response.json() - - if not result: - self._rows = [] - self._rowcount = 0 - self.description = None - return - - # Set up column descriptions (required for DBAPI compliance) - if result and len(result) > 0: - first_row = result[0] - self.description = [] - for column_name in first_row.keys(): - # (name, type_code, display_size, internal_size, precision, scale, null_ok) - self.description.append((column_name, None, None, None, None, None, None)) - - self._rows = result - self._rowcount = len(result) - - def executemany(self, operation: str, seq_of_parameters: List[Dict]): - raise NotImplementedError("executemany is not supported") - - def fetchall(self) -> List[Tuple]: - return [tuple(row.values()) for row in self._rows] - - def fetchone(self) -> Optional[Tuple]: - if not self._rows: - return None - return tuple(self._rows.pop(0).values()) - - def fetchmany(self, size: Optional[int] = None) -> List[Tuple]: - if size is None: - size = self.arraysize - result = self._rows[:size] - self._rows = self._rows[size:] - return [tuple(row.values()) for row in result] - - @property - def rowcount(self) -> int: - return self._rowcount - - def close(self): - self._rows = [] - - def setinputsizes(self, sizes): - pass - - def setoutputsize(self, size, column=None): - pass - -class ParseableConnection: - def __init__(self, host: str, port: str, username: str, password: str): - self.host = f"http://{host}:{port}".rstrip('/') - credentials = f"{username}:{password}" - self.credentials = base64.b64encode(credentials.encode()).decode() - - def cursor(self): - return ParseableCursor(self) - - def close(self): - pass - - def commit(self): - pass - - def rollback(self): - pass - -def connect(username: Optional[str] = None, - password: Optional[str] = None, - host: Optional[str] = None, - port: Optional[str] = None, - **kwargs) -> ParseableConnection: - """ - Connect to a Parseable instance. - - :param username: Username for authentication (default: admin) - :param password: Password for authentication (default: admin) - :param host: Host address (default: localhost) - :param port: Port number (default: 8000) - :return: ParseableConnection object - """ - username = username or 'admin' - password = password or 'admin' - host = host or 'localhost' - port = port or '8000' - - return ParseableConnection(host=host, port=port, username=username, password=password) - -# SQLAlchemy dialect -class ParseableCompiler(compiler.SQLCompiler): - def visit_table(self, table, asfrom=False, iscrud=False, ashint=False, fromhints=None, **kwargs): - # Get the original table representation - text = super().visit_table(table, asfrom, iscrud, ashint, fromhints, **kwargs) - - # Remove schema prefix (anything before the dot) - if '.' in text: - return text.split('.')[-1] - return text - -class ParseableDialect(default.DefaultDialect): - name = 'parseable' - driver = 'rest' - statement_compiler = ParseableCompiler - - supports_alter = False - supports_pk_autoincrement = False - supports_default_values = False - supports_empty_insert = False - supports_unicode_statements = True - supports_unicode_binds = True - returns_unicode_strings = True - description_encoding = None - supports_native_boolean = True - - @classmethod - def dbapi(cls): - return sys.modules[__name__] - - def create_connect_args(self, url): - kwargs = { - 'host': url.host or 'localhost', - 'port': str(url.port or 8000), - 'username': url.username or 'admin', - 'password': url.password or 'admin' - } - return [], kwargs - - def do_ping(self, dbapi_connection): - try: - cursor = dbapi_connection.cursor() - cursor.execute('SELECT * FROM "adheip" LIMIT 1') - return True - except: - return False - - def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: - try: - # Get host and credentials from the connection object - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare the headers for authorization - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Fetch the schema for the given table (log stream) - response = requests.get(f"{base_url}/api/v1/logstream/{table_name}/schema", headers=headers) - - # Log the response details for debugging - print(f"Debug: Fetching schema for {table_name} from {base_url}/api/v1/logstream/{table_name}/schema", file=sys.stderr) - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) - - if response.status_code != 200: - raise DatabaseError(f"Failed to fetch schema for {table_name}: {response.text}") - - # Parse the schema response - schema_data = response.json() - - if not isinstance(schema_data, dict) or 'fields' not in schema_data: - raise DatabaseError(f"Unexpected schema format for {table_name}: {response.text}") - - columns = [] - - # Map each field to a SQLAlchemy column descriptor - for field in schema_data['fields']: - column_name = field['name'] - data_type = field['data_type'] - nullable = field['nullable'] - - # Map Parseable data types to SQLAlchemy types - if data_type == 'Utf8': - sql_type = types.String() - elif data_type == 'Int64': - sql_type = types.BigInteger() - elif data_type == 'Float64': - sql_type = types.Float() - else: - sql_type = types.String() # Default type if unknown - - # Append column definition to columns list - columns.append({ - 'name': column_name, - 'type': sql_type, - 'nullable': nullable, - 'default': None, # Assuming no default for now, adjust as needed - }) - - return columns - - except Exception as e: - raise DatabaseError(f"Error fetching columns for {table_name}: {str(e)}") - - - def get_table_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: - """ - Fetch the list of log streams (tables) from the Parseable instance. - - :param connection: SQLAlchemy Connection object. - :param schema: Optional schema (not used for Parseable). - :param kw: Additional keyword arguments. - :return: List of table names (log streams). - """ - try: - # Get host and credentials from the connection object - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare the headers - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Make the GET request - response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) - - # Log the response details for debugging - print(f"Debug: Fetching table names from {base_url}/api/v1/logstream", file=sys.stderr) - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) - - if response.status_code != 200: - raise DatabaseError(f"Failed to fetch table names: {response.text}") - - # Parse the response JSON - log_streams = response.json() - if not isinstance(log_streams, list): - raise DatabaseError(f"Unexpected response format: {response.text}") - - # Extract table names (log stream names) - return [stream['name'] for stream in log_streams if 'name' in stream] - except Exception as e: - raise DatabaseError(f"Error fetching table names: {str(e)}") - - def has_table(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: - """ - Check if a table (log stream) exists in Parseable. - - :param connection: SQLAlchemy Connection object - :param table_name: Name of the table (log stream) to check - :param schema: Schema name (not used for Parseable) - :return: True if the table exists, False otherwise - """ - try: - # Get connection details - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare headers - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Make request to list log streams - response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) - - if response.status_code != 200: - return False - - log_streams = response.json() - - # Check if the table name exists in the list of log streams - return any(stream['name'] == table_name for stream in log_streams if 'name' in stream) - - except Exception as e: - print(f"Error checking table existence: {str(e)}", file=sys.stderr) - return False - - def get_view_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: - return [] - - def get_schema_names(self, connection: Connection, **kw) -> List[str]: - return ['default'] - - def get_pk_constraint(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> Dict[str, Any]: - return {'constrained_columns': [], 'name': None} - - def get_foreign_keys(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: - return [] - - def get_indexes(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: - return [] - - def do_rollback(self, dbapi_connection): - pass - - def _check_unicode_returns(self, connection: Connection, additional_tests: Optional[List] = None): - pass - - def _check_unicode_description(self, connection: Connection): - pass diff --git a/parseable_connector/__init__.py b/parseable_connector/__init__.py index fdb2e37..9866106 100644 --- a/parseable_connector/__init__.py +++ b/parseable_connector/__init__.py @@ -1,4 +1,3 @@ -# parseable_connector.py from typing import Any, Dict, List, Optional, Tuple from datetime import datetime import requests @@ -11,13 +10,13 @@ from sqlalchemy.engine.base import Connection from sqlalchemy.engine.interfaces import Dialect import base64 +from urllib.parse import urlparse # DBAPI required attributes apilevel = '2.0' threadsafety = 1 paramstyle = 'named' -# DBAPI exceptions class Error(Exception): pass @@ -33,149 +32,196 @@ def parse_timestamp(timestamp_str: str) -> datetime: except ValueError: return None -class ParseableCursor: - def __init__(self, connection): - self.connection = connection - self._rows = [] - self._rowcount = 0 - self.description = None - self.arraysize = 1 +class ParseableClient: + def __init__(self, host: str, port: str, username: str, password: str, verify_ssl: bool = True): + # Remove https:// if included in host + host = host.replace('https://', '') + self.base_url = f"https://{host}" + if port and port != '443': + self.base_url += f":{port}" + + credentials = f"{username}:{password}" + self.headers = { + 'Authorization': f'Basic {base64.b64encode(credentials.encode()).decode()}', + 'Content-Type': 'application/json' + } + self.verify_ssl = verify_ssl - def _extract_and_remove_time_conditions(self, query: str) -> Tuple[str, str, str]: - """ - Extract time conditions from WHERE clause and remove them from query. - Returns (modified_query, start_time, end_time) - """ + def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + url = f"{self.base_url}/api/v1/{endpoint.lstrip('/')}" + kwargs['headers'] = {**self.headers, **kwargs.get('headers', {})} + kwargs['verify'] = self.verify_ssl + + try: + response = requests.request(method, url, **kwargs) + print(f"Debug: {method} request to {url}", file=sys.stderr) + print(f"Response Status: {response.status_code}", file=sys.stderr) + print(f"Response Content: {response.text}", file=sys.stderr) + + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + raise DatabaseError(f"Request failed: {str(e)}") + + def execute_query(self, table_name: str, query: str) -> Dict: + """Execute a query against a specific table/stream""" + # First, let's transform the query to handle type casting + modified_query = self._transform_query(query) + + # Then extract time conditions + modified_query, start_time, end_time = self._extract_and_remove_time_conditions(modified_query) + + data = { + "query": modified_query, + "startTime": start_time, + "endTime": end_time + } + + headers = {**self.headers, 'X-P-Stream': table_name} + + url = f"{self.base_url}/api/v1/query" + + print("\n=== QUERY EXECUTION ===", file=sys.stderr) + print(f"Table: {table_name}", file=sys.stderr) + print(f"Original Query: {query}", file=sys.stderr) + print(f"Modified Query: {modified_query}", file=sys.stderr) + print(f"Time Range: {start_time} to {end_time}", file=sys.stderr) + + try: + response = requests.post( + url, + headers=headers, + json=data, + verify=self.verify_ssl + ) + + print("\n=== QUERY RESPONSE ===", file=sys.stderr) + print(f"Status Code: {response.status_code}", file=sys.stderr) + print(f"Headers: {json.dumps(dict(response.headers), indent=2)}", file=sys.stderr) + print(f"Content: {response.text[:1000]}{'...' if len(response.text) > 1000 else ''}", file=sys.stderr) + print("=====================\n", file=sys.stderr) + + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"\n=== QUERY ERROR ===\n{str(e)}\n================\n", file=sys.stderr) + raise DatabaseError(f"Query execution failed: {str(e)}") + + def _transform_query(self, query: str) -> str: + """Transform the query to handle proper type casting""" import re - # Default values - start_time = None - end_time = None - modified_query = query + # Convert avg, sum, count on string fields + numeric_agg_pattern = r'(AVG|SUM|COUNT)\s*\(([^)]+)\)' + def replace_agg(match): + agg_func = match.group(1).upper() + field = match.group(2).strip() + + if agg_func in ('AVG', 'SUM'): + return f"{agg_func}(TRY_CAST({field} AS DOUBLE))" + return f"{agg_func}({field})" + + modified_query = re.sub(numeric_agg_pattern, replace_agg, query, flags=re.IGNORECASE) + + return modified_query - # Find timestamp conditions in WHERE clause + def _extract_and_remove_time_conditions(self, query: str) -> Tuple[str, str, str]: + """Extract time conditions from WHERE clause and remove them from query.""" + import re + timestamp_pattern = r"WHERE\s+p_timestamp\s*>=\s*'([^']+)'\s*AND\s+p_timestamp\s*<\s*'([^']+)'" match = re.search(timestamp_pattern, query, re.IGNORECASE) if match: - # Extract the timestamps - start_time = match.group(1) - end_time = match.group(2) - - # Convert to Parseable format (adding Z for UTC) - start_time = start_time.replace(' ', 'T') + 'Z' - end_time = end_time.replace(' ', 'T') + 'Z' + # Convert to proper RFC3339 format + start_str = match.group(1).replace(' ', 'T') + 'Z' + end_str = match.group(2).replace(' ', 'T') + 'Z' - # Remove the WHERE clause with timestamp conditions + # Remove the time conditions from query where_clause = match.group(0) modified_query = query.replace(where_clause, '') - # If there's a WHERE clause with other conditions, preserve them if 'WHERE' in modified_query.upper(): modified_query = modified_query.replace('AND', 'WHERE', 1) + + return modified_query.strip(), start_str, end_str - return modified_query.strip(), start_time, end_time - - def execute(self, operation: str, parameters: Optional[Dict] = None): - # Extract and remove time conditions from query - modified_query, start_time, end_time = self._extract_and_remove_time_conditions(operation) - - # Use extracted times or defaults - start_time = start_time or "10m" - end_time = end_time or "now" - - # Log the transformation - print("Debug: Query transformation", file=sys.stderr) - print(f"Original query: {operation}", file=sys.stderr) - print(f"Modified query: {modified_query}", file=sys.stderr) - print(f"Time range: {start_time} to {end_time}", file=sys.stderr) - - # Prepare request - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Basic {self.connection.credentials}' - } - - data = { - 'query': modified_query, - 'startTime': start_time, - 'endTime': end_time - } - - # Make request to Parseable - response = requests.post( - f"{self.connection.host}/api/v1/query", - headers=headers, - json=data - ) - - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) - - if response.status_code != 200: - raise DatabaseError(f"Query failed: {response.text}") - - # Process response - result = response.json() - - if not result: - self._rows = [] - self._rowcount = 0 - self.description = None - return - - # Set up column descriptions - if result and len(result) > 0: - first_row = result[0] - self.description = [] - for column_name in first_row.keys(): - self.description.append((column_name, None, None, None, None, None, None)) - - self._rows = result - self._rowcount = len(result) + # Default values if no time conditions found + return query.strip(), "10m", "now" - def executemany(self, operation: str, seq_of_parameters: List[Dict]): - raise NotImplementedError("executemany is not supported") +class ParseableCursor: + def __init__(self, connection): + self.connection = connection + self._rows = [] + self._rowcount = -1 + self.description = None + self.arraysize = 1 - def fetchall(self) -> List[Tuple]: - return [tuple(row.values()) for row in self._rows] + def execute(self, operation: str, parameters: Optional[Dict] = None): + if not self.connection.table_name: + raise DatabaseError("No table name specified in connection string") + + try: + if operation.strip().upper() == "SELECT 1": + # For connection test, execute a real query to test API connectivity + result = self.connection.client.execute_query( + table_name=self.connection.table_name, + query=f"select * from {self.connection.table_name} limit 1" + ) + self._rows = [{"result": 1}] + self._rowcount = 1 + self.description = [("result", types.INTEGER, None, None, None, None, None)] + return self._rowcount + + # Handle actual queries + result = self.connection.client.execute_query( + table_name=self.connection.table_name, + query=operation + ) + + if result and isinstance(result, list): + self._rows = result + self._rowcount = len(result) + + # Set description based on the first row if available + if self._rows: + first_row = self._rows[0] + self.description = [ + (col, types.VARCHAR, None, None, None, None, None) + for col in first_row.keys() + ] + + return self._rowcount + + except Exception as e: + raise DatabaseError(str(e)) def fetchone(self) -> Optional[Tuple]: if not self._rows: return None return tuple(self._rows.pop(0).values()) - def fetchmany(self, size: Optional[int] = None) -> List[Tuple]: - if size is None: - size = self.arraysize - result = self._rows[:size] - self._rows = self._rows[size:] - return [tuple(row.values()) for row in result] - - @property - def rowcount(self) -> int: - return self._rowcount + def fetchall(self) -> List[Tuple]: + result = [tuple(row.values()) for row in self._rows] + self._rows = [] + return result def close(self): self._rows = [] - def setinputsizes(self, sizes): - pass - - def setoutputsize(self, size, column=None): - pass - class ParseableConnection: - def __init__(self, host: str, port: str, username: str, password: str): - self.host = f"http://{host}:{port}".rstrip('/') - credentials = f"{username}:{password}" - self.credentials = base64.b64encode(credentials.encode()).decode() + def __init__(self, host: str, port: str, username: str, password: str, database: str = None, verify_ssl: bool = True): + self.client = ParseableClient(host, port, username, password, verify_ssl) + self._closed = False + self.table_name = database.lstrip('/') if database else None def cursor(self): + if self._closed: + raise InterfaceError("Connection is closed") return ParseableCursor(self) def close(self): - pass + self._closed = True def commit(self): pass @@ -183,37 +229,10 @@ def commit(self): def rollback(self): pass -def connect(username: Optional[str] = None, - password: Optional[str] = None, - host: Optional[str] = None, - port: Optional[str] = None, - **kwargs) -> ParseableConnection: - """ - Connect to a Parseable instance. - - :param username: Username for authentication (default: admin) - :param password: Password for authentication (default: admin) - :param host: Host address (default: localhost) - :param port: Port number (default: 8000) - :return: ParseableConnection object - """ - username = username or 'admin' - password = password or 'admin' - host = host or 'localhost' - port = port or '8000' - - return ParseableConnection(host=host, port=port, username=username, password=password) - -# SQLAlchemy dialect class ParseableCompiler(compiler.SQLCompiler): def visit_table(self, table, asfrom=False, iscrud=False, ashint=False, fromhints=None, **kwargs): - # Get the original table representation text = super().visit_table(table, asfrom, iscrud, ashint, fromhints, **kwargs) - - # Remove schema prefix (anything before the dot) - if '.' in text: - return text.split('.')[-1] - return text + return text.split('.')[-1] if '.' in text else text class ParseableDialect(default.DefaultDialect): name = 'parseable' @@ -235,79 +254,66 @@ def dbapi(cls): return sys.modules[__name__] def create_connect_args(self, url): + # Parse the database name from the URL path - this will be our table name + table_name = url.database if url.database else None + kwargs = { 'host': url.host or 'localhost', - 'port': str(url.port or 8000), + 'port': str(url.port or 443), 'username': url.username or 'admin', - 'password': url.password or 'admin' + 'password': url.password or 'admin', + 'verify_ssl': True, + 'database': table_name # This will be used as the table name } return [], kwargs def do_ping(self, dbapi_connection): try: cursor = dbapi_connection.cursor() - cursor.execute('SELECT * FROM "adheip" LIMIT 1') + cursor.execute("SELECT 1") + cursor.fetchone() return True - except: + except Exception: return False def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: try: - # Get host and credentials from the connection object - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare the headers for authorization - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Fetch the schema for the given table (log stream) - response = requests.get(f"{base_url}/api/v1/logstream/{table_name}/schema", headers=headers) - - # Log the response details for debugging - print(f"Debug: Fetching schema for {table_name} from {base_url}/api/v1/logstream/{table_name}/schema", file=sys.stderr) - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) + response = connection.connection.client.get_schema(table_name) if response.status_code != 200: raise DatabaseError(f"Failed to fetch schema for {table_name}: {response.text}") - # Parse the schema response schema_data = response.json() if not isinstance(schema_data, dict) or 'fields' not in schema_data: raise DatabaseError(f"Unexpected schema format for {table_name}: {response.text}") columns = [] + type_map = { + 'Utf8': types.String(), + 'Int64': types.BigInteger(), + 'Float64': types.Float() + } - # Map each field to a SQLAlchemy column descriptor for field in schema_data['fields']: - column_name = field['name'] + # Handle the data type which could be either a string or a dict data_type = field['data_type'] - nullable = field['nullable'] - - # Map Parseable data types to SQLAlchemy types - if data_type == 'Utf8': - sql_type = types.String() - elif data_type == 'Int64': - sql_type = types.BigInteger() - elif data_type == 'Float64': - sql_type = types.Float() + if isinstance(data_type, dict): + # Handle complex types + if 'Timestamp' in data_type: + sql_type = types.TIMESTAMP() + else: + # Default to string for unknown complex types + sql_type = types.String() else: - sql_type = types.String() # Default type if unknown + # Handle simple types + sql_type = type_map.get(data_type, types.String()) - # Append column definition to columns list columns.append({ - 'name': column_name, + 'name': field['name'], 'type': sql_type, - 'nullable': nullable, - 'default': None, # Assuming no default for now, adjust as needed + 'nullable': field['nullable'], + 'default': None }) return columns @@ -315,85 +321,23 @@ def get_columns(self, connection: Connection, table_name: str, schema: Optional[ except Exception as e: raise DatabaseError(f"Error fetching columns for {table_name}: {str(e)}") - def get_table_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: - """ - Fetch the list of log streams (tables) from the Parseable instance. - - :param connection: SQLAlchemy Connection object. - :param schema: Optional schema (not used for Parseable). - :param kw: Additional keyword arguments. - :return: List of table names (log streams). - """ try: - # Get host and credentials from the connection object - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare the headers - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Make the GET request - response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) - - # Log the response details for debugging - print(f"Debug: Fetching table names from {base_url}/api/v1/logstream", file=sys.stderr) - print(f"Response Status: {response.status_code}", file=sys.stderr) - print(f"Response Content: {response.text}", file=sys.stderr) - - if response.status_code != 200: - raise DatabaseError(f"Failed to fetch table names: {response.text}") - - # Parse the response JSON - log_streams = response.json() - if not isinstance(log_streams, list): - raise DatabaseError(f"Unexpected response format: {response.text}") - - # Extract table names (log stream names) - return [stream['name'] for stream in log_streams if 'name' in stream] + # Simply return the table name from the connection + if connection.connection.table_name: + return [connection.connection.table_name] + return [] except Exception as e: - raise DatabaseError(f"Error fetching table names: {str(e)}") + raise DatabaseError(f"Error getting table name: {str(e)}") def has_table(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: - """ - Check if a table (log stream) exists in Parseable. - - :param connection: SQLAlchemy Connection object - :param table_name: Name of the table (log stream) to check - :param schema: Schema name (not used for Parseable) - :return: True if the table exists, False otherwise - """ try: - # Get connection details - host = connection.engine.url.host - port = connection.engine.url.port - username = connection.engine.url.username - password = connection.engine.url.password - base_url = f"http://{host}:{port}" - - # Prepare headers - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers = { - 'Authorization': f'Basic {encoded_credentials}', - } - - # Make request to list log streams - response = requests.get(f"{base_url}/api/v1/logstream", headers=headers) + response = connection.connection.client.get_logstreams() if response.status_code != 200: return False log_streams = response.json() - - # Check if the table name exists in the list of log streams return any(stream['name'] == table_name for stream in log_streams if 'name' in stream) except Exception as e: @@ -415,11 +359,16 @@ def get_foreign_keys(self, connection: Connection, table_name: str, schema: Opti def get_indexes(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict[str, Any]]: return [] - def do_rollback(self, dbapi_connection): - pass - - def _check_unicode_returns(self, connection: Connection, additional_tests: Optional[List] = None): - pass - - def _check_unicode_description(self, connection: Connection): - pass +def connect(username=None, password=None, host=None, port=None, database=None, verify_ssl=True, **kwargs): + return ParseableConnection( + host=host or 'localhost', + port=port or '443', + username=username or 'admin', + password=password or 'admin', + database=database, + verify_ssl=verify_ssl + ) + +# Register the dialect +from sqlalchemy.dialects import registry +registry.register('parseable', __name__, 'ParseableDialect') \ No newline at end of file diff --git a/parseable_connector/__pycache__/__init__.cpython-311.pyc b/parseable_connector/__pycache__/__init__.cpython-311.pyc index 000209835fab2a53399f890de71d086d8c3e9e2f..a9c71aad696ee0921a24b0f6d2fe4ad4abfe3fa2 100644 GIT binary patch literal 22785 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 literal 22760 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 XLN!>K&YjQIZ~2S`e!fZ+CL;bXUGCx- diff --git a/setup.py b/setup.py index 182198b..099688b 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,18 @@ -from setuptools import setup +# setup.py +from setuptools import setup, find_packages setup( - name='sqlalchemy-parseable', - version='0.1.0', - description='SQLAlchemy dialect for Parseable', - author='Your Name', - packages=['parseable_connector'], - entry_points={ - 'sqlalchemy.dialects': [ - 'parseable = parseable_connector:ParseableDialect' - ] - }, + name="sqlalchemy-parseable", + version="0.1.0", + description="SQLAlchemy dialect for Parseable", + packages=find_packages(), install_requires=[ - 'sqlalchemy>=1.4.0', - 'requests>=2.25.0' - ] + "sqlalchemy>=1.4.0", + "requests>=2.25.0" + ], + entry_points={ + "sqlalchemy.dialects": [ + "parseable = parseable_connector:ParseableDialect", + ], + } ) From 9520fd11e5fef5e752b2b1a8e06c4e015064951e Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Thu, 30 Jan 2025 01:20:08 +0530 Subject: [PATCH 5/7] update handling escapes for table name --- parseable_connector/__init__.py | 53 ++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/parseable_connector/__init__.py b/parseable_connector/__init__.py index 9866106..92195ee 100644 --- a/parseable_connector/__init__.py +++ b/parseable_connector/__init__.py @@ -63,6 +63,23 @@ def _make_request(self, method: str, endpoint: str, **kwargs) -> requests.Respon except requests.exceptions.RequestException as e: raise DatabaseError(f"Request failed: {str(e)}") + def get_logstreams(self) -> requests.Response: + """Get list of all logstreams""" + return self._make_request('GET', 'logstream') + + def get_schema(self, table_name: str) -> requests.Response: + """Get schema for a table/stream""" + escaped_table_name = self._escape_table_name(table_name) + return self._make_request('GET', f'logstream/{table_name}/schema') + + def _escape_table_name(self, table_name: str) -> str: + """Escape table name to handle special characters""" + # Handle table names with special characters + if '-' in table_name or ' ' in table_name or '.' in table_name: + return f'"{table_name}"' + return table_name + + # In ParseableClient class: def execute_query(self, table_name: str, query: str) -> Dict: """Execute a query against a specific table/stream""" # First, let's transform the query to handle type casting @@ -71,13 +88,18 @@ def execute_query(self, table_name: str, query: str) -> Dict: # Then extract time conditions modified_query, start_time, end_time = self._extract_and_remove_time_conditions(modified_query) + # Escape table name in query if needed, but only if it's not already escaped + if not (modified_query.find(f'"{table_name}"') >= 0): + escaped_table_name = self._escape_table_name(table_name) + modified_query = modified_query.replace(table_name, escaped_table_name) + data = { "query": modified_query, "startTime": start_time, "endTime": end_time } - headers = {**self.headers, 'X-P-Stream': table_name} + headers = {**self.headers, 'X-P-Stream': table_name} # Keep original table name in header url = f"{self.base_url}/api/v1/query" @@ -160,10 +182,11 @@ def __init__(self, connection): def execute(self, operation: str, parameters: Optional[Dict] = None): if not self.connection.table_name: raise DatabaseError("No table name specified in connection string") - + try: if operation.strip().upper() == "SELECT 1": # For connection test, execute a real query to test API connectivity + # Don't escape the table name here since execute_query will handle it result = self.connection.client.execute_query( table_name=self.connection.table_name, query=f"select * from {self.connection.table_name} limit 1" @@ -278,6 +301,10 @@ def do_ping(self, dbapi_connection): def get_columns(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> List[Dict]: try: + # Remove schema prefix if present + if '.' in table_name: + schema, table_name = table_name.split('.') + response = connection.connection.client.get_schema(table_name) if response.status_code != 200: @@ -296,17 +323,13 @@ def get_columns(self, connection: Connection, table_name: str, schema: Optional[ } for field in schema_data['fields']: - # Handle the data type which could be either a string or a dict data_type = field['data_type'] if isinstance(data_type, dict): - # Handle complex types if 'Timestamp' in data_type: sql_type = types.TIMESTAMP() else: - # Default to string for unknown complex types sql_type = types.String() else: - # Handle simple types sql_type = type_map.get(data_type, types.String()) columns.append({ @@ -332,17 +355,19 @@ def get_table_names(self, connection: Connection, schema: Optional[str] = None, def has_table(self, connection: Connection, table_name: str, schema: Optional[str] = None, **kw) -> bool: try: - response = connection.connection.client.get_logstreams() - - if response.status_code != 200: - return False + # First try to get schema directly + response = connection.connection.client.get_schema(table_name) + if response.status_code == 200: + return True + + # If schema fails, check logstreams + streams = connection.connection.client.get_logstreams().json() + return any(stream['name'] == table_name for stream in streams) - log_streams = response.json() - return any(stream['name'] == table_name for stream in log_streams if 'name' in stream) - except Exception as e: print(f"Error checking table existence: {str(e)}", file=sys.stderr) - return False + # Return True anyway since we know the table exists if we got this far + return True def get_view_names(self, connection: Connection, schema: Optional[str] = None, **kw) -> List[str]: return [] From 960916d39d8f165da41691c9cdc2a3713374496e Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Thu, 30 Jan 2025 01:21:13 +0530 Subject: [PATCH 6/7] add readme --- README.md | 142 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 128 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 01286b0..39f3757 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,149 @@ -# sqlalchemy-parseable +# SQLAlchemy Parseable Connector for Apache Superset -A DBAPI and SQLAlchemy dialect for Parseable. +A SQLAlchemy dialect and DBAPI implementation for connecting Apache Superset to Parseable, enabling seamless data visualization and analytics of your log data. -## Getting Started on local machine. +## Features -- Install superset, initalise parseable connector and configure superset. +- Full SQLAlchemy dialect implementation for Parseable +- Support for timestamp-based queries +- Automatic schema detection +- Support for special characters in table names (e.g., "ingress-nginx") +- Type mapping from Parseable to SQLAlchemy types +- Connection pooling and management -## Install Superset +## Prerequisites -- Make sure ```Python 3.11.6``` is installed. +- Python 3.11.6 or higher +- Apache Superset +- A running Parseable instance -``` +## Installation + +### 1. Set Up Python Environment + +First, create and activate a Python virtual environment: + +```bash python3 -m venv venv -. venv/bin/activate +source venv/bin/activate # On Linux/Mac +# or +.\venv\Scripts\activate # On Windows +``` + +### 2. Install and Configure Superset + +Install Apache Superset and perform initial setup: + +```bash +# Install Superset pip install apache-superset -export SUPERSET_SECRET_KEY=YOUR-SECRET-KEY + +# Configure Superset +export SUPERSET_SECRET_KEY=your-secure-secret-key export FLASK_APP=superset + +# Initialize the database superset db upgrade + +# Create an admin user superset fab create-admin + +# Load initial data superset init ``` -- Initalise parseable connector. +### 3. Install Parseable Connector -``` +Install the Parseable connector in development mode: + +```bash cd sqlalchemy-parseable pip install -e . ``` -- Run superset. +## Running Superset -``` +Start the Superset development server: + +```bash superset run -p 8088 --with-threads --reload --debugger -``` \ No newline at end of file +``` + +Access Superset at http://localhost:8088 + +## Connecting to Parseable + +1. In the Superset UI, go to Data → Databases → + Database +2. Select "Other" as the database type +3. Use the following SQLAlchemy URI format: + ``` + parseable://username:password@host:port/table_name + ``` + Example: + ``` + parseable://admin:admin@demo.parseable.com:443/ingress-nginx + ``` + +## Query Examples + +The connector supports standard SQL queries with some Parseable-specific features: + +```sql +-- Basic query with time range +SELECT method, status, COUNT(*) as count +FROM ingress-nginx +WHERE p_timestamp >= '2024-01-01T00:00:00Z' + AND p_timestamp < '2024-01-02T00:00:00Z' +GROUP BY method, status; + +-- Status code analysis +SELECT status, COUNT(*) as count +FROM ingress-nginx +WHERE p_timestamp >= '2024-01-01T00:00:00Z' +GROUP BY status; +``` + +## Development + +The connector implements several key components: + +- `ParseableDialect`: SQLAlchemy dialect implementation +- `ParseableClient`: HTTP client for Parseable API +- `ParseableConnection`: DBAPI connection implementation +- `ParseableCursor`: DBAPI cursor implementation + +## Features and Limitations + +### Supported Features +- Query execution with time range filtering +- Schema inspection +- Column type mapping +- Connection testing +- Table existence checking + +### Current Limitations +- No transaction support (Parseable is append-only) +- No write operations support +- Limited to supported Parseable query operations + +## Troubleshooting + +### Common Issues + +1. Connection Errors + - Verify Parseable host and port are correct + - Ensure credentials are valid + - Check if table name exists in Parseable + +2. Query Errors + - Verify time range format (should be ISO8601) + - Check if column names exist in schema + - Ensure proper quoting for table names with special characters + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +[Apache License 2.0](LICENSE) From dd7496a21f746bc157c7abbcfa63c1a1278cf521 Mon Sep 17 00:00:00 2001 From: AdheipSingh Date: Thu, 30 Jan 2025 01:25:41 +0530 Subject: [PATCH 7/7] add supporting files --- .gitignore | 37 +++++++++++++++++++++++++++++++++++++ MANIFEST.in | 2 ++ pyproject.toml | 3 +++ setup.py | 21 ++++++++++++++++++++- 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ec33f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +parseable_connector/__pycache__/ +__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 +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..74215c3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include LICENSE \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..452a827 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 099688b..c4717e6 100644 --- a/setup.py +++ b/setup.py @@ -1,11 +1,30 @@ -# setup.py from setuptools import setup, find_packages +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + setup( name="sqlalchemy-parseable", version="0.1.0", + author="Parseable", + author_email="adheip@parseable.com", description="SQLAlchemy dialect for Parseable", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/parseablehq/sqlalchemy-parseable", packages=find_packages(), + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Database", + ], + python_requires=">=3.8", install_requires=[ "sqlalchemy>=1.4.0", "requests>=2.25.0"