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()
}