From 7c12242c8313addeb04f6e664f322e792a08a4ee Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 17:30:49 +0100 Subject: [PATCH 1/4] Fix nested function calls in WHERE clause (GitHub Issue #11) The SQL parser was routing legacy functions (CONTAINS, STARTSWITH, ENDSWITH, etc.) through the string-based FunctionCondition path even when their arguments contained nested function calls like LOWER() or UPPER(). The string-based ResolveValue method cannot parse function call syntax, causing a JsonException. Added a check for nested function calls in arguments, routing them through the modern SqlExpressionCondition path which recursively evaluates expressions. Bumps version to 2.0.184. --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 2 +- ...MemoryEmulator.ProductionExtensions.csproj | 2 +- .../CosmosDB.InMemoryEmulator.csproj | 2 +- .../CosmosSqlParser.cs | 6 ++ .../SqlFunctionTests.cs | 67 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index 104ff31..445d83e 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.183 + 2.0.184 lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. MIT diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index b7872bb..41643ea 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.183 + 2.0.184 Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index f7db662..9729c45 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator - 2.0.183 + 2.0.184 lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. MIT diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs index db80734..04ec55b 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs +++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs @@ -969,6 +969,12 @@ public static WhereExpression ToWhereExpression(SqlExpression expr) return new SqlExpressionCondition(func); } + var hasNestedFunctions = func.Arguments.Any(ContainsFunctionCall); + if (hasNestedFunctions) + { + return new SqlExpressionCondition(func); + } + if (LegacyFunctionNames.Contains(func.FunctionName)) { var args = func.Arguments.Select(ExprToString).ToArray(); diff --git a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs index 0a91cdd..ba8bf23 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs @@ -3053,4 +3053,71 @@ public async Task Reverse_NonString_ReturnsUndefined() var results = await Query("SELECT REVERSE(42) AS r FROM c WHERE c.id = '1'"); results[0]["r"].Should().BeNull("REVERSE of non-string → undefined"); } + + // ── Nested function calls in WHERE clause (GitHub Issue #11) ── + + [Fact] + public async Task Contains_Lower_InWhere_MatchesCaseInsensitively() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "alice"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task StartsWith_Upper_InWhere_MatchesCaseInsensitively() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(UPPER(c.name), @prefix)") + .WithParameter("@prefix", "BOB"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } + + [Fact] + public async Task EndsWith_Lower_InWhere_MatchesSuffix() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(LOWER(c.name), @suffix)") + .WithParameter("@suffix", "brown"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } + + [Fact] + public async Task Contains_Lower_InWhere_NoMatch_ReturnsEmpty() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "ALICE"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("LOWER produces lowercase, 'ALICE' won't match"); + } + + [Fact] + public async Task Contains_Lower_InWhere_MultipleMatches() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'b')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } } From f533c687e78bb0803574d62ccedd0c6ebd4e7fcc Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 17:49:52 +0100 Subject: [PATCH 2/4] Add comprehensive tests for nested function calls in WHERE (Issue #11) 13 additional test cases covering: - 3-level deep nesting: CONTAINS(LOWER(TRIM(...))) - Nested inside nested: STARTSWITH(CONCAT(LOWER(...), ...), ...) - Both args as functions: CONTAINS(LOWER(...), LOWER(...)) - 3-arg CONTAINS with nested function + case-insensitive flag - IS_DEFINED/IS_NULL with nested function args - ARRAY_CONTAINS with nested function in search value - NOT combined with nested function predicate - AND with multiple nested function predicates - Undefined property through nested function - Null value flowing through nested function - Nested function in second argument only - Type conversion via ToString() in nested function --- .../SqlFunctionTests.cs | 170 +++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) diff --git a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs index ba8bf23..6821883 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs @@ -3054,8 +3054,6 @@ public async Task Reverse_NonString_ReturnsUndefined() results[0]["r"].Should().BeNull("REVERSE of non-string → undefined"); } - // ── Nested function calls in WHERE clause (GitHub Issue #11) ── - [Fact] public async Task Contains_Lower_InWhere_MatchesCaseInsensitively() { @@ -3120,4 +3118,172 @@ public async Task Contains_Lower_InWhere_MultipleMatches() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Bob Brown"); } + + // ── Deeper nesting ── + + [Fact] + public async Task Contains_Lower_Trim_InWhere_ThreeLevelsDeep() + { + await Seed(); + // Item 4 in main SqlFunctionTests has " diana " but our Seed doesn't have it — use existing data + // "Alice Anderson" → TRIM is no-op → LOWER → "alice anderson" → CONTAINS 'alice' + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(TRIM(c.name)), 'alice')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task StartsWith_ConcatLower_InWhere_NestedInsideNested() + { + await Seed(); + // CONCAT(LOWER("Alice Anderson"), "-suffix") → "alice anderson-suffix" → STARTSWITH "alice" + var results = await Query("SELECT * FROM c WHERE STARTSWITH(CONCAT(LOWER(c.name), '-suffix'), 'alice')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Both arguments are function calls ── + + [Fact] + public async Task Contains_BothArgsAreFunctions() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LOWER(@val))") + .WithParameter("@val", "ALICE"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── CONTAINS 3-arg form with nested function ── + + [Fact] + public async Task Contains_Lower_WithCaseInsensitiveFlag() + { + await Seed(); + // LOWER(c.name) → "alice anderson", search for "ALICE" case-insensitively → should match + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'ALICE', true)"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Other legacy functions with nested args ── + + [Fact] + public async Task IsDefined_WithNestedFunction() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(LOWER(c.name))"); + + results.Should().HaveCount(3, "all 3 seeded items have a name"); + } + + [Fact] + public async Task IsNull_WithNestedFunction() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE IS_NULL(LOWER(c.name))"); + + results.Should().BeEmpty("no seeded items have null name"); + } + + [Fact] + public async Task ArrayContains_WithNestedFunctionInSearchValue() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(@val))") + .WithParameter("@val", "DOT"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2, "items 1 and 3 have 'dot' in tags"); + } + + // ── Negation ── + + [Fact] + public async Task Not_Contains_Lower_InWhere() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT CONTAINS(LOWER(c.name), 'alice')"); + + results.Should().HaveCount(2); + results.Select(r => r["name"]!.Value()).Should().BeEquivalentTo("Bob Brown", "Charlie"); + } + + // ── Combined in AND/OR ── + + [Fact] + public async Task And_MultipleNestedFunctionPredicates() + { + await Seed(); + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'ali') AND STARTSWITH(UPPER(c.name), 'ALI')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Undefined property ── + + [Fact] + public async Task Contains_Lower_UndefinedProperty_NoMatch() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty("LOWER of undefined → undefined, CONTAINS should not match"); + } + + // ── Null value flowing through nested function ── + + [Fact] + public async Task Contains_Lower_NullProperty_NoMatch() + { + await Seed(); + // Add item with null name + await _container.CreateItemAsync( + new { id = "null-name", partitionKey = "pk1", name = (string?)null, value = 0, tags = Array.Empty() }, + new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'val')"); + + results.Should().NotContain(r => r["id"]!.Value() == "null-name", + "null name → LOWER(null) → undefined → CONTAINS should not match"); + } + + // ── Nested function in second argument only ── + + [Fact] + public async Task Contains_NestedFunctionInSecondArgOnly() + { + await Seed(); + // c.name is "Alice Anderson", LOWER(@val) → "alice anderson" — won't match because c.name has uppercase + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, LOWER(@val))") + .WithParameter("@val", "Alice"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("c.name has uppercase but LOWER(@val) is all lowercase — no substring match"); + } + + // ── Type conversion through nested function ── + + [Fact] + public async Task Contains_ToString_InWhere_TypeConversion() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(ToString(c[\"value\"]), '10')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } } From 683992317abb8efa3b8b6f0ab0c6a24599ad5a79 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 18:04:43 +0100 Subject: [PATCH 3/4] Fix IS_DEFINED returning true for UndefinedValue + edge case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IS_DEFINED() in EvaluateSqlFunction had a fallthrough that returned true for UndefinedValue.Instance (args[0] != null). This was exposed by routing nested-function legacy calls through the modern path. Fixed to check: args[0] is not null and not UndefinedValue. This also fixes the 8 pre-existing GitHubIssueReproTests failures. Added 4 edge case tests: - IS_DEFINED(LOWER(c.nonexistent)) → false - IS_NULL(LOWER(c.nonexistent)) → false - CONTAINS(LOWER(c.numericField), 'x') → no match - ARRAY_CONTAINS + nested function combined with AND --- .../InMemoryContainer.cs | 2 +- .../SqlFunctionTests.cs | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 1e1deef..3588111 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -6210,7 +6210,7 @@ and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" // (even when its value is null). if (func.Arguments[0] is ParameterExpression param) return parameters.ContainsKey(param.Name); - return args[0] != null; + return args[0] is not null and not UndefinedValue; } case "IS_NULL": return args.Length > 0 && args[0] is null; case "IS_ARRAY": diff --git a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs index 6821883..22cc8c0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs @@ -3286,4 +3286,57 @@ public async Task Contains_ToString_InWhere_TypeConversion() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Alice Anderson"); } + + // ── IS_DEFINED / IS_NULL with undefined flowing through nested function ── + + [Fact] + public async Task IsDefined_Lower_UndefinedProperty_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined, IS_DEFINED(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER of undefined → undefined → IS_DEFINED should be false"); + } + + [Fact] + public async Task IsNull_Lower_UndefinedProperty_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined, IS_NULL(undefined) → false (undefined ≠ null) + var results = await Query("SELECT * FROM c WHERE IS_NULL(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER of undefined → undefined → IS_NULL should be false"); + } + + // ── Non-string input flowing through nested function ── + + [Fact] + public async Task Contains_Lower_NonStringInput_NoMatch() + { + await Seed(); + // LOWER(42) → undefined, CONTAINS(undefined, ...) → undefined → excluded + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c[\"value\"]), 'x')"); + + results.Should().BeEmpty("LOWER(number) → undefined → CONTAINS should not match"); + } + + // ── ARRAY_CONTAINS partial match with nested function ── + + [Fact] + public async Task ArrayContains_WithNestedFunction_InSelectValueFilter() + { + await Seed(); + // Use ARRAY_CONTAINS with a simple nested function — LOWER(@val) resolves to "dot" + // then checks if "dot" is in the tags array + var query = new QueryDefinition( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(@val)) AND STARTSWITH(LOWER(c.name), 'alice')") + .WithParameter("@val", "DOT"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } } From 12af73ebaea3d2441dd72d584080f3e4b683f443 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 18:33:03 +0100 Subject: [PATCH 4/4] Fix coalesce/arithmetic in legacy function args + ARRAY_CONTAINS NRE Red-team edge case testing found 3 additional bugs in the nested function call fix: 1. ContainsFunctionCall only checked for nested function calls but missed CoalesceExpression (??) and arithmetic BinaryExpression in legacy function arguments. These still routed through the string- based FunctionCondition path, causing JsonException crashes. Added ContainsArithmetic and ContainsComplexExpression checks. 2. ARRAY_CONTAINS with UndefinedValue search value (from nested function on missing property) caused NullReferenceException. UndefinedValue.ToString() returns null, and searchStr.StartsWith throws NRE on object-type array elements. Fixed in both legacy and modern evaluation paths. Added 20 edge case tests covering coalesce, arithmetic, undefined propagation, deep nesting, type-checking, NOT, OR, and projection. Bumps version to 2.0.185. --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 2 +- ...MemoryEmulator.ProductionExtensions.csproj | 2 +- .../CosmosDB.InMemoryEmulator.csproj | 2 +- .../CosmosSqlParser.cs | 12 + .../InMemoryContainer.cs | 10 + .../SqlFunctionTests.cs | 273 ++++++++++++++++++ 6 files changed, 298 insertions(+), 3 deletions(-) diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index 445d83e..bc8f082 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.184 + 2.0.185 lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. MIT diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index 41643ea..c9ffda9 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.184 + 2.0.185 Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index 9729c45..a46a26e 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator - 2.0.184 + 2.0.185 lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. MIT diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs index 04ec55b..b95f4ec 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs +++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs @@ -975,6 +975,18 @@ public static WhereExpression ToWhereExpression(SqlExpression expr) return new SqlExpressionCondition(func); } + var hasArithmetic = func.Arguments.Any(ContainsArithmetic); + if (hasArithmetic) + { + return new SqlExpressionCondition(func); + } + + var hasComplexExpressions = func.Arguments.Any(ContainsComplexExpression); + if (hasComplexExpressions) + { + return new SqlExpressionCondition(func); + } + if (LegacyFunctionNames.Contains(func.FunctionName)) { var args = func.Arguments.Select(ExprToString).ToArray(); diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 3588111..feae8e2 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -5860,6 +5860,11 @@ private static bool EvaluateFunction( return jArray.Any(t => t.Type == JTokenType.Null); } + if (searchValue is UndefinedValue) + { + return false; + } + var searchStr = searchValue.ToString(); var partial = func.Arguments.Length >= 3 && string.Equals(func.Arguments[2].Trim(), "true", StringComparison.OrdinalIgnoreCase); @@ -6720,6 +6725,11 @@ and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" return jArray.Any(t => t.Type == JTokenType.Null); } + if (searchValue is UndefinedValue) + { + return false; + } + var searchStr = searchValue.ToString(); return ArrayContainsMatch(jArray, searchStr, partial); } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs index 22cc8c0..c0ae6f0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests/SqlFunctionTests.cs @@ -3339,4 +3339,277 @@ public async Task ArrayContains_WithNestedFunction_InSelectValueFilter() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Alice Anderson"); } + + // ══════════════════════════════════════════════════════════════════════════ + // Red-team edge case tests — probing the nested-function routing fix + // ══════════════════════════════════════════════════════════════════════════ + + // ── Coalesce expression in legacy function args (ContainsFunctionCall misses it) ── + + [Fact] + public async Task Contains_CoalesceInFirstArg_NullName_MatchesFallback() + { + await Seed(); + await _container.CreateItemAsync( + new { id = "null-name", partitionKey = "pk1", name = (string?)null, value = 0, tags = new[] { "dot" } }, + new PartitionKey("pk1")); + + // c.name is null → c.name ?? 'unknown person' → "unknown person" + // CONTAINS("unknown person", "unknown") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name ?? 'unknown person', 'unknown')"); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("null-name"); + } + + [Fact] + public async Task StartsWith_CoalesceInFirstArg_UndefinedField_MatchesFallback() + { + await Seed(); + // c.nonexistent is undefined → c.nonexistent ?? 'fallback' → "fallback" + // STARTSWITH("fallback", "fall") → true + var results = await Query("SELECT * FROM c WHERE STARTSWITH(c.nonexistent ?? 'fallback', 'fall')"); + + results.Should().HaveCount(3, "all 3 docs have undefined c.nonexistent, so coalesce should produce 'fallback'"); + } + + [Fact] + public async Task EndsWith_CoalesceInSecondArg_MatchesSuffix() + { + await Seed(); + // ENDSWITH(c.name, @missing ?? 'anderson') — coalesce in second arg + var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(LOWER(c.name), @suffix ?? 'anderson')") + .WithParameter("@suffix", (string?)null); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Arithmetic expression in legacy function args (also missed by ContainsFunctionCall) ── + + [Fact] + public async Task Contains_ArithmeticInSecondArg_ToStringOfComputation() + { + await Seed(); + // ToString(c.value * 2) is a function call wrapping arithmetic — ContainsFunctionCall catches it + // ToString(20) → "20", c.name = "Bob Brown" doesn't contain "20" + // But "Alice Anderson" has value=10, ToString(10*2)="20", name doesn't contain "20" + // None should match + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, ToString(c[\"value\"] * 2))"); + results.Should().BeEmpty(); + } + + // ── ARRAY_CONTAINS with UndefinedValue search on array with object elements ── + + [Fact] + public async Task ArrayContains_NestedFunctionReturnsUndefined_WithObjectArrayElements_NoMatch() + { + var container = new InMemoryContainer("array-obj", "/partitionKey"); + await container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", items = new[] { new { type = "book", title = "Cosmos" } } }, + new PartitionKey("pk1")); + + // LOWER(c.nonexistent) → undefined → ARRAY_CONTAINS with undefined search value + // Should return false, not crash + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, LOWER(c.nonexistent))"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("ARRAY_CONTAINS with undefined search on object array should be false, not crash"); + } + + [Fact] + public async Task ArrayContains_NestedFunctionReturnsUndefined_SimpleArray_NoMatch() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → search is undefined + var results = await Query("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(c.nonexistent))"); + results.Should().BeEmpty("ARRAY_CONTAINS with undefined search should return false"); + } + + // ── NOT + nested function + undefined property interaction ── + + [Fact] + public async Task Not_Contains_Lower_UndefinedProperty_ExcludesAll() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → CONTAINS(undefined, 'val') → undefined + // NOT undefined → undefined → excluded + var results = await Query("SELECT * FROM c WHERE NOT CONTAINS(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty( + "NOT CONTAINS where first arg is undefined should exclude (undefined propagation)"); + } + + [Fact] + public async Task Not_StartsWith_Lower_UndefinedProperty_ExcludesAll() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT STARTSWITH(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty( + "NOT STARTSWITH where first arg is undefined should exclude"); + } + + // ── CONTAINS with both args as different nested functions ── + + [Fact] + public async Task Contains_Lower_FirstArg_Reverse_SecondArg() + { + await Seed(); + // LOWER("Alice Anderson") → "alice anderson" + // REVERSE("ecila") → "alice" + // CONTAINS("alice anderson", "alice") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), REVERSE('ecila'))"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── CONTAINS with LEFT/SUBSTRING computed from same field ── + + [Fact] + public async Task Contains_Lower_And_Left_ComputedFromSameField() + { + await Seed(); + // LOWER("Alice Anderson") → "alice anderson" + // LEFT(LOWER("Alice Anderson"), 5) → "alice" + // CONTAINS("alice anderson", "alice") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LEFT(LOWER(c.name), 5))"); + + results.Should().HaveCount(3, "every name contains its own first 5 lowercase chars"); + } + + // ── IS_DEFINED with CONCAT where one arg is undefined ── + + [Fact] + public async Task IsDefined_ConcatWithOneUndefinedArg_ReturnsFalse() + { + await Seed(); + // CONCAT(c.name, c.nonexistent): c.nonexistent is undefined → CONCAT returns undefined + // IS_DEFINED(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(CONCAT(c.name, c.nonexistent))"); + + results.Should().BeEmpty("CONCAT with any undefined arg → undefined → IS_DEFINED should be false"); + } + + // ── IS_NULL with CONCAT where one arg is explicit null ── + + [Fact] + public async Task IsNull_ConcatWithNullArg_ReturnsFalse() + { + await Seed(); + // CONCAT(c.name, null): null arg → CONCAT returns undefined + // IS_NULL(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_NULL(CONCAT(c.name, null))"); + + results.Should().BeEmpty("CONCAT with null → undefined → IS_NULL(undefined) should be false"); + } + + // ── Type-checking functions through modern path ── + + [Fact] + public async Task IsString_Lower_DefinedField_ReturnsTrue() + { + await Seed(); + // LOWER(c.name) → string → IS_STRING(string) → true + var results = await Query("SELECT * FROM c WHERE IS_STRING(LOWER(c.name))"); + + results.Should().HaveCount(3, "all docs have string names → LOWER produces string → IS_STRING true"); + } + + [Fact] + public async Task IsString_Lower_UndefinedField_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → IS_STRING(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_STRING(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER(undefined) → undefined → IS_STRING should be false"); + } + + [Fact] + public async Task IsNumber_Length_DefinedField_ReturnsTrue() + { + await Seed(); + // LENGTH(c.name) → number → IS_NUMBER(number) → true + var results = await Query("SELECT * FROM c WHERE IS_NUMBER(LENGTH(c.name))"); + + results.Should().HaveCount(3, "all docs have string names → LENGTH produces number"); + } + + // ── Multiple nested-function predicates in OR ── + + [Fact] + public async Task Or_NestedFunctionPredicates() + { + await Seed(); + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'alice') OR ENDSWITH(LOWER(c.name), 'brown')"); + + results.Should().HaveCount(2); + results.Select(r => r["name"]!.Value()).Should() + .BeEquivalentTo("Alice Anderson", "Bob Brown"); + } + + // ── Deeply nested: 4 levels ── + + [Fact] + public async Task Contains_FourLevelNesting() + { + await Seed(); + // REVERSE(LOWER(TRIM(c.name))) = reverse of lowercase trimmed name + // "Alice Anderson" → TRIM → "Alice Anderson" → LOWER → "alice anderson" → REVERSE → "nosredna ecila" + // CONTAINS("nosredna ecila", "nosredna") → true + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(REVERSE(LOWER(TRIM(c.name))), 'nosredna')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── STARTSWITH with SUBSTRING in second arg ── + + [Fact] + public async Task StartsWith_SubstringInSecondArg() + { + await Seed(); + // SUBSTRING("prefix_test", 0, 3) → "pre" — static computation + // But actually, let's use a field reference + // SUBSTRING(LOWER(c.name), 0, 3) → first 3 chars of lowercase name + // STARTSWITH(LOWER(c.name), first3) → always true (string starts with its own prefix) + var results = await Query( + "SELECT * FROM c WHERE STARTSWITH(LOWER(c.name), SUBSTRING(LOWER(c.name), 0, 3))"); + + results.Should().HaveCount(3, "every string starts with its own first 3 chars"); + } + + // ── CONTAINS(LOWER(c.name), 'alice') in SELECT VALUE (not WHERE) ── + // This tests that the expression evaluation works in projection too + + [Fact] + public async Task NestedFunction_InSelectProjection() + { + await Seed(); + var results = await Query( + "SELECT CONTAINS(LOWER(c.name), 'alice') AS hasAlice FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["hasAlice"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task NestedFunction_InSelectProjection_FalseCase() + { + await Seed(); + var results = await Query( + "SELECT CONTAINS(LOWER(c.name), 'alice') AS hasAlice FROM c WHERE c.id = '2'"); + + results.Should().HaveCount(1); + results[0]["hasAlice"]!.Value().Should().BeFalse(); + } }