diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fa6b1ec1..41b3cb54c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## Dev +## [0.40.0 - 2025-07-23] + +### Added +* GFQL: Add comprehensive validation framework with detailed error reporting + * Built-in validation: `Chain()` constructor validates syntax automatically + * Schema validation: `validate_chain_schema()` validates queries against DataFrame schemas + * Pre-execution validation: `g.chain(ops, validate_schema=True)` catches errors before execution + * Structured error types: `GFQLValidationError`, `GFQLSyntaxError`, `GFQLTypeError`, `GFQLSchemaError` + * Error codes (E1xx syntax, E2xx type, E3xx schema) for programmatic error handling + * Collect-all mode: `validate(collect_all=True)` returns all errors instead of fail-fast + * JSON validation: `Chain.from_json()` validates during parsing for safe LLM integration + * Helpful error suggestions for common mistakes + * Example notebook: `demos/gfql/gfql_validation_fundamentals.ipynb` + ### Fixed * Engine: Fix resolve_engine() to use dynamic import for Plottable isinstance check to avoid Jinja dependency from pandas df.style getter (#701) * Engine: Fix resolve_engine() to check both Plotter and Plottable classes for proper type detection @@ -23,6 +37,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm * Wire protocol JSON format for client-server communication * Fix terminology: clarify g._node (node ID column) vs g._nodes (DataFrame) * Emphasize GFQL's declarative nature for graph-to-graph transformations + * Add validation framework documentation with error code reference ## [0.39.1 - 2025-07-07] diff --git a/ai_code_notes/gfql/README.md b/ai_code_notes/gfql/README.md index 16d84b3c0e..b8d8b210cc 100644 --- a/ai_code_notes/gfql/README.md +++ b/ai_code_notes/gfql/README.md @@ -37,6 +37,26 @@ g.chain([n(), e_forward(), n()]) # Pattern matching - Prefer `filter_dict` over `query` strings - Use appropriate engine: `pandas` (CPU) or `cudf` (GPU) +### Validation (Built-in) +```python +# Automatic syntax validation +chain = Chain([n(), e_forward(hops=-1)]) # Raises GFQLTypeError + +# Schema validation at runtime +g.chain([n({'missing': 'value'})]) # Raises GFQLSchemaError + +# Pre-execution validation +g.chain(ops, validate_schema=True) # Validate before execution + +# Collect all errors +errors = chain.validate(collect_all=True) +``` + +Error types: +- `GFQLSyntaxError` (E1xx): Structural issues +- `GFQLTypeError` (E2xx): Type mismatches +- `GFQLSchemaError` (E3xx): Missing columns, wrong types + ## 📋 When to Use GFQL ### Use GFQL When diff --git a/demos/gfql/gfql_validation_fundamentals.ipynb b/demos/gfql/gfql_validation_fundamentals.ipynb new file mode 100644 index 0000000000..d59cd80bf6 --- /dev/null +++ b/demos/gfql/gfql_validation_fundamentals.ipynb @@ -0,0 +1,214 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# GFQL Validation Fundamentals\n\nLearn how to use GFQL's built-in validation system to catch errors early and build robust graph applications.\n\n## What You'll Learn\n- How GFQL automatically validates queries\n- Understanding structured error messages with error codes\n- Schema validation against your data\n- Pre-execution validation for performance\n- Collecting all errors vs fail-fast mode\n\n## Prerequisites\n- Basic Python knowledge\n- PyGraphistry installed (`pip install graphistry[ai]`)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup and Imports\n", + "\n", + "First, let's import the necessary modules and create sample data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Core imports\nimport pandas as pd\nimport graphistry\nfrom graphistry.compute.chain import Chain\nfrom graphistry.compute.ast import n, e_forward, e_reverse\n\n# Exception types for error handling\nfrom graphistry.compute.exceptions import (\n GFQLValidationError,\n GFQLSyntaxError,\n GFQLTypeError,\n GFQLSchemaError,\n ErrorCode\n)\n\n# Check version\nprint(f\"PyGraphistry version: {graphistry.__version__}\")\nprint(\"\\nValidation is now built-in to GFQL operations!\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Automatic Syntax Validation\n", + "\n", + "GFQL validates operations automatically when you create them. No need to call separate validation functions!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Example 1: Valid chain creation\ntry:\n chain = Chain([\n n({'type': 'customer'}),\n e_forward(),\n n()\n ])\n print(\"Valid chain created successfully!\")\n print(f\"Chain has {len(chain.chain)} operations\")\nexcept GFQLValidationError as e:\n print(f\"Validation error: {e}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Example 2: Invalid parameter - negative hops\ntry:\n chain = Chain([\n n(),\n e_forward(hops=-1), # Invalid: negative hops\n n()\n ])\nexcept GFQLTypeError as e:\n print(f\"Caught validation error!\")\n print(f\" Error code: {e.code}\")\n print(f\" Message: {e.message}\")\n print(f\" Field: {e.context.get('field')}\")\n print(f\" Suggestion: {e.context.get('suggestion')}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Understanding Error Codes\n", + "\n", + "GFQL uses structured error codes for programmatic handling:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display available error codes\n", + "print(\"Error Code Categories:\")\n", + "print(\"\\nE1xx - Syntax Errors:\")\n", + "print(f\" {ErrorCode.E101}: Invalid type (e.g., chain not a list)\")\n", + "print(f\" {ErrorCode.E103}: Invalid parameter value\")\n", + "print(f\" {ErrorCode.E104}: Invalid direction\")\n", + "print(f\" {ErrorCode.E105}: Missing required field\")\n", + "\n", + "print(\"\\nE2xx - Type Errors:\")\n", + "print(f\" {ErrorCode.E201}: Type mismatch\")\n", + "print(f\" {ErrorCode.E204}: Invalid name type\")\n", + "\n", + "print(\"\\nE3xx - Schema Errors:\")\n", + "print(f\" {ErrorCode.E301}: Column not found\")\n", + "print(f\" {ErrorCode.E302}: Incompatible column type\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create Sample Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Create sample data\nnodes_df = pd.DataFrame({\n 'id': ['a', 'b', 'c', 'd', 'e'],\n 'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],\n 'type': ['customer', 'customer', 'product', 'product', 'customer'],\n 'score': [100, 85, 95, 120, 110],\n 'active': [True, True, False, True, False]\n})\n\nedges_df = pd.DataFrame({\n 'src': ['a', 'b', 'c', 'd', 'e'],\n 'dst': ['c', 'd', 'a', 'b', 'c'],\n 'weight': [1.0, 2.5, 0.8, 1.2, 3.0],\n 'edge_type': ['buys', 'buys', 'recommends', 'recommends', 'buys']\n})\n\n# Create graph using canonical graphistry.edges() and graphistry.nodes()\ng = graphistry.edges(edges_df, 'src', 'dst').nodes(nodes_df, 'id')\n\nprint(\"Graph created with:\")\nprint(f\" Nodes: {len(g._nodes)} (columns: {list(g._nodes.columns)})\")\nprint(f\" Edges: {len(g._edges)} (columns: {list(g._edges.columns)})\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Schema Validation (Runtime)\n", + "\n", + "When you execute a chain, GFQL automatically validates against your data schema:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Valid query - columns exist\ntry:\n result = g.chain([\n n({'type': 'customer'}),\n e_forward({'edge_type': 'buys'}),\n n({'type': 'product'})\n ])\n print(f\"Query executed successfully!\")\n print(f\" Found {len(result._nodes)} nodes\")\n print(f\" Found {len(result._edges)} edges\")\nexcept GFQLSchemaError as e:\n print(f\"Schema error: {e}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Invalid query - column doesn't exist\ntry:\n result = g.chain([\n n({'category': 'VIP'}) # 'category' column doesn't exist\n ])\nexcept GFQLSchemaError as e:\n print(f\"Schema validation caught the error!\")\n print(f\" Error code: {e.code}\")\n print(f\" Message: {e.message}\")\n print(f\" Suggestion: {e.context.get('suggestion')}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Type Mismatch Detection\n", + "\n", + "GFQL detects when you use the wrong type of value or predicate for a column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Type mismatch: string value on numeric column\ntry:\n result = g.chain([\n n({'score': 'high'}) # 'score' is numeric, not string\n ])\nexcept GFQLSchemaError as e:\n print(f\"Type mismatch detected!\")\n print(f\" {e}\")\n print(f\"\\n Column type: {e.context.get('column_type')}\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Using predicates\nfrom graphistry.compute.predicates.numeric import gt\nfrom graphistry.compute.predicates.str import contains\n\n# Correct: numeric predicate on numeric column\ntry:\n result = g.chain([n({'score': gt(90)})])\n print(f\"Valid: Found {len(result._nodes)} high-scoring nodes\")\nexcept GFQLSchemaError as e:\n print(f\"Error: {e}\")\n\n# Wrong: string predicate on numeric column\ntry:\n result = g.chain([n({'score': contains('9')})])\nexcept GFQLSchemaError as e:\n print(f\"\\nPredicate type mismatch caught!\")\n print(f\" {e.message}\")\n print(f\" Suggestion: {e.context.get('suggestion')}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Pre-Execution Validation\n\nYou have two options for validating queries:\n\n1. **Validate-only** (no execution): Use `validate_chain_schema()` to check compatibility without running the query\n2. **Validate-and-run**: Use `g.chain(..., validate_schema=True)` to validate before execution\n\nThis is useful for catching errors early, especially in production systems." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Pre-validate to catch errors early\nchain_to_test = Chain([\n n({'missing_col': 'value'}),\n e_forward({'also_missing': 'value'})\n])\n\n# Method 1: Validate AND run (stops at validation if invalid)\nprint(\"Method 1: Validate-and-run with validate_schema=True\")\ntry:\n result = g.chain(chain_to_test.chain, validate_schema=True)\n print(\"Query executed successfully\")\nexcept GFQLSchemaError as e:\n print(\"Pre-execution validation caught error!\")\n print(f\" Error: {e}\")\n print(\" (check) No graph operations were performed\")\n print(\" (check) Query was rejected before execution\")" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Method 2: Validate ONLY (no execution)\nprint(\"\\nMethod 2: Validate-only with validate_chain_schema()\")\nfrom graphistry.compute.validate_schema import validate_chain_schema\n\n# Check if chain is compatible with graph schema WITHOUT running it\ntry:\n validate_chain_schema(g, chain_to_test)\n print(\"Chain is valid for this graph schema\")\n print(\"Note: No query execution occurred - only validation!\")\nexcept GFQLSchemaError as e:\n print(f\"Schema incompatibility detected: {e}\")\n print(\" (check) This was validation-only - no query was executed\")\n print(\" (check) Use this method to test queries before running them\")" + }, + { + "cell_type": "code", + "source": "# Example: Demonstrating the difference\nprint(\"=== Demonstrating the difference ===\\n\")\n\n# Create a valid chain\nvalid_chain = Chain([\n n({'type': 'customer'}),\n e_forward()\n])\n\n# Validate-only: Just checks, doesn't run\nprint(\"1. Validate-only with validate_chain_schema():\")\ntry:\n validate_chain_schema(g, valid_chain)\n print(\" (check) Validation passed\")\n print(\" (check) Query NOT executed\")\n print(\" (check) No result object returned\")\nexcept GFQLSchemaError as e:\n print(f\" (x) Validation failed: {e}\")\n\n# Validate-and-run: Validates first, then executes if valid\nprint(\"\\n2. Validate-and-run with g.chain(..., validate_schema=True):\")\ntry:\n result = g.chain(valid_chain.chain, validate_schema=True)\n print(\" (check) Validation passed\")\n print(\" (check) Query WAS executed\")\n print(f\" (check) Result: {len(result._nodes)} nodes, {len(result._edges)} edges\")\nexcept GFQLSchemaError as e:\n print(f\" (x) Validation failed: {e}\")\n print(\" (x) Query NOT executed\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Collect All Errors vs Fail-Fast\n", + "\n", + "By default, validation fails on the first error. You can collect all errors instead:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Create a chain with multiple errors\nproblematic_chain = Chain([\n n({'missing1': 'value', 'score': 'not-a-number'}), # 2 errors\n e_forward({'missing2': 'value'}), # 1 error \n n({'type': gt(5)}) # 1 error: numeric predicate on string column\n])\n\n# Fail-fast mode (default)\nprint(\"Fail-fast mode:\")\ntry:\n problematic_chain.validate()\nexcept GFQLValidationError as e:\n print(f\" Stopped at first error: {e}\")\n\n# Collect-all mode\nprint(\"\\nCollect-all mode:\")\nerrors = problematic_chain.validate(collect_all=True)\nprint(f\" Found {len(errors)} syntax/type errors\")\n\n# For schema validation\nschema_errors = validate_chain_schema(g, problematic_chain, collect_all=True)\nprint(f\" Found {len(schema_errors)} schema errors:\")\nfor i, error in enumerate(schema_errors):\n print(f\"\\n Error {i+1}: [{error.code}] {error.message}\")\n if error.context.get('suggestion'):\n print(f\" Suggestion: {error.context['suggestion']}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Error Handling Best Practices" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Comprehensive error handling example\ndef safe_chain_execution(g, operations):\n \"\"\"Execute chain with proper error handling.\"\"\"\n try:\n # Create chain\n chain = Chain(operations)\n \n # Pre-validate if desired\n # errors = chain.validate_schema(g, collect_all=True)\n # if errors:\n # print(f\"Warning: {len(errors)} schema issues found\")\n \n # Execute\n result = g.chain(operations)\n return result\n \n except GFQLSyntaxError as e:\n print(f\"Syntax Error [{e.code}]: {e.message}\")\n if e.context.get('suggestion'):\n print(f\" Try: {e.context['suggestion']}\")\n return None\n \n except GFQLTypeError as e:\n print(f\"Type Error [{e.code}]: {e.message}\")\n print(f\" Field: {e.context.get('field')}\")\n print(f\" Value: {e.context.get('value')}\")\n return None\n \n except GFQLSchemaError as e:\n print(f\"Schema Error [{e.code}]: {e.message}\")\n if e.code == ErrorCode.E301:\n print(\" Column not found in data\")\n elif e.code == ErrorCode.E302:\n print(\" Type mismatch between query and data\")\n return None\n\n# Test with valid query\nprint(\"Valid query:\")\nresult = safe_chain_execution(g, [\n n({'type': 'customer'}),\n e_forward()\n])\nif result:\n print(f\" Success! Found {len(result._nodes)} nodes\")\n\n# Test with invalid query\nprint(\"\\nInvalid query:\")\nresult = safe_chain_execution(g, [\n n({'invalid_column': 'value'})\n])" + }, + { + "cell_type": "markdown", + "source": "## Summary\n\n### Key Takeaways\n\n1. **Automatic Validation**: GFQL validates automatically - no separate validation calls needed\n2. **Structured Errors**: Error codes (E1xx, E2xx, E3xx) help with programmatic handling\n3. **Helpful Messages**: Errors include suggestions for fixing issues\n4. **Two Validation Stages**:\n - Syntax/Type: During chain construction\n - Schema: During execution (or pre-execution)\n5. **Flexible Modes**: Choose between fail-fast or collect-all errors\n\n### Quick Reference\n\n```python\n# Automatic syntax validation\nchain = Chain([...]) # Validates syntax/types\n\n# Runtime schema validation \nresult = g.chain([...]) # Validates against data\n\n# Pre-execution schema validation\nresult = g.chain([...], validate_schema=True)\n\n# Validate chain against graph schema\nfrom graphistry.compute.validate_schema import validate_chain_schema\nvalidate_chain_schema(g, chain) # Throws GFQLSchemaError if invalid\n\n# Collect all syntax errors\nerrors = chain.validate(collect_all=True)\n\n# Collect all schema errors\nschema_errors = validate_chain_schema(g, chain, collect_all=True)\n```\n\n### Next Steps\n\n- Explore more complex query patterns\n- Learn about GFQL predicates for advanced filtering\n- Use validation in production applications", + "metadata": {} + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.8.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/source/api/gfql/validate.rst b/docs/source/api/gfql/validate.rst new file mode 100644 index 0000000000..c5384b301b --- /dev/null +++ b/docs/source/api/gfql/validate.rst @@ -0,0 +1,12 @@ +graphistry.compute.gfql.validate module +======================================= + +.. deprecated:: 2.0.0 + This external validation module is deprecated. GFQL now has built-in validation that happens automatically during chain construction and execution. + + See :doc:`../../gfql/validation/fundamentals` for the new validation system and :doc:`../../gfql/validation_migration_guide` for migration instructions. + +.. automodule:: graphistry.compute.gfql.validate + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/gfql/index.rst b/docs/source/gfql/index.rst index 7d3bba504f..e8b26a10db 100644 --- a/docs/source/gfql/index.rst +++ b/docs/source/gfql/index.rst @@ -28,3 +28,4 @@ See also: :caption: Developer Resources spec/index + validation/index diff --git a/docs/source/gfql/spec/language.md b/docs/source/gfql/spec/language.md index 3d0c444af5..65fae87b4b 100644 --- a/docs/source/gfql/spec/language.md +++ b/docs/source/gfql/spec/language.md @@ -374,4 +374,5 @@ This pattern is essential for extracting specific subsets from complex graph tra - {ref}`gfql-spec-python-embedding` - Python implementation details - {ref}`gfql-spec-wire-protocol` - JSON serialization format - {ref}`gfql-spec-cypher-mapping` - Cypher to GFQL translation with wire protocol -- [GFQL Quick Reference](../quick.rst) - Comprehensive examples and usage patterns \ No newline at end of file +- [GFQL Quick Reference](../quick.rst) - Comprehensive examples and usage patterns +- [GFQL Validation Guide](../validation/fundamentals.rst) - Learn validation basics \ No newline at end of file diff --git a/docs/source/gfql/spec/python_embedding.md b/docs/source/gfql/spec/python_embedding.md index 27be847e4b..cf20db1741 100644 --- a/docs/source/gfql/spec/python_embedding.md +++ b/docs/source/gfql/spec/python_embedding.md @@ -99,6 +99,120 @@ active_nodes = result._nodes.query("active == True") result._nodes.groupby('type').size() ``` +## Validation + +GFQL provides comprehensive validation to catch errors early: + +### Syntax Validation + +Operations are automatically validated during construction: + +```python +from graphistry.compute.chain import Chain +from graphistry.compute.ast import n, e_forward + +# Automatic validation on construction +chain = Chain([ + n({'type': 'person'}), + e_forward({'hops': -1}) # Raises GFQLTypeError: hops must be positive +]) +``` + +### Schema Validation + +You have two options for validating queries against your data schema: + +1. **Validate-only** (no execution): Use `validate_chain_schema()` to check compatibility without running the query +2. **Validate-and-run**: Use `g.chain(..., validate_schema=True)` to validate before execution + +```python +# Method 1: Validate-only (no execution) +from graphistry.compute.validate_schema import validate_chain_schema + +chain = Chain([n({'missing_column': 'value'})]) +try: + validate_chain_schema(g, chain) # Only validates, doesn't execute + print("Chain is valid for this graph") +except GFQLSchemaError as e: + print(f"Schema incompatibility: {e}") + print("No query was executed") + +# Method 2: Runtime validation (automatic) +try: + result = g.chain([ + n({'missing_column': 'value'}) + ]) # Validates during execution, raises GFQLSchemaError +except GFQLSchemaError as e: + print(f"Runtime validation error: {e}") + +# Method 3: Validate-and-run (pre-execution validation) +try: + result = g.chain([ + n({'missing_column': 'value'}) + ], validate_schema=True) # Validates first, only executes if valid +except GFQLSchemaError as e: + print(f"Pre-execution validation failed: {e}") + print("Query was not executed") +``` + +### Error Types + +GFQL uses structured exceptions with error codes: + +- **GFQLSyntaxError** (E1xx): Structural issues + - E101: Invalid type (e.g., chain not a list) + - E103: Invalid parameter value (e.g., negative hops) + - E104: Invalid direction + - E105: Missing required field + +- **GFQLTypeError** (E2xx): Type mismatches + - E201: Wrong value type (e.g., string instead of dict) + - E202: Predicate type mismatch + - E204: Invalid name type + +- **GFQLSchemaError** (E3xx): Data-related issues + - E301: Column not found + - E302: Incompatible column type (e.g., numeric predicate on string column) + +### Validation Modes + +```python +# Fail-fast mode (default) - raises on first error +chain.validate() + +# Collect-all mode - returns list of all errors +errors = chain.validate(collect_all=True) +for error in errors: + print(f"[{error.code}] {error.message}") + if error.suggestion: + print(f" Suggestion: {error.suggestion}") + +# Pre-validate schema without execution +from graphistry.compute.validate_schema import validate_chain_schema + +# Check schema compatibility +errors = validate_chain_schema(g, chain, collect_all=True) +``` + +### Example: Handling Validation Errors + +```python +from graphistry.compute.exceptions import GFQLValidationError, GFQLSchemaError + +try: + result = g.chain([ + n({'age': 'twenty-five'}) # Type mismatch + ]) +except GFQLSchemaError as e: + print(f"Schema error [{e.code}]: {e.message}") + print(f"Field: {e.context.get('field')}") + print(f"Suggestion: {e.context.get('suggestion')}") + # Output: + # Schema error [E302]: Type mismatch: column "age" is numeric but filter value is string + # Field: age + # Suggestion: Use a numeric value like age=25 +``` + ## Common Errors and Validation ### Type Mismatches diff --git a/docs/source/gfql/validation/fundamentals.rst b/docs/source/gfql/validation/fundamentals.rst new file mode 100644 index 0000000000..6de638e1f3 --- /dev/null +++ b/docs/source/gfql/validation/fundamentals.rst @@ -0,0 +1,204 @@ +GFQL Validation Fundamentals +============================ + +Learn how to use GFQL's built-in validation system to catch errors early and build robust graph applications. + +.. note:: + This guide is accompanied by an interactive Jupyter notebook. To run the examples yourself, see + `GFQL Validation Fundamentals notebook <../../demos/gfql/gfql_validation_fundamentals.html>`_. + +What You'll Learn +----------------- + +* How GFQL automatically validates queries +* Understanding structured error messages with error codes +* Schema validation against your data +* Pre-execution validation for performance +* Collecting all errors vs fail-fast mode + +Prerequisites +------------- + +* Basic Python knowledge +* PyGraphistry installed (``pip install graphistry[ai]``) + +Quick Start +----------- + +.. code-block:: python + + from graphistry.compute.chain import Chain + from graphistry.compute.ast import n, e_forward + from graphistry.compute.exceptions import GFQLValidationError + + # Automatic validation during construction + try: + chain = Chain([ + n({'type': 'customer'}), + e_forward(), + n() + ]) + print("Valid chain created!") + except GFQLValidationError as e: + print(f"Error: [{e.code}] {e.message}") + +Key Concepts +------------ + +Built-in Validation +^^^^^^^^^^^^^^^^^^^ + +GFQL validates automatically - no separate validation calls needed: + +* **Syntax validation**: Happens during chain construction +* **Schema validation**: Happens by default during ``g.chain()`` execution +* **Structured errors**: Error codes (E1xx, E2xx, E3xx) for programmatic handling + +Error Types +^^^^^^^^^^^ + +* **GFQLSyntaxError** (E1xx): Structural issues in query +* **GFQLTypeError** (E2xx): Type mismatches and invalid values +* **GFQLSchemaError** (E3xx): Missing columns, incompatible types + +Common Errors and Fixes +----------------------- + +Invalid Parameters +^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Wrong - negative hops + try: + chain = Chain([n(), e_forward(hops=-1)]) + except GFQLTypeError as e: + print(f"Error: {e.message}") # "hops must be a positive integer" + + # Correct + chain = Chain([n(), e_forward(hops=2)]) + +Missing Columns +^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Wrong - column doesn't exist + try: + result = g.chain([n({'category': 'VIP'})]) + except GFQLSchemaError as e: + print(f"Error: {e.message}") # Column "category" does not exist + print(f"Suggestion: {e.context.get('suggestion')}") + + # Correct - use existing columns + result = g.chain([n({'type': 'customer'})]) + +Type Mismatches +^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Wrong - string value on numeric column + try: + result = g.chain([n({'score': 'high'})]) + except GFQLSchemaError as e: + print(f"Error: {e.message}") # Type mismatch + + # Correct - use numeric predicate + from graphistry.compute.predicates.numeric import gt + result = g.chain([n({'score': gt(80)})]) + +Temporal Comparisons +^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + import pandas as pd + from graphistry.compute.predicates.numeric import gt, lt + + # Compare datetime columns + result = g.chain([ + n({'created_at': gt(pd.Timestamp('2024-01-01'))}) + ]) + + # Find recent activity (last 7 days) + result = g.chain([ + e_forward({ + 'timestamp': gt(pd.Timestamp.now() - pd.Timedelta(days=7)) + }) + ]) + +How Validation Works +-------------------- + +Default Behavior +^^^^^^^^^^^^^^^^ + +GFQL validates automatically - just write your queries and run them: + +.. code-block:: python + + # Validation happens automatically + result = g.chain([n({'type': 'customer'})]) + + # Errors are caught and reported clearly + try: + result = g.chain([n({'invalid_column': 'value'})]) + except GFQLSchemaError as e: + print(f"Error: {e.message}") + +Pre-Execution Validation Options +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You have two options for validating queries: + +1. **Validate-only** (no execution): Use ``validate_chain_schema()`` to check compatibility without running the query +2. **Validate-and-run**: Use ``g.gfql(..., validate_schema=True)`` to validate before execution + +.. code-block:: python + + # Method 1: Validate-only (no execution) + from graphistry.compute.validate_schema import validate_chain_schema + + try: + validate_chain_schema(g, chain) # Only validates, doesn't execute + print("Chain is valid for this graph schema") + except GFQLSchemaError as e: + print(f"Schema incompatibility: {e}") + + # Method 2: Validate-and-run + try: + result = g.gfql(chain.chain, validate_schema=True) # Validates, then executes if valid + print(f"Query executed: {len(result._nodes)} nodes") + except GFQLSchemaError as e: + print(f"Validation failed, query not executed: {e}") + +Error Collection +^^^^^^^^^^^^^^^^ + +Choose between fail-fast and collect-all modes: + +.. code-block:: python + + # Fail-fast (default) + try: + chain = Chain([problematic_operations]) + except GFQLValidationError as e: + print(f"First error: {e}") + + # Collect all errors + errors = chain.validate(collect_all=True) + for error in errors: + print(f"[{error.code}] {error.message}") + +Next Steps +---------- + +* :doc:`llm` - AI integration patterns +* :doc:`production` - Production deployment patterns + +See Also +-------- + +* :doc:`../spec/language` - Complete language specification +* :doc:`../overview` - GFQL overview \ No newline at end of file diff --git a/docs/source/gfql/validation/index.rst b/docs/source/gfql/validation/index.rst new file mode 100644 index 0000000000..0618fdf2a1 --- /dev/null +++ b/docs/source/gfql/validation/index.rst @@ -0,0 +1,25 @@ +GFQL Validation Guide +===================== + +Learn how to validate GFQL queries for syntax correctness, schema compatibility, and production use. + +.. toctree:: + :maxdepth: 1 + :caption: Validation Topics + + fundamentals + llm + production + +Overview +-------- + +The GFQL validation framework provides comprehensive tools for ensuring query correctness: + +* **Syntax Validation** - Check query structure without data +* **Schema Validation** - Verify queries against actual data schemas +* **Combined Validation** - Full validation with helpful error messages +* **LLM Integration** - Structured formats for AI code generation +* **Production Patterns** - Caching, monitoring, and CI/CD integration + +Start with :doc:`fundamentals` to learn the basics, then explore advanced topics based on your needs. \ No newline at end of file diff --git a/docs/source/gfql/validation/llm.rst b/docs/source/gfql/validation/llm.rst new file mode 100644 index 0000000000..d42516c1b2 --- /dev/null +++ b/docs/source/gfql/validation/llm.rst @@ -0,0 +1,184 @@ +GFQL Validation for LLMs +======================== + +Learn how to integrate GFQL's built-in validation with Large Language Models and automation pipelines. + +.. note:: + Explore the complete examples in + `GFQL Validation Fundamentals notebook <../../demos/gfql/gfql_validation_fundamentals.html>`_. + +Target Audience +--------------- + +* AI/ML Engineers building GFQL generation systems +* Developers integrating LLMs with graph queries +* Teams building automated query generation pipelines + +JSON Integration +---------------- + +GFQL queries use JSON for LLM integration: + +.. code-block:: json + + { + "type": "Chain", + "chain": [ + {"type": "Node", "filter_dict": {"type": "user"}}, + {"type": "Edge", "direction": "forward", "hops": 2}, + {"type": "Node", "filter_dict": {"score": {"type": "GT", "val": 80}}} + ] + } + +Validation Workflow +------------------- + +Parse and Validate +^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from graphistry.compute.exceptions import GFQLValidationError + from graphistry.compute.chain import Chain + + def json_to_chain(json_data): + """Parse JSON from LLM into query object.""" + try: + return Chain.from_json(json_data, validate=True) + except GFQLValidationError as e: + # Handle parse errors + return None, e + + def chain_to_json(chain): + """Convert query to JSON for LLM training/examples.""" + return chain.to_json(validate=False) # Already validated + + def validation_error_to_dict(error: GFQLValidationError) -> dict: + """Convert validation error to LLM-friendly format.""" + return { + "code": error.code, + "message": error.message, + "field": error.context.get("field"), + "value": str(error.context.get("value")) if error.context.get("value") else None, + "suggestion": error.context.get("suggestion"), + "operation_index": error.context.get("operation_index"), + "error_type": error.__class__.__name__ + } + +Validate Query Syntax +^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Validate syntax and structure + syntax_errors = chain.validate(collect_all=True) + + if syntax_errors: + print(f"Found {len(syntax_errors)} syntax errors") + for error in syntax_errors: + print(f" [{error.code}] {error.message}") + +Validate Against Schema +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from graphistry.compute.validate_schema import validate_chain_schema + + # Validate against actual data schema + schema_errors = [] + if g: # Your Plottable instance with data + schema_errors = validate_chain_schema(g, chain, collect_all=True) or [] + + if schema_errors: + print(f"Found {len(schema_errors)} schema errors") + for error in schema_errors: + print(f" [{error.code}] {error.message}") + +Combined Validation +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + # Complete validation pipeline + def validate_llm_query(json_data, graph=None): + """Full validation with detailed feedback.""" + # Parse + result = json_to_chain(json_data) + if isinstance(result, tuple): + return {"success": False, "parse_errors": [validation_error_to_dict(result[1])]} + + chain = result + + # Validate syntax + syntax_errors = chain.validate(collect_all=True) + + # Validate schema if graph provided + schema_errors = [] + if graph: + schema_errors = validate_chain_schema(graph, chain, collect_all=True) or [] + + # Return results + if syntax_errors or schema_errors: + return { + "success": False, + "syntax_errors": [validation_error_to_dict(e) for e in syntax_errors], + "schema_errors": [validation_error_to_dict(e) for e in schema_errors] + } + + return {"success": True, "chain": chain} + +Automated Fix Suggestions +------------------------- + +Generate actionable suggestions using structured error context: + +.. code-block:: python + + def generate_fix_suggestions(errors): + """Generate fix suggestions from validation errors.""" + fixes = [] + + for error in errors: + fix = { + "error_code": error.code, + "operation_index": error.context.get("operation_index"), + "field": error.context.get("field"), + "current_value": error.context.get("value"), + "suggested_action": error.context.get("suggestion") + } + + # Add specific fix actions based on error code + if error.code == ErrorCode.E103: # Invalid parameter value (e.g., negative hops) + fix["action"] = "replace_parameter" + # Extract valid value from suggestion if present + if "positive integer" in error.message: + fix["fix_hint"] = "Use a positive integer value" + elif error.code == ErrorCode.E301: # Column not found + fix["action"] = "replace_column" + # Available columns are in the suggestion text + if error.context.get("suggestion") and "Available columns:" in error.context.get("suggestion"): + fix["available_columns_hint"] = error.context.get("suggestion") + elif error.code == ErrorCode.E302: # Type mismatch + fix["action"] = "fix_type_mismatch" + fix["column_type"] = error.context.get("column_type") + + fixes.append(fix) + + return fixes + +Best Practices +-------------- + +1. **Built-in Validation**: Use GFQL's automatic validation during construction +2. **Error Codes**: Leverage structured error codes (E1xx, E2xx, E3xx) for programmatic handling +3. **Collect-All Mode**: Use ``collect_all=True`` for comprehensive error reporting to LLMs +4. **Schema Context**: Provide available columns and types in LLM prompts +5. **Pre-execution Validation**: Validate schema before expensive operations + +See Also +-------- + +* :doc:`production` - Production patterns +* :doc:`../spec/language` - Language specification +* :doc:`../spec/cypher_mapping` - Cypher to GFQL mapping \ No newline at end of file diff --git a/docs/source/gfql/validation/production.rst b/docs/source/gfql/validation/production.rst new file mode 100644 index 0000000000..d78c2ba462 --- /dev/null +++ b/docs/source/gfql/validation/production.rst @@ -0,0 +1,323 @@ +GFQL Validation in Production +============================= + +Production-ready patterns for GFQL built-in validation in platform engineering and DevOps contexts. + +.. note:: + See complete implementation examples in + `GFQL Validation Fundamentals notebook <../../demos/gfql/gfql_validation_fundamentals.html>`_. + +Target Audience +--------------- + +* Platform Engineers +* DevOps Teams +* Backend Developers +* System Architects + +Performance & Caching +--------------------- + +Schema Caching +^^^^^^^^^^^^^^ + +.. code-block:: python + + from functools import lru_cache + import time + + class CachedSchemaValidator: + def __init__(self, cache_size=1000, ttl_seconds=3600): + self.cache_size = cache_size + self.ttl_seconds = ttl_seconds + self._cache = {} + self._cache_times = {} + + # Cache the actual validation function + self._validate_uncached = lru_cache(maxsize=cache_size)( + self._validate_impl + ) + + def _validate_impl(self, operations_hash, plottable_id): + """Actual validation implementation.""" + # Implementation depends on having stable plottable references + pass + + def validate(self, operations, plottable): + """Validate with caching.""" + # Use built-in validation with caching layer + cache_key = (hash(str(operations)), id(plottable)) + + if cache_key in self._cache: + cache_time = self._cache_times.get(cache_key, 0) + if time.time() - cache_time < self.ttl_seconds: + return self._cache[cache_key] + + # Perform validation + validator = PlottableValidator(plottable) + result = validator.validate(operations, collect_all=True) + + # Cache result + self._cache[cache_key] = result + self._cache_times[cache_key] = time.time() + + return result + +Batch Validation +^^^^^^^^^^^^^^^^ + +.. code-block:: python + + def batch_validate_queries(operation_sets, plottable): + """Validate multiple queries efficiently with built-in validation.""" + validator = PlottableValidator(plottable) + + results = [] + for operations in operation_sets: + try: + errors = validator.validate(operations, collect_all=True) + results.append({ + "valid": len(errors) == 0, + "errors": [ + { + "code": e.code, + "message": e.message, + "field": e.context.get("field"), + "suggestion": e.context.get("suggestion") + } + for e in errors + ] + }) + except Exception as e: + results.append({ + "valid": False, + "error": str(e) + }) + + return results + +Testing Patterns +---------------- + +pytest Fixtures +^^^^^^^^^^^^^^^ + +.. code-block:: python + + import pytest + import pandas as pd + import graphistry + from graphistry.compute.chain import Chain + from graphistry.compute.ast import n, e_forward + from graphistry.compute.predicates.str import eq + from graphistry.compute.exceptions import GFQLValidationError + + @pytest.fixture + def sample_plottable(): + nodes = pd.DataFrame({ + 'id': [1, 2, 3], + 'type': ['A', 'B', 'A'] + }) + edges = pd.DataFrame({ + 'src': [1, 2], + 'dst': [2, 3] + }) + g = graphistry.nodes(nodes, node='id').edges(edges, source='src', destination='dst') + return g + + def test_valid_query(sample_plottable): + operations = [n({'type': eq('A')})] + + # Test syntax validation + chain = Chain(operations) # Should not raise + + # Test schema validation + result = sample_plottable.chain(operations) # Should not raise + assert len(result._nodes) > 0 + + def test_invalid_query_syntax(sample_plottable): + with pytest.raises(GFQLValidationError) as exc_info: + chain = Chain([n({'type': eq('A')}, name=123)]) # Invalid name type + assert exc_info.value.code.startswith('E2') # Type error + + def test_invalid_query_schema(sample_plottable): + operations = [n({'missing_column': eq('value')})] + + with pytest.raises(GFQLValidationError) as exc_info: + result = sample_plottable.chain(operations) # Schema validation fails + assert exc_info.value.code == 'E301' # Column not found + +API Integration +--------------- + +Flask Example +^^^^^^^^^^^^^ + +.. code-block:: python + + from flask import Flask, request, jsonify + from graphistry.compute.chain import Chain + from graphistry.compute.exceptions import GFQLValidationError + from graphistry.compute.ast import from_json + + app = Flask(__name__) + + @app.route('/api/v1/validate', methods=['POST']) + def validate_gfql(): + data = request.get_json() + operations_json = data.get('operations') + + try: + # Parse operations from JSON + operations = [from_json(op) for op in operations_json] + + # Validate syntax (automatic during Chain construction) + chain = Chain(operations) + syntax_errors = chain.validate(collect_all=True) + + # Prepare response + response = { + 'valid': len(syntax_errors) == 0, + 'errors': [ + { + 'code': e.code, + 'message': e.message, + 'field': e.context.get('field'), + 'suggestion': e.context.get('suggestion') + } + for e in syntax_errors + ] + } + + return jsonify(response) + + except Exception as e: + return jsonify({ + 'valid': False, + 'error': str(e) + }), 400 + + @app.route('/api/v1/validate-with-schema', methods=['POST']) + def validate_gfql_with_schema(): + data = request.get_json() + operations_json = data.get('operations') + plottable_data = data.get('plottable') # Serialized plottable + + try: + # Parse operations from JSON + operations = [from_json(op) for op in operations_json] + + # Would need to reconstruct plottable from data + # and use validate_chain_schema + from graphistry.compute.validate_schema import validate_chain_schema + + # This is a placeholder - actual implementation would need + # to deserialize plottable_data into a plottable instance + # errors = validate_chain_schema(plottable, operations, collect_all=True) + + return jsonify({ + 'valid': True, + 'message': 'Schema validation endpoint placeholder' + }) + + except Exception as e: + return jsonify({ + 'valid': False, + 'error': str(e) + }), 500 + +Security Considerations +----------------------- + +GFQL is designed with security in mind to prevent arbitrary code execution: + +**Safe Query Generation** + +Instead of generating Python code directly, generate JSON and use GFQL's validation: + +.. code-block:: python + + # DON'T: Generate Python code (security risk) + # query = f"g.chain([n({{'user_id': '{user_input}'}})])" + # eval(query) # NEVER DO THIS + + # DO: Generate JSON and validate + query_json = { + "type": "Chain", + "chain": [{ + "type": "Node", + "filter_dict": {"user_id": user_input} + }] + } + + # Safe parsing with validation + from graphistry.compute.chain import Chain + chain = Chain.from_json(query_json, validate=True) + result = g.chain(chain.chain) + +**Key Security Features** + +1. **No Code Execution**: GFQL operations are data structures, not executable code +2. **Input Validation**: All inputs are validated against strict schemas +3. **Type Safety**: Strong typing prevents injection attacks +4. **Bounded Operations**: Queries have defined limits (e.g., max hops) + +**Rate Limiting Example** + +.. code-block:: python + + from collections import defaultdict + import time + + class RateLimiter: + def __init__(self, requests_per_minute=100): + self.requests_per_minute = requests_per_minute + self.request_times = defaultdict(list) + + def check_rate_limit(self, user_id): + now = time.time() + user_requests = self.request_times[user_id] + + # Clean old requests + user_requests[:] = [t for t in user_requests if now - t < 60] + + if len(user_requests) >= self.requests_per_minute: + return False, f"Rate limit exceeded. Try again in {60 - (now - user_requests[0]):.0f} seconds" + + user_requests.append(now) + return True, None + +Production Checklist +-------------------- + +* **Built-in Validation**: Use GFQL's automatic validation system +* **Caching**: Implement validation result caching +* **Batch Processing**: Validate multiple queries efficiently +* **Testing**: Comprehensive test coverage with pytest +* **API Design**: RESTful endpoints with structured error responses +* **Security**: Generate JSON instead of Python code, use validation +* **Rate Limiting**: Implement per-user request limits +* **Error Codes**: Use structured error codes for programmatic handling + +Performance Guidelines +---------------------- + +1. **Schema Validation**: Use validate_schema=True (default) for production safety +2. **Pre-execution Validation**: Validate before expensive operations +3. **Caching**: Cache validation results with appropriate TTL +4. **Batch Processing**: Use collect_all=True for multiple error reporting +5. **Rate Limiting**: Set reasonable per-user request limits + +Next Steps +---------- + +* Implement production validation service +* Create runbooks for common issues +* Establish performance benchmarks + +See Also +-------- + +* :doc:`../spec/wire_protocol` - Wire protocol specification +* `PyGraphistry API Reference `_ +* `Production Deployment Guide `_ \ No newline at end of file diff --git a/docs/source/graphistry.compute.gfql.rst b/docs/source/graphistry.compute.gfql.rst new file mode 100644 index 0000000000..f42403baea --- /dev/null +++ b/docs/source/graphistry.compute.gfql.rst @@ -0,0 +1,29 @@ +graphistry.compute.gfql package +=============================== + +Submodules +---------- + +graphistry.compute.gfql.exceptions module +----------------------------------------- + +.. automodule:: graphistry.compute.gfql.exceptions + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.gfql.validate module +--------------------------------------- + +.. automodule:: graphistry.compute.gfql.validate + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: graphistry.compute.gfql + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/graphistry.compute.rst b/docs/source/graphistry.compute.rst new file mode 100644 index 0000000000..1291c74966 --- /dev/null +++ b/docs/source/graphistry.compute.rst @@ -0,0 +1,136 @@ +graphistry.compute package +========================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + graphistry.compute.gfql + graphistry.compute.predicates + +Submodules +---------- + +graphistry.compute.ASTSerializable module +----------------------------------------- + +.. automodule:: graphistry.compute.ASTSerializable + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.ComputeMixin module +-------------------------------------- + +.. automodule:: graphistry.compute.ComputeMixin + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.ast module +----------------------------- + +.. automodule:: graphistry.compute.ast + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.ast\_temporal module +--------------------------------------- + +.. automodule:: graphistry.compute.ast_temporal + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.chain module +------------------------------- + +.. automodule:: graphistry.compute.chain + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.chain\_remote module +--------------------------------------- + +.. automodule:: graphistry.compute.chain_remote + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.chain\_validate module +----------------------------------------- + +.. automodule:: graphistry.compute.chain_validate + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.cluster module +--------------------------------- + +.. automodule:: graphistry.compute.cluster + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.collapse module +---------------------------------- + +.. automodule:: graphistry.compute.collapse + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.conditional module +------------------------------------- + +.. automodule:: graphistry.compute.conditional + :members: + :undoc-members: + :show-inheritance: + + +graphistry.compute.filter\_by\_dict module +------------------------------------------ + +.. automodule:: graphistry.compute.filter_by_dict + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.hop module +----------------------------- + +.. automodule:: graphistry.compute.hop + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.python\_remote module +---------------------------------------- + +.. automodule:: graphistry.compute.python_remote + :members: + :undoc-members: + :show-inheritance: + +graphistry.compute.typing module +-------------------------------- + +.. automodule:: graphistry.compute.typing + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: graphistry.compute + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/graphistry.validate.rst b/docs/source/graphistry.validate.rst new file mode 100644 index 0000000000..b02efd093a --- /dev/null +++ b/docs/source/graphistry.validate.rst @@ -0,0 +1,21 @@ +graphistry.validate package +=========================== + +Submodules +---------- + +graphistry.validate.validate\_encodings module +---------------------------------------------- + +.. automodule:: graphistry.validate.validate_encodings + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: graphistry.validate + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/notebooks/gfql.rst b/docs/source/notebooks/gfql.rst index d03accb479..a967eae076 100644 --- a/docs/source/notebooks/gfql.rst +++ b/docs/source/notebooks/gfql.rst @@ -7,6 +7,7 @@ GFQL Graph queries :titlesonly: Intro to graph queries with hop and chain <../demos/more_examples/graphistry_features/hop_and_chain_graph_pattern_mining.ipynb> + GFQL Validation Fundamentals <../demos/gfql/gfql_validation_fundamentals.ipynb> DateTime Filtering Examples <../demos/gfql/temporal_predicates.ipynb> GPU Benchmarking <../demos/gfql/benchmark_hops_cpu_gpu.ipynb> GFQL Remote mode <../demos/gfql/gfql_remote.ipynb> diff --git a/graphistry/compute/ASTSerializable.py b/graphistry/compute/ASTSerializable.py index 4122f189b0..0ee1ad5d11 100644 --- a/graphistry/compute/ASTSerializable.py +++ b/graphistry/compute/ASTSerializable.py @@ -1,9 +1,11 @@ -from abc import ABC, abstractmethod -from typing import Dict -import pandas as pd +from abc import ABC +from typing import Dict, List, Optional, TYPE_CHECKING from graphistry.utils.json import JSONVal, serialize_to_json_val +if TYPE_CHECKING: + from graphistry.compute.exceptions import GFQLValidationError + class ASTSerializable(ABC): """ @@ -13,9 +15,67 @@ class ASTSerializable(ABC): reserved_fields = ['type'] - def validate(self) -> None: + def validate(self, collect_all: bool = False) -> Optional[List['GFQLValidationError']]: + """Validate this AST node. + + Args: + collect_all: If True, collect all errors instead of raising on first. + If False (default), raise on first error. + + Returns: + If collect_all=True: List of validation errors (empty if valid) + If collect_all=False: None if valid + + Raises: + GFQLValidationError: If collect_all=False and validation fails + """ + if not collect_all: + # Fail fast mode - raise on first error + self._validate_fields() + # Validate children + for child in self._get_child_validators(): + child.validate(collect_all=False) + return None + + # Collect all errors mode + errors: List['GFQLValidationError'] = [] + + # Collect own validation errors + try: + self._validate_fields() + except Exception as e: + # Import here to avoid circular dependency + from graphistry.compute.exceptions import GFQLValidationError + if isinstance(e, GFQLValidationError): + errors.append(e) + else: + # Re-raise non-validation errors + raise + + # Collect child validation errors + for child in self._get_child_validators(): + child_errors = child.validate(collect_all=True) + if child_errors: + errors.extend(child_errors) + + return errors + + def _validate_fields(self) -> None: + """Override in subclasses to validate specific fields. + + Should raise GFQLValidationError for validation failures. + Default implementation does nothing (for backward compatibility). + """ pass + def _get_child_validators(self) -> List['ASTSerializable']: + """Override in subclasses to return child AST nodes that need validation. + + Returns: + List of child AST nodes to validate + """ + return [] + def to_json(self, validate=True) -> Dict[str, JSONVal]: """ Returns JSON-compatible dictionry {"type": "ClassName", "arg1": val1, ...} @@ -30,11 +90,24 @@ def to_json(self, validate=True) -> Dict[str, JSONVal]: return data @classmethod - def from_json(cls, d: Dict[str, JSONVal]) -> 'ASTSerializable': + def from_json(cls, d: Dict[str, JSONVal], validate: bool = True) -> 'ASTSerializable': """ Given c.to_json(), hydrate back c - Corresponding c.__class__.__init__ must accept all non-reserved instance fields + Args: + d: Dictionary from to_json() + validate: If True (default), validate after parsing + + Returns: + Hydrated AST object + + Raises: + GFQLValidationError: If validate=True and validation fails """ constructor_args = {k: v for k, v in d.items() if k not in cls.reserved_fields} - return cls(**constructor_args) + instance = cls(**constructor_args) + + if validate: + instance.validate(collect_all=False) + + return instance diff --git a/graphistry/compute/ast.py b/graphistry/compute/ast.py index 59601cf474..f58c744e49 100644 --- a/graphistry/compute/ast.py +++ b/graphistry/compute/ast.py @@ -129,13 +129,60 @@ def __init__(self, filter_dict: Optional[dict] = None, name: Optional[str] = Non def __repr__(self) -> str: return f'ASTNode(filter_dict={self.filter_dict}, name={self._name})' - def validate(self) -> None: + def _validate_fields(self) -> None: + """Validate node fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + # Validate filter_dict if self.filter_dict is not None: - assert_record_match(self.filter_dict) - if self._name is not None: - assert isinstance(self._name, str) - if self.query is not None: - assert isinstance(self.query, str) + if not isinstance(self.filter_dict, dict): + raise GFQLTypeError( + ErrorCode.E201, + "filter_dict must be a dictionary", + field="filter_dict", + value=type(self.filter_dict).__name__, + suggestion="Use filter_dict={'column': 'value'}", + ) + + # Validate each key in filter_dict + for key, value in self.filter_dict.items(): + if not isinstance(key, str): + raise GFQLTypeError( + ErrorCode.E102, + "Filter keys must be strings", + field=f"filter_dict.{key}", + value=key, + suggestion="Use string column names as keys", + ) + + # Validate value is either ASTPredicate or json-serializable + if not (isinstance(value, ASTPredicate) or is_json_serializable(value)): + raise GFQLTypeError( + ErrorCode.E201, + "Filter values must be predicates or JSON-serializable", + field=f"filter_dict.{key}", + value=type(value).__name__, + suggestion="Use predicates like gt(5) or simple values", + ) + + # Validate name + if self._name is not None and not isinstance(self._name, str): + raise GFQLTypeError(ErrorCode.E204, "name must be a string", field="name", value=type(self._name).__name__) + + # Validate query + if self.query is not None and not isinstance(self.query, str): + raise GFQLTypeError( + ErrorCode.E205, "query must be a string", field="query", value=type(self.query).__name__ + ) + + def _get_child_validators(self) -> list: + """Return predicates that need validation.""" + children = [] + if self.filter_dict: + for value in self.filter_dict.values(): + if isinstance(value, ASTPredicate): + children.append(value) + return children def to_json(self, validate=True) -> dict: if validate: @@ -152,13 +199,14 @@ def to_json(self, validate=True) -> dict: } @classmethod - def from_json(cls, d: dict) -> 'ASTNode': + def from_json(cls, d: dict, validate: bool = True) -> 'ASTNode': out = ASTNode( filter_dict=maybe_filter_dict_from_json(d, 'filter_dict'), name=d['name'] if 'name' in d else None, query=d['query'] if 'query' in d else None ) - out.validate() + if validate: + out.validate() return out def __call__( @@ -207,6 +255,7 @@ class ASTEdge(ASTObject): """ Internal, not intended for use outside of this module. """ + def __init__( self, direction: Optional[Direction] = DEFAULT_DIRECTION, @@ -218,7 +267,7 @@ def __init__( source_node_query: Optional[str] = None, destination_node_query: Optional[str] = None, edge_query: Optional[str] = None, - name: Optional[str] = None + name: Optional[str] = None, ): super().__init__(name) @@ -245,24 +294,93 @@ def __init__( def __repr__(self) -> str: return f'ASTEdge(direction={self.direction}, edge_match={self.edge_match}, hops={self.hops}, to_fixed_point={self.to_fixed_point}, source_node_match={self.source_node_match}, destination_node_match={self.destination_node_match}, name={self._name}, source_node_query={self.source_node_query}, destination_node_query={self.destination_node_query}, edge_query={self.edge_query})' - def validate(self) -> None: - assert self.hops is None or isinstance(self.hops, int) - assert isinstance(self.to_fixed_point, bool) - assert self.direction in ['forward', 'reverse', 'undirected'] - if self.source_node_match is not None: - assert_record_match(self.source_node_match) - if self.edge_match is not None: - assert_record_match(self.edge_match) - if self.destination_node_match is not None: - assert_record_match(self.destination_node_match) - if self._name is not None: - assert isinstance(self._name, str) - if self.source_node_query is not None: - assert isinstance(self.source_node_query, str) - if self.destination_node_query is not None: - assert isinstance(self.destination_node_query, str) - if self.edge_query is not None: - assert isinstance(self.edge_query, str) + def _validate_fields(self) -> None: + """Validate edge fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError, GFQLSyntaxError + + # Validate hops + if self.hops is not None: + if not isinstance(self.hops, int) or self.hops < 1: + raise GFQLTypeError( + ErrorCode.E103, + "hops must be a positive integer or None", + field="hops", + value=self.hops, + suggestion="Use hops=2 for specific count, or to_fixed_point=True for unbounded", + ) + + # Validate to_fixed_point + if not isinstance(self.to_fixed_point, bool): + raise GFQLTypeError( + ErrorCode.E201, + "to_fixed_point must be a boolean", + field="to_fixed_point", + value=type(self.to_fixed_point).__name__, + ) + + # Validate direction + if self.direction not in ['forward', 'reverse', 'undirected']: + raise GFQLSyntaxError( + ErrorCode.E104, + f"Invalid edge direction: {self.direction}", + field="direction", + value=self.direction, + suggestion='Use "forward", "reverse", or "undirected"', + ) + + # Validate filter dicts + for filter_name, filter_dict in [ + ('source_node_match', self.source_node_match), + ('edge_match', self.edge_match), + ('destination_node_match', self.destination_node_match), + ]: + if filter_dict is not None: + if not isinstance(filter_dict, dict): + raise GFQLTypeError( + ErrorCode.E201, + f"{filter_name} must be a dictionary", + field=filter_name, + value=type(filter_dict).__name__, + ) + + for key, value in filter_dict.items(): + if not isinstance(key, str): + raise GFQLTypeError( + ErrorCode.E102, "Filter keys must be strings", field=f"{filter_name}.{key}", value=key + ) + + if not (isinstance(value, ASTPredicate) or is_json_serializable(value)): + raise GFQLTypeError( + ErrorCode.E201, + "Filter values must be predicates or JSON-serializable", + field=f"{filter_name}.{key}", + value=type(value).__name__, + ) + + # Validate name + if self._name is not None and not isinstance(self._name, str): + raise GFQLTypeError(ErrorCode.E204, "name must be a string", field="name", value=type(self._name).__name__) + + # Validate query strings + for query_name, query_value in [ + ("source_node_query", self.source_node_query), + ("destination_node_query", self.destination_node_query), + ("edge_query", self.edge_query), + ]: + if query_value is not None and not isinstance(query_value, str): + raise GFQLTypeError( + ErrorCode.E205, f"{query_name} must be a string", field=query_name, value=type(query_value).__name__ + ) + + def _get_child_validators(self) -> list: + """Return predicates that need validation.""" + children = [] + for filter_dict in [self.source_node_match, self.edge_match, self.destination_node_match]: + if filter_dict: + for value in filter_dict.values(): + if isinstance(value, ASTPredicate): + children.append(value) + return children def to_json(self, validate=True) -> dict: if validate: @@ -294,7 +412,7 @@ def to_json(self, validate=True) -> dict: } @classmethod - def from_json(cls, d: dict) -> 'ASTEdge': + def from_json(cls, d: dict, validate: bool = True) -> 'ASTEdge': out = ASTEdge( direction=d['direction'] if 'direction' in d else None, edge_match=maybe_filter_dict_from_json(d, 'edge_match'), @@ -307,7 +425,8 @@ def from_json(cls, d: dict) -> 'ASTEdge': edge_query=d['edge_query'] if 'edge_query' in d else None, name=d['name'] if 'name' in d else None ) - out.validate() + if validate: + out.validate() return out def __call__( @@ -376,7 +495,9 @@ class ASTEdgeForward(ASTEdge): """ Internal, not intended for use outside of this module. """ - def __init__(self, + + def __init__( + self, edge_match: Optional[dict] = DEFAULT_FILTER_DICT, hops: Optional[int] = DEFAULT_HOPS, source_node_match: Optional[dict] = DEFAULT_FILTER_DICT, @@ -385,7 +506,7 @@ def __init__(self, name: Optional[str] = None, source_node_query: Optional[str] = None, destination_node_query: Optional[str] = None, - edge_query: Optional[str] = None + edge_query: Optional[str] = None, ): super().__init__( direction='forward', @@ -397,11 +518,11 @@ def __init__(self, name=name, source_node_query=source_node_query, destination_node_query=destination_node_query, - edge_query=edge_query + edge_query=edge_query, ) @classmethod - def from_json(cls, d: dict) -> 'ASTEdge': + def from_json(cls, d: dict, validate: bool = True) -> 'ASTEdge': out = ASTEdgeForward( edge_match=maybe_filter_dict_from_json(d, 'edge_match'), hops=d['hops'] if 'hops' in d else None, @@ -413,16 +534,20 @@ def from_json(cls, d: dict) -> 'ASTEdge': edge_query=d['edge_query'] if 'edge_query' in d else None, name=d['name'] if 'name' in d else None ) - out.validate() + if validate: + out.validate() return out + e_forward = ASTEdgeForward # noqa: E305 class ASTEdgeReverse(ASTEdge): """ Internal, not intended for use outside of this module. """ - def __init__(self, + + def __init__( + self, edge_match: Optional[dict] = DEFAULT_FILTER_DICT, hops: Optional[int] = DEFAULT_HOPS, source_node_match: Optional[dict] = DEFAULT_FILTER_DICT, @@ -431,7 +556,7 @@ def __init__(self, name: Optional[str] = None, source_node_query: Optional[str] = None, destination_node_query: Optional[str] = None, - edge_query: Optional[str] = None + edge_query: Optional[str] = None, ): super().__init__( direction='reverse', @@ -443,11 +568,11 @@ def __init__(self, name=name, source_node_query=source_node_query, destination_node_query=destination_node_query, - edge_query=edge_query + edge_query=edge_query, ) @classmethod - def from_json(cls, d: dict) -> 'ASTEdge': + def from_json(cls, d: dict, validate: bool = True) -> 'ASTEdge': out = ASTEdgeReverse( edge_match=maybe_filter_dict_from_json(d, 'edge_match'), hops=d['hops'] if 'hops' in d else None, @@ -459,16 +584,20 @@ def from_json(cls, d: dict) -> 'ASTEdge': edge_query=d['edge_query'] if 'edge_query' in d else None, name=d['name'] if 'name' in d else None ) - out.validate() + if validate: + out.validate() return out + e_reverse = ASTEdgeReverse # noqa: E305 class ASTEdgeUndirected(ASTEdge): """ Internal, not intended for use outside of this module. """ - def __init__(self, + + def __init__( + self, edge_match: Optional[dict] = DEFAULT_FILTER_DICT, hops: Optional[int] = DEFAULT_HOPS, source_node_match: Optional[dict] = DEFAULT_FILTER_DICT, @@ -477,7 +606,7 @@ def __init__(self, name: Optional[str] = None, source_node_query: Optional[str] = None, destination_node_query: Optional[str] = None, - edge_query: Optional[str] = None + edge_query: Optional[str] = None, ): super().__init__( direction='undirected', @@ -489,11 +618,11 @@ def __init__(self, name=name, source_node_query=source_node_query, destination_node_query=destination_node_query, - edge_query=edge_query + edge_query=edge_query, ) @classmethod - def from_json(cls, d: dict) -> 'ASTEdge': + def from_json(cls, d: dict, validate: bool = True) -> 'ASTEdge': out = ASTEdgeUndirected( edge_match=maybe_filter_dict_from_json(d, 'edge_match'), hops=d['hops'] if 'hops' in d else None, @@ -505,32 +634,58 @@ def from_json(cls, d: dict) -> 'ASTEdge': edge_query=d['edge_query'] if 'edge_query' in d else None, name=d['name'] if 'name' in d else None ) - out.validate() + if validate: + out.validate() return out + e_undirected = ASTEdgeUndirected # noqa: E305 e = ASTEdgeUndirected # noqa: E305 ### -def from_json(o: JSONVal) -> Union[ASTNode, ASTEdge]: - assert isinstance(o, dict) - assert 'type' in o - out : Union[ASTNode, ASTEdge] +def from_json(o: JSONVal, validate: bool = True) -> Union[ASTNode, ASTEdge]: + from graphistry.compute.exceptions import ErrorCode, GFQLSyntaxError + + if not isinstance(o, dict): + raise GFQLSyntaxError(ErrorCode.E101, "AST JSON must be a dictionary", value=type(o).__name__) + + if 'type' not in o: + raise GFQLSyntaxError( + ErrorCode.E105, "AST JSON missing required 'type' field", suggestion="Add 'type' field: 'Node' or 'Edge'" + ) + + out: Union[ASTNode, ASTEdge] if o['type'] == 'Node': - out = ASTNode.from_json(o) + out = ASTNode.from_json(o, validate=validate) elif o['type'] == 'Edge': if 'direction' in o: if o['direction'] == 'forward': - out = ASTEdgeForward.from_json(o) + out = ASTEdgeForward.from_json(o, validate=validate) elif o['direction'] == 'reverse': - out = ASTEdgeReverse.from_json(o) + out = ASTEdgeReverse.from_json(o, validate=validate) elif o['direction'] == 'undirected': - out = ASTEdgeUndirected.from_json(o) + out = ASTEdgeUndirected.from_json(o, validate=validate) else: - raise ValueError(f'Edge has unknown direction {o["direction"]}') + raise GFQLSyntaxError( + ErrorCode.E104, + f"Edge has unknown direction: {o['direction']}", + field="direction", + value=o['direction'], + suggestion='Use "forward", "reverse", or "undirected"', + ) else: - raise ValueError('Edge missing direction') + raise GFQLSyntaxError( + ErrorCode.E105, + "Edge missing required 'direction' field", + suggestion="Add 'direction' field: 'forward', 'reverse', or 'undirected'", + ) else: - raise ValueError(f'Unknown type {o["type"]}') + raise GFQLSyntaxError( + ErrorCode.E101, + f"Unknown AST type: {o['type']}", + field="type", + value=o["type"], + suggestion="Use 'Node' or 'Edge'", + ) return out diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index ec95949c2f..5f1cb46af0 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Union, cast, List, Tuple +from typing import Dict, Union, cast, List, Tuple, Sequence, Optional, TYPE_CHECKING from graphistry.Engine import Engine, EngineAbstract, df_concat, resolve_engine from graphistry.Plottable import Plottable @@ -8,6 +8,10 @@ from graphistry.utils.json import JSONVal from .ast import ASTObject, ASTNode, ASTEdge, from_json as ASTObject_from_json from .typing import DataFrameT +from graphistry.compute.validate.validate_schema import validate_chain_schema + +if TYPE_CHECKING: + from graphistry.compute.exceptions import GFQLSchemaError, GFQLValidationError logger = setup_logger(__name__) @@ -20,22 +24,97 @@ class Chain(ASTSerializable): def __init__(self, chain: List[ASTObject]) -> None: self.chain = chain - def validate(self) -> None: - assert isinstance(self.chain, list) - for op in self.chain: - assert isinstance(op, ASTObject) - op.validate() + def validate(self, collect_all: bool = False) -> Optional[List['GFQLValidationError']]: + """Override to collect all chain validation errors.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError, GFQLValidationError + + if not collect_all: + # Use parent's fail-fast implementation + return super().validate(collect_all=False) + + # Collect all errors mode + errors: List[GFQLValidationError] = [] + + # Check if chain is a list + if not isinstance(self.chain, list): + errors.append(GFQLTypeError( + ErrorCode.E101, + f"Chain must be a list, but got {type(self.chain).__name__}. Wrap your operations in a list []." + )) + return errors # Can't continue if not a list + + # Check each operation + for i, op in enumerate(self.chain): + if not isinstance(op, ASTObject): + errors.append(GFQLTypeError( + ErrorCode.E101, + f"Chain operation at index {i} is not a valid GFQL operation. Got {type(op).__name__} instead of an ASTObject.", + operation_index=i, + actual_type=type(op).__name__, + suggestion="Use n() for nodes, e() for edges, or other GFQL operations" + )) + + # Validate child AST nodes + for child in self._get_child_validators(): + child_errors = child.validate(collect_all=True) + if child_errors: + errors.extend(child_errors) + + return errors + + def _validate_fields(self) -> None: + """Validate Chain fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.chain, list): + raise GFQLTypeError( + ErrorCode.E101, + f"Chain must be a list, but got {type(self.chain).__name__}. Wrap your operations in a list []." + ) + + for i, op in enumerate(self.chain): + if not isinstance(op, ASTObject): + raise GFQLTypeError( + ErrorCode.E101, + f"Chain operation at index {i} is not a valid GFQL operation. Got {type(op).__name__} instead of an ASTObject.", + operation_index=i, + actual_type=type(op).__name__, + suggestion="Use n() for nodes, e() for edges, or other GFQL operations" + ) + + def _get_child_validators(self) -> List[ASTSerializable]: + """Return child AST nodes that need validation.""" + # Only return valid ASTObject instances + return cast(List[ASTSerializable], [op for op in self.chain if isinstance(op, ASTObject)]) @classmethod - def from_json(cls, d: Dict[str, JSONVal]) -> 'Chain': + def from_json(cls, d: Dict[str, JSONVal], validate: bool = True) -> 'Chain': """ Convert a JSON AST into a list of ASTObjects """ - assert isinstance(d, dict) - assert 'chain' in d - assert isinstance(d['chain'], list) - out = cls([ASTObject_from_json(op) for op in d['chain']]) - out.validate() + from graphistry.compute.exceptions import ErrorCode, GFQLSyntaxError + + if not isinstance(d, dict): + raise GFQLSyntaxError( + ErrorCode.E101, + f"Chain JSON must be a dictionary, got {type(d).__name__}" + ) + + if 'chain' not in d: + raise GFQLSyntaxError( + ErrorCode.E105, + "Chain JSON missing required 'chain' field" + ) + + if not isinstance(d['chain'], list): + raise GFQLSyntaxError( + ErrorCode.E101, + f"Chain field must be a list, got {type(d['chain']).__name__}" + ) + + out = cls([ASTObject_from_json(op, validate=validate) for op in d['chain']]) + if validate: + out.validate() return out def to_json(self, validate=True) -> Dict[str, JSONVal]: @@ -49,6 +128,22 @@ def to_json(self, validate=True) -> Dict[str, JSONVal]: 'chain': [op.to_json() for op in self.chain] } + def validate_schema(self, g: Plottable, collect_all: bool = False) -> Optional[List['GFQLSchemaError']]: + """Validate this chain against a graph's schema without executing. + + Args: + g: Graph to validate against + collect_all: If True, collect all errors. If False, raise on first. + + Returns: + If collect_all=True: List of errors (empty if valid) + If collect_all=False: None if valid + + Raises: + GFQLSchemaError: If collect_all=False and validation fails + """ + return validate_chain_schema(g, self, collect_all) + ############################################################################### @@ -145,7 +240,7 @@ def combine_steps(g: Plottable, kind: str, steps: List[Tuple[ASTObject,Plottable # ############################################################################### -def chain(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Union[EngineAbstract, str] = EngineAbstract.AUTO) -> Plottable: +def chain(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Union[EngineAbstract, str] = EngineAbstract.AUTO, validate_schema: bool = True) -> Plottable: """ Chain a list of ASTObject (node/edge) traversal operations @@ -157,6 +252,7 @@ def chain(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Union[Eng Use `engine='cudf'` to force automatic GPU acceleration mode :param ops: List[ASTObject] Various node and edge matchers + :param validate_schema: Whether to validate the chain against the graph schema before executing :returns: Plotter :rtype: Plotter @@ -247,6 +343,9 @@ def chain(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Union[Eng if isinstance(ops, Chain): ops = ops.chain + if validate_schema: + validate_chain_schema(self, ops, collect_all=False) + if len(ops) == 0: return self diff --git a/graphistry/compute/exceptions.py b/graphistry/compute/exceptions.py new file mode 100644 index 0000000000..b39671327b --- /dev/null +++ b/graphistry/compute/exceptions.py @@ -0,0 +1,116 @@ +"""GFQL validation exceptions with structured error codes.""" + +from typing import Optional, Any, Dict + + +class ErrorCode: + """Error codes for GFQL validation errors. + + Error code ranges: + - E1xx: Syntax errors (structural issues) + - E2xx: Type errors (type mismatches) + - E3xx: Schema errors (data-related issues) + """ + + # Syntax errors (E1xx) + E101 = "invalid-chain-type" + E102 = "invalid-filter-key" + E103 = "invalid-hops-value" + E104 = "invalid-direction" + E105 = "missing-required-field" + E106 = "empty-chain" + + # Type errors (E2xx) + E201 = "type-mismatch" + E202 = "predicate-type-mismatch" + E203 = "invalid-predicate-value" + E204 = "invalid-name-type" + E205 = "invalid-query-type" + + # Schema errors (E3xx) - for future use + E301 = "column-not-found" + E302 = "incompatible-column-type" + E303 = "invalid-node-reference" + E304 = "invalid-edge-reference" + + +class GFQLValidationError(Exception): + """Base class for GFQL validation errors with structured information.""" + + def __init__(self, + code: str, + message: str, + field: Optional[str] = None, + value: Optional[Any] = None, + suggestion: Optional[str] = None, + operation_index: Optional[int] = None, + **extra_context): + """Initialize validation error with structured information. + + Args: + code: Error code from ErrorCode class + message: Human-readable error message + field: Field that caused the error (e.g., "filter_dict.user_id") + value: The invalid value that caused the error + suggestion: Helpful suggestion for fixing the error + operation_index: Index in chain where error occurred + **extra_context: Additional context information + """ + self.code = code + self.message = message + self.context = { + 'field': field, + 'value': value, + 'suggestion': suggestion, + 'operation_index': operation_index, + **extra_context + } + # Remove None values from context + self.context = {k: v for k, v in self.context.items() if v is not None} + + super().__init__(self.format_message()) + + def format_message(self) -> str: + """Format error message with code and context.""" + parts = [f"[{self.code}] {self.message}"] + + if 'field' in self.context: + parts.append(f"field: {self.context['field']}") + + if 'value' in self.context: + # Truncate long values + val_str = repr(self.context['value']) + if len(val_str) > 50: + val_str = val_str[:47] + "..." + parts.append(f"value: {val_str}") + + if 'operation_index' in self.context: + parts.append(f"at operation {self.context['operation_index']}") + + if 'suggestion' in self.context: + parts.append(f"suggestion: {self.context['suggestion']}") + + return " | ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + """Convert error to dictionary for structured output.""" + return { + 'code': self.code, + 'message': self.message, + **self.context + } + + +class GFQLSyntaxError(GFQLValidationError): + """Syntax errors in GFQL query structure.""" + pass + + +class GFQLTypeError(GFQLValidationError): + """Type mismatches in GFQL queries.""" + pass + + +class GFQLSchemaError(GFQLValidationError): + """Schema validation errors (column existence, type compatibility).""" + pass diff --git a/graphistry/compute/filter_by_dict.py b/graphistry/compute/filter_by_dict.py index 31d17726cc..e8c8cd2599 100644 --- a/graphistry/compute/filter_by_dict.py +++ b/graphistry/compute/filter_by_dict.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, TYPE_CHECKING, Union +from typing import Dict, Optional, Union import pandas as pd from graphistry.Engine import EngineAbstract, df_to_engine, resolve_engine, s_cons from graphistry.util import setup_logger @@ -21,16 +21,75 @@ def filter_by_dict(df: DataFrameT, filter_dict: Optional[dict] = None, engine: U if filter_dict is None or filter_dict == {}: return df - + engine_concrete = resolve_engine(engine, df) df = df_to_engine(df, engine_concrete) logger.debug('filter_by_dict engine: %s => %s', engine, engine_concrete) + from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError + predicates: Dict[str, ASTPredicate] = {} for col, val in filter_dict.items(): if col not in df.columns: - raise ValueError(f'Key "{col}" not in columns of df, available columns are: {df.columns}') - if isinstance(val, ASTPredicate): + raise GFQLSchemaError( + ErrorCode.E301, + f'Column "{col}" does not exist in dataframe', + field=col, + value=val, + suggestion=f'Available columns: {", ".join(df.columns[:10])}{"..." if len(df.columns) > 10 else ""}' + ) + + # Type checking for non-predicate values + if not isinstance(val, ASTPredicate): + # Check for obvious type mismatches + col_dtype = df[col].dtype + if pd.api.types.is_numeric_dtype(col_dtype) and isinstance(val, str): + raise GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: column "{col}" is numeric but filter value is string', + field=col, + value=val, + column_type=str(col_dtype), + suggestion=f'Use a numeric value like {col}=123' + ) + elif pd.api.types.is_string_dtype(col_dtype) and isinstance(val, (int, float)): + raise GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: column "{col}" is string but filter value is numeric', + field=col, + value=val, + column_type=str(col_dtype), + suggestion=f'Use a string value like {col}="value"' + ) + else: + # Validate predicates for appropriate column types + from .predicates.numeric import NumericASTPredicate, Between + from .predicates.str import Contains, Startswith, Endswith, Match + + col_dtype = df[col].dtype + + # Check numeric predicates on non-numeric columns + if isinstance(val, (NumericASTPredicate, Between)) and not pd.api.types.is_numeric_dtype(col_dtype): + raise GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: numeric predicate used on non-numeric column "{col}"', + field=col, + value=f"{val.__class__.__name__}(...)", + column_type=str(col_dtype), + suggestion='Use string predicates like contains() or startswith() for string columns' + ) + + # Check string predicates on non-string columns + if isinstance(val, (Contains, Startswith, Endswith, Match)) and not pd.api.types.is_string_dtype(col_dtype): + raise GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: string predicate used on non-string column "{col}"', + field=col, + value=f"{val.__class__.__name__}(...)", + column_type=str(col_dtype), + suggestion='Use numeric predicates like gt() or lt() for numeric columns' + ) + predicates[col] = val filter_dict_concrete = filter_dict if not predicates else { k: v diff --git a/graphistry/compute/gfql/__init__.py b/graphistry/compute/gfql/__init__.py new file mode 100644 index 0000000000..1df331d90f --- /dev/null +++ b/graphistry/compute/gfql/__init__.py @@ -0,0 +1,45 @@ +"""GFQL validation and related utilities.""" + +from graphistry.compute.gfql.validate import ( + ValidationIssue, + Schema, + validate_syntax, + validate_schema, + validate_query, + extract_schema, + extract_schema_from_dataframes, + format_validation_errors, + suggest_fixes +) + +from graphistry.compute.gfql.exceptions import ( + GFQLException, + GFQLValidationError, + GFQLSyntaxError, + GFQLSchemaError, + GFQLTypeError, + GFQLColumnNotFoundError +) + +__all__ = [ + # Validation classes + 'ValidationIssue', + 'Schema', + + # Validation functions + 'validate_syntax', + 'validate_schema', + 'validate_query', + 'extract_schema', + 'extract_schema_from_dataframes', + 'format_validation_errors', + 'suggest_fixes', + + # Exceptions + 'GFQLException', + 'GFQLValidationError', + 'GFQLSyntaxError', + 'GFQLSchemaError', + 'GFQLTypeError', + 'GFQLColumnNotFoundError' +] diff --git a/graphistry/compute/gfql/exceptions.py b/graphistry/compute/gfql/exceptions.py new file mode 100644 index 0000000000..fd44068bf6 --- /dev/null +++ b/graphistry/compute/gfql/exceptions.py @@ -0,0 +1,62 @@ +"""GFQL-specific exceptions for validation and error handling.""" + +from typing import Optional, Dict, Any + + +class GFQLException(Exception): + """Base exception for all GFQL-related errors.""" + + def __init__(self, message: str, context: Optional[Dict[str, Any]] = None): + self.context = context or {} + super().__init__(message) + + def __str__(self) -> str: + if self.context: + context_str = ", ".join(f"{k}={v}" for k, v in self.context.items()) + return f"{super().__str__()} ({context_str})" + return super().__str__() + + +class GFQLValidationError(GFQLException, ValueError): + """Base validation error. Inherits from ValueError for backwards compatibility.""" + pass + + +class GFQLSyntaxError(GFQLValidationError): + """Raised when GFQL query has invalid syntax.""" + pass + + +class GFQLSchemaError(GFQLValidationError): + """Raised when GFQL query references non-existent columns or has type mismatches.""" + pass + + +class GFQLSemanticError(GFQLValidationError): + """Raised when GFQL query is syntactically valid but semantically incorrect.""" + pass + + +class GFQLTypeError(GFQLSchemaError): + """Raised when a predicate is applied to incompatible column type.""" + + def __init__(self, column: str, column_type: str, predicate: str, expected_type: str): + message = f"Column '{column}' has type '{column_type}' but predicate '{predicate}' expects '{expected_type}'" + super().__init__(message, { + 'column': column, + 'column_type': column_type, + 'predicate': predicate, + 'expected_type': expected_type + }) + + +class GFQLColumnNotFoundError(GFQLSchemaError): + """Raised when a referenced column doesn't exist in the schema.""" + + def __init__(self, column: str, table: str, available_columns: list): + message = f"Column '{column}' not found in {table} data" + super().__init__(message, { + 'column': column, + 'table': table, + 'available_columns': available_columns + }) diff --git a/graphistry/compute/gfql/validate.py b/graphistry/compute/gfql/validate.py new file mode 100644 index 0000000000..801aaa923d --- /dev/null +++ b/graphistry/compute/gfql/validate.py @@ -0,0 +1,676 @@ +"""GFQL query validation utilities for syntax and schema checking. + +.. deprecated:: 0.34.0 + This module is deprecated. GFQL now has built-in validation. + See :doc:`/gfql/validation_migration_guide` for migration instructions. + + Instead of:: + + from graphistry.compute.gfql.validate import validate_syntax + issues = validate_syntax(query) + + Use:: + + from graphistry.compute.chain import Chain + try: + chain = Chain(query) # Automatic validation + except GFQLValidationError as e: + print(f"[{e.code}] {e.message}") +""" + +from typing import List, Optional, Dict, Union, Any, Tuple, TYPE_CHECKING +import warnings +import pandas as pd + +from graphistry.compute.chain import Chain +from graphistry.compute.ast import ASTNode, ASTEdge, ASTObject + +if TYPE_CHECKING: + from graphistry.Plottable import Plottable +from graphistry.compute.predicates.ASTPredicate import ASTPredicate +from graphistry.compute.predicates.numeric import NumericASTPredicate +from graphistry.compute.predicates.str import ( + Contains, Startswith, Endswith, Match, + IsNumeric, IsAlpha, IsDigit, IsLower, IsUpper, + IsSpace, IsAlnum, IsDecimal, IsTitle +) +from graphistry.compute.predicates.temporal import ( + IsMonthStart, IsMonthEnd, IsQuarterStart, IsQuarterEnd, + IsYearStart, IsYearEnd, IsLeapYear +) +from graphistry.util import setup_logger + +warnings.warn( + "The graphistry.compute.gfql.validate module is deprecated. " + "GFQL now has built-in validation. See the migration guide for details.", + DeprecationWarning, + stacklevel=2 +) + +logger = setup_logger(__name__) + + +class ValidationIssue: + """Represents a validation issue (error or warning).""" + + def __init__(self, + level: str, # 'error' or 'warning' + message: str, + operation_index: Optional[int] = None, + field: Optional[str] = None, + suggestion: Optional[str] = None, + error_type: Optional[str] = None): + self.level = level + self.message = message + self.operation_index = operation_index + self.field = field + self.suggestion = suggestion + self.error_type = error_type + + def __repr__(self) -> str: + parts = [f"{self.level.upper()}: {self.message}"] + if self.operation_index is not None: + parts.append(f"at operation {self.operation_index}") + if self.field: + parts.append(f"field: {self.field}") + if self.suggestion: + parts.append(f"Suggestion: {self.suggestion}") + return " | ".join(parts) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'level': self.level, + 'message': self.message, + 'operation_index': self.operation_index, + 'field': self.field, + 'suggestion': self.suggestion, + 'error_type': self.error_type + } + + +class Schema: + """Represents the schema of node and edge dataframes.""" + + def __init__(self, + node_columns: Optional[Dict[str, str]] = None, + edge_columns: Optional[Dict[str, str]] = None): + self.node_columns = node_columns or {} + self.edge_columns = edge_columns or {} + + def __repr__(self) -> str: + return (f"Schema(nodes={list(self.node_columns.keys())}, " + f"edges={list(self.edge_columns.keys())})") + + +# Error message templates +ERROR_MESSAGES = { + # Syntax errors + 'INVALID_CHAIN_TYPE': { + 'message': 'Chain must be a list of operations', + 'suggestion': 'Wrap your operations in a list: [n(), e(), n()]' + }, + 'INVALID_OPERATION': { + 'message': 'Operation at index {index} is not a valid GFQL operation', + 'suggestion': ('Use n() for nodes, ' + 'e()/e_forward()/e_reverse() for edges') + }, + 'INVALID_FILTER_KEY': { + 'message': 'Invalid filter key format: {key}', + 'suggestion': 'Filter keys must be strings' + }, + 'INVALID_HOPS': { + 'message': 'Hops must be a positive integer or None', + 'suggestion': ('Use hops=2 for specific count, ' + 'or to_fixed_point=True for unbounded') + }, + + # Schema errors + 'COLUMN_NOT_FOUND': { + 'message': 'Column "{column}" not found in {table} data', + 'suggestion': 'Available columns: {available}' + }, + 'TYPE_MISMATCH': { + 'message': ('Column "{column}" is {actual_type} but ' + 'predicate expects {expected_type}'), + 'suggestion': 'Use appropriate predicate for {actual_type} columns' + }, + 'INVALID_PREDICATE': { + 'message': ('Predicate {predicate} cannot be used with ' + '{column_type} columns'), + 'suggestion': 'Use {suggested_predicates} for {column_type} columns' + }, + + # Semantic errors + 'ORPHANED_EDGE': { + 'message': 'Edge operation at index {index} not connected to nodes', + 'suggestion': ('Add node operations before/after edge: ' + 'n() -> e() -> n()') + }, + 'UNBOUNDED_HOPS_WARNING': { + 'message': ('Unbounded hops (to_fixed_point=True) may be slow ' + 'on large graphs'), + 'suggestion': ('Consider using specific hop count for better ' + 'performance') + }, + 'EMPTY_CHAIN': { + 'message': 'Chain is empty', + 'suggestion': 'Add at least one operation: [n()]' + }, + 'INVALID_EDGE_DIRECTION': { + 'message': 'Invalid edge direction: {direction}', + 'suggestion': 'Use "forward", "reverse", or "undirected"' + } +} + + +def _format_error(error_key: str, **kwargs) -> Tuple[str, str]: + """Format error message with context.""" + template = ERROR_MESSAGES.get(error_key, { + 'message': f'Unknown error: {error_key}', + 'suggestion': 'Check documentation for valid syntax' + }) + message = template['message'].format(**kwargs) + suggestion = template['suggestion'].format(**kwargs) + return message, suggestion + + +def validate_syntax(chain: Union[Chain, List]) -> List[ValidationIssue]: + """ + Validate GFQL query syntax without requiring data. + + Args: + chain: GFQL chain or list of operations + + Returns: + List of validation issues (errors and warnings) + """ + issues = [] + + # Convert to list if Chain object + if isinstance(chain, Chain): + operations = chain.chain + elif isinstance(chain, list): + operations = chain + else: + message, suggestion = _format_error('INVALID_CHAIN_TYPE') + issues.append(ValidationIssue( + 'error', message, suggestion=suggestion, + error_type='INVALID_CHAIN_TYPE')) + return issues + + # Check empty chain + if not operations: + message, suggestion = _format_error('EMPTY_CHAIN') + issues.append(ValidationIssue( + 'error', message, suggestion=suggestion, + error_type='EMPTY_CHAIN')) + return issues + + # Validate each operation + for i, op in enumerate(operations): + # Check if valid operation type + if not isinstance(op, ASTObject): + message, suggestion = _format_error('INVALID_OPERATION', index=i) + issues.append(ValidationIssue( + 'error', message, operation_index=i, + suggestion=suggestion, error_type='INVALID_OPERATION')) + continue + + # Validate nodes + if isinstance(op, ASTNode): + if op.filter_dict: + for key, value in op.filter_dict.items(): + if not isinstance(key, str): + message, suggestion = _format_error( + 'INVALID_FILTER_KEY', key=key) + issues.append(ValidationIssue( + 'error', message, operation_index=i, + field=str(key), suggestion=suggestion, + error_type='INVALID_FILTER_KEY')) + + # Validate edges + elif isinstance(op, ASTEdge): + # Check hops + if (op.hops is not None + and (not isinstance(op.hops, int) or op.hops < 1)): + message, suggestion = _format_error('INVALID_HOPS') + issues.append(ValidationIssue( + 'error', message, operation_index=i, + field='hops', suggestion=suggestion, + error_type='INVALID_HOPS')) + + # Check unbounded hops warning + if op.to_fixed_point: + message, suggestion = _format_error('UNBOUNDED_HOPS_WARNING') + issues.append(ValidationIssue( + 'warning', message, operation_index=i, + suggestion=suggestion, + error_type='UNBOUNDED_HOPS_WARNING')) + + # Check edge filters + for filter_name, filter_dict in [ + ('edge_match', op.edge_match), + ('source_node_match', op.source_node_match), + ('destination_node_match', op.destination_node_match) + ]: + if filter_dict: + for key, value in filter_dict.items(): + if not isinstance(key, str): + message, suggestion = _format_error( + 'INVALID_FILTER_KEY', key=key) + issues.append(ValidationIssue( + 'error', message, operation_index=i, + field=f"{filter_name}.{key}", + suggestion=suggestion, + error_type='INVALID_FILTER_KEY')) + + # Check semantic issues + issues.extend(_validate_semantics(operations)) + + return issues + + +def _validate_semantics(operations: List[ASTObject]) -> List[ValidationIssue]: + """Validate semantic correctness of operation sequence.""" + issues = [] + + # Check for orphaned edges (edges not between nodes) + for i, op in enumerate(operations): + if isinstance(op, ASTEdge): + # Check if first or last operation + if i == 0 or i == len(operations) - 1: + # Edge at boundary - likely orphaned + message, suggestion = _format_error('ORPHANED_EDGE', index=i) + issues.append(ValidationIssue( + 'warning', message, operation_index=i, + suggestion=suggestion, + error_type='ORPHANED_EDGE')) + # Check if between two edges + elif (i > 0 and isinstance(operations[i - 1], ASTEdge) + and i < len(operations) - 1 + and isinstance(operations[i + 1], ASTEdge)): + message, suggestion = _format_error('ORPHANED_EDGE', index=i) + issues.append(ValidationIssue( + 'warning', message, operation_index=i, + suggestion=suggestion, + error_type='ORPHANED_EDGE')) + + return issues + + +def validate_schema(chain: Union[Chain, List], + schema: Schema) -> List[ValidationIssue]: + """ + Validate query against data schema. + + Args: + chain: GFQL chain or list of operations + schema: Schema object with column information + + Returns: + List of validation issues + """ + issues = [] + + # First do syntax validation + syntax_issues = validate_syntax(chain) + # Only keep errors from syntax validation for schema validation + issues.extend([issue for issue in syntax_issues if issue.level == 'error']) + + if any(issue.level == 'error' for issue in issues): + return issues # Don't do schema validation if syntax errors + + # Convert to list if Chain object + operations = chain.chain if isinstance(chain, Chain) else chain + + # Validate each operation against schema + for i, op in enumerate(operations): + if isinstance(op, ASTNode): + issues.extend(_validate_node_schema(op, schema, i)) + elif isinstance(op, ASTEdge): + issues.extend(_validate_edge_schema(op, schema, i)) + + return issues + + +def _validate_node_schema(node: ASTNode, schema: Schema, + op_index: int) -> List[ValidationIssue]: + """Validate node operation against schema.""" + issues = [] + + if node.filter_dict and schema.node_columns: + for col, predicate in node.filter_dict.items(): + # Check column exists + if col not in schema.node_columns: + available = list(schema.node_columns.keys())[:5] + if len(schema.node_columns) > 5: + available.append('...') + message, suggestion = _format_error( + 'COLUMN_NOT_FOUND', + column=col, + table='node', + available=', '.join(available)) + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=col, suggestion=suggestion, + error_type='COLUMN_NOT_FOUND')) + continue + + # Check predicate type compatibility + if isinstance(predicate, ASTPredicate): + col_type = schema.node_columns[col] + issues.extend(_validate_predicate_type( + predicate, col, col_type, op_index)) + + return issues + + +def _validate_edge_schema(edge: ASTEdge, schema: Schema, + op_index: int) -> List[ValidationIssue]: + """Validate edge operation against schema.""" + issues = [] + + # Validate edge filters + if edge.edge_match and schema.edge_columns: + for col, predicate in edge.edge_match.items(): + if col not in schema.edge_columns: + available = list(schema.edge_columns.keys())[:5] + if len(schema.edge_columns) > 5: + available.append('...') + message, suggestion = _format_error( + 'COLUMN_NOT_FOUND', + column=col, + table='edge', + available=', '.join(available)) + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=f"edge_match.{col}", suggestion=suggestion, + error_type='COLUMN_NOT_FOUND')) + continue + + if isinstance(predicate, ASTPredicate): + col_type = schema.edge_columns[col] + issues.extend(_validate_predicate_type( + predicate, col, col_type, op_index, + field_prefix="edge_match.")) + + # Validate source/dest node filters against node schema + for filter_name, filter_dict in [ + ('source_node_match', edge.source_node_match), + ('destination_node_match', edge.destination_node_match) + ]: + if filter_dict and schema.node_columns: + for col, predicate in filter_dict.items(): + if col not in schema.node_columns: + available = list(schema.node_columns.keys())[:5] + if len(schema.node_columns) > 5: + available.append('...') + message, suggestion = _format_error( + 'COLUMN_NOT_FOUND', + column=col, + table='node', + available=', '.join(available)) + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=f"{filter_name}.{col}", suggestion=suggestion, + error_type='COLUMN_NOT_FOUND')) + continue + + if isinstance(predicate, ASTPredicate): + col_type = schema.node_columns[col] + issues.extend(_validate_predicate_type( + predicate, col, col_type, op_index, + field_prefix=f"{filter_name}.")) + + return issues + + +def _validate_predicate_type(predicate: ASTPredicate, column: str, + column_type: str, op_index: int, + field_prefix: str = "") -> List[ValidationIssue]: + """Validate predicate is appropriate for column type.""" + issues = [] + + # Map pandas/numpy dtypes to categories + type_category = _get_type_category(column_type) + + # Define string predicate types + STRING_PREDICATES = ( + Contains, Startswith, Endswith, Match, + IsNumeric, IsAlpha, IsDigit, IsLower, IsUpper, + IsSpace, IsAlnum, IsDecimal, IsTitle + ) + + # Define temporal predicate types + TEMPORAL_PREDICATES = ( + IsMonthStart, IsMonthEnd, IsQuarterStart, IsQuarterEnd, + IsYearStart, IsYearEnd, IsLeapYear + ) + + # Check predicate compatibility + if (isinstance(predicate, NumericASTPredicate) + and type_category not in ['numeric', 'temporal']): + message, suggestion = _format_error( + 'TYPE_MISMATCH', + column=column, + actual_type=column_type, + expected_type='numeric') + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=f"{field_prefix}{column}", + suggestion=suggestion, + error_type='TYPE_MISMATCH')) + + elif (isinstance(predicate, STRING_PREDICATES) + and type_category != 'string'): + message, suggestion = _format_error( + 'TYPE_MISMATCH', + column=column, + actual_type=column_type, + expected_type='string') + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=f"{field_prefix}{column}", + suggestion=suggestion, + error_type='TYPE_MISMATCH')) + + elif (isinstance(predicate, TEMPORAL_PREDICATES) + and type_category != 'temporal'): + message, suggestion = _format_error( + 'TYPE_MISMATCH', + column=column, + actual_type=column_type, + expected_type='datetime') + issues.append(ValidationIssue( + 'error', message, operation_index=op_index, + field=f"{field_prefix}{column}", + suggestion=suggestion, + error_type='TYPE_MISMATCH')) + + return issues + + +def _get_type_category(dtype_str: str) -> str: + """Categorize dtype string into broad categories.""" + dtype_lower = str(dtype_str).lower() + + if any(t in dtype_lower for t in + ['int', 'float', 'double', 'numeric', 'decimal']): + return 'numeric' + elif any(t in dtype_lower for t in + ['str', 'object', 'char', 'text', 'varchar']): + return 'string' + elif any(t in dtype_lower for t in + ['date', 'time', 'timestamp', 'datetime']): + return 'temporal' + elif 'bool' in dtype_lower: + return 'boolean' + else: + return 'unknown' + + +def validate_query(chain: Union[Chain, List], + nodes_df: Optional[pd.DataFrame] = None, + edges_df: Optional[pd.DataFrame] = None + ) -> List[ValidationIssue]: + """ + Combined syntax and schema validation. + + Args: + chain: GFQL chain or list of operations + nodes_df: Optional node dataframe for schema validation + edges_df: Optional edge dataframe for schema validation + + Returns: + List of validation issues + """ + # Always do syntax validation + issues = validate_syntax(chain) + + # If data provided, also do schema validation + if nodes_df is not None or edges_df is not None: + schema = extract_schema_from_dataframes(nodes_df, edges_df) + schema_issues = validate_schema(chain, schema) + # Merge issues, avoiding duplicates + existing_errors = {(i.error_type, i.operation_index, i.field) + for i in issues} + for issue in schema_issues: + if ((issue.error_type, issue.operation_index, issue.field) + not in existing_errors): + issues.append(issue) + + return issues + + +def extract_schema(g: "Plottable") -> Schema: + """ + Extract schema from a Plottable object. + + Args: + g: Plottable object with node/edge data + + Returns: + Schema object + """ + + nodes_df = g._nodes if hasattr(g, '_nodes') else None + edges_df = g._edges if hasattr(g, '_edges') else None + + return extract_schema_from_dataframes(nodes_df, edges_df) + + +def extract_schema_from_dataframes( + nodes_df: Optional[pd.DataFrame] = None, + edges_df: Optional[pd.DataFrame] = None) -> Schema: + """ + Extract schema from pandas DataFrames. + + Args: + nodes_df: Optional node dataframe + edges_df: Optional edge dataframe + + Returns: + Schema object with column names and types + """ + node_columns = {} + edge_columns = {} + + if nodes_df is not None and hasattr(nodes_df, 'dtypes'): + node_columns = {str(col): str(dtype) + for col, dtype in nodes_df.dtypes.items()} + + if edges_df is not None and hasattr(edges_df, 'dtypes'): + edge_columns = {str(col): str(dtype) + for col, dtype in edges_df.dtypes.items()} + + return Schema(node_columns, edge_columns) + + +def format_validation_errors(issues: List[ValidationIssue]) -> str: + """ + Format validation errors for human/LLM consumption. + + Args: + issues: List of validation issues + + Returns: + Formatted error string + """ + if not issues: + return "No validation issues found." + + lines = ["GFQL Validation Report:"] + lines.append("-" * 50) + + errors = [i for i in issues if i.level == 'error'] + warnings = [i for i in issues if i.level == 'warning'] + + if errors: + lines.append(f"\nERRORS ({len(errors)}):") + for i, error in enumerate(errors, 1): + lines.append(f"\n{i}. {error.message}") + if error.operation_index is not None: + lines.append(f" Location: Operation {error.operation_index}") + if error.field: + lines.append(f" Field: {error.field}") + if error.suggestion: + lines.append(f" 💡 {error.suggestion}") + + if warnings: + lines.append(f"\nWARNINGS ({len(warnings)}):") + for i, warning in enumerate(warnings, 1): + lines.append(f"\n{i}. {warning.message}") + if warning.operation_index is not None: + lines.append( + f" Location: Operation {warning.operation_index}") + if warning.suggestion: + lines.append(f" 💡 {warning.suggestion}") + + return "\n".join(lines) + + +def suggest_fixes(chain: Union[Chain, List], + issues: List[ValidationIssue]) -> List[str]: + """ + Generate fix suggestions for validation issues. + + Args: + chain: The problematic chain + issues: Validation issues found + + Returns: + List of suggested fixes + """ + suggestions = [] + + # Group by error type for consolidated suggestions + error_types: Dict[str, List[ValidationIssue]] = {} + for issue in issues: + if issue.error_type: + error_types.setdefault(issue.error_type, []).append(issue) + + # Generate type-specific suggestions + if 'COLUMN_NOT_FOUND' in error_types: + missing_cols = {issue.field for issue in + error_types['COLUMN_NOT_FOUND'] if issue.field} + suggestions.append( + f"Missing columns: {', '.join(missing_cols)}. " + f"Check column names match your data.") + + if 'TYPE_MISMATCH' in error_types: + suggestions.append( + "Type mismatches found. Use numeric predicates (gt, lt) " + "for numbers, string predicates (contains, startswith) for text.") + + if 'ORPHANED_EDGE' in error_types: + suggestions.append( + "Edge operations should connect nodes. " + "Use pattern: n() -> e() -> n()") + + # Add general suggestions from issues + for issue in issues: + if issue.suggestion and issue.suggestion not in suggestions: + suggestions.append(issue.suggestion) + + return suggestions diff --git a/graphistry/compute/predicates/categorical.py b/graphistry/compute/predicates/categorical.py index c9d01f91c0..0243c5428c 100644 --- a/graphistry/compute/predicates/categorical.py +++ b/graphistry/compute/predicates/categorical.py @@ -12,8 +12,18 @@ def __init__(self, keep: Literal['first', 'last', False] = 'first') -> None: def __call__(self, s: SeriesT) -> SeriesT: return s.duplicated(keep=self.keep) - def validate(self) -> None: - assert self.keep in ['first', 'last', False] + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if self.keep not in ['first', 'last', False]: + raise GFQLTypeError( + ErrorCode.E201, + "keep must be 'first', 'last', or False", + field="keep", + value=self.keep, + suggestion="Use keep='first', keep='last', or keep=False" + ) def duplicated(keep: Literal['first', 'last', False] = 'first') -> Duplicated: """ diff --git a/graphistry/compute/predicates/comparison.py b/graphistry/compute/predicates/comparison.py index 24638318b5..eba63a6200 100644 --- a/graphistry/compute/predicates/comparison.py +++ b/graphistry/compute/predicates/comparison.py @@ -77,14 +77,18 @@ def _get_temporal_comparison_value(self, temporal_val: TemporalValue) -> Union[p raise TypeError(f"Unknown temporal value type: {type(temporal_val)}") - def validate(self) -> None: - """Validate both numeric and temporal values""" - if isinstance(self.val, (int, float)): - pass # Numeric values are always valid - elif isinstance(self.val, TemporalValue): - pass # Temporal values validated on construction - else: - raise TypeError(f"Invalid value type: {type(self.val)}") + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.val, (int, float, TemporalValue)): + raise GFQLTypeError( + ErrorCode.E201, + "val must be numeric or temporal", + field="val", + value=type(self.val).__name__, + suggestion="Use numeric values or temporal objects" + ) def to_json(self, validate=True) -> dict: """Serialize maintaining backward compatibility""" @@ -278,7 +282,10 @@ def __call__(self, s: SeriesT) -> SeriesT: else: raise TypeError("Between requires both bounds to be same type (numeric or temporal)") - def validate(self) -> None: + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + # Check types match lower_is_numeric = isinstance(self.lower, (int, float)) upper_is_numeric = isinstance(self.upper, (int, float)) @@ -286,9 +293,20 @@ def validate(self) -> None: upper_is_temporal = isinstance(self.upper, TemporalValue) if not ((lower_is_numeric and upper_is_numeric) or (lower_is_temporal and upper_is_temporal)): - raise TypeError("Between requires both bounds to be same type") + raise GFQLTypeError( + ErrorCode.E201, + "Between requires both bounds to be same type (numeric or temporal)", + field="bounds", + value=f"lower={type(self.lower).__name__}, upper={type(self.upper).__name__}" + ) - assert isinstance(self.inclusive, bool) + if not isinstance(self.inclusive, bool): + raise GFQLTypeError( + ErrorCode.E201, + "inclusive must be boolean", + field="inclusive", + value=type(self.inclusive).__name__ + ) def to_json(self, validate=True) -> dict: """Serialize maintaining backward compatibility""" diff --git a/graphistry/compute/predicates/is_in.py b/graphistry/compute/predicates/is_in.py index 0238ab3b69..be0b563907 100644 --- a/graphistry/compute/predicates/is_in.py +++ b/graphistry/compute/predicates/is_in.py @@ -87,8 +87,18 @@ def __call__(self, s: SeriesT) -> SeriesT: return s.isin(self.options) - def validate(self) -> None: - assert isinstance(self.options, list) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.options, list): + raise GFQLTypeError( + ErrorCode.E201, + "options must be a list", + field="options", + value=type(self.options).__name__ + ) + # Check normalized options are JSON serializable # (temporal values are converted to pandas types which are serializable) try: @@ -107,7 +117,12 @@ def validate(self) -> None: json_test.append(opt) assert_json_serializable(json_test) except Exception as e: - raise ValueError(f"Options not JSON serializable: {e}") + raise GFQLTypeError( + ErrorCode.E201, + f"Options not JSON serializable: {e}", + field="options", + value=str(self.options) + ) def to_json(self, validate=True) -> dict: """Override to handle temporal values in options""" diff --git a/graphistry/compute/predicates/numeric.py b/graphistry/compute/predicates/numeric.py index d2289d1b6c..9c93d167a6 100644 --- a/graphistry/compute/predicates/numeric.py +++ b/graphistry/compute/predicates/numeric.py @@ -9,8 +9,17 @@ class NumericASTPredicate(ASTPredicate): def __init__(self, val: Union[int, float]) -> None: self.val = val - def validate(self) -> None: - assert isinstance(self.val, (int, float)) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.val, (int, float)): + raise GFQLTypeError( + ErrorCode.E201, + "val must be numeric (int or float)", + field="val", + value=type(self.val).__name__ + ) ### @@ -104,10 +113,33 @@ def __call__(self, s: SeriesT) -> SeriesT: else: return (s > self.lower) & (s < self.upper) - def validate(self) -> None: - assert isinstance(self.lower, (int, float)) - assert isinstance(self.upper, (int, float)) - assert isinstance(self.inclusive, bool) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.lower, (int, float)): + raise GFQLTypeError( + ErrorCode.E201, + "lower must be numeric (int or float)", + field="lower", + value=type(self.lower).__name__ + ) + + if not isinstance(self.upper, (int, float)): + raise GFQLTypeError( + ErrorCode.E201, + "upper must be numeric (int or float)", + field="upper", + value=type(self.upper).__name__ + ) + + if not isinstance(self.inclusive, bool): + raise GFQLTypeError( + ErrorCode.E201, + "inclusive must be boolean", + field="inclusive", + value=type(self.inclusive).__name__ + ) def between(lower: float, upper: float, inclusive: bool = True) -> Between: """ diff --git a/graphistry/compute/predicates/str.py b/graphistry/compute/predicates/str.py index 25805b9b3f..629d0895ea 100644 --- a/graphistry/compute/predicates/str.py +++ b/graphistry/compute/predicates/str.py @@ -16,12 +16,49 @@ def __init__(self, pat: str, case: bool = True, flags: int = 0, na: Optional[boo def __call__(self, s: SeriesT) -> SeriesT: return s.str.contains(self.pat, self.case, self.flags, self.na, self.regex) - def validate(self) -> None: - assert isinstance(self.pat, str) - assert isinstance(self.case, bool) - assert isinstance(self.flags, int) - assert isinstance(self.na, (bool, type(None))) - assert isinstance(self.regex, bool) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.pat, str): + raise GFQLTypeError( + ErrorCode.E201, + "pat must be string", + field="pat", + value=type(self.pat).__name__ + ) + + if not isinstance(self.case, bool): + raise GFQLTypeError( + ErrorCode.E201, + "case must be boolean", + field="case", + value=type(self.case).__name__ + ) + + if not isinstance(self.flags, int): + raise GFQLTypeError( + ErrorCode.E201, + "flags must be integer", + field="flags", + value=type(self.flags).__name__ + ) + + if not isinstance(self.na, (bool, type(None))): + raise GFQLTypeError( + ErrorCode.E201, + "na must be boolean or None", + field="na", + value=type(self.na).__name__ + ) + + if not isinstance(self.regex, bool): + raise GFQLTypeError( + ErrorCode.E201, + "regex must be boolean", + field="regex", + value=type(self.regex).__name__ + ) def contains(pat: str, case: bool = True, flags: int = 0, na: Optional[bool] = None, regex: bool = True) -> Contains: """ @@ -38,9 +75,25 @@ def __init__(self, pat: str, na: Optional[str] = None) -> None: def __call__(self, s: SeriesT) -> SeriesT: return s.str.startswith(self.pat, self.na) - def validate(self) -> None: - assert isinstance(self.pat, str) - assert isinstance(self.na, (str, type(None))) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.pat, str): + raise GFQLTypeError( + ErrorCode.E201, + "pat must be string", + field="pat", + value=type(self.pat).__name__ + ) + + if not isinstance(self.na, (str, type(None))): + raise GFQLTypeError( + ErrorCode.E201, + "na must be string or None", + field="na", + value=type(self.na).__name__ + ) def startswith(pat: str, na: Optional[str] = None) -> Startswith: """ @@ -59,9 +112,25 @@ def __call__(self, s: SeriesT) -> SeriesT: """ return s.str.endswith(self.pat, self.na) - def validate(self) -> None: - assert isinstance(self.pat, str) - assert isinstance(self.na, (str, type(None))) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.pat, str): + raise GFQLTypeError( + ErrorCode.E201, + "pat must be string", + field="pat", + value=type(self.pat).__name__ + ) + + if not isinstance(self.na, (str, type(None))): + raise GFQLTypeError( + ErrorCode.E201, + "na must be string or None", + field="na", + value=type(self.na).__name__ + ) def endswith(pat: str, na: Optional[str] = None) -> Endswith: return Endswith(pat, na) @@ -76,11 +145,41 @@ def __init__(self, pat: str, case: bool = True, flags: int = 0, na: Optional[boo def __call__(self, s: SeriesT) -> SeriesT: return s.str.match(self.pat, self.case, self.flags, self.na) - def validate(self) -> None: - assert isinstance(self.pat, str) - assert isinstance(self.case, bool) - assert isinstance(self.flags, int) - assert isinstance(self.na, (bool, type(None))) + def _validate_fields(self) -> None: + """Validate predicate fields.""" + from graphistry.compute.exceptions import ErrorCode, GFQLTypeError + + if not isinstance(self.pat, str): + raise GFQLTypeError( + ErrorCode.E201, + "pat must be string", + field="pat", + value=type(self.pat).__name__ + ) + + if not isinstance(self.case, bool): + raise GFQLTypeError( + ErrorCode.E201, + "case must be boolean", + field="case", + value=type(self.case).__name__ + ) + + if not isinstance(self.flags, int): + raise GFQLTypeError( + ErrorCode.E201, + "flags must be integer", + field="flags", + value=type(self.flags).__name__ + ) + + if not isinstance(self.na, (bool, type(None))): + raise GFQLTypeError( + ErrorCode.E201, + "na must be boolean or None", + field="na", + value=type(self.na).__name__ + ) def match(pat: str, case: bool = True, flags: int = 0, na: Optional[bool] = None) -> Match: """ diff --git a/graphistry/compute/validate/__init__.py b/graphistry/compute/validate/__init__.py new file mode 100644 index 0000000000..b7a327aaa8 --- /dev/null +++ b/graphistry/compute/validate/__init__.py @@ -0,0 +1,5 @@ +"""Chain validation utilities.""" + +from .validate_schema import validate_chain_schema + +__all__ = ['validate_chain_schema'] diff --git a/graphistry/compute/validate/validate_schema.py b/graphistry/compute/validate/validate_schema.py new file mode 100644 index 0000000000..8f6597fe01 --- /dev/null +++ b/graphistry/compute/validate/validate_schema.py @@ -0,0 +1,220 @@ +"""Schema validation for GFQL chains without execution.""" + +from typing import List, Optional, Union, TYPE_CHECKING, cast +import pandas as pd +from graphistry.Plottable import Plottable +from graphistry.compute.ast import ASTObject, ASTNode, ASTEdge + +if TYPE_CHECKING: + from graphistry.compute.chain import Chain + +from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError +from graphistry.compute.predicates.ASTPredicate import ASTPredicate +from graphistry.compute.predicates.numeric import NumericASTPredicate, Between +from graphistry.compute.predicates.str import Contains, Startswith, Endswith, Match + + +def validate_chain_schema( + g: Plottable, + ops: Union[List[ASTObject], 'Chain'], + collect_all: bool = False +) -> Optional[List[GFQLSchemaError]]: + """Validate chain operations against graph schema without executing. + + This performs static analysis of the chain operations to detect: + - References to non-existent columns + - Type mismatches between filters and column types + - Invalid predicate usage + + Args: + g: The graph to validate against + ops: Chain operations to validate + collect_all: If True, collect all errors. If False, raise on first error. + + Returns: + If collect_all=True: List of schema errors (empty if valid) + If collect_all=False: None if valid + + Raises: + GFQLSchemaError: If collect_all=False and validation fails + """ + # Handle Chain objects + if hasattr(ops, 'chain'): + chain_ops = cast(List[ASTObject], ops.chain) + else: + chain_ops = ops + + errors: List[GFQLSchemaError] = [] + + # Get available columns + node_columns = set(g._nodes.columns) if g._nodes is not None else set() + edge_columns = set(g._edges.columns) if g._edges is not None else set() + + for i, op in enumerate(chain_ops): + op_errors = [] + + if isinstance(op, ASTNode): + op_errors = _validate_node_op(op, node_columns, g._nodes, collect_all) + elif isinstance(op, ASTEdge): + op_errors = _validate_edge_op(op, node_columns, edge_columns, g._nodes, g._edges, collect_all) + + # Add operation index to all errors + for e in op_errors: + e.context['operation_index'] = i + + if op_errors: + if collect_all: + errors.extend(op_errors) + else: + raise op_errors[0] + + return errors if collect_all else None + + +def _validate_node_op(op: ASTNode, node_columns: set, nodes_df: Optional[pd.DataFrame], collect_all: bool) -> List[GFQLSchemaError]: + """Validate node operation against schema.""" + errors = [] + if op.filter_dict and nodes_df is not None: + errors.extend(_validate_filter_dict(op.filter_dict, node_columns, nodes_df, "node", collect_all)) + return errors + + +def _validate_edge_op( + op: ASTEdge, + node_columns: set, + edge_columns: set, + nodes_df: Optional[pd.DataFrame], + edges_df: Optional[pd.DataFrame], + collect_all: bool +) -> List[GFQLSchemaError]: + """Validate edge operation against schema.""" + errors = [] + + # Validate edge filters + if op.edge_match and edges_df is not None: + errors.extend(_validate_filter_dict(op.edge_match, edge_columns, edges_df, "edge", collect_all)) + + # Validate source node filters + if op.source_node_match and nodes_df is not None: + errors.extend(_validate_filter_dict(op.source_node_match, node_columns, nodes_df, "source node", collect_all)) + + # Validate destination node filters + if op.destination_node_match and nodes_df is not None: + errors.extend(_validate_filter_dict(op.destination_node_match, node_columns, nodes_df, "destination node", collect_all)) + + return errors + + +def _validate_filter_dict( + filter_dict: dict, + columns: set, + df: pd.DataFrame, + context: str, + collect_all: bool = False +) -> List[GFQLSchemaError]: + """Validate filter dictionary against dataframe schema.""" + errors = [] + for col, val in filter_dict.items(): + try: + # Check column exists + if col not in columns: + error = GFQLSchemaError( + ErrorCode.E301, + f'Column "{col}" does not exist in {context} dataframe', + field=col, + value=val, + suggestion=f'Available columns: {", ".join(sorted(columns)[:10])}{"..." if len(columns) > 10 else ""}' + ) + if collect_all: + errors.append(error) + continue # Check next field + else: + raise error + + # Check type compatibility + col_dtype = df[col].dtype + + if not isinstance(val, ASTPredicate): + # Check literal value type matches + if pd.api.types.is_numeric_dtype(col_dtype) and isinstance(val, str): + error = GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: {context} column "{col}" is numeric but filter value is string', + field=col, + value=val, + column_type=str(col_dtype), + suggestion=f'Use a numeric value like {col}=123' + ) + if collect_all: + errors.append(error) + else: + raise error + elif pd.api.types.is_string_dtype(col_dtype) and isinstance(val, (int, float)): + error = GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: {context} column "{col}" is string but filter value is numeric', + field=col, + value=val, + column_type=str(col_dtype), + suggestion=f'Use a string value like {col}="value"' + ) + if collect_all: + errors.append(error) + else: + raise error + else: + # Check predicate type matches column type + if isinstance(val, (NumericASTPredicate, Between)) and not pd.api.types.is_numeric_dtype(col_dtype): + error = GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: numeric predicate used on non-numeric {context} column "{col}"', + field=col, + value=f"{val.__class__.__name__}(...)", + column_type=str(col_dtype), + suggestion='Use string predicates like contains() or startswith() for string columns' + ) + if collect_all: + errors.append(error) + else: + raise error + + if isinstance(val, (Contains, Startswith, Endswith, Match)) and not pd.api.types.is_string_dtype(col_dtype): + error = GFQLSchemaError( + ErrorCode.E302, + f'Type mismatch: string predicate used on non-string {context} column "{col}"', + field=col, + value=f"{val.__class__.__name__}(...)", + column_type=str(col_dtype), + suggestion='Use numeric predicates like gt() or lt() for numeric columns' + ) + if collect_all: + errors.append(error) + else: + raise error + + except GFQLSchemaError: + if not collect_all: + raise + + return errors + + +# Add to Chain class +def validate_schema(self: 'Chain', g: Plottable, collect_all: bool = False) -> Optional[List[GFQLSchemaError]]: + """Validate this chain against a graph's schema without executing. + + Args: + g: Graph to validate against + collect_all: If True, collect all errors. If False, raise on first. + + Returns: + If collect_all=True: List of schema errors + If collect_all=False: None if valid + + Raises: + GFQLSchemaError: If collect_all=False and validation fails + """ + return validate_chain_schema(g, self, collect_all) + + +# Monkey-patching moved to chain.py to avoid circular import diff --git a/graphistry/tests/compute/predicates/test_from_json.py b/graphistry/tests/compute/predicates/test_from_json.py index adafef1bbe..0a1bcc5676 100644 --- a/graphistry/tests/compute/predicates/test_from_json.py +++ b/graphistry/tests/compute/predicates/test_from_json.py @@ -1,5 +1,6 @@ from graphistry.compute.predicates.categorical import Duplicated from graphistry.compute.predicates.from_json import from_json +from graphistry.compute.exceptions import GFQLValidationError def test_from_json_good(): @@ -8,20 +9,22 @@ def test_from_json_good(): assert d.keep == 'last' def test_from_json_bad(): + # Invalid type should raise error try: from_json({'type': 'zzz'}) - assert False - except AssertionError: + assert False, "Should have raised an exception" + except Exception: + # Could be GFQLValidationError or other exception assert True + # Invalid keep value should raise GFQLValidationError try: from_json({'type': 'Duplicated', 'keep': 'zzz'}) - assert False - except AssertionError: + assert False, "Should have raised GFQLValidationError" + except GFQLValidationError: assert True - try: - from_json({'type': 'Duplicated'}) - assert False - except AssertionError: - assert True + # Missing keep parameter - should use default + # This might actually be valid with a default value + d = from_json({'type': 'Duplicated'}) + assert isinstance(d, Duplicated) diff --git a/graphistry/tests/compute/test_ast_serializable_validation.py b/graphistry/tests/compute/test_ast_serializable_validation.py new file mode 100644 index 0000000000..cffb6d04af --- /dev/null +++ b/graphistry/tests/compute/test_ast_serializable_validation.py @@ -0,0 +1,191 @@ +"""Tests for ASTSerializable validation functionality.""" + +import pytest +from typing import List + +from graphistry.compute.ASTSerializable import ASTSerializable +from graphistry.compute.exceptions import ErrorCode, GFQLValidationError +from graphistry.utils.json import JSONVal + + +class MockValidAST(ASTSerializable): + """Mock AST that validates successfully.""" + + def __init__(self, value: str = "valid"): + self.value = value + + def _validate_fields(self): + # Valid - no errors + pass + + +class MockInvalidAST(ASTSerializable): + """Mock AST that fails validation.""" + + def __init__(self, value: str = "invalid"): + self.value = value + + def _validate_fields(self): + raise GFQLValidationError( + ErrorCode.E101, + "Mock validation error", + field="value", + value=self.value + ) + + +class MockParentAST(ASTSerializable): + """Mock parent with children.""" + + def __init__(self, children: List[ASTSerializable]): + self.children = children + + def _get_child_validators(self): + return self.children + + +class TestASTSerializableValidation: + """Test base validation functionality.""" + + def test_valid_object_validates(self): + """Valid object passes validation.""" + obj = MockValidAST() + result = obj.validate() + assert result is None # No errors + + # Also works with collect_all + errors = obj.validate(collect_all=True) + assert errors == [] + + def test_invalid_object_fails_fast(self): + """Invalid object raises on first error by default.""" + obj = MockInvalidAST() + + with pytest.raises(GFQLValidationError) as exc_info: + obj.validate() + + assert exc_info.value.code == ErrorCode.E101 + assert "Mock validation error" in str(exc_info.value) + + def test_invalid_object_collect_all(self): + """Invalid object returns errors when collect_all=True.""" + obj = MockInvalidAST() + + errors = obj.validate(collect_all=True) + assert len(errors) == 1 + assert errors[0].code == ErrorCode.E101 + assert errors[0].message == "Mock validation error" + + def test_parent_validates_children_fail_fast(self): + """Parent validates children in fail-fast mode.""" + child1 = MockValidAST() + child2 = MockInvalidAST() + parent = MockParentAST([child1, child2]) + + with pytest.raises(GFQLValidationError) as exc_info: + parent.validate() + + assert exc_info.value.code == ErrorCode.E101 + + def test_parent_collects_child_errors(self): + """Parent collects all child errors.""" + child1 = MockInvalidAST("error1") + child2 = MockInvalidAST("error2") + parent = MockParentAST([child1, child2]) + + errors = parent.validate(collect_all=True) + assert len(errors) == 2 + assert all(e.code == ErrorCode.E101 for e in errors) + assert errors[0].context['value'] == "error1" + assert errors[1].context['value'] == "error2" + + def test_nested_validation(self): + """Nested structures validate correctly.""" + # grandchild -> child -> parent + grandchild = MockInvalidAST("nested_error") + child = MockParentAST([grandchild]) + parent = MockParentAST([child]) + + # Fail fast mode + with pytest.raises(GFQLValidationError) as exc_info: + parent.validate() + assert "nested_error" in str(exc_info.value) + + # Collect all mode + errors = parent.validate(collect_all=True) + assert len(errors) == 1 + assert errors[0].context['value'] == "nested_error" + + def test_to_json_validates_by_default(self): + """to_json validates by default.""" + obj = MockInvalidAST() + + with pytest.raises(GFQLValidationError): + obj.to_json() + + def test_to_json_skip_validation(self): + """to_json can skip validation.""" + obj = MockInvalidAST() + + # Should not raise + result = obj.to_json(validate=False) + assert result['type'] == 'MockInvalidAST' + assert result['value'] == 'invalid' + + def test_from_json_validates_by_default(self): + """from_json validates by default.""" + # Need a class that implements from_json properly + class TestAST(ASTSerializable): + def __init__(self, value: int): + self.value = value + + def _validate_fields(self): + if self.value < 0: + raise GFQLValidationError( + ErrorCode.E103, + "Value must be positive", + field="value", + value=self.value + ) + + # Valid case + obj = TestAST.from_json({'type': 'TestAST', 'value': 5}) + assert obj.value == 5 + + # Invalid case + with pytest.raises(GFQLValidationError) as exc_info: + TestAST.from_json({'type': 'TestAST', 'value': -1}) + assert exc_info.value.code == ErrorCode.E103 + + def test_from_json_skip_validation(self): + """from_json can skip validation.""" + class TestAST(ASTSerializable): + def __init__(self, value: int): + self.value = value + + def _validate_fields(self): + if self.value < 0: + raise GFQLValidationError(ErrorCode.E103, "Negative value") + + # Should not raise even with invalid data + obj = TestAST.from_json({'type': 'TestAST', 'value': -1}, validate=False) + assert obj.value == -1 + + # But manual validation would fail + with pytest.raises(GFQLValidationError): + obj.validate() + + def test_non_validation_errors_propagate(self): + """Non-validation errors are not caught.""" + class BrokenAST(ASTSerializable): + def _validate_fields(self): + raise ValueError("Not a validation error") + + obj = BrokenAST() + + # Should propagate in both modes + with pytest.raises(ValueError, match="Not a validation error"): + obj.validate() + + with pytest.raises(ValueError, match="Not a validation error"): + obj.validate(collect_all=True) diff --git a/graphistry/tests/compute/test_chain_schema_prevalidation.py b/graphistry/tests/compute/test_chain_schema_prevalidation.py new file mode 100644 index 0000000000..052254160b --- /dev/null +++ b/graphistry/tests/compute/test_chain_schema_prevalidation.py @@ -0,0 +1,171 @@ +"""Tests for pre-execution schema validation.""" + +import pytest +import pandas as pd +from graphistry import edges, nodes +from graphistry.compute.chain import Chain +from graphistry.compute.ast import n, e_forward +from graphistry.compute.validate.validate_schema import validate_chain_schema +from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError +from graphistry.compute.predicates.numeric import gt +from graphistry.compute.predicates.str import contains + + +class TestChainSchemaPreValidation: + """Test schema validation without execution.""" + + def setup_method(self): + """Set up test data.""" + self.edges_df = pd.DataFrame({ + 'src': ['a', 'b', 'c'], + 'dst': ['b', 'c', 'd'], + 'edge_type': ['friend', 'friend', 'enemy'], + 'weight': [1.0, 2.0, 3.0] + }) + + self.nodes_df = pd.DataFrame({ + 'id': ['a', 'b', 'c', 'd'], + 'type': ['person', 'person', 'company', 'company'], + 'age': [25, 30, None, None], + 'name': ['Alice', 'Bob', 'Corp1', 'Corp2'] + }) + + self.g = edges(self.edges_df, 'src', 'dst').nodes(self.nodes_df, 'id') + + def test_valid_operations_pass(self): + """Valid operations pass pre-validation.""" + ops = [ + n({'type': 'person'}), + e_forward({'edge_type': 'friend'}), + n({'type': 'company'}) + ] + + # Should not raise + result = validate_chain_schema(self.g, ops) + assert result is None + + def test_missing_node_column_caught(self): + """Missing node column is caught before execution.""" + ops = [n({'missing_col': 'value'})] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert exc_info.value.code == ErrorCode.E301 + assert 'missing_col' in str(exc_info.value) + assert 'does not exist in node' in str(exc_info.value) + + def test_missing_edge_column_caught(self): + """Missing edge column is caught before execution.""" + ops = [ + n(), + e_forward({'missing_edge': 'value'}) + ] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert exc_info.value.code == ErrorCode.E301 + assert 'missing_edge' in str(exc_info.value) + assert 'does not exist in edge' in str(exc_info.value) + + def test_type_mismatch_caught(self): + """Type mismatches are caught before execution.""" + # String value on numeric column + ops = [n({'age': 'twenty-five'})] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert exc_info.value.code == ErrorCode.E302 + assert 'numeric but filter value is string' in str(exc_info.value) + + def test_predicate_mismatch_caught(self): + """Predicate type mismatches are caught.""" + # Numeric predicate on string column + ops = [n({'type': gt(5)})] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert exc_info.value.code == ErrorCode.E302 + assert 'numeric predicate used on non-numeric' in str(exc_info.value) + + # String predicate on numeric column + ops = [n({'age': contains('old')})] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert exc_info.value.code == ErrorCode.E302 + assert 'string predicate used on non-string' in str(exc_info.value) + + def test_collect_all_errors(self): + """Can collect multiple schema errors.""" + ops = [ + n({'missing1': 'value', 'age': 'string'}), # 2 errors + e_forward({'missing2': 'value'}), # 1 error + n({'type': gt(5)}) # 1 error + ] + + errors = validate_chain_schema(self.g, ops, collect_all=True) + assert len(errors) >= 4 # At least 4 errors + + # Check operation indices are set + assert all('operation_index' in e.context for e in errors) + + def test_chain_method(self): + """Chain.validate_schema method works.""" + chain = Chain([ + n({'missing': 'value'}) + ]) + + # Should raise + with pytest.raises(GFQLSchemaError): + chain.validate_schema(self.g) + + # Collect all mode + errors = chain.validate_schema(self.g, collect_all=True) + assert len(errors) == 1 + assert errors[0].code == ErrorCode.E301 + + def test_edge_source_dest_validation(self): + """Edge source/destination node filters are validated.""" + # Invalid source node filter + ops = [ + n(), + e_forward(source_node_match={'bad_col': 'value'}) + ] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert 'does not exist in source node' in str(exc_info.value) + + # Invalid destination node filter + ops = [ + n(), + e_forward(destination_node_match={'bad_col': 'value'}) + ] + + with pytest.raises(GFQLSchemaError) as exc_info: + validate_chain_schema(self.g, ops) + + assert 'does not exist in destination node' in str(exc_info.value) + + def test_no_execution_happens(self): + """Verify that validation doesn't execute the chain.""" + # Create a chain that would fail if executed + # (since there's no path from person->company via 'missing' edges) + ops = [ + n({'type': 'person'}), + e_forward({'edge_type': 'missing'}), # No such edges + n({'type': 'company'}) + ] + + # Schema validation should pass (columns exist) + result = validate_chain_schema(self.g, ops) + assert result is None # Valid schema + + # But execution would return empty results + # (not testing execution here, just noting the difference) diff --git a/graphistry/tests/compute/test_chain_schema_validation.py b/graphistry/tests/compute/test_chain_schema_validation.py new file mode 100644 index 0000000000..c3ab7c4e40 --- /dev/null +++ b/graphistry/tests/compute/test_chain_schema_validation.py @@ -0,0 +1,131 @@ +"""Tests for Chain schema validation (data-aware validation).""" + +import pytest +import pandas as pd +from graphistry import edges, nodes +from graphistry.compute.chain import Chain +from graphistry.compute.ast import n, e_forward +from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError + + +class TestChainSchemaValidation: + """Test schema-aware validation in chain() function.""" + + def setup_method(self): + """Set up test data.""" + # Simple test graph with valid paths + self.edges_df = pd.DataFrame({ + 'src': ['a', 'b', 'b'], + 'dst': ['b', 'c', 'd'], + 'edge_type': ['friend', 'friend', 'enemy'] + }) + + self.nodes_df = pd.DataFrame({ + 'id': ['a', 'b', 'c', 'd'], + 'type': ['person', 'person', 'company', 'company'], + 'age': [25, 30, None, None] + }) + + self.g = edges(self.edges_df, 'src', 'dst').nodes(self.nodes_df, 'id') + + def test_valid_schema_operations(self): + """Valid operations pass schema validation.""" + # These should work - columns exist + result = self.g.chain([ + n({'type': 'person'}), + e_forward({'edge_type': 'friend'}), + n({'type': 'company'}) + ]) + + # Should have some results + assert len(result._nodes) > 0 + + def test_nonexistent_node_column(self): + """Reference to non-existent node column fails.""" + with pytest.raises(GFQLSchemaError) as exc_info: + self.g.chain([ + n({'missing_column': 'value'}) + ]) + + assert exc_info.value.code == ErrorCode.E301 + assert 'missing_column' in str(exc_info.value) + assert 'does not exist' in str(exc_info.value) + + def test_nonexistent_edge_column(self): + """Reference to non-existent edge column fails.""" + with pytest.raises(GFQLSchemaError) as exc_info: + self.g.chain([ + n(), + e_forward({'missing_edge_col': 'value'}) + ]) + + assert exc_info.value.code == ErrorCode.E301 + assert 'missing_edge_col' in str(exc_info.value) + + def test_type_mismatch_numeric_filter(self): + """Type mismatch in filter fails.""" + # 'type' column contains strings, not numbers + with pytest.raises(GFQLSchemaError) as exc_info: + self.g.chain([ + n({'type': 123}) # Wrong type + ]) + + assert exc_info.value.code == ErrorCode.E302 + assert 'type mismatch' in str(exc_info.value).lower() + + def test_empty_graph_warning(self): + """Empty graph should provide helpful error.""" + empty_edges = pd.DataFrame({'s': [], 'd': []}) + empty_nodes = pd.DataFrame({'id': []}) + empty_g = edges(empty_edges, 's', 'd').nodes(empty_nodes, 'id') + + # Should succeed but return empty result + result = empty_g.chain([n()]) + assert len(result._nodes) == 0 + + # But filtering on non-existent column should still fail + with pytest.raises(GFQLSchemaError) as exc_info: + empty_g.chain([n({'any_col': 'value'})]) + + assert exc_info.value.code == ErrorCode.E301 + + def test_collect_all_schema_errors(self): + """Can collect multiple schema errors.""" + chain_obj = Chain([ + n({'missing1': 'value'}), + e_forward({'missing2': 'value'}), + n({'type': 123}) # Wrong type + ]) + + # Note: chain() function would need collect_all parameter + # For now, it will fail fast on first error + with pytest.raises(GFQLSchemaError): + self.g.chain(chain_obj) + + def test_schema_validation_with_predicates(self): + """Schema validation works with predicates.""" + from graphistry.compute.predicates.numeric import gt + + # Valid predicate on numeric column + result = self.g.chain([ + n({'age': gt(20)}) + ]) + assert len(result._nodes) == 2 # Only persons have age + + # Invalid predicate on non-numeric column + with pytest.raises(GFQLSchemaError) as exc_info: + self.g.chain([ + n({'type': gt(20)}) # String column + ]) + + assert exc_info.value.code == ErrorCode.E302 + + def test_schema_validation_disabled(self): + """Schema validation can be disabled.""" + # This would need a parameter like validate_schema=False + # For now, document expected behavior + + # Future API: + # result = self.g.chain([n({'missing': 'value'})], validate_schema=False) + # Would return empty result instead of raising + pass diff --git a/graphistry/tests/compute/test_chain_validation.py b/graphistry/tests/compute/test_chain_validation.py new file mode 100644 index 0000000000..493d6073ea --- /dev/null +++ b/graphistry/tests/compute/test_chain_validation.py @@ -0,0 +1,145 @@ +"""Tests for Chain validation with new error system.""" + +import pytest +from graphistry.compute.chain import Chain +from graphistry.compute.ast import n, e_forward, ASTNode, ASTEdge +from graphistry.compute.exceptions import ErrorCode, GFQLValidationError, GFQLSyntaxError, GFQLTypeError + + +class TestChainValidation: + """Test Chain validation with structured errors.""" + + def test_valid_chain(self): + """Valid chains pass validation.""" + # Empty operations list is now invalid, but single operation is valid + chain = Chain([n()]) + chain.validate() # Should not raise + + # Multiple operations + chain = Chain([n(), e_forward(), n()]) + chain.validate() # Should not raise + + # With collect_all + errors = chain.validate(collect_all=True) + assert errors == [] + + def test_chain_not_list(self): + """Chain must be a list.""" + chain = Chain.__new__(Chain) # Skip __init__ + chain.chain = "not a list" + + with pytest.raises(GFQLTypeError) as exc_info: + chain.validate() + + assert exc_info.value.code == ErrorCode.E101 + assert "must be a list" in str(exc_info.value) + assert "str" in str(exc_info.value) + assert "Wrap your operations" in str(exc_info.value) + + def test_empty_chain(self): + """Empty chain is valid for backward compatibility.""" + chain = Chain([]) + chain.validate() # Should not raise + + errors = chain.validate(collect_all=True) + assert errors == [] + + def test_invalid_operation_type(self): + """Operations must be ASTObject instances.""" + chain = Chain.__new__(Chain) + chain.chain = [n(), "not an operation", e_forward()] + + with pytest.raises(GFQLTypeError) as exc_info: + chain.validate() + + assert exc_info.value.code == ErrorCode.E101 + assert "index 1" in str(exc_info.value) + assert "not a valid GFQL operation" in str(exc_info.value) + assert exc_info.value.context['operation_index'] == 1 + assert "Use n() for nodes" in str(exc_info.value) + + def test_chain_validates_children(self): + """Chain validates child operations.""" + # Create an invalid node (we'll implement this validation next) + # For now, test that child validation is called + node = n() + edge = e_forward() + chain = Chain([node, edge]) + + # Should validate children + chain.validate() # Currently passes since we haven't updated Node/Edge yet + + def test_chain_from_json_valid(self): + """from_json works with valid data.""" + data = { + 'type': 'Chain', + 'chain': [ + {'type': 'Node', 'filter_dict': {}}, + {'type': 'Edge', 'direction': 'forward'}, + {'type': 'Node', 'filter_dict': {}} + ] + } + + chain = Chain.from_json(data) + assert len(chain.chain) == 3 + assert isinstance(chain.chain[0], ASTNode) + assert isinstance(chain.chain[1], ASTEdge) + + def test_chain_from_json_not_dict(self): + """from_json requires dictionary.""" + with pytest.raises(GFQLSyntaxError) as exc_info: + Chain.from_json("not a dict") + + assert exc_info.value.code == ErrorCode.E101 + assert "must be a dictionary" in str(exc_info.value) + + def test_chain_from_json_missing_chain_field(self): + """from_json requires 'chain' field.""" + with pytest.raises(GFQLSyntaxError) as exc_info: + Chain.from_json({'type': 'Chain'}) + + assert exc_info.value.code == ErrorCode.E105 + assert "missing required 'chain' field" in str(exc_info.value) + + def test_chain_from_json_chain_not_list(self): + """'chain' field must be a list.""" + with pytest.raises(GFQLSyntaxError) as exc_info: + Chain.from_json({'type': 'Chain', 'chain': 'not a list'}) + + assert exc_info.value.code == ErrorCode.E101 + assert "Chain field must be a list" in str(exc_info.value) + + def test_chain_from_json_skip_validation(self): + """from_json can skip validation on operations.""" + # Operations with invalid field values + data = { + 'type': 'Chain', + 'chain': [ + {'type': 'Edge', 'direction': 'forward', 'hops': -1}, # Invalid hops + ] + } + + # Should not raise with validate=False + chain = Chain.from_json(data, validate=False) + assert len(chain.chain) == 1 + assert chain.chain[0].hops == -1 + + # But manual validation would fail + with pytest.raises(GFQLTypeError) as exc_info: + chain.validate() + # Should fail on invalid hops + assert exc_info.value.code == ErrorCode.E103 + + def test_chain_collect_all_errors(self): + """Chain can collect multiple errors.""" + # Create chain with multiple invalid operations + chain = Chain.__new__(Chain) + chain.chain = ["invalid1", "invalid2", n(), "invalid3"] + + errors = chain.validate(collect_all=True) + assert len(errors) >= 3 # At least 3 invalid operations + + # All should be type errors for invalid operations + for i, error in enumerate(errors[:3]): + assert error.code == ErrorCode.E101 + assert "not a valid GFQL operation" in error.message diff --git a/graphistry/tests/compute/test_gfql_exceptions.py b/graphistry/tests/compute/test_gfql_exceptions.py new file mode 100644 index 0000000000..6728070aaf --- /dev/null +++ b/graphistry/tests/compute/test_gfql_exceptions.py @@ -0,0 +1,167 @@ +"""Tests for GFQL validation exceptions.""" + +import pytest +from graphistry.compute.exceptions import ( + ErrorCode, GFQLValidationError, GFQLSyntaxError, + GFQLTypeError, GFQLSchemaError +) + + +class TestErrorCode: + """Test error code constants.""" + + def test_error_codes_exist(self): + """Error codes are defined.""" + assert ErrorCode.E101 == "invalid-chain-type" + assert ErrorCode.E102 == "invalid-filter-key" + assert ErrorCode.E201 == "type-mismatch" + assert ErrorCode.E301 == "column-not-found" + + def test_error_code_ranges(self): + """Error codes follow range convention.""" + # E1xx for syntax + assert ErrorCode.E101.startswith("invalid") + assert ErrorCode.E106 == "empty-chain" + + # E2xx for types + assert ErrorCode.E201 == "type-mismatch" + assert ErrorCode.E202 == "predicate-type-mismatch" + + # E3xx for schema + assert ErrorCode.E301 == "column-not-found" + assert ErrorCode.E302 == "incompatible-column-type" + + +class TestGFQLValidationError: + """Test base validation error.""" + + def test_basic_error_creation(self): + """Can create basic error.""" + error = GFQLValidationError(ErrorCode.E101, "Test message") + assert error.code == ErrorCode.E101 + assert error.message == "Test message" + assert str(error).startswith("[invalid-chain-type]") + assert "Test message" in str(error) + + def test_error_with_context(self): + """Error includes context fields.""" + error = GFQLValidationError( + ErrorCode.E102, + "Invalid filter key", + field="filter_dict.123", + value=123, + suggestion="Use string keys" + ) + + formatted = str(error) + assert "[invalid-filter-key]" in formatted + assert "field: filter_dict.123" in formatted + assert "value: 123" in formatted + assert "suggestion: Use string keys" in formatted + + def test_error_with_operation_index(self): + """Error includes operation index.""" + error = GFQLValidationError( + ErrorCode.E101, + "Bad operation", + operation_index=2 + ) + assert "at operation 2" in str(error) + + def test_error_truncates_long_values(self): + """Long values are truncated in string representation.""" + long_value = "x" * 100 + error = GFQLValidationError( + ErrorCode.E201, + "Value too long", + value=long_value + ) + formatted = str(error) + assert "..." in formatted + assert len(formatted) < 200 # Reasonable length + + def test_error_to_dict(self): + """Error converts to dictionary.""" + error = GFQLValidationError( + ErrorCode.E102, + "Test error", + field="test_field", + value="test_value", + custom_field="custom" + ) + + d = error.to_dict() + assert d['code'] == ErrorCode.E102 + assert d['message'] == "Test error" + assert d['field'] == "test_field" + assert d['value'] == "test_value" + assert d['custom_field'] == "custom" + + def test_error_filters_none_context(self): + """None values are filtered from context.""" + error = GFQLValidationError( + ErrorCode.E101, + "Test", + field=None, + value="something", + suggestion=None + ) + + assert 'field' not in error.context + assert 'suggestion' not in error.context + assert error.context['value'] == "something" + + +class TestErrorSubclasses: + """Test error subclasses.""" + + def test_syntax_error(self): + """GFQLSyntaxError works correctly.""" + error = GFQLSyntaxError( + ErrorCode.E101, + "Invalid syntax", + field="chain" + ) + assert isinstance(error, GFQLValidationError) + assert error.code == ErrorCode.E101 + assert "Invalid syntax" in str(error) + + def test_type_error(self): + """GFQLTypeError works correctly.""" + error = GFQLTypeError( + ErrorCode.E201, + "Type mismatch", + field="hops", + value="not_a_number" + ) + assert isinstance(error, GFQLValidationError) + assert error.code == ErrorCode.E201 + assert "Type mismatch" in str(error) + assert "not_a_number" in str(error) + + def test_schema_error(self): + """GFQLSchemaError works correctly.""" + error = GFQLSchemaError( + ErrorCode.E301, + "Column not found", + field="user_id", + suggestion="Available columns: id, name, email" + ) + assert isinstance(error, GFQLValidationError) + assert error.code == ErrorCode.E301 + assert "Column not found" in str(error) + assert "Available columns" in str(error) + + def test_error_inheritance(self): + """All errors inherit from base class.""" + errors = [ + GFQLSyntaxError(ErrorCode.E101, "test"), + GFQLTypeError(ErrorCode.E201, "test"), + GFQLSchemaError(ErrorCode.E301, "test") + ] + + for error in errors: + assert isinstance(error, GFQLValidationError) + assert isinstance(error, Exception) + assert hasattr(error, 'code') + assert hasattr(error, 'to_dict') diff --git a/graphistry/tests/compute/test_gfql_validation.py b/graphistry/tests/compute/test_gfql_validation.py new file mode 100644 index 0000000000..1a93888f91 --- /dev/null +++ b/graphistry/tests/compute/test_gfql_validation.py @@ -0,0 +1,203 @@ +"""Test script for GFQL validation helpers.""" + +import pandas as pd + +from graphistry import edges, nodes +from graphistry.compute.ast import n, e_forward, e_reverse +from graphistry.compute.gfql.validate import ( + validate_syntax, validate_schema, validate_query, + extract_schema_from_dataframes, format_validation_errors, + Schema, ValidationIssue +) +from graphistry.compute.predicates.numeric import gt, lt +from graphistry.compute.predicates.str import contains + + +def test_syntax_validation(): + """Test syntax validation without data.""" + print("=== Testing Syntax Validation ===\n") + + # Valid query + valid_chain = [n({"type": "person"}), e_forward(), n()] + issues = validate_syntax(valid_chain) + print(f"Valid chain issues: {len(issues)}") + if issues: + print(format_validation_errors(issues)) + + # Invalid query - not a list + try: + issues = validate_syntax("not a list") + print(f"\nInvalid type issues: {len(issues)}") + print(format_validation_errors(issues)) + except Exception as e: + print(f"Error: {e}") + + # Empty chain + issues = validate_syntax([]) + print(f"\nEmpty chain issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Invalid operation type + issues = validate_syntax([n(), "not an operation", e_forward()]) + print(f"\nInvalid operation issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Orphaned edge warning + issues = validate_syntax([e_forward(), e_reverse()]) + print(f"\nOrphaned edge issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Unbounded hops warning + issues = validate_syntax([n(), e_forward(to_fixed_point=True), n()]) + print(f"\nUnbounded hops issues: {len(issues)}") + print(format_validation_errors(issues)) + + +def test_schema_validation(): + """Test schema validation with data.""" + print("\n\n=== Testing Schema Validation ===\n") + + # Create sample data + nodes_df = pd.DataFrame({ + 'id': ['a', 'b', 'c'], + 'type': ['person', 'person', 'company'], + 'age': [25, 30, 5], + 'name': ['Alice', 'Bob', 'Corp'] + }) + + edges_df = pd.DataFrame({ + 'source': ['a', 'b', 'c'], + 'target': ['b', 'c', 'a'], + 'relationship': ['knows', 'works_at', 'employs'], + 'weight': [1.0, 2.0, 3.0] + }) + + # Extract schema + schema = extract_schema_from_dataframes(nodes_df, edges_df) + print(f"Schema: {schema}") + print(f"Node columns: {list(schema.node_columns.keys())}") + print(f"Edge columns: {list(schema.edge_columns.keys())}") + + # Valid query + valid_chain = [n({"type": "person"}), e_forward({"relationship": "knows"}), n()] + issues = validate_schema(valid_chain, schema) + print(f"\nValid query issues: {len(issues)}") + if issues: + print(format_validation_errors(issues)) + + # Column not found + invalid_chain = [n({"nonexistent": "value"})] + issues = validate_schema(invalid_chain, schema) + print(f"\nColumn not found issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Type mismatch - string predicate on numeric column + type_mismatch_chain = [n({"age": contains("25")})] + issues = validate_schema(type_mismatch_chain, schema) + print(f"\nType mismatch issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Valid numeric predicate + valid_numeric_chain = [n({"age": gt(20)})] + issues = validate_schema(valid_numeric_chain, schema) + print(f"\nValid numeric predicate issues: {len(issues)}") + if issues: + print(format_validation_errors(issues)) + + +def test_combined_validation(): + """Test combined validation.""" + print("\n\n=== Testing Combined Validation ===\n") + + nodes_df = pd.DataFrame({ + 'id': range(5), + 'value': [10, 20, 30, 40, 50] + }) + + edges_df = pd.DataFrame({ + 'source': [0, 1, 2, 3], + 'target': [1, 2, 3, 4], + 'weight': [1.0, 2.0, 3.0, 4.0] + }) + + # Query with both syntax and schema issues + problematic_chain = [ + n({"missing_col": "value"}), # Schema error + e_forward(hops=-1), # Syntax error + n({"value": gt(25)}) + ] + + issues = validate_query(problematic_chain, nodes_df, edges_df) + print(f"Combined validation issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Show issue details + print("\nIssue details:") + for issue in issues: + print(f" - {issue.level}: {issue.message}") + if issue.operation_index is not None: + print(f" at operation {issue.operation_index}") + if issue.field: + print(f" field: {issue.field}") + + +def test_edge_validation(): + """Test edge-specific validation.""" + print("\n\n=== Testing Edge Validation ===\n") + + nodes_df = pd.DataFrame({ + 'id': ['a', 'b', 'c'], + 'type': ['A', 'B', 'C'] + }) + + edges_df = pd.DataFrame({ + 'source': ['a', 'b'], + 'target': ['b', 'c'], + 'edge_type': ['follows', 'likes'] + }) + + schema = extract_schema_from_dataframes(nodes_df, edges_df) + + # Valid edge query + valid_chain = [ + n(), + e_forward( + edge_match={"edge_type": "follows"}, + source_node_match={"type": "A"}, + destination_node_match={"type": "B"} + ), + n() + ] + issues = validate_schema(valid_chain, schema) + print(f"Valid edge query issues: {len(issues)}") + if issues: + print(format_validation_errors(issues)) + + # Invalid edge column + invalid_chain = [ + n(), + e_forward({"missing_edge_col": "value"}), + n() + ] + issues = validate_schema(invalid_chain, schema) + print(f"\nInvalid edge column issues: {len(issues)}") + print(format_validation_errors(issues)) + + # Invalid node filter in edge + invalid_node_filter = [ + n(), + e_forward(source_node_match={"missing_node_col": "value"}), + n() + ] + issues = validate_schema(invalid_node_filter, schema) + print(f"\nInvalid node filter in edge issues: {len(issues)}") + print(format_validation_errors(issues)) + + +if __name__ == "__main__": + test_syntax_validation() + test_schema_validation() + test_combined_validation() + test_edge_validation() + + print("\n\n=== All tests completed! ===") diff --git a/graphistry/tests/compute/test_validate.py b/graphistry/tests/compute/test_validate.py new file mode 100644 index 0000000000..7b2c558c54 --- /dev/null +++ b/graphistry/tests/compute/test_validate.py @@ -0,0 +1,316 @@ +"""Unit tests for GFQL validation module.""" + +import pandas as pd +import pytest +from typing import List + +from graphistry.compute.gfql.validate import ( + validate_syntax, validate_schema, validate_query, + extract_schema, extract_schema_from_dataframes, + format_validation_errors, suggest_fixes, + ValidationIssue, Schema, + _format_error, _get_type_category +) +from graphistry.compute.ast import n, e_forward, e_reverse, e +from graphistry.compute.predicates.numeric import gt, lt, between +from graphistry.compute.predicates.str import contains, startswith +from graphistry.compute.chain import Chain +from graphistry.tests.common import NoAuthTestCase + + +class TestValidationIssue(NoAuthTestCase): + """Test ValidationIssue class.""" + + def test_init(self): + issue = ValidationIssue('error', 'Test message') + self.assertEqual(issue.level, 'error') + self.assertEqual(issue.message, 'Test message') + self.assertIsNone(issue.operation_index) + self.assertIsNone(issue.field) + self.assertIsNone(issue.suggestion) + self.assertIsNone(issue.error_type) + + def test_init_with_all_fields(self): + issue = ValidationIssue( + 'warning', 'Test warning', + operation_index=2, + field='test_field', + suggestion='Fix it', + error_type='TEST_ERROR' + ) + self.assertEqual(issue.level, 'warning') + self.assertEqual(issue.operation_index, 2) + self.assertEqual(issue.field, 'test_field') + self.assertEqual(issue.suggestion, 'Fix it') + self.assertEqual(issue.error_type, 'TEST_ERROR') + + def test_repr(self): + issue = ValidationIssue('error', 'Test message', suggestion='Fix it') + repr_str = repr(issue) + self.assertIn('ERROR', repr_str) + self.assertIn('Test message', repr_str) + self.assertIn('Fix it', repr_str) + + def test_to_dict(self): + issue = ValidationIssue( + 'error', 'Test', + operation_index=1, + field='field', + suggestion='Suggestion', + error_type='TYPE' + ) + d = issue.to_dict() + self.assertEqual(d['level'], 'error') + self.assertEqual(d['message'], 'Test') + self.assertEqual(d['operation_index'], 1) + self.assertEqual(d['field'], 'field') + self.assertEqual(d['suggestion'], 'Suggestion') + self.assertEqual(d['error_type'], 'TYPE') + + +class TestSchema(NoAuthTestCase): + """Test Schema class.""" + + def test_init_empty(self): + schema = Schema() + self.assertEqual(schema.node_columns, {}) + self.assertEqual(schema.edge_columns, {}) + + def test_init_with_data(self): + node_cols = {'id': 'int64', 'name': 'object'} + edge_cols = {'source': 'int64', 'target': 'int64'} + schema = Schema(node_cols, edge_cols) + self.assertEqual(schema.node_columns, node_cols) + self.assertEqual(schema.edge_columns, edge_cols) + + def test_repr(self): + schema = Schema({'id': 'int64', 'name': 'object'}, {'source': 'int64'}) + repr_str = repr(schema) + self.assertIn('nodes', repr_str) + self.assertIn('edges', repr_str) + self.assertIn('id', repr_str) + self.assertIn('name', repr_str) + + +class TestSyntaxValidation(NoAuthTestCase): + """Test syntax validation functions.""" + + def test_valid_chain(self): + chain = [n({"type": "person"}), e_forward(), n()] + issues = validate_syntax(chain) + self.assertEqual(len(issues), 0) + + def test_invalid_chain_type(self): + issues = validate_syntax("not a list") + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].level, 'error') + self.assertEqual(issues[0].error_type, 'INVALID_CHAIN_TYPE') + + def test_empty_chain(self): + issues = validate_syntax([]) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].level, 'error') + self.assertEqual(issues[0].error_type, 'EMPTY_CHAIN') + + def test_invalid_operation(self): + chain = [n(), "not an operation", e_forward()] + issues = validate_syntax(chain) + errors = [i for i in issues if i.level == 'error'] + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].operation_index, 1) + self.assertEqual(errors[0].error_type, 'INVALID_OPERATION') + + def test_invalid_filter_key(self): + chain = [n({123: "value"})] # Non-string key + issues = validate_syntax(chain) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'INVALID_FILTER_KEY') + + def test_invalid_hops(self): + chain = [n(), e_forward(hops=-1), n()] + issues = validate_syntax(chain) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'INVALID_HOPS') + + def test_unbounded_hops_warning(self): + chain = [n(), e_forward(to_fixed_point=True), n()] + issues = validate_syntax(chain) + warnings = [i for i in issues if i.level == 'warning'] + self.assertEqual(len(warnings), 1) + self.assertEqual(warnings[0].error_type, 'UNBOUNDED_HOPS_WARNING') + + def test_orphaned_edge_warning(self): + chain = [e_forward(), e_reverse()] + issues = validate_syntax(chain) + warnings = [i for i in issues if i.level == 'warning'] + self.assertEqual(len(warnings), 2) + self.assertTrue(all(w.error_type == 'ORPHANED_EDGE' for w in warnings)) + + def test_chain_object(self): + chain_obj = Chain([n(), e_forward(), n()]) + issues = validate_syntax(chain_obj) + self.assertEqual(len(issues), 0) + + +class TestSchemaValidation(NoAuthTestCase): + """Test schema validation functions.""" + + def setUp(self): + super().setUp() + self.schema = Schema( + node_columns={'id': 'int64', 'name': 'object', 'age': 'int64'}, + edge_columns={'source': 'int64', 'target': 'int64', 'type': 'object', 'weight': 'float64'} + ) + + def test_valid_query(self): + chain = [n({"name": "Alice"}), e_forward({"type": "knows"}), n()] + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 0) + + def test_column_not_found_node(self): + chain = [n({"missing_col": "value"})] + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'COLUMN_NOT_FOUND') + self.assertIn('node', issues[0].message) + + def test_column_not_found_edge(self): + chain = [n(), e_forward({"missing_edge_col": "value"}), n()] + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'COLUMN_NOT_FOUND') + self.assertIn('edge', issues[0].message) + + def test_type_mismatch_numeric(self): + chain = [n({"age": contains("25")})] # String predicate on numeric + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'TYPE_MISMATCH') + self.assertIn('string', issues[0].message) + + def test_type_mismatch_string(self): + chain = [n({"name": gt(10)})] # Numeric predicate on string + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].error_type, 'TYPE_MISMATCH') + self.assertIn('numeric', issues[0].message) + + def test_edge_node_filters(self): + chain = [ + n(), + e_forward( + source_node_match={"missing": "value"}, + destination_node_match={"id": 1} + ), + n() + ] + issues = validate_schema(chain, self.schema) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0].field, 'source_node_match.missing') + + +class TestValidateQuery(NoAuthTestCase): + """Test combined validation function.""" + + def test_without_data(self): + chain = [n(), e_forward(hops=-1), n()] + issues = validate_query(chain) + # Should only do syntax validation + self.assertTrue(any(i.error_type == 'INVALID_HOPS' for i in issues)) + + def test_with_data(self): + nodes_df = pd.DataFrame({'id': [1, 2, 3], 'type': ['A', 'B', 'C']}) + edges_df = pd.DataFrame({'source': [1, 2], 'target': [2, 3]}) + + chain = [n({"missing": "value"})] + issues = validate_query(chain, nodes_df, edges_df) + # Should include schema validation + self.assertTrue(any(i.error_type == 'COLUMN_NOT_FOUND' for i in issues)) + + +class TestSchemaExtraction(NoAuthTestCase): + """Test schema extraction functions.""" + + def test_extract_from_dataframes(self): + nodes_df = pd.DataFrame({'id': [1, 2], 'name': ['A', 'B']}) + edges_df = pd.DataFrame({'source': [1], 'target': [2], 'weight': [1.0]}) + + schema = extract_schema_from_dataframes(nodes_df, edges_df) + + self.assertIn('id', schema.node_columns) + self.assertIn('name', schema.node_columns) + self.assertIn('source', schema.edge_columns) + self.assertIn('weight', schema.edge_columns) + self.assertEqual(schema.edge_columns['weight'], 'float64') + + def test_extract_none_dataframes(self): + schema = extract_schema_from_dataframes(None, None) + self.assertEqual(schema.node_columns, {}) + self.assertEqual(schema.edge_columns, {}) + + def test_extract_partial_dataframes(self): + nodes_df = pd.DataFrame({'id': [1, 2]}) + schema = extract_schema_from_dataframes(nodes_df, None) + self.assertIn('id', schema.node_columns) + self.assertEqual(schema.edge_columns, {}) + + +class TestErrorFormatting(NoAuthTestCase): + """Test error formatting functions.""" + + def test_format_no_issues(self): + result = format_validation_errors([]) + self.assertEqual(result, "No validation issues found.") + + def test_format_errors_and_warnings(self): + issues = [ + ValidationIssue('error', 'Error 1', operation_index=0, suggestion='Fix 1'), + ValidationIssue('warning', 'Warning 1', operation_index=1, suggestion='Fix 2') + ] + result = format_validation_errors(issues) + self.assertIn('ERRORS (1)', result) + self.assertIn('WARNINGS (1)', result) + self.assertIn('Error 1', result) + self.assertIn('Warning 1', result) + self.assertIn('Fix 1', result) + + def test_suggest_fixes(self): + issues = [ + ValidationIssue('error', 'Column not found', error_type='COLUMN_NOT_FOUND', field='col1'), + ValidationIssue('error', 'Column not found', error_type='COLUMN_NOT_FOUND', field='col2'), + ValidationIssue('error', 'Type mismatch', error_type='TYPE_MISMATCH') + ] + suggestions = suggest_fixes([], issues) + self.assertTrue(any('Missing columns' in s for s in suggestions)) + self.assertTrue(any('Type mismatches' in s for s in suggestions)) + + +class TestHelperFunctions(NoAuthTestCase): + """Test internal helper functions.""" + + def test_format_error(self): + message, suggestion = _format_error('EMPTY_CHAIN') + self.assertEqual(message, 'Chain is empty') + self.assertIn('Add at least one operation', suggestion) + + def test_format_error_with_kwargs(self): + message, suggestion = _format_error('COLUMN_NOT_FOUND', + column='test', + table='node', + available='id, name') + self.assertIn('test', message) + self.assertIn('node', message) + self.assertIn('id, name', suggestion) + + def test_get_type_category(self): + self.assertEqual(_get_type_category('int64'), 'numeric') + self.assertEqual(_get_type_category('float32'), 'numeric') + self.assertEqual(_get_type_category('object'), 'string') + self.assertEqual(_get_type_category('str'), 'string') + self.assertEqual(_get_type_category('datetime64'), 'temporal') + self.assertEqual(_get_type_category('bool'), 'boolean') + self.assertEqual(_get_type_category('custom_type'), 'unknown') + + +if __name__ == '__main__': + pytest.main([__file__])