diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index 6824bf54890..566145a5b72 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -87,6 +87,8 @@ jobs: run: sbt -no-colors runtime/Benchmark/compile - name: Check Language Server Benchmark Compilation run: sbt -no-colors language-server/Benchmark/compile + - name: Check Searcher Benchmark Compilation + run: sbt -no-colors searcher/Benchmark/compile - name: Build the Uberjar run: sbt -no-colors runner/assembly - name: Test the Uberjar diff --git a/build.sbt b/build.sbt index bfa6861f7ea..c4704c24e02 100644 --- a/build.sbt +++ b/build.sbt @@ -482,6 +482,7 @@ lazy val `project-manager` = (project in file("lib/scala/project-manager")) "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, "commons-io" % "commons-io" % commonsIoVersion, "com.beachape" %% "enumeratum-circe" % enumeratumCirceVersion, + "com.typesafe.slick" %% "slick-hikaricp" % slickVersion % Runtime, "com.miguno.akka" %% "akka-mock-scheduler" % akkaMockSchedulerVersion % Test, "org.mockito" %% "mockito-scala" % mockitoScalaVersion % Test ), @@ -608,13 +609,19 @@ lazy val searcher = project .in(file("lib/scala/searcher")) .configs(Test) .settings( - libraryDependencies ++= akkaTest ++ Seq( - "com.typesafe.slick" %% "slick" % slickVersion, - "org.xerial" % "sqlite-jdbc" % sqliteVersion, - "org.scalatest" %% "scalatest" % scalatestVersion % Test + libraryDependencies ++= jmh ++ Seq( + "com.typesafe.slick" %% "slick" % slickVersion, + "org.xerial" % "sqlite-jdbc" % sqliteVersion, + "com.typesafe.slick" %% "slick-hikaricp" % slickVersion % Runtime, + "ch.qos.logback" % "logback-classic" % logbackClassicVersion % Test, + "org.scalatest" %% "scalatest" % scalatestVersion % Test ) ) - .dependsOn(`json-rpc-server-test` % Test) + .configs(Benchmark) + .settings( + inConfig(Benchmark)(Defaults.testSettings), + fork in Benchmark := true + ) lazy val `interpreter-dsl` = (project in file("lib/scala/interpreter-dsl")) .settings( @@ -681,8 +688,9 @@ lazy val `language-server` = (project in file("engine/language-server")) "io.methvin" % "directory-watcher" % directoryWatcherVersion, "com.beachape" %% "enumeratum-circe" % enumeratumCirceVersion, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion, - akkaTestkit % Test, "commons-io" % "commons-io" % commonsIoVersion, + akkaTestkit % Test, + "com.typesafe.slick" %% "slick-hikaricp" % slickVersion % Runtime, "org.scalatest" %% "scalatest" % scalatestVersion % Test, "org.scalacheck" %% "scalacheck" % scalacheckVersion % Test, "org.graalvm.sdk" % "polyglot-tck" % graalVersion % "provided" @@ -875,7 +883,8 @@ lazy val runner = project "com.monovore" %% "decline" % declineVersion, "io.github.spencerpark" % "jupyter-jvm-basekernel" % jupyterJvmBasekernelVersion, "org.jline" % "jline" % jlineVersion, - "org.typelevel" %% "cats-core" % catsVersion + "org.typelevel" %% "cats-core" % catsVersion, + "com.typesafe.slick" %% "slick-hikaricp" % slickVersion % Runtime ), connectInput in run := true ) @@ -906,4 +915,3 @@ lazy val runner = project .dependsOn(pkg) .dependsOn(`language-server`) .dependsOn(`polyglot-api`) - diff --git a/docs/language-server/protocol-language-server.md b/docs/language-server/protocol-language-server.md index ff422b06f03..aaa51c2df6c 100644 --- a/docs/language-server/protocol-language-server.md +++ b/docs/language-server/protocol-language-server.md @@ -27,7 +27,6 @@ transport formats, please look [here](./protocol-architecture). - [`SuggestionEntryArgument`](#suggestionentryargument) - [`SuggestionEntry`](#suggestionentry) - [`SuggestionEntryType`](#suggestionentrytype) - - [`SuggestionsDatabaseEntry`](#suggestionsdatabaseentry) - [`SuggestionsDatabaseUpdate`](#suggestionsdatabaseupdate) - [`File`](#file) - [`DirectoryTree`](#directorytree) @@ -141,6 +140,7 @@ transport formats, please look [here](./protocol-architecture). - [`CapabilityNotAcquired`](#capabilitynotacquired) - [`SessionNotInitialisedError`](#sessionnotinitialisederror) - [`SessionAlreadyInitialisedError`](#sessionalreadyinitialisederror) + - [`SuggestionsDatabaseError`](#suggestionsdatabaseerror) @@ -330,20 +330,6 @@ type SuggestionEntryType | Local; ``` -### `SuggestionsDatabaseEntry` -The entry in the suggestions database. - -#### Format - -```typescript -// The suggestions database entry. -interface SuggestionsDatabaseEntry { - // suggestion entry id; - id: number; - suggestion: Suggestion; -} -``` - ### `SuggestionsDatabaseUpdate` The update of the suggestions database. @@ -351,23 +337,20 @@ The update of the suggestions database. ```typescript // The kind of the suggestions database update. -type SuggestionsDatabaseUpdateKind +type SuggestionsDatabaseUpdate = Add - | Update - | Delete + | Remove -interface SuggestionsDatabaseUpdate { +interface Add { // suggestion entry id id: number; - kind: SuggestionsDatabaseUpdateKind; - name?: string; - module?: string; - arguments?: [SuggestionEntryArgument]; - selfType?: string; - returnType?: string; - documentation?: string; - scope?: SuggestionEntryScope; + // suggestion entry + suggestion: SuggestionEntry; } + +interface Remove { + // suggestion entry id + id: number; ``` ### `File` @@ -1720,11 +1703,11 @@ null None ## Refactoring -The language server also provides refactoring operations to restructure an +The language server also provides refactoring operations to restructure an internal body of code. ### `refactoring/renameProject` -This request is sent from the project manager to the server to refactor project +This request is sent from the project manager to the server to refactor project name in an interpreter runtime. - **Type:** Request @@ -2482,7 +2465,7 @@ null ```typescript { // The list of suggestions database entries - entries: [SuggestionsDatabaseEntry]; + entries: [SuggestionsDatabaseUpdate]; // The version of received suggestions database currentVersion: number; } @@ -2971,3 +2954,13 @@ Signals that session is already initialised. "message" : "Session already initialised" } ``` + +### `SuggestionsDatabaseError` +Signals about an error accessing the suggestions database. + +```typescript +"error" : { + "code" : 7001, + "message" : "Suggestions database error" +} +``` diff --git a/engine/language-server/src/main/resources/logback.xml b/engine/language-server/src/main/resources/logback.xml new file mode 100644 index 00000000000..6438014b07a --- /dev/null +++ b/engine/language-server/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level [%-15thread] %-36logger{36} %msg%n + + + + + + + + + diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala index de5d8678799..be03c56d0de 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/boot/MainModule.scala @@ -24,16 +24,12 @@ import org.enso.languageserver.protocol.json.{ JsonConnectionControllerFactory, JsonRpc } -import org.enso.languageserver.runtime.{ - ContextRegistry, - RuntimeConnector, - RuntimeKiller, - SuggestionsDatabaseEventsListener -} +import org.enso.languageserver.runtime._ import org.enso.languageserver.session.SessionRouter import org.enso.languageserver.text.BufferRegistry import org.enso.languageserver.util.binary.BinaryEncoder import org.enso.polyglot.{LanguageInfo, RuntimeOptions, RuntimeServerInfo} +import org.enso.searcher.sql.SqlSuggestionsRepo import org.graalvm.polyglot.Context import org.graalvm.polyglot.io.MessageEndpoint @@ -70,6 +66,12 @@ class MainModule(serverConfig: LanguageServerConfig) { implicit val materializer = SystemMaterializer.get(system) + val suggestionsRepo = { + val repo = SqlSuggestionsRepo()(system.dispatcher) + repo.init + repo + } + lazy val sessionRouter = system.actorOf(SessionRouter.props(), "session-router") @@ -95,7 +97,12 @@ class MainModule(serverConfig: LanguageServerConfig) { ) lazy val suggestionsDatabaseEventsListener = - system.actorOf(SuggestionsDatabaseEventsListener.props(sessionRouter)) + system.actorOf( + SuggestionsDatabaseEventsListener.props(sessionRouter, suggestionsRepo) + ) + + lazy val suggestionsHandler = + system.actorOf(SuggestionsHandler.props(suggestionsRepo)) lazy val capabilityRouter = system.actorOf( @@ -176,6 +183,7 @@ class MainModule(serverConfig: LanguageServerConfig) { capabilityRouter, fileManager, contextRegistry, + suggestionsHandler, stdOutController, stdErrController, stdInController, diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala index b08e183aa1a..d96b7a9029d 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala @@ -41,6 +41,11 @@ import org.enso.languageserver.runtime.{ SearchProtocol } import org.enso.languageserver.runtime.ExecutionApi._ +import org.enso.languageserver.runtime.SearchApi.{ + Completion, + GetSuggestionsDatabase, + GetSuggestionsDatabaseVersion +} import org.enso.languageserver.runtime.VisualisationApi.{ AttachVisualisation, DetachVisualisation, @@ -67,6 +72,7 @@ import scala.concurrent.duration._ * @param capabilityRouter a router that dispatches capability requests * @param fileManager performs operations with file system * @param contextRegistry a router that dispatches execution context requests + * @param suggestionsHandler a reference to the suggestions requests handler * @param requestTimeout a request timeout */ class JsonConnectionController( @@ -75,6 +81,7 @@ class JsonConnectionController( val capabilityRouter: ActorRef, val fileManager: ActorRef, val contextRegistry: ActorRef, + val suggestionsHandler: ActorRef, val stdOutController: ActorRef, val stdErrController: ActorRef, val stdInController: ActorRef, @@ -259,6 +266,12 @@ class JsonConnectionController( .props(requestTimeout, contextRegistry, rpcSession), ExecutionContextRecompute -> executioncontext.RecomputeHandler .props(requestTimeout, contextRegistry, rpcSession), + GetSuggestionsDatabaseVersion -> search.GetSuggestionsDatabaseVersionHandler + .props(requestTimeout, suggestionsHandler), + GetSuggestionsDatabase -> search.GetSuggestionsDatabaseHandler + .props(requestTimeout, suggestionsHandler), + Completion -> search.CompletionHandler + .props(requestTimeout, suggestionsHandler), AttachVisualisation -> AttachVisualisationHandler .props(rpcSession.clientId, requestTimeout, contextRegistry), DetachVisualisation -> DetachVisualisationHandler @@ -288,6 +301,7 @@ object JsonConnectionController { * @param capabilityRouter a router that dispatches capability requests * @param fileManager performs operations with file system * @param contextRegistry a router that dispatches execution context requests + * @param suggestionsHandler a reference to the suggestions requests handler * @param requestTimeout a request timeout * @return a configuration object */ @@ -297,6 +311,7 @@ object JsonConnectionController { capabilityRouter: ActorRef, fileManager: ActorRef, contextRegistry: ActorRef, + suggestionsHandler: ActorRef, stdOutController: ActorRef, stdErrController: ActorRef, stdInController: ActorRef, @@ -310,6 +325,7 @@ object JsonConnectionController { capabilityRouter, fileManager, contextRegistry, + suggestionsHandler, stdOutController, stdErrController, stdInController, diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala index ae0df2950a6..3a96a7c944a 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionControllerFactory.scala @@ -17,6 +17,7 @@ class JsonConnectionControllerFactory( capabilityRouter: ActorRef, fileManager: ActorRef, contextRegistry: ActorRef, + suggestionsHandler: ActorRef, stdOutController: ActorRef, stdErrController: ActorRef, stdInController: ActorRef, @@ -38,6 +39,7 @@ class JsonConnectionControllerFactory( capabilityRouter, fileManager, contextRegistry, + suggestionsHandler, stdOutController, stdErrController, stdInController, diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala index e9c1433ba04..904ae69fc9a 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonRpc.scala @@ -55,6 +55,9 @@ object JsonRpc { .registerRequest(AttachVisualisation) .registerRequest(DetachVisualisation) .registerRequest(ModifyVisualisation) + .registerRequest(GetSuggestionsDatabase) + .registerRequest(GetSuggestionsDatabaseVersion) + .registerRequest(Completion) .registerRequest(RenameProject) .registerNotification(ForceReleaseCapability) .registerNotification(GrantCapability) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/CompletionHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/CompletionHandler.scala new file mode 100644 index 00000000000..fe6928de8af --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/CompletionHandler.scala @@ -0,0 +1,92 @@ +package org.enso.languageserver.requesthandler.search + +import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props, Status} +import org.enso.jsonrpc.Errors.ServiceError +import org.enso.jsonrpc._ +import org.enso.languageserver.requesthandler.RequestTimeout +import org.enso.languageserver.runtime.SearchApi.{ + Completion, + SuggestionsDatabaseError +} +import org.enso.languageserver.runtime.SearchProtocol +import org.enso.languageserver.util.UnhandledLogging + +import scala.concurrent.duration.FiniteDuration + +/** + * A request handler for `search/completion` command. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ +class CompletionHandler( + timeout: FiniteDuration, + suggestionsHandler: ActorRef +) extends Actor + with ActorLogging + with UnhandledLogging { + + import context.dispatcher + + override def receive: Receive = requestStage + + private def requestStage: Receive = { + case Request( + Completion, + id, + Completion.Params(module, pos, selfType, returnType, tags) + ) => + suggestionsHandler ! SearchProtocol.Completion( + module, + pos, + selfType, + returnType, + tags + ) + val cancellable = + context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout) + context.become(responseStage(id, sender(), cancellable)) + } + + private def responseStage( + id: Id, + replyTo: ActorRef, + cancellable: Cancellable + ): Receive = { + case Status.Failure(ex) => + log.error(ex, "Search completion error") + replyTo ! ResponseError(Some(id), SuggestionsDatabaseError) + cancellable.cancel() + context.stop(self) + + case RequestTimeout => + log.error(s"Request $id timed out") + replyTo ! ResponseError(Some(id), ServiceError) + context.stop(self) + + case SearchProtocol.CompletionResult(version, results) => + replyTo ! ResponseResult( + Completion, + id, + Completion.Result(results, version) + ) + cancellable.cancel() + context.stop(self) + } +} + +object CompletionHandler { + + /** + * Creates configuration object used to create a [[CompletionHandler]]. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ + def props( + timeout: FiniteDuration, + suggestionsHandler: ActorRef + ): Props = + Props(new CompletionHandler(timeout, suggestionsHandler)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseHandler.scala new file mode 100644 index 00000000000..81f5ed5a2bf --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseHandler.scala @@ -0,0 +1,83 @@ +package org.enso.languageserver.requesthandler.search + +import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props, Status} +import org.enso.jsonrpc.Errors.ServiceError +import org.enso.jsonrpc._ +import org.enso.languageserver.requesthandler.RequestTimeout +import org.enso.languageserver.runtime.SearchApi.{ + GetSuggestionsDatabase, + SuggestionsDatabaseError +} +import org.enso.languageserver.runtime.SearchProtocol +import org.enso.languageserver.util.UnhandledLogging + +import scala.concurrent.duration.FiniteDuration + +/** + * A request handler for `search/getSuggestionsDatabase` command. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ +class GetSuggestionsDatabaseHandler( + timeout: FiniteDuration, + suggestionsHandler: ActorRef +) extends Actor + with ActorLogging + with UnhandledLogging { + + import context.dispatcher + + override def receive: Receive = requestStage + + private def requestStage: Receive = { + case Request(GetSuggestionsDatabase, id, _) => + suggestionsHandler ! SearchProtocol.GetSuggestionsDatabase + val cancellable = + context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout) + context.become(responseStage(id, sender(), cancellable)) + } + + private def responseStage( + id: Id, + replyTo: ActorRef, + cancellable: Cancellable + ): Receive = { + case Status.Failure(ex) => + log.error(ex, "GetSuggestionsDatabase error") + replyTo ! ResponseError(Some(id), SuggestionsDatabaseError) + cancellable.cancel() + context.stop(self) + + case RequestTimeout => + log.error(s"Request $id timed out") + replyTo ! ResponseError(Some(id), ServiceError) + context.stop(self) + + case SearchProtocol.GetSuggestionsDatabaseResult(updates, version) => + replyTo ! ResponseResult( + GetSuggestionsDatabase, + id, + GetSuggestionsDatabase.Result(updates, version) + ) + cancellable.cancel() + context.stop(self) + } +} + +object GetSuggestionsDatabaseHandler { + + /** + * Creates configuration object used to create a + * [[GetSuggestionsDatabaseHandler]]. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ + def props( + timeout: FiniteDuration, + suggestionsHandler: ActorRef + ): Props = + Props(new GetSuggestionsDatabaseHandler(timeout, suggestionsHandler)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseVersionHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseVersionHandler.scala new file mode 100644 index 00000000000..eb96c8552dc --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/search/GetSuggestionsDatabaseVersionHandler.scala @@ -0,0 +1,83 @@ +package org.enso.languageserver.requesthandler.search + +import akka.actor.{Actor, ActorLogging, ActorRef, Cancellable, Props, Status} +import org.enso.jsonrpc.Errors.ServiceError +import org.enso.jsonrpc._ +import org.enso.languageserver.requesthandler.RequestTimeout +import org.enso.languageserver.runtime.SearchApi.{ + GetSuggestionsDatabaseVersion, + SuggestionsDatabaseError +} +import org.enso.languageserver.runtime.SearchProtocol +import org.enso.languageserver.util.UnhandledLogging + +import scala.concurrent.duration.FiniteDuration + +/** + * A request handler for `search/getSuggestionsDatabaseVersion` command. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ +class GetSuggestionsDatabaseVersionHandler( + timeout: FiniteDuration, + suggestionsHandler: ActorRef +) extends Actor + with ActorLogging + with UnhandledLogging { + + import context.dispatcher + + override def receive: Receive = requestStage + + private def requestStage: Receive = { + case Request(GetSuggestionsDatabaseVersion, id, _) => + suggestionsHandler ! SearchProtocol.GetSuggestionsDatabaseVersion + val cancellable = + context.system.scheduler.scheduleOnce(timeout, self, RequestTimeout) + context.become(responseStage(id, sender(), cancellable)) + } + + private def responseStage( + id: Id, + replyTo: ActorRef, + cancellable: Cancellable + ): Receive = { + case Status.Failure(ex) => + log.error(ex, "GetSuggestionsDatabaseVersion error") + replyTo ! ResponseError(Some(id), SuggestionsDatabaseError) + cancellable.cancel() + context.stop(self) + + case RequestTimeout => + log.error(s"Request $id timed out") + replyTo ! ResponseError(Some(id), ServiceError) + context.stop(self) + + case SearchProtocol.GetSuggestionsDatabaseVersionResult(version) => + replyTo ! ResponseResult( + GetSuggestionsDatabaseVersion, + id, + GetSuggestionsDatabaseVersion.Result(version) + ) + cancellable.cancel() + context.stop(self) + } +} + +object GetSuggestionsDatabaseVersionHandler { + + /** + * Creates configuration object used to create a + * [[GetSuggestionsDatabaseVersionHandler]]. + * + * @param timeout request timeout + * @param suggestionsHandler a reference to the suggestions handler + */ + def props( + timeout: FiniteDuration, + suggestionsHandler: ActorRef + ): Props = + Props(new GetSuggestionsDatabaseVersionHandler(timeout, suggestionsHandler)) + +} diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchApi.scala index 72fad69c604..635a4a2ddae 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchApi.scala @@ -1,7 +1,12 @@ package org.enso.languageserver.runtime -import org.enso.jsonrpc.{HasParams, Method} -import org.enso.languageserver.runtime.SearchProtocol.SuggestionsDatabaseUpdate +import org.enso.jsonrpc.{Error, HasParams, HasResult, Method, Unused} +import org.enso.languageserver.runtime.SearchProtocol.{ + SuggestionId, + SuggestionKind, + SuggestionsDatabaseUpdate +} +import org.enso.text.editing.model.Position /** * The execution JSON RPC API provided by the language server. @@ -23,4 +28,55 @@ object SearchApi { } } + case object GetSuggestionsDatabase + extends Method("search/getSuggestionsDatabase") { + + case class Result( + entries: Seq[SuggestionsDatabaseUpdate], + currentVersion: Long + ) + + implicit val hasParams = new HasParams[this.type] { + type Params = Unused.type + } + implicit val hasResult = new HasResult[this.type] { + type Result = GetSuggestionsDatabase.Result + } + } + + case object GetSuggestionsDatabaseVersion + extends Method("search/getSuggestionsDatabaseVersion") { + + case class Result(version: Long) + + implicit val hasParams = new HasParams[this.type] { + type Params = Unused.type + } + implicit val hasResult = new HasResult[this.type] { + type Result = GetSuggestionsDatabaseVersion.Result + } + } + + case object Completion extends Method("search/completion") { + + case class Params( + module: String, + position: Position, + selfType: Option[String], + returnType: Option[String], + tags: Option[Seq[SuggestionKind]] + ) + + case class Result(results: Seq[SuggestionId], currentVersion: Long) + + implicit val hasParams = new HasParams[this.type] { + type Params = Completion.Params + } + implicit val hasResult = new HasResult[this.type] { + type Result = Completion.Result + } + } + + case object SuggestionsDatabaseError + extends Error(7001, "Suggestions database error") } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchProtocol.scala index 6e7e4086bf0..fcb9155d04b 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchProtocol.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SearchProtocol.scala @@ -1,48 +1,32 @@ package org.enso.languageserver.runtime +import enumeratum._ import io.circe.generic.auto._ import io.circe.syntax._ import io.circe.{Decoder, Encoder, Json} import org.enso.searcher.Suggestion +import org.enso.text.editing.model.Position object SearchProtocol { + type SuggestionId = Long + sealed trait SuggestionsDatabaseUpdate object SuggestionsDatabaseUpdate { /** Create or replace the database entry. * - * @param id suggestion id + * @param id the suggestion id * @param suggestion the new suggestion */ - case class Add(id: Long, suggestion: Suggestion) + case class Add(id: SuggestionId, suggestion: Suggestion) extends SuggestionsDatabaseUpdate /** Remove the database entry. * * @param id the suggestion id */ - case class Remove(id: Long) extends SuggestionsDatabaseUpdate - - /** Modify the database entry. - * - * @param id the suggestion id - * @param name the new suggestion name - * @param arguments the new suggestion arguments - * @param selfType the new self type of the suggestion - * @param returnType the new return type of the suggestion - * @param documentation the new documentation string - * @param scope the suggestion scope - */ - case class Modify( - id: Long, - name: Option[String], - arguments: Option[Seq[Suggestion.Argument]], - selfType: Option[String], - returnType: Option[String], - documentation: Option[String], - scope: Option[Suggestion.Scope] - ) extends SuggestionsDatabaseUpdate + case class Remove(id: SuggestionId) extends SuggestionsDatabaseUpdate private object CodecField { @@ -53,9 +37,7 @@ object SearchProtocol { val Add = "Add" - val Delete = "Delete" - - val Update = "Update" + val Remove = "Remove" } implicit val decoder: Decoder[SuggestionsDatabaseUpdate] = @@ -64,10 +46,7 @@ object SearchProtocol { case CodecType.Add => Decoder[SuggestionsDatabaseUpdate.Add].tryDecode(cursor) - case CodecType.Update => - Decoder[SuggestionsDatabaseUpdate.Modify].tryDecode(cursor) - - case CodecType.Delete => + case CodecType.Remove => Decoder[SuggestionsDatabaseUpdate.Remove].tryDecode(cursor) } } @@ -80,16 +59,10 @@ object SearchProtocol { .deepMerge(Json.obj(CodecField.Type -> CodecType.Add.asJson)) .dropNullValues - case modify: SuggestionsDatabaseUpdate.Modify => - Encoder[SuggestionsDatabaseUpdate.Modify] - .apply(modify) - .deepMerge(Json.obj(CodecField.Type -> CodecType.Update.asJson)) - .dropNullValues - case remove: SuggestionsDatabaseUpdate.Remove => Encoder[SuggestionsDatabaseUpdate.Remove] .apply(remove) - .deepMerge(Json.obj(CodecField.Type -> CodecType.Delete.asJson)) + .deepMerge(Json.obj(CodecField.Type -> CodecType.Remove.asJson)) } private object SuggestionType { @@ -152,9 +125,106 @@ object SearchProtocol { } } + /** The type of a suggestion. */ + sealed trait SuggestionKind extends EnumEntry + object SuggestionKind + extends Enum[SuggestionKind] + with CirceEnum[SuggestionKind] { + + /** An atom suggestion. */ + case object Atom extends SuggestionKind + + /** A method suggestion. */ + case object Method extends SuggestionKind + + /** A function suggestion. */ + case object Function extends SuggestionKind + + /** Local binding suggestion. */ + case object Local extends SuggestionKind + + override val values = findValues + + /** Create API kind from the [[Suggestion.Kind]] + * + * @param kind the suggestion kind + * @return the API kind + */ + def apply(kind: Suggestion.Kind): SuggestionKind = + kind match { + case Suggestion.Kind.Atom => Atom + case Suggestion.Kind.Method => Method + case Suggestion.Kind.Function => Function + case Suggestion.Kind.Local => Local + } + + /** Convert from API kind to [[Suggestion.Kind]] + * + * @param kind the API kind + * @return the suggestion kind + */ + def toSuggestion(kind: SuggestionKind): Suggestion.Kind = + kind match { + case Atom => Suggestion.Kind.Atom + case Method => Suggestion.Kind.Method + case Function => Suggestion.Kind.Function + case Local => Suggestion.Kind.Local + } + } + + /** A notification about changes in the suggestions database. + * + * @param updates the list of database updates + * @param currentVersion current version of the suggestions database + */ case class SuggestionsDatabaseUpdateNotification( updates: Seq[SuggestionsDatabaseUpdate], currentVersion: Long ) + /** The request to receive contents of the suggestions database. */ + case object GetSuggestionsDatabase + + /** The reply to the [[GetSuggestionsDatabase]] request. + * + * @param entries the entries of the suggestion database + * @param currentVersion current version of the suggestions database + */ + case class GetSuggestionsDatabaseResult( + entries: Seq[SuggestionsDatabaseUpdate], + currentVersion: Long + ) + + /** The request to receive the current version of the suggestions database. */ + case object GetSuggestionsDatabaseVersion + + /** The reply to the [[GetSuggestionsDatabaseVersion]] request. + * + * @param version current version of the suggestions database + */ + case class GetSuggestionsDatabaseVersionResult(version: Long) + + /** The completion request. + * + * @param module the edited module + * @param position the cursor position + * @param selfType filter entries matching the self type + * @param returnType filter entries matching the return type + * @param tags filter entries by suggestion type + */ + case class Completion( + module: String, + position: Position, + selfType: Option[String], + returnType: Option[String], + tags: Option[Seq[SuggestionKind]] + ) + + /** Te reply to the [[Completion]] request. + * + * @param currentVersion current version of the suggestions database + * @param results the list of suggestion ids matched the search query + */ + case class CompletionResult(currentVersion: Long, results: Seq[SuggestionId]) + } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListener.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListener.scala index f3041a19ec3..999abdfb51d 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListener.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListener.scala @@ -19,6 +19,10 @@ import org.enso.languageserver.runtime.SearchProtocol.{ import org.enso.languageserver.session.SessionRouter.DeliverToJsonController import org.enso.languageserver.util.UnhandledLogging import org.enso.polyglot.runtime.Runtime.Api +import org.enso.searcher.{Suggestion, SuggestionsRepo} + +import scala.concurrent.Future +import scala.util.{Failure, Success} /** * Event listener listens event stream for the suggestion database @@ -26,13 +30,17 @@ import org.enso.polyglot.runtime.Runtime.Api * is a singleton and created per context registry. * * @param sessionRouter the session router + * @param repo the suggestions repo */ final class SuggestionsDatabaseEventsListener( - sessionRouter: ActorRef + sessionRouter: ActorRef, + repo: SuggestionsRepo[Future] ) extends Actor with ActorLogging with UnhandledLogging { + import context.dispatcher + override def preStart(): Unit = { context.system.eventStream .subscribe(self, classOf[Api.SuggestionsDatabaseUpdateNotification]) @@ -56,41 +64,55 @@ final class SuggestionsDatabaseEventsListener( context.become(withClients(clients - client.clientId)) case msg: Api.SuggestionsDatabaseUpdateNotification => - clients.foreach { clientId => - sessionRouter ! DeliverToJsonController( - clientId, - SuggestionsDatabaseUpdateNotification(msg.updates.map(toUpdate), 0) - ) - } + applyDatabaseUpdates(msg) + .onComplete { + case Success(notification) => + if (notification.updates.nonEmpty) { + clients.foreach { clientId => + sessionRouter ! DeliverToJsonController(clientId, notification) + } + } + case Failure(ex) => + log.error( + ex, + "Error applying suggestion database updates: {}", + msg.updates + ) + } } - private def toUpdate( - update: Api.SuggestionsDatabaseUpdate - ): SuggestionsDatabaseUpdate = - update match { - case Api.SuggestionsDatabaseUpdate.Add(id, suggestion) => - SuggestionsDatabaseUpdate.Add(id, suggestion) - case Api.SuggestionsDatabaseUpdate.Modify( - id, - name, - arguments, - selfType, - returnType, - doc, - scope - ) => - SuggestionsDatabaseUpdate.Modify( - id, - name, - arguments, - selfType, - returnType, - doc, - scope - ) - case Api.SuggestionsDatabaseUpdate.Remove(id) => - SuggestionsDatabaseUpdate.Remove(id) + private def applyDatabaseUpdates( + msg: Api.SuggestionsDatabaseUpdateNotification + ): Future[SuggestionsDatabaseUpdateNotification] = { + val (added, removed) = msg.updates + .foldLeft((Seq[Suggestion](), Seq[Suggestion]())) { + case ((add, remove), msg: Api.SuggestionsDatabaseUpdate.Add) => + (add :+ msg.suggestion, remove) + case ((add, remove), msg: Api.SuggestionsDatabaseUpdate.Remove) => + (add, remove :+ msg.suggestion) + } + + for { + (_, removedIds) <- repo.removeAll(removed) + (version, addedIds) <- repo.insertAll(added) + } yield { + val updatesRemoved = removedIds.collect { + case Some(id) => SuggestionsDatabaseUpdate.Remove(id) + } + val updatesAdded = + (addedIds zip added).flatMap { + case (Some(id), suggestion) => + Some(SuggestionsDatabaseUpdate.Add(id, suggestion)) + case (None, suggestion) => + log.error("failed to insert suggestion: {}", suggestion) + None + } + SuggestionsDatabaseUpdateNotification( + updatesRemoved ++ updatesAdded, + version + ) } + } } object SuggestionsDatabaseEventsListener { @@ -100,8 +122,12 @@ object SuggestionsDatabaseEventsListener { * [[SuggestionsDatabaseEventsListener]]. * * @param sessionRouter the session router + * @param repo the suggestions repo */ - def props(sessionRouter: ActorRef): Props = - Props(new SuggestionsDatabaseEventsListener(sessionRouter)) + def props( + sessionRouter: ActorRef, + repo: SuggestionsRepo[Future] + ): Props = + Props(new SuggestionsDatabaseEventsListener(sessionRouter, repo)) } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsHandler.scala new file mode 100644 index 00000000000..2ff47552e74 --- /dev/null +++ b/engine/language-server/src/main/scala/org/enso/languageserver/runtime/SuggestionsHandler.scala @@ -0,0 +1,65 @@ +package org.enso.languageserver.runtime + +import akka.actor.{Actor, ActorLogging, Props} +import akka.pattern.pipe +import org.enso.languageserver.runtime.SearchProtocol._ +import org.enso.languageserver.util.UnhandledLogging +import org.enso.searcher.{SuggestionEntry, SuggestionsRepo} + +import scala.concurrent.Future + +/** + * The handler of search requests. + * + * Handler initializes the database and responds to the search requests. + * + * @param repo the suggestions repo + */ +final class SuggestionsHandler(repo: SuggestionsRepo[Future]) + extends Actor + with ActorLogging + with UnhandledLogging { + + import context.dispatcher + + override def receive: Receive = { + case GetSuggestionsDatabaseVersion => + repo.currentVersion + .map(GetSuggestionsDatabaseVersionResult) + .pipeTo(sender()) + + case GetSuggestionsDatabase => + repo.getAll + .map(Function.tupled(toGetSuggestionsDatabaseResult)) + .pipeTo(sender()) + + case Completion(_, _, selfType, returnType, tags) => + val kinds = tags.map(_.map(SuggestionKind.toSuggestion)) + repo + .search(selfType, returnType, kinds) + .map(CompletionResult.tupled) + .pipeTo(sender()) + } + + private def toGetSuggestionsDatabaseResult( + version: Long, + entries: Seq[SuggestionEntry] + ): GetSuggestionsDatabaseResult = { + val updates = entries.map(entry => + SuggestionsDatabaseUpdate.Add(entry.id, entry.suggestion) + ) + GetSuggestionsDatabaseResult(updates, version) + } +} + +object SuggestionsHandler { + + /** + * Creates a configuration object used to create a [[SuggestionsHandler]]. + * + * @param repo the suggestions repo + */ + def props(repo: SuggestionsRepo[Future]): Props = + Props(new SuggestionsHandler(repo)) + +} diff --git a/engine/language-server/src/test/resources/application.conf b/engine/language-server/src/test/resources/application.conf index 41d847fc610..85d09bd278c 100644 --- a/engine/language-server/src/test/resources/application.conf +++ b/engine/language-server/src/test/resources/application.conf @@ -1,6 +1,4 @@ akka.http.server.idle-timeout = infinite akka.http.server.remote-address-header = on akka.http.server.websocket.periodic-keep-alive-max-idle = 1 second -akka { - loglevel = "ERROR" -} +akka.loglevel = "ERROR" diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/runtime/Suggestions.scala b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/Suggestions.scala new file mode 100644 index 00000000000..7bbcc9b3559 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/Suggestions.scala @@ -0,0 +1,44 @@ +package org.enso.languageserver.runtime + +import org.enso.searcher.Suggestion + +/** Suggestion instances used in tests. */ +object Suggestions { + + val atom: Suggestion.Atom = + Suggestion.Atom( + name = "MyType", + arguments = Vector(Suggestion.Argument("a", "Any", false, false, None)), + returnType = "MyAtom", + documentation = None + ) + + val method: Suggestion.Method = + Suggestion.Method( + name = "foo", + arguments = Vector( + Suggestion.Argument("this", "MyType", false, false, None), + Suggestion.Argument("foo", "Number", false, true, Some("42")) + ), + selfType = "MyType", + returnType = "Number", + documentation = Some("Lovely") + ) + + val function: Suggestion.Function = + Suggestion.Function( + name = "print", + arguments = Vector(), + returnType = "IO", + scope = Suggestion.Scope(9, 22) + ) + + val local: Suggestion.Local = + Suggestion.Local( + name = "x", + returnType = "Number", + scope = Suggestion.Scope(34, 68) + ) + + val all = Seq(atom, method, function, local) +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListenerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListenerTest.scala new file mode 100644 index 00000000000..7d4aed3ce3b --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsDatabaseEventsListenerTest.scala @@ -0,0 +1,148 @@ +package org.enso.languageserver.runtime + +import java.nio.file.Files +import java.util.UUID + +import akka.actor.{ActorRef, ActorSystem} +import akka.testkit.{ImplicitSender, TestKit, TestProbe} +import org.enso.jsonrpc.test.RetrySpec +import org.enso.languageserver.capability.CapabilityProtocol.{ + AcquireCapability, + CapabilityAcquired +} +import org.enso.languageserver.data.{ + CapabilityRegistration, + ReceivesSuggestionsDatabaseUpdates +} +import org.enso.languageserver.session.JsonSession +import org.enso.languageserver.session.SessionRouter.DeliverToJsonController +import org.enso.polyglot.runtime.Runtime.Api +import org.enso.searcher.SuggestionsRepo +import org.enso.searcher.sql.SqlSuggestionsRepo +import org.scalatest.BeforeAndAfterAll +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpecLike + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class SuggestionsDatabaseEventsListenerTest + extends TestKit(ActorSystem("TestSystem")) + with ImplicitSender + with AnyWordSpecLike + with Matchers + with BeforeAndAfterAll + with RetrySpec { + + import system.dispatcher + + val Timeout: FiniteDuration = 5.seconds + + override def afterAll(): Unit = { + TestKit.shutdownActorSystem(system) + } + + "SuggestionsHandler" should { + + "subscribe to notification updates" taggedAs Retry() in withDb { + (router, repo) => + val handler = newEventsListener(router.ref, repo) + val clientId = UUID.randomUUID() + + handler ! AcquireCapability( + newJsonSession(clientId), + CapabilityRegistration(ReceivesSuggestionsDatabaseUpdates()) + ) + expectMsg(CapabilityAcquired) + } + + "receive runtime updates" taggedAs Retry() in withDb { (router, repo) => + val handler = newEventsListener(router.ref, repo) + val clientId = UUID.randomUUID() + + // acquire capability + handler ! AcquireCapability( + newJsonSession(clientId), + CapabilityRegistration(ReceivesSuggestionsDatabaseUpdates()) + ) + expectMsg(CapabilityAcquired) + + // receive updates + handler ! Api.SuggestionsDatabaseUpdateNotification( + Suggestions.all.map(Api.SuggestionsDatabaseUpdate.Add) + ) + + val updates = Suggestions.all.zipWithIndex.map { + case (suggestion, ix) => + SearchProtocol.SuggestionsDatabaseUpdate.Add(ix + 1L, suggestion) + } + router.expectMsg( + DeliverToJsonController( + clientId, + SearchProtocol.SuggestionsDatabaseUpdateNotification(updates, 4L) + ) + ) + + // check database entries exist + val (_, records) = Await.result(repo.getAll, Timeout) + records.map(_.suggestion) should contain theSameElementsAs Suggestions.all + } + + "apply runtime updates in correct order" taggedAs Retry() in withDb { + (router, repo) => + val handler = newEventsListener(router.ref, repo) + val clientId = UUID.randomUUID() + + // acquire capability + handler ! AcquireCapability( + newJsonSession(clientId), + CapabilityRegistration(ReceivesSuggestionsDatabaseUpdates()) + ) + expectMsg(CapabilityAcquired) + + // receive updates + handler ! Api.SuggestionsDatabaseUpdateNotification( + Suggestions.all.map(Api.SuggestionsDatabaseUpdate.Add) ++ + Suggestions.all.map(Api.SuggestionsDatabaseUpdate.Remove) + ) + + val updates = Suggestions.all.zipWithIndex.map { + case (suggestion, ix) => + SearchProtocol.SuggestionsDatabaseUpdate.Add(ix + 1L, suggestion) + } + router.expectMsg( + DeliverToJsonController( + clientId, + SearchProtocol.SuggestionsDatabaseUpdateNotification(updates, 4L) + ) + ) + + // check that database entries removed + val (_, all) = Await.result(repo.getAll, Timeout) + all.map(_.suggestion) should contain theSameElementsAs Suggestions.all + } + + } + + def newEventsListener( + router: ActorRef, + repo: SuggestionsRepo[Future] + ): ActorRef = + system.actorOf(SuggestionsDatabaseEventsListener.props(router, repo)) + + def newJsonSession(clientId: UUID): JsonSession = + JsonSession(clientId, TestProbe().ref) + + def withDb( + test: (TestProbe, SuggestionsRepo[Future]) => Any + ): Unit = { + val dbPath = Files.createTempFile("suggestions", ".db") + system.registerOnTermination(Files.deleteIfExists(dbPath)) + val repo = SqlSuggestionsRepo() + Await.ready(repo.init, Timeout) + val router = TestProbe("sessionRouterProbe") + + try test(router, repo) + finally repo.close() + } +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsHandlerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsHandlerTest.scala new file mode 100644 index 00000000000..f701b0fa381 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/runtime/SuggestionsHandlerTest.scala @@ -0,0 +1,146 @@ +package org.enso.languageserver.runtime + +import java.nio.file.Files + +import akka.actor.{ActorRef, ActorSystem} +import akka.testkit.{ImplicitSender, TestKit} +import org.enso.jsonrpc.test.RetrySpec +import org.enso.searcher.SuggestionsRepo +import org.enso.searcher.sql.SqlSuggestionsRepo +import org.enso.text.editing.model.Position +import org.scalatest.BeforeAndAfterAll +import org.scalatest.wordspec.AnyWordSpecLike + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class SuggestionsHandlerTest + extends TestKit(ActorSystem("TestSystem")) + with ImplicitSender + with AnyWordSpecLike + with BeforeAndAfterAll + with RetrySpec { + + import system.dispatcher + + val Timeout: FiniteDuration = 10.seconds + + override def afterAll(): Unit = { + TestKit.shutdownActorSystem(system) + } + + "SuggestionsHandler" should { + + "get initial suggestions database version" in withDb { repo => + val handler = newSuggestionsHandler(repo) + handler ! SearchProtocol.GetSuggestionsDatabaseVersion + + expectMsg(SearchProtocol.GetSuggestionsDatabaseVersionResult(0)) + } + + "get suggestions database version" in withDb { repo => + val handler = newSuggestionsHandler(repo) + Await.ready(repo.insert(Suggestions.atom), Timeout) + + handler ! SearchProtocol.GetSuggestionsDatabaseVersion + + expectMsg(SearchProtocol.GetSuggestionsDatabaseVersionResult(1)) + } + + "get initial suggestions database" in withDb { repo => + val handler = newSuggestionsHandler(repo) + handler ! SearchProtocol.GetSuggestionsDatabase + + expectMsg(SearchProtocol.GetSuggestionsDatabaseResult(Seq(), 0)) + } + + "get suggestions database" in withDb { repo => + val handler = newSuggestionsHandler(repo) + Await.ready(repo.insert(Suggestions.atom), Timeout) + handler ! SearchProtocol.GetSuggestionsDatabase + + expectMsg( + SearchProtocol.GetSuggestionsDatabaseResult( + Seq( + SearchProtocol.SuggestionsDatabaseUpdate.Add(1L, Suggestions.atom) + ), + 1 + ) + ) + } + + "search entries by empty search query" taggedAs Retry() in withDb { repo => + val handler = newSuggestionsHandler(repo) + Await.ready(repo.insertAll(Suggestions.all), Timeout) + handler ! SearchProtocol.Completion( + module = "Test.Main", + position = Position(0, 0), + selfType = None, + returnType = None, + tags = None + ) + + expectMsg(SearchProtocol.CompletionResult(4L, Seq())) + } + + "search entries by self type" taggedAs Retry() in withDb { repo => + val handler = newSuggestionsHandler(repo) + val (_, Seq(_, methodId, _, _)) = + Await.result(repo.insertAll(Suggestions.all), Timeout) + handler ! SearchProtocol.Completion( + module = "Test.Main", + position = Position(0, 0), + selfType = Some("MyType"), + returnType = None, + tags = None + ) + + expectMsg(SearchProtocol.CompletionResult(4L, Seq(methodId).flatten)) + } + + "search entries by return type" taggedAs Retry() in withDb { repo => + val handler = newSuggestionsHandler(repo) + val (_, Seq(_, _, functionId, _)) = + Await.result(repo.insertAll(Suggestions.all), Timeout) + handler ! SearchProtocol.Completion( + module = "Test.Main", + position = Position(0, 0), + selfType = None, + returnType = Some("IO"), + tags = None + ) + + expectMsg(SearchProtocol.CompletionResult(4L, Seq(functionId).flatten)) + } + + "search entries by tags" taggedAs Retry() in withDb { repo => + val handler = newSuggestionsHandler(repo) + val (_, Seq(_, _, _, localId)) = + Await.result(repo.insertAll(Suggestions.all), Timeout) + handler ! SearchProtocol.Completion( + module = "Test.Main", + position = Position(0, 0), + selfType = None, + returnType = None, + tags = Some(Seq(SearchProtocol.SuggestionKind.Local)) + ) + + expectMsg(SearchProtocol.CompletionResult(4L, Seq(localId).flatten)) + } + + } + + def newSuggestionsHandler(repo: SuggestionsRepo[Future]): ActorRef = + system.actorOf(SuggestionsHandler.props(repo)) + + def withDb(test: SuggestionsRepo[Future] => Any): Unit = { + val dbPath = Files.createTempFile("suggestions", ".db") + system.registerOnTermination(Files.deleteIfExists(dbPath)) + val repo = SqlSuggestionsRepo() + Await.ready(repo.init, Timeout) + + try test(repo) + finally repo.close() + } + +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala index be7e5cb9532..81635cabdd1 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/BaseServerTest.scala @@ -15,30 +15,30 @@ import org.enso.languageserver.filemanager.{ FileSystem, ReceivesTreeUpdatesHandler } -import org.enso.languageserver.io.{ - InputRedirectionController, - ObservableOutputStream, - ObservablePipedInputStream, - OutputKind, - OutputRedirectionController -} +import org.enso.languageserver.io._ import org.enso.languageserver.protocol.json.{ JsonConnectionControllerFactory, JsonRpc } import org.enso.languageserver.runtime.{ ContextRegistry, - SuggestionsDatabaseEventsListener + SuggestionsDatabaseEventsListener, + SuggestionsHandler } import org.enso.languageserver.session.SessionRouter import org.enso.languageserver.text.BufferRegistry +import org.enso.searcher.sql.SqlSuggestionsRepo +import scala.concurrent.Await import scala.concurrent.duration._ class BaseServerTest extends JsonRpcServerTestKit { - val testContentRoot = Files.createTempDirectory(null).toRealPath() - val testContentRootId = UUID.randomUUID() + val timeout: FiniteDuration = 3.seconds + + val testSuggestionsDbPath = Files.createTempFile("suggestions", ".db") + val testContentRoot = Files.createTempDirectory(null).toRealPath() + val testContentRootId = UUID.randomUUID() val config = Config( Map(testContentRootId -> testContentRoot.toFile), FileManagerConfig(timeout = 3.seconds), @@ -48,6 +48,7 @@ class BaseServerTest extends JsonRpcServerTestKit { val runtimeConnectorProbe = TestProbe() testContentRoot.toFile.deleteOnExit() + testSuggestionsDbPath.toFile.deleteOnExit() override def protocol: Protocol = JsonRpc.protocol @@ -77,7 +78,10 @@ class BaseServerTest extends JsonRpcServerTestKit { ) override def clientControllerFactory: ClientControllerFactory = { - val zioExec = ZioExec(zio.Runtime.default) + val zioExec = ZioExec(zio.Runtime.default) + val suggestionsRepo = SqlSuggestionsRepo()(system.dispatcher) + Await.ready(suggestionsRepo.init, timeout) + val fileManager = system.actorOf(FileManager.props(config, new FileSystem, zioExec)) val bufferRegistry = @@ -97,7 +101,9 @@ class BaseServerTest extends JsonRpcServerTestKit { ) val suggestionsDatabaseEventsListener = - system.actorOf(SuggestionsDatabaseEventsListener.props(sessionRouter)) + system.actorOf( + SuggestionsDatabaseEventsListener.props(sessionRouter, suggestionsRepo) + ) val capabilityRouter = system.actorOf( @@ -108,11 +114,15 @@ class BaseServerTest extends JsonRpcServerTestKit { ) ) + val suggestionsHandler = + system.actorOf(SuggestionsHandler.props(suggestionsRepo)) + new JsonConnectionControllerFactory( bufferRegistry, capabilityRouter, fileManager, contextRegistry, + suggestionsHandler, stdOutController, stdErrController, stdInController, diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/FileNotificationsTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/FileNotificationsTest.scala index 5ef8a3d3915..061bad1a153 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/FileNotificationsTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/FileNotificationsTest.scala @@ -3,16 +3,17 @@ package org.enso.languageserver.websocket.json import java.io.File import io.circe.literal._ +import org.enso.jsonrpc.test.RetrySpec import org.enso.polyglot.runtime.Runtime.Api import org.enso.text.editing.model.{Position, Range, TextEdit} -class FileNotificationsTest extends BaseServerTest { +class FileNotificationsTest extends BaseServerTest with RetrySpec { def file(name: String): File = new File(testContentRoot.toFile, name) "text operations" should { - "notify runtime about operations with files" in { + "notify runtime about operations with files" taggedAs Retry() in { // Interaction: // 1. Client 1 creates a file. // 2. Client 1 opens the file. diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SearchJsonMessages.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SearchJsonMessages.scala new file mode 100644 index 00000000000..45b61f7099a --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SearchJsonMessages.scala @@ -0,0 +1,71 @@ +package org.enso.languageserver.websocket.json + +import io.circe.literal._ + +object SearchJsonMessages { + + def acquireSuggestionsDatabaseUpdatesCapability(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "method": "capability/acquire", + "id": $reqId, + "params": { + "method": "search/receivesSuggestionsDatabaseUpdates", + "registerOptions": {} + } + } + """ + + def releaseSuggestionsDatabaseUpdatesCapability(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "method": "capability/release", + "id": $reqId, + "params": { + "method": "search/receivesSuggestionsDatabaseUpdates", + "registerOptions": {} + } + } + """ + + def getSuggestionsDatabaseVersion(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "method": "search/getSuggestionsDatabaseVersion", + "id": $reqId, + "params": null + } + """ + + def getSuggestionsDatabase(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "method": "search/getSuggestionsDatabase", + "id": $reqId, + "params": null + } + """ + + def completion(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "method": "search/completion", + "id": $reqId, + "params": { + "module": "Test.Main", + "position": { + "line": 0, + "character": 0 + } + } + } + """ + + def ok(reqId: Long) = + json""" + { "jsonrpc": "2.0", + "id": $reqId, + "result": null + } + """ +} diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsDatabaseEventsListenerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsDatabaseEventsListenerTest.scala index 803d7161682..55d085eea82 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsDatabaseEventsListenerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsDatabaseEventsListenerTest.scala @@ -1,32 +1,30 @@ package org.enso.languageserver.websocket.json import io.circe.literal._ +import org.enso.jsonrpc.test.FlakySpec +import org.enso.languageserver.runtime.Suggestions +import org.enso.languageserver.websocket.json.{SearchJsonMessages => json} import org.enso.polyglot.runtime.Runtime.Api -import org.enso.searcher.Suggestion +import org.scalatest.BeforeAndAfter -class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { +class SuggestionsDatabaseEventsListenerTest + extends BaseServerTest + with BeforeAndAfter + with FlakySpec { + + lazy val client = getInitialisedWsClient() "SuggestionsDatabaseEventListener" must { - "acquire and release capabilities" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) - - client.send(json.releaseSuggestionsDatabaseUpdatesCapability(1)) - client.expectJson(json.ok(1)) - } - - "send suggestions database add atom notifications" in { - val client = getInitialisedWsClient() + "send suggestions database notifications" taggedAs Flaky in { client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) client.expectJson(json.ok(0)) + // add atom system.eventStream.publish( Api.SuggestionsDatabaseUpdateNotification( - Seq(Api.SuggestionsDatabaseUpdate.Add(0, suggestion.atom)) + Seq(Api.SuggestionsDatabaseUpdate.Add(Suggestions.atom)) ) ) client.expectJson(json""" @@ -36,7 +34,7 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { "updates" : [ { "type" : "Add", - "id" : 0, + "id" : 1, "suggestion" : { "type" : "atom", "name" : "MyType", @@ -53,21 +51,15 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { } } ], - "currentVersion" : 0 + "currentVersion" : 1 } } """) - } - - "send suggestions database add method notifications" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) + // add method system.eventStream.publish( Api.SuggestionsDatabaseUpdateNotification( - Seq(Api.SuggestionsDatabaseUpdate.Add(0, suggestion.method)) + Seq(Api.SuggestionsDatabaseUpdate.Add(Suggestions.method)) ) ) client.expectJson(json""" @@ -77,7 +69,7 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { "updates" : [ { "type" : "Add", - "id" : 0, + "id" : 2, "suggestion" : { "type" : "method", "name" : "foo", @@ -99,25 +91,19 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { ], "selfType" : "MyType", "returnType" : "Number", - "documentation" : "My doc" + "documentation" : "Lovely" } } ], - "currentVersion" : 0 + "currentVersion" : 2 } } """) - } - - "send suggestions database add function notifications" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) + // add function system.eventStream.publish( Api.SuggestionsDatabaseUpdateNotification( - Seq(Api.SuggestionsDatabaseUpdate.Add(0, suggestion.function)) + Seq(Api.SuggestionsDatabaseUpdate.Add(Suggestions.function)) ) ) client.expectJson(json""" @@ -127,7 +113,7 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { "updates" : [ { "type" : "Add", - "id" : 0, + "id" : 3, "suggestion" : { "type" : "function", "name" : "print", @@ -135,27 +121,21 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { ], "returnType" : "IO", "scope" : { - "start" : 7, - "end" : 10 + "start" : 9, + "end" : 22 } } } ], - "currentVersion" : 0 + "currentVersion" : 3 } } """) - } - - "send suggestions database add local notifications" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) + // add local system.eventStream.publish( Api.SuggestionsDatabaseUpdateNotification( - Seq(Api.SuggestionsDatabaseUpdate.Add(0, suggestion.local)) + Seq(Api.SuggestionsDatabaseUpdate.Add(Suggestions.local)) ) ) client.expectJson(json""" @@ -165,47 +145,29 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { "updates" : [ { "type" : "Add", - "id" : 0, + "id" : 4, "suggestion" : { "type" : "local", "name" : "x", "returnType" : "Number", "scope" : { - "start" : 15, - "end" : 17 + "start" : 34, + "end" : 68 } } } ], - "currentVersion" : 0 + "currentVersion" : 4 } } """) - } - - "send suggestions database modify notifications" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) + // remove items system.eventStream.publish( Api.SuggestionsDatabaseUpdateNotification( Seq( - Api.SuggestionsDatabaseUpdate.Modify( - id = 0, - name = Some("foo"), - arguments = Some( - Seq( - Suggestion.Argument("a", "Any", true, false, None), - Suggestion.Argument("b", "Any", false, true, Some("77")) - ) - ), - selfType = Some("MyType"), - returnType = Some("IO"), - documentation = None, - scope = Some(Suggestion.Scope(12, 24)) - ) + Api.SuggestionsDatabaseUpdate.Remove(Suggestions.method), + Api.SuggestionsDatabaseUpdate.Remove(Suggestions.function) ) ) ) @@ -215,138 +177,20 @@ class SuggestionsDatabaseEventsListenerTest extends BaseServerTest { "params" : { "updates" : [ { - "type" : "Update", - "id" : 0, - "name" : "foo", - "arguments" : [ - { - "name" : "a", - "reprType" : "Any", - "isSuspended" : true, - "hasDefault" : false, - "defaultValue" : null - }, - { - "name" : "b", - "reprType" : "Any", - "isSuspended" : false, - "hasDefault" : true, - "defaultValue" : "77" - } - ], - "selfType" : "MyType", - "returnType" : "IO", - "scope" : { - "start" : 12, - "end" : 24 - } - } - ], - "currentVersion" : 0 - } - } - """) - } - - "send suggestions database remove notifications" in { - val client = getInitialisedWsClient() - - client.send(json.acquireSuggestionsDatabaseUpdatesCapability(0)) - client.expectJson(json.ok(0)) - - system.eventStream.publish( - Api.SuggestionsDatabaseUpdateNotification( - Seq(Api.SuggestionsDatabaseUpdate.Remove(101)) - ) - ) - client.expectJson(json""" - { "jsonrpc" : "2.0", - "method" : "search/suggestionsDatabaseUpdates", - "params" : { - "updates" : [ + "type" : "Remove", + "id" : 2 + }, { - "type" : "Delete", - "id" : 101 + "type" : "Remove", + "id" : 3 } ], - "currentVersion" : 0 + "currentVersion" : 6 } } """) + } } - object suggestion { - - val atom: Suggestion.Atom = - Suggestion.Atom( - name = "MyType", - arguments = Seq(Suggestion.Argument("a", "Any", false, false, None)), - returnType = "MyAtom", - documentation = None - ) - - val method: Suggestion.Method = - Suggestion.Method( - name = "foo", - arguments = Seq( - Suggestion.Argument("this", "MyType", false, false, None), - Suggestion.Argument("foo", "Number", false, true, Some("42")) - ), - selfType = "MyType", - returnType = "Number", - documentation = Some("My doc") - ) - - val function: Suggestion.Function = - Suggestion.Function( - name = "print", - arguments = Seq(), - returnType = "IO", - scope = Suggestion.Scope(7, 10) - ) - - val local: Suggestion.Local = - Suggestion.Local( - name = "x", - returnType = "Number", - scope = Suggestion.Scope(15, 17) - ) - } - - object json { - - def acquireSuggestionsDatabaseUpdatesCapability(reqId: Long) = - json""" - { "jsonrpc": "2.0", - "method": "capability/acquire", - "id": $reqId, - "params": { - "method": "search/receivesSuggestionsDatabaseUpdates", - "registerOptions": {} - } - } - """ - - def releaseSuggestionsDatabaseUpdatesCapability(reqId: Long) = - json""" - { "jsonrpc": "2.0", - "method": "capability/release", - "id": $reqId, - "params": { - "method": "search/receivesSuggestionsDatabaseUpdates", - "registerOptions": {} - } - } - """ - - def ok(reqId: Long) = - json""" - { "jsonrpc": "2.0", - "id": $reqId, - "result": null - } - """ - } - } diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsHandlerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsHandlerTest.scala new file mode 100644 index 00000000000..e5f09b5be78 --- /dev/null +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/SuggestionsHandlerTest.scala @@ -0,0 +1,56 @@ +package org.enso.languageserver.websocket.json +import io.circe.literal._ +import org.enso.languageserver.websocket.json.{SearchJsonMessages => json} + +class SuggestionsHandlerTest extends BaseServerTest { + + "SuggestionsHandler" must { + + "get initial suggestions database version" in { + val client = getInitialisedWsClient() + + client.send(json.getSuggestionsDatabaseVersion(0)) + client.expectJson(json""" + { "jsonrpc" : "2.0", + "id" : 0, + "result" : { + "version" : 0 + } + } + """) + } + + "get initial suggestions database" in { + val client = getInitialisedWsClient() + + client.send(json.getSuggestionsDatabase(0)) + client.expectJson(json""" + { "jsonrpc" : "2.0", + "id" : 0, + "result" : { + "entries" : [ + ], + "currentVersion" : 0 + } + } + """) + } + + "reply to completion request" in { + val client = getInitialisedWsClient() + + client.send(json.completion(0)) + client.expectJson(json""" + { "jsonrpc" : "2.0", + "id" : 0, + "result" : { + "results" : [ + ], + "currentVersion" : 0 + } + } + """) + } + } + +} diff --git a/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala b/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala index b1892c2a0f4..6eacd4c074f 100644 --- a/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala +++ b/engine/polyglot-api/src/main/scala/org/enso/polyglot/runtime/Runtime.scala @@ -321,10 +321,6 @@ object Runtime { new JsonSubTypes.Type( value = classOf[SuggestionsDatabaseUpdate.Remove], name = "suggestionsDatabaseUpdateRemove" - ), - new JsonSubTypes.Type( - value = classOf[SuggestionsDatabaseUpdate.Modify], - name = "suggestionsDatabaseUpdateModify" ) ) ) @@ -333,37 +329,16 @@ object Runtime { /** Create or replace the database entry. * - * @param id suggestion id * @param suggestion the new suggestion */ - case class Add(id: Long, suggestion: Suggestion) - extends SuggestionsDatabaseUpdate + case class Add(suggestion: Suggestion) extends SuggestionsDatabaseUpdate /** Remove the database entry. * - * @param id the suggestion id + * @param suggestion the suggestion to remove */ - case class Remove(id: Long) extends SuggestionsDatabaseUpdate - - /** Modify the database entry. - * - * @param id the suggestion id - * @param name the new suggestion name - * @param arguments the new suggestion arguments - * @param selfType the new self type of the suggestion - * @param returnType the new return type of the suggestion - * @param documentation the new documentation string - * @param scope the suggestion scope - */ - case class Modify( - id: Long, - name: Option[String], - arguments: Option[Seq[Suggestion.Argument]], - selfType: Option[String], - returnType: Option[String], - documentation: Option[String], - scope: Option[Suggestion.Scope] - ) extends SuggestionsDatabaseUpdate + case class Remove(suggestion: Suggestion) + extends SuggestionsDatabaseUpdate } /** diff --git a/engine/runtime/src/test/scala/org/enso/compiler/test/pass/resolve/DocumentationCommentsTest.scala b/engine/runtime/src/test/scala/org/enso/compiler/test/pass/resolve/DocumentationCommentsTest.scala index 06d75448237..0ce567647ac 100644 --- a/engine/runtime/src/test/scala/org/enso/compiler/test/pass/resolve/DocumentationCommentsTest.scala +++ b/engine/runtime/src/test/scala/org/enso/compiler/test/pass/resolve/DocumentationCommentsTest.scala @@ -215,7 +215,7 @@ class DocumentationCommentsTest extends CompilerTest with Inside { | IO.println "foo" | ## the return | 0 - | + | | f = case _ of | ## case 1 | Bar -> 100 diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala index 32d54f75706..1475a620495 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala @@ -247,7 +247,7 @@ class ProjectManagementApiSpec """) } - "start the Language Server if not running" in { + "start the Language Server if not running" taggedAs Flaky in { //given val projectName = "to-remove" implicit val client = new WsTestClient(address) diff --git a/lib/scala/searcher/src/bench/java/org/enso/searcher/sql/SuggestionsRepoBenchmark.java b/lib/scala/searcher/src/bench/java/org/enso/searcher/sql/SuggestionsRepoBenchmark.java new file mode 100644 index 00000000000..a63345b6628 --- /dev/null +++ b/lib/scala/searcher/src/bench/java/org/enso/searcher/sql/SuggestionsRepoBenchmark.java @@ -0,0 +1,101 @@ +package org.enso.searcher.sql; + +import org.enso.searcher.Suggestion; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; +import scala.collection.immutable.Seq; +import scala.concurrent.Await; +import scala.concurrent.ExecutionContext; +import scala.concurrent.duration.Duration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; + +@BenchmarkMode(Mode.AverageTime) +@Fork(1) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@State(Scope.Benchmark) +public class SuggestionsRepoBenchmark { + + static final int DATABASE_SIZE = 1000000; + static final Duration TIMEOUT = Duration.apply(3, TimeUnit.SECONDS); + + final Path dbfile = Path.of(System.getProperty("java.io.tmpdir"), "bench-suggestions.db"); + final Seq kinds = SuggestionRandom.nextKinds(); + + SqlSuggestionsRepo repo; + + @Setup + public void setup() throws TimeoutException, InterruptedException { + repo = SqlSuggestionsRepo.apply(dbfile, ExecutionContext.global()); + if (Files.notExists(dbfile)) { + System.out.println("initializing " + dbfile.toString() + " ..."); + Await.ready(repo.init(), TIMEOUT); + System.out.println("inserting records..."); + int size = 0; + while (size < DATABASE_SIZE) { + size = insertBatch(20000); + } + System.out.println("created " + size + " records"); + } + } + + @TearDown + public void tearDown() { + repo.close(); + } + + int insertBatch(int size) throws TimeoutException, InterruptedException { + Suggestion[] stubs = + Stream.generate(SuggestionRandom::nextSuggestion).limit(size).toArray(Suggestion[]::new); + return (int) Await.result(repo.insertBatch(stubs), TIMEOUT); + } + + static scala.Option none() { + return (scala.Option) scala.None$.MODULE$; + } + + @Benchmark + public Object searchBaseline() throws TimeoutException, InterruptedException { + return Await.result(repo.search(none(), none(), none()), TIMEOUT); + } + + @Benchmark + public Object searchByReturnType() throws TimeoutException, InterruptedException { + return Await.result(repo.search(none(), scala.Some.apply("MyType"), none()), TIMEOUT); + } + + @Benchmark + public Object searchBySelfType() throws TimeoutException, InterruptedException { + return Await.result(repo.search(scala.Some.apply("MyType"), none(), none()), TIMEOUT); + } + + @Benchmark + public Object searchBySelfReturnTypes() throws TimeoutException, InterruptedException { + return Await.result( + repo.search(scala.Some.apply("SelfType"), scala.Some.apply("ReturnType"), none()), TIMEOUT); + } + + @Benchmark + public Object searchByAll() throws TimeoutException, InterruptedException { + return Await.result( + repo.search( + scala.Some.apply("SelfType"), scala.Some.apply("ReturnType"), scala.Some.apply(kinds)), + TIMEOUT); + } + + public static void main(String[] args) throws RunnerException { + Options opt = + new OptionsBuilder().include(SuggestionsRepoBenchmark.class.getSimpleName()).build(); + + new Runner(opt).run(); + } +} diff --git a/lib/scala/searcher/src/bench/scala/org/enso/searcher/sql/SuggestionRandom.scala b/lib/scala/searcher/src/bench/scala/org/enso/searcher/sql/SuggestionRandom.scala new file mode 100644 index 00000000000..94a8391f040 --- /dev/null +++ b/lib/scala/searcher/src/bench/scala/org/enso/searcher/sql/SuggestionRandom.scala @@ -0,0 +1,73 @@ +package org.enso.searcher.sql + +import org.enso.searcher.Suggestion + +import scala.util.Random + +object SuggestionRandom { + + def nextKinds(): Seq[Suggestion.Kind] = + Set.fill(1)(nextKind()).toSeq + + def nextSuggestion(): Suggestion = { + nextKind() match { + case Suggestion.Kind.Atom => nextSuggestionAtom() + case Suggestion.Kind.Method => nextSuggestionMethod() + case Suggestion.Kind.Function => nextSuggestionFunction() + case Suggestion.Kind.Local => nextSuggestionLocal() + } + } + + def nextSuggestionAtom(): Suggestion.Atom = + Suggestion.Atom( + name = nextString(), + arguments = Seq(), + returnType = nextString(), + documentation = optional(nextString()) + ) + + def nextSuggestionMethod(): Suggestion.Method = + Suggestion.Method( + name = nextString(), + arguments = Seq(), + selfType = nextString(), + returnType = nextString(), + documentation = optional(nextString()) + ) + + def nextSuggestionFunction(): Suggestion.Function = + Suggestion.Function( + name = nextString(), + arguments = Seq(), + returnType = nextString(), + scope = nextScope() + ) + + def nextSuggestionLocal(): Suggestion.Local = + Suggestion.Local( + name = nextString(), + returnType = nextString(), + scope = nextScope() + ) + + def nextScope(): Suggestion.Scope = + Suggestion.Scope( + start = Random.nextInt(Int.MaxValue), + end = Random.nextInt(Int.MaxValue) + ) + + def nextKind(): Suggestion.Kind = + Random.nextInt(4) match { + case 0 => Suggestion.Kind.Atom + case 1 => Suggestion.Kind.Method + case 2 => Suggestion.Kind.Function + case 3 => Suggestion.Kind.Local + case x => throw new NoSuchElementException(s"nextKind: $x") + } + + def nextString(): String = + Random.nextString(Random.nextInt(26) + 5) + + def optional[A](f: => A): Option[A] = + Option.when(Random.nextBoolean())(f) +} diff --git a/lib/scala/searcher/src/main/resources/application.conf b/lib/scala/searcher/src/main/resources/application.conf deleted file mode 100644 index 25f86080d49..00000000000 --- a/lib/scala/searcher/src/main/resources/application.conf +++ /dev/null @@ -1,8 +0,0 @@ -searcher { - db { - url = "jdbc:sqlite:searcher.db" - driver = org.sqlite.JDBC - connectionPool = disabled - keepAliveConnection = true - } -} diff --git a/lib/scala/searcher/src/main/resources/reference.conf b/lib/scala/searcher/src/main/resources/reference.conf new file mode 100644 index 00000000000..e05cabcc6fa --- /dev/null +++ b/lib/scala/searcher/src/main/resources/reference.conf @@ -0,0 +1,7 @@ +searcher { + db { + url = "jdbc:sqlite::memory:" + driver = "org.sqlite.JDBC" + connectionPool = "HikariCP" + } +} diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/Database.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/Database.scala new file mode 100644 index 00000000000..71ce876d355 --- /dev/null +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/Database.scala @@ -0,0 +1,21 @@ +package org.enso.searcher + +/** The database queries + * + * @tparam F the type of the query + * @tparam G the type of the result + */ +trait Database[F[_], G[_]] { + + /** Run the database query. + * + * @param query the query to run + */ + def run[A](query: F[A]): G[A] + + /** Run the database query in one transaction */ + def transaction[A](query: F[A]): G[A] + + /** Close the database. */ + def close(): Unit +} diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/Suggestion.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/Suggestion.scala index 8970d28c792..deefab04f58 100644 --- a/lib/scala/searcher/src/main/scala/org/enso/searcher/Suggestion.scala +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/Suggestion.scala @@ -4,6 +4,23 @@ package org.enso.searcher sealed trait Suggestion object Suggestion { + /** The type of a suggestion. */ + sealed trait Kind + object Kind { + + /** The atom suggestion. */ + case object Atom extends Kind + + /** The method suggestion. */ + case object Method extends Kind + + /** The function suggestion. */ + case object Function extends Kind + + /** The suggestion of a local value. */ + case object Local extends Kind + } + /** An argument of an atom or a function. * * @param name the argument name diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionEntry.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionEntry.scala new file mode 100644 index 00000000000..c184f41f410 --- /dev/null +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionEntry.scala @@ -0,0 +1,8 @@ +package org.enso.searcher + +/** The entry in the suggestions database. + * + * @param id the suggestion id + * @param suggestion the suggestion + */ +case class SuggestionEntry(id: Long, suggestion: Suggestion) diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionsRepo.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionsRepo.scala index c7e049597da..e77ca27511e 100644 --- a/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionsRepo.scala +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/SuggestionsRepo.scala @@ -1,16 +1,35 @@ -package org.enso.searcher.sql - -import org.enso.searcher.Suggestion +package org.enso.searcher /** The object for accessing the suggestions database. */ trait SuggestionsRepo[F[_]] { - /** Find suggestions by the return type. + /** Initialize the repo. */ + def init: F[Unit] + + /** Clean the repo. */ + def clean: F[Unit] + + /** Get current version of the repo. */ + def currentVersion: F[Long] + + /** Get all suggestions. * - * @param returnType the return type of a suggestion - * @return the list of suggestions + * @return the current database version and the list of suggestions */ - def findBy(returnType: String): F[Seq[Suggestion]] + def getAll: F[(Long, Seq[SuggestionEntry])] + + /** Search suggestion by various parameters. + * + * @param selfType the selfType search parameter + * @param returnType the returnType search parameter + * @param kinds the list suggestion kinds to search + * @return the current database version and the list of found suggestion ids + */ + def search( + selfType: Option[String], + returnType: Option[String], + kinds: Option[Seq[Suggestion.Kind]] + ): F[(Long, Seq[Long])] /** Select the suggestion by id. * @@ -24,5 +43,26 @@ trait SuggestionsRepo[F[_]] { * @param suggestion the suggestion to insert * @return the id of an inserted suggestion */ - def insert(suggestion: Suggestion): F[Long] + def insert(suggestion: Suggestion): F[Option[Long]] + + /** Insert a list of suggestions + * + * @param suggestions the suggestions to insert + * @return the current database version and a list of inserted suggestion ids + */ + def insertAll(suggestions: Seq[Suggestion]): F[(Long, Seq[Option[Long]])] + + /** Remove the suggestion. + * + * @param suggestion the suggestion to remove + * @return the id of removed suggestion + */ + def remove(suggestion: Suggestion): F[Option[Long]] + + /** Remove a list of suggestions. + * + * @param suggestions the suggestions to remove + * @return the current database version and a list of removed suggestion ids + */ + def removeAll(suggestions: Seq[Suggestion]): F[(Long, Seq[Option[Long]])] } diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlDatabase.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlDatabase.scala new file mode 100644 index 00000000000..11c017edfd7 --- /dev/null +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlDatabase.scala @@ -0,0 +1,58 @@ +package org.enso.searcher.sql + +import com.typesafe.config.{Config, ConfigFactory} +import org.enso.searcher.Database +import slick.dbio.DBIO +import slick.jdbc.SQLiteProfile +import slick.jdbc.SQLiteProfile.api._ + +import scala.concurrent.Future + +/** Ths SQL database that runs Slick [[DBIO]] queries resulting in a [[Future]]. + * + * @param config the configuration + */ +final private[sql] class SqlDatabase(config: Option[Config] = None) + extends Database[DBIO, Future] { + + val db = SQLiteProfile.backend.Database + .forConfig(SqlDatabase.configPath, config.orNull) + + /** @inheritdoc */ + override def run[A](query: DBIO[A]): Future[A] = + db.run(query) + + /** @inheritdoc */ + override def transaction[A](query: DBIO[A]): Future[A] = + db.run(query.transactionally) + + /** @inheritdoc */ + override def close(): Unit = + db.close() +} + +object SqlDatabase { + + private val configPath: String = + "searcher.db" + + /** Create [[SqlDatabase]] instance. + * + * @param filename the database file path + * @return new sql database instance + */ + def apply(filename: String): SqlDatabase = { + val config = ConfigFactory + .parseString(s"""$configPath.url = "${jdbcUrl(filename)}"""") + .withFallback(ConfigFactory.load()) + new SqlDatabase(Some(config)) + } + + /** Create JDBC URL from the file path. */ + private def jdbcUrl(filename: String): String = + s"jdbc:sqlite:${escapePath(filename)}" + + /** Escape Windows path. */ + private def escapePath(path: String): String = + path.replace("\\", "\\\\") +} diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala index 1efdbcfed8d..b7d9370c7be 100644 --- a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/SqlSuggestionsRepo.scala @@ -1,53 +1,265 @@ package org.enso.searcher.sql -import org.enso.searcher.Suggestion +import java.nio.file.Path + +import org.enso.searcher.{Suggestion, SuggestionEntry, SuggestionsRepo} import slick.jdbc.SQLiteProfile.api._ -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} /** The object for accessing the suggestions database. */ -final class SqlSuggestionsRepo(implicit ec: ExecutionContext) - extends SuggestionsRepo[DBIO] { +final class SqlSuggestionsRepo private (db: SqlDatabase)(implicit + ec: ExecutionContext +) extends SuggestionsRepo[Future] { /** The query returning the arguments joined with the corresponding - * suggestions. */ + * suggestions. + */ private val joined: Query[ (Rep[Option[ArgumentsTable]], SuggestionsTable), (Option[ArgumentRow], SuggestionRow), Seq ] = - arguments - .joinRight(suggestions) + Arguments + .joinRight(Suggestions) .on(_.suggestionId === _.id) - /** @inheritdoc **/ - override def findBy(returnType: String): DBIO[Seq[Suggestion]] = { + /** @inheritdoc */ + override def init: Future[Unit] = + db.run(initQuery) + + /** @inheritdoc */ + override def clean: Future[Unit] = + db.run(cleanQuery) + + /** @inheritdoc */ + override def getAll: Future[(Long, Seq[SuggestionEntry])] = + db.run(getAllQuery) + + /** @inheritdoc */ + override def search( + selfType: Option[String], + returnType: Option[String], + kinds: Option[Seq[Suggestion.Kind]] + ): Future[(Long, Seq[Long])] = + db.run(searchQuery(selfType, returnType, kinds)) + + /** @inheritdoc */ + override def select(id: Long): Future[Option[Suggestion]] = + db.run(selectQuery(id)) + + /** @inheritdoc */ + override def insert(suggestion: Suggestion): Future[Option[Long]] = + db.run(insertQuery(suggestion)) + + /** @inheritdoc */ + override def insertAll( + suggestions: Seq[Suggestion] + ): Future[(Long, Seq[Option[Long]])] = + db.run(insertAllQuery(suggestions)) + + /** @inheritdoc */ + override def remove(suggestion: Suggestion): Future[Option[Long]] = + db.run(removeQuery(suggestion)) + + /** @inheritdoc */ + override def removeAll( + suggestions: Seq[Suggestion] + ): Future[(Long, Seq[Option[Long]])] = + db.run(removeAllQuery(suggestions)) + + /** @inheritdoc */ + override def currentVersion: Future[Long] = + db.run(currentVersionQuery) + + /** Close the database. */ + def close(): Unit = + db.close() + + /** Insert suggestions in a batch. */ + private[sql] def insertBatch(suggestions: Array[Suggestion]): Future[Int] = + db.run(insertBatchQuery(suggestions)) + + /** The query to initialize the repo. */ + private def initQuery: DBIO[Unit] = + (Suggestions.schema ++ Arguments.schema ++ Versions.schema).createIfNotExists + + /** The query to clean the repo. */ + private def cleanQuery: DBIO[Unit] = + for { + _ <- Suggestions.delete + _ <- Arguments.delete + _ <- Versions.delete + } yield () + + /** Get all suggestions. */ + private def getAllQuery: DBIO[(Long, Seq[SuggestionEntry])] = { val query = for { - (argument, suggestion) <- joined - if suggestion.returnType === returnType - } yield (argument, suggestion) - query.result.map(joinedToSuggestion) + suggestions <- joined.result.map(joinedToSuggestionEntries) + version <- currentVersionQuery + } yield (version, suggestions) + query.transactionally } - /** @inheritdoc **/ - override def select(id: Long): DBIO[Option[Suggestion]] = { + /** The query to search suggestion by various parameters. + * + * @param selfType the selfType search parameter + * @param returnType the returnType search parameter + * @param kinds the list suggestion kinds to search + * @return the list of suggestion ids + */ + private def searchQuery( + selfType: Option[String], + returnType: Option[String], + kinds: Option[Seq[Suggestion.Kind]] + ): DBIO[(Long, Seq[Long])] = { + val searchAction = + if (selfType.isEmpty && returnType.isEmpty && kinds.isEmpty) { + DBIO.successful(Seq()) + } else { + val query = searchQueryBuilder(selfType, returnType, kinds).map(_.id) + query.result + } + val query = for { + results <- searchAction + version <- currentVersionQuery + } yield (version, results) + query.transactionally + } + + /** The query to select the suggestion by id. + * + * @param id the id of a suggestion + * @return return the suggestion + */ + private def selectQuery(id: Long): DBIO[Option[Suggestion]] = { val query = for { (argument, suggestion) <- joined if suggestion.id === id } yield (argument, suggestion) - query.result.map(coll => joinedToSuggestion(coll).headOption) + query.result.map(coll => joinedToSuggestions(coll).headOption) } - /** @inheritdoc **/ - override def insert(suggestion: Suggestion): DBIO[Long] = { + /** The query to insert the suggestion + * + * @param suggestion the suggestion to insert + * @return the id of an inserted suggestion + */ + private def insertQuery(suggestion: Suggestion): DBIO[Option[Long]] = { val (suggestionRow, args) = toSuggestionRow(suggestion) - for { - id <- suggestions.returning(suggestions.map(_.id)) += suggestionRow - _ <- arguments ++= args.map(toArgumentRow(id, _)) + val query = for { + id <- Suggestions.returning(Suggestions.map(_.id)) += suggestionRow + _ <- Arguments ++= args.zipWithIndex.map { + case (argument, ix) => toArgumentRow(id, ix, argument) + } + _ <- incrementVersionQuery } yield id + query.transactionally.asTry.map { + case Failure(_) => None + case Success(id) => Some(id) + } } - private def joinedToSuggestion( + /** The query to insert a list of suggestions + * + * @param suggestions the suggestions to insert + * @return the list of inserted suggestion ids + */ + private def insertAllQuery( + suggestions: Seq[Suggestion] + ): DBIO[(Long, Seq[Option[Long]])] = { + val query = for { + ids <- DBIO.sequence(suggestions.map(insertQuery)) + version <- currentVersionQuery + } yield (version, ids) + query.transactionally + } + + /** The query to remove the suggestion. + * + * @param suggestion the suggestion to remove + * @return the id of removed suggestion + */ + private def removeQuery(suggestion: Suggestion): DBIO[Option[Long]] = { + val (raw, _) = toSuggestionRow(suggestion) + val selectQuery = Suggestions + .filter(_.kind === raw.kind) + .filter(_.name === raw.name) + .filter(_.scopeStart === raw.scopeStart) + .filter(_.scopeEnd === raw.scopeEnd) + val deleteQuery = for { + rows <- selectQuery.result + n <- selectQuery.delete + _ <- if (n > 0) incrementVersionQuery else DBIO.successful(()) + } yield rows.flatMap(_.id).headOption + deleteQuery.transactionally + } + + /** The query to remove a list of suggestions. + * + * @param suggestions the suggestions to remove + * @return the list of removed suggestion ids + */ + private def removeAllQuery( + suggestions: Seq[Suggestion] + ): DBIO[(Long, Seq[Option[Long]])] = { + val query = for { + ids <- DBIO.sequence(suggestions.map(removeQuery)) + version <- currentVersionQuery + } yield (version, ids) + query.transactionally + } + + /** The query to get current version of the repo. */ + private def currentVersionQuery: DBIO[Long] = { + for { + versionOpt <- Versions.result.headOption + } yield versionOpt.flatMap(_.id).getOrElse(0L) + } + + /** The query to increment the current version of the repo. */ + private def incrementVersionQuery: DBIO[Long] = { + val increment = for { + version <- Versions.returning(Versions.map(_.id)) += VersionRow(None) + _ <- Versions.filterNot(_.id === version).delete + } yield version + increment.transactionally + } + + /** The query to insert suggestions in a batch. */ + private def insertBatchQuery( + suggestions: Array[Suggestion] + ): DBIO[Int] = { + val rows = suggestions.map(toSuggestionRow) + for { + _ <- (Suggestions ++= rows.map(_._1)).asTry + size <- Suggestions.length.result + } yield size + } + + /** Create a search query by the provided parameters. */ + private def searchQueryBuilder( + selfType: Option[String], + returnType: Option[String], + kinds: Option[Seq[Suggestion.Kind]] + ): Query[SuggestionsTable, SuggestionRow, Seq] = { + Suggestions + .filterOpt(selfType) { + case (row, value) => row.selfType === value + } + .filterOpt(returnType) { + case (row, value) => row.returnType === value + } + .filterOpt(kinds) { + case (row, value) => row.kind inSet value.map(SuggestionKind(_)) + } + } + + /** Convert the rows of suggestions joined with arguments to a list of + * suggestions. + */ + private def joinedToSuggestions( coll: Seq[(Option[ArgumentRow], SuggestionRow)] ): Seq[Suggestion] = { coll @@ -58,6 +270,21 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) .toSeq } + /** Convert the rows of suggestions joined with arguments to a list of + * suggestion entries. + */ + private def joinedToSuggestionEntries( + coll: Seq[(Option[ArgumentRow], SuggestionRow)] + ): Seq[SuggestionEntry] = { + coll + .groupBy(_._2) + .view + .mapValues(_.flatMap(_._1)) + .map(Function.tupled(toSuggestionEntry)) + .toSeq + } + + /** Convert the suggestion to a row in the suggestions table. */ private def toSuggestionRow( suggestion: Suggestion ): (SuggestionRow, Seq[Suggestion.Argument]) = @@ -67,11 +294,11 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) id = None, kind = SuggestionKind.ATOM, name = name, - selfType = None, + selfType = SelfTypeColumn.EMPTY, returnType = returnType, documentation = doc, - scopeStart = None, - scopeEnd = None + scopeStart = ScopeColumn.EMPTY, + scopeEnd = ScopeColumn.EMPTY ) row -> args case Suggestion.Method(name, args, selfType, returnType, doc) => @@ -79,11 +306,11 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) id = None, kind = SuggestionKind.METHOD, name = name, - selfType = Some(selfType), + selfType = selfType, returnType = returnType, documentation = doc, - scopeStart = None, - scopeEnd = None + scopeStart = ScopeColumn.EMPTY, + scopeEnd = ScopeColumn.EMPTY ) row -> args case Suggestion.Function(name, args, returnType, scope) => @@ -91,11 +318,11 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) id = None, kind = SuggestionKind.FUNCTION, name = name, - selfType = None, + selfType = SelfTypeColumn.EMPTY, returnType = returnType, documentation = None, - scopeStart = Some(scope.start), - scopeEnd = Some(scope.end) + scopeStart = scope.start, + scopeEnd = scope.end ) row -> args case Suggestion.Local(name, returnType, scope) => @@ -103,22 +330,25 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) id = None, kind = SuggestionKind.LOCAL, name = name, - selfType = None, + selfType = SelfTypeColumn.EMPTY, returnType = returnType, documentation = None, - scopeStart = Some(scope.start), - scopeEnd = Some(scope.end) + scopeStart = scope.start, + scopeEnd = scope.end ) row -> Seq() } + /** Convert the argument to a row in the arguments table. */ private def toArgumentRow( suggestionId: Long, + index: Int, argument: Suggestion.Argument ): ArgumentRow = ArgumentRow( id = None, suggestionId = suggestionId, + index = index, name = argument.name, tpe = argument.reprType, isSuspended = argument.isSuspended, @@ -126,6 +356,14 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) defaultValue = argument.defaultValue ) + /** Convert the database rows to a suggestion entry. */ + private def toSuggestionEntry( + suggestion: SuggestionRow, + arguments: Seq[ArgumentRow] + ): SuggestionEntry = + SuggestionEntry(suggestion.id.get, toSuggestion(suggestion, arguments)) + + /** Convert the databaes rows to a suggestion. */ private def toSuggestion( suggestion: SuggestionRow, arguments: Seq[ArgumentRow] @@ -134,38 +372,36 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) case SuggestionKind.ATOM => Suggestion.Atom( name = suggestion.name, - arguments = arguments.map(toArgument), + arguments = arguments.sortBy(_.index).map(toArgument), returnType = suggestion.returnType, documentation = suggestion.documentation ) case SuggestionKind.METHOD => Suggestion.Method( name = suggestion.name, - arguments = arguments.map(toArgument), - selfType = suggestion.selfType.get, + arguments = arguments.sortBy(_.index).map(toArgument), + selfType = suggestion.selfType, returnType = suggestion.returnType, documentation = suggestion.documentation ) case SuggestionKind.FUNCTION => Suggestion.Function( name = suggestion.name, - arguments = arguments.map(toArgument), + arguments = arguments.sortBy(_.index).map(toArgument), returnType = suggestion.returnType, - scope = - Suggestion.Scope(suggestion.scopeStart.get, suggestion.scopeEnd.get) + scope = Suggestion.Scope(suggestion.scopeStart, suggestion.scopeEnd) ) case SuggestionKind.LOCAL => Suggestion.Local( name = suggestion.name, returnType = suggestion.returnType, - scope = - Suggestion.Scope(suggestion.scopeStart.get, suggestion.scopeEnd.get) + scope = Suggestion.Scope(suggestion.scopeStart, suggestion.scopeEnd) ) - case k => throw new NoSuchElementException(s"Unknown suggestion kind: $k") } + /** Convert the database row to the suggestion argument. */ private def toArgument(row: ArgumentRow): Suggestion.Argument = Suggestion.Argument( name = row.name, @@ -175,3 +411,23 @@ final class SqlSuggestionsRepo(implicit ec: ExecutionContext) defaultValue = row.defaultValue ) } + +object SqlSuggestionsRepo { + + /** Create the suggestions repo. + * + * @return the suggestions repo backed up by SQL database. + */ + def apply()(implicit ec: ExecutionContext): SqlSuggestionsRepo = { + new SqlSuggestionsRepo(new SqlDatabase()) + } + + /** Create the suggestions repo. + * + * @param path the path to the database file. + * @return the suggestions repo backed up by SQL database. + */ + def apply(path: Path)(implicit ec: ExecutionContext): SqlSuggestionsRepo = { + new SqlSuggestionsRepo(SqlDatabase(path.toString)) + } +} diff --git a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/Tables.scala b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/Tables.scala index 8da773e6beb..2c7371ab1ac 100644 --- a/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/Tables.scala +++ b/lib/scala/searcher/src/main/scala/org/enso/searcher/sql/Tables.scala @@ -1,5 +1,6 @@ package org.enso.searcher.sql +import org.enso.searcher.Suggestion import slick.jdbc.SQLiteProfile.api._ import scala.annotation.nowarn @@ -8,6 +9,7 @@ import scala.annotation.nowarn * * @param id the id of an argument * @param suggestionId the id of the suggestion + * @param index the argument position in the arguments list * @param name the argument name * @param tpe the argument type * @param isSuspended is the argument lazy @@ -17,6 +19,7 @@ import scala.annotation.nowarn case class ArgumentRow( id: Option[Long], suggestionId: Long, + index: Int, name: String, tpe: String, isSuspended: Boolean, @@ -39,13 +42,19 @@ case class SuggestionRow( id: Option[Long], kind: Byte, name: String, - selfType: Option[String], + selfType: String, returnType: String, documentation: Option[String], - scopeStart: Option[Int], - scopeEnd: Option[Int] + scopeStart: Int, + scopeEnd: Int ) +/** A row in the versions table. + * + * @param id the row id + */ +case class VersionRow(id: Option[Long]) + /** The type of a suggestion. */ object SuggestionKind { @@ -53,6 +62,31 @@ object SuggestionKind { val METHOD: Byte = 1 val FUNCTION: Byte = 2 val LOCAL: Byte = 3 + + /** Create a database suggestion kind. + * + * @param kind the suggestion kind + * @return the representation of the suggestion kind in the database + */ + def apply(kind: Suggestion.Kind): Byte = + kind match { + case Suggestion.Kind.Atom => ATOM + case Suggestion.Kind.Method => METHOD + case Suggestion.Kind.Function => FUNCTION + case Suggestion.Kind.Local => LOCAL + } +} + +object ScopeColumn { + + /** A constant representing an empty value in the scope column. */ + val EMPTY: Int = -1 +} + +object SelfTypeColumn { + + /** A constant representing en empty value in the self type column. */ + val EMPTY: String = "\u0500" } /** The schema of the arguments table. */ @@ -62,17 +96,27 @@ final class ArgumentsTable(tag: Tag) def id = column[Long]("id", O.PrimaryKey, O.AutoInc) def suggestionId = column[Long]("suggestion_id") + def index = column[Int]("index") def name = column[String]("name") def tpe = column[String]("type") def isSuspended = column[Boolean]("is_suspended", O.Default(false)) def hasDefault = column[Boolean]("has_default", O.Default(false)) def defaultValue = column[Option[String]]("default_value") def * = - (id.?, suggestionId, name, tpe, isSuspended, hasDefault, defaultValue) <> + ( + id.?, + suggestionId, + index, + name, + tpe, + isSuspended, + hasDefault, + defaultValue + ) <> (ArgumentRow.tupled, ArgumentRow.unapply) def suggestion = - foreignKey("suggestion_fk", suggestionId, suggestions)( + foreignKey("suggestion_fk", suggestionId, Suggestions)( _.id, onUpdate = ForeignKeyAction.Restrict, onDelete = ForeignKeyAction.Cascade @@ -87,11 +131,11 @@ final class SuggestionsTable(tag: Tag) def id = column[Long]("id", O.PrimaryKey, O.AutoInc) def kind = column[Byte]("kind") def name = column[String]("name") - def selfType = column[Option[String]]("self_type") + def selfType = column[String]("self_type") def returnType = column[String]("return_type") def documentation = column[Option[String]]("documentation") - def scopeStart = column[Option[Int]]("scope_start") - def scopeEnd = column[Option[Int]]("scope_end") + def scopeStart = column[Int]("scope_start", O.Default(ScopeColumn.EMPTY)) + def scopeEnd = column[Int]("scope_end", O.Default(ScopeColumn.EMPTY)) def * = ( id.?, @@ -105,10 +149,30 @@ final class SuggestionsTable(tag: Tag) ) <> (SuggestionRow.tupled, SuggestionRow.unapply) - def selfTypeIdx = index("self_type_idx", selfType) - def returnTypeIdx = index("return_type_idx", name) + def selfTypeIdx = index("suggestions_self_type_idx", selfType) + def returnTypeIdx = index("suggestions_return_type_idx", returnType) + def name_idx = index("suggestions_name_idx", name) + // NOTE: unique index should not contain nullable columns because SQLite + // teats NULLs as distinct values. + def uniqueIdx = + index( + "suggestion_unique_idx", + (kind, name, selfType, scopeStart, scopeEnd), + unique = true + ) } -object arguments extends TableQuery(new ArgumentsTable(_)) +/** The schema of the versions table. */ +@nowarn("msg=multiarg infix syntax") +final class VersionsTable(tag: Tag) extends Table[VersionRow](tag, "version") { -object suggestions extends TableQuery(new SuggestionsTable(_)) + def id = column[Long]("id", O.PrimaryKey, O.AutoInc) + + def * = id.? <> (VersionRow.apply, VersionRow.unapply) +} + +object Arguments extends TableQuery(new ArgumentsTable(_)) + +object Suggestions extends TableQuery(new SuggestionsTable(_)) + +object Versions extends TableQuery(new VersionsTable(_)) diff --git a/lib/scala/searcher/src/test/resources/application.conf b/lib/scala/searcher/src/test/resources/application.conf deleted file mode 100644 index f7d8aab96ae..00000000000 --- a/lib/scala/searcher/src/test/resources/application.conf +++ /dev/null @@ -1,6 +0,0 @@ -searcher.db { - url = "jdbc:sqlite:file::memory:?cache=shared" -} -akka { - loglevel = "ERROR" -} diff --git a/lib/scala/searcher/src/test/resources/logback-test.xml b/lib/scala/searcher/src/test/resources/logback-test.xml index 433debfaba7..6774939257b 100644 --- a/lib/scala/searcher/src/test/resources/logback-test.xml +++ b/lib/scala/searcher/src/test/resources/logback-test.xml @@ -8,6 +8,9 @@ + + + diff --git a/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala b/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala index 991d0cb2e92..c2b893845b2 100644 --- a/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala +++ b/lib/scala/searcher/src/test/scala/org/enso/searcher/sql/SuggestionsRepoTest.scala @@ -1,11 +1,9 @@ package org.enso.searcher.sql -import org.enso.jsonrpc.test.FlakySpec import org.enso.searcher.Suggestion -import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import slick.jdbc.SQLiteProfile.api._ +import org.scalatest.{BeforeAndAfter, BeforeAndAfterAll} import scala.concurrent.Await import scala.concurrent.ExecutionContext.Implicits.global @@ -13,51 +11,279 @@ import scala.concurrent.duration._ class SuggestionsRepoTest extends AnyWordSpec - with FlakySpec with Matchers + with BeforeAndAfter with BeforeAndAfterAll { - val Timeout: FiniteDuration = 3.seconds + val Timeout: FiniteDuration = 10.seconds - val db = Database.forConfig("searcher.db") - val repo = new SqlSuggestionsRepo() + val repo = SqlSuggestionsRepo() override def beforeAll(): Unit = { - Await.ready( - db.run((suggestions.schema ++ arguments.schema).createIfNotExists), - Timeout - ) + Await.ready(repo.init, Timeout) } override def afterAll(): Unit = { - db.close() + repo.close() } - "SuggestionsDBIO" should { + before { + Await.ready(repo.clean, Timeout) + } - "select suggestion by id" taggedAs Flaky in { + "SuggestionsRepo" should { + + "get all suggestions" in { val action = for { - id <- db.run(repo.insert(suggestion.atom)) - res <- db.run(repo.select(id)) + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + all <- repo.getAll + } yield all._2 + + val suggestions = Await.result(action, Timeout).map(_.suggestion) + suggestions should contain theSameElementsAs Seq( + suggestion.atom, + suggestion.method, + suggestion.function, + suggestion.local + ) + } + + "fail to insert duplicate suggestion" in { + val action = + for { + id1 <- repo.insert(suggestion.atom) + id2 <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + _ <- repo.insert(suggestion.local) + all <- repo.getAll + } yield (id1, id2, all._2) + + val (id1, id2, all) = Await.result(action, Timeout) + id1 shouldBe a[Some[_]] + id2 shouldBe a[None.type] + all.map(_.suggestion) should contain theSameElementsAs Seq( + suggestion.atom, + suggestion.method, + suggestion.function, + suggestion.local + ) + } + + "fail to insertAll duplicate suggestion" in { + val action = + for { + (v1, ids) <- repo.insertAll(Seq(suggestion.local, suggestion.local)) + (v2, all) <- repo.getAll + } yield (v1, v2, ids, all) + + val (v1, v2, ids, all) = Await.result(action, Timeout) + v1 shouldEqual v2 + ids.flatten.length shouldEqual 1 + all.map(_.suggestion) should contain theSameElementsAs Seq( + suggestion.local + ) + } + + "select suggestion by id" in { + val action = + for { + Some(id) <- repo.insert(suggestion.atom) + res <- repo.select(id) } yield res Await.result(action, Timeout) shouldEqual Some(suggestion.atom) } - "find suggestion by returnType" taggedAs Flaky in { + "remove suggestion" in { val action = for { - _ <- db.run(repo.insert(suggestion.local)) - _ <- db.run(repo.insert(suggestion.method)) - _ <- db.run(repo.insert(suggestion.function)) - res <- db.run(repo.findBy("MyType")) - } yield res + id1 <- repo.insert(suggestion.atom) + id2 <- repo.remove(suggestion.atom) + } yield (id1, id2) - Await.result(action, Timeout) should contain theSameElementsAs Seq( - suggestion.local, - suggestion.function + val (id1, id2) = Await.result(action, Timeout) + id1 shouldEqual id2 + } + + "get version" in { + val action = repo.currentVersion + + Await.result(action, Timeout) shouldEqual 0L + } + + "change version after insert" in { + val action = for { + v1 <- repo.currentVersion + _ <- repo.insert(suggestion.atom) + v2 <- repo.currentVersion + } yield (v1, v2) + + val (v1, v2) = Await.result(action, Timeout) + v1 should not equal v2 + } + + "not change version after failed insert" in { + val action = for { + v1 <- repo.currentVersion + _ <- repo.insert(suggestion.atom) + v2 <- repo.currentVersion + _ <- repo.insert(suggestion.atom) + v3 <- repo.currentVersion + } yield (v1, v2, v3) + + val (v1, v2, v3) = Await.result(action, Timeout) + v1 should not equal v2 + v2 shouldEqual v3 + } + + "change version after remove" in { + val action = for { + v1 <- repo.currentVersion + _ <- repo.insert(suggestion.local) + v2 <- repo.currentVersion + _ <- repo.remove(suggestion.local) + v3 <- repo.currentVersion + } yield (v1, v2, v3) + + val (v1, v2, v3) = Await.result(action, Timeout) + v1 should not equal v2 + v2 should not equal v3 + } + + "not change version after failed remove" in { + val action = for { + v1 <- repo.currentVersion + _ <- repo.insert(suggestion.local) + v2 <- repo.currentVersion + _ <- repo.remove(suggestion.local) + v3 <- repo.currentVersion + _ <- repo.remove(suggestion.local) + v4 <- repo.currentVersion + } yield (v1, v2, v3, v4) + + val (v1, v2, v3, v4) = Await.result(action, Timeout) + v1 should not equal v2 + v2 should not equal v3 + v3 shouldEqual v4 + } + + "search suggestion by empty query" in { + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + res <- repo.search(None, None, None) + } yield res._2 + + val res = Await.result(action, Timeout) + res.isEmpty shouldEqual true + } + + "search suggestion by self type" in { + val action = for { + _ <- repo.insert(suggestion.atom) + id2 <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + res <- repo.search(Some("Main"), None, None) + } yield (id2, res._2) + + val (id, res) = Await.result(action, Timeout) + res should contain theSameElementsAs Seq(id).flatten + } + + "search suggestion by return type" in { + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + id3 <- repo.insert(suggestion.function) + id4 <- repo.insert(suggestion.local) + res <- repo.search(None, Some("MyType"), None) + } yield (id3, id4, res._2) + + val (id1, id2, res) = Await.result(action, Timeout) + res should contain theSameElementsAs Seq(id1, id2).flatten + } + + "search suggestion by kind" in { + val kinds = Seq(Suggestion.Kind.Atom, Suggestion.Kind.Local) + val action = for { + id1 <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + id4 <- repo.insert(suggestion.local) + res <- repo.search(None, None, Some(kinds)) + } yield (id1, id4, res._2) + + val (id1, id2, res) = Await.result(action, Timeout) + res should contain theSameElementsAs Seq(id1, id2).flatten + } + + "search suggestion by empty kinds" in { + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + res <- repo.search(None, None, Some(Seq())) + } yield res._2 + + val res = Await.result(action, Timeout) + res.isEmpty shouldEqual true + } + + "search suggestion by return type and kind" in { + val kinds = Seq(Suggestion.Kind.Atom, Suggestion.Kind.Local) + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + id4 <- repo.insert(suggestion.local) + res <- repo.search(None, Some("MyType"), Some(kinds)) + } yield (id4, res._2) + + val (id, res) = Await.result(action, Timeout) + res should contain theSameElementsAs Seq(id).flatten + } + + "search suggestion by self and return types" in { + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + res <- repo.search(Some("Main"), Some("MyType"), None) + } yield res._2 + + val res = Await.result(action, Timeout) + res.isEmpty shouldEqual true + } + + "search suggestion by all parameters" in { + val kinds = Seq( + Suggestion.Kind.Atom, + Suggestion.Kind.Method, + Suggestion.Kind.Function ) + val action = for { + _ <- repo.insert(suggestion.atom) + _ <- repo.insert(suggestion.method) + _ <- repo.insert(suggestion.function) + _ <- repo.insert(suggestion.local) + res <- repo.search(Some("Main"), Some("MyType"), Some(kinds)) + } yield res._2 + + val res = Await.result(action, Timeout) + res.isEmpty shouldEqual true } }