C# .NET Core 5.0 - Driver (2.12.0) - Sort ignore BsonElement attribute on projected class

Hello, I am here to understand if I am doing something wrong or it’s the standard beheviour.

I have a Document on the database defined with the class

public class Document {
    public string Name { get; set; }
    public string Description{ get; set; }
    public List<string> Children { get; set; }
}

The properties on MongoDb are traslated by the following global rules

ConventionRegistry.Register("camelCase", new ConventionPack { 
    new CamelCaseElementNameConvention()
}, _ => true);

So all the properties is camelCase on the Db

Here is the ProjectClass

public class ProjectionDocument {
    
    [BsonElement("name")]
    public string Name { get; set; }
    
    [BsonElement("description")]
    public string Description { get; set; }
}

This projectionClass is also registered as:

BsonClassMap.RegisterClassMap<ProjectionDocument>(cm =>
{
   cm.AutoMap();
});

When I read the data I am doing the follow:

IMongoCollection<Document>().Aggregate()
    .Match(matchDefinition)
    .Project(a => new ProjectionDocument {
            Name = a.Name,
            Description = a.Description,
         }
    .Group(a => a.Name, a => new ProjectionDocument {
            Name = a.Key,
            Description = a.First().Description,
        }
    .Sort(Builders<ProjectionDocument>.Sort.Ascending("name"))
    .ToList();

This does not sorting right.
If I use “Name” instead of “name” it will work.

It is the expected behaviour ?
I am not using a Lamba expression because the sort field definition can be anything.

Second question:

Is
Project(Builders<Document>.Projection.Expression(a => new ProjectionDocument { ... }))
the same things as
.Project(a => new ProjectionDocument { ... })

?

Thanks in advance.
Kind Regards

Hi, Andrea,

Thanks for reaching out to us with your question about mapping C# property names to database fields.

Since you are using a Builder for the sort stage, you should refer to the field by its C# property name “Name” rather than by its database field name “name”. The CamelCaseElementNameConvention should take care of any C# property name to MongoDB field name conversions.

ASIDE: Since CamelCaseElementNameConvention is configured for all types, you shouldn’t need to annotate your ProjectionDocument class with field names. Even [BsonElement] isn’t needed since we map all public get/set properties by default.

Hope that helps. Let us know if you have any additional questions.

Sincerely,
James

Hello, James
thank you for your quick answer.

So just for better understanding.
If I am sorting with a Builder the FieldDefinition refer to the name of the C# property, even if there is BsonElement or the property on the DB is camelCase (globally setted)?

I’ve tried do the Sort directly to the IMongoCollection<Document> (with the Builder) and it works using the camelCase property name and not the C# property name.

Can you explain me why this difference?

Thanks again for your time!

Ok maybe it’s like:

If I use the Builders<T> for sorting, projecting etc. the query works with the BsonDocument obj

Instead, if I use the lamba to perform this operations it’s like working in an upper level (C#) so thats why the property name valid is the C# class property name.

I understood correctly?

Hi, Andrea,

If you are working with Builders<BsonDocument> then you would use the field names in the database. If you are working with Builders<DOMAIN> (where DOMAIN is one of your strongly-typed C# classes) then you would use the C# property names.

If you have encountered a case where you’re using your C# domain class but require using the database field names, please provide a self-contained repro so we can debug further as that is not the expected behaviour.

Sincerely,
James

Hello James,

I will check but I’d like if you can check what I am actually doing with this example.

I have a class

public class CSharpClass {
     public string Name {get; set; }
     public string Description {get; set; }
     .... (like 20 more properties)
}

Because I have a dynamic filter (the user can search into the documents by keywords + value)
I have created a method for build the filterDefinition and it is like this

	private static FilterDefinition<T> GetFilterDefinitionByFunction<T>(TreeParser.QueryTreeItem item, List<FilterDefinition<T>> other) where T : class
	{
		return item.Operator switch
		{
			Function.And => Builders<T>.Filter.And(other),
			Function.Or => Builders<T>.Filter.Or(other),
			Function.Equal => Builders<T>.Filter.Eq(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition)),
			Function.Contains => double.TryParse(item.Condition, out _)
				? Builders<T>.Filter.Eq(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition))
				: Builders<T>.Filter.Regex(Mappings.KeywordDbMapping[item.Keyword], $"(?i).*{item.Condition}.*"),

			Function.EqualOrGreaterThan => Builders<T>.Filter.Gte(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition)),
			Function.EqualOrLesserThan => Builders<T>.Filter.Lte(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition)),
			Function.LesserThan => Builders<T>.Filter.Lt(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition)),
			Function.GreaterThan => Builders<T>.Filter.Gt(Mappings.KeywordDbMapping[item.Keyword], GetStringOrNumber(item.Condition)),
			Function.Size => Builders<T>.Filter.Size(Mappings.KeywordDbMapping[item.Keyword], GetIntNumber(item.Condition)),
			_ => Builders<T>.Filter.Empty
		};
	}

