From af5e3063b96168ec42f5277aee0998852bff9814 Mon Sep 17 00:00:00 2001 From: Clemens Portele Date: Thu, 25 Jun 2026 16:32:49 +0200 Subject: [PATCH] features: execute native statements on a mutation session for transaction hooks Add Session.execute(List) to the FeatureTransactions.Session SPI so callers can run native statements on an open mutation transaction and receive the non-fatal SQL warnings they emit. This backs configurable per-transaction setup and pre-commit hooks in the OGC API transactions building block. - FeatureTransactions.Session: new execute(List) default; an empty list is a no-op, otherwise UnsupportedOperationException for providers that cannot run native statements. - SqlSession / JdbcSqlSession: run each statement in order and collect the JDBC SQLWarning chain (e.g. PostgreSQL RAISE WARNING / RAISE NOTICE), returning it. - On a statement failure throw FeatureMutationHookException, carrying the warnings collected before the failing statement so they survive the rollback path; this is an expected, configuration-driven outcome, not an illegal state. - SqlMutationSession delegates to the SQL session. --- .../features/sql/app/SqlMutationSession.java | 5 +++ .../features/sql/domain/SqlSession.java | 11 +++++++ .../features/sql/infra/db/JdbcSqlSession.java | 27 ++++++++++++++++ .../domain/FeatureMutationHookException.java | 32 +++++++++++++++++++ .../features/domain/FeatureTransactions.java | 20 ++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationHookException.java diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java index d50157ce6..0729a9697 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/app/SqlMutationSession.java @@ -1518,6 +1518,11 @@ private static String sqlString(String value) { return "'" + value.replace("'", "''") + "'"; } + @Override + public List execute(List statements) { + return sqlSession.execute(statements); + } + @Override public void commit() { sqlSession.commit(); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlSession.java index a98e210d1..d1e313069 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/domain/SqlSession.java @@ -45,6 +45,17 @@ String run( */ List runReturning(String sql); + /** + * Executes raw statements in order on this session's connection, ignoring any result sets, and + * returns the non-fatal SQL warnings they produced (e.g. PostgreSQL {@code RAISE WARNING} / + * {@code RAISE NOTICE}). A statement that fails throws and leaves the transaction open for + * rollback. Intended for transaction-lifecycle hooks (session setup, pre-commit checks). + * + * @param statements the statements to execute, in order + * @return the collected warning messages across all statements, in execution order + */ + List execute(List statements); + /** Commits all mutations performed against this session. */ void commit(); diff --git a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java index 3ede3b506..8252bd4c8 100644 --- a/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java +++ b/xtraplatform-features-sql/src/main/java/de/ii/xtraplatform/features/sql/infra/db/JdbcSqlSession.java @@ -8,10 +8,12 @@ package de.ii.xtraplatform.features.sql.infra.db; import de.ii.xtraplatform.base.domain.LogContext.MARKER; +import de.ii.xtraplatform.features.domain.FeatureMutationHookException; import de.ii.xtraplatform.features.sql.domain.SqlSession; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLWarning; import java.sql.Statement; import java.util.ArrayList; import java.util.List; @@ -158,6 +160,31 @@ public List runReturning(String sql) { } } + @Override + public List execute(List statements) { + if (finalised) { + throw new IllegalStateException("SQL session is closed"); + } + List warnings = new ArrayList<>(); + for (String sql : statements) { + if (LOGGER.isDebugEnabled(MARKER.SQL)) { + LOGGER.debug(MARKER.SQL, "Executing hook statement: {}", sql); + } + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + for (SQLWarning w = statement.getWarnings(); w != null; w = w.getNextWarning()) { + warnings.add(w.getMessage()); + } + } catch (SQLException e) { + // Expected, configuration-driven failure (e.g. a check function RAISE EXCEPTION) — carry + // the warnings collected so far so they survive the failure path. + throw new FeatureMutationHookException( + "Hook statement failed: " + e.getMessage() + " — statement: " + sql, e, warnings); + } + } + return warnings; + } + // Generators emit "RETURNING null" for child / junction / FK-update statements — these have // no caller-meaningful return value and their consumers are no-ops. Main inserts use // "RETURNING " and must run individually so their generated id can drive child SQL. diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationHookException.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationHookException.java new file mode 100644 index 000000000..05465bb0d --- /dev/null +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureMutationHookException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.features.domain; + +import java.util.List; + +/** + * Thrown by {@link FeatureTransactions.Session#execute(List)} when one of the raw statements (a + * transaction-lifecycle hook) fails. This is an expected, configuration-driven outcome — it + * triggers a rollback and is reported to the client — not an illegal state, so callers should log + * it quietly. Any non-fatal warnings collected from statements that ran before the failing one are + * carried in {@link #getWarnings()} so they are not lost on the failure path. + */ +public class FeatureMutationHookException extends RuntimeException { + + private final List warnings; + + public FeatureMutationHookException(String message, Throwable cause, List warnings) { + super(message, cause); + this.warnings = List.copyOf(warnings); + } + + /** Warnings emitted by hook statements that ran successfully before the failing statement. */ + public List getWarnings() { + return warnings; + } +} diff --git a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java index c71f8d660..5c7c2b4dc 100644 --- a/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java +++ b/xtraplatform-features/src/main/java/de/ii/xtraplatform/features/domain/FeatureTransactions.java @@ -283,6 +283,26 @@ default MutationResult cloneAndPatchFeature( "Clone-and-patch is not supported by this feature provider session"); } + /** + * Executes raw native statements in order against this session's open transaction and returns + * the non-fatal warnings they produced (e.g. PostgreSQL {@code RAISE WARNING} / {@code RAISE + * NOTICE}). A statement that fails throws a {@link FeatureMutationHookException} (carrying any + * warnings collected before the failing statement), leaving the transaction open for rollback. + * Used by transaction-lifecycle hooks (session setup, pre-commit checks). The default + * implementation accepts an empty list as a no-op and otherwise throws {@link + * UnsupportedOperationException} for providers that cannot run native statements. + * + * @param statements the statements to execute, in order + * @return the collected warning messages across all statements, in execution order + */ + default List execute(List statements) { + if (!statements.isEmpty()) { + throw new UnsupportedOperationException( + "Raw statement execution is not supported by this feature provider session"); + } + return List.of(); + } + /** Commits all mutations performed against this session. Throws if already finalised. */ void commit();