diff --git a/.github/actions/setup-test-database/action.yml b/.github/actions/setup-test-database/action.yml new file mode 100644 index 0000000000..b9de6446e0 --- /dev/null +++ b/.github/actions/setup-test-database/action.yml @@ -0,0 +1,98 @@ +name: Set up the test database +description: > + Selects the database the shards run against (H2 unless a manual run or a pull request label asks for + SQL Server or PostgreSQL), starts its container when one is needed and exports the settings the later + steps use: TEST_DB, TEST_DB_DRIVER, TEST_DB_URL and TEST_TIMEOUT_SECONDS. + +inputs: + database: + description: Database chosen on a manual run (h2, sqlserver, sqlserver2017 or postgres); empty on other triggers. + required: false + default: '' + pr-labels: + description: JSON list of the pull request labels; a test-sqlserver or test-postgres label opts in. + required: false + default: '[]' + commit-message: + description: Head commit message of a push; [test-sqlserver] or [test-postgres] in it opts in. + required: false + default: '' + +runs: + using: composite + steps: + - name: Start the selected database + shell: bash + env: + INPUT_DB: ${{ inputs.database }} + PR_LABELS_JSON: ${{ inputs.pr-labels }} + COMMIT_MSG: ${{ inputs.commit-message }} + run: | + # Test-only credentials of a throwaway container that lives for the length of the job. + SA_PASSWORD='ObpTests2026!Ci' + MSSQL_IMAGE='mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04' + POSTGRES_IMAGE='postgres:16-alpine' + + DB_CHOICE="${INPUT_DB:-h2}" + if [ "$DB_CHOICE" = "h2" ]; then + if echo "$PR_LABELS_JSON" | grep -q "test-sqlserver" || echo "$COMMIT_MSG" | grep -q "\[test-sqlserver\]"; then + DB_CHOICE="sqlserver" + elif echo "$PR_LABELS_JSON" | grep -q "test-postgres" || echo "$COMMIT_MSG" | grep -q "\[test-postgres\]"; then + DB_CHOICE="postgres" + fi + fi + + # sqlserver2017 is SQL Server 2017 (CU20, the build some deployments still run): the same + # tests and properties as sqlserver, a different image, and the 2017 image's own sqlcmd. + SQLCMD_BIN=/opt/mssql-tools18/bin/sqlcmd + SQLCMD_TRUST=(-No) + if [ "$DB_CHOICE" = "sqlserver2017" ]; then + DB_CHOICE="sqlserver" + MSSQL_IMAGE='mcr.microsoft.com/mssql/server:2017-CU20-ubuntu' + SQLCMD_BIN=/opt/mssql-tools/bin/sqlcmd + SQLCMD_TRUST=() + fi + + echo "TEST_DB=$DB_CHOICE" >> "$GITHUB_ENV" + echo "Selected test database: $DB_CHOICE" + + wait_until_ready() { + # $1 = name, $2 = seconds between tries, remaining args = readiness command + local name="$1" pause="$2"; shift 2 + for i in $(seq 1 30); do + if "$@" >/dev/null 2>&1; then + echo "$name is ready" + return 0 + fi + echo "Waiting for $name ($i/30)..." + sleep "$pause" + done + echo "::error::$name did not become ready" + docker logs "$name" 2>&1 | tail -50 || true + return 1 + } + + if [ "$DB_CHOICE" = "sqlserver" ]; then + docker run -d --name sqlserver -p 1433:1433 \ + -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \ + "$MSSQL_IMAGE" + SQLCMD=(docker exec sqlserver "$SQLCMD_BIN" -S localhost -U sa -P "$SA_PASSWORD" "${SQLCMD_TRUST[@]}" -b) + wait_until_ready sqlserver 5 "${SQLCMD[@]}" -Q 'SELECT 1' + "${SQLCMD[@]}" -Q "CREATE DATABASE obp_test_only" + echo "TEST_DB_DRIVER=com.microsoft.sqlserver.jdbc.SQLServerDriver" >> "$GITHUB_ENV" + echo "TEST_DB_URL=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=$SA_PASSWORD;encrypt=false" >> "$GITHUB_ENV" + # Shards on a server database run longer than on H2, and shard 6 (v7 + UK Open Banking) has taken + # from 30 to over 50 minutes on SQL Server depending on the runner, so it gets 75 minutes. + echo "TEST_TIMEOUT_SECONDS=4500" >> "$GITHUB_ENV" + elif [ "$DB_CHOICE" = "postgres" ]; then + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ + "$POSTGRES_IMAGE" + wait_until_ready postgres 2 docker exec postgres pg_isready -U postgres + echo "TEST_DB_DRIVER=org.postgresql.Driver" >> "$GITHUB_ENV" + echo "TEST_DB_URL=jdbc:postgresql://localhost:5432/obp_test?user=postgres&password=password" >> "$GITHUB_ENV" + echo "TEST_TIMEOUT_SECONDS=4500" >> "$GITHUB_ENV" + else + echo "Running with the default H2 in-memory database; no container is started." + echo "TEST_TIMEOUT_SECONDS=1500" >> "$GITHUB_ENV" + fi diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index f936dbaaf2..a3deda1da7 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -2,7 +2,20 @@ name: Build and publish container develop # read-write repo token # access to secrets -on: [push] +on: + push: + workflow_dispatch: + inputs: + database: + description: 'Database engine for tests (default: h2)' + required: false + default: 'h2' + type: choice + options: + - h2 + - sqlserver + - sqlserver2017 + - postgres env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} @@ -127,7 +140,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: ${{ (inputs.database == 'sqlserver' || inputs.database == 'sqlserver2017' || inputs.database == 'postgres' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.pull_request.labels.*.name, 'test-postgres') || contains(github.event.head_commit.message, '[test-sqlserver]') || contains(github.event.head_commit.message, '[test-postgres]')) && 90 || 35 }} strategy: fail-fast: false matrix: @@ -253,6 +266,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up the test database + uses: ./.github/actions/setup-test-database + with: + database: ${{ inputs.database }} + pr-labels: ${{ toJson(github.event.pull_request.labels.*.name) }} + commit-message: ${{ github.event.head_commit.message }} + - name: Set up JDK 25 uses: actions/setup-java@v4 with: @@ -301,6 +321,15 @@ jobs: cp obp-api/src/main/resources/props/sample.props.template \ obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props + + if [ -n "$TEST_DB_URL" ]; then + echo "Configuring $TEST_DB connection properties..." + echo "db.driver=$TEST_DB_DRIVER" >> obp-api/src/main/resources/props/test.default.props + echo "db.url=$TEST_DB_URL" >> obp-api/src/main/resources/props/test.default.props + else + echo "Using default H2 database." + fi + echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props @@ -433,14 +462,14 @@ jobs: # -pl obp-commons,obp-api: obp-commons' own 5 util suites run on whichever # shard's filter matches com.openbankproject.* (the catch-all shard); on every # other shard the filter matches nothing in obp-commons → 0 tests there. - # timeout 1500: hard-kill after 25 min to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min on H2, 75 on SQL Server and PostgreSQL): hard-kill to prevent Pekko non-daemon threads # (ConsentScheduler etc.) from keeping the JVM alive after tests complete. # Exit code 124 (timeout) is treated as success — tests are done, JVM just hung. # set +e: GitHub Actions uses -eo pipefail by default; without it, a 124 exit from # timeout would abort the step before the rc check below can run. set +e MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ - timeout 1500 mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ + timeout "$TEST_TIMEOUT_SECONDS" mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ -DwildcardSuites="$FILTER" $EXTRA_MVN_ARGS \ > maven-build-shard${{ matrix.shard }}.log 2>&1 rc=$? diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 9ac9596f01..a14a3c89b5 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -2,8 +2,21 @@ name: Build on Pull Request on: pull_request: + types: [opened, synchronize, reopened, labeled] branches: - "**" + workflow_dispatch: + inputs: + database: + description: 'Database engine for tests (default: h2)' + required: false + default: 'h2' + type: choice + options: + - h2 + - sqlserver + - sqlserver2017 + - postgres env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} @@ -121,7 +134,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: ${{ (inputs.database == 'sqlserver' || inputs.database == 'sqlserver2017' || inputs.database == 'postgres' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.pull_request.labels.*.name, 'test-postgres') || contains(github.event.head_commit.message, '[test-sqlserver]') || contains(github.event.head_commit.message, '[test-postgres]')) && 90 || 35 }} strategy: fail-fast: false matrix: @@ -247,6 +260,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up the test database + uses: ./.github/actions/setup-test-database + with: + database: ${{ inputs.database }} + pr-labels: ${{ toJson(github.event.pull_request.labels.*.name) }} + commit-message: ${{ github.event.head_commit.message }} + - name: Set up JDK 25 uses: actions/setup-java@v4 with: @@ -295,6 +315,15 @@ jobs: cp obp-api/src/main/resources/props/sample.props.template \ obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props + + if [ -n "$TEST_DB_URL" ]; then + echo "Configuring $TEST_DB connection properties..." + echo "db.driver=$TEST_DB_DRIVER" >> obp-api/src/main/resources/props/test.default.props + echo "db.url=$TEST_DB_URL" >> obp-api/src/main/resources/props/test.default.props + else + echo "Using default H2 database." + fi + echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props @@ -437,14 +466,14 @@ jobs: # -pl obp-commons,obp-api: obp-commons' own 5 util suites run on whichever # shard's filter matches com.openbankproject.* (the catch-all shard); on every # other shard the filter matches nothing in obp-commons → 0 tests there. - # timeout 1500: hard-kill after 25 min to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min on H2, 75 on SQL Server and PostgreSQL): hard-kill to prevent Pekko non-daemon threads # (ConsentScheduler etc.) from keeping the JVM alive after tests complete. # Exit code 124 (timeout) is treated as success — tests are done, JVM just hung. # set +e: GitHub Actions uses -eo pipefail by default; without it, a 124 exit from # timeout would abort the step before the rc check below can run. set +e MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ - timeout 1500 mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ + timeout "$TEST_TIMEOUT_SECONDS" mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ -DwildcardSuites="$FILTER" $EXTRA_MVN_ARGS \ > maven-build-shard${{ matrix.shard }}.log 2>&1 rc=$? diff --git a/CLAUDE.md b/CLAUDE.md index 0e0b01c6f9..df8b1d9bac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently registers 117 ResourceDocs; most arrived there for historical reasons and stay as-is. -**Request priority chain** (`Http4sApp.baseServices`): `corsHandler` (OPTIONS short-circuit) → `AppsPage` → `StatusPage` → `Http4sResourceDocs` → v510 → v600 → v500 → v700 → Berlin Group v2 → UK v2.0 → UK v3.1 → Berlin Group v1.3 (+Alias) → v400 → v310 → v300 → v220 → v210 → v200 → v140 → v130 → v121 → `dynamicEntityRoutes` → `dynamicEndpointRoutes` → DirectLogin → OpenIdConnect → AliveCheck → `notFoundCatchAll` (JSON 404). There is no Lift fallback — `Http4sLiftWebBridge` has been removed. Any unhandled `/obp/*` path returns a JSON 404 from `notFoundCatchAll`; it does not fall through to Lift. +**Request priority chain** (`Http4sApp.baseServices`): `corsHandler` (OPTIONS short-circuit) → `AppsPage` → `StatusPage` → `Http4sResourceDocs` → v510 → v600 → v500 → v700 → Berlin Group v2 → UK v2.0 → UK v3.1 → Berlin Group v1.3 (+Alias) → v400 → v310 → v300 → v220 → v210 → v200 → v140 → v130 → v121 → `dynamicEntityRoutes` → `dynamicEndpointRoutes` → DirectLogin → OpenIdConnect → AliveCheck → `notFoundCatchAll` (JSON 404). There is no Lift fallback. Any unhandled `/obp/*` path returns a JSON 404 from `notFoundCatchAll`; it does not fall through to Lift. **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). @@ -261,7 +261,7 @@ POST /obp/v4.0.0/banks → Http4s220 (HAS POST /banks → executes v2.2.0 createBank ✗) ``` -Before `Http4sLiftWebBridge` was removed, an un-migrated v4 override fell all the way through to the Lift bridge, which honoured the `collectResourceDocs` URL+verb dedup that keeps the highest-version handler for each route — so Lift's v4 createBank ran and the test passed. **That safety net is gone**: the chain now terminates in `notFoundCatchAll`, so a v4 path not matched by `Http4s400`'s own routes cascades down the http4s version bridges to an older handler (or 404s) — it never reaches a Lift v4 handler. Cure: before flipping a new version's `wrappedRoutesVxxxServices` into `Http4sApp.baseServices`, audit the version's overrides (Lift's `excludeEndpoints` is *not* the right list — it only names *removed* endpoints, not overrides) and migrate them too. +Under the old Lift fallback, an un-migrated v4 override fell all the way through to Lift, which honoured the `collectResourceDocs` URL+verb dedup that keeps the highest-version handler for each route — so Lift's v4 createBank ran and the test passed. **That safety net is gone**: the chain now terminates in `notFoundCatchAll`, so a v4 path not matched by `Http4s400`'s own routes cascades down the http4s version bridges to an older handler (or 404s) — it never reaches a Lift v4 handler. Cure: before flipping a new version's `wrappedRoutesVxxxServices` into `Http4sApp.baseServices`, audit the version's overrides (Lift's `excludeEndpoints` is *not* the right list — it only names *removed* endpoints, not overrides) and migrate them too. How to find overrides for a version: grep `lazy val (\w+)` in the target `APIMethods*.scala`, then check whether the same URL + verb also appears in any older `APIMethods*.scala`. The intersection is the override set. Migrate that set as part of the same PR that introduces the bridge; otherwise reviewers will see test failures whose proximate cause (a downstream version's handler running) doesn't match the file the migration touches. diff --git a/ideas/lift_mapper_in_the_future.md b/ideas/lift_mapper_in_the_future.md index c688ff771e..53adf1dcee 100644 --- a/ideas/lift_mapper_in_the_future.md +++ b/ideas/lift_mapper_in_the_future.md @@ -15,7 +15,7 @@ This document is the menu of responses to that staleness, cheapest to heaviest: ## Dependency-surface coupling: `lift-webkit` is hostage to Mapper (verified 2026-06-05) -This is the clearest concrete symptom of the staleness problem. The Lift Web teardown (PR #2828) removed every `net.liftweb.http` import from OBP source (128 files → 0) and deleted `Http4sLiftWebBridge`. That shrank the *reachable* attack surface, but it did **not** shrink the dependency/CVE-scan surface: `lift-webkit_2.12-3.5.0.jar` (the jar that *contains* `net.liftweb.http`) is still on the classpath, pulled in transitively: +This is the clearest concrete symptom of the staleness problem. The Lift Web teardown (PR #2828) removed every `net.liftweb.http` import from OBP source (128 files → 0) and deleted the Lift request fallback. That shrank the *reachable* attack surface, but it did **not** shrink the dependency/CVE-scan surface: `lift-webkit_2.12-3.5.0.jar` (the jar that *contains* `net.liftweb.http`) is still on the classpath, pulled in transitively: ``` obp-api → lift-mapper → lift-db → lift-webkit (3.5.0) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 8572bd7e32..13f4aafc88 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -666,10 +666,20 @@ class Boot extends MdcLoggable { def schemifyAll() = { Schemifier.schemify(true, Schemifier.infoF _, ToSchemify.models: _*) + if (code.api.util.DBUtil.isSqlServer) { + upgradeSqlServerDateTimeColumns() + } // Create default system-level "general" chat room (is_open_room = true) code.chat.ChatRoomTrait.chatRoomProvider.vend.getOrCreateDefaultRoom() } + private def upgradeSqlServerDateTimeColumns(): Unit = { + val result = code.api.util.SqlServerDateTimeUpgrade.upgradeAll() + if (result.failed.nonEmpty) { + logger.warn(s"upgradeSqlServerDateTimeColumns: ${result.failed.size} DATETIME columns could not be altered to DATETIME2 and keep the 1753 date floor: ${result.failed.mkString("; ")}") + } + } + /** * there will be a default bank and two default accounts in obp mapped mode. * These bank and accounts will be used for the payments. diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 79e5106ac7..ad07526837 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2294,7 +2294,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * this, so that SYS is accepted in all of them and not only in the versions written after it. */ def isBankIdWhereRolesCanBeHeld(bankId: String, callContext: Option[CallContext]): Boolean = - bankId.isEmpty || bankId == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID || BankX(BankId(bankId), callContext).map(_._1).isDefined + bankId.isEmpty || bankId == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID || BankX(BankId(bankId), callContext).map(_._1).exists(_.bankId.value == bankId) def hasEntitlement(bankId: String, userId: String, apiRole: ApiRole): Boolean = apiRole match { case RoleCombination(roles) => roles.forall(hasEntitlement(bankId, userId, _)) diff --git a/obp-api/src/main/scala/code/api/util/DBUtil.scala b/obp-api/src/main/scala/code/api/util/DBUtil.scala index e23c435e42..c9aef9143e 100644 --- a/obp-api/src/main/scala/code/api/util/DBUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DBUtil.scala @@ -37,7 +37,12 @@ import java.sql.{ResultSet, Types} object DBUtil { def dbUrl: String = APIUtil.getPropsValue("db.url") openOr Constant.h2DatabaseDefaultUrlValue - def isSqlServer: Boolean = dbUrl.contains("sqlserver") + /** + * Whether the configured database is SQL Server. It cannot change while the instance runs, and it is asked + * on every paginated or locking query, so it is worked out once. + */ + lazy val isSqlServer: Boolean = + dbUrl.contains("sqlserver") || APIUtil.getPropsValue("db.driver").exists(_.contains("sqlserver")) /** The endpoint timeout in whole seconds, rounded up, which is the unit JDBC takes. */ private[code] def queryTimeoutSeconds: Int = diff --git a/obp-api/src/main/scala/code/api/util/DoobieTransactor.scala b/obp-api/src/main/scala/code/api/util/DoobieTransactor.scala index 0b86d6ec91..6996c4b61c 100644 --- a/obp-api/src/main/scala/code/api/util/DoobieTransactor.scala +++ b/obp-api/src/main/scala/code/api/util/DoobieTransactor.scala @@ -222,6 +222,23 @@ object DoobieUtil extends MdcLoggable { */ def isSqlServer: Boolean = DBUtil.isSqlServer + /** + * The page clause in the form the database takes. SQL Server writes it as OFFSET ... FETCH and requires an + * ORDER BY in front of it; the other databases take LIMIT ... OFFSET. + */ + def pagination(limit: Int, offset: Int): Fragment = + if (isSqlServer) fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + else fr"LIMIT $limit OFFSET $offset" + + /** + * Row locking for a SELECT is a table hint on SQL Server, placed straight after the table name, and a + * FOR UPDATE suffix elsewhere. Use `lockHint` after the table and `forUpdate` at the end of the statement; + * exactly one of the two is empty on any database. + */ + def lockHint: Fragment = if (isSqlServer) fr"WITH (UPDLOCK, ROWLOCK)" else Fragment.empty + + def forUpdate: Fragment = if (isSqlServer) Fragment.empty else fr"FOR UPDATE" + /** * Get database URL for checking database type */ diff --git a/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala b/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala new file mode 100644 index 0000000000..dd1cf26f89 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala @@ -0,0 +1,182 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.util + +import code.util.Helper.MdcLoggable +import net.liftweb.db.DB +import net.liftweb.util.DefaultConnectionIdentifier + +import java.sql.Connection +import scala.collection.mutable.ListBuffer +import scala.util.control.NonFatal + +/** + * Lift Mapper creates MappedDateTime columns as legacy DATETIME on SQL Server, whose valid range starts at + * 1753-01-01. OBP stores dates before that (an example date of birth of 1100-01-01), which the driver + * rejects with "The conversion of a datetime2 data type to a datetime data type resulted in an + * out-of-range value". This upgrades every DATETIME column of every base table to DATETIME2. + * + * SQL Server refuses to change the type of a column that an index or a default constraint depends on + * (error 5074), and a plain `ALTER COLUMN ... DATETIME2 NULL` would also make a NOT NULL column nullable. + * So for each column the indexes and the default that depend on it are dropped, the type is changed with + * the nullability the column had, and then they are created again as they were. The work for one column + * is one transaction: if any step fails, the column and its indexes are left exactly as they were. + * + * An index is recreated with its uniqueness, clustered or not, key columns and their direction, included + * columns and filter. Storage options such as fill factor and compression are not carried over. + */ +object SqlServerDateTimeUpgrade extends MdcLoggable { + + /** Columns that were upgraded, and those that could not be, each as `[schema].[table].[column]`. */ + final case class Result(altered: List[String], failed: List[String]) + + private final case class DateTimeColumn(schema: String, table: String, column: String, columnId: Int, objectId: Int, nullable: Boolean) { + def qualifiedTable: String = s"${quote(schema)}.${quote(table)}" + def qualified: String = s"${quote(schema)}.${quote(table)}.${quote(column)}" + } + + private final case class IndexDefinition(name: String, unique: Boolean, clustered: Boolean, keys: List[(String, Boolean)], included: List[String], filter: Option[String]) + + private final case class DefaultConstraint(name: String, definition: String) + + private def quote(identifier: String): String = s"[${identifier.replace("]", "]]")}]" + + private def query[A](connection: Connection, sql: String, parameters: Any*)(read: java.sql.ResultSet => A): List[A] = { + val statement = connection.prepareStatement(sql) + try { + parameters.zipWithIndex.foreach { case (value, position) => statement.setObject(position + 1, value) } + val result = statement.executeQuery() + try { + val buffer = ListBuffer[A]() + while (result.next()) buffer += read(result) + buffer.toList + } finally result.close() + } finally statement.close() + } + + private def execute(connection: Connection, sql: String): Unit = { + val statement = connection.createStatement() + try statement.execute(sql) finally statement.close() + } + + private def dateTimeColumns(connection: Connection): List[DateTimeColumn] = + query(connection, + """SELECT s.name, t.name, c.name, c.column_id, t.object_id, c.is_nullable + |FROM sys.columns c + |JOIN sys.tables t ON t.object_id = c.object_id + |JOIN sys.schemas s ON s.schema_id = t.schema_id + |JOIN sys.types ty ON ty.user_type_id = c.user_type_id + |WHERE ty.name = 'datetime' + |ORDER BY s.name, t.name, c.column_id""".stripMargin + )(r => DateTimeColumn(r.getString(1), r.getString(2), r.getString(3), r.getInt(4), r.getInt(5), r.getBoolean(6))) + + /** The indexes that use the column as a key or as an included column. Throws for what cannot be recreated here. */ + private def dependentIndexes(connection: Connection, column: DateTimeColumn): List[IndexDefinition] = { + val indexes = query(connection, + """SELECT DISTINCT i.index_id, i.name, i.is_unique, i.type, i.is_primary_key, i.is_unique_constraint, i.filter_definition + |FROM sys.indexes i + |JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + |WHERE i.object_id = ? AND ic.column_id = ? AND i.index_id > 0""".stripMargin, + Int.box(column.objectId), Int.box(column.columnId) + )(r => (r.getInt(1), r.getString(2), r.getBoolean(3), r.getInt(4), r.getBoolean(5), r.getBoolean(6), Option(r.getString(7)))) + + indexes.map { case (indexId, name, unique, indexType, isPrimaryKey, isUniqueConstraint, filter) => + if (isPrimaryKey || isUniqueConstraint) + throw new IllegalStateException(s"$name is a primary key or unique constraint on a DATETIME column; it has to be upgraded by hand") + if (indexType != 1 && indexType != 2) + throw new IllegalStateException(s"$name is an index of a kind this upgrade does not recreate (type $indexType); it has to be upgraded by hand") + val parts = query(connection, + """SELECT c.name, ic.is_included_column, ic.is_descending_key + |FROM sys.index_columns ic + |JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + |WHERE ic.object_id = ? AND ic.index_id = ? + |ORDER BY ic.is_included_column, ic.key_ordinal, ic.index_column_id""".stripMargin, + Int.box(column.objectId), Int.box(indexId) + )(r => (r.getString(1), r.getBoolean(2), r.getBoolean(3))) + IndexDefinition( + name, unique, clustered = indexType == 1, + keys = parts.filterNot(_._2).map { case (c, _, descending) => (c, descending) }, + included = parts.filter(_._2).map(_._1), + filter = filter + ) + } + } + + private def defaultConstraints(connection: Connection, column: DateTimeColumn): List[DefaultConstraint] = + query(connection, + "SELECT name, definition FROM sys.default_constraints WHERE parent_object_id = ? AND parent_column_id = ?", + Int.box(column.objectId), Int.box(column.columnId) + )(r => DefaultConstraint(r.getString(1), r.getString(2))) + + private def createIndexSql(column: DateTimeColumn, index: IndexDefinition): String = { + val keys = index.keys.map { case (c, descending) => s"${quote(c)}${if (descending) " DESC" else ""}" }.mkString(", ") + val included = if (index.included.isEmpty) "" else s" INCLUDE (${index.included.map(quote).mkString(", ")})" + val filter = index.filter.map(f => s" WHERE $f").getOrElse("") + s"CREATE ${if (index.unique) "UNIQUE " else ""}${if (index.clustered) "CLUSTERED" else "NONCLUSTERED"} INDEX ${quote(index.name)} ON ${column.qualifiedTable} ($keys)$included$filter" + } + + private def upgradeColumn(connection: Connection, column: DateTimeColumn): Unit = { + val indexes = dependentIndexes(connection, column) + val defaults = defaultConstraints(connection, column) + try { + indexes.foreach(index => execute(connection, s"DROP INDEX ${quote(index.name)} ON ${column.qualifiedTable}")) + defaults.foreach(d => execute(connection, s"ALTER TABLE ${column.qualifiedTable} DROP CONSTRAINT ${quote(d.name)}")) + execute(connection, s"ALTER TABLE ${column.qualifiedTable} ALTER COLUMN ${quote(column.column)} DATETIME2 ${if (column.nullable) "NULL" else "NOT NULL"}") + defaults.foreach(d => execute(connection, s"ALTER TABLE ${column.qualifiedTable} ADD CONSTRAINT ${quote(d.name)} DEFAULT ${d.definition} FOR ${quote(column.column)}")) + indexes.foreach(index => execute(connection, createIndexSql(column, index))) + connection.commit() + } catch { + case NonFatal(e) => + connection.rollback() + throw e + } + } + + def upgradeAll(): Result = { + val altered = ListBuffer[String]() + val failed = ListBuffer[String]() + DB.use(DefaultConnectionIdentifier) { connection => + val columns = + try dateTimeColumns(connection) + catch { + case NonFatal(e) => + logger.warn(s"SqlServerDateTimeUpgrade: listing the DATETIME columns failed: ${e.getMessage}") + Nil + } + columns.foreach { column => + try { + upgradeColumn(connection, column) + altered += column.qualified + } catch { + case NonFatal(e) => failed += s"${column.qualified} (${e.getMessage})" + } + } + } + Result(altered.toList, failed.toList) + } +} 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 e0d9ec4749..69b7798320 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 @@ -194,6 +194,7 @@ object Migration extends MdcLoggable { renameDynamicEntityRoles() renameDynamicEntityDefinitionRoles() upperCaseStoredCurrencyCodes() + ensureConsentViewExists() } /** @@ -964,6 +965,18 @@ object Migration extends MdcLoggable { } } + /** + * Last step of the chain: earlier steps can drop v_consent (migrateConsentReferenceIdToUuid does), so + * make sure it exists once more. Registered like the other migrations so it runs once per database + * instead of on every boot. + */ + private def ensureConsentViewExists(): Boolean = { + val name = nameOf(ensureConsentViewExists) + runOnce(name) { + MigrationOfConsentView.addConsentView(name) + } + } + private def alterDynamicDataIdLength(): Boolean = { val name = nameOf(alterDynamicDataIdLength) runOnce(name) { diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentJwtPayload.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentJwtPayload.scala index c908dbc7a0..947d2179d2 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentJwtPayload.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentJwtPayload.scala @@ -45,7 +45,7 @@ object MigrationOfConsentJwtPayload extends MdcLoggable { try { val consents = MappedConsent.findAll( NullRef(MappedConsent.mJsonWebTokenPayload), - By_>(MappedConsent.mJsonWebToken, "") + NotNullRef(MappedConsent.mJsonWebToken) ) consents.foreach { consent => val jwt = consent.mJsonWebToken.get diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala index 7d9cfe25f4..318677e0c2 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentReferenceIdUuid.scala @@ -81,12 +81,12 @@ object MigrationOfConsentReferenceIdUuid { try { // 1. Backups — table-snapshot copies so we can recover from any later failure. val backupMappedConsent = if (isMssql) { - "SELECT * INTO backup_2026_05_mappedconsent FROM mappedconsent;" + "IF OBJECT_ID('backup_2026_05_mappedconsent', 'U') IS NULL SELECT * INTO backup_2026_05_mappedconsent FROM mappedconsent;" } else { "CREATE TABLE backup_2026_05_mappedconsent AS SELECT * FROM mappedconsent;" } val backupConsentItem = if (isMssql) { - "SELECT * INTO backup_2026_05_consent_item FROM consent_item;" + "IF OBJECT_ID('backup_2026_05_consent_item', 'U') IS NULL SELECT * INTO backup_2026_05_consent_item FROM consent_item;" } else { "CREATE TABLE backup_2026_05_consent_item AS SELECT * FROM consent_item;" } @@ -98,7 +98,8 @@ object MigrationOfConsentReferenceIdUuid { // 3. Add the new UUID column on mappedconsent, nullable for the backfill. val addColumn = if (isMssql) { - "ALTER TABLE mappedconsent ADD consent_reference_id VARCHAR(36) NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'consent_reference_id') + | ALTER TABLE mappedconsent ADD consent_reference_id VARCHAR(36) NULL;""".stripMargin } else { "ALTER TABLE mappedconsent ADD COLUMN IF NOT EXISTS consent_reference_id VARCHAR(36);" } @@ -115,18 +116,31 @@ object MigrationOfConsentReferenceIdUuid { // 5. Enforce NOT NULL + UNIQUE. val setNotNull = if (isMssql) { - "ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL;" + """IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'consent_reference_id' AND is_nullable = 1) + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'mappedconsent_consent_reference_id') + | DROP INDEX mappedconsent_consent_reference_id ON mappedconsent; + | IF EXISTS (SELECT 1 FROM sys.key_constraints WHERE name = 'uq_mappedconsent_consent_reference_id' AND parent_object_id = OBJECT_ID('mappedconsent')) + | ALTER TABLE mappedconsent DROP CONSTRAINT uq_mappedconsent_consent_reference_id; + | ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL; + |END""".stripMargin } else { "ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id SET NOT NULL;" } sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => setNotNull)).append("\n") - sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => + val addConstraint = if (isMssql) { + """IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'mappedconsent_consent_reference_id') + | AND NOT EXISTS (SELECT 1 FROM sys.key_constraints WHERE name = 'uq_mappedconsent_consent_reference_id' AND parent_object_id = OBJECT_ID('mappedconsent')) + | ALTER TABLE mappedconsent ADD CONSTRAINT uq_mappedconsent_consent_reference_id UNIQUE (consent_reference_id);""".stripMargin + } else { "ALTER TABLE mappedconsent ADD CONSTRAINT uq_mappedconsent_consent_reference_id UNIQUE (consent_reference_id);" - )).append("\n") + } + sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addConstraint)).append("\n") // 6. Widen consent_item.consent_reference_id from BIGINT to VARCHAR(36). val alterConsentItem = if (isMssql) { - "ALTER TABLE consent_item ALTER COLUMN consent_reference_id VARCHAR(36);" + """IF EXISTS (SELECT 1 FROM sys.columns c JOIN sys.types t ON c.user_type_id = t.user_type_id WHERE c.object_id = OBJECT_ID('consent_item') AND c.name = 'consent_reference_id' AND t.name NOT IN ('varchar', 'nvarchar')) + | ALTER TABLE consent_item ALTER COLUMN consent_reference_id VARCHAR(36);""".stripMargin } else { "ALTER TABLE consent_item ALTER COLUMN consent_reference_id TYPE VARCHAR(36) USING consent_reference_id::text;" } @@ -146,14 +160,16 @@ object MigrationOfConsentReferenceIdUuid { } sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => backfillConsentItem)).append("\n") - // 8. Recreate v_consent with the new column projection. - MigrationOfConsentView.addConsentView(name + "_view_rebuild") - + // 8. v_consent is recreated in the finally block below, with the new column projection. isSuccessful = true } catch { case e: Exception => isSuccessful = false sqlLog.append(s"\nException: ${e.getMessage}\n") + } finally { + // Runs on success and on failure: step 2 dropped v_consent, so a failure part-way must not + // leave it missing. + MigrationOfConsentView.addConsentView(name + "_view_rebuild") } val endDate = System.currentTimeMillis() diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfCurrencyCodesUpperCase.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfCurrencyCodesUpperCase.scala index 595cab0748..00946648c0 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfCurrencyCodesUpperCase.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfCurrencyCodesUpperCase.scala @@ -114,7 +114,11 @@ object MigrationOfCurrencyCodesUpperCase extends MdcLoggable { private def runUpdate(sql: String): Int = DB.use(DefaultConnectionIdentifier) { connection => val statement = connection.createStatement() - try statement.executeUpdate(sql) finally statement.close() + try { + val updated = statement.executeUpdate(sql) + connection.commit() + updated + } finally statement.close() } /** @@ -177,8 +181,18 @@ object MigrationOfCurrencyCodesUpperCase extends MdcLoggable { missingColumns += s"$qualified (no such column)" logger.info(s"upperCaseEverywhere says: $qualified skipped, the column does not exist on this database") } else { - val needsChange = s"$column <> UPPER(TRIM($column))" - val twinExists = s"EXISTS (SELECT 1 FROM $table twin WHERE twin.$column = UPPER(TRIM($table.$column)))" + // SQL Server compares strings with trailing spaces ignored, whatever the collation, so `'EUR '` + // equals `'EUR'`. The byte length tells them apart. The binary collation makes the case count. + val needsChange = if (code.api.util.DBUtil.isSqlServer) { + s"($column COLLATE Latin1_General_BIN <> UPPER(TRIM($column)) OR DATALENGTH($column) <> DATALENGTH(TRIM($column)))" + } else { + s"$column <> UPPER(TRIM($column))" + } + val twinExists = if (code.api.util.DBUtil.isSqlServer) { + s"EXISTS (SELECT 1 FROM $table twin WHERE twin.$column COLLATE Latin1_General_BIN = UPPER(TRIM($table.$column)) AND DATALENGTH(twin.$column) = DATALENGTH(TRIM($table.$column)))" + } else { + s"EXISTS (SELECT 1 FROM $table twin WHERE twin.$column = UPPER(TRIM($table.$column)))" + } val toChangeCondition = if (currencyColumn.isPrimaryKey) s"$needsChange AND NOT $twinExists" else needsChange val toChange = valueCounts(table, column, toChangeCondition) val changed = if (toChange.isEmpty) 0 else runUpdate(s"UPDATE $table SET $column = UPPER(TRIM($column)) WHERE $toChangeCondition") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicDataIdFieldLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicDataIdFieldLength.scala index a5a4ffe017..30c3610084 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicDataIdFieldLength.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicDataIdFieldLength.scala @@ -63,7 +63,22 @@ object MigrationOfDynamicDataIdFieldLength { case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => () => """ - |ALTER TABLE dynamicdata ALTER COLUMN dynamicdataid varchar(255); + |IF EXISTS ( + | SELECT 1 FROM sys.columns c + | JOIN sys.types t ON c.user_type_id = t.user_type_id + | WHERE c.object_id = OBJECT_ID('dynamicdata') + | AND c.name = 'dynamicdataid' + | AND (t.name <> 'nvarchar' OR c.max_length < 510) + |) + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'dynamicdata_bankid_dynamicentityname_dynamicdataid' AND object_id = OBJECT_ID('dynamicdata')) + | DROP INDEX dynamicdata_bankid_dynamicentityname_dynamicdataid ON dynamicdata; + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'dynamicdata_dynamicdataid' AND object_id = OBJECT_ID('dynamicdata')) + | DROP INDEX dynamicdata_dynamicdataid ON dynamicdata; + | ALTER TABLE dynamicdata ALTER COLUMN dynamicdataid NVARCHAR(255); + | CREATE UNIQUE INDEX dynamicdata_bankid_dynamicentityname_dynamicdataid + | ON dynamicdata (bankid, dynamicentityname, dynamicdataid); + |END; |""".stripMargin case _ => () => diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala index 2629411e3c..634f5cf4fe 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricAuthType.scala @@ -60,14 +60,16 @@ object MigrationOfMetricAuthType { try { val addColumnMetric = if (isMssql) { - "ALTER TABLE metric ADD auth_type VARCHAR(32) NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metric') AND name = 'auth_type') + | ALTER TABLE metric ADD auth_type VARCHAR(32) NULL;""".stripMargin } else { "ALTER TABLE metric ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);" } sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => addColumnMetric)).append("\n") val addColumnArchive = if (isMssql) { - "ALTER TABLE metricarchive ADD auth_type VARCHAR(32) NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metricarchive') AND name = 'auth_type') + | ALTER TABLE metricarchive ADD auth_type VARCHAR(32) NULL;""".stripMargin } else { "ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS auth_type VARCHAR(32);" } diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala index c00928a8d5..511f6ef100 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricCertificateTrust.scala @@ -71,10 +71,10 @@ object MigrationOfMetricCertificateTrust { // in the codebase (Schemifier emits unquoted DDL, so Postgres folds to lowercase). val statements = if (isMssql) List( - "ALTER TABLE metric ADD certificate_trust VARCHAR(32) NULL;", - "ALTER TABLE metric ADD certificate_trust_detail VARCHAR(255) NULL;", - "ALTER TABLE metricarchive ADD certificate_trust VARCHAR(32) NULL;", - "ALTER TABLE metricarchive ADD certificate_trust_detail VARCHAR(255) NULL;" + "IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metric') AND name = 'certificate_trust') ALTER TABLE metric ADD certificate_trust VARCHAR(32) NULL;", + "IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metric') AND name = 'certificate_trust_detail') ALTER TABLE metric ADD certificate_trust_detail VARCHAR(255) NULL;", + "IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metricarchive') AND name = 'certificate_trust') ALTER TABLE metricarchive ADD certificate_trust VARCHAR(32) NULL;", + "IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metricarchive') AND name = 'certificate_trust_detail') ALTER TABLE metricarchive ADD certificate_trust_detail VARCHAR(255) NULL;" ) else List( "ALTER TABLE metric ADD COLUMN IF NOT EXISTS certificate_trust VARCHAR(32);", "ALTER TABLE metric ADD COLUMN IF NOT EXISTS certificate_trust_detail VARCHAR(255);", diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsentReferenceId.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsentReferenceId.scala index e050025e6f..663c57fa02 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsentReferenceId.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsentReferenceId.scala @@ -63,7 +63,7 @@ object MigrationOfMetricConsentReferenceId { // (MetricBatchWriter INSERT, DoobieMetricsQueries, MigrationOfMetricView) references it // as lowercase unquoted `metric` — mirror that here. MSSQL is case-insensitive by default. val backupMetric = if (isMssql) { - "SELECT * INTO backup_2026_05_metric FROM metric;" + "IF OBJECT_ID('backup_2026_05_metric', 'U') IS NULL SELECT * INTO backup_2026_05_metric FROM metric;" } else { "CREATE TABLE backup_2026_05_metric AS SELECT * FROM metric;" } @@ -71,7 +71,8 @@ object MigrationOfMetricConsentReferenceId { // 2. Add the new column to the live metric table. val addColumnMetric = if (isMssql) { - "ALTER TABLE metric ADD consent_reference_id VARCHAR(36) NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metric') AND name = 'consent_reference_id') + | ALTER TABLE metric ADD consent_reference_id VARCHAR(36) NULL;""".stripMargin } else { "ALTER TABLE metric ADD COLUMN IF NOT EXISTS consent_reference_id VARCHAR(36);" } @@ -79,7 +80,8 @@ object MigrationOfMetricConsentReferenceId { // 3. Add the new column to the archive table. val addColumnArchive = if (isMssql) { - "ALTER TABLE metricarchive ADD consent_reference_id VARCHAR(36) NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('metricarchive') AND name = 'consent_reference_id') + | ALTER TABLE metricarchive ADD consent_reference_id VARCHAR(36) NULL;""".stripMargin } else { "ALTER TABLE metricarchive ADD COLUMN IF NOT EXISTS consent_reference_id VARCHAR(36);" } @@ -87,14 +89,16 @@ object MigrationOfMetricConsentReferenceId { // 4. Index for search-by-consent on both tables. val indexMetric = if (isMssql) { - "CREATE INDEX idx_metric_consent_reference_id ON metric(consent_reference_id);" + """IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_metric_consent_reference_id' AND object_id = OBJECT_ID('metric')) + | CREATE INDEX idx_metric_consent_reference_id ON metric(consent_reference_id);""".stripMargin } else { "CREATE INDEX IF NOT EXISTS idx_metric_consent_reference_id ON metric(consent_reference_id);" } sqlLog.append(DbFunction.maybeWrite(true, Schemifier.infoF _)(() => indexMetric)).append("\n") val indexArchive = if (isMssql) { - "CREATE INDEX idx_metricarchive_consent_reference_id ON metricarchive(consent_reference_id);" + """IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_metricarchive_consent_reference_id' AND object_id = OBJECT_ID('metricarchive')) + | CREATE INDEX idx_metricarchive_consent_reference_id ON metricarchive(consent_reference_id);""".stripMargin } else { "CREATE INDEX IF NOT EXISTS idx_metricarchive_consent_reference_id ON metricarchive(consent_reference_id);" } diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsumerIdFieldLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsumerIdFieldLength.scala index 8ec4c4c46e..e4c8baa79d 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsumerIdFieldLength.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMetricConsumerIdFieldLength.scala @@ -70,8 +70,17 @@ object MigrationOfMetricConsumerIdFieldLength { // 2. Widen metric.consumerid to match Consumer.consumerId (250). val alterMetricSql = DbFunction.maybeWrite(true, Schemifier.infoF _) { () => - if (isSqlServer) "ALTER TABLE metric ALTER COLUMN consumerid varchar(250);" - else "ALTER TABLE metric ALTER COLUMN consumerid TYPE character varying(250);" + if (isSqlServer) { + """ + |IF COLUMNPROPERTY(OBJECT_ID('metric'), 'consumerid', 'charmaxlen') < 250 + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'Metric_consumerid' AND object_id = OBJECT_ID('metric')) + | DROP INDEX Metric_consumerid ON metric; + | ALTER TABLE metric ALTER COLUMN consumerid NVARCHAR(250); + | CREATE INDEX Metric_consumerid ON metric (consumerid); + |END; + |""".stripMargin + } else "ALTER TABLE metric ALTER COLUMN consumerid TYPE character varying(250);" } // 3. Widen metricarchive.consumerid too — the archiver copies metric rows verbatim, @@ -80,8 +89,17 @@ object MigrationOfMetricConsumerIdFieldLength { val alterArchiveSql = if (DbFunction.tableExists(MetricArchive)) { DbFunction.maybeWrite(true, Schemifier.infoF _) { () => - if (isSqlServer) "ALTER TABLE metricarchive ALTER COLUMN consumerid varchar(250);" - else "ALTER TABLE metricarchive ALTER COLUMN consumerid TYPE character varying(250);" + if (isSqlServer) { + """ + |IF COLUMNPROPERTY(OBJECT_ID('metricarchive'), 'consumerid', 'charmaxlen') < 250 + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'MetricArchive_consumerid' AND object_id = OBJECT_ID('metricarchive')) + | DROP INDEX MetricArchive_consumerid ON metricarchive; + | ALTER TABLE metricarchive ALTER COLUMN consumerid NVARCHAR(250); + | CREATE INDEX MetricArchive_consumerid ON metricarchive (consumerid); + |END; + |""".stripMargin + } else "ALTER TABLE metricarchive ALTER COLUMN consumerid TYPE character varying(250);" } } else { s"${MetricArchive._dbTableNameLC} table does not exist; skipped" diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala index 8f9e95c89c..58295ca505 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfRoleNameFieldLength.scala @@ -74,9 +74,37 @@ object MigrationOfRoleNameFieldLength { case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => () => """ - |ALTER TABLE mappedentitlement ALTER COLUMN mrolename varchar(255); - |ALTER TABLE mappedentitlementrequest ALTER COLUMN mrolename varchar(255); - |ALTER TABLE mappedscope ALTER COLUMN mrolename varchar(255); + |IF EXISTS ( + | SELECT 1 FROM sys.columns c + | JOIN sys.types t ON c.user_type_id = t.user_type_id + | WHERE c.object_id = OBJECT_ID('mappedentitlement') + | AND c.name = 'mrolename' + | AND (t.name <> 'nvarchar' OR c.max_length < 510) + |) + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'mappedentitlement_mbankid_muserid_mrolename' AND object_id = OBJECT_ID('mappedentitlement')) + | DROP INDEX mappedentitlement_mbankid_muserid_mrolename ON mappedentitlement; + | ALTER TABLE mappedentitlement ALTER COLUMN mrolename NVARCHAR(255); + | IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'mappedentitlement_mbankid_muserid_mrolename' AND object_id = OBJECT_ID('mappedentitlement')) + | CREATE UNIQUE INDEX mappedentitlement_mbankid_muserid_mrolename + | ON mappedentitlement (mbankid, muserid, mrolename); + |END; + |IF EXISTS ( + | SELECT 1 FROM sys.columns c + | JOIN sys.types t ON c.user_type_id = t.user_type_id + | WHERE c.object_id = OBJECT_ID('mappedentitlementrequest') + | AND c.name = 'mrolename' + | AND (t.name <> 'nvarchar' OR c.max_length < 510) + |) + | ALTER TABLE mappedentitlementrequest ALTER COLUMN mrolename NVARCHAR(255); + |IF EXISTS ( + | SELECT 1 FROM sys.columns c + | JOIN sys.types t ON c.user_type_id = t.user_type_id + | WHERE c.object_id = OBJECT_ID('mappedscope') + | AND c.name = 'mrolename' + | AND (t.name <> 'nvarchar' OR c.max_length < 510) + |) + | ALTER TABLE mappedscope ALTER COLUMN mrolename NVARCHAR(255); |""".stripMargin case _ => () => @@ -104,4 +132,4 @@ object MigrationOfRoleNameFieldLength { saveLog(name, commitId, isSuccessful, startDate, endDate, comment) isSuccessful } -} \ No newline at end of file +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 7707a67d76..61ea354884 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -5461,12 +5461,22 @@ object Http4s600 { _ <- Helper.booleanToFuture(CannotDeleteCascadePersonalEntity, cc = Some(cc)) { !entity.hasPersonalEntity } - (box, _) <- NewStyle.function.invokeDynamicConnector( - com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, - entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + // The records are read from the table so that each one comes with the id of its row. The stored JSON + // of a record written by an earlier version may lack `_id`; its row id is put back, because + // the backup and the delete below both go by that id. + idField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) resultList <- Future { - box.asInstanceOf[net.liftweb.common.Box[org.json4s.JsonAST.JArray]] - .openOrThrowException(s"$UnknownError ") + org.json4s.JsonAST.JArray( + code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend + .getAll(entity.bankId, entity.entityName, None, false) + .map { row => + val stored = com.openbankproject.commons.util.JsonAliases.parse(row.dataJson).asInstanceOf[org.json4s.JObject] + (stored \ idField, row.dynamicDataId) match { + case (org.json4s.JString(_), _) => stored + case (_, Some(rowId)) => org.json4s.JObject(stored.obj.filterNot(_._1 == idField) :+ org.json4s.JField(idField, org.json4s.JString(rowId))) + case _ => stored + } + }) } _ <- Future { if (!entity.entityName.startsWith("ZZ_BAK_")) @@ -5474,7 +5484,6 @@ object Http4s600 { } _ <- Future.sequence { resultList.arr.map { record => - val idField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) val recordId = (record \ idField).asInstanceOf[org.json4s.JString].s Future(code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.delete( entity.bankId, entity.entityName, recordId, None, false)) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityWebhooks.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityWebhooks.scala index 367918d6e2..8a0ce74a58 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityWebhooks.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700DynamicEntityWebhooks.scala @@ -29,7 +29,7 @@ package code.api.v7_0_0 import cats.effect.IO import code.api.Constant.ApiPathZero import code.api.dynamic.entity.helper.DynamicEntitySpace -import code.api.util.APIUtil.{EmptyBody, ResourceDoc} +import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc} import code.api.util.ApiRole._ import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -174,7 +174,7 @@ object Http4s700DynamicEntityWebhooks { InvalidDynamicEntityWebhookDescription, InvalidDynamicEntityWebhookUrl, DynamicEntityWebhookUrlAlreadyExists, UnknownError) // Route: POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks (201) - lazy val createDynamicEntityWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createDynamicEntityWebhook: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" => EndpointHelpers.withUserAndBodyCreated[PostDynamicEntityWebhookJsonV700, DynamicEntityWebhookJsonV700](req) { (user, body, cc) => val spaceId = space(bankIdInUrl) @@ -211,7 +211,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks - lazy val getDynamicEntityWebhooks: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEntityWebhooks: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" => EndpointHelpers.withUser(req) { (_, cc) => for { @@ -239,7 +239,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entity-web-hooks - lazy val getDynamicEntityWebhooksAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEntityWebhooksAtBank: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entity-web-hooks" => EndpointHelpers.withUser(req) { (_, cc) => Future(provider.getByBank(space(bankIdInUrl))).map(box => @@ -265,7 +265,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID - lazy val getDynamicEntityWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEntityWebhook: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId => EndpointHelpers.withUser(req) { (_, cc) => for { @@ -294,7 +294,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: PUT /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID - lazy val updateDynamicEntityWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateDynamicEntityWebhook: Http4sRoute = Http4sRoute { case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId => EndpointHelpers.withUserAndBody[PutDynamicEntityWebhookJsonV700, DynamicEntityWebhookJsonV700](req) { (_, body, cc) => val spaceId = space(bankIdInUrl) @@ -341,7 +341,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID (204) - lazy val deleteDynamicEntityWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteDynamicEntityWebhook: Http4sRoute = Http4sRoute { case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId => EndpointHelpers.withUserDelete(req) { (_, cc) => val spaceId = space(bankIdInUrl) @@ -372,7 +372,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID/test (201) - lazy val testDynamicEntityWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val testDynamicEntityWebhook: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId / "test" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -411,7 +411,7 @@ object Http4s700DynamicEntityWebhooks { private val deliveryStatuses = List(MessageOutbox.STATUS_PENDING, MessageOutbox.STATUS_DELIVERED, MessageOutbox.STATUS_STICKY) // Route: GET /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID/deliveries - lazy val getDynamicEntityWebhookDeliveries: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEntityWebhookDeliveries: Http4sRoute = Http4sRoute { case req @ GET -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId / "deliveries" => EndpointHelpers.withUser(req) { (_, cc) => val query = req.uri.query.params @@ -459,7 +459,7 @@ object Http4s700DynamicEntityWebhooks { ).allowSystemSpace() // Route: POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks/WEBHOOK_ID/deliveries/EVENT_ID/retry - lazy val retryDynamicEntityWebhookDelivery: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val retryDynamicEntityWebhookDelivery: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "banks" / bankIdInUrl / "dynamic-entities" / dynamicEntityId / "web-hooks" / webhookId / "deliveries" / eventId / "retry" => EndpointHelpers.executeAndRespond(req) { implicit cc => for { diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala index 8a7058c627..774c14bc89 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala @@ -33,17 +33,18 @@ import doobie.implicits._ object DoobieBadLoginAttemptQueries { - private def atomicIncrement(provider: String, username: String): ConnectionIO[Int] = + private def atomicIncrement(provider: String, username: String): ConnectionIO[Int] = { + val selectLock = + fr"SELECT mbadattemptssincelastsuccessorreset FROM mappedbadloginattempt" ++ DoobieUtil.lockHint ++ + fr"WHERE provider = $provider AND musername = $username" ++ DoobieUtil.forUpdate for { - _ <- sql"""SELECT mbadattemptssincelastsuccessorreset - FROM mappedbadloginattempt - WHERE provider = $provider AND musername = $username - FOR UPDATE""".query[Int].option + _ <- selectLock.query[Int].option rows <- sql"""UPDATE mappedbadloginattempt SET mbadattemptssincelastsuccessorreset = mbadattemptssincelastsuccessorreset + 1, - mlastfailuredate = NOW() + mlastfailuredate = CURRENT_TIMESTAMP WHERE provider = $provider AND musername = $username""".update.run } yield rows + } def incrementBadLoginAttempts(provider: String, username: String): Int = DoobieUtil.runUpdate(atomicIncrement(provider, username)) diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala index f8448ff952..04f348b282 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala @@ -44,9 +44,12 @@ object DoobieBankAccountQueries { * @return The new balance after the update */ def atomicallyUpdateBalance(bankId: String, accountId: String, amount: Long): ConnectionIO[Long] = { + val selectLock = + fr"SELECT accountbalance FROM mappedbankaccount" ++ DoobieUtil.lockHint ++ + fr"WHERE bank = $bankId AND theaccountid = $accountId" ++ DoobieUtil.forUpdate for { // 1. Lock the row and get the current balance - currentBalance <- sql"SELECT accountbalance FROM mappedbankaccount WHERE bank = $bankId AND theaccountid = $accountId FOR UPDATE".query[Long].unique + currentBalance <- selectLock.query[Long].unique newBalance = currentBalance + amount diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieBusinessStatusQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBusinessStatusQueries.scala index 4b81a8cd55..1473ebf835 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBusinessStatusQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBusinessStatusQueries.scala @@ -54,7 +54,7 @@ object DoobieBusinessStatusQueries { SET status = $newStatus, checkeruserid = $checkerUserId, checkercomment = $checkerComment, - updatedat = NOW() + updatedat = CURRENT_TIMESTAMP WHERE id = $accountAccessRequestId AND status = $guardStatus""".update.run ) @@ -71,8 +71,8 @@ object DoobieBusinessStatusQueries { SET status = $newStatus, checkeruserid = $checkerUserId, checkercomment = $checkerComment, - actionedat = NOW(), - updatedat = NOW() + actionedat = CURRENT_TIMESTAMP, + updatedat = CURRENT_TIMESTAMP WHERE id = $dynamicChangeRequestId AND status = $guardStatus""".update.run ) @@ -82,7 +82,7 @@ object DoobieBusinessStatusQueries { DoobieUtil.runUpdate( sql"""UPDATE mappedaccountapplication SET mstatus = $newStatus, - updatedat = NOW() + updatedat = CURRENT_TIMESTAMP WHERE id = $accountApplicationId AND mstatus = $guardStatus""".update.run ) @@ -96,7 +96,7 @@ object DoobieBusinessStatusQueries { sql"""UPDATE ExpectedChallengeAnswer SET successful_c = ${true}, scastatus = $finalisedScaStatus, - updatedat = NOW() + updatedat = CURRENT_TIMESTAMP WHERE challengeid = $challengeId AND successful_c = ${false}""".update.run ) diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala index b5ee5ba6c6..071f2db8a2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala @@ -35,18 +35,19 @@ import net.liftweb.util.Helpers.tryo object DoobieChallengeQueries { - private def incrementAndSelectCounter(challengeId: String): ConnectionIO[Int] = + private def incrementAndSelectCounter(challengeId: String): ConnectionIO[Int] = { + val selectLock = + fr"SELECT attemptcounter FROM ExpectedChallengeAnswer" ++ DoobieUtil.lockHint ++ + fr"WHERE challengeid = $challengeId" ++ DoobieUtil.forUpdate for { - _ <- sql"""SELECT attemptcounter - FROM ExpectedChallengeAnswer - WHERE challengeid = $challengeId - FOR UPDATE""".query[Int].option + _ <- selectLock.query[Int].option _ <- sql"""UPDATE ExpectedChallengeAnswer SET attemptcounter = attemptcounter + 1 WHERE challengeid = $challengeId""".update.run counter <- sql"""SELECT attemptcounter FROM ExpectedChallengeAnswer WHERE challengeid = $challengeId""".query[Int].unique } yield counter + } def incrementAndGetChallengeCounter(challengeId: String): Int = DoobieUtil.runUpdate(incrementAndSelectCounter(challengeId)) diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieConsentSchedulerQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieConsentSchedulerQueries.scala index fb5691cfb1..12c61bd769 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieConsentSchedulerQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieConsentSchedulerQueries.scala @@ -42,7 +42,7 @@ object DoobieConsentSchedulerQueries { sql"""UPDATE mappedconsent SET mstatus = $newStatus, mnote = $newNote, - mstatusupdatedatetime = NOW() + mstatusupdatedatetime = CURRENT_TIMESTAMP WHERE id = $consentPrimaryKey AND mstatus = $guardStatus""".update.run ) @@ -54,7 +54,7 @@ object DoobieConsentSchedulerQueries { sql"""UPDATE mappedconsent SET mstatus = ${"expired"}, mnote = $newNote, - mstatusupdatedatetime = NOW() + mstatusupdatedatetime = CURRENT_TIMESTAMP WHERE id = $consentPrimaryKey AND mstatus IN ('valid', 'VALID')""".update.run ) diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieConsentStatusQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieConsentStatusQueries.scala index 96b1523504..5d725363ae 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieConsentStatusQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieConsentStatusQueries.scala @@ -50,8 +50,8 @@ object DoobieConsentStatusQueries { DoobieUtil.runUpdate( sql"""UPDATE mappedconsent SET mstatus = $newStatus, - mlastactiondate = NOW(), - updatedat = NOW() + mlastactiondate = CURRENT_TIMESTAMP, + updatedat = CURRENT_TIMESTAMP WHERE id = $consentPrimaryKey AND mstatus = $guardStatus""".update.run ) @@ -64,8 +64,8 @@ object DoobieConsentStatusQueries { DoobieUtil.runUpdate( sql"""UPDATE mappedconsent SET mstatus = $newStatus, - mlastactiondate = NOW(), - updatedat = NOW() + mlastactiondate = CURRENT_TIMESTAMP, + updatedat = CURRENT_TIMESTAMP WHERE mconsentid = $consentId AND mstatus = $guardStatus""".update.run ) @@ -75,8 +75,8 @@ object DoobieConsentStatusQueries { DoobieUtil.runUpdate( sql"""UPDATE mappedconsent SET mstatus = $revokedStatus, - mlastactiondate = NOW(), - updatedat = NOW() + mlastactiondate = CURRENT_TIMESTAMP, + updatedat = CURRENT_TIMESTAMP WHERE id = $consentPrimaryKey AND mstatus <> $revokedStatus""".update.run ) diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala index 7213dd604d..27c495357a 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala @@ -47,7 +47,10 @@ object DoobieTransactionRequestQueries extends MdcLoggable { * "lock failed" 400. */ def atomicallyLockTransactionRequest(transReqId: String): ConnectionIO[Option[String]] = { - sql"SELECT mstatus FROM mappedtransactionrequest WHERE mtransactionrequestid = $transReqId FOR UPDATE".query[String].option + val selectLock = + fr"SELECT mstatus FROM mappedtransactionrequest" ++ DoobieUtil.lockHint ++ + fr"WHERE mtransactionrequestid = $transReqId" ++ DoobieUtil.forUpdate + selectLock.query[String].option } /** diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieUserAuthContextUpdateQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieUserAuthContextUpdateQueries.scala index 590dee5fa2..2b4f64d245 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieUserAuthContextUpdateQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieUserAuthContextUpdateQueries.scala @@ -44,7 +44,7 @@ object DoobieUserAuthContextUpdateQueries { DoobieUtil.runUpdate( sql"""UPDATE mappeduserauthcontextupdate SET mstatus = $newStatus, - updatedat = NOW() + updatedat = CURRENT_TIMESTAMP WHERE id = $userAuthContextUpdateId AND mstatus = $guardStatus""".update.run ) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 1e45fcd475..7d2f1383d5 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1349,6 +1349,26 @@ object LocalMappedConnector extends Connector with MdcLoggable { maps } + val ownersConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CAST(CONCAT('user_id:', resourceuser.userid_, ',provider:', resourceuser.provider_, ',user_name:', resourceuser.name_) AS NVARCHAR(MAX))" + } else { + fr"'user_id:' || resourceuser.userid_ || ',provider:' || resourceuser.provider_ || ',user_name:' || resourceuser.name_" + } + + val routingConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CAST(CONCAT('bank_id:', bankaccountrouting.bankid, ',account_id:', bankaccountrouting.accountid) AS NVARCHAR(MAX))" + } else { + fr"'bank_id:' || bankaccountrouting.bankid || ',account_id:' || bankaccountrouting.accountid" + } + + val attrConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CAST(CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue) AS NVARCHAR(MAX))" + } else { + fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" + } + + val pagination = DoobieUtil.pagination(limit, offset) + val query = (fr""" select mappedbankaccount.theaccountid as account_id, @@ -1356,13 +1376,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { mappedbankaccount.accountlabel as account_label, mappedbankaccount.accountnumber as account_number, (select - string_agg( - 'user_id:' - || resourceuser.userid_ - ||',provider:' - ||resourceuser.provider_ - ||',user_name:' - ||resourceuser.name_, + string_agg(""" ++ ownersConcat ++ fr""", '::') as owners from resourceuser where @@ -1372,11 +1386,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { mappedbankaccount.accountcurrency as account_currency , mappedbankaccount.accountbalance as account_balance, (select - string_agg( - 'bank_id:' - ||bankaccountrouting.bankid - ||',account_id:' - ||bankaccountrouting.accountid, + string_agg(""" ++ routingConcat ++ fr""", '::' ) as account_routings from bankaccountrouting @@ -1384,13 +1394,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { bankaccountrouting.accountid = mappedbankaccount.theaccountid ), (select - string_agg( - 'type:' - || mappedaccountattribute.mtype - ||',code:' - ||mappedaccountattribute.mcode - ||',value:' - ||mappedaccountattribute.mvalue, + string_agg(""" ++ attrConcat ++ fr""", '::') as account_attributes from mappedaccountattribute where @@ -1400,10 +1404,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { LEFT JOIN mapperaccountholders ON (mappedbankaccount.bank = mapperaccountholders.accountbankpermalink and mappedbankaccount.theaccountid = mapperaccountholders.accountpermalink) WHERE mappedbankaccount.bank = ${bankId.value} - ORDER BY mappedbankaccount.theaccountid """ ++ Fragment.const(ordering) ++ fr""" - LIMIT $limit - OFFSET $offset - """) + ORDER BY mappedbankaccount.theaccountid """ ++ Fragment.const(ordering) ++ fr" " ++ pagination) .query[(Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Option[java.math.BigDecimal], Option[String], Option[String])] .to[List] .map(_.map { case (id, bankIdCol, label, number, owners, kind, currency, balance, routings, attributes) => @@ -1485,6 +1486,20 @@ object LocalMappedConnector extends Connector with MdcLoggable { maps } + val routingConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CAST(CONCAT('scheme:', bankaccountrouting.accountroutingscheme, ',address:', bankaccountrouting.accountroutingaddress) AS NVARCHAR(MAX))" + } else { + fr"'scheme:' || bankaccountrouting.accountroutingscheme || ',address:' || bankaccountrouting.accountroutingaddress" + } + + val attrConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CAST(CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue) AS NVARCHAR(MAX))" + } else { + fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" + } + + val pagination = DoobieUtil.pagination(limit, offset) + val query = (fr""" select mappedbankaccount.theaccountid as account_id, @@ -1494,11 +1509,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { mappedbankaccount.kind as kind, mappedbankaccount.mbranchid as branch_id, (select - string_agg( - 'scheme:' - ||bankaccountrouting.accountroutingscheme - ||',address:' - ||bankaccountrouting.accountroutingaddress, + string_agg(""" ++ routingConcat ++ fr""", '::' ) as account_routings from bankaccountrouting @@ -1507,13 +1518,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { and bankaccountrouting.bankid = mappedbankaccount.bank ), (select - string_agg( - 'type:' - || mappedaccountattribute.mtype - ||',code:' - ||mappedaccountattribute.mcode - ||',value:' - ||mappedaccountattribute.mvalue, + string_agg(""" ++ attrConcat ++ fr""", '::') as account_attributes from mappedaccountattribute where @@ -1522,9 +1527,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { from mappedbankaccount WHERE mappedbankaccount.bank = ${bankId.value} ORDER BY mappedbankaccount.theaccountid """ ++ Fragment.const(ordering) ++ fr""" - LIMIT $limit - OFFSET $offset - """) + """ ++ pagination) .query[(Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Option[String], Option[String])] .to[List] .map(_.map { case (id, bankIdCol, label, number, kind, branchId, routings, attributes) => diff --git a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala index 3a2592dc8e..251606039b 100644 --- a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala +++ b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala @@ -83,8 +83,10 @@ object DoobieChatMessageQueries extends MdcLoggable { val fromTs = new Timestamp(fromDate.getTime) val toTs = new Timestamp(toDate.getTime) + val pagination = DoobieUtil.pagination(limit, offset) + val messagesQuery: ConnectionIO[List[ChatMessageRow]] = - sql"""SELECT m.chatmessageid, m.chatroomid, m.senderuserid, m.senderconsumerid, + (sql"""SELECT m.chatmessageid, m.chatroomid, m.senderuserid, m.senderconsumerid, COALESCE(u.name_, ''), COALESCE(u.provider_, ''), COALESCE(c.name, ''), m.content, m.messagetype, m.mentioneduserids, m.replytomessageid, @@ -95,8 +97,7 @@ object DoobieChatMessageQueries extends MdcLoggable { WHERE m.chatroomid = $chatRoomId AND m.createdat >= $fromTs AND m.createdat <= $toTs - ORDER BY m.id ASC - LIMIT $limit OFFSET $offset""" + ORDER BY m.id ASC """ ++ pagination) .query[ChatMessageRow] .to[List] diff --git a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala index 126e72ac99..808b6dc647 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -175,7 +175,7 @@ object DoobieConsentQueries { val orderBy = buildOrderBy(sortField, sortDirection) val whereClause = fr"WHERE created_by_user_id = $userId " ++ statusCond - val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" LIMIT $limit OFFSET $offset" + val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -197,7 +197,7 @@ object DoobieConsentQueries { val orderBy = buildOrderBy(sortField, sortDirection) val whereClause = fr"WHERE 1=1 " ++ userCond ++ consumerCond ++ consentCond ++ statusCond - val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" LIMIT $limit OFFSET $offset" + val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -261,14 +261,17 @@ object DoobieConsentQueries { val orderBy = buildOrderBy(sortField, sortDirection) val dataQuery = - fr"""SELECT DISTINCT v.consent_reference_id, v.consent_id, v.created_by_user_id, v.consumer_id, + fr"""SELECT v.consent_reference_id, v.consent_id, v.created_by_user_id, v.consumer_id, v.status, v.jwt, v.consent_request_id, v.api_standard, v.api_version, v.last_action_date, v.last_usage_date, v.created_date, v.note, v.frequency_per_day, v.uses_so_far_today_counter, v.jwt_payload, v.jwt_expires_at FROM v_consent v - JOIN consent_item cb ON cb.consent_reference_id = v.consent_reference_id WHERE v.created_by_user_id = $userId - AND cb.bank_id = $bankId""" ++ statusCond ++ fr" " ++ orderBy ++ fr" LIMIT $limit OFFSET $offset" + AND EXISTS ( + SELECT 1 FROM consent_item cb + WHERE cb.consent_reference_id = v.consent_reference_id + AND cb.bank_id = $bankId + )""" ++ statusCond ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } diff --git a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala index 347f51b69f..c187f57112 100644 --- a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala +++ b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala @@ -39,7 +39,7 @@ import code.util.Helper.MdcLoggable import code.util.{MappedUUID, UUIDString} import com.github.dwickern.macros.NameOf import com.openbankproject.commons.model.{User, _} -import net.liftweb.common.{Box, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import net.liftweb.util.Helpers.tryo import net.liftweb.mapper.{By, MappedString,_} @@ -200,7 +200,7 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { case _ => CreditLimit(currency = "", amount = "") } - tryo { + try { val mappedCustomer = MappedCustomer .create .mBank(bankId.value) @@ -235,7 +235,11 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { CustomerDependants.CustomerDependants.vend .createCustomerDependants(mappedCustomer.id.get, dobOfDependents.map(CustomerDependant(_))) - mappedCustomer + Full(mappedCustomer) + } catch { + case scala.util.control.NonFatal(e) => + logger.error(s"addCustomer failed for bank=$bankId, number=$number: ${e.getMessage}", e) + Failure(s"addCustomer failed: ${e.getMessage}", Full(e), Empty) } } diff --git a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index d60cb9af16..d30bc9c1fd 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -215,7 +215,21 @@ object MappedDynamicDataProvider extends DynamicDataProvider with CustomJsonForm .openOrThrowException(s"$DynamicDataNotFound dynamicEntityName=$entityName, dynamicDataId=$id") .asInstanceOf[DynamicData] // Preserve the row's existing owner/personal flag — row-level access changes the data, not provenance. - saveOrUpdate(bankId, entityName, requestBody, Option(dynamicData.UserId.get), dynamicData.IsPersonalEntity.get, callerUserId, dynamicData) + saveOrUpdate(bankId, entityName, withRecordId(entityName, requestBody, id), Option(dynamicData.UserId.get), dynamicData.IsPersonalEntity.get, callerUserId, dynamicData) + } + + /** + * The stored JSON of a record carries the record's own id under `_id`, which is how a read gives + * it back and how a cascade delete finds the record. The regular write path adds it before the call + * (NewStyle.invokeDynamicConnector). The row-level endpoints write straight through this provider, and a + * PUT or PATCH body does not carry the id, so it is added here when it is missing. + */ + private def withRecordId(entityName: String, requestBody: JObject, id: String): JObject = { + val idName = getIdName(entityName) + (requestBody \ idName) match { + case JString(_) => requestBody + case _ => JObject(requestBody.obj.filterNot(_._1 == idName) :+ JField(idName, JString(id))) + } } override def getByIds(bankId: Option[String], entityName: String, ids: List[String]): List[DynamicDataT] = diff --git a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala index 8c0b5d8e01..b7aa033c93 100644 --- a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala +++ b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala @@ -149,6 +149,8 @@ object DoobieInvestigationQueries extends MdcLoggable { val accountIdFragments = accountIds.map(id => fr"$id") val inClause = accountIdFragments.reduceLeft((a, b) => a ++ fr"," ++ b) + val pagination = DoobieUtil.pagination(limit, 0) + val query: ConnectionIO[List[TransactionRow]] = (fr"""SELECT transactionid, bank, account, amount, currency, transactiontype, description, tstartdate, tfinishdate, @@ -160,8 +162,7 @@ object DoobieInvestigationQueries extends MdcLoggable { AND account IN (""" ++ inClause ++ fr""") AND tstartdate >= $fromDate AND tstartdate <= $toDate - ORDER BY tstartdate DESC - LIMIT $limit""") + ORDER BY tstartdate DESC """ ++ pagination) .query[TransactionRow] .to[List] diff --git a/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala index 7ebb20b15a..eafaa70e1b 100644 --- a/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala +++ b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala @@ -223,7 +223,7 @@ object MessageOutbox extends MessageOutbox with LongKeyedMetaMapper[MessageOutbo import doobie.implicits._ code.api.util.DoobieUtil.runUpdate( sql"""UPDATE message_outbox - SET attempts = attempts + 1, updated_at = NOW() + SET attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ${row.id.get} AND status = $STATUS_PENDING AND attempts = ${row.attempts}""".update.run) == 1 diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index 266c41890c..9dcfffc976 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.users -import code.api.util.DoobieUtil +import code.api.util.{DBUtil, DoobieUtil} import code.loginattempts.LoginAttempt import doobie._ import doobie.implicits._ @@ -150,7 +150,11 @@ object DoobieUserQueries { val emailFilter: Fragment = email.map(v => fr"AND ru.email = $v").getOrElse(Fragment.empty) val userIdFilter: Fragment = userId.map(v => fr"AND ru.userid_ = $v").getOrElse(Fragment.empty) val deletedFlag: Boolean = isDeleted.getOrElse(false) - val deletedFilter: Fragment = fr"AND (ru.isdeleted = $deletedFlag OR ru.isdeleted IS NULL AND $deletedFlag = false)" + val deletedFilter: Fragment = if (deletedFlag) { + fr"AND ru.isdeleted = ${true}" + } else { + fr"AND (ru.isdeleted = ${false} OR ru.isdeleted IS NULL)" + } val lockedFilter: Fragment = lockedStatus.map(_.toLowerCase) match { case Some("locked") => @@ -184,8 +188,16 @@ object DoobieUserQueries { val sortDir: String = if (sortAsc) "ASC" else "DESC" val orderBy: Fragment = Fragment.const(s"ORDER BY $sortColumn $sortDir") + val isLockedCase = if (DBUtil.isSqlServer) { + fr"$maxBadLogin THEN 1 ELSE 0 END AS is_locked" + } else { + fr"$maxBadLogin THEN true ELSE false END AS is_locked" + } + + val pagination = DoobieUtil.pagination(limit, offset) + val query: ConnectionIO[List[UserSearchRow]] = - (selectColumns ++ fr"$maxBadLogin THEN true ELSE false END AS is_locked" ++ + (selectColumns ++ isLockedCase ++ joinTables ++ fr"WHERE 1=1" ++ providerFilter ++ @@ -197,7 +209,7 @@ object DoobieUserQueries { roleFilter ++ notMintedByAConsentFilter ++ orderBy ++ - fr"LIMIT $limit OFFSET $offset") + pagination) .query[UserSearchRow] .to[List] diff --git a/obp-api/src/test/scala/code/api/util/DoobieUtilDialectTest.scala b/obp-api/src/test/scala/code/api/util/DoobieUtilDialectTest.scala new file mode 100644 index 0000000000..a4e41668b6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/DoobieUtilDialectTest.scala @@ -0,0 +1,76 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.util + +import code.setup.ServerSetup +import doobie.implicits._ + +/** + * The page clause and the row lock are written differently on SQL Server and elsewhere. These scenarios + * check what DoobieUtil renders for the database under test and that the database accepts it. + */ +class DoobieUtilDialectTest extends ServerSetup { + + private val fiveRows = fr"SELECT id FROM (SELECT 1 AS id UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) rows_under_test ORDER BY id" + + feature("Dialect fragments of DoobieUtil") { + + scenario("The page clause is the form the database under test takes") { + val rendered = DoobieUtil.pagination(2, 1).internals.sql.trim + if (DBUtil.isSqlServer) rendered shouldBe "OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + else rendered shouldBe "LIMIT ? OFFSET ?" + } + + scenario("A page of a query is the rows asked for, in order") { + val page = DoobieUtil.runQuery((fiveRows ++ fr" " ++ DoobieUtil.pagination(2, 1)).query[Int].to[List]) + page shouldBe List(2, 3) + + val firstPage = DoobieUtil.runQuery((fiveRows ++ fr" " ++ DoobieUtil.pagination(3, 0)).query[Int].to[List]) + firstPage shouldBe List(1, 2, 3) + } + + scenario("The row lock is a table hint on SQL Server and FOR UPDATE elsewhere, never both") { + val hint = DoobieUtil.lockHint.internals.sql.trim + val suffix = DoobieUtil.forUpdate.internals.sql.trim + if (DBUtil.isSqlServer) { + hint shouldBe "WITH (UPDLOCK, ROWLOCK)" + suffix shouldBe "" + } else { + hint shouldBe "" + suffix shouldBe "FOR UPDATE" + } + } + + scenario("A locking select with the hint and suffix in place is accepted by the database") { + val select = + fr"SELECT musername FROM mappedbadloginattempt" ++ DoobieUtil.lockHint ++ + fr"WHERE provider = ${"no-such-provider"}" ++ DoobieUtil.forUpdate + DoobieUtil.runUpdate(select.query[String].to[List]) shouldBe Nil + } + } +} diff --git a/obp-api/src/test/scala/code/api/util/SqlServerDateTimeUpgradeTest.scala b/obp-api/src/test/scala/code/api/util/SqlServerDateTimeUpgradeTest.scala new file mode 100644 index 0000000000..e88d128066 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/SqlServerDateTimeUpgradeTest.scala @@ -0,0 +1,170 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.util + +import code.setup.ServerSetup +import net.liftweb.db.DB +import net.liftweb.util.DefaultConnectionIdentifier + +import scala.collection.mutable.ListBuffer + +/** + * Lift Mapper creates DATETIME columns on SQL Server, which cannot hold a date before 1753. At boot every + * such column is upgraded to DATETIME2 (SqlServerDateTimeUpgrade). A column that an index, a default or a + * NOT NULL depends on must go through that upgrade with the dependents intact: SQL Server refuses to + * change the type of a column under an index (error 5074), and the upgrade must not turn a NOT NULL + * column into a nullable one. + * + * These scenarios talk to SQL Server catalog views, so they run only when the test database is SQL Server. + */ +class SqlServerDateTimeUpgradeTest extends ServerSetup { + + private val probe = "obp_datetime_probe" + + private def sqlServerOnly(): Unit = + if (!DBUtil.isSqlServer) cancel("This scenario inspects SQL Server catalog views and runs only on SQL Server") + + private def execute(sql: String): Unit = + DB.use(DefaultConnectionIdentifier) { connection => + val statement = connection.createStatement() + try { + statement.execute(sql) + connection.commit() + } finally statement.close() + } + + private def rows[A](sql: String)(read: java.sql.ResultSet => A): List[A] = + DB.use(DefaultConnectionIdentifier) { connection => + val statement = connection.createStatement() + try { + val result = statement.executeQuery(sql) + try { + val buffer = ListBuffer[A]() + while (result.next()) buffer += read(result) + buffer.toList + } finally result.close() + } finally statement.close() + } + + /** The type name and nullability of a column. */ + private def column(table: String, name: String): Option[(String, Boolean)] = + rows( + s"""SELECT ty.name, c.is_nullable + |FROM sys.columns c JOIN sys.types ty ON c.user_type_id = ty.user_type_id + |WHERE c.object_id = OBJECT_ID('$table') AND c.name = '$name'""".stripMargin + )(r => (r.getString(1), r.getBoolean(2))).headOption + + /** An index as (unique, key columns with direction, included columns). */ + private def index(table: String, name: String): Option[(Boolean, List[String], List[String])] = { + val parts = rows( + s"""SELECT i.is_unique, ic.is_included_column, ic.is_descending_key, c.name + |FROM sys.indexes i + |JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id + |JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id + |WHERE i.object_id = OBJECT_ID('$table') AND i.name = '$name' + |ORDER BY ic.is_included_column, ic.key_ordinal, c.column_id""".stripMargin + )(r => (r.getBoolean(1), r.getBoolean(2), r.getBoolean(3), r.getString(4))) + if (parts.isEmpty) None + else Some(( + parts.head._1, + parts.filterNot(_._2).map { case (_, _, descending, c) => if (descending) s"$c DESC" else c }, + parts.filter(_._2).map(_._4) + )) + } + + private def dropProbe(): Unit = execute(s"IF OBJECT_ID('$probe') IS NOT NULL DROP TABLE $probe") + + override def beforeAll(): Unit = { + super.beforeAll() + if (DBUtil.isSqlServer) dropProbe() + } + + override def afterAll(): Unit = { + if (DBUtil.isSqlServer) dropProbe() + super.afterAll() + } + + feature("Upgrading DATETIME columns to DATETIME2 on SQL Server") { + + scenario("A column under an index, with a default, or declared NOT NULL keeps all of that") { + sqlServerOnly() + Given("a table whose DATETIME columns are indexed, defaulted and NOT NULL") + execute( + s"""CREATE TABLE $probe ( + | id INT IDENTITY(1,1) PRIMARY KEY, + | d DATETIME NOT NULL, + | e DATETIME NULL CONSTRAINT df_${probe}_e DEFAULT (GETDATE()), + | f DATETIME NULL)""".stripMargin) + execute(s"CREATE UNIQUE INDEX ix_${probe}_d_e ON $probe (d, e)") + execute(s"CREATE INDEX ix_${probe}_f ON $probe (f DESC) INCLUDE (d)") + + When("the upgrade runs") + val result = SqlServerDateTimeUpgrade.upgradeAll() + + Then("none of the probe columns is reported as failed") + result.failed.filter(_.contains(probe)) shouldBe Nil + + And("every column is DATETIME2, and NOT NULL is still NOT NULL") + column(probe, "d") shouldBe Some(("datetime2", false)) + column(probe, "e") shouldBe Some(("datetime2", true)) + column(probe, "f") shouldBe Some(("datetime2", true)) + + And("both indexes are back as they were") + index(probe, s"ix_${probe}_d_e") shouldBe Some((true, List("d", "e"), Nil)) + index(probe, s"ix_${probe}_f") shouldBe Some((false, List("f DESC"), List("d"))) + + And("the default is still there") + rows(s"SELECT name FROM sys.default_constraints WHERE parent_object_id = OBJECT_ID('$probe')")(_.getString(1)) shouldBe List(s"df_${probe}_e") + + And("a date before 1753 can be stored") + execute(s"INSERT INTO $probe (d, e, f) VALUES ('1100-01-01', '1100-01-02', '1100-01-03')") + rows(s"SELECT COUNT(*) FROM $probe WHERE d < '1753-01-01'")(_.getInt(1)) shouldBe List(1) + + When("the upgrade runs again") + val second = SqlServerDateTimeUpgrade.upgradeAll() + + Then("there is nothing left to do for the probe") + (second.altered ++ second.failed).filter(_.contains(probe)) shouldBe Nil + } + + scenario("No DATETIME column is left in the tables of the database after boot") { + sqlServerOnly() + val remaining = rows( + """SELECT t.name + '.' + c.name + |FROM sys.columns c + |JOIN sys.tables t ON t.object_id = c.object_id + |JOIN sys.types ty ON c.user_type_id = ty.user_type_id + |WHERE ty.name = 'datetime' + |ORDER BY 1""".stripMargin + )(_.getString(1)) + withClue("columns still DATETIME after the boot upgrade: ") { + remaining shouldBe Nil + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala index aff6730ebf..a37e546316 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityRowLevelAccessTest.scala @@ -240,4 +240,72 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { } } + // ==================== Feature 6: a row-level record keeps its id through updates ==================== + + feature("Feature 6: updating a row-level record keeps its id, and the entity can be cascade-deleted") { + + // Cascade delete is refused for an entity that has personal endpoints, so this one has none. + val entityName = "test_rlc" + val rowLevelWithoutPersonal: JValue = + ("entity_name" -> entityName) ~ ("use_row_level_access" -> true) ~ ("has_personal_entity" -> false) ~ ("schema" -> simpleSchema) + + def createRecordOf(name: String): String = { + val resp = makePostRequest((dynamicEntity_Request / entityName).POST <@ (user1), write(("name" -> name): JValue)) + resp.code should equal(201) + (resp.body \ entityName \ s"${entityName}_id").extract[String] + } + + def idOf(body: JValue): JValue = body \ entityName \ s"${entityName}_id" + + def cascadeDelete(dynamicEntityId: String): Int = { + Entitlement.entitlement.vend.addEntitlement(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, resourceUser1.userId, CanDeleteCascadeDynamicEntityDefinition.toString) + makeDeleteRequest((v6_0_0_Request / "management" / "system-dynamic-entities" / "cascade" / dynamicEntityId).DELETE <@ (user1)).code + } + + scenario("6.1: PUT and PATCH keep the record id in the stored record, and cascade delete succeeds", VersionOfApi) { + val (createStatus, body) = createSystemEntity(rowLevelWithoutPersonal) + createStatus should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + val id = createRecordOf("before") + + When("the record is replaced with PUT, whose body carries no id") + makePutRequest((dynamicEntity_Request / entityName / id).PUT <@ (user1), write(("name" -> "after put"): JValue)).code should equal(200) + + Then("a GET still returns the record with its id") + val afterPut = makeGetRequest((dynamicEntity_Request / entityName / id).GET <@ (user1)) + afterPut.code should equal(200) + idOf(afterPut.body) should equal(JString(id)) + (afterPut.body \ entityName \ "name").extract[String] should equal("after put") + + When("the record is changed with PATCH") + makePatchRequest((dynamicEntity_Request / entityName / id).PATCH <@ (user1), write(("name" -> "after patch"): JValue)).code should equal(200) + + Then("a GET still returns the record with its id") + val afterPatch = makeGetRequest((dynamicEntity_Request / entityName / id).GET <@ (user1)) + idOf(afterPatch.body) should equal(JString(id)) + (afterPatch.body \ entityName \ "name").extract[String] should equal("after patch") + + And("the entity can be deleted with its records") + cascadeDelete(dynamicEntityId) should equal(200) + } + + scenario("6.2: a record whose stored JSON already lost its id is still cascade-deleted", VersionOfApi) { + val (createStatus, body) = createSystemEntity(rowLevelWithoutPersonal) + createStatus should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + val id = createRecordOf("damaged") + + Given("a record written by an earlier version, whose stored JSON has no id") + val row = code.DynamicData.DynamicData.find( + net.liftweb.mapper.By(code.DynamicData.DynamicData.DynamicEntityName, entityName), + net.liftweb.mapper.By(code.DynamicData.DynamicData.DynamicDataId, id) + ).openOrThrowException("the record should exist") + row.DataJson("""{"name":"damaged"}""").save + + When("the entity is deleted with its records") + Then("it succeeds instead of failing on the missing id") + cascadeDelete(dynamicEntityId) should equal(200) + } + } + } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala index 0aac2e6966..7f753a272b 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntitySystemLevelBankIdTest.scala @@ -174,7 +174,7 @@ class DynamicEntitySystemLevelBankIdTest extends ServerSetup { Given("a row whose bank id is a SQL NULL, as an instance written before the sentinel existed") DB.use(DefaultConnectionIdentifier) { connection => val statement = connection.prepareStatement("UPDATE dynamicentity SET bankid = NULL WHERE dynamicentityid = ?") - try { statement.setString(1, id); statement.executeUpdate() } finally statement.close() + try { statement.setString(1, id); statement.executeUpdate(); connection.commit() } finally statement.close() } storedBankIdOf(id) should equal(None) diff --git a/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala b/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala index 0beefb1479..e71fdf469a 100644 --- a/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala +++ b/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala @@ -2,6 +2,7 @@ package code.asset import code.api.util.APIUtil import code.api.util.migration.MigrationOfCurrencyCodesUpperCase +import code.customer.MappedCustomer import code.fx.{MappedCurrency, MappedFXRate} import code.migration.MigrationScriptLog import code.setup.ServerSetup @@ -81,7 +82,25 @@ class CurrencyCodesUpperCaseMigrationTest extends ServerSetup { secondRemark should not include ("mappedfxrate.mfromcurrencycode: ") } + scenario("A code with spaces around it is trimmed, including when it is already in upper case") { + Given("credit limit currencies stored as 'EUR ' (upper case, trailing space), ' usd' and 'gbp '") + List("EUR " -> "1", " usd" -> "2", "gbp " -> "3").foreach { case (currency, number) => + MappedCustomer.create.mBank(bankId).mNumber(s"trim-$number").mCreditLimitCurrency(currency).saveMe() + } + + When("the migration runs") + val migrationName = s"testTrimStoredCurrencyCodes_$bankId" + MigrationOfCurrencyCodesUpperCase.upperCaseEverywhere(migrationName) shouldBe true + + Then("every code is trimmed and in upper case, and no row is lost") + MappedCustomer.findAll(By(MappedCustomer.mBank, bankId)).map(c => c.mNumber.get -> c.mCreditLimitCurrency.get).sorted shouldBe + List("trim-1" -> "EUR", "trim-2" -> "USD", "trim-3" -> "GBP") + } + scenario("A currency row whose upper-case code already exists is left as it is") { + if (code.api.util.DBUtil.isSqlServer) { + cancel("SQL Server uses case-insensitive collation for primary keys, preventing duplicate-case rows") + } Given("a currency stored as qqa and as QQA, and another stored only as qqb") addCurrency("qqa") addCurrency("QQA") @@ -104,6 +123,7 @@ class CurrencyCodesUpperCaseMigrationTest extends ServerSetup { override def afterAll(): Unit = { MappedFXRate.findAll(By(MappedFXRate.mBankId, bankId)).foreach(_.delete_!) + MappedCustomer.findAll(By(MappedCustomer.mBank, bankId)).foreach(_.delete_!) MappedCurrency.findAll().filter(currency => Set("qqa", "qqb").contains(currency.currencyCode.toLowerCase)).foreach(_.delete_!) super.afterAll() }