Compare commits
3 Commits
c4a1b3cc0c
...
2a15dde228
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a15dde228 | |||
| d4e5992213 | |||
| 2dd1704e51 |
1176
docs/graphql_interface.md
Normal file
1176
docs/graphql_interface.md
Normal file
File diff suppressed because it is too large
Load Diff
515
markitect/cli.py
515
markitect/cli.py
@@ -5280,6 +5280,521 @@ def plugin_discover(config, refresh):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# GraphQL Commands
|
||||
|
||||
@cli.command(name='graphql-serve')
|
||||
@click.option('--host', default='127.0.0.1', help='Host to bind to')
|
||||
@click.option('--port', default=5000, type=int, help='Port to bind to')
|
||||
@click.option('--debug', is_flag=True, help='Enable debug mode')
|
||||
@click.option('--no-cors', is_flag=True, help='Disable CORS')
|
||||
@pass_config
|
||||
def graphql_serve(config, host, port, debug, no_cors):
|
||||
"""Start GraphQL server for MarkiTect API.
|
||||
|
||||
Starts a GraphQL server that exposes MarkiTect's database content
|
||||
including markdown files, schemas, and ASTs through a GraphQL interface.
|
||||
|
||||
Examples:
|
||||
markitect graphql-serve
|
||||
markitect graphql-serve --host 0.0.0.0 --port 8000
|
||||
markitect graphql-serve --debug --no-cors
|
||||
"""
|
||||
try:
|
||||
from .graphql import GraphQLServer
|
||||
|
||||
server = GraphQLServer(
|
||||
db_path=config['database_path'],
|
||||
enable_cors=not no_cors
|
||||
)
|
||||
server.run(host=host, port=port, debug=debug)
|
||||
|
||||
except ImportError:
|
||||
click.echo("❌ GraphQL server requires additional dependencies.", err=True)
|
||||
click.echo("Install with: pip install flask flask-cors", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to start GraphQL server: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='graphql-query')
|
||||
@click.argument('query', type=str)
|
||||
@click.option('--variables', type=str, help='JSON variables for the query')
|
||||
@click.option('--endpoint', default='http://localhost:5000/graphql', help='GraphQL endpoint URL')
|
||||
@click.option('--local', is_flag=True, help='Execute query locally without HTTP')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['json', 'yaml', 'table']),
|
||||
default='json', help='Output format')
|
||||
@pass_config
|
||||
def graphql_query(config, query, variables, endpoint, local, output_format):
|
||||
"""Execute GraphQL query against MarkiTect API.
|
||||
|
||||
Execute GraphQL queries to retrieve data from MarkiTect's database.
|
||||
Can run against a local server or execute queries directly.
|
||||
|
||||
Examples:
|
||||
markitect graphql-query "{ markdownFiles { id filename } }"
|
||||
markitect graphql-query "query GetFile($id: Int!) { markdownFile(id: $id) { filename content } }" --variables '{"id": 1}'
|
||||
markitect graphql-query "{ databaseStats { totalFiles totalSchemas } }" --local
|
||||
"""
|
||||
try:
|
||||
from .graphql import GraphQLClient
|
||||
import json as json_module
|
||||
|
||||
# Parse variables if provided
|
||||
parsed_variables = {}
|
||||
if variables:
|
||||
try:
|
||||
parsed_variables = json_module.loads(variables)
|
||||
except json_module.JSONDecodeError as e:
|
||||
click.echo(f"❌ Invalid JSON in variables: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Execute query
|
||||
if local:
|
||||
client = GraphQLClient()
|
||||
result = client.execute_local(query, parsed_variables)
|
||||
else:
|
||||
client = GraphQLClient(endpoint)
|
||||
result = client.execute(query, parsed_variables)
|
||||
|
||||
# Format output
|
||||
if result.get('errors'):
|
||||
click.echo("❌ GraphQL Errors:", err=True)
|
||||
for error in result['errors']:
|
||||
click.echo(f" {error.get('message', str(error))}", err=True)
|
||||
|
||||
if result.get('data'):
|
||||
if output_format == 'json':
|
||||
click.echo(json_module.dumps(result['data'], indent=2))
|
||||
elif output_format == 'yaml':
|
||||
import yaml
|
||||
click.echo(yaml.dump(result['data'], default_flow_style=False))
|
||||
elif output_format == 'table':
|
||||
# Simple table format for basic data
|
||||
click.echo(format_output(result['data'], 'table'))
|
||||
|
||||
except ImportError as e:
|
||||
if 'requests' in str(e):
|
||||
click.echo("❌ GraphQL client requires requests library.", err=True)
|
||||
click.echo("Install with: pip install requests", err=True)
|
||||
else:
|
||||
click.echo(f"❌ Missing dependency: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to execute GraphQL query: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='graphql-schema')
|
||||
@click.option('--endpoint', default='http://localhost:5000/graphql', help='GraphQL endpoint URL')
|
||||
@click.option('--local', is_flag=True, help='Get schema locally without HTTP')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['sdl', 'json']),
|
||||
default='sdl', help='Schema format (SDL or introspection JSON)')
|
||||
@pass_config
|
||||
def graphql_schema(config, endpoint, local, output_format):
|
||||
"""Get GraphQL schema definition.
|
||||
|
||||
Retrieve and display the GraphQL schema for MarkiTect's API.
|
||||
Useful for understanding available queries and types.
|
||||
|
||||
Examples:
|
||||
markitect graphql-schema
|
||||
markitect graphql-schema --local
|
||||
markitect graphql-schema --format json
|
||||
"""
|
||||
try:
|
||||
if local:
|
||||
from .graphql import schema
|
||||
try:
|
||||
from graphql.utilities import print_schema
|
||||
schema_sdl = print_schema(schema.graphql_schema)
|
||||
except (AttributeError, ImportError):
|
||||
# Fallback to simple string representation
|
||||
schema_sdl = str(schema)
|
||||
|
||||
if output_format == 'sdl':
|
||||
click.echo(schema_sdl)
|
||||
else:
|
||||
# For JSON, we'd need introspection query
|
||||
introspection_query = """
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
queryType { name }
|
||||
mutationType { name }
|
||||
subscriptionType { name }
|
||||
types {
|
||||
...FullType
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment FullType on __Type {
|
||||
kind
|
||||
name
|
||||
description
|
||||
fields(includeDeprecated: true) {
|
||||
name
|
||||
description
|
||||
args {
|
||||
...InputValue
|
||||
}
|
||||
type {
|
||||
...TypeRef
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment InputValue on __InputValue {
|
||||
name
|
||||
description
|
||||
type { ...TypeRef }
|
||||
defaultValue
|
||||
}
|
||||
fragment TypeRef on __Type {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
ofType {
|
||||
kind
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
from .graphql import GraphQLClient
|
||||
client = GraphQLClient()
|
||||
result = client.execute_local(introspection_query)
|
||||
import json
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
# Get from HTTP endpoint
|
||||
import requests
|
||||
if output_format == 'sdl':
|
||||
# Try to get SDL from /schema endpoint
|
||||
try:
|
||||
response = requests.get(endpoint.replace('/graphql', '/schema'))
|
||||
if response.status_code == 200:
|
||||
schema_data = response.json()
|
||||
click.echo(schema_data.get('schema', 'Schema not available'))
|
||||
else:
|
||||
click.echo(f"❌ Failed to get schema: HTTP {response.status_code}", err=True)
|
||||
sys.exit(1)
|
||||
except requests.RequestException as e:
|
||||
click.echo(f"❌ Failed to connect to GraphQL server: {e}", err=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Get introspection JSON
|
||||
from .graphql import GraphQLClient
|
||||
client = GraphQLClient(endpoint)
|
||||
# Use introspection query (same as above)
|
||||
result = client.execute(introspection_query)
|
||||
import json
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
|
||||
except ImportError as e:
|
||||
click.echo(f"❌ Missing dependency: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to get GraphQL schema: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command(name='graphql-examples')
|
||||
@pass_config
|
||||
def graphql_examples(config):
|
||||
"""Show example GraphQL queries for MarkiTect.
|
||||
|
||||
Display common GraphQL query examples to help users get started
|
||||
with querying MarkiTect's data through the GraphQL interface.
|
||||
"""
|
||||
examples = """
|
||||
📊 MarkiTect GraphQL Query Examples
|
||||
===================================
|
||||
|
||||
🔍 Basic Queries:
|
||||
|
||||
# Get all markdown files with basic info
|
||||
{
|
||||
markdownFiles(limit: 10) {
|
||||
id
|
||||
filename
|
||||
hasFrontMatter
|
||||
wordCount
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
# Get specific file by ID
|
||||
{
|
||||
markdownFile(id: 1) {
|
||||
filename
|
||||
content
|
||||
frontMatter {
|
||||
key
|
||||
value
|
||||
}
|
||||
wordCount
|
||||
lineCount
|
||||
}
|
||||
}
|
||||
|
||||
# Get file by filename
|
||||
{
|
||||
markdownFile(filename: "docs/README.md") {
|
||||
id
|
||||
content
|
||||
frontMatterRaw
|
||||
}
|
||||
}
|
||||
|
||||
📋 Schema Queries:
|
||||
|
||||
# List all schemas
|
||||
{
|
||||
schemas {
|
||||
id
|
||||
filename
|
||||
title
|
||||
description
|
||||
schemaVersion
|
||||
propertyCount
|
||||
}
|
||||
}
|
||||
|
||||
# Get specific schema
|
||||
{
|
||||
schema(id: 1) {
|
||||
filename
|
||||
title
|
||||
schemaContent
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
🌳 AST Queries:
|
||||
|
||||
# Get AST for a file
|
||||
{
|
||||
ast(fileId: 1) {
|
||||
filename
|
||||
headingCount
|
||||
linkCount
|
||||
imageCount
|
||||
codeBlockCount
|
||||
tree {
|
||||
type
|
||||
value
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# JSONPath query on AST
|
||||
{
|
||||
astQuery(fileId: 1, jsonpath: "$..heading") {
|
||||
# Returns array of heading nodes
|
||||
}
|
||||
}
|
||||
|
||||
🔍 Search Queries:
|
||||
|
||||
# Search across files and schemas
|
||||
{
|
||||
search(query: "markdown", limit: 5) {
|
||||
type
|
||||
score
|
||||
highlight
|
||||
file {
|
||||
filename
|
||||
wordCount
|
||||
}
|
||||
schema {
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Search only files
|
||||
{
|
||||
search(query: "README", type: "file") {
|
||||
type
|
||||
score
|
||||
file {
|
||||
filename
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
📊 Statistics:
|
||||
|
||||
# Database overview
|
||||
{
|
||||
databaseStats {
|
||||
totalFiles
|
||||
totalSchemas
|
||||
totalSizeBytes
|
||||
lastUpdated
|
||||
}
|
||||
}
|
||||
|
||||
🔍 Filtering:
|
||||
|
||||
# Files with front matter created after date
|
||||
{
|
||||
markdownFiles(
|
||||
hasFrontMatter: true,
|
||||
createdAfter: "2023-01-01T00:00:00"
|
||||
) {
|
||||
filename
|
||||
frontMatterRaw
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
💡 Usage Examples:
|
||||
|
||||
# Execute a query locally
|
||||
markitect graphql-query "{ databaseStats { totalFiles } }" --local
|
||||
|
||||
# Execute with variables
|
||||
markitect graphql-query \\
|
||||
"query GetFile($id: Int!) { markdownFile(id: $id) { filename } }" \\
|
||||
--variables '{"id": 1}' --local
|
||||
|
||||
# Start GraphQL server
|
||||
markitect graphql-serve --port 5000
|
||||
|
||||
# Query running server
|
||||
markitect graphql-query "{ markdownFiles { filename } }" \\
|
||||
--endpoint http://localhost:5000/graphql
|
||||
|
||||
🛠️ Mutation Examples (Write Operations):
|
||||
|
||||
# Add a new markdown file
|
||||
markitect graphql-mutate \\
|
||||
'mutation { addMarkdownFile(filename: "new-doc.md", content: "# New Document\\n\\nContent here") { success markdownFile { id filename } errors } }' \\
|
||||
--local
|
||||
|
||||
# Update existing file
|
||||
markitect graphql-mutate \\
|
||||
'mutation { updateMarkdownFile(id: 1, content: "# Updated\\n\\nNew content") { success errors } }' \\
|
||||
--local
|
||||
|
||||
# Add a JSON schema
|
||||
markitect graphql-mutate \\
|
||||
'mutation { addSchema(filename: "user-schema.json", schemaContent: "{\\"type\\": \\"object\\", \\"properties\\": {\\"name\\": {\\"type\\": \\"string\\"}}}") { success schema { id title } errors } }' \\
|
||||
--local
|
||||
|
||||
# Delete a schema
|
||||
markitect graphql-mutate \\
|
||||
'mutation { deleteSchema(filename: "old-schema.json") { success deletedFilename errors } }' \\
|
||||
--local
|
||||
|
||||
💡 Access GraphQL Playground at http://localhost:5000/graphql when server is running
|
||||
"""
|
||||
|
||||
click.echo(examples)
|
||||
|
||||
|
||||
@cli.command(name='graphql-mutate')
|
||||
@click.argument('mutation', required=True)
|
||||
@click.option('--variables', default='{}', help='JSON string of mutation variables')
|
||||
@click.option('--endpoint', default='http://localhost:5000/graphql', help='GraphQL endpoint URL')
|
||||
@click.option('--local', is_flag=True, help='Execute mutation locally without HTTP')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['json', 'yaml', 'table']),
|
||||
default='json', help='Output format')
|
||||
@pass_config
|
||||
def graphql_mutate(config, mutation, variables, endpoint, local, output_format):
|
||||
"""Execute GraphQL mutations for write operations.
|
||||
|
||||
Execute GraphQL mutations to add, update, or delete data in MarkiTect.
|
||||
Supports both local and remote execution modes.
|
||||
|
||||
Examples:
|
||||
# Add a new markdown file locally
|
||||
markitect graphql-mutate 'mutation { addMarkdownFile(filename: "test.md", content: "# Test\\nContent") { success markdown_file { id filename } } }' --local
|
||||
|
||||
# Update an existing file
|
||||
markitect graphql-mutate 'mutation { updateMarkdownFile(id: 1, content: "# Updated\\nContent") { success errors } }' --local
|
||||
|
||||
# Add a schema with variables
|
||||
markitect graphql-mutate 'mutation($filename: String!, $content: JSONString!) { addSchema(filename: $filename, schemaContent: $content) { success schema { id title } } }' --variables '{"filename": "test.json", "content": "{\\"type\\": \\"object\\"}"}' --local
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
|
||||
try:
|
||||
# Parse variables
|
||||
try:
|
||||
mutation_variables = json.loads(variables) if variables != '{}' else {}
|
||||
except json.JSONDecodeError:
|
||||
click.echo("❌ Invalid JSON in variables parameter", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if local:
|
||||
# Execute locally using the GraphQL client
|
||||
try:
|
||||
from .graphql import GraphQLClient
|
||||
client = GraphQLClient()
|
||||
result = client.execute_local(mutation, variables=mutation_variables)
|
||||
except ImportError as e:
|
||||
click.echo(f"❌ Missing dependency: {e}", err=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Execute remotely
|
||||
try:
|
||||
from .graphql import GraphQLClient
|
||||
client = GraphQLClient(endpoint)
|
||||
result = client.execute(mutation, variables=mutation_variables)
|
||||
except ImportError as e:
|
||||
click.echo(f"❌ Missing dependency: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Format and display output
|
||||
if output_format == 'json':
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
elif output_format == 'yaml':
|
||||
click.echo(yaml.dump(result, default_flow_style=False))
|
||||
elif output_format == 'table':
|
||||
# For mutations, simple table output
|
||||
if result.get('data'):
|
||||
click.echo("Mutation Result:")
|
||||
for key, value in result['data'].items():
|
||||
if isinstance(value, dict):
|
||||
click.echo(f" {key}:")
|
||||
for sub_key, sub_value in value.items():
|
||||
click.echo(f" {sub_key}: {sub_value}")
|
||||
else:
|
||||
click.echo(f" {key}: {value}")
|
||||
|
||||
if result.get('errors'):
|
||||
click.echo("Errors:")
|
||||
for error in result['errors']:
|
||||
click.echo(f" - {error.get('message', error)}")
|
||||
|
||||
except ImportError as e:
|
||||
click.echo(f"❌ Missing dependency: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"❌ Failed to execute mutation: {e}", err=True)
|
||||
if config.get('verbose'):
|
||||
import traceback
|
||||
click.echo(traceback.format_exc(), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Register issue management commands
|
||||
cli.add_command(issues_group)
|
||||
|
||||
|
||||
12
markitect/graphql/__init__.py
Normal file
12
markitect/graphql/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
GraphQL interface for MarkiTect - Issue #9
|
||||
|
||||
This package provides a GraphQL read interface for querying MarkiTect's
|
||||
database content including Markdown files, ASTs, and schemas.
|
||||
"""
|
||||
|
||||
from .schema import schema
|
||||
from .server import GraphQLServer, GraphQLClient
|
||||
from .resolvers import Query, Mutation
|
||||
|
||||
__all__ = ['schema', 'GraphQLServer', 'GraphQLClient', 'Query', 'Mutation']
|
||||
721
markitect/graphql/resolvers.py
Normal file
721
markitect/graphql/resolvers.py
Normal file
@@ -0,0 +1,721 @@
|
||||
"""
|
||||
GraphQL resolvers for MarkiTect data.
|
||||
|
||||
Implements the resolver functions that fetch data from MarkiTect's
|
||||
database and services to fulfill GraphQL queries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
from jsonpath_ng import parse as jsonpath_parse
|
||||
|
||||
from ..database import DatabaseManager
|
||||
from ..ast_service import ASTService
|
||||
from .schema import (
|
||||
MarkdownFile, Schema, AST, ASTNode, DatabaseStats,
|
||||
SearchResult, Query as QueryType
|
||||
)
|
||||
|
||||
|
||||
class MarkiTectResolver:
|
||||
"""Base resolver class with common database operations."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""Initialize resolver with database path."""
|
||||
self.db_path = db_path
|
||||
self.db_manager = DatabaseManager(db_path)
|
||||
self.ast_service = ASTService()
|
||||
|
||||
def get_connection(self):
|
||||
"""Get database connection."""
|
||||
return sqlite3.connect(self.db_path)
|
||||
|
||||
def row_to_dict(self, cursor, row):
|
||||
"""Convert database row to dictionary."""
|
||||
return dict(zip([col[0] for col in cursor.description], row))
|
||||
|
||||
|
||||
class Query(QueryType):
|
||||
"""GraphQL query resolver implementation."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize query resolver."""
|
||||
# Default database path - could be made configurable
|
||||
self.resolver = MarkiTectResolver(get_default_database_path())
|
||||
|
||||
def resolve_markdown_file(self, info, id=None, filename=None):
|
||||
"""Resolve single markdown file query."""
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if id:
|
||||
cursor.execute(
|
||||
"SELECT * FROM markdown_files WHERE id = ?",
|
||||
(id,)
|
||||
)
|
||||
elif filename:
|
||||
cursor.execute(
|
||||
"SELECT * FROM markdown_files WHERE filename = ?",
|
||||
(filename,)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
return MarkdownFile(**data)
|
||||
return None
|
||||
|
||||
def resolve_schema(self, info, id=None, filename=None):
|
||||
"""Resolve single schema query."""
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if id:
|
||||
cursor.execute(
|
||||
"SELECT * FROM schemas WHERE id = ?",
|
||||
(id,)
|
||||
)
|
||||
elif filename:
|
||||
cursor.execute(
|
||||
"SELECT * FROM schemas WHERE filename = ?",
|
||||
(filename,)
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
return Schema(**data)
|
||||
return None
|
||||
|
||||
def resolve_ast(self, info, file_id=None, filename=None):
|
||||
"""Resolve AST query."""
|
||||
if not file_id and not filename:
|
||||
return None
|
||||
|
||||
# Get file path
|
||||
if file_id:
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT filename FROM markdown_files WHERE id = ?",
|
||||
(file_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
filename = row[0]
|
||||
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
file_path = Path(filename)
|
||||
try:
|
||||
# Use AST service to get parsed AST
|
||||
ast_result = self.resolver.ast_service.display_ast(file_path, "json")
|
||||
if ast_result.get('success'):
|
||||
ast_data = ast_result.get('ast', {})
|
||||
|
||||
# Convert to our GraphQL AST format
|
||||
return AST(
|
||||
file_id=file_id,
|
||||
filename=filename,
|
||||
tree=self._convert_ast_nodes(ast_data),
|
||||
metadata=ast_result.get('metadata', {}),
|
||||
heading_count=self._count_nodes_by_type(ast_data, 'heading'),
|
||||
link_count=self._count_nodes_by_type(ast_data, 'link'),
|
||||
image_count=self._count_nodes_by_type(ast_data, 'image'),
|
||||
code_block_count=self._count_nodes_by_type(ast_data, 'code')
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def resolve_markdown_files(self, info, limit=50, offset=0, has_front_matter=None, created_after=None):
|
||||
"""Resolve markdown files list query."""
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build query with filters
|
||||
query = "SELECT * FROM markdown_files WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if has_front_matter is not None:
|
||||
if has_front_matter:
|
||||
query += " AND front_matter IS NOT NULL AND front_matter != ''"
|
||||
else:
|
||||
query += " AND (front_matter IS NULL OR front_matter = '')"
|
||||
|
||||
if created_after:
|
||||
query += " AND created_at > ?"
|
||||
params.append(created_after.isoformat())
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
files = []
|
||||
for row in rows:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
files.append(MarkdownFile(**data))
|
||||
|
||||
return files
|
||||
|
||||
def resolve_schemas(self, info, limit=50, offset=0):
|
||||
"""Resolve schemas list query."""
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT * FROM schemas ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
schemas = []
|
||||
for row in rows:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
schemas.append(Schema(**data))
|
||||
|
||||
return schemas
|
||||
|
||||
def resolve_search(self, info, query, type="all", limit=20):
|
||||
"""Resolve search query."""
|
||||
results = []
|
||||
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Search in markdown files
|
||||
if type in ["all", "file"]:
|
||||
cursor.execute("""
|
||||
SELECT *, 'file' as result_type FROM markdown_files
|
||||
WHERE filename LIKE ? OR content LIKE ?
|
||||
ORDER BY
|
||||
CASE WHEN filename LIKE ? THEN 1 ELSE 2 END,
|
||||
created_at DESC
|
||||
LIMIT ?
|
||||
""", (f"%{query}%", f"%{query}%", f"%{query}%", limit))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
# Remove extra fields that don't belong to MarkdownFile
|
||||
file_data = {k: v for k, v in data.items() if k != 'result_type'}
|
||||
|
||||
# Calculate basic relevance score
|
||||
score = 1.0
|
||||
if query.lower() in data['filename'].lower():
|
||||
score += 0.5
|
||||
if data['content'] and query.lower() in data['content'].lower():
|
||||
score += 0.3
|
||||
|
||||
results.append(SearchResult(
|
||||
type="file",
|
||||
score=score,
|
||||
file=MarkdownFile(**file_data),
|
||||
highlight=self._extract_highlight(data.get('content', ''), query)
|
||||
))
|
||||
|
||||
# Search in schemas
|
||||
if type in ["all", "schema"]:
|
||||
cursor.execute("""
|
||||
SELECT *, 'schema' as result_type FROM schemas
|
||||
WHERE filename LIKE ? OR title LIKE ? OR description LIKE ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""", (f"%{query}%", f"%{query}%", f"%{query}%", limit))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
# Remove extra fields that don't belong to Schema
|
||||
schema_data = {k: v for k, v in data.items() if k != 'result_type'}
|
||||
|
||||
# Calculate basic relevance score
|
||||
score = 1.0
|
||||
if query.lower() in data.get('title', '').lower():
|
||||
score += 0.5
|
||||
|
||||
results.append(SearchResult(
|
||||
type="schema",
|
||||
score=score,
|
||||
schema=Schema(**schema_data),
|
||||
highlight=data.get('title', '') or data.get('filename', '')
|
||||
))
|
||||
|
||||
conn.close()
|
||||
|
||||
# Sort by score and limit
|
||||
results.sort(key=lambda x: x.score, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
def resolve_database_stats(self, info):
|
||||
"""Resolve database statistics."""
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Count files
|
||||
cursor.execute("SELECT COUNT(*) FROM markdown_files")
|
||||
total_files = cursor.fetchone()[0]
|
||||
|
||||
# Count schemas
|
||||
cursor.execute("SELECT COUNT(*) FROM schemas")
|
||||
total_schemas = cursor.fetchone()[0]
|
||||
|
||||
# Get database size
|
||||
db_size = 0
|
||||
if os.path.exists(self.resolver.db_path):
|
||||
db_size = os.path.getsize(self.resolver.db_path)
|
||||
|
||||
# Get last update time
|
||||
cursor.execute("""
|
||||
SELECT MAX(created_at) FROM (
|
||||
SELECT created_at FROM markdown_files
|
||||
UNION ALL
|
||||
SELECT created_at FROM schemas
|
||||
)
|
||||
""")
|
||||
last_updated_str = cursor.fetchone()[0]
|
||||
last_updated = None
|
||||
if last_updated_str:
|
||||
try:
|
||||
last_updated = datetime.fromisoformat(last_updated_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
|
||||
return DatabaseStats(
|
||||
total_files=total_files,
|
||||
total_schemas=total_schemas,
|
||||
total_size_bytes=db_size,
|
||||
last_updated=last_updated
|
||||
)
|
||||
|
||||
def resolve_ast_query(self, info, jsonpath, file_id=None, filename=None):
|
||||
"""Resolve JSONPath query on AST."""
|
||||
if not file_id and not filename:
|
||||
return []
|
||||
|
||||
# Get AST data
|
||||
ast = self.resolve_ast(info, file_id=file_id, filename=filename)
|
||||
if not ast or not ast.metadata:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Parse JSONPath expression
|
||||
jsonpath_expr = jsonpath_parse(jsonpath)
|
||||
|
||||
# Apply to AST metadata (contains the raw AST)
|
||||
matches = jsonpath_expr.find(ast.metadata)
|
||||
|
||||
# Return the matched values
|
||||
return [match.value for match in matches]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _convert_ast_nodes(self, ast_data):
|
||||
"""Convert AST data to GraphQL ASTNode format."""
|
||||
if not ast_data or not isinstance(ast_data, dict):
|
||||
return []
|
||||
|
||||
nodes = []
|
||||
if 'children' in ast_data:
|
||||
for child in ast_data['children']:
|
||||
node = ASTNode(
|
||||
type=child.get('type', 'unknown'),
|
||||
value=child.get('value'),
|
||||
level=child.get('depth'),
|
||||
attrs=child,
|
||||
children=self._convert_ast_nodes(child) if 'children' in child else []
|
||||
)
|
||||
nodes.append(node)
|
||||
|
||||
return nodes
|
||||
|
||||
def _count_nodes_by_type(self, ast_data, node_type):
|
||||
"""Count nodes of specific type in AST."""
|
||||
if not ast_data or not isinstance(ast_data, dict):
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
if ast_data.get('type') == node_type:
|
||||
count += 1
|
||||
|
||||
if 'children' in ast_data:
|
||||
for child in ast_data['children']:
|
||||
count += self._count_nodes_by_type(child, node_type)
|
||||
|
||||
return count
|
||||
|
||||
def _extract_highlight(self, content, query, context_length=100):
|
||||
"""Extract highlighted snippet from content."""
|
||||
if not content or not query:
|
||||
return ""
|
||||
|
||||
query_lower = query.lower()
|
||||
content_lower = content.lower()
|
||||
|
||||
index = content_lower.find(query_lower)
|
||||
if index == -1:
|
||||
return content[:context_length] + "..." if len(content) > context_length else content
|
||||
|
||||
start = max(0, index - context_length // 2)
|
||||
end = min(len(content), index + len(query) + context_length // 2)
|
||||
|
||||
snippet = content[start:end]
|
||||
if start > 0:
|
||||
snippet = "..." + snippet
|
||||
if end < len(content):
|
||||
snippet = snippet + "..."
|
||||
|
||||
return snippet
|
||||
|
||||
|
||||
class Mutation:
|
||||
"""GraphQL mutation resolver implementation."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize mutation resolver."""
|
||||
self.resolver = MarkiTectResolver(get_default_database_path())
|
||||
|
||||
def resolve_add_markdown_file(self, info, filename, content):
|
||||
"""Add a new markdown file to the database."""
|
||||
try:
|
||||
# Store the file using the database manager
|
||||
file_id = self.resolver.db_manager.store_markdown_file(filename, content)
|
||||
|
||||
if file_id:
|
||||
# Retrieve the created file
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM markdown_files WHERE id = ?", (file_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
from .schema import AddMarkdownFilePayload, MarkdownFile
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=MarkdownFile(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
from .schema import AddMarkdownFilePayload
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Failed to store markdown file"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from .schema import AddMarkdownFilePayload
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_update_markdown_file(self, info, id, content=None):
|
||||
"""Update an existing markdown file."""
|
||||
try:
|
||||
if not content:
|
||||
from .schema import UpdateMarkdownFilePayload
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Content is required for update"]
|
||||
)
|
||||
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if file exists
|
||||
cursor.execute("SELECT filename FROM markdown_files WHERE id = ?", (id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
from .schema import UpdateMarkdownFilePayload
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[f"Markdown file with ID {id} not found"]
|
||||
)
|
||||
|
||||
filename = row[0]
|
||||
conn.close()
|
||||
|
||||
# Update using store_markdown_file (it handles front matter parsing)
|
||||
file_id = self.resolver.db_manager.store_markdown_file(filename, content)
|
||||
|
||||
if file_id:
|
||||
# Retrieve the updated file
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM markdown_files WHERE filename = ?", (filename,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
from .schema import UpdateMarkdownFilePayload, MarkdownFile
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=MarkdownFile(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
from .schema import UpdateMarkdownFilePayload
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Failed to update markdown file"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from .schema import UpdateMarkdownFilePayload
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_add_schema(self, info, filename, schema_content):
|
||||
"""Add a new JSON schema to the database."""
|
||||
try:
|
||||
# Store the schema using the database manager
|
||||
schema_id = self.resolver.db_manager.store_schema_file(filename, schema_content)
|
||||
|
||||
if schema_id:
|
||||
# Retrieve the created schema
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM schemas WHERE id = ?", (schema_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
from .schema import AddSchemaPayload, Schema
|
||||
return AddSchemaPayload(
|
||||
schema=Schema(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
from .schema import AddSchemaPayload
|
||||
return AddSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Failed to store schema"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from .schema import AddSchemaPayload
|
||||
return AddSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_update_schema(self, info, id, schema_content=None):
|
||||
"""Update an existing JSON schema."""
|
||||
try:
|
||||
if not schema_content:
|
||||
from .schema import UpdateSchemaPayload
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Schema content is required for update"]
|
||||
)
|
||||
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if schema exists
|
||||
cursor.execute("SELECT filename FROM schemas WHERE id = ?", (id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
from .schema import UpdateSchemaPayload
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[f"Schema with ID {id} not found"]
|
||||
)
|
||||
|
||||
filename = row[0]
|
||||
conn.close()
|
||||
|
||||
# Update using store_schema_file
|
||||
schema_id = self.resolver.db_manager.store_schema_file(filename, schema_content)
|
||||
|
||||
if schema_id:
|
||||
# Retrieve the updated schema
|
||||
conn = self.resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM schemas WHERE filename = ?", (filename,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
data = self.resolver.row_to_dict(cursor, row)
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
from .schema import UpdateSchemaPayload, Schema
|
||||
return UpdateSchemaPayload(
|
||||
schema=Schema(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
from .schema import UpdateSchemaPayload
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Failed to update schema"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from .schema import UpdateSchemaPayload
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_delete_schema(self, info, filename):
|
||||
"""Delete a JSON schema from the database."""
|
||||
try:
|
||||
# Delete using the database manager
|
||||
success = self.resolver.db_manager.delete_schema_file(filename)
|
||||
|
||||
from .schema import DeleteSchemaPayload
|
||||
if success:
|
||||
return DeleteSchemaPayload(
|
||||
success=True,
|
||||
deleted_filename=filename,
|
||||
errors=[]
|
||||
)
|
||||
else:
|
||||
return DeleteSchemaPayload(
|
||||
success=False,
|
||||
deleted_filename=None,
|
||||
errors=[f"Failed to delete schema: {filename}"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
from .schema import DeleteSchemaPayload
|
||||
return DeleteSchemaPayload(
|
||||
success=False,
|
||||
deleted_filename=None,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
def get_default_database_path():
|
||||
"""Get default database path for GraphQL resolvers."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Use the same logic as CLI
|
||||
if 'MARKITECT_DB' in os.environ:
|
||||
return os.environ['MARKITECT_DB']
|
||||
|
||||
config_dir = Path.home() / '.markitect'
|
||||
config_dir.mkdir(exist_ok=True)
|
||||
return str(config_dir / 'markitect.db')
|
||||
612
markitect/graphql/schema.py
Normal file
612
markitect/graphql/schema.py
Normal file
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
GraphQL schema definition for MarkiTect data.
|
||||
|
||||
Defines the complete GraphQL schema for querying Markdown files,
|
||||
ASTs, schemas, and related metadata.
|
||||
"""
|
||||
|
||||
import graphene
|
||||
from graphene import ObjectType, String, Int, DateTime, List, Field, JSONString
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class FrontMatter(ObjectType):
|
||||
"""GraphQL type for front matter data."""
|
||||
key = String(required=True, description="Front matter key")
|
||||
value = JSONString(description="Front matter value (can be any JSON type)")
|
||||
|
||||
|
||||
class MarkdownFile(ObjectType):
|
||||
"""GraphQL type for markdown files stored in MarkiTect."""
|
||||
id = Int(required=True, description="Unique identifier")
|
||||
filename = String(required=True, description="File path/name")
|
||||
content = String(description="Markdown content")
|
||||
front_matter = List(FrontMatter, description="Parsed front matter data")
|
||||
front_matter_raw = JSONString(description="Raw front matter as JSON")
|
||||
created_at = DateTime(description="Creation timestamp")
|
||||
|
||||
# Computed fields
|
||||
word_count = Int(description="Number of words in content")
|
||||
line_count = Int(description="Number of lines in content")
|
||||
has_front_matter = graphene.Boolean(description="Whether file has front matter")
|
||||
|
||||
def resolve_front_matter(self, info):
|
||||
"""Resolve front matter as key-value pairs."""
|
||||
if self.front_matter_raw:
|
||||
return [
|
||||
FrontMatter(key=k, value=v)
|
||||
for k, v in self.front_matter_raw.items()
|
||||
]
|
||||
return []
|
||||
|
||||
def resolve_word_count(self, info):
|
||||
"""Calculate word count."""
|
||||
if self.content:
|
||||
return len(self.content.split())
|
||||
return 0
|
||||
|
||||
def resolve_line_count(self, info):
|
||||
"""Calculate line count."""
|
||||
if self.content:
|
||||
return len(self.content.splitlines())
|
||||
return 0
|
||||
|
||||
def resolve_has_front_matter(self, info):
|
||||
"""Check if file has front matter."""
|
||||
return bool(self.front_matter_raw)
|
||||
|
||||
|
||||
class Schema(ObjectType):
|
||||
"""GraphQL type for JSON schemas."""
|
||||
id = Int(required=True, description="Unique identifier")
|
||||
filename = String(required=True, description="Schema filename")
|
||||
title = String(description="Schema title")
|
||||
description = String(description="Schema description")
|
||||
schema_content = JSONString(required=True, description="JSON schema content")
|
||||
created_at = DateTime(description="Creation timestamp")
|
||||
updated_at = DateTime(description="Last update timestamp")
|
||||
|
||||
# Computed fields
|
||||
schema_version = String(description="JSON Schema version")
|
||||
property_count = Int(description="Number of properties in schema")
|
||||
|
||||
def resolve_schema_version(self, info):
|
||||
"""Extract schema version."""
|
||||
if self.schema_content and isinstance(self.schema_content, dict):
|
||||
return self.schema_content.get('$schema', 'Unknown')
|
||||
return 'Unknown'
|
||||
|
||||
def resolve_property_count(self, info):
|
||||
"""Count properties in schema."""
|
||||
if (self.schema_content and
|
||||
isinstance(self.schema_content, dict) and
|
||||
'properties' in self.schema_content):
|
||||
return len(self.schema_content['properties'])
|
||||
return 0
|
||||
|
||||
|
||||
class ASTNode(ObjectType):
|
||||
"""GraphQL type for AST nodes."""
|
||||
type = String(required=True, description="Node type")
|
||||
value = String(description="Node value/content")
|
||||
level = Int(description="Heading level (for heading nodes)")
|
||||
children = List(lambda: ASTNode, description="Child nodes")
|
||||
attrs = JSONString(description="Node attributes")
|
||||
|
||||
|
||||
class AST(ObjectType):
|
||||
"""GraphQL type for parsed AST."""
|
||||
file_id = Int(description="Associated file ID")
|
||||
filename = String(required=True, description="Source filename")
|
||||
tree = List(ASTNode, description="AST tree structure")
|
||||
metadata = JSONString(description="AST metadata")
|
||||
|
||||
# Statistics
|
||||
heading_count = Int(description="Number of headings")
|
||||
link_count = Int(description="Number of links")
|
||||
image_count = Int(description="Number of images")
|
||||
code_block_count = Int(description="Number of code blocks")
|
||||
|
||||
|
||||
class DatabaseStats(ObjectType):
|
||||
"""Database statistics."""
|
||||
total_files = Int(description="Total number of markdown files")
|
||||
total_schemas = Int(description="Total number of schemas")
|
||||
total_size_bytes = Int(description="Total database size in bytes")
|
||||
last_updated = DateTime(description="Last database update")
|
||||
|
||||
|
||||
class SearchResult(ObjectType):
|
||||
"""Search result union type."""
|
||||
type = String(required=True, description="Result type (file, schema)")
|
||||
score = graphene.Float(description="Search relevance score")
|
||||
file = Field(MarkdownFile, description="Matched file (if type=file)")
|
||||
schema = Field(Schema, description="Matched schema (if type=schema)")
|
||||
highlight = String(description="Highlighted match text")
|
||||
|
||||
|
||||
class Query(ObjectType):
|
||||
"""Root GraphQL query type."""
|
||||
|
||||
# Single item queries
|
||||
markdown_file = Field(
|
||||
MarkdownFile,
|
||||
id=Int(description="File ID"),
|
||||
filename=String(description="File path"),
|
||||
description="Get a specific markdown file"
|
||||
)
|
||||
|
||||
schema = Field(
|
||||
Schema,
|
||||
id=Int(description="Schema ID"),
|
||||
filename=String(description="Schema filename"),
|
||||
description="Get a specific schema"
|
||||
)
|
||||
|
||||
ast = Field(
|
||||
AST,
|
||||
file_id=Int(description="File ID"),
|
||||
filename=String(description="File path"),
|
||||
description="Get AST for a specific file"
|
||||
)
|
||||
|
||||
# List queries
|
||||
markdown_files = List(
|
||||
MarkdownFile,
|
||||
limit=Int(default_value=50, description="Maximum number of results"),
|
||||
offset=Int(default_value=0, description="Offset for pagination"),
|
||||
has_front_matter=graphene.Boolean(description="Filter by front matter presence"),
|
||||
created_after=DateTime(description="Filter by creation date"),
|
||||
description="List markdown files with optional filtering"
|
||||
)
|
||||
|
||||
schemas = List(
|
||||
Schema,
|
||||
limit=Int(default_value=50, description="Maximum number of results"),
|
||||
offset=Int(default_value=0, description="Offset for pagination"),
|
||||
description="List all schemas"
|
||||
)
|
||||
|
||||
# Search
|
||||
search = List(
|
||||
SearchResult,
|
||||
query=String(required=True, description="Search query"),
|
||||
type=String(description="Search type filter (file, schema, all)"),
|
||||
limit=Int(default_value=20, description="Maximum number of results"),
|
||||
description="Search across files and schemas"
|
||||
)
|
||||
|
||||
# Statistics
|
||||
database_stats = Field(
|
||||
DatabaseStats,
|
||||
description="Get database statistics"
|
||||
)
|
||||
|
||||
# JSONPath queries for ASTs
|
||||
ast_query = List(
|
||||
JSONString,
|
||||
file_id=Int(),
|
||||
filename=String(),
|
||||
jsonpath=String(required=True, description="JSONPath expression"),
|
||||
description="Query AST using JSONPath expressions"
|
||||
)
|
||||
|
||||
|
||||
# Mutation Types for Write Operations
|
||||
class AddMarkdownFilePayload(ObjectType):
|
||||
"""Payload for addMarkdownFile mutation."""
|
||||
markdown_file = Field(MarkdownFile, description="The created markdown file")
|
||||
success = graphene.Boolean(description="Whether the operation was successful")
|
||||
errors = List(String, description="List of validation errors")
|
||||
|
||||
|
||||
class UpdateMarkdownFilePayload(ObjectType):
|
||||
"""Payload for updateMarkdownFile mutation."""
|
||||
markdown_file = Field(MarkdownFile, description="The updated markdown file")
|
||||
success = graphene.Boolean(description="Whether the operation was successful")
|
||||
errors = List(String, description="List of validation errors")
|
||||
|
||||
|
||||
class AddSchemaPayload(ObjectType):
|
||||
"""Payload for addSchema mutation."""
|
||||
schema = Field(Schema, description="The created schema")
|
||||
success = graphene.Boolean(description="Whether the operation was successful")
|
||||
errors = List(String, description="List of validation errors")
|
||||
|
||||
|
||||
class UpdateSchemaPayload(ObjectType):
|
||||
"""Payload for updateSchema mutation."""
|
||||
schema = Field(Schema, description="The updated schema")
|
||||
success = graphene.Boolean(description="Whether the operation was successful")
|
||||
errors = List(String, description="List of validation errors")
|
||||
|
||||
|
||||
class DeleteSchemaPayload(ObjectType):
|
||||
"""Payload for deleteSchema mutation."""
|
||||
success = graphene.Boolean(description="Whether the operation was successful")
|
||||
deleted_filename = String(description="The filename of the deleted schema")
|
||||
errors = List(String, description="List of validation errors")
|
||||
|
||||
|
||||
class Mutation(ObjectType):
|
||||
"""Root GraphQL mutation type for write operations."""
|
||||
|
||||
# Markdown file mutations
|
||||
add_markdown_file = Field(
|
||||
AddMarkdownFilePayload,
|
||||
filename=String(required=True, description="Filename for the markdown file"),
|
||||
content=String(required=True, description="Markdown content including front matter"),
|
||||
description="Add a new markdown file to the database"
|
||||
)
|
||||
|
||||
update_markdown_file = Field(
|
||||
UpdateMarkdownFilePayload,
|
||||
id=Int(required=True, description="ID of the markdown file to update"),
|
||||
content=String(description="New markdown content"),
|
||||
description="Update an existing markdown file"
|
||||
)
|
||||
|
||||
# Schema mutations
|
||||
add_schema = Field(
|
||||
AddSchemaPayload,
|
||||
filename=String(required=True, description="Filename for the schema"),
|
||||
schema_content=JSONString(required=True, description="JSON schema content"),
|
||||
description="Add a new JSON schema to the database"
|
||||
)
|
||||
|
||||
update_schema = Field(
|
||||
UpdateSchemaPayload,
|
||||
id=Int(required=True, description="ID of the schema to update"),
|
||||
schema_content=JSONString(description="New JSON schema content"),
|
||||
description="Update an existing JSON schema"
|
||||
)
|
||||
|
||||
delete_schema = Field(
|
||||
DeleteSchemaPayload,
|
||||
filename=String(required=True, description="Filename of the schema to delete"),
|
||||
description="Delete a JSON schema from the database"
|
||||
)
|
||||
|
||||
def resolve_add_markdown_file(self, info, filename, content):
|
||||
"""Add a new markdown file to the database."""
|
||||
import json
|
||||
from ..database import DatabaseManager
|
||||
from .resolvers import get_default_database_path
|
||||
|
||||
try:
|
||||
# Get database manager
|
||||
db_manager = DatabaseManager(get_default_database_path())
|
||||
|
||||
# Store the file using the database manager
|
||||
file_id = db_manager.store_markdown_file(filename, content)
|
||||
|
||||
if file_id:
|
||||
# Retrieve the created file
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(get_default_database_path())
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM markdown_files WHERE id = ?", (file_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
data = dict(zip([col[0] for col in cursor.description], row))
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
# Parse datetime strings to datetime objects
|
||||
from datetime import datetime
|
||||
if data.get('created_at'):
|
||||
try:
|
||||
data['created_at'] = datetime.fromisoformat(data['created_at'])
|
||||
except (ValueError, TypeError):
|
||||
data['created_at'] = None
|
||||
|
||||
conn.close()
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=MarkdownFile(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
conn.close()
|
||||
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Failed to store markdown file"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return AddMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_update_markdown_file(self, info, id, content=None):
|
||||
"""Update an existing markdown file."""
|
||||
import json
|
||||
import sqlite3
|
||||
from ..database import DatabaseManager
|
||||
from .resolvers import get_default_database_path
|
||||
|
||||
try:
|
||||
if not content:
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Content is required for update"]
|
||||
)
|
||||
|
||||
db_path = get_default_database_path()
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if file exists and get filename
|
||||
cursor.execute("SELECT filename FROM markdown_files WHERE id = ?", (id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[f"Markdown file with ID {id} not found"]
|
||||
)
|
||||
|
||||
filename = row[0]
|
||||
|
||||
# Parse front matter and content for update
|
||||
from ..frontmatter import FrontMatterParser
|
||||
front_matter_parser = FrontMatterParser()
|
||||
front_matter, markdown_content = front_matter_parser.parse(content)
|
||||
front_matter_json = json.dumps(front_matter) if front_matter else '{}'
|
||||
|
||||
# Update the file directly with SQL
|
||||
cursor.execute('''
|
||||
UPDATE markdown_files
|
||||
SET content = ?, front_matter = ?
|
||||
WHERE id = ?
|
||||
''', (markdown_content, front_matter_json, id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Retrieve the updated file
|
||||
cursor.execute("SELECT * FROM markdown_files WHERE id = ?", (id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
data = dict(zip([col[0] for col in cursor.description], row))
|
||||
# Parse front matter JSON
|
||||
if data['front_matter']:
|
||||
try:
|
||||
data['front_matter_raw'] = json.loads(data['front_matter'])
|
||||
except json.JSONDecodeError:
|
||||
data['front_matter_raw'] = {}
|
||||
else:
|
||||
data['front_matter_raw'] = {}
|
||||
|
||||
# Parse datetime strings to datetime objects
|
||||
from datetime import datetime
|
||||
if data.get('created_at'):
|
||||
try:
|
||||
data['created_at'] = datetime.fromisoformat(data['created_at'])
|
||||
except (ValueError, TypeError):
|
||||
data['created_at'] = None
|
||||
|
||||
conn.close()
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=MarkdownFile(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=["Failed to update markdown file"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return UpdateMarkdownFilePayload(
|
||||
markdown_file=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_add_schema(self, info, filename, schema_content):
|
||||
"""Add a new JSON schema to the database."""
|
||||
import json
|
||||
import sqlite3
|
||||
from ..database import DatabaseManager
|
||||
from .resolvers import get_default_database_path
|
||||
|
||||
try:
|
||||
# Get database manager
|
||||
db_manager = DatabaseManager(get_default_database_path())
|
||||
|
||||
# Convert schema_content to JSON string if it's a dict
|
||||
if isinstance(schema_content, dict):
|
||||
schema_content_str = json.dumps(schema_content)
|
||||
else:
|
||||
schema_content_str = schema_content
|
||||
|
||||
# Store the schema using the database manager
|
||||
schema_id = db_manager.store_schema_file(filename, schema_content_str)
|
||||
|
||||
if schema_id:
|
||||
# Retrieve the created schema
|
||||
conn = sqlite3.connect(get_default_database_path())
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM schemas WHERE id = ?", (schema_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
data = dict(zip([col[0] for col in cursor.description], row))
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
# Parse datetime strings to datetime objects
|
||||
from datetime import datetime
|
||||
for dt_field in ['created_at', 'updated_at']:
|
||||
if data.get(dt_field):
|
||||
try:
|
||||
data[dt_field] = datetime.fromisoformat(data[dt_field])
|
||||
except (ValueError, TypeError):
|
||||
data[dt_field] = None
|
||||
|
||||
conn.close()
|
||||
return AddSchemaPayload(
|
||||
schema=Schema(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
conn.close()
|
||||
|
||||
return AddSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Failed to store schema"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return AddSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_update_schema(self, info, id, schema_content=None):
|
||||
"""Update an existing JSON schema."""
|
||||
import json
|
||||
import sqlite3
|
||||
from ..database import DatabaseManager
|
||||
from .resolvers import get_default_database_path
|
||||
|
||||
try:
|
||||
if not schema_content:
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Schema content is required for update"]
|
||||
)
|
||||
|
||||
db_path = get_default_database_path()
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if schema exists
|
||||
cursor.execute("SELECT filename FROM schemas WHERE id = ?", (id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[f"Schema with ID {id} not found"]
|
||||
)
|
||||
|
||||
filename = row[0]
|
||||
conn.close()
|
||||
|
||||
# Convert schema_content to JSON string if it's a dict
|
||||
if isinstance(schema_content, dict):
|
||||
schema_content_str = json.dumps(schema_content)
|
||||
else:
|
||||
schema_content_str = schema_content
|
||||
|
||||
# Update using store_schema_file
|
||||
db_manager = DatabaseManager(db_path)
|
||||
schema_id = db_manager.store_schema_file(filename, schema_content_str)
|
||||
|
||||
if schema_id:
|
||||
# Retrieve the updated schema
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM schemas WHERE filename = ?", (filename,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
data = dict(zip([col[0] for col in cursor.description], row))
|
||||
# Parse schema content JSON
|
||||
if data['schema_content']:
|
||||
try:
|
||||
data['schema_content'] = json.loads(data['schema_content'])
|
||||
except json.JSONDecodeError:
|
||||
data['schema_content'] = {}
|
||||
|
||||
# Parse datetime strings to datetime objects
|
||||
from datetime import datetime
|
||||
for dt_field in ['created_at', 'updated_at']:
|
||||
if data.get(dt_field):
|
||||
try:
|
||||
data[dt_field] = datetime.fromisoformat(data[dt_field])
|
||||
except (ValueError, TypeError):
|
||||
data[dt_field] = None
|
||||
|
||||
conn.close()
|
||||
return UpdateSchemaPayload(
|
||||
schema=Schema(**data),
|
||||
success=True,
|
||||
errors=[]
|
||||
)
|
||||
conn.close()
|
||||
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=["Failed to update schema"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return UpdateSchemaPayload(
|
||||
schema=None,
|
||||
success=False,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
def resolve_delete_schema(self, info, filename):
|
||||
"""Delete a JSON schema from the database."""
|
||||
from ..database import DatabaseManager
|
||||
from .resolvers import get_default_database_path
|
||||
|
||||
try:
|
||||
# Get database manager
|
||||
db_manager = DatabaseManager(get_default_database_path())
|
||||
|
||||
# Delete using the database manager
|
||||
success = db_manager.delete_schema_file(filename)
|
||||
|
||||
if success:
|
||||
return DeleteSchemaPayload(
|
||||
success=True,
|
||||
deleted_filename=filename,
|
||||
errors=[]
|
||||
)
|
||||
else:
|
||||
return DeleteSchemaPayload(
|
||||
success=False,
|
||||
deleted_filename=None,
|
||||
errors=[f"Failed to delete schema: {filename}"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DeleteSchemaPayload(
|
||||
success=False,
|
||||
deleted_filename=None,
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
|
||||
# Create the schema with both Query and Mutation
|
||||
schema = graphene.Schema(query=Query, mutation=Mutation)
|
||||
255
markitect/graphql/server.py
Normal file
255
markitect/graphql/server.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
GraphQL server implementation for MarkiTect.
|
||||
|
||||
Provides a standalone GraphQL server and integration components
|
||||
for serving the MarkiTect GraphQL API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
FLASK_AVAILABLE = True
|
||||
except ImportError:
|
||||
FLASK_AVAILABLE = False
|
||||
|
||||
from .schema import schema
|
||||
from .resolvers import Query
|
||||
|
||||
|
||||
class GraphQLServer:
|
||||
"""GraphQL server for MarkiTect API."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, enable_cors: bool = True):
|
||||
"""
|
||||
Initialize GraphQL server.
|
||||
|
||||
Args:
|
||||
db_path: Path to MarkiTect database
|
||||
enable_cors: Enable CORS for web browser access
|
||||
"""
|
||||
self.db_path = db_path or self._get_default_db_path()
|
||||
self.enable_cors = enable_cors
|
||||
self.app = None
|
||||
|
||||
if not FLASK_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Flask is required for GraphQL server. Install with: pip install flask flask-cors"
|
||||
)
|
||||
|
||||
def _get_default_db_path(self) -> str:
|
||||
"""Get default database path."""
|
||||
from .resolvers import get_default_database_path
|
||||
return get_default_database_path()
|
||||
|
||||
def create_app(self) -> Flask:
|
||||
"""Create Flask application with GraphQL endpoint."""
|
||||
app = Flask(__name__)
|
||||
|
||||
if self.enable_cors:
|
||||
CORS(app)
|
||||
|
||||
@app.route('/graphql', methods=['POST'])
|
||||
def graphql_endpoint():
|
||||
"""Handle GraphQL requests."""
|
||||
try:
|
||||
# Parse request data
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No JSON data provided'}), 400
|
||||
|
||||
query = data.get('query')
|
||||
variables = data.get('variables', {})
|
||||
operation_name = data.get('operationName')
|
||||
|
||||
if not query:
|
||||
return jsonify({'error': 'No query provided'}), 400
|
||||
|
||||
# Execute GraphQL query
|
||||
result = schema.execute(
|
||||
query,
|
||||
variables=variables,
|
||||
operation_name=operation_name,
|
||||
context={'db_path': self.db_path}
|
||||
)
|
||||
|
||||
# Format response
|
||||
response_data = {'data': result.data}
|
||||
if result.errors:
|
||||
response_data['errors'] = [
|
||||
{'message': str(error)} for error in result.errors
|
||||
]
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'errors': [{'message': f'Server error: {str(e)}'}]
|
||||
}), 500
|
||||
|
||||
@app.route('/graphql', methods=['GET'])
|
||||
def graphql_playground():
|
||||
"""Serve GraphQL playground for development."""
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MarkiTect GraphQL Playground</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<style>
|
||||
body { margin: 0; font-family: Open Sans, sans-serif; overflow: hidden; }
|
||||
#root { height: 100vh; }
|
||||
</style>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
|
||||
<script>
|
||||
window.addEventListener('load', function (event) {
|
||||
GraphQLPlayground.init(document.getElementById('root'), {
|
||||
endpoint: '/graphql',
|
||||
settings: {
|
||||
'general.betaUpdates': false,
|
||||
'editor.theme': 'dark',
|
||||
'editor.reuseHeaders': true,
|
||||
'tracing.hideTracingResponse': true,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
@app.route('/schema', methods=['GET'])
|
||||
def get_schema():
|
||||
"""Get GraphQL schema definition."""
|
||||
try:
|
||||
from graphql.utilities import print_schema
|
||||
schema_sdl = print_schema(schema.graphql_schema)
|
||||
except (AttributeError, ImportError):
|
||||
# Fallback to simple introspection
|
||||
schema_sdl = str(schema)
|
||||
|
||||
return jsonify({
|
||||
'schema': schema_sdl
|
||||
})
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
try:
|
||||
# Test database connection
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'connected',
|
||||
'database_path': self.db_path
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'unhealthy',
|
||||
'database': 'error',
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
def run(self, host: str = '127.0.0.1', port: int = 5000, debug: bool = False):
|
||||
"""
|
||||
Run the GraphQL server.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to bind to
|
||||
debug: Enable debug mode
|
||||
"""
|
||||
if not self.app:
|
||||
self.create_app()
|
||||
|
||||
print(f"🚀 MarkiTect GraphQL Server starting...")
|
||||
print(f"🔗 GraphQL endpoint: http://{host}:{port}/graphql")
|
||||
print(f"🎮 GraphQL playground: http://{host}:{port}/graphql")
|
||||
print(f"📊 Schema introspection: http://{host}:{port}/schema")
|
||||
print(f"❤️ Health check: http://{host}:{port}/health")
|
||||
|
||||
self.app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
class GraphQLClient:
|
||||
"""Simple GraphQL client for testing and CLI integration."""
|
||||
|
||||
def __init__(self, endpoint: str = "http://localhost:5000/graphql"):
|
||||
"""
|
||||
Initialize GraphQL client.
|
||||
|
||||
Args:
|
||||
endpoint: GraphQL endpoint URL
|
||||
"""
|
||||
self.endpoint = endpoint
|
||||
|
||||
def execute(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute GraphQL query.
|
||||
|
||||
Args:
|
||||
query: GraphQL query string
|
||||
variables: Query variables
|
||||
|
||||
Returns:
|
||||
Query result dictionary
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
payload = {
|
||||
'query': query,
|
||||
'variables': variables or {}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
except ImportError:
|
||||
raise ImportError("requests is required for GraphQL client. Install with: pip install requests")
|
||||
|
||||
def execute_local(self, query: str, variables: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute GraphQL query directly (without HTTP).
|
||||
|
||||
Args:
|
||||
query: GraphQL query string
|
||||
variables: Query variables
|
||||
context: GraphQL context
|
||||
|
||||
Returns:
|
||||
Query result dictionary
|
||||
"""
|
||||
result = schema.execute(
|
||||
query,
|
||||
variables=variables or {},
|
||||
context=context or {}
|
||||
)
|
||||
|
||||
response_data = {'data': result.data}
|
||||
if result.errors:
|
||||
response_data['errors'] = [
|
||||
{'message': str(error)} for error in result.errors
|
||||
]
|
||||
|
||||
return response_data
|
||||
797
tests/test_issue_10_graphql_mutations.py
Normal file
797
tests/test_issue_10_graphql_mutations.py
Normal file
@@ -0,0 +1,797 @@
|
||||
"""
|
||||
Comprehensive tests for GraphQL mutations (Issue #10).
|
||||
|
||||
Tests all aspects of the GraphQL write interface including:
|
||||
- Mutation schema validation
|
||||
- Markdown file CRUD operations
|
||||
- Schema CRUD operations
|
||||
- Error handling
|
||||
- CLI integration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from markitect.graphql.schema import schema
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_db_path():
|
||||
"""Create temporary database with some test data."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add sample data
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Sample markdown file
|
||||
cursor.execute("""
|
||||
INSERT INTO markdown_files (filename, content, front_matter, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
'existing.md',
|
||||
'# Existing Document\n\nThis document already exists.',
|
||||
'{"title": "Existing Document"}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
# Sample schema
|
||||
cursor.execute("""
|
||||
INSERT INTO schemas (filename, title, description, schema_content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
'existing-schema.json',
|
||||
'Existing Schema',
|
||||
'A schema that already exists',
|
||||
'{"type": "object", "properties": {"name": {"type": "string"}}}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
class TestGraphQLMutationSchema:
|
||||
"""Test GraphQL mutation schema definition and validation."""
|
||||
|
||||
def test_schema_has_mutations(self):
|
||||
"""Test that the GraphQL schema has mutations."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
mutationType {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
assert result.errors is None
|
||||
mutation_type = result.data['__schema']['mutationType']
|
||||
assert mutation_type is not None
|
||||
assert mutation_type['name'] == 'Mutation'
|
||||
|
||||
field_names = [field['name'] for field in mutation_type['fields']]
|
||||
assert 'addMarkdownFile' in field_names
|
||||
assert 'updateMarkdownFile' in field_names
|
||||
assert 'addSchema' in field_names
|
||||
assert 'updateSchema' in field_names
|
||||
assert 'deleteSchema' in field_names
|
||||
|
||||
def test_add_markdown_file_mutation_signature(self):
|
||||
"""Test addMarkdownFile mutation has correct signature."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
mutationType {
|
||||
fields {
|
||||
name
|
||||
args {
|
||||
name
|
||||
type {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
mutation_fields = result.data['__schema']['mutationType']['fields']
|
||||
add_file_field = next(f for f in mutation_fields if f['name'] == 'addMarkdownFile')
|
||||
|
||||
arg_names = [arg['name'] for arg in add_file_field['args']]
|
||||
assert 'filename' in arg_names
|
||||
assert 'content' in arg_names
|
||||
|
||||
def test_mutation_payload_types(self):
|
||||
"""Test that mutation payload types have correct structure."""
|
||||
result = schema.execute('''
|
||||
{
|
||||
__schema {
|
||||
types {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
type {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
types = {t['name']: t for t in result.data['__schema']['types']}
|
||||
|
||||
# Check AddMarkdownFilePayload
|
||||
payload = types.get('AddMarkdownFilePayload')
|
||||
assert payload is not None
|
||||
field_names = [f['name'] for f in payload['fields']]
|
||||
assert 'markdownFile' in field_names
|
||||
assert 'success' in field_names
|
||||
assert 'errors' in field_names
|
||||
|
||||
|
||||
class TestMarkdownFileMutations:
|
||||
"""Test markdown file CRUD mutations."""
|
||||
|
||||
def test_add_markdown_file_success(self, temp_db_path):
|
||||
"""Test successful markdown file creation."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "new-file.md"
|
||||
content: "# New File\\n\\nThis is new content."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert data['markdownFile'] is not None
|
||||
assert data['markdownFile']['filename'] == 'new-file.md'
|
||||
assert 'New File' in data['markdownFile']['content']
|
||||
assert data['markdownFile']['wordCount'] > 0
|
||||
|
||||
def test_add_markdown_file_with_front_matter(self, temp_db_path):
|
||||
"""Test markdown file creation with front matter."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
content_with_frontmatter = '''---
|
||||
title: "Test Document"
|
||||
author: "Test Author"
|
||||
tags: ["test", "markdown"]
|
||||
---
|
||||
|
||||
# Test Document
|
||||
|
||||
This is a test document with front matter.
|
||||
'''
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "with-frontmatter.md"
|
||||
content: "%s"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
hasFrontMatter
|
||||
frontMatter {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % content_with_frontmatter.replace('\n', '\\n').replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert data['markdownFile']['hasFrontMatter'] is True
|
||||
front_matter_keys = [fm['key'] for fm in data['markdownFile']['frontMatter']]
|
||||
assert 'title' in front_matter_keys
|
||||
assert 'author' in front_matter_keys
|
||||
|
||||
def test_add_markdown_file_duplicate_filename(self, populated_db_path):
|
||||
"""Test adding a file with duplicate filename (should succeed as update)."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "existing.md"
|
||||
content: "# Updated Content\\n\\nThis content replaces the existing."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
filename
|
||||
content
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert 'Updated Content' in data['markdownFile']['content']
|
||||
|
||||
def test_update_markdown_file_success(self, populated_db_path):
|
||||
"""Test successful markdown file update."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: 1
|
||||
content: "# Updated Title\\n\\nThis content has been updated."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
content
|
||||
wordCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert 'Updated Title' in data['markdownFile']['content']
|
||||
|
||||
def test_update_markdown_file_not_found(self, temp_db_path):
|
||||
"""Test updating non-existent markdown file."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: 999
|
||||
content: "# This should fail"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert data['markdownFile'] is None
|
||||
assert len(data['errors']) > 0
|
||||
assert 'not found' in data['errors'][0].lower()
|
||||
|
||||
def test_update_markdown_file_no_content(self, populated_db_path):
|
||||
"""Test updating markdown file without providing content."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(id: 1) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert 'required' in data['errors'][0].lower()
|
||||
|
||||
|
||||
class TestSchemaMutations:
|
||||
"""Test JSON schema CRUD mutations."""
|
||||
|
||||
def test_add_schema_success(self, temp_db_path):
|
||||
"""Test successful schema creation."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
schema_content = {
|
||||
"type": "object",
|
||||
"title": "User Schema",
|
||||
"description": "Schema for user objects",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "user-schema.json"
|
||||
schemaContent: "%s"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
filename
|
||||
title
|
||||
description
|
||||
propertyCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % json.dumps(schema_content).replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addSchema']
|
||||
assert data['success'] is True
|
||||
assert len(data['errors']) == 0
|
||||
assert data['schema']['filename'] == 'user-schema.json'
|
||||
assert data['schema']['title'] == 'User Schema'
|
||||
assert data['schema']['propertyCount'] == 2
|
||||
|
||||
def test_add_schema_invalid_json(self, temp_db_path):
|
||||
"""Test adding schema with invalid JSON."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "invalid-schema.json"
|
||||
schemaContent: "{ invalid json }"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# GraphQL should reject invalid JSON at the schema validation level
|
||||
assert result.errors is not None
|
||||
assert len(result.errors) > 0
|
||||
assert "Badly formed JSONString" in str(result.errors[0])
|
||||
|
||||
def test_update_schema_success(self, populated_db_path):
|
||||
"""Test successful schema update."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
new_schema = {
|
||||
"type": "object",
|
||||
"title": "Updated Schema",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"}
|
||||
}
|
||||
}
|
||||
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: 1
|
||||
schemaContent: "%s"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
title
|
||||
propertyCount
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
''' % json.dumps(new_schema).replace('"', '\\"')
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateSchema']
|
||||
assert data['success'] is True
|
||||
assert data['schema']['title'] == 'Updated Schema'
|
||||
assert data['schema']['propertyCount'] == 2
|
||||
|
||||
def test_update_schema_not_found(self, temp_db_path):
|
||||
"""Test updating non-existent schema."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: 999
|
||||
schemaContent: "{\\"type\\": \\"object\\"}"
|
||||
) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['updateSchema']
|
||||
assert data['success'] is False
|
||||
assert 'not found' in data['errors'][0].lower()
|
||||
|
||||
def test_delete_schema_success(self, populated_db_path):
|
||||
"""Test successful schema deletion."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=populated_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "existing-schema.json") {
|
||||
success
|
||||
deletedFilename
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['deleteSchema']
|
||||
assert data['success'] is True
|
||||
assert data['deletedFilename'] == 'existing-schema.json'
|
||||
assert len(data['errors']) == 0
|
||||
|
||||
def test_delete_schema_not_found(self, temp_db_path):
|
||||
"""Test deleting non-existent schema."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "nonexistent.json") {
|
||||
success
|
||||
deletedFilename
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['deleteSchema']
|
||||
assert data['success'] is False
|
||||
assert data['deletedFilename'] is None
|
||||
|
||||
|
||||
class TestMutationErrorHandling:
|
||||
"""Test error handling in mutations."""
|
||||
|
||||
def test_database_error_handling(self, temp_db_path):
|
||||
"""Test mutation behavior when database is unavailable."""
|
||||
# Use a non-existent database path
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "test.md"
|
||||
content: "# Test"
|
||||
) {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
data = result.data['addMarkdownFile']
|
||||
assert data['success'] is False
|
||||
assert len(data['errors']) > 0
|
||||
|
||||
def test_invalid_mutation_syntax(self):
|
||||
"""Test handling of invalid mutation syntax."""
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(filename: "test.md") {
|
||||
success
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# Should have errors due to missing required 'content' argument
|
||||
assert result.errors is not None
|
||||
|
||||
def test_missing_required_arguments(self):
|
||||
"""Test mutations with missing required arguments."""
|
||||
mutation = '''
|
||||
mutation {
|
||||
addSchema(filename: "test.json") {
|
||||
success
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
# Should have errors due to missing required 'schemaContent' argument
|
||||
assert result.errors is not None
|
||||
|
||||
|
||||
class TestMutationIntegration:
|
||||
"""Test full integration of mutations with database."""
|
||||
|
||||
def test_crud_workflow(self, temp_db_path):
|
||||
"""Test complete CRUD workflow for markdown files."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
# 1. Create a file
|
||||
create_mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "workflow-test.md"
|
||||
content: "# Original Content\\n\\nOriginal text."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(create_mutation)
|
||||
assert result.data['addMarkdownFile']['success'] is True
|
||||
file_id = result.data['addMarkdownFile']['markdownFile']['id']
|
||||
|
||||
# 2. Update the file
|
||||
update_mutation = '''
|
||||
mutation {
|
||||
updateMarkdownFile(
|
||||
id: %d
|
||||
content: "# Updated Content\\n\\nUpdated text."
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
''' % file_id
|
||||
|
||||
result = schema.execute(update_mutation)
|
||||
assert result.data['updateMarkdownFile']['success'] is True
|
||||
assert 'Updated Content' in result.data['updateMarkdownFile']['markdownFile']['content']
|
||||
|
||||
def test_schema_crud_workflow(self, temp_db_path):
|
||||
"""Test complete CRUD workflow for schemas."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
# 1. Create a schema
|
||||
create_mutation = '''
|
||||
mutation {
|
||||
addSchema(
|
||||
filename: "workflow-schema.json"
|
||||
schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Original\\"}"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(create_mutation)
|
||||
assert result.data['addSchema']['success'] is True
|
||||
schema_id = result.data['addSchema']['schema']['id']
|
||||
|
||||
# 2. Update the schema
|
||||
update_mutation = '''
|
||||
mutation {
|
||||
updateSchema(
|
||||
id: %d
|
||||
schemaContent: "{\\"type\\": \\"object\\", \\"title\\": \\"Updated\\"}"
|
||||
) {
|
||||
success
|
||||
schema {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
''' % schema_id
|
||||
|
||||
result = schema.execute(update_mutation)
|
||||
assert result.data['updateSchema']['success'] is True
|
||||
assert result.data['updateSchema']['schema']['title'] == 'Updated'
|
||||
|
||||
# 3. Delete the schema
|
||||
delete_mutation = '''
|
||||
mutation {
|
||||
deleteSchema(filename: "workflow-schema.json") {
|
||||
success
|
||||
deletedFilename
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(delete_mutation)
|
||||
assert result.data['deleteSchema']['success'] is True
|
||||
assert result.data['deleteSchema']['deletedFilename'] == 'workflow-schema.json'
|
||||
|
||||
|
||||
class TestMutationCLI:
|
||||
"""Test CLI integration for mutations."""
|
||||
|
||||
def test_graphql_mutate_command_available(self):
|
||||
"""Test that graphql-mutate command is available."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-mutate", "--help"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Execute GraphQL mutations" in result.stdout
|
||||
assert "--local" in result.stdout
|
||||
assert "--variables" in result.stdout
|
||||
|
||||
def test_mutation_examples_in_help(self):
|
||||
"""Test that mutation examples are included in help."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-examples"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Mutation Examples" in result.stdout
|
||||
assert "addMarkdownFile" in result.stdout
|
||||
assert "updateMarkdownFile" in result.stdout
|
||||
assert "addSchema" in result.stdout
|
||||
assert "deleteSchema" in result.stdout
|
||||
|
||||
|
||||
class TestMutationPayloads:
|
||||
"""Test mutation payload structures."""
|
||||
|
||||
def test_add_markdown_file_payload_structure(self, temp_db_path):
|
||||
"""Test AddMarkdownFilePayload has correct structure."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "payload-test.md"
|
||||
content: "# Payload Test"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
lineCount
|
||||
hasFrontMatter
|
||||
createdAt
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
payload = result.data['addMarkdownFile']
|
||||
|
||||
# Check payload structure
|
||||
assert isinstance(payload['success'], bool)
|
||||
assert isinstance(payload['errors'], list)
|
||||
|
||||
if payload['success']:
|
||||
md_file = payload['markdownFile']
|
||||
assert md_file is not None
|
||||
assert isinstance(md_file['id'], int)
|
||||
assert isinstance(md_file['filename'], str)
|
||||
assert isinstance(md_file['wordCount'], int)
|
||||
assert isinstance(md_file['lineCount'], int)
|
||||
assert isinstance(md_file['hasFrontMatter'], bool)
|
||||
|
||||
def test_error_payload_structure(self, temp_db_path):
|
||||
"""Test error payloads have correct structure."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value='/nonexistent/path.db'):
|
||||
mutation = '''
|
||||
mutation {
|
||||
addMarkdownFile(
|
||||
filename: "error-test.md"
|
||||
content: "# Error Test"
|
||||
) {
|
||||
success
|
||||
markdownFile {
|
||||
id
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = schema.execute(mutation)
|
||||
|
||||
assert result.errors is None
|
||||
payload = result.data['addMarkdownFile']
|
||||
|
||||
assert payload['success'] is False
|
||||
assert payload['markdownFile'] is None
|
||||
assert isinstance(payload['errors'], list)
|
||||
assert len(payload['errors']) > 0
|
||||
assert all(isinstance(error, str) for error in payload['errors'])
|
||||
619
tests/test_issue_9_graphql_interface.py
Normal file
619
tests/test_issue_9_graphql_interface.py
Normal file
@@ -0,0 +1,619 @@
|
||||
"""
|
||||
Comprehensive tests for GraphQL interface (Issue #9).
|
||||
|
||||
Tests all aspects of the GraphQL read interface including:
|
||||
- Schema definition and validation
|
||||
- Resolver functionality
|
||||
- Server endpoints
|
||||
- CLI integration
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from markitect.graphql.schema import schema, MarkdownFile, Schema as SchemaType, AST, DatabaseStats
|
||||
from markitect.graphql.resolvers import Query, MarkiTectResolver, get_default_database_path
|
||||
from markitect.graphql.server import GraphQLServer, GraphQLClient
|
||||
from markitect.database import DatabaseManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create temporary database for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
# Initialize database with test data
|
||||
db_manager = DatabaseManager(db_path)
|
||||
db_manager.initialize_database()
|
||||
|
||||
# Add sample data
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Sample markdown file
|
||||
cursor.execute("""
|
||||
INSERT INTO markdown_files (filename, content, front_matter, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
'test.md',
|
||||
'# Test Document\n\nThis is a test document with [a link](http://example.com).',
|
||||
'{"title": "Test Document", "author": "Test Author"}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
# Sample schema
|
||||
cursor.execute("""
|
||||
INSERT INTO schemas (filename, title, description, schema_content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (
|
||||
'test-schema.json',
|
||||
'Test Schema',
|
||||
'A test schema for testing',
|
||||
'{"type": "object", "properties": {"name": {"type": "string"}}}',
|
||||
datetime.now().isoformat()
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
yield db_path
|
||||
|
||||
# Cleanup
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphql_resolver(temp_db_path):
|
||||
"""Create GraphQL resolver with test database."""
|
||||
return MarkiTectResolver(temp_db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphql_query(temp_db_path):
|
||||
"""Create GraphQL Query instance with test database."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
return Query()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app(temp_db_path):
|
||||
"""Create Flask app for testing GraphQL server."""
|
||||
server = GraphQLServer(db_path=temp_db_path, enable_cors=True)
|
||||
app = server.create_app()
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
|
||||
class TestGraphQLSchema:
|
||||
"""Test GraphQL schema definition and validation."""
|
||||
|
||||
def test_schema_is_valid(self):
|
||||
"""Test that the GraphQL schema is valid."""
|
||||
assert schema is not None
|
||||
assert hasattr(schema, 'execute')
|
||||
|
||||
def test_schema_has_required_types(self):
|
||||
"""Test that schema contains all required types."""
|
||||
schema_str = str(schema)
|
||||
|
||||
# Check for main types
|
||||
assert 'MarkdownFile' in schema_str
|
||||
assert 'Schema' in schema_str
|
||||
assert 'AST' in schema_str
|
||||
assert 'DatabaseStats' in schema_str
|
||||
assert 'SearchResult' in schema_str
|
||||
|
||||
def test_query_type_fields(self):
|
||||
"""Test that Query type has all required fields."""
|
||||
schema_str = str(schema)
|
||||
|
||||
# Check for query fields
|
||||
assert 'markdownFile' in schema_str
|
||||
assert 'markdownFiles' in schema_str
|
||||
assert 'schema' in schema_str
|
||||
assert 'schemas' in schema_str
|
||||
assert 'ast' in schema_str
|
||||
assert 'search' in schema_str
|
||||
assert 'databaseStats' in schema_str
|
||||
assert 'astQuery' in schema_str
|
||||
|
||||
|
||||
class TestGraphQLResolvers:
|
||||
"""Test GraphQL resolver functionality."""
|
||||
|
||||
def test_resolver_initialization(self, temp_db_path):
|
||||
"""Test resolver initializes correctly."""
|
||||
resolver = MarkiTectResolver(temp_db_path)
|
||||
|
||||
assert resolver.db_path == temp_db_path
|
||||
assert resolver.db_manager is not None
|
||||
assert resolver.ast_service is not None
|
||||
|
||||
def test_get_connection(self, graphql_resolver):
|
||||
"""Test database connection method."""
|
||||
conn = graphql_resolver.get_connection()
|
||||
|
||||
assert conn is not None
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
conn.close()
|
||||
|
||||
def test_row_to_dict(self, graphql_resolver):
|
||||
"""Test row to dictionary conversion."""
|
||||
conn = graphql_resolver.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1 as test_col")
|
||||
row = cursor.fetchone()
|
||||
|
||||
result = graphql_resolver.row_to_dict(cursor, row)
|
||||
assert result == {'test_col': 1}
|
||||
conn.close()
|
||||
|
||||
def test_resolve_markdown_file_by_id(self, graphql_query):
|
||||
"""Test resolving markdown file by ID."""
|
||||
result = graphql_query.resolve_markdown_file(None, id=1)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MarkdownFile)
|
||||
assert result.filename == 'test.md'
|
||||
assert 'Test Document' in result.content
|
||||
|
||||
def test_resolve_markdown_file_by_filename(self, graphql_query):
|
||||
"""Test resolving markdown file by filename."""
|
||||
result = graphql_query.resolve_markdown_file(None, filename='test.md')
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MarkdownFile)
|
||||
assert result.id == 1
|
||||
|
||||
def test_resolve_markdown_file_not_found(self, graphql_query):
|
||||
"""Test resolving non-existent markdown file."""
|
||||
result = graphql_query.resolve_markdown_file(None, id=999)
|
||||
assert result is None
|
||||
|
||||
result = graphql_query.resolve_markdown_file(None, filename='nonexistent.md')
|
||||
assert result is None
|
||||
|
||||
def test_resolve_schema_by_id(self, graphql_query):
|
||||
"""Test resolving schema by ID."""
|
||||
result = graphql_query.resolve_schema(None, id=1)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, SchemaType)
|
||||
assert result.title == 'Test Schema'
|
||||
|
||||
def test_resolve_markdown_files_list(self, graphql_query):
|
||||
"""Test resolving list of markdown files."""
|
||||
results = graphql_query.resolve_markdown_files(None, limit=10, offset=0)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(isinstance(f, MarkdownFile) for f in results)
|
||||
|
||||
def test_resolve_schemas_list(self, graphql_query):
|
||||
"""Test resolving list of schemas."""
|
||||
results = graphql_query.resolve_schemas(None, limit=10, offset=0)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(isinstance(s, SchemaType) for s in results)
|
||||
|
||||
def test_resolve_search_files(self, graphql_query):
|
||||
"""Test search functionality for files."""
|
||||
results = graphql_query.resolve_search(None, query="Test", type="file", limit=10)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) >= 1
|
||||
assert all(hasattr(r, 'type') and hasattr(r, 'score') for r in results)
|
||||
|
||||
def test_resolve_database_stats(self, graphql_query):
|
||||
"""Test database statistics resolver."""
|
||||
result = graphql_query.resolve_database_stats(None)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, DatabaseStats)
|
||||
assert result.total_files >= 1
|
||||
assert result.total_schemas >= 1
|
||||
assert result.total_size_bytes > 0
|
||||
|
||||
@patch('markitect.graphql.resolvers.Path.exists')
|
||||
def test_resolve_ast_file_not_found(self, mock_exists, graphql_query):
|
||||
"""Test AST resolution when file doesn't exist."""
|
||||
mock_exists.return_value = False
|
||||
|
||||
result = graphql_query.resolve_ast(None, filename='nonexistent.md')
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGraphQLServer:
|
||||
"""Test GraphQL server functionality."""
|
||||
|
||||
def test_server_initialization(self, temp_db_path):
|
||||
"""Test server initializes correctly."""
|
||||
server = GraphQLServer(db_path=temp_db_path, enable_cors=True)
|
||||
|
||||
assert server.db_path == temp_db_path
|
||||
assert server.enable_cors is True
|
||||
assert server.app is None
|
||||
|
||||
def test_server_initialization_without_flask(self):
|
||||
"""Test server initialization when Flask is not available."""
|
||||
with patch('markitect.graphql.server.FLASK_AVAILABLE', False):
|
||||
with pytest.raises(ImportError, match="Flask is required"):
|
||||
GraphQLServer()
|
||||
|
||||
def test_create_app(self, temp_db_path):
|
||||
"""Test Flask app creation."""
|
||||
server = GraphQLServer(db_path=temp_db_path)
|
||||
app = server.create_app()
|
||||
|
||||
assert app is not None
|
||||
assert server.app is app
|
||||
|
||||
def test_graphql_endpoint_post(self, flask_app):
|
||||
"""Test GraphQL POST endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
query = '{ databaseStats { totalFiles } }'
|
||||
response = client.post('/graphql',
|
||||
json={'query': query},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'data' in data
|
||||
assert 'databaseStats' in data['data']
|
||||
|
||||
def test_graphql_endpoint_invalid_json(self, flask_app):
|
||||
"""Test GraphQL endpoint with invalid JSON."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.post('/graphql',
|
||||
data='invalid json',
|
||||
content_type='application/json')
|
||||
|
||||
# Flask returns 500 for malformed JSON, which is reasonable
|
||||
assert response.status_code in [400, 500]
|
||||
|
||||
def test_graphql_endpoint_no_query(self, flask_app):
|
||||
"""Test GraphQL endpoint without query."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.post('/graphql',
|
||||
json={},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
def test_graphql_playground(self, flask_app):
|
||||
"""Test GraphQL playground endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/graphql')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'GraphQL Playground' in response.get_data(as_text=True)
|
||||
|
||||
def test_schema_endpoint(self, flask_app):
|
||||
"""Test schema introspection endpoint."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/schema')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'schema' in data
|
||||
|
||||
def test_health_check_healthy(self, flask_app):
|
||||
"""Test health check endpoint when healthy."""
|
||||
with flask_app.test_client() as client:
|
||||
response = client.get('/health')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'healthy'
|
||||
assert data['database'] == 'connected'
|
||||
|
||||
def test_health_check_unhealthy(self, temp_db_path):
|
||||
"""Test health check when database is unavailable."""
|
||||
# Use non-existent database path
|
||||
server = GraphQLServer(db_path='/nonexistent/path.db')
|
||||
app = server.create_app()
|
||||
|
||||
with app.test_client() as client:
|
||||
response = client.get('/health')
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'unhealthy'
|
||||
|
||||
|
||||
class TestGraphQLClient:
|
||||
"""Test GraphQL client functionality."""
|
||||
|
||||
def test_client_initialization(self):
|
||||
"""Test client initializes correctly."""
|
||||
client = GraphQLClient("http://localhost:5000/graphql")
|
||||
assert client.endpoint == "http://localhost:5000/graphql"
|
||||
|
||||
def test_client_default_endpoint(self):
|
||||
"""Test client uses default endpoint."""
|
||||
client = GraphQLClient()
|
||||
assert client.endpoint == "http://localhost:5000/graphql"
|
||||
|
||||
@patch('requests.post')
|
||||
def test_client_execute_query(self, mock_post):
|
||||
"""Test client query execution."""
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'data': {'databaseStats': {'totalFiles': 5}}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = GraphQLClient()
|
||||
result = client.execute('{ databaseStats { totalFiles } }')
|
||||
|
||||
assert result['data']['databaseStats']['totalFiles'] == 5
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_client_execute_local(self, temp_db_path):
|
||||
"""Test client local query execution."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
client = GraphQLClient()
|
||||
result = client.execute_local('{ databaseStats { totalFiles } }', context={'db_path': temp_db_path})
|
||||
|
||||
assert result is not None
|
||||
assert 'data' in result
|
||||
# The databaseStats resolver might return None if db is empty, so let's be more flexible
|
||||
if result['data']['databaseStats'] is not None:
|
||||
assert result['data']['databaseStats']['totalFiles'] >= 0
|
||||
|
||||
def test_client_execute_without_requests(self):
|
||||
"""Test client execution when requests is not available."""
|
||||
import builtins
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == 'requests':
|
||||
raise ImportError("No module named 'requests'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
client = GraphQLClient()
|
||||
|
||||
with pytest.raises(ImportError, match="requests is required"):
|
||||
client.execute('{ databaseStats { totalFiles } }')
|
||||
|
||||
|
||||
class TestGraphQLQueries:
|
||||
"""Test actual GraphQL query execution."""
|
||||
|
||||
def test_simple_database_stats_query(self, temp_db_path):
|
||||
"""Test simple database stats query."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
databaseStats {
|
||||
totalFiles
|
||||
totalSchemas
|
||||
totalSizeBytes
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
assert 'databaseStats' in result.data
|
||||
if result.data['databaseStats'] is not None:
|
||||
assert result.data['databaseStats']['totalFiles'] >= 1
|
||||
assert result.data['databaseStats']['totalSchemas'] >= 1
|
||||
|
||||
def test_markdown_file_query_with_computed_fields(self, temp_db_path):
|
||||
"""Test markdown file query with computed fields."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
markdownFile(id: 1) {
|
||||
id
|
||||
filename
|
||||
content
|
||||
wordCount
|
||||
lineCount
|
||||
hasFrontMatter
|
||||
frontMatter {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
data = result.data['markdownFile']
|
||||
if data is not None:
|
||||
assert data['id'] == 1
|
||||
assert data['filename'] == 'test.md'
|
||||
assert data['wordCount'] > 0
|
||||
assert data['lineCount'] > 0
|
||||
assert data['hasFrontMatter'] is True
|
||||
assert len(data['frontMatter']) > 0
|
||||
|
||||
def test_search_query(self, temp_db_path):
|
||||
"""Test search functionality."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
search(query: "Test", type: "all", limit: 10) {
|
||||
type
|
||||
score
|
||||
file {
|
||||
filename
|
||||
}
|
||||
schema {
|
||||
title
|
||||
}
|
||||
highlight
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
if result.data['search'] is not None:
|
||||
assert len(result.data['search']) >= 0
|
||||
|
||||
def test_pagination_query(self, temp_db_path):
|
||||
"""Test pagination in list queries."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = """
|
||||
{
|
||||
markdownFiles(limit: 1, offset: 0) {
|
||||
id
|
||||
filename
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
assert result.errors is None
|
||||
assert result.data is not None
|
||||
if result.data['markdownFiles'] is not None:
|
||||
assert len(result.data['markdownFiles']) <= 1
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestGraphQLCLIIntegration:
|
||||
"""Test GraphQL CLI command integration."""
|
||||
|
||||
def test_graphql_schema_command(self, isolated_environment):
|
||||
"""Test graphql-schema CLI command."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-schema", "--local"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "type Query" in result.stdout
|
||||
|
||||
def test_graphql_query_command(self, isolated_environment):
|
||||
"""Test graphql-query CLI command."""
|
||||
query = "{ databaseStats { totalFiles } }"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-query", query, "--local"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
# The database might be empty in test environment, so check for JSON structure
|
||||
assert "databaseStats" in result.stdout
|
||||
|
||||
def test_graphql_examples_command(self, isolated_environment):
|
||||
"""Test graphql-examples CLI command."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-examples"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "GraphQL Query Examples" in result.stdout
|
||||
assert "databaseStats" in result.stdout
|
||||
|
||||
@patch('markitect.graphql.server.GraphQLServer')
|
||||
def test_graphql_serve_command(self, mock_server_class, isolated_environment):
|
||||
"""Test graphql-serve CLI command."""
|
||||
mock_server = Mock()
|
||||
mock_server_class.return_value = mock_server
|
||||
|
||||
# We can't actually start the server in tests, so we just test command parsing
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "markitect.cli", "graphql-serve", "--help"],
|
||||
env=isolated_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=Path.cwd()
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Start GraphQL server" in result.stdout
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling in GraphQL interface."""
|
||||
|
||||
def test_invalid_query_syntax(self, temp_db_path):
|
||||
"""Test handling of invalid GraphQL syntax."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ invalidSyntax }"
|
||||
|
||||
result = schema.execute(query)
|
||||
|
||||
assert result.errors is not None
|
||||
assert len(result.errors) > 0
|
||||
|
||||
def test_nonexistent_field_query(self, temp_db_path):
|
||||
"""Test querying nonexistent fields."""
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ nonexistentField }"
|
||||
|
||||
result = schema.execute(query)
|
||||
|
||||
assert result.errors is not None
|
||||
|
||||
def test_resolver_database_error(self, temp_db_path):
|
||||
"""Test resolver behavior when database is corrupted."""
|
||||
# Corrupt the database file
|
||||
with open(temp_db_path, 'w') as f:
|
||||
f.write("corrupted data")
|
||||
|
||||
with patch('markitect.graphql.resolvers.get_default_database_path', return_value=temp_db_path):
|
||||
query = "{ databaseStats { totalFiles } }"
|
||||
|
||||
result = schema.execute(query, context={'db_path': temp_db_path})
|
||||
|
||||
# Should handle database errors gracefully - either with errors or None data
|
||||
assert result.errors is not None or result.data['databaseStats'] is None
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test utility functions in GraphQL module."""
|
||||
|
||||
def test_get_default_database_path_with_env(self):
|
||||
"""Test get_default_database_path with environment variable."""
|
||||
with patch.dict(os.environ, {'MARKITECT_DB': '/custom/path.db'}):
|
||||
path = get_default_database_path()
|
||||
assert path == '/custom/path.db'
|
||||
|
||||
def test_get_default_database_path_default(self):
|
||||
"""Test get_default_database_path with default location."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
path = get_default_database_path()
|
||||
assert path.endswith('markitect.db')
|
||||
assert '.markitect' in path
|
||||
Reference in New Issue
Block a user