From 83273602db900923e25ba1a9c118b979f4bc03f6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 10:03:41 +0200 Subject: [PATCH 01/36] ci: run ScalaTests against SQL Server 2022 --- .github/workflows/build_container.yml | 24 +++++++++++++++++++++++- .github/workflows/build_pull_request.yml | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 8bc7fe3465..e08b7b58de 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -127,7 +127,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -240,6 +240,20 @@ jobs: code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_PID: Developer + MSSQL_SA_PASSWORD: "ObpTests2026!Ci" + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b" + --health-interval 10s + --health-timeout 10s + --health-retries 30 + --health-start-period 60s redis: image: redis:7-alpine # pinned + small (~30MB) to cut redis docker-pull flakiness ports: @@ -253,6 +267,12 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Create SQL Server test database + run: | + docker exec "${{ job.services.sqlserver.id }}" \ + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' \ + -No -b -Q "CREATE DATABASE obp_test_only" + - name: Set up JDK 25 uses: actions/setup-java@v4 with: @@ -301,6 +321,8 @@ 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 + echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props 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 diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 659251b6df..2a2aa4333a 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -121,7 +121,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -234,6 +234,20 @@ jobs: code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_PID: Developer + MSSQL_SA_PASSWORD: "ObpTests2026!Ci" + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b" + --health-interval 10s + --health-timeout 10s + --health-retries 30 + --health-start-period 60s redis: image: redis:7-alpine # pinned + small (~30MB) to cut redis docker-pull flakiness ports: @@ -247,6 +261,12 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Create SQL Server test database + run: | + docker exec "${{ job.services.sqlserver.id }}" \ + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' \ + -No -b -Q "CREATE DATABASE obp_test_only" + - name: Set up JDK 25 uses: actions/setup-java@v4 with: @@ -295,6 +315,8 @@ 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 + echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props 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 From 65cab11f6b82a8001eca680aaaa4b6c0775d5754 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 11:05:41 +0200 Subject: [PATCH 02/36] fix: handle dependent index during mrolename migration on sql server --- .../MigrationOfRoleNameFieldLength.scala | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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..a5b9400ac4 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,35 @@ 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 + | DROP INDEX mappedentitlement_mbankid_muserid_mrolename ON mappedentitlement; + | ALTER TABLE mappedentitlement ALTER COLUMN mrolename NVARCHAR(255); + | 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 +130,4 @@ object MigrationOfRoleNameFieldLength { saveLog(name, commitId, isSuccessful, startDate, endDate, comment) isSuccessful } -} \ No newline at end of file +} From aadfef88ba88b4f0b765c8be6b6d15330240794f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 11:19:54 +0200 Subject: [PATCH 03/36] fix: handle dependent index and idempotency for sql server migrations --- .../MigrationOfConsentReferenceIdUuid.scala | 18 ++++++---- .../migration/MigrationOfMetricAuthType.scala | 6 ++-- .../MigrationOfMetricCertificateTrust.scala | 8 ++--- .../MigrationOfMetricConsentReferenceId.scala | 14 +++++--- ...grationOfMetricConsumerIdFieldLength.scala | 36 ++++++++++++++++--- 5 files changed, 61 insertions(+), 21 deletions(-) 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..869986ef88 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);" } @@ -120,13 +121,18 @@ object MigrationOfConsentReferenceIdUuid { "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.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 <> 'varchar') + | 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;" } 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..a72b69ebf9 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,22 @@ 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 EXISTS ( + | SELECT 1 FROM sys.columns c + | WHERE c.object_id = OBJECT_ID('metric') + | AND c.name = 'consumerid' + | AND c.max_length < 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 varchar(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 +94,22 @@ 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 EXISTS ( + | SELECT 1 FROM sys.columns c + | WHERE c.object_id = OBJECT_ID('metricarchive') + | AND c.name = 'consumerid' + | AND c.max_length < 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 varchar(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" From 92451defcf91365da2cc1a034d38ca05dca72a3d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 11:32:12 +0200 Subject: [PATCH 04/36] fix: handle dependent index on dynamicdataid for sql server migration --- .../MigrationOfDynamicDataIdFieldLength.scala | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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..5a2e7e740a 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,21 @@ 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 + | WHERE c.object_id = OBJECT_ID('dynamicdata') + | AND c.name = 'dynamicdataid' + | AND c.max_length < 255 + |) + |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 varchar(255); + | CREATE UNIQUE INDEX dynamicdata_bankid_dynamicentityname_dynamicdataid + | ON dynamicdata (bankid, dynamicentityname, dynamicdataid); + |END; |""".stripMargin case _ => () => From 537b7e5758456c00391aa2188e453f2c47c93816 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 12:29:25 +0200 Subject: [PATCH 05/36] fix: resolve SQL Server dialect incompatibilities in queries and migrations --- .../src/main/scala/code/api/util/DBUtil.scala | 2 +- .../MigrationOfConsentJwtPayload.scala | 2 +- .../DoobieBadLoginAttemptQueries.scala | 20 +++++++---- .../DoobieBankAccountQueries.scala | 7 +++- .../DoobieBusinessStatusQueries.scala | 10 +++--- .../DoobieChallengeQueries.scala | 18 +++++++--- .../DoobieConsentSchedulerQueries.scala | 4 +-- .../DoobieConsentStatusQueries.scala | 12 +++---- .../DoobieTransactionRequestQueries.scala | 7 +++- .../DoobieUserAuthContextUpdateQueries.scala | 2 +- .../bankconnectors/LocalMappedConnector.scala | 36 +++++++++++-------- .../customer/MappedCustomerProvider.scala | 10 ++++-- .../code/messageoutbox/MessageOutbox.scala | 2 +- .../scala/code/users/DoobieUserQueries.scala | 18 ++++++++-- .../CurrencyCodesUpperCaseMigrationTest.scala | 3 ++ 15 files changed, 102 insertions(+), 51 deletions(-) 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..641d138bf3 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,7 @@ import java.sql.{ResultSet, Types} object DBUtil { def dbUrl: String = APIUtil.getPropsValue("db.url") openOr Constant.h2DatabaseDefaultUrlValue - def isSqlServer: Boolean = dbUrl.contains("sqlserver") + def 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/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/bankconnectors/DoobieBadLoginAttemptQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala index 8a7058c627..20ad8b2404 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala @@ -33,17 +33,25 @@ 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 = if (code.api.util.DBUtil.isSqlServer) { + sql"""SELECT mbadattemptssincelastsuccessorreset + FROM mappedbadloginattempt WITH (UPDLOCK, ROWLOCK) + WHERE provider = $provider AND musername = $username""" + } else { + sql"""SELECT mbadattemptssincelastsuccessorreset + FROM mappedbadloginattempt + WHERE provider = $provider AND musername = $username + FOR UPDATE""" + } 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..36c2e5515e 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala @@ -44,9 +44,14 @@ object DoobieBankAccountQueries { * @return The new balance after the update */ def atomicallyUpdateBalance(bankId: String, accountId: String, amount: Long): ConnectionIO[Long] = { + val selectLock = if (code.api.util.DBUtil.isSqlServer) { + sql"SELECT accountbalance FROM mappedbankaccount WITH (UPDLOCK, ROWLOCK) WHERE bank = $bankId AND theaccountid = $accountId" + } else { + sql"SELECT accountbalance FROM mappedbankaccount WHERE bank = $bankId AND theaccountid = $accountId FOR UPDATE" + } 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..701d04c175 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala @@ -35,18 +35,26 @@ import net.liftweb.util.Helpers.tryo object DoobieChallengeQueries { - private def incrementAndSelectCounter(challengeId: String): ConnectionIO[Int] = + private def incrementAndSelectCounter(challengeId: String): ConnectionIO[Int] = { + val selectLock = if (code.api.util.DBUtil.isSqlServer) { + sql"""SELECT attemptcounter + FROM ExpectedChallengeAnswer WITH (UPDLOCK, ROWLOCK) + WHERE challengeid = $challengeId""" + } else { + sql"""SELECT attemptcounter + FROM ExpectedChallengeAnswer + WHERE challengeid = $challengeId + FOR UPDATE""" + } 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..5479c2db7e 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala @@ -47,7 +47,12 @@ 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 = if (code.api.util.DBUtil.isSqlServer) { + sql"SELECT mstatus FROM mappedtransactionrequest WITH (UPDLOCK, ROWLOCK) WHERE mtransactionrequestid = $transReqId" + } else { + sql"SELECT mstatus FROM mappedtransactionrequest WHERE mtransactionrequestid = $transReqId FOR UPDATE" + } + 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..5528cf3da8 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1485,6 +1485,24 @@ object LocalMappedConnector extends Connector with MdcLoggable { maps } + val routingConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CONCAT('scheme:', bankaccountrouting.accountroutingscheme, ',address:', bankaccountrouting.accountroutingaddress)" + } else { + fr"'scheme:' || bankaccountrouting.accountroutingscheme || ',address:' || bankaccountrouting.accountroutingaddress" + } + + val attrConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue)" + } else { + fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" + } + + val pagination = if (code.api.util.DBUtil.isSqlServer) { + fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit OFFSET $offset" + } + val query = (fr""" select mappedbankaccount.theaccountid as account_id, @@ -1494,11 +1512,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 +1521,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 +1530,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/customer/MappedCustomerProvider.scala b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala index 347f51b69f..e50beb10b7 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 e: Throwable => + 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/messageoutbox/MessageOutbox.scala b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala index 3e94ae4c78..7ab7c91c2c 100644 --- a/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala +++ b/obp-api/src/main/scala/code/messageoutbox/MessageOutbox.scala @@ -217,7 +217,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..9a4d7d73cc 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._ @@ -184,8 +184,20 @@ 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 = if (DBUtil.isSqlServer) { + fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit OFFSET $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/asset/CurrencyCodesUpperCaseMigrationTest.scala b/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala index 0beefb1479..babe823616 100644 --- a/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala +++ b/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala @@ -82,6 +82,9 @@ class CurrencyCodesUpperCaseMigrationTest extends ServerSetup { } 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") From 9d7667998754b892c5109c18f5acd7ccc53559a9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 13:36:54 +0200 Subject: [PATCH 06/36] fix: resolve SQL Server datetime ranges, boolean literals, collation, and transaction locks --- .../main/scala/bootstrap/liftweb/Boot.scala | 75 +++++++++++++++ .../main/scala/code/api/util/APIUtil.scala | 2 +- .../code/api/util/migration/Migration.scala | 8 ++ .../MigrationOfCurrencyCodesUpperCase.scala | 18 +++- .../MigrationOfSqlServerDateTimeColumns.scala | 96 +++++++++++++++++++ .../scala/code/users/DoobieUserQueries.scala | 6 +- .../DynamicEntitySystemLevelBankIdTest.scala | 2 +- 7 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 8edce53e85..ceaa53e120 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -666,10 +666,85 @@ 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 knownStatements = List( + "ALTER TABLE mappedcustomer ALTER COLUMN mdateofbirth DATETIME2 NULL", + "ALTER TABLE mappedcustomer ALTER COLUMN mfaceimagetime DATETIME2 NULL", + "ALTER TABLE mappedcustomer ALTER COLUMN mlastokdate DATETIME2 NULL", + "ALTER TABLE mappedcustomerdependant ALTER COLUMN mdateofbirth DATETIME2 NULL", + "ALTER TABLE mappedusercustomerlink ALTER COLUMN mdateinserted DATETIME2 NULL", + "ALTER TABLE mappedphysicalcard ALTER COLUMN mvalidfrom DATETIME2 NULL", + "ALTER TABLE mappedphysicalcard ALTER COLUMN mexpires DATETIME2 NULL", + "ALTER TABLE mappedphysicalcard ALTER COLUMN mcollected DATETIME2 NULL", + "ALTER TABLE mappedphysicalcard ALTER COLUMN mposted DATETIME2 NULL", + "ALTER TABLE mappedphysicalcard ALTER COLUMN mreplacementdate DATETIME2 NULL", + "ALTER TABLE mappedmeeting ALTER COLUMN mwhen DATETIME2 NULL" + ) + DB.use(net.liftweb.util.DefaultConnectionIdentifier) { conn => + for (sql <- knownStatements) { + try { + val stmt = conn.createStatement() + try { + stmt.execute(sql) + conn.commit() + } finally { + stmt.close() + } + } catch { + case NonFatal(e) => + logger.warn(s"upgradeSqlServerDateTimeColumns (known) failed for '$sql': ${e.getMessage}") + } + } + + try { + val stmt = conn.createStatement() + val columns = scala.collection.mutable.ListBuffer[(String, String, String)]() + try { + val rs = stmt.executeQuery( + """ + |SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME + |FROM INFORMATION_SCHEMA.COLUMNS c + |JOIN INFORMATION_SCHEMA.TABLES t + | ON c.TABLE_NAME = t.TABLE_NAME AND c.TABLE_SCHEMA = t.TABLE_SCHEMA + |WHERE c.DATA_TYPE = 'datetime' AND t.TABLE_TYPE = 'BASE TABLE' + |""".stripMargin + ) + while (rs.next()) { + columns += ((rs.getString(1), rs.getString(2), rs.getString(3))) + } + rs.close() + } finally { + stmt.close() + } + + for ((schema, table, column) <- columns) { + try { + val alterStmt = conn.createStatement() + try { + alterStmt.execute(s"ALTER TABLE [$schema].[$table] ALTER COLUMN [$column] DATETIME2 NULL") + conn.commit() + } finally { + alterStmt.close() + } + } catch { + case NonFatal(e) => + logger.debug(s"upgradeSqlServerDateTimeColumns (dynamic): could not alter [$schema].[$table].[$column]: ${e.getMessage}") + } + } + } catch { + case NonFatal(e) => + logger.warn(s"upgradeSqlServerDateTimeColumns dynamic query failed: ${e.getMessage}") + } + } + } + /** * 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 3d6e2dfd64..84ae777a27 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/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index e0d9ec4749..751d2f841a 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() + alterSqlServerDateTimeColumns() } /** @@ -964,6 +965,13 @@ object Migration extends MdcLoggable { } } + private def alterSqlServerDateTimeColumns(): Boolean = { + val name = nameOf(alterSqlServerDateTimeColumns) + runOnce(name) { + MigrationOfSqlServerDateTimeColumns.alterColumns(name) + } + } + private def alterDynamicDataIdLength(): Boolean = { val name = nameOf(alterDynamicDataIdLength) runOnce(name) { 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..4964b9a684 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,16 @@ 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)))" + val needsChange = if (code.api.util.DBUtil.isSqlServer) { + s"$column COLLATE Latin1_General_BIN <> UPPER(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)))" + } 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/MigrationOfSqlServerDateTimeColumns.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala new file mode 100644 index 0000000000..f24f1b54d2 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala @@ -0,0 +1,96 @@ +/** +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.migration + +import code.api.util.{APIUtil, DBUtil} +import code.api.util.migration.Migration.saveLog +import code.util.Helper.MdcLoggable +import net.liftweb.db.DB +import net.liftweb.util.DefaultConnectionIdentifier + +import scala.collection.mutable.ListBuffer +import scala.util.control.NonFatal + +/** + * On SQL Server, Lift Mapper's MappedDateTime generates legacy DATETIME columns whose valid date + * range is 1753-01-01 to 9999-12-31. OBP example dates and historical records use dates before + * 1753 (such as 1100-01-01), which causes Microsoft JDBC Driver / SQL Server to throw: + * "The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value." + * + * This migration alters DATETIME columns in customer, card, and meeting tables to DATETIME2 NULL, + * which supports the full range 0001-01-01 to 9999-12-31. + */ +object MigrationOfSqlServerDateTimeColumns extends MdcLoggable { + + def alterColumns(name: String): Boolean = { + if (!DBUtil.isSqlServer) { + return true + } + + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val altered = ListBuffer[String]() + + val targetColumns = List( + ("mappedcustomer", "mdateofbirth"), + ("mappedcustomer", "mfaceimagetime"), + ("mappedcustomer", "mlastokdate"), + ("mappedcustomerdependant", "mdateofbirth"), + ("mappedusercustomerlink", "mdateinserted"), + ("mappedphysicalcard", "mvalidfrom"), + ("mappedphysicalcard", "mexpires"), + ("mappedphysicalcard", "mcollected"), + ("mappedphysicalcard", "mposted"), + ("mappedphysicalcard", "mreplacementdate"), + ("mappedmeeting", "mwhen") + ) + + DB.use(DefaultConnectionIdentifier) { conn => + for ((table, col) <- targetColumns) { + val sql = s"ALTER TABLE $table ALTER COLUMN $col DATETIME2 NULL" + try { + val stmt = conn.createStatement() + try { + stmt.execute(sql) + conn.commit() + altered += s"$table.$col" + } finally { + stmt.close() + } + } catch { + case NonFatal(e) => + logger.warn(s"MigrationOfSqlServerDateTimeColumns: skipping $sql: ${e.getMessage}") + } + } + } + + val endDate = System.currentTimeMillis() + val comment = s"Altered SQL Server DATETIME columns to DATETIME2: ${altered.mkString(", ")}" + saveLog(name, commitId, isSuccessful = true, startDate, endDate, comment) + true + } +} diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index 9a4d7d73cc..f24a98bafa 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -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") => 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) From 96bddcec28492bfd173178b474da58b4504b50f3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 14:14:31 +0200 Subject: [PATCH 07/36] fix: adapt SQL Server pagination and extend shard timeout --- .github/workflows/build_container.yml | 4 +- .github/workflows/build_pull_request.yml | 4 +- .../bankconnectors/LocalMappedConnector.scala | 51 ++++++++++--------- .../code/chat/DoobieChatMessageQueries.scala | 13 +++-- .../code/consent/DoobieConsentQueries.scala | 15 ++++-- .../DoobieInvestigationQueries.scala | 11 ++-- .../scala/code/api/v1_2_1/API1_2_1Test.scala | 20 +++++++- 7 files changed, 78 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index e08b7b58de..037ccf3903 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -455,14 +455,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 2400: hard-kill after 40 min 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 2400 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 2a2aa4333a..1cb673d274 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -459,14 +459,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 2400: hard-kill after 40 min 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 2400 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/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 5528cf3da8..8ce6bae9f0 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1349,6 +1349,30 @@ object LocalMappedConnector extends Connector with MdcLoggable { maps } + val ownersConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CONCAT('user_id:', resourceuser.userid_, ',provider:', resourceuser.provider_, ',user_name:', resourceuser.name_)" + } else { + fr"'user_id:' || resourceuser.userid_ || ',provider:' || resourceuser.provider_ || ',user_name:' || resourceuser.name_" + } + + val routingConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CONCAT('bank_id:', bankaccountrouting.bankid, ',account_id:', bankaccountrouting.accountid)" + } else { + fr"'bank_id:' || bankaccountrouting.bankid || ',account_id:' || bankaccountrouting.accountid" + } + + val attrConcat = if (code.api.util.DBUtil.isSqlServer) { + fr"CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue)" + } else { + fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" + } + + val pagination = if (code.api.util.DBUtil.isSqlServer) { + fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit OFFSET $offset" + } + val query = (fr""" select mappedbankaccount.theaccountid as account_id, @@ -1356,13 +1380,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 +1390,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 +1398,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 +1408,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) => diff --git a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala index 3a2592dc8e..2415951d02 100644 --- a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala +++ b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala @@ -30,7 +30,7 @@ package code.chat import java.sql.Timestamp import java.util.Date -import code.api.util.DoobieUtil +import code.api.util.{DBUtil, DoobieUtil} import code.util.Helper.MdcLoggable import doobie._ import doobie.implicits._ @@ -83,8 +83,14 @@ object DoobieChatMessageQueries extends MdcLoggable { val fromTs = new Timestamp(fromDate.getTime) val toTs = new Timestamp(toDate.getTime) + val pagination = if (DBUtil.isSqlServer) { + fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit OFFSET $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 +101,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..c2272a85cb 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -30,7 +30,7 @@ package code.consent import doobie._ import doobie.implicits._ import doobie.implicits.javasql._ -import code.api.util.DoobieUtil +import code.api.util.{DBUtil, DoobieUtil} import java.sql.Timestamp import scala.concurrent.{ExecutionContext, Future} @@ -163,6 +163,13 @@ object DoobieConsentQueries { case (_, _) => fr"ORDER BY created_date DESC" } + private def buildPagination(limit: Int, offset: Int): Fragment = + if (DBUtil.isSqlServer) { + fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit OFFSET $offset" + } + private def buildGetConsentsQuery( userId: String, status: Option[String], @@ -175,7 +182,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" " ++ buildPagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -197,7 +204,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" " ++ buildPagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -268,7 +275,7 @@ object DoobieConsentQueries { 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 cb.bank_id = $bankId""" ++ statusCond ++ fr" " ++ orderBy ++ fr" " ++ buildPagination(limit, offset) dataQuery.query[ConsentRow].to[List] } diff --git a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala index 8c0b5d8e01..8bb228169a 100644 --- a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala +++ b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala @@ -29,7 +29,7 @@ package code.investigation import java.sql.Timestamp -import code.api.util.DoobieUtil +import code.api.util.{DBUtil, DoobieUtil} import code.util.Helper.MdcLoggable import doobie._ import doobie.implicits._ @@ -149,6 +149,12 @@ object DoobieInvestigationQueries extends MdcLoggable { val accountIdFragments = accountIds.map(id => fr"$id") val inClause = accountIdFragments.reduceLeft((a, b) => a ++ fr"," ++ b) + val pagination = if (DBUtil.isSqlServer) { + fr"OFFSET 0 ROWS FETCH NEXT $limit ROWS ONLY" + } else { + fr"LIMIT $limit" + } + val query: ConnectionIO[List[TransactionRow]] = (fr"""SELECT transactionid, bank, account, amount, currency, transactiontype, description, tstartdate, tfinishdate, @@ -160,8 +166,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/test/scala/code/api/v1_2_1/API1_2_1Test.scala b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala index 12e51ef796..04a727316b 100644 --- a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala +++ b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala @@ -280,8 +280,24 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat def randomTransaction(bankId : String, accountId : String, viewId: String) : TransactionJSON = { val transactionsJson = getTransactions(bankId, accountId, viewId, user1).body.extract[TransactionsJSON].transactions - val randomPosition = nextInt(transactionsJson.size) - transactionsJson(randomPosition) + val candidates = if (transactionsJson.nonEmpty) { + transactionsJson + } else { + val ownerList = getTransactions(bankId, accountId, SYSTEM_OWNER_VIEW_ID, user1).body.extract[TransactionsJSON].transactions + if (ownerList.nonEmpty) { + ownerList + } else { + code.model.dataAccess.MappedBankAccount.find( + net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.bank, bankId), + net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.theAccountId, accountId) + ).foreach { mba => + createTransaction(mba, new java.util.Date(), new java.util.Date(), true) + } + getTransactions(bankId, accountId, SYSTEM_OWNER_VIEW_ID, user1).body.extract[TransactionsJSON].transactions + } + } + val randomPosition = nextInt(candidates.size) + candidates(randomPosition) } def randomCustomViewsIdsToGrant(bankId : String, accountId : String) : List[String]= { From a735a2bd743df9bedef19c47b7412044afb4e8f7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 15:04:01 +0200 Subject: [PATCH 08/36] fix: guarantee v_consent view recreation and increase shard 6 timeout --- .github/workflows/build_container.yml | 6 +++--- .github/workflows/build_pull_request.yml | 6 +++--- .../scala/code/api/util/migration/Migration.scala | 1 + .../MigrationOfConsentReferenceIdUuid.scala | 13 ++++++++++--- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 037ccf3903..6a72b040db 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -127,7 +127,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -455,14 +455,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 2400: hard-kill after 40 min to prevent Pekko non-daemon threads + # timeout 3000: hard-kill after 50 min 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 2400 mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ + timeout 3000 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 1cb673d274..773dec7778 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -121,7 +121,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -459,14 +459,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 2400: hard-kill after 40 min to prevent Pekko non-daemon threads + # timeout 3000: hard-kill after 50 min 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 2400 mvn process-resources scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ + timeout 3000 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/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 751d2f841a..aac9c1f3e4 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 @@ -195,6 +195,7 @@ object Migration extends MdcLoggable { renameDynamicEntityDefinitionRoles() upperCaseStoredCurrencyCodes() alterSqlServerDateTimeColumns() + MigrationOfConsentView.addConsentView("ensure_v_consent_exists_final") } /** 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 869986ef88..8d9fb58948 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 @@ -116,13 +116,18 @@ object MigrationOfConsentReferenceIdUuid { // 5. Enforce NOT NULL + UNIQUE. val setNotNull = if (isMssql) { - "ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL;" + """IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'mappedconsent_consent_reference_id') + |BEGIN + | IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'consent_reference_id' AND is_nullable = 1) + | 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") val addConstraint = if (isMssql) { - """IF NOT EXISTS (SELECT 1 FROM sys.key_constraints WHERE name = 'uq_mappedconsent_consent_reference_id' AND parent_object_id = OBJECT_ID('mappedconsent')) + """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);" @@ -131,7 +136,7 @@ object MigrationOfConsentReferenceIdUuid { // 6. Widen consent_item.consent_reference_id from BIGINT to VARCHAR(36). val alterConsentItem = if (isMssql) { - """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 <> 'varchar') + """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;" @@ -160,6 +165,8 @@ object MigrationOfConsentReferenceIdUuid { case e: Exception => isSuccessful = false sqlLog.append(s"\nException: ${e.getMessage}\n") + } finally { + MigrationOfConsentView.addConsentView(name + "_view_rebuild") } val endDate = System.currentTimeMillis() From df1409c78614f4354d73e2a3557b1c9ea8313577 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 15:38:27 +0200 Subject: [PATCH 09/36] fix: use EXISTS instead of DISTINCT for bank consent queries on SQL Server --- .../main/scala/code/consent/DoobieConsentQueries.scala | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala index c2272a85cb..0c35e417ad 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -268,14 +268,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" " ++ buildPagination(limit, 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" " ++ buildPagination(limit, offset) dataQuery.query[ConsentRow].to[List] } From df247ea819998b425c9ba7663dca7a5236115fae Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 22:45:42 +0200 Subject: [PATCH 10/36] fix: catch NonFatal in customer creation and track datetime migration status --- .../MigrationOfSqlServerDateTimeColumns.scala | 15 +++++++++++---- .../code/customer/MappedCustomerProvider.scala | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala index f24f1b54d2..bdd4b47378 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala @@ -54,6 +54,7 @@ object MigrationOfSqlServerDateTimeColumns extends MdcLoggable { val startDate = System.currentTimeMillis() val commitId: String = APIUtil.gitCommit val altered = ListBuffer[String]() + val failed = ListBuffer[String]() val targetColumns = List( ("mappedcustomer", "mdateofbirth"), @@ -83,14 +84,20 @@ object MigrationOfSqlServerDateTimeColumns extends MdcLoggable { } } catch { case NonFatal(e) => - logger.warn(s"MigrationOfSqlServerDateTimeColumns: skipping $sql: ${e.getMessage}") + logger.warn(s"MigrationOfSqlServerDateTimeColumns: failed $sql: ${e.getMessage}") + failed += s"$table.$col (${e.getMessage})" } } } val endDate = System.currentTimeMillis() - val comment = s"Altered SQL Server DATETIME columns to DATETIME2: ${altered.mkString(", ")}" - saveLog(name, commitId, isSuccessful = true, startDate, endDate, comment) - true + val isSuccessful = failed.isEmpty + val comment = if (isSuccessful) { + s"Altered SQL Server DATETIME columns to DATETIME2: ${altered.mkString(", ")}" + } else { + s"Failed to alter columns: ${failed.mkString("; ")}; successfully altered: ${altered.mkString(", ")}" + } + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful } } diff --git a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala index e50beb10b7..c187f57112 100644 --- a/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala +++ b/obp-api/src/main/scala/code/customer/MappedCustomerProvider.scala @@ -237,7 +237,7 @@ object MappedCustomerProvider extends CustomerProvider with MdcLoggable { Full(mappedCustomer) } catch { - case e: Throwable => + 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) } From b7047e3d5007b8c37bde4433d3d1567eb75ffb1b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 23:09:48 +0200 Subject: [PATCH 11/36] fix: preserve nvarchar for dynamic data id, enforce consent not null, and guard datetime ddl --- .../main/scala/bootstrap/liftweb/Boot.scala | 27 -------------- .../MigrationOfConsentReferenceIdUuid.scala | 9 +++-- .../MigrationOfDynamicDataIdFieldLength.scala | 5 +-- .../MigrationOfSqlServerDateTimeColumns.scala | 36 +++++++++++++++---- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ceaa53e120..cf52403778 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -674,34 +674,7 @@ class Boot extends MdcLoggable { } private def upgradeSqlServerDateTimeColumns(): Unit = { - val knownStatements = List( - "ALTER TABLE mappedcustomer ALTER COLUMN mdateofbirth DATETIME2 NULL", - "ALTER TABLE mappedcustomer ALTER COLUMN mfaceimagetime DATETIME2 NULL", - "ALTER TABLE mappedcustomer ALTER COLUMN mlastokdate DATETIME2 NULL", - "ALTER TABLE mappedcustomerdependant ALTER COLUMN mdateofbirth DATETIME2 NULL", - "ALTER TABLE mappedusercustomerlink ALTER COLUMN mdateinserted DATETIME2 NULL", - "ALTER TABLE mappedphysicalcard ALTER COLUMN mvalidfrom DATETIME2 NULL", - "ALTER TABLE mappedphysicalcard ALTER COLUMN mexpires DATETIME2 NULL", - "ALTER TABLE mappedphysicalcard ALTER COLUMN mcollected DATETIME2 NULL", - "ALTER TABLE mappedphysicalcard ALTER COLUMN mposted DATETIME2 NULL", - "ALTER TABLE mappedphysicalcard ALTER COLUMN mreplacementdate DATETIME2 NULL", - "ALTER TABLE mappedmeeting ALTER COLUMN mwhen DATETIME2 NULL" - ) DB.use(net.liftweb.util.DefaultConnectionIdentifier) { conn => - for (sql <- knownStatements) { - try { - val stmt = conn.createStatement() - try { - stmt.execute(sql) - conn.commit() - } finally { - stmt.close() - } - } catch { - case NonFatal(e) => - logger.warn(s"upgradeSqlServerDateTimeColumns (known) failed for '$sql': ${e.getMessage}") - } - } try { val stmt = conn.createStatement() 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 8d9fb58948..09eca416ad 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 @@ -116,10 +116,13 @@ object MigrationOfConsentReferenceIdUuid { // 5. Enforce NOT NULL + UNIQUE. val setNotNull = if (isMssql) { - """IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'mappedconsent_consent_reference_id') + """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.columns WHERE object_id = OBJECT_ID('mappedconsent') AND name = 'consent_reference_id' AND is_nullable = 1) - | ALTER TABLE mappedconsent ALTER COLUMN consent_reference_id VARCHAR(36) NOT NULL; + | 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;" 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 5a2e7e740a..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 @@ -65,16 +65,17 @@ object MigrationOfDynamicDataIdFieldLength { """ |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 c.max_length < 255 + | 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 varchar(255); + | ALTER TABLE dynamicdata ALTER COLUMN dynamicdataid NVARCHAR(255); | CREATE UNIQUE INDEX dynamicdata_bankid_dynamicentityname_dynamicdataid | ON dynamicdata (bankid, dynamicentityname, dynamicdataid); |END; diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala index bdd4b47378..de8ddce366 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala @@ -72,19 +72,41 @@ object MigrationOfSqlServerDateTimeColumns extends MdcLoggable { DB.use(DefaultConnectionIdentifier) { conn => for ((table, col) <- targetColumns) { - val sql = s"ALTER TABLE $table ALTER COLUMN $col DATETIME2 NULL" try { - val stmt = conn.createStatement() + var isDatetime = false + val checkStmt = conn.prepareStatement( + """SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + |WHERE TABLE_NAME = ? AND COLUMN_NAME = ? AND LOWER(DATA_TYPE) = 'datetime'""".stripMargin + ) try { - stmt.execute(sql) - conn.commit() - altered += s"$table.$col" + checkStmt.setString(1, table) + checkStmt.setString(2, col) + val rs = checkStmt.executeQuery() + try { + isDatetime = rs.next() + } finally { + rs.close() + } } finally { - stmt.close() + checkStmt.close() + } + + if (isDatetime) { + val sql = s"ALTER TABLE $table ALTER COLUMN $col DATETIME2 NULL" + val stmt = conn.createStatement() + try { + stmt.execute(sql) + conn.commit() + altered += s"$table.$col" + } finally { + stmt.close() + } + } else { + altered += s"$table.$col (already datetime2)" } } catch { case NonFatal(e) => - logger.warn(s"MigrationOfSqlServerDateTimeColumns: failed $sql: ${e.getMessage}") + logger.warn(s"MigrationOfSqlServerDateTimeColumns: failed $table.$col: ${e.getMessage}") failed += s"$table.$col (${e.getMessage})" } } From 74e64ffdfae5345884e35cf09dca507a66ff6c16 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 23:22:13 +0200 Subject: [PATCH 12/36] ci: default tests to H2 with optional manual SQL Server and PostgreSQL runs --- .github/workflows/build_container.yml | 102 ++++++++++++++++++----- .github/workflows/build_pull_request.yml | 99 +++++++++++++++++----- 2 files changed, 160 insertions(+), 41 deletions(-) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 6a72b040db..15e95f8a4b 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -2,7 +2,19 @@ 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 + - postgres env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} @@ -240,20 +252,6 @@ jobs: code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest services: - sqlserver: - image: mcr.microsoft.com/mssql/server:2022-latest - env: - ACCEPT_EULA: "Y" - MSSQL_PID: Developer - MSSQL_SA_PASSWORD: "ObpTests2026!Ci" - ports: - - 1433:1433 - options: >- - --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b" - --health-interval 10s - --health-timeout 10s - --health-retries 30 - --health-start-period 60s redis: image: redis:7-alpine # pinned + small (~30MB) to cut redis docker-pull flakiness ports: @@ -267,11 +265,62 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create SQL Server test database + - name: Setup test database container (optional) + env: + INPUT_DB: ${{ inputs.database }} + PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - docker exec "${{ job.services.sqlserver.id }}" \ - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' \ - -No -b -Q "CREATE DATABASE obp_test_only" + DB_CHOICE="${INPUT_DB:-h2}" + + # If triggered by pull_request or push, check labels / commit messages for manual opt-in + 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 + + echo "TEST_DB=$DB_CHOICE" >> $GITHUB_ENV + echo "Selected test database: $DB_CHOICE" + + if [ "$DB_CHOICE" = "sqlserver" ]; then + echo "Starting SQL Server 2022 container..." + docker run -d --name sqlserver -p 1433:1433 \ + -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e 'MSSQL_SA_PASSWORD=ObpTests2026!Ci' \ + mcr.microsoft.com/mssql/server:2022-latest + + echo "Waiting for SQL Server to be healthy..." + for i in $(seq 1 30); do + if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b >/dev/null 2>&1; then + echo "SQL Server is ready!" + break + fi + echo "Waiting for SQL Server ($i/30)..." + sleep 5 + done + docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" + + elif [ "$DB_CHOICE" = "postgres" ]; then + echo "Starting PostgreSQL 15 container..." + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ + postgres:15-alpine + + echo "Waiting for PostgreSQL to be healthy..." + for i in $(seq 1 30); do + if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then + echo "PostgreSQL is ready!" + break + fi + echo "Waiting for PostgreSQL ($i/30)..." + sleep 2 + done + + else + echo "Running with default H2 in-memory database (no container required)." + fi - name: Set up JDK 25 uses: actions/setup-java@v4 @@ -321,8 +370,19 @@ 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 - echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props + + if [ "$TEST_DB" = "sqlserver" ]; then + echo "Configuring SQL Server connection properties..." + echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props + elif [ "$TEST_DB" = "postgres" ]; then + echo "Configuring PostgreSQL connection properties..." + echo db.driver=org.postgresql.Driver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:postgresql://localhost:5432/obp_test?user=postgres&password=password' >> 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 diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 773dec7778..cecc28a668 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -4,6 +4,17 @@ on: pull_request: branches: - "**" + workflow_dispatch: + inputs: + database: + description: 'Database engine for tests (default: h2)' + required: false + default: 'h2' + type: choice + options: + - h2 + - sqlserver + - postgres env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} @@ -234,20 +245,6 @@ jobs: code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest services: - sqlserver: - image: mcr.microsoft.com/mssql/server:2022-latest - env: - ACCEPT_EULA: "Y" - MSSQL_PID: Developer - MSSQL_SA_PASSWORD: "ObpTests2026!Ci" - ports: - - 1433:1433 - options: >- - --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b" - --health-interval 10s - --health-timeout 10s - --health-retries 30 - --health-start-period 60s redis: image: redis:7-alpine # pinned + small (~30MB) to cut redis docker-pull flakiness ports: @@ -261,11 +258,62 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create SQL Server test database + - name: Setup test database container (optional) + env: + INPUT_DB: ${{ inputs.database }} + PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - docker exec "${{ job.services.sqlserver.id }}" \ - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' \ - -No -b -Q "CREATE DATABASE obp_test_only" + DB_CHOICE="${INPUT_DB:-h2}" + + # If triggered by pull_request or push, check labels / commit messages for manual opt-in + 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 + + echo "TEST_DB=$DB_CHOICE" >> $GITHUB_ENV + echo "Selected test database: $DB_CHOICE" + + if [ "$DB_CHOICE" = "sqlserver" ]; then + echo "Starting SQL Server 2022 container..." + docker run -d --name sqlserver -p 1433:1433 \ + -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e 'MSSQL_SA_PASSWORD=ObpTests2026!Ci' \ + mcr.microsoft.com/mssql/server:2022-latest + + echo "Waiting for SQL Server to be healthy..." + for i in $(seq 1 30); do + if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b >/dev/null 2>&1; then + echo "SQL Server is ready!" + break + fi + echo "Waiting for SQL Server ($i/30)..." + sleep 5 + done + docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" + + elif [ "$DB_CHOICE" = "postgres" ]; then + echo "Starting PostgreSQL 15 container..." + docker run -d --name postgres -p 5432:5432 \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ + postgres:15-alpine + + echo "Waiting for PostgreSQL to be healthy..." + for i in $(seq 1 30); do + if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then + echo "PostgreSQL is ready!" + break + fi + echo "Waiting for PostgreSQL ($i/30)..." + sleep 2 + done + + else + echo "Running with default H2 in-memory database (no container required)." + fi - name: Set up JDK 25 uses: actions/setup-java@v4 @@ -315,8 +363,19 @@ 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 - echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props + + if [ "$TEST_DB" = "sqlserver" ]; then + echo "Configuring SQL Server connection properties..." + echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props + elif [ "$TEST_DB" = "postgres" ]; then + echo "Configuring PostgreSQL connection properties..." + echo db.driver=org.postgresql.Driver >> obp-api/src/main/resources/props/test.default.props + echo 'db.url=jdbc:postgresql://localhost:5432/obp_test?user=postgres&password=password' >> 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 From c3760223d9e06d40944b15f21cb1b0054f57148d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:25:45 +0200 Subject: [PATCH 13/36] fix: guard the DROP INDEX in the SQL Server role name migration The unique index on mappedentitlement is dropped only when it exists and is recreated only when it is missing, so a database that lacks an index of that name no longer fails the batch and aborts boot on every start. --- .../util/migration/MigrationOfRoleNameFieldLength.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 a5b9400ac4..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 @@ -82,10 +82,12 @@ object MigrationOfRoleNameFieldLength { | AND (t.name <> 'nvarchar' OR c.max_length < 510) |) |BEGIN - | DROP INDEX mappedentitlement_mbankid_muserid_mrolename ON mappedentitlement; + | 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); - | CREATE UNIQUE INDEX mappedentitlement_mbankid_muserid_mrolename - | ON mappedentitlement (mbankid, muserid, mrolename); + | 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 From a38323ae1faf4eb5b09c8121411a63d13583da54 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:25:51 +0200 Subject: [PATCH 14/36] fix: widen metric consumerid on SQL Server by character length and keep it NVARCHAR The guard compared a byte length with a character count, so an NVARCHAR column of 125 to 249 characters was skipped, and the ALTER turned the column into VARCHAR, which no longer matched the mapped model. The check now uses the character length and the column stays NVARCHAR(250). --- ...igrationOfMetricConsumerIdFieldLength.scala | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) 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 a72b69ebf9..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 @@ -72,16 +72,11 @@ object MigrationOfMetricConsumerIdFieldLength { val alterMetricSql = DbFunction.maybeWrite(true, Schemifier.infoF _) { () => if (isSqlServer) { """ - |IF EXISTS ( - | SELECT 1 FROM sys.columns c - | WHERE c.object_id = OBJECT_ID('metric') - | AND c.name = 'consumerid' - | AND c.max_length < 250 - |) + |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 varchar(250); + | ALTER TABLE metric ALTER COLUMN consumerid NVARCHAR(250); | CREATE INDEX Metric_consumerid ON metric (consumerid); |END; |""".stripMargin @@ -96,16 +91,11 @@ object MigrationOfMetricConsumerIdFieldLength { DbFunction.maybeWrite(true, Schemifier.infoF _) { () => if (isSqlServer) { """ - |IF EXISTS ( - | SELECT 1 FROM sys.columns c - | WHERE c.object_id = OBJECT_ID('metricarchive') - | AND c.name = 'consumerid' - | AND c.max_length < 250 - |) + |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 varchar(250); + | ALTER TABLE metricarchive ALTER COLUMN consumerid NVARCHAR(250); | CREATE INDEX MetricArchive_consumerid ON metricarchive (consumerid); |END; |""".stripMargin From 2d4be1ad421224d69c2a1a9b995616ea73747897 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:27:12 +0200 Subject: [PATCH 15/36] fix: run the final v_consent ensure once and drop the duplicate rebuild in the consent reference id migration --- .../scala/code/api/util/migration/Migration.scala | 14 +++++++++++++- .../MigrationOfConsentReferenceIdUuid.scala | 6 +++--- 2 files changed, 16 insertions(+), 4 deletions(-) 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 aac9c1f3e4..e5b8e22c42 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 @@ -195,7 +195,7 @@ object Migration extends MdcLoggable { renameDynamicEntityDefinitionRoles() upperCaseStoredCurrencyCodes() alterSqlServerDateTimeColumns() - MigrationOfConsentView.addConsentView("ensure_v_consent_exists_final") + ensureConsentViewExists() } /** @@ -966,6 +966,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 alterSqlServerDateTimeColumns(): Boolean = { val name = nameOf(alterSqlServerDateTimeColumns) runOnce(name) { 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 09eca416ad..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 @@ -160,15 +160,15 @@ 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") } From f681d36ca08de562d27bb75472feeb8117f28613 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:27:23 +0200 Subject: [PATCH 16/36] refactor: remove the SQL Server DATETIME migration that duplicates the schemify hook --- .../code/api/util/migration/Migration.scala | 8 -- .../MigrationOfSqlServerDateTimeColumns.scala | 125 ------------------ 2 files changed, 133 deletions(-) delete mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala 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 e5b8e22c42..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,7 +194,6 @@ object Migration extends MdcLoggable { renameDynamicEntityRoles() renameDynamicEntityDefinitionRoles() upperCaseStoredCurrencyCodes() - alterSqlServerDateTimeColumns() ensureConsentViewExists() } @@ -978,13 +977,6 @@ object Migration extends MdcLoggable { } } - private def alterSqlServerDateTimeColumns(): Boolean = { - val name = nameOf(alterSqlServerDateTimeColumns) - runOnce(name) { - MigrationOfSqlServerDateTimeColumns.alterColumns(name) - } - } - private def alterDynamicDataIdLength(): Boolean = { val name = nameOf(alterDynamicDataIdLength) runOnce(name) { diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala deleted file mode 100644 index de8ddce366..0000000000 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfSqlServerDateTimeColumns.scala +++ /dev/null @@ -1,125 +0,0 @@ -/** -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.migration - -import code.api.util.{APIUtil, DBUtil} -import code.api.util.migration.Migration.saveLog -import code.util.Helper.MdcLoggable -import net.liftweb.db.DB -import net.liftweb.util.DefaultConnectionIdentifier - -import scala.collection.mutable.ListBuffer -import scala.util.control.NonFatal - -/** - * On SQL Server, Lift Mapper's MappedDateTime generates legacy DATETIME columns whose valid date - * range is 1753-01-01 to 9999-12-31. OBP example dates and historical records use dates before - * 1753 (such as 1100-01-01), which causes Microsoft JDBC Driver / SQL Server to throw: - * "The conversion of a datetime2 data type to a datetime data type resulted in an out-of-range value." - * - * This migration alters DATETIME columns in customer, card, and meeting tables to DATETIME2 NULL, - * which supports the full range 0001-01-01 to 9999-12-31. - */ -object MigrationOfSqlServerDateTimeColumns extends MdcLoggable { - - def alterColumns(name: String): Boolean = { - if (!DBUtil.isSqlServer) { - return true - } - - val startDate = System.currentTimeMillis() - val commitId: String = APIUtil.gitCommit - val altered = ListBuffer[String]() - val failed = ListBuffer[String]() - - val targetColumns = List( - ("mappedcustomer", "mdateofbirth"), - ("mappedcustomer", "mfaceimagetime"), - ("mappedcustomer", "mlastokdate"), - ("mappedcustomerdependant", "mdateofbirth"), - ("mappedusercustomerlink", "mdateinserted"), - ("mappedphysicalcard", "mvalidfrom"), - ("mappedphysicalcard", "mexpires"), - ("mappedphysicalcard", "mcollected"), - ("mappedphysicalcard", "mposted"), - ("mappedphysicalcard", "mreplacementdate"), - ("mappedmeeting", "mwhen") - ) - - DB.use(DefaultConnectionIdentifier) { conn => - for ((table, col) <- targetColumns) { - try { - var isDatetime = false - val checkStmt = conn.prepareStatement( - """SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - |WHERE TABLE_NAME = ? AND COLUMN_NAME = ? AND LOWER(DATA_TYPE) = 'datetime'""".stripMargin - ) - try { - checkStmt.setString(1, table) - checkStmt.setString(2, col) - val rs = checkStmt.executeQuery() - try { - isDatetime = rs.next() - } finally { - rs.close() - } - } finally { - checkStmt.close() - } - - if (isDatetime) { - val sql = s"ALTER TABLE $table ALTER COLUMN $col DATETIME2 NULL" - val stmt = conn.createStatement() - try { - stmt.execute(sql) - conn.commit() - altered += s"$table.$col" - } finally { - stmt.close() - } - } else { - altered += s"$table.$col (already datetime2)" - } - } catch { - case NonFatal(e) => - logger.warn(s"MigrationOfSqlServerDateTimeColumns: failed $table.$col: ${e.getMessage}") - failed += s"$table.$col (${e.getMessage})" - } - } - } - - val endDate = System.currentTimeMillis() - val isSuccessful = failed.isEmpty - val comment = if (isSuccessful) { - s"Altered SQL Server DATETIME columns to DATETIME2: ${altered.mkString(", ")}" - } else { - s"Failed to alter columns: ${failed.mkString("; ")}; successfully altered: ${altered.mkString(", ")}" - } - saveLog(name, commitId, isSuccessful, startDate, endDate, comment) - isSuccessful - } -} From ddd74445e04ce7ecfe4c891427d5010a6c45274c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:27:28 +0200 Subject: [PATCH 17/36] fix: report SQL Server DATETIME2 upgrade failures as one warning --- obp-api/src/main/scala/bootstrap/liftweb/Boot.scala | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index cf52403778..cec4935453 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -697,6 +697,7 @@ class Boot extends MdcLoggable { stmt.close() } + val failed = scala.collection.mutable.ListBuffer[String]() for ((schema, table, column) <- columns) { try { val alterStmt = conn.createStatement() @@ -708,9 +709,12 @@ class Boot extends MdcLoggable { } } catch { case NonFatal(e) => - logger.debug(s"upgradeSqlServerDateTimeColumns (dynamic): could not alter [$schema].[$table].[$column]: ${e.getMessage}") + failed += s"[$schema].[$table].[$column] (${e.getMessage})" } } + if (failed.nonEmpty) { + logger.warn(s"upgradeSqlServerDateTimeColumns: ${failed.size} of ${columns.size} DATETIME columns could not be altered to DATETIME2 and keep the 1753 date floor: ${failed.mkString("; ")}") + } } catch { case NonFatal(e) => logger.warn(s"upgradeSqlServerDateTimeColumns dynamic query failed: ${e.getMessage}") From b5a3fe4fb0d22bd498d309a0e410b0a28e945b71 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:27:38 +0200 Subject: [PATCH 18/36] fix: cast SQL Server string_agg operands to NVARCHAR(MAX) to avoid the 8000-byte limit --- .../code/bankconnectors/LocalMappedConnector.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 8ce6bae9f0..b46d8ffd7e 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1350,19 +1350,19 @@ object LocalMappedConnector extends Connector with MdcLoggable { } val ownersConcat = if (code.api.util.DBUtil.isSqlServer) { - fr"CONCAT('user_id:', resourceuser.userid_, ',provider:', resourceuser.provider_, ',user_name:', resourceuser.name_)" + 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"CONCAT('bank_id:', bankaccountrouting.bankid, ',account_id:', bankaccountrouting.accountid)" + 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"CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue)" + 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" } @@ -1491,13 +1491,13 @@ object LocalMappedConnector extends Connector with MdcLoggable { } val routingConcat = if (code.api.util.DBUtil.isSqlServer) { - fr"CONCAT('scheme:', bankaccountrouting.accountroutingscheme, ',address:', bankaccountrouting.accountroutingaddress)" + 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"CONCAT('type:', mappedaccountattribute.mtype, ',code:', mappedaccountattribute.mcode, ',value:', mappedaccountattribute.mvalue)" + 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" } From d929cc8d6495985c896f17b3bf17e8b81633541e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:27:52 +0200 Subject: [PATCH 19/36] ci: run the PostgreSQL option on 16 and let the test database labels trigger the PR workflow --- .github/workflows/build_container.yml | 4 ++-- .github/workflows/build_pull_request.yml | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 15e95f8a4b..950f3e7da5 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -303,10 +303,10 @@ jobs: docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" elif [ "$DB_CHOICE" = "postgres" ]; then - echo "Starting PostgreSQL 15 container..." + echo "Starting PostgreSQL 16 container..." docker run -d --name postgres -p 5432:5432 \ -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ - postgres:15-alpine + postgres:16-alpine echo "Waiting for PostgreSQL to be healthy..." for i in $(seq 1 30); do diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index cecc28a668..30019d405a 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -2,6 +2,7 @@ name: Build on Pull Request on: pull_request: + types: [opened, synchronize, reopened, labeled] branches: - "**" workflow_dispatch: @@ -262,15 +263,14 @@ jobs: env: INPUT_DB: ${{ inputs.database }} PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} - COMMIT_MSG: ${{ github.event.head_commit.message }} run: | DB_CHOICE="${INPUT_DB:-h2}" - # If triggered by pull_request or push, check labels / commit messages for manual opt-in + # On pull_request runs, a test-sqlserver / test-postgres label opts in to that database if [ "$DB_CHOICE" = "h2" ]; then - if echo "$PR_LABELS_JSON" | grep -q "test-sqlserver" || echo "$COMMIT_MSG" | grep -q "\[test-sqlserver\]"; then + if echo "$PR_LABELS_JSON" | 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 + elif echo "$PR_LABELS_JSON" | grep -q "test-postgres"; then DB_CHOICE="postgres" fi fi @@ -296,10 +296,10 @@ jobs: docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" elif [ "$DB_CHOICE" = "postgres" ]; then - echo "Starting PostgreSQL 15 container..." + echo "Starting PostgreSQL 16 container..." docker run -d --name postgres -p 5432:5432 \ -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ - postgres:15-alpine + postgres:16-alpine echo "Waiting for PostgreSQL to be healthy..." for i in $(seq 1 30); do From 9c8150dc580bf83636a3eb4ffd5f701139086e75 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:28:04 +0200 Subject: [PATCH 20/36] test: limit the random transaction fallback to SQL Server --- obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala index 04a727316b..946a0a5e6c 100644 --- a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala +++ b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala @@ -280,7 +280,9 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat def randomTransaction(bankId : String, accountId : String, viewId: String) : TransactionJSON = { val transactionsJson = getTransactions(bankId, accountId, viewId, user1).body.extract[TransactionsJSON].transactions - val candidates = if (transactionsJson.nonEmpty) { + // The fallback below only exists for SQL Server test data; on other databases an empty list is a + // real failure and must not be papered over. + val candidates = if (transactionsJson.nonEmpty || !code.api.util.DBUtil.isSqlServer) { transactionsJson } else { val ownerList = getTransactions(bankId, accountId, SYSTEM_OWNER_VIEW_ID, user1).body.extract[TransactionsJSON].transactions From f833208e8a555d374876916975b4784d8fbd32bf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:49:19 +0200 Subject: [PATCH 21/36] refactor: move the SQL Server DATETIME2 upgrade out of Boot into its own object --- .../main/scala/bootstrap/liftweb/Boot.scala | 48 +--------- .../api/util/SqlServerDateTimeUpgrade.scala | 96 +++++++++++++++++++ 2 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index cec4935453..a9860b46ba 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -674,51 +674,9 @@ class Boot extends MdcLoggable { } private def upgradeSqlServerDateTimeColumns(): Unit = { - DB.use(net.liftweb.util.DefaultConnectionIdentifier) { conn => - - try { - val stmt = conn.createStatement() - val columns = scala.collection.mutable.ListBuffer[(String, String, String)]() - try { - val rs = stmt.executeQuery( - """ - |SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME - |FROM INFORMATION_SCHEMA.COLUMNS c - |JOIN INFORMATION_SCHEMA.TABLES t - | ON c.TABLE_NAME = t.TABLE_NAME AND c.TABLE_SCHEMA = t.TABLE_SCHEMA - |WHERE c.DATA_TYPE = 'datetime' AND t.TABLE_TYPE = 'BASE TABLE' - |""".stripMargin - ) - while (rs.next()) { - columns += ((rs.getString(1), rs.getString(2), rs.getString(3))) - } - rs.close() - } finally { - stmt.close() - } - - val failed = scala.collection.mutable.ListBuffer[String]() - for ((schema, table, column) <- columns) { - try { - val alterStmt = conn.createStatement() - try { - alterStmt.execute(s"ALTER TABLE [$schema].[$table] ALTER COLUMN [$column] DATETIME2 NULL") - conn.commit() - } finally { - alterStmt.close() - } - } catch { - case NonFatal(e) => - failed += s"[$schema].[$table].[$column] (${e.getMessage})" - } - } - if (failed.nonEmpty) { - logger.warn(s"upgradeSqlServerDateTimeColumns: ${failed.size} of ${columns.size} DATETIME columns could not be altered to DATETIME2 and keep the 1753 date floor: ${failed.mkString("; ")}") - } - } catch { - case NonFatal(e) => - logger.warn(s"upgradeSqlServerDateTimeColumns dynamic query failed: ${e.getMessage}") - } + 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("; ")}") } } 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..6675211ce2 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala @@ -0,0 +1,96 @@ +/** +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 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. + */ +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]) + + def upgradeAll(): Result = { + val altered = ListBuffer[String]() + val failed = ListBuffer[String]() + DB.use(DefaultConnectionIdentifier) { conn => + try { + val columns = ListBuffer[(String, String, String)]() + val stmt = conn.createStatement() + try { + val rs = stmt.executeQuery( + """ + |SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME + |FROM INFORMATION_SCHEMA.COLUMNS c + |JOIN INFORMATION_SCHEMA.TABLES t + | ON c.TABLE_NAME = t.TABLE_NAME AND c.TABLE_SCHEMA = t.TABLE_SCHEMA + |WHERE c.DATA_TYPE = 'datetime' AND t.TABLE_TYPE = 'BASE TABLE' + |""".stripMargin + ) + while (rs.next()) { + columns += ((rs.getString(1), rs.getString(2), rs.getString(3))) + } + rs.close() + } finally { + stmt.close() + } + + for ((schema, table, column) <- columns) { + val qualified = s"[$schema].[$table].[$column]" + try { + val alterStmt = conn.createStatement() + try { + alterStmt.execute(s"ALTER TABLE [$schema].[$table] ALTER COLUMN [$column] DATETIME2 NULL") + conn.commit() + altered += qualified + } finally { + alterStmt.close() + } + } catch { + case NonFatal(e) => + failed += s"$qualified (${e.getMessage})" + } + } + } catch { + case NonFatal(e) => + logger.warn(s"SqlServerDateTimeUpgrade: listing the DATETIME columns failed: ${e.getMessage}") + } + } + Result(altered.toList, failed.toList) + } +} From f7bee7aecf2f98fd861aa67d4f62fb23db0e4f9b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:50:15 +0200 Subject: [PATCH 22/36] test: cover DATETIME2 upgrade with indexes, defaults and NOT NULL, and trimmed currency codes --- .../util/SqlServerDateTimeUpgradeTest.scala | 170 ++++++++++++++++++ .../CurrencyCodesUpperCaseMigrationTest.scala | 17 ++ 2 files changed, 187 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/SqlServerDateTimeUpgradeTest.scala 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/asset/CurrencyCodesUpperCaseMigrationTest.scala b/obp-api/src/test/scala/code/asset/CurrencyCodesUpperCaseMigrationTest.scala index babe823616..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,6 +82,21 @@ 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") @@ -107,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() } From 7e49b97fe6ef826be7ae99c7c7cec147c7cc08ec Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:52:45 +0200 Subject: [PATCH 23/36] fix: keep indexes, defaults and NOT NULL when upgrading SQL Server DATETIME columns to DATETIME2 --- .../api/util/SqlServerDateTimeUpgrade.scala | 164 +++++++++++++----- 1 file changed, 125 insertions(+), 39 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala b/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala index 6675211ce2..dd1cf26f89 100644 --- a/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala +++ b/obp-api/src/main/scala/code/api/util/SqlServerDateTimeUpgrade.scala @@ -31,6 +31,7 @@ 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 @@ -39,56 +40,141 @@ import scala.util.control.NonFatal * 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) { conn => - try { - val columns = ListBuffer[(String, String, String)]() - val stmt = conn.createStatement() - try { - val rs = stmt.executeQuery( - """ - |SELECT c.TABLE_SCHEMA, c.TABLE_NAME, c.COLUMN_NAME - |FROM INFORMATION_SCHEMA.COLUMNS c - |JOIN INFORMATION_SCHEMA.TABLES t - | ON c.TABLE_NAME = t.TABLE_NAME AND c.TABLE_SCHEMA = t.TABLE_SCHEMA - |WHERE c.DATA_TYPE = 'datetime' AND t.TABLE_TYPE = 'BASE TABLE' - |""".stripMargin - ) - while (rs.next()) { - columns += ((rs.getString(1), rs.getString(2), rs.getString(3))) - } - rs.close() - } finally { - stmt.close() + 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 } - - for ((schema, table, column) <- columns) { - val qualified = s"[$schema].[$table].[$column]" - try { - val alterStmt = conn.createStatement() - try { - alterStmt.execute(s"ALTER TABLE [$schema].[$table] ALTER COLUMN [$column] DATETIME2 NULL") - conn.commit() - altered += qualified - } finally { - alterStmt.close() - } - } catch { - case NonFatal(e) => - failed += s"$qualified (${e.getMessage})" - } + columns.foreach { column => + try { + upgradeColumn(connection, column) + altered += column.qualified + } catch { + case NonFatal(e) => failed += s"${column.qualified} (${e.getMessage})" } - } catch { - case NonFatal(e) => - logger.warn(s"SqlServerDateTimeUpgrade: listing the DATETIME columns failed: ${e.getMessage}") } } Result(altered.toList, failed.toList) From f4df88de3474c85bc1d6653be80cbd041aa5860c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:52:45 +0200 Subject: [PATCH 24/36] fix: trim trailing spaces from stored currency codes on SQL Server --- .../util/migration/MigrationOfCurrencyCodesUpperCase.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 4964b9a684..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 @@ -181,13 +181,15 @@ 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 { + // 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))" + 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)))" + 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)))" } From 6e3b4e5100cdf1d849c5e291f945f6602f52b53e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:53:28 +0200 Subject: [PATCH 25/36] refactor: share the pagination and row lock dialect switch and compute isSqlServer once --- .../src/main/scala/code/api/util/DBUtil.scala | 7 +- .../code/api/util/DoobieTransactor.scala | 17 +++++ .../DoobieBadLoginAttemptQueries.scala | 13 +--- .../DoobieBankAccountQueries.scala | 8 +- .../DoobieChallengeQueries.scala | 13 +--- .../DoobieTransactionRequestQueries.scala | 8 +- .../bankconnectors/LocalMappedConnector.scala | 12 +-- .../code/chat/DoobieChatMessageQueries.scala | 8 +- .../code/consent/DoobieConsentQueries.scala | 15 +--- .../DoobieInvestigationQueries.scala | 8 +- .../scala/code/users/DoobieUserQueries.scala | 6 +- .../code/api/util/DoobieUtilDialectTest.scala | 76 +++++++++++++++++++ 12 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/util/DoobieUtilDialectTest.scala 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 641d138bf3..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") || APIUtil.getPropsValue("db.driver").exists(_.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/bankconnectors/DoobieBadLoginAttemptQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala index 20ad8b2404..774c14bc89 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBadLoginAttemptQueries.scala @@ -34,16 +34,9 @@ import doobie.implicits._ object DoobieBadLoginAttemptQueries { private def atomicIncrement(provider: String, username: String): ConnectionIO[Int] = { - val selectLock = if (code.api.util.DBUtil.isSqlServer) { - sql"""SELECT mbadattemptssincelastsuccessorreset - FROM mappedbadloginattempt WITH (UPDLOCK, ROWLOCK) - WHERE provider = $provider AND musername = $username""" - } else { - sql"""SELECT mbadattemptssincelastsuccessorreset - FROM mappedbadloginattempt - WHERE provider = $provider AND musername = $username - FOR UPDATE""" - } + val selectLock = + fr"SELECT mbadattemptssincelastsuccessorreset FROM mappedbadloginattempt" ++ DoobieUtil.lockHint ++ + fr"WHERE provider = $provider AND musername = $username" ++ DoobieUtil.forUpdate for { _ <- selectLock.query[Int].option rows <- sql"""UPDATE mappedbadloginattempt diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala index 36c2e5515e..04f348b282 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieBankAccountQueries.scala @@ -44,11 +44,9 @@ object DoobieBankAccountQueries { * @return The new balance after the update */ def atomicallyUpdateBalance(bankId: String, accountId: String, amount: Long): ConnectionIO[Long] = { - val selectLock = if (code.api.util.DBUtil.isSqlServer) { - sql"SELECT accountbalance FROM mappedbankaccount WITH (UPDLOCK, ROWLOCK) WHERE bank = $bankId AND theaccountid = $accountId" - } else { - sql"SELECT accountbalance FROM mappedbankaccount WHERE bank = $bankId AND theaccountid = $accountId FOR UPDATE" - } + 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 <- selectLock.query[Long].unique diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala index 701d04c175..071f2db8a2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieChallengeQueries.scala @@ -36,16 +36,9 @@ import net.liftweb.util.Helpers.tryo object DoobieChallengeQueries { private def incrementAndSelectCounter(challengeId: String): ConnectionIO[Int] = { - val selectLock = if (code.api.util.DBUtil.isSqlServer) { - sql"""SELECT attemptcounter - FROM ExpectedChallengeAnswer WITH (UPDLOCK, ROWLOCK) - WHERE challengeid = $challengeId""" - } else { - sql"""SELECT attemptcounter - FROM ExpectedChallengeAnswer - WHERE challengeid = $challengeId - FOR UPDATE""" - } + val selectLock = + fr"SELECT attemptcounter FROM ExpectedChallengeAnswer" ++ DoobieUtil.lockHint ++ + fr"WHERE challengeid = $challengeId" ++ DoobieUtil.forUpdate for { _ <- selectLock.query[Int].option _ <- sql"""UPDATE ExpectedChallengeAnswer diff --git a/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala b/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala index 5479c2db7e..27c495357a 100644 --- a/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala +++ b/obp-api/src/main/scala/code/bankconnectors/DoobieTransactionRequestQueries.scala @@ -47,11 +47,9 @@ object DoobieTransactionRequestQueries extends MdcLoggable { * "lock failed" 400. */ def atomicallyLockTransactionRequest(transReqId: String): ConnectionIO[Option[String]] = { - val selectLock = if (code.api.util.DBUtil.isSqlServer) { - sql"SELECT mstatus FROM mappedtransactionrequest WITH (UPDLOCK, ROWLOCK) WHERE mtransactionrequestid = $transReqId" - } else { - sql"SELECT mstatus FROM mappedtransactionrequest WHERE mtransactionrequestid = $transReqId FOR UPDATE" - } + 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/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index b46d8ffd7e..7d2f1383d5 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -1367,11 +1367,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" } - val pagination = if (code.api.util.DBUtil.isSqlServer) { - fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit OFFSET $offset" - } + val pagination = DoobieUtil.pagination(limit, offset) val query = (fr""" select @@ -1502,11 +1498,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { fr"'type:' || mappedaccountattribute.mtype || ',code:' || mappedaccountattribute.mcode || ',value:' || mappedaccountattribute.mvalue" } - val pagination = if (code.api.util.DBUtil.isSqlServer) { - fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit OFFSET $offset" - } + val pagination = DoobieUtil.pagination(limit, offset) val query = (fr""" select diff --git a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala index 2415951d02..251606039b 100644 --- a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala +++ b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala @@ -30,7 +30,7 @@ package code.chat import java.sql.Timestamp import java.util.Date -import code.api.util.{DBUtil, DoobieUtil} +import code.api.util.DoobieUtil import code.util.Helper.MdcLoggable import doobie._ import doobie.implicits._ @@ -83,11 +83,7 @@ object DoobieChatMessageQueries extends MdcLoggable { val fromTs = new Timestamp(fromDate.getTime) val toTs = new Timestamp(toDate.getTime) - val pagination = if (DBUtil.isSqlServer) { - fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit OFFSET $offset" - } + val pagination = DoobieUtil.pagination(limit, offset) val messagesQuery: ConnectionIO[List[ChatMessageRow]] = (sql"""SELECT m.chatmessageid, m.chatroomid, m.senderuserid, m.senderconsumerid, diff --git a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala index 0c35e417ad..808b6dc647 100644 --- a/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala +++ b/obp-api/src/main/scala/code/consent/DoobieConsentQueries.scala @@ -30,7 +30,7 @@ package code.consent import doobie._ import doobie.implicits._ import doobie.implicits.javasql._ -import code.api.util.{DBUtil, DoobieUtil} +import code.api.util.DoobieUtil import java.sql.Timestamp import scala.concurrent.{ExecutionContext, Future} @@ -163,13 +163,6 @@ object DoobieConsentQueries { case (_, _) => fr"ORDER BY created_date DESC" } - private def buildPagination(limit: Int, offset: Int): Fragment = - if (DBUtil.isSqlServer) { - fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit OFFSET $offset" - } - private def buildGetConsentsQuery( userId: String, status: Option[String], @@ -182,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" " ++ buildPagination(limit, offset) + val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -204,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" " ++ buildPagination(limit, offset) + val dataQuery = selectColumns ++ fr" " ++ whereClause ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } @@ -278,7 +271,7 @@ object DoobieConsentQueries { SELECT 1 FROM consent_item cb WHERE cb.consent_reference_id = v.consent_reference_id AND cb.bank_id = $bankId - )""" ++ statusCond ++ fr" " ++ orderBy ++ fr" " ++ buildPagination(limit, offset) + )""" ++ statusCond ++ fr" " ++ orderBy ++ fr" " ++ DoobieUtil.pagination(limit, offset) dataQuery.query[ConsentRow].to[List] } diff --git a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala index 8bb228169a..b7aa033c93 100644 --- a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala +++ b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala @@ -29,7 +29,7 @@ package code.investigation import java.sql.Timestamp -import code.api.util.{DBUtil, DoobieUtil} +import code.api.util.DoobieUtil import code.util.Helper.MdcLoggable import doobie._ import doobie.implicits._ @@ -149,11 +149,7 @@ object DoobieInvestigationQueries extends MdcLoggable { val accountIdFragments = accountIds.map(id => fr"$id") val inClause = accountIdFragments.reduceLeft((a, b) => a ++ fr"," ++ b) - val pagination = if (DBUtil.isSqlServer) { - fr"OFFSET 0 ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit" - } + val pagination = DoobieUtil.pagination(limit, 0) val query: ConnectionIO[List[TransactionRow]] = (fr"""SELECT transactionid, bank, account, amount, currency, transactiontype, diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index f24a98bafa..9dcfffc976 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -194,11 +194,7 @@ object DoobieUserQueries { fr"$maxBadLogin THEN true ELSE false END AS is_locked" } - val pagination = if (DBUtil.isSqlServer) { - fr"OFFSET $offset ROWS FETCH NEXT $limit ROWS ONLY" - } else { - fr"LIMIT $limit OFFSET $offset" - } + val pagination = DoobieUtil.pagination(limit, offset) val query: ConnectionIO[List[UserSearchRow]] = (selectColumns ++ isLockedCase ++ 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 + } + } +} From 3d67eda977729e78594be5a8cb5de206a06c8cf5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 00:54:11 +0200 Subject: [PATCH 26/36] ci: share the test database setup, pin the SQL Server image and fail when the database does not start --- .../actions/setup-test-database/action.yml | 86 +++++++++++++++++++ .github/workflows/build_container.yml | 80 +++-------------- .github/workflows/build_pull_request.yml | 79 +++-------------- 3 files changed, 112 insertions(+), 133 deletions(-) create mode 100644 .github/actions/setup-test-database/action.yml diff --git a/.github/actions/setup-test-database/action.yml b/.github/actions/setup-test-database/action.yml new file mode 100644 index 0000000000..a13fc88ed4 --- /dev/null +++ b/.github/actions/setup-test-database/action.yml @@ -0,0 +1,86 @@ +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 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 + + 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 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -No -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" + # SQL Server shards run roughly twice as long as H2 ones. + echo "TEST_TIMEOUT_SECONDS=3000" >> "$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=1500" >> "$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 950f3e7da5..6af00a387b 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -139,7 +139,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: ${{ (inputs.database == 'sqlserver' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.head_commit.message, '[test-sqlserver]')) && 60 || 35 }} strategy: fail-fast: false matrix: @@ -265,62 +265,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup test database container (optional) - env: - INPUT_DB: ${{ inputs.database }} - PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} - COMMIT_MSG: ${{ github.event.head_commit.message }} - run: | - DB_CHOICE="${INPUT_DB:-h2}" - - # If triggered by pull_request or push, check labels / commit messages for manual opt-in - 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 - - echo "TEST_DB=$DB_CHOICE" >> $GITHUB_ENV - echo "Selected test database: $DB_CHOICE" - - if [ "$DB_CHOICE" = "sqlserver" ]; then - echo "Starting SQL Server 2022 container..." - docker run -d --name sqlserver -p 1433:1433 \ - -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e 'MSSQL_SA_PASSWORD=ObpTests2026!Ci' \ - mcr.microsoft.com/mssql/server:2022-latest - - echo "Waiting for SQL Server to be healthy..." - for i in $(seq 1 30); do - if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b >/dev/null 2>&1; then - echo "SQL Server is ready!" - break - fi - echo "Waiting for SQL Server ($i/30)..." - sleep 5 - done - docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" - - elif [ "$DB_CHOICE" = "postgres" ]; then - echo "Starting PostgreSQL 16 container..." - docker run -d --name postgres -p 5432:5432 \ - -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ - postgres:16-alpine - - echo "Waiting for PostgreSQL to be healthy..." - for i in $(seq 1 30); do - if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then - echo "PostgreSQL is ready!" - break - fi - echo "Waiting for PostgreSQL ($i/30)..." - sleep 2 - done - - else - echo "Running with default H2 in-memory database (no container required)." - fi + - 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 @@ -371,14 +321,10 @@ jobs: obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props - if [ "$TEST_DB" = "sqlserver" ]; then - echo "Configuring SQL Server connection properties..." - echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props - elif [ "$TEST_DB" = "postgres" ]; then - echo "Configuring PostgreSQL connection properties..." - echo db.driver=org.postgresql.Driver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:postgresql://localhost:5432/obp_test?user=postgres&password=password' >> 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 @@ -515,14 +461,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 3000: hard-kill after 50 min to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min, 50 on SQL Server): 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 3000 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 30019d405a..7b9efbd6c1 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -133,7 +133,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: ${{ (inputs.database == 'sqlserver' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.head_commit.message, '[test-sqlserver]')) && 60 || 35 }} strategy: fail-fast: false matrix: @@ -259,61 +259,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup test database container (optional) - env: - INPUT_DB: ${{ inputs.database }} - PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} - run: | - DB_CHOICE="${INPUT_DB:-h2}" - - # On pull_request runs, a test-sqlserver / test-postgres label opts in to that database - if [ "$DB_CHOICE" = "h2" ]; then - if echo "$PR_LABELS_JSON" | grep -q "test-sqlserver"; then - DB_CHOICE="sqlserver" - elif echo "$PR_LABELS_JSON" | grep -q "test-postgres"; then - DB_CHOICE="postgres" - fi - fi - - echo "TEST_DB=$DB_CHOICE" >> $GITHUB_ENV - echo "Selected test database: $DB_CHOICE" - - if [ "$DB_CHOICE" = "sqlserver" ]; then - echo "Starting SQL Server 2022 container..." - docker run -d --name sqlserver -p 1433:1433 \ - -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e 'MSSQL_SA_PASSWORD=ObpTests2026!Ci' \ - mcr.microsoft.com/mssql/server:2022-latest - - echo "Waiting for SQL Server to be healthy..." - for i in $(seq 1 30); do - if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -Q 'SELECT 1' -b >/dev/null 2>&1; then - echo "SQL Server is ready!" - break - fi - echo "Waiting for SQL Server ($i/30)..." - sleep 5 - done - docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'ObpTests2026!Ci' -No -b -Q "CREATE DATABASE obp_test_only" - - elif [ "$DB_CHOICE" = "postgres" ]; then - echo "Starting PostgreSQL 16 container..." - docker run -d --name postgres -p 5432:5432 \ - -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=obp_test \ - postgres:16-alpine - - echo "Waiting for PostgreSQL to be healthy..." - for i in $(seq 1 30); do - if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then - echo "PostgreSQL is ready!" - break - fi - echo "Waiting for PostgreSQL ($i/30)..." - sleep 2 - done - - else - echo "Running with default H2 in-memory database (no container required)." - fi + - 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 @@ -364,14 +315,10 @@ jobs: obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props - if [ "$TEST_DB" = "sqlserver" ]; then - echo "Configuring SQL Server connection properties..." - echo db.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:sqlserver://localhost:1433;databaseName=obp_test_only;user=sa;password=ObpTests2026!Ci;encrypt=false' >> obp-api/src/main/resources/props/test.default.props - elif [ "$TEST_DB" = "postgres" ]; then - echo "Configuring PostgreSQL connection properties..." - echo db.driver=org.postgresql.Driver >> obp-api/src/main/resources/props/test.default.props - echo 'db.url=jdbc:postgresql://localhost:5432/obp_test?user=postgres&password=password' >> 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 @@ -518,14 +465,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 3000: hard-kill after 50 min to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min, 50 on SQL Server): 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 3000 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=$? From 496df8fd056564549bb48dbb7336adc0004e857e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 02:14:01 +0200 Subject: [PATCH 27/36] test: drop the SQL Server fallback in randomTransaction so an empty transaction list fails --- .../scala/code/api/v1_2_1/API1_2_1Test.scala | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala index 946a0a5e6c..12e51ef796 100644 --- a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala +++ b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala @@ -280,26 +280,8 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat def randomTransaction(bankId : String, accountId : String, viewId: String) : TransactionJSON = { val transactionsJson = getTransactions(bankId, accountId, viewId, user1).body.extract[TransactionsJSON].transactions - // The fallback below only exists for SQL Server test data; on other databases an empty list is a - // real failure and must not be papered over. - val candidates = if (transactionsJson.nonEmpty || !code.api.util.DBUtil.isSqlServer) { - transactionsJson - } else { - val ownerList = getTransactions(bankId, accountId, SYSTEM_OWNER_VIEW_ID, user1).body.extract[TransactionsJSON].transactions - if (ownerList.nonEmpty) { - ownerList - } else { - code.model.dataAccess.MappedBankAccount.find( - net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.bank, bankId), - net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.theAccountId, accountId) - ).foreach { mba => - createTransaction(mba, new java.util.Date(), new java.util.Date(), true) - } - getTransactions(bankId, accountId, SYSTEM_OWNER_VIEW_ID, user1).body.extract[TransactionsJSON].transactions - } - } - val randomPosition = nextInt(candidates.size) - candidates(randomPosition) + val randomPosition = nextInt(transactionsJson.size) + transactionsJson(randomPosition) } def randomCustomViewsIdsToGrant(bankId : String, accountId : String) : List[String]= { From 1510440799b5121efddb13bf926c71ee31f71472 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 02:14:01 +0200 Subject: [PATCH 28/36] test: a row-level record keeps its id through PUT and PATCH and its entity can be cascade-deleted --- .../DynamicEntityRowLevelAccessTest.scala | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) 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..0d10919987 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,61 @@ 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") { + + def idOf(body: JValue): JValue = body \ "test_rl" \ "test_rl_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(entityRowLevel) + createStatus should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + val id = createRecord("before") + + When("the record is replaced with PUT, whose body carries no id") + makePutRequest((dynamicEntity_Request / "test_rl" / 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 / "test_rl" / id).GET <@ (user1)) + afterPut.code should equal(200) + idOf(afterPut.body) should equal(JString(id)) + (afterPut.body \ "test_rl" \ "name").extract[String] should equal("after put") + + When("the record is changed with PATCH") + makePatchRequest((dynamicEntity_Request / "test_rl" / 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 / "test_rl" / id).GET <@ (user1)) + idOf(afterPatch.body) should equal(JString(id)) + (afterPatch.body \ "test_rl" \ "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(entityRowLevel) + createStatus should equal(201) + val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] + val id = createRecord("damaged") + + Given("a record written by an earlier version, whose stored JSON has no test_rl_id") + val row = code.DynamicData.DynamicData.find( + net.liftweb.mapper.By(code.DynamicData.DynamicData.DynamicEntityName, "test_rl"), + 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) + } + } + } From b5edaa10b70ccd3fa1e253fb905678b1486e76f2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 01:52:05 +0200 Subject: [PATCH 29/36] test: use an entity without personal endpoints for the row-level cascade scenarios --- .../DynamicEntityRowLevelAccessTest.scala | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) 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 0d10919987..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 @@ -244,7 +244,18 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { feature("Feature 6: updating a row-level record keeps its id, and the entity can be cascade-deleted") { - def idOf(body: JValue): JValue = body \ "test_rl" \ "test_rl_id" + // 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) @@ -252,41 +263,41 @@ class DynamicEntityRowLevelAccessTest extends V600ServerSetup { } scenario("6.1: PUT and PATCH keep the record id in the stored record, and cascade delete succeeds", VersionOfApi) { - val (createStatus, body) = createSystemEntity(entityRowLevel) + val (createStatus, body) = createSystemEntity(rowLevelWithoutPersonal) createStatus should equal(201) val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] - val id = createRecord("before") + val id = createRecordOf("before") When("the record is replaced with PUT, whose body carries no id") - makePutRequest((dynamicEntity_Request / "test_rl" / id).PUT <@ (user1), write(("name" -> "after put"): JValue)).code should equal(200) + 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 / "test_rl" / id).GET <@ (user1)) + val afterPut = makeGetRequest((dynamicEntity_Request / entityName / id).GET <@ (user1)) afterPut.code should equal(200) idOf(afterPut.body) should equal(JString(id)) - (afterPut.body \ "test_rl" \ "name").extract[String] should equal("after put") + (afterPut.body \ entityName \ "name").extract[String] should equal("after put") When("the record is changed with PATCH") - makePatchRequest((dynamicEntity_Request / "test_rl" / id).PATCH <@ (user1), write(("name" -> "after patch"): JValue)).code should equal(200) + 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 / "test_rl" / id).GET <@ (user1)) + val afterPatch = makeGetRequest((dynamicEntity_Request / entityName / id).GET <@ (user1)) idOf(afterPatch.body) should equal(JString(id)) - (afterPatch.body \ "test_rl" \ "name").extract[String] should equal("after patch") + (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(entityRowLevel) + val (createStatus, body) = createSystemEntity(rowLevelWithoutPersonal) createStatus should equal(201) val dynamicEntityId = (body \ "dynamic_entity_id").extract[String] - val id = createRecord("damaged") + val id = createRecordOf("damaged") - Given("a record written by an earlier version, whose stored JSON has no test_rl_id") + 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, "test_rl"), + 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 From 3f149ab425ef613f085fb9a5ad262067f2ecc3de Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 01:52:53 +0200 Subject: [PATCH 30/36] fix: keep the record id when a row-level record is updated and cascade delete by row id --- .../scala/code/api/v6_0_0/Http4s600.scala | 21 +++++++++++++------ .../MapppedDynamicDataProvider.scala | 16 +++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) 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 6ee653f302..36b33d2977 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 @@ -5456,12 +5456,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_")) @@ -5469,7 +5479,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/dynamicEntity/MapppedDynamicDataProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala index f01743340f..423380cd8d 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/MapppedDynamicDataProvider.scala @@ -207,7 +207,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] = From 18e47d9636021471f69aeb5976e8c60a1f34a7fd Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 03:06:24 +0200 Subject: [PATCH 31/36] ci: give PostgreSQL shards the same longer timeout as SQL Server --- .github/actions/setup-test-database/action.yml | 4 ++-- .github/workflows/build_container.yml | 4 ++-- .github/workflows/build_pull_request.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup-test-database/action.yml b/.github/actions/setup-test-database/action.yml index a13fc88ed4..dd72d50f90 100644 --- a/.github/actions/setup-test-database/action.yml +++ b/.github/actions/setup-test-database/action.yml @@ -70,7 +70,7 @@ runs: "${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" - # SQL Server shards run roughly twice as long as H2 ones. + # Shards on a server database run longer than on H2; shard 6 alone passes 25 minutes. echo "TEST_TIMEOUT_SECONDS=3000" >> "$GITHUB_ENV" elif [ "$DB_CHOICE" = "postgres" ]; then docker run -d --name postgres -p 5432:5432 \ @@ -79,7 +79,7 @@ runs: 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=1500" >> "$GITHUB_ENV" + echo "TEST_TIMEOUT_SECONDS=3000" >> "$GITHUB_ENV" else echo "Running with the default H2 in-memory database; no container is started." echo "TEST_TIMEOUT_SECONDS=1500" >> "$GITHUB_ENV" diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 6af00a387b..25dee888a7 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -139,7 +139,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.head_commit.message, '[test-sqlserver]')) && 60 || 35 }} + timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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]')) && 60 || 35 }} strategy: fail-fast: false matrix: @@ -461,7 +461,7 @@ 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 $TEST_TIMEOUT_SECONDS (25 min, 50 on SQL Server): hard-kill to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min on H2, 50 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 diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 7b9efbd6c1..4d5958f722 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -133,7 +133,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || contains(github.event.pull_request.labels.*.name, 'test-sqlserver') || contains(github.event.head_commit.message, '[test-sqlserver]')) && 60 || 35 }} + timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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]')) && 60 || 35 }} strategy: fail-fast: false matrix: @@ -465,7 +465,7 @@ 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 $TEST_TIMEOUT_SECONDS (25 min, 50 on SQL Server): hard-kill to prevent Pekko non-daemon threads + # timeout $TEST_TIMEOUT_SECONDS (25 min on H2, 50 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 From 4084d32becc9a65401efefc37add99b7081e2694 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 04:26:06 +0200 Subject: [PATCH 32/36] ci: allow 75 minutes per shard on SQL Server and PostgreSQL --- .github/actions/setup-test-database/action.yml | 7 ++++--- .github/workflows/build_container.yml | 4 ++-- .github/workflows/build_pull_request.yml | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-test-database/action.yml b/.github/actions/setup-test-database/action.yml index dd72d50f90..d55c7875ea 100644 --- a/.github/actions/setup-test-database/action.yml +++ b/.github/actions/setup-test-database/action.yml @@ -70,8 +70,9 @@ runs: "${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; shard 6 alone passes 25 minutes. - echo "TEST_TIMEOUT_SECONDS=3000" >> "$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 \ @@ -79,7 +80,7 @@ runs: 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=3000" >> "$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" diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 25dee888a7..4ad046ff14 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -139,7 +139,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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]')) && 60 || 35 }} + timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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: @@ -461,7 +461,7 @@ 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 $TEST_TIMEOUT_SECONDS (25 min on H2, 50 on SQL Server and PostgreSQL): hard-kill 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 diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 4d5958f722..35d611f7b7 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -133,7 +133,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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]')) && 60 || 35 }} + timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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: @@ -465,7 +465,7 @@ 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 $TEST_TIMEOUT_SECONDS (25 min on H2, 50 on SQL Server and PostgreSQL): hard-kill 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 From 357e7a2f0cc23edb43160114876b3680d5497106 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 09:06:31 +0200 Subject: [PATCH 33/36] refactor: rename the http4sbridge test package to http4sserver --- .github/scripts/test_speed_report.py | 2 +- .github/workflows/build_container.yml | 8 ++++---- .github/workflows/build_pull_request.yml | 8 ++++---- CLAUDE.md | 2 +- docs/testing/EXTRA_TESTS_TODO.md | 2 +- .../Http4sServerIntegrationTest.scala | 2 +- run_tests_parallel.sh | 6 +++--- 7 files changed, 15 insertions(+), 15 deletions(-) rename obp-api/src/test/scala/code/api/{http4sbridge => http4sserver}/Http4sServerIntegrationTest.scala (99%) diff --git a/.github/scripts/test_speed_report.py b/.github/scripts/test_speed_report.py index 194ff69ddf..5255b23988 100644 --- a/.github/scripts/test_speed_report.py +++ b/.github/scripts/test_speed_report.py @@ -43,7 +43,7 @@ HTTP4S_INTEGRATION_SUITES = { "code.api.v7_0_0.Http4s700RoutesTest", "code.api.v7_0_0.Http4s700TransactionTest", - "code.api.http4sbridge.Http4sServerIntegrationTest", + "code.api.http4sserver.Http4sServerIntegrationTest", "code.api.v5_0_0.Http4s500SystemViewsTest", } diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 4ad046ff14..b08e837509 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -131,7 +131,7 @@ jobs: # Shard 3 ~155s v6_0_0 only (isolated after v2_x moved to shard 7) # Shard 4 ~183s v5_1_0 v5_0_0 v3_0_0 # Shard 5 ~193s ResourceDocs v3_1_0 v1_4_0 v1_3_0 - # Shard 6 ~168s v7_0_0 http4sbridge UKOpenBanking + # Shard 6 ~168s v7_0_0 http4sserver UKOpenBanking # Shard 7 ~280s model + views + customer + util + berlin + v2_x # Shard 8 ~240s connector + auth + login + mgmt + metrics + catch-all # Shard 9 ~110s v4_0_0 Dynamic* (6 heavy test classes) @@ -182,10 +182,10 @@ jobs: code.api.v1_4_0 code.api.v1_3_0 - shard: 6 - name: "v7 + http4sbridge + UKOpenBanking" + name: "v7 + http4sserver + UKOpenBanking" test_filter: >- code.api.v7_0_0 - code.api.http4sbridge + code.api.http4sserver code.api.UKOpenBanking - shard: 7 name: "model + views + customer + util + small data + berlin + v2_x" @@ -410,7 +410,7 @@ jobs: SHARD3="code.api.v6_0_0" SHARD4="code.api.v5_1_0 code.api.v5_0_0 code.api.v3_0_0" SHARD5="code.api.ResourceDocs1_4_0 code.api.v3_1_0 code.api.v1_4_0 code.api.v1_3_0" - SHARD6="code.api.v7_0_0 code.api.http4sbridge code.api.UKOpenBanking" + SHARD6="code.api.v7_0_0 code.api.http4sserver code.api.UKOpenBanking" SHARD7="code.model code.views code.customer code.usercustomerlinks \ code.api.util code.errormessages code.atms code.branches \ code.products code.crm code.accountHolder code.api.berlin \ diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 35d611f7b7..886aaf0497 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -125,7 +125,7 @@ jobs: # Shard 3 ~250s v6_0_0 (split from v2_x; v2_x moved to shard 7) # Shard 4 ~232s v5_1_0 + v5_0_0 + v3_0_0 # Shard 5 ~252s ResourceDocs + v3_1_0 + v1_4_0 + v1_3_0 - # Shard 6 ~200s v7_0_0 + http4sbridge + UKOpenBanking + # Shard 6 ~200s v7_0_0 + http4sserver + UKOpenBanking # Shard 7 ~280s model + views + customer + util + berlin + small data + v2_x # Shard 8 ~240s connector + auth + login + mgmt + metrics + catch-all # Shard 9 ~300s v4_0_0 Dynamic* (6 classes: 9 400+ lines each) @@ -176,10 +176,10 @@ jobs: code.api.v1_4_0 code.api.v1_3_0 - shard: 6 - name: "v7 + http4sbridge + UKOpenBanking" + name: "v7 + http4sserver + UKOpenBanking" test_filter: >- code.api.v7_0_0 - code.api.http4sbridge + code.api.http4sserver code.api.UKOpenBanking - shard: 7 name: "model + views + customer + util + small data + berlin + v2_x" @@ -415,7 +415,7 @@ jobs: SHARD3="code.api.v6_0_0" SHARD4="code.api.v5_1_0 code.api.v5_0_0 code.api.v3_0_0" SHARD5="code.api.ResourceDocs1_4_0 code.api.v3_1_0 code.api.v1_4_0 code.api.v1_3_0" - SHARD6="code.api.v7_0_0 code.api.http4sbridge code.api.UKOpenBanking" + SHARD6="code.api.v7_0_0 code.api.http4sserver code.api.UKOpenBanking" SHARD7="code.model code.views code.customer code.usercustomerlinks \ code.api.util code.errormessages code.atms code.branches \ code.products code.crm code.accountHolder code.api.berlin \ diff --git a/CLAUDE.md b/CLAUDE.md index 3ae2cd705d..3497d0545f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,7 +286,7 @@ Shards are defined per-matrix-entry in `.github/workflows/build_pull_request.yml | `code.api.v6_0_0` | 3 | | `code.api.v5_1_0`, `code.api.v5_0_0`, `code.api.v3_0_0` | 4 | | `code.api.ResourceDocs1_4_0`, `code.api.v3_1_0`, `code.api.v1_4_0`, `code.api.v1_3_0` | 5 | -| `code.api.v7_0_0`, `code.api.http4sbridge`, `code.api.UKOpenBanking` | 6 | +| `code.api.v7_0_0`, `code.api.http4sserver`, `code.api.UKOpenBanking` | 6 | | `code.model`, `code.views`, `code.customer`, `code.usercustomerlinks`, `code.api.util`, `code.errormessages`, `code.atms`, `code.branches`, `code.products`, `code.crm`, `code.accountHolder`, `code.api.berlin`, `code.api.v2_*` | 7 | | `code.connector`, `code.util`, `code.api.Authentication*`, `code.api.dauthTest`, `code.api.DirectLoginTest`, `code.api.gateWayloginTest`, `code.api.OBPRestHelperTest`, `code.entitlement`, `code.bankaccountcreation`, `code.bankconnectors`, `code.container`, `code.management`, `code.metrics`, `code.concurrency` | 8 | | anything else | **8** (catch-all) | diff --git a/docs/testing/EXTRA_TESTS_TODO.md b/docs/testing/EXTRA_TESTS_TODO.md index bc0c2e169f..239a72044e 100644 --- a/docs/testing/EXTRA_TESTS_TODO.md +++ b/docs/testing/EXTRA_TESTS_TODO.md @@ -5,7 +5,7 @@ Tracks dependency bumps where compile + the standard 4-suite smoke test passed, Test suites currently used as the smoke gate: - `code.api.v7_0_0.Http4s700RoutesTest` - `code.api.v7_0_0.Http4s700TransactionTest` -- `code.api.http4sbridge.Http4sServerIntegrationTest` +- `code.api.http4sserver.Http4sServerIntegrationTest` Test DB is H2; many integrations are stubbed or absent. diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala similarity index 99% rename from obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala rename to obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala index dfb38a0897..7762802fc6 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala @@ -25,7 +25,7 @@ TESOBE (http://www.tesobe.com/) */ -package code.api.http4sbridge +package code.api.http4sserver import org.json4s._ import code.Http4sTestServer diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 9e804e2536..52cd43adfd 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -100,7 +100,7 @@ done # Hardcoded ports collide when several project checkouts run this script at the # same time. The un-injected 8087 even collides WITHIN one run, because the # suites that start Http4sTestServer are split across shards (v5_0_0 in shard 2; -# http4sbridge/v7 in shard 4) — both JVMs would bind the default 8087. +# http4sserver/v7 in shard 4) — both JVMs would bind the default 8087. # So we pick random high free ports per shard. A fixed base + upward scan can't # solve simultaneous launches: at fork time no shard has bound yet, so every # concurrent run picks the same base. Random high range + lsof skip (catches @@ -146,7 +146,7 @@ code.management,code.metrics,code.model,code.views,code.usercustomerlinks,\ code.customer,code.errormessages" # Shard 4 base — auth/login/connector/util plus any packages not in shards 1-3 -S4_BASE="code.api.v5_1_0,code.api.v3_1_0,code.api.http4sbridge,code.api.v7_0_0,\ +S4_BASE="code.api.v5_1_0,code.api.v3_1_0,code.api.http4sserver,code.api.v7_0_0,\ code.api.Authentication,code.api.dauthTest,code.api.DirectLoginTest,\ code.api.gateWayloginTest,code.api.OBPRestHelperTest,code.util,code.connector" @@ -184,7 +184,7 @@ S4_6="code.api.ResourceDocs1_4_0,code.api.util,code.api.berlin,\ code.management,code.metrics,code.model,code.views,code.usercustomerlinks,\ code.customer,code.errormessages" -S5_6="code.api.v5_1_0,code.api.v3_1_0,code.api.http4sbridge,code.api.v7_0_0" +S5_6="code.api.v5_1_0,code.api.v3_1_0,code.api.http4sserver,code.api.v7_0_0" S6_6="code.api.Authentication,code.api.dauthTest,code.api.DirectLoginTest,\ code.api.gateWayloginTest,code.api.OBPRestHelperTest,code.util,code.connector" From 70877af600540fd80863828d65d66f5895acd16f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 09:15:49 +0200 Subject: [PATCH 34/36] docs: drop the references to the removed Http4sLiftWebBridge --- CLAUDE.md | 4 ++-- ideas/lift_mapper_in_the_future.md | 2 +- .../scala/code/api/util/http4s/Http4sStandardHeaders.scala | 3 +-- obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3497d0545f..1057ab3f12 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). @@ -257,7 +257,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/code/api/util/http4s/Http4sStandardHeaders.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sStandardHeaders.scala index 5fe559ead9..a8b703a5b5 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sStandardHeaders.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sStandardHeaders.scala @@ -40,8 +40,7 @@ import java.util.UUID /** * Standard HTTP response headers applied to every response. * - * Extracted from Http4sLiftWebBridge so Http4sApp can apply them - * without depending on the bridge at all. + * Http4sApp applies them to every response, whichever route served it. */ object Http4sStandardHeaders { diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 84911a0d98..d194aa079b 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -2394,7 +2394,7 @@ object Http4s500 { // ─── path-rewriting bridge: /obp/v5.0.0/… → /obp/v4.0.0/… ───────────── // Cascades inherited (v1.2.1–v4.0.0) endpoints through the http4s versions - // instead of falling all the way through to Http4sLiftWebBridge. + // instead of falling all the way through to the JSON 404 catch-all. val v500ToV400Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => val rawPath = req.uri.path.renderString if (rawPath.startsWith("/obp/v5.0.0/")) { From 0d72acbc9999d4a04619da39350fa9b03823a04f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Thu, 15 Oct 2026 09:41:06 +0200 Subject: [PATCH 35/36] ci: add a SQL Server 2017 option to the manual database choice --- .github/actions/setup-test-database/action.yml | 15 +++++++++++++-- .github/workflows/build_container.yml | 3 ++- .github/workflows/build_pull_request.yml | 3 ++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-test-database/action.yml b/.github/actions/setup-test-database/action.yml index d55c7875ea..b9de6446e0 100644 --- a/.github/actions/setup-test-database/action.yml +++ b/.github/actions/setup-test-database/action.yml @@ -6,7 +6,7 @@ description: > inputs: database: - description: Database chosen on a manual run (h2, sqlserver or postgres); empty on other triggers. + description: Database chosen on a manual run (h2, sqlserver, sqlserver2017 or postgres); empty on other triggers. required: false default: '' pr-labels: @@ -42,6 +42,17 @@ runs: 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" @@ -65,7 +76,7 @@ runs: 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 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -No -b) + 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" diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index b08e837509..a3deda1da7 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -14,6 +14,7 @@ on: options: - h2 - sqlserver + - sqlserver2017 - postgres env: @@ -139,7 +140,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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 }} + 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: diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 886aaf0497..a14a3c89b5 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -15,6 +15,7 @@ on: options: - h2 - sqlserver + - sqlserver2017 - postgres env: @@ -133,7 +134,7 @@ jobs: test: needs: compile runs-on: ubuntu-latest - timeout-minutes: ${{ (inputs.database == 'sqlserver' || 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 }} + 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: From da6b1f94dd13280b5c449660a3ceae90fac682d1 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 16 Oct 2026 13:06:09 +0200 Subject: [PATCH 36/36] fix: bind the dynamic entity webhook ResourceDocs to their routes --- .../Http4s700DynamicEntityWebhooks.scala | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 {