Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,36 @@ private static void EnsureSCIMRepresentationAttributeIndexesAreCreated(IMongoDat
{
var compoundIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.RepresentationId).Ascending(a => a.SchemaAttributeId).Ascending(a => a.ValueString);
var representationIdIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.RepresentationId);

// Add optimized index for filtering by SchemaAttributeId and ValueString (commonly used in SCIM queries)
var schemaAttributeValueIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.SchemaAttributeId).Ascending(a => a.ValueString);

// Add index for ValueString with case-insensitive collation (useful for regex queries)
var caseInsensitiveCollation = new Collation("en", strength: CollationStrength.Secondary);
var valueStringCaseInsensitiveOptions = new CreateIndexOptions { Collation = caseInsensitiveCollation };
var valueStringIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.ValueString);

EnsureIndexCreated(db, "RepresentationId_1_SchemaAttributeId_1_ValueString_1", name, compoundIndex);
EnsureIndexCreated(db, "RepresentationId_1", name, representationIdIndex);
EnsureIndexCreated(db, "SchemaAttributeId_1_ValueString_1", name, schemaAttributeValueIndex);
EnsureIndexCreated(db, "ValueString_1_case_insensitive", name, valueStringIndex, valueStringCaseInsensitiveOptions);
}

private static async void EnsureIndexCreated(IMongoDatabase db, string indexName, string name, IndexKeysDefinition<SCIMRepresentationAttribute> indexDefinition)
private static async void EnsureIndexCreated(IMongoDatabase db, string indexName, string name, IndexKeysDefinition<SCIMRepresentationAttribute> indexDefinition, CreateIndexOptions options = null)
{
var collection = db.GetCollection<SCIMRepresentationAttribute>(name);
var indexes = await collection.Indexes.List().ToListAsync();
if (indexes.Any(i => i.Elements.Any(e => e.Name == "name" && e.Value.AsString == indexName))) return;
collection.Indexes.CreateOne(indexDefinition);

if (options != null)
{
var indexModel = new CreateIndexModel<SCIMRepresentationAttribute>(indexDefinition, options);
collection.Indexes.CreateOne(indexModel);
}
else
{
collection.Indexes.CreateOne(indexDefinition);
}
}

private static IMongoCollection<T> EnsureCollectionIsCreated<T>(IMongoDatabase db, string name)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) SimpleIdServer. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.

using MongoDB.Driver;
using System;
using System.Linq.Expressions;
using System.Text.RegularExpressions;

namespace SimpleIdServer.Scim.Persistence.MongoDB.Extensions
{
/// <summary>
/// MongoDB-specific optimized expression extensions that avoid $expr usage for better index performance
/// </summary>
public static class MongoDbOptimizedExpressionExtensions
{
/// <summary>
/// Creates an optimized case-insensitive equality expression for MongoDB using regex instead of $expr
/// This allows MongoDB to use indexes effectively
/// </summary>
public static Expression CreateOptimizedCaseInsensitiveEqual(Expression propertyExpression, string value)
{
if (string.IsNullOrEmpty(value))
{
// For null/empty values, use direct comparison
return Expression.Equal(propertyExpression, Expression.Constant(null, typeof(string)));
}

// Escape regex special characters in the value
var escapedValue = Regex.Escape(value);

// Create case-insensitive regex pattern
var regexPattern = $"^{escapedValue}$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);

// Use MongoDB's regex matching which can use indexes when anchored
var regexConstant = Expression.Constant(regex, typeof(Regex));
var isMatchMethod = typeof(Regex).GetMethod("IsMatch", new[] { typeof(string) });

// Handle null values by using null-conditional operator pattern
var nullCheck = Expression.Equal(propertyExpression, Expression.Constant(null, typeof(string)));
var regexMatch = Expression.Call(regexConstant, isMatchMethod, propertyExpression);

// If property is null and value is empty, return true; otherwise use regex match
if (string.IsNullOrEmpty(value))
{
return nullCheck;
}

// For non-null values, use: property != null && Regex.IsMatch(property, pattern)
var notNullCheck = Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(string)));
return Expression.AndAlso(notNullCheck, regexMatch);
}

