spring-data-mongodb icon indicating copy to clipboard operation
spring-data-mongodb copied to clipboard

@MongoId not working for nested object queries using a projection class

Open nniesen opened this issue 2 years ago • 2 comments

The @MongoId annotation is not being taken into account for nested object queries using a projection class that doesn't include the nested object. This causes collection scans and results in query not finding the documents.

Actual: { "someRef.id" : "a"} Expected: { "someRef._id" : "a"}

Using:

+--- org.springframework.boot:spring-boot-dependencies:3.1.5
|    +--- org.springframework.boot:spring-boot-starter-data-mongodb:3.1.5 (c)
|    +--- org.springframework.boot:spring-boot-starter:3.1.5 (c)
|    +--- org.mongodb:mongodb-driver-sync:4.6.1 (c)
|    +--- org.springframework.data:spring-data-mongodb:3.1.5 (c)

Example code:

@SpringBootApplication
@EnableMongoRepositories
public class ProjectionMongoIdApplication implements CommandLineRunner {
    @Autowired
    MongoTemplate mongoTemplate;

    @SuppressWarnings("resource")
    public static void main(String[] args) {
        SpringApplication.run(IndexIdMappingApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        mongoTemplate.dropCollection(Widget.class);
        IndexOperations indexOps = mongoTemplate.indexOps(Widget.class);
        indexOps.ensureIndex(new Index("someRef._id", Direction.ASC));
        
        mongoTemplate.save(new Widget("123", new SomeRef("a", "Apple")));
        mongoTemplate.save(new Widget("456", new SomeRef("b", "Banana")));

        // queries { "someRef._id" : "a"} and uses index for "someRef._id"
        mongoTemplate.find(new Query(Criteria.where("someRef.id").is("a")), Widget.class).forEach(w -> {
            System.out.printf("query by someRef.id found %s:%s\n", w.id, w.name);
        });

        // queries { "someRef._id" : "a"} and uses index for "someRef._id"
        mongoTemplate.find(new Query(Criteria.where("someRef._id").is("a")), Widget.class).forEach(w -> {
            System.out.printf("query by someRef._id found found %s:%s\n", w.id, w.name);
        });

        // queries { "someRef._id" : "a"} and uses index for "someRef._id"
        Query query = new Query(Criteria.where("someRef.id").is("a"));
        query.fields().include("name");
        mongoTemplate.find(query, Widget.class).forEach(w -> {
            System.out.printf("query by someRef.id w include fields found %s:%s\n", w.id, w.name);
        });

        // BUG: queries { "someRef.id" : "a"}, finds no doc, and results in a collection scan because it can't find index for "someRef.id"
        mongoTemplate.find(new Query(Criteria.where("someRef.id").is("a")), WidgetProjection.class).forEach(w -> {
            System.out.printf("query by someRef.id w projection class found %s:%s\n", w.id, w.name);
        });

        // queries { "someRef._id" : "a"} and uses index for "someRef._id"
        mongoTemplate.find(new Query(Criteria.where("someRef._id").is("a")), WidgetProjection.class).forEach(w -> {
            System.out.printf("query by someRef._id w projection class found %s:%s\n", w.id, w.name);
        });
    }
}

@Document
class Widget {
    @Id
    String id;
    String name;
    SomeRef someRef;
    
    public Widget(String id, SomeRef someRef) {
        this.id = id;
        this.name = "name-" + id;
        this.someRef = someRef;
    }
}

@Document(collection = "widget")
class WidgetProjection {
    String id;
    String name;
//    SomeRef someRef; // Adding this makes it work but don't want it on the projection.
}

class SomeRef {
    @MongoId(FieldType.IMPLICIT)
    String id;
    String name;
    
    public SomeRef(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

Logging (logging.level.org.springframework.data.mongodb.core: DEBUG)

... find using query: { "someRef._id" : "a"} fields: Document{{}} for class: class Widget ...
query by someRef.id found 123:name-123
... find using query: { "someRef._id" : "a"} fields: Document{{}} for class: class Widget ...
query by someRef._id found found 123:name-123
... find using query: { "someRef._id" : "a"} fields: Document{{name=1}} for class: class Widget ...
query by someRef.id w include fields found 123:name-123
... find using query: { "someRef.id" : "a"} fields: Document{{}} for class: class WidgetProjection ...
                                                    ^- BUG: using wrong id and no documents found and collection scan
... find using query: { "someRef._id" : "a"} fields: Document{{}} for class: class WidgetProjection ...
query by someRef._id w projection class found 123:name-123

Profiling:

db.setProfilingLevel(2)
db.getCollection("system.profile").find({"command.find": "widget"}, {"command.filter": 1, planSummary:1})

{
    "command" : {
        "filter" : {
            "someRef._id" : "a"
        }
    },
    "planSummary" : "IXSCAN { someRef._id: 1 }"
}
{
    "command" : {
        "filter" : {
            "someRef._id" : "a"
        }
    },
    "planSummary" : "IXSCAN { someRef._id: 1 }"
}
{
    "command" : {
        "filter" : {
            "someRef._id" : "a"
        }
    },
    "planSummary" : "IXSCAN { someRef._id: 1 }"
}
{
    "command" : {
        "filter" : {
            "someRef.id" : "a"
        }
    },
    "planSummary" : "COLLSCAN"
}
{
    "command" : {
        "filter" : {
            "someRef._id" : "a"
        }
    },
    "planSummary" : "IXSCAN { someRef._id: 1 }"
}

nniesen avatar Nov 14 '23 20:11 nniesen

Thank you for reaching out. The snippet above works as designed. The query mapping is only able to use fields that are actually contained in the given domain type. WidgetProjection however does not contain the path to the property used in the criteria. Therefore it will treat the given input as is.

The fluent template API offers dedicated methods that allow to map the query against a given domain type and project results to a different one.

List<WidgetProjection> result = template.query(Widget.class)
	.as(WidgetProjection.class)
	.matching(where("someRef.id").is("a"))
	.all();

christophstrobl avatar Nov 15 '23 06:11 christophstrobl

Thank you for reaching out. The snippet above works as designed. The query mapping is only able to use fields that are actually contained in the given domain type. WidgetProjection however does not contain the path to the property used in the criteria. Therefore it will treat the given input as is.

That's unfortunate since it's not intuitive and seems to behave differently than the rest of the API. It seems like the mapping should work off of the collections domain type not the projections domain type. I'm assuming the problem for you is that there is no difference between those two; i.e., you only have the class/type passed to the find.

It might be more obvious/discoverable if there was an additional signature of:

/** equivalent to query(Class<T> domainType).as(Class<R> resultType).matching(CriteriaDefinition criteria) */
public <R> List<R> find(Query query, Class<?> entityClass, Class<R> resultType)

nniesen avatar Nov 15 '23 16:11 nniesen