so when I call this BuilderMethod I do

var filterDefinition = GetFilterDefinitionByFunction<CSharpClass>(result.TreeItem, ...);

this call use a strongly typed C# class (used also fo the insert operation with the global camelCase convention) and the properties names that I get from

Mappings.KeywordDbMapping[item.Keyword]

is camelCase property name (because on MongoDb are like that) and it works greatly.

But after I do the

IMongoDbCollection<CSharpClass>.Aggregate().Match(filterDefinition)

and add

.Project(a => new NewCSharpDocument {
     .....
})

the sortDefinition use the C# property name.

Why the Match(filterDefinition) use the camelCase conventionad MongoDb properties?
Hope to have explained my question

Kind Regards,
Andrea

Hi, Andrea,

Thank you for your additional clarifications. I dug into your code a bit more and believe I understand the root cause of the problem.

In your aggregation, you are projecting into ProjectionDocument, grouping those results by Name into a new ProjectionDocument, and then attempting to sort those results. Our LINQ provider isn’t smart enough to realize that you’ve implicitly renamed Name to _id in the grouping stage.

This is probably somewhat confusing without a concrete example. Let’s consider the following aggregation…

ASIDE: In the .NET/C# driver you can see how we translate any query into MQL by calling ToString() on the query.

var query = coll.Aggregate()
                .Match(Builders<Document>.Filter.Empty)
                .Project(a => new ProjectionDocument
                {
                    Name = a.Name,
                    Description = a.Description,
                })
                .Group(a => a.Name, a => new ProjectionDocument
                {
                    Name = a.Key,
                    Description = a.First().Description,
                })
                .Sort(Builders<ProjectionDocument>.Sort.Ascending("name"));

Console.WriteLine(query.ToString());

The resulting aggregation is:

aggregate([
  { "$match" : { } },
  { "$project" : { "Name" : "$name", "Description" : "$description", "_id" : 0 } },
  { "$group" : { "_id" : "$Name", "Description" : { "$first" : "$Description" } } },
  { "$sort" : { "name" : 1 } }
])

Note the output of the $group stage is a set of documents of the form {_id: <<STRING>>, Description: <<STRING>>}. There is no mention of the name field in the output of the $group stage and thus the $sort stage is a no op.

Let’s consider a slightly different, but equivalent aggregation. Notably we use Name = a.First().Name rather than Name = a.Key:

var query = coll.Aggregate()
                .Match(Builders<Document>.Filter.Empty)
                .Project(a => new ProjectionDocument
                {
                    Name = a.Name,
                    Description = a.Description,
                })
                .Group(a => a.Name, a => new ProjectionDocument
                {
                    Name = a.First().Name,
                    Description = a.First().Description,
                })
                .Sort(Builders<ProjectionDocument>.Sort.Ascending("Name"));

Console.WriteLine(query.ToString());

The resulting MQL is:

aggregate([
  { "$match" : { } },
  { "$project" : { "Name" : "$name", "Description" : "$description", "_id" : 0 } },
  { "$group" : { "_id" : "$Name", "Name" : { "$first" : "$Name" }, "Description" : { "$first" : "$Description" } } },
  { "$sort" : { "Name" : 1 } }
])

The output of the $group stage now contains documents of the form {_id: <<STRING>>, Name: <<STRING>>, Description: <<STRING>>}. The $sort stage is now able to sort on the Name field as desired.

Note that the camelCaseConvention only plays a role in the $project where we are determining database field names from C# property names. e.g. { "$project" : { "Name" : "$name", "Description" : "$description", "_id" : 0 } } After the $project stage, we have transient documents with the correctly-cased field names that can be referred to by their C# property names.

Hope this explanation helps.

Sincerely,
James

2 Likes

Hello James,

thank you for your detailed clarifications.
Now I understood perfectly the behaviour!

Thanks again,
Andrea

1 Like

This topic was automatically closed 5 days after the last reply. New replies are no longer allowed.