/// <summary>
/// Creates an optimized starts-with expression for MongoDB that can use indexes
/// </summary>
public static Expression CreateOptimizedCaseInsensitiveStartsWith(Expression propertyExpression, string value)
{
if (string.IsNullOrEmpty(value))
{
// For empty value, everything starts with empty string
return Expression.Constant(true);
}

// Escape regex special characters in the value
var escapedValue = Regex.Escape(value);

// Create case-insensitive regex pattern for starts with
var regexPattern = $"^{escapedValue}";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);

var regexConstant = Expression.Constant(regex, typeof(Regex));
var isMatchMethod = typeof(Regex).GetMethod("IsMatch", new[] { typeof(string) });

// Handle null values
var notNullCheck = Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(string)));
var regexMatch = Expression.Call(regexConstant, isMatchMethod, propertyExpression);

return Expression.AndAlso(notNullCheck, regexMatch);
}

/// <summary>
/// Creates an optimized contains expression for MongoDB
/// </summary>
public static Expression CreateOptimizedCaseInsensitiveContains(Expression propertyExpression, string value)
{
if (string.IsNullOrEmpty(value))
{
// For empty value, everything contains empty string
return Expression.Constant(true);
}

// Escape regex special characters in the value
var escapedValue = Regex.Escape(value);

// Create case-insensitive regex pattern for contains
var regexPattern = escapedValue;
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);

var regexConstant = Expression.Constant(regex, typeof(Regex));
var isMatchMethod = typeof(Regex).GetMethod("IsMatch", new[] { typeof(string) });

// Handle null values
var notNullCheck = Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(string)));
var regexMatch = Expression.Call(regexConstant, isMatchMethod, propertyExpression);

return Expression.AndAlso(notNullCheck, regexMatch);
}

