diff --git a/backend/.dockerignore b/backend/.dockerignore index 72ce45c..ca0a98a 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,9 +1,8 @@ # .dockerignore (in ./backend directory) .venv __pycache__ +src/__pycache__/ *.pyc *.pyo .pytest_cache .coverage -# Add any other files/folders specific to your project -# that shouldn't be copied into the Docker image \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 71e080e..1dd7d41 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,9 +16,10 @@ WORKDIR /app # Copy dependency definition files and your submodule source code # The submodule needs to be at /app/sqlglot-vql for the path dependency to work COPY --chown=appuser:appgroup ./pyproject.toml ./uv.lock* ./ +COPY --chown=appuser:appgroup ./README.md ./ COPY --chown=appuser:appgroup ./sqlglot-vql ./sqlglot-vql/ # Copy the rest of your application files -COPY --chown=appuser:appgroup ./README.md ./main.py ./ +COPY --chown=appuser:appgroup ./src ./src/ # Install all project dependencies, including sqlglot-vql from the local path # uv will read pyproject.toml, see the file:// reference, and build/install sqlglot-vql @@ -29,4 +30,4 @@ RUN uv pip install --system --no-cache-dir . USER appuser EXPOSE 5000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"] \ No newline at end of file +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "5000"] \ No newline at end of file diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 7760bf6..0000000 --- a/backend/main.py +++ /dev/null @@ -1,483 +0,0 @@ -import os -from typing import List, Dict, Any, Optional -import logging - -from pydantic_ai import Agent, RunContext -from fastapi import FastAPI, HTTPException, status -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field - -import sqlalchemy as db -from sqlalchemy import text -from sqlalchemy.exc import SQLAlchemyError, ProgrammingError, OperationalError - -import sqlglot -from sqlglot import exp, parse_one -from sqlglot.errors import ParseError - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) -DENODO_HOST = os.getenv("DENODO_HOST") -DENODO_PORT = 9996 -DENODO_DATABASE = os.getenv("DENODO_DB") -DENODO_USERNAME = os.getenv("DENODO_USER") -DENODO_PASSWORD = os.getenv("DENODO_PW") - -# --- SQLAlchemy Engine Setup --- -DATABASE_URL = f"denodo://{DENODO_USERNAME}:{DENODO_PASSWORD}@{DENODO_HOST}:{DENODO_PORT}/{DENODO_DATABASE}" - -try: - engine = db.create_engine(DATABASE_URL) - with engine.connect() as connection: - print("Successfully connected to Denodo.") -except SQLAlchemyError as e: - print(f"FATAL: Could not connect to Denodo database: {e}") - exit(1) - engine = None # Set engine to None if connection fails -except ImportError as e: - print("FATAL: Could not import Denodo driver. Make sure it's installed.") - print(f"ImportError: {e}") - exit(1) - engine = None - - -# --- FastAPI Application --- -app = FastAPI( - title="VQLForge Backend", - description="The backend to transpile and validate SQL to VQL", - version="1.0.0", -) -# --- CORS Configuration --- -origins = [ - "http://localhost:4999", # The origin of your frontend app - "http://127.0.0.1:4999", # Sometimes needed as well - # Add other origins if deployed (e.g., "https://your-frontend-domain.com") -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, # Allows specific origins - allow_credentials=True, # Allows cookies (if applicable) - allow_methods=["*"], # Allows all methods (GET, POST, OPTIONS, etc.) - allow_headers=["*"], # Allows all headers (including Content-Type) -) -# --- End CORS Configuration --- -# --- Pydantic Models --- - - -class SqlQueryRequest(BaseModel): - sql: str = Field(..., example="SELECT count(*) AS total FROM some_view") - dialect: str - vdb: str - - -class VqlValidateRequest(BaseModel): - sql: str - vql: str - - -class VqlQueryResponse(BaseModel): - vql: str - - -class QueryResultRow(BaseModel): - # Using Dict[str, Any] for flexibility as column types can vary - row: Dict[str, Any] - - -class QueryResponse(BaseModel): - results: List[Dict[str, Any]] - parsed_ast: str | None = None # Optionally return the AST - message: str | None = None - - -class TranslationError(BaseModel): - explanation: str - sql_suggestion: str - - -class ValidationError(BaseModel): - explanation: str - sql_suggestion: str - - -class VqlValidationApiResponse(BaseModel): - validated: bool - error_analysis: Optional[ValidationError] = ( - None # Still expects ValidationError type - ) - message: Optional[str] = None - - -class TranslateApiResponse(BaseModel): - vql: str | None = None - error_analysis: TranslationError | None = None - message: str | None = None - - -class HealthCheck(BaseModel): - """Response model to validate and return when performing a health check.""" - - status: str = "OK" - - -# --- API Endpoint --- - -def _initialize_agent(system_prompt: str, output_type: type) -> Agent: - """Initializes a Pydantic AI Agent with the specified parameters.""" - google_api_key = os.getenv("GEMINI_API_KEY") - if not google_api_key: - logger.error("GEMINI_API_KEY environment variable not set.") - # Raise an exception that the main endpoint can catch - raise HTTPException( - status_code=500, detail="AI service configuration error: API key missing." - ) - # Assuming 'gemini-2.5-flash-preview-04-17' is the desired model for both - return Agent( - "gemini-2.5-flash-preview-04-17", - system_prompt=system_prompt, - output_type=output_type, - ) - - -def transform_vdb(node: exp.Expression, vdb_name: str) -> exp.Expression: - """Recursively prefixes unqualified tables in a SQL expression with a VDB name. - - This acts as a sqlglot transformer function. - - Args: - node: The current sqlglot Expression node being traversed. - vdb_name: The VDB name to use as the database/catalog qualifier. - - Returns: - The potentially modified Expression node. - """ - if isinstance(node, exp.Table): - is_qualified = bool( - node.db or node.catalog or node.args.get("db") or node.args.get("catalog") - ) - - if not is_qualified and isinstance(node.this, exp.Identifier): - logger.debug(f"Transforming table: {node.sql()} -> Adding db: {vdb_name}") - new_node = node.copy() - new_node.set( - "db", exp.Identifier(this=vdb_name, quoted=False) - ) # Assume VDB names aren't typically quoted - return new_node - - return node - - -def analyze_validation_err(error: str, sql: str) -> ValidationError: - """ - Analyzes VQL validation errors using an AI agent. - Provides an explanation for the error and suggests a corrected VQL query based on the input error and SQL. - Uses tools to fetch available views/functions if needed. - - Args: - error: The error message string returned by the VQL validation process. - sql: The original VQL query string that failed validation. - - Returns: - A ValidationError object containing the AI-generated explanation and - suggested corrected SQL. - - Raises: - HTTPException: - - 500: If the GEMINI_API_KEY environment variable is not set. - - 503: If the AI agent returns an invalid or unexpected response. - - 503: If there's an error communicating with the AI service. - """ - agent = _initialize_agent( - "You are an SQL Validation assistant", ValidationError - ) - - @agent.tool - def get_views(ctx: RunContext[str]) -> list[str]: - # This is a placeholder. Later, this would query Denodo metadata. - # For now, returning static list. - return ["this", "is", "a", "placeholder", "test3"] - - @agent.tool - def get_denodo_functions(ctx: RunContext[str]) -> list[str]: - if engine is None: - raise HTTPException( - status_code=503, # Service Unavailable - detail="Database connection is not available. Check server logs.", - ) - vql = f"list functions" - try: - with engine.connect() as connection: - query = text(vql) - result = connection.execute(query) - functions: list[str] = [row[2] for row in result] - - logger.info(f"Successfully retrieved VDB names: {functions}") - return functions - except Exception as e: - # Log the actual exception for debugging - logger.error(f"Error executing VQL query '{vql}' to get functions: {e}", exc_info=True) - # Raise an HTTP exception that is more user-friendly for the API client - raise HTTPException( - status_code=500, # Internal Server Error for query execution failures - detail=f"Failed to retrieve functions from Denodo: {str(e)}", - ) - - @agent.tool - def get_vdbs(ctx: RunContext[str]) -> list[str]: - if engine is None: - raise HTTPException( - status_code=503, # Service Unavailable - detail="Database connection is not available. Check server logs.", - ) - vql = f"select db_name from get_databases()" - try: - with engine.connect() as connection: - query = text(vql) - result = connection.execute(query) - db_names: list[str] = [row.db_name for row in result] - - logger.info(f"Successfully retrieved VDB names: {db_names}") - return db_names - except Exception as e: - # Log the actual exception for debugging - logger.error(f"Error executing VQL query '{vql}' to get VDBs: {e}", exc_info=True) - # Raise an HTTP exception that is more user-friendly for the API client - raise HTTPException( - status_code=500, # Internal Server Error for query execution failures - detail=f"Failed to retrieve VDB list from the database: {str(e)}", - ) - - prompt: str = f"""Analyze the VQL Validation error. Explain concisely why the `Input VQL` failed based on the `Error` and provide the corrected `Valid SQL`. - Do not use ```sql markdown for the corrected SQL response. Do not explain what you are doing, just provide the explanation and the suggestion directly. - If the table is missing, use the get_views to determine which tables are available and use the best guess in your suggestion. - If a function is not valid, use get_denodo_functions to check for available denodo functions. - If a database name is invalid, use get_vdbs to check for database names. Suggest one that is similar to the input or tell the user to double check the input. - **ERROR:** - {error} - **Input SQL:** - ```sql - {sql}```""" - try: - response = agent.run_sync(prompt) - if response and response.output: - logger.info(f"AI Analysis Explanation: {response.output.explanation}") - logger.info(f"AI Analysis Suggestion: {response.output.sql_suggestion}") - return response.output - else: - logger.error(f"AI agent returned unexpected response: {response}") - raise HTTPException( - status_code=503, detail="AI service returned an invalid response." - ) - - except Exception as agent_error: - logger.error(f"Error calling AI Agent: {agent_error}", exc_info=True) - # Raise an HTTPException to be caught by FastAPI and return a 5xx error - raise HTTPException( - status_code=503, detail=f"AI service unavailable or failed: {agent_error}" - ) - - -def analyze_translation_err(exception: str, sql: str) -> TranslationError: - """Analyzes an SQL translation error using an AI agent to provide an explanation and suggestion. - - Args: - exception: The error message string from the SQL execution attempt. - sql: The original SQL query string that caused the error. - - Returns: - A TranslationError object containing the AI's explanation and suggested SQL correction. - - Raises: - HTTPException: If the AI API key is not configured, the AI service returns - an invalid response, or the AI service call fails. - """ - agent = _initialize_agent( - "You are an SQL Translation assistant", TranslationError - ) - - prompt = f"""Analyze the SQL error below. Explain concisely why the `Input SQL` failed based on the `Error` and provide the corrected `Valid SQL`. - Do not use ```sql markdown for the corrected SQL response. Do not explain what you are doing, just provide the explanation and the suggestion directly. - **ERROR:** - {exception} - **Input SQL:** - ```sql - {sql}```""" - - try: - response = agent.run_sync(prompt) - if response and response.output: - logger.info(f"AI Analysis Explanation: {response.output.explanation}") - logger.info(f"AI Analysis Suggestion: {response.output.sql_suggestion}") - return response.output - else: - logger.error(f"AI agent returned unexpected response: {response}") - raise HTTPException( - status_code=503, detail="AI service returned an invalid response." - ) - - except Exception as agent_error: - logger.error(f"Error calling AI Agent: {agent_error}", exc_info=True) - # Raise an HTTPException to be caught by FastAPI and return a 5xx error - raise HTTPException( - status_code=503, detail=f"AI service unavailable or failed: {agent_error}" - ) - - -@app.get( - "/health", - tags=["healthcheck"], - summary="Perform a Health Check", - response_description="Return HTTP Status Code 200 (OK)", - status_code=status.HTTP_200_OK, - response_model=HealthCheck, -) -def get_health() -> HealthCheck: - """ - ## Perform a Health Check - Endpoint to perform a healthcheck on. This endpoint can primarily be used Docker - to ensure a robust container orchestration and management is in place. Other - services which rely on proper functioning of the API service will not deploy if this - endpoint returns any other HTTP status code except 200 (OK). - Returns: - HealthCheck: Returns a JSON response with the health status - """ - return HealthCheck(status="OK") - - -@app.post("/validate") -def validate_vql_query(request: VqlValidateRequest) -> VqlValidationApiResponse: - """ - Validates VQL syntax against Denodo using DESC QUERYPLAN. - Provides AI analysis if validation fails with a known error type. - """ - if engine is None: - raise HTTPException( - status_code=503, # Service Unavailable - detail="Database connection is not available. Check server logs.", - ) - - vql: str = request.vql - vql = f"DESC QUERYPLAN {vql}" - - try: - with engine.connect() as connection: - query = text(request.vql) - # We don't actually need the results, just whether it throws an error - connection.execute(query) - logger.info("VQL validation successful via DESC QUERYPLAN.") - return VqlValidationApiResponse( - validated=True, - error_analysis=None, - message="VQL syntax check successful!", - ) - - except (OperationalError, ProgrammingError) as e: - db_error_message = str(getattr(e, "orig", e)) # Get specific DB error - logger.warning(f"Denodo VQL validation failed: {db_error_message}") - try: - ai_analysis_result: ValidationError = analyze_validation_err( - db_error_message, request.vql - ) - return VqlValidationApiResponse( - validated=False, error_analysis=ai_analysis_result, message=None - ) - except HTTPException as http_exc: - logger.error( - f"AI analysis failed during validation error handling: {http_exc.detail}" - ) - return VqlValidationApiResponse( - validated=False, - error_analysis=None, - message=f"Validation Failed: {db_error_message}. Additionally, AI analysis failed: {http_exc.detail}", - ) - except Exception as ai_err: # Catch unexpected errors during AI call - logger.error( - f"Unexpected error during AI validation analysis: {ai_err}", - exc_info=True, - ) - return VqlValidationApiResponse( - validated=False, - error_analysis=None, - message=f"Validation Failed: {db_error_message}. Additionally, an unexpected error occurred during AI analysis.", - ) - - except SQLAlchemyError as e: - logger.error( - f"Database connection or general SQLAlchemy error during validation: {e}", - exc_info=True, - ) - # Return validation failed with a generic DB error message - return VqlValidationApiResponse( - validated=False, - error_analysis=None, - message=f"Database error during validation: {str(e)}", - ) - - except Exception as e: - logger.error(f"Unexpected error during VQL validation: {e}", exc_info=True) - # Return validation failed with a generic unexpected error message - return VqlValidationApiResponse( - validated=False, - error_analysis=None, - message=f"An unexpected error occurred during validation: {str(e)}", - ) - - -@app.post("/translate") -def translate_sql(request: SqlQueryRequest) -> TranslateApiResponse: - source_sql = request.sql - dialect: str = request.dialect - vdb: str = request.vdb - if not source_sql: - raise HTTPException(status_code=400, detail="Missing 'sql' in request body") - if not dialect: - raise HTTPException(status_code=400, detail="Missing 'dialect' in request body") - print(f"Using sqlglot version: {sqlglot.__version__}") - logger.debug(f"Received translation request: dialect='{dialect}', vdb='{vdb}'") - logger.debug(f"Source SQL: {source_sql}") - - try: - # Example: Simple uppercase conversion - converted_vql = f"-- VQL Conversion of:\n{source_sql.upper()}" - expression_tree = parse_one(source_sql, dialect=dialect) - if vdb: - expression_tree = expression_tree.transform(transform_vdb, vdb) - converted_vql = expression_tree.sql(dialect="denodo", pretty=True) - - print(f"Received SQL: {source_sql}") - print(f"Returning VQL: {converted_vql}") - print(f"{dialect}") - return TranslateApiResponse(vql=converted_vql) - except ParseError as pe: - print(f"SQL Parsing Error during translation: {pe}") - try: - # analyze_translation_err still returns a TranslationError instance - ai_analysis_result: TranslationError = analyze_translation_err( - str(pe), source_sql - ) - # Return the error analysis case within the new model - return TranslateApiResponse(error_analysis=ai_analysis_result) - except ( - HTTPException - ) as http_exc: # If AI service itself fails (e.g., key error) - # Let FastAPI handle this HTTP Exception directly - raise http_exc - except Exception as ai_err: # Catch unexpected errors during the AI call - print(f"Error during AI analysis: {ai_err}") - # Return a generic error message using the new model's message field - # Alternatively, raise HTTPException(status_code=500, detail=f"AI Analysis Error: {str(ai_err)}") - return TranslateApiResponse( - message=f"An error occurred during AI analysis: {str(ai_err)}" - ) - - except Exception as e: # Catch other general exceptions during translation - print(f"General Error during translation: {e}") - # Return a generic error message using the new model's message field - # Alternatively, raise HTTPException(status_code=500, detail=f"Translation Error: {str(e)}") - return TranslateApiResponse(message=f"Translation failed: {str(e)}") - - -@app.get("/") -def read_root(): - return {"message": "Hello!"} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 802a520..9d855d3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "vqlforge" version = "0.1.0" -description = "Add your description here" +description = "VQLForge Backend" readme = "README.md" requires-python = ">=3.12" dependencies = [ @@ -18,8 +18,6 @@ dependencies = [ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -include = [ - "main.py", -] +packages = ["src"] [tool.hatch.metadata] allow-direct-references = true \ No newline at end of file diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/health.py b/backend/src/api/health.py new file mode 100644 index 0000000..1e41874 --- /dev/null +++ b/backend/src/api/health.py @@ -0,0 +1,17 @@ +# src/api/endpoints/health.py +from fastapi import APIRouter, status +from src.schemas.common import HealthCheck + +router = APIRouter() + + +@router.get( + "/health", + tags=["healthcheck"], + summary="Perform a Health Check", + response_description="Return HTTP Status Code 200 (OK)", + status_code=status.HTTP_200_OK, + response_model=HealthCheck, +) +def get_health_status() -> HealthCheck: + return HealthCheck(status="OK") diff --git a/backend/src/api/router.py b/backend/src/api/router.py new file mode 100644 index 0000000..51d61c5 --- /dev/null +++ b/backend/src/api/router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter +from src.api import health, translate, validate + +api_router = APIRouter() +api_router.include_router(health.router) # /health +api_router.include_router(translate.router) # /translate +api_router.include_router(validate.router) # /validate diff --git a/backend/src/api/translate.py b/backend/src/api/translate.py new file mode 100644 index 0000000..f3e20a0 --- /dev/null +++ b/backend/src/api/translate.py @@ -0,0 +1,53 @@ +# src/api/endpoints/translate.py +import logging +import sqlglot +from sqlglot import parse_one +from sqlglot.errors import ParseError +from fastapi import APIRouter, HTTPException + +from src.schemas.translation import SqlQueryRequest, TranslateApiResponse +from src.utils.ai_analyzer import analyze_sql_translation_error +from src.utils.vdb_transformer import transform_vdb_table_qualification # Updated import + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/translate", response_model=TranslateApiResponse, tags=["VQL Forge"]) +def translate_sql_to_vql(request: SqlQueryRequest) -> TranslateApiResponse: + source_sql = request.sql + dialect: str = request.dialect + vdb: str = request.vdb + + if not source_sql: + raise HTTPException(status_code=400, detail="Missing 'sql' in request body") + if not dialect: + raise HTTPException(status_code=400, detail="Missing 'dialect' in request body") + + logger.info(f"SQLGlot version: {sqlglot.__version__}") + logger.debug(f"Translation request: dialect='{dialect}', vdb='{vdb}', SQL='{source_sql[:100]}...'") + + try: + expression_tree = parse_one(source_sql, dialect=dialect) + if vdb: # Only apply transformation if vdb is provided + expression_tree = expression_tree.transform(transform_vdb_table_qualification, vdb) + converted_vql = expression_tree.sql(dialect="denodo", pretty=True) + + logger.info(f"Successfully translated SQL to VQL. VQL: {converted_vql[:100]}...") + return TranslateApiResponse(vql=converted_vql) + + except ParseError as pe: + logger.warning(f"SQL Parsing Error during translation: {pe}", exc_info=True) + try: + ai_analysis_result = analyze_sql_translation_error(str(pe), source_sql) + return TranslateApiResponse(error_analysis=ai_analysis_result) + except HTTPException as http_exc: # AI service's own HTTPExceptions (e.g. API key) + raise http_exc + except Exception as ai_err: # Other errors from AI service call + logger.error(f"Error during AI analysis for translation parse error: {ai_err}", exc_info=True) + return TranslateApiResponse(message=f"SQL parsing failed: {pe}. AI analysis also failed: {ai_err}") + + except Exception as e: + logger.error(f"General Error during SQL translation: {e}", exc_info=True) + # Optionally, try AI analysis for generic errors too, or just return a generic message + return TranslateApiResponse(message=f"Translation failed due to an unexpected error: {str(e)}") diff --git a/backend/src/api/validate.py b/backend/src/api/validate.py new file mode 100644 index 0000000..a39448b --- /dev/null +++ b/backend/src/api/validate.py @@ -0,0 +1,65 @@ +# src/api/endpoints/validate.py +import logging +from fastapi import APIRouter, HTTPException +from sqlalchemy import text +from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError + +from src.schemas.validation import VqlValidateRequest, VqlValidationApiResponse +from src.utils.ai_analyzer import analyze_vql_validation_error +from src.db.session import get_engine # Use the centralized engine + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/validate", response_model=VqlValidationApiResponse, tags=["VQL Forge"]) +def validate_vql_query_endpoint(request: VqlValidateRequest) -> VqlValidationApiResponse: + engine = get_engine() # Get engine, will raise ConnectionError if not init + if engine is None: # Should be caught by get_engine, but as a safeguard + raise HTTPException( + status_code=503, + detail="Database connection is not available. Check server logs.", + ) + + # The original request.sql might be the SQL that was translated to VQL. + # The request.vql is what we are validating. + # For AI analysis, we probably want to pass the VQL that failed. + desc_query_plan_vql: str = f"DESC QUERYPLAN {request.vql}" + logger.info(f"Attempting to validate VQL (via DESC QUERYPLAN): {request.vql[:100]}...") + + try: + with engine.connect() as connection: + # DESC QUERYPLAN doesn't return rows on success, just executes + connection.execute(text(desc_query_plan_vql)) + logger.info("VQL validation successful via DESC QUERYPLAN.") + return VqlValidationApiResponse( + validated=True, + message="VQL syntax check successful!", + ) + except (OperationalError, ProgrammingError) as e: + db_error_message = str(getattr(e, "orig", e)) # Get specific DB error + logger.warning(f"Denodo VQL validation failed: {db_error_message}") + try: + ai_analysis_result = analyze_vql_validation_error(db_error_message, request.vql) + return VqlValidationApiResponse( + validated=False, error_analysis=ai_analysis_result + ) + except HTTPException as http_exc: # AI service's own HTTPExceptions + logger.error(f"AI analysis failed during validation handling: {http_exc.detail}") + raise http_exc # Re-raise if it's an issue like API key + except Exception as ai_err: + logger.error(f"Unexpected error during AI validation analysis: {ai_err}", exc_info=True) + return VqlValidationApiResponse( + validated=False, + message=f"Validation Failed: {db_error_message}. AI analysis also encountered an error: {ai_err}", + ) + except SQLAlchemyError as e: + logger.error(f"Database connection/SQLAlchemy error during validation: {e}", exc_info=True) + return VqlValidationApiResponse( + validated=False, message=f"Database error during validation: {str(e)}" + ) + except Exception as e: + logger.error(f"Unexpected error during VQL validation: {e}", exc_info=True) + return VqlValidationApiResponse( + validated=False, message=f"An unexpected error occurred: {str(e)}" + ) diff --git a/backend/src/config.py b/backend/src/config.py new file mode 100644 index 0000000..4f3c001 --- /dev/null +++ b/backend/src/config.py @@ -0,0 +1,26 @@ +import os +from pydantic_settings import BaseSettings, SettingsConfigDict +from dotenv import load_dotenv + +load_dotenv() # Loads variables from .env file + + +class Settings(BaseSettings): + DENODO_HOST: str + DENODO_PORT: int = 9996 + DENODO_DB: str + DENODO_USER: str + DENODO_PW: str + GEMINI_API_KEY: str + + DATABASE_URL: str | None = None # Will be constructed + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + def __init__(self, **values): + super().__init__(**values) + if not self.DATABASE_URL: # Construct if not manually set + self.DATABASE_URL = f"denodo://{self.DENODO_USER}:{self.DENODO_PW}@{self.DENODO_HOST}:{self.DENODO_PORT}/{self.DENODO_DB}" + + +settings = Settings() diff --git a/backend/src/db/__init__.py b/backend/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/db/session.py b/backend/src/db/session.py new file mode 100644 index 0000000..ab55529 --- /dev/null +++ b/backend/src/db/session.py @@ -0,0 +1,41 @@ +import logging +import sqlalchemy as db +from sqlalchemy.exc import SQLAlchemyError, OperationalError +from sqlalchemy.engine import Engine # For type hinting + +from src.config import settings # Import the settings + +logger = logging.getLogger(__name__) +engine: Engine | None = None + + +def init_db_engine() -> Engine | None: + global engine + if settings.DATABASE_URL is None: + logger.fatal("DATABASE_URL is not configured.") + return None + try: + engine = db.create_engine(settings.DATABASE_URL) + with engine.connect() as connection: + logger.info("Successfully connected to Denodo.") + return engine + except ImportError as e: + logger.fatal(f"Could not import Denodo driver. Make sure it's installed. ImportError: {e}") + engine = None + return None + except SQLAlchemyError as e: + logger.fatal(f"Could not connect to Denodo database: {e}") + engine = None + return None + + +# Initialize the engine when this module is imported +# You might want to delay this if you have conditional DB usage +engine = init_db_engine() + + +def get_engine() -> Engine: + if engine is None: + # This situation should ideally be handled at app startup + raise ConnectionError("Database engine is not initialized.") + return engine diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..20f59b1 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,60 @@ +# src/main.py +import logging +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.api.router import api_router # Import the main router +from src.config import settings # To access settings if needed for app config +from src.db.session import engine as db_engine, init_db_engine # To ensure DB is up + +# Configure logging +logging.basicConfig(level=logging.INFO) # You can make level configurable via settings +logger = logging.getLogger(__name__) + +# Initialize services +if not db_engine: + logger.warning("Database engine not initialized on import. Attempting explicit init.") + # Potentially exit if DB is critical for app startup + # For now, we let endpoints handle `get_engine()` failure if init_db_engine() fails here. + # If init_db_engine() itself raises an unhandled exception or exits, app won't start. + if init_db_engine() is None: + logger.fatal("Application startup failed: Could not connect to the database.") + # Depending on your deployment, you might exit(1) or let it run and fail on requests + # For Kubernetes/Docker, failing to start might be better for restart policies. + # exit(1) # Uncomment if DB is absolutely critical for app to even start + +app = FastAPI( + title="VQLForge Backend", + description="The backend to transpile and validate SQL to VQL", + version="1.0.0", + # Potentially add lifespan events for DB connection pool management if needed +) + +# --- CORS Configuration --- +origins = [ + "http://localhost:4999", + "http://127.0.0.1:4999", + # Add production frontend origins here +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +# --- End CORS Configuration --- + +# Include the main API router +app.include_router(api_router) + +# A simple root endpoint can remain here or be moved to its own router + + +@app.get("/", tags=["Default"]) +def read_root(): + return {"message": "Welcome to VQLForge Backend!"} + +# For UVicorn ASGI server: +# Run with: uv uvicorn src.main:app --reload diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/schemas/common.py b/backend/src/schemas/common.py new file mode 100644 index 0000000..940813f --- /dev/null +++ b/backend/src/schemas/common.py @@ -0,0 +1,16 @@ +from typing import List, Dict, Any +from pydantic import BaseModel + + +class HealthCheck(BaseModel): + status: str = "OK" + + +class QueryResultRow(BaseModel): + row: Dict[str, Any] + + +class QueryResponse(BaseModel): + results: List[Dict[str, Any]] + parsed_ast: str | None = None + message: str | None = None diff --git a/backend/src/schemas/translation.py b/backend/src/schemas/translation.py new file mode 100644 index 0000000..b4746c3 --- /dev/null +++ b/backend/src/schemas/translation.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import BaseModel, Field + + +class SqlQueryRequest(BaseModel): + sql: str = Field(..., example="SELECT count(*) AS total FROM some_view") + dialect: str + vdb: str + + +class VqlQueryResponse(BaseModel): # If still used directly anywhere + vql: str + + +class TranslationError(BaseModel): + explanation: str + sql_suggestion: str + + +class TranslateApiResponse(BaseModel): + vql: str | None = None + error_analysis: Optional[TranslationError] = None + message: str | None = None diff --git a/backend/src/schemas/validation.py b/backend/src/schemas/validation.py new file mode 100644 index 0000000..d3d1050 --- /dev/null +++ b/backend/src/schemas/validation.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import BaseModel +from src.schemas.translation import TranslationError # If it's the same structure + +# If ValidationError is truly distinct from TranslationError, define it separately + + +class ValidationError(BaseModel): + explanation: str + sql_suggestion: str + + +class VqlValidateRequest(BaseModel): + sql: str # Assuming this was meant to be vql or just generic sql to validate + vql: str # The VQL to validate + + +class VqlValidationApiResponse(BaseModel): + validated: bool + error_analysis: Optional[ValidationError] = None + message: Optional[str] = None diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/utils/ai_analyzer.py b/backend/src/utils/ai_analyzer.py new file mode 100644 index 0000000..ee4828c --- /dev/null +++ b/backend/src/utils/ai_analyzer.py @@ -0,0 +1,106 @@ +# src/services/ai_analyzer.py +import logging +from typing import Type +from fastapi import HTTPException +from pydantic_ai import Agent, RunContext + +from src.config import settings +from src.schemas.translation import TranslationError +from src.schemas.validation import ValidationError +# Import the Denodo client functions +from src.utils.denodo_client import get_available_views_from_denodo, get_denodo_functions_list, get_vdb_names_list + +logger = logging.getLogger(__name__) + + +def _initialize_ai_agent(system_prompt: str, output_type: Type) -> Agent: + if not settings.GEMINI_API_KEY: + logger.error("GEMINI_API_KEY environment variable not set.") + raise HTTPException( + status_code=500, detail="AI service configuration error: API key missing." + ) + return Agent( + # Consider making model name a config variable + "gemini-2.5-flash-preview-04-17", # "gemini-1.5-flash-latest" might be more current + system_prompt=system_prompt, + output_type=output_type, + # llm_kwargs={"api_key": settings.GEMINI_API_KEY} # pydantic-ai typically handles GOOGLE_API_KEY env var directly + ) + + +def analyze_vql_validation_error(error: str, input_vql: str) -> ValidationError: + agent = _initialize_ai_agent( + "You are an SQL Validation assistant for Denodo VQL", ValidationError + ) + + @agent.tool + def get_views(ctx: RunContext[str]) -> list[str]: + # Potentially pass vdb context if available from the original request + # For now, calling without specific VDB context for views + return get_available_views_from_denodo() + + @agent.tool + def get_denodo_functions(ctx: RunContext[str]) -> list[str]: + return get_denodo_functions_list() + + @agent.tool + def get_vdbs(ctx: RunContext[str]) -> list[str]: + return get_vdb_names_list() + + prompt = f"""Analyze the Denodo VQL Validation error. Explain concisely why the `Input VQL` failed based on the `Error` and provide the corrected `Valid VQL Suggestion`. + Do not use ```sql markdown for the corrected VQL response. Do not explain what you are doing, just provide the explanation and the suggestion directly. + If the table/view is missing, use the get_views tool to determine which views are available and use the best guess in your suggestion. + If a function is not valid, use get_denodo_functions tool to check for available Denodo functions. + If a database name (VDB) is invalid, use get_vdbs tool to check for database names. Suggest one that is similar or advise the user to check. + **ERROR:** + {error} + **Input VQL:** + ```vql + {input_vql}```""" + try: + response = agent.run_sync(prompt) + if response and response.output: + logger.info(f"AI Validation Analysis Explanation: {response.output.explanation}") + logger.info(f"AI Validation Analysis Suggestion: {response.output.sql_suggestion}") + return response.output + else: + logger.error(f"AI agent returned unexpected response for validation: {response}") + raise HTTPException( + status_code=503, detail="AI service returned an invalid response for validation." + ) + except Exception as agent_error: + logger.error(f"Error calling AI Agent for validation: {agent_error}", exc_info=True) + raise HTTPException( + status_code=503, detail=f"AI service for validation unavailable or failed: {agent_error}" + ) + + +def analyze_sql_translation_error(exception_message: str, input_sql: str) -> TranslationError: + agent = _initialize_ai_agent( + "You are an SQL Translation assistant, focusing on transpiling to Denodo VQL", TranslationError + ) + # Add tools here if the translation assistant needs them (e.g., to understand target VQL features) + + prompt = f"""Analyze the SQL parsing/translation error. Explain concisely why the `Input SQL` failed based on the `Error` and provide a corrected `Valid SQL Suggestion` that would be parsable by the original dialect or a hint for VQL. + Do not use ```sql markdown for the corrected SQL response. Do not explain what you are doing, just provide the explanation and the suggestion directly. + **ERROR:** + {exception_message} + **Input SQL:** + ```sql + {input_sql}```""" + try: + response = agent.run_sync(prompt) + if response and response.output: + logger.info(f"AI Translation Analysis Explanation: {response.output.explanation}") + logger.info(f"AI Translation Analysis Suggestion: {response.output.sql_suggestion}") + return response.output + else: + logger.error(f"AI agent returned unexpected response for translation: {response}") + raise HTTPException( + status_code=503, detail="AI service returned an invalid response for translation." + ) + except Exception as agent_error: + logger.error(f"Error calling AI Agent for translation: {agent_error}", exc_info=True) + raise HTTPException( + status_code=503, detail=f"AI service for translation unavailable or failed: {agent_error}" + ) diff --git a/backend/src/utils/denodo_client.py b/backend/src/utils/denodo_client.py new file mode 100644 index 0000000..14084e1 --- /dev/null +++ b/backend/src/utils/denodo_client.py @@ -0,0 +1,49 @@ +# src/services/denodo_client.py +import logging +from fastapi import HTTPException +from sqlalchemy import text +from src.db.session import get_engine # Use the centralized engine + +logger = logging.getLogger(__name__) + + +def get_available_views_from_denodo(vdb_name: str | None = None) -> list[str]: + # This needs actual implementation to query Denodo's metadata. + # Example: "LIST VIEWS ALL" or query information_schema views + # For now, returning a placeholder + logger.warning("get_available_views_from_denodo is using placeholder data.") + return ["placeholder_view1", f"placeholder_view_in_{vdb_name}" if vdb_name else "placeholder_view2"] + + +def get_denodo_functions_list() -> list[str]: + engine = get_engine() + vql = "LIST FUNCTIONS" + try: + with engine.connect() as connection: + result = connection.execute(text(vql)) + functions: list[str] = [row[2] for row in result if len(row) > 2] # Added safety for row length + logger.info(f"Successfully retrieved Denodo functions: {len(functions)} functions found.") + return functions + except Exception as e: + logger.error(f"Error executing VQL query '{vql}' to get functions: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve functions from Denodo: {str(e)}", + ) + + +def get_vdb_names_list() -> list[str]: + engine = get_engine() + vql = "SELECT db_name FROM GET_DATABASES()" + try: + with engine.connect() as connection: + result = connection.execute(text(vql)) + db_names: list[str] = [row.db_name for row in result] + logger.info(f"Successfully retrieved VDB names: {db_names}") + return db_names + except Exception as e: + logger.error(f"Error executing VQL query '{vql}' to get VDBs: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve VDB list from the database: {str(e)}", + ) diff --git a/backend/src/utils/vdb_transformer.py b/backend/src/utils/vdb_transformer.py new file mode 100644 index 0000000..72b3170 --- /dev/null +++ b/backend/src/utils/vdb_transformer.py @@ -0,0 +1,20 @@ +import logging +from sqlglot import exp + +logger = logging.getLogger(__name__) + + +def transform_vdb_table_qualification(node: exp.Expression, vdb_name: str) -> exp.Expression: + """ + Recursively prefixes unqualified tables in a SQL expression with a VDB name. + """ + if isinstance(node, exp.Table): + is_qualified = bool( + node.db or node.catalog or node.args.get("db") or node.args.get("catalog") + ) + if not is_qualified and isinstance(node.this, exp.Identifier): + logger.debug(f"Transforming table: {node.sql()} -> Adding db: {vdb_name}") + new_node = node.copy() + new_node.set("db", exp.Identifier(this=vdb_name, quoted=False)) + return new_node + return node diff --git a/backend/vdb_conf.yaml b/backend/vdb_conf.yaml new file mode 100644 index 0000000..c2949e9 --- /dev/null +++ b/backend/vdb_conf.yaml @@ -0,0 +1,4 @@ +vdbs: + - admin + - test + - placeholder1 diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yml similarity index 100% rename from docker-compose.prod.yaml rename to docker-compose.prod.yml diff --git a/docker-compose.yml b/docker-compose.yml index 7871f1a..3ae5a95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,13 @@ services: - DENODO_PW=${DENODO_PW} # AI KEY - GEMINI_API_KEY=${AI_API_KEY} - - CONTAINER_BACKEND_PORT=5000 + - CONTAINER_BACKEND_PORT=${CONTAINER_BACKEND_PORT:-5000} + # Files + - APP_VDB_CONF=/opt/vdb_conf.yaml user: appuser + volumes: + - ${HOST_PROJECT_PATH}/backend/vdb_conf.yaml:/opt/vdb_conf.yaml + + networks: - app_network \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js index 39f8755..c566be6 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,57 +1,83 @@ // --- At the top of App.js --- -import React, { useState, useCallback } from 'react'; -// ... other MUI imports +import React, { useState, useCallback, useEffect } from 'react'; import { CssBaseline, AppBar, Toolbar, Typography, Container, Box, - Button, CircularProgress, Card, CardContent, CardHeader, Alert, // Keep Alert for simple errors + Button, CircularProgress, Card, CardContent, CardHeader, Alert, AlertTitle, IconButton, Stack, useTheme, Autocomplete, TextField, } from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; // Keep for simple alerts +import CloseIcon from '@mui/icons-material/Close'; import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; import VerifiedIcon from '@mui/icons-material/Verified'; import WhatshotIcon from '@mui/icons-material/Whatshot'; import CodeMirror from '@uiw/react-codemirror'; import { sql } from '@codemirror/lang-sql'; import { oneDark } from '@codemirror/theme-one-dark'; -// import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; // Not used currently import { purple, blueGrey } from '@mui/material/colors'; // --- Import the new component --- -import AiErrorAnalysis from './AiErrorAnalysis'; -import AiValidationErrorAnalysis from './AiValidationErrorAnalysis'; +import AiErrorAnalysis from './AiErrorAnalysis'; // Assuming this component exists +import AiValidationErrorAnalysis from './AiValidationErrorAnalysis'; // Assuming this component exists // --- Configuration --- const availableDialects = [{ value: 'athena', label: 'Athena' }, { value: 'bigquery', label: 'BigQuery' }, { value: 'clickhouse', label: 'ClickHouse' }, { value: 'databricks', label: 'Databricks' }, { value: 'doris', label: 'Doris' }, { value: 'drill', label: 'Drill' }, { value: 'druid', label: 'Druid' }, { value: 'duckdb', label: 'DuckDB' }, { value: 'dune', label: 'Dune' }, { value: 'hive', label: 'Hive' }, { value: 'materialize', label: 'Materialize' }, { value: 'mysql', label: 'MySQL' }, { value: 'oracle', label: 'Oracle' }, { value: 'postgres', label: 'PostgreSQL' }, { value: 'presto', label: 'Presto' }, { value: 'prql', label: 'PRQL' }, { value: 'redshift', label: 'Redshift' }, { value: 'risingwave', label: 'RisingWave' }, { value: 'snowflake', label: 'Snowflake' }, { value: 'spark', label: 'Spark SQL' }, { value: 'spark2', label: 'Spark SQL 2' }, { value: 'sqlite', label: 'SQLite' }, { value: 'starrocks', label: 'StarRocks' }, { value: 'tableau', label: 'Tableau' }, { value: 'teradata', label: 'Teradata' }, { value: 'trino', label: 'Trino' }]; const editorExtensions = [sql()]; - -// --- Placeholder VDB Options --- -const availableVDBs = [ - { value: 'vdb_placeholder_1', label: 'VDB Option 1' }, - { value: 'vdb_placeholder_2', label: 'VDB Option 2' }, - { value: 'vdb_placeholder_3', label: 'VDB Option 3' }, -]; +const initialTargetSqlPlaceholder = '-- Target SQL will appear here after conversion...'; function App() { const theme = useTheme(); const API_BASE_URL = ''; const [sourceDialect, setSourceDialect] = useState(availableDialects[0]); - const [selectedVDB, setSelectedVDB] = useState(availableVDBs[0]); + // --- VDB State --- + const [actualAvailableVDBs, setActualAvailableVDBs] = useState([]); + const [selectedVDB, setSelectedVDB] = useState(null); // Initialize to null + const [vdbsLoading, setVdbsLoading] = useState(false); + const [vdbsError, setVdbsError] = useState(null); + const [sourceSql, setSourceSql] = useState('SELECT\n c.customer_id,\n c.name,\n COUNT(o.order_id) AS total_orders\nFROM\n customers c\nLEFT JOIN\n orders o ON c.customer_id = o.customer_id\nWHERE\n c.signup_date >= \'2023-01-01\'\nGROUP BY\n c.customer_id, c.name\nHAVING\n COUNT(o.order_id) > 5\nORDER BY\n total_orders DESC\nLIMIT 10;'); - const [targetSql, setTargetSql] = useState('-- Target SQL will appear here after conversion...'); + const [targetSql, setTargetSql] = useState(initialTargetSqlPlaceholder); const [isLoading, setIsLoading] = useState(false); - // --- Modified Error State --- - // Can hold a string for simple errors or the { explanation, sql_suggestion } object for AI analysis - const [error, setError] = useState(null); // Initialize to null or empty string + const [error, setError] = useState(null); const [isValidating, setIsValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); - const initialTargetSqlPlaceholder = '-- Target SQL will appear here after conversion...'; const clearValidationState = () => { setValidationResult(null); }; - const clearErrorState = () => { setError(null); }; // Helper to clear error + const clearErrorState = () => { setError(null); }; + + // --- Fetch VDBs --- + useEffect(() => { + setVdbsLoading(true); + setVdbsError(null); + fetch(`${API_BASE_URL}/api/vdbs`) + .then(response => { + if (!response.ok) { + return response.text().then(text => { throw new Error(`Failed to fetch VDBs: ${response.status} ${response.statusText} - ${text}`); }); + } + return response.json(); + }) + .then(data => { + if (data && Array.isArray(data.results)) { + setActualAvailableVDBs(data.results); + // Optionally set a default selected VDB if list is not empty and none is selected + // if (data.results.length > 0 && !selectedVDB) { + // setSelectedVDB(data.results[0]); + // } + } else { + console.error("VDB data from API is not in the expected format:", data); + throw new Error("VDB data is not in the expected format (missing 'results' array)."); + } + setVdbsLoading(false); + }) + .catch(err => { + console.error("Error fetching VDBs:", err); + setVdbsError(err.message || "Could not fetch VDB options."); + setActualAvailableVDBs([]); // Set to empty array on error to avoid issues with Autocomplete + setVdbsLoading(false); + }); + }, [API_BASE_URL]); // Removed selectedVDB from dependency to avoid re-fetch on selection const onSourceChange = useCallback((value) => { setSourceSql(value); - clearErrorState(); // Clear error on source change + clearErrorState(); clearValidationState(); }, []); @@ -68,40 +94,38 @@ function App() { setTargetSql(initialTargetSqlPlaceholder); }; - // --- Handler Functions for AiErrorAnalysis --- const handleApplySuggestion = (suggestedSql) => { - setSourceSql(suggestedSql); // Update the source editor - setError(null); // Clear the error notification - setTargetSql(initialTargetSqlPlaceholder); // Optionally reset target - clearValidationState(); // Clear any previous validation + setSourceSql(suggestedSql); + setError(null); + setTargetSql(initialTargetSqlPlaceholder); + clearValidationState(); }; const handleUseVqlSuggestion = (suggestedVql) => { - setTargetSql(suggestedVql); // Update the target VQL editor - clearValidationState(); // Clear the validation error/analysis message + setTargetSql(suggestedVql); + clearValidationState(); }; const handleDismissError = () => { - setError(null); // Simply clear the error notification + setError(null); }; - // --- End Handler Functions --- const handleConvert = async () => { setIsLoading(true); - clearErrorState(); // Clear previous errors explicitly + clearErrorState(); clearValidationState(); if (!sourceDialect || !selectedVDB) { - setError("Source Dialect and VDB must be selected."); // Set simple string error + setError("Source Dialect and VDB must be selected."); setIsLoading(false); return; } const requestBody = { sql: sourceSql, dialect: sourceDialect.value, - vdb: selectedVDB.value + vdb: selectedVDB.value // selectedVDB is an object, use its value property }; try { - const response = await fetch(`${API_BASE_URL}/api/translate`, { // Ensure /api prefix if needed + const response = await fetch(`${API_BASE_URL}/api/translate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(requestBody) @@ -112,7 +136,7 @@ function App() { try { const errorData = await response.json(); errorDetails = errorData.detail || errorData.message || JSON.stringify(errorData); - if (!errorDetails.toLowerCase().includes(response.status.toString())) { + if (errorDetails && !errorDetails.toLowerCase().includes(response.status.toString())) { errorDetails = `(${response.status}) ${errorDetails}`; } } catch (parseError) { @@ -124,69 +148,49 @@ function App() { throw new Error(errorDetails); } - const data = await response.json(); // Assuming backend sends TranslateApiResponse structure + const data = await response.json(); if (data && typeof data.vql === 'string') { - console.log("Translation successful."); setTargetSql(data.vql); - // Ensure error is explicitly cleared on success clearErrorState(); - } else if (data && data.error_analysis && typeof data.error_analysis.explanation === 'string') { - console.log("Translation failed, received AI analysis."); - // --- Set error state with the object --- setError(data.error_analysis); setTargetSql(initialTargetSqlPlaceholder); - } else if (data && typeof data.message === 'string') { - console.log("Received general message:", data.message); - setError(`Translation Info: ${data.message}`); // Set simple string error + setError(`Translation Info: ${data.message}`); setTargetSql(initialTargetSqlPlaceholder); - } else { - console.error("Unexpected success data format:", data); throw new Error("Received unexpected success data format from the translation endpoint."); } } catch (err) { console.error("Conversion process failed:", err); - setError(err.message || 'Unknown conversion error.'); // Set simple string error + setError(err.message || 'Unknown conversion error.'); setTargetSql('-- Conversion Error --'); } finally { setIsLoading(false); } }; - // --- handleValidateQuery (Ensure it checks for object-type errors) --- const handleValidateQuery = async () => { - // Prevent validation if a translation AI error is showing if (error && typeof error === 'object' && error !== null && error.explanation) { - console.log("Validation skipped: Translation analysis error is currently displayed."); setValidationResult({ status: 'info', message: 'Resolve the translation error (Apply or Dismiss) before validating.' }); return; } - // Prevent validation if a validation AI error is showing if (validationResult?.status === 'error_ai') { - console.log("Validation skipped: Validation analysis error is currently displayed."); - // Optionally, set an info message again, or just do nothing - // setValidationResult({ status: 'info', message: 'Dismiss the current validation analysis before validating again.' }); return; } - - // Original checks for targetSql content if (!targetSql || targetSql === initialTargetSqlPlaceholder || targetSql === '-- Conversion Error --') { - console.log("Validation skipped: No valid VQL in target editor."); setValidationResult({ status: 'info', message: 'Convert the SQL to VQL first or resolve conversion errors.' }); return; } - // Basic input checks - if (!sourceSql.trim() || !sourceDialect || !selectedVDB || isLoading || isValidating) { - return; // Should already be disabled, but double-check + if (!sourceSql.trim() || !sourceDialect || !selectedVDB || isLoading || isValidating || vdbsLoading) { + return; } setIsValidating(true); - clearValidationState(); // Clear previous validation results - clearErrorState(); // Clear general translation errors when starting validation + clearValidationState(); + clearErrorState(); const vqlToValidate = targetSql; const vqlWithoutLineBreaks = vqlToValidate.replace(/[\r\n]+/g, ' ').trim(); @@ -197,12 +201,12 @@ function App() { } const validateRequestBody = { - sql: sourceSql, // Send original SQL for context if backend uses it + sql: sourceSql, vql: vqlWithoutLineBreaks }; try { - const validateResponse = await fetch(`${API_BASE_URL}/api/validate`, { // Use /validate + const validateResponse = await fetch(`${API_BASE_URL}/api/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(validateRequestBody) @@ -216,35 +220,24 @@ function App() { throw new Error(`(${validateResponse.status}) Server returned non-JSON response: ${textError || validateResponse.statusText}`); } - if (!validateResponse.ok) { - // Server indicated failure (e.g., 4xx, 5xx), but might contain AI analysis or message if (validationData?.error_analysis) { - console.log("Validation failed, received AI analysis."); setValidationResult({ status: 'error_ai', data: validationData.error_analysis }); } else { const errorMessage = validationData?.message || validationData?.detail || `Validation Request Failed: ${validateResponse.status}`; - console.error("Validation failed:", errorMessage); setValidationResult({ status: 'error', message: errorMessage }); } } else { - // Response is OK (2xx) if (validationData.validated) { - console.log("Validation successful."); setValidationResult({ status: 'success', message: validationData.message || `VQL syntax check successful!` }); } else { - // Validated === false, check for AI analysis first if (validationData.error_analysis) { - console.log("Validation failed (validated=false), received AI analysis."); setValidationResult({ status: 'error_ai', data: validationData.error_analysis }); } else { - // Validated === false, but no AI analysis provided - console.warn("Validation failed (validated=false), no AI analysis provided."); setValidationResult({ status: 'error', message: validationData.message || 'Validation Failed: Denodo rejected the query syntax/plan.' }); } } } - } catch (err) { console.error("Validation process error:", err); setValidationResult({ @@ -256,10 +249,8 @@ function App() { } }; - // --- Simplified loading state --- - const anyLoading = isLoading || isValidating; + const anyLoading = isLoading || isValidating || vdbsLoading; - // --- Fixed widths and renderEditorCard --- const targetEditorFixedWidth = '550px'; const controlsFixedWidth = '220px'; const renderEditorCard = (title, borderColor, value, readOnly = false, onChangeHandler = null) => ( @@ -270,9 +261,9 @@ function App() { ); - // Function to render close button for simple alerts const alertCloseButton = (onCloseHandler) => (); - return ( - {/* ... (AppBar content - no changes needed) ... */} @@ -299,11 +287,8 @@ function App() { - {/* --- Notifications Area --- */} - {/* --- Updated Error Handling --- */} {error && typeof error === 'object' && error.explanation && error.sql_suggestion && ( - // Render the detailed AI analysis component )} {error && typeof error === 'string' && ( - // Render a simple MUI Alert for string errors Error {error} )} - {/* --- Validation AI Error Analysis --- */} + {/* --- VDB Loading/Error --- */} + {vdbsLoading && !vdbsError && ( // Show loading only if no error + }>Loading VDB options... + )} + {vdbsError && ( + setVdbsError(null))} + > + VDB Load Issue + {vdbsError} - VDB selection might be unavailable or incomplete. + + )} {validationResult?.status === 'error_ai' && validationResult.data && ( )} - {/* --- Validation Alerts*/} {validationResult?.status === 'success' && ( Validation Successful @@ -342,9 +337,15 @@ function App() { {validationResult.message} )} + {/* Moved error type error (the one related to validation) down to avoid overlap with general error */} + {validationResult?.status === 'error' && ( + + Validation Error + {validationResult.message} + + )} - {/* --- Editors and Controls (Flexbox Layout - Remains the Same) --- */} - {/* Source SQL Editor */} {renderEditorCard(`Source (${sourceDialect ? sourceDialect.label : 'Select Dialect'})`, theme.palette.primary.main, sourceSql, false, onSourceChange)} - {/* Controls Column */} - {/* Source Dialect Autocomplete */} } /> - {/* VDB Autocomplete */} option.label || ""} value={selectedVDB} onChange={handleVDBChange} isOptionEqualToValue={(option, value) => option?.value === value?.value} - disabled={anyLoading} + disabled={anyLoading || !!vdbsError || actualAvailableVDBs.length === 0} fullWidth - renderInput={(params) => } + renderInput={(params) => ( + + {vdbsLoading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} /> - {/* Convert Button */} {isLoading && ()} - {/* Validate Button */}