diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cffd565..b2bca3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ MongoDB plugin for IntelliJ IDEA. ## [Unreleased] ### Added +* [INTELLIJ-173](https://jira.mongodb.org/browse/INTELLIJ-173) Add support for parsing, inspecting and autocompleting in a project stage written using `Aggregation.match` and chained `ProjectionOperations` using `andInclude` and `andExclude`. * [INTELLIJ-172](https://jira.mongodb.org/browse/INTELLIJ-172) Add support for parsing, inspecting and autocompleting in an aggregation written using Spring Data MongoDB (`MongoTemplate.aggregate`, `MongoTemplate.aggregateStream`) and a match stage written using `Aggregation.match`. * [INTELLIJ-179](https://jira.mongodb.org/browse/INTELLIJ-179) Telemetry when Create Index intention is clicked. It can be disabled in the Plugin settings. diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaCompletionContributorTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaCompletionContributorTest.kt index b7f3c38b..302f6e64 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaCompletionContributorTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaCompletionContributorTest.kt @@ -392,4 +392,522 @@ class Repository { }, ) } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project("") + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in a Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project(Fields.fields("")) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in fields helper passed to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project(Fields.from(Fields.field(""))) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in field helper passed to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude("") + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude(Fields.fields("")) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in fields helper passed to andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude(Fields.from(Fields.field(""))) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in field helper passed to andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andExclude("") + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in andExclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } } diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaMongoDbAutocompletionPopupHandlerTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaMongoDbAutocompletionPopupHandlerTest.kt index 7e5a9e1f..f03e366e 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaMongoDbAutocompletionPopupHandlerTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/autocomplete/SpringCriteriaMongoDbAutocompletionPopupHandlerTest.kt @@ -399,4 +399,529 @@ class Repository { }, ) } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project() + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in a Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project(Fields.fields()) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in fields helper passed to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project(Fields.from(Fields.field())) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in field helper passed to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude() + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude(Fields.fields()) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in fields helper passed to andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude(Fields.from(Fields.field())) + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in field helper passed to andInclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.query.Criteria; + +import java.util.List; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public List allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andExclude() + ), + Book.class, + Book.class + ).getMappedResults(); + } +} + """, + ) + fun `should autocomplete fields from the current namespace in andExclude call chained to Aggregation#project call`( + fixture: CodeInsightTestFixture, + ) { + val (dataSource, readModelProvider) = fixture.setupConnection() + fixture.specifyDatabase("myDatabase") + fixture.specifyDialect(SpringCriteriaDialect) + + val namespace = Namespace("myDatabase", "book") + + `when`( + readModelProvider.slice(eq(dataSource), eq(GetCollectionSchema.Slice(namespace))) + ).thenReturn( + GetCollectionSchema( + CollectionSchema( + namespace, + BsonObject( + mapOf( + "myField" to BsonString, + "myField2" to BsonString, + ), + ), + ), + ), + ) + + fixture.type('"') + val elements = fixture.completeBasic() + + assertTrue( + elements.containsElements { + it.lookupString == "myField" + }, + ) + + assertTrue( + elements.containsElements { + it.lookupString == "myField2" + }, + ) + } } diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inspections/impl/SpringCriteriaFieldCheckLinterInspectionTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inspections/impl/SpringCriteriaFieldCheckLinterInspectionTest.kt index 82a1d81b..9490f8f8 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inspections/impl/SpringCriteriaFieldCheckLinterInspectionTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inspections/impl/SpringCriteriaFieldCheckLinterInspectionTest.kt @@ -190,11 +190,29 @@ class BookRepository { ); } + String releasedFromMethodCall() { + return "released"; + } + public void allReleasedBooksAggregate() { + String releasedAsVariable = "released"; template.aggregate( Aggregation.newAggregation( Aggregation.match( where("released").is(true) + ), + Aggregation.project( + "released", + releasedAsVariable, + releasedFromMethodCall() + ).andInclude( + "released", + releasedAsVariable, + releasedFromMethodCall() + ).andExclude( + "released", + releasedAsVariable, + releasedFromMethodCall() ) ), Book.class, diff --git a/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/springcriteria/SpringCriteriaRepository.java b/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/springcriteria/SpringCriteriaRepository.java index d3229d3b..1f4fbd44 100644 --- a/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/springcriteria/SpringCriteriaRepository.java +++ b/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/springcriteria/SpringCriteriaRepository.java @@ -35,7 +35,7 @@ private List allMoviesWithRatingAtLeastAgg(int rating) { return template.aggregate( Aggregation.newAggregation( Aggregation.match(where( "tomatoes.viewer.rating").gte(rating)), - Aggregation.project("asd").andInclude("asd").andExclude("qwe") + Aggregation.project("fieldA").andInclude("fieldB").andExclude("fieldC") ), Movie.class, Movie.class diff --git a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/AggregationStagesParser.kt b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/AggregationStagesParser.kt index db9ab95f..53d72807 100644 --- a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/AggregationStagesParser.kt +++ b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/AggregationStagesParser.kt @@ -5,7 +5,7 @@ import com.intellij.psi.PsiMethod import com.intellij.psi.PsiMethodCallExpression import com.mongodb.jbplugin.dialects.javadriver.glossary.fuzzyResolveMethod import com.mongodb.jbplugin.dialects.javadriver.glossary.resolveToMethodCallExpression -import com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers.MatchStageParser +import com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers.StageParser import com.mongodb.jbplugin.mql.Node /** @@ -17,9 +17,9 @@ import com.mongodb.jbplugin.mql.Node * The AggregationParser concerns itself only with parsing the aggregation related semantics and * leave the rest as a responsibility for the composing unit. */ -class AggregationStagesParser(private val matchStageParser: MatchStageParser) { +class AggregationStagesParser(private val stageParsers: List) { private fun isStageCall(stageCallMethod: PsiMethod): Boolean { - return matchStageParser.canParse(stageCallMethod) + return stageParsers.any { it.canParse(stageCallMethod) } } private fun parseAggregationStages( @@ -37,14 +37,11 @@ class AggregationStagesParser(private val matchStageParser: MatchStageParser) { components = emptyList() ) - if (matchStageParser.canParse(stageCallMethod)) { - matchStageParser.parse(stageCall) - } else { - Node( - source = stageCall, - components = emptyList() - ) - } + val parser = stageParsers.find { it.canParse(stageCallMethod) } + parser?.parse(stageCall) ?: Node( + source = stageCall, + components = emptyList() + ) } } diff --git a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/SpringCriteriaDialectParser.kt b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/SpringCriteriaDialectParser.kt index 1241315b..e5a41b99 100644 --- a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/SpringCriteriaDialectParser.kt +++ b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/SpringCriteriaDialectParser.kt @@ -11,6 +11,8 @@ import com.mongodb.jbplugin.dialects.springcriteria.QueryTargetCollectionExtract import com.mongodb.jbplugin.dialects.springcriteria.QueryTargetCollectionExtractor.extractCollectionFromStringTypeParameter import com.mongodb.jbplugin.dialects.springcriteria.QueryTargetCollectionExtractor.or import com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers.MatchStageParser +import com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers.ProjectStageParser +import com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers.StageParser import com.mongodb.jbplugin.mql.BsonAny import com.mongodb.jbplugin.mql.BsonArray import com.mongodb.jbplugin.mql.Node @@ -21,8 +23,15 @@ private const val CRITERIA_CLASS_FQN = "org.springframework.data.mongodb.core.qu private const val DOCUMENT_FQN = "org.springframework.data.mongodb.core.mapping.Document" private const val MONGO_TEMPLATE_FQN = "org.springframework.data.mongodb.core.MongoTemplate" const val AGGREGATE_FQN = "org.springframework.data.mongodb.core.aggregation.Aggregation" +const val PROJECTION_OPERATION_FQN = "org.springframework.data.mongodb.core.aggregation.ProjectionOperation" +const val FIELDS_FQN = "org.springframework.data.mongodb.core.aggregation.Fields" object SpringCriteriaDialectParser : DialectParser { + private val aggregationStageParsers: List = listOf( + MatchStageParser(::parseFilterRecursively), + ProjectStageParser() + ) + override fun isCandidateForQuery(source: PsiElement) = inferCommandFromMethod((source as? PsiMethodCallExpression)?.fuzzyResolveMethod()).type != IsCommand.CommandType.UNKNOWN @@ -233,7 +242,7 @@ object SpringCriteriaDialectParser : DialectParser { ), HasAggregation( children = AggregationStagesParser( - matchStageParser = MatchStageParser(::parseFilterRecursively) + stageParsers = aggregationStageParsers ).parse(mongoOpCall) ) ) @@ -289,7 +298,11 @@ object SpringCriteriaDialectParser : DialectParser { } } - return isString && methodCall.isCriteriaExpression() + return isString && + ( + methodCall.isCriteriaExpression() || + methodCall.isSuitableForFieldAutoCompleteInAggregation(aggregationStageParsers) + ) } private fun isInsideDocAnnotations(source: PsiElement): Boolean { @@ -596,6 +609,13 @@ fun PsiMethodCallExpression.isCriteriaExpression(): Boolean { return method.containingClass?.qualifiedName == CRITERIA_CLASS_FQN } +fun PsiMethodCallExpression.isSuitableForFieldAutoCompleteInAggregation( + parsers: List +): Boolean { + val method = fuzzyResolveMethod() ?: return false + return parsers.any { it.isSuitableForFieldAutoComplete(this, method) } +} + private fun PsiMethodCallExpression.findSpringMongoDbExpression(): PsiMethodCallExpression? { val method = fuzzyResolveMethod() ?: return null if (INTERFACES_WITH_QUERY_METHODS.any { diff --git a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/MatchStageParser.kt b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/MatchStageParser.kt index f81b1e36..86c33366 100644 --- a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/MatchStageParser.kt +++ b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/MatchStageParser.kt @@ -4,6 +4,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiMethod import com.intellij.psi.PsiMethodCallExpression import com.mongodb.jbplugin.dialects.springcriteria.AGGREGATE_FQN +import com.mongodb.jbplugin.dialects.springcriteria.isCriteriaExpression import com.mongodb.jbplugin.mql.Node import com.mongodb.jbplugin.mql.components.HasFilter import com.mongodb.jbplugin.mql.components.Name @@ -23,6 +24,13 @@ class MatchStageParser( ) ) + override fun isSuitableForFieldAutoComplete( + methodCall: PsiMethodCallExpression, + method: PsiMethod + ): Boolean { + return methodCall.isCriteriaExpression() + } + override fun canParse(stageCallMethod: PsiMethod): Boolean { return stageCallMethod.containingClass?.qualifiedName == AGGREGATE_FQN && stageCallMethod.name == "match" diff --git a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParser.kt b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParser.kt new file mode 100644 index 00000000..224a00c1 --- /dev/null +++ b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParser.kt @@ -0,0 +1,252 @@ +package com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiExpression +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiMethodCallExpression +import com.mongodb.jbplugin.dialects.javadriver.glossary.fuzzyResolveMethod +import com.mongodb.jbplugin.dialects.javadriver.glossary.resolveFieldNameFromExpression +import com.mongodb.jbplugin.dialects.javadriver.glossary.resolveToMethodCallExpression +import com.mongodb.jbplugin.dialects.springcriteria.AGGREGATE_FQN +import com.mongodb.jbplugin.dialects.springcriteria.FIELDS_FQN +import com.mongodb.jbplugin.dialects.springcriteria.PROJECTION_OPERATION_FQN +import com.mongodb.jbplugin.mql.BsonInt32 +import com.mongodb.jbplugin.mql.Node +import com.mongodb.jbplugin.mql.components.HasFieldReference +import com.mongodb.jbplugin.mql.components.HasProjections +import com.mongodb.jbplugin.mql.components.HasValueReference +import com.mongodb.jbplugin.mql.components.Name +import com.mongodb.jbplugin.mql.components.Named + +class ProjectStageParser : StageParser { + private fun createProjectNode( + source: PsiElement, + projections: List> + ): Node { + return Node( + source = source, + components = listOf( + Named(Name.PROJECT), + HasProjections( + children = projections + ) + ) + ) + } + + private fun createProjectedFieldNode( + fieldExpression: PsiExpression, + projectionName: Name + ): Node { + val fieldReference = fieldExpression.resolveFieldNameFromExpression() + return Node( + source = fieldExpression as PsiElement, + components = listOf( + Named(projectionName), + HasFieldReference(fieldReference), + HasValueReference( + reference = when (projectionName) { + Name.INCLUDE, + Name.EXCLUDE -> HasValueReference.Inferred( + source = fieldExpression, + value = if (projectionName == Name.INCLUDE) 1 else 0, + type = BsonInt32 + ) + else -> HasValueReference.Unknown + } + ) + ) + ) + } + + /** + * Parses a `PsiExpression` that represents a `Field` object and returns a `Node` for the field + * referenced in the `Field` object. + * + * The only helper that we support for creating a `Field` object is `Fields.field()` with the + * following alternative signatures: + * 1. `Fields.field("fieldA")` + * 2. `Fields.field(fieldBFromVariable)` + * 3. `Fields.field(fieldCFromMethodCall())` + */ + private fun fieldObjectExpressionToProjectedFieldNode( + fieldObjectExpression: PsiExpression, + projectionName: Name + ): Node? { + val resolvedFieldMethodCall = fieldObjectExpression.resolveToMethodCallExpression { + _, + fieldObjectMethod + -> + fieldObjectMethod.containingClass?.qualifiedName == FIELDS_FQN && + fieldObjectMethod.name == "field" + } ?: return null + + val resolvedFieldMethod = resolvedFieldMethodCall.fuzzyResolveMethod() ?: return null + // This represents the following call: + // `Fields.field("fieldAlias", "actualFieldInDocument")` + // and since this translates to a rename operation, which we do not support just yet, we + // will ignore this until we come back to this. + if (resolvedFieldMethodCall.argumentList.expressions.size == 2) { + return null + } else { + val fieldExpression = resolvedFieldMethodCall.argumentList.expressions.getOrNull(0) + ?: return null + return createProjectedFieldNode(fieldExpression, projectionName) + } + } + + /** + * Parses a `PsiExpression` that represents a `Fields` object and returns a `Node` for each + * field referenced in the `Fields` object. + * + * The two different ways of generating a `Fields` object that we support and parse in here are: + * 1. `Fields.fields("fieldA", fieldBFromVariable, fieldCFromMethodCall())` + * 2. `Fields.from(Fields.field("fieldA"), Fields.field(fieldBFromVariable))` + */ + private fun fieldsObjectExpressionToProjectedFieldNode( + fieldsObjectExpression: PsiExpression, + projectionName: Name + ): List> { + val resolvedFieldsMethodCall = fieldsObjectExpression.resolveToMethodCallExpression { + _, + fieldsObjectMethod + -> + fieldsObjectMethod.containingClass?.qualifiedName == FIELDS_FQN && + (fieldsObjectMethod.name == "fields" || fieldsObjectMethod.name == "from") + } ?: return emptyList() + + val resolvedFieldsMethod = resolvedFieldsMethodCall.fuzzyResolveMethod() + ?: return emptyList() + + return when (resolvedFieldsMethod.name) { + "fields" -> resolvedFieldsMethodCall.argumentList.expressions.mapNotNull { + createProjectedFieldNode(it, projectionName) + } + "from" -> resolvedFieldsMethodCall.argumentList.expressions.mapNotNull { + fieldObjectExpressionToProjectedFieldNode(it, projectionName) + } + else -> { + // log.warn("Unsupported Fields method: ${resolvedFieldsMethod.name}") + emptyList() + } + } + } + + /** + * Parses a method that has the following overloaded signatures: + * 1. methodCall(String... fieldNames) + * 2. methodCall(Fields fields) + */ + private fun parseMethodCallWithStringVarArgsAndFields( + methodCall: PsiMethodCallExpression, + projectionName: Name, + ): List> { + val method = methodCall.fuzzyResolveMethod() ?: return emptyList() + return if (method.isVarArgs) { + methodCall.argumentList.expressions.map { + createProjectedFieldNode(fieldExpression = it, projectionName = projectionName) + } + } else { + val maybeFieldsObjectExpression = methodCall.argumentList.expressions.getOrNull(0) + ?: return emptyList() + fieldsObjectExpressionToProjectedFieldNode(maybeFieldsObjectExpression, projectionName) + } + } + + private fun parseAndIncludeCall(methodCall: PsiMethodCallExpression): List> { + // andInclude has two overloads, one which takes string varargs and another + // which takes a Fields object + return parseMethodCallWithStringVarArgsAndFields(methodCall, Name.INCLUDE) + } + + private fun parseAndExcludeCall(methodCall: PsiMethodCallExpression): List> { + // andExclude only accepts a varargs of strings as arguments + return methodCall.argumentList.expressions.map { + createProjectedFieldNode(fieldExpression = it, projectionName = Name.EXCLUDE) + } + } + + private fun parseProjectCall(methodCall: PsiMethodCallExpression): List> { + // Aggregation.project() has three overloads + // 1. Aggregation.project(String... fieldNames) + // 2. Aggregation.project(Fields fields) + // 3. Aggregation.project(Class type) + // We only support the first 2 and ignore the third one + return parseMethodCallWithStringVarArgsAndFields(methodCall, Name.INCLUDE) + } + + override fun isSuitableForFieldAutoComplete( + methodCall: PsiMethodCallExpression, + method: PsiMethod + ): Boolean { + val methodFqn = method.containingClass?.qualifiedName + // Autocomplete for Aggregation.project("") + return ( + methodFqn == AGGREGATE_FQN && + method.name == "project" + ) || + // Autocomplete for Aggregation.project().andInclude("") + ( + methodFqn == PROJECTION_OPERATION_FQN && + (method.name == "andInclude" || method.name == "andExclude") + ) || + // Autocomplete for Aggregation.project(Fields.fields("")) or, + // Aggregation.project(Fields.from(Fields.field(""))) + ( + methodFqn == FIELDS_FQN && + (method.name == "fields" || method.name == "field") + ) + } + + override fun canParse(stageCallMethod: PsiMethod): Boolean { + val methodFqn = stageCallMethod.containingClass?.qualifiedName ?: return false + // Project stage call might contain chained operations which might result + // in a method call from `PROJECTION_OPERATION_FQN` so we account for both + return listOf(AGGREGATE_FQN, PROJECTION_OPERATION_FQN).contains(methodFqn) && + // we consider the root call project and any chained calls which we currently + // support + listOf("project", "andInclude", "andExclude").contains(stageCallMethod.name) + } + + override fun parse(stageCall: PsiMethodCallExpression): Node { + return createProjectNode( + source = stageCall, + projections = stageCall.gatherChainedCalls().flatMap { methodCall -> + val method = methodCall.fuzzyResolveMethod() ?: return@flatMap emptyList() + when (method.name) { + "andInclude" -> parseAndIncludeCall(methodCall) + "andExclude" -> parseAndExcludeCall(methodCall) + "project" -> parseProjectCall(methodCall) + else -> emptyList() + } + } + ) + } +} + +/** + * From a PsiMethodCallExpression, it attempts to travel upwards the chain(assuming there is one) + * while gathering other PsiMethodCallExpressions it comes across, until there is no further + * PsiMethodCallExpression in the chain. For example, consider the following method call + * ``` + * Aggregation.project().andInclude("fieldA").andExclude("_id") + * ``` + * and given that the method was called for PsiMethodCallExpression referring the entire call above, + * the chain will consist of the following method calls + * [ + * Aggregation.project().andInclude("fieldA").andExclude("_id"), + * Aggregation.project().andInclude("fieldA") + * Aggregation.project() + * ] + */ +fun PsiMethodCallExpression.gatherChainedCalls(): List { + val chain = mutableListOf() + var currentCall: PsiMethodCallExpression? = this + + while (currentCall != null) { + chain.add(currentCall) + currentCall = currentCall.methodExpression.qualifierExpression as? PsiMethodCallExpression + } + + return chain +} diff --git a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/StageParser.kt b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/StageParser.kt index e0f504cc..b349a31d 100644 --- a/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/StageParser.kt +++ b/packages/mongodb-dialects/spring-criteria/src/main/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/StageParser.kt @@ -6,6 +6,10 @@ import com.intellij.psi.PsiMethodCallExpression import com.mongodb.jbplugin.mql.Node interface StageParser { + fun isSuitableForFieldAutoComplete( + methodCall: PsiMethodCallExpression, + method: PsiMethod + ): Boolean fun canParse(stageCallMethod: PsiMethod): Boolean fun parse(stageCall: PsiMethodCallExpression): Node } diff --git a/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/IntegrationTest.kt b/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/IntegrationTest.kt index c91ea332..888f252a 100644 --- a/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/IntegrationTest.kt +++ b/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/IntegrationTest.kt @@ -39,6 +39,7 @@ import com.mongodb.jbplugin.mql.components.HasAggregation import com.mongodb.jbplugin.mql.components.HasCollectionReference import com.mongodb.jbplugin.mql.components.HasFieldReference import com.mongodb.jbplugin.mql.components.HasFilter +import com.mongodb.jbplugin.mql.components.HasProjections import com.mongodb.jbplugin.mql.components.HasUpdates import com.mongodb.jbplugin.mql.components.HasValueReference import com.mongodb.jbplugin.mql.components.IsCommand @@ -352,6 +353,30 @@ fun Node.filterN( filter.assertions() } +fun Node.projectionN( + n: Int, + name: Name? = null, + assertions: Node.() -> Unit = { + } +) { + val projections = component>() + assertNotNull(projections) + + val projection = projections!!.children[n] + + if (name != null) { + val qname = projection.component() + assertNotEquals(null, qname) { + "Expected a named operation with name $name but null found." + } + assertEquals(name, qname?.name) { + "Expected a named operation with name $name but $qname found." + } + } + + projection.assertions() +} + fun Node.updateN( n: Int, name: Name? = null, diff --git a/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParserTest.kt b/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParserTest.kt new file mode 100644 index 00000000..dce50de7 --- /dev/null +++ b/packages/mongodb-dialects/spring-criteria/src/test/kotlin/com/mongodb/jbplugin/dialects/springcriteria/aggregationstageparsers/ProjectStageParserTest.kt @@ -0,0 +1,838 @@ +package com.mongodb.jbplugin.dialects.springcriteria.aggregationstageparsers + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.mongodb.jbplugin.dialects.springcriteria.IntegrationTest +import com.mongodb.jbplugin.dialects.springcriteria.ParsingTest +import com.mongodb.jbplugin.dialects.springcriteria.SpringCriteriaDialectParser +import com.mongodb.jbplugin.dialects.springcriteria.assert +import com.mongodb.jbplugin.dialects.springcriteria.collection +import com.mongodb.jbplugin.dialects.springcriteria.component +import com.mongodb.jbplugin.dialects.springcriteria.field +import com.mongodb.jbplugin.dialects.springcriteria.getQueryAtMethod +import com.mongodb.jbplugin.dialects.springcriteria.projectionN +import com.mongodb.jbplugin.dialects.springcriteria.stageN +import com.mongodb.jbplugin.dialects.springcriteria.value +import com.mongodb.jbplugin.mql.BsonInt32 +import com.mongodb.jbplugin.mql.components.HasCollectionReference +import com.mongodb.jbplugin.mql.components.HasFieldReference +import com.mongodb.jbplugin.mql.components.HasProjections +import com.mongodb.jbplugin.mql.components.HasSourceDialect +import com.mongodb.jbplugin.mql.components.HasValueReference +import com.mongodb.jbplugin.mql.components.IsCommand +import com.mongodb.jbplugin.mql.components.Name +import org.junit.jupiter.api.Assertions.assertEquals + +@IntegrationTest +class ProjectStageParserTest { + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public AggregationResults allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project() + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse an empty project stage`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(0, children.size) + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + public AggregationResults allReleasedBooks() { + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude().andExclude() + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse an empty project stage with empty chained calls`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(0, children.size) + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project("fieldA", fieldBFromVariable, fieldCFromMethodCall()) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse string field names passed as varargs to Aggregation#project`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project( + Fields.fields("fieldA", fieldBFromVariable, fieldCFromMethodCall()) + ) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse field names provided via Fields#fields to Aggregation#project`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project( + Fields.from( + Fields.field("fieldA"), + Fields.field(fieldBFromVariable), + Fields.field(fieldCFromMethodCall()) + ) + ) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse field names provided via Field#field objects to Aggregation#project`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude("fieldA", fieldBFromVariable, fieldCFromMethodCall()) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse chained andInclude calls with string field names passed as varargs`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude(Fields.fields("fieldA", fieldBFromVariable, fieldCFromMethodCall())) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse chained andInclude calls with field names provided via Fields#fields`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.Fields;import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andInclude( + Fields.from( + Fields.field("fieldA"), + Fields.field(fieldBFromVariable), + Fields.field(fieldCFromMethodCall()) + ) + ) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse chained andInclude calls with field names provided via Fields#Field objects`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project().andExclude("fieldA", fieldBFromVariable, fieldCFromMethodCall()) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse chained andExclude calls with string field names passed as varargs`( + psiFile: PsiFile + ) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.EXCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(0, value) + } + } + + projectionN(1, Name.EXCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(0, value) + } + } + + projectionN(2, Name.EXCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(0, value) + } + } + } + } + } + + @ParsingTest( + fileName = "Book.java", + """ +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.List; + +@Document +record Book() {} + +class Repository { + private final MongoTemplate template; + + public Repository(MongoTemplate template) { + this.template = template; + } + + private String fieldCFromMethodCall() { + return "fieldC"; + } + + public AggregationResults allReleasedBooks() { + String fieldBFromVariable = "fieldB"; + return template.aggregate( + Aggregation.newAggregation( + Aggregation.project("fieldA").andInclude(fieldBFromVariable).andExclude(fieldCFromMethodCall()) + ), + Book.class, + Book.class + ); + } +} + """ + ) + fun `should be able to parse multiple chained calls`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "allReleasedBooks") + SpringCriteriaDialectParser.parse(query).assert(IsCommand.CommandType.AGGREGATE) { + component { + assertEquals(HasSourceDialect.DialectName.SPRING_CRITERIA, name) + } + + collection> { + assertEquals("book", collection) + } + + stageN(0, Name.PROJECT) { + component> { + assertEquals(3, children.size) + } + + projectionN(0, Name.EXCLUDE) { + field> { + assertEquals("fieldC", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(0, value) + } + } + + projectionN(1, Name.INCLUDE) { + field> { + assertEquals("fieldB", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + + projectionN(2, Name.INCLUDE) { + field> { + assertEquals("fieldA", fieldName) + } + value> { + assertEquals(BsonInt32, type) + assertEquals(1, value) + } + } + } + } + } +}