Skip to content

CSHARP-4528: Extend Search.Equals to support number and date #1043

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 22, 2023
Merged
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
25 changes: 22 additions & 3 deletions src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,37 @@ Func<BsonArray> Render(List<SearchDefinition<TDocument>> searchDefinitions) =>
}
}

internal sealed class EqualsSearchDefinition<TDocument> : OperatorSearchDefinition<TDocument>
internal sealed class EqualsSearchDefinition<TDocument, TField> : OperatorSearchDefinition<TDocument>
{
private readonly BsonValue _value;

public EqualsSearchDefinition(FieldDefinition<TDocument> path, BsonValue value, SearchScoreDefinition<TDocument> score)
public EqualsSearchDefinition(FieldDefinition<TDocument> path, TField value, SearchScoreDefinition<TDocument> score)
: base(OperatorType.Equals, path, score)
{
_value = value;
_value = ToBsonValue(value);
}

private protected override BsonDocument RenderArguments(IBsonSerializer<TDocument> documentSerializer, IBsonSerializerRegistry serializerRegistry) =>
new("value", _value);

private static BsonValue ToBsonValue(TField value) =>
value switch
{
bool v => (BsonBoolean)v,
sbyte v => (BsonInt32)v,
byte v => (BsonInt32)v,
short v => (BsonInt32)v,
ushort v => (BsonInt32)v,
int v => (BsonInt32)v,
uint v => (BsonInt64)v,
long v => (BsonInt64)v,
float v => (BsonDouble)v,
double v => (BsonDouble)v,
DateTime v => (BsonDateTime)v,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support DateTimeOffset too?

Copy link
Contributor Author

@BorisDog BorisDog Mar 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can. We don't have a direct implicit conversion to BsonDateTime, so not sure how best to account for offset in this case.
Maybe we can consider support for DateTimeOffset both for Equals and Range in a separate ticket?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that BsonDateTime stores the date in UTC, we can do something like this:
DateTimeOffset v => (BsonDateTime)v.UtcDateTime,

We would also need to add tests.

DateTimeOffset v => (BsonDateTime)v.UtcDateTime,
ObjectId v => (BsonObjectId)v,
_ => throw new InvalidCastException()
};
}