/// <summary>
/// Creates an optimized ends-with expression for MongoDB
/// </summary>
public static Expression CreateOptimizedCaseInsensitiveEndsWith(Expression propertyExpression, string value)
{
if (string.IsNullOrEmpty(value))
{
// For empty value, everything ends with empty string
return Expression.Constant(true);
}

// Escape regex special characters in the value
var escapedValue = Regex.Escape(value);

// Create case-insensitive regex pattern for ends with
var regexPattern = $"{escapedValue}$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);

var regexConstant = Expression.Constant(regex, typeof(Regex));
var isMatchMethod = typeof(Regex).GetMethod("IsMatch", new[] { typeof(string) });

// Handle null values
var notNullCheck = Expression.NotEqual(propertyExpression, Expression.Constant(null, typeof(string)));
var regexMatch = Expression.Call(regexConstant, isMatchMethod, propertyExpression);

return Expression.AndAlso(notNullCheck, regexMatch);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,63 @@ public static Expression EvaluateAttributes(this SCIMComparisonExpression expres
var propertyValueBoolean = Expression.Property(attr, "ValueBoolean");
var propertyValueDecimal = Expression.Property(attr, "ValueDecimal");
var propertyValueBinary = Expression.Property(attr, "ValueBinary");
var comparison = SCIMExpressionLinqExtensions.BuildComparisonExpression(expression, lastChild.SchemaAttribute,
propertyValueString,
propertyValueInteger,
propertyValueDatetime,
propertyValueBoolean,
propertyValueDecimal,
propertyValueBinary,
parameterExpression);

// Use MongoDB-optimized comparison expressions for string types to avoid $expr performance issues
Expression comparison;
if (lastChild.SchemaAttribute.Type == SCIMSchemaAttributeTypes.STRING && !lastChild.SchemaAttribute.CaseExact)
{
comparison = BuildMongoDbOptimizedStringComparison(expression, propertyValueString);
}
else
{
comparison = SCIMExpressionLinqExtensions.BuildComparisonExpression(expression, lastChild.SchemaAttribute,
propertyValueString,
propertyValueInteger,
propertyValueDatetime,
propertyValueBoolean,
propertyValueDecimal,
propertyValueBinary,
parameterExpression);
}

return Expression.And(Expression.Equal(schemaAttributeId, Expression.Constant(lastChild.SchemaAttribute.Id)), comparison);
}

/// <summary>
/// Builds MongoDB-optimized string comparison expressions that avoid $expr and can use indexes effectively
/// </summary>
private static Expression BuildMongoDbOptimizedStringComparison(SCIMComparisonExpression expression, MemberExpression propertyValueString)
{
var value = expression.Value;

switch (expression.ComparisonOperator)
{
case SCIMComparisonOperators.EQ:
return MongoDbOptimizedExpressionExtensions.CreateOptimizedCaseInsensitiveEqual(propertyValueString, value);

case SCIMComparisonOperators.NE:
var equalExpr = MongoDbOptimizedExpressionExtensions.CreateOptimizedCaseInsensitiveEqual(propertyValueString, value);
return Expression.Not(equalExpr);

case SCIMComparisonOperators.SW:
return MongoDbOptimizedExpressionExtensions.CreateOptimizedCaseInsensitiveStartsWith(propertyValueString, value);

case SCIMComparisonOperators.EW:
return MongoDbOptimizedExpressionExtensions.CreateOptimizedCaseInsensitiveEndsWith(propertyValueString, value);

case SCIMComparisonOperators.CO:
return MongoDbOptimizedExpressionExtensions.CreateOptimizedCaseInsensitiveContains(propertyValueString, value);

default:
// For other operators (GT, LT, GE, LE, PR), fall back to default behavior
// as they don't typically benefit from case-insensitive optimization
return SCIMExpressionLinqExtensions.BuildComparisonExpression(
expression,
new SCIMSchemaAttribute("temp") { Type = SCIMSchemaAttributeTypes.STRING, CaseExact = false },
propertyValueString);
}
}

#endregion
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# MongoDB Performance Optimization for SCIM

## Problem Statement

The original issue reported that SCIM filtering on MongoDB was generating inefficient queries like:

```json
{
"Attribute.SchemaAttributeId": "26d51050-4962-4348-a6cb-310c198eeee3",
"$expr": {
"$eq": [
{ "$toLower": { "$ifNull": ["$Attribute.ValueString", ""] } },
"150017355"
]
}
}
```

This query pattern forced MongoDB into full collection scans because:
1. `$expr` with `$toLower` and `$ifNull` functions prevent index usage
2. MongoDB cannot use indexes when expressions involve function calls in `$expr`
3. Large collections (859k documents) result in poor performance (~2.11 minutes)

## Solution Overview

The solution implements MongoDB-specific optimizations for case-insensitive string comparisons in the SCIM persistence layer.

### Key Changes

1. **Optimized Expression Engine**: New `MongoDbOptimizedExpressionExtensions` class that generates regex-based queries instead of `$expr` queries
2. **Enhanced Indexes**: Additional compound and case-insensitive collation indexes for better query performance
3. **Selective Optimization**: Only case-insensitive string operations are optimized, preserving existing behavior for other data types

### Technical Details

#### Before (Problematic Pattern)
```csharp
// Generated inefficient $expr query with $toLower/$ifNull
e1 = Expression.Coalesce(e1, Expression.Constant(string.Empty));
e1 = Expression.Call(e1, typeof(string).GetMethod("ToLower", System.Type.EmptyTypes));
e2 = Expression.Call(e2, typeof(string).GetMethod("ToLower", System.Type.EmptyTypes));
return Expression.Equal(e1, e2);
```

#### After (Optimized Pattern)
```csharp
// Uses regex patterns that can leverage MongoDB indexes
var regexPattern = $"^{Regex.Escape(value)}$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);
// MongoDB can use indexes for anchored regex patterns
```

#### New Index Strategy
```csharp
// Additional indexes for better performance
var schemaAttributeValueIndex = Builders<SCIMRepresentationAttribute>.IndexKeys
.Ascending(a => a.SchemaAttributeId)
.Ascending(a => a.ValueString);

var caseInsensitiveCollation = new Collation("en", strength: CollationStrength.Secondary);
var valueStringIndex = Builders<SCIMRepresentationAttribute>.IndexKeys
.Ascending(a => a.ValueString);
```

## Performance Impact

The optimization specifically targets the query pattern that was causing performance issues:

- **Query Type**: `eventid eq "150017355"` (case-insensitive equality)
- **Before**: Full collection scan with `$expr` functions
- **After**: Index-supported regex pattern matching
- **Index Usage**: Leverages `SchemaAttributeId_1_ValueString_1` compound index

## Backward Compatibility

- ✅ Existing case-sensitive queries unchanged
- ✅ Non-string attribute queries unchanged
- ✅ Complex attribute expressions unchanged
- ✅ All existing tests continue to pass
- ✅ Only optimizes problematic case-insensitive string operations

## Usage

The optimization is automatically applied when:
1. Using MongoDB persistence layer
2. Filtering on string attributes with `CaseExact = false`
3. Using equality (`eq`), starts-with (`sw`), ends-with (`ew`), or contains (`co`) operations

No changes required in application code - the optimization is transparent to SCIM API consumers.
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,36 @@ private static void EnsureSCIMRepresentationAttributeIndexesAreCreated(IMongoDat
{
var compoundIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.RepresentationId).Ascending(a => a.SchemaAttributeId).Ascending(a => a.ValueString);
var representationIdIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.RepresentationId);

// Add optimized index for filtering by SchemaAttributeId and ValueString (commonly used in SCIM queries)
var schemaAttributeValueIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.SchemaAttributeId).Ascending(a => a.ValueString);

