diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/SqlAuditStore.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/SqlAuditStore.java index 29ef0fb20..a1754fd1e 100644 --- a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/SqlAuditStore.java +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/SqlAuditStore.java @@ -15,33 +15,50 @@ */ package io.flamingock.store.sql; +import io.flamingock.internal.common.core.audit.AuditPersistenceFactory; +import io.flamingock.internal.common.core.audit.AuditReader; import io.flamingock.internal.common.core.context.ContextResolver; +import io.flamingock.internal.common.core.error.FlamingockException; +import io.flamingock.internal.common.core.feature.Features; import io.flamingock.internal.core.configuration.community.CommunityConfigurable; import io.flamingock.internal.core.external.store.CommunityAuditStore; import io.flamingock.internal.core.external.store.audit.community.CommunityAuditPersistence; import io.flamingock.internal.core.external.store.lock.community.CommunityLockService; +import io.flamingock.internal.core.journal.JournalEventSequencer; +import io.flamingock.internal.core.journal.JournalEventSequencerFactory; import io.flamingock.internal.util.Constants; +import io.flamingock.internal.util.FeatureFlag; import io.flamingock.internal.util.constants.CommunityPersistenceConstants; import io.flamingock.internal.util.id.RunnerId; import io.flamingock.store.sql.internal.SqlAuditPersistence; +import io.flamingock.store.sql.internal.SqlAuditRepository; import io.flamingock.store.sql.internal.SqlLockService; +import io.flamingock.store.sql.internal.SqlJournalEventStore; import io.flamingock.externalsystem.sql.api.SqlExternalSystem; import javax.sql.DataSource; public class SqlAuditStore implements CommunityAuditStore { + private static final String SQL_IDENTIFIER_PATTERN = "[A-Za-z][A-Za-z0-9_]*"; + private static final String DEFAULT_JOURNAL_REPOSITORY_NAME = "flamingockJournalEvents"; + + private final SqlExternalSystem targetSystem; private final DataSource dataSource; private CommunityConfigurable communityConfiguration; private RunnerId runnerId; - private SqlAuditPersistence persistence; private SqlLockService lockService; + private SqlJournalEventStore journalEventStore; + private JournalEventSequencerFactory journalEventSequencerFactory; + private SqlAuditRepository auditRepository; private String auditRepositoryName = CommunityPersistenceConstants.DEFAULT_AUDIT_STORE_NAME; private String lockRepositoryName = CommunityPersistenceConstants.DEFAULT_LOCK_STORE_NAME; + private String journalRepositoryName = DEFAULT_JOURNAL_REPOSITORY_NAME; private boolean autoCreate = true; - private SqlAuditStore(DataSource dataSource) { - this.dataSource = dataSource; + private SqlAuditStore(SqlExternalSystem targetSystem) { + this.targetSystem = targetSystem; + this.dataSource = targetSystem.getDataSource(); } /** @@ -55,7 +72,7 @@ private SqlAuditStore(DataSource dataSource) { * @return a new audit store bound to the same SQL datasource as the target system */ public static SqlAuditStore from(SqlExternalSystem targetSystem) { - return new SqlAuditStore(targetSystem.getDataSource()); + return new SqlAuditStore(targetSystem); } @Override @@ -73,6 +90,11 @@ public SqlAuditStore withLockRepositoryName(String lockRepositoryName) { return this; } + public SqlAuditStore withJournalRepositoryName(String journalRepositoryName) { + this.journalRepositoryName = journalRepositoryName; + return this; + } + public SqlAuditStore withAutoCreate(boolean autoCreate) { this.autoCreate = autoCreate; return this; @@ -82,24 +104,84 @@ public SqlAuditStore withAutoCreate(boolean autoCreate) { public void initialize(ContextResolver baseContext) { runnerId = baseContext.getRequiredDependencyValue(RunnerId.class); communityConfiguration = baseContext.getRequiredDependencyValue(CommunityConfigurable.class); + validate(); + auditRepository = new SqlAuditRepository(dataSource, auditRepositoryName); + journalEventStore = new SqlJournalEventStore( + dataSource, + journalRepositoryName, + targetSystem.getTxWrapper()); + journalEventSequencerFactory = new JournalEventSequencerFactory(journalEventStore); + auditRepository.initialize(autoCreate); + + lockService = new SqlLockService(dataSource, lockRepositoryName); + lockService.initialize(autoCreate); } @Override - public synchronized CommunityAuditPersistence getPersistence() { - if (persistence == null) { - persistence = new SqlAuditPersistence(communityConfiguration, dataSource, auditRepositoryName, autoCreate); + public AuditPersistenceFactory getPersistenceFactory() { + return stageId -> { + boolean journalEventsEnabled = isJournalEventsEnabled(); + JournalEventSequencer journalEventSequencer = null; + if (journalEventsEnabled) { + journalEventStore.initialize(autoCreate); + journalEventSequencer = journalEventSequencerFactory.forStream(stageId); + } + + SqlAuditPersistence persistence = new SqlAuditPersistence( + communityConfiguration, + auditRepository, + journalEventStore, + journalEventSequencer, + targetSystem.getTxWrapper(), + journalEventsEnabled); persistence.initialize(runnerId); - } - return persistence; + return persistence; + }; + } + + @Override + public synchronized AuditReader getAuditReader() { + return () -> auditRepository.getAuditHistory(); } @Override public synchronized CommunityLockService getLockService() { - if (lockService == null) { - lockService = new SqlLockService(dataSource, lockRepositoryName); - lockService.initialize(autoCreate); - } return lockService; } + + private void validate() { + if (targetSystem == null || dataSource == null) { + throw new FlamingockException("The 'SqlExternalSystem' and its 'DataSource' are required."); + } + validateRepositoryName(auditRepositoryName, "auditRepositoryName"); + validateRepositoryName(lockRepositoryName, "lockRepositoryName"); + validateRepositoryName(journalRepositoryName, "journalRepositoryName"); + if (auditRepositoryName.trim().equalsIgnoreCase(lockRepositoryName.trim())) { + throw new FlamingockException("The 'auditRepositoryName' and 'lockRepositoryName' properties must not be the same."); + } + if (journalRepositoryName.trim().equalsIgnoreCase(auditRepositoryName.trim())) { + throw new FlamingockException("The 'journalRepositoryName' and 'auditRepositoryName' properties must not be the same."); + } + if (journalRepositoryName.trim().equalsIgnoreCase(lockRepositoryName.trim())) { + throw new FlamingockException("The 'journalRepositoryName' and 'lockRepositoryName' properties must not be the same."); + } + } + + private void validateRepositoryName(String repositoryName, String propertyName) { + if (repositoryName == null || repositoryName.trim().isEmpty()) { + throw new FlamingockException(propertyName + " must not be blank"); + } + if (!repositoryName.matches(SQL_IDENTIFIER_PATTERN)) { + throw new FlamingockException(propertyName + " must be a simple SQL identifier"); + } + } + + private static boolean isJournalEventsEnabled() { + try { + return FeatureFlag.isEnabled(Features.JOURNAL_EVENTS, false); + } catch (RuntimeException exception) { + return false; + } + } } diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/AuditEntryMapper.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/AuditEntryMapper.java new file mode 100644 index 000000000..45d3af6a7 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/AuditEntryMapper.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.audit.AuditTxType; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Binds and reads the typed, flattened SQL representation of an {@link AuditEntry}. + */ +final class AuditEntryMapper { + + private static final List COLUMN_NAMES = Collections.unmodifiableList(Arrays.asList( + "execution_id", "stage_id", "change_id", "author", "created_at", "state", "invoked_class", + "invoked_method", "source_file", "metadata", "execution_millis", "execution_hostname", + "error_trace", "type", "tx_strategy", "target_system_id", "change_order", "recovery_strategy", + "transaction_flag", "system_change")); + + private AuditEntryMapper() { + } + + static List columnNames() { + return COLUMN_NAMES; + } + + static void bind(PreparedStatement statement, AuditEntry auditEntry, int firstColumn) throws SQLException { + bind(statement, auditEntry, firstColumn, Types.BOOLEAN); + } + + static void bind(PreparedStatement statement, + AuditEntry auditEntry, + int firstColumn, + int nullableBooleanType) throws SQLException { + int column = firstColumn; + statement.setString(column++, auditEntry.getExecutionId()); + statement.setString(column++, auditEntry.getStageId()); + statement.setString(column++, auditEntry.getChangeId()); + statement.setString(column++, auditEntry.getAuthor()); + if (auditEntry.getCreatedAt() == null) { + statement.setNull(column++, Types.TIMESTAMP); + } else { + statement.setTimestamp(column++, Timestamp.valueOf(auditEntry.getCreatedAt())); + } + statement.setString(column++, auditEntry.getState() == null ? null : auditEntry.getState().name()); + statement.setString(column++, auditEntry.getClassName()); + statement.setString(column++, auditEntry.getMethodName()); + statement.setString(column++, auditEntry.getSourceFile()); + statement.setString(column++, auditEntry.getMetadata() == null ? null : auditEntry.getMetadata().toString()); + statement.setLong(column++, auditEntry.getExecutionMillis()); + statement.setString(column++, auditEntry.getExecutionHostname()); + statement.setString(column++, auditEntry.getErrorTrace()); + statement.setString(column++, auditEntry.getType() == null ? null : auditEntry.getType().name()); + statement.setString(column++, auditEntry.getTxType() == null ? null : auditEntry.getTxType().name()); + statement.setString(column++, auditEntry.getTargetSystemId()); + statement.setString(column++, auditEntry.getOrder()); + statement.setString(column++, auditEntry.getRecoveryStrategy() == null ? null : auditEntry.getRecoveryStrategy().name()); + setNullableBoolean(statement, column++, auditEntry.getTransactionFlag(), nullableBooleanType); + setNullableBoolean(statement, column, auditEntry.getSystemChange(), nullableBooleanType); + } + + static AuditEntry fromResultSet(ResultSet resultSet) throws SQLException { + Timestamp createdAt = resultSet.getTimestamp(columnName(4)); + return new AuditEntry( + resultSet.getString(columnName(0)), + resultSet.getString(columnName(1)), + resultSet.getString(columnName(2)), + resultSet.getString(columnName(3)), + createdAt == null ? null : createdAt.toLocalDateTime(), + enumValue(AuditEntry.Status.class, resultSet.getString(columnName(5))), + enumValue(AuditEntry.ChangeType.class, resultSet.getString(columnName(13))), + resultSet.getString(columnName(6)), + resultSet.getString(columnName(7)), + resultSet.getString(columnName(8)), + resultSet.getLong(columnName(10)), + resultSet.getString(columnName(11)), + resultSet.getString(columnName(9)), + readBoolean(resultSet, columnName(19)), + resultSet.getString(columnName(12)), + AuditTxType.fromString(resultSet.getString(columnName(14))), + resultSet.getString(columnName(15)), + resultSet.getString(columnName(16)), + enumValue(RecoveryStrategy.class, resultSet.getString(columnName(17))), + readNullableBoolean(resultSet, columnName(18))); + } + + private static String columnName(int index) { + return COLUMN_NAMES.get(index); + } + + private static void setNullableBoolean(PreparedStatement statement, + int column, + Boolean value, + int nullableBooleanType) throws SQLException { + if (value == null) { + statement.setNull(column, nullableBooleanType); + } else { + statement.setBoolean(column, value); + } + } + + private static boolean readBoolean(ResultSet resultSet, String column) throws SQLException { + Boolean value = readNullableBoolean(resultSet, column); + return value != null && value; + } + + private static Boolean readNullableBoolean(ResultSet resultSet, String column) throws SQLException { + Object value = resultSet.getObject(column); + if (value == null) { + return null; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + return Boolean.valueOf(value.toString()); + } + + private static > E enumValue(Class type, String value) { + return value == null ? null : Enum.valueOf(type, value); + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/JournalEventConstants.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/JournalEventConstants.java new file mode 100644 index 000000000..6cd07ee6b --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/JournalEventConstants.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +/** + * SQL names used by the relational Journal Event store. + */ +final class JournalEventConstants { + + static final String EVENT_ID = "event_id"; + static final String EVENT_TYPE = "event_type"; + static final String EVENT_VERSION = "event_version"; + static final String STREAM_ID = "stream_id"; + static final String STREAM_SEQUENCE = "stream_sequence"; + static final String OCCURRED_AT = "occurred_at"; + static final String ACKNOWLEDGED = "acknowledged"; + + static final String PENDING_EVENTS_INDEX = "pending_events"; + static final String EVENT_ID_INDEX = "event_id"; + + private JournalEventConstants() { + } + + /** + * Validates a configured SQL identifier before it is interpolated into DDL or DML. + * + * @param value identifier to validate + * @param fieldName configuration field containing the identifier + */ + static void validateIdentifier(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + if (!value.matches("[A-Za-z][A-Za-z0-9_]*")) { + throw new IllegalArgumentException(fieldName + " must be a simple SQL identifier"); + } + } + + /** + * Ensures that two configured SQL resources cannot address the same table. + */ + static void validateDistinct(String firstName, + String firstField, + String secondName, + String secondField) { + if (firstName.trim().equalsIgnoreCase(secondName.trim())) { + throw new IllegalArgumentException(firstField + " and " + secondField + " must not be the same"); + } + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditPersistence.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditPersistence.java index 9fcb3950f..96197954a 100644 --- a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditPersistence.java +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditPersistence.java @@ -15,45 +15,91 @@ */ package io.flamingock.store.sql.internal; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.context.RuntimeContext; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.transaction.TransactionWrapper; import io.flamingock.internal.core.configuration.community.CommunityConfigurable; +import io.flamingock.internal.core.context.BasicRuntimeContext; import io.flamingock.internal.core.external.store.audit.community.AbstractCommunityAuditPersistence; -import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.core.journal.JournalEventSequencer; import io.flamingock.internal.util.Result; import io.flamingock.internal.util.id.RunnerId; import javax.sql.DataSource; +import java.sql.Connection; import java.util.List; public class SqlAuditPersistence extends AbstractCommunityAuditPersistence { - private final DataSource dataSource; - private final String auditRepositoryName; - private final boolean autoCreate; - private SqlAuditor auditor; + private final SqlAuditRepository auditRepository; + private final SqlJournalEventStore journalEventStore; + private final JournalEventSequencer journalEventSequencer; + private final TransactionWrapper txWrapper; + private final boolean journalEventsEnabled; + /** + * Creates persistence over collaborators whose schema readiness belongs to the store and stage factory. + * + * @param localConfiguration community configuration + * @param auditRepository ready audit table writer/reader + * @param journalEventStore ready relational Journal Event store + * @param journalEventSequencer stage-scoped sequence allocator + * @param txWrapper transaction wrapper shared with the SQL target system + * @param journalEventsEnabled feature flag snapshot captured for this stage + */ public SqlAuditPersistence(CommunityConfigurable localConfiguration, - DataSource dataSource, - String auditRepositoryName, - boolean autoCreate) { + SqlAuditRepository auditRepository, + SqlJournalEventStore journalEventStore, + JournalEventSequencer journalEventSequencer, + TransactionWrapper txWrapper, + boolean journalEventsEnabled) { super(localConfiguration); - this.dataSource = dataSource; - this.auditRepositoryName = auditRepositoryName; - this.autoCreate = autoCreate; + this.auditRepository = auditRepository; + this.journalEventStore = journalEventStore; + this.journalEventSequencer = journalEventSequencer; + this.txWrapper = txWrapper; + this.journalEventsEnabled = journalEventsEnabled; } @Override protected void doInitialize(RunnerId runnerId) { - auditor = new SqlAuditor(dataSource, auditRepositoryName, autoCreate); - auditor.initialize(); + if (auditRepository == null) { + throw new IllegalStateException("SQL persistence is missing its audit repository"); + } + if (journalEventsEnabled) { + if (journalEventStore == null || journalEventSequencer == null || txWrapper == null) { + throw new IllegalStateException("Journal-enabled SQL persistence is missing transaction collaborators"); + } + } } @Override public List getAuditHistory() { - return auditor.getAuditHistory(); + return auditRepository.getAuditHistory(); } + // Keep the lock through transaction commit: replaceCurrentState uses a caller-owned connection. @Override - public Result writeEntry(AuditEntry auditEntry) { - return auditor.writeEntry(auditEntry); + public synchronized Result writeEntry(AuditEntry auditEntry) { + if (!journalEventsEnabled) { + return auditRepository.writeEntry(auditEntry); + } + + RuntimeContext baseContext = new BasicRuntimeContext("write-changeState-" + auditEntry.getChangeId()); + Result result = txWrapper.wrapInTransaction(baseContext, runtimeContext -> { + Connection connection = runtimeContext.getContext().getRequiredDependencyValue(Connection.class); + JournalEvent journalEvent = journalEventSequencer.newEvent(auditEntry); + journalEventStore.append(connection, journalEvent); + Result currentStateResult = auditRepository.replaceCurrentState(connection, auditEntry); + if (currentStateResult instanceof Result.Error) { + throw new IllegalStateException("Failed to replace local current audit state", + ((Result.Error) currentStateResult).getError()); + } + return currentStateResult == null ? Result.OK() : currentStateResult; + }); + journalEventSequencer.confirm(); + return result; } + } diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditRepository.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditRepository.java new file mode 100644 index 000000000..c2b0d8588 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditRepository.java @@ -0,0 +1,192 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.sql.SqlDialect; +import io.flamingock.internal.common.sql.dialectHelpers.SqlAuditorDialectHelper; +import io.flamingock.internal.util.Result; + +import javax.sql.DataSource; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public class SqlAuditRepository { + + private final DataSource dataSource; + private final String auditTableName; + private SqlAuditorDialectHelper dialectHelper = null; + + public SqlAuditRepository(DataSource dataSource, String auditTableName) { + JournalEventConstants.validateIdentifier(auditTableName, "auditTableName"); + this.dataSource = dataSource; + this.auditTableName = auditTableName; + } + + public synchronized void initialize(boolean autoCreate) { + try (Connection conn = dataSource.getConnection()) { + this.dialectHelper = new SqlAuditorDialectHelper(conn); + if (!tableExists(conn.getMetaData())) { + if (!autoCreate) { + throw new IllegalStateException("SQL audit table '" + auditTableName + "' does not exist"); + } + try (Statement stmt = conn.createStatement()) { + stmt.executeUpdate(dialectHelper.getCreateTableSqlString(auditTableName)); + } + } + } catch (SQLException e) { + throw new RuntimeException("Failed to initialize audit table", e); + } + } + + public Result writeEntry(AuditEntry auditEntry) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + + // For Informix, ensure autoCommit is enabled for audit writes + if (dialectHelper != null && dialectHelper.getSqlDialect() == SqlDialect.INFORMIX) { + conn.setAutoCommit(true); + } + + try (PreparedStatement ps = conn.prepareStatement( + dialectHelper.getInsertSqlString(auditTableName))) { + AuditEntryMapper.bind(ps, auditEntry, 1, getNullableBooleanJdbcType()); + ps.executeUpdate(); + } + return Result.OK(); + } catch (SQLException e) { + return new Result.Error(e); + } finally { + if (conn != null) { + try { + conn.close(); + } catch (SQLException e) { + // Log but don't throw + } + } + } + } + + /** + * Replaces the local current state for a change on a caller-owned transaction connection. + * + *

This operation is used only when Journal Events are enabled. The journal retains every transition, + * while the audit table keeps one current row per change. No commit is performed here; the caller owns the + * transaction that also appends the corresponding event.

+ * + * @param connection transaction-scoped connection + * @param auditEntry new current state + * @return successful result after the update or zero-row insert completes + */ + Result replaceCurrentState(Connection connection, AuditEntry auditEntry) { + if (connection == null) { + throw new IllegalArgumentException("connection must not be null"); + } + if (auditEntry == null) { + throw new IllegalArgumentException("auditEntry must not be null"); + } + JournalEventConstants.validateIdentifier(auditTableName, "auditTableName"); + if (auditEntry.getChangeId() == null || auditEntry.getChangeId().trim().isEmpty()) { + throw new IllegalArgumentException("changeId must not be blank"); + } + if (dialectHelper == null) { + throw new IllegalStateException("SQL auditor is not initialized"); + } + + StringBuilder updateSql = new StringBuilder("UPDATE ") + .append(auditTableName) + .append(" SET "); + for (String columnName : AuditEntryMapper.columnNames()) { + if (updateSql.charAt(updateSql.length() - 1) != ' ') { + updateSql.append(", "); + } + updateSql.append(columnName).append(" = ?"); + } + updateSql.append(" WHERE change_id = ?"); + + try (PreparedStatement update = connection.prepareStatement(updateSql.toString())) { + int nullableBooleanJdbcType = getNullableBooleanJdbcType(); + AuditEntryMapper.bind(update, auditEntry, 1, nullableBooleanJdbcType); + update.setString(AuditEntryMapper.columnNames().size() + 1, auditEntry.getChangeId()); + int updatedRows = update.executeUpdate(); + + if (updatedRows > 1) { + throw new IllegalStateException("Current audit state update matched " + updatedRows + + " rows for changeId '" + auditEntry.getChangeId() + "'"); + } + if (updatedRows == 0) { + try (PreparedStatement insert = connection.prepareStatement( + dialectHelper.getInsertSqlString(auditTableName))) { + AuditEntryMapper.bind(insert, auditEntry, 1, nullableBooleanJdbcType); + insert.executeUpdate(); + } + } + return Result.OK(); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to replace local current audit state", exception); + } + } + + public List getAuditHistory() { + List entries = new ArrayList<>(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(dialectHelper.getSelectHistorySqlString(auditTableName))) { + while (rs.next()) { + entries.add(AuditEntryMapper.fromResultSet(rs)); + } + } catch (SQLException e) { + throw new RuntimeException("Failed to read audit history", e); + } + return entries; + } + + private boolean tableExists(DatabaseMetaData metadata) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (auditTableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } + + private int getNullableBooleanJdbcType() { + switch (dialectHelper.getSqlDialect()) { + case MYSQL: + case MARIADB: + return Types.TINYINT; + case POSTGRESQL: + case H2: + case FIREBIRD: + case INFORMIX: + return Types.BOOLEAN; + case SQLITE: + return Types.INTEGER; + case SQLSERVER: + case SYBASE: + return Types.BIT; + case ORACLE: + return Types.NUMERIC; + case DB2: + default: + return Types.SMALLINT; + } + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditor.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditor.java deleted file mode 100644 index eb3df8be8..000000000 --- a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlAuditor.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2025 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.store.sql.internal; - -import io.flamingock.internal.common.core.audit.AuditEntry; -import io.flamingock.internal.common.core.audit.AuditReader; -import io.flamingock.internal.common.core.audit.AuditTxType; -import io.flamingock.internal.common.core.audit.AuditWriter; -import io.flamingock.internal.common.sql.SqlDialect; -import io.flamingock.internal.common.sql.dialectHelpers.SqlAuditorDialectHelper; -import io.flamingock.internal.util.Result; - -import javax.sql.DataSource; -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -public class SqlAuditor implements AuditWriter, AuditReader { - - private final DataSource dataSource; - private final String auditTableName; - private final boolean autoCreate; - private SqlAuditorDialectHelper dialectHelper = null; - - public SqlAuditor(DataSource dataSource, String auditTableName, boolean autoCreate) { - this.dataSource = dataSource; - this.auditTableName = auditTableName; - this.autoCreate = autoCreate; - } - - public void initialize() { - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement()) { - this.dialectHelper = new SqlAuditorDialectHelper(conn); - if (autoCreate) { - stmt.executeUpdate(dialectHelper.getCreateTableSqlString(auditTableName)); - } - } catch (SQLException e) { - // Firebird throws an error when table already exists; ignore that specific case - if (dialectHelper != null && dialectHelper.getSqlDialect() == SqlDialect.FIREBIRD) { - int errorCode = e.getErrorCode(); - String sqlState = e.getSQLState(); - String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : ""; - - if (errorCode == 335544351 || "42000".equals(sqlState) || msg.contains("already exists")) { - return; - } - } - throw new RuntimeException("Failed to initialize audit table", e); - } - } - - @Override - public Result writeEntry(AuditEntry auditEntry) { - Connection conn = null; - try { - conn = dataSource.getConnection(); - - // For Informix, ensure autoCommit is enabled for audit writes - if (dialectHelper != null && dialectHelper.getSqlDialect() == SqlDialect.INFORMIX) { - conn.setAutoCommit(true); - } - - try (PreparedStatement ps = conn.prepareStatement( - dialectHelper.getInsertSqlString(auditTableName))) { - ps.setString(1, auditEntry.getExecutionId()); - ps.setString(2, auditEntry.getStageId()); - ps.setString(3, auditEntry.getChangeId()); - ps.setString(4, auditEntry.getAuthor()); - ps.setTimestamp(5, Timestamp.valueOf(auditEntry.getCreatedAt())); - ps.setString(6, auditEntry.getState() != null ? auditEntry.getState().name() : null); - ps.setString(7, auditEntry.getClassName()); - ps.setString(8, auditEntry.getMethodName()); - ps.setString(9, auditEntry.getSourceFile()); - ps.setString(10, auditEntry.getMetadata() != null ? auditEntry.getMetadata().toString() : null); - ps.setLong(11, auditEntry.getExecutionMillis()); - ps.setString(12, auditEntry.getExecutionHostname()); - ps.setString(13, auditEntry.getErrorTrace()); - ps.setString(14, auditEntry.getType() != null ? auditEntry.getType().name() : null); - ps.setString(15, auditEntry.getTxType() != null ? auditEntry.getTxType().name() : null); - ps.setString(16, auditEntry.getTargetSystemId()); - ps.setString(17, auditEntry.getOrder()); - ps.setString(18, auditEntry.getRecoveryStrategy() != null ? auditEntry.getRecoveryStrategy().name() : null); - ps.setObject(19, auditEntry.getTransactionFlag()); - ps.setObject(20, auditEntry.getSystemChange()); - ps.executeUpdate(); - } - return Result.OK(); - } catch (SQLException e) { - return new Result.Error(e); - } finally { - if (conn != null) { - try { - conn.close(); - } catch (SQLException e) { - // Log but don't throw - } - } - } - } - - - @Override - public List getAuditHistory() { - List entries = new ArrayList<>(); - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(dialectHelper.getSelectHistorySqlString(auditTableName))) { - while (rs.next()) { - AuditEntry entry = new AuditEntry( - rs.getString("execution_id"), - rs.getString("stage_id"), - rs.getString("change_id"), - rs.getString("author"), - rs.getTimestamp("created_at").toLocalDateTime(), - rs.getString("state") != null ? AuditEntry.Status.valueOf(rs.getString("state")) : null, - rs.getString("type") != null ? AuditEntry.ChangeType.valueOf(rs.getString("type")) : null, - rs.getString("invoked_class"), - rs.getString("invoked_method"), - rs.getString("source_file"), - rs.getLong("execution_millis"), - rs.getString("execution_hostname"), - rs.getString("metadata"), - rs.getBoolean("system_change"), - rs.getString("error_trace"), - AuditTxType.fromString(rs.getString("tx_strategy")), - rs.getString("target_system_id"), - rs.getString("change_order"), - rs.getString("recovery_strategy") != null ? io.flamingock.api.RecoveryStrategy.valueOf(rs.getString("recovery_strategy")) : null, - rs.getObject("transaction_flag") != null ? rs.getBoolean("transaction_flag") : null - ); - entries.add(entry); - } - } catch (SQLException e) { - throw new RuntimeException("Failed to read audit history", e); - } - return entries; - } -} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalDialectHelper.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalDialectHelper.java new file mode 100644 index 000000000..a61d0fa5c --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalDialectHelper.java @@ -0,0 +1,329 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.internal.common.sql.SqlDialect; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Types; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Provides portable Journal Event SQL without relying on vendor-specific upsert or pagination syntax. + */ +public final class SqlJournalDialectHelper { + + private static final String INDEX_PREFIX = "idx_"; + private static final int INDEX_HASH_LENGTH = 8; + + private final SqlDialect sqlDialect; + + public SqlJournalDialectHelper(SqlDialect sqlDialect) { + if (sqlDialect == null) { + throw new IllegalArgumentException("sqlDialect must not be null"); + } + this.sqlDialect = sqlDialect; + } + + public SqlDialect getSqlDialect() { + return sqlDialect; + } + + int getMaximumIndexNameLength() { + switch (sqlDialect) { + case ORACLE: + return 30; + case POSTGRESQL: + return 63; + default: + return 128; + } + } + + List getIndexNames(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + return Collections.unmodifiableList(Arrays.asList( + indexName(tableName, JournalEventConstants.PENDING_EVENTS_INDEX), + indexName(tableName, JournalEventConstants.EVENT_ID_INDEX))); + } + + List getColumnDefinitions() { + List auditColumnNames = AuditEntryMapper.columnNames(); + return Collections.unmodifiableList(Arrays.asList( + new ColumnDefinition(JournalEventConstants.EVENT_ID, ColumnType.VARCHAR, 255, false), + new ColumnDefinition(JournalEventConstants.EVENT_TYPE, ColumnType.VARCHAR, 32, false), + new ColumnDefinition(JournalEventConstants.EVENT_VERSION, ColumnType.INTEGER, 0, false), + new ColumnDefinition(JournalEventConstants.STREAM_ID, ColumnType.VARCHAR, 255, false), + new ColumnDefinition(JournalEventConstants.STREAM_SEQUENCE, ColumnType.LONG, 19, false), + new ColumnDefinition(JournalEventConstants.OCCURRED_AT, ColumnType.TIMESTAMP, 0, false), + new ColumnDefinition(JournalEventConstants.ACKNOWLEDGED, ColumnType.BOOLEAN, 0, false), + new ColumnDefinition(auditColumnNames.get(0), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(1), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(2), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(3), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(4), ColumnType.TIMESTAMP, 0, true), + new ColumnDefinition(auditColumnNames.get(5), ColumnType.VARCHAR, 64, true), + new ColumnDefinition(auditColumnNames.get(6), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(7), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(8), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(9), ColumnType.TEXT, 2048, true), + new ColumnDefinition(auditColumnNames.get(10), ColumnType.LONG, 19, true), + new ColumnDefinition(auditColumnNames.get(11), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(12), ColumnType.TEXT, 2048, true), + new ColumnDefinition(auditColumnNames.get(13), ColumnType.VARCHAR, 64, true), + new ColumnDefinition(auditColumnNames.get(14), ColumnType.VARCHAR, 64, true), + new ColumnDefinition(auditColumnNames.get(15), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(16), ColumnType.VARCHAR, 255, true), + new ColumnDefinition(auditColumnNames.get(17), ColumnType.VARCHAR, 64, true), + new ColumnDefinition(auditColumnNames.get(18), ColumnType.BOOLEAN, 0, true), + new ColumnDefinition(auditColumnNames.get(19), ColumnType.BOOLEAN, 0, true))); + } + + int getBooleanJdbcType() { + switch (sqlDialect) { + case MYSQL: + case MARIADB: + return Types.TINYINT; + case POSTGRESQL: + case H2: + case FIREBIRD: + case INFORMIX: + return Types.BOOLEAN; + case SQLITE: + return Types.INTEGER; + case SQLSERVER: + case SYBASE: + return Types.BIT; + case ORACLE: + return Types.NUMERIC; + case DB2: + default: + return Types.SMALLINT; + } + } + + public String getCreateTableSqlString(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + StringBuilder sql = new StringBuilder("CREATE TABLE ") + .append(tableName) + .append(" ("); + List definitions = getColumnDefinitions(); + for (int i = 0; i < definitions.size(); i++) { + if (i > 0) { + sql.append(", "); + } + ColumnDefinition definition = definitions.get(i); + sql.append(definition.name) + .append(' ') + .append(sqlType(definition)); + if (!definition.nullable) { + sql.append(" NOT NULL"); + } + } + return sql.append(", PRIMARY KEY (") + .append(JournalEventConstants.STREAM_ID) + .append(", ") + .append(JournalEventConstants.STREAM_SEQUENCE) + .append(")") + .append(')') + .toString(); + } + + public List getCreateIndexSqlStrings(String tableName) { + List indexNames = getIndexNames(tableName); + return Collections.unmodifiableList(Arrays.asList( + String.format("CREATE INDEX %s ON %s (%s, %s, %s)", + indexNames.get(0), tableName, JournalEventConstants.ACKNOWLEDGED, + JournalEventConstants.STREAM_ID, JournalEventConstants.STREAM_SEQUENCE), + String.format("CREATE INDEX %s ON %s (%s)", + indexNames.get(1), tableName, JournalEventConstants.EVENT_ID))); + } + + public String getInsertSqlString(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + StringBuilder columns = new StringBuilder(); + StringBuilder placeholders = new StringBuilder(); + for (ColumnDefinition definition : getColumnDefinitions()) { + if (columns.length() > 0) { + columns.append(", "); + placeholders.append(", "); + } + columns.append(definition.name); + placeholders.append("?"); + } + return String.format("INSERT INTO %s (%s) VALUES (%s)", tableName, columns, placeholders); + } + + private String sqlType(ColumnDefinition definition) { + switch (definition.type) { + case VARCHAR: + return getVarcharType(definition.size); + case INTEGER: + return "INTEGER"; + case LONG: + return getLongType(); + case TIMESTAMP: + return getTimestampType(); + case BOOLEAN: + return getBooleanType(); + case TEXT: + return getTextType(); + default: + throw new IllegalArgumentException("Unsupported Journal column type: " + definition.type); + } + } + + public String getLastEventSqlString(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + return String.format( + "SELECT * FROM %s WHERE stream_id = ? ORDER BY stream_sequence DESC", + tableName); + } + + public String getUnacknowledgedEventsSqlString(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + return String.format( + "SELECT * FROM %s WHERE acknowledged = ? ORDER BY stream_id ASC, stream_sequence ASC", + tableName); + } + + public String getAcknowledgeSqlString(String tableName) { + JournalEventConstants.validateIdentifier(tableName, "tableName"); + return String.format( + "UPDATE %s SET acknowledged = ? WHERE event_id = ? AND acknowledged = ?", + tableName); + } + + private String indexName(String tableName, String suffix) { + String naturalName = INDEX_PREFIX + tableName + "_" + suffix; + int maximumLength = getMaximumIndexNameLength(); + if (naturalName.length() <= maximumLength) { + return naturalName; + } + + String hash = hash(tableName); + int tableLength = maximumLength - INDEX_PREFIX.length() - suffix.length() - hash.length() - 2; + if (tableLength < 1) { + throw new IllegalArgumentException("Table name cannot produce a valid SQL index name"); + } + return INDEX_PREFIX + tableName.substring(0, tableLength) + "_" + suffix + "_" + hash; + } + + private static String hash(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(INDEX_HASH_LENGTH); + for (int i = 0; i < INDEX_HASH_LENGTH / 2; i++) { + result.append(String.format("%02x", digest[i])); + } + return result.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private String getVarcharType(int length) { + return (sqlDialect == SqlDialect.ORACLE ? "VARCHAR2(" : "VARCHAR(") + length + ")"; + } + + private String getLongType() { + return sqlDialect == SqlDialect.ORACLE ? "NUMBER(19)" : "BIGINT"; + } + + private String getTimestampType() { + switch (sqlDialect) { + case SQLSERVER: + case SYBASE: + return "DATETIME"; + case INFORMIX: + return "DATETIME YEAR TO FRACTION(3)"; + default: + return "TIMESTAMP"; + } + } + + private String getTextType() { + switch (sqlDialect) { + case MYSQL: + case MARIADB: + case POSTGRESQL: + case SQLSERVER: + case SYBASE: + case SQLITE: + return "TEXT"; + case INFORMIX: + return "LVARCHAR(2048)"; + case ORACLE: + return "VARCHAR2(4000)"; + case DB2: + case FIREBIRD: + case H2: + default: + return "VARCHAR(4000)"; + } + } + + private String getBooleanType() { + switch (sqlDialect) { + case MYSQL: + case MARIADB: + return "TINYINT(1)"; + case POSTGRESQL: + case H2: + case FIREBIRD: + case INFORMIX: + return "BOOLEAN"; + case SQLITE: + return "INTEGER"; + case SQLSERVER: + case SYBASE: + return "BIT"; + case ORACLE: + return "NUMBER(1)"; + case DB2: + default: + return "SMALLINT"; + } + } + + enum ColumnType { + VARCHAR, + INTEGER, + LONG, + TIMESTAMP, + BOOLEAN, + TEXT + } + + static final class ColumnDefinition { + final String name; + final ColumnType type; + final int size; + final boolean nullable; + + ColumnDefinition(String name, ColumnType type, int size, boolean nullable) { + this.name = name; + this.type = type; + this.size = size; + this.nullable = nullable; + } + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventMapper.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventMapper.java new file mode 100644 index 000000000..392088b99 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventMapper.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.journal.JournalEventType; +import io.flamingock.internal.common.sql.SqlDialect; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; + +/** + * Maps the supported SQL Journal Event envelope and its flattened audit payload. + */ +final class SqlJournalEventMapper { + + private final int nullableBooleanType; + + SqlJournalEventMapper() { + this(SqlDialect.H2); + } + + SqlJournalEventMapper(SqlDialect sqlDialect) { + nullableBooleanType = new SqlJournalDialectHelper(sqlDialect).getBooleanJdbcType(); + } + + void bind(PreparedStatement statement, JournalEvent event) throws SQLException { + requireSupportedEvent(event); + statement.setString(1, event.getEventId()); + statement.setString(2, event.getEventType().name()); + statement.setInt(3, event.getEventVersion()); + statement.setString(4, event.getStreamId()); + statement.setLong(5, event.getStreamSequence()); + statement.setTimestamp(6, Timestamp.from(event.getOccurredAt())); + statement.setBoolean(7, event.isAcknowledged()); + AuditEntryMapper.bind(statement, event.getData(), 8, nullableBooleanType); + } + + JournalEvent fromResultSet(ResultSet resultSet) throws SQLException { + JournalEventType eventType = JournalEventType.valueOf( + resultSet.getString(JournalEventConstants.EVENT_TYPE)); + if (eventType != JournalEventType.CHANGE_STATE) { + throw new UnsupportedOperationException("Unsupported SQL Journal Event type: " + eventType); + } + + Timestamp occurredAt = resultSet.getTimestamp(JournalEventConstants.OCCURRED_AT); + if (occurredAt == null) { + throw new SQLException("Journal event occurred_at must not be null"); + } + + return new JournalEvent<>( + resultSet.getString(JournalEventConstants.EVENT_ID), + eventType, + resultSet.getInt(JournalEventConstants.EVENT_VERSION), + resultSet.getString(JournalEventConstants.STREAM_ID), + resultSet.getLong(JournalEventConstants.STREAM_SEQUENCE), + occurredAt.toInstant(), + AuditEntryMapper.fromResultSet(resultSet), + resultSet.getBoolean(JournalEventConstants.ACKNOWLEDGED)); + } + + private static void requireSupportedEvent(JournalEvent event) { + if (event == null || event.getEventType() != JournalEventType.CHANGE_STATE + || !(event.getData() instanceof AuditEntry)) { + throw new UnsupportedOperationException("SQL Journal Events support CHANGE_STATE with AuditEntry payloads only"); + } + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventStore.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventStore.java new file mode 100644 index 000000000..02981c8cf --- /dev/null +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlJournalEventStore.java @@ -0,0 +1,532 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.context.RuntimeContext; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.transaction.TransactionWrapper; +import io.flamingock.internal.common.sql.SqlDialect; +import io.flamingock.internal.common.sql.SqlDialectFactory; +import io.flamingock.internal.core.context.BasicRuntimeContext; +import io.flamingock.internal.core.journal.JournalEventStore; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * JDBC implementation of the local Journal Event buffer. + * + *

The store owns schema lifecycle and read/acknowledgement operations. Appends deliberately receive a + * caller-owned connection so an audit current-state write and its event can share one transaction.

+ */ +public class SqlJournalEventStore implements JournalEventStore { + + private final DataSource dataSource; + private final String tableName; + private final TransactionWrapper txWrapper; + private SqlJournalEventMapper mapper; + + private SqlJournalDialectHelper dialectHelper; + + /** + * Creates a journal store over a configured datasource. + * + * @param dataSource datasource used by schema and read operations + * @param tableName journal table name + * @param txWrapper SQL transaction wrapper that owns Journal writes + */ + public SqlJournalEventStore(DataSource dataSource, String tableName, TransactionWrapper txWrapper) { + if (dataSource == null) { + throw new IllegalArgumentException("dataSource must not be null"); + } + JournalEventConstants.validateIdentifier(tableName, "tableName"); + if (txWrapper == null) { + throw new IllegalArgumentException("txWrapper must not be null"); + } + this.dataSource = dataSource; + this.tableName = tableName; + this.txWrapper = txWrapper; + } + + /** + * Creates or validates the journal table and its indexes. + * + * @param autoCreate whether the table and indexes may be created when missing + */ + public synchronized void initialize(boolean autoCreate) { + try (Connection connection = dataSource.getConnection()) { + dialectHelper = new SqlJournalDialectHelper(SqlDialectFactory.getSqlDialect(connection)); + mapper = new SqlJournalEventMapper(dialectHelper.getSqlDialect()); + if (!tableExists(connection.getMetaData())) { + if (!autoCreate) { + throw new IllegalStateException("SQL journal table '" + tableName + "' does not exist"); + } + createSchema(connection); + } + validateSchema(connection.getMetaData()); + } catch (SQLException exception) { + throw sqlFailure("Failed to initialize SQL journal table '" + tableName + "'", exception); + } + } + + /** + * Appends an immutable event using the supplied transaction-scoped connection. + * + * @param connection transaction-scoped connection owned by the caller + * @param event event to append + */ + void append(Connection connection, JournalEvent event) { + ensureInitialized(); + if (connection == null) { + throw new IllegalArgumentException("connection must not be null"); + } + try (PreparedStatement statement = connection.prepareStatement(dialectHelper.getInsertSqlString(tableName))) { + mapper.bind(statement, event); + statement.executeUpdate(); + } catch (SQLException exception) { + throw sqlFailure("Failed to append SQL journal event", exception); + } + } + + @Override + public Optional> getLastEventByStream(String streamId) { + ensureInitialized(); + if (streamId == null || streamId.trim().isEmpty()) { + return Optional.empty(); + } + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + dialectHelper.getLastEventSqlString(tableName))) { + statement.setString(1, streamId); + statement.setMaxRows(1); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapper.fromResultSet(resultSet)) : Optional.empty(); + } + } catch (SQLException exception) { + throw sqlFailure("Failed to read last SQL journal event", exception); + } + } + + @Override + public List> getUnacknowledgedEvents(int limit) { + ensureInitialized(); + if (limit <= 0) { + return Collections.emptyList(); + } + + List> events = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + dialectHelper.getUnacknowledgedEventsSqlString(tableName))) { + statement.setBoolean(1, false); + statement.setMaxRows(limit); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + events.add(mapper.fromResultSet(resultSet)); + } + } + return events; + } catch (SQLException exception) { + throw sqlFailure("Failed to read unacknowledged SQL journal events", exception); + } + } + + @Override + public long acknowledgeEvents(Collection eventIds) { + ensureInitialized(); + if (eventIds == null || eventIds.isEmpty()) { + return 0L; + } + + Set validEventIds = new LinkedHashSet<>(); + for (String eventId : eventIds) { + if (eventId != null && !eventId.trim().isEmpty()) { + validEventIds.add(eventId); + } + } + if (validEventIds.isEmpty()) { + return 0L; + } + + long acknowledged = 0L; + for (String eventId : validEventIds) { + RuntimeContext baseContext = new BasicRuntimeContext( + "acknowledge-journal-event-" + UUID.randomUUID()); + acknowledged += txWrapper.wrapInTransaction(baseContext, runtimeContext -> { + Connection connection = runtimeContext.getContext().getRequiredDependencyValue(Connection.class); + return acknowledgeEvents(connection, Collections.singleton(eventId)); + }); + } + return acknowledged; + } + + private long acknowledgeEvents(Connection connection, Collection eventIds) { + long acknowledged = 0L; + try (PreparedStatement statement = connection.prepareStatement( + dialectHelper.getAcknowledgeSqlString(tableName))) { + for (String eventId : eventIds) { + statement.setBoolean(1, true); + statement.setString(2, eventId); + statement.setBoolean(3, false); + acknowledged += statement.executeUpdate(); + } + return acknowledged; + } catch (SQLException exception) { + throw sqlFailure("Failed to acknowledge SQL journal events", exception); + } + } + + private void createSchema(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + dialectHelper.getCreateTableSqlString(tableName))) { + statement.executeUpdate(); + } + for (String indexSql : dialectHelper.getCreateIndexSqlStrings(tableName)) { + try (PreparedStatement statement = connection.prepareStatement(indexSql)) { + statement.executeUpdate(); + } + } + } + + private void validateSchema(DatabaseMetaData metadata) throws SQLException { + List actualColumns = readColumns(metadata); + List expectedColumns = dialectHelper.getColumnDefinitions(); + Set expectedColumnNames = new HashSet<>(); + for (SqlJournalDialectHelper.ColumnDefinition expected : expectedColumns) { + expectedColumnNames.add(expected.name.toLowerCase(Locale.ROOT)); + } + for (ColumnMetadata actual : actualColumns) { + if (!expectedColumnNames.contains(actual.name.toLowerCase(Locale.ROOT)) + && actual.blocksExplicitInsert()) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' has extra non-null column '" + actual.name + + "' without a default, identity, or generated value"); + } + } + + Map> columnsByName = new HashMap<>(); + for (ColumnMetadata actual : actualColumns) { + columnsByName.computeIfAbsent(actual.name.toLowerCase(Locale.ROOT), ignored -> new ArrayList<>()) + .add(actual); + } + + for (SqlJournalDialectHelper.ColumnDefinition expected : expectedColumns) { + List matches = columnsByName.get(expected.name.toLowerCase(Locale.ROOT)); + if (matches == null || matches.isEmpty()) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' is missing required column '" + expected.name + "'"); + } + if (matches.size() > 1) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' has ambiguous required column '" + expected.name + "'"); + } + ColumnMetadata actual = matches.get(0); + if (actual.nullable != (expected.nullable + ? DatabaseMetaData.columnNullable : DatabaseMetaData.columnNoNulls)) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' has incorrect nullability for column '" + expected.name + "'"); + } + if (!matchesColumnType(expected, actual)) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' has incorrect type or capacity for column '" + expected.name + "'"); + } + } + + validateIndexes(metadata); + validatePrimaryKey(metadata); + } + + private List readColumns(DatabaseMetaData metadata) throws SQLException { + List columns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, null, null)) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + columns.add(new ColumnMetadata( + resultSet.getString("COLUMN_NAME"), + resultSet.getInt("DATA_TYPE"), + resultSet.getString("TYPE_NAME"), + resultSet.getInt("COLUMN_SIZE"), + resultSet.getInt("NULLABLE"), + resultSet.getInt("ORDINAL_POSITION"), + readOptionalMetadata(resultSet, "COLUMN_DEF"), + readOptionalMetadata(resultSet, "IS_AUTOINCREMENT"), + readOptionalMetadata(resultSet, "IS_GENERATEDCOLUMN"))); + } + } + } + columns.sort(Comparator.comparingInt(column -> column.ordinalPosition)); + return columns; + } + + private OptionalMetadata readOptionalMetadata(ResultSet resultSet, String columnName) { + try { + return new OptionalMetadata(true, resultSet.getString(columnName)); + } catch (SQLException | RuntimeException exception) { + return new OptionalMetadata(false, null); + } + } + + private Map readIndexes(DatabaseMetaData metadata) throws SQLException { + Map indexes = new HashMap<>(); + for (String candidate : tableNameCandidates()) { + try (ResultSet resultSet = metadata.getIndexInfo(null, null, candidate, false, false)) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + String indexName = resultSet.getString("INDEX_NAME"); + String columnName = resultSet.getString("COLUMN_NAME"); + if (indexName != null && columnName != null) { + String key = indexName.toLowerCase(Locale.ROOT); + IndexMetadata index = indexes.get(key); + if (index == null) { + index = new IndexMetadata(); + indexes.put(key, index); + } + index.nonUnique = resultSet.getBoolean("NON_UNIQUE"); + index.columns.put(resultSet.getShort("ORDINAL_POSITION"), columnName); + } + } + } + } + } + return indexes; + } + + private void validateIndexes(DatabaseMetaData metadata) throws SQLException { + Map indexes = readIndexes(metadata); + List names = dialectHelper.getIndexNames(tableName); + List> expectedColumns = new ArrayList<>(); + expectedColumns.add(asList(JournalEventConstants.ACKNOWLEDGED, + JournalEventConstants.STREAM_ID, JournalEventConstants.STREAM_SEQUENCE)); + expectedColumns.add(asList(JournalEventConstants.EVENT_ID)); + + for (int i = 0; i < names.size(); i++) { + IndexMetadata index = indexes.get(names.get(i).toLowerCase(Locale.ROOT)); + if (index == null) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' is missing index '" + names.get(i) + "'"); + } + if (!index.nonUnique || !sameColumns(index.columnsInOrder(), expectedColumns.get(i))) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' has incorrect shape for index '" + names.get(i) + "'"); + } + } + } + + private void validatePrimaryKey(DatabaseMetaData metadata) throws SQLException { + Map primaryKeyColumns = new HashMap<>(); + for (String candidate : tableNameCandidates()) { + try (ResultSet resultSet = metadata.getPrimaryKeys(null, null, candidate)) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + primaryKeyColumns.put(resultSet.getShort("KEY_SEQ"), resultSet.getString("COLUMN_NAME")); + } + } + } + } + if (primaryKeyColumns.size() != 2 + || !JournalEventConstants.STREAM_ID.equalsIgnoreCase(primaryKeyColumns.get((short) 1)) + || !JournalEventConstants.STREAM_SEQUENCE.equalsIgnoreCase(primaryKeyColumns.get((short) 2))) { + throw new IllegalStateException("SQL journal table '" + tableName + + "' must have primary key (stream_id, stream_sequence)"); + } + } + + private String[] tableNameCandidates() { + return new String[]{tableName, tableName.toUpperCase(), tableName.toLowerCase()}; + } + + private boolean tableExists(DatabaseMetaData metadata) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } + + private void ensureInitialized() { + if (dialectHelper == null || mapper == null) { + throw new IllegalStateException("SQL journal store is not initialized"); + } + } + + private IllegalStateException sqlFailure(String message, SQLException exception) { + return new IllegalStateException(message, exception); + } + + private boolean matchesColumnType(SqlJournalDialectHelper.ColumnDefinition expected, + ColumnMetadata actual) { + switch (expected.type) { + case VARCHAR: + return actual.jdbcType == java.sql.Types.VARCHAR && actual.columnSize == expected.size; + case INTEGER: + return actual.jdbcType == java.sql.Types.INTEGER + || (dialectHelper.getSqlDialect() == SqlDialect.ORACLE + && isNumeric(actual.jdbcType)); + case LONG: + return (actual.jdbcType == java.sql.Types.BIGINT && actual.columnSize >= expected.size) + || (dialectHelper.getSqlDialect() == SqlDialect.SQLITE + && actual.jdbcType == java.sql.Types.INTEGER) + || (dialectHelper.getSqlDialect() == SqlDialect.ORACLE + && isNumeric(actual.jdbcType) && actual.columnSize >= expected.size); + case TIMESTAMP: + return actual.jdbcType == java.sql.Types.TIMESTAMP + || (dialectHelper.getSqlDialect() == SqlDialect.SQLITE + && "TIMESTAMP".equalsIgnoreCase(actual.typeName)); + case BOOLEAN: + return actual.jdbcType == dialectHelper.getBooleanJdbcType() + || isBooleanDriverAlias(actual); + case TEXT: + if (actual.jdbcType == java.sql.Types.CLOB || actual.jdbcType == java.sql.Types.NCLOB) { + return false; + } + return (actual.jdbcType == java.sql.Types.VARCHAR + || actual.jdbcType == java.sql.Types.LONGVARCHAR) + && (actual.columnSize >= expected.size + || "TEXT".equalsIgnoreCase(actual.typeName)); + default: + return false; + } + } + + private boolean isNumeric(int jdbcType) { + return jdbcType == java.sql.Types.NUMERIC || jdbcType == java.sql.Types.DECIMAL; + } + + private boolean isBooleanDriverAlias(ColumnMetadata actual) { + switch (dialectHelper.getSqlDialect()) { + case MYSQL: + case MARIADB: + return actual.jdbcType == java.sql.Types.BIT + || actual.jdbcType == java.sql.Types.BOOLEAN; + case POSTGRESQL: + case INFORMIX: + return actual.jdbcType == java.sql.Types.BIT; + default: + return false; + } + } + + private static List asList(String... values) { + return new ArrayList<>(java.util.Arrays.asList(values)); + } + + private static boolean sameColumns(List actual, List expected) { + if (actual.size() != expected.size()) { + return false; + } + for (int i = 0; i < actual.size(); i++) { + if (!actual.get(i).equalsIgnoreCase(expected.get(i))) { + return false; + } + } + return true; + } + + private static final class ColumnMetadata { + private final String name; + private final int jdbcType; + private final String typeName; + private final int columnSize; + private final int nullable; + private final int ordinalPosition; + private final OptionalMetadata columnDefault; + private final OptionalMetadata autoIncrement; + private final OptionalMetadata generated; + + private ColumnMetadata(String name, + int jdbcType, + String typeName, + int columnSize, + int nullable, + int ordinalPosition, + OptionalMetadata columnDefault, + OptionalMetadata autoIncrement, + OptionalMetadata generated) { + this.name = name; + this.jdbcType = jdbcType; + this.typeName = typeName; + this.columnSize = columnSize; + this.nullable = nullable; + this.ordinalPosition = ordinalPosition; + this.columnDefault = columnDefault; + this.autoIncrement = autoIncrement; + this.generated = generated; + } + + private boolean blocksExplicitInsert() { + return nullable == DatabaseMetaData.columnNoNulls + && columnDefault.available + && !hasUsableDefault() + && isNo(autoIncrement) + && isNo(generated); + } + + private boolean hasUsableDefault() { + return columnDefault.value != null + && !"NULL".equalsIgnoreCase(columnDefault.value.trim()); + } + + private boolean isNo(OptionalMetadata metadata) { + return metadata.available && "NO".equalsIgnoreCase(metadata.value); + } + } + + private static final class OptionalMetadata { + private final boolean available; + private final String value; + + private OptionalMetadata(boolean available, String value) { + this.available = available; + this.value = value; + } + } + + private static final class IndexMetadata { + private boolean nonUnique; + private final Map columns = new HashMap<>(); + + private List columnsInOrder() { + List> entries = new ArrayList<>(columns.entrySet()); + entries.sort(Map.Entry.comparingByKey()); + List orderedColumns = new ArrayList<>(); + for (Map.Entry entry : entries) { + orderedColumns.add(entry.getValue()); + } + return orderedColumns; + } + } +} diff --git a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlLockService.java b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlLockService.java index ddc3ee604..f1b4f5d73 100644 --- a/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlLockService.java +++ b/community/flamingock-sql-auditstore/src/main/java/io/flamingock/store/sql/internal/SqlLockService.java @@ -40,12 +40,16 @@ public SqlLockService(DataSource dataSource, String lockRepositoryName) { this.lockRepositoryName = lockRepositoryName; } - public void initialize(boolean autoCreate) { - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement()) { + public synchronized void initialize(boolean autoCreate) { + try (Connection conn = dataSource.getConnection()) { this.dialectHelper = new SqlLockDialectHelper(conn); - if (autoCreate) { - stmt.executeUpdate(dialectHelper.getCreateTableSqlString(lockRepositoryName)); + if (!tableExists(conn.getMetaData())) { + if (!autoCreate) { + throw new IllegalStateException("SQL lock table '" + lockRepositoryName + "' does not exist"); + } + try (Statement stmt = conn.createStatement()) { + stmt.executeUpdate(dialectHelper.getCreateTableSqlString(lockRepositoryName)); + } } } catch (SQLException e) { // For Informix, ignore "Table or view already exists" error (SQLCODE -310) @@ -293,4 +297,15 @@ private CommunityLockEntry getLockEntry(Connection conn, String key) throws SQLE private void upsertLockEntry(Connection conn, String key, String owner, LocalDateTime expiresAt) throws SQLException { dialectHelper.upsertLockEntry(conn, lockRepositoryName, key, owner, LockStatus.LOCK_HELD.name(), expiresAt); } + + private boolean tableExists(DatabaseMetaData metadata) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (lockRepositoryName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } } diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/PipelineTestHelper.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/PipelineTestHelper.java deleted file mode 100644 index 5d378e9b3..000000000 --- a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/PipelineTestHelper.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2023 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.store.sql; - -import io.flamingock.api.StageType; -import io.flamingock.api.annotations.Change; -import io.flamingock.api.annotations.TargetSystem; -import io.flamingock.internal.common.core.metadata.FlamingockMetadata; -import io.flamingock.internal.common.core.preview.CodePreviewChange; -import io.flamingock.internal.common.core.preview.PreviewConstructor; -import io.flamingock.internal.common.core.preview.PreviewMethod; -import io.flamingock.internal.common.core.preview.PreviewPipeline; -import io.flamingock.internal.common.core.preview.PreviewStage; -import io.flamingock.internal.common.core.change.RecoveryDescriptor; -import io.flamingock.internal.common.core.change.TargetSystemDescriptor; -import io.flamingock.internal.core.change.loaded.ChangeOrderUtil; -import io.flamingock.internal.util.Pair; -import io.flamingock.internal.util.Trio; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class PipelineTestHelper { - - private static final Function, ChangeInfo> infoExtractor = c -> { - Change ann = c.getAnnotation(Change.class); - TargetSystem targetSystemAnn = c.getAnnotation(TargetSystem.class); - String targetSystemId = targetSystemAnn != null ? targetSystemAnn.id() : null; - String changeId = ann.id(); - String order = ChangeOrderUtil.getMatchedOrderFromClassName(changeId, null, c.getName()); - return new ChangeInfo(changeId, order, ann.author(), targetSystemId, ann.transactional()); - }; - - @NotNull - private static List getParameterTypes(List> second) { - return second - .stream() - .map(Class::getName) - .collect(Collectors.toList()); - } - - /** - * Builds a {@link PreviewPipeline} composed of a single {@link PreviewStage} containing one or more {@link CodePreviewChange}s. - *

- * Each change is derived from a {@link Pair} where: - *

    - *
  • The first item is the {@link Class} annotated with {@link Change}
  • - *
  • The second item is a {@link List} of parameter types (as {@link Class}) expected by the method annotated with {@code @Apply}
  • - *
  • The third item is a {@link List} of parameter types (as {@link Class}) expected by the method annotated with {@code @Rollback}
  • - *
- * - * @param changeDefinitions varargs of pairs containing change classes and their execution method parameters - * @return a {@link PreviewPipeline} ready for preview or testing - */ - @SafeVarargs - public static FlamingockMetadata getPreviewPipeline(String stageName, Trio, List>, List>>... changeDefinitions) { - - List changes = Arrays.stream(changeDefinitions) - .map(trio -> { - ChangeInfo changeInfo = infoExtractor.apply(trio.getFirst()); - PreviewMethod rollback = null; - if (trio.getThird() != null) { - rollback = new PreviewMethod("rollback", getParameterTypes(trio.getThird())); - } - - List changeList = new ArrayList<>(); - changeList.add(new CodePreviewChange( - changeInfo.getChangeId(), - changeInfo.getOrder(), - changeInfo.getAuthor(), - trio.getFirst().getName(), - null, - PreviewConstructor.getDefault(), - new PreviewMethod("apply", getParameterTypes(trio.getSecond())), - rollback, - false, - changeInfo.transactional, - false, - changeInfo.targetSystem, - RecoveryDescriptor.getDefault(), - false - )); - return changeList; - }) - .flatMap(List::stream) - .collect(Collectors.toList()); - - PreviewStage stage = new PreviewStage( - stageName, - StageType.DEFAULT, - "some description", - null, - null, - changes - ); - - PreviewPipeline previewPipeline = new PreviewPipeline(Collections.singletonList(stage)); - return new FlamingockMetadata(previewPipeline, null, null); - } - - @SafeVarargs - public static FlamingockMetadata getPreviewPipeline(Trio, List>, List>>... changeDefinitions) { - return getPreviewPipeline("default-stage-name", changeDefinitions); - } - - - - static class ChangeInfo { - private final String changeId; - private final String order; - private final String author; - private final TargetSystemDescriptor targetSystem; - private final boolean transactional; - - public ChangeInfo(String changeId, String order, String author, String targetSystemId, boolean transactional) { - this.changeId = changeId; - this.order = order; - this.author = author; - this.targetSystem = new TargetSystemDescriptor(targetSystemId); - this.transactional = transactional; - } - - public String getChangeId() { - return changeId; - } - - public String getOrder() { - return order; - } - - public String getAuthor() { - return author; - } - - public TargetSystemDescriptor getTargetSystem() { - return targetSystem; - } - - public boolean isTransactional() { - return transactional; - } - } - -} diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditStoreTest.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditStoreTest.java index b3f174b47..14c2b27c2 100644 --- a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditStoreTest.java +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditStoreTest.java @@ -18,8 +18,18 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import io.flamingock.common.test.pipeline.CodeChangeTestDefinition; +import io.flamingock.core.kit.audit.AuditEntryTestFactory; import io.flamingock.core.kit.TestKit; import io.flamingock.core.kit.audit.AuditTestSupport; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.audit.AuditTxType; +import io.flamingock.internal.common.core.error.FlamingockException; +import io.flamingock.internal.common.core.feature.Features; +import io.flamingock.internal.core.external.store.audit.community.CommunityAuditPersistence; +import io.flamingock.internal.core.configuration.community.CommunityConfiguration; +import io.flamingock.internal.core.context.SimpleContext; +import io.flamingock.internal.util.FeatureFlag; +import io.flamingock.internal.util.id.RunnerId; import io.flamingock.internal.common.sql.SqlDialect; import io.flamingock.internal.core.operation.OperationException; import io.flamingock.store.sql.changes.postgresql.failedWithoutRollback._001__create_index; @@ -35,10 +45,14 @@ import org.sqlite.SQLiteDataSource; import org.testcontainers.containers.JdbcDatabaseContainer; import org.testcontainers.junit.jupiter.Testcontainers; +import org.mockito.MockedStatic; import javax.sql.DataSource; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.sql.*; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -94,6 +108,7 @@ void startContainers() { @AfterEach void tearDown() throws SQLException { + FeatureFlag.remove(Features.JOURNAL_EVENTS); if (context != null) { context.cleanup(); } @@ -396,6 +411,245 @@ void failedWithoutRollback(SqlDialect sqlDialect, String dialectName) throws Exc verifyDataState(context, true); } + @Test + @DisplayName("When journal events are enabled the SQL store creates a stage-scoped journal beside current audit state") + void journalEnabledUsesStageScopedPersistenceAndIndependentReader() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + context = setupTest(SqlDialect.SQLITE, "sqlite"); + + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem) + .withAuditRepositoryName("flamingockAuditLog") + .withLockRepositoryName("flamingockLock") + .withJournalRepositoryName("customJournalEvents"); + auditStore.initialize(baseContext); + + CommunityAuditPersistence persistence = auditStore.getPersistenceFactory().get("stage-one"); + persistence.writeEntry(auditEntry("journal-change", AuditEntry.Status.STARTED)); + persistence.writeEntry(auditEntry("journal-change", AuditEntry.Status.APPLIED)); + + assertEquals(1, auditStore.getAuditReader().getAuditHistory().size()); + assertEquals(1, countRows("flamingockAuditLog")); + assertEquals(2, countRows("customJournalEvents")); + } + + @ParameterizedTest + @MethodSource("dialectProvider") + @DisplayName("journal-enabled writes round-trip through every runtime SQL dialect") + void journalEnabledRoundTripsAcrossRuntimeDialects(SqlDialect sqlDialect, String dialectName) throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + context = setupTest(sqlDialect, dialectName); + + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem); + auditStore.initialize(baseContext); + + CommunityAuditPersistence persistence = auditStore.getPersistenceFactory().get("matrix-stage"); + persistence.writeEntry(auditEntry("matrix-change", AuditEntry.Status.STARTED)); + persistence.writeEntry(auditEntry("matrix-change", AuditEntry.Status.APPLIED)); + + assertEquals(1, auditStore.getAuditReader().getAuditHistory().size()); + assertEquals(2, countRows("flamingockJournalEvents")); + } + + @Test + @DisplayName("keeps the default Journal repository name private to the SQL audit store") + void keepsDefaultJournalRepositoryNamePrivateToSqlAuditStore() throws Exception { + Field defaultRepositoryName = SqlAuditStore.class.getDeclaredField("DEFAULT_JOURNAL_REPOSITORY_NAME"); + + assertTrue(Modifier.isPrivate(defaultRepositoryName.getModifiers())); + assertTrue(Modifier.isStatic(defaultRepositoryName.getModifiers())); + assertTrue(Modifier.isFinal(defaultRepositoryName.getModifiers())); + defaultRepositoryName.setAccessible(true); + assertEquals("flamingockJournalEvents", defaultRepositoryName.get(null)); + } + + @Test + @DisplayName("The journal repository name cannot collide with an audit or lock repository") + void journalRepositoryNameMustBeDistinct() throws Exception { + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem) + .withAuditRepositoryName("sameRepository") + .withLockRepositoryName("differentRepository") + .withJournalRepositoryName("sameRepository"); + + assertThrows(FlamingockException.class, () -> auditStore.initialize(baseContext)); + } + + @Test + @DisplayName("auto-create disabled validates the audit table before journal readiness") + void autoCreateDisabledValidatesAuditBeforeJournal() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem).withAutoCreate(false); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> auditStore.initialize(baseContext)); + + assertTrue(exception.getMessage().toLowerCase().contains("audit"), + "audit readiness must fail before journal readiness"); + } + + @Test + @DisplayName("auto-create disabled validates the lock table during store initialization") + void autoCreateDisabledValidatesLockDuringStoreInitialization() throws Exception { + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore.from(targetSystem).initialize(baseContext); + try (Connection connection = context.dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE flamingockLock"); + } + + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem).withAutoCreate(false); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> auditStore.initialize(baseContext)); + + assertTrue(exception.getMessage().toLowerCase().contains("lock"), + "lock readiness must fail after audit readiness succeeds"); + } + + @Test + @DisplayName("a stage snapshots the journal flag once and uses the captured value") + void stageSnapshotsJournalFlagOnce() throws Exception { + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem); + auditStore.initialize(baseContext); + + AtomicInteger flagReads = new AtomicInteger(); + try (MockedStatic flags = org.mockito.Mockito.mockStatic(FeatureFlag.class)) { + flags.when(() -> FeatureFlag.isEnabled(Features.JOURNAL_EVENTS, false)) + .thenAnswer(invocation -> flagReads.getAndIncrement() == 0); + + CommunityAuditPersistence persistence = auditStore.getPersistenceFactory().get("captured-stage"); + persistence.writeEntry(auditEntry("captured-flag", AuditEntry.Status.APPLIED)); + + assertEquals(1, countRows("flamingockJournalEvents")); + assertEquals(1, flagReads.get()); + flags.verify(() -> FeatureFlag.isEnabled(Features.JOURNAL_EVENTS, false), org.mockito.Mockito.times(1)); + } + } + + @Test + @DisplayName("a Journal flag lookup failure falls back to disabled without touching Journal storage") + void flagLookupFailureFallsBackToDisabledJournal() throws Exception { + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem); + auditStore.initialize(baseContext); + + try (MockedStatic flags = org.mockito.Mockito.mockStatic(FeatureFlag.class)) { + flags.when(() -> FeatureFlag.isEnabled(Features.JOURNAL_EVENTS, false)) + .thenThrow(new RuntimeException("flag lookup failed")); + + CommunityAuditPersistence persistence = auditStore.getPersistenceFactory().get("fallback-stage"); + persistence.writeEntry(auditEntry("fallback-change", AuditEntry.Status.APPLIED)); + + assertEquals(1, auditStore.getAuditReader().getAuditHistory().size()); + assertFalse(tableExists("flamingockJournalEvents")); + flags.verify(() -> FeatureFlag.isEnabled(Features.JOURNAL_EVENTS, false), org.mockito.Mockito.times(1)); + } + } + + @Test + @DisplayName("repeated stage initialization validates existing resources without duplicate DDL") + void repeatedStageInitializationIsIdempotent() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem); + auditStore.initialize(baseContext); + + auditStore.getPersistenceFactory().get("repeatable-stage"); + auditStore.getPersistenceFactory().get("repeatable-stage"); + + assertEquals(0, countRows("flamingockJournalEvents")); + } + + @Test + @DisplayName("the stage factory does not reinitialize store-owned audit readiness") + void stageFactoryDoesNotReinitializeAuditReadiness() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + context = setupTest(SqlDialect.SQLITE, "sqlite"); + SimpleContext baseContext = new SimpleContext(); + baseContext.addDependency(RunnerId.generate()); + baseContext.addDependency(new CommunityConfiguration()); + SqlTargetSystem targetSystem = new SqlTargetSystem("sql", context.dataSource); + targetSystem.initialize(baseContext); + SqlAuditStore auditStore = SqlAuditStore.from(targetSystem); + auditStore.initialize(baseContext); + + try (Connection connection = context.dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE flamingockAuditLog"); + } + + assertNotNull(auditStore.getPersistenceFactory().get("factory-boundary")); + assertFalse(tableExists("flamingockAuditLog")); + assertTrue(tableExists("flamingockJournalEvents")); + } + + private int countRows(String tableName) throws SQLException { + try (Connection connection = context.dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM " + tableName)) { + resultSet.next(); + return resultSet.getInt(1); + } + } + + private boolean tableExists(String tableName) throws SQLException { + try (Connection connection = context.dataSource.getConnection(); + ResultSet resultSet = connection.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return true; + } + } + return false; + } + } + + private static AuditEntry auditEntry(String changeId, AuditEntry.Status status) { + return AuditEntryTestFactory.createTestAuditEntry(changeId, status, AuditTxType.NON_TX, (Class) null); + } + private void verifyDataState(TestContext context, Boolean partial) throws SQLException { try (Connection conn = context.dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT name FROM test_table WHERE id = ?")) { diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditTestHelper.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditTestHelper.java index 43459a11e..384eec220 100644 --- a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditTestHelper.java +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/SqlAuditTestHelper.java @@ -48,7 +48,7 @@ public static void createTables(DataSource dataSource, SqlDialect dialect) throw } private static void dropTablesIfExist(Connection conn, SqlDialect dialect) throws SQLException { - String[] tables = {"flamingockAuditLog", "test_table", "flamingockLock"}; + String[] tables = {"flamingockAuditLog", "flamingockJournalEvents", "test_table", "flamingockLock"}; for (String table : tables) { try { String dropSql = getDropTableSql(table, dialect); @@ -113,13 +113,13 @@ private static String getCreateLockTableSql(SqlDialect dialect) { case MYSQL: case MARIADB: case SQLITE: - case H2: return "CREATE TABLE flamingockLock (" + "`key` VARCHAR(255) PRIMARY KEY, " + "status VARCHAR(32), " + "owner VARCHAR(255), " + "expires_at TIMESTAMP)"; case POSTGRESQL: + case H2: return "CREATE TABLE flamingockLock (" + "\"key\" VARCHAR(255) PRIMARY KEY," + "status VARCHAR(32)," + @@ -163,24 +163,6 @@ private static String getCreateLockTableSql(SqlDialect dialect) { } } - public static void verifyPartialDataState(DataSource dataSource) throws SQLException { - try (Connection conn = dataSource.getConnection(); - PreparedStatement ps = conn.prepareStatement("SELECT name FROM test_table WHERE id = ?")) { - ps.setString(1, "test-client-Federico"); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next() || !"Federico".equals(rs.getString("name"))) { - throw new AssertionError("Federico not found"); - } - } - ps.setString(1, "test-client-Jorge"); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next() || !"Jorge".equals(rs.getString("name"))) { - throw new AssertionError("Jorge not found"); - } - } - } - } - private static String getIndexCheckSql(SqlDialect dialect) { switch (dialect) { case POSTGRESQL: diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlAuditPersistenceJournalTest.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlAuditPersistenceJournalTest.java new file mode 100644 index 000000000..663646023 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlAuditPersistenceJournalTest.java @@ -0,0 +1,673 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.core.kit.audit.AuditEntryTestFactory; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.audit.AuditTxType; +import io.flamingock.internal.common.core.error.DatabaseTransactionException; +import io.flamingock.internal.common.core.feature.Features; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.journal.JournalEventType; +import io.flamingock.internal.core.configuration.community.CommunityConfiguration; +import io.flamingock.internal.core.journal.JournalEventSequencer; +import io.flamingock.internal.core.journal.JournalEventSequencerFactory; +import io.flamingock.internal.core.transaction.TransactionManager; +import io.flamingock.internal.util.FeatureFlag; +import io.flamingock.internal.util.Result; +import io.flamingock.internal.util.id.RunnerId; +import io.flamingock.targetsystem.sql.SqlTxWrapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SqlAuditPersistenceJournalTest { + + private static final String AUDIT_TABLE = "flamingockAuditLog"; + private static final String JOURNAL_TABLE = "flamingockJournalEvents"; + private static final String STREAM_ID = "stage-under-test"; + + private DataSource dataSource; + private SqlTxWrapper txWrapper; + + @BeforeEach + void setUp() throws SQLException { + JdbcDataSource jdbcDataSource = new JdbcDataSource(); + jdbcDataSource.setURL("jdbc:h2:mem:sql_audit_journal;DB_CLOSE_DELAY=-1"); + jdbcDataSource.setUser("sa"); + jdbcDataSource.setPassword(""); + dataSource = jdbcDataSource; + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute("DROP TABLE IF EXISTS " + JOURNAL_TABLE); + connection.createStatement().execute("DROP TABLE IF EXISTS " + AUDIT_TABLE); + } + txWrapper = new SqlTxWrapper(new TransactionManager<>(this::openConnection)); + } + + @AfterEach + void tearDown() { + FeatureFlag.remove(Features.JOURNAL_EVENTS); + } + + @Test + @DisplayName("journal disabled keeps append history and never creates journal storage") + void journalDisabledKeepsLegacyAppendPathAndCreatesNoJournalTable() throws Exception { + SqlJournalEventStore journalStore = new SqlJournalEventStore(dataSource, JOURNAL_TABLE, txWrapper); + SqlAuditPersistence persistence = persistenceFor( + new SqlAuditRepository(dataSource, AUDIT_TABLE), journalStore, mock(JournalEventSequencer.class), false); + + persistence.writeEntry(auditEntry("change-1", AuditEntry.Status.STARTED)); + persistence.writeEntry(auditEntry("change-1", AuditEntry.Status.APPLIED)); + + assertEquals(2, persistence.getAuditHistory().size()); + assertFalse(tableExists(JOURNAL_TABLE), "flag OFF must not initialize or access the journal table"); + } + + @Test + @DisplayName("SQL persistence initialization consumes a ready audit writer") + void persistenceInitializationConsumesReadyAuditWriter() throws Exception { + SqlAuditRepository auditRepository = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditRepository.initialize(true); + SqlAuditPersistence persistence = new SqlAuditPersistence( + new CommunityConfiguration(), auditRepository, null, null, null, false); + + persistence.initialize(RunnerId.generate()); + persistence.writeEntry(auditEntry("legacy-constructor", AuditEntry.Status.APPLIED)); + + assertEquals(1, persistence.getAuditHistory().size()); + } + + @Test + @DisplayName("SQL persistence initialization does not perform schema setup") + void persistenceInitializationDoesNotPerformSchemaSetup() { + SqlAuditRepository auditRepository = org.mockito.Mockito.mock(SqlAuditRepository.class); + SqlJournalEventStore journalStore = org.mockito.Mockito.mock(SqlJournalEventStore.class); + JournalEventSequencer sequencer = org.mockito.Mockito.mock(JournalEventSequencer.class); + SqlAuditPersistence persistence = new SqlAuditPersistence( + new CommunityConfiguration(), auditRepository, journalStore, sequencer, txWrapper, true); + + persistence.initialize(RunnerId.generate()); + + verify(auditRepository, never()).initialize(ArgumentMatchers.anyBoolean()); + verify(journalStore, never()).initialize(ArgumentMatchers.anyBoolean()); + } + + @Test + @DisplayName("journal enabled retains event history while the audit table stores current state") + void journalEnabledSplitsCurrentStateFromHistory() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditPersistence persistence = persistenceFor( + new SqlAuditRepository(dataSource, AUDIT_TABLE), journalStore, newSequencer(journalStore), true); + + persistence.writeEntry(auditEntry("change-1", AuditEntry.Status.STARTED)); + persistence.writeEntry(auditEntry("change-1", AuditEntry.Status.APPLIED)); + + List auditHistory = persistence.getAuditHistory(); + Optional> last = journalStore.getLastEventByStream(STREAM_ID); + + assertEquals(1, auditHistory.size()); + assertEquals(AuditEntry.Status.APPLIED, auditHistory.get(0).getState()); + assertTrue(last.isPresent()); + assertEquals(2L, last.get().getStreamSequence()); + assertEquals(2, journalStore.getUnacknowledgedEvents(10).size()); + } + + @Test + @DisplayName("journal-enabled writes update every mapped value without replacing the current row") + void journalEnabledUpdatesEveryMappedValueWithoutReplacingCurrentRow() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, newSequencer(journalStore), true); + AuditEntry initial = fullAuditEntry("identity-change", "initial", AuditEntry.Status.STARTED); + AuditEntry updated = fullAuditEntry("identity-change", "updated", AuditEntry.Status.APPLIED); + + persistence.writeEntry(initial); + long currentRowId = currentRowId("identity-change"); + + persistence.writeEntry(updated); + + assertEquals(1, auditRowCount("identity-change")); + assertEquals(currentRowId, currentRowId("identity-change")); + assertAuditEntryEquals(updated, persistence.getAuditHistory().get(0)); + } + + @Test + @DisplayName("current-state updates bind nullable audit values through the dialect mapper") + void currentStateUpdatesBindNullableValues() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + auditor.writeEntry(fullAuditEntry("nullable-current", "initial", AuditEntry.Status.STARTED)); + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + auditor.replaceCurrentState(connection, nullableAuditEntry("nullable-current")); + connection.commit(); + } + + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement( + "SELECT author, created_at, state, metadata, error_trace, tx_strategy, " + + "target_system_id, change_order, recovery_strategy, transaction_flag, system_change " + + "FROM " + AUDIT_TABLE + " WHERE change_id = ?")) { + statement.setString(1, "nullable-current"); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next()); + assertNull(resultSet.getString("author")); + assertNull(resultSet.getTimestamp("created_at")); + assertNull(resultSet.getString("state")); + assertNull(resultSet.getString("metadata")); + assertNull(resultSet.getString("error_trace")); + assertEquals(AuditTxType.NON_TX.name(), resultSet.getString("tx_strategy")); + assertNull(resultSet.getString("target_system_id")); + assertNull(resultSet.getString("change_order")); + assertEquals(RecoveryStrategy.MANUAL_INTERVENTION.name(), resultSet.getString("recovery_strategy")); + assertNull(resultSet.getObject("transaction_flag")); + assertFalse(resultSet.getBoolean("system_change")); + } + } + } + + @Test + @DisplayName("append and history mapping preserve nullable audit values") + void appendAndHistoryMappingPreserveNullableValues() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + + Result result = auditor.writeEntry(nullableAuditEntry("nullable-append")); + + assertTrue(result instanceof Result.Ok); + AuditEntry actual = auditor.getAuditHistory().get(0); + assertNull(actual.getCreatedAt()); + assertNull(actual.getAuthor()); + assertNull(actual.getState()); + assertNull(actual.getTransactionFlag()); + assertFalse(actual.getSystemChange()); + } + + @Test + @DisplayName("current-state writes leave transaction ownership with the caller") + void currentStateWriteDoesNotCommitOrCloseCallerConnection() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + auditor.replaceCurrentState(connection, + fullAuditEntry("caller-owned", "uncommitted", AuditEntry.Status.APPLIED)); + + assertFalse(connection.isClosed()); + assertFalse(connection.getAutoCommit()); + assertEquals(0, auditRowCount("caller-owned")); + connection.rollback(); + } + + assertEquals(0, auditRowCount("caller-owned")); + } + + @Test + @DisplayName("journal-enabled writes insert exactly one current row when the update matches nothing") + void journalEnabledInsertsWhenCurrentRowIsAbsent() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, newSequencer(journalStore), true); + + persistence.writeEntry(fullAuditEntry("new-current-row", "inserted", AuditEntry.Status.APPLIED)); + + assertEquals(1, auditRowCount("new-current-row")); + assertEquals(1, persistence.getAuditHistory().size()); + } + + @Test + @DisplayName("more than one current row fails transactionally without repairing audit state") + void multipleCurrentRowsFailWithoutRepairOrSequenceConfirmation() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + AuditEntry first = fullAuditEntry("duplicate-current", "first", AuditEntry.Status.STARTED); + AuditEntry second = fullAuditEntry("duplicate-current", "second", AuditEntry.Status.FAILED); + auditor.writeEntry(first); + auditor.writeEntry(second); + + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, newSequencer(journalStore), true); + + assertThrows(DatabaseTransactionException.class, + () -> persistence.writeEntry(fullAuditEntry("duplicate-current", "attempted", AuditEntry.Status.APPLIED))); + + assertEquals(2, auditRowCount("duplicate-current")); + assertEquals(2, persistence.getAuditHistory().size()); + assertEquals(first.getExecutionId(), persistence.getAuditHistory().get(0).getExecutionId()); + assertEquals(second.getExecutionId(), persistence.getAuditHistory().get(1).getExecutionId()); + assertTrue(journalStore.getUnacknowledgedEvents(10).isEmpty()); + + persistence.writeEntry(fullAuditEntry("after-duplicate", "retry", AuditEntry.Status.APPLIED)); + + assertEquals(1L, journalStore.getLastEventByStream(STREAM_ID).get().getStreamSequence()); + } + + @Test + @DisplayName("journal current-state writes reject a null change id before DML") + void nullChangeIdIsRejectedBeforeDml() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + + try (Connection connection = dataSource.getConnection()) { + assertThrows(IllegalArgumentException.class, + () -> auditor.replaceCurrentState(connection, fullAuditEntry(null, "null-id", AuditEntry.Status.APPLIED))); + } + + assertEquals(0, auditRowCount(null)); + } + + @Test + @DisplayName("journal current-state writes reject a blank change id before DML") + void blankChangeIdIsRejectedBeforeDml() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + + try (Connection connection = dataSource.getConnection()) { + assertThrows(IllegalArgumentException.class, + () -> auditor.replaceCurrentState(connection, fullAuditEntry(" ", "blank-id", AuditEntry.Status.APPLIED))); + } + + assertEquals(0, auditRowCount(" ")); + } + + @Test + @DisplayName("invalid audit table identifiers are rejected before current-state DML") + void invalidAuditTableIdentifierIsRejectedBeforeDml() { + assertThrows(IllegalArgumentException.class, + () -> new SqlAuditRepository(dataSource, "audit-log")); + assertThrows(IllegalArgumentException.class, + () -> new SqlAuditRepository(dataSource, " ")); + } + + @Test + @DisplayName("journal-disabled writes keep append behavior and skip current-state persistence") + void journalDisabledWritesUseAppendOnlyRepositoryOperation() { + SqlAuditRepository auditor = mock(SqlAuditRepository.class); + when(auditor.writeEntry(ArgumentMatchers.any(AuditEntry.class))).thenReturn(Result.OK()); + SqlAuditPersistence persistence = new SqlAuditPersistence( + new CommunityConfiguration(), auditor, null, null, null, false); + + Result result = persistence.writeEntry(auditEntry("append-only", AuditEntry.Status.APPLIED)); + + assertTrue(result instanceof Result.Ok); + verify(auditor).writeEntry(ArgumentMatchers.any(AuditEntry.class)); + verify(auditor, never()).replaceCurrentState( + ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class)); + } + + @Test + @DisplayName("journal-enabled writes append the event before current state and confirm after success") + void journalWriteAppendsEventBeforeCurrentStateAndConfirmsAfterSuccess() { + SqlAuditRepository auditor = mock(SqlAuditRepository.class); + SqlJournalEventStore journalStore = mock(SqlJournalEventStore.class); + JournalEventSequencer sequencer = mock(JournalEventSequencer.class); + AuditEntry auditEntry = auditEntry("ordered-write", AuditEntry.Status.APPLIED); + JournalEvent event = event(STREAM_ID, 1L, "ordered-event", auditEntry.getChangeId()); + when(sequencer.newEvent(auditEntry)).thenReturn(event); + when(auditor.replaceCurrentState( + ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class))) + .thenReturn(Result.OK()); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, sequencer, true); + + persistence.writeEntry(auditEntry); + + org.mockito.InOrder order = inOrder(sequencer, journalStore, auditor); + order.verify(sequencer).newEvent(auditEntry); + order.verify(journalStore).append(ArgumentMatchers.any(Connection.class), ArgumentMatchers.same(event)); + order.verify(auditor).replaceCurrentState( + ArgumentMatchers.any(Connection.class), ArgumentMatchers.same(auditEntry)); + order.verify(sequencer).confirm(); + } + + @Test + @DisplayName("journal enabled does not backfill an existing audit row") + void journalEnabledDoesNotBackfillExistingAuditHistory() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + auditor.writeEntry(auditEntry("legacy-change", AuditEntry.Status.APPLIED)); + + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, newSequencer(journalStore), true); + persistence.writeEntry(auditEntry("new-change", AuditEntry.Status.APPLIED)); + + assertEquals(2, persistence.getAuditHistory().size()); + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + assertEquals("new-change", journalStore.getUnacknowledgedEvents(10).get(0).getData().getChangeId()); + } + + @Test + @DisplayName("a journal append failure rolls the current audit write back") + void journalFailureRollsBackAuditEntry() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + JournalEventSequencer sequencer = newSequencer(journalStore); + appendDirectly(journalStore, event(STREAM_ID, 1L, "occupying-event", "occupying-change")); + SqlAuditPersistence persistence = persistenceFor( + new SqlAuditRepository(dataSource, AUDIT_TABLE), journalStore, sequencer, true); + + assertThrows(DatabaseTransactionException.class, + () -> persistence.writeEntry(auditEntry("failed-change", AuditEntry.Status.APPLIED))); + + assertTrue(persistence.getAuditHistory().isEmpty()); + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + } + + @Test + @DisplayName("an audit write failure rolls the journal event back") + void auditFailureRollsBackJournalEvent() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditRepository failingAuditor = mock(SqlAuditRepository.class); + doThrow(new IllegalStateException("audit write failed")) + .when(failingAuditor) + .replaceCurrentState(ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class)); + SqlAuditPersistence persistence = persistenceFor(failingAuditor, journalStore, newSequencer(journalStore), true); + + assertThrows(DatabaseTransactionException.class, + () -> persistence.writeEntry(auditEntry("failed-audit", AuditEntry.Status.APPLIED))); + + assertTrue(journalStore.getUnacknowledgedEvents(10).isEmpty()); + } + + @Test + @DisplayName("a failed journal transaction reuses its unconfirmed stream position") + void failedWriteLeavesNoSequenceGap() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + JournalEventSequencer sequencer = newSequencer(journalStore); + SqlAuditRepository failingAuditor = mock(SqlAuditRepository.class); + doThrow(new IllegalStateException("audit write failed")) + .when(failingAuditor) + .replaceCurrentState(ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class)); + SqlAuditPersistence persistence = persistenceFor(failingAuditor, journalStore, sequencer, true); + + assertThrows(DatabaseTransactionException.class, + () -> persistence.writeEntry(auditEntry("first-attempt", AuditEntry.Status.APPLIED))); + when(failingAuditor.replaceCurrentState( + ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class))) + .thenReturn(Result.OK()); + + persistence.writeEntry(auditEntry("retry", AuditEntry.Status.APPLIED)); + + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + assertEquals(1L, journalStore.getUnacknowledgedEvents(10).get(0).getStreamSequence()); + assertEquals("retry", journalStore.getUnacknowledgedEvents(10).get(0).getData().getChangeId()); + } + + @Test + @DisplayName("the audit reader stays independent from journal delivery reads") + void auditReaderRemainsIndependentFromJournalStore() throws Exception { + FeatureFlag.enable(Features.JOURNAL_EVENTS); + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, newSequencer(journalStore), true); + persistence.writeEntry(auditEntry("reader-change", AuditEntry.Status.APPLIED)); + + assertEquals(1, persistence.getAuditHistory().size()); + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + } + + @Test + @DisplayName("the captured journal flag controls writes without a second global flag read") + void capturedJournalFlagControlsWrites() throws Exception { + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditPersistence persistence = persistenceFor( + new SqlAuditRepository(dataSource, AUDIT_TABLE), journalStore, newSequencer(journalStore), true); + + persistence.writeEntry(auditEntry("captured-flag", AuditEntry.Status.APPLIED)); + + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + assertEquals(1, persistence.getAuditHistory().size()); + } + + @Test + @DisplayName("the first flag-on write updates a legacy row and emits only the transition event") + void firstFlagOnWriteUpdatesLegacyCurrentState() throws Exception { + SqlAuditRepository auditor = new SqlAuditRepository(dataSource, AUDIT_TABLE); + auditor.initialize(true); + auditor.writeEntry(auditEntry("legacy-transition", AuditEntry.Status.STARTED)); + + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditPersistence persistence = persistenceFor(auditor, journalStore, + newSequencer(journalStore), true); + + persistence.writeEntry(auditEntry("legacy-transition", AuditEntry.Status.APPLIED)); + + assertEquals(1, persistence.getAuditHistory().size()); + assertEquals(AuditEntry.Status.APPLIED, persistence.getAuditHistory().get(0).getState()); + assertEquals(1, journalStore.getUnacknowledgedEvents(10).size()); + assertEquals("legacy-transition", + journalStore.getUnacknowledgedEvents(10).get(0).getData().getChangeId()); + } + + @Test + @DisplayName("a returned audit error rolls the journal event back") + void returnedAuditErrorRollsBackJournalEvent() throws Exception { + SqlJournalEventStore journalStore = initializedJournalStore(); + SqlAuditRepository failingAuditor = mock(SqlAuditRepository.class); + when(failingAuditor.replaceCurrentState( + ArgumentMatchers.any(Connection.class), ArgumentMatchers.any(AuditEntry.class))) + .thenReturn(new Result.Error(new IllegalStateException("audit write failed"))); + SqlAuditPersistence persistence = persistenceFor(failingAuditor, journalStore, + newSequencer(journalStore), true); + + assertThrows(DatabaseTransactionException.class, + () -> persistence.writeEntry(auditEntry("failed-result", AuditEntry.Status.APPLIED))); + + assertTrue(journalStore.getUnacknowledgedEvents(10).isEmpty()); + } + + private SqlJournalEventStore initializedJournalStore() { + SqlJournalEventStore journalStore = new SqlJournalEventStore(dataSource, JOURNAL_TABLE, txWrapper); + journalStore.initialize(true); + return journalStore; + } + + private JournalEventSequencer newSequencer(SqlJournalEventStore journalStore) { + return new JournalEventSequencerFactory(journalStore).forStream(STREAM_ID); + } + + private SqlAuditPersistence persistenceFor(SqlAuditRepository auditor, + SqlJournalEventStore journalStore, + JournalEventSequencer sequencer, + boolean journalEventsEnabled) { + auditor.initialize(true); + SqlAuditPersistence persistence = new SqlAuditPersistence( + new CommunityConfiguration(), auditor, journalStore, sequencer, txWrapper, journalEventsEnabled); + persistence.initialize(RunnerId.generate()); + return persistence; + } + + private void appendDirectly(SqlJournalEventStore journalStore, JournalEvent event) throws Exception { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + journalStore.append(connection, event); + connection.commit(); + } + } + + private Connection openConnection() { + try { + return dataSource.getConnection(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not open test connection", exception); + } + } + + private boolean tableExists(String tableName) throws SQLException { + try (Connection connection = dataSource.getConnection(); + ResultSet resultSet = connection.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (tableName.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return true; + } + } + return false; + } + } + + private long currentRowId(String changeId) throws SQLException { + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement( + "SELECT id FROM " + AUDIT_TABLE + " WHERE change_id = ?")) { + statement.setString(1, changeId); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next()); + long id = resultSet.getLong(1); + assertFalse(resultSet.next()); + return id; + } + } + } + + private int auditRowCount(String changeId) throws SQLException { + String sql = changeId == null + ? "SELECT COUNT(*) FROM " + AUDIT_TABLE + : "SELECT COUNT(*) FROM " + AUDIT_TABLE + " WHERE change_id = ?"; + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement(sql)) { + if (changeId != null) { + statement.setString(1, changeId); + } + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next()); + return resultSet.getInt(1); + } + } + } + + private static AuditEntry auditEntry(String changeId, AuditEntry.Status status) { + return AuditEntryTestFactory.createTestAuditEntry(changeId, status, AuditTxType.NON_TX, (Class) null); + } + + private static AuditEntry fullAuditEntry(String changeId, String suffix, AuditEntry.Status status) { + return new AuditEntry( + "execution-" + suffix, + "stage-" + suffix, + changeId, + "author-" + suffix, + LocalDateTime.of(2026, 8, 18, 10, 20, 30), + status, + AuditEntry.ChangeType.STANDARD_CODE, + "class-" + suffix, + "method-" + suffix, + "source-" + suffix, + 100L + suffix.length(), + "host-" + suffix, + "metadata-" + suffix, + true, + "error-" + suffix, + AuditTxType.NON_TX, + "target-" + suffix, + "order-" + suffix, + RecoveryStrategy.ALWAYS_RETRY, + false); + } + + private static AuditEntry nullableAuditEntry(String changeId) { + return new AuditEntry( + "execution-nullable", + "stage-nullable", + changeId, + null, + null, + null, + null, + null, + null, + null, + 0L, + null, + null, + false, + null, + null, + null, + null, + null, + null); + } + + private static void assertAuditEntryEquals(AuditEntry expected, AuditEntry actual) { + assertEquals(expected.getExecutionId(), actual.getExecutionId()); + assertEquals(expected.getStageId(), actual.getStageId()); + assertEquals(expected.getChangeId(), actual.getChangeId()); + assertEquals(expected.getAuthor(), actual.getAuthor()); + assertEquals(expected.getCreatedAt(), actual.getCreatedAt()); + assertEquals(expected.getState(), actual.getState()); + assertEquals(expected.getType(), actual.getType()); + assertEquals(expected.getClassName(), actual.getClassName()); + assertEquals(expected.getMethodName(), actual.getMethodName()); + assertEquals(expected.getSourceFile(), actual.getSourceFile()); + assertEquals(expected.getExecutionMillis(), actual.getExecutionMillis()); + assertEquals(expected.getExecutionHostname(), actual.getExecutionHostname()); + assertEquals(expected.getMetadata(), actual.getMetadata()); + assertEquals(expected.getSystemChange(), actual.getSystemChange()); + assertEquals(expected.getErrorTrace(), actual.getErrorTrace()); + assertEquals(expected.getTxType(), actual.getTxType()); + assertEquals(expected.getTargetSystemId(), actual.getTargetSystemId()); + assertEquals(expected.getOrder(), actual.getOrder()); + assertEquals(expected.getRecoveryStrategy(), actual.getRecoveryStrategy()); + assertEquals(expected.getTransactionFlag(), actual.getTransactionFlag()); + } + + private static JournalEvent event(String streamId, + long sequence, + String eventId, + String changeId) { + return new JournalEvent<>( + eventId, + JournalEventType.CHANGE_STATE, + JournalEvent.DEFAULT_VERSION, + streamId, + sequence, + Instant.now(), + auditEntry(changeId, AuditEntry.Status.APPLIED), + false); + } +} diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalDialectHelperTest.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalDialectHelperTest.java new file mode 100644 index 000000000..fd5581b73 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalDialectHelperTest.java @@ -0,0 +1,253 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.internal.common.sql.SqlDialect; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqlJournalDialectHelperTest { + + private static final String TABLE_NAME = "flamingockJournalEvents"; + + @ParameterizedTest(name = "{0} journal schema is typed and portable") + @EnumSource(SqlDialect.class) + @DisplayName("generates the journal schema and indexes for every supported SQL dialect") + void generatesTypedSchemaAndIndexesForEveryDialect(SqlDialect dialect) { + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(dialect); + String ddl = helper.getCreateTableSqlString(TABLE_NAME).toUpperCase(); + List indexSql = helper.getCreateIndexSqlStrings(TABLE_NAME); + + assertTrue(ddl.contains("EVENT_ID")); + assertTrue(ddl.contains("EVENT_TYPE")); + assertTrue(ddl.contains("EVENT_VERSION")); + assertTrue(ddl.contains("STREAM_ID")); + assertTrue(ddl.contains("STREAM_SEQUENCE")); + assertTrue(ddl.contains("OCCURRED_AT")); + assertTrue(ddl.contains("ACKNOWLEDGED")); + assertTrue(ddl.contains("CREATED_AT")); + assertTrue(ddl.contains("PRIMARY KEY")); + assertFalse(ddl.contains("JSON"), "journal payloads must not use JSON columns"); + assertFalse(ddl.contains("CLOB"), "journal payloads must not use CLOB columns"); + + assertEquals(2, countOccurrences(ddl, "STREAM_ID"), + "stream_id must appear as a column and as both composite-key references"); + assertEquals(2, indexSql.size(), "pending and event-id indexes complement the composite primary key"); + assertTrue(indexSql.stream().allMatch(sql -> sql.toUpperCase().contains("CREATE INDEX"))); + assertNotNull(helper.getSqlDialect()); + assertTrue(helper.getIndexNames(TABLE_NAME).stream() + .allMatch(name -> name.length() <= helper.getMaximumIndexNameLength())); + + List definitionNames = columnNames(helper.getColumnDefinitions()); + assertTrue(Arrays.asList("event_id", "stream_id", "stream_sequence", "occurred_at", "acknowledged") + .stream().allMatch(definitionNames::contains)); + assertEquals(definitionNames, insertColumnNames(helper.getInsertSqlString(TABLE_NAME))); + } + + @Test + @DisplayName("keeps Journal schema names separate from the ordered audit payload names") + void keepsMinimalNameOwnershipBoundaries() throws Exception { + List expectedAuditColumns = Arrays.asList( + "execution_id", "stage_id", "change_id", "author", "created_at", "state", + "invoked_class", "invoked_method", "source_file", "metadata", "execution_millis", + "execution_hostname", "error_trace", "type", "tx_strategy", "target_system_id", + "change_order", "recovery_strategy", "transaction_flag", "system_change"); + + assertEquals(expectedAuditColumns, AuditEntryMapper.columnNames()); + assertEquals(20, AuditEntryMapper.columnNames().size()); + assertThrows(UnsupportedOperationException.class, + () -> AuditEntryMapper.columnNames().add("unexpected_column")); + assertFalse(Arrays.stream(AuditEntryMapper.class.getDeclaredFields()) + .anyMatch(field -> expectedAuditColumns.contains(field.getName().toLowerCase(Locale.ROOT)))); + + assertFalse(Arrays.stream(JournalEventConstants.class.getDeclaredFields()) + .anyMatch(field -> expectedAuditColumns.contains(field.getName().toLowerCase(Locale.ROOT)))); + assertFalse(Modifier.isPublic(JournalEventConstants.class.getModifiers())); + assertTrue(Modifier.isPublic(SqlJournalDialectHelper.class.getModifiers())); + assertTrue(Modifier.isFinal(SqlJournalDialectHelper.class.getModifiers())); + assertClassIsAbsent("io.flamingock.store.sql.internal.SqlAuditColumnConstants"); + assertClassIsAbsent("io.flamingock.store.sql.internal.JournalEventPersistenceConstants"); + } + + @ParameterizedTest(name = "{0} uses the exact journal type policy") + @EnumSource(SqlDialect.class) + @DisplayName("uses exact portable types and capacities") + void usesExactPortableTypes(SqlDialect dialect) { + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(dialect); + String ddl = helper.getCreateTableSqlString(TABLE_NAME).toUpperCase(Locale.ROOT); + + assertTrue(ddl.contains("EVENT_ID " + varcharType(dialect, 255) + " NOT NULL")); + assertTrue(ddl.contains("EVENT_TYPE " + varcharType(dialect, 32) + " NOT NULL")); + assertTrue(ddl.contains("EVENT_VERSION INTEGER NOT NULL")); + assertTrue(ddl.contains("STREAM_ID " + varcharType(dialect, 255) + " NOT NULL")); + assertTrue(ddl.contains("STREAM_SEQUENCE " + longType(dialect) + " NOT NULL")); + assertTrue(ddl.contains("OCCURRED_AT " + timestampType(dialect) + " NOT NULL")); + assertTrue(ddl.contains("ACKNOWLEDGED " + booleanType(dialect) + " NOT NULL")); + assertTrue(ddl.contains("PRIMARY KEY (STREAM_ID, STREAM_SEQUENCE)")); + assertTrue(ddl.contains("METADATA " + textType(dialect))); + assertTrue(ddl.contains("ERROR_TRACE " + textType(dialect))); + assertFalse(ddl.contains("CLOB")); + assertTrue(ddl.contains("TRANSACTION_FLAG " + booleanType(dialect))); + assertTrue(ddl.contains("SYSTEM_CHANGE " + booleanType(dialect))); + } + + @Test + @DisplayName("keeps the required typed Journal definitions and text capacity policy") + void keepsTypedColumnDefinitionsAndTextCapacity() { + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(SqlDialect.H2); + + assertEquals(Arrays.asList( + "event_id", "event_type", "event_version", "stream_id", "stream_sequence", "occurred_at", + "acknowledged", "execution_id", "stage_id", "change_id", "author", "created_at", "state", + "invoked_class", "invoked_method", "source_file", "metadata", "execution_millis", + "execution_hostname", "error_trace", "type", "tx_strategy", "target_system_id", + "change_order", "recovery_strategy", "transaction_flag", "system_change"), + columnNames(helper.getColumnDefinitions())); + assertEquals(27, helper.getColumnDefinitions().size()); + assertEquals(SqlJournalDialectHelper.ColumnType.TEXT, helper.getColumnDefinitions().get(16).type); + assertEquals(2048, helper.getColumnDefinitions().get(16).size); + assertEquals(SqlJournalDialectHelper.ColumnType.TEXT, helper.getColumnDefinitions().get(19).type); + assertEquals(2048, helper.getColumnDefinitions().get(19).size); + assertTrue(helper.getColumnDefinitions().get(25).nullable); + assertTrue(helper.getColumnDefinitions().get(26).nullable); + } + + private static List columnNames(List definitions) { + List names = new java.util.ArrayList<>(); + for (SqlJournalDialectHelper.ColumnDefinition definition : definitions) { + names.add(definition.name); + } + return names; + } + + private static List insertColumnNames(String insertSql) { + int start = insertSql.indexOf('(') + 1; + int end = insertSql.indexOf(") VALUES"); + return Arrays.asList(insertSql.substring(start, end).split(", ")); + } + + @Test + @DisplayName("derives deterministic table-scoped index names within dialect limits") + void derivesDeterministicTableScopedIndexNames() { + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(SqlDialect.ORACLE); + String tableName = "journalEventsWithAnIntentionallyVeryLongTableNameForOracle"; + + List first = helper.getIndexNames(tableName); + List second = helper.getIndexNames(tableName); + + assertEquals(first, second); + assertEquals(2, first.stream().distinct().count()); + assertTrue(first.stream().allMatch(name -> name.length() <= 30)); + assertTrue(first.stream().allMatch(name -> name.startsWith("idx_"))); + assertTrue(helper.getCreateIndexSqlStrings(tableName).stream() + .allMatch(sql -> first.stream().anyMatch(sql::contains))); + + String shortTableName = "customJournalEvents"; + assertEquals(Arrays.asList( + "idx_customJournalEvents_pending_events", + "idx_customJournalEvents_event_id"), + new SqlJournalDialectHelper(SqlDialect.H2).getIndexNames(shortTableName)); + } + + private static void assertClassIsAbsent(String className) { + assertThrows(ClassNotFoundException.class, () -> Class.forName(className)); + } + + private static String varcharType(SqlDialect dialect, int size) { + return (dialect == SqlDialect.ORACLE ? "VARCHAR2(" : "VARCHAR(") + size + ")"; + } + + private static String longType(SqlDialect dialect) { + return dialect == SqlDialect.ORACLE ? "NUMBER(19)" : "BIGINT"; + } + + private static String timestampType(SqlDialect dialect) { + if (dialect == SqlDialect.SQLSERVER || dialect == SqlDialect.SYBASE) { + return "DATETIME"; + } + if (dialect == SqlDialect.INFORMIX) { + return "DATETIME YEAR TO FRACTION(3)"; + } + return "TIMESTAMP"; + } + + private static String booleanType(SqlDialect dialect) { + switch (dialect) { + case MYSQL: + case MARIADB: + return "TINYINT(1)"; + case POSTGRESQL: + case H2: + case FIREBIRD: + case INFORMIX: + return "BOOLEAN"; + case SQLITE: + return "INTEGER"; + case SQLSERVER: + case SYBASE: + return "BIT"; + case ORACLE: + return "NUMBER(1)"; + case DB2: + default: + return "SMALLINT"; + } + } + + private static String textType(SqlDialect dialect) { + switch (dialect) { + case MYSQL: + case MARIADB: + case POSTGRESQL: + case SQLSERVER: + case SYBASE: + case SQLITE: + return "TEXT"; + case INFORMIX: + return "LVARCHAR(2048)"; + case ORACLE: + return "VARCHAR2(4000)"; + case DB2: + case FIREBIRD: + case H2: + default: + return "VARCHAR(4000)"; + } + } + + private static int countOccurrences(String value, String token) { + int count = 0; + int offset = 0; + while ((offset = value.indexOf(token, offset)) >= 0) { + count++; + offset += token.length(); + } + return count; + } +} diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventMapperTest.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventMapperTest.java new file mode 100644 index 000000000..b6284a1ee --- /dev/null +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventMapperTest.java @@ -0,0 +1,277 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.audit.AuditTxType; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.journal.JournalEventType; +import io.flamingock.internal.common.sql.SqlDialect; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.Types; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqlJournalEventMapperTest { + + private static final String TABLE_NAME = "flamingockJournalEvents"; + + @Test + @DisplayName("round-trips the typed CHANGE_STATE envelope and flattened AuditEntry payload") + void roundTripsTypedChangeStateEvent() throws Exception { + AuditEntry auditEntry = auditEntry(); + Instant occurredAt = Instant.parse("2026-08-11T10:20:30.123456Z"); + JournalEvent source = new JournalEvent<>( + "event-1", JournalEventType.CHANGE_STATE, 3, "stage-1", 7L, occurredAt, auditEntry, false); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:journal_mapper;DB_CLOSE_DELAY=-1")) { + SqlJournalDialectHelper dialectHelper = new SqlJournalDialectHelper(SqlDialect.H2); + connection.createStatement().execute(dialectHelper.getCreateTableSqlString(TABLE_NAME)); + for (String indexSql : dialectHelper.getCreateIndexSqlStrings(TABLE_NAME)) { + connection.createStatement().execute(indexSql); + } + + try (PreparedStatement insert = connection.prepareStatement(dialectHelper.getInsertSqlString(TABLE_NAME))) { + new SqlJournalEventMapper().bind(insert, source); + insert.executeUpdate(); + } + + try (PreparedStatement select = connection.prepareStatement( + "SELECT * FROM " + TABLE_NAME + " WHERE event_id = ?")) { + select.setString(1, source.getEventId()); + try (ResultSet resultSet = select.executeQuery()) { + assertTrue(resultSet.next(), "the mapper must write a row"); + JournalEvent actual = new SqlJournalEventMapper().fromResultSet(resultSet); + + assertEquals(source.getEventId(), actual.getEventId()); + assertEquals(source.getEventType(), actual.getEventType()); + assertEquals(source.getEventVersion(), actual.getEventVersion()); + assertEquals(source.getStreamId(), actual.getStreamId()); + assertEquals(source.getStreamSequence(), actual.getStreamSequence()); + assertEquals(source.getOccurredAt(), actual.getOccurredAt()); + assertFalse(actual.isAcknowledged()); + assertAuditEntryEquals(auditEntry, actual.getData()); + } + } + } + } + + @Test + @DisplayName("rejects event types whose payload mapping is not implemented") + void rejectsUnsupportedEventType() throws Exception { + JournalEvent unsupported = new JournalEvent<>( + "event-unsupported", JournalEventType.EXECUTION_STATE, "stage-1", 1L, Instant.now(), auditEntry()); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:journal_mapper_unsupported;DB_CLOSE_DELAY=-1"); + PreparedStatement statement = connection.prepareStatement("SELECT 1")) { + assertThrows(UnsupportedOperationException.class, + () -> new SqlJournalEventMapper().bind(statement, unsupported)); + } + } + + @Test + @DisplayName("round-trips an acknowledged event and nullable audit fields") + void roundTripsAcknowledgedEventAndNullableFields() throws Exception { + AuditEntry auditEntry = new AuditEntry( + "execution-nullable", "stage-nullable", "change-nullable", null, + null, null, null, + null, null, null, 0L, null, null, false, null, null, + null, null, null, null); + JournalEvent source = new JournalEvent<>( + "event-acknowledged", JournalEventType.CHANGE_STATE, JournalEvent.DEFAULT_VERSION, + "stage-nullable", 2L, + Instant.parse("2026-08-11T10:20:30Z"), auditEntry, true); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:journal_mapper_nullable;DB_CLOSE_DELAY=-1")) { + SqlJournalDialectHelper dialectHelper = new SqlJournalDialectHelper(SqlDialect.H2); + connection.createStatement().execute(dialectHelper.getCreateTableSqlString(TABLE_NAME)); + try (PreparedStatement insert = connection.prepareStatement(dialectHelper.getInsertSqlString(TABLE_NAME))) { + new SqlJournalEventMapper().bind(insert, source); + insert.executeUpdate(); + } + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT * FROM " + TABLE_NAME)) { + assertTrue(resultSet.next()); + JournalEvent actual = new SqlJournalEventMapper().fromResultSet(resultSet); + + assertTrue(actual.isAcknowledged()); + assertEquals(source.getEventVersion(), actual.getEventVersion()); + assertEquals(source.getData().getExecutionId(), actual.getData().getExecutionId()); + assertNull(actual.getData().getCreatedAt()); + assertEquals(source.getData().getTxType(), actual.getData().getTxType()); + assertEquals(source.getData().getRecoveryStrategy(), actual.getData().getRecoveryStrategy()); + assertEquals(source.getData().getTransactionFlag(), actual.getData().getTransactionFlag()); + assertEquals(source.getData().getMetadata(), actual.getData().getMetadata()); + } + } + } + + @Test + @DisplayName("keeps event occurrence time separate from the audit creation time") + void keepsEnvelopeAndPayloadTimesDistinct() throws Exception { + AuditEntry auditEntry = auditEntry(); + JournalEvent source = new JournalEvent<>( + "event-time", JournalEventType.CHANGE_STATE, "stage-time", 1L, + Instant.parse("2026-08-11T12:00:00Z"), auditEntry); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:journal_mapper_times;DB_CLOSE_DELAY=-1")) { + SqlJournalDialectHelper dialectHelper = new SqlJournalDialectHelper(SqlDialect.H2); + connection.createStatement().execute(dialectHelper.getCreateTableSqlString(TABLE_NAME)); + try (PreparedStatement insert = connection.prepareStatement(dialectHelper.getInsertSqlString(TABLE_NAME))) { + new SqlJournalEventMapper().bind(insert, source); + insert.executeUpdate(); + } + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT occurred_at, created_at FROM " + TABLE_NAME)) { + assertTrue(resultSet.next()); + assertFalse(resultSet.getTimestamp("occurred_at").toLocalDateTime() + .equals(resultSet.getTimestamp("created_at").toLocalDateTime()), + "the envelope and payload timestamps must be stored independently"); + } + } + } + + @Test + @DisplayName("enforces stream position uniqueness without requiring globally unique event IDs") + void enforcesCompositeStreamPositionAndAllowsDuplicateEventIds() throws Exception { + JournalEvent first = new JournalEvent<>( + "event-shared", JournalEventType.CHANGE_STATE, "stage-1", 1L, Instant.now(), auditEntry()); + JournalEvent otherStream = new JournalEvent<>( + "event-shared", JournalEventType.CHANGE_STATE, "stage-2", 1L, Instant.now(), auditEntry()); + JournalEvent collidingPosition = new JournalEvent<>( + "event-other", JournalEventType.CHANGE_STATE, "stage-1", 1L, Instant.now(), auditEntry()); + + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:journal_mapper_identity;DB_CLOSE_DELAY=-1")) { + SqlJournalDialectHelper dialectHelper = new SqlJournalDialectHelper(SqlDialect.H2); + connection.createStatement().execute(dialectHelper.getCreateTableSqlString(TABLE_NAME)); + for (String indexSql : dialectHelper.getCreateIndexSqlStrings(TABLE_NAME)) { + connection.createStatement().execute(indexSql); + } + + insert(connection, dialectHelper, first); + insert(connection, dialectHelper, otherStream); + + assertThrows(Exception.class, () -> insert(connection, dialectHelper, collidingPosition)); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM " + TABLE_NAME)) { + assertTrue(resultSet.next()); + assertEquals(2, resultSet.getInt(1)); + } + } + } + + @Test + @DisplayName("keeps transaction handles package-private") + void keepsTransactionHandlesPackagePrivate() throws Exception { + Method append = SqlJournalEventStore.class.getDeclaredMethod( + "append", Connection.class, JournalEvent.class); + Method replaceCurrentState = SqlAuditRepository.class.getDeclaredMethod( + "replaceCurrentState", Connection.class, AuditEntry.class); + + assertFalse(Modifier.isPublic(append.getModifiers())); + assertFalse(Modifier.isPublic(replaceCurrentState.getModifiers())); + } + + @ParameterizedTest(name = "{0} uses its dialect boolean JDBC NULL type") + @MethodSource("nullableBooleanDialects") + @DisplayName("binds nullable booleans with the dialect JDBC type") + void bindsNullableBooleansWithDialectType(SqlDialect dialect, int expectedJdbcType) throws Exception { + PreparedStatement statement = Mockito.mock(PreparedStatement.class); + AuditEntry auditEntry = new AuditEntry( + "execution", "stage", "change", "author", LocalDateTime.now(), null, null, + null, null, null, 0L, null, null, false, null, null, + null, null, null, null); + JournalEvent event = new JournalEvent<>( + "event", JournalEventType.CHANGE_STATE, "stage", 1L, Instant.now(), auditEntry); + + new SqlJournalEventMapper(dialect).bind(statement, event); + + Mockito.verify(statement).setNull(26, expectedJdbcType); + Mockito.verify(statement).setBoolean(27, false); + } + + private static Stream nullableBooleanDialects() { + return Stream.of( + Arguments.of(SqlDialect.MYSQL, Types.TINYINT), + Arguments.of(SqlDialect.SQLSERVER, Types.BIT), + Arguments.of(SqlDialect.ORACLE, Types.NUMERIC), + Arguments.of(SqlDialect.DB2, Types.SMALLINT)); + } + + private static void insert(Connection connection, + SqlJournalDialectHelper dialectHelper, + JournalEvent event) throws Exception { + try (PreparedStatement insert = connection.prepareStatement(dialectHelper.getInsertSqlString(TABLE_NAME))) { + new SqlJournalEventMapper().bind(insert, event); + insert.executeUpdate(); + } + } + + private static AuditEntry auditEntry() { + return new AuditEntry( + "execution-1", "stage-1", "change-1", "author-1", + LocalDateTime.of(2020, 1, 2, 3, 4, 5, 600_000_000), + AuditEntry.Status.APPLIED, AuditEntry.ChangeType.STANDARD_CODE, + "com.example.Change", "apply", "Change.java", 123L, "host-1", + "metadata-value", false, "error-trace", AuditTxType.NON_TX, "sql", "001", + RecoveryStrategy.MANUAL_INTERVENTION, true); + } + + private static void assertAuditEntryEquals(AuditEntry expected, AuditEntry actual) { + assertEquals(expected.getExecutionId(), actual.getExecutionId()); + assertEquals(expected.getStageId(), actual.getStageId()); + assertEquals(expected.getChangeId(), actual.getChangeId()); + assertEquals(expected.getAuthor(), actual.getAuthor()); + assertEquals(expected.getCreatedAt(), actual.getCreatedAt()); + assertEquals(expected.getState(), actual.getState()); + assertEquals(expected.getType(), actual.getType()); + assertEquals(expected.getClassName(), actual.getClassName()); + assertEquals(expected.getMethodName(), actual.getMethodName()); + assertEquals(expected.getSourceFile(), actual.getSourceFile()); + assertEquals(expected.getExecutionMillis(), actual.getExecutionMillis()); + assertEquals(expected.getExecutionHostname(), actual.getExecutionHostname()); + assertEquals(expected.getMetadata(), actual.getMetadata()); + assertEquals(expected.getSystemChange(), actual.getSystemChange()); + assertEquals(expected.getErrorTrace(), actual.getErrorTrace()); + assertEquals(expected.getTxType(), actual.getTxType()); + assertEquals(expected.getTargetSystemId(), actual.getTargetSystemId()); + assertEquals(expected.getOrder(), actual.getOrder()); + assertEquals(expected.getRecoveryStrategy(), actual.getRecoveryStrategy()); + assertEquals(expected.getTransactionFlag(), actual.getTransactionFlag()); + } +} diff --git a/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventStoreJdbcTest.java b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventStoreJdbcTest.java new file mode 100644 index 000000000..d0f8aded6 --- /dev/null +++ b/community/flamingock-sql-auditstore/src/test/java/io/flamingock/store/sql/internal/SqlJournalEventStoreJdbcTest.java @@ -0,0 +1,442 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.store.sql.internal; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import io.flamingock.core.kit.audit.AuditEntryTestFactory; +import io.flamingock.internal.common.core.audit.AuditEntry; +import io.flamingock.internal.common.core.audit.AuditTxType; +import io.flamingock.internal.common.core.context.RuntimeContext; +import io.flamingock.internal.common.core.error.DatabaseTransactionException; +import io.flamingock.internal.common.core.journal.JournalEvent; +import io.flamingock.internal.common.core.journal.JournalEventType; +import io.flamingock.internal.common.core.transaction.TransactionWrapper; +import io.flamingock.internal.common.sql.SqlDialect; +import io.flamingock.internal.core.transaction.TransactionManager; +import io.flamingock.targetsystem.sql.SqlTxWrapper; +import org.h2.jdbcx.JdbcDataSource; +import org.sqlite.SQLiteDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqlJournalEventStoreJdbcTest { + + private static final String TABLE_NAME = "flamingockJournalEvents"; + + private DataSource dataSource; + private SqlTxWrapper txWrapper; + private SqlJournalEventStore journalEventStore; + + @BeforeEach + void setUp() throws SQLException { + JdbcDataSource jdbcDataSource = new JdbcDataSource(); + jdbcDataSource.setURL("jdbc:h2:mem:sql_journal_store;DB_CLOSE_DELAY=-1"); + jdbcDataSource.setUser("sa"); + jdbcDataSource.setPassword(""); + dataSource = jdbcDataSource; + txWrapper = transactionWrapperFor(dataSource); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute("DROP TABLE IF EXISTS " + TABLE_NAME); + } + journalEventStore = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + } + + @Test + @DisplayName("initializes the journal schema and validates it on a later startup") + void initializesAndValidatesSchema() throws Exception { + journalEventStore.initialize(true); + + SqlJournalEventStore secondStore = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + secondStore.initialize(false); + + assertFalse(secondStore.getLastEventByStream("missing").isPresent()); + } + + @Test + @DisplayName("does not create a missing table when auto-create is disabled") + void rejectsMissingSchemaWhenAutoCreateIsDisabled() { + assertThrows(IllegalStateException.class, () -> journalEventStore.initialize(false)); + } + + @Test + @DisplayName("accepts SQLite INTEGER affinity for the portable 64-bit sequence columns") + void acceptsSqliteIntegerAffinityForLongColumns() throws Exception { + Path databaseFile = Files.createTempFile("sql-journal-metadata-", ".db"); + SQLiteDataSource sqliteDataSource = new SQLiteDataSource(); + sqliteDataSource.setUrl("jdbc:sqlite:" + databaseFile.toAbsolutePath()); + + try { + SqlJournalEventStore sqliteStore = new SqlJournalEventStore( + sqliteDataSource, TABLE_NAME, transactionWrapperFor(sqliteDataSource)); + + sqliteStore.initialize(true); + + assertFalse(sqliteStore.getLastEventByStream("missing").isPresent()); + } finally { + Files.deleteIfExists(databaseFile); + } + } + + @Test + @DisplayName("returns the highest sequence for one stream and ignores other streams") + void returnsLastEventByStream() throws Exception { + journalEventStore.initialize(true); + append(event("stage-1", 1L, "event-1", false)); + append(event("stage-1", 3L, "event-3", false)); + append(event("stage-1", 2L, "event-2", true)); + append(event("stage-2", 9L, "other-stream", false)); + + Optional> last = journalEventStore.getLastEventByStream("stage-1"); + + assertTrue(last.isPresent()); + assertEquals("event-3", last.get().getEventId()); + assertEquals(3L, last.get().getStreamSequence()); + assertFalse(journalEventStore.getLastEventByStream("unknown").isPresent()); + } + + @Test + @DisplayName("returns pending events in stream-position order and honors the requested limit") + void returnsPendingEventsInBoundedOrder() throws Exception { + journalEventStore.initialize(true); + append(event("stage-2", 1L, "event-2-1", false)); + append(event("stage-1", 2L, "event-1-2", false)); + append(event("stage-1", 1L, "event-1-1", false)); + append(event("stage-0", 1L, "acknowledged", true)); + + List ids = journalEventStore.getUnacknowledgedEvents(2).stream() + .map(JournalEvent::getEventId) + .collect(Collectors.toList()); + + assertEquals(Arrays.asList("event-1-1", "event-1-2"), ids); + assertEquals(3, journalEventStore.getUnacknowledgedEvents(10).size()); + assertEquals(Collections.emptyList(), journalEventStore.getUnacknowledgedEvents(0)); + assertFalse(journalEventStore.getLastEventByStream(" ").isPresent()); + } + + @Test + @DisplayName("acknowledges every pending row for duplicate event IDs and is idempotent") + void acknowledgesDuplicateEventIdsAndSkipsBlankIds() throws Exception { + journalEventStore.initialize(true); + append(event("stage-1", 1L, "shared-event-id", false)); + append(event("stage-2", 1L, "shared-event-id", false)); + + HikariConfig config = new HikariConfig(); + config.setJdbcUrl("jdbc:h2:mem:sql_journal_store;DB_CLOSE_DELAY=-1"); + config.setUsername("sa"); + config.setPassword(""); + config.setAutoCommit(false); + try (HikariDataSource nonAutoCommitDataSource = new HikariDataSource(config)) { + SqlTxWrapper nonAutoCommitTxWrapper = new SqlTxWrapper( + new TransactionManager<>(() -> { + try { + return nonAutoCommitDataSource.getConnection(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not open test connection", exception); + } + })); + SqlJournalEventStore transactionalStore = new SqlJournalEventStore( + nonAutoCommitDataSource, TABLE_NAME, nonAutoCommitTxWrapper); + transactionalStore.initialize(false); + + long acknowledged = transactionalStore.acknowledgeEvents( + Arrays.asList(null, "", " ", "shared-event-id")); + + assertEquals(2L, acknowledged); + assertEquals(0, transactionalStore.getUnacknowledgedEvents(10).size()); + assertEquals(0L, transactionalStore.acknowledgeEvents( + Collections.singletonList("shared-event-id"))); + } + } + + @Test + @DisplayName("acknowledgements use independent transactions") + void acknowledgesEachEventInItsOwnTransaction() throws Exception { + journalEventStore.initialize(true); + append(event("stage-1", 1L, "first-event", false)); + append(event("stage-2", 1L, "second-event", false)); + + int[] transactionCount = {0}; + TransactionWrapper failingAfterSecondTransaction = new TransactionWrapper() { + @Override + public RESULT wrapInTransaction( + CONTEXT runtimeContext, Function operation) { + int transactionNumber = ++transactionCount[0]; + return txWrapper.wrapInTransaction(runtimeContext, context -> { + RESULT result = operation.apply(context); + if (transactionNumber == 2) { + throw new IllegalStateException("forced second acknowledgement failure"); + } + return result; + }); + } + }; + SqlJournalEventStore transactionalStore = new SqlJournalEventStore( + dataSource, TABLE_NAME, failingAfterSecondTransaction); + transactionalStore.initialize(false); + + assertThrows(DatabaseTransactionException.class, + () -> transactionalStore.acknowledgeEvents(Arrays.asList("first-event", "second-event"))); + + assertEquals(2, transactionCount[0]); + assertEquals(Collections.singletonList("second-event"), transactionalStore.getUnacknowledgedEvents(10) + .stream() + .map(JournalEvent::getEventId) + .collect(Collectors.toList())); + } + + @Test + @DisplayName("rejects a stream-position collision without replacing the original event") + void rejectsStreamPositionCollision() throws Exception { + journalEventStore.initialize(true); + append(event("stage-1", 1L, "original", false)); + + assertThrows(RuntimeException.class, () -> append(event("stage-1", 1L, "replacement", false))); + + assertEquals("original", journalEventStore.getLastEventByStream("stage-1").get().getEventId()); + } + + @Test + @DisplayName("accepts extra columns and a different physical required-column order") + void acceptsExtraColumnsAndPhysicalReordering() throws Exception { + createReorderedSchema(); + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + restarted.initialize(false); + append(restarted, event("reordered-stage", 1L, "reordered-event", false)); + + assertEquals("reordered-event", + restarted.getLastEventByStream("reordered-stage").get().getEventId()); + } + + @Test + @DisplayName("rejects an extra non-null column without a default or generated value") + void rejectsInsertBlockingExtraColumn() throws Exception { + journalEventStore.initialize(true); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " ADD required_extra VARCHAR(20) NOT NULL"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, () -> restarted.initialize(false)); + + assertTrue(exception.getMessage().toLowerCase().contains("required_extra")); + } + + @Test + @DisplayName("rejects a missing required journal column") + void rejectsMissingRequiredColumn() throws Exception { + journalEventStore.initialize(true); + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(SqlDialect.H2); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute("DROP INDEX " + helper.getIndexNames(TABLE_NAME).get(1)); + connection.createStatement().execute("ALTER TABLE " + TABLE_NAME + " DROP COLUMN event_id"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + @Test + @DisplayName("rejects ambiguous case-insensitive metadata for a required journal column") + void rejectsAmbiguousRequiredColumnMetadata() throws Exception { + journalEventStore.initialize(true); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " ADD \"Event_Id\" VARCHAR(255)"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + @Test + @DisplayName("rejects a journal column with the wrong type or capacity") + void rejectsWrongColumnMetadata() throws Exception { + journalEventStore.initialize(true); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " ALTER COLUMN event_id VARCHAR(64) NOT NULL"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + @Test + @DisplayName("rejects a required journal column made nullable") + void rejectsWrongNullability() throws Exception { + journalEventStore.initialize(true); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " ALTER COLUMN event_type VARCHAR(32) NULL"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + @Test + @DisplayName("rejects a journal primary key with the wrong column order") + void rejectsWrongPrimaryKeyOrder() throws Exception { + journalEventStore.initialize(true); + try (Connection connection = dataSource.getConnection()) { + String primaryKeyName; + try (java.sql.ResultSet resultSet = connection.getMetaData() + .getPrimaryKeys(null, null, TABLE_NAME.toUpperCase())) { + assertTrue(resultSet.next()); + primaryKeyName = resultSet.getString("PK_NAME"); + } + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " DROP CONSTRAINT " + primaryKeyName); + connection.createStatement().execute( + "ALTER TABLE " + TABLE_NAME + " ADD PRIMARY KEY (stream_sequence, stream_id)"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + @Test + @DisplayName("rejects an index with the expected name but the wrong column shape") + void rejectsWrongIndexShape() throws Exception { + journalEventStore.initialize(true); + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(SqlDialect.H2); + String pendingIndex = helper.getIndexNames(TABLE_NAME).get(0); + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute("DROP INDEX " + pendingIndex); + connection.createStatement().execute( + "CREATE INDEX " + pendingIndex + " ON " + TABLE_NAME + " (event_id)"); + } + + SqlJournalEventStore restarted = new SqlJournalEventStore(dataSource, TABLE_NAME, txWrapper); + + assertThrows(IllegalStateException.class, () -> restarted.initialize(false)); + } + + private void append(JournalEvent event) throws Exception { + append(journalEventStore, event); + } + + private void append(SqlJournalEventStore store, JournalEvent event) throws Exception { + try (Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + store.append(connection, event); + connection.commit(); + } + } + + private void createReorderedSchema() throws SQLException { + SqlJournalDialectHelper helper = new SqlJournalDialectHelper(SqlDialect.H2); + List definitions = + new ArrayList<>(helper.getColumnDefinitions()); + Collections.reverse(definitions); + + StringBuilder ddl = new StringBuilder("CREATE TABLE ") + .append(TABLE_NAME) + .append(" (unexpected_column VARCHAR(20) DEFAULT 'extra' NOT NULL, "); + for (SqlJournalDialectHelper.ColumnDefinition definition : definitions) { + if (ddl.charAt(ddl.length() - 1) != ' ') { + ddl.append(", "); + } + ddl.append(definition.name) + .append(' ') + .append(h2Type(definition)) + .append(definition.nullable ? "" : " NOT NULL"); + } + ddl.append(", PRIMARY KEY (stream_id, stream_sequence))"); + + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute(ddl.toString()); + for (String indexSql : helper.getCreateIndexSqlStrings(TABLE_NAME)) { + connection.createStatement().execute(indexSql); + } + } + } + + private SqlTxWrapper transactionWrapperFor(DataSource source) { + return new SqlTxWrapper(new TransactionManager<>(() -> { + try { + return source.getConnection(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not open test connection", exception); + } + })); + } + + private static String h2Type(SqlJournalDialectHelper.ColumnDefinition definition) { + switch (definition.type) { + case VARCHAR: + return "VARCHAR(" + definition.size + ")"; + case INTEGER: + return "INTEGER"; + case LONG: + return "BIGINT"; + case TIMESTAMP: + return "TIMESTAMP"; + case BOOLEAN: + return "BOOLEAN"; + case TEXT: + return "VARCHAR(4000)"; + default: + throw new AssertionError("Unsupported test column type: " + definition.type); + } + } + + private static JournalEvent event(String streamId, + long sequence, + String eventId, + boolean acknowledged) { + return new JournalEvent<>( + eventId, + JournalEventType.CHANGE_STATE, + JournalEvent.DEFAULT_VERSION, + streamId, + sequence, + Instant.parse("2026-08-11T10:20:30Z").plusSeconds(sequence), + AuditEntryTestFactory.createTestAuditEntry( + eventId, AuditEntry.Status.APPLIED, AuditTxType.NON_TX, (Class) null), + acknowledged); + } +} diff --git a/core/target-systems/flamingock-sql-externalsystem-api/build.gradle.kts b/core/target-systems/flamingock-sql-externalsystem-api/build.gradle.kts index 47a2bb63f..708930a53 100644 --- a/core/target-systems/flamingock-sql-externalsystem-api/build.gradle.kts +++ b/core/target-systems/flamingock-sql-externalsystem-api/build.gradle.kts @@ -1,8 +1,7 @@ val coreApiVersion: String by extra val sqlVersion: String by extra dependencies { - implementation("io.flamingock:flamingock-core-api:${coreApiVersion}") - implementation("io.flamingock:flamingock-sql-util:${sqlVersion}") + api(project(":core:flamingock-core-commons")) //General compileOnly("software.amazon.awssdk:dynamodb-enhanced:2.25.29") @@ -14,4 +13,4 @@ java { toolchain { languageVersion.set(JavaLanguageVersion.of(8)) } -} \ No newline at end of file +} diff --git a/core/target-systems/flamingock-sql-externalsystem-api/src/main/java/io/flamingock/externalsystem/sql/api/SqlExternalSystem.java b/core/target-systems/flamingock-sql-externalsystem-api/src/main/java/io/flamingock/externalsystem/sql/api/SqlExternalSystem.java index 3b1698d19..e37bd332b 100644 --- a/core/target-systems/flamingock-sql-externalsystem-api/src/main/java/io/flamingock/externalsystem/sql/api/SqlExternalSystem.java +++ b/core/target-systems/flamingock-sql-externalsystem-api/src/main/java/io/flamingock/externalsystem/sql/api/SqlExternalSystem.java @@ -15,10 +15,10 @@ */ package io.flamingock.externalsystem.sql.api; -import io.flamingock.api.external.ExternalSystem; +import io.flamingock.internal.common.core.transaction.TransactionalExternalSystem; import javax.sql.DataSource; -public interface SqlExternalSystem extends ExternalSystem { +public interface SqlExternalSystem extends TransactionalExternalSystem { DataSource getDataSource(); } diff --git a/core/target-systems/flamingock-sql-targetsystem/src/main/java/io/flamingock/targetsystem/sql/SqlTxWrapper.java b/core/target-systems/flamingock-sql-targetsystem/src/main/java/io/flamingock/targetsystem/sql/SqlTxWrapper.java index a97b1504b..74edf2195 100644 --- a/core/target-systems/flamingock-sql-targetsystem/src/main/java/io/flamingock/targetsystem/sql/SqlTxWrapper.java +++ b/core/target-systems/flamingock-sql-targetsystem/src/main/java/io/flamingock/targetsystem/sql/SqlTxWrapper.java @@ -38,9 +38,6 @@ public class SqlTxWrapper implements TransactionWrapper { public SqlTxWrapper(TransactionManager txManager) { this.txManager = txManager; } - - - private String getIsolationLevelName(int isolationLevel) { switch (isolationLevel) { case Connection.TRANSACTION_READ_UNCOMMITTED: return "READ_UNCOMMITTED"; @@ -74,8 +71,9 @@ private String formatDuration(Duration duration) { @Override public RESULT wrapInTransaction(CONTEXT executionContext, Function operation) { LocalDateTime transactionStart = LocalDateTime.now(); + String sessionId = executionContext.getSessionId(); - try (Connection connection = txManager.startSession(executionContext.getSessionId())) { + try (Connection connection = txManager.startSession(sessionId)) { boolean originalAutoCommit = connection.getAutoCommit(); String isolationLevel = getIsolationLevelName(connection.getTransactionIsolation()); String connectionInfo = getConnectionInfo(connection); @@ -151,6 +149,8 @@ public RESULT wrapInTransaction(CONTEXT "Connection establishment failed", e ); + } finally { + txManager.closeSession(sessionId); } } } diff --git a/core/target-systems/flamingock-sql-targetsystem/src/test/java/io/flamingock/targetsystem/sql/SqlTxWrapperLifecycleTest.java b/core/target-systems/flamingock-sql-targetsystem/src/test/java/io/flamingock/targetsystem/sql/SqlTxWrapperLifecycleTest.java new file mode 100644 index 000000000..c35971450 --- /dev/null +++ b/core/target-systems/flamingock-sql-targetsystem/src/test/java/io/flamingock/targetsystem/sql/SqlTxWrapperLifecycleTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.targetsystem.sql; + +import io.flamingock.internal.common.core.context.RuntimeContext; +import io.flamingock.internal.common.core.error.DatabaseTransactionException; +import io.flamingock.internal.core.change.navigation.step.FailedStep; +import io.flamingock.internal.core.context.BasicRuntimeContext; +import io.flamingock.internal.core.transaction.TransactionManager; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class SqlTxWrapperLifecycleTest { + + private DataSource dataSource; + private SqlTxWrapper txWrapper; + + @BeforeEach + void setUp() throws SQLException { + JdbcDataSource jdbcDataSource = new JdbcDataSource(); + jdbcDataSource.setURL("jdbc:h2:mem:sql_tx_wrapper_lifecycle;DB_CLOSE_DELAY=-1"); + jdbcDataSource.setUser("sa"); + jdbcDataSource.setPassword(""); + dataSource = jdbcDataSource; + try (Connection connection = dataSource.getConnection()) { + connection.createStatement().execute("DROP TABLE IF EXISTS tx_events"); + connection.createStatement().execute("CREATE TABLE tx_events (id INT PRIMARY KEY, payload VARCHAR(255))"); + } + txWrapper = new SqlTxWrapper(new TransactionManager<>(this::openConnection)); + } + + @Test + @DisplayName("commits callback writes and returns the callback value") + void commitsSuccessfulCallback() throws Exception { + BasicRuntimeContext context = new BasicRuntimeContext("success"); + + String result = txWrapper.wrapInTransaction(context, runtimeContext -> { + insert(runtimeContext, 1, "committed"); + return "success"; + }); + + assertEquals("success", result); + assertEquals(1, countRows()); + } + + @Test + @DisplayName("rolls back writes when the callback returns a FailedStep") + void rollsBackFailedStepValue() throws Exception { + FailedStep failedStep = mock(FailedStep.class); + BasicRuntimeContext context = new BasicRuntimeContext("failed-value"); + + FailedStep result = txWrapper.wrapInTransaction(context, runtimeContext -> { + insert(runtimeContext, 2, "rolled-back-value"); + return failedStep; + }); + + assertSame(failedStep, result); + assertEquals(0, countRows()); + } + + @Test + @DisplayName("rolls back writes and wraps callback exceptions") + void rollsBackCallbackException() throws Exception { + BasicRuntimeContext context = new BasicRuntimeContext("exception"); + + DatabaseTransactionException exception = assertThrows(DatabaseTransactionException.class, + () -> txWrapper.wrapInTransaction(context, runtimeContext -> { + insert(runtimeContext, 3, "rolled-back-exception"); + throw new IllegalStateException("callback failed"); + })); + + assertEquals("callback failed", exception.getCause().getMessage()); + assertEquals(0, countRows()); + } + + @Test + @DisplayName("closes a session after each transaction so the same session ID can be reused") + void reusesSessionIdWithFreshConnection() throws Exception { + BasicRuntimeContext firstContext = new BasicRuntimeContext("reused-session"); + BasicRuntimeContext secondContext = new BasicRuntimeContext("reused-session"); + + txWrapper.wrapInTransaction(firstContext, runtimeContext -> { + insert(runtimeContext, 4, "first-transaction"); + return "first"; + }); + txWrapper.wrapInTransaction(secondContext, runtimeContext -> { + insert(runtimeContext, 5, "second-transaction"); + return "second"; + }); + + assertEquals(2, countRows()); + } + + @Test + @DisplayName("closes a session after a value-reported rollback before a later transaction") + void reusesSessionIdAfterFailedStepRollback() throws Exception { + BasicRuntimeContext failedContext = new BasicRuntimeContext("failed-then-reused"); + BasicRuntimeContext successfulContext = new BasicRuntimeContext("failed-then-reused"); + FailedStep failedStep = mock(FailedStep.class); + + txWrapper.wrapInTransaction(failedContext, runtimeContext -> { + insert(runtimeContext, 6, "discarded"); + return failedStep; + }); + txWrapper.wrapInTransaction(successfulContext, runtimeContext -> { + insert(runtimeContext, 7, "committed-after-failure"); + return "success"; + }); + + assertEquals(1, countRows()); + } + + private Connection openConnection() { + try { + return dataSource.getConnection(); + } catch (SQLException exception) { + throw new IllegalStateException("Could not open H2 connection", exception); + } + } + + private static void insert(RuntimeContext runtimeContext, int id, String value) { + try { + Connection connection = runtimeContext.getContext().getRequiredDependencyValue(Connection.class); + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO tx_events (id, payload) VALUES (?, ?)")) { + statement.setInt(1, id); + statement.setString(2, value); + statement.executeUpdate(); + } + } catch (SQLException exception) { + throw new IllegalStateException("Could not insert test row", exception); + } + } + + private int countRows() throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM tx_events"); + ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getInt(1); + } + } +}