diff --git a/spring-modulith-events/pom.xml b/spring-modulith-events/pom.xml index 8a4224e5..80dda962 100644 --- a/spring-modulith-events/pom.xml +++ b/spring-modulith-events/pom.xml @@ -16,6 +16,7 @@ spring-modulith-events-core spring-modulith-events-jpa + spring-modulith-events-jdbc spring-modulith-events-jackson spring-modulith-events-tests spring-modulith-events-starter diff --git a/spring-modulith-events/spring-modulith-events-jdbc/pom.xml b/spring-modulith-events/spring-modulith-events-jdbc/pom.xml new file mode 100644 index 00000000..cae93718 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/pom.xml @@ -0,0 +1,93 @@ + + 4.0.0 + + + org.springframework.experimental + spring-modulith-events + 0.1.0-SNAPSHOT + ../pom.xml + + + Spring Modulith - Events - JDBC-based registry + spring-modulith-events-jdbc + + + 17 + org.springframework.modulith.events.jdbc + + + + + + ${project.groupId} + spring-modulith-events-core + ${project.version} + + + org.springframework.data + spring-data-jdbc + + + + + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-data-jdbc + test + + + org.hsqldb + hsqldb + test + + + com.h2database + h2 + test + + + org.postgresql + postgresql + test + + + + + + spring-milestone + https://repo.spring.io/milestone + + false + + + + + \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseSchemaInitializer.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseSchemaInitializer.java new file mode 100644 index 00000000..f067e9cf --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseSchemaInitializer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.events.jdbc; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.MetaDataAccessException; +import org.springframework.util.FileCopyUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +/** + * Initializes the DB schema used to store events + * + * @author Dmitry Belyaev, Björn Kieling + */ +public class DatabaseSchemaInitializer implements ResourceLoaderAware, InitializingBean { + + private final JdbcTemplate jdbcTemplate; + + private ResourceLoader resourceLoader; + + @Value("${spring.modulith.events.schema-initialization.enabled:false}") + private boolean initEnabled; + + public DatabaseSchemaInitializer(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @Override + public void afterPropertiesSet() throws MetaDataAccessException { + if (!initEnabled) { + return; + } + + DatabaseType databaseType = DatabaseType.fromMetaData(jdbcTemplate.getDataSource()); + String databaseName = databaseType.name().toLowerCase(); + var schemaDdlResource = resourceLoader.getResource("/schema-" + databaseName + ".sql"); + var schemaDdl = asString(schemaDdlResource); + jdbcTemplate.execute(schemaDdl); + } + + private String asString(Resource resource) { + try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseType.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseType.java new file mode 100644 index 00000000..9d17ed4d --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/DatabaseType.java @@ -0,0 +1,112 @@ +/* + * Copyright 2006-2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.events.jdbc; + +import org.springframework.jdbc.support.JdbcUtils; +import org.springframework.jdbc.support.MetaDataAccessException; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; +import java.sql.DatabaseMetaData; +import java.util.HashMap; +import java.util.Map; + +/** + * Enum representing a database type, such as DB2 or oracle. The type also contains a + * product name, which is expected to be the same as the product name provided by the + * database driver's metadata. + * + * @author Lucas Ward + */ +public enum DatabaseType { + + DERBY("Apache Derby"), DB2("DB2"), DB2VSE("DB2VSE"), DB2ZOS("DB2ZOS"), DB2AS400("DB2AS400"), + HSQL("HSQL Database Engine"), SQLSERVER("Microsoft SQL Server"), MYSQL("MySQL"), ORACLE("Oracle"), + POSTGRES("PostgreSQL"), SYBASE("Sybase"), H2("H2"), SQLITE("SQLite"), HANA("HDB"); + + private static final Map nameMap; + + static { + nameMap = new HashMap<>(); + for (DatabaseType type : values()) { + nameMap.put(type.getProductName(), type); + } + } + // A description is necessary due to the nature of database descriptions + // in metadata. + private final String productName; + + private DatabaseType(String productName) { + this.productName = productName; + } + + public String getProductName() { + return productName; + } + + /** + * Static method to obtain a DatabaseType from the provided product name. + * @param productName {@link String} containing the product name. + * @return the {@link DatabaseType} for given product name. + * @throws IllegalArgumentException if none is found. + */ + public static DatabaseType fromProductName(String productName) { + if (productName.equals("MariaDB")) + productName = "MySQL"; + if (!nameMap.containsKey(productName)) { + throw new IllegalArgumentException("DatabaseType not found for product name: [" + productName + "]"); + } + else { + return nameMap.get(productName); + } + } + + /** + * Convenience method that pulls a database product name from the DataSource's + * metadata. + * @param dataSource {@link DataSource} to the database to be used. + * @return {@link DatabaseType} for the {@link DataSource} specified. + * @throws MetaDataAccessException thrown if error occured during Metadata lookup. + */ + public static DatabaseType fromMetaData(DataSource dataSource) throws MetaDataAccessException { + String databaseProductName = JdbcUtils.extractDatabaseMetaData(dataSource, + DatabaseMetaData::getDatabaseProductName); + if (StringUtils.hasText(databaseProductName) && databaseProductName.startsWith("DB2")) { + String databaseProductVersion = JdbcUtils.extractDatabaseMetaData(dataSource, + DatabaseMetaData::getDatabaseProductVersion); + if (databaseProductVersion.startsWith("ARI")) { + databaseProductName = "DB2VSE"; + } + else if (databaseProductVersion.startsWith("DSN")) { + databaseProductName = "DB2ZOS"; + } + else if (databaseProductName.contains("AS") + && (databaseProductVersion.startsWith("QSQ") || databaseProductVersion + .substring(databaseProductVersion.indexOf('V')).matches("V\\dR\\d[mM]\\d"))) { + databaseProductName = "DB2AS400"; + } + else { + databaseProductName = JdbcUtils.commonDatabaseName(databaseProductName); + } + } + else { + databaseProductName = JdbcUtils.commonDatabaseName(databaseProductName); + } + return fromProductName(databaseProductName); + } + +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfiguration.java new file mode 100644 index 00000000..d8ef802b --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfiguration.java @@ -0,0 +1,43 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.events.jdbc; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.events.EventSerializer; +import org.springframework.modulith.events.config.EventPublicationConfigurationExtension; + +/** + * @author Dmitry Belyaev, Björn Kieling + */ +@Configuration(proxyBeanMethods = false) +class JdbcEventPublicationAutoConfiguration implements EventPublicationConfigurationExtension { + + @Bean + public JdbcEventPublicationRepository jpaEventPublicationRepository( + JdbcTemplate jdbcTemplate, EventSerializer serializer) { + // TODO Why do we want to instantiate the serializer here and what + // happens if no serializer is available or is not compatible to + // JDBC serialization? + return new JdbcEventPublicationRepository(jdbcTemplate, serializer); + } + + @Bean + public DatabaseSchemaInitializer databaseSchemaInitializer(JdbcTemplate jdbcTemplate) { + return new DatabaseSchemaInitializer(jdbcTemplate); + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java new file mode 100644 index 00000000..7f2fcb26 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -0,0 +1,198 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.events.jdbc; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.events.CompletableEventPublication; +import org.springframework.modulith.events.EventPublication; +import org.springframework.modulith.events.EventPublicationRepository; +import org.springframework.modulith.events.EventSerializer; +import org.springframework.modulith.events.PublicationTargetIdentifier; +import org.springframework.transaction.annotation.Transactional; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Repository to store {@link EventPublication}s. + * + * @author Dmitry Belyaev, Björn Kieling + */ +@Slf4j +@RequiredArgsConstructor +public class JdbcEventPublicationRepository implements EventPublicationRepository { + + private static final String SQL_STATEMENT_INSERT = """ + INSERT INTO EVENT_PUBLICATION (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) + VALUES (?, ?, ?, ?, ?) + """; + private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ + SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT + FROM EVENT_PUBLICATION + WHERE COMPLETION_DATE IS NULL + """; + private static final String SQL_STATEMENT_UPDATE = """ + UPDATE EVENT_PUBLICATION + SET COMPLETION_DATE = ? + WHERE ID = ? + """; + private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ + SELECT * FROM EVENT_PUBLICATION + WHERE SERIALIZED_EVENT = ? AND LISTENER_ID = ? + ORDER BY PUBLICATION_DATE + """; + + private final JdbcTemplate jdbcTemplate; + private final EventSerializer serializer; + + @Override + @Transactional + public EventPublication create(EventPublication publication) { + String serializedEvent = serializeEvent(publication.getEvent()); + jdbcTemplate.update(SQL_STATEMENT_INSERT, // + UUID.randomUUID(), // + publication.getEvent().getClass().getName(), // + publication.getTargetIdentifier().getValue(), // + publication.getPublicationDate(), // + serializedEvent); + + return publication; + } + + @Override + @Transactional + public EventPublication updateCompletionDate(CompletableEventPublication publication) { + String serializedEvent = serializeEvent(publication.getEvent()); + List results = jdbcTemplate.query(SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID, + (rs, rowNum) -> rs.getObject("ID", UUID.class), serializedEvent, publication.getTargetIdentifier().getValue()); + if (!results.isEmpty()) { + jdbcTemplate.update(SQL_STATEMENT_UPDATE, publication.getCompletionDate().orElse(null), results.get(0)); + } + + return publication; + } + + @Override + @Transactional(readOnly = true) + public List findByCompletionDateIsNull() { + return jdbcTemplate.query(SQL_STATEMENT_FIND_UNCOMPLETED, this::mapResultSetToEventPublications); + } + + @Override + @Transactional(readOnly = true) + public Optional findByEventAndTargetIdentifier(Object event, + PublicationTargetIdentifier targetIdentifier) { + + String serializedEvent = serializeEvent(event); + List results = jdbcTemplate.query(SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID, + this::mapResultSetToEventPublications, serializedEvent, targetIdentifier.getValue()); + if (results.isEmpty()) { + return Optional.empty(); + } else { + // if there are several events with exactly the same payload we return the oldest one first + return Optional.of(results.get(0)); + } + } + + private String serializeEvent(Object event) { + return serializer.serialize(event).toString(); + } + + private List mapResultSetToEventPublications(ResultSet rs) throws SQLException { + var result = new ArrayList(); + while (rs.next()) { + entityToDomain(rs).ifPresent(result::add); + } + return result; + } + + private Optional entityToDomain(ResultSet rs) throws SQLException { + var id = rs.getObject("ID", UUID.class); + var eventClassName = rs.getString("EVENT_TYPE"); + Class eventClass; + try { + eventClass = Class.forName(eventClassName); + } catch (ClassNotFoundException e) { + LOG.warn("Event '{}' of unknown type '{}' found", id, eventClassName); + return Optional.empty(); + } + + return Optional.of(JdbcEventPublication.builder() + .completionDate(Optional.ofNullable(rs.getTimestamp("COMPLETION_DATE")).map(Timestamp::toInstant).orElse(null)) + .eventType(eventClass) // + .listenerId(rs.getString("LISTENER_ID")) // + .publicationDate(rs.getTimestamp("PUBLICATION_DATE").toInstant()) // + .serializedEvent(rs.getString("SERIALIZED_EVENT")) // + .serializer(serializer) // + .build()); + } + + @EqualsAndHashCode + @Builder + private static class JdbcEventPublication implements CompletableEventPublication { + + private final UUID id; + private final Instant publicationDate; + private final String listenerId; + private final String serializedEvent; + private final Class eventType; + + private final EventSerializer serializer; + private Instant completionDate; + + @Override + public Object getEvent() { + return serializer.deserialize(serializedEvent, eventType); + } + + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(listenerId); + } + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Optional getCompletionDate() { + return Optional.ofNullable(completionDate); + } + + @Override + public boolean isPublicationCompleted() { + return completionDate != null; + } + + @Override + public CompletableEventPublication markCompleted() { + this.completionDate = Instant.now(); + return this; + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..17fb0947 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.modulith.events.jdbc.JdbcEventPublicationAutoConfiguration diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql new file mode 100644 index 00000000..9a770217 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION +( + ID UUID NOT NULL, + COMPLETION_DATE TIMESTAMP(9) WITH TIME ZONE, + EVENT_TYPE VARCHAR(512) NOT NULL, + LISTENER_ID VARCHAR(512) NOT NULL, + PUBLICATION_DATE TIMESTAMP(9) WITH TIME ZONE NOT NULL, + SERIALIZED_EVENT VARCHAR(4000) NOT NULL, + PRIMARY KEY (ID) +) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsql.sql new file mode 100644 index 00000000..28d7a155 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsql.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION +( + ID UUID NOT NULL, + COMPLETION_DATE TIMESTAMP(9), + EVENT_TYPE VARCHAR(512) NOT NULL, + LISTENER_ID VARCHAR(512) NOT NULL, + PUBLICATION_DATE TIMESTAMP(9) NOT NULL, + SERIALIZED_EVENT VARCHAR(4000) NOT NULL, + PRIMARY KEY (ID) +) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgres.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgres.sql new file mode 100644 index 00000000..9a770217 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgres.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION +( + ID UUID NOT NULL, + COMPLETION_DATE TIMESTAMP(9) WITH TIME ZONE, + EVENT_TYPE VARCHAR(512) NOT NULL, + LISTENER_ID VARCHAR(512) NOT NULL, + PUBLICATION_DATE TIMESTAMP(9) WITH TIME ZONE NOT NULL, + SERIALIZED_EVENT VARCHAR(4000) NOT NULL, + PRIMARY KEY (ID) +) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfigurationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfigurationIntegrationTests.java new file mode 100644 index 00000000..b736217f --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationAutoConfigurationIntegrationTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.events.jdbc; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationContext; +import org.springframework.modulith.events.EventPublicationRegistry; +import org.springframework.modulith.events.EventSerializer; +import org.springframework.modulith.testapp.TestApplication; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Dmitry Belyaev, Björn Kieling + */ +@SpringBootTest( + classes = TestApplication.class, + properties = "spring.modulith.events.schema-initialization.enabled=true" +) +public class JdbcEventPublicationAutoConfigurationIntegrationTests { + + @Autowired + private ApplicationContext context; + + @MockBean + private EventSerializer serializer; + + @Test + void bootstrapsApplicationComponents() { + + assertThat(context.getBean(EventPublicationRegistry.class)).isNotNull(); + assertThat(context.getBean(JdbcEventPublicationRepository.class)).isNotNull(); + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/DatabaseSchemaInitializerTest.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/DatabaseSchemaInitializerTest.java new file mode 100644 index 00000000..90261003 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/DatabaseSchemaInitializerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.testapp; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.events.jdbc.DatabaseSchemaInitializer; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; + +/** + * @author Dmitry Belyaev, Björn Kieling + */ +public class DatabaseSchemaInitializerTest { + + @Nested + @DataJdbcTest(properties = { + "spring.modulith.events.schema-initialization.enabled=true" + }) + @Import(DatabaseSchemaInitializer.class) + class InitializationEnabled { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldCreateDatabaseSchemaOnStartUp() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM EVENT_PUBLICATION", Long.class); + + assertThat(count).isEqualTo(0); + } + } + + @Nested + @DataJdbcTest(properties = { + "spring.modulith.events.schema-initialization.enabled=false" + }) + @Import(DatabaseSchemaInitializer.class) + class InitializationDisabled { + + @SpyBean + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldNotCreateDatabaseSchemaOnStartUp() { + Mockito.verify(jdbcTemplate, Mockito.never()).execute(anyString()); + } + } + + @Nested + @DataJdbcTest + @Import(DatabaseSchemaInitializer.class) + class InitializationDisabledByDefault { + + @SpyBean + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldNotCreateDatabaseSchemaOnStartUp() { + Mockito.verify(jdbcTemplate, Mockito.never()).execute(anyString()); + } + } + + @Nested + @DataJdbcTest(properties = { + "spring.modulith.events.schema-initialization.enabled=true" + }) + @ActiveProfiles("hsql") + @Import(DatabaseSchemaInitializer.class) + class InitializationUseHsql { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldCreateDatabaseSchemaOnStartUp() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM EVENT_PUBLICATION", Long.class); + + assertThat(count).isEqualTo(0); + } + } + + @Nested + @DataJdbcTest(properties = { + "spring.modulith.events.schema-initialization.enabled=true" + }) + @ActiveProfiles("h2") + @Import(DatabaseSchemaInitializer.class) + class InitializationUseH2 { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldCreateDatabaseSchemaOnStartUp() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM EVENT_PUBLICATION", Long.class); + + assertThat(count).isEqualTo(0); + } + } + + @Nested + @Disabled + @DataJdbcTest(properties = { + "spring.modulith.events.schema-initialization.enabled=true" + }) + @ActiveProfiles("postgres") + @Import(DatabaseSchemaInitializer.class) + class InitializationUsePostgres { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void shouldCreateDatabaseSchemaOnStartUp() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM EVENT_PUBLICATION", Long.class); + + assertThat(count).isEqualTo(0); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/JdbcEventPublicationRepositoryIntegrationTests.java new file mode 100644 index 00000000..8a5e2c49 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/JdbcEventPublicationRepositoryIntegrationTests.java @@ -0,0 +1,319 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.testapp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.modulith.events.CompletableEventPublication; +import org.springframework.modulith.events.EventPublication; +import org.springframework.modulith.events.EventSerializer; +import org.springframework.modulith.events.PublicationTargetIdentifier; +import org.springframework.modulith.events.jdbc.DatabaseSchemaInitializer; +import org.springframework.modulith.events.jdbc.JdbcEventPublicationRepository; +import org.springframework.test.context.ActiveProfiles; + +/** + * @author Dmitry Belyaev, Björn Kieling + */ +class JdbcEventPublicationRepositoryIntegrationTests { + + private static final PublicationTargetIdentifier TARGET_IDENTIFIER = PublicationTargetIdentifier.of("listener"); + + private JdbcEventPublicationRepository repository; + + private final EventSerializer eventSerializer = mock(EventSerializer.class); + + private abstract class TestBase { + + @Autowired + protected JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + repository = new JdbcEventPublicationRepository(jdbcTemplate, eventSerializer); + } + } + + @Nested + @DataJdbcTest + @ActiveProfiles("hsql") + @Import(DatabaseSchemaInitializer.class) + class HSQL extends TestBase { + + @Nested + class CreateAndUpdate { + + @Test + void shouldPersistAndUpdateEventPublication() { + shouldPersistAndUpdateEventPublicationTest(); + } + + @Test + void shouldUpdateSingleEventPublication() { + shouldUpdateSingleEventPublicationTest(); + } + } + + @Nested + class FindByCompletionDateIsNull { + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + + @Nested + class FindBySerializedEventAndListenerId { + + @Test + void shouldTolerateEmptyResult() { + shouldTolerateEmptyResultTest(); + } + + @Test + void shouldReturnTheOldestEvent() throws InterruptedException { + shouldReturnTheOldestEventTest(); + } + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + } + + @Nested + @DataJdbcTest + @ActiveProfiles("h2") + @Import(DatabaseSchemaInitializer.class) + class H2 extends TestBase { + + @Nested + class CreateAndUpdate { + + @Test + void shouldPersistAndUpdateEventPublication() { + shouldPersistAndUpdateEventPublicationTest(); + } + + @Test + void shouldUpdateSingleEventPublication() { + shouldUpdateSingleEventPublicationTest(); + } + } + + @Nested + class FindByCompletionDateIsNull { + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + + @Nested + class FindBySerializedEventAndListenerId { + + @Test + void shouldTolerateEmptyResult() { + shouldTolerateEmptyResultTest(); + } + + @Test + void shouldReturnTheOldestEvent() throws InterruptedException { + shouldReturnTheOldestEventTest(); + } + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + } + + @Nested + @Disabled + @DataJdbcTest + @ActiveProfiles("postgres") + @Import(DatabaseSchemaInitializer.class) + class Postgres extends TestBase { + + @Nested + class CreateAndUpdate { + + @Test + void shouldPersistAndUpdateEventPublication() { + shouldPersistAndUpdateEventPublicationTest(); + } + + @Test + void shouldUpdateSingleEventPublication() { + shouldUpdateSingleEventPublicationTest(); + } + } + + @Nested + class FindByCompletionDateIsNull { + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + + @Nested + class FindBySerializedEventAndListenerId { + + @Test + void shouldTolerateEmptyResult() { + shouldTolerateEmptyResultTest(); + } + + @Test + void shouldReturnTheOldestEvent() throws InterruptedException { + shouldReturnTheOldestEventTest(); + } + + @Test + void shouldSilentlyIgnoreNotSerializableEvents() { + shouldSilentlyIgnoreNotSerializableEventsTest(jdbcTemplate); + } + } + } + + private void shouldPersistAndUpdateEventPublicationTest() { + TestEvent testEvent = new TestEvent("id"); + String serializedEvent = "{\"eventId\":\"id\"}"; + + when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent); + when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + + CompletableEventPublication publication = CompletableEventPublication.of(testEvent, TARGET_IDENTIFIER); + + // Store publication + repository.create(publication); + + List eventPublications = repository.findByCompletionDateIsNull(); + assertThat(eventPublications).hasSize(1); + assertThat(eventPublications.get(0).getEvent()).isEqualTo(publication.getEvent()); + assertThat(eventPublications.get(0).getTargetIdentifier()).isEqualTo(publication.getTargetIdentifier()); + assertThat(repository.findByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) + .isPresent(); + + // Complete publication + repository.updateCompletionDate(publication.markCompleted()); + + assertThat(repository.findByCompletionDateIsNull()).isEmpty(); + } + + private void shouldUpdateSingleEventPublicationTest() { + TestEvent testEvent1 = new TestEvent("id1"); + TestEvent testEvent2 = new TestEvent("id2"); + String serializedEvent1 = "{\"eventId\":\"id1\"}"; + String serializedEvent2 = "{\"eventId\":\"id2\"}"; + + when(eventSerializer.serialize(testEvent1)).thenReturn(serializedEvent1); + when(eventSerializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); + when(eventSerializer.serialize(testEvent2)).thenReturn(serializedEvent2); + when(eventSerializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); + + CompletableEventPublication publication1 = CompletableEventPublication.of(testEvent1, TARGET_IDENTIFIER); + CompletableEventPublication publication2 = CompletableEventPublication.of(testEvent2, TARGET_IDENTIFIER); + + // Store publication + repository.create(publication1); + repository.create(publication2); + + // Complete publication + repository.updateCompletionDate(publication2.markCompleted()); + + List withCompletionDateNull = repository.findByCompletionDateIsNull(); + assertThat(withCompletionDateNull).hasSize(1); + assertThat(withCompletionDateNull.get(0).getEvent()).isEqualTo(testEvent1); + } + + private void shouldTolerateEmptyResultTest() { + TestEvent testEvent = new TestEvent("id"); + String serializedEvent = "{\"eventId\":\"id\"}"; + when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent); + + Optional actual = + repository.findByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); + + assertThat(actual).isEmpty(); + } + + private void shouldReturnTheOldestEventTest() throws InterruptedException { + TestEvent testEvent = new TestEvent("id"); + String serializedEvent = "{\"eventId\":\"id\"}"; + when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent); + when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + + CompletableEventPublication publicationOld = CompletableEventPublication.of(testEvent, TARGET_IDENTIFIER); + Thread.sleep(10); + CompletableEventPublication publicationNew = CompletableEventPublication.of(testEvent, TARGET_IDENTIFIER); + + repository.create(publicationNew); + repository.create(publicationOld); + + + Optional actual = + repository.findByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); + + assertThat(actual).isNotEmpty(); + assertThat(actual.get().getPublicationDate()).isEqualTo(publicationOld.getPublicationDate()); + } + + private void shouldSilentlyIgnoreNotSerializableEventsTest(JdbcTemplate jdbcTemplate) { + TestEvent testEvent = new TestEvent("id"); + String serializedEvent = "{\"eventId\":\"id\"}"; + when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent); + when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + + CompletableEventPublication publication = CompletableEventPublication.of(testEvent, TARGET_IDENTIFIER); + + // Store publication + repository.create(publication); + jdbcTemplate.update("UPDATE EVENT_PUBLICATION SET EVENT_TYPE='abc'"); + + Optional actual = + repository.findByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); + + assertThat(actual).isEmpty(); + } + + private static final class TestEvent { + private final String eventId; + + private TestEvent(String eventId) { + this.eventId = eventId; + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/TestApplication.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/TestApplication.java new file mode 100644 index 00000000..8081d213 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/testapp/TestApplication.java @@ -0,0 +1,25 @@ +/* + * Copyright 2022 the original author or authors. + * + * 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 + * + * https://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 org.springframework.modulith.testapp; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Dmitry Belyaev, Björn Kieling + */ +@SpringBootApplication +public class TestApplication { +} diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-h2.properties b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-h2.properties new file mode 100644 index 00000000..b29b54fa --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-h2.properties @@ -0,0 +1,4 @@ +spring.datasource.driverClassName=org.h2.Driver +spring.test.database.replace=NONE + +spring.modulith.events.schema-initialization.enabled=true diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-hsql.properties b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-hsql.properties new file mode 100644 index 00000000..f7bdc38c --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-hsql.properties @@ -0,0 +1,5 @@ +spring.datasource.driverClassName=org.hsqldb.jdbc.JDBCDriver +spring.datasource.url=jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1 +spring.test.database.replace=NONE + +spring.modulith.events.schema-initialization.enabled=true diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-postgres.properties b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-postgres.properties new file mode 100644 index 00000000..3d86965b --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/resources/application-postgres.properties @@ -0,0 +1,7 @@ +spring.datasource.driverClassName=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.username=username +spring.datasource.password=password +spring.test.database.replace=NONE + +spring.modulith.events.schema-initialization.enabled=true