Skip to content

Commit

Permalink
Feat/search (#856)
Browse files Browse the repository at this point in the history
Searchbar for one or more tenant.

Delete project search bar from tenant page.

---------

Co-authored-by: HelaKaraa <[email protected]>
Co-authored-by: baudelotphilippe <[email protected]>
  • Loading branch information
3 people authored Aug 4, 2024
1 parent c321007 commit 2f09781
Show file tree
Hide file tree
Showing 29 changed files with 1,045 additions and 77 deletions.
1 change: 1 addition & 0 deletions app/fr/maif/izanami/application.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class IzanamiComponentsInstances(
lazy val eventController = wire[EventController]
lazy val webhookController = wire[WebhookController]
lazy val frontendController = wire[FrontendController]
lazy val searchController = wire[SearchController]

override lazy val assets: Assets = wire[Assets]
lazy val router: Router = {
Expand Down
14 changes: 13 additions & 1 deletion app/fr/maif/izanami/datastores/FeatureContextDatastore.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fr.maif.izanami.env.Env
import fr.maif.izanami.env.PostgresqlErrors.{FOREIGN_KEY_VIOLATION, RELATION_DOES_NOT_EXISTS, UNIQUE_VIOLATION}
import fr.maif.izanami.env.pgimplicits.EnhancedRow
import fr.maif.izanami.errors._
import fr.maif.izanami.events.{FeatureUpdated, SourceFeatureUpdated}
import fr.maif.izanami.events.{SourceFeatureUpdated}
import fr.maif.izanami.models.Feature.{activationConditionRead, activationConditionWrite}
import fr.maif.izanami.models.FeatureContext.generateSubContextId
import fr.maif.izanami.models._
Expand Down Expand Up @@ -545,6 +545,18 @@ class FeatureContextDatastore(val env: Env) extends Datastore {
}
}
}

def findLocalContexts(tenant: String, idCandidates: Set[String]): Future[List[String]] = {
env.postgresql.queryAll(
s"""
|SELECT id
|FROM feature_contexts
|WHERE id=ANY($$1)
|""".stripMargin,
List(idCandidates.toArray),
schemas=Set(tenant)
){r => r.optString("id")}
}
}

