feat: Complete Issue #14 - Database Query CLI Interface MAJOR MILESTONE

Implement comprehensive database query interface with multiple output formats:

• Add query command for executing read-only SQL queries with security constraints
• Add schema command for database structure inspection
• Add metadata command for file information display
• Support table, JSON, and YAML output formats across all commands
• Implement SQL injection prevention and safety checks
• Add tabulate dependency for enhanced table formatting
• Create 35 comprehensive tests covering all functionality

This delivers the core USP "Relational Document Metadata" by making the
database fully queryable through CLI commands with multiple output formats.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-25 03:30:10 +02:00
parent f866298948
commit 1840d0654d
5 changed files with 1179 additions and 2 deletions

View File

@@ -19,8 +19,10 @@ import click
import os
import sys
import json
import yaml
from pathlib import Path
from typing import Optional
from tabulate import tabulate
from .database import DatabaseManager
from .document_manager import DocumentManager
@@ -31,6 +33,53 @@ from .serializer import ASTSerializer
pass_config = click.make_pass_decorator(dict, ensure=True)
def format_output(data, output_format):
"""
Format data according to specified output format.
Args:
data: Data to format
output_format: Format type ('table', 'json', 'yaml')
Returns:
Formatted string output
"""
if output_format == 'json':
return json.dumps(data, indent=2, default=str)
elif output_format == 'yaml':
return yaml.dump(data, default_flow_style=False, allow_unicode=True)
elif output_format == 'table':
try:
# Check if it's a list type
if isinstance(data, (type([]), type(()))):
if data and isinstance(data[0], dict):
# List of dictionaries - format as table
headers = sorted(data[0].keys())
rows = []
for item in data:
row = []
for header in headers:
row.append(item.get(header, ''))
rows.append(row)
return tabulate(rows, headers=headers, tablefmt='grid')
else:
# List of simple values
return tabulate([[item] for item in data], headers=['Value'], tablefmt='grid')
elif isinstance(data, dict):
# Single dictionary - format as key-value table
rows = [[key, value] for key, value in data.items()]
return tabulate(rows, headers=['Key', 'Value'], tablefmt='grid')
else:
# Fallback to string representation
return str(data)
except Exception as e:
# Fallback to string if table formatting fails
return f"Table formatting error: {e}\nData: {str(data)}"
else:
# Default to table format
return format_output(data, 'table')
@click.group()
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
@click.option('--config', 'config_file', type=click.Path(exists=True), help='Configuration file path')
@@ -402,6 +451,161 @@ def modify(config, file_path, add_section, section_content, section_level, updat
sys.exit(1)
@cli.command()
@click.argument('sql', type=str)
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml']), default='table', help='Output format')
@pass_config
def query(config, sql, format):
"""
Execute SQL query against the database.
Execute read-only SQL queries to explore and analyze document metadata.
Only SELECT and WITH statements are allowed for security.
SQL: SQL query to execute (SELECT statements only)
Examples:
markitect query "SELECT filename, created_at FROM markdown_files"
markitect query "SELECT COUNT(*) as total FROM markdown_files" --format json
markitect query "SELECT * FROM markdown_files WHERE filename LIKE '%.md'" --format yaml
"""
try:
if config['verbose']:
click.echo(f"Executing query: {sql}", err=True)
db_manager = config['db_manager']
# Execute the query
results = db_manager.execute_query(sql)
if not results:
if format == 'json':
click.echo('[]')
elif format == 'yaml':
click.echo('[]')
else:
click.echo("No results found.")
return
# Format and display results
formatted_output = format_output(results, format)
click.echo(formatted_output)
if config['verbose']:
click.echo(f"Query returned {len(results)} result(s)", err=True)
except ValueError as e:
click.echo(f"Query error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Database error: {e}", err=True)
if config['verbose']:
import traceback
click.echo(traceback.format_exc(), err=True)
sys.exit(1)
@cli.command()
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml']), default='table', help='Output format')
@pass_config
def schema(config, format):
"""
Show database schema and table structure.
Display the structure of all tables in the database, including
column names, types, and constraints.
Examples:
markitect schema
markitect schema --format json
markitect schema --format yaml
"""
try:
if config['verbose']:
click.echo("Retrieving database schema...", err=True)
db_manager = config['db_manager']
# Get schema information
schema_info = db_manager.get_schema()
if not schema_info:
click.echo("No tables found in database.")
return
# Format and display schema
formatted_output = format_output(schema_info, format)
click.echo(formatted_output)
if config['verbose']:
table_count = len(schema_info)
click.echo(f"Schema contains {table_count} table(s)", err=True)
except Exception as e:
click.echo(f"Schema error: {e}", err=True)
if config['verbose']:
import traceback
click.echo(traceback.format_exc(), err=True)
sys.exit(1)
@cli.command()
@click.argument('file_path', type=str)
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml']), default='table', help='Output format')
@pass_config
def metadata(config, file_path, format):
"""
Display file metadata and front matter.
Show detailed information about a specific file including its
front matter, database metadata, and processing information.
FILE_PATH: Name of the file to display metadata for
Examples:
markitect metadata README.md
markitect metadata docs/guide.md --format json
markitect metadata config.md --format yaml
"""
try:
if config['verbose']:
click.echo(f"Retrieving metadata for: {file_path}", err=True)
db_manager = config['db_manager']
# Get file information from database
file_info = db_manager.get_markdown_file(file_path)
if not file_info:
click.echo(f"File not found in database: {file_path}", err=True)
click.echo("Use 'markitect ingest' to process the file first.", err=True)
sys.exit(1)
# Parse front matter for better display
if file_info.get('front_matter'):
try:
if isinstance(file_info['front_matter'], str):
file_info['front_matter'] = eval(file_info['front_matter'])
except (ValueError, TypeError, SyntaxError):
if config['verbose']:
click.echo("Warning: Could not parse front matter", err=True)
# Format and display metadata
formatted_output = format_output(file_info, format)
click.echo(formatted_output)
if config['verbose']:
content_length = len(file_info.get('content', ''))
click.echo(f"Content length: {content_length} characters", err=True)
except Exception as e:
click.echo(f"Metadata error: {e}", err=True)
if config['verbose']:
import traceback
click.echo(traceback.format_exc(), err=True)
sys.exit(1)
@cli.command()
@pass_config
def list(config):