Skip to content
18 changes: 18 additions & 0 deletions src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,24 @@ public static WhereExpression ToWhereExpression(SqlExpression expr)
return new SqlExpressionCondition(func);
}

var hasNestedFunctions = func.Arguments.Any(ContainsFunctionCall);
if (hasNestedFunctions)
{
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();
Expand Down
6 changes: 6 additions & 0 deletions src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ void RegisterTrigger(string id, TriggerType type, TriggerOperation operation,
/// </summary>
int? DefaultTimeToLive { get; set; }

/// <summary>
/// The unique key policy for this container. Set to configure unique key constraints
/// that enforce uniqueness of one or more values within a logical partition.
/// </summary>
UniqueKeyPolicy UniqueKeyPolicy { get; set; }

/// <summary>
/// Number of feed ranges returned by <c>GetFeedRangesAsync</c>. Defaults to 1.
/// Set to a higher value to simulate multiple physical partitions.
Expand Down
85 changes: 81 additions & 4 deletions src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,16 @@ public int? DefaultTimeToLive
}
private int? _defaultTimeToLive;

/// <summary>
/// The unique key policy for this container. Set to configure unique key constraints
/// that enforce uniqueness of one or more values within a logical partition.
/// </summary>
public UniqueKeyPolicy UniqueKeyPolicy
{
get => _containerProperties.UniqueKeyPolicy;
set => _containerProperties.UniqueKeyPolicy = value ?? new UniqueKeyPolicy();
}

/// <summary>The partition key path(s) for this container.</summary>
public IReadOnlyList<string> PartitionKeyPaths { get; }

Expand Down Expand Up @@ -3297,17 +3307,22 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud

foreach (var uniqueKey in policy.UniqueKeys)
{
var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(p.TrimStart('/'))?.ToString()).ToList();
var newTokens = uniqueKey.Paths.Select(p => jObj.SelectToken(CosmosPathToSelectTokenPath(p))).ToList();

// Cosmos DB allows multiple items where ALL unique key paths are null/missing
if (newTokens.All(t => t is null || t.Type == JTokenType.Null))
continue;

foreach (var (existingKey, existingJson) in _items)
{
if (existingKey.PartitionKey != partitionKey) continue;
if (excludeItemId != null && existingKey.Id == excludeItemId) continue;
if (IsExpired(existingKey)) continue;

var existingObj = JsonParseHelpers.ParseJson(existingJson);
var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(p.TrimStart('/'))?.ToString()).ToList();
var existingTokens = uniqueKey.Paths.Select(p => existingObj.SelectToken(CosmosPathToSelectTokenPath(p))).ToList();

if (newValues.SequenceEqual(existingValues))
if (UniqueKeyTokensMatch(newTokens, existingTokens))
{
throw new InMemoryCosmosException(
"Unique index constraint violation.",
Expand All @@ -3317,6 +3332,53 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud
}
}

/// <summary>
/// Compares two lists of JTokens for unique key equality, preserving JSON type information.
/// Null/missing tokens are treated as equal to each other. Different JSON types (e.g. integer 42 vs string "42")
/// are treated as distinct values. Numeric types (Integer/Float) are compared by value so that 1 and 1.0 conflict.
/// This matches real Cosmos DB behavior.
/// </summary>
private static bool UniqueKeyTokensMatch(List<JToken> newTokens, List<JToken> existingTokens)
{
if (newTokens.Count != existingTokens.Count) return false;

for (int i = 0; i < newTokens.Count; i++)
{
var a = newTokens[i];
var b = existingTokens[i];

bool aNull = a is null || a.Type == JTokenType.Null;
bool bNull = b is null || b.Type == JTokenType.Null;

if (aNull && bNull) continue;
if (aNull || bNull) return false;

bool aNumeric = a.Type == JTokenType.Integer || a.Type == JTokenType.Float;
bool bNumeric = b.Type == JTokenType.Integer || b.Type == JTokenType.Float;

if (aNumeric && bNumeric)
{
// Compare numeric values regardless of Integer/Float distinction
if ((double)a != (double)b) return false;
}
else
{
if (a.Type != b.Type) return false;
if (!JToken.DeepEquals(a, b)) return false;
}
}
return true;
}

/// <summary>
/// Converts a Cosmos DB path (e.g. "/value/code") to a Newtonsoft.Json SelectToken path (e.g. "value.code").
/// </summary>
private static string CosmosPathToSelectTokenPath(string cosmosPath)
{
var segments = cosmosPath.TrimStart('/').Split('/');
return BuildSelectTokenPath(segments);
}

private bool ValidateUniqueKeysStream(JObject jObj, string partitionKey, string excludeItemId = null)
{
try
Expand Down Expand Up @@ -5962,6 +6024,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);
Expand Down Expand Up @@ -6312,7 +6379,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":
Expand Down Expand Up @@ -6822,6 +6889,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);
}
Expand Down Expand Up @@ -8435,6 +8507,7 @@ private static void ApplyPatchOperations(JObject jObj, IReadOnlyList<PatchOperat
/// <summary>
/// Converts path segments to a Newtonsoft.Json SelectToken-compatible path.
/// Numeric segments become array indexers (e.g., ["runs","0"] → "runs[0]").
/// Segments containing special characters (spaces, dots, etc.) use bracket notation.
/// </summary>
internal static string BuildSelectTokenPath(IEnumerable<string> segments)
{
Expand All @@ -8445,6 +8518,10 @@ internal static string BuildSelectTokenPath(IEnumerable<string> segments)
{
sb.Append('[').Append(segment).Append(']');
}
else if (segment.IndexOfAny(new[] { '.', ' ', '[', ']', '\'', '"' }) >= 0)
{
sb.Append("['").Append(segment).Append("']");
}
else
{
if (sb.Length > 0) sb.Append('.');
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<!-- Shared NuGet package metadata for all source (packable) projects -->
<PropertyGroup>
<IsPackable>true</IsPackable>
<Version>4.0.5</Version>
<Version>4.0.6</Version>
<Authors>lemonlion</Authors>
<Copyright>Copyright (c) 2026 lemonlion</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
Loading
Loading