object FeatureContextDatastore {
Expand Down
152 changes: 152 additions & 0 deletions app/fr/maif/izanami/datastores/SearchDatastore.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package fr.maif.izanami.datastores

import fr.maif.izanami.env.Env
import fr.maif.izanami.env.pgimplicits.EnhancedRow
import fr.maif.izanami.utils.Datastore
import play.api.libs.json.{JsObject}

import scala.concurrent.Future

class SearchDatastore(val env: Env) extends Datastore {
def tenantSearch(tenant: String, username: String, query: String): Future[List[(String, JsObject, Double)]] = {
env.postgresql.queryAll(
s"""
|WITH scored_projects AS (
| SELECT
| p.name,
| p.description,
| izanami.SIMILARITY(p.name, $$1) as name_score,
| izanami.SIMILARITY(p.description, $$1) as description_score
| FROM projects p
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| LEFT JOIN users_projects_rights upr ON (utr.username=$$2 AND p.name=upr.project)
| WHERE utr.level='ADMIN'
| OR upr.level is not null
| OR u.admin=true
|), scored_features AS (
| SELECT
| f.project,
| f.name,
| f.description,
| izanami.SIMILARITY(f.name, $$1) as name_score,
| izanami.SIMILARITY(f.description, $$1) as description_score
| FROM scored_projects p, features f
| WHERE f.project=p.name
|), scored_keys AS (
| SELECT
| k.name,
| k.description,
| izanami.SIMILARITY(k.name, $$1) as name_score,
| izanami.SIMILARITY(k.description, $$1) as description_score
| FROM apikeys k
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| LEFT JOIN users_keys_rights ukr ON (utr.username=$$2 AND k.name=ukr.apikey)
| WHERE utr.level='ADMIN'
| OR ukr.level is not null
| OR u.admin=true
|), scored_tags AS (
| SELECT
| t.name,
| t.description,
| izanami.SIMILARITY(t.name, $$1) as name_score,
| izanami.SIMILARITY(t.description, $$1) as description_score
| FROM tags t
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| WHERE utr.level IS NOT NULL
| OR u.admin=true
|), scored_scripts AS (
| SELECT
| s.id as name,
| izanami.SIMILARITY(s.id, $$1) as name_score
| FROM wasm_script_configurations s
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| WHERE utr.level IS NOT NULL
| OR u.admin=true
| ), scored_global_contexts AS (
| SELECT
| c.parent,
| c.name as name,
| izanami.SIMILARITY(c.name, $$1) as name_score
| FROM global_feature_contexts c
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| WHERE utr.level IS NOT NULL
| OR u.admin=true
| ), scored_local_contexts AS (
| SELECT
| c.parent,
| c.global_parent,
| c.project,
| c.name as name,
| izanami.SIMILARITY(c.name, $$1) as name_score
| FROM feature_contexts c
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| WHERE utr.level IS NOT NULL
| OR u.admin=true
| ), scored_webhooks AS (
| SELECT
| w.name,
| w.description,
| izanami.SIMILARITY(w.name, $$1) as name_score,
| izanami.SIMILARITY(w.description, $$1) as description_score
| FROM webhooks w
| LEFT JOIN izanami.users u ON u.username=$$2
| LEFT JOIN izanami.users_tenants_rights utr ON (utr.username=$$2 AND utr.tenant=$$3)
| LEFT JOIN users_webhooks_rights uwr ON (utr.username=$$2 AND w.name=uwr.webhook)
| WHERE utr.level='ADMIN'
| OR uwr.level is not null
| OR u.admin=true
| )
|SELECT row_to_json(f.*) as json, GREATEST(f.name_score, f.description_score) AS match_score, 'feature' as _type, $$3 as tenant
|FROM scored_features f
|WHERE f.name_score > 0.2 OR f.description_score > 0.2
|UNION ALL
|SELECT row_to_json(p.*) as json, GREATEST(p.name_score, p.description_score) AS match_score, 'project' as _type, $$3 as tenant
|FROM scored_projects p
|WHERE p.name_score > 0.2 OR p.description_score > 0.2
|UNION ALL
|SELECT row_to_json(k.*) as json, GREATEST(k.name_score, k.description_score) AS match_score, 'key' as _type, $$3 as tenant
|FROM scored_keys k
|WHERE k.name_score > 0.2 OR k.description_score > 0.2
|UNION ALL
|SELECT row_to_json(t.*) as json, GREATEST(t.name_score, t.description_score) AS match_score, 'tag' as _type, $$3 as tenant
|FROM scored_tags t
|WHERE t.name_score > 0.2 OR t.description_score > 0.2
|UNION ALL
|SELECT row_to_json(s.*) as json, s.name_score AS match_score, 'script' as _type, $$3 as tenant
|FROM scored_scripts s
|WHERE s.name_score > 0.2
|UNION ALL
|SELECT row_to_json(gc.*) as json, gc.name_score AS match_score, 'global_context' as _type, $$3 as tenant
|FROM scored_global_contexts gc
|WHERE gc.name_score > 0.2
|UNION ALL
|SELECT row_to_json(lc.*) as json, lc.name_score AS match_score, 'local_context' as _type, $$3 as tenant
|FROM scored_local_contexts lc
|WHERE lc.name_score > 0.2
|UNION ALL
|SELECT row_to_json(w.*) as json, GREATEST(w.name_score, w.description_score) AS match_score, 'webhook' as _type, $$3 as tenant
|FROM scored_webhooks w
|WHERE w.name_score > 0.2 OR w.description_score > 0.2
|ORDER BY match_score DESC LIMIT 10
|""".stripMargin,
List(query, username, tenant),
schemas = Set(tenant)
) { r =>
{
for (
t <- r.optString("_type");
json <- r.optJsObject("json");
score <- r.optDouble("match_score")
) yield {
(t, json, score)
}
}
}
}
}
1 change: 1 addition & 0 deletions app/fr/maif/izanami/env/env.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Datastores(env: Env) {
val configuration: ConfigurationDatastore = new ConfigurationDatastore(env)
val webhook: WebhooksDatastore = new WebhooksDatastore(env)
val stats: StatsDatastore = new StatsDatastore(env)
val search : SearchDatastore = new SearchDatastore(env)

def onStart(): Future[Unit] = {
for {
Expand Down
188 changes: 188 additions & 0 deletions app/fr/maif/izanami/web/SearchController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package fr.maif.izanami.web

import fr.maif.izanami.env.Env
import fr.maif.izanami.utils.syntax.implicits.{BetterJsValue, BetterSyntax}
import fr.maif.izanami.web.PathElement.pathElementWrite
import play.api.libs.json.{JsObject, Json, Writes}
import play.api.mvc._

import scala.annotation.tailrec
import scala.concurrent.{ExecutionContext, Future}

class SearchController(
val env: Env,
val controllerComponents: ControllerComponents,
val simpleAuthAction: AuthenticatedAction,
val tenantRightAction: TenantRightsAction
) extends BaseController {
implicit val ec: ExecutionContext = env.executionContext

def search(query: String): Action[AnyContent] = tenantRightAction.async {
implicit request: UserRequestWithTenantRights[AnyContent] =>
{
val tenants = request.user.tenantRights.keySet
Future
.sequence(
tenants
.map(tenant =>
env.datastores.search
.tenantSearch(tenant, request.user.username, query)
.map(l =>
l.map(t => {
(t._1, t._2, t._3, tenant)
})
)
)
)
.map(l => l.flatten.toList.sortBy(_._3)(Ordering.Double.TotalOrdering.reverse).take(10))
.flatMap(results => {
Future.sequence(results.map { case (rowType, rowJson, _, tenant) =>
buildPath(rowType, rowJson, tenant)
.map(pathElements => {
val name = (rowJson \ "name").asOpt[String].getOrElse("")
val jsonPath =
Json.toJson(pathElements.prepended(TenantPathElement(tenant)))(Writes.seq(pathElementWrite))
Json.obj(
"type" -> rowType,
"name" -> name,
"path" -> jsonPath,
"tenant" -> tenant
)
})
})
})
.map(r => Ok(Json.toJson(r)))
}
}

def searchForTenant(tenant: String, query: String): Action[AnyContent] = simpleAuthAction.async {
implicit request: UserNameRequest[AnyContent] =>
{
env.datastores.search
.tenantSearch(tenant, request.user, query)
.flatMap(results => {
Future.sequence(results.map { case (rowType, rowJson, _) =>
buildPath(rowType, rowJson, tenant)
.map(pathElements => {
val jsonPath = Json.toJson(pathElements)(Writes.seq(pathElementWrite))
val name = (rowJson \ "name").asOpt[String].getOrElse("")
Json.obj(
"type" -> rowType,
"name" -> name,
"path" -> jsonPath,
"tenant" -> tenant
)
})
})
})
.map(res => Ok(Json.toJson(res)))
}
}

def buildPath(rowType: String, rowJson: JsObject, tenant: String): Future[Seq[PathElement]] = {
rowType match {
case "feature" => Seq(ProjectPathElement((rowJson \ "project").as[String])).future
case "global_context" => {
(rowJson \ "parent")
.asOpt[String]
.map(parent => {
parent.split("_").toSeq.drop(1).map(ctx => GlobalContextPathElement(ctx))
})
.getOrElse(Seq.empty)
.future
}
case "local_context" => {
(rowJson \ "global_parent")
.asOpt[String]
.map(parent => {
val parts = parent.split("_").toSeq
val project = (rowJson \ "project").as[String]
parts
.drop(1)
.map(ctx => GlobalContextPathElement(ctx))
.appended(ProjectPathElement(project))
.future
})
.orElse(
(rowJson \ "parent")
.asOpt[String]
.map(parent => {
val parts = parent.split("_")
val project = parts.head

// We look for shortes local context parent, since before him all contexts will be global
env.datastores.featureContext
.findLocalContexts(tenant, generateParentCandidates(parts.drop(1)).map(s => s"${project}_${s}"))
.map(ctxs => {
val parentLocalContext = ctxs.sortBy(_.length).headOption
val parts: Seq[PathElement] = parentLocalContext
.map(lc => {
val shortestLocalContextParts = lc.split("_")
val parentLocalContextName = shortestLocalContextParts.last
val globalContextParts =
generateParentCandidates(shortestLocalContextParts.dropRight(1).drop(1))
.map(name => GlobalContextPathElement(name))
.toSeq
.appended(ProjectPathElement(project))

val localContextParts = parent
.replace(lc, "")
.split("_")
.filter(_.nonEmpty)
.map(str => LocalContextPathElement(str))
.toSeq
.prepended(LocalContextPathElement(parentLocalContextName))

globalContextParts.concat(localContextParts)
})
.getOrElse(Seq.empty)
parts
})
})
)
.getOrElse(Future.successful(Seq()))
}
case _ => Seq.empty.future
}
}

private def generateParentCandidates(parentParts: Seq[String]): Set[String] = {
@tailrec
def act(current: Seq[String], res: Seq[String], previousItems: Seq[String]): Seq[String] = {
val newRes = if (previousItems.nonEmpty) res.appended(previousItems.mkString("_")) else res
if (current.isEmpty) {
newRes
} else {
act(current.drop(1), newRes, previousItems.appended(current.head))
}
}

act(parentParts, Seq(), Seq()).toSet
}
}

sealed trait PathElement {
def pathElementType: String
def name: String
}
case class ProjectPathElement(name: String) extends PathElement {
override def pathElementType: String = "project"
}
case class GlobalContextPathElement(name: String) extends PathElement {
override def pathElementType: String = "global_context"
}
case class LocalContextPathElement(name: String) extends PathElement {
override def pathElementType: String = "local_context"
}
case class TenantPathElement(name: String) extends PathElement {
override def pathElementType: String = "tenant"
}

object PathElement {
val pathElementWrite: Writes[PathElement] = { p =>
Json.obj(
"type" -> p.pathElementType,
"name" -> p.name
)
}
}
3 changes: 3 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ PUT /api/admin/tenants/:tenant/webhooks/:id
GET /api/admin/tenants/:tenant/webhooks/:id/users fr.maif.izanami.web.UserController.readUsersForWebhook(tenant: String, id: String)
PUT /api/admin/tenants/:tenant/webhooks/:webhook/users/:user/rights fr.maif.izanami.web.UserController.updateUserRightsForWebhook(tenant: String, webhook: String, user: String)

# Search application endpoints
GET /api/admin/search fr.maif.izanami.web.SearchController.search(query: String)
GET /api/admin/tenants/:tenant/search fr.maif.izanami.web.SearchController.searchForTenant(tenant: String, query: String)

# Client application endpoints
GET /api/v2/features/:id fr.maif.izanami.web.FeatureController.checkFeatureForContext(id: String, user: String ?= "", context: fr.maif.izanami.web.FeatureContextPath)
Expand Down
Loading

0 comments on commit 2f09781

Please sign in to comment.