diff --git a/.github/workflows/quality-check.yaml b/.github/workflows/quality-check.yaml index dfed3297..b98f4093 100644 --- a/.github/workflows/quality-check.yaml +++ b/.github/workflows/quality-check.yaml @@ -120,7 +120,10 @@ jobs: sudo apt install -y docker docker-compose - name: Run Test Suite run: | - ./gradlew --stacktrace --console=plain "clean" "cleanTest" "unitTest" "jacocoTestReport" -x "packages:jetbrains-plugin:uiTest" + ./gradlew --stacktrace --console=plain "clean" "cleanTest" "unitTest" -x "packages:jetbrains-plugin:uiTest" + - name: Generate Coverage Report + run: | + ./gradlew "jacocoTestReport" $(./gradlew "jacocoTestReport" --dry-run | awk '/^:/ { print "-x" $1 }' | sed '$ d') - name: Publish Test Report uses: mikepenz/action-junit-report@v4 if: success() || failure() # always run even if the previous step fails diff --git a/.gitignore b/.gitignore index 74208f68..1c0956e9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ packages/jetbrains-plugin/src/main/resources/build.properties /**/video /**/.DS_Store -FAKE_BROWSER_OUTPUT \ No newline at end of file +FAKE_BROWSER_OUTPUT +/.kotlin diff --git a/CHANGELOG.md b/CHANGELOG.md index 308c03bc..07d8f758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ MongoDB plugin for IntelliJ IDEA. ## [Unreleased] ### Added +* [INTELLIJ-29](https://jira.mongodb.org/browse/INTELLIJ-29): Shows an inlay hint near a Java query that shows in which collection the query is +going to be run in case it could be inferred. * [INTELLIJ-17](https://jira.mongodb.org/browse/INTELLIJ-17): Added a toolbar that allows to attach a MongoDB data source to the current editor. This data source is used for autocompletion and type checking. * [INTELLIJ-14](https://jira.mongodb.org/browse/INTELLIJ-14): Send telemetry when a connection to a MongoDB Cluster fails. diff --git a/packages/jetbrains-plugin/build.gradle.kts b/packages/jetbrains-plugin/build.gradle.kts index 963adb14..fafb1fe1 100644 --- a/packages/jetbrains-plugin/build.gradle.kts +++ b/packages/jetbrains-plugin/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(project(":packages:mongodb-access-adapter:datagrip-access-adapter")) implementation(project(":packages:mongodb-autocomplete-engine")) implementation(project(":packages:mongodb-dialects")) + implementation(project(":packages:mongodb-dialects:java-driver")) implementation(project(":packages:mongodb-linting-engine")) implementation(project(":packages:mongodb-mql-model")) @@ -40,6 +41,7 @@ dependencies { testCompileOnly(libs.testing.intellij.ideImpl) testCompileOnly(libs.testing.intellij.coreUi) + testImplementation(libs.mongodb.driver) testImplementation(libs.testing.jsoup) testImplementation(libs.testing.video.recorder) testImplementation(libs.testing.remoteRobot) diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/editor/EditorToolbarDecorator.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/editor/EditorToolbarDecorator.kt index 45bd7d83..e6ca8722 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/editor/EditorToolbarDecorator.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/editor/EditorToolbarDecorator.kt @@ -128,7 +128,14 @@ class EditorToolbarDecorator( private fun isEditingJavaFileWithMongoDbRelatedCode(): Boolean { val project = editor.project ?: return false - val psiFile = PsiManager.getInstance(project).findFile(editor.virtualFile) ?: return false + val psiFileResult = runCatching { PsiManager.getInstance(project).findFile(editor.virtualFile) } + + if (psiFileResult.isFailure) { + return false + } + + val psiFile = psiFileResult.getOrThrow()!! + if (psiFile.language != JavaLanguage.INSTANCE) { return false } diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlay.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlay.kt new file mode 100644 index 00000000..15deda36 --- /dev/null +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlay.kt @@ -0,0 +1,73 @@ +package com.mongodb.jbplugin.inlays + +import com.intellij.codeInsight.hints.declarative.* +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiMethodCallExpression +import com.intellij.psi.util.parentOfType +import com.mongodb.jbplugin.dialects.javadriver.glossary.NamespaceExtractor +import com.mongodb.jbplugin.dialects.javadriver.glossary.findContainingClass +import com.mongodb.jbplugin.dialects.javadriver.glossary.isMongoDbCollectionClass + +/** + * This inlay shows for the current query in which namespace is going to run, if possible, + * according to the extraction rules of NamespaceExtractor. + * + * @see NamespaceExtractor + */ +class JavaDriverQueryNamespaceInlay : InlayHintsProvider { + override fun createCollector( + file: PsiFile, + editor: Editor, + ): InlayHintsCollector = QueryNamespaceInlayHintsCollector() + + class QueryNamespaceInlayHintsCollector : SharedBypassCollector { + override fun collectFromElement( + element: PsiElement, + sink: InlayTreeSink, + ) { + val asMethodCall = element as? PsiMethodCallExpression + + val callsAcollection = + asMethodCall + ?.methodExpression + ?.resolve() + ?.findContainingClass() + ?.isMongoDbCollectionClass( + element.project, + ) == true + + val callsSuperClass = + asMethodCall?.findContainingClass() != null && + asMethodCall.findContainingClass().superClass != null && + (asMethodCall.methodExpression.resolve() as? PsiMethod)?.containingClass == + asMethodCall.findContainingClass().superClass + + if (callsAcollection || callsSuperClass) { + val namespace = + runCatching { + NamespaceExtractor.extractNamespace(element) + ?: NamespaceExtractor.extractNamespace(element.parentOfType()!!) + }.getOrNull() + + namespace ?: return + + val documentManager = PsiDocumentManager.getInstance(element.project) + val document = documentManager.getDocument(element.containingFile)!! + val lineOfElement = document.getLineNumber(element.textOffset) + + sink.addPresentation( + EndOfLinePosition(lineOfElement), + emptyList(), + "Inferred MongoDB namespace for this query.", + true, + ) { + text(namespace.toString()) + } + } + } + } +} diff --git a/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml b/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml index 4bb02a32..6a3a7dab 100644 --- a/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml +++ b/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml @@ -22,6 +22,14 @@ bundle="messages.TelemetryBundle" key="notification.group.name" /> + diff --git a/packages/jetbrains-plugin/src/main/resources/messages/InspectionsAndInlaysBundle.properties b/packages/jetbrains-plugin/src/main/resources/messages/InspectionsAndInlaysBundle.properties new file mode 100644 index 00000000..3f777388 --- /dev/null +++ b/packages/jetbrains-plugin/src/main/resources/messages/InspectionsAndInlaysBundle.properties @@ -0,0 +1,2 @@ +notification.group.inspection.results=MongoDB Inspection Results +inlay.namespace.name=MongoDB Namespace Inlay \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/editor/javaEditor/JavaDriverToolbarVisibilityUiTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/editor/javaEditor/JavaDriverToolbarVisibilityUiTest.kt index 2a80a525..85eeb49a 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/editor/javaEditor/JavaDriverToolbarVisibilityUiTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/editor/javaEditor/JavaDriverToolbarVisibilityUiTest.kt @@ -33,7 +33,7 @@ class JavaDriverToolbarVisibilityUiTest { @Test @RequiresProject("basic-java-project-with-mongodb") fun `shows the toolbar in a java file with references to the driver`(remoteRobot: RemoteRobot) { - remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java") + remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java") val toolbar = remoteRobot.findJavaEditorToolbar() assertTrue(toolbar.isShowing) } @@ -51,7 +51,7 @@ class JavaDriverToolbarVisibilityUiTest { remoteRobot: RemoteRobot, url: MongoDbServerUrl, ) { - remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java") + remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java") val toolbar = remoteRobot.findJavaEditorToolbar() assertTrue(toolbar.dataSources.listValues().contains(javaClass.simpleName)) @@ -63,7 +63,7 @@ class JavaDriverToolbarVisibilityUiTest { remoteRobot: RemoteRobot, url: MongoDbServerUrl, ) { - remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java") + remoteRobot.ideaFrame().openFile("/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java") val toolbar = remoteRobot.findJavaEditorToolbar() assertTrue(toolbar.dataSources.listValues().contains(javaClass.simpleName)) diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/CodeInsightTestExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/CodeInsightTestExtensions.kt new file mode 100644 index 00000000..00c1115b --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/CodeInsightTestExtensions.kt @@ -0,0 +1,220 @@ +/** + * JUnit extension to run tests that depend on code insights without building the whole IDE. + * They are more lightweight than UI tests. + */ + +package com.mongodb.jbplugin.fixtures + +import com.intellij.java.library.JavaLibraryUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.util.Disposer +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiFile +import com.intellij.testFramework.PsiTestUtil +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.mongodb.client.MongoClient +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.* + +import java.lang.reflect.Method +import java.net.URI +import java.net.URL +import java.nio.file.Paths +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * Annotation to add to the test function. + */ +@Retention(AnnotationRetention.RUNTIME) +@Test +annotation class ParsingTest( + val fileName: String, + @Language("java") val value: String, +) + +/** + * Annotation to be used in the test, at the class level. + * + * @see com.mongodb.jbplugin.accessadapter.datagrip.adapter.DataGripMongoDbDriverTest + */ +@ExtendWith(CodeInsightTestExtension::class) +annotation class CodeInsightTest + +/** + * Extension implementation. Must not be used directly. + */ +internal class CodeInsightTestExtension : + BeforeEachCallback, + AfterEachCallback, + InvocationInterceptor, + ParameterResolver { + private val namespace = ExtensionContext.Namespace.create(CodeInsightTestExtension::class.java) + private val testFixtureKey = "TESTFIXTURE" + + override fun beforeEach(context: ExtensionContext) { + val projectFixture = + IdeaTestFixtureFactory + .getFixtureFactory() + .createLightFixtureBuilder(context.requiredTestClass.simpleName) + .fixture + + val testFixture = + IdeaTestFixtureFactory + .getFixtureFactory() + .createCodeInsightFixture( + projectFixture, + ) + + context.getStore(namespace).put(testFixtureKey, testFixture) + testFixture.setUp() + + ApplicationManager.getApplication().invokeAndWait { + if (!JavaLibraryUtil.hasLibraryJar(testFixture.module, "org.mongodb:mongodb-driver-sync:5.1.1")) { + runCatching { + PsiTestUtil.addProjectLibrary( + testFixture.module, + "mongodb-driver-sync", + listOf( + Path( + pathToJavaDriver(), + ).toAbsolutePath().toString(), + ), + ) + } + } + } + + val tmpRootDir = testFixture.tempDirFixture.getFile(".")!! + + PsiTestUtil.addSourceRoot(testFixture.module, testFixture.project.guessProjectDir()!!) + PsiTestUtil.addSourceRoot(testFixture.module, tmpRootDir) + + val parsingTest = context.requiredTestMethod.getAnnotation(ParsingTest::class.java) + + ApplicationManager.getApplication().invokeAndWait { + val fileName = Path(tmpRootDir.path, "src", "main", "java", parsingTest.fileName).absolutePathString() + + testFixture.configureByText( + fileName, + parsingTest.value, + ) + } + } + + override fun interceptTestMethod( + invocation: InvocationInterceptor.Invocation, + invocationContext: ReflectiveInvocationContext, + extensionContext: ExtensionContext, + ) { + val throwable: AtomicReference = AtomicReference(null) + val finished = AtomicBoolean(false) + + val fixture = extensionContext.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + val dumbService = fixture.project.getService(DumbService::class.java) + dumbService.runWhenSmart { + ApplicationManager.getApplication().invokeAndWait { + val result = + runCatching { + invocation.proceed() + } + + result.onSuccess { + finished.set(true) + } + + result.onFailure { + throwable.set(it) + finished.set(true) + } + } + } + + runBlocking { + withTimeout(10.seconds) { + while (!finished.get()) { + delay(50.milliseconds) + } + } + } + + throwable.get()?.let { + System.err.println(it.message) + it.printStackTrace(System.err) + throw it + } + } + + override fun afterEach(context: ExtensionContext) { + val testFixture = context.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + + ApplicationManager.getApplication().invokeAndWait { + runCatching { + val fileEditorManager = FileEditorManager.getInstance(testFixture.project) + fileEditorManager.openFiles.forEach { + fileEditorManager.closeFile(it) + } + fileEditorManager.allEditors.forEach { + Disposer.dispose(it) + } + + testFixture.tearDown() + } + } + } + + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext, + ): Boolean = + parameterContext.parameter.type == Project::class.java || + parameterContext.parameter.type == CodeInsightTestFixture::class.java || + parameterContext.parameter.type == PsiFile::class.java || + parameterContext.parameter.type == JavaPsiFacade::class.java + + override fun resolveParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext, + ): Any { + val fixture = extensionContext.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + + return when (parameterContext.parameter.type) { + Project::class.java -> fixture.project + CodeInsightTestFixture::class.java -> fixture + PsiFile::class.java -> fixture.file + JavaPsiFacade::class.java -> JavaPsiFacade.getInstance(fixture.project) + else -> TODO("Parameter of type ${parameterContext.parameter.type.canonicalName} is not supported.") + } + } + + private fun pathToJavaDriver(): String { + val classResource: URL = + MongoClient::class.java.getResource(MongoClient::class.java.getSimpleName() + ".class") + ?: throw RuntimeException("class resource is null") + val url: String = classResource.toString() + if (url.startsWith("jar:file:")) { + // extract 'file:......jarName.jar' part from the url string + val path = url.replace("^jar:(file:.*[.]jar)!/.*".toRegex(), "$1") + try { + return Paths.get(URI(path)).toString() + } catch (e: Exception) { + throw RuntimeException("Invalid Jar File URL String") + } + } + throw RuntimeException("Invalid Jar File URL String") + } +} diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt index b0796227..997ee17b 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt @@ -15,7 +15,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.project.impl.ProjectImpl import com.intellij.serviceContainer.ComponentManagerImpl -import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.common.cleanApplicationState +import com.intellij.testFramework.common.initTestApplication import com.intellij.testFramework.replaceService import com.mongodb.jbplugin.observability.LogMessage import com.mongodb.jbplugin.observability.LogMessageBuilder @@ -39,7 +40,6 @@ import kotlinx.coroutines.test.TestScope * * @see com.mongodb.jbplugin.observability.LogMessageTest */ -@TestApplication @ExtendWith(IntegrationTestExtension::class) annotation class IntegrationTest @@ -56,6 +56,8 @@ private class IntegrationTestExtension : private lateinit var testScope: TestScope override fun beforeTestExecution(context: ExtensionContext?) { + initTestApplication() + application = ApplicationManager.getApplication() as ApplicationEx project = ProjectImpl( @@ -73,6 +75,8 @@ private class IntegrationTestExtension : application.invokeAndWait({ ProjectManager.getInstance().closeAndDispose(project) }, ModalityState.defaultModalityState()) + + ApplicationManager.getApplication().cleanApplicationState() } override fun supportsParameter( diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlayTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlayTest.kt new file mode 100644 index 00000000..5104c5a0 --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/inlays/JavaDriverQueryNamespaceInlayTest.kt @@ -0,0 +1,42 @@ +package com.mongodb.jbplugin.inlays + +import com.intellij.psi.PsiFile +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.mongodb.jbplugin.fixtures.CodeInsightTest +import com.mongodb.jbplugin.fixtures.ParsingTest +import org.junit.jupiter.api.Assertions.* + +@CodeInsightTest +class JavaDriverQueryNamespaceInlayTest { + @ParsingTest( + fileName = "Repository.java", + value = """ +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public class Repository { + private final MongoClient client; + + public Repository(MongoClient client) { + this.client = client; + } + + public FindIterable exampleFind() { + return client.getDatabase("myDatabase") + .getCollection("myCollection") + .find(); + } +} + """, + ) + fun `shows a inlay hint when the namespace is resolved`( + psiFile: PsiFile, + fixture: CodeInsightTestFixture, + ) { + fixture.testInlays() + } +} diff --git a/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java b/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java new file mode 100644 index 00000000..18fb3186 --- /dev/null +++ b/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepository.java @@ -0,0 +1,22 @@ +package alt.mongodb.javadriver; + +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public class JavaDriverRepository { + private final MongoClient client; + + public JavaDriverRepository(MongoClient client) { + this.client = client; + } + + public FindIterable exampleFind() { + return client.getDatabase("myDatabase") + .getCollection("myCollection") + .find(); + } +} diff --git a/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java b/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java deleted file mode 100644 index 0aec6101..00000000 --- a/packages/jetbrains-plugin/src/test/resources/project-fixtures/basic-java-project-with-mongodb/src/main/java/alt/mongodb/javadriver/JavaDriverRepositoryExample.java +++ /dev/null @@ -1,13 +0,0 @@ -package alt.mongodb.javadriver; - -import com.mongodb.client.FindIterable; -import com.mongodb.client.MongoClient; -import org.bson.Document; - -public class JavaDriverRepositoryExample { - public FindIterable exampleFind(MongoClient mongoClient) { - return mongoClient.getDatabase("myDatabase") - .getCollection("myCollection") - .find(); - } -} diff --git a/packages/mongodb-access-adapter/datagrip-access-adapter/src/test/kotlin/com/mongodb/jbplugin/accessadapter/datagrip/IntegrationTest.kt b/packages/mongodb-access-adapter/datagrip-access-adapter/src/test/kotlin/com/mongodb/jbplugin/accessadapter/datagrip/IntegrationTest.kt index 97bb8134..0cd70f60 100644 --- a/packages/mongodb-access-adapter/datagrip-access-adapter/src/test/kotlin/com/mongodb/jbplugin/accessadapter/datagrip/IntegrationTest.kt +++ b/packages/mongodb-access-adapter/datagrip-access-adapter/src/test/kotlin/com/mongodb/jbplugin/accessadapter/datagrip/IntegrationTest.kt @@ -18,8 +18,9 @@ import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.common.cleanApplicationState +import com.intellij.testFramework.common.initTestApplication import com.intellij.testFramework.junit5.RunInEdt -import com.intellij.testFramework.junit5.TestApplication import com.intellij.util.ui.EDT import com.mongodb.jbplugin.accessadapter.MongoDbDriver import com.mongodb.jbplugin.accessadapter.datagrip.adapter.DataGripMongoDbDriver @@ -50,7 +51,6 @@ enum class MongoDbVersion( * * @see com.mongodb.jbplugin.accessadapter.datagrip.adapter.DataGripMongoDbDriverTest */ -@TestApplication @RunInEdt(allMethods = true, writeIntent = true) @ExtendWith(IntegrationTestExtension::class) @Testcontainers(parallel = false) @@ -76,6 +76,8 @@ internal class IntegrationTestExtension : private val versionKey = "VERSION" override fun beforeAll(context: ExtensionContext?) { + initTestApplication() + val annotation = context!!.requiredTestClass.getAnnotation(IntegrationTest::class.java) val container = MongoDBContainer("mongo:${annotation.mongodb.versionString}-jammy") @@ -162,6 +164,8 @@ internal class IntegrationTestExtension : runBlocking { driver.runCommand("test", Document(mapOf("dropDatabase" to 1)), Unit::class) } + + ApplicationManager.getApplication().cleanApplicationState() } override fun afterAll(context: ExtensionContext?) { diff --git a/packages/mongodb-dialects/build.gradle.kts b/packages/mongodb-dialects/build.gradle.kts new file mode 100644 index 00000000..67a652ef --- /dev/null +++ b/packages/mongodb-dialects/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation(project(":packages:mongodb-mql-model")) +} diff --git a/packages/mongodb-dialects/java-driver/build.gradle.kts b/packages/mongodb-dialects/java-driver/build.gradle.kts new file mode 100644 index 00000000..929f45f3 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/build.gradle.kts @@ -0,0 +1,52 @@ +repositories { + maven("https://www.jetbrains.com/intellij-repository/releases/") + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") +} + +plugins { + alias(libs.plugins.intellij) +} + +tasks { + named("test", Test::class) { + environment("TESTCONTAINERS_RYUK_DISABLED", "true") + val homePath = + project.layout.buildDirectory + .dir("idea-sandbox/config-test") + .get() + .asFile.absolutePath + + jvmArgs( + listOf( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt=ALL-UNNAMED", + "--add-opens=java.desktop/javax.swing=ALL-UNNAMED", + "--add-opens=java.desktop/sun.awt=ALL-UNNAMED", + "-Dpolyglot.engine.WarnInterpreterOnly=false", + "-Dpolyglot.log.level=OFF", + "-Didea.home.path=$homePath", + ), + ) + } +} + +intellij { + version.set(libs.versions.intellij.min) // Target IDE Version + type.set(libs.versions.intellij.type) // Target IDE Platform + + plugins.set(listOf("com.intellij.java", "com.intellij.database")) +} + +dependencies { + implementation(project(":packages:mongodb-mql-model")) + implementation(project(":packages:mongodb-dialects")) + + testImplementation(libs.mongodb.driver) + testImplementation("com.jetbrains.intellij.platform:test-framework-junit5:241.15989.155") { + exclude("ai.grazie.spell") + exclude("ai.grazie.utils") + exclude("ai.grazie.nlp") + exclude("ai.grazie.model") + exclude("org.jetbrains.teamcity") + } +} diff --git a/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialect.kt b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialect.kt new file mode 100644 index 00000000..b44464e9 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialect.kt @@ -0,0 +1,10 @@ +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.psi.PsiElement +import com.mongodb.jbplugin.dialects.Dialect +import com.mongodb.jbplugin.dialects.DialectParser + +object JavaDriverDialect : Dialect { + override val parser: DialectParser + get() = JavaDriverDialectParser +} diff --git a/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParser.kt b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParser.kt new file mode 100644 index 00000000..379bcbfc --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParser.kt @@ -0,0 +1,33 @@ +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiMethodCallExpression +import com.intellij.psi.util.PsiTreeUtil +import com.mongodb.jbplugin.dialects.DialectParser +import com.mongodb.jbplugin.mql.Node +import com.mongodb.jbplugin.mql.components.HasCollectionReference + +object JavaDriverDialectParser : DialectParser { + override fun isCandidateForQuery(source: PsiElement): Boolean = + (source as? PsiMethodCallExpression)?.findMongoDbCollectionReference(source.project) != null + + override fun attachment(source: PsiElement): PsiElement = + (source as PsiMethodCallExpression).findMongoDbCollectionReference(source.project)!! + + override fun parse(source: PsiElement): Node { + val owningMethod = + PsiTreeUtil.getParentOfType(source, PsiMethod::class.java) + ?: return Node(source, emptyList()) + val namespace = NamespaceExtractor.extractNamespace(owningMethod) + + return Node( + source, + listOf( + namespace?.let { + HasCollectionReference(HasCollectionReference.Known(namespace)) + } ?: HasCollectionReference(HasCollectionReference.Unknown), + ), + ) + } +} diff --git a/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractor.kt b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractor.kt new file mode 100644 index 00000000..db71fcf1 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractor.kt @@ -0,0 +1,452 @@ +/** + * This class is used to extract the namespace of a query for the Java Driver. + */ + +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.openapi.project.Project +import com.intellij.psi.* +import com.intellij.psi.util.PsiTreeUtil.* +import com.mongodb.jbplugin.mql.Namespace + +private typealias FoundAssignedPsiFields = List> + +@Suppress("ktlint") // it seems the class is too complex for ktlint to understand it +object NamespaceExtractor { + fun extractNamespace(query: PsiElement): Namespace? { + val currentClass = query.findContainingClass() + + val allMethodCallsInMethod = query.collectTypeUntil( + PsiMethodCallExpression::class.java, + PsiMethod::class.java + ) + findChildrenOfType(query, PsiMethodCallExpression::class.java) + + val referencesToMongoDbClasses = + allMethodCallsInMethod.mapNotNull { + it.findCurrentReferenceToMongoDbObject() + } + + val constructorAssignmentFromConstructorRefs: List = + referencesToMongoDbClasses.flatMap { ref -> + val resolution = ref.resolve() ?: return@flatMap emptyList() + + when (resolution) { + // we assume constructor injection + // find in the constructor how it's defined + is PsiField -> { + return@flatMap resolveConstructorArgumentReferencesForField( + resolution.findContainingClass(), + Pair(null, resolution), + ) + } + // this can be either a chain call of methods, like getDatabase(..).getCollection() + // with constant values or references to fields + is PsiMethod -> { + val innerMethodCalls = findChildrenOfType( + resolution, + PsiMethodCallExpression::class.java + ) + val resolutions = + innerMethodCalls + .filter { + it.type?.isMongoDbClass(it.project) == true + }.mapNotNull { + runCatching { + extractRelevantFieldsFromChain(it) + }.getOrNull() + }.flatten() + .distinctBy { it.first } + + val containingClass = resolution.findContainingClass() + return@flatMap resolutions.flatMap { + resolveConstructorArgumentReferencesForField(containingClass, it) + } + } + + else -> + return@flatMap emptyList() + } + } + + val constructorAssignmentFromMethodsRefs: List = allMethodCallsInMethod + .mapNotNull { + // if we can remove the namespace from this call, return directly + // we don't need to traverse the tree anymore + val maybeNamespace = runCatching { extractNamespaceFromDriverConfigurationMethodChain(it) } + .getOrNull() + if (maybeNamespace != null) { + return maybeNamespace + } + it.findMongoDbClassReference(it.project) + }.flatMap { + when (it.reference?.resolve()) { + is PsiField -> + resolveConstructorArgumentReferencesForField( + currentClass, + Pair(null, it.reference?.resolve() as PsiField), + ) + + is PsiMethod -> { + val method = it.reference?.resolve() as PsiMethod + if (method.containingClass?.isMongoDbClass(method.project) == true + && it is PsiMethodCallExpression + ) { + return extractNamespaceFromDriverConfigurationMethodChain( + it + ) + } + val allInnerExpressions = findChildrenOfAnyType( + method, + PsiMethodCallExpression::class.java, + PsiExpression::class.java + ) + + val foundNamespace = allInnerExpressions + .filterIsInstance() + .firstNotNullOfOrNull { + extractNamespaceFromDriverConfigurationMethodChain(it) + } + + if (foundNamespace != null) { + return foundNamespace + } + + emptyList() + } + + else -> emptyList() + } + } + + val constructorAssignments = constructorAssignmentFromConstructorRefs + constructorAssignmentFromMethodsRefs + // at this point, we need to resolve fields or parameters that are not known yet + // but might be resolvable through the actual class or the abstract class + val resolvedScopes = + constructorAssignments.mapNotNull { assignment -> + currentClass.constructors.firstNotNullOfOrNull { + if (assignment.parameter != null) { + val callToSuperConstructor = getCallToSuperConstructor(it, constructorAssignments) + + val indexOfParameter = + assignment.constructor.parameterList.getParameterIndex( + assignment.parameter, + ) + + runCatching { + Pair( + assignment.concept, + callToSuperConstructor!!.argumentList.expressions[indexOfParameter], + ) + }.getOrNull() + } else { + Pair(assignment.concept, assignment.resolutionExpression) + } + } + } + + val collection = resolvedScopes.find { it.first == AssignmentConcept.COLLECTION } + val database = resolvedScopes.find { it.first == AssignmentConcept.DATABASE } + val client = resolvedScopes.find { it.first == AssignmentConcept.CLIENT } + + if (collection != null && database == null) { + // if we have a parameter for the collection, but we don't have the database + // assume it's a call to getDatabase().getCollection() + return extractNamespaceFromDriverConfigurationMethodChain(collection.second as PsiMethodCallExpression) + } else if (collection != null && database != null) { + // if we have a parameter for a collection and database, try to resolve them either + // from the parent constructor or the actual constructor + val databaseString = database.second.tryToResolveAsConstantString()!! + val collectionString = collection.second.tryToResolveAsConstantString()!! + return Namespace(databaseString, collectionString) + } else if (client != null || resolvedScopes.size == 1) { + // if it's not a client and there is only one resolved variable + // guess from the actual constructor + val mongodbNamespaceDriverExpression = + currentClass.constructors.firstNotNullOfOrNull { + val callToSuperConstructor = + findChildrenOfType(it, PsiMethodCallExpression::class.java).first { + it.methodExpression.text == "super" && + it.methodExpression.resolve() == constructorAssignments.first().constructor + } + + val indexOfParameter = + constructorAssignments.first().constructor.parameterList.getParameterIndex( + constructorAssignments.first().parameter!!, + ) + callToSuperConstructor.argumentList.expressions[indexOfParameter] + } + + return extractNamespaceFromDriverConfigurationMethodChain( + mongodbNamespaceDriverExpression as PsiMethodCallExpression, + ) + } + + return null + } + + /** + * Returns all the relevant assignments and fields that are target of the received + * field + * + * Example input: + * + * containingClass = PsiClass:MyRepository + * field = Pair(AssignmentConcept.COLLECTION, PsiField:myCollection) + * + * Example output: + * listOf( + * FieldAndConstructorAssignment( + * concept = AssignmentCollection.COLLECTION, + * field = PsiField: myCollection, + * constructor = PsiMethod: MyRepository(MongoCollection), + * parameter = PsiParameter:MongoCollection@index 0, + * resolutionExpression = null, // because it's an assignment without any additional logic + * )) + * + */ + private fun resolveConstructorArgumentReferencesForField( + containingClass: PsiClass, + field: Pair, + ): List { + return containingClass.constructors.flatMap { constructor -> + val assignments = + findChildrenOfType(constructor, PsiAssignmentExpression::class.java) + val fieldAssignment = + assignments.find { assignment -> + assignment.lExpression.reference?.resolve() == field.second + } + fieldAssignment?.let { + val assignmentConcept = + field.first + ?: fieldAssignment.type.guessAssignmentConcept(fieldAssignment.project) + ?: return emptyList() + val asParameter = fieldAssignment.rExpression?.reference?.resolve() as? PsiParameter + asParameter?.let { + listOf( + FieldAndConstructorAssignment( + assignmentConcept, + field.second, + constructor, + asParameter, + null, + ), + ) + } ?: run { + // extract from chain + val foundAssignments = + extractRelevantAssignments( + constructor, + fieldAssignment.rExpression as PsiMethodCallExpression, + ) + foundAssignments.ifEmpty { + listOf( + FieldAndConstructorAssignment( + assignmentConcept, + field.second, + constructor, + null, + fieldAssignment.rExpression, + ), + ) + } + } + } ?: emptyList() + } + } + + /** + * Gets the relevant call to the super constructor within the current + * constructor, if it's compatible with the current constructor signature, + * based on the field assignments. + */ + private fun getCallToSuperConstructor( + currentConstructor: PsiMethod?, + constructorAssignments: List, + ): PsiMethodCallExpression? { + return findChildrenOfType(currentConstructor, PsiMethodCallExpression::class.java).firstOrNull { + it.methodExpression.text == "super" && + it.methodExpression.resolve() == constructorAssignments.first().constructor + } + } + + /** + * Extracts the namespace from a chain of calls like: + * ```kotlin + * client.getDatabase(...).getCollection(...) + * ``` + */ + private fun extractNamespaceFromDriverConfigurationMethodChain(callExpr: PsiMethodCallExpression): Namespace? { + val returnsCollection = callExpr.type?.isMongoDbCollectionClass(callExpr.project) == true + val collection: String? = + if (returnsCollection) { + callExpr.argumentList.expressions[0].tryToResolveAsConstantString() + } else { + null + } + + val dbExpression = + if (callExpr.type?.isMongoDbDatabaseClass(callExpr.project) == true) { + callExpr + } else if (callExpr.methodExpression.qualifierExpression + ?.type + ?.isMongoDbDatabaseClass(callExpr.project) == true + ) { + callExpr.methodExpression.qualifierExpression as PsiMethodCallExpression? + } else { + null + } + + val database: String? = + dbExpression?.let { + dbExpression.argumentList.expressions[0].tryToResolveAsConstantString() + } + + if (database == null || collection == null) { + return null + } + + return Namespace(database, collection) + } + + /** + * Extract all the field assignments from a constructor that are relevant + * for the current method call expression. For example, if we have the following + * callExpr: + * + * ```kotlin + * client.getDatabase(...).getCollection(...) + * ``` + * + * The only relevant field to extract is client from the constructor. + */ + private fun extractRelevantAssignments( + constructor: PsiMethod, + callExpr: PsiMethodCallExpression, + ): List { + val result = mutableListOf() + val returnsCollection = callExpr.type?.isMongoDbCollectionClass(callExpr.project) == true + if (returnsCollection) { + val parameter = + callExpr.argumentList.expressions[0] + .reference + ?.resolve() as? PsiParameter + parameter?.let { + result.add( + FieldAndConstructorAssignment( + AssignmentConcept.COLLECTION, + null, + constructor, + parameter, + null, + ), + ) + } + } + + val dbExpression = + if (callExpr.type?.isMongoDbDatabaseClass(callExpr.project) == true) { + callExpr + } else if (callExpr.methodExpression.qualifierExpression + ?.type + ?.isMongoDbDatabaseClass(callExpr.project) == true + ) { + callExpr.methodExpression.qualifierExpression as PsiMethodCallExpression? + } else { + null + } + + dbExpression?.let { + val parameter = + dbExpression.argumentList.expressions[0] + .reference + ?.resolve() as? PsiParameter + parameter?.let { + result.add( + FieldAndConstructorAssignment( + AssignmentConcept.DATABASE, + null, + constructor, + parameter, + null, + ), + ) + } + } + + return result + } + + private fun extractRelevantFieldsFromChain(callExpr: PsiMethodCallExpression): FoundAssignedPsiFields { + val result = mutableListOf>() + val returnsCollection = callExpr.type?.isMongoDbCollectionClass(callExpr.project) == true + if (returnsCollection) { + val field = + callExpr.argumentList.expressions[0] + .reference + ?.resolve() as? PsiField + field?.let { + result.add(Pair(AssignmentConcept.COLLECTION, field)) + } + } + + val dbExpression = + if (callExpr.type?.isMongoDbDatabaseClass(callExpr.project) == true) { + callExpr + } else if (callExpr.methodExpression.qualifierExpression + ?.type + ?.isMongoDbDatabaseClass(callExpr.project) == true + ) { + callExpr.methodExpression.qualifierExpression as PsiMethodCallExpression? + } else { + null + } + + dbExpression?.let { + val field = + dbExpression.argumentList.expressions[0] + .reference + ?.resolve() as? PsiField + field?.let { + result.add(Pair(AssignmentConcept.DATABASE, field)) + } + } + + return result + } +} + +private enum class AssignmentConcept { + CLIENT, + DATABASE, + COLLECTION, +; +} + +/** + * @property concept + * @property field + * @property constructor + * @property parameter + * @property resolutionExpression + */ +private data class FieldAndConstructorAssignment( + val concept: AssignmentConcept, + val field: PsiField?, + val constructor: PsiMethod, + val parameter: PsiParameter?, + val resolutionExpression: PsiExpression?, +) + +private fun PsiType?.guessAssignmentConcept(project: Project): AssignmentConcept? { + this ?: return null + + return if (isMongoDbClientClass(project)) { + AssignmentConcept.CLIENT + } else if (isMongoDbDatabaseClass(project)) { + AssignmentConcept.DATABASE + } else if (isMongoDbCollectionClass(project)) { + AssignmentConcept.COLLECTION + } else { + null + } +} diff --git a/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/PsiMdbTreeUtil.kt b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/PsiMdbTreeUtil.kt new file mode 100644 index 00000000..84a38866 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/main/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/PsiMdbTreeUtil.kt @@ -0,0 +1,259 @@ +/** + * Defines an a set of extension methods to extract metadata from a Psi tree. + */ + +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.lang.jvm.JvmModifier +import com.intellij.openapi.project.Project +import com.intellij.psi.* +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.PsiTypesUtil +import com.intellij.psi.util.childrenOfType +import com.intellij.psi.util.parentOfType + +/** + * Helper extension function to get the containing class of any element. + * + * @return + */ +fun PsiElement.findContainingClass(): PsiClass = + parentOfType(withSelf = true) + ?: childrenOfType().first() + +/** + * Helper function to check if a type is a MongoDB Collection + * + * @param project + * @return + */ +fun PsiClass.isMongoDbCollectionClass(project: Project): Boolean { + val javaFacade = JavaPsiFacade.getInstance(project) + + val mdbCollectionClass = + javaFacade.findClass( + "com.mongodb.client.MongoCollection", + GlobalSearchScope.everythingScope(project), + ) + + return this == mdbCollectionClass +} + +/** + * Helper function to check if a type is a MongoDB Collection + * + * @param project + */ +fun PsiType.isMongoDbCollectionClass(project: Project): Boolean { + val thisClass = PsiTypesUtil.getPsiClass(this) + return thisClass?.isMongoDbCollectionClass(project) == true +} + +/** + * Helper function to check if a type is a MongoDB Database + * + * @param project + * @return + */ +fun PsiClass.isMongoDbDatabaseClass(project: Project): Boolean { + val javaFacade = JavaPsiFacade.getInstance(project) + + val mdbDatabaseClass = + javaFacade.findClass( + "com.mongodb.client.MongoDatabase", + GlobalSearchScope.everythingScope(project), + ) + + return this == mdbDatabaseClass +} + +/** + * Helper function to check if a type is a MongoDB Database + * + * @param project + * @return + */ +fun PsiType.isMongoDbDatabaseClass(project: Project): Boolean { + val thisClass = PsiTypesUtil.getPsiClass(this) + return thisClass?.isMongoDbDatabaseClass(project) == true +} + +/** + * Helper function to check if a type is a MongoDB Client + * + * @param project + * @return + */ +fun PsiClass.isMongoDbClientClass(project: Project): Boolean { + val javaFacade = JavaPsiFacade.getInstance(project) + + val mdbClientClass = + javaFacade.findClass( + "com.mongodb.client.MongoClient", + GlobalSearchScope.everythingScope(project), + ) + + return this == mdbClientClass +} + +/** + * Helper function to check if a type is a MongoDB Client + * + * @param project + * @return + */ +fun PsiType.isMongoDbClientClass(project: Project): Boolean { + val thisClass = PsiTypesUtil.getPsiClass(this) + return thisClass?.isMongoDbClientClass(project) == true +} + +/** + * Helper function to check if a type is a MongoDB Class + * + * @param project + * @return + */ +fun PsiType?.isMongoDbClass(project: Project): Boolean = + PsiTypesUtil.getPsiClass(this)?.run { + isMongoDbCollectionClass(project) || + isMongoDbDatabaseClass(project) || + isMongoDbClientClass(project) + } == true + +/** + * Checks if a class is a MongoDB class + * + * @param project + * @return + */ +fun PsiClass.isMongoDbClass(project: Project): Boolean = + isMongoDbCollectionClass(project) || + isMongoDbDatabaseClass(project) || + isMongoDbClientClass(project) + +/** + * Checks if a method is calling a MongoDB driver method. + * + * @return + */ +fun PsiMethod.isUsingMongoDbClasses(): Boolean = + PsiTreeUtil.findChildrenOfType(this, PsiMethodCallExpression::class.java).any { + it.methodExpression.qualifierExpression + ?.type + ?.isMongoDbClass(this.project) == true + } + +/** + * Finds all references to the MongoDB driver in a method. + * + * @return + */ +fun PsiMethod.findAllReferencesToMongoDbObjects(): List = + PsiTreeUtil + .findChildrenOfType(this, PsiExpression::class.java) + .filter { + it.type + ?.isMongoDbClass(this.project) == true + }.mapNotNull { it.reference } + +/** + * Find, from a method call, the current MongoDB driver method is getting called. + */ +fun PsiMethodCallExpression.findCurrentReferenceToMongoDbObject(): PsiReference? { + if (methodExpression.type?.isMongoDbClass(project) == true) { + return methodExpression.reference + } else if (methodExpression.qualifierExpression is PsiSuperExpression || + methodExpression.qualifierExpression is PsiThisExpression || + methodExpression.qualifierExpression == null + ) { + val resolution = methodExpression.resolve() + if (resolution is PsiField) { + return if (resolution.type.isMongoDbClass(project)) resolution.reference else null + } else { + return (methodExpression.resolve() as PsiMethod?)?.findAllReferencesToMongoDbObjects()?.first() + } + } else { + if (methodExpression.qualifierExpression is PsiMethodCallExpression) { + return (methodExpression.qualifierExpression as PsiMethodCallExpression) + .findCurrentReferenceToMongoDbObject() + } + } + + return null +} + +/** + * Collects all elements of type T upwards until a type S is found. + * + * @param type + * @param stopWord + */ +fun PsiElement.collectTypeUntil( + type: Class, + stopWord: Class, +): List { + if (stopWord.isInstance(this)) { + return emptyList() + } + + if (type.isInstance(this)) { + return listOf(this as T) + (this.parent?.collectTypeUntil(type, stopWord) ?: emptyList()) + } + + return emptyList() +} + +/** + * Returns the reference to any MongoDB driver call. + * + * @param project + */ +fun PsiMethodCallExpression.findMongoDbClassReference(project: Project): PsiExpression? { + if (methodExpression.type?.isMongoDbClass(project) == true) { + return methodExpression + } else if (methodExpression.qualifierExpression is PsiMethodCallExpression) { + return (methodExpression.qualifierExpression as PsiMethodCallExpression).findMongoDbClassReference(project) + } else if (methodExpression.qualifierExpression?.reference?.resolve() is PsiField) { + return methodExpression.qualifierExpression + } else { + return null + } +} + +/** + * Returns the reference to a MongoDB driver collection. + * + * @param project + */ +fun PsiMethodCallExpression.findMongoDbCollectionReference(project: Project): PsiExpression? { + if (methodExpression.type?.isMongoDbCollectionClass(project) == true) { + return methodExpression + } else if (methodExpression.qualifierExpression is PsiMethodCallExpression) { + return (methodExpression.qualifierExpression as PsiMethodCallExpression).findMongoDbCollectionReference(project) + } else if (methodExpression.qualifierExpression?.reference?.resolve() is PsiField) { + return methodExpression.qualifierExpression + } else { + return null + } +} + +/** + * Resolves to the value of the expression if it can be known at compile time + * or null if it can only be known at runtime. + */ +fun PsiElement.tryToResolveAsConstantString(): String? { + if (this is PsiReferenceExpression) { + val varRef = this.resolve()!! + return varRef.tryToResolveAsConstantString() + } else if (this is PsiLocalVariable) { + return this.initializer?.tryToResolveAsConstantString() + } else if (this is PsiLiteralValue) { + val facade = JavaPsiFacade.getInstance(this.project) + return facade.constantEvaluationHelper.computeConstantExpression(this) as? String + } else if (this is PsiField && this.hasModifier(JvmModifier.FINAL)) { + return this.initializer?.tryToResolveAsConstantString() + } + + return null +} diff --git a/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/IntegrationTest.kt b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/IntegrationTest.kt new file mode 100644 index 00000000..46fd7274 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/IntegrationTest.kt @@ -0,0 +1,212 @@ +/** + * Class that contains the JUnit5 extension to run tests + * that use the IntelliJ Java parser. + */ + +package com.mongodb.jbplugin.dialects.javadriver + +import com.intellij.java.library.JavaLibraryUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.project.DumbService +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiUtil +import com.intellij.psi.util.childrenOfType +import com.intellij.testFramework.PsiTestUtil +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.mongodb.client.MongoClient +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.* +import java.lang.reflect.Method +import java.net.URI +import java.net.URL +import java.nio.file.Paths +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString + +@Retention(AnnotationRetention.RUNTIME) +@Test +annotation class ParsingTest( + val fileName: String, + @Language("java") val value: String, +) + +/** + * Annotation to be used in the test, at the class level. + * + * @see com.mongodb.jbplugin.accessadapter.datagrip.adapter.DataGripMongoDbDriverTest + */ +@ExtendWith(IntegrationTestExtension::class) +annotation class IntegrationTest + +/** + * Extension implementation. Must not be used directly. + */ +internal class IntegrationTestExtension : + BeforeAllCallback, + AfterAllCallback, + BeforeEachCallback, + InvocationInterceptor, + ParameterResolver { + private val namespace = ExtensionContext.Namespace.create(IntegrationTestExtension::class.java) + private val testFixtureKey = "TESTFIXTURE" + + override fun beforeAll(context: ExtensionContext) { + val projectFixture = + IdeaTestFixtureFactory + .getFixtureFactory() + .createLightFixtureBuilder(context.requiredTestClass.simpleName) + .fixture + + val testFixture = + IdeaTestFixtureFactory + .getFixtureFactory() + .createCodeInsightFixture( + projectFixture, + ) + + context.getStore(namespace).put(testFixtureKey, testFixture) + testFixture.setUp() + + ApplicationManager.getApplication().invokeAndWait { + if (!JavaLibraryUtil.hasLibraryJar(testFixture.module, "org.mongodb:mongodb-driver-sync:5.1.1")) { + runCatching { + PsiTestUtil.addProjectLibrary( + testFixture.module, + "mongodb-driver-sync", + listOf( + Path( + pathToJavaDriver(), + ).toAbsolutePath().toString(), + ), + ) + } + } + } + + PsiTestUtil.addSourceRoot(testFixture.module, testFixture.project.guessProjectDir()!!) + } + + override fun beforeEach(context: ExtensionContext) { + val fixture = context.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + + ApplicationManager.getApplication().invokeAndWait { + val parsingTest = context.requiredTestMethod.getAnnotation(ParsingTest::class.java) + val path = ModuleUtilCore.getModuleDirPath(fixture.module) + val fileName = Path(path, "src", "main", "java", parsingTest.fileName).absolutePathString() + fixture.configureByText( + fileName, + parsingTest.value, + ) + } + } + + override fun interceptTestMethod( + invocation: InvocationInterceptor.Invocation, + invocationContext: ReflectiveInvocationContext, + extensionContext: ExtensionContext, + ) { + val throwable: AtomicReference = AtomicReference(null) + val finished = AtomicBoolean(false) + + val fixture = extensionContext.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + val dumbService = fixture.project.getService(DumbService::class.java) + dumbService.runWhenSmart { + val result = + runCatching { + invocation.proceed() + } + result.onSuccess { + finished.set(true) + } + + result.onFailure { + finished.set(true) + throwable.set(it) + } + } + + while (!finished.get()) { + Thread.sleep(1) + } + + throwable.get()?.let { + System.err.println(it.message) + it.printStackTrace(System.err) + throw it + } + } + + override fun afterAll(context: ExtensionContext) { + val testFixture = context.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + + ApplicationManager.getApplication().invokeAndWait { + testFixture.tearDown() + } + } + + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext, + ): Boolean = + parameterContext.parameter.type == Project::class.java || + parameterContext.parameter.type == CodeInsightTestFixture::class.java || + parameterContext.parameter.type == PsiFile::class.java || + parameterContext.parameter.type == JavaPsiFacade::class.java + + override fun resolveParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext, + ): Any { + val fixture = extensionContext.getStore(namespace).get(testFixtureKey) as CodeInsightTestFixture + + return when (parameterContext.parameter.type) { + Project::class.java -> fixture.project + CodeInsightTestFixture::class.java -> fixture + PsiFile::class.java -> fixture.file + JavaPsiFacade::class.java -> JavaPsiFacade.getInstance(fixture.project) + else -> TODO("Parameter of type ${parameterContext.parameter.type.canonicalName} is not supported.") + } + } + + private fun pathToJavaDriver(): String { + val classResource: URL = + MongoClient::class.java.getResource(MongoClient::class.java.getSimpleName() + ".class") + ?: throw RuntimeException("class resource is null") + val url: String = classResource.toString() + if (url.startsWith("jar:file:")) { + // extract 'file:......jarName.jar' part from the url string + val path = url.replace("^jar:(file:.*[.]jar)!/.*".toRegex(), "$1") + try { + return Paths.get(URI(path)).toString() + } catch (e: Exception) { + throw RuntimeException("Invalid Jar File URL String") + } + } + throw RuntimeException("Invalid Jar File URL String") + } +} + +fun PsiFile.getClassByName(name: String): PsiClass = + childrenOfType().first { + it.name == name + } + +fun PsiFile.getQueryAtMethod( + className: String, + methodName: String, +): PsiElement { + val actualClass = getClassByName(className) + val method = actualClass.allMethods.first { it.name == methodName } + val returnExpr = PsiUtil.findReturnStatements(method).last() + return returnExpr.returnValue!! +} diff --git a/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParserTest.kt b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParserTest.kt new file mode 100644 index 00000000..7a7bf538 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectParserTest.kt @@ -0,0 +1,135 @@ +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiReferenceExpression +import com.intellij.psi.util.PsiTreeUtil +import com.mongodb.jbplugin.dialects.javadriver.IntegrationTest +import com.mongodb.jbplugin.dialects.javadriver.ParsingTest +import com.mongodb.jbplugin.dialects.javadriver.getQueryAtMethod +import com.mongodb.jbplugin.mql.components.HasCollectionReference +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue + +@IntegrationTest +class JavaDriverDialectParserTest { + @ParsingTest( + fileName = "Repository.java", + value = """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class Repository { + private final MongoCollection collection; + + public Repository(MongoClient client) { + this.collection = client.getDatabase("simple").getCollection("books"); + } + + public Document findBookById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + """, + ) + fun `can parse a mongodb query using the driver`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "findBookById") + assertTrue(JavaDriverDialectParser.isCandidateForQuery(query)) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class Repository { + private final MongoCollection collection; + + public Repository(MongoClient client) { + this.collection = client.getDatabase("simple").getCollection("books"); + } + + public Document findBookById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + """, + ) + fun `the attachment happens in the collection method`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "findBookById") + val collectionReference = + PsiTreeUtil + .findChildrenOfType(query, PsiReferenceExpression::class.java) + .first { it.text.endsWith("collection") } + + assertTrue(JavaDriverDialectParser.isCandidateForQuery(query)) + assertEquals(collectionReference, JavaDriverDialectParser.attachment(query)) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class Repository { + private final MongoCollection collection; + + public Repository(MongoClient client) { + this.collection = client.getDatabase("simple").getCollection("books"); + } + + public Document findBookById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + """, + ) + fun `can extract the namespace of a query`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "findBookById") + val parsedQuery = JavaDriverDialect.parser.parse(query) + + val knownReference = parsedQuery.component()?.reference as HasCollectionReference.Known + val namespace = knownReference.namespace + + assertEquals("simple", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + fileName = "Repository.java", + value = """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class Repository { + private final MongoCollection collection; + + public Repository(MongoCollection collection) { + this.collection = collection; + } + + public Document findBookById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + """, + ) + fun `handles gracefully when the namespace is unknown`(psiFile: PsiFile) { + val query = psiFile.getQueryAtMethod("Repository", "findBookById") + val parsedQuery = JavaDriverDialect.parser.parse(query) + + val unknownReference = + parsedQuery.component()?.reference as HasCollectionReference.Unknown + + assertEquals(HasCollectionReference.Unknown, unknownReference) + } +} diff --git a/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectTest.kt b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectTest.kt new file mode 100644 index 00000000..984722e1 --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/JavaDriverDialectTest.kt @@ -0,0 +1,11 @@ +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class JavaDriverDialectTest { + @Test + fun `returns the correct parser`() { + assertEquals(JavaDriverDialectParser, JavaDriverDialect.parser) + } +} diff --git a/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractorTest.kt b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractorTest.kt new file mode 100644 index 00000000..eda53ace --- /dev/null +++ b/packages/mongodb-dialects/java-driver/src/test/kotlin/com/mongodb/jbplugin/dialects/javadriver/glossary/NamespaceExtractorTest.kt @@ -0,0 +1,379 @@ +package com.mongodb.jbplugin.dialects.javadriver.glossary + +import com.intellij.psi.PsiFile +import com.mongodb.jbplugin.dialects.javadriver.IntegrationTest +import com.mongodb.jbplugin.dialects.javadriver.ParsingTest +import com.mongodb.jbplugin.dialects.javadriver.getQueryAtMethod +import org.junit.jupiter.api.Assertions.* + +@IntegrationTest +class NamespaceExtractorTest { + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public abstract class AbstractRepository { + private final MongoCollection collection; + + protected AbstractRepository(MongoCollection collection) { + this.collection = collection; + } + + protected final T findById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + +public final class UserRepository extends AbstractRepository { + public UserRepository(MongoClient client) { + super(client.getDatabase("production").getCollection("users", User.class)); + } + + public User findUserById(ObjectId id) { + return super.findById(id); + } +} + """, + ) + fun `extracts from a complex chain of dependency injection`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("UserRepository", "findUserById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("production", namespace.database) + assertEquals("users", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public abstract class AbstractRepository { + private final MongoCollection collection; + + protected AbstractRepository(MongoCollection collection) { + this.collection = collection; + } + + protected final T findById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + +public final class UserRepository extends AbstractRepository { + public UserRepository(MongoClient client) { + super(client.getDatabase("production").getCollection("users", User.class)); + } + + public User findUserById(ObjectId id) { + return findById(id); + } +} + """, + ) + fun `extracts from a complex chain of dependency injection without explicit super call`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("UserRepository", "findUserById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("production", namespace.database) + assertEquals("users", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public abstract class AbstractRepository { + private final MongoCollection collection; + + protected AbstractRepository(MongoClient client, String database, String collection) { + this.collection = client.getDatabase(database).getCollection(collection); + } + + protected final T findById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + +public final class BookRepository extends AbstractRepository { + public BookRepository(MongoClient client) { + super(client, "staging", "books"); + } + + public User findBookById(ObjectId id) { + return super.findById(id); + } +} + """, + ) + fun `extracts from a complex chain of dependency injection with different arguments`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("staging", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import java.lang.String; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public abstract class AbstractRepository { + private final MongoCollection collection; + + protected AbstractRepository(MongoClient client, String database, String collection) { + this.collection = client.getDatabase(database).getCollection(collection); + } + + protected final T findById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + +public final class BookRepository extends AbstractRepository { + private static final String DATABASE = "staging"; + private static final String COLLECTION = "books"; + + public BookRepository(MongoClient client) { + super(client, DATABASE, COLLECTION); + } + + public User findBookById(ObjectId id) { + return super.findById(id); + } +} + """, + ) + fun `extracts from a complex chain of dependency injection with java constants`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("staging", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public abstract class AbstractRepository { + private final MongoClient client; + private final String database; + private final String collection; + + protected AbstractRepository(MongoClient client, String database, String collection) { + this.client = client; + this.database = database; + this.collection = collection; + } + + protected final T findById(ObjectId id) { + return this.getCollection().find(eq("_id", id)).first(); + } + + protected final MongoCollection getCollection() { + return this.client.getDatabase(database).getCollection(collection); + } +} + +public final class BookRepository extends AbstractRepository { + public BookRepository(MongoClient client) { + super(client, "production", "books"); + } + + public User findBookById(ObjectId id) { + return super.findById(id); + } +} + """, + ) + fun `extracts from a complex chain of dependency injection with a factory method`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("production", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class BookRepository { + private final MongoCollection collection; + + public BookRepository(MongoClient client) { + this.collection = client.getDatabase("simple").getCollection("books"); + } + + public User findBookById(ObjectId id) { + return this.collection.find(eq("_id", id)).first(); + } +} + """, + ) + fun `extracts from a basic repository with dependency injection`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("simple", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class BookRepository { + private final MongoClient client; + + public BookRepository(MongoClient client) { + this.client = client; + } + + public User findBookById(ObjectId id) { + return this.getCollection().find(eq("_id", id)).first(); + } + + private MongoCollection getCollection() { + return client.getDatabase("simple").getCollection("books"); + } +} + """, + ) + fun `extracts from a basic repository with dependency injection and a factory method`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("simple", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public final class BookRepository { + private final MongoClient client; + + public BookRepository(MongoClient client) { + this.client = client; + } + + public User findBookById(ObjectId id) { + return client.getDatabase("simple").getCollection("books").find(eq("_id", id)).first(); + } +} + """, + ) + fun `extracts from a basic repository with dependency injection only`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("BookRepository", "findBookById") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("simple", namespace.database) + assertEquals("books", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +public class JavaDriverRepository { + private final MongoClient client; + + public JavaDriverRepository(MongoClient client) { + this.client = client; + } + + public FindIterable exampleFind() { + return client.getDatabase("myDatabase") + .getCollection("myCollection") + .find(); + } +} + """, + ) + fun `extracts from a hardcoded example`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("JavaDriverRepository", "exampleFind") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("myDatabase", namespace.database) + assertEquals("myCollection", namespace.collection) + } + + @ParsingTest( + "Repository.java", + """ +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.bson.types.ObjectId; +import static com.mongodb.client.model.Filters.*; + +abstract class BaseRepository { + private final MongoClient client; + private final String database; + private final String collection; + + protected BaseRepository(MongoClient client, String database, String collection) { + this.client = client; + this.database = database; + this.collection = collection; + } + + protected final MongoCollection getCollection() { + return client.getDatabase(database).getCollection(collection); + } +} + +public class JavaDriverRepository extends BaseRepository { + public static final String DATABASE = "myDatabase"; + public static final String COLLECTION = "myCollection"; + + public JavaDriverRepository(MongoClient client) { + super(client, DATABASE, COLLECTION); + } + + public FindIterable exampleFind() { + return getCollection().find(); + } +} + """, + ) + fun `extracts from a mms like example`(psiFile: PsiFile) { + val methodToAnalyse = psiFile.getQueryAtMethod("JavaDriverRepository", "exampleFind") + val namespace = NamespaceExtractor.extractNamespace(methodToAnalyse)!! + assertEquals("myDatabase", namespace.database) + assertEquals("myCollection", namespace.collection) + } +} diff --git a/packages/mongodb-dialects/src/main/kotlin/com/mongodb/jbplugin/dialects/Dialect.kt b/packages/mongodb-dialects/src/main/kotlin/com/mongodb/jbplugin/dialects/Dialect.kt new file mode 100644 index 00000000..ae8bb9f2 --- /dev/null +++ b/packages/mongodb-dialects/src/main/kotlin/com/mongodb/jbplugin/dialects/Dialect.kt @@ -0,0 +1,33 @@ +/** + * Represents a dialect, which is a way of writing MongoDb queries. Each dialect must have a + * parser, that will convert the input content to an MQL AST. + */ + +package com.mongodb.jbplugin.dialects + +import com.mongodb.jbplugin.mql.Node + +/** + * Represents the dialect itself, S is the input type of the dialect. It's an opaque type, + * we don't expect knowing anything about it. For any parser that depends on IntelliJ PsiElements, + * S = PsiElement. + * + * @param S + */ +interface Dialect { + val parser: DialectParser +} + +/** + * The parser itself. It only generates an MQL AST from the source, it doesn't analyse + * anything. + * + * @param S + */ +interface DialectParser { + fun isCandidateForQuery(source: S): Boolean + + fun attachment(source: S): S + + fun parse(source: S): Node +} diff --git a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/Node.kt b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/Node.kt index 017972cb..fa0ac08e 100644 --- a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/Node.kt +++ b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/Node.kt @@ -37,6 +37,8 @@ data class Node( ) { inline fun component(): C? = components.firstOrNull { it is C } as C? + fun component(withClass: Class): C? = components.firstOrNull { withClass.isInstance(it) } as C? + inline fun components(): List = components.filterIsInstance() inline fun hasComponent(): Boolean = component() != null diff --git a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasCollectionReference.kt b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasCollectionReference.kt index 3e693243..bd16e006 100644 --- a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasCollectionReference.kt +++ b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasCollectionReference.kt @@ -1,27 +1,29 @@ package com.mongodb.jbplugin.mql.components +import com.mongodb.jbplugin.mql.Component import com.mongodb.jbplugin.mql.Namespace /** * @property reference */ -class HasCollectionReference( +data class HasCollectionReference( val reference: CollectionReference, -) { +) : Component { data object Unknown : CollectionReference + sealed interface CollectionReference /** - * @property namespace - */ -data class Known( + * @property namespace + */ + data class Known( val namespace: Namespace, ) : CollectionReference /** - * @property collection - */ -data class OnlyCollection( + * @property collection + */ + data class OnlyCollection( val collection: String, ) : CollectionReference } diff --git a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasFilter.kt b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasFilter.kt index 9725a22b..7c684cda 100644 --- a/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasFilter.kt +++ b/packages/mongodb-mql-model/src/main/kotlin/com/mongodb/jbplugin/mql/components/HasFilter.kt @@ -1,5 +1,6 @@ package com.mongodb.jbplugin.mql.components +import com.mongodb.jbplugin.mql.Component import com.mongodb.jbplugin.mql.Node /** @@ -8,4 +9,4 @@ import com.mongodb.jbplugin.mql.Node */ data class HasFilter( val filter: Node, -) +) : Component diff --git a/packages/mongodb-mql-model/src/test/kotlin/com/mongodb/jbplugin/mql/NodeTest.kt b/packages/mongodb-mql-model/src/test/kotlin/com/mongodb/jbplugin/mql/NodeTest.kt index 51c234a0..5f0cd662 100644 --- a/packages/mongodb-mql-model/src/test/kotlin/com/mongodb/jbplugin/mql/NodeTest.kt +++ b/packages/mongodb-mql-model/src/test/kotlin/com/mongodb/jbplugin/mql/NodeTest.kt @@ -1,9 +1,10 @@ package com.mongodb.jbplugin.mql -import com.mongodb.jbplugin.mql.components.HasFieldReference -import com.mongodb.jbplugin.mql.components.Named +import com.mongodb.jbplugin.mql.components.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource class NodeTest { @Test @@ -27,9 +28,14 @@ class NodeTest { val node = Node( null, - listOf(HasFieldReference(HasFieldReference.Known("field1")), HasFieldReference(HasFieldReference.Known( -"field2" -))), + listOf( + HasFieldReference(HasFieldReference.Known("field1")), + HasFieldReference( + HasFieldReference.Known( + "field2", + ), + ), + ), ) val fieldReferences = node.components() @@ -60,4 +66,42 @@ class NodeTest { assertFalse(hasNamedComponent) } + + @MethodSource("validComponents") + @ParameterizedTest + fun `does support the following component`( + component: Component, + componentClass: Class, + ) { + val node = + Node( + null, + listOf( + component, + ), + ) + + assertNotNull(node.component(componentClass)) + } + + companion object { + @JvmStatic + fun validComponents(): Array> = + arrayOf( + arrayOf(HasChildren(emptyList()), HasChildren::class.java), + arrayOf(HasCollectionReference(HasCollectionReference.Unknown), HasCollectionReference::class.java), + arrayOf(HasCollectionReference(HasCollectionReference.Known(Namespace("db", "coll"))), + HasCollectionReference::class.java), + arrayOf(HasCollectionReference(HasCollectionReference.OnlyCollection("coll")), + HasCollectionReference::class.java), + arrayOf(HasFieldReference(HasFieldReference.Unknown), HasFieldReference::class.java), + arrayOf(HasFieldReference(HasFieldReference.Known("abc")), HasFieldReference::class.java), + arrayOf(HasFilter(Node(null, emptyList())), HasFilter::class.java), + arrayOf(HasValueReference(HasValueReference.Unknown), HasValueReference::class.java), + arrayOf(HasValueReference(HasValueReference.Constant(123, "int")), HasValueReference::class.java), + arrayOf(HasValueReference(HasValueReference.Runtime("int")), HasValueReference::class.java), + arrayOf(HasValueReference(HasValueReference.Unknown), HasValueReference::class.java), + arrayOf(Named("abc"), Named::class.java), + ) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 834348c4..539a1177 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,9 +11,10 @@ pluginManagement { include( "packages:mongodb-mql-model", "packages:mongodb-dialects", + "packages:mongodb-dialects:java-driver", "packages:mongodb-autocomplete-engine", "packages:mongodb-linting-engine", "packages:mongodb-access-adapter", "packages:mongodb-access-adapter:datagrip-access-adapter", "packages:jetbrains-plugin", -) \ No newline at end of file +)