// Add index for ValueString with case-insensitive collation (useful for regex queries)
var caseInsensitiveCollation = new Collation("en", strength: CollationStrength.Secondary);
var valueStringCaseInsensitiveOptions = new CreateIndexOptions { Collation = caseInsensitiveCollation };
var valueStringIndex = Builders<SCIMRepresentationAttribute>.IndexKeys.Ascending(a => a.ValueString);

EnsureIndexCreated(db, "RepresentationId_1_SchemaAttributeId_1_ValueString_1", name, compoundIndex);
EnsureIndexCreated(db, "RepresentationId_1", name, representationIdIndex);
EnsureIndexCreated(db, "SchemaAttributeId_1_ValueString_1", name, schemaAttributeValueIndex);
EnsureIndexCreated(db, "ValueString_1_case_insensitive", name, valueStringIndex, valueStringCaseInsensitiveOptions);
}

private static async void EnsureIndexCreated(IMongoDatabase db, string indexName, string name, IndexKeysDefinition<SCIMRepresentationAttribute> indexDefinition)
private static async void EnsureIndexCreated(IMongoDatabase db, string indexName, string name, IndexKeysDefinition<SCIMRepresentationAttribute> indexDefinition, CreateIndexOptions options = null)
{
var collection = db.GetCollection<SCIMRepresentationAttribute>(name);
var indexes = await collection.Indexes.List().ToListAsync();
if (indexes.Any(i => i.Elements.Any(e => e.Name == "name" && e.Value.AsString == indexName))) return;
collection.Indexes.CreateOne(indexDefinition);

if (options != null)
{
var indexModel = new CreateIndexModel<SCIMRepresentationAttribute>(indexDefinition, options);
collection.Indexes.CreateOne(indexModel);
}
else
{
collection.Indexes.CreateOne(indexDefinition);
}
}

private static IMongoCollection<T> EnsureCollectionIsCreated<T>(IMongoDatabase db, string name)
Expand Down
Loading