Skip to content

Commit 30913b4

Browse files
authored
CSHARP-4927: Add support for Atlas Search $in operator to builder (#1249)
1 parent 9ea6e66 commit 30913b4

File tree

5 files changed

+211
-8
lines changed

5 files changed

+211
-8
lines changed

src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,48 @@ private protected override BsonDocument RenderArguments(SearchDefinitionRenderCo
222222
new(_area.Render());
223223
}
224224

225+
internal sealed class InSearchDefinition<TDocument, TField> : OperatorSearchDefinition<TDocument>
226+
{
227+
private readonly BsonArray _values;
228+
229+
public InSearchDefinition(
230+
SearchPathDefinition<TDocument> path,
231+
IEnumerable<TField> values,
232+
SearchScoreDefinition<TDocument> score)
233+
: base(OperatorType.In, path, score)
234+
{
235+
Ensure.IsNotNullOrEmpty(values, nameof(values));
236+
var array = new BsonArray(values.Select(ToBsonValue));
237+
238+
var bsonType = array[0].GetType();
239+
_values = Ensure.That(array, arr => arr.All(v => v.GetType() == bsonType), nameof(values), "All values must be of the same type.");
240+
}
241+
242+
private protected override BsonDocument RenderArguments(SearchDefinitionRenderContext<TDocument> renderContext) =>
243+
new("value", _values);
244+
245+
private static BsonValue ToBsonValue(TField value) =>
246+
value switch
247+
{
248+
bool v => (BsonBoolean)v,
249+
sbyte v => (BsonInt32)v,
250+
byte v => (BsonInt32)v,
251+
short v => (BsonInt32)v,
252+
ushort v => (BsonInt32)v,
253+
int v => (BsonInt32)v,
254+
uint v => (BsonInt64)v,
255+
long v => (BsonInt64)v,
256+
float v => (BsonDouble)v,
257+
double v => (BsonDouble)v,
258+
decimal v => (BsonDecimal128)v,
259+
DateTime v => (BsonDateTime)v,
260+
DateTimeOffset v => (BsonDateTime)v.UtcDateTime,
261+
string v => (BsonString)v,
262+
ObjectId v => (BsonObjectId)v,
263+
_ => throw new InvalidCastException()
264+
};
265+
}
266+
225267
internal sealed class MoreLikeThisSearchDefinition<TDocument, TLike> : OperatorSearchDefinition<TDocument>
226268
{
227269
private readonly TLike[] _like;

src/MongoDB.Driver/Search/SearchDefinition.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ private protected enum OperatorType
116116
Facet,
117117
GeoShape,
118118
GeoWithin,
119+
In,
119120
MoreLikeThis,
120121
Near,
121122
Phrase,

src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,34 @@ public SearchDefinition<TDocument> GeoWithin<TCoordinates>(
298298
where TCoordinates : GeoJsonCoordinates =>
299299
new GeoWithinSearchDefinition<TDocument, TCoordinates>(path, area, score);
300300

301+
/// <summary>
302+
/// Creates a search definition that queries for documents where the value of the field equals to any of specified values.
303+
/// </summary>
304+
/// <typeparam name="TField">The type of the field. Valid types are: boolean, ObjectId, number, date, string.</typeparam>
305+
/// <param name="path">The indexed field or fields to search.</param>
306+
/// <param name="values">Values to compare the field with.</param>
307+
/// <param name="score">The score modifier.</param>
308+
/// <returns>An In search definition.</returns>
309+
public SearchDefinition<TDocument> In<TField>(
310+
SearchPathDefinition<TDocument> path,
311+
IEnumerable<TField> values,
312+
SearchScoreDefinition<TDocument> score = null) =>
313+
new InSearchDefinition<TDocument, TField>(path, values, score);
314+
315+
/// <summary>
316+
/// Creates a search definition that queries for documents where the value of the field equals to any of specified values.
317+
/// </summary>
318+
/// <typeparam name="TField">The type of the field. Valid types are: boolean, ObjectId, number, date, string.</typeparam>
319+
/// <param name="path">The indexed field or fields to search.</param>
320+
/// <param name="values">Values to compare the field with.</param>
321+
/// <param name="score">The score modifier.</param>
322+
/// <returns>An In search definition.</returns>
323+
public SearchDefinition<TDocument> In<TField>(
324+
Expression<Func<TDocument, TField>> path,
325+
IEnumerable<TField> values,
326+
SearchScoreDefinition<TDocument> score = null) =>
327+
In(new ExpressionFieldDefinition<TDocument>(path), values, score);
328+
301329
/// <summary>
302330
/// Creates a search definition that returns documents similar to the input documents.
303331
/// </summary>

tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,22 @@ public void GeoWithin(string geometryType)
222222
results.First().Name.Should().Be("Ribeira Charming Duplex");
223223
}
224224

225+
[Fact]
226+
public void In()
227+
{
228+
var results = GetSynonymTestCollection()
229+
.Aggregate()
230+
.Search(
231+
Builders<Movie>.Search.In(x => x.Runtime, new[] { 31, 231 }),
232+
new() { Sort = Builders<Movie>.Sort.Descending(x => x.Runtime)})
233+
.Limit(10)
234+
.ToList();
235+
236+
results.Count.Should().Be(2);
237+
results[0].Runtime.Should().Be(231);
238+
results[1].Runtime.Should().Be(31);
239+
}
240+
225241
[Fact]
226242
public void MoreLikeThis()
227243
{
@@ -437,14 +453,12 @@ public void Should()
437453
[Fact]
438454
public void Sort()
439455
{
440-
var results = GetTestCollection().Aggregate()
441-
.Search(
442-
Builders.Search.Text(x => x.Body, "liberty"),
443-
new() { Sort = Builders.Sort.Descending(x => x.Title) })
444-
.Project<HistoricalDocument>(Builders.Projection.Include(x => x.Title))
445-
.Limit(1)
446-
.ToList();
447-
results.Should().ContainSingle().Which.Title.Should().Be("US Constitution");
456+
var result = SearchSingle(
457+
Builders.Search.Text(x => x.Body, "liberty"),
458+
Builders.Projection.Include(x => x.Title),
459+
Builders.Sort.Descending(x => x.Title));
460+
461+
result.Title.Should().Be("US Constitution");
448462
}
449463

450464
[Fact]
@@ -632,6 +646,9 @@ public class Movie
632646
[BsonElement("title")]
633647
public string Title { get; set; }
634648

649+
[BsonElement("runtime")]
650+
public int Runtime { get; set; }
651+
635652
[BsonElement("score")]
636653
public double Score { get; set; }
637654
}

tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,118 @@ public void GeoWithin_typed()
475475
"{ geoWithin: { circle: { center: { type: 'Point', coordinates: [-161.323242, 22.512557] }, radius: 7.5 }, path: 'location' } }");
476476
}
477477

