diff --git a/src/Searchlight/Exceptions/InvalidEngineSetting.cs b/src/Searchlight/Exceptions/InvalidEngineSetting.cs new file mode 100644 index 0000000..b887489 --- /dev/null +++ b/src/Searchlight/Exceptions/InvalidEngineSetting.cs @@ -0,0 +1,27 @@ +namespace Searchlight.Exceptions +{ + /// + /// Exception to be thrown if the SearchlightEngine was configured incorrectly + /// + public class InvalidEngineSetting : SearchlightException + { + public string OriginalFilter { get; internal set; } + + /// + /// Fields that are missing or incorrect + /// + public string[] Fields { get; set; } + + public string ErrorMessage => + $"These fields are either missing or are set incorrectly: {string.Join(",", Fields)}"; + + /// + /// Constructor + /// + /// + public InvalidEngineSetting(params string[] fields) + { + Fields = fields; + } + } +} \ No newline at end of file diff --git a/src/Searchlight/LinqExecutor.cs b/src/Searchlight/LinqExecutor.cs index 4e16b9d..d9df3a1 100644 --- a/src/Searchlight/LinqExecutor.cs +++ b/src/Searchlight/LinqExecutor.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using System.Runtime.CompilerServices; using Searchlight.Parsing; using Searchlight.Query; @@ -149,6 +150,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base Expression field; Expression value; Expression result; + var comparison = src?.Engine?.StringComparison ?? StringComparison.OrdinalIgnoreCase; var t = typeof(T); @@ -179,7 +181,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base // ReSharper disable once AssignNullToNotNullAttribute typeof(string).GetMethod("Equals", new [] { typeof(string), typeof(string), typeof(StringComparison) }), - field, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)); + field, value, Expression.Constant(comparison)); } else { @@ -197,7 +199,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base { typeof(string), typeof(string), typeof(StringComparison) }), - field, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + field, value, Expression.Constant(comparison)), Expression.Constant(0))); } else @@ -216,7 +218,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base { typeof(string), typeof(string), typeof(StringComparison) }), - field, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + field, value, Expression.Constant(comparison)), Expression.Constant(0))); } else @@ -235,7 +237,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base { typeof(string), typeof(string), typeof(StringComparison) }), - field, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + field, value, Expression.Constant(comparison)), Expression.Constant(0))); } else @@ -254,7 +256,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base { typeof(string), typeof(string), typeof(StringComparison) }), - field, value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + field, value, Expression.Constant(comparison)), Expression.Constant(0))); } else @@ -268,7 +270,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base // ReSharper disable once AssignNullToNotNullAttribute typeof(string).GetMethod("StartsWith", new [] { typeof(string), typeof(StringComparison) }), - value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + value, Expression.Constant(comparison)), Expression.MakeCatchBlock(typeof(Exception), null, Expression.Constant(false, typeof(Boolean)), null) ); @@ -280,7 +282,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base // ReSharper disable once AssignNullToNotNullAttribute typeof(string).GetMethod("EndsWith", new [] { typeof(string), typeof(StringComparison) }), - value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + value, Expression.Constant(comparison)), Expression.MakeCatchBlock(typeof(Exception), null, Expression.Constant(false, typeof(Boolean)), null) ); @@ -291,7 +293,7 @@ private static Expression BuildOneExpression(ParameterExpression select, Base // ReSharper disable once AssignNullToNotNullAttribute typeof(string).GetMethod("Contains", new [] { typeof(string), typeof(StringComparison) }), - value, Expression.Constant(StringComparison.OrdinalIgnoreCase)), + value, Expression.Constant(comparison)), Expression.MakeCatchBlock(typeof(Exception), null, Expression.Constant(false, typeof(Boolean)), null) ); diff --git a/src/Searchlight/SearchlightEngine.cs b/src/Searchlight/SearchlightEngine.cs index 24adce6..d19c01c 100644 --- a/src/Searchlight/SearchlightEngine.cs +++ b/src/Searchlight/SearchlightEngine.cs @@ -65,6 +65,23 @@ public class SearchlightEngine /// public bool useNoCount { get; set; } = true; + /// + /// Whether or not to use case sensitive comparisons + /// + /// Note: Odd numbers in the StringComparison enum are case insensitive + /// + public bool CaseSensitiveComparison => (int)StringComparison % 2 == 0; + + /// + /// The string comparison for the engine + /// + public StringComparison StringComparison { get; set; } = StringComparison.OrdinalIgnoreCase; + + /// + /// The collation to use for case sensitive comparisons, must be specified for SQL Server + /// + public string Collation { get; set; } = string.Empty; + /// /// Adds a new class to the engine /// diff --git a/src/Searchlight/SqlExecutor.cs b/src/Searchlight/SqlExecutor.cs index e1eafb5..6b2c671 100644 --- a/src/Searchlight/SqlExecutor.cs +++ b/src/Searchlight/SqlExecutor.cs @@ -62,7 +62,7 @@ public static SqlQuery ToPostgresCommand(this SyntaxTree query) private static SqlQuery CreateSql(SqlDialect dialect, SyntaxTree query, SearchlightEngine engine) { var sql = new SqlQuery() { Syntax = query }; - sql.WhereClause = RenderJoinedClauses(dialect, query.Filter, sql); + sql.WhereClause = RenderJoinedClauses(dialect, query.Filter, sql, engine); sql.OrderByClause = RenderOrderByClause(query.OrderBy); // Sanity test - is the query too complicated to be safe to run? @@ -171,8 +171,9 @@ private static string RenderOrderByClause(List list) /// /// /// + /// /// - private static string RenderJoinedClauses(SqlDialect dialect, List clause, SqlQuery sql) + private static string RenderJoinedClauses(SqlDialect dialect, List clause, SqlQuery sql, SearchlightEngine engine) { var sb = new StringBuilder(); for (var i = 0; i < clause.Count; i++) @@ -193,7 +194,7 @@ private static string RenderJoinedClauses(SqlDialect dialect, List c } } - sb.Append(RenderClause(dialect, clause[i], sql)); + sb.Append(RenderClause(dialect, clause[i], sql, engine)); } return sb.ToString(); @@ -205,9 +206,10 @@ private static string RenderJoinedClauses(SqlDialect dialect, List c /// /// /// + /// /// /// - private static string RenderClause(SqlDialect dialect, BaseClause clause, SqlQuery sql) + private static string RenderClause(SqlDialect dialect, BaseClause clause, SqlQuery sql, SearchlightEngine engine) { switch (clause) { @@ -215,7 +217,7 @@ private static string RenderClause(SqlDialect dialect, BaseClause clause, SqlQue return $"{bc.Column.OriginalName} {(bc.Negated ? "NOT " : "")}BETWEEN {sql.AddParameter(bc.LowerValue.GetValue(), bc.Column.FieldType)} AND {sql.AddParameter(bc.UpperValue.GetValue(), bc.Column.FieldType)}"; case CompoundClause compoundClause: - return $"({RenderJoinedClauses(dialect, compoundClause.Children, sql)})"; + return $"({RenderJoinedClauses(dialect, compoundClause.Children, sql, engine)})"; case CriteriaClause cc: var rawValue = cc.Value.GetValue(); switch (cc.Operation) @@ -226,7 +228,8 @@ private static string RenderClause(SqlDialect dialect, BaseClause clause, SqlQue case OperationType.LessThan: case OperationType.LessThanOrEqual: case OperationType.NotEqual: - return RenderComparisonClause(cc.Column.OriginalName, cc.Negated, cc.Operation, sql.AddParameter(rawValue, cc.Column.FieldType)); + return RenderComparisonClause(dialect, cc, + sql.AddParameter(rawValue, cc.Column.FieldType), engine); case OperationType.Contains: return RenderLikeClause(dialect, cc, sql, rawValue, "%", "%"); case OperationType.StartsWith: @@ -257,15 +260,51 @@ private static string RenderClause(SqlDialect dialect, BaseClause clause, SqlQue { OperationType.GreaterThanOrEqual, new Tuple(">=", "<") }, }; - private static string RenderComparisonClause(string column, bool negated, OperationType op, string parameter) + private static string RenderComparisonClause(SqlDialect dialect, CriteriaClause cc, string parameter, SearchlightEngine engine) { + var op = cc.Operation; + var negated = cc.Negated; + var column = cc.Column.OriginalName; + var fieldType = cc.Column.FieldType; + if (!CanonicalOps.TryGetValue(op, out var opstrings)) { throw new Exception($"Invalid comparison type {op}"); } var operationSymbol = negated ? opstrings.Item2 : opstrings.Item1; - return $"{column} {operationSymbol} {parameter}"; + + if (engine.CaseSensitiveComparison) + { + switch (dialect) + { + case SqlDialect.MicrosoftSqlServer: + if (string.IsNullOrEmpty(engine.Collation)) + { + throw new InvalidEngineSetting(nameof(SearchlightEngine.Collation)); + } + + return $"{column} {operationSymbol} {parameter} COLLATE {engine.Collation}"; + case SqlDialect.MySql: + return $"{column} {operationSymbol} BINARY {parameter}"; + case SqlDialect.PostgreSql: + default: + return $"{column} {operationSymbol} {parameter}"; + } + } + + // Case insensitive comparison + switch (dialect) + { + case SqlDialect.PostgreSql: + return fieldType == typeof(string) + ? $"LOWER({column}) {operationSymbol} LOWER({parameter})" + : $"{column} {operationSymbol} {parameter}"; + case SqlDialect.MySql: + case SqlDialect.MicrosoftSqlServer: + default: + return $"{column} {operationSymbol} {parameter}"; + } } private static string RenderLikeClause(SqlDialect dialect, CriteriaClause clause, SqlQuery sql, object rawValue, @@ -285,8 +324,29 @@ private static string RenderLikeClause(SqlDialect dialect, CriteriaClause clause var escapeCommand = dialect == SqlDialect.MicrosoftSqlServer ? " ESCAPE '\\'" : string.Empty; var notCommand = clause.Negated ? "NOT " : ""; var likeValue = prefix + EscapeLikeValue(stringValue) + suffix; - return - $"{clause.Column.OriginalName} {notCommand}{likeCommand} {sql.AddParameter(likeValue, clause.Column.FieldType)}{escapeCommand}"; + + if (!(sql.Syntax?.Source?.Engine?.CaseSensitiveComparison ?? false)) + return + $"{clause.Column.OriginalName} {notCommand}{likeCommand} {sql.AddParameter(likeValue, clause.Column.FieldType)}{escapeCommand}"; + + switch (dialect) + { + case SqlDialect.MySql: + return + $"{clause.Column.OriginalName} {notCommand}{likeCommand} BINARY {sql.AddParameter(likeValue, clause.Column.FieldType)}{escapeCommand}"; + case SqlDialect.MicrosoftSqlServer: + if (string.IsNullOrEmpty(sql.Syntax?.Source?.Engine?.Collation)) + { + throw new InvalidEngineSetting(nameof(SearchlightEngine.Collation)); + } + + return + $"{clause.Column.OriginalName} {notCommand}{likeCommand} {sql.AddParameter(likeValue, clause.Column.FieldType)}{escapeCommand} COLLATE {sql.Syntax.Source.Engine.Collation}"; + case SqlDialect.PostgreSql: + default: + return + $"{clause.Column.OriginalName} {notCommand}{likeCommand} {sql.AddParameter(likeValue, clause.Column.FieldType)}{escapeCommand}"; + } } private static string EscapeLikeValue(string stringValue) diff --git a/tests/Searchlight.Tests/Executors/EmployeeTestSuite.cs b/tests/Searchlight.Tests/Executors/EmployeeTestSuite.cs index 0db1654..635be0a 100644 --- a/tests/Searchlight.Tests/Executors/EmployeeTestSuite.cs +++ b/tests/Searchlight.Tests/Executors/EmployeeTestSuite.cs @@ -16,6 +16,7 @@ public class EmployeeTestSuite private readonly DataSource _src; private readonly List _list; private readonly Func>> _executor; + private readonly StringComparison _comparison; private EmployeeTestSuite(DataSource src, List list, Func>> executor) @@ -23,6 +24,7 @@ private EmployeeTestSuite(DataSource src, List list, _src = src; _list = list; _executor = executor; + _comparison = src?.Engine?.StringComparison ?? StringComparison.OrdinalIgnoreCase; } /// @@ -75,6 +77,23 @@ public static async Task CaseInsensitiveStringTestSuite(DataSource src, List + /// Validates correctness of this executor's ability to execute case insensitive string comparisons + /// + /// + /// + /// + public static async Task CaseSensitiveStringTestSuite(DataSource src, List list, + Func>> executor) + { + var suite = new EmployeeTestSuite(src, list, executor); + await suite.LessThanOrEqualQuery(true); + await suite.LessThanQuery(true); + await suite.GreaterThanQuery(true); + await suite.GreaterThanOrEqualQuery(true); + await suite.StringEqualsCaseSensitive(); + } private async Task QueryListCollection() { @@ -200,7 +219,7 @@ private async Task EndsWithQuery() Assert.AreEqual(2, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(e.name.EndsWith("s", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(e.name.EndsWith("s", _comparison)); } } @@ -219,7 +238,7 @@ private async Task ContainsQuery() Assert.AreEqual(9, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(e != null && e.name.Contains('s', StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(e != null && e.name.Contains('s', _comparison)); } // Now test the opposite @@ -229,7 +248,7 @@ private async Task ContainsQuery() foreach (var e in results.records) { Assert.IsTrue( - e != null && (e.name == null || !e.name.Contains('s', StringComparison.OrdinalIgnoreCase))); + e != null && (e.name == null || !e.name.Contains('s', _comparison))); } // Test for the presence of special characters that might cause problems for parsing @@ -242,77 +261,81 @@ private async Task ContainsQuery() } } - private async Task GreaterThanQuery() + private async Task GreaterThanQuery(bool caseSensitive = false) { - var syntax = _src.ParseFilter("name gt 'b'"); + var parameter = caseSensitive ? "B" : "b"; + var syntax = _src.ParseFilter($"name gt '{parameter}'"); Assert.AreEqual(1, syntax.Filter.Count); Assert.AreEqual(ConjunctionType.NONE, syntax.Filter[0].Conjunction); Assert.AreEqual("name", ((CriteriaClause)syntax.Filter[0]).Column.FieldName); Assert.AreEqual(OperationType.GreaterThan, ((CriteriaClause)syntax.Filter[0]).Operation); - Assert.AreEqual("b", ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); + Assert.AreEqual(parameter, ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); // Execute the query and ensure that each result matches var results = await _executor(syntax); Assert.AreEqual(8, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(string.Compare(e.name, "b", StringComparison.CurrentCultureIgnoreCase) > 0); + Assert.IsTrue(string.Compare(e.name, parameter, _comparison) > 0); } } - - private async Task GreaterThanOrEqualQuery() + + private async Task GreaterThanOrEqualQuery(bool caseSensitive = false) { - var syntax = _src.ParseFilter("name ge 'bob rogers'"); + var parameter = caseSensitive ? "Bob Rogers" : "bob rogers"; + var syntax = _src.ParseFilter($"name ge '{parameter}'"); Assert.AreEqual(1, syntax.Filter.Count); Assert.AreEqual(ConjunctionType.NONE, syntax.Filter[0].Conjunction); Assert.AreEqual("name", ((CriteriaClause)syntax.Filter[0]).Column.FieldName); Assert.AreEqual(OperationType.GreaterThanOrEqual, ((CriteriaClause)syntax.Filter[0]).Operation); - Assert.AreEqual("bob rogers", ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); + Assert.AreEqual(parameter, ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); // Execute the query and ensure that each result matches var results = await _executor(syntax); Assert.AreEqual(7, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(string.Compare(e.name[.."bob rogers".Length], "bob rogers", - StringComparison.CurrentCultureIgnoreCase) >= 0); + Assert.IsTrue(string.Compare(e.name[..parameter.Length], parameter, + _comparison) >= 0); } } - private async Task LessThanQuery() + private async Task LessThanQuery(bool caseSensitive = false) { - var syntax = _src.ParseFilter("name lt 'b'"); + var parameter = caseSensitive ? "B" : "b"; + var syntax = _src.ParseFilter($"name lt '{parameter}'"); Assert.AreEqual(1, syntax.Filter.Count); Assert.AreEqual(ConjunctionType.NONE, syntax.Filter[0].Conjunction); Assert.AreEqual("name", ((CriteriaClause)syntax.Filter[0]).Column.FieldName); Assert.AreEqual(OperationType.LessThan, ((CriteriaClause)syntax.Filter[0]).Operation); - Assert.AreEqual("b", ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); + Assert.AreEqual(parameter, ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); // Execute the query and ensure that each result matches var results = await _executor(syntax); Assert.AreEqual(1, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(string.Compare(e.name, "b", StringComparison.CurrentCultureIgnoreCase) < 0); + Assert.IsTrue(string.Compare(e.name, parameter, _comparison) < 0); } } - private async Task LessThanOrEqualQuery() + private async Task LessThanOrEqualQuery(bool caseSensitive = false) { - var syntax = _src.ParseFilter("name le 'bob rogers'"); + var parameter = caseSensitive ? "Bob Rogers" : "bob rogers"; + var syntax = _src.ParseFilter($"name le '{parameter}'"); Assert.AreEqual(1, syntax.Filter.Count); Assert.AreEqual(ConjunctionType.NONE, syntax.Filter[0].Conjunction); Assert.AreEqual("name", ((CriteriaClause)syntax.Filter[0]).Column.FieldName); Assert.AreEqual(OperationType.LessThanOrEqual, ((CriteriaClause)syntax.Filter[0]).Operation); - Assert.AreEqual("bob rogers", ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); + Assert.AreEqual(parameter, ((CriteriaClause)syntax.Filter[0]).Value.GetValue()); // Execute the query and ensure that each result matches var results = await _executor(syntax); Assert.AreEqual(3, results.records.Length); foreach (var e in results.records) { - Assert.IsTrue(string.Compare(e.name[.."bob rogers".Length], "bob rogers", - StringComparison.CurrentCultureIgnoreCase) <= 0); + Assert.IsTrue(string.Compare(e.name[..parameter.Length], parameter, + _comparison) <= 0); } } @@ -416,6 +439,22 @@ private async Task StringEqualsCaseInsensitive() Assert.IsNotNull(result); Assert.AreEqual(_list.Count - 1, result.records.Length); } + + private async Task StringEqualsCaseSensitive() + { + var syntax = _src.ParseFilter("name eq 'ALICE SMITH'"); + var result = await _executor(syntax); + + Assert.IsFalse(result.records.Any(p => p.name == "Alice Smith")); + Assert.IsNotNull(result); + Assert.AreEqual(0, result.records.Length); + + syntax = _src.ParseFilter("name eq 'Alice Smith'"); + result = await _executor(syntax); + Assert.IsTrue(result.records.Any(p => p.name == "Alice Smith")); + Assert.IsNotNull(result); + Assert.AreEqual(1, result.records.Length); + } private async Task DefinedDateOperators() { diff --git a/tests/Searchlight.Tests/Executors/LinqExecutorTests.cs b/tests/Searchlight.Tests/Executors/LinqExecutorTests.cs index c5d8738..4172d1c 100644 --- a/tests/Searchlight.Tests/Executors/LinqExecutorTests.cs +++ b/tests/Searchlight.Tests/Executors/LinqExecutorTests.cs @@ -39,7 +39,9 @@ public void SetupTests() public async Task EmployeeTestSuite() { await Executors.EmployeeTestSuite.BasicTestSuite(_src, _list, _linq); - await Executors.EmployeeTestSuite.CaseInsensitiveStringTestSuite(_src, _list, _linq); + + _src.Engine = new SearchlightEngine { StringComparison = StringComparison.Ordinal }; + await Executors.EmployeeTestSuite.CaseSensitiveStringTestSuite(_src, _list, _linq); } // ========================================================= diff --git a/tests/Searchlight.Tests/Executors/MysqlExecutorTests.cs b/tests/Searchlight.Tests/Executors/MysqlExecutorTests.cs index 59b4fb3..c557ca7 100644 --- a/tests/Searchlight.Tests/Executors/MysqlExecutorTests.cs +++ b/tests/Searchlight.Tests/Executors/MysqlExecutorTests.cs @@ -118,5 +118,8 @@ public async Task Cleanup() public async Task EmployeeTestSuite() { await Executors.EmployeeTestSuite.BasicTestSuite(_src, _list, _executor); + + _src.Engine = new SearchlightEngine { StringComparison = StringComparison.Ordinal }; + await Executors.EmployeeTestSuite.CaseSensitiveStringTestSuite(_src, _list, _executor); } } \ No newline at end of file diff --git a/tests/Searchlight.Tests/Executors/PostgresExecutorTests.cs b/tests/Searchlight.Tests/Executors/PostgresExecutorTests.cs index c31d73d..5fc791a 100644 --- a/tests/Searchlight.Tests/Executors/PostgresExecutorTests.cs +++ b/tests/Searchlight.Tests/Executors/PostgresExecutorTests.cs @@ -146,5 +146,8 @@ public async Task Cleanup() public async Task EmployeeTestSuite() { await Executors.EmployeeTestSuite.BasicTestSuite(_src, _list, _executor); + + _src.Engine = new SearchlightEngine { StringComparison = StringComparison.Ordinal }; + await Executors.EmployeeTestSuite.CaseSensitiveStringTestSuite(_src, _list, _executor); } } \ No newline at end of file diff --git a/tests/Searchlight.Tests/Executors/SqlServerExecutorTests.cs b/tests/Searchlight.Tests/Executors/SqlServerExecutorTests.cs index c00de3a..72e7352 100644 --- a/tests/Searchlight.Tests/Executors/SqlServerExecutorTests.cs +++ b/tests/Searchlight.Tests/Executors/SqlServerExecutorTests.cs @@ -15,7 +15,7 @@ public class SqlServerExecutorTests { private DataSource _src; private string _connectionString; - private Func>> _postgres; + private Func>> _executor; private List _list; private MsSqlContainer _container; @@ -60,7 +60,7 @@ public async Task SetupClient() // Keep track of the correct result expectations and execution process _list = EmployeeObj.GetTestList(); - _postgres = async syntax => + _executor = async syntax => { var sql = syntax.ToSqlServerCommand(); var result = new List(); @@ -158,6 +158,13 @@ public async Task CleanupMongo() [TestMethod] public async Task EmployeeTestSuite() { - await Executors.EmployeeTestSuite.BasicTestSuite(_src, _list, _postgres); + await Executors.EmployeeTestSuite.BasicTestSuite(_src, _list, _executor); + + _src.Engine = new SearchlightEngine + { + StringComparison = StringComparison.Ordinal, + Collation = "SQL_Latin1_General_CP1_CS_AS" + }; + await Executors.EmployeeTestSuite.CaseSensitiveStringTestSuite(_src, _list, _executor); } } \ No newline at end of file