internal sealed class ExistsSearchDefinition<TDocument> : OperatorSearchDefinition<TDocument>
Expand Down
52 changes: 14 additions & 38 deletions src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,58 +73,34 @@ public SearchDefinition<TDocument> Autocomplete<TField>(
/// <summary>
/// Creates a search definition that queries for documents where an indexed field is equal
/// to the specified value.
/// Supported value types are boolean, numeric, ObjectId and date.
/// </summary>
/// <param name="path">The indexed field to search.</param>
/// <param name="value">The value to query for.</param>
/// <param name="score">The score modifier.</param>
/// <returns>An equality search definition.</returns>
public SearchDefinition<TDocument> Equals(
FieldDefinition<TDocument, bool> path,
bool value,
SearchScoreDefinition<TDocument> score = null) =>
new EqualsSearchDefinition<TDocument>(path, value, score);

/// <summary>
/// Creates a search definition that queries for documents where an indexed field is equal
/// to the specified value.
/// </summary>
/// <param name="path">The indexed field to search.</param>
/// <param name="value">The value to query for.</param>
/// <param name="score">The score modifier.</param>
/// <returns>An equality search definition.</returns>
public SearchDefinition<TDocument> Equals(
FieldDefinition<TDocument, ObjectId> path,
ObjectId value,
SearchScoreDefinition<TDocument> score = null) =>
new EqualsSearchDefinition<TDocument>(path, value, score);

/// <summary>
/// Creates a search definition that queries for documents where an indexed field is equal
/// to the specified value.
/// </summary>
/// <param name="path">The indexed field to search.</param>
/// <param name="value">The value to query for.</param>
/// <param name="score">The score modifier.</param>
/// <returns>An equality search definition.</returns>
public SearchDefinition<TDocument> Equals(
Expression<Func<TDocument, bool>> path,
bool value,
SearchScoreDefinition<TDocument> score = null) =>
Equals(new ExpressionFieldDefinition<TDocument, bool>(path), value, score);
public SearchDefinition<TDocument> Equals<TField>(
FieldDefinition<TDocument, TField> path,
TField value,
SearchScoreDefinition<TDocument> score = null)
where TField : struct, IComparable<TField> =>
new EqualsSearchDefinition<TDocument, TField>(path, value, score);

/// <summary>
/// Creates a search definition that queries for documents where an indexed field is equal
/// to the specified value.
/// Supported value types are boolean, numeric, ObjectId and date.
/// </summary>
/// <param name="path">The indexed field to search.</param>
/// <param name="value">The value to query for.</param>
/// <param name="score">The score modifier.</param>
/// <returns>An equality search definition.</returns>
public SearchDefinition<TDocument> Equals(
Expression<Func<TDocument, ObjectId>> path,
ObjectId value,
SearchScoreDefinition<TDocument> score = null) =>
Equals(new ExpressionFieldDefinition<TDocument, ObjectId>(path), value, score);
public SearchDefinition<TDocument> Equals<TField>(
Expression<Func<TDocument, TField>> path,
TField value,
SearchScoreDefinition<TDocument> score = null)
where TField : struct, IComparable<TField> =>
Equals(new ExpressionFieldDefinition<TDocument, TField>(path), value, score);

/// <summary>
/// Creates a search definition that tests if a path to a specified indexed field name
Expand Down
91 changes: 67 additions & 24 deletions tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using FluentAssertions;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
Expand Down Expand Up @@ -176,41 +177,66 @@ public void Compound_typed()
"{ compound: { must: [{ exists: { path: 'age' } }, { exists: { path: 'fn' } }, { exists: { path: 'ln' } }], mustNot: [{ exists: { path: 'ret' } }, { exists: { path: 'dob' } }] } }");
}

[Fact]
public void Equals()
[Theory]
[MemberData(nameof(EqualsSupportedTypesTestData))]
public void Equals_should_render_supported_type<T>(
T value,
string valueRendered,
Expression<Func<Person, T>> fieldExpression,
string fieldRendered)
where T : struct, IComparable<T>
{
var subject = CreateSubject<BsonDocument>();
var subjectTyped = CreateSubject<Person>();

AssertRendered(
subject.Equals("x", true),
"{ equals: { path: 'x', value: true } }");
AssertRendered(
subject.Equals("x", ObjectId.Empty),
"{ equals: { path: 'x', value: { $oid: '000000000000000000000000' } } }");
subject.Equals("x", value),
$"{{ equals: {{ path: 'x', value: {valueRendered} }} }}");

var scoreBuilder = new SearchScoreDefinitionBuilder<BsonDocument>();
AssertRendered(
subject.Equals("x", true, scoreBuilder.Constant(1)),
"{ equals: { path: 'x', value: true, score: { constant: { value: 1 } } } }");
subject.Equals("x", value, scoreBuilder.Constant(1)),
$"{{ equals: {{ path: 'x', value: {valueRendered}, score: {{ constant: {{ value: 1 }} }} }} }}");

AssertRendered(
subjectTyped.Equals(fieldExpression, value),
$"{{ equals: {{ path: '{fieldRendered}', value: {valueRendered} }} }}");
}