478+
[Theory]
479+
[MemberData(nameof(InTestData))]
480+
public void In<T>(T[] fieldValues, string[] fieldsRendered)
481+
{
482+
var subject = CreateSubject<BsonDocument>();
483+
484+
AssertRendered(
485+
subject.In("x", fieldValues),
486+
$"{{ in: {{ path: 'x', value: [{string.Join(",", fieldsRendered)}] }} }}");
487+
}
488+
489+
public static readonly object[][] InTestData =
490+
{
491+
new object[] { new bool[] { true, false }, new[] { "true", "false" } },
492+
new object[] { new byte[] { 1, 2 }, new[] { "1", "2" } },
493+
new object[] { new sbyte[] { 1, 2 }, new[] { "1", "2" } },
494+
new object[] { new short[] { 1, 2 }, new[] { "1", "2" } },
495+
new object[] { new ushort[] { 1, 2 }, new[] { "1", "2" } },
496+
new object[] { new int[] { 1, 2 }, new[] { "1", "2" } },
497+
new object[] { new uint[] { 1, 2 }, new[] { "1", "2" } },
498+
new object[] { new long[] { long.MaxValue, long.MinValue }, new[] { "NumberLong(\"9223372036854775807\")", "NumberLong(\"-9223372036854775808\")" } },
499+
new object[] { new float[] { 1.5f, 2.5f }, new[] { "1.5", "2.5" } },
500+
new object[] { new double[] { 1.5, 2.5 }, new[] { "1.5", "2.5" } },
501+
new object[] { new decimal[] { 1.5m, 2.5m }, new[] { "NumberDecimal(\"1.5\")", "NumberDecimal(\"2.5\")" } },
502+
new object[] { new[] { "str1", "str2" }, new[] { "'str1'", "'str2'" } },
503+
new object[] { new[] { DateTime.MinValue, DateTime.MaxValue }, new[] { "ISODate(\"0001-01-01T00:00:00Z\")", "ISODate(\"9999-12-31T23:59:59.999Z\")" } },
504+
new object[] { new[] { DateTimeOffset.MinValue, DateTimeOffset.MaxValue }, new[] { "ISODate(\"0001-01-01T00:00:00Z\")", "ISODate(\"9999-12-31T23:59:59.999Z\")" } },
505+
new object[] { new[] { ObjectId.Empty, ObjectId.Parse("4d0ce088e447ad08b4721a37") }, new[] { "{ $oid: '000000000000000000000000' }", "{ $oid: '4d0ce088e447ad08b4721a37' }" } },
506+
new object[] { new object[] { (byte)1, (short)2, (int)3 }, new[] { "1", "2", "3" } }
507+
};
508+
509+
[Theory]
510+
[MemberData(nameof(InTypedTestData))]
511+
public void In_typed<T>(
512+
T[] fieldValues,
513+
string[] fieldValuesRendered,
514+
Expression<Func<Person, T>> fieldExpression,
515+
string fieldNameRendered)
516+
{
517+
var subject = CreateSubject<Person>();
518+
var fieldValuesArray = $"[{string.Join(",", fieldValuesRendered)}]";
519+
520+
AssertRendered(
521+
subject.In("x", fieldValues),
522+
$"{{ in: {{ path: 'x', value: {fieldValuesArray} }} }}");
523+
524+
AssertRendered(
525+
subject.In(fieldExpression, fieldValues),
526+
$"{{ in: {{path: '{fieldNameRendered}', value: {fieldValuesArray} }} }}");
527+
}
528+
529+
public static readonly object[][] InTypedTestData =
530+
{
531+
new object[] { new bool[] { true, false }, new[] { "true", "false" }, Exp(p => p.Retired), "ret" },
532+
new object[] { new byte[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.UInt8), nameof(Person.UInt8) },
533+
new object[] { new sbyte[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.Int8), nameof(Person.Int8) },
534+
new object[] { new short[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.Int16), nameof(Person.Int16) },
535+
new object[] { new ushort[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.UInt16), nameof(Person.UInt16) },
536+
new object[] { new int[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.Int32), nameof(Person.Int32) },
537+
new object[] { new uint[] { 1, 2 }, new[] { "1", "2" }, Exp(p => p.UInt32), nameof(Person.UInt32) },
538+
new object[] { new long[] { long.MaxValue, long.MinValue }, new[] { "NumberLong(\"9223372036854775807\")", "NumberLong(\"-9223372036854775808\")" }, Exp(p => p.Int64), nameof(Person.Int64) },
539+
new object[] { new float[] { 1.5f, 2.5f }, new[] { "1.5", "2.5" }, Exp(p => p.Float), nameof(Person.Float) },
540+
new object[] { new double[] { 1.5, 2.5 }, new[] { "1.5", "2.5" }, Exp(p => p.Double), nameof(Person.Double) },
541+
new object[] { new decimal[] { 1.5m, 2.5m }, new[] { "NumberDecimal(\"1.5\")", "NumberDecimal(\"2.5\")" }, Exp(p => p.Decimal), nameof(Person.Decimal) },
542+
new object[] { new[] { "str1", "str2" }, new[] { "'str1'", "'str2'" }, Exp(p => p.FirstName), "fn" },
543+
new object[] { new[] { DateTime.MinValue, DateTime.MaxValue }, new[] { "ISODate(\"0001-01-01T00:00:00Z\")", "ISODate(\"9999-12-31T23:59:59.999Z\")" }, Exp(p => p.Birthday), "dob" },
544+
new object[] { new[] { DateTimeOffset.MinValue, DateTimeOffset.MaxValue }, new[] { "ISODate(\"0001-01-01T00:00:00Z\")", "ISODate(\"9999-12-31T23:59:59.999Z\")" }, Exp(p => p.DateTimeOffset), nameof(Person.DateTimeOffset)},
545+
new object[] { new[] { ObjectId.Empty, ObjectId.Parse("4d0ce088e447ad08b4721a37") }, new[] { "{ $oid: '000000000000000000000000' }", "{ $oid: '4d0ce088e447ad08b4721a37' }" }, Exp(p => p.Id), "_id" },
546+
new object[] { new object[] { (byte)1, (short)2, (int)3 }, new[] { "1", "2", "3" }, Exp(p => p.Object), nameof(Person.Object) }
547+
};
548+
549+
[Theory]
550+
[MemberData(nameof(InUnsupportedTypesTestData))]
551+
public void In_should_throw_on_unsupported_types<T>(T value, Expression<Func<Person, T>> fieldExpression)
552+
{
553+
var subject = CreateSubject<BsonDocument>();
554+
Record.Exception(() => subject.In("x", new[] { value } )).Should().BeOfType<InvalidCastException>();
555+
556+
var subjectTyped = CreateSubject<Person>();
557+
Record.Exception(() => subjectTyped.In(fieldExpression, new[] { value })).Should().BeOfType<InvalidCastException>();
558+
}
559+
560+
[Fact]
561+
public void In_should_throw_when_values_are_invalid()
562+
{
563+
var subject = CreateSubject<BsonDocument>();
564+
Record.Exception(() => subject.In("x", new int[] { })).Should().BeOfType<ArgumentException>();
565+
Record.Exception(() => subject.In<int>("x", null)).Should().BeOfType<ArgumentNullException>();
566+
567+
var subjectTyped = CreateSubject<Person>();
568+
Record.Exception(() => subjectTyped.In(p => p.Age, new int[] { })).Should().BeOfType<ArgumentException>();
569+
Record.Exception(() => subjectTyped.In(p => p.Age, null)).Should().BeOfType<ArgumentNullException>();
570+
}
571+
572+
public static object[][] InUnsupportedTypesTestData => new[]
573+
{
574+
new object[] { (ulong)1, Exp(p => p.UInt64) },
575+
new object[] { TimeSpan.Zero, Exp(p => p.TimeSpan) },
576+
};
577+
578+
[Fact]
579+
public void In_should_throw_when_values_are_not_of_same_type()
580+
{
581+
var values = new object[] { 1.5, 1 };
582+
583+
var subject = CreateSubject<BsonDocument>();
584+
Record.Exception(() => subject.In("x", values)).Should().BeOfType<ArgumentException>();
585+
586+
var subjectTyped = CreateSubject<Person>();
587+
Record.Exception(() => subjectTyped.In(p => p.Object, values)).Should().BeOfType<ArgumentException>();
588+
}
589+
478590
[Fact]
479591
public void MoreLikeThis()
480592
{
@@ -1117,6 +1229,7 @@ public class Person : SimplePerson
11171229
public ulong UInt64 { get; set; }
11181230
public float Float { get; set; }
11191231
public double Double { get; set; }
1232+
public decimal Decimal { get; set; }
11201233

11211234
public DateTimeOffset DateTimeOffset { get; set; }
11221235
public TimeSpan TimeSpan { get; set; }
@@ -1135,6 +1248,8 @@ public class Person : SimplePerson
11351248

11361249
[BsonElement("ret")]
11371250
public bool Retired { get; set; }
1251+
1252+
public object Object { get; set; }
11381253
}
11391254

11401255
public class Family

0 commit comments

Comments
 (0)