diff --git a/Mimeo.DynamicUI.Tests/OData/ODataExpressionGeneratorTests.cs b/Mimeo.DynamicUI.Tests/OData/ODataExpressionGeneratorTests.cs index 6a6fbf5..9072588 100644 --- a/Mimeo.DynamicUI.Tests/OData/ODataExpressionGeneratorTests.cs +++ b/Mimeo.DynamicUI.Tests/OData/ODataExpressionGeneratorTests.cs @@ -26,6 +26,70 @@ public void CanFilterSimpleString() expression.Should().Be("tolower(TestProperty) eq 'value'"); } + [Theory] + [InlineData(true, "'", "tolower(TestProperty) eq ''''")] + [InlineData(true, "O'Brien", "tolower(TestProperty) eq 'o''brien'")] + [InlineData(false, "O'Brien", "TestProperty eq 'O''Brien'")] + [InlineData(false, "''", "TestProperty eq ''''''")] + public void CanFilterString_EscapesSingleQuotes(bool ignoreCase, string value, string expectedQuery) + { + // Arrange + var expressionGenerator = new ODataExpressionGenerator(Mock.Of()); + var definition = new DataFieldDefinition("TestClass", typeof(string), "TestProperty"); + var filter = new DataQueryFilter(definition, DataFilterOperator.Equals, value) + { + IgnoreCase = ignoreCase + }; + var query = new DataQuery + { + Filters = [filter] + }; + + // Act + var expression = expressionGenerator.GenerateODataFilter(query); + + // Assert + expression.Should().Be(expectedQuery); + } + + [Fact] + public void CanFilterStringIn_EscapesSingleQuotes() + { + // Arrange + var expressionGenerator = new ODataExpressionGenerator(Mock.Of()); + var definition = new DataFieldDefinition("TestClass", typeof(string), "TestProperty"); + var filter = new DataQueryFilter(definition, DataFilterOperator.In, new List { "O'Brien", "Smith" }); + var query = new DataQuery + { + Filters = [filter] + }; + + // Act + var expression = expressionGenerator.GenerateODataFilter(query); + + // Assert + expression.Should().Be("tolower(TestProperty) in ('o''brien','smith')"); + } + + [Fact] + public void CanFilterListOfString_EscapesSingleQuotes() + { + // Arrange + var expressionGenerator = new ODataExpressionGenerator(Mock.Of()); + var definition = new DataFieldDefinition("TestClass", typeof(List), "TestProperty"); + var filter = new DataQueryFilter(definition, DataFilterOperator.Contains, "'"); + var query = new DataQuery + { + Filters = [filter] + }; + + // Act + var expression = expressionGenerator.GenerateODataFilter(query); + + // Assert + expression.Should().Be("TestProperty/any(i: contains(tolower(i), ''''))"); + } + [Fact] public void CanFilterListOfString() { diff --git a/Mimeo.DynamicUI/Data/OData/ODataExpressionGenerator.cs b/Mimeo.DynamicUI/Data/OData/ODataExpressionGenerator.cs index deaaad7..d74aa58 100644 --- a/Mimeo.DynamicUI/Data/OData/ODataExpressionGenerator.cs +++ b/Mimeo.DynamicUI/Data/OData/ODataExpressionGenerator.cs @@ -297,11 +297,11 @@ protected virtual string GenerateStringODataFilterExpression(string property, Da { if (ignoreCase) { - propertyValueString = $"'{propertyValueString.ToLower()}'"; + propertyValueString = $"'{EscapeODataString(propertyValueString.ToLower())}'"; } else { - propertyValueString = $"'{propertyValueString}'"; + propertyValueString = $"'{EscapeODataString(propertyValueString)}'"; } if (!string.IsNullOrEmpty(propertyValueString) && @operator == DataFilterOperator.Contains) @@ -347,8 +347,8 @@ protected virtual string GenerateStringODataFilterExpression(string property, Da else if (propertyValue is IEnumerable propertyValueEnumerable) { string propertyValueExpression = ignoreCase - ? $"({string.Join(",", propertyValueEnumerable.Select(v => $"'{v.ToLower()}'"))})" - : $"({string.Join(",", propertyValueEnumerable.Select(v => $"'{v}'"))})"; + ? $"({string.Join(",", propertyValueEnumerable.Select(v => $"'{EscapeODataString(v.ToLower())}'"))})" + : $"({string.Join(",", propertyValueEnumerable.Select(v => $"'{EscapeODataString(v)}'"))})"; if (@operator == DataFilterOperator.In) { @@ -377,11 +377,11 @@ protected virtual string GenerateEnumerableODataFilter(string property, DataFilt var enumerableValue = ((IEnumerable)(propertyValue != null ? propertyValue : Enumerable.Empty())).AsQueryable(); var enumerableValueAsString = "(" + string.Join(",", - (enumerableValue.ElementType == typeof(string) ? enumerableValue.Cast().Select(i => $@"'{i}'").Cast() : enumerableValue.Cast())) + ")"; + (enumerableValue.ElementType == typeof(string) ? enumerableValue.Cast().Select(i => $@"'{EscapeODataString(i)}'").Cast() : enumerableValue.Cast())) + ")"; var enumerableValueAsStringOrForAny = string.Join(" or ", (enumerableValue.ElementType == typeof(string) - ? enumerableValue.Cast().Select(i => $@"i eq '{i}'").Cast() + ? enumerableValue.Cast().Select(i => $@"i eq '{EscapeODataString(i)}'").Cast() : enumerableValue.Cast().Select(i => $@"i eq {i}").Cast())); if (enumerableValue.Any() && @operator == DataFilterOperator.Contains) @@ -408,7 +408,7 @@ protected virtual string GenerateEnumerableODataFilter(string property, DataFilt { return "(" + string.Join(" and ", (enumerableValue.ElementType == typeof(string) - ? enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq '{i}')").Cast() + ? enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq '{EscapeODataString(i)}')").Cast() : enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq {i})").Cast())) + ")"; } @@ -417,7 +417,7 @@ protected virtual string GenerateEnumerableODataFilter(string property, DataFilt { return "not (" + string.Join(" and ", (enumerableValue.ElementType == typeof(string) - ? enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq '{i}')").Cast() + ? enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq '{EscapeODataString(i)}')").Cast() : enumerableValue.Cast().Select(i => $@"{property}/any(i: i eq {i})").Cast())) + ")"; } @@ -539,7 +539,7 @@ protected string GetODataValue(object value) return value switch { - string => $"'{value}'", + string stringValue => $"'{EscapeODataString(stringValue)}'", DateTimeOffset dateTimeOffset => dateTimeOffset.UtcDateTime.ToString("o"), DateTime dateTime => dateTime.ToString("o"), _ => value.ToString()! @@ -602,6 +602,14 @@ protected static bool IsNumeric(Type source) } } + /// + /// Escapes a value for use inside a single-quoted OData string literal + /// + protected static string EscapeODataString(string value) + { + return value.Replace("'", "''"); + } + protected static string? GetValueAsString(object? value) { if (value == null)