diff --git a/Musoq.Converter.Tests/CompilationExceptionTests.cs b/Musoq.Converter.Tests/CompilationExceptionTests.cs new file mode 100644 index 00000000..fe98f438 --- /dev/null +++ b/Musoq.Converter.Tests/CompilationExceptionTests.cs @@ -0,0 +1,162 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.CodeAnalysis; +using Musoq.Converter.Exceptions; +using System; +using System.Linq; + +namespace Musoq.Converter.Tests; + +[TestClass] +public class CompilationExceptionTests +{ + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var message = "Compilation failed"; + var generatedCode = "public class Test { }"; + var diagnostics = new Diagnostic[0]; + var queryContext = "SELECT * FROM test"; + + // Act + var exception = new CompilationException(message, generatedCode, diagnostics, queryContext); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(generatedCode, exception.GeneratedCode); + Assert.AreEqual(queryContext, exception.QueryContext); + Assert.IsNotNull(exception.CompilationErrors); + } + + [TestMethod] + public void Constructor_WithMinimalParameters_ShouldUseDefaults() + { + // Arrange + var message = "Simple error"; + + // Act + var exception = new CompilationException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(string.Empty, exception.GeneratedCode); + Assert.AreEqual(string.Empty, exception.QueryContext); + Assert.IsNotNull(exception.CompilationErrors); + Assert.AreEqual(0, exception.CompilationErrors.Count()); + } + + [TestMethod] + public void Constructor_WithInnerException_ShouldSetInnerException() + { + // Arrange + var message = "Compilation error"; + var innerException = new InvalidOperationException("Inner error"); + + // Act + var exception = new CompilationException(message, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + } + + [TestMethod] + public void ForAssemblyLoadFailure_ShouldCreateAppropriateException() + { + // Arrange + var innerException = new System.IO.FileLoadException("Could not load assembly"); + var queryContext = "SELECT id FROM users"; + + // Act + var exception = CompilationException.ForAssemblyLoadFailure(innerException, queryContext); + + // Assert + Assert.AreEqual(innerException, exception.InnerException); + Assert.AreEqual(queryContext, exception.QueryContext); + Assert.IsTrue(exception.Message.Contains("compiled query assembly could not be loaded")); + Assert.IsTrue(exception.Message.Contains("missing dependencies")); + Assert.IsTrue(exception.Message.Contains("incompatible assembly references")); + Assert.IsTrue(exception.Message.Contains("data source plugins are properly installed")); + } + + [TestMethod] + public void ForTypeResolutionFailure_ShouldCreateAppropriateException() + { + // Arrange + var typeName = "Query_12345"; + var queryContext = "SELECT name FROM products"; + + // Act + var exception = CompilationException.ForTypeResolutionFailure(typeName, queryContext); + + // Assert + Assert.AreEqual(queryContext, exception.QueryContext); + Assert.IsTrue(exception.Message.Contains($"Could not resolve type '{typeName}'")); + Assert.IsTrue(exception.Message.Contains("problem with code generation")); + Assert.IsTrue(exception.Message.Contains("missing references")); + Assert.IsTrue(exception.Message.Contains("query syntax")); + Assert.IsTrue(exception.Message.Contains("schemas are registered")); + } + + [TestMethod] + public void ForAssemblyLoadFailure_WithDefaultContext_ShouldUseUnknown() + { + // Arrange + var innerException = new Exception("Load error"); + + // Act + var exception = CompilationException.ForAssemblyLoadFailure(innerException); + + // Assert + Assert.AreEqual("Unknown", exception.QueryContext); + } + + [TestMethod] + public void ForTypeResolutionFailure_WithDefaultContext_ShouldUseUnknown() + { + // Arrange + var typeName = "TestType"; + + // Act + var exception = CompilationException.ForTypeResolutionFailure(typeName); + + // Assert + Assert.AreEqual("Unknown", exception.QueryContext); + } + + [TestMethod] + public void Constructor_WithNullParameters_ShouldHandleGracefully() + { + // Arrange + var message = "Test message"; + + // Act + var exception = new CompilationException(message, null, null, null); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(string.Empty, exception.GeneratedCode); + Assert.AreEqual(string.Empty, exception.QueryContext); + Assert.IsNotNull(exception.CompilationErrors); + Assert.AreEqual(0, exception.CompilationErrors.Count()); + } + + [TestMethod] + public void Constructor_WithNullParametersAndInnerException_ShouldHandleGracefully() + { + // Arrange + var message = "Test message"; + var innerException = new Exception("Inner"); + + // Act + var exception = new CompilationException(message, innerException, null, null, null); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + Assert.AreEqual(string.Empty, exception.GeneratedCode); + Assert.AreEqual(string.Empty, exception.QueryContext); + Assert.IsNotNull(exception.CompilationErrors); + Assert.AreEqual(0, exception.CompilationErrors.Count()); + } +} \ No newline at end of file diff --git a/Musoq.Converter.Tests/ErrorHandlingIntegrationTests.cs b/Musoq.Converter.Tests/ErrorHandlingIntegrationTests.cs new file mode 100644 index 00000000..b43db351 --- /dev/null +++ b/Musoq.Converter.Tests/ErrorHandlingIntegrationTests.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Converter.Exceptions; +using Musoq.Parser.Exceptions; + +namespace Musoq.Converter.Tests; + +[TestClass] +public class ErrorHandlingIntegrationTests +{ + [TestMethod] + public void WhenQueryContainsBackslash_ShouldProvideHelpfulError() + { + const string queryWithBackslash = "select 5 \\ 2 from #test.source()"; + + var exception = Assert.ThrowsException(() => + { + InstanceCreator.CompileForExecution(queryWithBackslash, "TestAssembly", null, null); + }); + + // Debug output + Console.WriteLine($"Exception Message: {exception.Message}"); + Console.WriteLine($"Inner Exception: {exception.InnerException?.GetType()?.Name}"); + Console.WriteLine($"Inner Exception Message: {exception.InnerException?.Message}"); + + Assert.IsNotNull(exception.InnerException); + Assert.IsInstanceOfType(exception.InnerException, typeof(QueryValidationException)); + + var validationException = (QueryValidationException)exception.InnerException; + Assert.IsTrue(validationException.Message.Contains("problematic characters") && validationException.Message.Contains("\\")); + } + + [TestMethod] + public void WhenQueryContainsQuestionMark_ShouldProvideHelpfulError() + { + const string queryWithQuestionMark = "select id from #test.source() where name = ?"; + + var exception = Assert.ThrowsException(() => + { + InstanceCreator.CompileForExecution(queryWithQuestionMark, "TestAssembly", null, null); + }); + + // Debug output + Console.WriteLine($"Exception Message: {exception.Message}"); + Console.WriteLine($"Inner Exception: {exception.InnerException?.GetType()?.Name}"); + Console.WriteLine($"Inner Exception Message: {exception.InnerException?.Message}"); + + Assert.IsNotNull(exception.InnerException); + Assert.IsInstanceOfType(exception.InnerException, typeof(QueryValidationException)); + + var validationException = (QueryValidationException)exception.InnerException; + Assert.IsTrue(validationException.Message.Contains("problematic characters") && validationException.Message.Contains("?")); + } + + [TestMethod] + public void WhenQueryIsEmpty_ShouldProvideHelpfulError() + { + const string emptyQuery = ""; + + var exception = Assert.ThrowsException(() => + { + InstanceCreator.CompileForExecution(emptyQuery, "TestAssembly", null, null); + }); + + // Debug output + Console.WriteLine($"Exception Message: {exception.Message}"); + Console.WriteLine($"Inner Exception: {exception.InnerException?.GetType()?.Name}"); + + // Should be caught by the early null check, not validation + Assert.IsTrue(exception.Message.Contains("RawQuery cannot be null or whitespace")); + } + + [TestMethod] + public void WhenQueryHasUnbalancedParentheses_ShouldProvideHelpfulError() + { + const string unbalancedQuery = "select sum(column from #test.source()"; + + var exception = Assert.ThrowsException(() => + { + InstanceCreator.CompileForExecution(unbalancedQuery, "TestAssembly", null, null); + }); + + Assert.IsNotNull(exception.InnerException); + Assert.IsInstanceOfType(exception.InnerException, typeof(QueryValidationException)); + + var validationException = (QueryValidationException)exception.InnerException; + Assert.IsTrue(validationException.Message.Contains("Missing") && validationException.Message.Contains("closing parenthesis")); + } + + [TestMethod] + public void WhenQueryIsValid_ShouldPassValidation() + { + const string validQuery = "select 1 as Value"; + + // This should not throw an exception during validation + // (it might fail later due to missing schema, but validation should pass) + var exception = Assert.ThrowsException(() => + { + InstanceCreator.CompileForExecution(validQuery, "TestAssembly", null, null); + }); + + // Should fail for a different reason (missing schema/compilation), not validation + Assert.IsFalse(exception.Message.Contains("Query validation failed")); + } +} \ No newline at end of file diff --git a/Musoq.Converter/Build/CreateTree.cs b/Musoq.Converter/Build/CreateTree.cs index e76e102b..a2727a99 100644 --- a/Musoq.Converter/Build/CreateTree.cs +++ b/Musoq.Converter/Build/CreateTree.cs @@ -1,6 +1,7 @@ using System; using Musoq.Converter.Exceptions; using Musoq.Parser.Lexing; +using Musoq.Parser.Validation; namespace Musoq.Converter.Build; @@ -16,6 +17,10 @@ public override void Build(BuildItems items) try { + // Early validation before expensive parsing + var validator = new QueryValidator(); + validator.ValidateAndThrow(items.RawQuery); + var lexer = new Lexer(items.RawQuery, true); var parser = new Parser.Parser(lexer); diff --git a/Musoq.Converter/Exceptions/CompilationException.cs b/Musoq.Converter/Exceptions/CompilationException.cs index d6b6ea09..a4d41bdf 100644 --- a/Musoq.Converter/Exceptions/CompilationException.cs +++ b/Musoq.Converter/Exceptions/CompilationException.cs @@ -1,11 +1,66 @@ using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; namespace Musoq.Converter.Exceptions; +/// +/// Exception thrown when C# code compilation fails during query processing. +/// Provides detailed compilation diagnostics and helpful guidance for resolution. +/// public class CompilationException : Exception { - public CompilationException(string message) + public string GeneratedCode { get; } + public IEnumerable CompilationErrors { get; } + public string QueryContext { get; } + + public CompilationException(string message, string generatedCode = null, IEnumerable compilationErrors = null, string queryContext = null) : base(message) { + GeneratedCode = generatedCode ?? string.Empty; + CompilationErrors = compilationErrors ?? Enumerable.Empty(); + QueryContext = queryContext ?? string.Empty; + } + + public CompilationException(string message, Exception innerException, string generatedCode = null, IEnumerable compilationErrors = null, string queryContext = null) + : base(message, innerException) + { + GeneratedCode = generatedCode ?? string.Empty; + CompilationErrors = compilationErrors ?? Enumerable.Empty(); + QueryContext = queryContext ?? string.Empty; + } + + public static CompilationException ForCompilationFailure(IEnumerable errors, string generatedCode, string queryContext = "Unknown") + { + var errorList = errors.ToList(); + var errorDetails = string.Join("\n", errorList + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"- {d.GetMessage()} (Line: {d.Location.GetLineSpan().StartLinePosition.Line + 1})")); + + var message = $"Failed to compile the generated C# code for your SQL query. " + + $"Compilation errors:\n{errorDetails}\n\n" + + "This usually indicates a problem with the SQL query syntax or unsupported operations. " + + "Please check your query for syntax errors, column references, and method calls."; + + return new CompilationException(message, generatedCode, errorList, queryContext); + } + + public static CompilationException ForAssemblyLoadFailure(Exception innerException, string queryContext = "Unknown") + { + var message = "The compiled query assembly could not be loaded. " + + "This may be due to missing dependencies or incompatible assembly references. " + + "Please ensure all required data source plugins are properly installed."; + + return new CompilationException(message, innerException, queryContext: queryContext); + } + + public static CompilationException ForTypeResolutionFailure(string typeName, string queryContext = "Unknown") + { + var message = $"Could not resolve type '{typeName}' in the compiled assembly. " + + "This indicates a problem with code generation or missing references. " + + "Please check your query syntax and ensure all required schemas are registered."; + + return new CompilationException(message, queryContext: queryContext); } } \ No newline at end of file diff --git a/Musoq.Evaluator.Tests/CannotResolveMethodExceptionTests.cs b/Musoq.Evaluator.Tests/CannotResolveMethodExceptionTests.cs new file mode 100644 index 00000000..6926edeb --- /dev/null +++ b/Musoq.Evaluator.Tests/CannotResolveMethodExceptionTests.cs @@ -0,0 +1,252 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Evaluator.Exceptions; +using Musoq.Parser.Nodes; +using Musoq.Parser; +using System; +using System.Linq; + +namespace Musoq.Evaluator.Tests; + +[TestClass] +public class CannotResolveMethodExceptionTests +{ + private class MockNode : Node + { + private readonly Type _returnType; + + public MockNode(Type returnType) : base() + { + _returnType = returnType; + } + + public override Type ReturnType => _returnType; + public override string Id => $"mock_{_returnType?.Name ?? "null"}"; + public override void Accept(IExpressionVisitor visitor) { /* Mock implementation */ } + public override string ToString() => $"MockNode({_returnType?.Name})"; + } + + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var message = "Method resolution failed"; + var methodName = "TestMethod"; + var argumentTypes = new[] { "String", "Int32" }; + var availableSignatures = new[] { "TestMethod(String)", "TestMethod(Int32, String)" }; + + // Act + var exception = new CannotResolveMethodException(message, methodName, argumentTypes, availableSignatures); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(methodName, exception.MethodName); + Assert.AreEqual(2, exception.ArgumentTypes.Length); + Assert.IsTrue(exception.ArgumentTypes.Contains("String")); + Assert.IsTrue(exception.ArgumentTypes.Contains("Int32")); + Assert.AreEqual(2, exception.AvailableSignatures.Length); + } + + [TestMethod] + public void Constructor_WithMinimalParameters_ShouldUseDefaults() + { + // Arrange + var message = "Simple error"; + + // Act + var exception = new CannotResolveMethodException(message); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(string.Empty, exception.MethodName); + Assert.AreEqual(0, exception.ArgumentTypes.Length); + Assert.AreEqual(0, exception.AvailableSignatures.Length); + } + + [TestMethod] + public void CreateForNullArguments_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "TestMethod"; + + // Act + var exception = CannotResolveMethodException.CreateForNullArguments(methodName); + + // Assert + Assert.AreEqual(methodName, exception.MethodName); + Assert.IsTrue(exception.Message.Contains($"Cannot resolve method '{methodName}'")); + Assert.IsTrue(exception.Message.Contains("one or more arguments are null")); + Assert.IsTrue(exception.Message.Contains("column references")); + Assert.IsTrue(exception.Message.Contains("expressions are valid")); + Assert.IsTrue(exception.Message.Contains("referenced columns exist")); + } + + [TestMethod] + public void CreateForCannotMatchMethodNameOrArguments_WithValidArgs_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "Sum"; + var args = new Node[] + { + new MockNode(typeof(int)), + new MockNode(typeof(string)) + }; + var availableSignatures = new[] { "Sum(Int32)", "Sum(Decimal)" }; + + // Act + var exception = CannotResolveMethodException.CreateForCannotMatchMethodNameOrArguments(methodName, args, availableSignatures); + + // Assert + Assert.AreEqual(methodName, exception.MethodName); + Assert.IsTrue(exception.Message.Contains($"Cannot resolve method '{methodName}'")); + Assert.IsTrue(exception.Message.Contains("with arguments (System.Int32, System.String)")); + Assert.IsTrue(exception.Message.Contains("Available method signatures:")); + Assert.IsTrue(exception.Message.Contains("- Sum(Int32)")); + Assert.IsTrue(exception.Message.Contains("- Sum(Decimal)")); + Assert.IsTrue(exception.Message.Contains("Method name spelling")); + Assert.IsTrue(exception.Message.Contains("Number and types of arguments")); + Assert.AreEqual(2, exception.ArgumentTypes.Length); + Assert.AreEqual(2, exception.AvailableSignatures.Length); + } + + [TestMethod] + public void CreateForCannotMatchMethodNameOrArguments_WithNullArgs_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "TestMethod"; + Node[] args = null; + + // Act + var exception = CannotResolveMethodException.CreateForCannotMatchMethodNameOrArguments(methodName, args); + + // Assert + Assert.AreEqual(methodName, exception.MethodName); + Assert.IsTrue(exception.Message.Contains("with arguments (no arguments)")); + Assert.IsTrue(exception.Message.Contains("No matching methods found")); + Assert.AreEqual(0, exception.ArgumentTypes.Length); + } + + [TestMethod] + public void CreateForCannotMatchMethodNameOrArguments_WithEmptyArgs_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "TestMethod"; + var args = new Node[0]; + + // Act + var exception = CannotResolveMethodException.CreateForCannotMatchMethodNameOrArguments(methodName, args); + + // Assert + Assert.IsTrue(exception.Message.Contains("with arguments (no arguments)")); + Assert.AreEqual(0, exception.ArgumentTypes.Length); + } + + [TestMethod] + public void CreateForCannotMatchMethodNameOrArguments_WithArgsWithNullReturnType_ShouldFilterNulls() + { + // Arrange + var methodName = "TestMethod"; + var args = new Node[] + { + new MockNode(typeof(int)), + new MockNode(null), // This should be filtered out + new MockNode(typeof(string)) + }; + + // Act + var exception = CannotResolveMethodException.CreateForCannotMatchMethodNameOrArguments(methodName, args); + + // Assert + Assert.AreEqual(2, exception.ArgumentTypes.Length); // Only non-null types + Assert.IsTrue(exception.ArgumentTypes.Contains("System.Int32")); + Assert.IsTrue(exception.ArgumentTypes.Contains("System.String")); + } + + [TestMethod] + public void CreateForCannotMatchMethodNameOrArguments_WithNoAvailableSignatures_ShouldShowNoMatchingMethods() + { + // Arrange + var methodName = "UnknownMethod"; + var args = new Node[] { new MockNode(typeof(int)) }; + + // Act + var exception = CannotResolveMethodException.CreateForCannotMatchMethodNameOrArguments(methodName, args); + + // Assert + Assert.IsTrue(exception.Message.Contains("No matching methods found")); + Assert.IsFalse(exception.Message.Contains("Available method signatures:")); + } + + [TestMethod] + public void CreateForAmbiguousMatch_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "Convert"; + var args = new Node[] { new MockNode(typeof(object)) }; + var matchingSignatures = new[] { "Convert(Object) -> String", "Convert(Object) -> Int32" }; + + // Act + var exception = CannotResolveMethodException.CreateForAmbiguousMatch(methodName, args, matchingSignatures); + + // Assert + Assert.AreEqual(methodName, exception.MethodName); + Assert.IsTrue(exception.Message.Contains($"method call '{methodName}(System.Object)' is ambiguous")); + Assert.IsTrue(exception.Message.Contains("Ambiguous matches:")); + Assert.IsTrue(exception.Message.Contains("- Convert(Object) -> String")); + Assert.IsTrue(exception.Message.Contains("- Convert(Object) -> Int32")); + Assert.IsTrue(exception.Message.Contains("more specific argument types")); + Assert.IsTrue(exception.Message.Contains("explicit casting")); + Assert.AreEqual(1, exception.ArgumentTypes.Length); + Assert.AreEqual(2, exception.AvailableSignatures.Length); + } + + [TestMethod] + public void CreateForAmbiguousMatch_WithNullArgs_ShouldHandleGracefully() + { + // Arrange + var methodName = "TestMethod"; + Node[] args = null; + var matchingSignatures = new[] { "TestMethod()", "TestMethod() -> String" }; + + // Act + var exception = CannotResolveMethodException.CreateForAmbiguousMatch(methodName, args, matchingSignatures); + + // Assert + Assert.IsTrue(exception.Message.Contains("TestMethod(no arguments)")); + Assert.AreEqual(0, exception.ArgumentTypes.Length); + } + + [TestMethod] + public void CreateForUnsupportedOperation_ShouldCreateAppropriateException() + { + // Arrange + var methodName = "FileWrite"; + var context = "read-only query context"; + + // Act + var exception = CannotResolveMethodException.CreateForUnsupportedOperation(methodName, context); + + // Assert + Assert.AreEqual(methodName, exception.MethodName); + Assert.IsTrue(exception.Message.Contains($"method '{methodName}' is not supported")); + Assert.IsTrue(exception.Message.Contains($"in {context}")); + Assert.IsTrue(exception.Message.Contains("not be available in certain query contexts")); + Assert.IsTrue(exception.Message.Contains("specific data types")); + Assert.IsTrue(exception.Message.Contains("documentation for supported operations")); + } + + [TestMethod] + public void Constructor_WithNullParameters_ShouldUseDefaults() + { + // Arrange + var message = "Test message"; + + // Act + var exception = new CannotResolveMethodException(message, null, null, null); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(string.Empty, exception.MethodName); + Assert.AreEqual(0, exception.ArgumentTypes.Length); + Assert.AreEqual(0, exception.AvailableSignatures.Length); + } +} \ No newline at end of file diff --git a/Musoq.Evaluator/Exceptions/CannotResolveMethodException.cs b/Musoq.Evaluator/Exceptions/CannotResolveMethodException.cs index 56411659..2e210ba5 100644 --- a/Musoq.Evaluator/Exceptions/CannotResolveMethodException.cs +++ b/Musoq.Evaluator/Exceptions/CannotResolveMethodException.cs @@ -4,20 +4,75 @@ namespace Musoq.Evaluator.Exceptions; -public class CannotResolveMethodException(string message) - : Exception(message) +/// +/// Exception thrown when a method cannot be resolved during query evaluation. +/// Provides detailed information about the method resolution failure and suggestions. +/// +public class CannotResolveMethodException : Exception { + public string MethodName { get; } + public string[] ArgumentTypes { get; } + public string[] AvailableSignatures { get; } + + public CannotResolveMethodException(string message, string methodName = null, string[] argumentTypes = null, string[] availableSignatures = null) + : base(message) + { + MethodName = methodName ?? string.Empty; + ArgumentTypes = argumentTypes ?? new string[0]; + AvailableSignatures = availableSignatures ?? new string[0]; + } + public static CannotResolveMethodException CreateForNullArguments(string methodName) { - return new CannotResolveMethodException($"Method {methodName} cannot be resolved because of null arguments"); + var message = $"Cannot resolve method '{methodName}' because one or more arguments are null. " + + "This usually indicates a problem with column references or expressions. " + + "Please check that all referenced columns exist and expressions are valid."; + + return new CannotResolveMethodException(message, methodName); } - public static CannotResolveMethodException CreateForCannotMatchMethodNameOrArguments(string methodName, Node[] args) + public static CannotResolveMethodException CreateForCannotMatchMethodNameOrArguments(string methodName, Node[] args, string[] availableSignatures = null) + { + var argTypes = args?.Length > 0 + ? args.Where(a => a?.ReturnType != null).Select(f => f.ReturnType.ToString()).ToArray() + : new string[0]; + + var argumentsText = argTypes.Length > 0 ? string.Join(", ", argTypes) : "no arguments"; + + var availableText = availableSignatures?.Length > 0 + ? $"\n\nAvailable method signatures:\n{string.Join("\n", availableSignatures.Select(s => $"- {s}"))}" + : "\n\nNo matching methods found. Please check the method name and available functions."; + + var message = $"Cannot resolve method '{methodName}' with arguments ({argumentsText}).{availableText}" + + "\n\nPlease check:\n" + + "- Method name spelling\n" + + "- Number and types of arguments\n" + + "- Available functions in the current context"; + + return new CannotResolveMethodException(message, methodName, argTypes, availableSignatures); + } + + public static CannotResolveMethodException CreateForAmbiguousMatch(string methodName, Node[] args, string[] matchingSignatures) { - var types = args.Length > 0 - ? args.Select(f => f.ReturnType.ToString()).Aggregate((a, b) => a + ", " + b) - : string.Empty; + var argTypes = args?.Where(a => a?.ReturnType != null).Select(f => f.ReturnType.ToString()).ToArray() ?? new string[0]; + var argumentsText = argTypes.Length > 0 ? string.Join(", ", argTypes) : "no arguments"; - return new CannotResolveMethodException($"Method {methodName} with argument types {string.Join(", ", types)} cannot be resolved"); + var matchesText = matchingSignatures?.Length > 0 + ? $"\n\nAmbiguous matches:\n{string.Join("\n", matchingSignatures.Select(s => $"- {s}"))}" + : ""; + + var message = $"The method call '{methodName}({argumentsText})' is ambiguous.{matchesText}" + + "\n\nPlease provide more specific argument types or use explicit casting to resolve the ambiguity."; + + return new CannotResolveMethodException(message, methodName, argTypes, matchingSignatures); + } + + public static CannotResolveMethodException CreateForUnsupportedOperation(string methodName, string context) + { + var message = $"The method '{methodName}' is not supported in {context}. " + + "Some operations may not be available in certain query contexts or with specific data types. " + + "Please check the documentation for supported operations."; + + return new CannotResolveMethodException(message, methodName); } } \ No newline at end of file diff --git a/Musoq.Parser.Tests/QueryValidationExceptionTests.cs b/Musoq.Parser.Tests/QueryValidationExceptionTests.cs new file mode 100644 index 00000000..639f877e --- /dev/null +++ b/Musoq.Parser.Tests/QueryValidationExceptionTests.cs @@ -0,0 +1,154 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Parser.Exceptions; +using System.Linq; + +namespace Musoq.Parser.Tests; + +[TestClass] +public class QueryValidationExceptionTests +{ + [TestMethod] + public void ForEmptyQuery_ShouldCreateAppropriateException() + { + // Act + var exception = QueryValidationException.ForEmptyQuery(); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("Query cannot be empty")); + Assert.AreEqual(string.Empty, exception.Query); + Assert.AreEqual(1, exception.ValidationIssues.Count()); + Assert.AreEqual(ValidationIssueType.EmptyQuery, exception.ValidationIssues.First().Type); + } + + [TestMethod] + public void ForInvalidCharacters_ShouldCreateAppropriateException() + { + // Arrange + var query = "SELECT * FROM table WHERE column = 'value'`"; + var invalidChars = new char[] { '`' }; + + // Act + var exception = QueryValidationException.ForInvalidCharacters(query, invalidChars); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("invalid characters")); + Assert.IsTrue(exception.Message.Contains("'`'")); + Assert.AreEqual(query, exception.Query); + Assert.AreEqual(1, exception.ValidationIssues.Count()); + Assert.AreEqual(ValidationIssueType.InvalidCharacters, exception.ValidationIssues.First().Type); + } + + [TestMethod] + public void ForUnbalancedParentheses_ShouldCreateAppropriateException() + { + // Arrange + var query = "SELECT * FROM table WHERE (column = 'value'"; + + // Act + var exception = QueryValidationException.ForUnbalancedParentheses(query); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("unbalanced parentheses")); + Assert.AreEqual(query, exception.Query); + Assert.AreEqual(1, exception.ValidationIssues.Count()); + Assert.AreEqual(ValidationIssueType.UnbalancedParentheses, exception.ValidationIssues.First().Type); + } + + [TestMethod] + public void ForUnbalancedQuotes_ShouldCreateAppropriateException() + { + // Arrange + var query = "SELECT * FROM table WHERE column = 'value"; + + // Act + var exception = QueryValidationException.ForUnbalancedQuotes(query); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("unbalanced quotes")); + Assert.AreEqual(query, exception.Query); + Assert.AreEqual(1, exception.ValidationIssues.Count()); + Assert.AreEqual(ValidationIssueType.UnbalancedQuotes, exception.ValidationIssues.First().Type); + } + + [TestMethod] + public void ForSuspiciousPatterns_ShouldCreateAppropriateException() + { + // Arrange + var query = "SELECT * FROM table; DROP TABLE users;"; + var patterns = new string[] { "DROP TABLE", "DELETE FROM" }; + + // Act + var exception = QueryValidationException.ForSuspiciousPatterns(query, patterns); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("potentially problematic patterns")); + Assert.IsTrue(exception.Message.Contains("DROP TABLE")); + Assert.AreEqual(query, exception.Query); + Assert.AreEqual(1, exception.ValidationIssues.Count()); + Assert.AreEqual(ValidationIssueType.SuspiciousPattern, exception.ValidationIssues.First().Type); + } + + [TestMethod] + public void ForMultipleIssues_ShouldCreateAppropriateException() + { + // Arrange + var query = "invalid query"; + var issues = new[] + { + new ValidationIssue(ValidationIssueType.InvalidStructure, "Invalid structure"), + new ValidationIssue(ValidationIssueType.MissingKeywords, "Missing SELECT keyword") + }; + + // Act + var exception = QueryValidationException.ForMultipleIssues(query, issues); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("2 issue(s)")); + Assert.IsTrue(exception.Message.Contains("Invalid structure")); + Assert.IsTrue(exception.Message.Contains("Missing SELECT")); + Assert.AreEqual(query, exception.Query); + Assert.AreEqual(2, exception.ValidationIssues.Count()); + } + + [TestMethod] + public void ValidationIssue_ConstructorWithAllParameters_ShouldSetProperties() + { + // Arrange + var type = ValidationIssueType.InvalidCharacters; + var message = "Test message"; + var position = 10; + var suggestion = "Test suggestion"; + + // Act + var issue = new ValidationIssue(type, message, position, suggestion); + + // Assert + Assert.AreEqual(type, issue.Type); + Assert.AreEqual(message, issue.Message); + Assert.AreEqual(position, issue.Position); + Assert.AreEqual(suggestion, issue.Suggestion); + } + + [TestMethod] + public void ValidationIssue_ConstructorWithMinimalParameters_ShouldSetDefaultValues() + { + // Arrange + var type = ValidationIssueType.EmptyQuery; + var message = "Test message"; + + // Act + var issue = new ValidationIssue(type, message); + + // Assert + Assert.AreEqual(type, issue.Type); + Assert.AreEqual(message, issue.Message); + Assert.IsNull(issue.Position); + Assert.AreEqual(string.Empty, issue.Suggestion); + } +} \ No newline at end of file diff --git a/Musoq.Parser.Tests/QueryValidatorTests.cs b/Musoq.Parser.Tests/QueryValidatorTests.cs new file mode 100644 index 00000000..02fbcbdb --- /dev/null +++ b/Musoq.Parser.Tests/QueryValidatorTests.cs @@ -0,0 +1,310 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Parser.Exceptions; +using Musoq.Parser.Validation; +using System.Linq; + +namespace Musoq.Parser.Tests; + +[TestClass] +public class QueryValidatorTests +{ + private QueryValidator _validator; + + [TestInitialize] + public void Setup() + { + _validator = new QueryValidator(); + } + + [TestMethod] + public void ValidateQuery_WithValidQuery_ShouldReturnNoIssues() + { + // Arrange + var query = "SELECT Name, Age FROM #users.data()"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + Assert.AreEqual(0, issues.Count); + } + + [TestMethod] + public void ValidateQuery_WithEmptyQuery_ShouldReturnEmptyQueryIssue() + { + // Arrange + var query = ""; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + Assert.AreEqual(1, issues.Count); + Assert.AreEqual(ValidationIssueType.EmptyQuery, issues[0].Type); + Assert.IsTrue(issues[0].Message.Contains("cannot be empty")); + } + + [TestMethod] + public void ValidateQuery_WithNullQuery_ShouldReturnEmptyQueryIssue() + { + // Arrange + string query = null; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + Assert.AreEqual(1, issues.Count); + Assert.AreEqual(ValidationIssueType.EmptyQuery, issues[0].Type); + } + + [TestMethod] + public void ValidateQuery_WithUnbalancedParentheses_ShouldReturnUnbalancedIssue() + { + // Arrange + var query = "SELECT Name FROM #test.data( WHERE Age > 25"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var parenthesesIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.UnbalancedParentheses); + Assert.IsNotNull(parenthesesIssue); + Assert.IsTrue(parenthesesIssue.Message.Contains("Missing") && parenthesesIssue.Message.Contains("closing")); + } + + [TestMethod] + public void ValidateQuery_WithUnbalancedQuotes_ShouldReturnUnbalancedIssue() + { + // Arrange + var query = "SELECT Name FROM #test.data() WHERE Name = 'John"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var quotesIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.UnbalancedQuotes); + Assert.IsNotNull(quotesIssue); + Assert.IsTrue(quotesIssue.Message.Contains("Unbalanced")); + } + + [TestMethod] + public void ValidateQuery_WithInvalidCharacters_ShouldReturnInvalidCharactersIssue() + { + // Arrange + var query = "SELECT `Name` FROM #test.data()"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var charactersIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.InvalidCharacters); + Assert.IsNotNull(charactersIssue); + Assert.IsTrue(charactersIssue.Message.Contains("problematic characters")); + Assert.IsTrue(charactersIssue.Message.Contains("'`'")); + Assert.IsTrue(charactersIssue.Suggestion.Contains("double quotes")); + } + + [TestMethod] + public void ValidateQuery_WithSuspiciousPatterns_ShouldReturnSuspiciousPatternIssue() + { + // Arrange + var query = "SELECT * FROM #test.data(); DROP TABLE users;"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var suspiciousIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.SuspiciousPattern); + Assert.IsNotNull(suspiciousIssue); + Assert.IsTrue(suspiciousIssue.Message.Contains("data modification")); + Assert.IsTrue(suspiciousIssue.Message.Contains("DROP TABLE")); + } + + [TestMethod] + public void ValidateQuery_WithMissingKeywords_ShouldReturnMissingKeywordsIssue() + { + // Arrange + var query = "Name, Age WHERE Age > 25"; // Missing SELECT and FROM + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var keywordsIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.MissingKeywords); + Assert.IsNotNull(keywordsIssue); + Assert.IsTrue(keywordsIssue.Message.Contains("missing required keywords")); + Assert.IsTrue(keywordsIssue.Message.Contains("SELECT") && keywordsIssue.Message.Contains("FROM")); + } + + [TestMethod] + public void ValidateQuery_WithTooLongQuery_ShouldReturnTooLongIssue() + { + // Arrange + var query = "SELECT Name FROM #test.data() " + new string('X', 10000); + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var longIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.TooLong); + Assert.IsNotNull(longIssue); + Assert.IsTrue(longIssue.Message.Contains("too long")); + } + + [TestMethod] + public void ValidateQuery_WithDeeplyNestedQuery_ShouldReturnTooComplexIssue() + { + // Arrange + var query = "SELECT Name FROM #test.data() WHERE Age > " + + new string('(', 15) + "25" + new string(')', 15); + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var complexIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.TooComplex); + Assert.IsNotNull(complexIssue); + Assert.IsTrue(complexIssue.Message.Contains("deep nesting")); + } + + [TestMethod] + public void ValidateQuery_WithManyJoins_ShouldReturnTooComplexIssue() + { + // Arrange + var query = "SELECT * FROM #a.data() a " + + "JOIN #b.data() b ON a.Id = b.Id " + + "JOIN #c.data() c ON b.Id = c.Id " + + "JOIN #d.data() d ON c.Id = d.Id " + + "JOIN #e.data() e ON d.Id = e.Id " + + "JOIN #f.data() f ON e.Id = f.Id " + + "JOIN #g.data() g ON f.Id = g.Id"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var complexIssue = issues.FirstOrDefault(i => i.Type == ValidationIssueType.TooComplex); + Assert.IsNotNull(complexIssue); + Assert.IsTrue(complexIssue.Message.Contains("many JOIN operations")); + } + + [TestMethod] + public void ValidateAndThrow_WithValidQuery_ShouldNotThrow() + { + // Arrange + var query = "SELECT Name FROM #test.data()"; + + // Act & Assert + _validator.ValidateAndThrow(query); // Should not throw + } + + [TestMethod] + public void ValidateAndThrow_WithInvalidQuery_ShouldThrowQueryValidationException() + { + // Arrange + var query = "SELECT `Name` FROM #test.data( WHERE condition"; // Multiple issues + + // Act & Assert + var exception = Assert.ThrowsException(() => + _validator.ValidateAndThrow(query)); + + Assert.IsTrue(exception.Message.Contains("validation failed")); + Assert.IsTrue(exception.ValidationIssues.Any()); + } + + [TestMethod] + public void GetQuerySuggestions_WithSelectStar_ShouldSuggestSpecificColumns() + { + // Arrange + var query = "SELECT * FROM #test.data()"; + + // Act + var suggestions = _validator.GetQuerySuggestions(query); + + // Assert + Assert.IsTrue(suggestions.Any(s => s.Contains("specific columns"))); + } + + [TestMethod] + public void GetQuerySuggestions_WithoutSchemaReference_ShouldSuggestSchemaReference() + { + // Arrange + var query = "SELECT Name FROM users"; + + // Act + var suggestions = _validator.GetQuerySuggestions(query); + + // Assert + Assert.IsTrue(suggestions.Any(s => s.Contains("schema references"))); + } + + [TestMethod] + public void GetQuerySuggestions_WithoutWhereOrLimit_ShouldSuggestFiltering() + { + // Arrange + var query = "SELECT Name FROM #test.data()"; + + // Act + var suggestions = _validator.GetQuerySuggestions(query); + + // Assert + Assert.IsTrue(suggestions.Any(s => s.Contains("WHERE clause") || s.Contains("TAKE/LIMIT"))); + } + + [TestMethod] + public void GetQuerySuggestions_WithEmptyQuery_ShouldReturnEmptyList() + { + // Arrange + var query = ""; + + // Act + var suggestions = _validator.GetQuerySuggestions(query); + + // Assert + Assert.AreEqual(0, suggestions.Count); + } + + [TestMethod] + public void ValidateQuery_WithMultipleIssues_ShouldReturnAllIssues() + { + // Arrange + var query = "SELECT `Name` FROM #test.data( WHERE Name = 'John"; // Backtick + unbalanced parens + quotes + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + Assert.IsTrue(issues.Count >= 2); // Should have multiple issues + Assert.IsTrue(issues.Any(i => i.Type == ValidationIssueType.InvalidCharacters)); + Assert.IsTrue(issues.Any(i => i.Type == ValidationIssueType.UnbalancedParentheses)); + } + + [TestMethod] + public void ValidateQuery_WithQuotesInStrings_ShouldNotReportFalsePositives() + { + // Arrange + var query = "SELECT Name FROM #test.data() WHERE Description = 'He said \"Hello\" to me'"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var quoteIssues = issues.Where(i => i.Type == ValidationIssueType.UnbalancedQuotes).ToList(); + Assert.AreEqual(0, quoteIssues.Count, "Should not report unbalanced quotes when quotes are within strings"); + } + + [TestMethod] + public void ValidateQuery_WithParenthesesInStrings_ShouldNotReportFalsePositives() + { + // Arrange + var query = "SELECT Name FROM #test.data() WHERE Description = 'Function(param)'"; + + // Act + var issues = _validator.ValidateQuery(query); + + // Assert + var parenIssues = issues.Where(i => i.Type == ValidationIssueType.UnbalancedParentheses).ToList(); + Assert.AreEqual(0, parenIssues.Count, "Should not report unbalanced parentheses when they are within strings"); + } +} \ No newline at end of file diff --git a/Musoq.Parser.Tests/SyntaxExceptionTests.cs b/Musoq.Parser.Tests/SyntaxExceptionTests.cs new file mode 100644 index 00000000..eca02219 --- /dev/null +++ b/Musoq.Parser.Tests/SyntaxExceptionTests.cs @@ -0,0 +1,213 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Parser.Exceptions; + +namespace Musoq.Parser.Tests; + +[TestClass] +public class SyntaxExceptionTests +{ + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var message = "Test error message"; + var queryPart = "SELECT * FROM"; + var position = 5; + var expectedTokens = "table name"; + var actualToken = "WHERE"; + + // Act + var exception = new SyntaxException(message, queryPart, position, expectedTokens, actualToken); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(expectedTokens, exception.ExpectedTokens); + Assert.AreEqual(actualToken, exception.ActualToken); + } + + [TestMethod] + public void ForUnexpectedToken_ShouldCreateAppropriateException() + { + // Arrange + var actualToken = "WHERE"; + var expectedTokens = new[] { "FROM", "table name" }; + var queryPart = "SELECT * WHERE"; + var position = 9; + + // Act + var exception = SyntaxException.ForUnexpectedToken(actualToken, expectedTokens, queryPart, position); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains($"Unexpected token '{actualToken}'")); + Assert.IsTrue(exception.Message.Contains("at position 9")); + Assert.IsTrue(exception.Message.Contains("Expected: FROM, table name")); + Assert.IsTrue(exception.Message.Contains("Query context")); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + Assert.AreEqual("FROM, table name", exception.ExpectedTokens); + Assert.AreEqual(actualToken, exception.ActualToken); + } + + [TestMethod] + public void ForUnexpectedToken_WithoutPosition_ShouldNotIncludePosition() + { + // Arrange + var actualToken = "WHERE"; + var expectedTokens = new[] { "FROM" }; + var queryPart = "SELECT * WHERE"; + + // Act + var exception = SyntaxException.ForUnexpectedToken(actualToken, expectedTokens, queryPart); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains($"Unexpected token '{actualToken}'")); + Assert.IsFalse(exception.Message.Contains("at position")); + Assert.IsTrue(exception.Message.Contains("Expected: FROM")); + } + + [TestMethod] + public void ForUnexpectedToken_WithEmptyExpectedTokens_ShouldUseDefaultMessage() + { + // Arrange + var actualToken = "INVALID"; + var expectedTokens = new string[0]; + var queryPart = "SELECT * INVALID"; + + // Act + var exception = SyntaxException.ForUnexpectedToken(actualToken, expectedTokens, queryPart); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("Expected: valid SQL token")); + } + + [TestMethod] + public void ForMissingToken_ShouldCreateAppropriateException() + { + // Arrange + var expectedToken = "FROM"; + var queryPart = "SELECT *"; + var position = 8; + + // Act + var exception = SyntaxException.ForMissingToken(expectedToken, queryPart, position); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains($"Missing required token '{expectedToken}'")); + Assert.IsTrue(exception.Message.Contains("at position 8")); + Assert.IsTrue(exception.Message.Contains("Query context")); + Assert.IsTrue(exception.Message.Contains($"add the missing '{expectedToken}'")); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(expectedToken, exception.ExpectedTokens); + } + + [TestMethod] + public void ForInvalidStructure_ShouldCreateAppropriateException() + { + // Arrange + var issue = "Nested queries are not supported"; + var queryPart = "SELECT * FROM (SELECT"; + var position = 15; + + // Act + var exception = SyntaxException.ForInvalidStructure(issue, queryPart, position); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("Invalid SQL structure")); + Assert.IsTrue(exception.Message.Contains("at position 15")); + Assert.IsTrue(exception.Message.Contains(issue)); + Assert.IsTrue(exception.Message.Contains("Query context")); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + } + + [TestMethod] + public void ForUnsupportedSyntax_ShouldCreateAppropriateException() + { + // Arrange + var feature = "Common Table Expressions (CTE)"; + var queryPart = "WITH cte AS ("; + var position = 0; + + // Act + var exception = SyntaxException.ForUnsupportedSyntax(feature, queryPart, position); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains("Unsupported SQL syntax")); + Assert.IsTrue(exception.Message.Contains("at position 0")); + Assert.IsTrue(exception.Message.Contains(feature)); + Assert.IsTrue(exception.Message.Contains("refer to the documentation")); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + } + + [TestMethod] + public void WithSuggestions_ShouldCreateAppropriateException() + { + // Arrange + var baseMessage = "Invalid table reference"; + var queryPart = "SELECT * FROM unknwon_table"; + var suggestions = new[] + { + "Check table name spelling", + "Ensure table exists in schema", + "Use #schema.table syntax for data sources" + }; + var position = 14; + + // Act + var exception = SyntaxException.WithSuggestions(baseMessage, queryPart, suggestions, position); + + // Assert + Assert.IsNotNull(exception); + Assert.IsTrue(exception.Message.Contains(baseMessage)); + Assert.IsTrue(exception.Message.Contains("Suggestions:")); + Assert.IsTrue(exception.Message.Contains("- Check table name spelling")); + Assert.IsTrue(exception.Message.Contains("- Ensure table exists")); + Assert.IsTrue(exception.Message.Contains("- Use #schema.table syntax")); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(position, exception.Position); + } + + [TestMethod] + public void WithSuggestions_WithEmptySuggestions_ShouldNotIncludeSuggestionsSection() + { + // Arrange + var baseMessage = "Invalid syntax"; + var queryPart = "SELECT *"; + var suggestions = new string[0]; + + // Act + var exception = SyntaxException.WithSuggestions(baseMessage, queryPart, suggestions); + + // Assert + Assert.IsNotNull(exception); + Assert.AreEqual(baseMessage, exception.Message); + Assert.IsFalse(exception.Message.Contains("Suggestions:")); + } + + [TestMethod] + public void Constructor_WithInnerException_ShouldSetInnerException() + { + // Arrange + var message = "Parse error"; + var queryPart = "SELECT"; + var innerException = new System.ArgumentException("Inner error"); + + // Act + var exception = new SyntaxException(message, queryPart, innerException); + + // Assert + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(queryPart, exception.QueryPart); + Assert.AreEqual(innerException, exception.InnerException); + } +} \ No newline at end of file diff --git a/Musoq.Parser.Tests/UnknownTokenExceptionTests.cs b/Musoq.Parser.Tests/UnknownTokenExceptionTests.cs new file mode 100644 index 00000000..6f8a878f --- /dev/null +++ b/Musoq.Parser.Tests/UnknownTokenExceptionTests.cs @@ -0,0 +1,266 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Parser.Lexing; + +namespace Musoq.Parser.Tests; + +[TestClass] +public class UnknownTokenExceptionTests +{ + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var position = 10; + var unknownChar = '`'; + var remainingQuery = "unknown_table FROM"; + var surroundingContext = "SELECT * FROM `unknown_table"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery, surroundingContext); + + // Assert + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(unknownChar, exception.UnknownCharacter); + Assert.AreEqual(remainingQuery, exception.RemainingQuery); + Assert.AreEqual(surroundingContext, exception.SurroundingContext); + } + + [TestMethod] + public void Constructor_ShouldGenerateAppropriateMessage() + { + // Arrange + var position = 15; + var unknownChar = '`'; + var remainingQuery = "table_name FROM users"; + var surroundingContext = "SELECT * FROM `table_name"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery, surroundingContext); + + // Assert + Assert.IsTrue(exception.Message.Contains($"Unrecognized character '{unknownChar}'")); + Assert.IsTrue(exception.Message.Contains($"at position {position}")); + Assert.IsTrue(exception.Message.Contains($"Near: '{surroundingContext}'")); + Assert.IsTrue(exception.Message.Contains("Remaining query:")); + Assert.IsTrue(exception.Message.Contains("Please check your SQL syntax")); + } + + [TestMethod] + public void Constructor_WithBacktick_ShouldProvideSuggestions() + { + // Arrange + var position = 10; + var unknownChar = '`'; + var remainingQuery = "table"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Use double quotes")); + Assert.IsTrue(exception.Message.Contains("Use single quotes")); + } + + [TestMethod] + public void Constructor_WithBrackets_ShouldProvideSuggestions() + { + // Arrange + var position = 10; + var unknownChar = '['; + var remainingQuery = "table]"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Use double quotes")); + Assert.IsTrue(exception.Message.Contains("instead of brackets")); + } + + [TestMethod] + public void Constructor_WithCurlyBraces_ShouldProvideSuggestions() + { + // Arrange + var position = 10; + var unknownChar = '{'; + var remainingQuery = "expression}"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Use parentheses")); + Assert.IsTrue(exception.Message.Contains("for grouping")); + } + + [TestMethod] + public void Constructor_WithSemicolon_ShouldProvideSuggestions() + { + // Arrange + var position = 25; + var unknownChar = ';'; + var remainingQuery = ""; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Semicolon is not required")); + } + + [TestMethod] + public void Constructor_WithBackslash_ShouldProvideSuggestions() + { + // Arrange + var position = 15; + var unknownChar = '\\'; + var remainingQuery = " 2"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Use forward slash")); + Assert.IsTrue(exception.Message.Contains("for division")); + } + + [TestMethod] + public void Constructor_WithQuestionMark_ShouldProvideSuggestions() + { + // Arrange + var position = 20; + var unknownChar = '?'; + var remainingQuery = "param"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("Did you mean:")); + Assert.IsTrue(exception.Message.Contains("Use parameters with @ or # prefix")); + } + + [TestMethod] + public void Constructor_WithUnknownCharacter_ShouldNotProvideSuggestions() + { + // Arrange + var position = 10; + var unknownChar = '@'; // Not in suggestions list + var remainingQuery = "param"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsFalse(exception.Message.Contains("Did you mean:")); + } + + [TestMethod] + public void Constructor_WithLongRemainingQuery_ShouldTruncate() + { + // Arrange + var position = 10; + var unknownChar = '`'; + var remainingQuery = "this_is_a_very_long_query_that_should_be_truncated_because_it_exceeds_fifty_characters"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsTrue(exception.Message.Contains("...")); + Assert.IsTrue(exception.Message.Contains("this_is_a_very_long_query_that_should_be_truncated")); + } + + [TestMethod] + public void Constructor_WithShortRemainingQuery_ShouldNotTruncate() + { + // Arrange + var position = 10; + var unknownChar = '`'; + var remainingQuery = "short"; + + // Act + var exception = new UnknownTokenException(position, unknownChar, remainingQuery); + + // Assert + Assert.IsFalse(exception.Message.Contains("...")); + Assert.IsTrue(exception.Message.Contains("short")); + } + + [TestMethod] + public void ForInvalidCharacter_ShouldCreateAppropriateException() + { + // Arrange + var position = 14; // Position of the backtick in "SELECT * FROM `table_name` WHERE..." + var character = '`'; + var fullQuery = "SELECT * FROM `table_name` WHERE condition = 'value'"; + + // Act + var exception = UnknownTokenException.ForInvalidCharacter(position, character, fullQuery); + + // Assert + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(character, exception.UnknownCharacter); + Assert.IsTrue(exception.RemainingQuery.StartsWith("`table_name"), $"Expected to start with '`table_name' but was '{exception.RemainingQuery}'"); + Assert.IsTrue(exception.SurroundingContext.Contains("FROM"), $"Expected to contain 'FROM' but was '{exception.SurroundingContext}'"); + Assert.IsTrue(exception.SurroundingContext.Contains("`table_nam"), $"Expected to contain '`table_nam' but was '{exception.SurroundingContext}'"); + } + + [TestMethod] + public void ForInvalidCharacter_WithPositionNearStart_ShouldHandleEdgeCase() + { + // Arrange + var position = 2; + var character = '`'; + var fullQuery = "SE`LECT * FROM table"; + + // Act + var exception = UnknownTokenException.ForInvalidCharacter(position, character, fullQuery); + + // Assert + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(character, exception.UnknownCharacter); + Assert.IsTrue(exception.RemainingQuery.StartsWith("`LECT")); + Assert.IsTrue(exception.SurroundingContext.StartsWith("SE")); + } + + [TestMethod] + public void ForInvalidCharacter_WithPositionNearEnd_ShouldHandleEdgeCase() + { + // Arrange + var position = 19; // Position of the backtick in "SELECT * FROM table`" + var character = '`'; + var fullQuery = "SELECT * FROM table`"; + + // Act + var exception = UnknownTokenException.ForInvalidCharacter(position, character, fullQuery); + + // Assert + Assert.AreEqual(position, exception.Position); + Assert.AreEqual(character, exception.UnknownCharacter); + Assert.IsTrue(exception.RemainingQuery.StartsWith("`")); + Assert.IsTrue(exception.SurroundingContext.Contains("FROM table`")); + } + + [TestMethod] + public void Constructor_WithNullParameters_ShouldHandleGracefully() + { + // Arrange + var position = 10; + var unknownChar = '`'; + + // Act + var exception = new UnknownTokenException(position, unknownChar, null, null); + + // Assert + Assert.AreEqual(string.Empty, exception.RemainingQuery); + Assert.AreEqual(string.Empty, exception.SurroundingContext); + Assert.IsTrue(exception.Message.Contains($"'{unknownChar}'")); + Assert.IsTrue(exception.Message.Contains($"position {position}")); + } +} \ No newline at end of file diff --git a/Musoq.Parser/Exceptions/QueryValidationException.cs b/Musoq.Parser/Exceptions/QueryValidationException.cs new file mode 100644 index 00000000..cd5a339d --- /dev/null +++ b/Musoq.Parser/Exceptions/QueryValidationException.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Musoq.Parser.Exceptions; + +/// +/// Exception thrown when query validation fails before parsing. +/// Provides early validation feedback to improve user experience. +/// +public class QueryValidationException : ArgumentException +{ + public string Query { get; } + public IEnumerable ValidationIssues { get; } + + public QueryValidationException(string query, IEnumerable issues, string message) + : base(message) + { + Query = query ?? string.Empty; + ValidationIssues = issues ?? Enumerable.Empty(); + } + + public static QueryValidationException ForMultipleIssues(string query, IEnumerable issues) + { + var issuesList = issues.ToList(); + var issuesText = string.Join("\n", issuesList.Select(i => $"- {i.Message} (Type: {i.Type})")); + + var message = $"Query validation failed with {issuesList.Count} issue(s):\n{issuesText}" + + "\n\nPlease fix these issues before running the query."; + + return new QueryValidationException(query, issuesList, message); + } + + public static QueryValidationException ForEmptyQuery() + { + var issue = new ValidationIssue(ValidationIssueType.EmptyQuery, "Query cannot be empty or null."); + return ForMultipleIssues(string.Empty, new[] { issue }); + } + + public static QueryValidationException ForInvalidCharacters(string query, char[] invalidChars) + { + var charsText = string.Join(", ", invalidChars.Select(c => $"'{c}'")); + var issue = new ValidationIssue( + ValidationIssueType.InvalidCharacters, + $"Query contains invalid characters: {charsText}. Please use only valid SQL characters." + ); + return ForMultipleIssues(query, new[] { issue }); + } + + public static QueryValidationException ForUnbalancedParentheses(string query) + { + var issue = new ValidationIssue( + ValidationIssueType.UnbalancedParentheses, + "Query has unbalanced parentheses. Please ensure all opening parentheses have matching closing parentheses." + ); + return ForMultipleIssues(query, new[] { issue }); + } + + public static QueryValidationException ForUnbalancedQuotes(string query) + { + var issue = new ValidationIssue( + ValidationIssueType.UnbalancedQuotes, + "Query has unbalanced quotes. Please ensure all opening quotes have matching closing quotes." + ); + return ForMultipleIssues(query, new[] { issue }); + } + + public static QueryValidationException ForSuspiciousPatterns(string query, string[] patterns) + { + var patternsText = string.Join(", ", patterns); + var issue = new ValidationIssue( + ValidationIssueType.SuspiciousPattern, + $"Query contains potentially problematic patterns: {patternsText}. Please review your query structure." + ); + return ForMultipleIssues(query, new[] { issue }); + } +} + +/// +/// Represents a specific validation issue found in a query. +/// +public class ValidationIssue +{ + public ValidationIssueType Type { get; } + public string Message { get; } + public int? Position { get; } + public string Suggestion { get; } + + public ValidationIssue(ValidationIssueType type, string message, int? position = null, string suggestion = null) + { + Type = type; + Message = message ?? string.Empty; + Position = position; + Suggestion = suggestion ?? string.Empty; + } +} + +/// +/// Types of validation issues that can occur in queries. +/// +public enum ValidationIssueType +{ + EmptyQuery, + InvalidCharacters, + UnbalancedParentheses, + UnbalancedQuotes, + SuspiciousPattern, + TooLong, + TooComplex, + MissingKeywords, + InvalidStructure +} \ No newline at end of file diff --git a/Musoq.Parser/Exceptions/SyntaxException.cs b/Musoq.Parser/Exceptions/SyntaxException.cs index ba3eff0b..6259cae4 100644 --- a/Musoq.Parser/Exceptions/SyntaxException.cs +++ b/Musoq.Parser/Exceptions/SyntaxException.cs @@ -1,18 +1,91 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace Musoq.Parser.Exceptions; +/// +/// Exception thrown when SQL syntax parsing fails. +/// Provides detailed context about the error location and helpful suggestions. +/// public class SyntaxException : Exception { public string QueryPart { get; } + public int? Position { get; } + public string ExpectedTokens { get; } + public string ActualToken { get; } - public SyntaxException(string message, string queryPart) : base(message) + public SyntaxException(string message, string queryPart, int? position = null, string expectedTokens = null, string actualToken = null) + : base(message) { - QueryPart = queryPart; + QueryPart = queryPart ?? string.Empty; + Position = position; + ExpectedTokens = expectedTokens ?? string.Empty; + ActualToken = actualToken ?? string.Empty; } - public SyntaxException(string message, string queryPart, Exception innerException) : base(message, innerException) + public SyntaxException(string message, string queryPart, Exception innerException, int? position = null, string expectedTokens = null, string actualToken = null) + : base(message, innerException) { - QueryPart = queryPart; + QueryPart = queryPart ?? string.Empty; + Position = position; + ExpectedTokens = expectedTokens ?? string.Empty; + ActualToken = actualToken ?? string.Empty; + } + + public static SyntaxException ForUnexpectedToken(string actualToken, string[] expectedTokens, string queryPart, int? position = null) + { + var expectedList = expectedTokens?.Length > 0 ? string.Join(", ", expectedTokens) : "valid SQL token"; + var positionText = position.HasValue ? $" at position {position}" : ""; + + var message = $"Unexpected token '{actualToken}'{positionText}. Expected: {expectedList}." + + $"\nQuery context: ...{queryPart}" + + "\n\nPlease check your SQL syntax for missing or incorrect tokens."; + + return new SyntaxException(message, queryPart, position, expectedList, actualToken); + } + + public static SyntaxException ForMissingToken(string expectedToken, string queryPart, int? position = null) + { + var positionText = position.HasValue ? $" at position {position}" : ""; + + var message = $"Missing required token '{expectedToken}'{positionText}." + + $"\nQuery context: ...{queryPart}" + + $"\n\nPlease add the missing '{expectedToken}' to your query."; + + return new SyntaxException(message, queryPart, position, expectedToken); + } + + public static SyntaxException ForInvalidStructure(string issue, string queryPart, int? position = null) + { + var positionText = position.HasValue ? $" at position {position}" : ""; + + var message = $"Invalid SQL structure{positionText}: {issue}" + + $"\nQuery context: ...{queryPart}" + + "\n\nPlease check the SQL query structure and syntax."; + + return new SyntaxException(message, queryPart, position); + } + + public static SyntaxException ForUnsupportedSyntax(string feature, string queryPart, int? position = null) + { + var positionText = position.HasValue ? $" at position {position}" : ""; + + var message = $"Unsupported SQL syntax{positionText}: {feature}" + + $"\nQuery context: ...{queryPart}" + + "\n\nPlease refer to the documentation for supported SQL syntax."; + + return new SyntaxException(message, queryPart, position); + } + + public static SyntaxException WithSuggestions(string baseMessage, string queryPart, string[] suggestions, int? position = null) + { + var suggestionsText = suggestions?.Length > 0 + ? $"\n\nSuggestions:\n{string.Join("\n", suggestions.Select(s => $"- {s}"))}" + : ""; + + var message = baseMessage + suggestionsText; + + return new SyntaxException(message, queryPart, position); } } \ No newline at end of file diff --git a/Musoq.Parser/Lexing/UnknownTokenException.cs b/Musoq.Parser/Lexing/UnknownTokenException.cs index 37830810..c70e0c2a 100644 --- a/Musoq.Parser/Lexing/UnknownTokenException.cs +++ b/Musoq.Parser/Lexing/UnknownTokenException.cs @@ -1,11 +1,67 @@ using System; +using System.Linq; namespace Musoq.Parser.Lexing; +/// +/// Exception thrown when the lexer encounters an unrecognized character or token sequence. +/// Provides context about the location and suggestions for resolution. +/// public class UnknownTokenException : Exception { - public UnknownTokenException(int position, char c, string s) - : base($"Token '{c}' that starts at position {position} was unrecognized. Rest of the unparsed query is '{s}'") + public int Position { get; } + public char UnknownCharacter { get; } + public string RemainingQuery { get; } + public string SurroundingContext { get; } + + public UnknownTokenException(int position, char unknownCharacter, string remainingQuery, string surroundingContext = null) + : base(GenerateMessage(position, unknownCharacter, remainingQuery, surroundingContext)) + { + Position = position; + UnknownCharacter = unknownCharacter; + RemainingQuery = remainingQuery ?? string.Empty; + SurroundingContext = surroundingContext ?? string.Empty; + } + + private static string GenerateMessage(int position, char unknownCharacter, string remainingQuery, string surroundingContext) { + var contextInfo = !string.IsNullOrEmpty(surroundingContext) + ? $"\nNear: '{surroundingContext}'" + : string.Empty; + + var suggestions = GetSuggestions(unknownCharacter); + var suggestionsText = suggestions.Any() + ? $"\n\nDid you mean: {string.Join(", ", suggestions)}" + : string.Empty; + + return $"Unrecognized character '{unknownCharacter}' at position {position}.{contextInfo}" + + $"\nRemaining query: '{remainingQuery?.Substring(0, Math.Min(50, remainingQuery?.Length ?? 0))}'" + + (remainingQuery?.Length > 50 ? "..." : "") + + suggestionsText + + "\n\nPlease check your SQL syntax for typos or unsupported characters."; + } + + private static string[] GetSuggestions(char unknownChar) + { + return unknownChar switch + { + '`' => new[] { "Use double quotes \" for identifiers", "Use single quotes ' for strings" }, + '[' or ']' => new[] { "Use double quotes \" for identifiers instead of brackets" }, + '{' or '}' => new[] { "Use parentheses ( ) for grouping expressions" }, + ';' => new[] { "Semicolon is not required at the end of queries" }, + '\\' => new[] { "Use forward slash / for division" }, + '?' => new[] { "Use parameters with @ or # prefix" }, + _ => new string[0] + }; + } + + public static UnknownTokenException ForInvalidCharacter(int position, char character, string fullQuery) + { + var start = Math.Max(0, position - 10); + var end = Math.Min(fullQuery.Length, position + 10); + var surroundingContext = fullQuery.Substring(start, end - start); + var remainingQuery = fullQuery.Substring(position); + + return new UnknownTokenException(position, character, remainingQuery, surroundingContext); } } \ No newline at end of file diff --git a/Musoq.Parser/Validation/QueryValidator.cs b/Musoq.Parser/Validation/QueryValidator.cs new file mode 100644 index 00000000..8dc10fb4 --- /dev/null +++ b/Musoq.Parser/Validation/QueryValidator.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Musoq.Parser.Exceptions; + +namespace Musoq.Parser.Validation; + +/// +/// Provides early validation of SQL queries before parsing to catch common issues +/// and provide user-friendly error messages with suggestions. +/// +public class QueryValidator +{ + private static readonly Dictionary CharacterSuggestions = new() + { + { '\\', new[] { "Use forward slash / for division operations" } }, + { '?', new[] { "Use parameters with @ or # prefix for parameterized queries" } } + }; + + private static readonly string[] SuspiciousPatterns = new[] + { + @"\bDROP\s+DATABASE\b", + @"\bTRUNCATE\s+TABLE\b" + }; + + private static readonly string[] RequiredKeywords = new[] { "SELECT" }; + + /// + /// Validates a query and returns validation issues if any are found. + /// + /// The SQL query to validate + /// List of validation issues, empty if query is valid + public List ValidateQuery(string query) + { + var issues = new List(); + + if (string.IsNullOrWhiteSpace(query)) + { + issues.Add(new ValidationIssue( + ValidationIssueType.EmptyQuery, + "Query cannot be empty or null.", + suggestion: "Please provide a valid SQL query starting with SELECT.")); + return issues; + } + + // Check for basic structural issues + ValidateBasicStructure(query, issues); + + // Check for balanced parentheses and quotes + ValidateBalancedDelimiters(query, issues); + + // Check for invalid characters + ValidateCharacters(query, issues); + + // Check for suspicious patterns + ValidateSuspiciousPatterns(query, issues); + + // Check for required keywords + ValidateRequiredKeywords(query, issues); + + // Check query complexity + ValidateComplexity(query, issues); + + return issues; + } + + /// + /// Validates a query and throws QueryValidationException if issues are found. + /// + /// The SQL query to validate + /// Thrown if validation issues are found + public void ValidateAndThrow(string query) + { + var issues = ValidateQuery(query); + if (issues.Any()) + { + throw QueryValidationException.ForMultipleIssues(query, issues); + } + } + + private void ValidateBasicStructure(string query, List issues) + { + var trimmedQuery = query.Trim(); + + if (trimmedQuery.Length > 10000) + { + issues.Add(new ValidationIssue( + ValidationIssueType.TooLong, + "Query is too long (over 10,000 characters).", + suggestion: "Consider breaking the query into smaller parts or simplifying the logic.")); + } + } + + private void ValidateBalancedDelimiters(string query, List issues) + { + // Check parentheses + var parenthesesBalance = 0; + var singleQuoteBalance = 0; + var doubleQuoteBalance = 0; + var inSingleQuotes = false; + var inDoubleQuotes = false; + + for (int i = 0; i < query.Length; i++) + { + var ch = query[i]; + + // Handle escaping + if (i > 0 && query[i - 1] == '\\') + continue; + + switch (ch) + { + case '\'': + if (!inDoubleQuotes) + { + inSingleQuotes = !inSingleQuotes; + singleQuoteBalance += inSingleQuotes ? 1 : -1; + } + break; + case '"': + if (!inSingleQuotes) + { + inDoubleQuotes = !inDoubleQuotes; + doubleQuoteBalance += inDoubleQuotes ? 1 : -1; + } + break; + case '(': + if (!inSingleQuotes && !inDoubleQuotes) + parenthesesBalance++; + break; + case ')': + if (!inSingleQuotes && !inDoubleQuotes) + parenthesesBalance--; + break; + } + } + + if (parenthesesBalance != 0) + { + issues.Add(new ValidationIssue( + ValidationIssueType.UnbalancedParentheses, + parenthesesBalance > 0 + ? $"Missing {parenthesesBalance} closing parenthesis(es)." + : $"Missing {-parenthesesBalance} opening parenthesis(es).", + suggestion: "Check that all opening parentheses have matching closing parentheses.")); + } + + if (singleQuoteBalance != 0) + { + issues.Add(new ValidationIssue( + ValidationIssueType.UnbalancedQuotes, + "Unbalanced single quotes in query.", + suggestion: "Check that all single quotes are properly closed.")); + } + + if (doubleQuoteBalance != 0) + { + issues.Add(new ValidationIssue( + ValidationIssueType.UnbalancedQuotes, + "Unbalanced double quotes in query.", + suggestion: "Check that all double quotes are properly closed.")); + } + } + + private void ValidateCharacters(string query, List issues) + { + var invalidChars = new List(); + var positions = new List(); + + for (int i = 0; i < query.Length; i++) + { + var ch = query[i]; + if (CharacterSuggestions.ContainsKey(ch)) + { + invalidChars.Add(ch); + positions.Add(i); + } + } + + if (invalidChars.Any()) + { + var uniqueChars = invalidChars.Distinct().ToArray(); + var suggestions = uniqueChars.SelectMany(c => CharacterSuggestions[c]).Distinct().ToArray(); + + issues.Add(new ValidationIssue( + ValidationIssueType.InvalidCharacters, + $"Query contains potentially problematic characters: {string.Join(", ", uniqueChars.Select(c => $"'{c}'"))}.", + positions.First(), + $"Suggestions: {string.Join("; ", suggestions)}")); + } + } + + private void ValidateSuspiciousPatterns(string query, List issues) + { + var upperQuery = query.ToUpperInvariant(); + var matchedPatterns = new List(); + + foreach (var pattern in SuspiciousPatterns) + { + var regex = new Regex(pattern, RegexOptions.IgnoreCase); + if (regex.IsMatch(upperQuery)) + { + var match = regex.Match(upperQuery); + matchedPatterns.Add(match.Value); + } + } + + if (matchedPatterns.Any()) + { + issues.Add(new ValidationIssue( + ValidationIssueType.SuspiciousPattern, + $"Query contains patterns typically used for data modification: {string.Join(", ", matchedPatterns)}.", + suggestion: "Musoq is designed for querying (read-only operations). Consider using appropriate data source operations if modification is intended.")); + } + } + + private void ValidateRequiredKeywords(string query, List issues) + { + var upperQuery = query.ToUpperInvariant(); + var missingKeywords = new List(); + + foreach (var keyword in RequiredKeywords) + { + if (!upperQuery.Contains(keyword)) + { + missingKeywords.Add(keyword); + } + } + + if (missingKeywords.Any()) + { + issues.Add(new ValidationIssue( + ValidationIssueType.MissingKeywords, + $"Query is missing required keywords: {string.Join(", ", missingKeywords)}.", + suggestion: "Musoq queries must follow SQL syntax and include SELECT and FROM clauses.")); + } + } + + private void ValidateComplexity(string query, List issues) + { + // Count nested levels + var maxNestingLevel = 0; + var currentLevel = 0; + var inQuotes = false; + + for (int i = 0; i < query.Length; i++) + { + var ch = query[i]; + + if (ch == '\'' || ch == '"') + { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes) + { + if (ch == '(') + { + currentLevel++; + maxNestingLevel = Math.Max(maxNestingLevel, currentLevel); + } + else if (ch == ')') + { + currentLevel--; + } + } + } + + if (maxNestingLevel > 10) + { + issues.Add(new ValidationIssue( + ValidationIssueType.TooComplex, + $"Query has very deep nesting (level {maxNestingLevel}).", + suggestion: "Consider breaking complex queries into simpler parts using Common Table Expressions (CTEs) or subqueries.")); + } + + // Count number of JOINs + var joinCount = Regex.Matches(query, @"\bJOIN\b", RegexOptions.IgnoreCase).Count; + if (joinCount > 5) + { + issues.Add(new ValidationIssue( + ValidationIssueType.TooComplex, + $"Query has many JOIN operations ({joinCount}).", + suggestion: "Consider optimizing the query or using data source-specific operations when possible.")); + } + } + + /// + /// Gets suggestions for improving a query based on common patterns. + /// + /// The SQL query to analyze + /// List of suggestions for query improvement + public List GetQuerySuggestions(string query) + { + var suggestions = new List(); + + if (string.IsNullOrWhiteSpace(query)) + return suggestions; + + // Check for missing schema references (only suggest, don't require) + if (query.Contains("FROM ") && !query.Contains("#") && !query.Contains("table ")) + { + suggestions.Add("Consider using schema references (e.g., #schema.table) for external data sources in Musoq."); + } + + // Check for potential performance issues + if (query.ToUpperInvariant().Contains("SELECT *")) + { + suggestions.Add("Consider selecting specific columns instead of * for better performance."); + } + + // Check for missing WHERE clauses on large data sources + var hasWhere = query.ToUpperInvariant().Contains("WHERE"); + var hasLimitOrTake = query.ToUpperInvariant().Contains("LIMIT") || query.ToUpperInvariant().Contains("TAKE"); + + if (!hasWhere && !hasLimitOrTake && query.ToUpperInvariant().Contains("FROM")) + { + suggestions.Add("Consider adding WHERE clause or TAKE/LIMIT to avoid processing large datasets."); + } + + return suggestions; + } +} \ No newline at end of file diff --git a/Musoq.Schema.Tests/DataSourceConnectionExceptionTests.cs b/Musoq.Schema.Tests/DataSourceConnectionExceptionTests.cs new file mode 100644 index 00000000..6a1af02e --- /dev/null +++ b/Musoq.Schema.Tests/DataSourceConnectionExceptionTests.cs @@ -0,0 +1,234 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Schema.Exceptions; +using System; + +namespace Musoq.Schema.Tests; + +[TestClass] +public class DataSourceConnectionExceptionTests +{ + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var dataSourceName = "test-source"; + var dataSourceType = "file"; + var message = "Connection failed"; + var connectionString = "path=/test/file.csv"; + + // Act + var exception = new DataSourceConnectionException(dataSourceName, dataSourceType, message, connectionString); + + // Assert + Assert.AreEqual(dataSourceName, exception.DataSourceName); + Assert.AreEqual(dataSourceType, exception.DataSourceType); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(connectionString, exception.ConnectionString); + } + + [TestMethod] + public void ForConnectionFailure_ShouldCreateAppropriateException() + { + // Arrange + var dataSourceName = "test-db"; + var dataSourceType = "database"; + var innerException = new InvalidOperationException("Network timeout"); + var connectionString = "server=localhost;database=test"; + + // Act + var exception = DataSourceConnectionException.ForConnectionFailure(dataSourceName, dataSourceType, innerException, connectionString); + + // Assert + Assert.AreEqual(dataSourceName, exception.DataSourceName); + Assert.AreEqual(dataSourceType, exception.DataSourceType); + Assert.AreEqual(connectionString, exception.ConnectionString); + Assert.AreEqual(innerException, exception.InnerException); + Assert.IsTrue(exception.Message.Contains("Failed to connect")); + Assert.IsTrue(exception.Message.Contains("Network connectivity")); + Assert.IsTrue(exception.Message.Contains("Connection parameters")); + } + + [TestMethod] + public void ForConnectionFailure_WithFileType_ShouldProvideFileAdvice() + { + // Arrange + var dataSourceName = "csv-source"; + var dataSourceType = "file"; + var innerException = new System.IO.FileNotFoundException(); + + // Act + var exception = DataSourceConnectionException.ForConnectionFailure(dataSourceName, dataSourceType, innerException); + + // Assert + Assert.IsTrue(exception.Message.Contains("file path exists and is accessible")); + } + + [TestMethod] + public void ForConnectionFailure_WithGitType_ShouldProvideGitAdvice() + { + // Arrange + var dataSourceName = "repo-source"; + var dataSourceType = "git"; + var innerException = new InvalidOperationException(); + + // Act + var exception = DataSourceConnectionException.ForConnectionFailure(dataSourceName, dataSourceType, innerException); + + // Assert + Assert.IsTrue(exception.Message.Contains("repository path is valid and accessible")); + } + + [TestMethod] + public void ForConnectionFailure_WithWebType_ShouldProvideWebAdvice() + { + // Arrange + var dataSourceName = "api-source"; + var dataSourceType = "web"; + var innerException = new System.Net.Http.HttpRequestException(); + + // Act + var exception = DataSourceConnectionException.ForConnectionFailure(dataSourceName, dataSourceType, innerException); + + // Assert + Assert.IsTrue(exception.Message.Contains("URL accessibility and network connectivity")); + } + + [TestMethod] + public void ForInitializationFailure_ShouldCreateAppropriateException() + { + // Arrange + var dataSourceName = "plugin-source"; + var dataSourceType = "custom"; + var innerException = new TypeLoadException("Could not load plugin"); + + // Act + var exception = DataSourceConnectionException.ForInitializationFailure(dataSourceName, dataSourceType, innerException); + + // Assert + Assert.AreEqual(dataSourceName, exception.DataSourceName); + Assert.AreEqual(dataSourceType, exception.DataSourceType); + Assert.AreEqual(innerException, exception.InnerException); + Assert.IsTrue(exception.Message.Contains("Failed to initialize")); + Assert.IsTrue(exception.Message.Contains("plugin installation")); + Assert.IsTrue(exception.Message.Contains("Configuration parameters")); + Assert.IsTrue(exception.Message.Contains("Required dependencies")); + } + + [TestMethod] + public void ForInvalidParameters_ShouldCreateAppropriateException() + { + // Arrange + var dataSourceName = "param-source"; + var dataSourceType = "file"; + var parameterIssue = "File path cannot be empty"; + + // Act + var exception = DataSourceConnectionException.ForInvalidParameters(dataSourceName, dataSourceType, parameterIssue); + + // Assert + Assert.AreEqual(dataSourceName, exception.DataSourceName); + Assert.AreEqual(dataSourceType, exception.DataSourceType); + Assert.IsTrue(exception.Message.Contains("Invalid parameters")); + Assert.IsTrue(exception.Message.Contains(parameterIssue)); + Assert.IsTrue(exception.Message.Contains("File sources typically require a valid file path")); + Assert.IsTrue(exception.Message.Contains("refer to the documentation")); + } + + [TestMethod] + public void ForInvalidParameters_WithDatabaseType_ShouldProvideDatabaseAdvice() + { + // Arrange + var dataSourceName = "db-source"; + var dataSourceType = "database"; + var parameterIssue = "Invalid connection string format"; + + // Act + var exception = DataSourceConnectionException.ForInvalidParameters(dataSourceName, dataSourceType, parameterIssue); + + // Assert + Assert.IsTrue(exception.Message.Contains("Database sources require a valid connection string")); + } + + [TestMethod] + public void ForInvalidParameters_WithWebType_ShouldProvideWebAdvice() + { + // Arrange + var dataSourceName = "web-source"; + var dataSourceType = "http"; + var parameterIssue = "Invalid URL format"; + + // Act + var exception = DataSourceConnectionException.ForInvalidParameters(dataSourceName, dataSourceType, parameterIssue); + + // Assert + Assert.IsTrue(exception.Message.Contains("Web sources require a valid URL")); + } + + [TestMethod] + public void ForTimeout_ShouldCreateAppropriateException() + { + // Arrange + var dataSourceName = "slow-source"; + var dataSourceType = "database"; + var timeout = TimeSpan.FromSeconds(30); + + // Act + var exception = DataSourceConnectionException.ForTimeout(dataSourceName, dataSourceType, timeout); + + // Assert + Assert.AreEqual(dataSourceName, exception.DataSourceName); + Assert.AreEqual(dataSourceType, exception.DataSourceType); + Assert.IsTrue(exception.Message.Contains("timed out after 30 seconds")); + Assert.IsTrue(exception.Message.Contains("Increasing the timeout value")); + Assert.IsTrue(exception.Message.Contains("network connectivity")); + Assert.IsTrue(exception.Message.Contains("data source performance")); + } + + [TestMethod] + public void Constructor_WithInnerException_ShouldSetInnerException() + { + // Arrange + var dataSourceName = "error-source"; + var dataSourceType = "file"; + var message = "Test error"; + var innerException = new ArgumentException("Inner error"); + + // Act + var exception = new DataSourceConnectionException(dataSourceName, dataSourceType, message, innerException); + + // Assert + Assert.AreEqual(innerException, exception.InnerException); + Assert.AreEqual(message, exception.Message); + } + + [TestMethod] + public void Constructor_WithNullParameters_ShouldUseEmptyStrings() + { + // Arrange + var message = "Test message"; + + // Act + var exception = new DataSourceConnectionException(null, null, message, null); + + // Assert + Assert.AreEqual(string.Empty, exception.DataSourceName); + Assert.AreEqual(string.Empty, exception.DataSourceType); + Assert.AreEqual(string.Empty, exception.ConnectionString); + Assert.AreEqual(message, exception.Message); + } + + [TestMethod] + public void ForInvalidParameters_WithUnknownType_ShouldProvideGenericAdvice() + { + // Arrange + var dataSourceName = "unknown-source"; + var dataSourceType = "unknown"; + var parameterIssue = "Some parameter issue"; + + // Act + var exception = DataSourceConnectionException.ForInvalidParameters(dataSourceName, dataSourceType, parameterIssue); + + // Assert + Assert.IsTrue(exception.Message.Contains("Check the parameter format required for this data source type")); + } +} \ No newline at end of file diff --git a/Musoq.Schema.Tests/PluginLoadExceptionTests.cs b/Musoq.Schema.Tests/PluginLoadExceptionTests.cs new file mode 100644 index 00000000..343e93f9 --- /dev/null +++ b/Musoq.Schema.Tests/PluginLoadExceptionTests.cs @@ -0,0 +1,237 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Musoq.Schema.Exceptions; +using System; +using System.Linq; + +namespace Musoq.Schema.Tests; + +[TestClass] +public class PluginLoadExceptionTests +{ + [TestMethod] + public void Constructor_WithAllParameters_ShouldSetProperties() + { + // Arrange + var pluginName = "TestPlugin"; + var message = "Plugin load failed"; + var pluginPath = "/path/to/plugin.dll"; + var availablePlugins = new[] { "Plugin1", "Plugin2" }; + + // Act + var exception = new PluginLoadException(pluginName, message, pluginPath, availablePlugins); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(pluginPath, exception.PluginPath); + Assert.AreEqual(2, exception.AvailablePlugins.Length); + Assert.IsTrue(exception.AvailablePlugins.Contains("Plugin1")); + Assert.IsTrue(exception.AvailablePlugins.Contains("Plugin2")); + } + + [TestMethod] + public void ForPluginNotFound_WithAvailablePlugins_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "MissingPlugin"; + var availablePlugins = new[] { "FilePlugin", "GitPlugin", "DatabasePlugin" }; + + // Act + var exception = PluginLoadException.ForPluginNotFound(pluginName, availablePlugins); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsTrue(exception.Message.Contains($"Plugin '{pluginName}' was not found")); + Assert.IsTrue(exception.Message.Contains("Available plugins:")); + Assert.IsTrue(exception.Message.Contains("- FilePlugin")); + Assert.IsTrue(exception.Message.Contains("- GitPlugin")); + Assert.IsTrue(exception.Message.Contains("- DatabasePlugin")); + Assert.IsTrue(exception.Message.Contains("Plugin name spelling")); + Assert.IsTrue(exception.Message.Contains("Plugin installation")); + Assert.AreEqual(3, exception.AvailablePlugins.Length); + } + + [TestMethod] + public void ForPluginNotFound_WithNoAvailablePlugins_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "MissingPlugin"; + var availablePlugins = new string[0]; + + // Act + var exception = PluginLoadException.ForPluginNotFound(pluginName, availablePlugins); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsTrue(exception.Message.Contains($"Plugin '{pluginName}' was not found")); + Assert.IsTrue(exception.Message.Contains("No plugins are currently loaded")); + Assert.IsFalse(exception.Message.Contains("Available plugins:")); + Assert.AreEqual(0, exception.AvailablePlugins.Length); + } + + [TestMethod] + public void ForLoadFailure_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "FailedPlugin"; + var pluginPath = "/path/to/failed.dll"; + var innerException = new System.IO.FileLoadException("Could not load assembly"); + + // Act + var exception = PluginLoadException.ForLoadFailure(pluginName, pluginPath, innerException); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.AreEqual(pluginPath, exception.PluginPath); + Assert.AreEqual(innerException, exception.InnerException); + Assert.IsTrue(exception.Message.Contains($"Failed to load plugin '{pluginName}'")); + Assert.IsTrue(exception.Message.Contains($"from '{pluginPath}'")); + Assert.IsTrue(exception.Message.Contains("Missing dependencies")); + Assert.IsTrue(exception.Message.Contains("Incompatible .NET version")); + Assert.IsTrue(exception.Message.Contains("Corrupted assembly file")); + Assert.IsTrue(exception.Message.Contains("Security restrictions")); + } + + [TestMethod] + public void ForRegistrationFailure_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "BadPlugin"; + var innerException = new TypeLoadException("Invalid plugin type"); + + // Act + var exception = PluginLoadException.ForRegistrationFailure(pluginName, innerException); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.AreEqual(innerException, exception.InnerException); + Assert.IsTrue(exception.Message.Contains($"Failed to register plugin '{pluginName}'")); + Assert.IsTrue(exception.Message.Contains("loaded but could not be properly initialized")); + Assert.IsTrue(exception.Message.Contains("Invalid plugin configuration")); + Assert.IsTrue(exception.Message.Contains("Missing required interfaces")); + Assert.IsTrue(exception.Message.Contains("Initialization errors")); + Assert.IsTrue(exception.Message.Contains("Dependency conflicts")); + } + + [TestMethod] + public void ForDuplicateRegistration_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "DuplicatePlugin"; + var existingPluginInfo = "Version 1.0, loaded from /path/to/existing.dll"; + + // Act + var exception = PluginLoadException.ForDuplicateRegistration(pluginName, existingPluginInfo); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsTrue(exception.Message.Contains($"plugin named '{pluginName}' is already registered")); + Assert.IsTrue(exception.Message.Contains($"Existing plugin: {existingPluginInfo}")); + Assert.IsTrue(exception.Message.Contains("Plugin names must be unique")); + Assert.IsTrue(exception.Message.Contains("Use a different plugin name")); + Assert.IsTrue(exception.Message.Contains("Unregister the existing plugin")); + Assert.IsTrue(exception.Message.Contains("duplicate plugin installations")); + } + + [TestMethod] + public void ForInvalidInterface_ShouldCreateAppropriateException() + { + // Arrange + var pluginName = "InvalidPlugin"; + var expectedInterface = "IDataSourcePlugin"; + + // Act + var exception = PluginLoadException.ForInvalidInterface(pluginName, expectedInterface); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsTrue(exception.Message.Contains($"Plugin '{pluginName}' does not implement")); + Assert.IsTrue(exception.Message.Contains($"required interface '{expectedInterface}'")); + Assert.IsTrue(exception.Message.Contains("implement the proper interfaces")); + Assert.IsTrue(exception.Message.Contains("Implements the correct interface")); + Assert.IsTrue(exception.Message.Contains("proper method signatures")); + Assert.IsTrue(exception.Message.Contains("compatible Musoq libraries")); + } + + [TestMethod] + public void Constructor_WithInnerException_ShouldSetInnerException() + { + // Arrange + var pluginName = "ErrorPlugin"; + var message = "Plugin error"; + var innerException = new InvalidOperationException("Inner error"); + var pluginPath = "/path/to/plugin.dll"; + + // Act + var exception = new PluginLoadException(pluginName, message, innerException, pluginPath); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.AreEqual(message, exception.Message); + Assert.AreEqual(innerException, exception.InnerException); + Assert.AreEqual(pluginPath, exception.PluginPath); + } + + [TestMethod] + public void Constructor_WithNullParameters_ShouldUseEmptyValues() + { + // Arrange + var message = "Test message"; + + // Act + var exception = new PluginLoadException(null, message, null, null); + + // Assert + Assert.AreEqual(string.Empty, exception.PluginName); + Assert.AreEqual(string.Empty, exception.PluginPath); + Assert.AreEqual(0, exception.AvailablePlugins.Length); + Assert.AreEqual(message, exception.Message); + } + + [TestMethod] + public void ForPluginNotFound_WithNullAvailablePlugins_ShouldHandleGracefully() + { + // Arrange + var pluginName = "TestPlugin"; + + // Act + var exception = PluginLoadException.ForPluginNotFound(pluginName, null); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsTrue(exception.Message.Contains("No plugins are currently loaded")); + Assert.AreEqual(0, exception.AvailablePlugins.Length); + } + + [TestMethod] + public void ForLoadFailure_WithNullInnerException_ShouldStillCreateException() + { + // Arrange + var pluginName = "TestPlugin"; + var pluginPath = "/path/to/plugin.dll"; + + // Act + var exception = PluginLoadException.ForLoadFailure(pluginName, pluginPath, null); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.AreEqual(pluginPath, exception.PluginPath); + Assert.IsNull(exception.InnerException); + Assert.IsTrue(exception.Message.Contains("Failed to load plugin")); + } + + [TestMethod] + public void ForRegistrationFailure_WithNullInnerException_ShouldStillCreateException() + { + // Arrange + var pluginName = "TestPlugin"; + + // Act + var exception = PluginLoadException.ForRegistrationFailure(pluginName, null); + + // Assert + Assert.AreEqual(pluginName, exception.PluginName); + Assert.IsNull(exception.InnerException); + Assert.IsTrue(exception.Message.Contains("Failed to register plugin")); + } +} \ No newline at end of file diff --git a/Musoq.Schema/Exceptions/DataSourceConnectionException.cs b/Musoq.Schema/Exceptions/DataSourceConnectionException.cs new file mode 100644 index 00000000..7a9d70cd --- /dev/null +++ b/Musoq.Schema/Exceptions/DataSourceConnectionException.cs @@ -0,0 +1,102 @@ +using System; + +namespace Musoq.Schema.Exceptions; + +/// +/// Exception thrown when data source connection or initialization fails. +/// Provides specific guidance for different types of connection issues. +/// +public class DataSourceConnectionException : InvalidOperationException +{ + public string DataSourceName { get; } + public string ConnectionString { get; } + public string DataSourceType { get; } + + public DataSourceConnectionException(string dataSourceName, string dataSourceType, string message, string connectionString = null) + : base(message) + { + DataSourceName = dataSourceName ?? string.Empty; + DataSourceType = dataSourceType ?? string.Empty; + ConnectionString = connectionString ?? string.Empty; + } + + public DataSourceConnectionException(string dataSourceName, string dataSourceType, string message, Exception innerException, string connectionString = null) + : base(message, innerException) + { + DataSourceName = dataSourceName ?? string.Empty; + DataSourceType = dataSourceType ?? string.Empty; + ConnectionString = connectionString ?? string.Empty; + } + + public static DataSourceConnectionException ForConnectionFailure(string dataSourceName, string dataSourceType, Exception innerException, string connectionString = null) + { + var message = $"Failed to connect to data source '{dataSourceName}' of type '{dataSourceType}'. " + + GetConnectionAdvice(dataSourceType) + + "\n\nPlease check:\n" + + "- Network connectivity\n" + + "- Connection parameters\n" + + "- Authentication credentials\n" + + "- Data source availability"; + + return new DataSourceConnectionException(dataSourceName, dataSourceType, message, innerException, connectionString); + } + + public static DataSourceConnectionException ForInitializationFailure(string dataSourceName, string dataSourceType, Exception innerException) + { + var message = $"Failed to initialize data source '{dataSourceName}' of type '{dataSourceType}'. " + + "This usually indicates a configuration or compatibility issue. " + + "\n\nPlease check:\n" + + "- Data source plugin installation\n" + + "- Configuration parameters\n" + + "- Required dependencies\n" + + "- System permissions"; + + return new DataSourceConnectionException(dataSourceName, dataSourceType, message, innerException); + } + + public static DataSourceConnectionException ForInvalidParameters(string dataSourceName, string dataSourceType, string parameterIssue) + { + var message = $"Invalid parameters for data source '{dataSourceName}' of type '{dataSourceType}': {parameterIssue}. " + + GetParameterAdvice(dataSourceType) + + "\n\nPlease refer to the documentation for correct parameter format and values."; + + return new DataSourceConnectionException(dataSourceName, dataSourceType, message); + } + + public static DataSourceConnectionException ForTimeout(string dataSourceName, string dataSourceType, TimeSpan timeout) + { + var message = $"Connection to data source '{dataSourceName}' of type '{dataSourceType}' timed out after {timeout.TotalSeconds} seconds. " + + "This may indicate network issues or high load on the data source. " + + "\n\nPlease try:\n" + + "- Increasing the timeout value\n" + + "- Checking network connectivity\n" + + "- Verifying data source performance\n" + + "- Using more specific queries to reduce load"; + + return new DataSourceConnectionException(dataSourceName, dataSourceType, message); + } + + private static string GetConnectionAdvice(string dataSourceType) + { + return dataSourceType?.ToLowerInvariant() switch + { + "file" or "os" => "For file-based sources, ensure the file path exists and is accessible.", + "git" => "For Git repositories, ensure the repository path is valid and accessible.", + "database" or "sql" => "For database connections, verify the connection string and database availability.", + "web" or "http" or "api" => "For web-based sources, check URL accessibility and network connectivity.", + _ => "Please verify the data source configuration and accessibility." + }; + } + + private static string GetParameterAdvice(string dataSourceType) + { + return dataSourceType?.ToLowerInvariant() switch + { + "file" or "os" => "File sources typically require a valid file path.", + "git" => "Git sources require a valid repository path or URL.", + "database" or "sql" => "Database sources require a valid connection string.", + "web" or "http" or "api" => "Web sources require a valid URL and may need authentication.", + _ => "Check the parameter format required for this data source type." + }; + } +} \ No newline at end of file diff --git a/Musoq.Schema/Exceptions/PluginLoadException.cs b/Musoq.Schema/Exceptions/PluginLoadException.cs new file mode 100644 index 00000000..5b813f62 --- /dev/null +++ b/Musoq.Schema/Exceptions/PluginLoadException.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Musoq.Schema.Exceptions; + +/// +/// Exception thrown when plugin loading or registration fails. +/// Provides detailed information about plugin issues and resolution steps. +/// +public class PluginLoadException : InvalidOperationException +{ + public string PluginName { get; } + public string PluginPath { get; } + public string[] AvailablePlugins { get; } + + public PluginLoadException(string pluginName, string message, string pluginPath = null, string[] availablePlugins = null) + : base(message) + { + PluginName = pluginName ?? string.Empty; + PluginPath = pluginPath ?? string.Empty; + AvailablePlugins = availablePlugins ?? new string[0]; + } + + public PluginLoadException(string pluginName, string message, Exception innerException, string pluginPath = null, string[] availablePlugins = null) + : base(message, innerException) + { + PluginName = pluginName ?? string.Empty; + PluginPath = pluginPath ?? string.Empty; + AvailablePlugins = availablePlugins ?? new string[0]; + } + + public static PluginLoadException ForPluginNotFound(string pluginName, string[] availablePlugins) + { + var availableText = availablePlugins?.Length > 0 + ? $"\n\nAvailable plugins:\n{string.Join("\n", availablePlugins.Select(p => $"- {p}"))}" + : "\n\nNo plugins are currently loaded."; + + var message = $"Plugin '{pluginName}' was not found.{availableText}" + + "\n\nPlease check:\n" + + "- Plugin name spelling\n" + + "- Plugin installation\n" + + "- Plugin registration\n" + + "- Assembly loading path"; + + return new PluginLoadException(pluginName, message, availablePlugins: availablePlugins); + } + + public static PluginLoadException ForLoadFailure(string pluginName, string pluginPath, Exception innerException) + { + var message = $"Failed to load plugin '{pluginName}' from '{pluginPath}'. " + + "This usually indicates a compatibility or dependency issue. " + + "\n\nPossible causes:\n" + + "- Missing dependencies\n" + + "- Incompatible .NET version\n" + + "- Corrupted assembly file\n" + + "- Security restrictions\n" + + "\nPlease ensure the plugin is compatible and all dependencies are available."; + + return new PluginLoadException(pluginName, message, innerException, pluginPath); + } + + public static PluginLoadException ForRegistrationFailure(string pluginName, Exception innerException) + { + var message = $"Failed to register plugin '{pluginName}'. " + + "The plugin was loaded but could not be properly initialized. " + + "\n\nThis may be due to:\n" + + "- Invalid plugin configuration\n" + + "- Missing required interfaces\n" + + "- Initialization errors\n" + + "- Dependency conflicts\n" + + "\nPlease check the plugin implementation and configuration."; + + return new PluginLoadException(pluginName, message, innerException); + } + + public static PluginLoadException ForDuplicateRegistration(string pluginName, string existingPluginInfo) + { + var message = $"A plugin named '{pluginName}' is already registered. " + + $"Existing plugin: {existingPluginInfo}" + + "\n\nPlugin names must be unique. Please:\n" + + "- Use a different plugin name\n" + + "- Unregister the existing plugin first\n" + + "- Check for duplicate plugin installations"; + + return new PluginLoadException(pluginName, message); + } + + public static PluginLoadException ForInvalidInterface(string pluginName, string expectedInterface) + { + var message = $"Plugin '{pluginName}' does not implement the required interface '{expectedInterface}'. " + + "All plugins must implement the proper interfaces to be compatible with Musoq. " + + "\n\nPlease ensure the plugin:\n" + + "- Implements the correct interface\n" + + "- Has proper method signatures\n" + + "- Is compiled against compatible Musoq libraries"; + + return new PluginLoadException(pluginName, message); + } +} \ No newline at end of file