diff --git a/obp-api/src/main/scala/code/api/util/DoobieAccountAccessViewQueries.scala b/obp-api/src/main/scala/code/api/util/DoobieAccountAccessViewQueries.scala new file mode 100644 index 0000000000..03437bd165 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/DoobieAccountAccessViewQueries.scala @@ -0,0 +1,97 @@ +package code.api.util + +import doobie._ +import doobie.implicits._ + +/** + * Row from the v_account_access_with_views SQL view. + * This view joins accountaccess + resourceuser + viewdefinition in a single query. + */ +case class AccountAccessWithViewRow( + accountAccessId: Long, + bankId: String, + accountId: String, + viewId: String, + consumerId: String, + userId: String, + username: String, + email: Option[String], + provider: String, + resourceUserPrimaryKey: Long, + viewName: String, + viewDescription: Option[String], + metadataView: Option[String], + isSystem: Boolean, + isPublic: Boolean, + isFirehose: Boolean +) + +/** + * Doobie queries against the v_account_access_with_views SQL view. + * + * These replace multiple Mapper queries (AccountAccess + ResourceUser + ViewDefinition) + * with a single SQL query, eliminating N+1 patterns and reducing round-trips. + * + * The SQL view is created by MigrationOfAccountAccessWithViewsView. + */ +object DoobieAccountAccessViewQueries { + + private val baseSelect = fr""" + SELECT account_access_id, bank_id, account_id, view_id, consumer_id, + user_id, username, email, provider, resource_user_primary_key, + view_name, view_description, metadata_view, is_system, is_public, is_firehose + FROM v_account_access_with_views + """ + + /** + * Filter for private views only, unless allowPublicViews is enabled. + */ + private def privateFilter: Fragment = { + if (APIUtil.allowPublicViews) fr"" + else fr"AND is_public = ${false}" + } + + /** Get all account access rows for a user (by userId UUID string). */ + def getByUser(userId: String): List[AccountAccessWithViewRow] = { + val query = (baseSelect ++ fr"WHERE user_id = $userId" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } + + /** Get account access rows for a user at a specific bank. */ + def getByUserAndBank(userId: String, bankId: String): List[AccountAccessWithViewRow] = { + val query = (baseSelect ++ fr"WHERE user_id = $userId AND bank_id = $bankId" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } + + /** Get account access rows for a user at a specific bank/account. */ + def getByUserBankAccount(userId: String, bankId: String, accountId: String): List[AccountAccessWithViewRow] = { + val query = (baseSelect ++ fr"WHERE user_id = $userId AND bank_id = $bankId AND account_id = $accountId" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } + + /** Get account access rows for a user filtered by view IDs. */ + def getByUserAndViewIds(userId: String, viewIds: List[String]): List[AccountAccessWithViewRow] = { + if (viewIds.isEmpty) return Nil + val inClause = viewIds.map(v => fr"$v").reduceLeft((a, b) => a ++ fr"," ++ b) + val query = (baseSelect ++ fr"WHERE user_id = $userId AND view_id IN (" ++ inClause ++ fr")" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } + + /** Get account access rows for a user at a specific bank through a specific view. */ + def getByUserBankView(userId: String, bankId: String, viewId: String): List[AccountAccessWithViewRow] = { + val query = (baseSelect ++ fr"WHERE user_id = $userId AND bank_id = $bankId AND view_id = $viewId" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } + + /** Get all account access rows for a bank/account (for permissions). */ + def getByBankAccount(bankId: String, accountId: String): List[AccountAccessWithViewRow] = { + val query = (baseSelect ++ fr"WHERE bank_id = $bankId AND account_id = $accountId" ++ privateFilter) + .query[AccountAccessWithViewRow].to[List] + DoobieUtil.runQuery(query) + } +} diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 34527df59f..75a46d6cda 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -109,6 +109,7 @@ object Migration extends MdcLoggable { alterRoleNameLength() alterConsentRequestColumnConsumerIdLength() alterMappedConsentColumnConsumerIdLength() + addAccountAccessWithViewsView(startedBeforeSchemifier) } private def dummyScript(): Boolean = { @@ -569,6 +570,18 @@ object Migration extends MdcLoggable { MigrationOfMappedConsent.alterColumnConsumerIdLength(name) } } + + private def addAccountAccessWithViewsView(startedBeforeSchemifier: Boolean): Boolean = { + if(startedBeforeSchemifier == true) { + logger.warn(s"Migration.database.addAccountAccessWithViewsView(true) cannot be run before Schemifier.") + true + } else { + val name = nameOf(addAccountAccessWithViewsView(startedBeforeSchemifier)) + runOnce(name) { + MigrationOfAccountAccessWithViewsView.addAccountAccessWithViewsView(name) + } + } + } } /** diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfAccountAccessWithViewsView.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfAccountAccessWithViewsView.scala new file mode 100644 index 0000000000..c9ae6a4e49 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfAccountAccessWithViewsView.scala @@ -0,0 +1,105 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.views.system.AccountAccess +import net.liftweb.mapper.Schemifier + +object MigrationOfAccountAccessWithViewsView { + + def addAccountAccessWithViewsView(name: String): Boolean = { + DbFunction.tableExists(AccountAccess) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") openOr("org.h2.Driver") match { + case value if value.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ + |CREATE OR ALTER VIEW v_account_access_with_views AS + |SELECT + | aa.id AS account_access_id, + | aa.bank_id AS bank_id, + | aa.account_id AS account_id, + | aa.view_id AS view_id, + | aa.consumer_id AS consumer_id, + | ru.userid_ AS user_id, + | ru.name_ AS username, + | ru.email AS email, + | ru.provider_ AS provider, + | ru.id AS resource_user_primary_key, + | vd.name_ AS view_name, + | vd.description_ AS view_description, + | vd.metadataview_ AS metadata_view, + | vd.issystem_ AS is_system, + | vd.ispublic_ AS is_public, + | vd.isfirehose_ AS is_firehose + |FROM accountaccess aa + |JOIN resourceuser ru ON ru.id = aa.user_fk + |JOIN viewdefinition vd ON ( + | (vd.issystem_ = 1 AND vd.view_id = aa.view_id) + | OR + | (vd.issystem_ = 0 AND vd.bank_id = aa.bank_id + | AND vd.account_id = aa.account_id + | AND vd.view_id = aa.view_id) + |); + |""".stripMargin + case _ => + () => + """ + |CREATE OR REPLACE VIEW v_account_access_with_views AS + |SELECT + | aa.id AS account_access_id, + | aa.bank_id AS bank_id, + | aa.account_id AS account_id, + | aa.view_id AS view_id, + | aa.consumer_id AS consumer_id, + | ru.userid_ AS user_id, + | ru.name_ AS username, + | ru.email AS email, + | ru.provider_ AS provider, + | ru.id AS resource_user_primary_key, + | vd.name_ AS view_name, + | vd.description_ AS view_description, + | vd.metadataview_ AS metadata_view, + | vd.issystem_ AS is_system, + | vd.ispublic_ AS is_public, + | vd.isfirehose_ AS is_firehose + |FROM accountaccess aa + |JOIN resourceuser ru ON ru.id = aa.user_fk + |JOIN viewdefinition vd ON ( + | (vd.issystem_ = true AND vd.view_id = aa.view_id) + | OR + | (vd.issystem_ = false AND vd.bank_id = aa.bank_id + | AND vd.account_id = aa.account_id + | AND vd.view_id = aa.view_id) + |); + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${AccountAccess._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 97eb57605c..1a9ad953e6 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5000,7 +5000,7 @@ trait APIMethods600 { bank_id = "", account_id = "", view_id = "owner", - short_name = "Owner", + view_name = "Owner", description = "The owner of the account", metadata_view = "owner", is_public = false, @@ -5059,7 +5059,7 @@ trait APIMethods600 { bank_id = "", account_id = "", view_id = "owner", - short_name = "Owner", + view_name = "Owner", description = "The owner of the account. Has full privileges.", metadata_view = "owner", is_public = false, @@ -5121,7 +5121,7 @@ trait APIMethods600 { // EmptyBody, // ViewJsonV600( // view_id = "owner", -// short_name = "Owner", +// view_name = "Owner", // description = "The owner of the account. Has full privileges.", // metadata_view = "owner", // is_public = false, @@ -5198,7 +5198,7 @@ trait APIMethods600 { bank_id = "", account_id = "", view_id = "owner", - short_name = "Owner", + view_name = "Owner", description = "This is the owner view", metadata_view = "owner", is_public = false, @@ -5456,7 +5456,7 @@ trait APIMethods600 { bank_id = ExampleValue.bankIdExample.value, account_id = ExampleValue.accountIdExample.value, view_id = "_work", - short_name = "Work", + view_name = "Work", description = "A custom view for work-related transactions.", metadata_view = "_work", is_public = false, @@ -11257,7 +11257,7 @@ trait APIMethods600 { |* Owners |* Type |* Balance - |* Available views (sorted by short_name) + |* Available views (sorted by view_name) | |More details about the data moderation by the view [here](#1_2_1-getViewsForBankAccount). | @@ -11278,7 +11278,7 @@ trait APIMethods600 { bank_id = "", account_id = "", view_id = "owner", - short_name = "Owner", + view_name = "Owner", description = "The owner of the account", metadata_view = "owner", is_public = false, @@ -11332,7 +11332,7 @@ trait APIMethods600 { BankIdAccountId(account.bankId, account.accountId) ) val viewsAvailable = - availableViews.map(JSONFactory600.createViewJsonV600).sortBy(_.short_name) + availableViews.map(JSONFactory600.createViewJsonV600).sortBy(_.view_name) val tags = Tags.tags.vend.getTagsOnAccount(bankId, accountId)(viewId) ( createBankAccountJSON600( diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index e392c53d60..c711b4e700 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -1787,7 +1787,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { bank_id: String, account_id: String, view_id: String, - short_name: String, + view_name: String, description: String, metadata_view: String, is_public: Boolean, @@ -1841,7 +1841,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { bank_id = view.bankId.value, account_id = view.accountId.value, view_id = view.viewId.value, - short_name = view.name, + view_name = view.name, description = view.description, metadata_view = view.metadataView, is_public = view.isPublic, diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 06897b9780..bf65798300 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -5,7 +5,7 @@ import code.api.APIFailure import code.api.Constant._ import code.api.util.APIUtil._ import code.api.util.ErrorMessages._ -import code.api.util.{APIUtil, CallContext} +import code.api.util.{APIUtil, AccountAccessWithViewRow, CallContext, DoobieAccountAccessViewQueries} import code.model.dataAccess.ResourceUser import code.util.Helper.MdcLoggable import code.views.system.ViewDefinition.create @@ -22,20 +22,12 @@ import scala.concurrent.Future object MapperViews extends Views with MdcLoggable { private def getViewsForUser(user: User): List[View] = { - val accountAccessList = AccountAccess.findAll( - By(AccountAccess.user_fk, user.userPrimaryKey.value), - OrderBy(AccountAccess.bank_id, Ascending), - OrderBy(AccountAccess.account_id, Ascending) - ) - getViewsCommonPart(accountAccessList) - } + val rows = DoobieAccountAccessViewQueries.getByUser(user.userId) + rowsToViewDefinitions(rows).map(_._2) + } private def getViewsForUserAndAccount(user: User, account : BankIdAccountId): List[View] = { - val accountAccessList = AccountAccess.findAll( - By(AccountAccess.user_fk, user.userPrimaryKey.value), - By(AccountAccess.bank_id, account.bankId.value), - By(AccountAccess.account_id, account.accountId.value) - ) - getViewsCommonPart(accountAccessList) + val rows = DoobieAccountAccessViewQueries.getByUserBankAccount(user.userId, account.bankId.value, account.accountId.value) + rowsToViewDefinitions(rows).map(_._2) } private def getViewFromAccountAccess(accountAccess: AccountAccess) = { @@ -114,31 +106,85 @@ object MapperViews extends Views with MdcLoggable { batchLoadViewsForAccountAccess(accountAccessList).map(_._2) } - def permissions(account : BankIdAccountId) : List[Permission] = { - // 1. Single query: get all AccountAccess for this account - val allAccountAccess = AccountAccess.findAll( - By(AccountAccess.bank_id, account.bankId.value), - By(AccountAccess.account_id, account.accountId.value) - ) + /** + * Convert Doobie rows to AccountAccess Mapper instances. + * These are unsaved in-memory objects with fields populated from the row data. + */ + private def rowToAccountAccess(row: AccountAccessWithViewRow): AccountAccess = { + AccountAccess.create + .user_fk(row.resourceUserPrimaryKey) + .bank_id(row.bankId) + .account_id(row.accountId) + .view_id(row.viewId) + .consumer_id(row.consumerId) + } + + /** + * Batch-load ViewDefinition objects from Doobie rows. + * The Doobie query already filtered for valid views (SQL JOIN) and private views (WHERE clause). + * We still need the rich ViewDefinition Mapper objects for the View trait interface. + * + * Returns (row, ViewDefinition) pairs for rows that have a matching ViewDefinition. + */ + private def rowsToViewDefinitions(rows: List[AccountAccessWithViewRow]): List[(AccountAccessWithViewRow, ViewDefinition)] = { + if (rows.isEmpty) return Nil + + val (systemRows, customRows) = rows.partition(_.isSystem) + + // System views: load each distinct viewId once to check existence, + // then call findSystemView per row (ViewDefinition is mutable, needs fresh instance per row) + val distinctSystemViewIds = systemRows.map(_.viewId).distinct + val validSystemViewIds: Set[String] = distinctSystemViewIds + .filter(vid => ViewDefinition.findSystemView(vid).isDefined) + .toSet + + val systemPairs: List[(AccountAccessWithViewRow, ViewDefinition)] = systemRows.flatMap { row => + if (validSystemViewIds.contains(row.viewId)) { + ViewDefinition.findSystemView(row.viewId).toList.map { v => + (row, v.bank_id(row.bankId).account_id(row.accountId)) + } + } else Nil + } - // 2. Batch-load users: one query using ByList instead of N individual FK lookups - val distinctUserFks = allAccountAccess.map(_.user_fk.get).distinct - val usersMap: Map[Long, ResourceUser] = if (distinctUserFks.nonEmpty) { - ResourceUser.findAll(ByList(ResourceUser.id, distinctUserFks)) + // Custom views: one batch query using ByList, then index for fast lookup + val customPairs: List[(AccountAccessWithViewRow, ViewDefinition)] = if (customRows.nonEmpty) { + val distinctCustomViewIds = customRows.map(_.viewId).distinct + val allCustomViews = ViewDefinition.findAll( + By(ViewDefinition.isSystem_, false), + ByList(ViewDefinition.view_id, distinctCustomViewIds) + ) + val customViewMap: Map[(String, String, String), ViewDefinition] = allCustomViews + .map(v => (v.bank_id.get, v.account_id.get, v.view_id.get) -> v) + .toMap + + customRows.flatMap { row => + customViewMap.get((row.bankId, row.accountId, row.viewId)).map(v => (row, v)) + } + } else Nil + + systemPairs ::: customPairs + } + + def permissions(account : BankIdAccountId) : List[Permission] = { + // 1. Single Doobie query against the SQL view (replaces AccountAccess + view joins) + val rows = DoobieAccountAccessViewQueries.getByBankAccount(account.bankId.value, account.accountId.value) + val viewPairs = rowsToViewDefinitions(rows) + + // 2. Batch-load users by primary key + val distinctUserPks = viewPairs.map(_._1.resourceUserPrimaryKey).distinct + val usersMap: Map[Long, ResourceUser] = if (distinctUserPks.nonEmpty) { + ResourceUser.findAll(ByList(ResourceUser.id, distinctUserPks)) .map(u => u.id.get -> u).toMap } else Map.empty - // 3. Batch-load views for all access records - val viewPairs = batchLoadViewsForAccountAccess(allAccountAccess) - - // 4. Group views by user FK and build Permission objects - val viewsByUserFk: Map[Long, List[View]] = viewPairs - .groupBy(_._1.user_fk.get) - .map { case (userFk, pairs) => userFk -> pairs.map(_._2: View) } + // 3. Group views by user PK and build Permission objects + val viewsByUserPk: Map[Long, List[View]] = viewPairs + .groupBy(_._1.resourceUserPrimaryKey) + .map { case (pk, pairs) => pk -> pairs.map(_._2: View) } - distinctUserFks.flatMap { userFk => - usersMap.get(userFk).map { user => - Permission(user, viewsByUserFk.getOrElse(userFk, Nil)) + distinctUserPks.flatMap { pk => + usersMap.get(pk).map { user => + Permission(user, viewsByUserPk.getOrElse(pk, Nil)) } } } @@ -618,44 +664,30 @@ object MapperViews extends Views with MdcLoggable { } def privateViewsUserCanAccess(user: User): (List[View], List[AccountAccess]) ={ - val allAccountAccess = AccountAccess.findAllByUserPrimaryKey(user.userPrimaryKey) - val pairs = batchLoadViewsForAccountAccess(allAccountAccess) - (pairs.map(_._2).distinct, pairs.map(_._1)) + val rows = DoobieAccountAccessViewQueries.getByUser(user.userId) + val viewPairs = rowsToViewDefinitions(rows) + (viewPairs.map(_._2).distinct, viewPairs.map { case (row, _) => rowToAccountAccess(row) }) } def privateViewsUserCanAccess(user: User, viewIds: List[ViewId]): (List[View], List[AccountAccess]) ={ - val allAccountAccess = AccountAccess.findAll( - By(AccountAccess.user_fk, user.userPrimaryKey.value), - ByList(AccountAccess.view_id, viewIds.map(_.value)) - ) - val pairs = batchLoadViewsForAccountAccess(allAccountAccess) - (pairs.map(_._2), pairs.map(_._1)) + val rows = DoobieAccountAccessViewQueries.getByUserAndViewIds(user.userId, viewIds.map(_.value)) + val viewPairs = rowsToViewDefinitions(rows) + (viewPairs.map(_._2), viewPairs.map { case (row, _) => rowToAccountAccess(row) }) } def privateViewsUserCanAccessAtBank(user: User, bankId: BankId): (List[View], List[AccountAccess]) ={ - val allAccountAccess = AccountAccess.findAll( - By(AccountAccess.user_fk, user.userPrimaryKey.value), - By(AccountAccess.bank_id, bankId.value) - ) - val pairs = batchLoadViewsForAccountAccess(allAccountAccess) - (pairs.map(_._2), pairs.map(_._1)) + val rows = DoobieAccountAccessViewQueries.getByUserAndBank(user.userId, bankId.value) + val viewPairs = rowsToViewDefinitions(rows) + (viewPairs.map(_._2), viewPairs.map { case (row, _) => rowToAccountAccess(row) }) } def getAccountAccessAtBankThroughView(user: User, bankId: BankId, viewId: ViewId): (List[View], List[AccountAccess]) ={ - val allAccountAccess = AccountAccess.findAll( - By(AccountAccess.user_fk, user.userPrimaryKey.value), - By(AccountAccess.bank_id, bankId.value), - By(AccountAccess.view_id, viewId.value) - ) - val pairs = batchLoadViewsForAccountAccess(allAccountAccess) - (pairs.map(_._2), pairs.map(_._1)) + val rows = DoobieAccountAccessViewQueries.getByUserBankView(user.userId, bankId.value, viewId.value) + val viewPairs = rowsToViewDefinitions(rows) + (viewPairs.map(_._2), viewPairs.map { case (row, _) => rowToAccountAccess(row) }) } def privateViewsUserCanAccessForAccount(user: User, bankIdAccountId : BankIdAccountId) : List[View] = { - val accountAccess = AccountAccess.findByBankIdAccountIdUserPrimaryKey( - bankIdAccountId.bankId, - bankIdAccountId.accountId, - user.userPrimaryKey - ) - val pairs = batchLoadViewsForAccountAccess(accountAccess) - pairs.map(_._2).distinct + val rows = DoobieAccountAccessViewQueries.getByUserBankAccount(user.userId, bankIdAccountId.bankId.value, bankIdAccountId.accountId.value) + val viewPairs = rowsToViewDefinitions(rows) + viewPairs.map(_._2).distinct }