[Fact]
public void Equals_typed()
public static object[][] EqualsSupportedTypesTestData => new[]
{
var subject = CreateSubject<Person>();
new object[] { true, "true", Exp(p => p.Retired), "ret" },
new object[] { (sbyte)1, "1", Exp(p => p.Int8), nameof(Person.Int8), },
new object[] { (byte)1, "1", Exp(p => p.UInt8), nameof(Person.UInt8), },
new object[] { (short)1, "1", Exp(p => p.Int16), nameof(Person.Int16) },
new object[] { (ushort)1, "1", Exp(p => p.UInt16), nameof(Person.UInt16) },
new object[] { (int)1, "1", Exp(p => p.Int32), nameof(Person.Int32) },
new object[] { (uint)1, "1", Exp(p => p.UInt32), nameof(Person.UInt32) },
new object[] { long.MaxValue, "NumberLong(\"9223372036854775807\")", Exp(p => p.Int64), nameof(Person.Int64) },
new object[] { (float)1, "1", Exp(p => p.Float), nameof(Person.Float) },
new object[] { (double)1, "1", Exp(p => p.Double), nameof(Person.Double) },
new object[] { DateTime.MinValue, "ISODate(\"0001-01-01T00:00:00Z\")", Exp(p => p.Birthday), "dob" },
new object[] { DateTimeOffset.MaxValue, "ISODate(\"9999-12-31T23:59:59.999Z\")", Exp(p => p.DateTimeOffset), nameof(Person.DateTimeOffset) },
new object[] { ObjectId.Empty, "{ $oid: '000000000000000000000000' }", Exp(p => p.Id), "_id" }
};

AssertRendered(
subject.Equals(x => x.Retired, true),
"{ equals: { path: 'ret', value: true } }");
AssertRendered(
subject.Equals("Retired", true),
"{ equals: { path: 'ret', value: true } }");
[Theory]
[MemberData(nameof(EqualsUnsupporteddTypesTestData))]
public void Equals_should_throw_on_unsupported_type<T>(T value, Expression<Func<Person, T>> fieldExpression) where T : struct, IComparable<T>
{
var subject = CreateSubject<BsonDocument>();
Record.Exception(() => subject.Equals("x", value)).Should().BeOfType<InvalidCastException>();

AssertRendered(
subject.Equals(x => x.Id, ObjectId.Empty),
"{ equals: { path: '_id', value: { $oid: '000000000000000000000000' } } }");
var subjectTyped = CreateSubject<Person>();
Record.Exception(() => subjectTyped.Equals(fieldExpression, value)).Should().BeOfType<InvalidCastException>();
}

public static object[][] EqualsUnsupporteddTypesTestData => new[]
{
new object[] { (ulong)1, Exp(p => p.UInt64) },
new object[] { TimeSpan.Zero, Exp(p => p.TimeSpan) },
};

[Fact]
public void Exists()
{
Expand Down Expand Up @@ -928,8 +954,24 @@ private void AssertRendered<TDocument>(SearchDefinition<TDocument> query, BsonDo

private SearchDefinitionBuilder<TDocument> CreateSubject<TDocument>() => new SearchDefinitionBuilder<TDocument>();

private class Person : SimplePerson
private static Expression<Func<Person, T>> Exp<T>(Expression<Func<Person, T>> expression) => expression;

public class Person : SimplePerson
{
public byte UInt8 { get; set; }
public sbyte Int8 { get; set; }
public short Int16 { get; set; }
public ushort UInt16 { get; set; }
public int Int32 { get; set; }
public uint UInt32 { get; set; }
public long Int64 { get; set; }
public ulong UInt64 { get; set; }
public float Float { get; set; }
public double Double { get; set; }

public DateTimeOffset DateTimeOffset { get; set; }
public TimeSpan TimeSpan { get; set; }

[BsonElement("age")]
public int Age { get; set; }

Expand All @@ -938,14 +980,15 @@ private class Person : SimplePerson

[BsonId]
public ObjectId Id { get; set; }

[BsonElement("location")]
public GeoJsonPoint<GeoJson2DGeographicCoordinates> Location { get; set; }

[BsonElement("ret")]
public bool Retired { get; set; }
}

private class SimplePerson
public class SimplePerson
{
[BsonElement("fn")]
public string FirstName { get; set; }
Expand All @@ -954,7 +997,7 @@ private class SimplePerson
public string LastName { get; set; }
}

private class SimplestPerson
public class SimplestPerson
{
[BsonElement("fn")]
public string FirstName { get; set; }
Expand Down